commit 2a49a752868b920facf67054d28de8df5149239d Author: wehub-resource-sync Date: Mon Jul 13 11:59:37 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..7349271 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,9 @@ +{ + "name": "Hoppscotch", + "image": "mcr.microsoft.com/devcontainers/typescript-node:18", + "forwardPorts": [3000], + "features": { + "ghcr.io/NicoVIII/devcontainer-features/pnpm:1": {} + }, + "postCreateCommand": "cp .env.example .env && pnpm i" +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b57b57 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +.devenv* +.direnv +.devcontainer +.git +.github +.husky +.vscode + +.envrc +devenv.yaml +devenv.nix +.prettierrc.js +.prettierignore +.editorconfig +.npmrc +.firebaserc + +node_modules +**/node_modules +**/*/node_modules + +**/dist +**/build +**/target + +**/__tests__ +**/*.test.* +**/coverage + +*.md +LICENSE +CODEOWNERS + +.DS_Store +*.log diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a1c0c00 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# EditorConfig is awesome: https://EditorConfig.org + +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..894571b --- /dev/null +++ b/.envrc @@ -0,0 +1,3 @@ +source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k=" + +use devenv diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..feafd4b --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "postwoman-api" + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..05cd776 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: hoppscotch +open_collective: hoppscotch diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml new file mode 100644 index 0000000..f54aaa4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/--bug-report.yaml @@ -0,0 +1,117 @@ +name: Bug Report +description: Create a bug report to help us improve Hoppscotch +title: "[bug]: " +labels: [bug, need testing] +body: + - type: markdown + attributes: + value: | + Thank you for taking the time to report this issue. Complete information helps us resolve it faster. + + - type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for the bug you encountered + options: + - label: I have searched existing issues and this bug hasn't been reported yet + required: true + + - type: dropdown + id: platform + attributes: + label: Platform + description: How are you accessing Hoppscotch? + options: + - Web App + - Desktop App + validations: + required: true + + - type: dropdown + id: browser + attributes: + label: Browser + description: | + Which browser is affected? + - For web app: Select the browser you're using + - For desktop app: Select your default browser + options: + - Chrome + - Firefox + - Safari + - Edge + - Opera + - Other (specify in additional info) + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating System + description: Which operating system are you using? + options: + - Windows + - macOS + - Linux + - Other (specify in additional info) + validations: + required: true + + - type: textarea + attributes: + label: Bug Description + description: | + Describe the bug, how to reproduce it, and any additional context that would help us fix it. Include screenshots when possible. + + **For request-related issues:** Please specify which interceptor you're using (Browser, Agent, Extension, Proxy, Native, etc.) + placeholder: | + ## What happened? + When I do , happens and I see the error: + ``` + [paste error message here] + ``` + What I expected is + + ## Steps to reproduce + 1. Go to '...' + 2. Click on '....' + 3. See error + + ## Screenshots + [Attach screenshots here if available - they're really helpful!] + + ## Additional context + Interceptor used (if request-related): [Browser/Agent/Extension/Proxy/Native/etc.] + + ## Logs and Console Output (if available) + ``` + Paste any relevant logs here + ``` + + ## Additional details (if relevant) + - Device specifics: + - Special configurations: + validations: + required: true + + - type: dropdown + id: deployment + attributes: + label: Deployment Type + description: What type of Hoppscotch deployment are you using? + options: + - Hoppscotch Cloud + - Self-hosted (on-prem deployment) + validations: + required: true + + - type: input + id: version + attributes: + label: Version + description: | + For Self-Hosted deployments: Specify your deployment version + For Desktop App: Specify your app version + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/--feature-request.yaml b/.github/ISSUE_TEMPLATE/--feature-request.yaml new file mode 100644 index 0000000..9baddac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/--feature-request.yaml @@ -0,0 +1,28 @@ +name: Feature request +description: Suggest a feature to improve Hoppscotch +title: "[feature]: " +labels: [feature] +body: +- type: markdown + attributes: + value: | + Thank you for taking the time to request a feature for Hoppscotch +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue related to this feature request already exists + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: Summary + description: One paragraph description of the feature + validations: + required: true +- type: textarea + attributes: + label: Why should this be worked on? + description: A concise description of the problems or use cases for this feature request + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..83bb3b5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,7 @@ +contact_links: + - name: Help and support + url: https://github.com/hoppscotch/hoppscotch#support + about: Reach out to us on our Discord server or Telegram group or GitHub discussions. + - name: Dedicated support + url: mailto:support@hoppscotch.io + about: Write to us if you'd like dedicated support using Hoppscotch diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6086465 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: +- package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + time: '00:00' + open-pull-requests-limit: 0 + reviewers: + - liyasthomas diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..6c6eacd --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ + + + +Closes # + + + +### What's changed + + + + +### Notes to reviewers + diff --git a/.github/semantic.yml b/.github/semantic.yml new file mode 100644 index 0000000..40b6927 --- /dev/null +++ b/.github/semantic.yml @@ -0,0 +1,5 @@ +# Always validate the PR title AND all the commits +titleAndCommits: true +# Allows use of Merge commits (eg on github: "Merge branch 'master' into feature/ride-unicorns") +# this is only relevant when using commitsOnly: true (or titleAndCommits: true) +allowMergeCommits: true diff --git a/.github/workflows/build-hoppscotch-agent.yml b/.github/workflows/build-hoppscotch-agent.yml new file mode 100644 index 0000000..0b935af --- /dev/null +++ b/.github/workflows/build-hoppscotch-agent.yml @@ -0,0 +1,670 @@ +name: Build Agent Self Host - AIO +on: + workflow_dispatch: + inputs: + version: + description: Tag of the version to build + required: true + branch: + description: Branch to checkout + required: true + default: "main" + release_notes: + description: Release notes for the update + required: false + default: "PLACEHOLDER RELEASE NOTES" + build_macos_x64: + description: Build for macOS x64 + type: boolean + required: false + default: true + build_macos_arm64: + description: Build for macOS ARM64 + type: boolean + required: false + default: true + build_linux_deb: + description: Build Linux DEB package + type: boolean + required: false + default: true + build_linux_appimage: + description: Build Linux AppImage + type: boolean + required: false + default: true + build_windows_installer: + description: Build Windows MSI installer + type: boolean + required: false + default: true + build_windows_portable: + description: Build Windows portable executable + type: boolean + required: false + default: true +env: + CARGO_TERM_COLOR: always +jobs: + build-macos-x86_64: + name: Build MacOS x86_64 (.dmg) + runs-on: macos-latest + if: ${{ inputs.build_macos_x64 }} + defaults: + run: + shell: bash + timeout-minutes: 60 + steps: + - name: Checkout hoppscotch/hoppscotch + uses: actions/checkout@v3 + with: + repository: hoppscotch/hoppscotch + ref: ${{ inputs.branch }} + token: ${{ secrets.HOPPSCOTCH_GITHUB_CHECKOUT_TOKEN }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.15.0 + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - name: Install Rust target + timeout-minutes: 5 + run: rustup target add x86_64-apple-darwin + - name: Install additional tools + timeout-minutes: 5 + run: | + mkdir __dist/ + cd __dist/ + curl -LO "https://github.com/tauri-apps/tauri/releases/download/tauri-cli-v2.0.1/cargo-tauri-x86_64-apple-darwin.zip" + unzip cargo-tauri-x86_64-apple-darwin.zip + chmod +x cargo-tauri + sudo mv cargo-tauri /usr/local/bin/tauri + - name: Import Code-Signing Certificates + uses: apple-actions/import-codesign-certs@v3 + with: + p12-file-base64: ${{ secrets.HOPPSCOTCH_APPLE_CERTIFICATE }} + p12-password: ${{ secrets.HOPPSCOTCH_APPLE_CERTIFICATE_PASSWORD }} + keychain-password: ${{ secrets.KEYCHAIN_PASSWORD }} + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-x86-${{ hashFiles('**/Cargo.lock') }} + - name: Install dependencies + timeout-minutes: 15 + run: | + cd packages/hoppscotch-agent + pnpm install --filter hoppscotch-agent + - name: Build Tauri app + timeout-minutes: 30 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.AGENT_TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.AGENT_TAURI_SIGNING_PASSWORD }} + APPLE_ID: ${{ secrets.HOPPSCOTCH_APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.HOPPSCOTCH_APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.HOPPSCOTCH_APPLE_TEAM_ID }} + APPLE_SIGNING_IDENTITY: ${{ secrets.HOPPSCOTCH_APPLE_SIGNING_IDENTITY }} + run: | + cd packages/hoppscotch-agent + echo "Starting x86_64 build..." + pnpm tauri build --verbose --target x86_64-apple-darwin + echo "Build completed" + - name: Prepare artifacts + run: | + mkdir -p artifacts/{sigs,updaters,shas} + mv packages/hoppscotch-agent/src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*_x64.dmg artifacts/Hoppscotch_Agent_mac_x64.dmg + mv packages/hoppscotch-agent/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/updaters/Hoppscotch_Agent_mac_update_x64.tar.gz + mv packages/hoppscotch-agent/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/*.app.tar.gz.sig artifacts/sigs/Hoppscotch_Agent_mac_update_x64.tar.gz.sig + - name: Generate checksums + timeout-minutes: 2 + run: | + cd artifacts + for file in *; do + if [ -f "$file" ]; then + shasum -a 256 "$file" > "shas/${file}.sha256" + fi + done + cd updaters + for file in *; do + if [ -f "$file" ]; then + shasum -a 256 "$file" > "../shas/${file}.sha256" + fi + done + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: Hoppscotch_Agent-macos-x86_64 + path: artifacts/* + build-macos-aarch64: + name: Build MacOS ARM64 (.dmg) + runs-on: macos-latest + if: ${{ inputs.build_macos_arm64 }} + defaults: + run: + shell: bash + timeout-minutes: 60 + steps: + - name: Checkout hoppscotch/hoppscotch + uses: actions/checkout@v3 + with: + repository: hoppscotch/hoppscotch + ref: ${{ inputs.branch }} + token: ${{ secrets.HOPPSCOTCH_GITHUB_CHECKOUT_TOKEN }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.15.0 + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - name: Install Rust target + timeout-minutes: 5 + run: rustup target add aarch64-apple-darwin + - name: Install additional tools + timeout-minutes: 5 + run: | + mkdir __dist/ + cd __dist/ + curl -LO "https://github.com/tauri-apps/tauri/releases/download/tauri-cli-v2.0.1/cargo-tauri-aarch64-apple-darwin.zip" + unzip cargo-tauri-aarch64-apple-darwin.zip + chmod +x cargo-tauri + sudo mv cargo-tauri /usr/local/bin/tauri + - name: Import Code-Signing Certificates + uses: apple-actions/import-codesign-certs@v3 + with: + p12-file-base64: ${{ secrets.HOPPSCOTCH_APPLE_CERTIFICATE }} + p12-password: ${{ secrets.HOPPSCOTCH_APPLE_CERTIFICATE_PASSWORD }} + keychain-password: ${{ secrets.KEYCHAIN_PASSWORD }} + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-arm-${{ hashFiles('**/Cargo.lock') }} + - name: Install dependencies + timeout-minutes: 15 + run: | + cd packages/hoppscotch-agent + pnpm install --filter hoppscotch-agent + - name: Build Tauri app + timeout-minutes: 30 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.AGENT_TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.AGENT_TAURI_SIGNING_PASSWORD }} + APPLE_ID: ${{ secrets.HOPPSCOTCH_APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.HOPPSCOTCH_APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.HOPPSCOTCH_APPLE_TEAM_ID }} + APPLE_SIGNING_IDENTITY: ${{ secrets.HOPPSCOTCH_APPLE_SIGNING_IDENTITY }} + run: | + cd packages/hoppscotch-agent + echo "Starting ARM64 build..." + pnpm tauri build --verbose --target aarch64-apple-darwin + echo "Build completed" + - name: Prepare artifacts + run: | + mkdir -p artifacts/{sigs,updaters,shas} + mv packages/hoppscotch-agent/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*_aarch64.dmg artifacts/Hoppscotch_Agent_mac_aarch64.dmg + mv packages/hoppscotch-agent/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/updaters/Hoppscotch_Agent_mac_update_aarch64.tar.gz + mv packages/hoppscotch-agent/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz.sig artifacts/sigs/Hoppscotch_Agent_mac_update_aarch64.tar.gz.sig + - name: Generate checksums + timeout-minutes: 2 + run: | + cd artifacts + for file in *; do + if [ -f "$file" ]; then + shasum -a 256 "$file" > "shas/${file}.sha256" + fi + done + cd updaters + for file in *; do + if [ -f "$file" ]; then + shasum -a 256 "$file" > "../shas/${file}.sha256" + fi + done + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: Hoppscotch_Agent-macos-arm64 + path: artifacts/* + build-linux-deb: + name: Build Linux x86_64 (.deb) + runs-on: ubuntu-22.04 + if: ${{ inputs.build_linux_deb }} + defaults: + run: + shell: bash + timeout-minutes: 60 + steps: + - name: Checkout hoppscotch/hoppscotch + uses: actions/checkout@v3 + with: + repository: hoppscotch/hoppscotch + ref: ${{ inputs.branch }} + token: ${{ secrets.HOPPSCOTCH_GITHUB_CHECKOUT_TOKEN }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.15.0 + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - name: Install system dependencies + timeout-minutes: 5 + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev \ + build-essential \ + curl \ + wget \ + file \ + libxdo-dev \ + libssl-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev + - name: Install additional tools + timeout-minutes: 5 + run: | + curl -LO "https://github.com/tauri-apps/tauri/releases/download/tauri-cli-v2.0.1/cargo-tauri-x86_64-unknown-linux-gnu.tgz" + tar -xzf cargo-tauri-x86_64-unknown-linux-gnu.tgz + chmod +x cargo-tauri + sudo mv cargo-tauri /usr/local/bin/tauri + + curl -LO "https://github.com/thedodd/trunk/releases/download/v0.17.5/trunk-x86_64-unknown-linux-gnu.tar.gz" + tar -xzf trunk-x86_64-unknown-linux-gnu.tar.gz + chmod +x trunk + sudo mv trunk /usr/local/bin/ + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Install dependencies + timeout-minutes: 15 + run: | + cd packages/hoppscotch-agent + pnpm install --filter hoppscotch-agent + - name: Build Tauri app + timeout-minutes: 30 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + run: | + cd packages/hoppscotch-agent + pnpm tauri build --verbose -b deb -b updater + - name: Prepare artifacts + run: | + mkdir -p artifacts/{sigs,shas} + mv packages/hoppscotch-agent/src-tauri/target/release/bundle/deb/*.deb artifacts/Hoppscotch_Agent_linux_x64.deb + - name: Generate checksums + run: | + cd artifacts + for file in *; do + if [ -f "$file" ]; then + sha256sum "$file" > "shas/${file}.sha256" + fi + done + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: Hoppscotch_Agent-linux-deb + path: artifacts/* + build-linux-appimage: + name: Build Linux x86_64 (.AppImage) + runs-on: ubuntu-22.04 + if: ${{ inputs.build_linux_appimage }} + defaults: + run: + shell: bash + timeout-minutes: 60 + steps: + - name: Checkout hoppscotch/hoppscotch + uses: actions/checkout@v3 + with: + repository: hoppscotch/hoppscotch + ref: ${{ inputs.branch }} + token: ${{ secrets.HOPPSCOTCH_GITHUB_CHECKOUT_TOKEN }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.15.0 + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - name: Install system dependencies + timeout-minutes: 5 + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev \ + build-essential \ + curl \ + wget \ + file \ + libxdo-dev \ + libssl-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev + - name: Install additional tools + timeout-minutes: 5 + run: | + curl -LO "https://github.com/tauri-apps/tauri/releases/download/tauri-cli-v2.0.1/cargo-tauri-x86_64-unknown-linux-gnu.tgz" + tar -xzf cargo-tauri-x86_64-unknown-linux-gnu.tgz + chmod +x cargo-tauri + sudo mv cargo-tauri /usr/local/bin/tauri + + curl -LO "https://github.com/thedodd/trunk/releases/download/v0.17.5/trunk-x86_64-unknown-linux-gnu.tar.gz" + tar -xzf trunk-x86_64-unknown-linux-gnu.tar.gz + chmod +x trunk + sudo mv trunk /usr/local/bin/ + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Install dependencies + timeout-minutes: 15 + run: | + cd packages/hoppscotch-agent + pnpm install --filter hoppscotch-agent + - name: Build Tauri app + timeout-minutes: 30 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + run: | + cd packages/hoppscotch-agent + pnpm tauri build --verbose -b appimage -b updater + - name: Prepare artifacts + run: | + mkdir -p artifacts/{sigs,shas} + mv packages/hoppscotch-agent/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/Hoppscotch_Agent_linux_x64.AppImage + mv packages/hoppscotch-agent/src-tauri/target/release/bundle/appimage/*.AppImage.sig artifacts/sigs/Hoppscotch_Agent_linux_x64.AppImage.sig + - name: Generate checksums + run: | + cd artifacts + for file in *; do + if [ -f "$file" ]; then + sha256sum "$file" > "shas/${file}.sha256" + fi + done + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: Hoppscotch_Agent-linux-appimage + path: artifacts/* + build-windows-installer: + name: Build Windows x86_64 (.msi) + runs-on: windows-latest + if: ${{ inputs.build_windows_installer }} + defaults: + run: + shell: bash + timeout-minutes: 60 + steps: + - name: Checkout hoppscotch/hoppscotch + uses: actions/checkout@v3 + with: + repository: hoppscotch/hoppscotch + ref: ${{ inputs.branch }} + token: ${{ secrets.HOPPSCOTCH_GITHUB_CHECKOUT_TOKEN }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.15.0 + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - name: Download trusted-signing-cli + shell: pwsh + run: | + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe" -OutFile "trusted-signing-cli.exe" + Move-Item -Path "trusted-signing-cli.exe" -Destination "$env:GITHUB_WORKSPACE\trusted-signing-cli.exe" + echo "$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + - name: Setting up Windows Environment + timeout-minutes: 20 + shell: bash + env: + WINDOWS_SIGN_COMMAND: trusted-signing-cli -e ${{ secrets.AZURE_ENDPOINT }} -a ${{ secrets.AZURE_CODE_SIGNING_NAME }} -c ${{ secrets.AZURE_CERT_PROFILE_NAME }} %1 + run: | + cd packages/hoppscotch-agent + cat './src-tauri/tauri.conf.json' | jq '.bundle .windows += { "signCommand": env.WINDOWS_SIGN_COMMAND}' > './src-tauri/temp.json' && mv './src-tauri/temp.json' './src-tauri/tauri.conf.json' + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Install dependencies + timeout-minutes: 15 + shell: bash + run: | + cd packages/hoppscotch-agent + pnpm install --filter hoppscotch-agent + - name: Build Tauri app + timeout-minutes: 30 + shell: powershell + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.AGENT_TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.AGENT_TAURI_SIGNING_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + run: | + cd packages/hoppscotch-agent + pnpm tauri build --verbose -b msi -b updater + - name: Prepare artifacts + shell: bash + run: | + mkdir -p artifacts/{sigs,shas} + mv packages/hoppscotch-agent/src-tauri/target/release/bundle/msi/*_x64_en-US.msi artifacts/Hoppscotch_Agent_win_x64.msi + mv packages/hoppscotch-agent/src-tauri/target/release/bundle/msi/*_x64_en-US.msi.sig artifacts/sigs/Hoppscotch_Agent_win_x64.msi.sig + - name: Generate checksums + shell: powershell + run: | + cd artifacts + Get-ChildItem -File | ForEach-Object { + $hash = Get-FileHash -Algorithm SHA256 $_.Name + $hash.Hash + " " + $_.Name | Out-File -Encoding UTF8 "shas/$($_.Name).sha256" + } + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: Hoppscotch_Agent-windows-installer + path: artifacts/* + build-windows-portable: + name: Build Windows x86_64 Portable + runs-on: windows-latest + if: ${{ inputs.build_windows_portable }} + defaults: + run: + shell: bash + timeout-minutes: 60 + steps: + - name: Checkout hoppscotch/hoppscotch + uses: actions/checkout@v3 + with: + repository: hoppscotch/hoppscotch + ref: ${{ inputs.branch }} + token: ${{ secrets.HOPPSCOTCH_GITHUB_CHECKOUT_TOKEN }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.15.0 + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - name: Download trusted-signing-cli + shell: pwsh + run: | + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe" -OutFile "trusted-signing-cli.exe" + Move-Item -Path "trusted-signing-cli.exe" -Destination "$env:GITHUB_WORKSPACE\trusted-signing-cli.exe" + echo "$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + - name: Setting up Windows Environment + timeout-minutes: 20 + shell: bash + env: + WINDOWS_SIGN_COMMAND: trusted-signing-cli -e ${{ secrets.AZURE_ENDPOINT }} -a ${{ secrets.AZURE_CODE_SIGNING_NAME }} -c ${{ secrets.AZURE_CERT_PROFILE_NAME }} %1 + run: | + cd packages/hoppscotch-agent + cat './src-tauri/tauri.portable.conf.json' | jq '.bundle .windows += { "signCommand": env.WINDOWS_SIGN_COMMAND}' > './src-tauri/temp_portable.json' && mv './src-tauri/temp_portable.json' './src-tauri/tauri.portable.conf.json' + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Install dependencies + timeout-minutes: 15 + shell: bash + run: | + cd packages/hoppscotch-agent + pnpm install --filter hoppscotch-agent + - name: Build Tauri app + timeout-minutes: 30 + shell: powershell + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.AGENT_TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.AGENT_TAURI_SIGNING_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + run: | + cd packages/hoppscotch-agent + pnpm tauri build --verbose --config src-tauri/tauri.portable.conf.json -- --no-default-features --features portable + - name: Sign portable executable + shell: powershell + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + run: | + cd packages/hoppscotch-agent + trusted-signing-cli -e ${{ secrets.AZURE_ENDPOINT }} -a ${{ secrets.AZURE_CODE_SIGNING_NAME }} -c ${{ secrets.AZURE_CERT_PROFILE_NAME }} "src-tauri/target/release/hoppscotch-agent.exe" + - name: Zip portable executable + shell: powershell + run: | + Compress-Archive -Path "packages/hoppscotch-agent/src-tauri/target/release/hoppscotch-agent.exe" -DestinationPath "packages/hoppscotch-agent/src-tauri/target/release/Hoppscotch_Agent_win_x64_portable.zip" + - name: Prepare artifacts + shell: bash + run: | + mkdir -p artifacts/{sigs,shas} + mv packages/hoppscotch-agent/src-tauri/target/release/Hoppscotch_Agent_win_x64_portable.zip artifacts/Hoppscotch_Agent_win_x64_portable.zip + - name: Generate checksums + shell: powershell + run: | + cd artifacts + Get-ChildItem -File | ForEach-Object { + $hash = Get-FileHash -Algorithm SHA256 $_.Name + $hash.Hash + " " + $_.Name | Out-File -Encoding UTF8 "shas/$($_.Name).sha256" + } + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: Hoppscotch_Agent-windows-portable + path: artifacts/* + create-update-manifest: + name: Create Update Manifest + needs: [build-macos-x86_64, build-macos-aarch64, build-linux-deb, build-linux-appimage, build-windows-installer, build-windows-portable] + runs-on: ubuntu-latest + if: ${{ inputs.build_macos_x64 && inputs.build_macos_arm64 && inputs.build_linux_appimage && inputs.build_windows_installer }} + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + - name: List downloaded artifacts + run: find artifacts -type f | sort + - name: Create update manifest + run: | + VERSION="${{ inputs.version }}" + CURRENT_DATE=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ") + + cat > artifacts/hoppscotch-agent-update.json << EOF + { + "version": "${VERSION}", + "notes": "${{ inputs.release_notes }}", + "pub_date": "${CURRENT_DATE}", + "platforms": { + "darwin-x86_64": { + "signature": "$(cat artifacts/Hoppscotch_Agent-macos-x86_64/sigs/Hoppscotch_Agent_mac_update_x64.tar.gz.sig)", + "url": "https://github.com/hoppscotch/agent-releases/releases/download/${VERSION}/Hoppscotch_Agent_mac_update_x64.tar.gz" + }, + "darwin-aarch64": { + "signature": "$(cat artifacts/Hoppscotch_Agent-macos-arm64/sigs/Hoppscotch_Agent_mac_update_aarch64.tar.gz.sig)", + "url": "https://github.com/hoppscotch/agent-releases/releases/download/${VERSION}/Hoppscotch_Agent_mac_update_aarch64.tar.gz" + }, + "linux-x86_64": { + "signature": "$(cat artifacts/Hoppscotch_Agent-linux-appimage/sigs/Hoppscotch_Agent_linux_x64.AppImage.sig)", + "url": "https://github.com/hoppscotch/agent-releases/releases/download/${VERSION}/Hoppscotch_Agent_linux_x64.AppImage" + }, + "windows-x86_64": { + "signature": "$(cat artifacts/Hoppscotch_Agent-windows-installer/sigs/Hoppscotch_Agent_win_x64.msi.sig)", + "url": "https://github.com/hoppscotch/agent-releases/releases/download/${VERSION}/Hoppscotch_Agent_win_x64.msi" + } + } + } + EOF + - name: Upload manifest + uses: actions/upload-artifact@v4 + with: + name: update-manifest + path: artifacts/hoppscotch-agent-update.json diff --git a/.github/workflows/build-hoppscotch-desktop.yml b/.github/workflows/build-hoppscotch-desktop.yml new file mode 100644 index 0000000..3e5f657 --- /dev/null +++ b/.github/workflows/build-hoppscotch-desktop.yml @@ -0,0 +1,502 @@ +name: Build Desktop Self Host - AIO +on: + workflow_dispatch: + inputs: + version: + description: Tag of the version to build + required: true + repository: + description: Repository to checkout + required: false + default: "hoppscotch/hoppscotch" + branch: + description: Branch to checkout + required: true + default: "main" + tag: + description: Tag to checkout (takes precedence over branch if provided) + required: false + default: "" + release_notes: + description: Release notes for the update + required: false + default: "PLACEHOLDER RELEASE NOTES" + disable_signing: + description: Disable executable signing + required: false + type: boolean + default: false + build_linux: + description: Build for Linux + type: boolean + required: false + default: true + build_windows: + description: Build for Windows + type: boolean + required: false + default: true + build_macos_x64: + description: Build for macOS x64 + type: boolean + required: false + default: true + build_macos_arm64: + description: Build for macOS ARM64 + type: boolean + required: false + default: true +env: + CARGO_TERM_COLOR: always + WORKSPACE_PATH: ${{ github.workspace }} + WEB_PATH: ${{ github.workspace }}/packages/hoppscotch-selfhost-web + DESKTOP_PATH: ${{ github.workspace }}/packages/hoppscotch-desktop + BUNDLER_PATH: ${{ github.workspace }}/packages/hoppscotch-desktop/crates/webapp-bundler +jobs: + build-linux: + runs-on: ubuntu-24.04 + if: ${{ inputs.build_linux }} + steps: + - uses: actions/checkout@v3 + with: + repository: ${{ inputs.repository }} + ref: ${{ inputs.tag != '' && inputs.tag || inputs.branch }} + token: ${{ secrets.HOPPSCOTCH_GITHUB_CHECKOUT_TOKEN }} + - uses: actions/setup-node@v3 + with: + node-version: 20 + - uses: pnpm/action-setup@v4 + with: + version: 10.18.3 + - uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + override: true + - name: Install additional tools + run: | + curl -LO "https://github.com/tauri-apps/tauri/releases/download/tauri-cli-v2.2.0/cargo-tauri-x86_64-unknown-linux-gnu.tgz" + tar -xzf cargo-tauri-x86_64-unknown-linux-gnu.tgz + chmod +x cargo-tauri + sudo mv cargo-tauri /usr/local/bin/tauri + - name: Install system dependencies + run: | + sudo apt update; + sudo apt install -y \ + build-essential \ + curl \ + wget \ + file \ + libssl-dev \ + libgtk-3-dev \ + libappindicator3-dev \ + librsvg2-dev; + + sudo apt install -y \ + libwebkit2gtk-4.1-0=2.44.0-2 \ + libwebkit2gtk-4.1-dev=2.44.0-2 \ + libjavascriptcoregtk-4.1-0=2.44.0-2 \ + libjavascriptcoregtk-4.1-dev=2.44.0-2 \ + gir1.2-javascriptcoregtk-4.1=2.44.0-2 \ + gir1.2-webkit2-4.1=2.44.0-2; + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Setup environment + run: | + if [ ! -z "${{ secrets.ENV_FILE_CONTENT }}" ]; then + echo "${{ secrets.ENV_FILE_CONTENT }}" > ${{ env.WORKSPACE_PATH }}/.env + echo "Created .env file from repository secret" + elif [ -f "${{ env.WORKSPACE_PATH }}/.env" ]; then + echo "Using existing .env file found in repository" + else + cp ${{ env.WORKSPACE_PATH }}/.env.example ${{ env.WORKSPACE_PATH }}/.env + echo "No .env found, copied from .env.example template" + fi + pnpm install --dir ${{ env.DESKTOP_PATH }} + - name: Build web app + run: | + pnpm install --dir ${{ env.WEB_PATH }} + pnpm --dir ${{ env.WEB_PATH }} generate + - name: Build and run webapp-bundler + run: | + cargo build --release --manifest-path ${{ env.BUNDLER_PATH }}/Cargo.toml + ${{ env.BUNDLER_PATH }}/target/release/webapp-bundler \ + --input ${{ env.WEB_PATH }}/dist \ + --output ${{ env.DESKTOP_PATH }}/bundle.zip \ + --manifest ${{ env.DESKTOP_PATH }}/manifest.json + - name: Build AppImage + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + RUST_LOG: debug + run: pnpm --dir ${{ env.DESKTOP_PATH }} tauri build --verbose -b appimage -b updater + - name: Build DEB + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + RUST_LOG: debug + run: pnpm --dir ${{ env.DESKTOP_PATH }} tauri build --verbose -b deb -b updater + - name: Prepare artifacts + run: | + ls -lahR ${{ env.DESKTOP_PATH }}/src-tauri/target/release/bundle/ + mkdir -p dist + cp ${{ env.DESKTOP_PATH }}/src-tauri/target/release/bundle/appimage/*.AppImage dist/Hoppscotch_SelfHost_linux_x64.AppImage + cp ${{ env.DESKTOP_PATH }}/src-tauri/target/release/bundle/appimage/*.AppImage.sig dist/Hoppscotch_SelfHost_linux_x64.AppImage.sig + cp ${{ env.DESKTOP_PATH }}/src-tauri/target/release/bundle/deb/*.deb dist/Hoppscotch_SelfHost_linux_x64.deb + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: selfhost-desktop-linux + path: dist/* + build-windows: + runs-on: windows-latest + if: ${{ inputs.build_windows }} + steps: + - uses: actions/checkout@v3 + with: + repository: ${{ inputs.repository }} + ref: ${{ inputs.tag != '' && inputs.tag || inputs.branch }} + token: ${{ secrets.HOPPSCOTCH_GITHUB_CHECKOUT_TOKEN }} + - name: Set Perl environment variables + shell: pwsh + run: | + echo "PERL=$((where.exe perl)[0])" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + echo "OPENSSL_SRC_PERL=$((where.exe perl)[0])" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + - uses: actions/setup-node@v3 + with: + node-version: 20 + - uses: pnpm/action-setup@v4 + with: + version: 10.18.3 + - uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + override: true + - name: Download trusted-signing-cli + if: ${{ inputs.disable_signing != true }} + shell: pwsh + run: | + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe" -OutFile "trusted-signing-cli.exe" + Move-Item -Path "trusted-signing-cli.exe" -Destination "$env:GITHUB_WORKSPACE\trusted-signing-cli.exe" + echo "$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + - name: Setting up Windows Signing Environment + if: ${{ inputs.disable_signing != true }} + timeout-minutes: 20 + shell: bash + env: + WINDOWS_SIGN_COMMAND: trusted-signing-cli -e ${{ secrets.AZURE_ENDPOINT }} -a ${{ secrets.AZURE_CODE_SIGNING_NAME }} -c ${{ secrets.AZURE_CERT_PROFILE_NAME }} %1 + run: | + cat "${{ env.DESKTOP_PATH }}/src-tauri/tauri.conf.json" | jq '.bundle .windows += { "signCommand": env.WINDOWS_SIGN_COMMAND}' > "${{ env.DESKTOP_PATH }}/src-tauri/temp.json" && mv "${{ env.DESKTOP_PATH }}/src-tauri/temp.json" "${{ env.DESKTOP_PATH }}/src-tauri/tauri.conf.json" + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Setup environment + shell: pwsh + run: | + if ("${{ secrets.ENV_FILE_CONTENT }}" -ne "") { + "${{ secrets.ENV_FILE_CONTENT }}" | Out-File -FilePath ${{ env.WORKSPACE_PATH }}\.env -Encoding utf8 + Write-Host "Created .env file from repository secret" + } elseif (Test-Path -Path "${{ env.WORKSPACE_PATH }}\.env") { + Write-Host "Using existing .env file found in repository" + } else { + Copy-Item ${{ env.WORKSPACE_PATH }}\.env.example ${{ env.WORKSPACE_PATH }}\.env + Write-Host "No .env found, copied from .env.example template" + } + pnpm install -f --shamefully-hoist --ignore-scripts + pnpm --filter hoppscotch-backend exec prisma generate + pnpm install -f --shamefully-hoist --dir ${{ env.DESKTOP_PATH }} + - name: Build web app + shell: pwsh + run: | + pnpm install --dir ${{ env.WEB_PATH }} + pnpm --dir ${{ env.WEB_PATH }} generate + - name: Build and run webapp-bundler + shell: pwsh + run: | + cargo build --release --manifest-path ${{ env.BUNDLER_PATH }}\Cargo.toml + ${{ env.BUNDLER_PATH }}\target\release\webapp-bundler.exe ` + --input ${{ env.WEB_PATH }}\dist ` + --output ${{ env.DESKTOP_PATH }}\bundle.zip ` + --manifest ${{ env.DESKTOP_PATH }}\manifest.json + - name: Build Tauri app with Azure signing + if: ${{ inputs.disable_signing != true }} + shell: powershell + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + RUST_LOG: debug + run: pnpm --dir ${{ env.DESKTOP_PATH }} tauri build --verbose -b msi -b updater + - name: Build Tauri app without Azure signing + if: ${{ inputs.disable_signing == true }} + shell: powershell + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + RUST_LOG: debug + run: pnpm --dir ${{ env.DESKTOP_PATH }} tauri build --verbose -b msi -b updater + - name: Prepare artifacts + shell: pwsh + run: | + Get-ChildItem -Recurse ${{ env.DESKTOP_PATH }}\src-tauri\target\release\bundle + mkdir dist + Copy-Item ${{ env.DESKTOP_PATH }}\src-tauri\target\release\bundle\msi\*_x64_en-US.msi dist\Hoppscotch_SelfHost_win_x64.msi + Copy-Item ${{ env.DESKTOP_PATH }}\src-tauri\target\release\bundle\msi\*_x64_en-US.msi.sig dist\Hoppscotch_SelfHost_win_x64.msi.sig + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: selfhost-desktop-windows + path: dist/* + build-macos-x64: + runs-on: macos-latest + if: ${{ inputs.build_macos_x64 }} + steps: + - uses: actions/checkout@v3 + with: + repository: ${{ inputs.repository }} + ref: ${{ inputs.tag != '' && inputs.tag || inputs.branch }} + token: ${{ secrets.HOPPSCOTCH_GITHUB_CHECKOUT_TOKEN }} + - uses: actions/setup-node@v3 + with: + node-version: 20 + - uses: pnpm/action-setup@v4 + with: + version: 10.18.3 + - uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + override: true + - name: Install Rust target + run: rustup target add x86_64-apple-darwin + - name: Install additional tools + run: | + mkdir __dist/ + cd __dist/ + curl -LO "https://github.com/tauri-apps/tauri/releases/download/tauri-cli-v2.2.0/cargo-tauri-x86_64-apple-darwin.zip" + unzip cargo-tauri-x86_64-apple-darwin.zip + chmod +x cargo-tauri + sudo mv cargo-tauri /usr/local/bin/tauri + - uses: apple-actions/import-codesign-certs@v3 + if: ${{ inputs.disable_signing != true }} + with: + p12-file-base64: ${{ secrets.HOPPSCOTCH_APPLE_CERTIFICATE }} + p12-password: ${{ secrets.HOPPSCOTCH_APPLE_CERTIFICATE_PASSWORD }} + keychain-password: ${{ secrets.KEYCHAIN_PASSWORD }} + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-x86_64-${{ hashFiles('**/Cargo.lock') }} + - name: Setup environment + run: | + if [ ! -z "${{ secrets.ENV_FILE_CONTENT }}" ]; then + echo "${{ secrets.ENV_FILE_CONTENT }}" > ${{ env.WORKSPACE_PATH }}/.env + echo "Created .env file from repository secret" + elif [ -f "${{ env.WORKSPACE_PATH }}/.env" ]; then + echo "Using existing .env file found in repository" + else + cp ${{ env.WORKSPACE_PATH }}/.env.example ${{ env.WORKSPACE_PATH }}/.env + echo "No .env found, copied from .env.example template" + fi + pnpm install --dir ${{ env.DESKTOP_PATH }} + - name: Build web app + run: | + pnpm install --dir ${{ env.WEB_PATH }} + pnpm --dir ${{ env.WEB_PATH }} generate + - name: Build and run webapp-bundler + run: | + cargo build --release --manifest-path ${{ env.BUNDLER_PATH }}/Cargo.toml + ${{ env.BUNDLER_PATH }}/target/release/webapp-bundler \ + --input ${{ env.WEB_PATH }}/dist \ + --output ${{ env.DESKTOP_PATH }}/bundle.zip \ + --manifest ${{ env.DESKTOP_PATH }}/manifest.json + - name: Build Tauri app with Apple signing + if: ${{ inputs.disable_signing != true }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.HOPPSCOTCH_APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.HOPPSCOTCH_APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.HOPPSCOTCH_APPLE_TEAM_ID }} + APPLE_SIGNING_IDENTITY: ${{ secrets.HOPPSCOTCH_APPLE_SIGNING_IDENTITY }} + RUST_LOG: debug + run: pnpm --dir ${{ env.DESKTOP_PATH }} tauri build --verbose --target x86_64-apple-darwin + - name: Build Tauri app without Apple signing + if: ${{ inputs.disable_signing == true }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + RUST_LOG: debug + run: pnpm --dir ${{ env.DESKTOP_PATH }} tauri build --verbose --target x86_64-apple-darwin + - name: Prepare artifacts + run: | + ls -lahR ${{ env.DESKTOP_PATH }}/src-tauri/target/x86_64-apple-darwin/release/bundle/ + mkdir -p dist + cp ${{ env.DESKTOP_PATH }}/src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg dist/Hoppscotch_SelfHost_mac_x64.dmg + cp ${{ env.DESKTOP_PATH }}/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/Hoppscotch.app.tar.gz dist/Hoppscotch_SelfHost_mac_x64.tar.gz + cp ${{ env.DESKTOP_PATH }}/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/Hoppscotch.app.tar.gz.sig dist/Hoppscotch_SelfHost_mac_x64.tar.gz.sig + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: selfhost-desktop-macos-x64 + path: dist/* + build-macos-arm64: + runs-on: macos-latest + if: ${{ inputs.build_macos_arm64 }} + steps: + - uses: actions/checkout@v3 + with: + repository: ${{ inputs.repository }} + ref: ${{ inputs.tag != '' && inputs.tag || inputs.branch }} + token: ${{ secrets.HOPPSCOTCH_GITHUB_CHECKOUT_TOKEN }} + - uses: actions/setup-node@v3 + with: + node-version: 20 + - uses: pnpm/action-setup@v4 + with: + version: 10.18.3 + - uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + override: true + - name: Install Rust target + run: rustup target add aarch64-apple-darwin + - name: Install additional tools + run: | + mkdir __dist/ + cd __dist/ + curl -LO "https://github.com/tauri-apps/tauri/releases/download/tauri-cli-v2.2.0/cargo-tauri-aarch64-apple-darwin.zip" + unzip cargo-tauri-aarch64-apple-darwin.zip + chmod +x cargo-tauri + sudo mv cargo-tauri /usr/local/bin/tauri + - uses: apple-actions/import-codesign-certs@v3 + if: ${{ inputs.disable_signing != true }} + with: + p12-file-base64: ${{ secrets.HOPPSCOTCH_APPLE_CERTIFICATE }} + p12-password: ${{ secrets.HOPPSCOTCH_APPLE_CERTIFICATE_PASSWORD }} + keychain-password: ${{ secrets.KEYCHAIN_PASSWORD }} + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-aarch64-${{ hashFiles('**/Cargo.lock') }} + - name: Setup environment + run: | + if [ ! -z "${{ secrets.ENV_FILE_CONTENT }}" ]; then + echo "${{ secrets.ENV_FILE_CONTENT }}" > ${{ env.WORKSPACE_PATH }}/.env + echo "Created .env file from repository secret" + elif [ -f "${{ env.WORKSPACE_PATH }}/.env" ]; then + echo "Using existing .env file found in repository" + else + cp ${{ env.WORKSPACE_PATH }}/.env.example ${{ env.WORKSPACE_PATH }}/.env + echo "No .env found, copied from .env.example template" + fi + pnpm install --dir ${{ env.DESKTOP_PATH }} + - name: Build web app + run: | + pnpm install --dir ${{ env.WEB_PATH }} + pnpm --dir ${{ env.WEB_PATH }} generate + - name: Build and run webapp-bundler + run: | + cargo build --release --manifest-path ${{ env.BUNDLER_PATH }}/Cargo.toml + ${{ env.BUNDLER_PATH }}/target/release/webapp-bundler \ + --input ${{ env.WEB_PATH }}/dist \ + --output ${{ env.DESKTOP_PATH }}/bundle.zip \ + --manifest ${{ env.DESKTOP_PATH }}/manifest.json + - name: Build Tauri app with Apple signing + if: ${{ inputs.disable_signing != true }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.HOPPSCOTCH_APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.HOPPSCOTCH_APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.HOPPSCOTCH_APPLE_TEAM_ID }} + APPLE_SIGNING_IDENTITY: ${{ secrets.HOPPSCOTCH_APPLE_SIGNING_IDENTITY }} + RUST_LOG: debug + run: pnpm --dir ${{ env.DESKTOP_PATH }} tauri build --verbose --target aarch64-apple-darwin + - name: Build Tauri app without Apple signing + if: ${{ inputs.disable_signing == true }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + RUST_LOG: debug + run: pnpm --dir ${{ env.DESKTOP_PATH }} tauri build --verbose --target aarch64-apple-darwin + - name: Prepare artifacts + run: | + ls -lahR ${{ env.DESKTOP_PATH }}/src-tauri/target/aarch64-apple-darwin/release/bundle/ + mkdir -p dist + cp ${{ env.DESKTOP_PATH }}/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg dist/Hoppscotch_SelfHost_mac_aarch64.dmg + cp ${{ env.DESKTOP_PATH }}/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Hoppscotch.app.tar.gz dist/Hoppscotch_SelfHost_mac_aarch64.tar.gz + cp ${{ env.DESKTOP_PATH }}/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Hoppscotch.app.tar.gz.sig dist/Hoppscotch_SelfHost_mac_aarch64.tar.gz.sig + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: selfhost-desktop-macos-aarch64 + path: dist/* + create-update-manifest: + needs: [build-linux, build-windows, build-macos-x64, build-macos-arm64] + runs-on: ubuntu-latest + if: ${{ inputs.build_linux && inputs.build_windows && inputs.build_macos_x64 && inputs.build_macos_arm64 }} + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Create update manifest + run: | + VERSION="${{ inputs.version }}" + CURRENT_DATE=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ") + + cat > artifacts/hoppscotch-selfhost-desktop.json << EOF + { + "version": "${VERSION}", + "notes": "${{ inputs.release_notes }}", + "pub_date": "${CURRENT_DATE}", + "platforms": { + "linux-x86_64": { + "signature": "$(cat artifacts/selfhost-desktop-linux/Hoppscotch_SelfHost_linux_x64.AppImage.sig)", + "url": "https://github.com/hoppscotch/releases/releases/download/${VERSION}/Hoppscotch_SelfHost_linux_x64.AppImage" + }, + "windows-x86_64": { + "signature": "$(cat artifacts/selfhost-desktop-windows/Hoppscotch_SelfHost_win_x64.msi.sig)", + "url": "https://github.com/hoppscotch/releases/releases/download/${VERSION}/Hoppscotch_SelfHost_win_x64.msi" + }, + "darwin-x86_64": { + "signature": "$(cat artifacts/selfhost-desktop-macos-x64/Hoppscotch_SelfHost_mac_x64.tar.gz.sig)", + "url": "https://github.com/hoppscotch/releases/releases/download/${VERSION}/Hoppscotch_SelfHost_mac_x64.tar.gz" + }, + "darwin-aarch64": { + "signature": "$(cat artifacts/selfhost-desktop-macos-aarch64/Hoppscotch_SelfHost_mac_aarch64.tar.gz.sig)", + "url": "https://github.com/hoppscotch/releases/releases/download/${VERSION}/Hoppscotch_SelfHost_mac_aarch64.tar.gz" + } + } + } + EOF + - name: Upload manifest + uses: actions/upload-artifact@v4 + with: + name: selfhost-desktop-update-manifest + path: artifacts/hoppscotch-selfhost-desktop.json diff --git a/.github/workflows/release-push-docker.yml b/.github/workflows/release-push-docker.yml new file mode 100644 index 0000000..c7e3ef5 --- /dev/null +++ b/.github/workflows/release-push-docker.yml @@ -0,0 +1,182 @@ +name: "Push containers to Docker Hub on release" + +on: + push: + tags: + - '*.*.*' + workflow_dispatch: + inputs: + # NO INPUTS + +jobs: + build: + strategy: + matrix: + platform: [ + { platform: linux/amd64, cache: docker-release-amd64, artifactSuffix: amd64 }, + { platform: linux/arm64, cache: docker-release-arm64, artifactSuffix: arm64 }, + ] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup environment + run: cp .env.example .env + + - name: Setup QEMU + uses: docker/setup-qemu-action@v3 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push the backend container by digest + id: backend-build + uses: docker/build-push-action@v4 + with: + context: . + file: ./prod.Dockerfile + target: backend + cache-from: type=gha,timeout=200m,scope=${{ matrix.platform.cache }} + cache-to: type=gha,mode=max,timeout=200m,scope=${{ matrix.platform.cache }} + platforms: | + ${{ matrix.platform.platform }} + tags: | + ${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }} + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + + - name: Build and push the frontend container by digest + id: frontend-build + uses: docker/build-push-action@v4 + with: + context: . + file: ./prod.Dockerfile + target: app + cache-from: type=gha,timeout=200m,scope=${{ matrix.platform.cache }} + cache-to: type=gha,mode=max,timeout=200m,scope=${{ matrix.platform.cache }} + platforms: | + ${{ matrix.platform.platform }} + tags: | + ${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }} + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + + - name: Build and push the admin dashboard container by digest + id: sh_admin-build + uses: docker/build-push-action@v4 + with: + context: . + file: ./prod.Dockerfile + target: sh_admin + cache-from: type=gha,timeout=200m,scope=${{ matrix.platform.cache }} + cache-to: type=gha,mode=max,timeout=200m,scope=${{ matrix.platform.cache }} + platforms: | + ${{ matrix.platform.platform }} + tags: | + ${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }} + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + + - name: Build and push the AIO container by digest + id: aio-build + uses: docker/build-push-action@v4 + with: + context: . + file: ./prod.Dockerfile + target: aio + cache-from: type=gha,timeout=200m,scope=${{ matrix.platform.cache }} + cache-to: type=gha,mode=max,timeout=200m,scope=${{ matrix.platform.cache }} + platforms: | + ${{ matrix.platform.platform }} + tags: | + ${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }} + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + + - name: Make digest files + run: | + backend_digest="${{ steps.backend-build.outputs.digest }}" + frontend_digest="${{ steps.frontend-build.outputs.digest }}" + sh_admin_digest="${{ steps.sh_admin-build.outputs.digest }}" + aio_digest="${{ steps.aio-build.outputs.digest }}" + + mkdir -p digests/backend digests/frontend digests/sh_admin digests/aio + + touch "digests/backend/${backend_digest#sha256:}" + touch "digests/frontend/${frontend_digest#sha256:}" + touch "digests/sh_admin/${sh_admin_digest#sha256:}" + touch "digests/aio/${aio_digest#sha256:}" + + - name: Upload digests files as artifacts + uses: actions/upload-artifact@v4 + with: + name: release-docker-build-digests-${{ github.ref_name }}-${{ matrix.platform.artifactSuffix }} + path: digests/* + if-no-files-found: error + retention-days: 1 + + assemble-and-push-to-docker-hub: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests from artifacts + uses: actions/download-artifact@v4 + with: + path: digests + pattern: release-docker-build-digests-${{ github.ref_name }}-* + merge-multiple: true + + - name: Setup QEMU + uses: docker/setup-qemu-action@v3 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: "[Backend] - Create manifest list and push" + working-directory: digests/backend + run: | + docker buildx imagetools create -t ${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}:${{ github.ref_name }} \ + $(printf '${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}@sha256:%s ' *) + + docker buildx imagetools create -t ${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}:latest \ + $(printf '${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}@sha256:%s ' *) + + + - name: "[Frontend] - Create manifest list and push" + working-directory: digests/frontend + run: | + docker buildx imagetools create -t ${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}:${{ github.ref_name }} \ + $(printf '${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}@sha256:%s ' *) + + docker buildx imagetools create -t ${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}:latest \ + $(printf '${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}@sha256:%s ' *) + + + - name: "[SH Admin] - Create manifest list and push" + working-directory: digests/sh_admin + run: | + docker buildx imagetools create -t ${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}:${{ github.ref_name }} \ + $(printf '${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}@sha256:%s ' *) + + docker buildx imagetools create -t ${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}:latest \ + $(printf '${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}@sha256:%s ' *) + + - name: "[AIO] - Create manifest list and push" + working-directory: digests/aio + run: | + docker buildx imagetools create -t ${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }}:${{ github.ref_name }} \ + $(printf '${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }}@sha256:%s ' *) + + docker buildx imagetools create -t ${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }}:latest \ + $(printf '${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }}@sha256:%s ' *) + diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..56d67b5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,45 @@ +name: Node.js CI + +on: + push: + branches: [main, next, patch] + pull_request: + branches: [main, next, patch] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + strategy: + matrix: + # Pinned to Node.js 22 due to known test failures on Node.js 24. + # Future TODO: Investigate test failures and move to Node.js 24 (Active LTS). + node-version: [22] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup environment + run: mv .env.example .env + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 10 + run_install: false + + - name: Install dependencies + run: pnpm install + env: + DATABASE_URL: postgresql://postgres:testpass@localhost:5432/hoppscotch + DATA_ENCRYPTION_KEY: "12345678901234567890123456789012" + + - name: Run tests + run: pnpm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fa9e3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,190 @@ +# Firebase +.firebase + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock +*.env + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# SvelteKit build / generate output +.svelte-kit + +# IDE / Editor +.idea + +# Service worker +sw.* + +# Mac OSX +.DS_Store + +# Vim swap files +*.swp + +# Build data +.hoppscotch + +# File explorer +.directory + +# Tests screenshots +tests/*/screenshots + +# Tests videos +tests/*/videos + +# Local Netlify folder +.netlify + +# PNPM +.pnpm-store + +# GQL SDL generated for the frontends +gql-gen/ + +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml + +# AI Coding Assistants +.claude/ +.cursor/ +.aider* +.windsurfrules diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..dab272d --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no-install commitlint --edit "" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..1382386 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npm run pre-commit \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..ff0c3c1 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +shamefully-hoist=false +save-prefix='' diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..906185b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +.dependabot +.github +.nuxt +.hoppscotch +.vscode +package-lock.json +node_modules +dist \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..40c0e8d --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,8 @@ +module.exports = { + semi: false, + trailingComma: "es5", + singleQuote: false, + printWidth: 80, + useTabs: false, + tabWidth: 2 +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..61c3fb9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +Visit [releases](https://github.com/hoppscotch/hoppscotch/releases) for full changelog. diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..5c83766 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,21 @@ +# CODEOWNERS is prioritized from bottom to top + +# Packages +/packages/codemirror-lang-graphql/ @AndrewBastin +/packages/hoppscotch-cli/ @jamesgeorge007 +/packages/hoppscotch-data/ @AndrewBastin +/packages/hoppscotch-js-sandbox/ @jamesgeorge007 +/packages/hoppscotch-selfhost-web/ @jamesgeorge007 +/packages/hoppscotch-selfhost-desktop/ @AndrewBastin +/packages/hoppscotch-sh-admin/ @JoelJacobStephen +/packages/hoppscotch-backend/ @balub + +# READMEs and other documentation files +*.md @liyasthomas + +# Self Host deployment related files +*.Dockerfile @balub +docker-compose.yml @balub +docker-compose.deploy.yml @balub +*.Caddyfile @balub +.dockerignore @balub diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5a23ef5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +support@hoppscotch.io. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ce37ce4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,14 @@ +# Contributing + +When contributing to this repository, please first discuss the change you wish to make via issue, +email, or any other method with the owners of this repository before making a change. + +Please note we have a code of conduct, please follow it in all your interactions with the project. + +## Pull Request Process + +1. Ensure any install or build dependencies are removed before the end of the layer when doing a + build. +2. Update the README.md with details of changes to the interface, this includes new environment + variables, exposed ports, useful file locations and container parameters. +3. Make sure you do not expose environment variables or other sensitive information in your PR. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4d20996 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffe946e --- /dev/null +++ b/README.md @@ -0,0 +1,299 @@ +
+ + Hoppscotch + +

+ + Hoppscotch + +

+ + Open Source API Development Ecosystem + +

+ +[![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen?logo=github)](CODE_OF_CONDUCT.md) [![Website](https://img.shields.io/website?url=https%3A%2F%2Fhoppscotch.io&logo=hoppscotch)](https://hoppscotch.io) [![Tests](https://github.com/hoppscotch/hoppscotch/actions/workflows/tests.yml/badge.svg)](https://github.com/hoppscotch/hoppscotch/actions) [![Tweet](https://img.shields.io/twitter/url?url=https%3A%2F%2Fhoppscotch.io%2F)](https://x.com/share?text=%F0%9F%91%BD%20Hoppscotch%20%E2%80%A2%20Open%20source%20API%20development%20ecosystem%20-%20Helps%20you%20create%20requests%20faster,%20saving%20precious%20time%20on%20development.&url=https://hoppscotch.io&hashtags=hoppscotch&via=hoppscotch_io) + +

+

+ + Built with ❤︎ by + + contributors + + +

+
+

+ + + + + Hoppscotch + + +

+
+ +_We highly recommend you take a look at the [**Hoppscotch Documentation**](https://docs.hoppscotch.io) to learn more about the app._ + +#### **Support** + +[![Chat on Discord](https://img.shields.io/badge/chat-Discord-7289DA?logo=discord)](https://hoppscotch.io/discord) [![Chat on Telegram](https://img.shields.io/badge/chat-Telegram-2CA5E0?logo=telegram)](https://hoppscotch.io/telegram) [![Discuss on GitHub](https://img.shields.io/badge/discussions-GitHub-333333?logo=github)](https://github.com/hoppscotch/hoppscotch/discussions) + +### **Features** + +❤️ **Lightweight:** Crafted with minimalistic UI design. + +⚡️ **Fast:** Send requests and get responses in real time. + +🗄️ **HTTP Methods:** Request methods define the type of action you are requesting to be performed. + +- `GET` - Requests retrieve resource information +- `POST` - The server creates a new entry in a database +- `PUT` - Updates an existing resource +- `PATCH` - Very similar to `PUT` but makes a partial update on a resource +- `DELETE` - Deletes resource or related component +- `HEAD` - Retrieve response headers identical to those of a GET request, but without the response body. +- `CONNECT` - Establishes a tunnel to the server identified by the target resource +- `OPTIONS` - Describe the communication options for the target resource +- `TRACE` - Performs a message loop-back test along the path to the target resource +- `` - Some APIs use custom request methods such as `LIST`. Type in your custom methods. + +🌈 **Theming:** Customizable combinations for background, foreground, and accent colors — [customize now](https://hoppscotch.io/settings). + +- Choose a theme: System preference, Light, Dark, and Black +- Choose accent colors: Green, Teal, Blue, Indigo, Purple, Yellow, Orange, Red, and Pink +- Distraction-free Zen mode + +_Customized themes are synced with your cloud/local session._ + +🔥 **PWA:** Install as a [Progressive Web App](https://web.dev/progressive-web-apps) on your device. + +- Instant loading with Service Workers +- Offline support +- Low RAM/memory and CPU usage +- Add to Home Screen +- Desktop PWA + +🚀 **Request:** Retrieve response from endpoint instantly. + +1. Choose `method` +2. Enter `URL` +3. Send + +- Copy/share public "Share URL" +- Generate/copy request code snippets for 10+ languages and frameworks +- Import `cURL` +- Label requests + +🔌 **WebSocket:** Establish full-duplex communication channels over a single TCP connection. + +📡 **Server-Sent Events:** Receive a stream of updates from a server over an HTTP connection without resorting to polling. + +🌩 **Socket.IO:** Send and Receive data with the SocketIO server. + +🦟 **MQTT:** Subscribe and Publish to topics of an MQTT Broker. + +🔮 **GraphQL:** GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data. + +- Set endpoint and get schema +- Multi-column docs +- Set custom request headers +- Query schema +- Get query response + +🔐 **Authorization:** Allows to identify the end-user. + +- None +- Basic +- Bearer Token +- OAuth 2.0 +- OIDC Access Token/PKCE + +📢 **Headers:** Describes the format the body of your request is being sent in. + +📫 **Parameters:** Use request parameters to set varying parts in simulated requests. + +📃 **Request Body:** Used to send and receive data via the REST API. + +- Set `Content Type` +- FormData, JSON, and many more +- Toggle between key-value and RAW input parameter list + +📮 **Response:** Contains the status line, headers, and the message/response body. + +- Copy the response to the clipboard +- Download the response as a file +- View response headers +- View raw and preview HTML, image, JSON, and XML responses + +⏰ **History:** Request entries are synced with your cloud/local session storage. + +📁 **Collections:** Keep your API requests organized with collections and folders. Reuse them with a single click. + +- Unlimited collections, folders, and requests +- Nested folders +- Export and import as a file or GitHub gist + +_Collections are synced with your cloud/local session storage._ + +📜 **Pre-Request Scripts:** Snippets of code associated with a request that is executed before the request is sent. + +- Set environment variables +- Include timestamp in the request headers +- Send a random alphanumeric string in the URL parameters +- Any JavaScript functions + +👨‍👩‍👧‍👦 **Teams:** Helps you collaborate across your teams to design, develop, and test APIs faster. + +- Create unlimited teams +- Create unlimited shared collections +- Create unlimited team members +- Role-based access control +- Cloud sync +- Multiple devices + +👥 **Workspaces:** Organize your personal and team collections environments into workspaces. Easily switch between workspaces to manage multiple projects. + +- Create unlimited workspaces +- Switch between personal and team workspaces + +⌨️ **Keyboard Shortcuts:** Optimized for efficiency. + +> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/documentation/features/shortcuts)** + +🌐 **Proxy:** Enable Proxy Mode from Settings to access blocked APIs. + +- Hide your IP address +- Fixes [`CORS`](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) (Cross-Origin Resource Sharing) issues +- Access APIs served in non-HTTPS (`http://`) endpoints +- Use your Proxy URL + +_Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/hoppscotch/proxyscotch)** - **[Privacy Policy](https://docs.hoppscotch.io/support/privacy)**._ + +🌎 **i18n:** Experience the app in your language. + +Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) for details on our [`CODE OF CONDUCT`](CODE_OF_CONDUCT.md) and the process for submitting pull requests to us. + +☁️ **Auth + Sync:** Sign in and sync your data in real-time across all your devices. + +**Sign in with:** + +- GitHub +- Google +- Microsoft +- Email +- SSO (Single Sign-On)[^EE] + +**🔄 Synchronize your data:** Handoff to continue tasks on your other devices. + +- Workspaces +- History +- Collections +- Environments +- Settings + +✅ **Post-Request Tests:** Write tests associated with a request that is executed after the request's response. + +- Check the status code as an integer +- Filter response headers +- Parse the response data +- Set environment variables +- Write JavaScript code + +🌱 **Environments:** Environment variables allow you to store and reuse values in your requests and scripts. + +- Unlimited environments and variables +- Initialize through the pre-request script +- Export as / import from GitHub gist + +
+ Use-cases + +--- + +- By storing a value in a variable, you can reference it throughout your request section +- If you need to update the value, you only have to change it in one place +- Using variables increases your ability to work efficiently and minimizes the likelihood of error + +--- + +
+ +🚚 **Bulk Edit:** Edit key-value pairs in bulk. + +- Entries are separated by newline +- Keys and values are separated by `:` +- Prepend `#` to any row you want to add but keep disabled + +🎛️ **Admin dashboard:** Manage your team and invite members. + +- Insights +- Manage users +- Manage teams + +📦 **Add-ons:** Official add-ons for hoppscotch. + +- **[Hoppscotch CLI](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-cli)** - Command-line interface for Hoppscotch. +- **[Proxy](https://github.com/hoppscotch/proxyscotch)** - A simple proxy server created for Hoppscotch. +- **[Browser Extensions](https://github.com/hoppscotch/hoppscotch-extension)** - Browser extensions that enhance your Hoppscotch experience. + + [![Firefox](https://raw.github.com/alrra/browser-logos/master/src/firefox/firefox_16x16.png) **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/hoppscotch)  |  [![Chrome](https://raw.github.com/alrra/browser-logos/master/src/chrome/chrome_16x16.png) **Chrome**](https://chrome.google.com/webstore/detail/hoppscotch-extension-for-c/amknoiejhlmhancpahfcfcfhllgkpbld) + + > **Extensions fix `CORS` issues.** + +_Add-ons are developed and maintained under **[Hoppscotch Organization](https://github.com/hoppscotch)**._ + +**For a complete list of features, please read our [documentation](https://docs.hoppscotch.io).** + +## **Demo** + +- Web : [hoppscotch.io](https://hoppscotch.io) +- Windows/Linux/macOS : [Desktop Apps](https://docs.hoppscotch.io/documentation/clients/desktop#download-hoppscotch-desktop-app) + +## Usage + +1. Provide your API endpoint in the URL field +2. Click "Send" to simulate the request +3. View the response + +## Developing + +Follow our [self-hosting documentation](https://docs.hoppscotch.io/documentation/self-host/getting-started) to get started with the development environment. + +## Contributing + +Please contribute using [GitHub Flow](https://guides.github.com/introduction/flow). Create a branch, add commits, and [open a pull request](https://github.com/hoppscotch/hoppscotch/compare). + +Please read [`CONTRIBUTING`](CONTRIBUTING.md) for details on our [`CODE OF CONDUCT`](CODE_OF_CONDUCT.md), and the process for submitting pull requests to us. + +## Continuous Integration + +We use [GitHub Actions](https://github.com/features/actions) for continuous integration. Check out our [build workflows](https://github.com/hoppscotch/hoppscotch/actions). + +## Changelog + +See the [`CHANGELOG`](CHANGELOG.md) file for details. + +## Authors + +This project owes its existence to the collective efforts of all those who contribute — [contribute now](CONTRIBUTING.md). + + + +## License + +This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) — see the [`LICENSE`](LICENSE) file for details. + +[^EE]: Enterprise edition feature. [Learn more](https://docs.hoppscotch.io/documentation/self-host/getting-started). diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..ac19fea --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`hoppscotch/hoppscotch` +- 原始仓库:https://github.com/hoppscotch/hoppscotch +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0ecb916 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,135 @@ +# Security Policy + +- [Security Policy](#security-policy) + - [Scope](#scope) + - [Architecture and threat model](#architecture-and-threat-model) + - [Desktop app](#desktop-app) + - [Hoppscotch Agent](#hoppscotch-agent) + - [Self-hosted instances](#self-hosted-instances) + - [Security controls](#security-controls) + - [Reporting a security vulnerability](#reporting-a-security-vulnerability) + - [What does not qualify as a vulnerability](#what-does-not-qualify-as-a-vulnerability) + +## Scope + +This policy covers components in the [hoppscotch/hoppscotch](https://github.com/hoppscotch/hoppscotch) repository: + +- **Desktop app**: the Tauri-based desktop client, including standalone use and connections to self-hosted or cloud-hosted instances. +- **Hoppscotch Agent**: the local relay service that runs on the user's machine and proxies requests from the web client. +- **Hoppscotch CLI**: the command-line client for running collections and tests, against either local collection files or an instance. +- **Self-hosted backend**: the Node.js backend, PostgreSQL data layer, and associated services deployed by self-hosting organisations. +- **Self-hosted web client and admin panel**: the web frontend and admin dashboard served by a self-hosted instance. + +**Out of scope** (separate security boundaries): + +- The cloud-hosted platform at hoppscotch.io or the website at hoppscotch.com. + - If you find a vulnerability that spans both the cloud platform and a component covered here, report it; we will coordinate triage across boundaries. +- The Hoppscotch browser extension (separate repository and distribution channel). +- Third-party proxies or community forks. + +## Architecture and threat model + +Hoppscotch is a client-side API development and testing tool. The threat model differs by deployment mode. + +### Desktop app + +**The user is the operator.** The person sending API requests is the same person who configured the tool and entered their credentials. This is fundamentally different from a multi-tenant web service where untrusted users submit input to a shared backend. + +**Local data storage is by design.** In standalone mode, the Desktop app persists collections, environments, request history, and credentials (including tokens, API keys, and other secrets) in local storage. This data is protected by OS-level access controls and, where enabled, full-disk encryption (FileVault, BitLocker, LUKS). When connected to a self-hosted or cloud backend, data syncs to the server while a local copy is retained (see the [self-hosted section](#self-hosted-instances)). + +**Secret environment variables are stored locally and never synced to the server.** Environment variables marked as secret are kept in the client's local store (the Desktop app's data store or the browser's local storage, depending on platform) and are excluded from server sync. They follow the same local-data security posture as other credentials on that platform. + +**The relay sends HTTP requests to arbitrary URLs provided by the user.** This includes localhost, private IP ranges, and cloud metadata endpoints. The relay runs on the user's machine, and the user controls what URLs it reaches. Separately, the Desktop app's realtime features (including WebSocket, SSE, Socket.IO, and MQTT) can also connect to user-specified endpoints under the same trust model: the user initiates and controls the connection. + +**Per-domain TLS configuration is user-controlled.** The relay supports custom CA certificates, client certificates (PEM and PKCS#12), and per-domain toggles for host and peer verification. Users can disable TLS verification for specific domains to work with self-signed certificates or corporate PKI environments. These are deliberate operator choices on the user's own machine. + +**The Desktop app loads web application bundles from instances the user adds.** When a user adds a self-hosted instance, the app downloads the instance's compiled web application (HTML, JavaScript, CSS) and runs it in an embedded webview. Remote bundles are verified with Ed25519 signatures and per-file BLAKE3 integrity hashes before loading (see [Security controls](#security-controls)). Bundles shipped with the installer are trusted as part of the build and release signing process. Adding an instance is an explicit trust decision, comparable to installing an extension or connecting to a self-hosted service. + +**Debug-level logging is intentional.** The Desktop app logs at debug level by default and writes to rotating local log files. The log files sit alongside the same data in the application's own data store. + +**Auto-updates are signature-verified.** The Desktop app checks `releases.hoppscotch.com` for available updates. Update manifests are verified against a public key before any binary is applied. This is a read-only check; no user data, credentials, or usage information is transmitted. + +**Local backups are created on version changes.** The Desktop app creates backups of the local data store when the application version changes, retaining up to three backups. These backups follow the same security posture as the primary data store: local files protected by OS access controls. + +### Hoppscotch Agent + +The Agent is a standalone local service that acts as an HTTP relay for the Hoppscotch web client, providing capabilities the browser sandbox restricts (custom headers, localhost access, client certificates, CORS bypass). + +**The Agent runs on the user's machine and listens on localhost.** It binds to port 9119 with a permissive CORS policy, meaning any origin can reach the port at the network level. Access control is enforced at the application layer through a registration handshake: the user enters a 6-digit one-time password displayed in the Agent UI, which establishes an encrypted communication channel (AES-256-GCM with X25519 key exchange). After registration, subsequent requests are authenticated and encrypted. The OTP does not expire and registration attempts are not rate-limited; the security assumption is that the user initiates registration intentionally while the Agent UI is visible. + +**The same relay trust model applies.** The Agent sends requests to arbitrary user-specified URLs, including private IP ranges and localhost. The user controls what URLs it reaches and what TLS, proxy, and certificate configuration applies per domain. + +**Agent data is stored locally.** Registration keys, per-domain settings (proxy configuration, client certificates, CA certificates, TLS verification toggles), and logs follow the same local-data security posture as the Desktop app. + +### Self-hosted instances + +With the backend deployed, the security model changes: + +**The instance administrator is the operator; users are tenants.** Self-hosted instances support multiple users, teams, role-based access control, and shared collections. Authentication and authorisation boundaries must hold between users, and server-side data must be protected at rest and in transit. + +**Data is stored server-side and locally.** Collections, environments, request history, and team data are persisted in PostgreSQL. Desktop app users connected to a self-hosted backend also retain a local copy; the local copy follows the same posture described in the [Desktop app section](#desktop-app). Credentials in shared team collections are accessible to team members with appropriate roles. The self-hosting organisation is responsible for database encryption, backup security, and access controls. + +**Collections can be published via public URLs.** Self-hosted instances allow publishing collections as documentation accessible via UUID-based slugs. Published documentation is publicly accessible without authentication. The self-hosting organisation controls which collections are published. + +**The admin dashboard has elevated privileges.** Instance administrators can view and manage all users, send invitations, and configure instance-wide settings through the admin interface. Admin actions are subject to role checks but operate across all teams and users on the instance. + +**Infrastructure API tokens provide programmatic access.** The backend supports API tokens (infra tokens) with configurable expiry for programmatic access to instance management. These tokens should be treated with the same care as admin credentials. + +**Backend session management.** User sessions use configurable cookie names and auto-generated session secrets. The self-hosting organisation can override session configuration via environment variables. Session secrets must be set explicitly in production deployments; auto-generated values are not suitable for production use. + +**Optional analytics.** If `INFRA.ALLOW_ANALYTICS_COLLECTION` is enabled, the backend sends aggregate instance telemetry (user count, workspace count, version) to PostHog. Opt-in, disabled by default. No request content, credentials, or per-user data is included. + +## Security controls + +**Bundle signature verification.** Remote bundles from self-hosted instances are verified with Ed25519 signatures and per-file BLAKE3 hashes. A bundle with an invalid signature or hash mismatch is rejected and will not load. The signing key is fetched from the serving instance over the instance connection (TLS/HTTPS strongly recommended). Signature verification protects against bundle corruption in the local cache and against tampering in transit when the connection is trusted. It does not protect against a compromised instance, since the instance provides both the key and the bundle, nor against an active man-in-the-middle if the key is fetched over untrusted transport, since an attacker could replace both. The trust boundary is the connection to the instance and the user's decision to add it. + +**Script sandboxing.** Pre-request and post-request scripts are isolated from the host environment. By default, scripts run in a QuickJS WebAssembly sandbox on every platform — isolated from the browser context, the Tauri IPC layer (on Desktop), and the host OS. The opt-out mechanism for the legacy compatibility mode differs per platform: Desktop and web expose the "Experimental scripting sandbox" toggle in Settings (on by default); the CLI opts in via the `--legacy-sandbox` flag. The legacy compatibility mode is retained as a backward-compatibility path for scripts that rely on host JavaScript semantics not exposed under QuickJS — on Desktop and web it runs scripts in a dedicated Web Worker using the `Function` constructor; on the CLI it runs scripts in an `isolated-vm` V8 isolate. The Web Worker legacy path does not provide the same isolation guarantees as QuickJS; the `isolated-vm` legacy path provides V8-isolate-level isolation but a different API surface from the QuickJS path. In the QuickJS paths, scripts receive controlled access to request data via the `pw`, `hopp`, and `pm` API namespaces, with request mutation limited to the documented pre-request APIs; network access is mediated through a controlled fetch hook, and scripts cannot make arbitrary system calls or access the filesystem. The Web Worker legacy mode preserves a separate Web Worker execution context but exposes only the `pw` namespace and does not mediate access to standard worker globals such as `fetch`; users opting in accept that scripts can reach any URL the worker context can reach. Scripts imported from external collection files follow the same default-versus-legacy execution path and constraints as locally authored scripts. + +**Update signature verification.** The auto-updater verifies update manifests against a public key before applying any update. A tampered manifest or binary will be rejected. + +**Rate limiting.** The self-hosted backend enforces request rate limiting via configurable TTL and max-request thresholds (`INFRA.RATE_LIMIT_TTL`, `INFRA.RATE_LIMIT_MAX`). This applies to REST and GraphQL endpoints by default, though some authenticated mutations opt out of throttling where rate limiting would interfere with normal interactive use. + +**GraphQL query complexity limiting.** The self-hosted backend enforces query complexity limits on the GraphQL API to prevent denial-of-service through deeply nested or expensive queries. + +## Reporting a security vulnerability + +We use [GitHub Security Advisories](https://github.com/hoppscotch/hoppscotch/security/advisories) to manage reports. If you do not receive a response, reach out to support@hoppscotch.io with the GHSA advisory link. + +If you disagree with our assessment, reply on the advisory with additional context or evidence. We will re-evaluate. + +Reports must demonstrate familiarity with the architecture and threat model described in this document. A report that flags a behaviour already documented here as intentional, or that applies a generic vulnerability classification (such as SSRF, insecure storage, or CORS misconfiguration) without explaining how the finding circumvents the stated trust model, will be closed. This applies to all reports regardless of how they were produced, including those generated with AI tools, LLMs, or automated scanners. + +> [!NOTE] +> Advisories may move to the relevant repository (for example, an XSS in a UI component might belong in [`@hoppscotch/ui`](https://github.com/hoppscotch/ui)). If in doubt, open your report in `hoppscotch/hoppscotch` GHSA. + +**Do not create a GitHub issue to report a security vulnerability.** + +## What does not qualify as a vulnerability + +Review the threat model above before reporting. The architecture and threat model section documents deliberate design decisions for each component. A finding that matches a known vulnerability class (CWE, OWASP category, or similar) is not automatically a vulnerability in this project; the threat model explains why. Reports that restate a documented design decision as a vulnerability will be closed without further analysis. The following are by design or out of scope. + +**Intended Desktop app and Agent behaviour:** +- The relay or Agent sending requests to private IP ranges, localhost, or cloud metadata endpoints. This is the product's core function. +- Credentials, tokens, or API keys stored in local storage, the application data store, local log files, or local backups on the user's machine. Local data is protected by OS-level access controls. +- Debug-level log output containing request details including headers and authentication data. The same data already exists in the local data store. +- A self-hosted instance bundle having access to application data within the Desktop app after passing signature verification. Adding an instance is an explicit trust decision. +- Users disabling TLS host or peer verification for specific domains. This is an operator-controlled per-domain setting for working with self-signed or internal certificates. +- WebSocket, SSE, Socket.IO, or MQTT connections reaching user-specified endpoints, including internal addresses. These are separate realtime features under the same trust model as HTTP relay requests. +- Pre-request or post-request scripts from imported collections executing in the sandbox. The sandbox applies equally to imported and locally authored scripts. +- The Desktop app checking `releases.hoppscotch.com` for updates. No user data is transmitted; update manifests are signature-verified. +- The Agent accepting connections from any origin on localhost:9119. CORS is permissive by design; access control is enforced through the registration handshake and encrypted channel. +- The Agent's registration OTP having no expiry and registration attempts not being rate-limited. The security assumption, documented in the Agent section above, is that the user initiates registration intentionally while the Agent UI is visible on their own machine. This is not CWE-307 (improper restriction of excessive authentication attempts) because the Agent is a local service, not a remote authentication endpoint. +- Theoretical attacks against the Desktop app or Agent that require prior local access to the user's machine, since the attacker already has access to the same data through the operating system. + +**Intended self-hosted behaviour:** +- First-run configuration endpoints being accessible without authentication before any administrator exists. These endpoints are intentionally unauthenticated during initial bootstrap so that the self-hosting organisation can complete setup, and are gated once an administrator is provisioned. This is not CWE-306 (missing authentication for critical function); it is the documented bootstrap path. Reports against an uninitialised instance describe the intended path. Bootstrap-related findings against an instance that has already been onboarded are a distinct issue and should be reported. +- Published collections being accessible without authentication via their public URL. The self-hosting organisation controls which collections are published. This is not CWE-284 (improper access control); publication is an explicit operator action. +- The auto-generated session secret used when `INFRA.SESSION_SECRET` is not set. The threat model already notes that auto-generated values are not suitable for production deployments. The self-hosting organisation is responsible for setting explicit secrets in their environment configuration. +- Some authenticated GraphQL mutations opting out of rate limiting. These opt-outs are intentional where throttling would interfere with normal interactive use and are scoped to authenticated sessions. + +**Out of scope:** +- Vulnerabilities in dependencies without a demonstrated practical attack against Hoppscotch. +- Automated scanner output, AI-generated vulnerability reports, or generic security assessments that have not been validated against this document's architecture and threat model. A report must identify what specific security control is missing or bypassable in context, not merely flag a code pattern that matches a known vulnerability class. Tools that scan a codebase and produce findings without reading the threat model will generate false positives against this project. +- Applying a generic vulnerability classification to behaviour this document explains as intentional. Sending HTTP requests to user-specified private IP ranges is the product's core function, not server-side request forgery (CWE-918). Storing credentials in local files on the user's own machine is the expected data model for a single-user developer tool, not insecure credential storage (CWE-312). A permissive CORS policy on a localhost service with application-layer authentication is documented above, not a CORS misconfiguration (CWE-942). +- Missing HTTP security headers (Content-Security-Policy, Strict-Transport-Security, X-Frame-Options) on the self-hosted web client without a demonstrated attack that the header would have prevented in this application's deployment context. +- Findings against hoppscotch.io or hoppscotch.com; report through the platform's security channel. Cross-boundary reports involving both a self-hosted component and the cloud platform are accepted here and will be coordinated. diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md new file mode 100644 index 0000000..fa58f38 --- /dev/null +++ b/TRANSLATIONS.md @@ -0,0 +1,33 @@ +# Translations + +Thanks for showing your interest in helping us to translate the software. + +## Creating a new translation + +Before you start working on a new language, please look through the [open pull requests](https://github.com/hoppscotch/hoppscotch/pulls) to see if anyone is already working on a translation. If you find one, please join the discussion and help us keep the existing translations up to date. + +if there is no existing translation, you can create a new one by following these steps: + +1. **[Fork the repository](https://github.com/hoppscotch/hoppscotch/fork).** +2. **Checkout the `main` branch for latest translations.** +3. **Create a new branch for your translation with base branch `main`.** +4. **Create target language file in the [`/packages/hoppscotch-common/locales`](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-common/locales) directory.** +5. **Copy the contents of the source file [`/packages/hoppscotch-common/locales/en.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/locales/en.json) to the target language file.** +6. **Translate the strings in the target language file.** +7. **Add your language entry to [`/packages/hoppscotch-common/languages.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/languages.json).** +8. **Save and commit changes.** +9. **Send a pull request.** + +_You may send a pull request before all steps above are complete: e.g., you may want to ask for help with translations, or getting tests to pass. However, your pull request will not be merged until all steps above are complete._ + +Completing an initial translation of the whole site is a fairly large task. One way to break that task up is to work with other translators through pull requests on your fork. You can also [add collaborators to your fork](https://help.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository) if you'd like to invite other translators to commit directly to your fork and share responsibility for merging pull requests. + +## Updating a translation + +### Corrections + +If you notice spelling or grammar errors, typos, or opportunities for better phrasing, open a pull request with your suggested fix. If you see a problem that you aren't sure of or don't have time to fix, [open an issue](https://github.com/hoppscotch/hoppscotch/issues/new/choose). + +### Broken links + +When tests find broken links, try to fix them across all translations. Ideally, only update the linked URLs, so that translation changes will definitely not be necessary. diff --git a/aio-multiport-setup.Caddyfile b/aio-multiport-setup.Caddyfile new file mode 100644 index 0000000..6a00ebb --- /dev/null +++ b/aio-multiport-setup.Caddyfile @@ -0,0 +1,31 @@ +{ + admin off + persist_config off +} + +:3000 { + try_files {path} / + root * /site/selfhost-web + file_server +} + +:3100 { + try_files {path} / + root * /site/sh-admin-multiport-setup + file_server +} + +:3170 { + @mock { + header_regexp host Host ^[^.]+\.mock\..*$ + } + + handle @mock { + rewrite * /mock{uri} + reverse_proxy localhost:8080 + } + + handle { + reverse_proxy localhost:8080 + } +} diff --git a/aio-subpath-access.Caddyfile b/aio-subpath-access.Caddyfile new file mode 100644 index 0000000..4bf8e3b --- /dev/null +++ b/aio-subpath-access.Caddyfile @@ -0,0 +1,46 @@ +{ + admin off + persist_config off +} + +:{$HOPP_AIO_ALTERNATE_PORT:80} { + # Serve the `selfhost-web` SPA by default + root * /site/selfhost-web + file_server + + handle_path /admin* { + root * /site/sh-admin-subpath-access + file_server + + # Ensures any non-existent file in the server is routed to the SPA + try_files {path} / + } + + # Handle requests under `/backend*` path + handle_path /backend* { + @mock { + header_regexp host Host ^[^.]+\.mock\..*$ + } + + handle @mock { + rewrite * /mock{uri} + reverse_proxy localhost:8080 + } + + handle { + reverse_proxy localhost:8080 + } + } + + # Handle requests under `/desktop-app-server*` path + handle_path /desktop-app-server* { + reverse_proxy localhost:3200 + } + + # Catch-all route for unknown paths, serves `selfhost-web` SPA + handle { + root * /site/selfhost-web + file_server + try_files {path} / + } +} diff --git a/aio_run.mjs b/aio_run.mjs new file mode 100644 index 0000000..605742e --- /dev/null +++ b/aio_run.mjs @@ -0,0 +1,81 @@ +#!/usr/local/bin/node +// @ts-check + +import { execSync, spawn } from "child_process" +import fs from "fs" +import process from "process" + +function runChildProcessWithPrefix(command, args, prefix) { + const childProcess = spawn(command, args); + + childProcess.stdout.on('data', (data) => { + const output = data.toString().trim().split('\n'); + output.forEach((line) => { + console.log(`${prefix} | ${line}`); + }); + }); + + childProcess.stderr.on('data', (data) => { + const error = data.toString().trim().split('\n'); + error.forEach((line) => { + console.error(`${prefix} | ${line}`); + }); + }); + + childProcess.on('close', (code) => { + console.log(`${prefix} Child process exited with code ${code}`); + }); + + childProcess.on('error', (stuff) => { + console.log("error") + console.log(stuff) + }) + + return childProcess +} + +const envFileContent = Object.entries(process.env) + .filter(([env]) => env.startsWith("VITE_")) + .sort(([envA], [envB]) => envA.localeCompare(envB)) + .map(([env, val]) => `${env}=${ + (val.startsWith("\"") && val.endsWith("\"")) + ? val + : `"${val}"` + }`) + .join("\n") + +fs.writeFileSync("build.env", envFileContent) + +execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`) + +fs.rmSync("build.env") + +const caddyFileName = process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true' ? 'aio-subpath-access.Caddyfile' : 'aio-multiport-setup.Caddyfile' +const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", `/etc/caddy/${caddyFileName}`, "--adapter", "caddyfile"], "App/Admin Dashboard Caddy") +const backendProcess = runChildProcessWithPrefix("node", ["/dist/backend/dist/src/main.js"], "Backend Server") +const webappProcess = runChildProcessWithPrefix("webapp-server", [], "Webapp Server") + +caddyProcess.on("exit", (code) => { + console.log(`Exiting process because Caddy Server exited with code ${code}`) + process.exit(code) +}) + +backendProcess.on("exit", (code) => { + console.log(`Exiting process because Backend Server exited with code ${code}`) + process.exit(code) +}) + +webappProcess.on("exit", (code) => { + console.log(`Exiting process because Webapp Server exited with code ${code}`) + process.exit(code) +}) + +process.on('SIGINT', () => { + console.log("SIGINT received, exiting...") + + caddyProcess.kill("SIGINT") + backendProcess.kill("SIGINT") + webappProcess.kill("SIGINT") + + process.exit(0) +}) diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..5717d25 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ["@commitlint/config-conventional"], +} diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 0000000..c39fd98 --- /dev/null +++ b/devenv.lock @@ -0,0 +1,105 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1774428097, + "narHash": "sha256-yQAutPgbsVHsN/SygZDyzMRxQn6Im53PJkrI377N8Sg=", + "owner": "cachix", + "repo": "devenv", + "rev": "957d63f663f230dc8ac3b85f950690e56fe8b1e0", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1774423251, + "narHash": "sha256-g/PP8G9WcP4vtZVOBNYwfGxLnwLQoTERHnef8irAMeQ=", + "owner": "nix-community", + "repo": "fenix", + "rev": "b70d7535088cd8a9e4322c372a475f66ffa18adf", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1774273680, + "narHash": "sha256-a++tZ1RQsDb1I0NHrFwdGuRlR5TORvCEUksM459wKUA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fdc7b8f7b30fdbedec91b71ed82f36e1637483ed", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "fenix": "fenix", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1774376228, + "narHash": "sha256-7oA0u4aghFjjIcIDKZ26NUpXH7hVXGPC0sI1OfK7NUk=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "eabb84b771420b8396ab4bb4747694302d9be277", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1774408260, + "narHash": "sha256-Jn9d9r85dmf3gTMnSRt6t+DP2nQ5uJns/MMXg2FpzfM=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "d6471ee5a8f470251e6e5b83a20a182eb6c46c9b", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} \ No newline at end of file diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..b705c8e --- /dev/null +++ b/devenv.nix @@ -0,0 +1,189 @@ +{ pkgs, lib, config, inputs, ... }: + +let + rosettaPkgs = + if pkgs.stdenv.isDarwin && pkgs.stdenv.isAarch64 + then pkgs.pkgsx86_64Darwin + else pkgs; + + darwinPackages = with pkgs; [ + apple-sdk + ]; + + linuxPackages = with pkgs; [ + nodePackages.prisma + prisma-engines + libsoup_3 + webkitgtk_4_1 + librsvg + libappindicator + libayatana-appindicator + ]; + +in { + packages = with pkgs; [ + git + lima + colima + docker + jq + # NOTE: In case there's `Cannot find module: ... bcrypt ...` error, try `npm rebuild bcrypt` + # See: https://github.com/kelektiv/node.bcrypt.js/issues/800 + # See: https://github.com/kelektiv/node.bcrypt.js/issues/1055 + nodejs_22 + nodePackages.typescript-language-server + nodePackages."@volar/vue-language-server" + cargo-edit + cargo-tauri + ] ++ lib.optionals pkgs.stdenv.isDarwin darwinPackages + ++ lib.optionals pkgs.stdenv.isLinux linuxPackages; + + env = { + APP_GREET = "Hoppscotch"; + DATABASE_URL = "postgresql://postgres:testpass@localhost:5432/hoppscotch?connect_timeout=300"; + DOCKER_BUILDKIT = "1"; + COMPOSE_DOCKER_CLI_BUILD = "1"; + } // lib.optionalAttrs pkgs.stdenv.isLinux { + # NOTE: Setting these `PRISMA_*` environment variable fixes + # "Error: Failed to fetch sha256 checksum at https://binaries.prisma.sh/all_commits//linux-nixos/libquery_engine.so.node.gz.sha256 - 404 Not Found" + # See: https://github.com/prisma/prisma/discussions/3120 + PRISMA_QUERY_ENGINE_LIBRARY = "${pkgs.prisma-engines}/lib/libquery_engine.node"; + PRISMA_QUERY_ENGINE_BINARY = "${pkgs.prisma-engines}/bin/query-engine"; + PRISMA_SCHEMA_ENGINE_BINARY = "${pkgs.prisma-engines}/bin/schema-engine"; + + LD_LIBRARY_PATH = lib.makeLibraryPath [ + pkgs.libappindicator + pkgs.libayatana-appindicator + ]; + } // lib.optionalAttrs pkgs.stdenv.isDarwin { + # Place to put macOS-specific environment variables + }; + + scripts = { + hello.exec = "echo hello from $APP_GREET"; + e.exec = "emacs"; + lima-setup.exec = "limactl start template://docker"; + lima-clean.exec = "limactl rm -f $(limactl ls -q)"; + colima-start.exec = "colima start --cpu 8 --memory 50"; + + docker-prune.exec = '' + echo "Cleaning up unused Docker resources..." + docker system prune -f + ''; + + docker-logs.exec = "docker logs -f hoppscotch-aio"; + + docker-status.exec = '' + echo "Container Status:" + docker ps -a --filter "name=hoppscotch-aio" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + ''; + + db-reset.exec = '' + echo "Resetting database..." + psql -U postgres -d hoppscotch -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' + echo "Database reset complete." + ''; + + docker-clean-all.exec = '' + echo "Starting complete Docker cleanup..." + + echo "1/6: Stopping all running containers..." + CONTAINERS=$(docker ps -q) + if [ -n "$CONTAINERS" ]; then + docker stop $CONTAINERS + echo "✓ Containers stopped" + else + echo "• No running containers found" + fi + + echo "2/6: Removing all containers..." + CONTAINERS=$(docker ps -aq) + if [ -n "$CONTAINERS" ]; then + docker rm $CONTAINERS + echo "✓ Containers removed" + else + echo "• No containers to remove" + fi + + echo "3/6: Removing all images..." + IMAGES=$(docker images -q) + if [ -n "$IMAGES" ]; then + docker rmi --force $IMAGES + echo "✓ Images removed" + else + echo "• No images to remove" + fi + + echo "4/6: Removing all volumes..." + VOLUMES=$(docker volume ls -q) + if [ -n "$VOLUMES" ]; then + docker volume rm $VOLUMES + echo "✓ Volumes removed" + else + echo "• No volumes to remove" + fi + + echo "5/6: Removing custom networks..." + NETWORKS=$(docker network ls --filter type=custom -q) + if [ -n "$NETWORKS" ]; then + docker network rm $NETWORKS + echo "✓ Networks removed" + else + echo "• No custom networks to remove" + fi + + echo "6/6: Running system prune..." + docker system prune --all --force --volumes + echo "✓ System pruned" + + echo "Done!" + ''; + }; + + enterShell = '' + git --version + echo "Hoppscotch development environment ready!" + ${lib.optionalString pkgs.stdenv.isDarwin '' + # Place to put macOS-specific shell initialization + ''} + ${lib.optionalString pkgs.stdenv.isLinux '' + # Place to put Linux-specific shell initialization + ''} + ''; + + enterTest = '' + echo "Running tests" + ''; + + dotenv.enable = true; + + languages = { + typescript = { + enable = true; + }; + javascript = { + package = pkgs.nodejs_22; + enable = true; + npm.enable = true; + pnpm.enable = true; + }; + go = { + enable = true; + package = pkgs.go_1_25; + }; + rust = { + enable = true; + channel = "nightly"; + components = [ + "rustc" + "cargo" + "clippy" + "rustfmt" + "rust-analyzer" + "llvm-tools-preview" + "rust-src" + "rustc-codegen-cranelift-preview" + ]; + }; + }; +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 0000000..d016920 --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,14 @@ +inputs: + fenix: + url: github:nix-community/fenix + inputs: + nixpkgs: + follows: nixpkgs + nixpkgs: + url: github:NixOS/nixpkgs/nixpkgs-unstable + rust-overlay: + url: github:oxalica/rust-overlay + inputs: + nixpkgs: + follows: nixpkgs +allowUnfree: true diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml new file mode 100644 index 0000000..e9b7919 --- /dev/null +++ b/docker-compose.deploy.yml @@ -0,0 +1,59 @@ +# THIS IS NOT TO BE USED FOR PERSONAL DEPLOYMENTS! +# Internal Docker Compose Image used for internal testing deployments + +services: + hoppscotch-db: + image: postgres:15 + user: postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: testpass + POSTGRES_DB: hoppscotch + healthcheck: + test: + [ + "CMD-SHELL", + "sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'", + ] + interval: 5s + timeout: 5s + retries: 10 + + hoppscotch-aio: + container_name: hoppscotch-aio + build: + dockerfile: prod.Dockerfile + context: . + target: aio + environment: + # DATABASE_URL is read from the .env file to allow the backend to connect with an external database. + # This allows the backend to retain existing data and prevents database resets during deployments. + # - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch + - ENABLE_SUBPATH_BASED_ACCESS=true + env_file: + - ./.env + depends_on: + hoppscotch-db: + condition: service_healthy + command: + [ + "sh", + "-c", + "pnpm exec prisma migrate deploy && node /usr/src/app/aio_run.mjs", + ] + healthcheck: + test: + [ + "CMD", + "curl", + "-f", + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "http://localhost:80", + ] + interval: 2s + timeout: 10s + retries: 30 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6f10393 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,257 @@ +# To make it easier to self-host, we have a preset docker compose config that also +# has a container with a Postgres instance running. +# You can tweak around this file to match your instances + +# PROFILES EXPLANATION: +# +# We use Docker Compose profiles to manage different deployment scenarios and avoid port conflicts. +# +# These are all the available profiles: +# - default: All-in-one service + database + auto-migration (recommended for most users) +# - default-no-db: All-in-one service without database (for users with external DB) +# - backend: The backend service only +# - app: The main Hoppscotch application and the webapp server +# - admin: The self-host admin dashboard only +# - database: Just the PostgreSQL database +# - just-backend: All services except webapp for local development +# - deprecated: All deprecated services (not recommended) + +# USAGE: +# +# To run the default setup: docker compose --profile default up +# To run without database: docker compose --profile default-no-db up +# To run specific components: docker compose --profile backend up +# To run all except webapp: docker compose --profile just-backend up +# To run deprecated services: docker compose --profile deprecated up + +# NOTE: The default and default-no-db profiles should not be mixed with individual service +# profiles as they would conflict on ports. + +services: + # This service runs the backend app in the port 3170 + hoppscotch-backend: + profiles: ["backend", "just-backend", "app", "admin"] + container_name: hoppscotch-backend + build: + dockerfile: prod.Dockerfile + context: . + target: backend + env_file: + - ./.env + restart: always + environment: + # Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well) + - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300 + - PORT=8080 + volumes: + # Uncomment the line below when modifying code. Only applicable when using the "dev" target. + # - ./packages/hoppscotch-backend/:/usr/src/app + - /usr/src/app/node_modules/ + depends_on: + hoppscotch-db: + condition: service_healthy + ports: + - "3180:80" + - "3170:3170" + + # The main hoppscotch app with integrated webapp server. This will be hosted at port 3000 + # The webapp server will be accessible at port 3200 + # NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for + # the SH admin dashboard server at packages/hoppscotch-selfhost-web/Caddyfile + hoppscotch-app: + profiles: ["app"] + container_name: hoppscotch-app + build: + dockerfile: prod.Dockerfile + context: . + target: app + env_file: + - ./.env + depends_on: + - hoppscotch-backend + ports: + - "3080:80" + - "3000:3000" + - "3200:3200" + + # The Self Host dashboard for managing the app. This will be hosted at port 3100 + # NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for + # the SH admin dashboard server at packages/hoppscotch-sh-admin/Caddyfile + hoppscotch-sh-admin: + profiles: ["admin"] + container_name: hoppscotch-sh-admin + build: + dockerfile: prod.Dockerfile + context: . + target: sh_admin + env_file: + - ./.env + depends_on: + - hoppscotch-backend + ports: + - "3280:80" + - "3100:3100" + + # The service that spins up all services at once in one container + hoppscotch-aio: + profiles: ["default"] + container_name: hoppscotch-aio + restart: unless-stopped + build: + dockerfile: prod.Dockerfile + context: . + target: aio + env_file: + - ./.env + depends_on: + hoppscotch-db: + condition: service_healthy + ports: + - "3000:3000" + - "3100:3100" + - "3170:3170" + - "3200:3200" + - "3080:80" + + # Profile with no database dependency (purely developmental) + hoppscotch-aio-no-db: + profiles: ["default-no-db"] + container_name: hoppscotch-aio + restart: unless-stopped + build: + dockerfile: prod.Dockerfile + context: . + target: aio + env_file: + - ./.env + ports: + - "3000:3000" + - "3100:3100" + - "3170:3170" + - "3200:3200" + - "3080:80" + + # The preset DB service, you can delete/comment the below lines if + # you are using an external postgres instance + # This will be exposed at port 5432 + hoppscotch-db: + profiles: + [ + "default", + "database", + "just-backend", + "backend", + "app", + "admin", + "deprecated", + ] + image: postgres:15 + ports: + - "5432:5432" + user: postgres + environment: + # The default user defined by the docker image + POSTGRES_USER: postgres + # NOTE: Please UPDATE THIS PASSWORD! + POSTGRES_PASSWORD: testpass + POSTGRES_DB: hoppscotch + healthcheck: + test: + [ + "CMD-SHELL", + "sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'", + ] + interval: 5s + timeout: 5s + retries: 10 + + # Auto-migration service - handles database migrations automatically + hoppscotch-migrate: + profiles: ["default", "just-backend", "backend", "app", "admin"] + build: + dockerfile: prod.Dockerfile + context: . + target: backend + env_file: + - ./.env + depends_on: + hoppscotch-db: + condition: service_healthy + command: sh -c "pnpm exec prisma migrate deploy" + + # All the services listed below are deprecated + # These services are kept for backward compatibility but should not be used for new deployments + hoppscotch-old-backend: + profiles: ["deprecated"] + container_name: hoppscotch-old-backend + build: + dockerfile: packages/hoppscotch-backend/Dockerfile + context: . + target: prod + env_file: + - ./.env + restart: always + environment: + # Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well) + - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300 + - PORT=3000 + volumes: + # Uncomment the line below when modifying code. Only applicable when using the "dev" target. + # - ./packages/hoppscotch-backend/:/usr/src/app + - /usr/src/app/node_modules/ + depends_on: + hoppscotch-db: + condition: service_healthy + ports: + - "3170:3000" + + hoppscotch-old-app: + profiles: ["deprecated"] + container_name: hoppscotch-old-app + build: + dockerfile: packages/hoppscotch-selfhost-web/Dockerfile + context: . + env_file: + - ./.env + depends_on: + - hoppscotch-old-backend + ports: + - "3000:8080" + + hoppscotch-old-sh-admin: + profiles: ["deprecated"] + container_name: hoppscotch-old-sh-admin + build: + dockerfile: packages/hoppscotch-sh-admin/Dockerfile + context: . + env_file: + - ./.env + depends_on: + - hoppscotch-old-backend + ports: + - "3100:8080" + +# DEPLOYMENT SCENARIOS: +# 1. Default deployment (recommended): +# docker compose --profile default up +# This will start: AIO + database + auto-migration +# +# 2. Default deployment without database: +# docker compose --profile default-no-db up +# This will start: AIO only (use when you have an external database) +# +# 3. Individual service deployment: +# docker compose --profile backend up # Just the backend +# docker compose --profile app up # Just the app and webapp server +# docker compose --profile admin up # Just the admin dashboard +# docker compose --profile database up # Just the database +# +# 4. Development deployment: +# docker compose --profile just-backend up # All services except webapp +# +# 5. Deprecated services: +# docker compose --profile deprecated up +# This will start all deprecated services (not recommended for new deployments) +# +# Remember: The default and default-no-db profiles should not be mixed with individual service +# profiles as they would conflict on ports. diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..4120bed --- /dev/null +++ b/firebase.json @@ -0,0 +1,19 @@ +{ + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "hosting": { + "predeploy": [ + "mv .env.example .env && npm install -g pnpm && pnpm i && pnpm run generate" + ], + "public": "packages/hoppscotch-web/dist", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] + } +} diff --git a/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 0000000..415027e --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,4 @@ +{ + "indexes": [], + "fieldOverrides": [] +} diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..830e397 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,13 @@ +service cloud.firestore { + match /databases/{database}/documents { + // Make sure the uid of the requesting user matches name of the user + // document. The wildcard expression {userId} makes the userId variable + // available in rules. + match /users/{userId} { + allow read, write, create, update, delete: if request.auth.uid != null && request.auth.uid == userId; + } + match /users/{userId}/{document=**} { + allow read, write, create, update, delete: if request.auth.uid != null && request.auth.uid == userId; + } + } +} diff --git a/healthcheck.sh b/healthcheck.sh new file mode 100644 index 0000000..8777c74 --- /dev/null +++ b/healthcheck.sh @@ -0,0 +1,25 @@ +curlCheck() { + if ! curl -s --head "$1" | head -n 1 | grep -q "HTTP/1.[01] [23].."; then + echo "URL request failed!" + return 1 + else + echo "URL request succeeded!" + return 0 + fi +} + +# Wait for initial startup period to avoid unnecessary error logs +# Check if the container has been running for at least 15 seconds +UPTIME=$(awk '{print int($1)}' /proc/uptime) +if [ "$UPTIME" -lt 15 ]; then + echo "Container still starting up (uptime: ${UPTIME}s), skipping health check..." + exit 0 +fi + +if [ "$ENABLE_SUBPATH_BASED_ACCESS" = "true" ]; then + curlCheck "http://localhost:${HOPP_AIO_ALTERNATE_PORT:-80}/backend/ping" || exit 1 +else + curlCheck "http://localhost:3000" || exit 1 + curlCheck "http://localhost:3100" || exit 1 + curlCheck "http://localhost:3170/ping" || exit 1 +fi diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..29037a6 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["./*"], + "@/*": ["./*"], + "~~/*": ["./*"], + "@@/*": ["./*"] + } + }, + "exclude": ["node_modules", ".nuxt", "dist"] +} diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..4a3899f --- /dev/null +++ b/netlify.toml @@ -0,0 +1,77 @@ +[build.environment] + NODE_VERSION = "14" + NPM_FLAGS = "--prefix=/dev/null" + +[build] + base = "/" + publish = "packages/hoppscotch-web/dist" + command = "npx pnpm i --store=node_modules/.pnpm-store && npx pnpm run generate" + +[[headers]] + for = "/*" + [headers.values] + X-Frame-Options = "SAMEORIGIN" + X-XSS-Protection = "1; mode=block" + +[[redirects]] + from = "/discord" + to = "https://discord.gg/GAMWxmR" + status = 301 + force = true + +[[redirects]] + from = "/telegram" + to = "https://t.me/hoppscotch" + status = 301 + force = true + +[[redirects]] + from = "/beta" + to = "https://forms.gle/XPYDMp8m6JHNWcYp9" + status = 301 + force = true + +[[redirects]] + from = "/careers" + to = "https://company.hoppscotch.io/careers" + status = 301 + force = true + +[[redirects]] + from = "/newsletter" + to = "http://eepurl.com/hy0eWH" + status = 301 + force = true + +[[redirects]] + from = "/twitter" + to = "https://x.com/hoppscotch_io" + status = 301 + force = true + +[[redirects]] + from = "/github" + to = "https://github.com/hoppscotch/hoppscotch" + status = 301 + force = true + +[[redirects]] + from = "/announcements" + to = "https://company.hoppscotch.io/announcements" + status = 301 + force = true + +[[redirects]] + from = "/robots.txt" + to = "/robots.txt" + status = 200 + +[[redirects]] + from = "/sitemap.xml" + to = "/sitemap.xml" + status = 200 + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 diff --git a/package.json b/package.json new file mode 100644 index 0000000..6c56e90 --- /dev/null +++ b/package.json @@ -0,0 +1,70 @@ +{ + "name": "hoppscotch-app", + "version": "3.0.1", + "description": "Open source API development ecosystem", + "author": "Hoppscotch (support@hoppscotch.io)", + "private": true, + "license": "MIT", + "packageManager": "pnpm@10.33.4", + "scripts": { + "preinstall": "npx only-allow pnpm", + "prepare": "husky", + "dev": "pnpm -r do-dev", + "gen-gql": "cross-env GQL_SCHEMA_EMIT_LOCATION='../../../gql-gen/backend-schema.gql' pnpm -r generate-gql-sdl", + "generate": "pnpm -r do-build-prod", + "start": "http-server packages/hoppscotch-selfhost-web/dist -p 3000", + "lint": "pnpm -r do-lint", + "typecheck": "pnpm -r do-typecheck", + "lintfix": "pnpm -r do-lintfix", + "pre-commit": "pnpm -r do-lint && pnpm -r do-typecheck", + "test": "pnpm -r do-test", + "generate-ui": "pnpm -r do-build-ui" + }, + "workspaces": [ + "./packages/*" + ], + "devDependencies": { + "@commitlint/cli": "20.5.2", + "@commitlint/config-conventional": "20.5.0", + "@hoppscotch/ui": "0.2.6", + "@types/node": "24.10.1", + "cross-env": "10.1.0", + "http-server": "14.1.1", + "husky": "9.1.7", + "lint-staged": "16.4.0" + }, + "pnpm": { + "overrides": { + "@xmldom/xmldom": "0.8.13", + "apiconnect-wsdl": "2.0.36", + "body-parser": "2.2.1", + "cross-spawn": "7.0.6", + "execa@<2.0.0": "2.0.0", + "form-data@>=4.0.0 <4.0.6": "4.0.6", + "glob@>=11.0.0 <11.1.0": "11.1.0", + "hono@<4.12.25": "4.12.25", + "liquidjs@<10.26.0": "10.26.0", + "minimatch@>=4.0.0 <4.2.5": "4.2.5", + "multer@2.1.1": "2.2.0", + "serialize-javascript@<7.0.3": "7.0.3", + "vue": "3.5.38", + "ws": "8.21.0" + }, + "onlyBuiltDependencies": [ + "@apollo/protobufjs", + "@import-meta-env/unplugin", + "@nestjs/core", + "@prisma/client", + "@prisma/engines", + "@swc/core", + "argon2", + "bcrypt", + "canvas", + "core-js", + "esbuild", + "isolated-vm", + "prisma", + "vue-demi" + ] + } +} diff --git a/packages/codemirror-lang-graphql/.gitignore b/packages/codemirror-lang-graphql/.gitignore new file mode 100644 index 0000000..0ef4732 --- /dev/null +++ b/packages/codemirror-lang-graphql/.gitignore @@ -0,0 +1,4 @@ +/node_modules +package-lock.json +/dist +/src/*.d.ts diff --git a/packages/codemirror-lang-graphql/.npmignore b/packages/codemirror-lang-graphql/.npmignore new file mode 100644 index 0000000..9bd9760 --- /dev/null +++ b/packages/codemirror-lang-graphql/.npmignore @@ -0,0 +1,5 @@ +/src +/test +/node_modules +rollup.config.js +tsconfig.json diff --git a/packages/codemirror-lang-graphql/README.md b/packages/codemirror-lang-graphql/README.md new file mode 100644 index 0000000..613036c --- /dev/null +++ b/packages/codemirror-lang-graphql/README.md @@ -0,0 +1 @@ +A [CodeMirror 6](https://codemirror.net/6) language plugin for GraphQL \ No newline at end of file diff --git a/packages/codemirror-lang-graphql/package.json b/packages/codemirror-lang-graphql/package.json new file mode 100644 index 0000000..6f14492 --- /dev/null +++ b/packages/codemirror-lang-graphql/package.json @@ -0,0 +1,31 @@ +{ + "name": "@hoppscotch/codemirror-lang-graphql", + "version": "0.2.0", + "description": "GraphQL language support for CodeMirror", + "author": "Hoppscotch (support@hoppscotch.io)", + "license": "MIT", + "scripts": { + "prepare": "rollup -c && tsc --emitDeclarationOnly --declaration" + }, + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "exports": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "types": "dist/index.d.ts", + "sideEffects": false, + "dependencies": { + "@codemirror/language": "6.11.3", + "@lezer/highlight": "1.2.1", + "@lezer/lr": "1.4.2" + }, + "devDependencies": { + "@lezer/generator": "1.8.0", + "@rollup/plugin-typescript": "12.1.4", + "mocha": "11.7.6", + "rollup": "4.59.0", + "typescript": "5.9.3" + } +} diff --git a/packages/codemirror-lang-graphql/rollup.config.js b/packages/codemirror-lang-graphql/rollup.config.js new file mode 100644 index 0000000..d19001c --- /dev/null +++ b/packages/codemirror-lang-graphql/rollup.config.js @@ -0,0 +1,17 @@ +import typescript from "@rollup/plugin-typescript" +import { lezer } from "@lezer/generator/rollup" + +export default { + input: "src/index.js", + external: (id) => id != "tslib" && !/^(\.?\/|\w:)/.test(id), + output: [ + { file: "dist/index.cjs", format: "cjs" }, + { dir: "./dist", format: "es" }, + ], + plugins: [ + lezer(), + typescript({ + tsconfig: "./tsconfig.json" + }) + ], +} diff --git a/packages/codemirror-lang-graphql/src/index.js b/packages/codemirror-lang-graphql/src/index.js new file mode 100644 index 0000000..5d004b8 --- /dev/null +++ b/packages/codemirror-lang-graphql/src/index.js @@ -0,0 +1,57 @@ +import { parser } from "./syntax.grammar" +import { + LRLanguage, + LanguageSupport, + indentNodeProp, + foldNodeProp, + foldInside, + delimitedIndent, +} from "@codemirror/language" +import { styleTags, tags as t } from "@lezer/highlight" + +export const GQLLanguage = LRLanguage.define({ + parser: parser.configure({ + props: [ + indentNodeProp.add({ + "SelectionSet FieldsDefinition ObjectValue SchemaDefinition RootTypeDef": + delimitedIndent({ closing: "}", align: true }), + }), + foldNodeProp.add({ + Application: foldInside, + "SelectionSet FieldsDefinition ObjectValue RootOperationTypeDefinition RootTypeDef": + (node) => { + return { + from: node.from, + to: node.to, + } + }, + }), + styleTags({ + Comment: t.lineComment, + Name: t.propertyName, + StringValue: t.string, + IntValue: t.integer, + FloatValue: t.float, + NullValue: t.null, + BooleanValue: t.bool, + Comma: t.separator, + "OperationDefinition/Name": t.definition(t.function(t.variableName)), + "OperationType TypeKeyword SchemaKeyword FragmentKeyword OnKeyword DirectiveKeyword RepeatableKeyword SchemaKeyword ExtendKeyword ScalarKeyword InterfaceKeyword UnionKeyword EnumKeyword InputKeyword ImplementsKeyword": t.keyword, + "ExecutableDirectiveLocation TypeSystemDirectiveLocation": t.atom, + "DirectiveName!": t.annotation, + "\"{\" \"}\"": t.brace, + "\"(\" \")\"": t.paren, + "\"[\" \"]\"": t.squareBracket, + "Type! NamedType": t.typeName, + }), + ], + }), + languageData: { + commentTokens: { line: "#" }, + closeBrackets: { brackets: ["(", "[", "{", '"', '"""'] }, + }, +}) + +export function GQL() { + return new LanguageSupport(GQLLanguage) +} diff --git a/packages/codemirror-lang-graphql/src/syntax.grammar b/packages/codemirror-lang-graphql/src/syntax.grammar new file mode 100644 index 0000000..1f9ad12 --- /dev/null +++ b/packages/codemirror-lang-graphql/src/syntax.grammar @@ -0,0 +1,434 @@ +@top SourceFile { + Document +} + +@precedence { + fieldDef @right, + typeDef @right +} + +Document { + Definition+ +} + +Definition { + ExecutableDefinition | + TypeSystemDefinition | + TypeSystemExtension +} + +ExecutableDefinition { + OperationDefinition | + FragmentDefinition +} + +TypeSystemDefinition { + SchemaDefinition | + TypeDefinition | + DirectiveDefinition +} + +TypeSystemExtension { + SchemaExtension | + TypeExtension +} + +SchemaKeyword { + @specialize +} + +SchemaDefinition { + Description? SchemaKeyword Directives? RootTypeDef +} + +RootTypeDef { + "{" RootOperationTypeDefinition+ "}" +} + +ExtendKeyword { + @specialize +} + +SchemaExtension { + ExtendKeyword SchemaKeyword Directives? RootTypeDef +} + +TypeExtension { + ScalarTypeExtension | + ObjectTypeExtension | + InterfaceTypeExtension | + UnionTypeExtension | + EnumTypeExtension | + InputObjectTypeExtension +} + +ScalarKeyword { + @specialize +} + +ScalarTypeExtension { + ExtendKeyword ScalarKeyword Name Directives +} + +ObjectTypeExtension /* precedence: right 0 */ { + ExtendKeyword TypeKeyword Name ImplementsInterfaces? Directives? !typeDef FieldsDefinition | + ExtendKeyword TypeKeyword Name ImplementsInterfaces? Directives? +} + +InterfaceKeyword { + @specialize +} + +InterfaceTypeExtension /* precedence: right 0 */ { + ExtendKeyword InterfaceKeyword Name ImplementsInterfaces? Directives? FieldsDefinition | + ExtendKeyword InterfaceKeyword Name ImplementsInterfaces? Directives? +} + +UnionKeyword { + @specialize +} + +UnionTypeExtension /* precedence: right 0 */ { + ExtendKeyword UnionKeyword Name Directives? UnionMemberTypes | + ExtendKeyword UnionKeyword Name Directives? +} + +EnumKeyword { + @specialize +} + +EnumTypeExtension /* precedence: right 0 */ { + ExtendKeyword EnumKeyword Name Directives? !typeDef EnumValuesDefinition | + ExtendKeyword EnumKeyword Name Directives? +} + +InputKeyword { + @specialize +} + +InputObjectTypeExtension /* precedence: right 0 */ { + ExtendKeyword InputKeyword Name Directives? InputFieldsDefinition+ | + ExtendKeyword InputKeyword Name Directives? +} + +InputFieldsDefinition { + !fieldDef "{" InputValueDefinition+ "}" +} + +EnumValuesDefinition { + !fieldDef "{" EnumValueDefinition+ "}" +} + +EnumValueDefinition { + Description? EnumValue Directives? +} + +ImplementsKeyword { + @specialize +} + +ImplementsInterfaces { + ImplementsInterfaces "&" NamedType | + ImplementsKeyword "&"? NamedType +} + +FieldsDefinition { + !fieldDef "{" FieldDefinition+ "}" +} + +FieldDefinition { + Description? Name ArgumentsDefinition? ":" Type Directives? +} + +ArgumentsDefinition { + "(" InputValueDefinition+ ")" +} + +InputValueDefinition { + Description? Name ":" Type DefaultValue? Directives? +} + +DefaultValue { + "=" Value +} + +UnionMemberTypes { + UnionMemberTypes "|" NamedType | + "=" "|"? NamedType +} + +RootOperationTypeDefinition { + OperationType ":" NamedType +} + +OperationDefinition { + SelectionSet | + OperationType Name? VariableDefinitions? Directives? SelectionSet +} + +TypeDefinition { + ScalarTypeDefinition | + ObjectTypeDefinition | + InterfaceTypeDefinition | + UnionTypeDefinition | + EnumTypeDefinition | + InputObjectTypeDefinition +} + +ScalarTypeDefinition /* precedence: right 0 */ { + Description? ScalarKeyword Name Directives? +} + +TypeKeyword { + @specialize +} + +ObjectTypeDefinition /* precedence: right 0 */ { + Description? TypeKeyword Name ImplementsInterfaces? Directives? FieldsDefinition? +} + +InterfaceTypeDefinition /* precedence: right 0 */ { + Description? InterfaceKeyword Name ImplementsInterfaces? Directives? FieldsDefinition? +} + +UnionTypeDefinition /* precedence: right 0 */ { + Description? UnionKeyword Name Directives? UnionMemberTypes? +} + +EnumTypeDefinition /* precedence: right 0 */ { + Description? EnumKeyword Name Directives? !typeDef EnumValuesDefinition? +} + +InputObjectTypeDefinition /* precedence: right 0 */ { + Description? InputKeyword Name Directives? !typeDef InputFieldsDefinition? +} + +VariableDefinitions { + "(" VariableDefinition+ ")" +} + +VariableDefinition { + Variable ":" Type DefaultValue? Directives? Comma? +} + +SelectionSet { + "{" Selection* "}" +} + +Selection { + Field | + InlineFragment | + FragmentSpread +} + +Field { + Alias? Name Arguments? Directive? SelectionSet? +} + +Alias { + Name ":" +} + +Arguments { + "(" Argument+ ")" +} + +Argument { + Name ":" Value +} + +Value { + Variable | + StringValue | + IntValue | + FloatValue | + BooleanValue | + NullValue | + EnumValue | + ListValue | + ObjectValue +} + +Variable { + "$" Name +} + +EnumValue { + Name +} + +ListValue { + "[" Value* "]" +} + +ObjectValue { + "{" ObjectField* "}" +} + +ObjectField { + Name ":" Value Comma? +} + +FragmentSpread { + "..." FragmentName Directives? +} + +FragmentKeyword { + @specialize +} + +FragmentDefinition { + FragmentKeyword FragmentName TypeCondition Directives? SelectionSet +} + +FragmentName { + Name +} + +InlineFragment { + "..." TypeCondition? Directives? SelectionSet +} + +OnKeyword { + @specialize +} + +TypeCondition { + OnKeyword NamedType +} + +Directives { + Directive+ +} + +DirectiveName { + "@" Name +} + +Directive { + DirectiveName Arguments? +} + +DirectiveKeyword { + @specialize +} + +RepeatableKeyword { + @specialize +} + +DirectiveDefinition /* precedence: right 1 */ { + Description? DirectiveKeyword "@" Name ArgumentsDefinition? RepeatableKeyword ? OnKeyword DirectiveLocations +} + +DirectiveLocations { + DirectiveLocations "|" DirectiveLocation | + "|"? DirectiveLocation +} + +DirectiveLocation { + ExecutableDirectiveLocation | + TypeSystemDirectiveLocation +} + +Type { + NamedType | + ListType | + NonNullType +} + +NamedType { + Name +} + +ListType { + "[" Type "]" +} + +NonNullType { + NamedType "!" | + ListType "!" +} + +Description { + StringValue +} + +OperationType { + @specialize + | @specialize + | @specialize +} + +BooleanValue { + @specialize + | @specialize +} + +NullValue { + @specialize +} + +ExecutableDirectiveLocation { + @specialize + | @specialize + | @specialize + | @specialize + | @specialize + | @specialize + | @specialize + | @specialize +} + +TypeSystemDirectiveLocation { + @specialize + | @specialize + | @specialize + | @specialize + | @specialize + | @specialize + | @specialize + | @specialize + | @specialize + | @specialize + | @specialize +} + + +@tokens { + whitespace { + std.whitespace+ + } + StringValue { + "\"\"\"" (!["] | "\\n" | "\"" "\""? !["])* "\"\"\"" | "\"" !["\\\n]* "\"" + } + IntValue { + "-"? "0" + | "-"? std.digit+ + } + + FloatValue { + IntValue ("." std.digit+ | ("e" | "E") IntValue+) + } + + @precedence { IntValue, FloatValue } + + Name { + $[_A-Za-z] $[_0-9A-Za-z]* + } + + Comma { + "," + } + + Comment { + "#" ![\n]* + } + + + "{" "}" +} + +@skip { whitespace | Comment } + +@detectDelim diff --git a/packages/codemirror-lang-graphql/tsconfig.json b/packages/codemirror-lang-graphql/tsconfig.json new file mode 100644 index 0000000..7c5d26e --- /dev/null +++ b/packages/codemirror-lang-graphql/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "strict": true, + "target": "es6", + "module": "es2020", + "newLine": "lf", + "declaration": true, + "declarationDir": "./dist", + "moduleResolution": "node", + "skipLibCheck": true, + "allowJs": true + }, + "include": ["src/*"] +} diff --git a/packages/hoppscotch-agent/.gitignore b/packages/hoppscotch-agent/.gitignore new file mode 100644 index 0000000..e9f02db --- /dev/null +++ b/packages/hoppscotch-agent/.gitignore @@ -0,0 +1,33 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml diff --git a/packages/hoppscotch-agent/README.md b/packages/hoppscotch-agent/README.md new file mode 100644 index 0000000..8d850ae --- /dev/null +++ b/packages/hoppscotch-agent/README.md @@ -0,0 +1,281 @@ +
+ +

Hoppscotch Agent

+

+ Download | + Official Docs +

+
+ +
+ +#### Hoppscotch Agent is a cross-platform HTTP request relay for Hoppscotch built with [Tauri V2](https://v2.tauri.app/) that adds capabilities like custom headers, certificates, proxies, and local access typically restricted in browsers. + +The agent runs as a local system service on port `9119`, acting as an intermediary between the Hoppscotch web application and target APIs. It establishes an encrypted communication channel authenticated via an OTP registration process. + +## Installation + +### Standard Installation + +1. Download the latest version of Hoppscotch Agent from [releases](https://github.com/hoppscotch/agent-releases) +2. Run the installer +3. Follow the installation wizard to complete setup +4. The agent automatically starts and appears in your system tray + +### Portable Version + +The portable version runs without installation and does not include automatic updates. + +1. Download the portable version for your operating system +2. Extract the archive to your desired location +3. Run the executable directly +4. The agent will start and appear in your system tray + +> [!Note] +> The portable version uses a separate configuration (`tauri.portable.conf.json`) that disables bundling and updater functionality. + +## Getting Started + +### Registration + +1. Open Hoppscotch web app and navigate to **Settings** → **Interceptors** +2. Select **Agent** from the available interceptors +3. Click **Register Agent** button +4. The agent window displays a 6-digit verification code +5. Enter the verification code in the OTP input field +6. Click the confirm button to establish connection +7. The agent displays a masked auth key hash when successfully registered + +### Usage + +Once registered, all HTTP requests made through Hoppscotch are processed by the agent. The agent provides: + +- CORS bypass by processing requests locally +- Client certificate authentication for mutual TLS +- HTTP Digest Authentication using challenge-response mechanisms +- Custom headers that browsers typically restrict +- Proxy routing with authentication support +- Local network and localhost access +- SSL/TLS verification controls +- And much more + +## Domain-Specific Configuration + +The agent (and `Native`) interceptor supports per-domain configuration overrides with a global default (`*`) domain: + +### Domain Management +- **Global Defaults**: Base settings applied to all domains (domain: `*`) +- **Domain Overrides**: Specific settings for individual domains (e.g., `api.example.com`) +- **Domain Addition**: Add new domains through the domain management modal +- **Domain Removal**: Remove custom domain configurations (global default cannot be removed) + +### SSL/TLS Security Settings + +For each domain, configure: + +- **Verify Host**: Enable/disable hostname verification during SSL handshake +- **Verify Peer**: Enable/disable peer certificate verification +- **CA Certificates**: Upload custom Certificate Authority certificates for domain validation +- **Client Certificates**: Configure client certificates for mutual TLS authentication + +## Client Certificates + +The agent supports client certificate authentication for APIs requiring mutual TLS: + +### Certificate Formats +- **.pem certificates**: Requires separate certificate (.crt/.cer/.pem) and private key (.key/.pem) files +- **.pfx/.pkcs12 certificates**: Single file format with optional password protection + +### Configuration + +1. Access **Settings** → **Interceptors** → **Agent** in Hoppscotch +2. Select the target domain from the domain selector +3. Click **Client Certificates** button +4. Choose certificate format (PEM or PFX tab) +5. Upload certificate files: + - **PEM**: Upload certificate file and private key file separately + - **PFX**: Upload .pfx/.pkcs12 file and enter password if required +6. Configuration is automatically saved per domain + +### CA Certificates + +Custom Certificate Authority certificates can be added per domain: + +1. Navigate to the CA Certificates section for the target domain +2. Click **Add Certificate File** +3. Upload the CA certificate file +4. Toggle certificate inclusion on/off as needed +5. Remove certificates using the trash icon + +## Proxy Configuration + +The agent supports HTTP/HTTPS proxy routing with authentication (including NTLM): + +### Proxy Settings +- **Proxy URL**: HTTP/HTTPS proxy server address with port +- **Proxy Authentication**: Username and password for proxy server authentication +- **Per-Domain**: Each domain can have different proxy configurations + +### Configuration + +1. Select the target domain +2. Toggle the **Proxy** switch to enable +3. Enter the proxy URL (e.g., `http://proxy.example.com:8080`) +4. Configure proxy authentication if required: + - Username field + - Password field (with show/hide toggle) + +## System Integration + +### System Tray +The agent runs with system tray integration, providing access to: +- **Show Registrations**: View active connections and registration status +- **Clear Registrations**: Remove all registered instances +- **Maximize Window**: Show the agent interface window +- **Quit**: Exit the agent application + +### Configuration Storage +The agent stores configuration in platform-specific locations: + +- **Windows**: `%APPDATA%\io.hoppscotch.agent\` +- **macOS**: `~/Library/Application Support/io.hoppscotch.agent/` +- **Linux**: `~/.config/io.hoppscotch.agent/` + +### Logging +Logs are stored in platform-specific directories: + +- **Windows**: `%LOCALAPPDATA%\io.hoppscotch.agent\logs\` +- **macOS**: `~/Library/Logs/io.hoppscotch.agent/` +- **Linux**: `~/.local/share/io.hoppscotch.agent/logs/` + +### Auto-Start Configuration +The standard installation includes auto-start functionality. The portable version does not include auto-start and must be launched manually. + +## Building from Source + +### Prerequisites + +- [Node.js](https://nodejs.org/) (v18 or later) +- [pnpm](https://pnpm.io/) package manager +- [Rust](https://rustup.rs/) (latest stable) +- [Tauri CLI](https://tauri.app/v1/guides/getting-started/prerequisites) + +### Development + +```bash +# Clone the repository +git clone https://github.com/hoppscotch/hoppscotch.git +cd hoppscotch/packages/hoppscotch-agent + +# Install dependencies +pnpm install + +# Start development server +pnpm tauri dev +``` + +### Production Build + +```bash +# Build standard version +pnpm tauri build + +# Build portable version +pnpm tauri build --config src-tauri/tauri.portable.conf.json +``` + +The built applications will be available in `src-tauri/target/release/bundle/` + +### Build Variants + +Two build configurations are available: + +- **Standard** (`tauri.conf.json`): Includes installer, auto-updater, and auto-start functionality +- **Portable** (`tauri.portable.conf.json`): Standalone executable without installation requirements + +## Network Configuration + +### Default Port +The agent runs on port `9119` by default. Make sure this port is not blocked by firewalls. + +### Communication Protocol +- **Encryption**: AES-256-GCM for all agent-to-web-app communication +- **Authentication**: X25519 key exchange for secure channel establishment +- **Registration**: One-time 6-digit OTP verification process + +## System Requirements + +### Windows +- **OS Version**: Windows 10 1803+ or Windows 11 +- **Architecture**: x64 +- **Dependencies**: WebView2 Runtime (auto-installed for standard version) + +### macOS +- **OS Version**: macOS 10.15 (Catalina) or later +- **Architecture**: Intel x64 or Apple Silicon (ARM64) + +### Linux +- **Architecture**: x64 +- **Dependencies**: WebKit2GTK 2.44.0+ (usually pre-installed) +- **Minimum**: Systems with GLIBC 2.38+ + +## Troubleshooting + +### Agent Detection Issues +1. **"Agent not detected" popup**: Verify the agent is running by checking the system tray for the Hoppscotch icon +2. **Switching interceptors blocked**: If the "Agent not detected" popup prevents switching interceptors, restart your browser and stop the agent before changing interceptor settings +3. **Port accessibility**: Check that no firewall is blocking port `9119` +4. **Browser compatibility**: Safari on macOS may have CORS issues with localhost:9119 due to access control checks, try Chrome/Firefox for agent registration + +### Registration Failures +1. **"Failed to initiate the registration"**: This error may occur due to browser security policies or extension conflicts +2. **Missing OTP input field**: Verify the agent window is focused and displaying a 6-digit verification code +3. **OTP expiration**: Registration codes have limited lifetime, restart the registration process if the code expires +4. **Network connectivity**: Verify browser can reach localhost:9119/handshake +5. **Version compatibility**: Some agent versions may be incompatible with specific Hoppscotch web app versions. For self-hosted setups, make sure Agent version in the release matches, see https://github.com/hoppscotch/hoppscotch/issues/4936#issuecomment-2756981053 + +### Certificate Issues +1. Verify certificate format is supported (.pem or .pfx/.pkcs12) +2. Check certificate expiration dates +3. Confirm private key matches certificate (for .pem files) +4. Verify domain configuration matches target API hostname +5. Confirm certificate password is correct (for .pfx/.pkcs12) +6. Check CA certificate inclusion status (toggle on/off) + +### Request Processing Issues +1. **Custom headers not applied**: Verify the agent is selected as interceptor, browsers may override headers like User-Agent when using default HTTP methods +2. **CORS errors**: Confirm agent interceptor is active and requests are routing through localhost:9119 +3. **SSL/TLS verification**: Check verify host/peer settings for the target domain +4. **Proxy routing**: Verify proxy URL format includes protocol (http:// or https://) + +### System-Specific Issues + +#### Windows +1. Check WebView2 Runtime is installed (auto-installed with standard version) +2. Check Windows Defender or antivirus exclusions for the agent executable +3. Verify agent has network permissions through Windows Firewall + +#### macOS +1. Safari browser may block agent connections due to CORS policies, try Chrome or Firefox instead +2. Check macOS Gatekeeper settings if agent fails to start +3. Verify agent is allowed in System Preferences → Security & Privacy + +#### Linux +1. Check WebKit2GTK dependencies are installed +2. Check systemd logs if agent fails to start as service +3. Verify GLIBC version compatibility (requires 2.38+) + +### Portable Version Issues +1. Manual WebView2 installation - may be required on older versions of Windows +2. No auto-start capability - must launch manually after system restart +3. No automatic updates - download new versions manually +4. Verify executable permissions on Unix-like systems +5. Check that portable version is extracted to a writable directory + +### Log +Check agent logs for detailed error information: +- **Windows**: `%LOCALAPPDATA%\io.hoppscotch.agent\logs\` +- **macOS**: `~/Library/Logs/io.hoppscotch.agent/` +- **Linux**: `~/.local/share/io.hoppscotch.agent/logs/` + +Look for connection errors, certificate validation failures, or proxy authentication issues in the log files. diff --git a/packages/hoppscotch-agent/eslint.config.mjs b/packages/hoppscotch-agent/eslint.config.mjs new file mode 100644 index 0000000..1ca0349 --- /dev/null +++ b/packages/hoppscotch-agent/eslint.config.mjs @@ -0,0 +1,67 @@ +import pluginVue from "eslint-plugin-vue" +import { + defineConfigWithVueTs, + vueTsConfigs, +} from "@vue/eslint-config-typescript" +import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended" +import globals from "globals" + +export default defineConfigWithVueTs( + { + ignores: [ + "**/*.d.ts", + "dist/**", + "node_modules/**", + "src-tauri/**", + ], + }, + pluginVue.configs["flat/recommended"], + vueTsConfigs.recommended, + eslintPluginPrettierRecommended, + { + files: ["**/*.ts", "**/*.js", "**/*.vue"], + linterOptions: { + reportUnusedDisableDirectives: false, + }, + languageOptions: { + sourceType: "module", + ecmaVersion: "latest", + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + requireConfigFile: false, + ecmaFeatures: { + jsx: false, + }, + }, + }, + rules: { + semi: [2, "never"], + "no-console": "off", + "no-debugger": process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn", + "prettier/prettier": + process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn", + "vue/multi-word-component-names": "off", + "vue/no-side-effects-in-computed-properties": "off", + "@typescript-eslint/no-unused-vars": [ + process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn", + { + args: "all", + argsIgnorePattern: "^_", + caughtErrors: "all", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + varsIgnorePattern: "^_", + ignoreRestSiblings: true, + }, + ], + "@typescript-eslint/no-unused-expressions": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-function-type": "off", + "no-undef": "off", + }, + } +) diff --git a/packages/hoppscotch-agent/index.html b/packages/hoppscotch-agent/index.html new file mode 100644 index 0000000..7a5662e --- /dev/null +++ b/packages/hoppscotch-agent/index.html @@ -0,0 +1,13 @@ + + + + + + + Hoppscotch Agent + + +
+ + + diff --git a/packages/hoppscotch-agent/package.json b/packages/hoppscotch-agent/package.json new file mode 100644 index 0000000..5c9dd6f --- /dev/null +++ b/packages/hoppscotch-agent/package.json @@ -0,0 +1,52 @@ +{ + "name": "hoppscotch-agent", + "private": true, + "version": "0.1.17", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "tauri": "tauri", + "lint": "eslint src", + "lint:ts": "vue-tsc --noEmit", + "lintfix": "eslint --fix src", + "prod-lint": "cross-env HOPP_LINT_FOR_PROD=true pnpm run lint", + "do-lint": "pnpm run prod-lint", + "do-typecheck": "pnpm run lint:ts", + "do-lintfix": "pnpm run lintfix" + }, + "dependencies": { + "@hoppscotch/ui": "0.2.6", + "@tauri-apps/api": "2.1.1", + "@tauri-apps/plugin-shell": "2.3.3", + "@vueuse/core": "14.3.0", + "axios": "1.18.0", + "fp-ts": "2.16.11", + "lodash-es": "4.18.1", + "vue": "3.5.38" + }, + "devDependencies": { + "@iconify-json/lucide": "1.2.114", + "@tauri-apps/cli": "2.9.3", + "@types/lodash-es": "4.17.12", + "@types/node": "24.10.1", + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@vitejs/plugin-vue": "6.0.7", + "@vue/eslint-config-typescript": "14.8.0", + "autoprefixer": "10.5.0", + "cross-env": "10.1.0", + "eslint": "9.39.2", + "eslint-plugin-prettier": "5.5.6", + "eslint-plugin-vue": "10.9.2", + "globals": "16.5.0", + "postcss": "8.5.15", + "tailwindcss": "3.4.16", + "typescript": "5.9.3", + "unplugin-icons": "22.5.0", + "unplugin-vue-components": "30.0.0", + "vite": "7.3.2", + "vue-tsc": "2.2.0" + } +} diff --git a/packages/hoppscotch-agent/postcss.config.js b/packages/hoppscotch-agent/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/packages/hoppscotch-agent/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/packages/hoppscotch-agent/public/icon.png b/packages/hoppscotch-agent/public/icon.png new file mode 100644 index 0000000..3778416 Binary files /dev/null and b/packages/hoppscotch-agent/public/icon.png differ diff --git a/packages/hoppscotch-agent/public/tauri.svg b/packages/hoppscotch-agent/public/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/packages/hoppscotch-agent/public/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/hoppscotch-agent/public/vite.svg b/packages/hoppscotch-agent/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/packages/hoppscotch-agent/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/hoppscotch-agent/src-tauri/.cargo/config.toml b/packages/hoppscotch-agent/src-tauri/.cargo/config.toml new file mode 100644 index 0000000..9017258 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/.cargo/config.toml @@ -0,0 +1,25 @@ +# Enable static linking for C runtime library on Windows. +# +# Rust uses the msvc toolchain on Windows, +# which by default dynamically links the C runtime (CRT) to the binary. +# +# This creates a runtime dependency on the Visual C++ Redistributable (`vcredist`), +# meaning the target machine must have `vcredist` installed for the application to run. +# +# Since `portable` version doesn't have an installer, +# we can't rely on it to install dependencies, so this config. +# +# Basically: +# - The `+crt-static` flag instructs the Rust compiler to statically link the C runtime for Windows builds.\ +# - To avoids runtime errors related to missing `vcredist` installations. +# - Results in a larger binary size because the runtime is bundled directly into the executable. +# +# For MSVC targets specifically, it will compile code with `/MT` or static linkage. +# See: - RFC 1721: https://rust-lang.github.io/rfcs/1721-crt-static.html +# - Rust Reference - Runtime: https://doc.rust-lang.org/reference/runtime.html +# - MSVC Linking Options: https://docs.microsoft.com/en-us/cpp/build/reference/md-mt-ld-use-run-time-library +# - Rust Issue #37406: https://github.com/rust-lang/rust/issues/37406 +# - Tauri Issue #3048: https://github.com/tauri-apps/tauri/issues/3048 +# - Rust Linkage: https://doc.rust-lang.org/reference/linkage.html +[target.'cfg(windows)'] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/packages/hoppscotch-agent/src-tauri/.gitignore b/packages/hoppscotch-agent/src-tauri/.gitignore new file mode 100644 index 0000000..b21bd68 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/.gitignore @@ -0,0 +1,7 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas diff --git a/packages/hoppscotch-agent/src-tauri/Cargo.lock b/packages/hoppscotch-agent/src-tauri/Cargo.lock new file mode 100644 index 0000000..fd52c1d --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/Cargo.lock @@ -0,0 +1,7334 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "ashpd" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle 0.6.2", + "serde", + "serde_repr", + "tokio", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.2", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.2", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.2", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" +dependencies = [ + "axum", + "axum-core", + "bytes", + "fastrand", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "multer", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base16" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.3", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.10.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.8", +] + +[[package]] +name = "cc" +version = "1.2.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "739eb0f94557554b3ca9a86d2d37bebd49c5e6d0c1d2bda35ba5bdac830befc2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "foreign-types 0.5.0", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.108", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.108", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curl" +version = "0.4.47" +source = "git+https://github.com/CuriousCorrelation/curl-rust.git#1ec8079cf527b9cf47cc7a48c68b458affdae273" +dependencies = [ + "curl-sys", + "libc", + "openssl-probe", + "openssl-sys", + "socket2 0.5.10", +] + +[[package]] +name = "curl-sys" +version = "0.4.77+curl-8.10.1" +source = "git+https://github.com/CuriousCorrelation/curl-rust.git#1ec8079cf527b9cf47cc7a48c68b458affdae273" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "windows-sys 0.52.0", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.108", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", + "serde", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.108", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "block2 0.6.2", + "libc", + "objc2 0.6.3", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "dlopen2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b54f373ccf864bf587a89e880fb7610f8d73f3045f13580948ccbcaff26febff" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embed-resource" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.8", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "file-rotate" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8e2fa049328a1f3295991407a88585805d126dfaadf74b9fe8c194c730aafc" +dependencies = [ + "chrono", + "flate2", +] + +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.10.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.12.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hoppscotch-agent" +version = "0.1.17" +dependencies = [ + "aes-gcm", + "axum", + "axum-extra", + "base16", + "chrono", + "dashmap", + "dirs 6.0.0", + "file-rotate", + "lazy_static", + "native-dialog", + "rand 0.8.5", + "relay", + "serde", + "serde_json", + "sha2", + "tauri", + "tauri-build", + "tauri-plugin-autostart", + "tauri-plugin-dialog", + "tauri-plugin-http", + "tauri-plugin-shell", + "tauri-plugin-single-instance", + "tauri-plugin-store", + "tauri-plugin-updater", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tower-http", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uuid", + "winreg 0.52.0", + "x25519-dalek", +] + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-serde" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd" +dependencies = [ + "http", + "serde", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.1", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.0", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847" +dependencies = [ + "cfb", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.10.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser", + "html5ever", + "indexmap 2.12.0", + "selectors", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall", +] + +[[package]] +name = "libz-sys" +version = "1.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "minisign-verify" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e856fdd13623a2f5f2f54676a4ee49502a96a80ef4a62bcedd23d52427c44d43" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.17.16", + "serde", + "thiserror 2.0.17", + "windows-sys 0.60.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "native-dialog" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e7038885d2aeab236bd60da9e159a5967b47cde3292da3b15ff1bec27c039f" +dependencies = [ + "ascii", + "block", + "cocoa", + "core-foundation 0.9.4", + "dirs-next", + "objc", + "objc-foundation", + "objc_id", + "once_cell", + "raw-window-handle 0.5.2", + "thiserror 1.0.69", + "versions", + "wfd", + "which", + "winapi", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle 0.6.2", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.10.0", + "block2 0.6.2", + "libc", + "objc2 0.6.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2 0.6.3", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.10.0", + "block2 0.6.2", + "libc", + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-javascript-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" +dependencies = [ + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.10.0", + "block2 0.6.2", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-javascript-core", + "objc2-security", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl" +version = "0.10.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-src" +version = "300.5.4+3.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2 0.6.3", + "objc2-foundation 0.3.2", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.1", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.12.0", + "quick-xml 0.38.3", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.7", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "pxfm" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +dependencies = [ + "num-traits", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.1", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.1", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.17", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "relay" +version = "0.1.1" +source = "git+https://github.com/CuriousCorrelation/relay.git#ed2329e4ebb71bb984c4705aa950cb9c3f9ff931" +dependencies = [ + "bytes", + "curl", + "dashmap", + "env_logger", + "http", + "http-serde", + "infer 0.16.0", + "lazy_static", + "log", + "mime", + "openssl", + "openssl-sys", + "serde", + "serde_json", + "strum", + "thiserror 1.0.69", + "time", + "tokio-util", + "tracing", + "url", + "url-escape", + "urlencoding", +] + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "async-compression", + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle 0.6.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.108", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.12.0", + "schemars 0.9.0", + "schemars 1.0.4", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +dependencies = [ + "bytemuck", + "cfg_aliases", + "core-graphics 0.24.0", + "foreign-types 0.5.0", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", + "raw-window-handle 0.6.2", + "redox_syscall", + "wasm-bindgen", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.108", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +dependencies = [ + "bitflags 2.10.0", + "block2 0.6.2", + "core-foundation 0.10.1", + "core-graphics 0.24.0", + "crossbeam-channel", + "dispatch", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "lazy_static", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "once_cell", + "parking_lot", + "raw-window-handle 0.6.2", + "scopeguard", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e492485dd390b35f7497401f67694f46161a2a00ffd800938d5dd3c898fb9d8" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs 6.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "image", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle 0.6.2", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.17", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87d6f8cafe6a75514ce5333f115b7b1866e8e68d9672bf4ca89fc0f35697ea9d" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 6.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.8", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ef707148f0755110ca54377560ab891d722de4d53297595380a748026f139f" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.108", + "tauri-utils", + "thiserror 2.0.17", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71664fd715ee6e382c05345ad258d6d1d50f90cf1b58c0aa726638b33c2a075d" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.108", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076c78a474a7247c90cad0b6e87e593c4c620ed4efdb79cbe0214f0021f6c39d" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.8", + "walkdir", +] + +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19" +dependencies = [ + "log", + "raw-window-handle 0.6.2", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.17", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.17", + "toml 0.9.8", + "url", +] + +[[package]] +name = "tauri-plugin-http" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00685aceab12643cf024f712ab0448ba8fcadf86f2391d49d2e5aa732aacc70" +dependencies = [ + "bytes", + "cookie_store", + "data-url", + "http", + "regex", + "reqwest", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.17", + "tokio", + "url", + "urlpattern", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c374b6db45f2a8a304f0273a15080d98c70cde86178855fc24653ba657a1144c" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "tokio", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.17", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-store" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a77036340a97eb5bbe1b3209c31e5f27f75e6f92a52fd9dd4b211ef08bf310" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "tokio", + "tracing", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" +dependencies = [ + "base64 0.22.1", + "dirs 6.0.0", + "flate2", + "futures-util", + "http", + "infer 0.19.0", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.17", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9368f09358496f2229313fccb37682ad116b7f46fa76981efe116994a0628926" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2 0.6.3", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle 0.6.2", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.17", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "929f5df216f5c02a9e894554401bcdab6eec3e39ec6a4a7731c7067fc8688a93" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "once_cell", + "percent-encoding", + "raw-window-handle 0.6.2", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever", + "http", + "infer 0.19.0", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.17", + "toml 0.9.8", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd21509dd1fa9bd355dc29894a6ff10635880732396aa38c0066c1e6c1ab8074" +dependencies = [ + "embed-resource", + "toml 0.9.8", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.1", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +dependencies = [ + "indexmap 2.12.0", + "serde_core", + "serde_spanned 1.0.3", + "toml_datetime 0.7.3", + "toml_parser", + "toml_writer", + "winnow 0.7.13", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.12.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.12.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap 2.12.0", + "toml_datetime 0.7.3", + "toml_parser", + "winnow 0.7.13", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow 0.7.13", +] + +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror 1.0.69", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "time", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "tray-icon" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d5572781bee8e3f994d7467084e1b1fd7a93ce66bd480f8156ba89dee55a2b" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.17.16", + "serde", + "thiserror 2.0.17", + "windows-sys 0.60.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "url-escape" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44e0ce4d1246d075ca5abec4b41d33e87a6054d08e2366b63205665e950db218" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "rand 0.9.2", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "versions" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c73a36bc44e3039f51fbee93e39f41225f6b17b380eb70cc2aab942df06b34dd" +dependencies = [ + "itertools", + "nom", +] + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.108", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.2", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +dependencies = [ + "bitflags 2.10.0", + "rustix 1.1.2", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +dependencies = [ + "proc-macro2", + "quick-xml 0.37.5", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" +dependencies = [ + "thiserror 2.0.17", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "wfd" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c17bbfb155305bcb79144f568c3b796275ba4db5d5856597bc85acefe29b819" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle 0.6.2", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wry" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" +dependencies = [ + "base64 0.22.1", + "block2 0.6.2", + "cookie", + "crossbeam-channel", + "dirs 6.0.0", + "dpi", + "dunce", + "gdkx11", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "ndk", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle 0.6.2", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.17", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.2", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix", + "ordered-stream", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.13", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.108", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow 0.7.13", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.12.0", + "memchr", +] + +[[package]] +name = "zvariant" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 0.7.13", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.108", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.108", + "winnow 0.7.13", +] diff --git a/packages/hoppscotch-agent/src-tauri/Cargo.toml b/packages/hoppscotch-agent/src-tauri/Cargo.toml new file mode 100644 index 0000000..c8809b0 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "hoppscotch-agent" +version = "0.1.17" +description = "A cross-platform HTTP request agent for Hoppscotch for advanced request handling including custom headers, certificates, proxies, and local system integration." +authors = ["AndrewBastin", "CuriousCorrelation"] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +name = "hoppscotch_agent_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2.5.2", features = [] } + +[dependencies] +tauri = { version = "2.9.3", features = ["tray-icon", "image-png"] } +tauri-plugin-shell = "2.3.3" +tauri-plugin-autostart = { version = "2.5.1", optional = true } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1.48.0", features = ["full"] } +dashmap = { version = "6.1.0", features = ["serde"] } +axum = { version = "0.7.9" } +axum-extra = { version = "0.9.6", features = ["typed-header"] } +tower-http = { version = "0.6.6", features = ["cors"] } +tokio-util = "0.7.17" +uuid = { version = "1.18.1", features = [ "v4", "fast-rng" ] } +chrono = { version = "0.4", features = ["serde"] } +rand = "0.8.5" +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json", "fmt", "std", "time"] } +tracing-appender = "0.2.3" +relay = { git = "https://github.com/CuriousCorrelation/relay.git" } +thiserror = "1.0.69" +tauri-plugin-store = "2.4.1" +x25519-dalek = { version = "2.0.1", features = ["getrandom"] } +base16 = "0.2.1" +aes-gcm = { version = "0.10.3", features = ["aes"] } +tauri-plugin-updater = "2.9.0" +tauri-plugin-dialog = "2.4.2" +lazy_static = "1.5.0" +tauri-plugin-single-instance = "2.3.6" +tauri-plugin-http = { version = "2.5.4", features = ["gzip"] } +native-dialog = "0.7.0" +sha2 = "0.10.9" +file-rotate = "0.8.0" +dirs = "6.0.0" + +[target.'cfg(windows)'.dependencies] +tempfile = { version = "3.23.0" } +winreg = { version = "0.52.0" } + +[features] +default = ["tauri-plugin-autostart"] +portable = [] diff --git a/packages/hoppscotch-agent/src-tauri/build.rs b/packages/hoppscotch-agent/src-tauri/build.rs new file mode 100644 index 0000000..65c5c67 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/build.rs @@ -0,0 +1,5 @@ +fn main() { + tauri_build::build(); + println!("cargo::rerun-if-env-changed=UPDATER_PUB_KEY"); + println!("cargo::rerun-if-env-changed=UPDATER_URL"); +} diff --git a/packages/hoppscotch-agent/src-tauri/capabilities/default.json b/packages/hoppscotch-agent/src-tauri/capabilities/default.json new file mode 100644 index 0000000..70c49c1 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/capabilities/default.json @@ -0,0 +1,25 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main", "test"], + "permissions": [ + { + "identifier": "http:default", + "allow": [ + { + "url": "https://*.tauri.app" + }, + { + "url": "https://*.microsoft.*" + } + ] + }, + "core:default", + "shell:allow-open", + "core:window:allow-close", + "core:window:allow-hide", + "core:window:allow-set-focus", + "core:window:allow-set-always-on-top" + ] +} diff --git a/packages/hoppscotch-agent/src-tauri/icons/128x128.png b/packages/hoppscotch-agent/src-tauri/icons/128x128.png new file mode 100644 index 0000000..3778416 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/128x128.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/128x128@2x.png b/packages/hoppscotch-agent/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..e5b8846 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/128x128@2x.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/32x32.png b/packages/hoppscotch-agent/src-tauri/icons/32x32.png new file mode 100644 index 0000000..e553d90 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/32x32.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/Square107x107Logo.png b/packages/hoppscotch-agent/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..e8e31c2 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/Square107x107Logo.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/Square142x142Logo.png b/packages/hoppscotch-agent/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..938bf8d Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/Square142x142Logo.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/Square150x150Logo.png b/packages/hoppscotch-agent/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..647408c Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/Square150x150Logo.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/Square284x284Logo.png b/packages/hoppscotch-agent/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..a898743 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/Square284x284Logo.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/Square30x30Logo.png b/packages/hoppscotch-agent/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..e1a1f77 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/Square30x30Logo.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/Square310x310Logo.png b/packages/hoppscotch-agent/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..69bf56b Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/Square310x310Logo.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/Square44x44Logo.png b/packages/hoppscotch-agent/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..bb5b895 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/Square44x44Logo.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/Square71x71Logo.png b/packages/hoppscotch-agent/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..6854168 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/Square71x71Logo.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/Square89x89Logo.png b/packages/hoppscotch-agent/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..94a65e2 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/Square89x89Logo.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/StoreLogo.png b/packages/hoppscotch-agent/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..db4b61b Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/StoreLogo.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..b334e00 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ea31185 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..b334e00 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..ee4bfd7 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..794a5c6 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..ee4bfd7 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..fbef329 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9aee0bf Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..fbef329 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d63d25b Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1443a0c Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d63d25b Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..3e0f1c3 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5f449d4 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..3e0f1c3 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/icon.icns b/packages/hoppscotch-agent/src-tauri/icons/icon.icns new file mode 100644 index 0000000..293556d Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/icon.icns differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/icon.ico b/packages/hoppscotch-agent/src-tauri/icons/icon.ico new file mode 100644 index 0000000..3e3249c Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/icon.ico differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/icon.png b/packages/hoppscotch-agent/src-tauri/icons/icon.png new file mode 100644 index 0000000..a91230b Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/icon.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-20x20@1x.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..e63a928 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..8caffb7 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-20x20@2x.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..8caffb7 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-20x20@3x.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..c51270a Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-29x29@1x.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..c89515b Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..4c23c68 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-29x29@2x.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..4c23c68 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-29x29@3x.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..cd117af Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-40x40@1x.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..8caffb7 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..9f5c51d Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-40x40@2x.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..9f5c51d Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-40x40@3x.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..97738c1 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-512@2x.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..ed29af6 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-60x60@2x.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..97738c1 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-60x60@3x.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..6eb8333 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-76x76@1x.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..38d7d79 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-76x76@2x.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..4dbf0d7 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..aed3613 Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/packages/hoppscotch-agent/src-tauri/icons/tray_icon.png b/packages/hoppscotch-agent/src-tauri/icons/tray_icon.png new file mode 100644 index 0000000..b29857f Binary files /dev/null and b/packages/hoppscotch-agent/src-tauri/icons/tray_icon.png differ diff --git a/packages/hoppscotch-agent/src-tauri/src/command.rs b/packages/hoppscotch-agent/src-tauri/src/command.rs new file mode 100644 index 0000000..40de8c5 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/command.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use crate::{ + model::{MaskedRegistration, RegistrationsList}, + state::AppState, + util::generate_auth_key_hash, +}; + +#[tauri::command] +#[tracing::instrument(skip(state))] +pub async fn get_otp(state: tauri::State<'_, Arc>) -> Result, ()> { + tracing::debug!("Retrieving current OTP"); + let otp = state.active_registration_code.read().await.clone(); + if otp.is_some() { + tracing::debug!("OTP found"); + } else { + tracing::debug!("No active OTP"); + } + Ok(otp) +} + +#[tauri::command] +#[tracing::instrument(skip(state))] +pub fn list_registrations(state: tauri::State<'_, Arc>) -> Result { + tracing::debug!("Retrieving registrations list"); + + let masked_registrations = state + .get_registrations() + .iter() + .map(|entry| MaskedRegistration { + registered_at: entry.value().registered_at, + auth_key_hash: generate_auth_key_hash(entry.key()), + }) + .collect(); + + Ok(RegistrationsList { + registrations: masked_registrations, + total: state.get_registrations().len(), + }) +} diff --git a/packages/hoppscotch-agent/src-tauri/src/controller.rs b/packages/hoppscotch-agent/src-tauri/src/controller.rs new file mode 100644 index 0000000..42f2621 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/controller.rs @@ -0,0 +1,399 @@ +use std::sync::Arc; + +use axum::{ + body::Bytes, + extract::{Path, State}, + http::HeaderMap, + Json, +}; +use axum_extra::{ + headers::{authorization::Bearer, Authorization}, + TypedHeader, +}; +use chrono::Utc; +use rand::Rng; +use serde_json::json; +use tauri::{AppHandle, Emitter}; +use uuid::Uuid; +use x25519_dalek::{EphemeralSecret, PublicKey}; + +use crate::{ + error::{AgentError, AgentResult}, + global::NONCE, + model::{ + AuthKeyResponse, ConfirmedRegistrationRequest, HandshakeResponse, LogEntry, LogLevel, + MaskedRegistration, Registration, + }, + state::AppState, + util::{generate_auth_key_hash, EncryptedJson}, +}; + +#[tracing::instrument] +fn generate_otp() -> String { + let otp: u32 = rand::thread_rng().gen_range(0..1_000_000); + let formatted = format!("{:06}", otp); + tracing::debug!("Generated OTP: {}", formatted); + formatted +} + +#[tracing::instrument(skip(app_handle))] +pub async fn handshake( + State((_, app_handle)): State<(Arc, AppHandle)>, +) -> AgentResult> { + tracing::info!("Processing handshake request"); + let response = HandshakeResponse { + status: "success".to_string(), + __hoppscotch__agent__: true, + agent_version: app_handle.package_info().version.to_string(), + }; + tracing::info!("Handshake successful"); + Ok(Json(response)) +} + +#[tracing::instrument(skip(state, app_handle))] +pub async fn receive_registration( + State((state, app_handle)): State<(Arc, AppHandle)>, +) -> AgentResult> { + let otp = generate_otp(); + tracing::info!("Generated new registration OTP"); + + let mut active_registration_code = state.active_registration_code.write().await; + + if !active_registration_code.is_none() { + tracing::warn!("Registration attempt while another registration is active"); + return Ok(Json( + json!({ "message": "There is already an existing registration happening" }), + )); + } + + *active_registration_code = Some(otp.clone()); + + match app_handle.emit("registration-received", otp) { + Ok(_) => { + tracing::info!("Registration event emitted successfully"); + Ok(Json( + json!({ "message": "Registration received and stored" }), + )) + } + Err(e) => { + tracing::error!("Failed to emit registration event: {}", e); + Err(AgentError::InternalServerError) + } + } +} + +#[tracing::instrument(skip(state, _app_handle))] +pub async fn registration( + State((state, _app_handle)): State<(Arc, AppHandle)>, + TypedHeader(auth_header): TypedHeader>, +) -> AgentResult> { + let token = auth_header.token(); + + if !state.validate_access(token) { + tracing::warn!("Unauthorized attempt to list registrations"); + return Err(AgentError::Unauthorized); + } + + let registration = state + .get_registration(token) + .ok_or(AgentError::Unauthorized)?; + + let key_b16 = registration.shared_secret_b16; + + let registration = MaskedRegistration { + registered_at: registration.registered_at, + auth_key_hash: generate_auth_key_hash(token), + }; + + tracing::info!("Successfully retrieved registrations list"); + Ok(EncryptedJson { + key_b16, + data: registration, + }) +} + +#[tracing::instrument(skip(state, app_handle), fields(auth_key))] +pub async fn verify_registration( + State((state, app_handle)): State<(Arc, AppHandle)>, + Json(confirmed_registration): Json, +) -> AgentResult> { + tracing::info!("Verifying registration request"); + + if !state + .validate_registration(&confirmed_registration.registration) + .await + { + tracing::warn!("Invalid registration attempt"); + return Err(AgentError::InvalidRegistration); + } + + let auth_key = Uuid::new_v4().to_string(); + let created_at = Utc::now(); + + tracing::Span::current().record("auth_key", &auth_key.as_str()); + + let auth_key_copy = auth_key.clone(); + let secret_key = EphemeralSecret::random(); + let public_key = PublicKey::from(&secret_key); + + let their_public_key = { + let public_key_slice: &[u8; 32] = + &base16::decode(&confirmed_registration.client_public_key_b16) + .map_err(|_| AgentError::InvalidClientPublicKey)?[0..32] + .try_into() + .map_err(|_| AgentError::InvalidClientPublicKey)?; + + PublicKey::from(public_key_slice.to_owned()) + }; + + let shared_secret = secret_key.diffie_hellman(&their_public_key); + + if let Err(e) = state.update_registrations(app_handle.clone(), |regs| { + regs.insert( + auth_key_copy, + Registration { + registered_at: created_at, + shared_secret_b16: base16::encode_lower(shared_secret.as_bytes()), + }, + ); + }) { + tracing::error!("Failed to update registrations: {:?}", e); + return Err(e); + } + + let auth_payload = json!({ + "auth_key": auth_key, + "created_at": created_at + }); + + if let Err(e) = app_handle.emit("authenticated", &auth_payload) { + tracing::error!("Failed to emit authenticated event: {:?}", e); + return Err(AgentError::InternalServerError); + } + + let _ = state.clear_active_registration().await; + + tracing::info!("Registration verified successfully"); + Ok(Json(AuthKeyResponse { + auth_key, + created_at, + agent_public_key_b16: base16::encode_lower(public_key.as_bytes()), + })) +} + +#[tracing::instrument(skip(state, app_handle), fields(auth_key = %auth_key))] +pub async fn delete_registration( + State((state, app_handle)): State<(Arc, AppHandle)>, + TypedHeader(auth_header): TypedHeader>, + Path(auth_key): Path, +) -> AgentResult> { + if !state.validate_access(auth_header.token()) { + tracing::warn!("Unauthorized deletion attempt"); + return Err(AgentError::Unauthorized); + } + + let _removed = state.update_registrations(app_handle.clone(), |regs| { + regs.remove(&auth_key); + })?; + + tracing::info!("Registration deleted successfully"); + let message = format!("{} registration deleted successfully", auth_key); + Ok(Json(json!({ "message": message }))) +} + +#[tracing::instrument(skip(state, body, _app_handle), fields(req_id))] +pub async fn execute( + State((state, _app_handle)): State<(Arc, AppHandle)>, + TypedHeader(auth_header): TypedHeader>, + headers: HeaderMap, + body: Bytes, +) -> AgentResult> { + let nonce = match headers.get(NONCE) { + Some(n) => match n.to_str() { + Ok(n) => n, + Err(_) => { + tracing::warn!("Invalid nonce header"); + return Err(AgentError::Unauthorized); + } + }, + None => { + tracing::warn!("Missing nonce header"); + return Err(AgentError::Unauthorized); + } + }; + + let request = match state.validate_access_and_get_data::( + auth_header.token(), + nonce, + &body, + ) { + Some(r) => r, + None => { + tracing::warn!("Invalid access or data"); + return Err(AgentError::Unauthorized); + } + }; + + let request_id = request.id; + + tracing::Span::current().record("request_id", &request_id); + + let reg_info = match state.get_registration(auth_header.token()) { + Some(r) => r, + None => { + tracing::warn!("Registration info not found"); + return Err(AgentError::Unauthorized); + } + }; + + Ok(relay::execute(request) + .await + .map(|response| EncryptedJson { + key_b16: reg_info.shared_secret_b16, + data: response, + })?) +} + +/// Provides a way for registered clients to check if their +/// registration still holds, this route is supposed to return +/// an encrypted `true` value if the given auth_key is good. +/// Since its encrypted with the shared secret established during +/// registration, the client also needs the shared secret to verify +/// if the read fails, or the auth_key didn't validate and this route returns +/// undefined, we can count on the registration not being valid anymore. +#[tracing::instrument(skip(state, _app_handle))] +pub async fn registered_handshake( + State((state, _app_handle)): State<(Arc, AppHandle)>, + TypedHeader(auth_header): TypedHeader>, +) -> AgentResult> { + let reg_info = state.get_registration(auth_header.token()); + + match reg_info { + Some(reg) => { + tracing::info!("Handshake successful"); + Ok(EncryptedJson { + key_b16: reg.shared_secret_b16, + data: json!(true), + }) + } + None => { + tracing::warn!("Unauthorized handshake attempt"); + Err(AgentError::Unauthorized) + } + } +} + +#[tracing::instrument(skip(state, _app_handle), fields(request_id = %request_id))] +pub async fn cancel( + State((state, _app_handle)): State<(Arc, AppHandle)>, + TypedHeader(auth_header): TypedHeader>, + Path(request_id): Path, +) -> AgentResult> { + if !state.validate_access(auth_header.token()) { + tracing::warn!("Unauthorized cancellation attempt"); + return Err(AgentError::Unauthorized); + } + + if let Ok(()) = relay::cancel(request_id.try_into().unwrap()).await { + tracing::info!("Request cancelled successfully"); + Ok(Json(json!({"message": "Request cancelled successfully"}))) + } else { + tracing::warn!("Request not found"); + Err(AgentError::RequestNotFound) + } +} + +#[tracing::instrument(skip_all)] +pub async fn log_sink( + State((state, _app_handle)): State<(Arc, AppHandle)>, + TypedHeader(auth_header): TypedHeader>, + headers: HeaderMap, + body: Bytes, +) -> AgentResult> { + if !state.validate_access(auth_header.token()) { + tracing::warn!("Unauthorized log sink access attempt"); + return Err(AgentError::Unauthorized); + } + + let nonce = match headers.get(NONCE) { + Some(n) => match n.to_str() { + Ok(n) => n, + Err(_) => { + tracing::warn!("Invalid nonce header"); + return Err(AgentError::Unauthorized); + } + }, + None => { + tracing::warn!("Missing nonce header"); + return Err(AgentError::Unauthorized); + } + }; + + let log_entry: LogEntry = + match state.validate_access_and_get_data(auth_header.token(), nonce, &body) { + Some(entry) => entry, + None => { + tracing::warn!("Failed to decrypt or parse log entry"); + return Err(AgentError::BadRequest("Invalid log entry format".into())); + } + }; + + let metadata_str = log_entry + .metadata + .map(|m| m.to_string()) + .unwrap_or_default(); + + let correlation = log_entry.correlation_id.unwrap_or_default(); + + match log_entry.level { + LogLevel::Debug => { + tracing::debug!( + timestamp = %log_entry.timestamp, + context = %log_entry.context, + source = %log_entry.source, + metadata = %metadata_str, + correlation_id = %correlation, + "{}", + log_entry.message + ); + } + LogLevel::Info => { + tracing::info!( + timestamp = %log_entry.timestamp, + context = %log_entry.context, + source = %log_entry.source, + metadata = %metadata_str, + correlation_id = %correlation, + "{}", + log_entry.message + ); + } + LogLevel::Warn => { + tracing::warn!( + timestamp = %log_entry.timestamp, + context = %log_entry.context, + source = %log_entry.source, + metadata = %metadata_str, + correlation_id = %correlation, + "{}", + log_entry.message + ); + } + LogLevel::Error => { + tracing::error!( + timestamp = %log_entry.timestamp, + context = %log_entry.context, + source = %log_entry.source, + metadata = %metadata_str, + correlation_id = %correlation, + "{}", + log_entry.message + ); + } + } + + Ok(Json(json!({ + "status": "success", + "message": "Log entry processed" + }))) +} diff --git a/packages/hoppscotch-agent/src-tauri/src/dialog.rs b/packages/hoppscotch-agent/src-tauri/src/dialog.rs new file mode 100644 index 0000000..e4967b4 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/dialog.rs @@ -0,0 +1,58 @@ +use native_dialog::{MessageDialog, MessageType}; + +pub fn panic(msg: &str) { + const FATAL_ERROR: &str = "Fatal error"; + + MessageDialog::new() + .set_type(MessageType::Error) + .set_title(FATAL_ERROR) + .set_text(msg) + .show_alert() + .unwrap_or_default(); + + tracing::error!("{}: {}", FATAL_ERROR, msg); + + panic!("{}: {}", FATAL_ERROR, msg); +} + +pub fn info(msg: &str) { + tracing::info!("{}", msg); + + MessageDialog::new() + .set_type(MessageType::Info) + .set_title("Info") + .set_text(msg) + .show_alert() + .unwrap_or_default(); +} + +pub fn warn(msg: &str) { + tracing::warn!("{}", msg); + + MessageDialog::new() + .set_type(MessageType::Warning) + .set_title("Warning") + .set_text(msg) + .show_alert() + .unwrap_or_default(); +} + +pub fn error(msg: &str) { + tracing::error!("{}", msg); + + MessageDialog::new() + .set_type(MessageType::Error) + .set_title("Error") + .set_text(msg) + .show_alert() + .unwrap_or_default(); +} + +pub fn confirm(title: &str, msg: &str, icon: MessageType) -> bool { + MessageDialog::new() + .set_type(icon) + .set_title(title) + .set_text(msg) + .show_confirm() + .unwrap_or_default() +} diff --git a/packages/hoppscotch-agent/src-tauri/src/error.rs b/packages/hoppscotch-agent/src-tauri/src/error.rs new file mode 100644 index 0000000..b7ef452 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/error.rs @@ -0,0 +1,99 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AgentError { + #[error("FATAL: No `main` window found")] + NoMainWindow, + #[error("Tauri error: {0}")] + Tauri(#[from] tauri::Error), + #[error("Invalid Registration")] + InvalidRegistration, + #[error("Invalid Client Public Key")] + InvalidClientPublicKey, + #[error("Unauthorized")] + Unauthorized, + #[error("Request not found or already completed")] + RequestNotFound, + #[error("Internal server error")] + InternalServerError, + #[error("Invalid request: {0}")] + BadRequest(String), + #[error("Client certificate error")] + ClientCertError, + #[error("Root certificate error")] + RootCertError, + #[error("Invalid method")] + InvalidMethod, + #[error("Invalid URL")] + InvalidUrl, + #[error("Invalid headers")] + InvalidHeaders, + #[error("Request run error: {0}")] + RequestRunError(String), + #[error("Request cancelled")] + RequestCancelled, + #[error("Failed to clear registrations")] + RegistrationClearError, + #[error("Failed to insert registrations")] + RegistrationInsertError, + #[error("Failed to save registrations to store")] + RegistrationSaveError, + #[error("Serde error: {0}")] + Serde(#[from] serde_json::Error), + #[error("Store error: {0}")] + TauriPluginStore(#[from] tauri_plugin_store::Error), + #[error("Relay error: {0}")] + Relay(#[from] relay::error::RelayError), + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Log init error: {0}")] + LogInit(String), + #[error("Log init global error: {0}")] + LogInitGlobal(#[from] tracing::subscriber::SetGlobalDefaultError), +} + +impl From for AgentError { + fn from(err: tracing_appender::rolling::InitError) -> Self { + AgentError::LogInit(err.to_string()) + } +} + +impl IntoResponse for AgentError { + fn into_response(self) -> Response { + let (status, error_message) = match self { + AgentError::InvalidRegistration => (StatusCode::BAD_REQUEST, self.to_string()), + AgentError::InvalidClientPublicKey => (StatusCode::BAD_REQUEST, self.to_string()), + AgentError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()), + AgentError::RequestNotFound => (StatusCode::NOT_FOUND, self.to_string()), + AgentError::InternalServerError => { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()) + } + AgentError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), + AgentError::ClientCertError => (StatusCode::BAD_REQUEST, self.to_string()), + AgentError::RootCertError => (StatusCode::BAD_REQUEST, self.to_string()), + AgentError::InvalidMethod => (StatusCode::BAD_REQUEST, self.to_string()), + AgentError::InvalidUrl => (StatusCode::BAD_REQUEST, self.to_string()), + AgentError::InvalidHeaders => (StatusCode::BAD_REQUEST, self.to_string()), + AgentError::RequestRunError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), + AgentError::RequestCancelled => (StatusCode::BAD_REQUEST, self.to_string()), + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error".to_string(), + ), + }; + + let body = Json(json!({ + "error": error_message, + })); + + (status, body).into_response() + } +} + +pub type AgentResult = std::result::Result; diff --git a/packages/hoppscotch-agent/src-tauri/src/global.rs b/packages/hoppscotch-agent/src-tauri/src/global.rs new file mode 100644 index 0000000..002729d --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/global.rs @@ -0,0 +1,3 @@ +pub const AGENT_STORE: &str = "app_data.bin"; +pub const REGISTRATIONS: &str = "registrations"; +pub const NONCE: &str = "X-Hopp-Nonce"; diff --git a/packages/hoppscotch-agent/src-tauri/src/lib.rs b/packages/hoppscotch-agent/src-tauri/src/lib.rs new file mode 100644 index 0000000..145d39d --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/lib.rs @@ -0,0 +1,260 @@ +pub mod command; +pub mod controller; +pub mod dialog; +pub mod error; +pub mod global; +pub mod logger; +pub mod model; +pub mod route; +pub mod server; +pub mod state; +pub mod tray; +pub mod updater; +pub mod util; +pub mod webview; + +use std::sync::Arc; +use tauri::{AppHandle, Emitter, Listener, Manager, WebviewWindowBuilder}; +use tauri_plugin_updater::UpdaterExt; +use tokio_util::sync::CancellationToken; + +use error::{AgentError, AgentResult}; +use model::Payload; +use state::AppState; + +pub const HOPPSCOTCH_AGENT_IDENTIFIER: &str = "io.hoppscotch.agent"; + +#[tracing::instrument(skip(app_handle))] +fn create_main_window(app_handle: &AppHandle) -> AgentResult<()> { + tracing::info!("Creating main application window"); + + let main = &app_handle + .config() + .app + .windows + .first() + .ok_or(AgentError::NoMainWindow)?; + + tracing::debug!("Building webview window from config"); + let window = WebviewWindowBuilder::from_config(app_handle, main)?.build()?; + + window.hide()?; + + tracing::info!("Main window created successfully"); + Ok(()) +} + +#[tracing::instrument(skip(app_handle))] +pub fn show_main_window(app_handle: &AppHandle) -> AgentResult<()> { + tracing::debug!("Attempting to show main window"); + if let Some(window) = app_handle.get_webview_window("main") { + window.show()?; + window.set_focus()?; + tracing::info!("Main window shown and focused"); + } + Ok(()) +} + +#[tracing::instrument(skip(app_handle))] +pub fn hide_main_window(app_handle: &AppHandle) -> AgentResult<()> { + tracing::debug!("Attempting to hide main window"); + if let Some(window) = app_handle.get_webview_window("main") { + window.hide()?; + tracing::info!("Main window hidden"); + } + Ok(()) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tracing::info!("Initializing Hoppscotch Agent"); + + // The installer takes care of installing `WebView`, + // this check is only required for portable variant. + #[cfg(all(feature = "portable", windows))] + { + tracing::debug!("Checking WebView initialization for portable Windows variant"); + webview::init_webview(); + } + + let cancellation_token = CancellationToken::new(); + let server_cancellation_token = cancellation_token.clone(); + + tracing::debug!("Building Tauri application"); + let builder = tauri::Builder::default() + // NOTE: Currently, plugins run in the order they were added in to the builder, + // so `tauri_plugin_single_instance` needs to be registered first. + // See: https://github.com/tauri-apps/plugins-workspace/tree/v2/plugins/single-instance + .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { + tracing::info!( + app_name = %app.package_info().name, + "Single instance handler triggered" + ); + + if let Err(e) = app.emit("single-instance", Payload::new(args, cwd)) { + tracing::error!(error = %e, "Failed to emit single-instance event"); + } + + // Application is already running, bring it to foreground. + if let Err(e) = show_main_window(&app) { + tracing::error!(error = %e, "Failed to show window"); + } + })) + .plugin(tauri_plugin_store::Builder::new().build()) + .setup(move |app| { + tracing::info!("Setting up application"); + let app_handle = app.handle(); + + #[cfg(all(desktop, not(feature = "portable")))] + { + use tauri_plugin_autostart::MacosLauncher; + use tauri_plugin_autostart::ManagerExt; + + tracing::debug!("Configuring autostart for desktop variant"); + let _ = app.handle().plugin(tauri_plugin_autostart::init( + MacosLauncher::LaunchAgent, + None, + )); + + let autostart_manager = app.autolaunch(); + + tracing::info!( + enabled = autostart_manager.is_enabled().unwrap_or(false), + "Checking autostart status" + ); + + if !autostart_manager.is_enabled().unwrap_or(false) { + if let Err(e) = autostart_manager.enable() { + tracing::error!(error = %e, "Failed to enable autostart"); + } else { + tracing::info!("Autostart enabled successfully"); + } + } + }; + + #[cfg(desktop)] + { + tracing::debug!("Initializing desktop-specific features"); + let _ = app + .handle() + .plugin(tauri_plugin_updater::Builder::new().build()); + let _ = app.handle().plugin(tauri_plugin_dialog::init()); + + let updater = app.updater_builder().build().unwrap(); + let app_handle_ref = app_handle.clone(); + + tauri::async_runtime::spawn_blocking(|| { + tauri::async_runtime::block_on(async { + updater::check_and_install_updates(app_handle_ref, updater).await; + }) + }); + }; + + // Create and hide the main window during setup. + create_main_window(&app_handle)?; + + tracing::debug!("Initializing application state"); + let app_state = Arc::new(AppState::new(app_handle.clone())?); + app.manage(app_state.clone()); + + let server_cancellation_token = server_cancellation_token.clone(); + let server_app_handle = app_handle.clone(); + + tracing::debug!("Spawning server process"); + tauri::async_runtime::spawn(async move { + server::run_server(app_state, server_cancellation_token, server_app_handle).await; + }); + + #[cfg(all(desktop))] + { + tracing::debug!("Creating system tray"); + let handle = app.handle(); + tray::create_tray(handle)?; + } + + // Blocks the app from populating the macOS dock + #[cfg(target_os = "macos")] + { + tracing::debug!("Setting macOS activation policy"); + app_handle + .set_activation_policy(tauri::ActivationPolicy::Accessory) + .unwrap(); + }; + + let app_handle_ref = app_handle.clone(); + app_handle.listen("maximize-window", move |_| { + tracing::info!("Maximize window event triggered"); + if let Some(window) = app_handle_ref.get_webview_window("main") { + if let Err(e) = window.emit("show-otp-view", ()) { + tracing::error!("Failed to emit show-otp-view event: {}", e); + } + + if let Err(e) = show_main_window(&app_handle_ref) { + tracing::error!("Failed to maximize window: {}", e); + } + } + }); + + let app_handle_ref = app_handle.clone(); + app_handle.listen("registration-received", move |_| { + tracing::info!("Registration received event triggered"); + if let Err(e) = show_main_window(&app_handle_ref) { + tracing::error!(error = %e, "Failed to show window"); + } + }); + + tracing::info!("Application setup completed successfully"); + Ok(()) + }) + .manage(cancellation_token) + .on_window_event(|window, event| { + match &event { + tauri::WindowEvent::CloseRequested { api, .. } => { + tracing::info!("Window close requested"); + api.prevent_close(); + + if let Err(e) = window.hide() { + tracing::error!(error = %e, "Failed to hide window"); + } + + let app_state = window.state::>(); + let mut current_code = app_state.active_registration_code.blocking_write(); + if current_code.is_some() { + tracing::debug!("Clearing active registration code"); + *current_code = None; + } + + if let Err(e) = window.emit("window-hidden", ()) { + tracing::error!(error = %e, "Failed to emit window-hidden event"); + } + } + _ => { + tracing::debug!(event = ?event, "Window event received"); + } + }; + }) + .invoke_handler(tauri::generate_handler![ + command::get_otp, + command::list_registrations + ]); + + tracing::info!("Building Tauri application with context"); + let app = builder + .build(tauri::generate_context!()) + .expect("error while building tauri application"); + + tracing::info!("Running application"); + app.run(|app_handle, event| match event { + tauri::RunEvent::ExitRequested { api, code, .. } => { + if code.is_none() || matches!(code, Some(0)) { + tracing::info!("Exit requested, preventing immediate exit"); + api.prevent_exit(); + } else if code.is_some() { + tracing::info!("Exit with non-zero code requested, initiating shutdown"); + let state = app_handle.state::(); + state.cancel(); + } + } + _ => {} + }); +} diff --git a/packages/hoppscotch-agent/src-tauri/src/logger.rs b/packages/hoppscotch-agent/src-tauri/src/logger.rs new file mode 100644 index 0000000..610b674 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/logger.rs @@ -0,0 +1,53 @@ +use std::path::PathBuf; + +use file_rotate::{compression::Compression, suffix::AppendCount, ContentLimit, FileRotate}; +use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +use crate::HOPPSCOTCH_AGENT_IDENTIFIER; + +pub struct LogGuard(pub tracing_appender::non_blocking::WorkerGuard); + +pub fn setup(log_dir: &PathBuf) -> Result> { + std::fs::create_dir_all(log_dir)?; + + let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| "debug".into()); + + let log_file_path = log_dir.join(&format!("{}.log", HOPPSCOTCH_AGENT_IDENTIFIER)); + tracing::info!(log_file_path =? &log_file_path); + + let file = FileRotate::new( + &log_file_path, + AppendCount::new(5), + ContentLimit::Bytes(10 * 1024 * 1024), + Compression::None, + None, + ); + + let (non_blocking, guard) = tracing_appender::non_blocking(file); + + let console_layer = fmt::layer() + .with_writer(std::io::stdout) + .with_thread_ids(true) + .with_thread_names(true) + .with_ansi(!cfg!(target_os = "windows")); + + let file_layer = fmt::layer() + .with_writer(non_blocking) + .with_ansi(false) + .with_thread_ids(true) + .with_thread_names(true) + .with_timer(tracing_subscriber::fmt::time::UtcTime::rfc_3339()); + + tracing_subscriber::registry() + .with(env_filter) + .with(file_layer) + .with(console_layer) + .init(); + + tracing::info!( + log_file = %log_file_path.display(), + "Logging initialized with rotating file" + ); + + Ok(LogGuard(guard)) +} diff --git a/packages/hoppscotch-agent/src-tauri/src/main.rs b/packages/hoppscotch-agent/src-tauri/src/main.rs new file mode 100644 index 0000000..146be72 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/main.rs @@ -0,0 +1,48 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use hoppscotch_agent_lib::{ + logger::{self, LogGuard}, + HOPPSCOTCH_AGENT_IDENTIFIER, +}; + +fn main() { + // Follows how `tauri` does this and exactly matches desktop's approach + // see: https://github.com/tauri-apps/tauri/blob/dev/crates/tauri/src/path/desktop.rs + let path = { + #[cfg(target_os = "macos")] + let path = + dirs::home_dir().map(|dir| dir.join("Library/Logs").join(HOPPSCOTCH_AGENT_IDENTIFIER)); + + #[cfg(not(target_os = "macos"))] + let path = + dirs::data_local_dir().map(|dir| dir.join(HOPPSCOTCH_AGENT_IDENTIFIER).join("logs")); + + path + }; + + let Some(log_file_path) = path else { + eprint!("Failed to setup logging!"); + + println!("Starting Hoppscotch Agent..."); + + return hoppscotch_agent_lib::run(); + }; + + let Ok(LogGuard(guard)) = logger::setup(&log_file_path) else { + eprint!("Failed to setup logging!"); + + println!("Starting Hoppscotch Agent..."); + + return hoppscotch_agent_lib::run(); + }; + + // This keeps the guard alive, this is scoped to `main` + // so it can only drop when the entire app exits, + // so safe to have it like this. + let _guard = guard; + + tracing::info!("Starting Hoppscotch Agent..."); + + hoppscotch_agent_lib::run() +} diff --git a/packages/hoppscotch-agent/src-tauri/src/model.rs b/packages/hoppscotch-agent/src-tauri/src/model.rs new file mode 100644 index 0000000..718422e --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/model.rs @@ -0,0 +1,110 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +/// Describes one registered app instance +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Registration { + pub registered_at: DateTime, + + /// base16 (lowercase) encoded shared secret that the client + /// and agent established during registration that is used + /// to encrypt traffic between them + pub shared_secret_b16: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MaskedRegistration { + pub registered_at: DateTime, + pub auth_key_hash: String, +} + +impl From<(&String, &Registration)> for MaskedRegistration { + fn from((key, registration): (&String, &Registration)) -> Self { + let hash = Sha256::digest(key.as_bytes()); + let short_hash = base16::encode_lower(&hash[..3]); + + Self { + registered_at: registration.registered_at, + auth_key_hash: short_hash, + } + } +} + +#[derive(Debug, Serialize)] +pub struct RegistrationsList { + pub registrations: Vec, + pub total: usize, +} + +/// Single instance payload. +#[derive(Clone, Serialize)] +pub struct Payload { + args: Vec, + cwd: String, +} + +impl Payload { + pub fn new(args: Vec, cwd: String) -> Self { + Self { args, cwd } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct HandshakeResponse { + #[allow(non_snake_case)] + pub __hoppscotch__agent__: bool, + + pub status: String, + pub agent_version: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ConfirmedRegistrationRequest { + pub registration: String, + + /// base16 (lowercase) encoded public key shared by the client + /// to the agent so that the agent can establish a shared secret + /// which will be used to encrypt traffic between agent + /// and client after registration + pub client_public_key_b16: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AuthKeyResponse { + pub auth_key: String, + pub created_at: DateTime, + + /// base16 (lowercase) encoded public key shared by the + /// agent so that the client can establish a shared secret + /// which will be used to encrypt traffic between agent + /// and client after registration + pub agent_public_key_b16: String, +} + +/// A logger guard, managed by tauri runtime to make sure +/// logger doesn't get cleaned up or dropped during app's run time. +pub struct LogGuard(pub tracing_appender::non_blocking::WorkerGuard); + +#[derive(Debug, Deserialize)] +pub struct LogEntry { + pub timestamp: String, + pub level: LogLevel, + pub context: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub correlation_id: Option, +} + +#[derive(Debug, Deserialize, Clone, Copy)] +#[serde(rename_all = "UPPERCASE")] +pub enum LogLevel { + Debug, + Info, + Warn, + Error, +} diff --git a/packages/hoppscotch-agent/src-tauri/src/route.rs b/packages/hoppscotch-agent/src-tauri/src/route.rs new file mode 100644 index 0000000..e636256 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/route.rs @@ -0,0 +1,34 @@ +use axum::{ + routing::{delete, get, post}, + Router, +}; +use std::sync::Arc; +use tauri::AppHandle; + +use crate::{controller, state::AppState}; + +pub fn route(state: Arc, app_handle: AppHandle) -> Router { + Router::new() + .route("/handshake", get(controller::handshake)) + .route( + "/receive-registration", + post(controller::receive_registration), + ) + .route( + "/verify-registration", + post(controller::verify_registration), + ) + .route( + "/registered-handshake", + get(controller::registered_handshake), + ) + .route("/registration", get(controller::registration)) + .route( + "/registrations/:auth_key", + delete(controller::delete_registration), + ) + .route("/execute", post(controller::execute)) + .route("/cancel/:req_id", post(controller::cancel)) + .route("/log-sink", post(controller::log_sink)) + .with_state((state, app_handle)) +} diff --git a/packages/hoppscotch-agent/src-tauri/src/server.rs b/packages/hoppscotch-agent/src-tauri/src/server.rs new file mode 100644 index 0000000..3381847 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/server.rs @@ -0,0 +1,50 @@ +use axum::Router; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; +use tower_http::cors::CorsLayer; + +use crate::route; +use crate::state::AppState; + +#[tracing::instrument(skip(state, cancellation_token, app_handle))] +pub async fn run_server( + state: Arc, + cancellation_token: CancellationToken, + app_handle: tauri::AppHandle, +) { + tracing::info!("Initializing server"); + let cors = CorsLayer::permissive(); + + let app = Router::new() + .merge(route::route(state, app_handle)) + .layer(cors); + + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 9119)); + tracing::info!(address = %addr, "Starting server"); + + match tokio::net::TcpListener::bind(&addr).await { + Ok(listener) => { + tracing::info!(address = %addr, "Server bound successfully"); + + if let Err(e) = axum::serve(listener, app.into_make_service()) + .with_graceful_shutdown(async move { + cancellation_token.cancelled().await; + tracing::info!("Graceful shutdown initiated"); + }) + .await + { + tracing::error!(error = %e, "Server error occurred"); + return; + } + + tracing::info!("Server shut down successfully"); + } + Err(e) => { + tracing::error!( + error = %e, + address = %addr, + "Failed to bind server to address" + ); + } + } +} diff --git a/packages/hoppscotch-agent/src-tauri/src/state.rs b/packages/hoppscotch-agent/src-tauri/src/state.rs new file mode 100644 index 0000000..4bd211f --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/state.rs @@ -0,0 +1,277 @@ +use aes_gcm::{aead::Aead, Aes256Gcm, KeyInit}; +use axum::body::Bytes; +use dashmap::DashMap; +use serde::de::DeserializeOwned; +use tauri_plugin_store::StoreExt; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; + +use crate::{ + error::{AgentError, AgentResult}, + global::{AGENT_STORE, REGISTRATIONS}, + model::Registration, +}; + +#[derive(Debug, Default)] +pub struct AppState { + /// The active registration code that is being registered. + pub active_registration_code: RwLock>, + + /// Cancellation Tokens for the running requests + pub cancellation_tokens: DashMap, + + /// Registrations against the agent, the key is the auth + /// token associated to the registration + registrations: DashMap, +} + +impl AppState { + #[tracing::instrument(skip(app_handle))] + pub fn new(app_handle: tauri::AppHandle) -> AgentResult { + tracing::info!("Initializing application state"); + let store = match app_handle.store(AGENT_STORE) { + Ok(store) => store, + Err(e) => { + tracing::error!("Failed to access app store: {}", e); + return Err(e.into()); + } + }; + + // Try loading and parsing registrations from the store, if that failed, + // load the default list + let registrations = store + .get(REGISTRATIONS) + .and_then(|val| serde_json::from_value(val.clone()).ok()) + .unwrap_or_else(|| { + tracing::debug!("No existing registrations found, initializing empty map"); + DashMap::new() + }); + + // Try to save the latest registrations list + let _ = store.set(REGISTRATIONS, serde_json::to_value(®istrations)?); + + if let Err(e) = store.save() { + tracing::error!("Failed to persist store changes: {}", e); + return Err(e.into()); + } + + tracing::info!("Application state initialized successfully"); + + Ok(Self { + active_registration_code: RwLock::new(None), + cancellation_tokens: DashMap::new(), + registrations, + }) + } + + /// Gets you a readonly reference to the registrations list + /// NOTE: Although DashMap API allows you to update the list from an immutable + /// reference, you shouldn't do it for registrations as `update_registrations` + /// performs save operation that needs to be done and should be used instead + #[tracing::instrument] + pub fn get_registrations(&self) -> &DashMap { + tracing::debug!("Retrieving registrations list"); + &self.registrations + } + + /// Provides you an opportunity to update the registrations list + /// and also persists the data to the disk. + /// This function bypasses `store.reload()` to avoid issues from stale or inconsistent + /// data on disk. By relying solely on the in-memory `self.registrations`, + /// we make sure that updates are applied based on the most recent changes in memory. + #[tracing::instrument(skip(self, app_handle, update_func))] + pub fn update_registrations( + &self, + app_handle: tauri::AppHandle, + update_func: impl FnOnce(&DashMap), + ) -> Result<(), AgentError> { + tracing::info!("Updating registrations"); + update_func(&self.registrations); + + let store = match app_handle.store(AGENT_STORE) { + Ok(store) => store, + Err(e) => { + tracing::error!("Failed to access app store: {}", e); + return Err(e.into()); + } + }; + + if store.has(REGISTRATIONS) { + tracing::debug!("Clearing existing registrations from store"); + // We've confirmed `REGISTRATIONS` exists in the store + if !store.delete(REGISTRATIONS) { + tracing::error!("Failed to clear existing registrations"); + return Err(AgentError::RegistrationClearError); + } + } else { + tracing::debug!("`REGISTRATIONS` key not found in store; continuing with update."); + } + + // Since we've established `self.registrations` as the source of truth, + // we avoid reloading the store from disk and instead choose to override it. + match serde_json::to_value(self.registrations.clone()) { + Ok(value) => { + let _ = store.set(REGISTRATIONS, value); + } + Err(e) => { + tracing::error!("Failed to serialize registrations: {}", e); + return Err(e.into()); + } + } + + // Explicitly save the changes + if let Err(e) = store.save() { + tracing::error!("Failed to persist store changes: {}", e); + return Err(e.into()); + } + + tracing::info!("Registrations updated successfully"); + Ok(()) + } + + /// Clear all the registrations + #[tracing::instrument(skip(self, app_handle))] + pub fn clear_registrations(&self, app_handle: tauri::AppHandle) -> Result<(), AgentError> { + tracing::info!("Clearing all registrations"); + self.update_registrations(app_handle, |registrations| registrations.clear())?; + tracing::info!("All registrations cleared successfully"); + Ok(()) + } + + #[tracing::instrument(skip(self))] + pub async fn clear_active_registration(&self) { + tracing::debug!("Clearing active registration code"); + let mut active_registration_code = self.active_registration_code.write().await; + *active_registration_code = None; + tracing::debug!("Active registration code cleared"); + } + + #[tracing::instrument(skip(self))] + pub async fn validate_registration(&self, registration: &str) -> bool { + tracing::debug!("Validating registration code"); + let is_valid = self.active_registration_code.read().await.as_deref() == Some(registration); + if is_valid { + tracing::info!("Registration code validated successfully"); + } else { + tracing::warn!("Invalid registration code provided"); + } + is_valid + } + + #[tracing::instrument(skip(self))] + pub fn remove_cancellation_token(&self, req_id: usize) -> Option<(usize, CancellationToken)> { + tracing::debug!(req_id, "Removing cancellation token"); + let result = self.cancellation_tokens.remove(&req_id); + if result.is_some() { + tracing::info!(req_id, "Cancellation token removed successfully"); + } else { + tracing::debug!(req_id, "No cancellation token found to remove"); + } + result + } + + #[tracing::instrument(skip(self))] + pub fn add_cancellation_token(&self, req_id: usize, cancellation_token: CancellationToken) { + tracing::debug!(req_id, "Adding new cancellation token"); + self.cancellation_tokens.insert(req_id, cancellation_token); + tracing::debug!(req_id, "Cancellation token added successfully"); + } + + #[tracing::instrument(skip(self))] + pub fn validate_access(&self, auth_key: &str) -> bool { + tracing::debug!(auth_key, "Validating access"); + let is_valid = self.registrations.get(auth_key).is_some(); + if is_valid { + tracing::info!(auth_key, "Access validated successfully"); + } else { + tracing::warn!(auth_key, "Invalid access attempt"); + } + is_valid + } + + #[tracing::instrument(skip(self, data))] + pub fn validate_access_and_get_data( + &self, + auth_key: &str, + nonce: &str, + data: &Bytes, + ) -> Option + where + T: DeserializeOwned, + { + tracing::debug!( + auth_key, + nonce_len = nonce.len(), + "Validating access and decrypting data" + ); + + let registration = match self.registrations.get(auth_key) { + Some(reg) => reg, + None => { + tracing::warn!(auth_key, "Registration not found"); + return None; + } + }; + + let key: [u8; 32] = match base16::decode(®istration.shared_secret_b16).ok()?[0..32] + .try_into() + .ok() + { + Some(k) => k, + None => { + tracing::error!(auth_key, "Failed to decode shared secret"); + return None; + } + }; + + let nonce: [u8; 12] = match base16::decode(nonce).ok()?[0..12].try_into().ok() { + Some(n) => n, + None => { + tracing::error!(auth_key, "Failed to decode nonce"); + return None; + } + }; + + let cipher = Aes256Gcm::new(&key.into()); + let data = data.iter().cloned().collect::>(); + + let plain_data = match cipher.decrypt(&nonce.into(), data.as_slice()) { + Ok(d) => d, + Err(e) => { + tracing::error!(auth_key, error = ?e, "Decryption failed"); + return None; + } + }; + + match serde_json::from_reader(plain_data.as_slice()) { + Ok(result) => { + tracing::info!(auth_key, "Data successfully decrypted and parsed"); + Some(result) + } + Err(e) => { + tracing::error!(auth_key, error = ?e, "Failed to parse decrypted data"); + None + } + } + } + + #[tracing::instrument(skip(self))] + pub fn get_registration(&self, auth_key: &str) -> Option { + tracing::debug!(auth_key, "Retrieving registration tracing::info"); + let result = self + .registrations + .get(auth_key) + .map(|reference| reference.value().clone()); + + if result.is_some() { + tracing::info!( + auth_key, + "Registration tracing::info retrieved successfully" + ); + } else { + tracing::debug!(auth_key, "No registration tracing::info found"); + } + + result + } +} diff --git a/packages/hoppscotch-agent/src-tauri/src/tray.rs b/packages/hoppscotch-agent/src-tauri/src/tray.rs new file mode 100644 index 0000000..5ae7900 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/tray.rs @@ -0,0 +1,126 @@ +use crate::{show_main_window, state::AppState}; +use lazy_static::lazy_static; +use std::sync::Arc; +use tauri::{ + image::Image, + menu::{MenuBuilder, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + AppHandle, Emitter, Manager, +}; + +const TRAY_ICON_DATA: &'static [u8] = include_bytes!("../icons/tray_icon.png"); + +lazy_static! { + static ref TRAY_ICON: Image<'static> = Image::from_bytes(TRAY_ICON_DATA).unwrap(); +} + +pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { + let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let clear_registrations = MenuItem::with_id( + app, + "clear_registrations", + "Clear Registrations", + true, + None::<&str>, + )?; + let maximize_window = MenuItem::with_id( + app, + "maximize_window", + "Maximize Window", + true, + None::<&str>, + )?; + let show_registrations = MenuItem::with_id( + app, + "show_registrations", + "Show Registrations", + true, + None::<&str>, + )?; + + let pkg_info = app.package_info(); + let app_name = pkg_info.name.clone(); + let app_version = pkg_info.version.clone(); + + let app_name_item = MenuItem::with_id(app, "app_name", app_name, false, None::<&str>)?; + let app_version_item = MenuItem::with_id( + app, + "app_version", + format!("Version: {}", app_version), + false, + None::<&str>, + )?; + + let menu = MenuBuilder::new(app) + .item(&app_name_item) + .item(&app_version_item) + .separator() + .item(&maximize_window) + .separator() + .item(&clear_registrations) + .item(&show_registrations) + .separator() + .separator() + .item(&quit_i) + .build()?; + + let _ = TrayIconBuilder::with_id("hopp-tray") + .tooltip("Hoppscotch Agent") + .icon(if cfg!(target_os = "macos") { + TRAY_ICON.clone() + } else { + app.default_window_icon().unwrap().clone() + }) + .icon_as_template(cfg!(target_os = "macos")) + .menu(&menu) + .show_menu_on_left_click(true) + .on_menu_event(move |app, event| match event.id.as_ref() { + "quit" => { + tracing::info!("Exiting the agent..."); + // Exit with a specific code to allow actual exit. + app.exit(1); + } + "clear_registrations" => { + let app_state = app.state::>(); + + app_state + .clear_registrations(app.clone()) + .expect("Invariant violation: Failed to clear registrations"); + } + "show_registrations" => { + app.emit("show-registrations", ()).unwrap_or_else(|e| { + tracing::error!("Failed to emit show-registrations event: {}", e); + }); + if let Err(e) = show_main_window(&app) { + tracing::error!("Failed to show window: {}", e); + } + } + "maximize_window" => { + app.emit("maximize-window", ()).unwrap_or_else(|e| { + tracing::error!("Failed to emit maximize-window event: {}", e); + }); + if let Err(e) = show_main_window(&app) { + tracing::error!("Failed to maximize window: {}", e); + } + } + _ => { + tracing::warn!("Unhandled menu event: {:?}", event.id); + } + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + if let Err(e) = show_main_window(&app) { + tracing::error!("Failed to show window from tray: {}", e); + } + } + }) + .build(app); + + Ok(()) +} diff --git a/packages/hoppscotch-agent/src-tauri/src/updater.rs b/packages/hoppscotch-agent/src-tauri/src/updater.rs new file mode 100644 index 0000000..48c6d2a --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/updater.rs @@ -0,0 +1,67 @@ +use tauri::Manager; +use tauri_plugin_dialog::DialogExt; +use tauri_plugin_dialog::MessageDialogButtons; +use tauri_plugin_dialog::MessageDialogKind; + +#[cfg(feature = "portable")] +use {crate::dialog, crate::util, native_dialog::MessageType}; + +pub async fn check_and_install_updates( + app: tauri::AppHandle, + updater: tauri_plugin_updater::Updater, +) { + let update = updater.check().await; + + if let Ok(Some(update)) = update { + #[cfg(not(feature = "portable"))] + { + let do_update = app + .dialog() + .message(format!( + "Update to {} is available!{}", + update.version, + update + .body + .clone() + .map(|body| format!("\n\nRelease Notes: {}", body)) + .unwrap_or("".into()) + )) + .title("Hoppscotch Agent Update Available") + .kind(MessageDialogKind::Info) + .buttons(MessageDialogButtons::OkCancelCustom( + "Update".to_string(), + "Cancel".to_string(), + )) + .blocking_show(); + + if do_update { + let _ = update.download_and_install(|_, _| {}, || {}).await; + + tauri::process::restart(&app.env()); + } + } + #[cfg(feature = "portable")] + { + let download_url = "https://hoppscotch.com/download"; + let message = format!( + "An update (version {}) is available for the Hoppscotch Agent.\n\nPlease download the latest portable version from our website.", + update.version + ); + + dialog::info(&message); + + if dialog::confirm( + "Open Download Page", + "Would you like to open the download page in your browser?", + MessageType::Info, + ) { + if let None = util::open_link(download_url) { + dialog::error(&format!( + "Failed to open download page. Please visit {}", + download_url + )); + } + } + } + } +} diff --git a/packages/hoppscotch-agent/src-tauri/src/util.rs b/packages/hoppscotch-agent/src-tauri/src/util.rs new file mode 100644 index 0000000..56b3fb0 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/util.rs @@ -0,0 +1,94 @@ +use std::process::{Command, Stdio}; + +use aes_gcm::{aead::Aead, AeadCore, Aes256Gcm, KeyInit}; +use axum::{ + body::Body, + response::{IntoResponse, Response}, +}; +use rand::rngs::OsRng; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::global::NONCE; + +pub fn generate_auth_key_hash(auth_key: &str) -> String { + let hash = Sha256::digest(auth_key.as_bytes()); + base16::encode_lower(&hash[..3]) +} + +pub fn open_link(link: &str) -> Option<()> { + let null = Stdio::null(); + + #[cfg(target_os = "windows")] + { + Command::new("rundll32") + .args(["url.dll,FileProtocolHandler", link]) + .stdout(null) + .spawn() + .ok() + .map(|_| ()) + } + + #[cfg(target_os = "macos")] + { + Command::new("open") + .arg(link) + .stdout(null) + .spawn() + .ok() + .map(|_| ()) + } + + #[cfg(target_os = "linux")] + { + Command::new("xdg-open") + .arg(link) + .stdout(null) + .spawn() + .ok() + .map(|_| ()) + } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + None + } +} + +#[derive(Debug)] +pub struct EncryptedJson { + pub key_b16: String, + pub data: T, +} + +impl IntoResponse for EncryptedJson +where + T: Serialize, +{ + fn into_response(self) -> Response { + let serialized_response = serde_json::to_vec(&self.data) + .expect("Failed serializing response to vec for encryption"); + + let key: [u8; 32] = base16::decode(&self.key_b16).unwrap()[0..32] + .try_into() + .unwrap(); + + let cipher = Aes256Gcm::new(&key.into()); + + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + + let nonce_b16 = base16::encode_lower(&nonce); + + let encrypted_response = cipher + .encrypt(&nonce, serialized_response.as_slice()) + .expect("Failed encrypting response"); + + let mut response = Response::new(Body::from(encrypted_response)); + let response_headers = response.headers_mut(); + + response_headers.insert("Content-Type", "application/octet-stream".parse().unwrap()); + response_headers.insert(NONCE, nonce_b16.parse().unwrap()); + + response + } +} diff --git a/packages/hoppscotch-agent/src-tauri/src/webview/error.rs b/packages/hoppscotch-agent/src-tauri/src/webview/error.rs new file mode 100644 index 0000000..7bfeff5 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/webview/error.rs @@ -0,0 +1,15 @@ +use std::io; + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum WebViewError { + #[error("Failed to open URL: {0}")] + UrlOpen(#[from] io::Error), + #[error("Failed to download WebView2 installer: {0}")] + Download(String), + #[error("WebView2 installation failed: {0}")] + Installation(String), + #[error("Failed during request: {0}")] + Request(#[from] tauri_plugin_http::reqwest::Error), +} diff --git a/packages/hoppscotch-agent/src-tauri/src/webview/mod.rs b/packages/hoppscotch-agent/src-tauri/src/webview/mod.rs new file mode 100644 index 0000000..b303b05 --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/src/webview/mod.rs @@ -0,0 +1,212 @@ +/// The WebView2 Runtime is a critical dependency for Tauri applications on Windows. +/// We need to check for its presence, see [Source: GitHub Issue #59 - Portable windows build](https://github.com/tauri-apps/tauri-action/issues/59#issuecomment-827142638) +/// +/// > "Tauri requires an installer if you define app resources, external binaries or running on environments that do not have Webview2 runtime installed. So I don't think it's a good idea to have a "portable" option since a Tauri binary itself isn't 100% portable." +/// +/// The approach for checking WebView2 installation is based on Microsoft's official documentation, which states: +/// +/// > ###### Detect if a WebView2 Runtime is already installed +/// > +/// > To verify that a WebView2 Runtime is installed, use one of the following approaches: +/// > +/// > * Approach 1: Inspect the `pv (REG_SZ)` regkey for the WebView2 Runtime at both of the following registry locations. The `HKEY_LOCAL_MACHINE` regkey is used for _per-machine_ install. The `HKEY_CURRENT_USER` regkey is used for _per-user_ install. +/// > +/// > For WebView2 applications, at least one of these regkeys must be present and defined with a version greater than 0.0.0.0. If neither regkey exists, or if only one of these regkeys exists but its value is `null`, an empty string, or 0.0.0.0, this means that the WebView2 Runtime isn't installed on the client. Inspect these regkeys to detect whether the WebView2 Runtime is installed, and to get the version of the WebView2 Runtime. Find `pv (REG_SZ)` at the following two locations. +/// > +/// > The two registry locations to inspect on 64-bit Windows: +/// > +/// > ``` +/// > HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5} +/// > +/// > HKEY_CURRENT_USER\Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5} +/// > ``` +/// > +/// > The two registry locations to inspect on 32-bit Windows: +/// > +/// > ``` +/// > HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5} +/// > +/// > HKEY_CURRENT_USER\Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5} +/// > ``` +/// > +/// > * Approach 2: Run [GetAvailableCoreWebView2BrowserVersionString](/microsoft-edge/webview2/reference/win32/webview2-idl#getavailablecorewebview2browserversionstring) and evaluate whether the `versionInfo` is `nullptr`. `nullptr` indicates that the WebView2 Runtime isn't installed. This API returns version information for the WebView2 Runtime or for any installed preview channels of Microsoft Edge (Beta, Dev, or Canary). +/// +/// See: https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution?tabs=dotnetcsharp#detect-if-a-webview2-runtime-is-already-installed +/// +/// Our implementation uses Approach 1, checking both the 32-bit (WOW6432Node) and 64-bit registry locations +/// to make sure we have critical dependency compatibility with different system architectures. +pub mod error; + +use std::{io, ops::Not}; + +use native_dialog::MessageType; + +use crate::{dialog, util}; +use error::WebViewError; + +#[cfg(windows)] +use { + std::io::Cursor, + std::process::Command, + tauri_plugin_http::reqwest, + tempfile::TempDir, + winreg::{ + enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}, + RegKey, + }, +}; + +const TAURI_WEBVIEW_REF: &str = "https://v2.tauri.app/references/webview-versions/"; +const WINDOWS_WEBVIEW_REF: &str = + "https://developer.microsoft.com/microsoft-edge/webview2/#download-section"; + +fn is_available() -> bool { + #[cfg(windows)] + { + const KEY_WOW64: &str = r"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; + const KEY: &str = + r"SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; + + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + + [ + hklm.open_subkey(KEY_WOW64), + hkcu.open_subkey(KEY_WOW64), + hklm.open_subkey(KEY), + hkcu.open_subkey(KEY), + ] + .into_iter() + .any(|result| result.is_ok()) + } + + #[cfg(not(windows))] + { + true + } +} + +fn open_install_website() -> Result<(), WebViewError> { + let url = if cfg!(windows) { + WINDOWS_WEBVIEW_REF + } else { + TAURI_WEBVIEW_REF + }; + + util::open_link(url).map(|_| ()).ok_or_else(|| { + WebViewError::UrlOpen(io::Error::new( + io::ErrorKind::Other, + "Failed to open browser to WebView download section", + )) + }) +} + +#[cfg(windows)] +async fn install() -> Result<(), WebViewError> { + const WEBVIEW2_BOOTSTRAPPER_URL: &str = "https://go.microsoft.com/fwlink/p/?LinkId=2124703"; + const DEFAULT_FILENAME: &str = "MicrosoftEdgeWebview2Setup.exe"; + + let client = reqwest::Client::builder() + .user_agent("Hoppscotch Agent") + .gzip(true) + .build()?; + + let response = client.get(WEBVIEW2_BOOTSTRAPPER_URL).send().await?; + + if !response.status().is_success() { + return Err(WebViewError::Download(format!( + "Failed to download WebView2 bootstrapper. Status: {}", + response.status() + ))); + } + + let filename = + get_filename_from_response(&response).unwrap_or_else(|| DEFAULT_FILENAME.to_owned()); + + let tmp_dir = TempDir::with_prefix("WebView-setup-")?; + let installer_path = tmp_dir.path().join(filename); + + let content = response.bytes().await?; + { + let mut file = std::fs::File::create(&installer_path)?; + io::copy(&mut Cursor::new(content), &mut file)?; + } + + let status = Command::new(&installer_path).args(["/install"]).status()?; + + if !status.success() { + return Err(WebViewError::Installation(format!( + "Installer exited with code `{}`.", + status.code().unwrap_or(-1) + ))); + } + + Ok(()) +} + +#[cfg(windows)] +fn get_filename_from_response(response: &reqwest::Response) -> Option { + response + .headers() + .get("content-disposition") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split("filename=").last()) + .map(|name| name.trim().replace('\"', "")) + .or_else(|| { + response + .url() + .path_segments() + .and_then(|segments| segments.last()) + .map(|name| name.to_string()) + }) + .filter(|name| !name.is_empty()) +} + +#[cfg(not(windows))] +async fn install() -> Result<(), WebViewError> { + Err(WebViewError::Installation( + "Unable to auto-install WebView. Please refer to https://v2.tauri.app/references/webview-versions/".to_string(), + )) +} + +pub fn init_webview() { + if is_available() { + return; + } + + if dialog::confirm( + "WebView Error", + "WebView is required for this application to work.\n\n\ + Do you want to install it?", + MessageType::Error, + ) + .not() + { + tracing::warn!("Declined to setup WebView."); + + std::process::exit(1); + } + + if let Err(e) = tauri::async_runtime::block_on(install()) { + dialog::error(&format!( + "Failed to install WebView: {}\n\n\ + Please install it manually from webpage that should open when you click 'Ok'.\n\n\ + If that doesn't work, please visit Microsoft Edge Webview2 download section.", + e + )); + + if let Err(e) = open_install_website() { + tracing::warn!("Failed to launch WebView website:\n{}", e); + } + + std::process::exit(1); + } + + if is_available().not() { + dialog::panic( + "Unable to setup WebView:\n\n\ + Please install it manually and relaunch the application.\n\ + https://developer.microsoft.com/microsoft-edge/webview2/#download-section", + ); + } +} diff --git a/packages/hoppscotch-agent/src-tauri/tauri.conf.json b/packages/hoppscotch-agent/src-tauri/tauri.conf.json new file mode 100644 index 0000000..d23437e --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/tauri.conf.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://schema.tauri.app/config/2.0.0-rc", + "productName": "Hoppscotch Agent", + "version": "0.1.17", + "identifier": "io.hoppscotch.agent", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://127.0.0.1:1420", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "Hoppscotch Agent", + "width": 600, + "height": 400, + "center": true, + "resizable": false, + "maximizable": false, + "minimizable": false, + "focus": true, + "alwaysOnTop": true, + "create": false + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "createUpdaterArtifacts": true + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRBQzgxQjc3MzJCMjZENEMKUldSTWJiSXlkeHZJU3EvQW1abFVlREVhRDNlM0ZhOVJYaHN4M0FpbXZhcUFzSVdVbG84RWhPa1AK", + "endpoints": ["https://releases.hoppscotch.com/hoppscotch-agent.json"] + } + } +} diff --git a/packages/hoppscotch-agent/src-tauri/tauri.portable.conf.json b/packages/hoppscotch-agent/src-tauri/tauri.portable.conf.json new file mode 100644 index 0000000..657477e --- /dev/null +++ b/packages/hoppscotch-agent/src-tauri/tauri.portable.conf.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://schema.tauri.app/config/2.0.0-rc", + "productName": "Hoppscotch Agent Portable", + "version": "0.1.17", + "identifier": "io.hoppscotch.agent", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "Hoppscotch Agent Portable", + "width": 600, + "height": 400, + "center": true, + "resizable": false, + "maximizable": false, + "minimizable": false, + "focus": true, + "alwaysOnTop": true, + "create": false + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": false, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "createUpdaterArtifacts": false + }, + "plugins": { + "updater": { + "active": false + } + } +} diff --git a/packages/hoppscotch-agent/src/App.vue b/packages/hoppscotch-agent/src/App.vue new file mode 100644 index 0000000..ee37057 --- /dev/null +++ b/packages/hoppscotch-agent/src/App.vue @@ -0,0 +1,228 @@ + + + diff --git a/packages/hoppscotch-agent/src/index.css b/packages/hoppscotch-agent/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/packages/hoppscotch-agent/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/packages/hoppscotch-agent/src/main.ts b/packages/hoppscotch-agent/src/main.ts new file mode 100644 index 0000000..a1a836c --- /dev/null +++ b/packages/hoppscotch-agent/src/main.ts @@ -0,0 +1,11 @@ +import { createApp } from "vue" +import App from "./App.vue" +import "./index.css" + +import { plugin as HoppUI } from "@hoppscotch/ui" + +import "@hoppscotch/ui/themes.css" + +import "@hoppscotch/ui/style.css" + +createApp(App).use(HoppUI).mount("#app") diff --git a/packages/hoppscotch-agent/src/pages/otp.vue b/packages/hoppscotch-agent/src/pages/otp.vue new file mode 100644 index 0000000..dfee5bb --- /dev/null +++ b/packages/hoppscotch-agent/src/pages/otp.vue @@ -0,0 +1,92 @@ + + + diff --git a/packages/hoppscotch-agent/src/pages/registrations.vue b/packages/hoppscotch-agent/src/pages/registrations.vue new file mode 100644 index 0000000..37eea79 --- /dev/null +++ b/packages/hoppscotch-agent/src/pages/registrations.vue @@ -0,0 +1,68 @@ + + + diff --git a/packages/hoppscotch-agent/src/vite-env.d.ts b/packages/hoppscotch-agent/src/vite-env.d.ts new file mode 100644 index 0000000..fc81239 --- /dev/null +++ b/packages/hoppscotch-agent/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent<{}, {}, any>; + export default component; +} diff --git a/packages/hoppscotch-agent/tailwind.config.js b/packages/hoppscotch-agent/tailwind.config.js new file mode 100644 index 0000000..8d2a79d --- /dev/null +++ b/packages/hoppscotch-agent/tailwind.config.js @@ -0,0 +1,6 @@ +import preset from '@hoppscotch/ui/ui-preset' + +export default { + content: ['src/**/*.{vue,html}'], + presets: [preset] +} diff --git a/packages/hoppscotch-agent/tsconfig.json b/packages/hoppscotch-agent/tsconfig.json new file mode 100644 index 0000000..f82888f --- /dev/null +++ b/packages/hoppscotch-agent/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/packages/hoppscotch-agent/tsconfig.node.json b/packages/hoppscotch-agent/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/packages/hoppscotch-agent/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/hoppscotch-agent/vite.config.ts b/packages/hoppscotch-agent/vite.config.ts new file mode 100644 index 0000000..002646e --- /dev/null +++ b/packages/hoppscotch-agent/vite.config.ts @@ -0,0 +1,59 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import tailwindcss from 'tailwindcss'; +import autoprefixer from 'autoprefixer'; +import path from 'path'; +import Icons from "unplugin-icons/vite"; +import IconsResolver from 'unplugin-icons/resolver'; +import Components from 'unplugin-vue-components/vite'; + +const host = process.env.TAURI_DEV_HOST; + +// https://vitejs.dev/config/ +export default defineConfig(async () => ({ + plugins: [ + vue(), + Icons({ compiler: 'vue3' }), + Components({ + resolvers: [ + IconsResolver({ + prefix: '' // optional, default is 'i' + }) + ] + }) + ], + css: { + postcss: { + plugins: [ + tailwindcss, + autoprefixer, + ], + }, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src') + } + }, + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // + // 1. prevent vite from obscuring rust errors + clearScreen: false, + // 2. tauri expects a fixed port, fail if that port is not available + server: { + port: 1420, + strictPort: true, + host: '127.0.0.1', + hmr: host + ? { + protocol: "ws", + host, + port: 1421, + } + : undefined, + watch: { + // 3. tell vite to ignore watching `src-tauri` + ignored: ["**/src-tauri/**"], + }, + }, +})); diff --git a/packages/hoppscotch-backend/.dockerignore b/packages/hoppscotch-backend/.dockerignore new file mode 100644 index 0000000..4b90444 --- /dev/null +++ b/packages/hoppscotch-backend/.dockerignore @@ -0,0 +1 @@ +./node_modules diff --git a/packages/hoppscotch-backend/.gitignore b/packages/hoppscotch-backend/.gitignore new file mode 100644 index 0000000..331a57b --- /dev/null +++ b/packages/hoppscotch-backend/.gitignore @@ -0,0 +1,45 @@ +# compiled output +/dist +/node_modules + +.vscode + +.env + +# Prisma +src/generated/ + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# Generated artifacts (GQL Schema SDL generation etc.) +gen/ diff --git a/packages/hoppscotch-backend/.prettierrc b/packages/hoppscotch-backend/.prettierrc new file mode 100644 index 0000000..a20502b --- /dev/null +++ b/packages/hoppscotch-backend/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} diff --git a/packages/hoppscotch-backend/Dockerfile b/packages/hoppscotch-backend/Dockerfile new file mode 100644 index 0000000..ccc4e82 --- /dev/null +++ b/packages/hoppscotch-backend/Dockerfile @@ -0,0 +1,38 @@ +FROM node:20.12.2 AS builder + +WORKDIR /usr/src/app + +# # Install pnpm +RUN npm i -g pnpm + +COPY .env . +COPY pnpm-lock.yaml . +RUN pnpm fetch + +ENV APP_PORT=${PORT} +ENV DB_URL=${DATABASE_URL} + +# # PNPM package install +COPY ./packages/hoppscotch-backend . +RUN pnpm i --filter hoppscotch-backend + +# Prisma bits +RUN pnpm exec prisma generate + +FROM builder AS dev + +ENV PRODUCTION="false" + +CMD ["pnpm", "run", "start:dev"] + +EXPOSE 3170 + + +FROM builder AS prod + +ENV PRODUCTION="true" + +CMD ["pnpm", "run", "start:prod"] + +EXPOSE 3170 + diff --git a/packages/hoppscotch-backend/backend.Caddyfile b/packages/hoppscotch-backend/backend.Caddyfile new file mode 100644 index 0000000..4c6599c --- /dev/null +++ b/packages/hoppscotch-backend/backend.Caddyfile @@ -0,0 +1,19 @@ +{ + admin off + persist_config off +} + +:80 :3170 { + @mock { + header_regexp host Host ^[^.]+\.mock\..*$ + } + + handle @mock { + rewrite * /mock{uri} + reverse_proxy localhost:8080 + } + + handle { + reverse_proxy localhost:8080 + } +} diff --git a/packages/hoppscotch-backend/eslint.config.js b/packages/hoppscotch-backend/eslint.config.js new file mode 100644 index 0000000..d105934 --- /dev/null +++ b/packages/hoppscotch-backend/eslint.config.js @@ -0,0 +1,50 @@ +const { defineConfig, globalIgnores } = require('eslint/config'); +const tsParser = require('@typescript-eslint/parser'); +const typescriptEslintEslintPlugin = require('@typescript-eslint/eslint-plugin'); +const globals = require('globals'); +const js = require('@eslint/js'); + +const { FlatCompat } = require('@eslint/eslintrc'); + +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +module.exports = defineConfig([ + { + languageOptions: { + parser: tsParser, + sourceType: 'module', + + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + }, + + globals: { + ...globals.node, + ...globals.jest, + }, + }, + + plugins: { + '@typescript-eslint': typescriptEslintEslintPlugin, + }, + + extends: compat.extends( + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ), + + rules: { + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + 'no-empty-function': 'off', + '@typescript-eslint/no-empty-function': 'error', + }, + }, + globalIgnores(['**/.eslintrc.js']), +]); diff --git a/packages/hoppscotch-backend/global.d.ts b/packages/hoppscotch-backend/global.d.ts new file mode 100644 index 0000000..78769eb --- /dev/null +++ b/packages/hoppscotch-backend/global.d.ts @@ -0,0 +1 @@ +import '@relmify/jest-fp-ts'; diff --git a/packages/hoppscotch-backend/jest.setup.js b/packages/hoppscotch-backend/jest.setup.js new file mode 100644 index 0000000..18ffac7 --- /dev/null +++ b/packages/hoppscotch-backend/jest.setup.js @@ -0,0 +1 @@ +require('@relmify/jest-fp-ts'); diff --git a/packages/hoppscotch-backend/nest-cli.json b/packages/hoppscotch-backend/nest-cli.json new file mode 100644 index 0000000..7d9782c --- /dev/null +++ b/packages/hoppscotch-backend/nest-cli.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "assets": [{ "include": "mailer/templates/**/*", "outDir": "dist/src" }], + "watchAssets": true + } +} diff --git a/packages/hoppscotch-backend/package.json b/packages/hoppscotch-backend/package.json new file mode 100644 index 0000000..42e60d8 --- /dev/null +++ b/packages/hoppscotch-backend/package.json @@ -0,0 +1,144 @@ +{ + "name": "hoppscotch-backend", + "version": "2026.6.0", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "files": [ + "prisma", + "prisma.config.ts", + "dist", + "src/mailer/templates" + ], + "scripts": { + "prebuild": "rimraf dist", + "build": "nest build", + "postbuild": "mkdir -p dist/mailer && cp -r src/mailer/templates dist/mailer/templates", + "generate-gql-sdl": "cross-env GQL_SCHEMA_EMIT_LOCATION='../../../../gql-gen/backend-schema.gql' GENERATE_GQL_SCHEMA=true WHITELISTED_ORIGINS='' nest start", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "postinstall": "cross-env DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder prisma generate && pnpm run generate-gql-sdl", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "do-test": "pnpm run test" + }, + "dependencies": { + "@apollo/server": "5.5.1", + "@as-integrations/express5": "1.1.2", + "@nestjs-modules/mailer": "2.3.7", + "@nestjs/apollo": "13.4.2", + "@nestjs/common": "11.1.27", + "@nestjs/config": "4.0.4", + "@nestjs/core": "11.1.27", + "@nestjs/graphql": "13.4.2", + "@nestjs/jwt": "11.0.2", + "@nestjs/passport": "11.0.0", + "@nestjs/platform-express": "11.1.27", + "@nestjs/schedule": "6.1.3", + "@nestjs/swagger": "11.4.4", + "@nestjs/terminus": "11.1.1", + "@nestjs/throttler": "6.5.0", + "@prisma/adapter-pg": "7.8.0", + "@prisma/client": "7.8.0", + "argon2": "0.44.0", + "bcrypt": "6.0.0", + "class-transformer": "0.5.1", + "class-validator": "0.15.1", + "cookie": "1.1.1", + "cookie-parser": "1.4.7", + "dotenv": "17.4.2", + "express": "5.2.1", + "fp-ts": "2.16.11", + "graphql": "16.14.0", + "graphql-query-complexity": "1.1.1", + "graphql-redis-subscriptions": "2.7.0", + "graphql-subscriptions": "3.0.0", + "handlebars": "4.7.9", + "io-ts": "2.2.22", + "morgan": "1.11.0", + "nodemailer": "9.0.1", + "passport": "0.7.0", + "passport-github2": "0.1.12", + "passport-google-oauth20": "2.0.0", + "passport-jwt": "4.0.1", + "passport-local": "1.0.0", + "passport-microsoft": "2.1.0", + "pg": "8.22.0", + "posthog-node": "5.38.2", + "prisma": "7.8.0", + "reflect-metadata": "0.2.2", + "rimraf": "6.1.3", + "rxjs": "7.8.2" + }, + "devDependencies": { + "@eslint/eslintrc": "3.3.5", + "@eslint/js": "10.0.1", + "@nestjs/cli": "11.0.23", + "@nestjs/schematics": "11.1.0", + "@nestjs/testing": "11.1.27", + "@relmify/jest-fp-ts": "2.1.1", + "@types/bcrypt": "6.0.0", + "@types/cookie-parser": "1.4.10", + "@types/express": "5.0.6", + "@types/jest": "30.0.0", + "@types/node": "25.9.3", + "@types/nodemailer": "8.0.1", + "@types/passport-github2": "1.2.9", + "@types/passport-google-oauth20": "2.0.17", + "@types/passport-jwt": "4.0.1", + "@types/passport-microsoft": "2.1.1", + "@types/supertest": "7.2.0", + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "cross-env": "10.1.0", + "eslint": "10.5.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-prettier": "5.5.6", + "globals": "17.6.0", + "jest": "30.4.2", + "jest-mock-extended": "4.0.1", + "prettier": "3.8.4", + "source-map-support": "0.5.21", + "supertest": "7.2.2", + "ts-jest": "29.4.11", + "ts-loader": "9.6.1", + "ts-node": "10.9.2", + "tsconfig-paths": "4.2.0", + "typescript": "5.9.3" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "setupFilesAfterEnv": [ + "../jest.setup.js" + ], + "preset": "ts-jest", + "transform": { + "^.+\\.(t|j)s$": [ + "ts-jest", + { + "diagnostics": false + } + ] + }, + "clearMocks": true, + "collectCoverage": true, + "coverageDirectory": "coverage", + "coverageProvider": "v8", + "rootDir": "src", + "moduleNameMapper": { + "^src/(.*)$": "/$1" + } + } +} diff --git a/packages/hoppscotch-backend/prisma.config.ts b/packages/hoppscotch-backend/prisma.config.ts new file mode 100644 index 0000000..921e7b4 --- /dev/null +++ b/packages/hoppscotch-backend/prisma.config.ts @@ -0,0 +1,12 @@ +import 'dotenv/config'; +import { defineConfig, env } from 'prisma/config'; + +export default defineConfig({ + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations', + }, + datasource: { + url: env('DATABASE_URL'), + }, +}); diff --git a/packages/hoppscotch-backend/prisma/migrations/20230406064219_init/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20230406064219_init/migration.sql new file mode 100644 index 0000000..149af39 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20230406064219_init/migration.sql @@ -0,0 +1,270 @@ +-- CreateEnum +CREATE TYPE "ReqType" AS ENUM ('REST', 'GQL'); + +-- CreateEnum +CREATE TYPE "TeamMemberRole" AS ENUM ('OWNER', 'VIEWER', 'EDITOR'); + +-- CreateTable +CREATE TABLE "Team" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "Team_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamMember" ( + "id" TEXT NOT NULL, + "role" "TeamMemberRole" NOT NULL, + "userUid" TEXT NOT NULL, + "teamID" TEXT NOT NULL, + + CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamInvitation" ( + "id" TEXT NOT NULL, + "teamID" TEXT NOT NULL, + "creatorUid" TEXT NOT NULL, + "inviteeEmail" TEXT NOT NULL, + "inviteeRole" "TeamMemberRole" NOT NULL, + + CONSTRAINT "TeamInvitation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamCollection" ( + "id" TEXT NOT NULL, + "parentID" TEXT, + "teamID" TEXT NOT NULL, + "title" TEXT NOT NULL, + "orderIndex" INTEGER NOT NULL, + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedOn" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TeamCollection_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamRequest" ( + "id" TEXT NOT NULL, + "collectionID" TEXT NOT NULL, + "teamID" TEXT NOT NULL, + "title" TEXT NOT NULL, + "request" JSONB NOT NULL, + "orderIndex" INTEGER NOT NULL, + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedOn" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TeamRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Shortcode" ( + "id" TEXT NOT NULL, + "request" JSONB NOT NULL, + "creatorUid" TEXT, + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Shortcode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamEnvironment" ( + "id" TEXT NOT NULL, + "teamID" TEXT NOT NULL, + "name" TEXT NOT NULL, + "variables" JSONB NOT NULL, + + CONSTRAINT "TeamEnvironment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "uid" TEXT NOT NULL, + "displayName" TEXT, + "email" TEXT, + "photoURL" TEXT, + "isAdmin" BOOLEAN NOT NULL DEFAULT false, + "refreshToken" TEXT, + "currentRESTSession" JSONB, + "currentGQLSession" JSONB, + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "User_pkey" PRIMARY KEY ("uid") +); + +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "providerRefreshToken" TEXT, + "providerAccessToken" TEXT, + "providerScope" TEXT, + "loggedIn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VerificationToken" ( + "deviceIdentifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "userUid" TEXT NOT NULL, + "expiresOn" TIMESTAMP(3) NOT NULL +); + +-- CreateTable +CREATE TABLE "UserSettings" ( + "id" TEXT NOT NULL, + "userUid" TEXT NOT NULL, + "properties" JSONB NOT NULL, + "updatedOn" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserSettings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserHistory" ( + "id" TEXT NOT NULL, + "userUid" TEXT NOT NULL, + "reqType" "ReqType" NOT NULL, + "request" JSONB NOT NULL, + "responseMetadata" JSONB NOT NULL, + "isStarred" BOOLEAN NOT NULL, + "executedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserHistory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserEnvironment" ( + "id" TEXT NOT NULL, + "userUid" TEXT NOT NULL, + "name" TEXT, + "variables" JSONB NOT NULL, + "isGlobal" BOOLEAN NOT NULL, + + CONSTRAINT "UserEnvironment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "InvitedUsers" ( + "adminUid" TEXT NOT NULL, + "adminEmail" TEXT NOT NULL, + "inviteeEmail" TEXT NOT NULL, + "invitedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "UserRequest" ( + "id" TEXT NOT NULL, + "collectionID" TEXT NOT NULL, + "userUid" TEXT NOT NULL, + "title" TEXT NOT NULL, + "request" JSONB NOT NULL, + "type" "ReqType" NOT NULL, + "orderIndex" INTEGER NOT NULL, + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedOn" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserCollection" ( + "id" TEXT NOT NULL, + "parentID" TEXT, + "userUid" TEXT NOT NULL, + "title" TEXT NOT NULL, + "orderIndex" INTEGER NOT NULL, + "type" "ReqType" NOT NULL, + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedOn" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserCollection_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMember_teamID_userUid_key" ON "TeamMember"("teamID", "userUid"); + +-- CreateIndex +CREATE INDEX "TeamInvitation_teamID_idx" ON "TeamInvitation"("teamID"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamInvitation_teamID_inviteeEmail_key" ON "TeamInvitation"("teamID", "inviteeEmail"); + +-- CreateIndex +CREATE UNIQUE INDEX "Shortcode_id_creatorUid_key" ON "Shortcode"("id", "creatorUid"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_deviceIdentifier_token_key" ON "VerificationToken"("deviceIdentifier", "token"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserSettings_userUid_key" ON "UserSettings"("userUid"); + +-- CreateIndex +CREATE UNIQUE INDEX "InvitedUsers_inviteeEmail_key" ON "InvitedUsers"("inviteeEmail"); + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamInvitation" ADD CONSTRAINT "TeamInvitation_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_parentID_fkey" FOREIGN KEY ("parentID") REFERENCES "TeamCollection"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamRequest" ADD CONSTRAINT "TeamRequest_collectionID_fkey" FOREIGN KEY ("collectionID") REFERENCES "TeamCollection"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamRequest" ADD CONSTRAINT "TeamRequest_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamEnvironment" ADD CONSTRAINT "TeamEnvironment_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserSettings" ADD CONSTRAINT "UserSettings_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserHistory" ADD CONSTRAINT "UserHistory_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserEnvironment" ADD CONSTRAINT "UserEnvironment_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "InvitedUsers" ADD CONSTRAINT "InvitedUsers_adminUid_fkey" FOREIGN KEY ("adminUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserRequest" ADD CONSTRAINT "UserRequest_collectionID_fkey" FOREIGN KEY ("collectionID") REFERENCES "UserCollection"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserRequest" ADD CONSTRAINT "UserRequest_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserCollection" ADD CONSTRAINT "UserCollection_parentID_fkey" FOREIGN KEY ("parentID") REFERENCES "UserCollection"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserCollection" ADD CONSTRAINT "UserCollection_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/hoppscotch-backend/prisma/migrations/20231106120154_embeds_addition/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20231106120154_embeds_addition/migration.sql new file mode 100644 index 0000000..d545bd4 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20231106120154_embeds_addition/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - A unique constraint covering the columns `[id]` on the table `Shortcode` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Shortcode" ADD COLUMN "embedProperties" JSONB, +ADD COLUMN "updatedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- CreateIndex +CREATE UNIQUE INDEX "Shortcode_id_key" ON "Shortcode"("id"); + +-- AddForeignKey +ALTER TABLE "Shortcode" ADD CONSTRAINT "Shortcode_creatorUid_fkey" FOREIGN KEY ("creatorUid") REFERENCES "User"("uid") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/hoppscotch-backend/prisma/migrations/20231124104640_infra_config/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20231124104640_infra_config/migration.sql new file mode 100644 index 0000000..1e2d72c --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20231124104640_infra_config/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "InfraConfig" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "value" TEXT, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedOn" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "InfraConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "InfraConfig_name_key" ON "InfraConfig"("name"); diff --git a/packages/hoppscotch-backend/prisma/migrations/20231130082054_collection_headers/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20231130082054_collection_headers/migration.sql new file mode 100644 index 0000000..0276e90 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20231130082054_collection_headers/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "TeamCollection" ADD COLUMN "data" JSONB; + +-- AlterTable +ALTER TABLE "UserCollection" ADD COLUMN "data" JSONB; diff --git a/packages/hoppscotch-backend/prisma/migrations/20240226053141_full_text_search_additions/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20240226053141_full_text_search_additions/migration.sql new file mode 100644 index 0000000..25ca672 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20240226053141_full_text_search_additions/migration.sql @@ -0,0 +1,22 @@ +-- This is a custom migration file which is not generated by Prisma. +-- The aim of this migration is to add text search indices to the TeamCollection and TeamRequest tables. + +-- Create Extension +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- Create GIN Trigram Index for Team Collection title +CREATE INDEX + "TeamCollection_title_trgm_idx" +ON + "TeamCollection" +USING + GIN (title gin_trgm_ops); + +-- Create GIN Trigram Index for Team Collection title +CREATE INDEX + "TeamRequest_title_trgm_idx" +ON + "TeamRequest" +USING + GIN (title gin_trgm_ops); + diff --git a/packages/hoppscotch-backend/prisma/migrations/20240519093155_add_last_logged_on_to_user/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20240519093155_add_last_logged_on_to_user/migration.sql new file mode 100644 index 0000000..5664c4f --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20240519093155_add_last_logged_on_to_user/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastLoggedOn" TIMESTAMP(3); diff --git a/packages/hoppscotch-backend/prisma/migrations/20240520091033_personal_access_tokens/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20240520091033_personal_access_tokens/migration.sql new file mode 100644 index 0000000..53abda8 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20240520091033_personal_access_tokens/migration.sql @@ -0,0 +1,19 @@ + +-- CreateTable +CREATE TABLE "PersonalAccessToken" ( + "id" TEXT NOT NULL, + "userUid" TEXT NOT NULL, + "label" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresOn" TIMESTAMP(3), + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedOn" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PersonalAccessToken_token_key" ON "PersonalAccessToken"("token"); + +-- AddForeignKey +ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/hoppscotch-backend/prisma/migrations/20240621062554_user_last_active_on/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20240621062554_user_last_active_on/migration.sql new file mode 100644 index 0000000..9e0ff1c --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20240621062554_user_last_active_on/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastActiveOn" TIMESTAMP(3); diff --git a/packages/hoppscotch-backend/prisma/migrations/20240725043411_infra_config_encryption/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20240725043411_infra_config_encryption/migration.sql new file mode 100644 index 0000000..71cf28a --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20240725043411_infra_config_encryption/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "InfraConfig" ADD COLUMN "isEncrypted" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/hoppscotch-backend/prisma/migrations/20240726121956_infra_token/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20240726121956_infra_token/migration.sql new file mode 100644 index 0000000..10971ba --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20240726121956_infra_token/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "InfraToken" ( + "id" TEXT NOT NULL, + "creatorUid" TEXT NOT NULL, + "label" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresOn" TIMESTAMP(3), + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "InfraToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "InfraToken_token_key" ON "InfraToken"("token"); diff --git a/packages/hoppscotch-backend/prisma/migrations/20241118054346_infra_config_sync_with_env_file/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20241118054346_infra_config_sync_with_env_file/migration.sql new file mode 100644 index 0000000..23ccdb5 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20241118054346_infra_config_sync_with_env_file/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "InfraConfig" DROP COLUMN "active", +ADD COLUMN "lastSyncedEnvFileValue" TEXT; diff --git a/packages/hoppscotch-backend/prisma/migrations/20250306054346_team_access_role_enum/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20250306054346_team_access_role_enum/migration.sql new file mode 100644 index 0000000..e695e0d --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20250306054346_team_access_role_enum/migration.sql @@ -0,0 +1,2 @@ +-- Alter the enum type "TeamMemberRole" to "TeamAccessRole" +ALTER TYPE "TeamMemberRole" RENAME TO "TeamAccessRole"; diff --git a/packages/hoppscotch-backend/prisma/migrations/20250725154928_timestamp_with_timezone/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20250725154928_timestamp_with_timezone/migration.sql new file mode 100644 index 0000000..6078978 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20250725154928_timestamp_with_timezone/migration.sql @@ -0,0 +1,53 @@ +-- AlterTable +ALTER TABLE "Account" ALTER COLUMN "loggedIn" SET DATA TYPE TIMESTAMPTZ(3) USING "loggedIn" AT TIME ZONE 'UTC'; + +-- AlterTable +ALTER TABLE "InfraConfig" ALTER COLUMN "createdOn" SET DATA TYPE TIMESTAMPTZ(3) USING "createdOn" AT TIME ZONE 'UTC', +ALTER COLUMN "updatedOn" SET DATA TYPE TIMESTAMPTZ(3) USING "updatedOn" AT TIME ZONE 'UTC'; + +-- AlterTable +ALTER TABLE "InfraToken" ALTER COLUMN "expiresOn" SET DATA TYPE TIMESTAMPTZ(3) USING "expiresOn" AT TIME ZONE 'UTC', +ALTER COLUMN "createdOn" SET DATA TYPE TIMESTAMPTZ(3) USING "createdOn" AT TIME ZONE 'UTC', +ALTER COLUMN "updatedOn" SET DATA TYPE TIMESTAMPTZ(3) USING "updatedOn" AT TIME ZONE 'UTC'; + +-- AlterTable +ALTER TABLE "InvitedUsers" ALTER COLUMN "invitedOn" SET DATA TYPE TIMESTAMPTZ(3) USING "invitedOn" AT TIME ZONE 'UTC'; + +-- AlterTable +ALTER TABLE "PersonalAccessToken" ALTER COLUMN "expiresOn" SET DATA TYPE TIMESTAMPTZ(3) USING "expiresOn" AT TIME ZONE 'UTC', +ALTER COLUMN "createdOn" SET DATA TYPE TIMESTAMPTZ(3) USING "createdOn" AT TIME ZONE 'UTC', +ALTER COLUMN "updatedOn" SET DATA TYPE TIMESTAMPTZ(3) USING "updatedOn" AT TIME ZONE 'UTC'; + +-- AlterTable +ALTER TABLE "Shortcode" ALTER COLUMN "createdOn" SET DATA TYPE TIMESTAMPTZ(3) USING "createdOn" AT TIME ZONE 'UTC', +ALTER COLUMN "updatedOn" SET DATA TYPE TIMESTAMPTZ(3) USING "updatedOn" AT TIME ZONE 'UTC'; + +-- AlterTable +ALTER TABLE "TeamCollection" ALTER COLUMN "createdOn" SET DATA TYPE TIMESTAMPTZ(3) USING "createdOn" AT TIME ZONE 'UTC', +ALTER COLUMN "updatedOn" SET DATA TYPE TIMESTAMPTZ(3) USING "updatedOn" AT TIME ZONE 'UTC'; + +-- AlterTable +ALTER TABLE "TeamRequest" ALTER COLUMN "createdOn" SET DATA TYPE TIMESTAMPTZ(3) USING "createdOn" AT TIME ZONE 'UTC', +ALTER COLUMN "updatedOn" SET DATA TYPE TIMESTAMPTZ(3) USING "updatedOn" AT TIME ZONE 'UTC'; + +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "createdOn" SET DATA TYPE TIMESTAMPTZ(3) USING "createdOn" AT TIME ZONE 'UTC', +ALTER COLUMN "lastLoggedOn" SET DATA TYPE TIMESTAMPTZ(3) USING "lastLoggedOn" AT TIME ZONE 'UTC', +ALTER COLUMN "lastActiveOn" SET DATA TYPE TIMESTAMPTZ(3) USING "lastActiveOn" AT TIME ZONE 'UTC'; + +-- AlterTable +ALTER TABLE "UserCollection" ALTER COLUMN "createdOn" SET DATA TYPE TIMESTAMPTZ(3) USING "createdOn" AT TIME ZONE 'UTC', +ALTER COLUMN "updatedOn" SET DATA TYPE TIMESTAMPTZ(3) USING "updatedOn" AT TIME ZONE 'UTC'; + +-- AlterTable +ALTER TABLE "UserHistory" ALTER COLUMN "executedOn" SET DATA TYPE TIMESTAMPTZ(3) USING "executedOn" AT TIME ZONE 'UTC'; + +-- AlterTable +ALTER TABLE "UserRequest" ALTER COLUMN "createdOn" SET DATA TYPE TIMESTAMPTZ(3) USING "createdOn" AT TIME ZONE 'UTC', +ALTER COLUMN "updatedOn" SET DATA TYPE TIMESTAMPTZ(3) USING "updatedOn" AT TIME ZONE 'UTC'; + +-- AlterTable +ALTER TABLE "UserSettings" ALTER COLUMN "updatedOn" SET DATA TYPE TIMESTAMPTZ(3) USING "updatedOn" AT TIME ZONE 'UTC'; + +-- AlterTable +ALTER TABLE "VerificationToken" ALTER COLUMN "expiresOn" SET DATA TYPE TIMESTAMPTZ(3) USING "expiresOn" AT TIME ZONE 'UTC'; diff --git a/packages/hoppscotch-backend/prisma/migrations/20250818154928_fix_order_index/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20250818154928_fix_order_index/migration.sql new file mode 100644 index 0000000..715e606 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20250818154928_fix_order_index/migration.sql @@ -0,0 +1,59 @@ +-- Recalculate orderIndex for UserCollection per (parentID) +WITH ordered AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY "parentID" + ORDER BY "orderIndex" ASC, "createdOn" ASC, id ASC + ) AS new_index + FROM "UserCollection" +) +UPDATE "UserCollection" uc +SET "orderIndex" = o.new_index +FROM ordered o +WHERE uc.id = o.id; + +-- Recalculate orderIndex for UserRequest per (collectionID) +WITH ordered AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY "collectionID" + ORDER BY "orderIndex" ASC, "createdOn" ASC, id ASC + ) AS new_index + FROM "UserRequest" +) +UPDATE "UserRequest" ur +SET "orderIndex" = o.new_index +FROM ordered o +WHERE ur.id = o.id; + +-- Recalculate orderIndex for TeamCollection per (teamID, parentID) +WITH ordered AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY "teamID", "parentID" + ORDER BY "orderIndex" ASC, "createdOn" ASC, id ASC + ) AS new_index + FROM "TeamCollection" +) +UPDATE "TeamCollection" tc +SET "orderIndex" = o.new_index +FROM ordered o +WHERE tc.id = o.id; + +-- Recalculate orderIndex for TeamRequest per (teamID, collectionID) +WITH ordered AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY "teamID", "collectionID" + ORDER BY "orderIndex" ASC, "createdOn" ASC, id ASC + ) AS new_index + FROM "TeamRequest" +) +UPDATE "TeamRequest" tr +SET "orderIndex" = o.new_index +FROM ordered o +WHERE tr.id = o.id; diff --git a/packages/hoppscotch-backend/prisma/migrations/20250822044741_order_index_unique_constraint/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20250822044741_order_index_unique_constraint/migration.sql new file mode 100644 index 0000000..8f3f756 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20250822044741_order_index_unique_constraint/migration.sql @@ -0,0 +1,21 @@ +-- Recreate as DEFERRABLE UNIQUE CONSTRAINTS + +ALTER TABLE "TeamCollection" +ADD CONSTRAINT "TeamCollection_teamID_parentID_orderIndex_key" + UNIQUE ("teamID", "parentID", "orderIndex") + DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE "TeamRequest" +ADD CONSTRAINT "TeamRequest_teamID_collectionID_orderIndex_key" + UNIQUE ("teamID", "collectionID", "orderIndex") + DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE "UserCollection" +ADD CONSTRAINT "UserCollection_userUid_parentID_orderIndex_key" + UNIQUE ("userUid", "parentID", "orderIndex") + DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE "UserRequest" +ADD CONSTRAINT "UserRequest_userUid_collectionID_orderIndex_key" + UNIQUE ("userUid", "collectionID", "orderIndex") + DEFERRABLE INITIALLY DEFERRED; diff --git a/packages/hoppscotch-backend/prisma/migrations/20251016080714_mock_server/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20251016080714_mock_server/migration.sql new file mode 100644 index 0000000..b0caa4c --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20251016080714_mock_server/migration.sql @@ -0,0 +1,142 @@ +-- CreateEnum +CREATE TYPE "WorkspaceType" AS ENUM ('USER', 'TEAM'); + +-- CreateEnum +CREATE TYPE "MockServerAction" AS ENUM ('CREATED', 'DELETED', 'ACTIVATED', 'DEACTIVATED'); + +-- CreateTable +CREATE TABLE "MockServer" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "subdomain" TEXT NOT NULL, + "creatorUid" TEXT, + "collectionID" TEXT NOT NULL, + "workspaceType" "WorkspaceType" NOT NULL, + "workspaceID" TEXT NOT NULL, + "delayInMs" INTEGER NOT NULL DEFAULT 0, + "isPublic" BOOLEAN NOT NULL DEFAULT true, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "hitCount" INTEGER NOT NULL DEFAULT 0, + "lastHitAt" TIMESTAMPTZ(3), + "createdOn" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedOn" TIMESTAMPTZ(3) NOT NULL, + "deletedAt" TIMESTAMPTZ(3), + + CONSTRAINT "MockServer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MockServerLog" ( + "id" TEXT NOT NULL, + "mockServerID" TEXT NOT NULL, + "requestMethod" TEXT NOT NULL, + "requestPath" TEXT NOT NULL, + "requestHeaders" JSONB NOT NULL, + "requestBody" JSONB, + "requestQuery" JSONB, + "responseStatus" INTEGER NOT NULL, + "responseHeaders" JSONB NOT NULL, + "responseBody" JSONB, + "responseTime" INTEGER NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "executedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MockServerLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MockServerActivity" ( + "id" TEXT NOT NULL, + "mockServerID" TEXT NOT NULL, + "action" "MockServerAction" NOT NULL, + "performedBy" TEXT, + "performedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MockServerActivity_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "MockServer_subdomain_key" ON "MockServer"("subdomain"); + +-- CreateIndex +CREATE INDEX "MockServerLog_mockServerID_idx" ON "MockServerLog"("mockServerID"); + +-- CreateIndex +CREATE INDEX "MockServerLog_mockServerID_executedAt_idx" ON "MockServerLog"("mockServerID", "executedAt"); + +-- CreateIndex +CREATE INDEX "MockServerActivity_mockServerID_idx" ON "MockServerActivity"("mockServerID"); + +-- AddForeignKey +ALTER TABLE "MockServer" ADD CONSTRAINT "MockServer_creatorUid_fkey" FOREIGN KEY ("creatorUid") REFERENCES "User"("uid") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MockServerLog" ADD CONSTRAINT "MockServerLog_mockServerID_fkey" FOREIGN KEY ("mockServerID") REFERENCES "MockServer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MockServerActivity" ADD CONSTRAINT "MockServerActivity_mockServerID_fkey" FOREIGN KEY ("mockServerID") REFERENCES "MockServer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + + +-- Add mockExamples column to UserRequest +ALTER TABLE "UserRequest" +ADD COLUMN "mockExamples" JSONB; + +-- Add mockExamples column to TeamRequest +ALTER TABLE "TeamRequest" +ADD COLUMN "mockExamples" JSONB; + +-- Create function to sync mock examples +CREATE OR REPLACE FUNCTION sync_mock_examples() +RETURNS TRIGGER AS $$ +BEGIN + NEW."mockExamples" := jsonb_build_object( + 'examples', + COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'key', key, + 'name', value->>'name', + 'endpoint', value->'originalRequest'->>'endpoint', + 'method', value->'originalRequest'->>'method', + 'headers', COALESCE(value->'originalRequest'->'headers', '[]'::jsonb), + 'statusCode', (value->>'code')::int, + 'statusText', value->>'status', + 'responseBody', value->>'body', + 'responseHeaders', COALESCE(value->'headers', '[]'::jsonb) + ) + ) + FROM jsonb_each(NEW.request->'responses') AS responses(key, value) + WHERE jsonb_typeof(NEW.request->'responses') = 'object' + ), + '[]'::jsonb + ) + ); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create trigger for UserRequest +CREATE TRIGGER trigger_sync_mock_examples_user_request +BEFORE INSERT OR UPDATE OF request ON "UserRequest" +FOR EACH ROW +EXECUTE FUNCTION sync_mock_examples(); + +-- Create trigger for TeamRequest +CREATE TRIGGER trigger_sync_mock_examples_team_request +BEFORE INSERT OR UPDATE OF request ON "TeamRequest" +FOR EACH ROW +EXECUTE FUNCTION sync_mock_examples(); + +-- Backfill existing data for UserRequest +UPDATE "UserRequest" SET request = request WHERE request IS NOT NULL; + +-- Backfill existing data for TeamRequest +UPDATE "TeamRequest" SET request = request WHERE request IS NOT NULL; + +-- Add GIN indexes +CREATE INDEX "idx_mock_examples_user_requests_gin" ON "UserRequest" USING GIN ("mockExamples"); +CREATE INDEX "idx_mock_examples_team_requests_gin" ON "TeamRequest" USING GIN ("mockExamples"); \ No newline at end of file diff --git a/packages/hoppscotch-backend/prisma/migrations/20251110141554_api_doc/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20251110141554_api_doc/migration.sql new file mode 100644 index 0000000..2c8cc01 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20251110141554_api_doc/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "PublishedDocs" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "collectionID" TEXT NOT NULL, + "creatorUid" TEXT NOT NULL, + "version" TEXT NOT NULL, + "autoSync" BOOLEAN NOT NULL, + "documentTree" JSONB, + "workspaceType" "WorkspaceType" NOT NULL, + "workspaceID" TEXT NOT NULL, + "metadata" JSONB, + "createdOn" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedOn" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "PublishedDocs_pkey" PRIMARY KEY ("id") +); diff --git a/packages/hoppscotch-backend/prisma/migrations/20251202154928_fix_order_index/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20251202154928_fix_order_index/migration.sql new file mode 100644 index 0000000..27e35d2 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20251202154928_fix_order_index/migration.sql @@ -0,0 +1,30 @@ +-- Recalculate orderIndex for UserCollection per (parentID) +WITH ordered AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY "userUid", "parentID" + ORDER BY "orderIndex" ASC, "createdOn" ASC, id ASC + ) AS new_index + FROM "UserCollection" +) +UPDATE "UserCollection" uc +SET "orderIndex" = o.new_index +FROM ordered o +WHERE uc.id = o.id; + +-- Recalculate orderIndex for TeamCollection per (teamID, parentID) +WITH ordered AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY "teamID", "parentID" + ORDER BY "orderIndex" ASC, "createdOn" ASC, id ASC + ) AS new_index + FROM "TeamCollection" +) +UPDATE "TeamCollection" tc +SET "orderIndex" = o.new_index +FROM ordered o +WHERE tc.id = o.id; + diff --git a/packages/hoppscotch-backend/prisma/migrations/20251203202452_cascade_delete/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20251203202452_cascade_delete/migration.sql new file mode 100644 index 0000000..894af9e --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20251203202452_cascade_delete/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "TeamCollection" DROP CONSTRAINT "TeamCollection_parentID_fkey"; + +-- DropForeignKey +ALTER TABLE "UserRequest" DROP CONSTRAINT "UserRequest_collectionID_fkey"; + +-- AddForeignKey +ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_parentID_fkey" FOREIGN KEY ("parentID") REFERENCES "TeamCollection"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserRequest" ADD CONSTRAINT "UserRequest_collectionID_fkey" FOREIGN KEY ("collectionID") REFERENCES "UserCollection"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/hoppscotch-backend/prisma/migrations/20251207122817_add_slug_to_published_docs/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20251207122817_add_slug_to_published_docs/migration.sql new file mode 100644 index 0000000..03ae84c --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20251207122817_add_slug_to_published_docs/migration.sql @@ -0,0 +1,31 @@ +-- Step 1: Add slug column as nullable first +ALTER TABLE "PublishedDocs" ADD COLUMN "slug" TEXT; + +-- Step 2: For backward compatibility, set slug = id for existing records +UPDATE "PublishedDocs" SET "slug" = "id" WHERE "slug" IS NULL; + +-- Step 3: Handle duplicates - for multiple published docs with same collection and version +-- Keep the latest one (most recent), delete all older ones +-- delete old duplicates are safe, as multiple published docs with same collection and version is not expected behavior till v2025.11.x +WITH ranked_docs AS ( + SELECT + id, + "collectionID", + version, + "createdOn", + ROW_NUMBER() OVER (PARTITION BY "collectionID", version ORDER BY "createdOn" DESC) as rn + FROM "PublishedDocs" +) +DELETE FROM "PublishedDocs" +WHERE id IN ( + SELECT id FROM ranked_docs WHERE rn > 1 +); + +-- Step 4: Now make slug NOT NULL +ALTER TABLE "PublishedDocs" ALTER COLUMN "slug" SET NOT NULL; + +-- CreateIndex +CREATE INDEX "PublishedDocs_collectionID_idx" ON "PublishedDocs"("collectionID"); + +-- CreateIndex +CREATE UNIQUE INDEX "PublishedDocs_slug_version_key" ON "PublishedDocs"("slug", "version"); diff --git a/packages/hoppscotch-backend/prisma/migrations/20260209063744_published_doc_environment/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20260209063744_published_doc_environment/migration.sql new file mode 100644 index 0000000..7c9d0aa --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20260209063744_published_doc_environment/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "PublishedDocs" ADD COLUMN "environmentID" TEXT, +ADD COLUMN "environmentName" TEXT, +ADD COLUMN "environmentVariables" JSONB; diff --git a/packages/hoppscotch-backend/prisma/migrations/migration_lock.toml b/packages/hoppscotch-backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma new file mode 100644 index 0000000..26a3b26 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -0,0 +1,342 @@ +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" +} + +datasource db { + provider = "postgresql" +} + +model Team { + id String @id @default(cuid()) + name String + TeamCollection TeamCollection[] + TeamEnvironment TeamEnvironment[] + TeamInvitation TeamInvitation[] + members TeamMember[] + TeamRequest TeamRequest[] +} + +model TeamMember { + id String @id @default(uuid()) + role TeamAccessRole + userUid String + teamID String + team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) + + @@unique([teamID, userUid]) +} + +model TeamInvitation { + id String @id @default(cuid()) + teamID String + creatorUid String + inviteeEmail String + inviteeRole TeamAccessRole + team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) + + @@unique([teamID, inviteeEmail]) + @@index([teamID]) +} + +model TeamCollection { + id String @id @default(cuid()) + parentID String? + teamID String + title String + orderIndex Int + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @updatedAt @db.Timestamptz(3) + data Json? + parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id], onDelete: Cascade) + children TeamCollection[] @relation("TeamCollectionChildParent") + team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) + requests TeamRequest[] + + @@unique([teamID, parentID, orderIndex]) +} + +model TeamRequest { + id String @id @default(cuid()) + collectionID String + teamID String + title String + request Json + mockExamples Json? + orderIndex Int + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @updatedAt @db.Timestamptz(3) + collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade) + team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) + + @@unique([teamID, collectionID, orderIndex]) +} + +model Shortcode { + id String @id @unique + request Json + creatorUid String? + createdOn DateTime @default(now()) @db.Timestamptz(3) + embedProperties Json? + updatedOn DateTime @default(now()) @updatedAt @db.Timestamptz(3) + User User? @relation(fields: [creatorUid], references: [uid]) + + @@unique([id, creatorUid], name: "creator_uid_shortcode_unique") +} + +model TeamEnvironment { + id String @id @default(cuid()) + teamID String + name String + variables Json + team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) +} + +model User { + uid String @id @default(cuid()) + displayName String? + email String? @unique + photoURL String? + isAdmin Boolean @default(false) + refreshToken String? + currentRESTSession Json? + currentGQLSession Json? + createdOn DateTime @default(now()) @db.Timestamptz(3) + lastLoggedOn DateTime? @db.Timestamptz(3) + lastActiveOn DateTime? @db.Timestamptz(3) + providerAccounts Account[] + invitedUsers InvitedUsers[] + mockServers MockServer[] + personalAccessTokens PersonalAccessToken[] + shortcodes Shortcode[] + userCollections UserCollection[] + UserEnvironments UserEnvironment[] + UserHistory UserHistory[] + userRequests UserRequest[] + settings UserSettings? + VerificationToken VerificationToken[] +} + +model Account { + id String @id @default(cuid()) + userId String + provider String + providerAccountId String + providerRefreshToken String? + providerAccessToken String? + providerScope String? + loggedIn DateTime @default(now()) @db.Timestamptz(3) + user User @relation(fields: [userId], references: [uid], onDelete: Cascade) + + @@unique([provider, providerAccountId], name: "verifyProviderAccount") +} + +model VerificationToken { + deviceIdentifier String + token String @unique @default(cuid()) + userUid String + expiresOn DateTime @db.Timestamptz(3) + user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) + + @@unique([deviceIdentifier, token], name: "passwordless_deviceIdentifier_tokens") +} + +model UserSettings { + id String @id @default(cuid()) + userUid String @unique + properties Json + updatedOn DateTime @updatedAt @db.Timestamptz(3) + user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) +} + +model UserHistory { + id String @id @default(cuid()) + userUid String + reqType ReqType + request Json + responseMetadata Json + isStarred Boolean + executedOn DateTime @default(now()) @db.Timestamptz(3) + user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) +} + +model UserEnvironment { + id String @id @default(cuid()) + userUid String + name String? + variables Json + isGlobal Boolean + user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) +} + +model InvitedUsers { + adminUid String + adminEmail String + inviteeEmail String @unique + invitedOn DateTime @default(now()) @db.Timestamptz(3) + user User @relation(fields: [adminUid], references: [uid], onDelete: Cascade) +} + +model UserRequest { + id String @id @default(cuid()) + collectionID String + userUid String + title String + request Json + mockExamples Json? + type ReqType + orderIndex Int + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @updatedAt @db.Timestamptz(3) + userCollection UserCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade) + user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) + + @@unique([userUid, collectionID, orderIndex]) +} + +model UserCollection { + id String @id @default(cuid()) + parentID String? + userUid String + title String + orderIndex Int + type ReqType + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @updatedAt @db.Timestamptz(3) + data Json? + parent UserCollection? @relation("ParentUserCollection", fields: [parentID], references: [id], onDelete: Cascade) + children UserCollection[] @relation("ParentUserCollection") + user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) + requests UserRequest[] + + @@unique([userUid, parentID, orderIndex]) +} + +model InfraConfig { + id String @id @default(cuid()) + name String @unique + value String? + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @updatedAt @db.Timestamptz(3) + isEncrypted Boolean @default(false) + lastSyncedEnvFileValue String? +} + +model PersonalAccessToken { + id String @id @default(cuid()) + userUid String + label String + token String @unique @default(uuid()) + expiresOn DateTime? @db.Timestamptz(3) + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @updatedAt @db.Timestamptz(3) + user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) +} + +model InfraToken { + id String @id @default(cuid()) + creatorUid String + label String + token String @unique @default(uuid()) + expiresOn DateTime? @db.Timestamptz(3) + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @default(now()) @db.Timestamptz(3) +} + +model MockServer { + id String @id @default(cuid()) + name String + subdomain String @unique + creatorUid String? + collectionID String + workspaceType WorkspaceType + workspaceID String + delayInMs Int @default(0) + isPublic Boolean @default(true) + isActive Boolean @default(true) + hitCount Int @default(0) + lastHitAt DateTime? @db.Timestamptz(3) + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @updatedAt @db.Timestamptz(3) + deletedAt DateTime? @db.Timestamptz(3) + user User? @relation(fields: [creatorUid], references: [uid], onDelete: SetNull) + requestLogs MockServerLog[] + activityHistory MockServerActivity[] +} + +model MockServerLog { + id String @id @default(cuid()) + mockServerID String + requestMethod String + requestPath String + requestHeaders Json + requestBody Json? + requestQuery Json? + responseStatus Int + responseHeaders Json + responseBody Json? + responseTime Int + ipAddress String? + userAgent String? + executedAt DateTime @default(now()) @db.Timestamptz(3) + mockServer MockServer @relation(fields: [mockServerID], references: [id], onDelete: Cascade) + + @@index([mockServerID]) + @@index([mockServerID, executedAt]) +} + +model MockServerActivity { + id String @id @default(cuid()) + mockServerID String + action MockServerAction + performedBy String? + performedAt DateTime @default(now()) @db.Timestamptz(3) + mockServer MockServer @relation(fields: [mockServerID], references: [id], onDelete: Cascade) + + @@index([mockServerID]) +} + +model PublishedDocs { + id String @id @default(cuid()) + slug String + title String + collectionID String + creatorUid String + version String + autoSync Boolean + documentTree Json? // Optional if autoSync is true + workspaceType WorkspaceType + workspaceID String + environmentID String? + environmentName String? + environmentVariables Json? + metadata Json? + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @updatedAt @db.Timestamptz(3) + + @@unique([slug, version]) + @@index([collectionID]) +} + +enum WorkspaceType { + USER + TEAM +} + +enum ReqType { + REST + GQL +} + +enum TeamAccessRole { + OWNER + VIEWER + EDITOR +} + +enum MockServerAction { + CREATED + DELETED + ACTIVATED + DEACTIVATED +} diff --git a/packages/hoppscotch-backend/prod_run.mjs b/packages/hoppscotch-backend/prod_run.mjs new file mode 100644 index 0000000..5aa7748 --- /dev/null +++ b/packages/hoppscotch-backend/prod_run.mjs @@ -0,0 +1,66 @@ +#!/usr/local/bin/node +// @ts-check + +import { spawn } from 'child_process'; +import process from 'process'; + +function runChildProcessWithPrefix(command, args, prefix) { + const childProcess = spawn(command, args); + + childProcess.stdout.on('data', (data) => { + const output = data.toString().trim().split('\n'); + output.forEach((line) => { + console.log(`${prefix} | ${line}`); + }); + }); + + childProcess.stderr.on('data', (data) => { + const error = data.toString().trim().split('\n'); + error.forEach((line) => { + console.error(`${prefix} | ${line}`); + }); + }); + + childProcess.on('close', (code) => { + console.log(`${prefix} Child process exited with code ${code}`); + }); + + childProcess.on('error', (stuff) => { + console.error('error'); + console.error(stuff); + }); + + return childProcess; +} + +const caddyProcess = runChildProcessWithPrefix( + 'caddy', + ['run', '--config', '/etc/caddy/backend.Caddyfile', '--adapter', 'caddyfile'], + 'App/Admin Dashboard Caddy', +); +const backendProcess = runChildProcessWithPrefix( + 'node', + ['/dist/backend/dist/src/main.js'], + 'Backend Server', +); + +caddyProcess.on('exit', (code) => { + console.log(`Exiting process because Caddy Server exited with code ${code}`); + process.exit(code); +}); + +backendProcess.on('exit', (code) => { + console.log( + `Exiting process because Backend Server exited with code ${code}`, + ); + process.exit(code); +}); + +process.on('SIGINT', () => { + console.log('SIGINT received, exiting...'); + + caddyProcess.kill('SIGINT'); + backendProcess.kill('SIGINT'); + + process.exit(0); +}); diff --git a/packages/hoppscotch-backend/src/access-token/access-token.controller.ts b/packages/hoppscotch-backend/src/access-token/access-token.controller.ts new file mode 100644 index 0000000..19eb572 --- /dev/null +++ b/packages/hoppscotch-backend/src/access-token/access-token.controller.ts @@ -0,0 +1,112 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Post, + Query, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { AccessTokenService } from './access-token.service'; +import { CreateAccessTokenDto } from './dto/create-access-token.dto'; +import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; +import * as E from 'fp-ts/Either'; +import { throwHTTPErr } from 'src/utils'; +import { GqlUser } from 'src/decorators/gql-user.decorator'; +import { AuthUser } from 'src/types/AuthUser'; +import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard'; +import { PATAuthGuard } from 'src/guards/rest-pat-auth.guard'; +import { AccessTokenInterceptor } from 'src/interceptors/access-token.interceptor'; +import { TeamEnvironmentsService } from 'src/team-environments/team-environments.service'; +import { TeamCollectionService } from 'src/team-collection/team-collection.service'; +import { ACCESS_TOKENS_INVALID_DATA_ID } from 'src/errors'; +import { createCLIErrorResponse } from './helper'; + +@UseGuards(ThrottlerBehindProxyGuard) +@Controller({ path: 'access-tokens', version: '1' }) +export class AccessTokenController { + constructor( + private readonly accessTokenService: AccessTokenService, + private readonly teamCollectionService: TeamCollectionService, + private readonly teamEnvironmentsService: TeamEnvironmentsService, + ) {} + + @Post('create') + @UseGuards(JwtAuthGuard) + async createPAT( + @GqlUser() user: AuthUser, + @Body() createAccessTokenDto: CreateAccessTokenDto, + ) { + const result = await this.accessTokenService.createPAT( + createAccessTokenDto, + user, + ); + if (E.isLeft(result)) throwHTTPErr(result.left); + return result.right; + } + + @Delete('revoke') + @UseGuards(JwtAuthGuard) + async deletePAT(@GqlUser() user: AuthUser, @Query('id') id: string) { + if (!id) { + throw new BadRequestException( + createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID), + ); + } + + const result = await this.accessTokenService.deletePAT(id, user.uid); + + if (E.isLeft(result)) throwHTTPErr(result.left); + return result.right; + } + + @Get('list') + @UseGuards(JwtAuthGuard) + async listAllUserPAT( + @GqlUser() user: AuthUser, + @Query('offset', ParseIntPipe) offset: number, + @Query('limit', ParseIntPipe) limit: number, + ) { + return await this.accessTokenService.listAllUserPAT( + user.uid, + offset, + limit, + ); + } + + @Get('collection/:id') + @UseGuards(PATAuthGuard) + @UseInterceptors(AccessTokenInterceptor) + async fetchCollection(@GqlUser() user: AuthUser, @Param('id') id: string) { + const res = await this.teamCollectionService.getCollectionForCLI( + id, + user.uid, + ); + + if (E.isLeft(res)) + throw new BadRequestException( + createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID), + ); + return res.right; + } + + @Get('environment/:id') + @UseGuards(PATAuthGuard) + @UseInterceptors(AccessTokenInterceptor) + async fetchEnvironment(@GqlUser() user: AuthUser, @Param('id') id: string) { + const res = await this.teamEnvironmentsService.getTeamEnvironmentForCLI( + id, + user.uid, + ); + + if (E.isLeft(res)) + throw new BadRequestException( + createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID), + ); + return res.right; + } +} diff --git a/packages/hoppscotch-backend/src/access-token/access-token.module.ts b/packages/hoppscotch-backend/src/access-token/access-token.module.ts new file mode 100644 index 0000000..0e33d41 --- /dev/null +++ b/packages/hoppscotch-backend/src/access-token/access-token.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AccessTokenController } from './access-token.controller'; +import { AccessTokenService } from './access-token.service'; +import { TeamCollectionModule } from 'src/team-collection/team-collection.module'; +import { TeamEnvironmentsModule } from 'src/team-environments/team-environments.module'; +import { TeamModule } from 'src/team/team.module'; + +@Module({ + imports: [TeamCollectionModule, TeamEnvironmentsModule, TeamModule], + controllers: [AccessTokenController], + providers: [AccessTokenService], + exports: [AccessTokenService], +}) +export class AccessTokenModule {} diff --git a/packages/hoppscotch-backend/src/access-token/access-token.service.spec.ts b/packages/hoppscotch-backend/src/access-token/access-token.service.spec.ts new file mode 100644 index 0000000..befa010 --- /dev/null +++ b/packages/hoppscotch-backend/src/access-token/access-token.service.spec.ts @@ -0,0 +1,226 @@ +import { AccessTokenService } from './access-token.service'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { + ACCESS_TOKEN_EXPIRY_INVALID, + ACCESS_TOKEN_LABEL_SHORT, + ACCESS_TOKEN_NOT_FOUND, +} from 'src/errors'; +import { AuthUser } from 'src/types/AuthUser'; +import { PersonalAccessToken } from 'src/generated/prisma/client'; +import { AccessToken } from 'src/types/AccessToken'; +import { HttpStatus } from '@nestjs/common'; + +const mockPrisma = mockDeep(); + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const accessTokenService = new AccessTokenService(mockPrisma); + +const currentTime = new Date(); + +const user: AuthUser = { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + createdOn: currentTime, + currentGQLSession: {}, + currentRESTSession: {}, + lastLoggedOn: currentTime, + lastActiveOn: currentTime, +}; + +const PATCreatedOn = new Date(); +const expiryInDays = 7; +const PATExpiresOn = new Date( + PATCreatedOn.getTime() + expiryInDays * 24 * 60 * 60 * 1000, +); + +const userAccessToken: PersonalAccessToken = { + id: 'skfvhj8uvdfivb', + userUid: user.uid, + label: 'test', + token: '0140e328-b187-4823-ae4b-ed4bec832ac2', + expiresOn: PATExpiresOn, + createdOn: PATCreatedOn, + updatedOn: new Date(), +}; + +const userAccessTokenCasted: AccessToken = { + id: userAccessToken.id, + label: userAccessToken.label, + createdOn: userAccessToken.createdOn, + lastUsedOn: userAccessToken.updatedOn, + expiresOn: userAccessToken.expiresOn, +}; + +beforeEach(() => { + mockReset(mockPrisma); +}); + +describe('AccessTokenService', () => { + describe('createPAT', () => { + test('should throw ACCESS_TOKEN_LABEL_SHORT if label is too short', async () => { + const result = await accessTokenService.createPAT( + { + label: 'a', + expiryInDays: 7, + }, + user, + ); + expect(result).toEqualLeft({ + message: ACCESS_TOKEN_LABEL_SHORT, + statusCode: HttpStatus.BAD_REQUEST, + }); + }); + + test('should throw ACCESS_TOKEN_EXPIRY_INVALID if expiry date is invalid', async () => { + const result = await accessTokenService.createPAT( + { + label: 'test', + expiryInDays: 9, + }, + user, + ); + expect(result).toEqualLeft({ + message: ACCESS_TOKEN_EXPIRY_INVALID, + statusCode: HttpStatus.BAD_REQUEST, + }); + }); + + test('should successfully create a new Access Token', async () => { + mockPrisma.personalAccessToken.create.mockResolvedValueOnce( + userAccessToken, + ); + + const result = await accessTokenService.createPAT( + { + label: userAccessToken.label, + expiryInDays, + }, + user, + ); + expect(result).toEqualRight({ + token: `pat-${userAccessToken.token}`, + info: userAccessTokenCasted, + }); + }); + }); + + describe('deletePAT', () => { + test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => { + mockPrisma.personalAccessToken.deleteMany.mockResolvedValueOnce({ + count: 0, + }); + + const result = await accessTokenService.deletePAT( + userAccessToken.id, + user.uid, + ); + expect(mockPrisma.personalAccessToken.deleteMany).toHaveBeenCalledWith({ + where: { id: userAccessToken.id, userUid: user.uid }, + }); + expect(result).toEqualLeft({ + message: ACCESS_TOKEN_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + test('should successfully delete a new Access Token', async () => { + mockPrisma.personalAccessToken.deleteMany.mockResolvedValueOnce({ + count: 1, + }); + + const result = await accessTokenService.deletePAT( + userAccessToken.id, + user.uid, + ); + expect(mockPrisma.personalAccessToken.deleteMany).toHaveBeenCalledWith({ + where: { id: userAccessToken.id, userUid: user.uid }, + }); + expect(result).toEqualRight(true); + }); + + test('should throw ACCESS_TOKEN_NOT_FOUND when token belongs to a different user', async () => { + mockPrisma.personalAccessToken.deleteMany.mockResolvedValueOnce({ + count: 0, + }); + + const result = await accessTokenService.deletePAT( + userAccessToken.id, + 'different-user-uid', + ); + expect(mockPrisma.personalAccessToken.deleteMany).toHaveBeenCalledWith({ + where: { id: userAccessToken.id, userUid: 'different-user-uid' }, + }); + expect(result).toEqualLeft({ + message: ACCESS_TOKEN_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); + }); + + describe('listAllUserPAT', () => { + test('should successfully return a list of user Access Tokens', async () => { + mockPrisma.personalAccessToken.findMany.mockResolvedValueOnce([ + userAccessToken, + ]); + + const result = await accessTokenService.listAllUserPAT(user.uid, 0, 10); + expect(result).toEqual([userAccessTokenCasted]); + }); + }); + + describe('getUserPAT', () => { + test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => { + mockPrisma.personalAccessToken.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await accessTokenService.getUserPAT(userAccessToken.token); + expect(result).toEqualLeft(ACCESS_TOKEN_NOT_FOUND); + }); + + test('should successfully return a user Access Tokens', async () => { + mockPrisma.personalAccessToken.findUniqueOrThrow.mockResolvedValueOnce({ + ...userAccessToken, + user, + } as any); + + const result = await accessTokenService.getUserPAT( + `pat-${userAccessToken.token}`, + ); + expect(result).toEqualRight({ + user, + ...userAccessToken, + } as any); + }); + }); + + describe('updateLastUsedforPAT', () => { + test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => { + mockPrisma.personalAccessToken.update.mockRejectedValueOnce( + 'RecordNotFound', + ); + + const result = await accessTokenService.updateLastUsedForPAT( + userAccessToken.token, + ); + expect(result).toEqualLeft(ACCESS_TOKEN_NOT_FOUND); + }); + + test('should successfully update lastUsedOn for a user Access Tokens', async () => { + mockPrisma.personalAccessToken.update.mockResolvedValueOnce( + userAccessToken, + ); + + const result = await accessTokenService.updateLastUsedForPAT( + `pat-${userAccessToken.token}`, + ); + expect(result).toEqualRight(userAccessTokenCasted); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/access-token/access-token.service.ts b/packages/hoppscotch-backend/src/access-token/access-token.service.ts new file mode 100644 index 0000000..70659c6 --- /dev/null +++ b/packages/hoppscotch-backend/src/access-token/access-token.service.ts @@ -0,0 +1,193 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { CreateAccessTokenDto } from './dto/create-access-token.dto'; +import { AuthUser } from 'src/types/AuthUser'; +import { calculateExpirationDate, isValidLength } from 'src/utils'; +import * as E from 'fp-ts/Either'; +import { + ACCESS_TOKEN_EXPIRY_INVALID, + ACCESS_TOKEN_LABEL_SHORT, + ACCESS_TOKEN_NOT_FOUND, +} from 'src/errors'; +import { CreateAccessTokenResponse } from './helper'; +import { PersonalAccessToken } from 'src/generated/prisma/client'; +import { AccessToken } from 'src/types/AccessToken'; + +@Injectable() +export class AccessTokenService { + constructor(private readonly prisma: PrismaService) {} + + TITLE_LENGTH = 3; + VALID_TOKEN_DURATIONS = [7, 30, 60, 90]; + TOKEN_PREFIX = 'pat-'; + + /** + * Validate the expiration date of the token + * + * @param expiresOn Number of days the token is valid for + * @returns Boolean indicating if the expiration date is valid + */ + private validateExpirationDate(expiresOn: null | number) { + if (expiresOn === null || this.VALID_TOKEN_DURATIONS.includes(expiresOn)) + return true; + return false; + } + + /** + * Typecast a database PersonalAccessToken to a AccessToken model + * @param token database PersonalAccessToken + * @returns AccessToken model + */ + private cast(token: PersonalAccessToken): AccessToken { + return { + id: token.id, + label: token.label, + createdOn: token.createdOn, + expiresOn: token.expiresOn, + lastUsedOn: token.updatedOn, + }; + } + + /** + * Extract UUID from the token + * + * @param token Personal Access Token + * @returns UUID of the token + */ + private extractUUID(token): string | null { + if (!token.startsWith(this.TOKEN_PREFIX)) return null; + return token.slice(this.TOKEN_PREFIX.length); + } + + /** + * Create a Personal Access Token + * + * @param createAccessTokenDto DTO for creating a Personal Access Token + * @param user AuthUser object + * @returns Either of the created token or error message + */ + async createPAT(createAccessTokenDto: CreateAccessTokenDto, user: AuthUser) { + const isTitleValid = isValidLength( + createAccessTokenDto.label, + this.TITLE_LENGTH, + ); + if (!isTitleValid) + return E.left({ + message: ACCESS_TOKEN_LABEL_SHORT, + statusCode: HttpStatus.BAD_REQUEST, + }); + + if (!this.validateExpirationDate(createAccessTokenDto.expiryInDays)) + return E.left({ + message: ACCESS_TOKEN_EXPIRY_INVALID, + statusCode: HttpStatus.BAD_REQUEST, + }); + + const createdPAT = await this.prisma.personalAccessToken.create({ + data: { + userUid: user.uid, + label: createAccessTokenDto.label, + expiresOn: calculateExpirationDate(createAccessTokenDto.expiryInDays), + }, + }); + + const res: CreateAccessTokenResponse = { + token: `${this.TOKEN_PREFIX}${createdPAT.token}`, + info: this.cast(createdPAT), + }; + + return E.right(res); + } + + /** + * Delete a Personal Access Token + * + * @param accessTokenID ID of the Personal Access Token + * @param userUid UID of the user requesting the deletion + * @returns Either of true or error message + */ + async deletePAT(accessTokenID: string, userUid: string) { + const { count } = await this.prisma.personalAccessToken.deleteMany({ + where: { id: accessTokenID, userUid }, + }); + + if (count === 0) { + return E.left({ + message: ACCESS_TOKEN_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + } + + return E.right(true); + } + + /** + * List all Personal Access Tokens of a user + * + * @param userUid UID of the user + * @param offset Offset for pagination + * @param limit Limit for pagination + * @returns Either of the list of Personal Access Tokens or error message + */ + async listAllUserPAT(userUid: string, offset: number, limit: number) { + const userPATs = await this.prisma.personalAccessToken.findMany({ + where: { + userUid: userUid, + }, + skip: offset, + take: limit, + orderBy: { + createdOn: 'desc', + }, + }); + + const userAccessTokenList = userPATs.map((pat) => this.cast(pat)); + + return userAccessTokenList; + } + + /** + * Get a Personal Access Token + * + * @param accessToken Personal Access Token + * @returns Either of the Personal Access Token or error message + */ + async getUserPAT(accessToken: string) { + const extractedToken = this.extractUUID(accessToken); + if (!extractedToken) return E.left(ACCESS_TOKEN_NOT_FOUND); + + try { + const userPAT = await this.prisma.personalAccessToken.findUniqueOrThrow({ + where: { token: extractedToken }, + include: { user: true }, + }); + return E.right(userPAT); + } catch { + return E.left(ACCESS_TOKEN_NOT_FOUND); + } + } + + /** + * Update the last used date of a Personal Access Token + * + * @param token Personal Access Token + * @returns Either of the updated Personal Access Token or error message + */ + async updateLastUsedForPAT(token: string) { + const extractedToken = this.extractUUID(token); + if (!extractedToken) return E.left(ACCESS_TOKEN_NOT_FOUND); + + try { + const updatedAccessToken = await this.prisma.personalAccessToken.update({ + where: { token: extractedToken }, + data: { + updatedOn: new Date(), + }, + }); + + return E.right(this.cast(updatedAccessToken)); + } catch { + return E.left(ACCESS_TOKEN_NOT_FOUND); + } + } +} diff --git a/packages/hoppscotch-backend/src/access-token/dto/create-access-token.dto.ts b/packages/hoppscotch-backend/src/access-token/dto/create-access-token.dto.ts new file mode 100644 index 0000000..c6b51cb --- /dev/null +++ b/packages/hoppscotch-backend/src/access-token/dto/create-access-token.dto.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty, IsNumber, IsString, ValidateIf } from 'class-validator'; + +// Inputs to create a new PAT +export class CreateAccessTokenDto { + @IsString() + @IsNotEmpty() + label: string; + + @ValidateIf((o) => o.expiryInDays !== null) + @IsNumber() + expiryInDays: number | null; +} diff --git a/packages/hoppscotch-backend/src/access-token/helper.ts b/packages/hoppscotch-backend/src/access-token/helper.ts new file mode 100644 index 0000000..dc52ed5 --- /dev/null +++ b/packages/hoppscotch-backend/src/access-token/helper.ts @@ -0,0 +1,17 @@ +import { AccessToken } from 'src/types/AccessToken'; + +// Response type of PAT creation method +export type CreateAccessTokenResponse = { + token: string; + info: AccessToken; +}; + +// Response type of any error in PAT module +export type CLIErrorResponse = { + reason: string; +}; + +// Return a CLIErrorResponse object +export function createCLIErrorResponse(reason: string): CLIErrorResponse { + return { reason }; +} diff --git a/packages/hoppscotch-backend/src/admin/admin.model.ts b/packages/hoppscotch-backend/src/admin/admin.model.ts new file mode 100644 index 0000000..43bd196 --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/admin.model.ts @@ -0,0 +1,9 @@ +import { ObjectType, OmitType } from '@nestjs/graphql'; +import { User } from 'src/user/user.model'; + +@ObjectType() +export class Admin extends OmitType(User, [ + 'isAdmin', + 'currentRESTSession', + 'currentGQLSession', +]) {} diff --git a/packages/hoppscotch-backend/src/admin/admin.module.ts b/packages/hoppscotch-backend/src/admin/admin.module.ts new file mode 100644 index 0000000..1fb4c27 --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/admin.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { AdminResolver } from './admin.resolver'; +import { AdminService } from './admin.service'; +import { UserModule } from '../user/user.module'; +import { TeamModule } from '../team/team.module'; +import { TeamInvitationModule } from '../team-invitation/team-invitation.module'; +import { TeamEnvironmentsModule } from '../team-environments/team-environments.module'; +import { TeamCollectionModule } from '../team-collection/team-collection.module'; +import { TeamRequestModule } from '../team-request/team-request.module'; +import { InfraResolver } from './infra.resolver'; +import { ShortcodeModule } from 'src/shortcode/shortcode.module'; +import { InfraConfigModule } from 'src/infra-config/infra-config.module'; +import { UserHistoryModule } from 'src/user-history/user-history.module'; + +@Module({ + imports: [ + UserModule, + TeamModule, + TeamInvitationModule, + TeamEnvironmentsModule, + TeamCollectionModule, + TeamRequestModule, + ShortcodeModule, + InfraConfigModule, + UserHistoryModule, + ], + providers: [InfraResolver, AdminResolver, AdminService], + exports: [AdminService], +}) +export class AdminModule {} diff --git a/packages/hoppscotch-backend/src/admin/admin.resolver.ts b/packages/hoppscotch-backend/src/admin/admin.resolver.ts new file mode 100644 index 0000000..bb1469c --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/admin.resolver.ts @@ -0,0 +1,381 @@ +import { + Args, + ID, + Mutation, + Query, + Resolver, + Subscription, +} from '@nestjs/graphql'; +import { Admin } from './admin.model'; +import { UseGuards } from '@nestjs/common'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { GqlAdminGuard } from './guards/gql-admin.guard'; +import { GqlAdmin } from './decorators/gql-admin.decorator'; +import { AdminService } from './admin.service'; +import * as E from 'fp-ts/Either'; +import { throwErr } from '../utils'; +import { AuthUser } from '../types/AuthUser'; +import { InvitedUser } from './invited-user.model'; +import { GqlUser } from '../decorators/gql-user.decorator'; +import { PubSubService } from '../pubsub/pubsub.service'; +import { Team, TeamMember } from '../team/team.model'; +import { + AddUserToTeamArgs, + ChangeUserRoleInTeamArgs, +} from './input-types.args'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { SkipThrottle } from '@nestjs/throttler'; +import { UserDeletionResult } from 'src/user/user.model'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => Admin) +export class AdminResolver { + constructor( + private readonly adminService: AdminService, + private readonly pubsub: PubSubService, + ) {} + + /* Query */ + + @Query(() => Admin, { + description: 'Gives details of the admin executing this query', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + admin(@GqlAdmin() admin: Admin) { + return admin; + } + + /* Mutations */ + + @Mutation(() => InvitedUser, { + description: 'Invite a user to the infra using email', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async inviteNewUser( + @GqlUser() adminUser: AuthUser, + @Args({ + name: 'inviteeEmail', + description: 'invitee email', + }) + inviteeEmail: string, + ): Promise { + const invitedUser = await this.adminService.inviteUserToSignInViaEmail( + adminUser.uid, + adminUser.email, + inviteeEmail, + ); + if (E.isLeft(invitedUser)) throwErr(invitedUser.left); + return invitedUser.right; + } + + @Mutation(() => Boolean, { + description: 'Revoke a user invites by invitee emails', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async revokeUserInvitationsByAdmin( + @Args({ + name: 'inviteeEmails', + description: 'Invitee Emails', + type: () => [String], + }) + inviteeEmails: string[], + ): Promise { + const invite = await this.adminService.revokeUserInvitations(inviteeEmails); + if (E.isLeft(invite)) throwErr(invite.left); + return invite.right; + } + + @Mutation(() => Boolean, { + description: 'Delete an user account from infra', + deprecationReason: 'Use removeUsersByAdmin instead', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async removeUserByAdmin( + @Args({ + name: 'userUID', + description: 'users UID', + type: () => ID, + }) + userUID: string, + ): Promise { + const removedUser = await this.adminService.removeUserAccount(userUID); + if (E.isLeft(removedUser)) throwErr(removedUser.left); + return removedUser.right; + } + + @Mutation(() => [UserDeletionResult], { + description: 'Delete user accounts from infra', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async removeUsersByAdmin( + @Args({ + name: 'userUIDs', + description: 'users UID', + type: () => [ID], + }) + userUIDs: string[], + ): Promise { + const deletionResults = + await this.adminService.removeUserAccounts(userUIDs); + if (E.isLeft(deletionResults)) throwErr(deletionResults.left); + return deletionResults.right; + } + + @Mutation(() => Boolean, { + description: 'Make user an admin', + deprecationReason: 'Use makeUsersAdmin instead', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async makeUserAdmin( + @Args({ + name: 'userUID', + description: 'users UID', + type: () => ID, + }) + userUID: string, + ): Promise { + const admin = await this.adminService.makeUserAdmin(userUID); + if (E.isLeft(admin)) throwErr(admin.left); + return admin.right; + } + + @Mutation(() => Boolean, { + description: 'Make users an admin', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async makeUsersAdmin( + @Args({ + name: 'userUIDs', + description: 'users UID', + type: () => [ID], + }) + userUIDs: string[], + ): Promise { + const isUpdated = await this.adminService.makeUsersAdmin(userUIDs); + if (E.isLeft(isUpdated)) throwErr(isUpdated.left); + return isUpdated.right; + } + + @Mutation(() => Boolean, { + description: 'Update user display name', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async updateUserDisplayNameByAdmin( + @Args({ + name: 'userUID', + description: 'users UID', + type: () => ID, + }) + userUID: string, + @Args({ + name: 'displayName', + description: 'users display name', + }) + displayName: string, + ): Promise { + const isUpdated = await this.adminService.updateUserDisplayName( + userUID, + displayName, + ); + if (E.isLeft(isUpdated)) throwErr(isUpdated.left); + return isUpdated.right; + } + + @Mutation(() => Boolean, { + description: 'Remove user as admin', + deprecationReason: 'Use demoteUsersByAdmin instead', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async removeUserAsAdmin( + @Args({ + name: 'userUID', + description: 'users UID', + type: () => ID, + }) + userUID: string, + ): Promise { + const admin = await this.adminService.removeUserAsAdmin(userUID); + if (E.isLeft(admin)) throwErr(admin.left); + return admin.right; + } + + @Mutation(() => Boolean, { + description: 'Remove users as admin', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async demoteUsersByAdmin( + @Args({ + name: 'userUIDs', + description: 'users UID', + type: () => [ID], + }) + userUIDs: string[], + ): Promise { + const isUpdated = await this.adminService.demoteUsersByAdmin(userUIDs); + if (E.isLeft(isUpdated)) throwErr(isUpdated.left); + return isUpdated.right; + } + + @Mutation(() => Team, { + description: + 'Create a new team by providing the user uid to nominate as Team owner', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async createTeamByAdmin( + @GqlAdmin() adminUser: Admin, + @Args({ + name: 'userUid', + description: 'users uid to make team owner', + type: () => ID, + }) + userUid: string, + @Args({ name: 'name', description: 'Displayed name of the team' }) + name: string, + ): Promise { + const createdTeam = await this.adminService.createATeam(userUid, name); + if (E.isLeft(createdTeam)) throwErr(createdTeam.left); + return createdTeam.right; + } + @Mutation(() => TeamMember, { + description: 'Change the role of a user in a team', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async changeUserRoleInTeamByAdmin( + @GqlAdmin() adminUser: Admin, + @Args() args: ChangeUserRoleInTeamArgs, + ): Promise { + const updatedRole = await this.adminService.changeRoleOfUserTeam( + args.userUID, + args.teamID, + args.newRole, + ); + if (E.isLeft(updatedRole)) throwErr(updatedRole.left); + return updatedRole.right; + } + @Mutation(() => Boolean, { + description: 'Remove the user from a team', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async removeUserFromTeamByAdmin( + @GqlAdmin() adminUser: Admin, + @Args({ + name: 'userUid', + description: 'users UID', + type: () => ID, + }) + userUid: string, + @Args({ + name: 'teamID', + description: 'team ID', + type: () => ID, + }) + teamID: string, + ): Promise { + const removedUser = await this.adminService.removeUserFromTeam( + userUid, + teamID, + ); + if (E.isLeft(removedUser)) throwErr(removedUser.left); + return removedUser.right; + } + @Mutation(() => TeamMember, { + description: 'Add a user to a team with email and team member role', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async addUserToTeamByAdmin( + @GqlAdmin() adminUser: Admin, + @Args() args: AddUserToTeamArgs, + ): Promise { + const addedUser = await this.adminService.addUserToTeam( + args.teamID, + args.userEmail, + args.role, + ); + if (E.isLeft(addedUser)) throwErr(addedUser.left); + return addedUser.right; + } + + @Mutation(() => Team, { + description: 'Change a team name', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async renameTeamByAdmin( + @GqlAdmin() adminUser: Admin, + @Args({ name: 'teamID', description: 'ID of the team', type: () => ID }) + teamID: string, + @Args({ name: 'newName', description: 'The updated name of the team' }) + newName: string, + ): Promise { + const renamedTeam = await this.adminService.renameATeam(teamID, newName); + if (E.isLeft(renamedTeam)) throwErr(renamedTeam.left); + return renamedTeam.right; + } + @Mutation(() => Boolean, { + description: 'Delete a team', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async deleteTeamByAdmin( + @Args({ name: 'teamID', description: 'ID of the team', type: () => ID }) + teamID: string, + ): Promise { + const deletedTeam = await this.adminService.deleteATeam(teamID); + if (E.isLeft(deletedTeam)) throwErr(deletedTeam.left); + return deletedTeam.right; + } + + @Mutation(() => Boolean, { + description: 'Revoke a team Invite by Invite ID', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async revokeTeamInviteByAdmin( + @Args({ + name: 'inviteID', + description: 'Team Invite ID', + type: () => ID, + }) + inviteID: string, + ): Promise { + const invite = await this.adminService.revokeTeamInviteByID(inviteID); + if (E.isLeft(invite)) throwErr(invite.left); + return true; + } + + @Mutation(() => Boolean, { + description: 'Revoke Shortcode by ID', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async revokeShortcodeByAdmin( + @Args({ + name: 'code', + description: 'The shortcode to delete', + type: () => ID, + }) + code: string, + ): Promise { + const res = await this.adminService.deleteShortcode(code); + if (E.isLeft(res)) throwErr(res.left); + return true; + } + + @Mutation(() => Boolean, { + description: 'Revoke all User History', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async revokeAllUserHistoryByAdmin(): Promise { + const isDeleted = await this.adminService.deleteAllUserHistory(); + if (E.isLeft(isDeleted)) throwErr(isDeleted.left); + return true; + } + + /* Subscriptions */ + + @Subscription(() => InvitedUser, { + description: 'Listen for User Invitation', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlAdminGuard) + userInvited(@GqlUser() admin: AuthUser) { + return this.pubsub.asyncIterator(`admin/${admin.uid}/invited`); + } +} diff --git a/packages/hoppscotch-backend/src/admin/admin.service.spec.ts b/packages/hoppscotch-backend/src/admin/admin.service.spec.ts new file mode 100644 index 0000000..2a9cd8d --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/admin.service.spec.ts @@ -0,0 +1,301 @@ +import { AdminService } from './admin.service'; +import { PubSubService } from '../pubsub/pubsub.service'; +import { mockDeep } from 'jest-mock-extended'; +import { InvitedUsers, User as DbUser } from 'src/generated/prisma/client'; +import { UserService } from '../user/user.service'; +import { TeamService } from '../team/team.service'; +import { TeamEnvironmentsService } from '../team-environments/team-environments.service'; +import { TeamRequestService } from '../team-request/team-request.service'; +import { TeamInvitationService } from '../team-invitation/team-invitation.service'; +import { TeamCollectionService } from '../team-collection/team-collection.service'; +import { MailerService } from '../mailer/mailer.service'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { + DUPLICATE_EMAIL, + INVALID_EMAIL, + ONLY_ONE_ADMIN_ACCOUNT, + USER_ALREADY_INVITED, + USER_INVITATION_DELETION_FAILED, +} from '../errors'; +import { ShortcodeService } from 'src/shortcode/shortcode.service'; +import { ConfigService } from '@nestjs/config'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; +import * as E from 'fp-ts/Either'; +import { UserHistoryService } from 'src/user-history/user-history.service'; + +const mockPrisma = mockDeep(); +const mockPubSub = mockDeep(); +const mockUserService = mockDeep(); +const mockTeamService = mockDeep(); +const mockTeamEnvironmentsService = mockDeep(); +const mockTeamRequestService = mockDeep(); +const mockTeamInvitationService = mockDeep(); +const mockTeamCollectionService = mockDeep(); +const mockMailerService = mockDeep(); +const mockShortcodeService = mockDeep(); +const mockConfigService = mockDeep(); +const mockUserHistoryService = mockDeep(); + +const adminService = new AdminService( + mockUserService, + mockTeamService, + mockTeamCollectionService, + mockTeamRequestService, + mockTeamEnvironmentsService, + mockTeamInvitationService, + mockPubSub, + mockPrisma, + mockMailerService, + mockShortcodeService, + mockConfigService, + mockUserHistoryService, +); + +const invitedUsers: InvitedUsers[] = [ + { + adminUid: 'uid1', + adminEmail: 'admin1@example.com', + inviteeEmail: 'i@example.com', + invitedOn: new Date(), + }, + { + adminUid: 'uid2', + adminEmail: 'admin2@example.com', + inviteeEmail: 'u@example.com', + invitedOn: new Date(), + }, +]; + +const dbAdminUsers: DbUser[] = [ + { + uid: 'uid 1', + displayName: 'displayName', + email: 'email@email.com', + photoURL: 'photoURL', + isAdmin: true, + refreshToken: 'refreshToken', + currentRESTSession: '', + currentGQLSession: '', + lastLoggedOn: new Date(), + lastActiveOn: new Date(), + createdOn: new Date(), + }, + { + uid: 'uid 2', + displayName: 'displayName', + email: 'email@email.com', + photoURL: 'photoURL', + isAdmin: true, + refreshToken: 'refreshToken', + currentRESTSession: '', + currentGQLSession: '', + lastLoggedOn: new Date(), + lastActiveOn: new Date(), + createdOn: new Date(), + }, +]; + +describe('AdminService', () => { + describe('fetchInvitedUsers', () => { + test('should resolve right and apply pagination correctly', async () => { + mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]); + mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers); + + const paginationArgs: OffsetPaginationArgs = { take: 5, skip: 2 }; + await adminService.fetchInvitedUsers(paginationArgs); + + expect(mockPrisma.invitedUsers.findMany).toHaveBeenCalledWith({ + ...paginationArgs, + orderBy: { + invitedOn: 'desc', + }, + where: { + NOT: { + inviteeEmail: { + in: [dbAdminUsers[0].email], + mode: 'insensitive', + }, + }, + }, + }); + }); + test('should resolve right and return an array of invited users', async () => { + const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 }; + + mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]); + mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers); + + const results = await adminService.fetchInvitedUsers(paginationArgs); + expect(results).toEqual(invitedUsers); + }); + test('should resolve left and return an empty array if invited users not found', async () => { + const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 }; + + mockPrisma.invitedUsers.findMany.mockResolvedValue([]); + + const results = await adminService.fetchInvitedUsers(paginationArgs); + expect(results).toEqual([]); + }); + }); + + describe('inviteUserToSignInViaEmail', () => { + test('should resolve right and create a invited user', async () => { + mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(null); + mockPrisma.invitedUsers.create.mockResolvedValueOnce(invitedUsers[0]); + const result = await adminService.inviteUserToSignInViaEmail( + invitedUsers[0].adminUid, + invitedUsers[0].adminEmail, + invitedUsers[0].inviteeEmail, + ); + expect(mockPrisma.invitedUsers.create).toHaveBeenCalledWith({ + data: { + adminUid: invitedUsers[0].adminUid, + adminEmail: invitedUsers[0].adminEmail, + inviteeEmail: invitedUsers[0].inviteeEmail, + }, + }); + return expect(result).toEqualRight(invitedUsers[0]); + }); + test('should resolve right, create a invited user and publish a subscription', async () => { + mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(null); + mockPrisma.invitedUsers.create.mockResolvedValueOnce(invitedUsers[0]); + await adminService.inviteUserToSignInViaEmail( + invitedUsers[0].adminUid, + invitedUsers[0].adminEmail, + invitedUsers[0].inviteeEmail, + ); + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `admin/${invitedUsers[0].adminUid}/invited`, + invitedUsers[0], + ); + }); + test('should resolve left and return an error when invalid invitee email is passed', async () => { + const result = await adminService.inviteUserToSignInViaEmail( + invitedUsers[0].adminUid, + invitedUsers[0].adminEmail, + 'invalidemail', + ); + return expect(result).toEqualLeft(INVALID_EMAIL); + }); + test('should resolve left and return an error when user already invited', async () => { + mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(invitedUsers[0]); + const result = await adminService.inviteUserToSignInViaEmail( + invitedUsers[0].adminUid, + invitedUsers[0].adminEmail, + invitedUsers[0].inviteeEmail, + ); + return expect(result).toEqualLeft(USER_ALREADY_INVITED); + }); + test('should resolve left and return an error when invitee and admin email is same', async () => { + const result = await adminService.inviteUserToSignInViaEmail( + invitedUsers[0].adminUid, + invitedUsers[0].inviteeEmail, + invitedUsers[0].inviteeEmail, + ); + return expect(result).toEqualLeft(DUPLICATE_EMAIL); + }); + }); + + describe('revokeUserInvitations', () => { + test('should resolve left and return error if email not invited', async () => { + mockPrisma.invitedUsers.deleteMany.mockRejectedValueOnce( + 'RecordNotFound', + ); + + const result = await adminService.revokeUserInvitations([ + 'test@gmail.com', + ]); + + expect(result).toEqualLeft(USER_INVITATION_DELETION_FAILED); + }); + + test('should resolve right and return deleted invitee email', async () => { + mockPrisma.invitedUsers.deleteMany.mockResolvedValueOnce({ count: 1 }); + + const result = await adminService.revokeUserInvitations([ + invitedUsers[0].inviteeEmail, + ]); + + expect(mockPrisma.invitedUsers.deleteMany).toHaveBeenCalledWith({ + where: { + inviteeEmail: { + in: [invitedUsers[0].inviteeEmail], + mode: 'insensitive', + }, + }, + }); + expect(result).toEqualRight(true); + }); + }); + + describe('removeUsersAsAdmin', () => { + test('should resolve right and make admins to users', async () => { + mockUserService.fetchAdminUsers.mockResolvedValueOnce(dbAdminUsers); + mockUserService.removeUsersAsAdmin.mockResolvedValueOnce(E.right(true)); + + return expect( + await adminService.demoteUsersByAdmin([dbAdminUsers[0].uid]), + ).toEqualRight(true); + }); + + test('should resolve left and return error if only one admin in the infra', async () => { + mockUserService.fetchAdminUsers.mockResolvedValueOnce(dbAdminUsers); + mockUserService.removeUsersAsAdmin.mockResolvedValueOnce(E.right(true)); + + return expect( + await adminService.demoteUsersByAdmin( + dbAdminUsers.map((user) => user.uid), + ), + ).toEqualLeft(ONLY_ONE_ADMIN_ACCOUNT); + }); + }); + + describe('getUsersCount', () => { + test('should return count of all users in the organization', async () => { + mockUserService.getUsersCount.mockResolvedValueOnce(10); + + const result = await adminService.getUsersCount(); + expect(result).toEqual(10); + }); + }); + + describe('getTeamsCount', () => { + test('should return count of all teams in the organization', async () => { + mockTeamService.getTeamsCount.mockResolvedValueOnce(10); + + const result = await adminService.getTeamsCount(); + expect(result).toEqual(10); + }); + }); + + describe('getTeamCollectionsCount', () => { + test('should return count of all Team Collections in the organization', async () => { + mockTeamCollectionService.getTeamCollectionsCount.mockResolvedValueOnce( + 10, + ); + + const result = await adminService.getTeamCollectionsCount(); + expect(result).toEqual(10); + }); + }); + + describe('getTeamRequestsCount', () => { + test('should return count of all Team Collections in the organization', async () => { + mockTeamRequestService.getTeamRequestsCount.mockResolvedValueOnce(10); + + const result = await adminService.getTeamRequestsCount(); + expect(result).toEqual(10); + }); + }); + + describe('deleteAllUserHistory', () => { + test('should resolve right and delete all user history', async () => { + mockUserHistoryService.deleteAllHistories.mockResolvedValueOnce( + E.right(true), + ); + + const result = await adminService.deleteAllUserHistory(); + expect(result).toEqualRight(true); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/admin/admin.service.ts b/packages/hoppscotch-backend/src/admin/admin.service.ts new file mode 100644 index 0000000..40330c2 --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/admin.service.ts @@ -0,0 +1,683 @@ +import { Injectable } from '@nestjs/common'; +import { UserService } from '../user/user.service'; +import { PubSubService } from '../pubsub/pubsub.service'; +import { PrismaService } from '../prisma/prisma.service'; +import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; +import { validateEmail } from '../utils'; +import { + ADMIN_CAN_NOT_BE_DELETED, + DUPLICATE_EMAIL, + EMAIL_FAILED, + INVALID_EMAIL, + ONLY_ONE_ADMIN_ACCOUNT, + TEAM_INVITE_ALREADY_MEMBER, + USERS_NOT_FOUND, + USER_ALREADY_INVITED, + USER_INVITATION_DELETION_FAILED, + USER_IS_ADMIN, + USER_NOT_FOUND, +} from '../errors'; +import { MailerService } from '../mailer/mailer.service'; +import { InvitedUser } from './invited-user.model'; +import { TeamService } from '../team/team.service'; +import { TeamCollectionService } from '../team-collection/team-collection.service'; +import { TeamRequestService } from '../team-request/team-request.service'; +import { TeamEnvironmentsService } from '../team-environments/team-environments.service'; +import { TeamInvitationService } from '../team-invitation/team-invitation.service'; +import { TeamAccessRole } from '../team/team.model'; +import { ShortcodeService } from 'src/shortcode/shortcode.service'; +import { ConfigService } from '@nestjs/config'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; +import { UserDeletionResult } from 'src/user/user.model'; +import { UserHistoryService } from 'src/user-history/user-history.service'; + +@Injectable() +export class AdminService { + constructor( + private readonly userService: UserService, + private readonly teamService: TeamService, + private readonly teamCollectionService: TeamCollectionService, + private readonly teamRequestService: TeamRequestService, + private readonly teamEnvironmentsService: TeamEnvironmentsService, + private readonly teamInvitationService: TeamInvitationService, + private readonly pubsub: PubSubService, + private readonly prisma: PrismaService, + private readonly mailerService: MailerService, + private readonly shortcodeService: ShortcodeService, + private readonly configService: ConfigService, + private readonly userHistoryService: UserHistoryService, + ) {} + + /** + * Fetch all the users in the infra. + * @param cursorID Users uid + * @param take number of users to fetch + * @returns an Either of array of user or error + * @deprecated use fetchUsersV2 instead + */ + async fetchUsers(cursorID: string, take: number) { + const allUsers = await this.userService.fetchAllUsers(cursorID, take); + return allUsers; + } + + /** + * Fetch all the users in the infra. + * @param searchString search on users displayName or email + * @param paginationOption pagination options + * @returns an Either of array of user or error + */ + async fetchUsersV2( + searchString: string, + paginationOption: OffsetPaginationArgs, + ) { + const allUsers = await this.userService.fetchAllUsersV2( + searchString, + paginationOption, + ); + return allUsers; + } + + /** + * Invite a user to join the infra. + * @param adminUID Admin's UID + * @param adminEmail Admin's email + * @param inviteeEmail Invitee's email + * @returns an Either of `InvitedUser` object or error + */ + async inviteUserToSignInViaEmail( + adminUID: string, + adminEmail: string, + inviteeEmail: string, + ) { + if (inviteeEmail.toLowerCase() == adminEmail.toLowerCase()) { + return E.left(DUPLICATE_EMAIL); + } + if (!validateEmail(inviteeEmail)) return E.left(INVALID_EMAIL); + + const alreadyInvitedUser = await this.prisma.invitedUsers.findFirst({ + where: { + inviteeEmail: { + equals: inviteeEmail, + mode: 'insensitive', + }, + }, + }); + if (alreadyInvitedUser != null) return E.left(USER_ALREADY_INVITED); + + try { + await this.mailerService.sendUserInvitationEmail(inviteeEmail, { + template: 'user-invitation', + variables: { + inviteeEmail: inviteeEmail, + magicLink: `${this.configService.get('VITE_BASE_URL')}`, + }, + }); + } catch (e) { + return E.left(EMAIL_FAILED); + } + + // Add invitee email to the list of invited users by admin + const dbInvitedUser = await this.prisma.invitedUsers.create({ + data: { + adminUid: adminUID, + adminEmail: adminEmail, + inviteeEmail: inviteeEmail, + }, + }); + + const invitedUser = { + adminEmail: dbInvitedUser.adminEmail, + adminUid: dbInvitedUser.adminUid, + inviteeEmail: dbInvitedUser.inviteeEmail, + invitedOn: dbInvitedUser.invitedOn, + }; + + // Publish invited user subscription + await this.pubsub.publish(`admin/${adminUID}/invited`, invitedUser); + + return E.right(invitedUser); + } + + /** + * Update the display name of a user + * @param userUid Who's display name is being updated + * @param displayName New display name of the user + * @returns an Either of boolean or error + */ + async updateUserDisplayName(userUid: string, displayName: string) { + const updatedUser = await this.userService.updateUserDisplayName( + userUid, + displayName, + ); + if (E.isLeft(updatedUser)) return E.left(updatedUser.left); + + return E.right(true); + } + + /** + * Revoke infra level user invitations + * @param inviteeEmails Invitee's emails + * @param adminUid Admin Uid + * @returns an Either of boolean or error string + */ + async revokeUserInvitations(inviteeEmails: string[]) { + const areAllEmailsValid = inviteeEmails.every((email) => + validateEmail(email), + ); + if (!areAllEmailsValid) { + return E.left(INVALID_EMAIL); + } + + try { + await this.prisma.invitedUsers.deleteMany({ + where: { + inviteeEmail: { in: inviteeEmails, mode: 'insensitive' }, + }, + }); + return E.right(true); + } catch (error) { + return E.left(USER_INVITATION_DELETION_FAILED); + } + } + + /** + * Fetch the list of invited users by the admin. + * @returns an Either of array of `InvitedUser` object or error + */ + async fetchInvitedUsers(paginationOption: OffsetPaginationArgs) { + const userEmailObjs = await this.prisma.user.findMany({ + select: { + email: true, + }, + }); + + const pendingInvitedUsers = await this.prisma.invitedUsers.findMany({ + take: paginationOption.take, + skip: paginationOption.skip, + orderBy: { + invitedOn: 'desc', + }, + where: { + NOT: { + inviteeEmail: { + in: userEmailObjs.map((user) => user.email), + mode: 'insensitive', + }, + }, + }, + }); + + const users: InvitedUser[] = pendingInvitedUsers.map( + (user) => { ...user }, + ); + + return users; + } + + /** + * Fetch all the teams in the infra. + * @param cursorID team id + * @param take number of items to fetch + * @returns an array of teams + * @deprecated use fetchAllTeamsV2 instead + */ + async fetchAllTeams(cursorID: string, take: number) { + const allTeams = await this.teamService.fetchAllTeams(cursorID, take); + return allTeams; + } + + /** + * Fetch all the teams in the infra. + * @param searchString search on team name or ID + * @param paginationOption pagination options + * @returns an array of teams + */ + async fetchAllTeamsV2( + searchString: string, + paginationOption: OffsetPaginationArgs, + ) { + const allTeams = await this.teamService.fetchAllTeamsV2( + searchString, + paginationOption, + ); + return allTeams; + } + + /** + * Fetch the count of all the members in a team. + * @param teamID team id + * @returns a count of team members + */ + async membersCountInTeam(teamID: string) { + const teamMembersCount = + await this.teamService.getCountOfMembersInTeam(teamID); + return teamMembersCount; + } + + /** + * Fetch count of all the collections in a team. + * @param teamID team id + * @returns a of count of collections + */ + async collectionCountInTeam(teamID: string) { + const teamCollectionsCount = + await this.teamCollectionService.totalCollectionsInTeam(teamID); + return teamCollectionsCount; + } + + /** + * Fetch the count of all the requests in a team. + * @param teamID team id + * @returns a count of total requests in a team + */ + async requestCountInTeam(teamID: string) { + const teamRequestsCount = + await this.teamRequestService.totalRequestsInATeam(teamID); + + return teamRequestsCount; + } + + /** + * Fetch the count of all the environments in a team. + * @param teamID team id + * @returns a count of environments in a team + */ + async environmentCountInTeam(teamID: string) { + const envCount = await this.teamEnvironmentsService.totalEnvsInTeam(teamID); + return envCount; + } + + /** + * Fetch all the invitations for a given team. + * @param teamID team id + * @returns an array team invitations + */ + async pendingInvitationCountInTeam(teamID: string) { + const invitations = + await this.teamInvitationService.getTeamInvitations(teamID); + + return invitations; + } + + /** + * Change the role of a user in a team + * @param userUid users uid + * @param teamID team id + * @returns an Either of updated `TeamMember` object or error + */ + async changeRoleOfUserTeam( + userUid: string, + teamID: string, + newRole: TeamAccessRole, + ) { + const updatedTeamMember = await this.teamService.updateTeamAccessRole( + teamID, + userUid, + newRole, + ); + + if (E.isLeft(updatedTeamMember)) return E.left(updatedTeamMember.left); + + return E.right(updatedTeamMember.right); + } + + /** + * Remove the user from a team + * @param userUid users uid + * @param teamID team id + * @returns an Either of boolean or error + */ + async removeUserFromTeam(userUid: string, teamID: string) { + const removedUser = await this.teamService.leaveTeam(teamID, userUid); + if (E.isLeft(removedUser)) return E.left(removedUser.left); + + return E.right(removedUser.right); + } + + /** + * Add the user to a team + * @param teamID team id + * @param userEmail users email + * @param role team member role for the user + * @returns an Either of boolean or error + */ + async addUserToTeam(teamID: string, userEmail: string, role: TeamAccessRole) { + if (!validateEmail(userEmail)) return E.left(INVALID_EMAIL); + + const user = await this.userService.findUserByEmail(userEmail); + if (O.isNone(user)) return E.left(USER_NOT_FOUND); + + const teamMember = await this.teamService.getTeamMemberTE( + teamID, + user.value.uid, + )(); + if (E.isLeft(teamMember)) { + const addedUser = await this.teamService.addMemberToTeamWithEmail( + teamID, + userEmail, + role, + ); + if (E.isLeft(addedUser)) return E.left(addedUser.left); + + const userInvitation = + await this.teamInvitationService.getTeamInviteByEmailAndTeamID( + userEmail, + teamID, + ); + + if (E.isRight(userInvitation)) { + await this.teamInvitationService.revokeInvitation( + userInvitation.right.id, + ); + } + + return E.right(addedUser.right); + } + + return E.left(TEAM_INVITE_ALREADY_MEMBER); + } + + /** + * Create a new team + * @param userUid user uid + * @param name team name + * @returns an Either of `Team` object or error + */ + async createATeam(userUid: string, name: string) { + const validUser = await this.userService.findUserById(userUid); + if (O.isNone(validUser)) return E.left(USER_NOT_FOUND); + + const createdTeam = await this.teamService.createTeam(name, userUid); + if (E.isLeft(createdTeam)) return E.left(createdTeam.left); + + return E.right(createdTeam.right); + } + + /** + * Renames a team + * @param teamID team ID + * @param newName new team name + * @returns an Either of `Team` object or error + */ + async renameATeam(teamID: string, newName: string) { + const renamedTeam = await this.teamService.renameTeam(teamID, newName); + if (E.isLeft(renamedTeam)) return E.left(renamedTeam.left); + + return E.right(renamedTeam.right); + } + + /** + * Deletes a team + * @param teamID team ID + * @returns an Either of boolean or error + */ + async deleteATeam(teamID: string) { + const deleteTeam = await this.teamService.deleteTeam(teamID); + if (E.isLeft(deleteTeam)) return E.left(deleteTeam.left); + + return E.right(deleteTeam.right); + } + + /** + * Fetch all admin accounts + * @returns an array of admin users + */ + async fetchAdmins() { + const admins = this.userService.fetchAdminUsers(); + return admins; + } + + /** + * Fetch a user by UID + * @param userUid User UID + * @returns an Either of `User` obj or error + */ + async fetchUserInfo(userUid: string) { + const user = await this.userService.findUserById(userUid); + if (O.isNone(user)) return E.left(USER_NOT_FOUND); + + return E.right(user.value); + } + + /** + * Remove a user account by UID + * @param userUid User UID + * @returns an Either of boolean or error + * + */ + async removeUserAccount(userUid: string) { + const user = await this.userService.findUserById(userUid); + if (O.isNone(user)) return E.left(USER_NOT_FOUND); + + if (user.value.isAdmin) return E.left(USER_IS_ADMIN); + + const delUser = await this.userService.deleteUserByUID(user.value)(); + if (E.isLeft(delUser)) return E.left(delUser.left); + return E.right(delUser.right); + } + + /** + * Remove user (not Admin) accounts by UIDs + * @param userUIDs User UIDs + * @returns an Either of boolean or error + */ + async removeUserAccounts(userUIDs: string[]) { + const userDeleteResult: UserDeletionResult[] = []; + + // step 1: fetch all users + const allUsersList = await this.userService.findUsersByIds(userUIDs); + if (allUsersList.length === 0) return E.left(USERS_NOT_FOUND); + + // step 2: admin user can not be deleted without removing admin status/role + allUsersList.forEach((user) => { + if (user.isAdmin) { + userDeleteResult.push({ + userUID: user.uid, + isDeleted: false, + errorMessage: ADMIN_CAN_NOT_BE_DELETED, + }); + } + }); + + const nonAdminUsers = allUsersList.filter((user) => !user.isAdmin); + const deletedUserEmails: string[] = []; + + // step 3: delete non-admin users + const deletionPromises = nonAdminUsers.map((user) => { + return this.userService + .deleteUserByUID(user)() + .then((res) => { + if (E.isLeft(res)) { + return { + userUID: user.uid, + isDeleted: false, + errorMessage: res.left, + } as UserDeletionResult; + } + + deletedUserEmails.push(user.email); + return { + userUID: user.uid, + isDeleted: true, + errorMessage: null, + } as UserDeletionResult; + }); + }); + const promiseResult = await Promise.allSettled(deletionPromises); + + // step 4: revoke all the invites sent to the deleted users + await this.revokeUserInvitations(deletedUserEmails); + + // step 5: return the result + promiseResult.forEach((result) => { + if (result.status === 'fulfilled') { + userDeleteResult.push(result.value); + } + }); + + return E.right(userDeleteResult); + } + + /** + * Make a user an admin + * @param userUid User UID + * @returns an Either of boolean or error + * @deprecated use makeUsersAdmin instead + */ + async makeUserAdmin(userUID: string) { + const admin = await this.userService.makeAdmin(userUID); + if (E.isLeft(admin)) return E.left(admin.left); + return E.right(true); + } + + /** + * Make users to admin + * @param userUid User UIDs + * @returns an Either of boolean or error + */ + async makeUsersAdmin(userUIDs: string[]) { + const isUpdated = await this.userService.makeAdmins(userUIDs); + if (E.isLeft(isUpdated)) return E.left(isUpdated.left); + return E.right(true); + } + + /** + * Remove user as admin + * @param userUid User UID + * @returns an Either of boolean or error + * @deprecated use demoteUsersByAdmin instead + */ + async removeUserAsAdmin(userUID: string) { + const adminUsers = await this.userService.fetchAdminUsers(); + if (adminUsers.length === 1) return E.left(ONLY_ONE_ADMIN_ACCOUNT); + + const admin = await this.userService.removeUserAsAdmin(userUID); + if (E.isLeft(admin)) return E.left(admin.left); + return E.right(true); + } + + /** + * Remove users as admin + * @param userUIDs User UIDs + * @returns an Either of boolean or error + */ + async demoteUsersByAdmin(userUIDs: string[]) { + const adminUsers = await this.userService.fetchAdminUsers(); + + const remainingAdmins = adminUsers.filter( + (adminUser) => !userUIDs.includes(adminUser.uid), + ); + if (remainingAdmins.length < 1) { + return E.left(ONLY_ONE_ADMIN_ACCOUNT); + } + + const isUpdated = await this.userService.removeUsersAsAdmin(userUIDs); + if (E.isLeft(isUpdated)) return E.left(isUpdated.left); + return E.right(isUpdated.right); + } + + /** + * Fetch list of all the Users in org + * @returns number of users in the org + */ + async getUsersCount() { + const usersCount = this.userService.getUsersCount(); + return usersCount; + } + + /** + * Fetch list of all the Teams in org + * @returns number of users in the org + */ + async getTeamsCount() { + const teamsCount = this.teamService.getTeamsCount(); + return teamsCount; + } + + /** + * Fetch list of all the Team Collections in org + * @returns number of users in the org + */ + async getTeamCollectionsCount() { + const teamCollectionCount = + this.teamCollectionService.getTeamCollectionsCount(); + return teamCollectionCount; + } + + /** + * Fetch list of all the Team Requests in org + * @returns number of users in the org + */ + async getTeamRequestsCount() { + const teamRequestCount = this.teamRequestService.getTeamRequestsCount(); + return teamRequestCount; + } + + /** + * Get team info by ID + * @param teamID Team ID + * @returns an Either of `Team` or error + */ + async getTeamInfo(teamID: string) { + const team = await this.teamService.getTeamWithIDTE(teamID)(); + if (E.isLeft(team)) return E.left(team.left); + return E.right(team.right); + } + + /** + * Revoke a team invite by ID + * @param inviteID Team Invite ID + * @returns an Either of boolean or error + */ + async revokeTeamInviteByID(inviteID: string) { + const teamInvite = + await this.teamInvitationService.revokeInvitation(inviteID); + + if (E.isLeft(teamInvite)) return E.left(teamInvite.left); + + return E.right(teamInvite.right); + } + + /** + * Fetch all created ShortCodes + * + * @param args Pagination arguments + * @param userEmail User email + * @returns ShortcodeWithUserEmail + */ + async fetchAllShortcodes( + cursorID: string, + take: number, + userEmail: string = null, + ) { + return this.shortcodeService.fetchAllShortcodes( + { cursor: cursorID, take }, + userEmail, + ); + } + + /** + * Delete a Shortcode + * + * @param shortcodeID ID of Shortcode being deleted + * @returns Boolean on successful deletion + */ + async deleteShortcode(shortcodeID: string) { + const result = await this.shortcodeService.deleteShortcode(shortcodeID); + + if (E.isLeft(result)) return E.left(result.left); + return E.right(result.right); + } + + /** + * Delete all user history + * @returns Boolean on successful deletion + */ + async deleteAllUserHistory() { + const result = await this.userHistoryService.deleteAllHistories(); + + if (E.isLeft(result)) return E.left(result.left); + return E.right(result.right); + } +} diff --git a/packages/hoppscotch-backend/src/admin/decorators/gql-admin.decorator.ts b/packages/hoppscotch-backend/src/admin/decorators/gql-admin.decorator.ts new file mode 100644 index 0000000..7aafb55 --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/decorators/gql-admin.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; + +export const GqlAdmin = createParamDecorator( + (data: unknown, context: ExecutionContext) => { + const ctx = GqlExecutionContext.create(context); + return ctx.getContext().req.user; + }, +); diff --git a/packages/hoppscotch-backend/src/admin/guards/gql-admin.guard.ts b/packages/hoppscotch-backend/src/admin/guards/gql-admin.guard.ts new file mode 100644 index 0000000..70e4953 --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/guards/gql-admin.guard.ts @@ -0,0 +1,14 @@ +import { Injectable, ExecutionContext, CanActivate } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; + +@Injectable() +export class GqlAdminGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const ctx = GqlExecutionContext.create(context); + const { req, headers } = ctx.getContext(); + const request = headers ? headers : req; + const user = request.user; + if (user.isAdmin) return true; + else return false; + } +} diff --git a/packages/hoppscotch-backend/src/admin/guards/rest-admin.guard.ts b/packages/hoppscotch-backend/src/admin/guards/rest-admin.guard.ts new file mode 100644 index 0000000..c855dc7 --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/guards/rest-admin.guard.ts @@ -0,0 +1,11 @@ +import { Injectable, ExecutionContext, CanActivate } from '@nestjs/common'; + +@Injectable() +export class RESTAdminGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const user = request.user; + + return user.isAdmin; + } +} diff --git a/packages/hoppscotch-backend/src/admin/infra.model.ts b/packages/hoppscotch-backend/src/admin/infra.model.ts new file mode 100644 index 0000000..02422a8 --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/infra.model.ts @@ -0,0 +1,10 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { Admin } from './admin.model'; + +@ObjectType() +export class Infra { + @Field(() => Admin, { + description: 'Admin who executed the action', + }) + executedBy: Admin; +} diff --git a/packages/hoppscotch-backend/src/admin/infra.resolver.ts b/packages/hoppscotch-backend/src/admin/infra.resolver.ts new file mode 100644 index 0000000..61e6869 --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/infra.resolver.ts @@ -0,0 +1,403 @@ +import { UseGuards } from '@nestjs/common'; +import { + Args, + ID, + Mutation, + Query, + ResolveField, + Resolver, +} from '@nestjs/graphql'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { Infra } from './infra.model'; +import { AdminService } from './admin.service'; +import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; +import { GqlAdminGuard } from './guards/gql-admin.guard'; +import { User } from 'src/user/user.model'; +import { AuthUser } from 'src/types/AuthUser'; +import { throwErr } from 'src/utils'; +import * as E from 'fp-ts/Either'; +import { Admin } from './admin.model'; +import { + OffsetPaginationArgs, + PaginationArgs, +} from 'src/types/input-types.args'; +import { InvitedUser } from './invited-user.model'; +import { Team } from 'src/team/team.model'; +import { TeamInvitation } from 'src/team-invitation/team-invitation.model'; +import { GqlAdmin } from './decorators/gql-admin.decorator'; +import { ShortcodeWithUserEmail } from 'src/shortcode/shortcode.model'; +import { InfraConfig } from 'src/infra-config/infra-config.model'; +import { InfraConfigService } from 'src/infra-config/infra-config.service'; +import { + EnableAndDisableSSOArgs, + InfraConfigArgs, +} from 'src/infra-config/input-args'; +import { InfraConfigEnum } from 'src/types/InfraConfig'; +import { ServiceStatus } from 'src/infra-config/helper'; +import { FetchAllTeamsV2Args, FetchAllUsersV2Args } from './input-types.args'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => Infra) +export class InfraResolver { + constructor( + private readonly adminService: AdminService, + private readonly infraConfigService: InfraConfigService, + ) {} + + @Query(() => Infra, { + description: 'Fetch details of the Infrastructure', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + infra(@GqlAdmin() admin: Admin) { + const infra: Infra = { executedBy: admin }; + return infra; + } + + @ResolveField(() => [User], { + description: 'Returns a list of all admin users in infra', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async admins() { + const admins = await this.adminService.fetchAdmins(); + return admins; + } + + @ResolveField(() => User, { + description: 'Returns a user info by UID', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async userInfo( + @Args({ + name: 'userUid', + type: () => ID, + description: 'The user UID', + }) + userUid: string, + ): Promise { + const user = await this.adminService.fetchUserInfo(userUid); + if (E.isLeft(user)) throwErr(user.left); + return user.right; + } + + @ResolveField(() => [User], { + description: 'Returns a list of all the users in infra', + deprecationReason: 'Use allUsersV2 instead', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async allUsers(@Args() args: PaginationArgs): Promise { + const users = await this.adminService.fetchUsers(args.cursor, args.take); + return users; + } + + @ResolveField(() => [User], { + description: 'Returns a list of all the users in infra', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async allUsersV2(@Args() args: FetchAllUsersV2Args): Promise { + const users = await this.adminService.fetchUsersV2(args.searchString, { + skip: args.skip, + take: args.take, + }); + return users; + } + + @ResolveField(() => [InvitedUser], { + description: 'Returns a list of all the invited users', + }) + async invitedUsers( + @Args() args: OffsetPaginationArgs, + ): Promise { + const users = await this.adminService.fetchInvitedUsers(args); + return users; + } + + @ResolveField(() => [Team], { + description: 'Returns a list of all the teams in the infra', + deprecationReason: 'Use allTeamsV2 instead', + }) + async allTeams(@Args() args: PaginationArgs): Promise { + const teams = await this.adminService.fetchAllTeams(args.cursor, args.take); + return teams; + } + + @ResolveField(() => [Team], { + description: 'Returns a list of all the teams in the infra', + }) + async allTeamsV2(@Args() args: FetchAllTeamsV2Args): Promise { + const teams = await this.adminService.fetchAllTeamsV2(args.searchString, { + skip: args.skip, + take: args.take, + }); + return teams; + } + + @ResolveField(() => Team, { + description: 'Returns a team info by ID when requested by Admin', + }) + async teamInfo( + @Args({ + name: 'teamID', + type: () => ID, + description: 'Team ID for which info to fetch', + }) + teamID: string, + ): Promise { + const team = await this.adminService.getTeamInfo(teamID); + if (E.isLeft(team)) throwErr(team.left); + return team.right; + } + + @ResolveField(() => Number, { + description: 'Return count of all the members in a team', + }) + async membersCountInTeam( + @Args({ + name: 'teamID', + type: () => ID, + description: 'Team ID for which team members to fetch', + nullable: false, + }) + teamID: string, + ): Promise { + const teamMembersCount = await this.adminService.membersCountInTeam(teamID); + return teamMembersCount; + } + + @ResolveField(() => Number, { + description: 'Return count of all the stored collections in a team', + }) + async collectionCountInTeam( + @Args({ + name: 'teamID', + type: () => ID, + description: 'Team ID for which team members to fetch', + }) + teamID: string, + ): Promise { + const teamCollCount = await this.adminService.collectionCountInTeam(teamID); + return teamCollCount; + } + + @ResolveField(() => Number, { + description: 'Return count of all the stored requests in a team', + }) + async requestCountInTeam( + @Args({ + name: 'teamID', + type: () => ID, + description: 'Team ID for which team members to fetch', + }) + teamID: string, + ): Promise { + const teamReqCount = await this.adminService.requestCountInTeam(teamID); + return teamReqCount; + } + + @ResolveField(() => Number, { + description: 'Return count of all the stored environments in a team', + }) + async environmentCountInTeam( + @Args({ + name: 'teamID', + type: () => ID, + description: 'Team ID for which team members to fetch', + }) + teamID: string, + ): Promise { + const envsCount = await this.adminService.environmentCountInTeam(teamID); + return envsCount; + } + + @ResolveField(() => [TeamInvitation], { + description: 'Return all the pending invitations in a team', + }) + async pendingInvitationCountInTeam( + @Args({ + name: 'teamID', + type: () => ID, + description: 'Team ID for which team members to fetch', + }) + teamID: string, + ) { + const invitations = + await this.adminService.pendingInvitationCountInTeam(teamID); + return invitations; + } + + @ResolveField(() => Number, { + description: 'Return total number of Users in organization', + }) + async usersCount() { + return this.adminService.getUsersCount(); + } + + @ResolveField(() => Number, { + description: 'Return total number of Teams in organization', + }) + async teamsCount() { + return this.adminService.getTeamsCount(); + } + + @ResolveField(() => Number, { + description: 'Return total number of Team Collections in organization', + }) + async teamCollectionsCount() { + return this.adminService.getTeamCollectionsCount(); + } + + @ResolveField(() => Number, { + description: 'Return total number of Team Requests in organization', + }) + async teamRequestsCount() { + return this.adminService.getTeamRequestsCount(); + } + + @ResolveField(() => [ShortcodeWithUserEmail], { + description: 'Returns a list of all the shortcodes in the infra', + }) + async allShortcodes( + @Args() args: PaginationArgs, + @Args({ + name: 'userEmail', + nullable: true, + description: 'Users email to filter shortcodes by', + }) + userEmail: string, + ) { + return await this.adminService.fetchAllShortcodes( + args.cursor, + args.take, + userEmail, + ); + } + + @Query(() => [InfraConfig], { + description: 'Retrieve configuration details for the instance', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async infraConfigs( + @Args({ + name: 'configNames', + type: () => [InfraConfigEnum], + description: 'Configs to fetch', + }) + names: InfraConfigEnum[], + ) { + const infraConfigs = await this.infraConfigService.getMany(names); + if (E.isLeft(infraConfigs)) throwErr(infraConfigs.left); + return infraConfigs.right; + } + + @Query(() => [String], { + description: 'Allowed Auth Provider list', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + allowedAuthProviders() { + return this.infraConfigService.getAllowedAuthProviders(); + } + + /* Mutations */ + + @Mutation(() => [InfraConfig], { + description: 'Update Infra Configs', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async updateInfraConfigs( + @Args({ + name: 'infraConfigs', + type: () => [InfraConfigArgs], + description: 'InfraConfigs to update', + }) + infraConfigs: InfraConfigArgs[], + ) { + const updatedRes = await this.infraConfigService.updateMany(infraConfigs); + if (E.isLeft(updatedRes)) throwErr(updatedRes.left); + return updatedRes.right; + } + + @Mutation(() => Boolean, { + description: 'Enable or disable analytics collection', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async toggleAnalyticsCollection( + @Args({ + name: 'status', + type: () => ServiceStatus, + description: 'Toggle analytics collection', + }) + analyticsCollectionStatus: ServiceStatus, + ) { + const res = await this.infraConfigService.toggleAnalyticsCollection( + analyticsCollectionStatus, + ); + if (E.isLeft(res)) throwErr(res.left); + return res.right; + } + + @Mutation(() => Boolean, { + description: 'Reset Infra Configs with default values (.env)', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async resetInfraConfigs() { + const resetRes = await this.infraConfigService.reset(); + if (E.isLeft(resetRes)) throwErr(resetRes.left); + return true; + } + + @Mutation(() => Boolean, { + description: 'Enable or Disable SSO for login/signup', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async enableAndDisableSSO( + @Args({ + name: 'providerInfo', + type: () => [EnableAndDisableSSOArgs], + description: 'SSO provider and status', + }) + providerInfo: EnableAndDisableSSOArgs[], + ) { + const isUpdated = + await this.infraConfigService.enableAndDisableSSO(providerInfo); + if (E.isLeft(isUpdated)) throwErr(isUpdated.left); + + return true; + } + + @Mutation(() => Boolean, { + description: 'Enable or Disable SMTP for sending emails', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async toggleSMTP( + @Args({ + name: 'status', + type: () => ServiceStatus, + description: 'Toggle SMTP', + }) + status: ServiceStatus, + ) { + const isUpdated = + await this.infraConfigService.enableAndDisableSMTP(status); + if (E.isLeft(isUpdated)) throwErr(isUpdated.left); + return true; + } + + @Mutation(() => Boolean, { + description: 'Enable or Disable User History Storing in DB', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async toggleUserHistoryStore( + @Args({ + name: 'status', + type: () => ServiceStatus, + description: 'Toggle User History Store', + }) + status: ServiceStatus, + ) { + const isUpdated = await this.infraConfigService.toggleServiceStatus( + InfraConfigEnum.USER_HISTORY_STORE_ENABLED, + status, + ); + if (E.isLeft(isUpdated)) throwErr(isUpdated.left); + return true; + } +} diff --git a/packages/hoppscotch-backend/src/admin/input-types.args.ts b/packages/hoppscotch-backend/src/admin/input-types.args.ts new file mode 100644 index 0000000..d7f1258 --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/input-types.args.ts @@ -0,0 +1,80 @@ +import { Field, ID, ArgsType } from '@nestjs/graphql'; +import { TeamAccessRole } from '../team/team.model'; +import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; + +@ArgsType() +export class FetchAllUsersV2Args extends OffsetPaginationArgs { + @Field({ + name: 'searchString', + nullable: true, + description: 'Search on users displayName or email', + }) + @IsString() + @IsOptional() + searchString: string; +} + +@ArgsType() +export class FetchAllTeamsV2Args extends OffsetPaginationArgs { + @Field({ + name: 'searchString', + nullable: true, + description: 'Search on team name or ID', + }) + @IsString() + @IsOptional() + searchString: string; +} + +@ArgsType() +export class ChangeUserRoleInTeamArgs { + @IsString() + @IsNotEmpty() + @Field(() => ID, { + name: 'userUID', + description: 'users UID', + }) + userUID: string; + + @IsString() + @IsNotEmpty() + @Field(() => ID, { + name: 'teamID', + description: 'team ID', + }) + teamID: string; + + @IsEnum(TeamAccessRole) + @Field(() => TeamAccessRole, { + name: 'newRole', + description: 'updated team role', + }) + newRole: TeamAccessRole; +} + +@ArgsType() +export class AddUserToTeamArgs { + @IsString() + @IsNotEmpty() + @Field(() => ID, { + name: 'teamID', + description: 'team ID', + }) + teamID: string; + + @IsEnum(TeamAccessRole) + @Field(() => TeamAccessRole, { + name: 'role', + description: 'The role of the user to add in the team', + }) + role: TeamAccessRole; + + @IsString() + @IsNotEmpty() + @Field({ + name: 'userEmail', + description: 'Email of the user to add to team', + }) + userEmail: string; +} diff --git a/packages/hoppscotch-backend/src/admin/invited-user.model.ts b/packages/hoppscotch-backend/src/admin/invited-user.model.ts new file mode 100644 index 0000000..f61564b --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/invited-user.model.ts @@ -0,0 +1,24 @@ +import { ObjectType, ID, Field } from '@nestjs/graphql'; + +@ObjectType() +export class InvitedUser { + @Field(() => ID, { + description: 'Admin UID', + }) + adminUid: string; + + @Field({ + description: 'Admin email', + }) + adminEmail: string; + + @Field({ + description: 'Invitee email', + }) + inviteeEmail: string; + + @Field({ + description: 'Date when the user invitation was sent', + }) + invitedOn: Date; +} diff --git a/packages/hoppscotch-backend/src/app.controller.ts b/packages/hoppscotch-backend/src/app.controller.ts new file mode 100644 index 0000000..298e955 --- /dev/null +++ b/packages/hoppscotch-backend/src/app.controller.ts @@ -0,0 +1,11 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ThrottlerBehindProxyGuard } from './guards/throttler-behind-proxy.guard'; + +@Controller('ping') +@UseGuards(ThrottlerBehindProxyGuard) +export class AppController { + @Get() + ping(): string { + return 'Success'; + } +} diff --git a/packages/hoppscotch-backend/src/app.module.ts b/packages/hoppscotch-backend/src/app.module.ts new file mode 100644 index 0000000..54ffc82 --- /dev/null +++ b/packages/hoppscotch-backend/src/app.module.ts @@ -0,0 +1,138 @@ +import { HttpException, Module } from '@nestjs/common'; +import { GraphQLModule } from '@nestjs/graphql'; +import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; +import { UserModule } from './user/user.module'; +import { GQLComplexityPlugin } from './plugins/GQLComplexityPlugin'; +import { AuthModule } from './auth/auth.module'; +import { UserSettingsModule } from './user-settings/user-settings.module'; +import { UserEnvironmentsModule } from './user-environment/user-environments.module'; +import { UserRequestModule } from './user-request/user-request.module'; +import { UserHistoryModule } from './user-history/user-history.module'; +import { + subscriptionContextCookieParser, + extractAccessTokenFromAuthRecords, +} from './auth/helper'; +import { TeamModule } from './team/team.module'; +import { TeamEnvironmentsModule } from './team-environments/team-environments.module'; +import { TeamCollectionModule } from './team-collection/team-collection.module'; +import { TeamRequestModule } from './team-request/team-request.module'; +import { TeamInvitationModule } from './team-invitation/team-invitation.module'; +import { AdminModule } from './admin/admin.module'; +import { UserCollectionModule } from './user-collection/user-collection.module'; +import { ShortcodeModule } from './shortcode/shortcode.module'; +import { COOKIES_NOT_FOUND } from './errors'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { AppController } from './app.controller'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { InfraConfigModule } from './infra-config/infra-config.module'; +import { loadInfraConfiguration } from './infra-config/helper'; +import { MailerModule } from './mailer/mailer.module'; +import { PostHogModule } from './posthog/posthog.module'; +import { ScheduleModule } from '@nestjs/schedule'; +import { HealthModule } from './health/health.module'; +import { AccessTokenModule } from './access-token/access-token.module'; +import { UserLastActiveOnInterceptor } from './interceptors/user-last-active-on.interceptor'; +import { InfraTokenModule } from './infra-token/infra-token.module'; +import { PrismaModule } from './prisma/prisma.module'; +import { PubSubModule } from './pubsub/pubsub.module'; +import { SortModule } from './orchestration/sort/sort.module'; +import { MockServerModule } from './mock-server/mock-server.module'; +import { PublishedDocsModule } from './published-docs/published-docs.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [async () => loadInfraConfiguration()], + }), + GraphQLModule.forRootAsync({ + driver: ApolloDriver, + inject: [ConfigService], + useFactory: async (configService: ConfigService) => { + return { + buildSchemaOptions: { + numberScalarMode: 'integer', + }, + playground: configService.get('PRODUCTION') !== 'true', + autoSchemaFile: true, + installSubscriptionHandlers: true, + subscriptions: { + 'subscriptions-transport-ws': { + path: '/graphql', + onConnect: (connectionParams, websocket) => { + const websocketHeaders = websocket?.upgradeReq?.headers; + + try { + const accessToken = + extractAccessTokenFromAuthRecords(connectionParams); + const authorization = `Bearer ${accessToken}`; + + return { headers: { ...websocketHeaders, authorization } }; + } catch (authError) { + const cookiesFromHeader = websocketHeaders?.cookie; + const cookies = cookiesFromHeader + ? subscriptionContextCookieParser(cookiesFromHeader) + : null; + + if (!cookies) { + throw new HttpException(COOKIES_NOT_FOUND, 400, { + cause: new Error(COOKIES_NOT_FOUND), + }); + } + + return { headers: { ...websocketHeaders, cookies } }; + } + }, + }, + }, + context: ({ req, res, connection }) => ({ + req, + res, + connection, + }), + }; + }, + }), + ThrottlerModule.forRootAsync({ + inject: [ConfigService], + useFactory: async (configService: ConfigService) => [ + { + ttl: +configService.get('INFRA.RATE_LIMIT_TTL'), + limit: +configService.get('INFRA.RATE_LIMIT_MAX'), + }, + ], + }), + PrismaModule, + PubSubModule, + MailerModule.register(), + UserModule, + AuthModule.register(), + AdminModule, + UserSettingsModule, + UserEnvironmentsModule, + UserHistoryModule, + UserRequestModule, + TeamModule, + TeamEnvironmentsModule, + TeamCollectionModule, + TeamRequestModule, + TeamInvitationModule, + UserCollectionModule, + ShortcodeModule, + InfraConfigModule, + PostHogModule, + ScheduleModule.forRoot(), + HealthModule, + AccessTokenModule, + InfraTokenModule, + SortModule, + MockServerModule, + PublishedDocsModule, + ], + providers: [ + GQLComplexityPlugin, + { provide: 'APP_INTERCEPTOR', useClass: UserLastActiveOnInterceptor }, + ], + controllers: [AppController], +}) +export class AppModule {} diff --git a/packages/hoppscotch-backend/src/auth/auth.controller.ts b/packages/hoppscotch-backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..94fbe55 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/auth.controller.ts @@ -0,0 +1,230 @@ +import { + Body, + Controller, + Get, + Post, + Query, + Request, + Res, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { SignInMagicDto } from './dto/signin-magic.dto'; +import { VerifyMagicDto } from './dto/verify-magic.dto'; +import { Response } from 'express'; +import * as E from 'fp-ts/Either'; +import { RTJwtAuthGuard } from './guards/rt-jwt-auth.guard'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { GqlUser } from 'src/decorators/gql-user.decorator'; +import { AuthUser } from 'src/types/AuthUser'; +import { RTCookie } from 'src/decorators/rt-cookie.decorator'; +import { AuthProvider, authCookieHandler, authProviderCheck } from './helper'; +import { isValidLocalhostRedirectUri } from './redirect-uri.validator'; +import { GoogleSSOGuard } from './guards/google-sso.guard'; +import { GithubSSOGuard } from './guards/github-sso.guard'; +import { MicrosoftSSOGuard } from './guards/microsoft-sso.guard'; +import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard'; +import { SkipThrottle } from '@nestjs/throttler'; +import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; +import { ConfigService } from '@nestjs/config'; +import { throwHTTPErr } from 'src/utils'; +import { UserLastLoginInterceptor } from 'src/interceptors/user-last-login.interceptor'; + +@UseGuards(ThrottlerBehindProxyGuard) +@Controller({ path: 'auth', version: '1' }) +export class AuthController { + constructor( + private authService: AuthService, + private configService: ConfigService, + ) {} + + @Get('providers') + async getAuthProviders() { + const providers = await this.authService.getAuthProviders(); + return { providers }; + } + + /** + ** Route to initiate magic-link auth for a users email + */ + @Post('signin') + async signInMagicLink( + @Body() authData: SignInMagicDto, + @Query('origin') origin: string, + ) { + if ( + !authProviderCheck( + AuthProvider.EMAIL, + this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'), + ) + ) { + throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 }); + } + + const deviceIdToken = await this.authService.signInMagicLink( + authData.email, + origin, + ); + if (E.isLeft(deviceIdToken)) throwHTTPErr(deviceIdToken.left); + return deviceIdToken.right; + } + + /** + ** Route to verify and sign in a valid user via magic-link + */ + @Post('verify') + async verify(@Body() data: VerifyMagicDto, @Res() res: Response) { + const authTokens = await this.authService.verifyMagicLinkTokens(data); + if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); + authCookieHandler(res, authTokens.right, false, null, this.configService); + } + + /** + ** Route to refresh auth tokens with Refresh Token Rotation + * @see https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation + */ + @Get('refresh') + @UseGuards(RTJwtAuthGuard) + async refresh( + @GqlUser() user: AuthUser, + @RTCookie() refresh_token: string, + @Res() res, + ) { + const newTokenPair = await this.authService.refreshAuthTokens( + refresh_token, + user, + ); + if (E.isLeft(newTokenPair)) throwHTTPErr(newTokenPair.left); + authCookieHandler(res, newTokenPair.right, false, null, this.configService); + } + + /** + ** Route to initiate SSO auth via Google + */ + @Get('google') + @UseGuards(GoogleSSOGuard) + async googleAuth(@Request() req) {} + + /** + ** Callback URL for Google SSO + * @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works + */ + @Get('google/callback') + @SkipThrottle() + @UseGuards(GoogleSSOGuard) + @UseInterceptors(UserLastLoginInterceptor) + async googleAuthRedirect(@Request() req, @Res() res) { + const authTokens = await this.authService.generateAuthTokens(req.user.uid); + if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); + authCookieHandler( + res, + authTokens.right, + true, + req.authInfo.state.redirect_uri, + this.configService, + ); + } + + /** + ** Route to initiate SSO auth via Github + */ + @Get('github') + @UseGuards(GithubSSOGuard) + async githubAuth(@Request() req) {} + + /** + ** Callback URL for Github SSO + * @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works + */ + @Get('github/callback') + @SkipThrottle() + @UseGuards(GithubSSOGuard) + @UseInterceptors(UserLastLoginInterceptor) + async githubAuthRedirect(@Request() req, @Res() res) { + const authTokens = await this.authService.generateAuthTokens(req.user.uid); + if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); + authCookieHandler( + res, + authTokens.right, + true, + req.authInfo.state.redirect_uri, + this.configService, + ); + } + + /** + ** Route to initiate SSO auth via Microsoft + */ + @Get('microsoft') + @UseGuards(MicrosoftSSOGuard) + async microsoftAuth(@Request() req) {} + + /** + ** Callback URL for Microsoft SSO + * @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works + */ + @Get('microsoft/callback') + @SkipThrottle() + @UseGuards(MicrosoftSSOGuard) + @UseInterceptors(UserLastLoginInterceptor) + async microsoftAuthRedirect(@Request() req, @Res() res) { + const authTokens = await this.authService.generateAuthTokens(req.user.uid); + if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); + authCookieHandler( + res, + authTokens.right, + true, + req.authInfo.state.redirect_uri, + this.configService, + ); + } + + /** + ** Log user out by clearing cookies containing auth tokens + */ + @Get('logout') + async logout(@Res() res: Response) { + res.clearCookie('access_token'); + res.clearCookie('refresh_token'); + return res.status(200).send(); + } + + @Get('verify/admin') + @UseGuards(JwtAuthGuard) + async verifyAdmin(@GqlUser() user: AuthUser) { + const userInfo = await this.authService.verifyAdmin(user); + if (E.isLeft(userInfo)) throwHTTPErr(userInfo.left); + return userInfo.right; + } + + @Get('desktop') + @UseGuards(JwtAuthGuard) + @UseInterceptors(UserLastLoginInterceptor) + async desktopAuthCallback( + @GqlUser() user: AuthUser, + @Query('redirect_uri') redirectUri: string, + ) { + if (!isValidLocalhostRedirectUri(redirectUri)) { + throwHTTPErr({ + message: 'Invalid desktop callback URL', + statusCode: 400, + }); + } + + const tokens = await this.authService.generateAuthTokens(user.uid); + if (E.isLeft(tokens)) throwHTTPErr(tokens.left); + + return tokens.right; + } + + @Get('verify-token') + @UseGuards(JwtAuthGuard) + async verifyToken(@GqlUser() user: AuthUser) { + return { + isValid: true, + uid: user.uid, + message: 'Token is valid', + }; + } +} diff --git a/packages/hoppscotch-backend/src/auth/auth.module.ts b/packages/hoppscotch-backend/src/auth/auth.module.ts new file mode 100644 index 0000000..0e6c48e --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/auth.module.ts @@ -0,0 +1,66 @@ +import { Module } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { UserModule } from 'src/user/user.module'; +import { PassportModule } from '@nestjs/passport'; +import { JwtModule } from '@nestjs/jwt'; +import { JwtStrategy } from './strategies/jwt.strategy'; +import { RTJwtStrategy } from './strategies/rt-jwt.strategy'; +import { GoogleStrategy } from './strategies/google.strategy'; +import { GithubStrategy } from './strategies/github.strategy'; +import { MicrosoftStrategy } from './strategies/microsoft.strategy'; +import { AuthProvider, authProviderCheck } from './helper'; +import { ConfigService } from '@nestjs/config'; +import { + getConfiguredSSOProvidersFromInfraConfig, + isInfraConfigTablePopulated, +} from 'src/infra-config/helper'; +import { InfraConfigModule } from 'src/infra-config/infra-config.module'; + +@Module({ + imports: [ + UserModule, + PassportModule, + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('INFRA.JWT_SECRET'), + }), + }), + InfraConfigModule, + ], + providers: [AuthService], + controllers: [AuthController], +}) +export class AuthModule { + static async register() { + if (process.env.GENERATE_GQL_SCHEMA === 'true') { + return { module: AuthModule }; + } + + const isInfraConfigPopulated = await isInfraConfigTablePopulated(); + if (!isInfraConfigPopulated) { + return { module: AuthModule }; + } + + const allowedAuthProviders = + await getConfiguredSSOProvidersFromInfraConfig(); + + const providers = [ + ...(authProviderCheck(AuthProvider.GOOGLE, allowedAuthProviders) + ? [GoogleStrategy] + : []), + ...(authProviderCheck(AuthProvider.GITHUB, allowedAuthProviders) + ? [GithubStrategy] + : []), + ...(authProviderCheck(AuthProvider.MICROSOFT, allowedAuthProviders) + ? [MicrosoftStrategy] + : []), + ]; + + return { + module: AuthModule, + providers: [...providers, JwtStrategy, RTJwtStrategy], + }; + } +} diff --git a/packages/hoppscotch-backend/src/auth/auth.service.spec.ts b/packages/hoppscotch-backend/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..ceb04cc --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/auth.service.spec.ts @@ -0,0 +1,426 @@ +import { HttpStatus } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Account, VerificationToken } from 'src/generated/prisma/client'; +import { mockDeep } from 'jest-mock-extended'; +import { + INVALID_EMAIL, + INVALID_MAGIC_LINK_DATA, + INVALID_REFRESH_TOKEN, + MAGIC_LINK_EXPIRED, + VERIFICATION_TOKEN_DATA_NOT_FOUND, + USER_NOT_FOUND, +} from 'src/errors'; +import { MailerService } from 'src/mailer/mailer.service'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { AuthUser } from 'src/types/AuthUser'; +import { UserService } from 'src/user/user.service'; +import { AuthService } from './auth.service'; +import * as O from 'fp-ts/Option'; +import { VerifyMagicDto } from './dto/verify-magic.dto'; +import * as E from 'fp-ts/Either'; +import { ConfigService } from '@nestjs/config'; +import { InfraConfigService } from 'src/infra-config/infra-config.service'; + +const mockPrisma = mockDeep(); +const mockUser = mockDeep(); +const mockJWT = mockDeep(); +const mockMailer = mockDeep(); +const mockConfigService = mockDeep(); +const mockInfraConfigService = mockDeep(); + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const authService = new AuthService( + mockUser, + mockPrisma, + mockJWT, + mockMailer, + mockConfigService, + mockInfraConfigService, +); + +const currentTime = new Date(); + +const user: AuthUser = { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, + currentGQLSession: {}, + currentRESTSession: {}, +}; + +const passwordlessData: VerificationToken = { + deviceIdentifier: 'k23hb7u7gdcujhb', + token: 'jhhj24sdjvl', + userUid: user.uid, + expiresOn: new Date(), +}; + +const magicLinkVerify: VerifyMagicDto = { + deviceIdentifier: 'Dscdc', + token: 'SDcsdc', +}; + +const accountDetails: Account = { + id: '123dcdc', + userId: user.uid, + provider: 'email', + providerAccountId: user.uid, + providerRefreshToken: 'dscsdc', + providerAccessToken: 'sdcsdcsdc', + providerScope: 'user.email', + loggedIn: currentTime, +}; + +let nowPlus30 = new Date(); +nowPlus30.setMinutes(nowPlus30.getMinutes() + 30000); +nowPlus30 = new Date(nowPlus30); + +describe('signInMagicLink', () => { + test('Should throw error if email is not in valid format', async () => { + const result = await authService.signInMagicLink('bbbgmail.com', 'admin'); + expect(result).toEqualLeft({ + message: INVALID_EMAIL, + statusCode: HttpStatus.BAD_REQUEST, + }); + }); + + test('Should successfully create a new user account and return the passwordless details', async () => { + // check to see if user exists, return none + mockUser.findUserByEmail.mockResolvedValue(O.none); + // create new user + mockUser.createUserViaMagicLink.mockResolvedValue(user); + // create new entry in VerificationToken table + mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData); + // Read env variable 'MAGIC_LINK_TOKEN_VALIDITY' from config service + mockConfigService.get.mockReturnValue('3'); + + const result = await authService.signInMagicLink( + 'dwight@dundermifflin.com', + 'admin', + ); + expect(result).toEqualRight({ + deviceIdentifier: passwordlessData.deviceIdentifier, + }); + }); + + test('Should successfully return the passwordless details for a pre-existing user account', async () => { + // check to see if user exists, return error + mockUser.findUserByEmail.mockResolvedValueOnce(O.some(user)); + // create new entry in VerificationToken table + mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData); + + const result = await authService.signInMagicLink( + 'dwight@dundermifflin.com', + 'admin', + ); + expect(result).toEqualRight({ + deviceIdentifier: passwordlessData.deviceIdentifier, + }); + }); +}); + +describe('verifyMagicLinkTokens', () => { + test('Should throw INVALID_MAGIC_LINK_DATA if data is invalid', async () => { + mockPrisma.verificationToken.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await authService.verifyMagicLinkTokens(magicLinkVerify); + expect(result).toEqualLeft({ + message: INVALID_MAGIC_LINK_DATA, + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + test('Should throw USER_NOT_FOUND if user is invalid', async () => { + // validatePasswordlessTokens + mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce( + passwordlessData, + ); + // findUserById + mockUser.findUserById.mockResolvedValue(O.none); + + const result = await authService.verifyMagicLinkTokens(magicLinkVerify); + expect(result).toEqualLeft({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + test('Should successfully return auth token pair with provider account existing', async () => { + // validatePasswordlessTokens + mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({ + ...passwordlessData, + expiresOn: nowPlus30, + }); + // findUserById + mockUser.findUserById.mockResolvedValue(O.some(user)); + // checkIfProviderAccountExists + mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails); + // mockPrisma.account.findUnique.mockResolvedValueOnce(null); + // generateAuthTokens + mockJWT.sign.mockReturnValue(user.refreshToken); + // UpdateUserRefreshToken + mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user)); + // deletePasswordlessVerificationToken + mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData); + // usersService.updateUserLastLoggedOn + mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true)); + + const result = await authService.verifyMagicLinkTokens(magicLinkVerify); + expect(result).toEqualRight({ + access_token: user.refreshToken, + refresh_token: user.refreshToken, + }); + }); + + test('Should successfully return auth token pair with provider account not existing', async () => { + // validatePasswordlessTokens + mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({ + ...passwordlessData, + expiresOn: nowPlus30, + }); + // findUserById + mockUser.findUserById.mockResolvedValue(O.some(user)); + // checkIfProviderAccountExists + mockPrisma.account.findUnique.mockResolvedValueOnce(null); + mockUser.createUserSSO.mockResolvedValueOnce(user); + // generateAuthTokens + mockJWT.sign.mockReturnValue(user.refreshToken); + // UpdateUserRefreshToken + mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user)); + // deletePasswordlessVerificationToken + mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData); + // usersService.updateUserLastLoggedOn + mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true)); + + const result = await authService.verifyMagicLinkTokens(magicLinkVerify); + expect(result).toEqualRight({ + access_token: user.refreshToken, + refresh_token: user.refreshToken, + }); + }); + + test('Should throw MAGIC_LINK_EXPIRED if passwordless token is expired', async () => { + // validatePasswordlessTokens + mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({ + ...passwordlessData, + expiresOn: new Date('2020-01-01T00:00:00Z'), + }); + // findUserById + mockUser.findUserById.mockResolvedValue(O.some(user)); + // checkIfProviderAccountExists + mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails); + + const result = await authService.verifyMagicLinkTokens(magicLinkVerify); + expect(result).toEqualLeft({ + message: MAGIC_LINK_EXPIRED, + statusCode: HttpStatus.UNAUTHORIZED, + }); + }); + + test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => { + // validatePasswordlessTokens + mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({ + ...passwordlessData, + expiresOn: nowPlus30, + }); + // findUserById + mockUser.findUserById.mockResolvedValue(O.some(user)); + // checkIfProviderAccountExists + mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails); + // mockPrisma.account.findUnique.mockResolvedValueOnce(null); + // generateAuthTokens + mockJWT.sign.mockReturnValue(user.refreshToken); + // UpdateUserRefreshToken + mockUser.updateUserRefreshToken.mockResolvedValueOnce( + E.left(USER_NOT_FOUND), + ); + + const result = await authService.verifyMagicLinkTokens(magicLinkVerify); + expect(result).toEqualLeft({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + test('Should throw PASSWORDLESS_DATA_NOT_FOUND when deleting passwordlessVerification entry from DB', async () => { + // validatePasswordlessTokens + mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({ + ...passwordlessData, + expiresOn: nowPlus30, + }); + // findUserById + mockUser.findUserById.mockResolvedValue(O.some(user)); + // checkIfProviderAccountExists + mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails); + // mockPrisma.account.findUnique.mockResolvedValueOnce(null); + // generateAuthTokens + mockJWT.sign.mockReturnValue(user.refreshToken); + // UpdateUserRefreshToken + mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user)); + // deletePasswordlessVerificationToken + mockPrisma.verificationToken.delete.mockRejectedValueOnce('RecordNotFound'); + + const result = await authService.verifyMagicLinkTokens(magicLinkVerify); + expect(result).toEqualLeft({ + message: VERIFICATION_TOKEN_DATA_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); +}); + +describe('generateAuthTokens', () => { + test('Should successfully generate tokens with valid inputs', async () => { + mockJWT.sign.mockReturnValue(user.refreshToken); + // UpdateUserRefreshToken + mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user)); + + const result = await authService.generateAuthTokens(user.uid); + expect(result).toEqualRight({ + access_token: 'hbfvdkhjbvkdvdfjvbnkhjb', + refresh_token: 'hbfvdkhjbvkdvdfjvbnkhjb', + }); + }); + + test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => { + mockJWT.sign.mockReturnValue(user.refreshToken); + // UpdateUserRefreshToken + mockUser.updateUserRefreshToken.mockResolvedValueOnce( + E.left(USER_NOT_FOUND), + ); + + const result = await authService.generateAuthTokens(user.uid); + expect(result).toEqualLeft({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); +}); + +jest.mock('argon2', () => { + return { + verify: jest.fn((x, y) => { + if (y === null) return false; + return true; + }), + hash: jest.fn(), + }; +}); + +describe('refreshAuthTokens', () => { + test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => { + // generateAuthTokens + mockJWT.sign.mockReturnValue(user.refreshToken); + // UpdateUserRefreshToken + mockUser.updateUserRefreshToken.mockResolvedValueOnce( + E.left(USER_NOT_FOUND), + ); + + const result = await authService.refreshAuthTokens( + '$argon2id$v=19$m=65536,t=3,p=4$MvVOam2clCOLtJFGEE26ZA$czvA5ez9hz+A/LML8QRgqgaFuWa5JcbwkH6r+imTQbs', + user, + ); + expect(result).toEqualLeft({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + test('Should throw USER_NOT_FOUND when user is invalid', async () => { + const result = await authService.refreshAuthTokens( + 'jshdcbjsdhcbshdbc', + null, + ); + expect(result).toEqualLeft({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + test('Should successfully refresh the tokens and generate a new auth token pair', async () => { + // generateAuthTokens + mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb'); + // UpdateUserRefreshToken + mockUser.updateUserRefreshToken.mockResolvedValueOnce( + E.right({ + ...user, + refreshToken: 'sdhjcbjsdhcbshjdcb', + }), + ); + + const result = await authService.refreshAuthTokens( + '$argon2id$v=19$m=65536,t=3,p=4$MvVOam2clCOLtJFGEE26ZA$czvA5ez9hz+A/LML8QRgqgaFuWa5JcbwkH6r+imTQbs', + user, + ); + expect(result).toEqualRight({ + access_token: 'sdhjcbjsdhcbshjdcb', + refresh_token: 'sdhjcbjsdhcbshjdcb', + }); + }); + + test('Should throw INVALID_REFRESH_TOKEN when the refresh token is invalid', async () => { + // generateAuthTokens + mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb'); + mockPrisma.user.update.mockResolvedValueOnce({ + ...user, + refreshToken: 'sdhjcbjsdhcbshjdcb', + }); + + const result = await authService.refreshAuthTokens(null, user); + expect(result).toEqualLeft({ + message: INVALID_REFRESH_TOKEN, + statusCode: HttpStatus.NOT_FOUND, + }); + }); +}); + +describe('verifyAdmin', () => { + test('should successfully elevate user to admin when userCount is 1 ', async () => { + // getUsersCount + mockUser.getUsersCount.mockResolvedValueOnce(1); + // makeAdmin + mockUser.makeAdmin.mockResolvedValueOnce( + E.right({ + ...user, + isAdmin: true, + }), + ); + + const result = await authService.verifyAdmin(user); + expect(result).toEqualRight({ isAdmin: true }); + }); + + test('should return true if user is already an admin', async () => { + const result = await authService.verifyAdmin({ ...user, isAdmin: true }); + expect(result).toEqualRight({ isAdmin: true }); + }); + + test('should throw USERS_NOT_FOUND when userUid is invalid', async () => { + // getUsersCount + mockUser.getUsersCount.mockResolvedValueOnce(1); + // makeAdmin + mockUser.makeAdmin.mockResolvedValueOnce(E.left(USER_NOT_FOUND)); + + const result = await authService.verifyAdmin(user); + expect(result).toEqualLeft({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + test('should return false when user is not an admin and userCount is greater than 1', async () => { + // getUsersCount + mockUser.getUsersCount.mockResolvedValueOnce(13); + + const result = await authService.verifyAdmin(user); + expect(result).toEqualRight({ isAdmin: false }); + }); +}); diff --git a/packages/hoppscotch-backend/src/auth/auth.service.ts b/packages/hoppscotch-backend/src/auth/auth.service.ts new file mode 100644 index 0000000..58a904b --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/auth.service.ts @@ -0,0 +1,392 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { MailerService } from 'src/mailer/mailer.service'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { UserService } from 'src/user/user.service'; +import { VerifyMagicDto } from './dto/verify-magic.dto'; +import * as argon2 from 'argon2'; +import * as bcrypt from 'bcrypt'; +import * as O from 'fp-ts/Option'; +import * as E from 'fp-ts/Either'; +import { DeviceIdentifierToken } from 'src/types/Passwordless'; +import { + INVALID_EMAIL, + INVALID_MAGIC_LINK_DATA, + VERIFICATION_TOKEN_DATA_NOT_FOUND, + MAGIC_LINK_EXPIRED, + USER_NOT_FOUND, + INVALID_REFRESH_TOKEN, +} from 'src/errors'; +import { validateEmail } from 'src/utils'; +import { + AccessTokenPayload, + AuthTokens, + RefreshTokenPayload, +} from 'src/types/AuthTokens'; +import { JwtService } from '@nestjs/jwt'; +import { RESTError } from 'src/types/RESTError'; +import { AuthUser, IsAdmin } from 'src/types/AuthUser'; +import { VerificationToken } from 'src/generated/prisma/client'; +import { Origin } from './helper'; +import { ConfigService } from '@nestjs/config'; +import { InfraConfigService } from 'src/infra-config/infra-config.service'; + +@Injectable() +export class AuthService { + constructor( + private readonly usersService: UserService, + private readonly prisma: PrismaService, + private readonly jwtService: JwtService, + private readonly mailerService: MailerService, + private readonly configService: ConfigService, + private readonly infraConfigService: InfraConfigService, + ) {} + + /** + * Generate Id and token for email Magic-Link auth + * + * @param user User Object + * @returns Created VerificationToken token + */ + private async generateMagicLinkTokens(user: AuthUser) { + const salt = await bcrypt.genSalt( + parseInt(this.configService.get('INFRA.TOKEN_SALT_COMPLEXITY')), + ); + + // Calculate expiration time by adding hours to current time + let validityInHours = parseInt( + this.configService.get('INFRA.MAGIC_LINK_TOKEN_VALIDITY'), + ); + if (isNaN(validityInHours)) validityInHours = 24; // Default: 24 hours + + const expiresOn = new Date(); + expiresOn.setHours(expiresOn.getHours() + validityInHours); + + const idToken = await this.prisma.verificationToken.create({ + data: { + deviceIdentifier: salt, + userUid: user.uid, + expiresOn: expiresOn, + }, + }); + + return idToken; + } + + /** + * Check if VerificationToken exist or not + * + * @param magicLinkTokens Object containing deviceIdentifier and token + * @returns Option of VerificationToken token + */ + private async validatePasswordlessTokens(magicLinkTokens: VerifyMagicDto) { + try { + const tokens = await this.prisma.verificationToken.findUniqueOrThrow({ + where: { + passwordless_deviceIdentifier_tokens: { + deviceIdentifier: magicLinkTokens.deviceIdentifier, + token: magicLinkTokens.token, + }, + }, + }); + return O.some(tokens); + } catch (error) { + return O.none; + } + } + + /** + * Generate new refresh token for user + * + * @param userUid User Id + * @returns Generated refreshToken + */ + private async generateRefreshToken(userUid: string) { + const refreshTokenPayload: RefreshTokenPayload = { + iss: this.configService.get('VITE_BASE_URL'), + sub: userUid, + aud: [this.configService.get('VITE_BASE_URL')], + }; + + const refreshToken = await this.jwtService.sign(refreshTokenPayload, { + expiresIn: this.configService.get('INFRA.REFRESH_TOKEN_VALIDITY'), //7 Days + }); + + const refreshTokenHash = await argon2.hash(refreshToken); + + const updatedUser = await this.usersService.updateUserRefreshToken( + refreshTokenHash, + userUid, + ); + if (E.isLeft(updatedUser)) + return E.left({ + message: updatedUser.left, + statusCode: HttpStatus.NOT_FOUND, + }); + + return E.right(refreshToken); + } + + /** + * Generate access and refresh token pair + * + * @param userUid User ID + * @returns Either of generated AuthTokens + */ + async generateAuthTokens(userUid: string) { + const accessTokenPayload: AccessTokenPayload = { + iss: this.configService.get('VITE_BASE_URL'), + sub: userUid, + aud: [this.configService.get('VITE_BASE_URL')], + }; + + const refreshToken = await this.generateRefreshToken(userUid); + if (E.isLeft(refreshToken)) return E.left(refreshToken.left); + + return E.right({ + access_token: await this.jwtService.sign(accessTokenPayload, { + expiresIn: this.configService.get('INFRA.ACCESS_TOKEN_VALIDITY'), //1 Day + }), + refresh_token: refreshToken.right, + }); + } + + /** + * Deleted used VerificationToken tokens + * + * @param passwordlessTokens VerificationToken entry to delete from DB + * @returns Either of deleted VerificationToken token + */ + private async deleteMagicLinkVerificationTokens( + passwordlessTokens: VerificationToken, + ) { + try { + const deletedPasswordlessToken = + await this.prisma.verificationToken.delete({ + where: { + passwordless_deviceIdentifier_tokens: { + deviceIdentifier: passwordlessTokens.deviceIdentifier, + token: passwordlessTokens.token, + }, + }, + }); + return E.right(deletedPasswordlessToken); + } catch (error) { + return E.left(VERIFICATION_TOKEN_DATA_NOT_FOUND); + } + } + + /** + * Verify if Provider account exists for User + * + * @param user User Object + * @param SSOUserData User data from SSO providers (Magic,Google,Github,Microsoft) + * @returns Either of existing user provider Account + */ + async checkIfProviderAccountExists(user: AuthUser, SSOUserData) { + const provider = await this.prisma.account.findUnique({ + where: { + verifyProviderAccount: { + provider: SSOUserData.provider, + providerAccountId: SSOUserData.id, + }, + }, + }); + + if (!provider) return O.none; + + return O.some(provider); + } + + /** + * Create User (if not already present) and send email to initiate Magic-Link auth + * + * @param email User's email + * @returns Either containing DeviceIdentifierToken + */ + async signInMagicLink(email: string, origin: string) { + if (!validateEmail(email)) + return E.left({ + message: INVALID_EMAIL, + statusCode: HttpStatus.BAD_REQUEST, + }); + + let user: AuthUser; + const queriedUser = await this.usersService.findUserByEmail(email); + + if (O.isNone(queriedUser)) { + user = await this.usersService.createUserViaMagicLink(email); + } else { + user = queriedUser.value; + } + + const generatedTokens = await this.generateMagicLinkTokens(user); + + // check to see if origin is valid + let url: string; + switch (origin) { + case Origin.ADMIN: + url = this.configService.get('VITE_ADMIN_URL'); + break; + case Origin.APP: + url = this.configService.get('VITE_BASE_URL'); + break; + default: + // if origin is invalid by default set URL to Hoppscotch-App + url = this.configService.get('VITE_BASE_URL'); + } + + await this.mailerService.sendEmail(email, { + template: 'user-invitation', + variables: { + inviteeEmail: email, + magicLink: `${url}/enter?token=${generatedTokens.token}`, + }, + }); + + return E.right({ + deviceIdentifier: generatedTokens.deviceIdentifier, + }); + } + + /** + * Verify and authenticate user from received data for Magic-Link + * + * @param magicLinkIDTokens magic-link verification tokens from client + * @returns Either of generated AuthTokens + */ + async verifyMagicLinkTokens( + magicLinkIDTokens: VerifyMagicDto, + ): Promise | E.Left> { + const passwordlessTokens = + await this.validatePasswordlessTokens(magicLinkIDTokens); + if (O.isNone(passwordlessTokens)) + return E.left({ + message: INVALID_MAGIC_LINK_DATA, + statusCode: HttpStatus.NOT_FOUND, + }); + + const user = await this.usersService.findUserById( + passwordlessTokens.value.userUid, + ); + if (O.isNone(user)) + return E.left({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + + /** + * * Check to see if entry for Magic-Link is present in the Account table for user + * * If user was created with another provider findUserById may return true + */ + const profile = { + provider: 'magic', + id: user.value.email, + }; + const providerAccountExists = await this.checkIfProviderAccountExists( + user.value, + profile, + ); + + if (O.isNone(providerAccountExists)) { + await this.usersService.createProviderAccount( + user.value, + null, + null, + profile, + ); + } + + const currentTime = new Date(); + if (currentTime > passwordlessTokens.value.expiresOn) + return E.left({ + message: MAGIC_LINK_EXPIRED, + statusCode: HttpStatus.UNAUTHORIZED, + }); + + const tokens = await this.generateAuthTokens( + passwordlessTokens.value.userUid, + ); + if (E.isLeft(tokens)) + return E.left({ + message: tokens.left.message, + statusCode: tokens.left.statusCode, + }); + + const deletedPasswordlessToken = + await this.deleteMagicLinkVerificationTokens(passwordlessTokens.value); + if (E.isLeft(deletedPasswordlessToken)) + return E.left({ + message: deletedPasswordlessToken.left, + statusCode: HttpStatus.NOT_FOUND, + }); + + this.usersService.updateUserLastLoggedOn(passwordlessTokens.value.userUid); + + return E.right(tokens.right); + } + + /** + * Refresh refresh and auth tokens + * + * @param hashedRefreshToken Hashed refresh token received from client + * @param user User Object + * @returns Either of generated AuthTokens + */ + async refreshAuthTokens(hashedRefreshToken: string, user: AuthUser) { + // Check to see user is valid + if (!user) + return E.left({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + + // Check to see if the hashed refresh_token received from the client is the same as the refresh_token saved in the DB + const isTokenMatched = await argon2.verify( + user.refreshToken, + hashedRefreshToken, + ); + if (!isTokenMatched) + return E.left({ + message: INVALID_REFRESH_TOKEN, + statusCode: HttpStatus.NOT_FOUND, + }); + + // if tokens match, generate new pair of auth tokens + const generatedAuthTokens = await this.generateAuthTokens(user.uid); + if (E.isLeft(generatedAuthTokens)) + return E.left({ + message: generatedAuthTokens.left.message, + statusCode: generatedAuthTokens.left.statusCode, + }); + + return E.right(generatedAuthTokens.right); + } + + /** + * Verify is signed in User is an admin or not + * + * @param user User Object + * @returns Either of boolean if user is admin or not + */ + async verifyAdmin(user: AuthUser) { + if (user.isAdmin) return E.right({ isAdmin: true }); + + const usersCount = await this.usersService.getUsersCount(); + if (usersCount === 1) { + const elevatedUser = await this.usersService.makeAdmin(user.uid); + if (E.isLeft(elevatedUser)) + return E.left({ + message: elevatedUser.left, + statusCode: HttpStatus.NOT_FOUND, + }); + + return E.right({ isAdmin: true }); + } + + return E.right({ isAdmin: false }); + } + + getAuthProviders() { + return this.infraConfigService.getAllowedAuthProviders(); + } +} diff --git a/packages/hoppscotch-backend/src/auth/dto/signin-magic.dto.ts b/packages/hoppscotch-backend/src/auth/dto/signin-magic.dto.ts new file mode 100644 index 0000000..e6ddc14 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/dto/signin-magic.dto.ts @@ -0,0 +1,7 @@ +import { IsEmail } from 'class-validator'; + +// Inputs to initiate Magic-Link auth flow +export class SignInMagicDto { + @IsEmail() + email: string; +} diff --git a/packages/hoppscotch-backend/src/auth/dto/verify-magic.dto.ts b/packages/hoppscotch-backend/src/auth/dto/verify-magic.dto.ts new file mode 100644 index 0000000..27f9a43 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/dto/verify-magic.dto.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +// Inputs to verify and sign a user in via magic-link +export class VerifyMagicDto { + @IsString() + @IsNotEmpty() + deviceIdentifier: string; + + @IsString() + @IsNotEmpty() + token: string; +} diff --git a/packages/hoppscotch-backend/src/auth/guards/github-sso.guard.ts b/packages/hoppscotch-backend/src/auth/guards/github-sso.guard.ts new file mode 100644 index 0000000..fc69bcc --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/guards/github-sso.guard.ts @@ -0,0 +1,39 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { AuthProvider, authProviderCheck } from '../helper'; +import { Observable } from 'rxjs'; +import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; +import { ConfigService } from '@nestjs/config'; +import { throwHTTPErr } from 'src/utils'; + +@Injectable() +export class GithubSSOGuard extends AuthGuard('github') implements CanActivate { + constructor(private readonly configService: ConfigService) { + super(); + } + + canActivate( + context: ExecutionContext, + ): boolean | Promise | Observable { + if ( + !authProviderCheck( + AuthProvider.GITHUB, + this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'), + ) + ) { + throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 }); + } + + return super.canActivate(context); + } + + getAuthenticateOptions(context: ExecutionContext) { + const req = context.switchToHttp().getRequest(); + + return { + state: { + redirect_uri: req.query.redirect_uri, + }, + }; + } +} diff --git a/packages/hoppscotch-backend/src/auth/guards/google-sso.guard.ts b/packages/hoppscotch-backend/src/auth/guards/google-sso.guard.ts new file mode 100644 index 0000000..5bb27fe --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/guards/google-sso.guard.ts @@ -0,0 +1,39 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { AuthProvider, authProviderCheck } from '../helper'; +import { Observable } from 'rxjs'; +import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; +import { ConfigService } from '@nestjs/config'; +import { throwHTTPErr } from 'src/utils'; + +@Injectable() +export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate { + constructor(private readonly configService: ConfigService) { + super(); + } + + canActivate( + context: ExecutionContext, + ): boolean | Promise | Observable { + if ( + !authProviderCheck( + AuthProvider.GOOGLE, + this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'), + ) + ) { + throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 }); + } + + return super.canActivate(context); + } + + getAuthenticateOptions(context: ExecutionContext) { + const req = context.switchToHttp().getRequest(); + + return { + state: { + redirect_uri: req.query.redirect_uri, + }, + }; + } +} diff --git a/packages/hoppscotch-backend/src/auth/guards/jwt-auth.guard.ts b/packages/hoppscotch-backend/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..2155290 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/packages/hoppscotch-backend/src/auth/guards/microsoft-sso.guard.ts b/packages/hoppscotch-backend/src/auth/guards/microsoft-sso.guard.ts new file mode 100644 index 0000000..e2e5e25 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/guards/microsoft-sso.guard.ts @@ -0,0 +1,45 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { AuthProvider, authProviderCheck } from '../helper'; +import { Observable } from 'rxjs'; +import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; +import { ConfigService } from '@nestjs/config'; +import { throwHTTPErr } from 'src/utils'; + +@Injectable() +export class MicrosoftSSOGuard + extends AuthGuard('microsoft') + implements CanActivate +{ + constructor(private readonly configService: ConfigService) { + super(); + } + + canActivate( + context: ExecutionContext, + ): boolean | Promise | Observable { + if ( + !authProviderCheck( + AuthProvider.MICROSOFT, + this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'), + ) + ) { + throwHTTPErr({ + message: AUTH_PROVIDER_NOT_SPECIFIED, + statusCode: 404, + }); + } + + return super.canActivate(context); + } + + getAuthenticateOptions(context: ExecutionContext) { + const req = context.switchToHttp().getRequest(); + + return { + state: { + redirect_uri: req.query.redirect_uri, + }, + }; + } +} diff --git a/packages/hoppscotch-backend/src/auth/guards/rt-jwt-auth.guard.ts b/packages/hoppscotch-backend/src/auth/guards/rt-jwt-auth.guard.ts new file mode 100644 index 0000000..5b4ec68 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/guards/rt-jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class RTJwtAuthGuard extends AuthGuard('jwt-refresh') {} diff --git a/packages/hoppscotch-backend/src/auth/helper.ts b/packages/hoppscotch-backend/src/auth/helper.ts new file mode 100644 index 0000000..0f58b8b --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/helper.ts @@ -0,0 +1,235 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { AuthTokens } from 'src/types/AuthTokens'; +import { Response } from 'express'; +import * as cookie from 'cookie'; +import { + AUTH_HEADER_NOT_FOUND, + AUTH_PROVIDER_NOT_SPECIFIED, + COOKIES_NOT_FOUND, + INVALID_AUTH_HEADER, +} from 'src/errors'; +import { throwErr } from 'src/utils'; +import { ConfigService } from '@nestjs/config'; +import { IncomingHttpHeaders } from 'http'; + +enum AuthTokenType { + ACCESS_TOKEN = 'access_token', + REFRESH_TOKEN = 'refresh_token', +} + +export enum Origin { + ADMIN = 'admin', + APP = 'app', +} + +export enum AuthProvider { + GOOGLE = 'GOOGLE', + GITHUB = 'GITHUB', + MICROSOFT = 'MICROSOFT', + EMAIL = 'EMAIL', +} + +/** + * Sets and returns the cookies in the response object on successful authentication + * @param res Express Response Object + * @param authTokens Object containing the access and refresh tokens + * @param redirect if true will redirect to provided URL else just send a 200 status code + */ +export const authCookieHandler = ( + res: Response, + authTokens: AuthTokens, + redirect: boolean, + redirectUrl: string | null, + configService: ConfigService, +) => { + // Calculate token validity periods in milliseconds + let accessTokenValidityInMs = parseInt( + configService.get('INFRA.ACCESS_TOKEN_VALIDITY'), + ); + let refreshTokenValidityInMs = parseInt( + configService.get('INFRA.REFRESH_TOKEN_VALIDITY'), + ); + + // Set default values if parsing results in NaN + if (isNaN(accessTokenValidityInMs)) accessTokenValidityInMs = 86400000; // Default: 1 day + if (isNaN(refreshTokenValidityInMs)) refreshTokenValidityInMs = 604800000; // Default: 7 days + + res.cookie(AuthTokenType.ACCESS_TOKEN, authTokens.access_token, { + httpOnly: true, + secure: configService.get('INFRA.ALLOW_SECURE_COOKIES') === 'true', + sameSite: 'lax', + maxAge: accessTokenValidityInMs, + }); + res.cookie(AuthTokenType.REFRESH_TOKEN, authTokens.refresh_token, { + httpOnly: true, + secure: configService.get('INFRA.ALLOW_SECURE_COOKIES') === 'true', + sameSite: 'lax', + maxAge: refreshTokenValidityInMs, + }); + + if (!redirect) { + return res.status(HttpStatus.OK).send(); + } + + // check to see if redirectUrl is a whitelisted url + const whitelistedOrigins = + configService.get('WHITELISTED_ORIGINS')?.split(',') ?? []; + if (!whitelistedOrigins.includes(redirectUrl)) + // if it is not redirect by default to App + redirectUrl = configService.get('VITE_BASE_URL'); + + return res.status(HttpStatus.OK).redirect(redirectUrl); +}; + +/** + * Decode the cookie header from incoming websocket connects and returns a auth token pair + * @param rawCookies cookies from the websocket connection + * @returns AuthTokens for JWT strategy to use + */ +export const subscriptionContextCookieParser = (rawCookies: string) => { + const cookies = cookie.parse(rawCookies); + + if ( + !cookies[AuthTokenType.ACCESS_TOKEN] && + !cookies[AuthTokenType.REFRESH_TOKEN] + ) { + throw new HttpException(COOKIES_NOT_FOUND, 400, { + cause: new Error(COOKIES_NOT_FOUND), + }); + } + + return { + access_token: cookies[AuthTokenType.ACCESS_TOKEN], + refresh_token: cookies[AuthTokenType.REFRESH_TOKEN], + }; +}; + +/** + * Check to see if given auth provider is present in the VITE_ALLOWED_AUTH_PROVIDERS env variable + * + * @param provider Provider we want to check the presence of + * @returns Boolean if provider specified is present or not + */ +export function authProviderCheck( + provider: string, + VITE_ALLOWED_AUTH_PROVIDERS: string, +) { + if (!provider) { + throwErr(AUTH_PROVIDER_NOT_SPECIFIED); + } + + const envVariables = VITE_ALLOWED_AUTH_PROVIDERS?.split(',') ?? []; + + if (!envVariables.includes(provider.toUpperCase())) return false; + + return true; +} + +/** + * Extract cookie as key-value pairs from headers of a request + * @param headers HTTP request headers containing auth tokens + * @returns Cookie's key-value pairs + */ +export const extractCookieAsKeyValuesFromHeaders = ( + headers: IncomingHttpHeaders, +) => { + const cookieHeader = + headers['cookie'] || headers['Cookie'] || headers['COOKIE']; + + if (!cookieHeader) { + throw new HttpException(COOKIES_NOT_FOUND, 400, { + cause: new Error(COOKIES_NOT_FOUND), + }); + } + + const cookieStr = Array.isArray(cookieHeader) + ? cookieHeader[0] + : cookieHeader; + + const kv = cookieStr + .split(';') + .map((cookie) => cookie.trim()) + .reduce( + (acc, curr) => { + const [key, value] = curr.split('='); + acc[key] = value; + return acc; + }, + {} as Record, + ); + + return kv; +}; + +/** + * Extract auth tokens from cookie headers of a request + * @param headers HTTP request headers containing auth tokens + * @returns AuthTokens for JWT strategy to use + */ +export const extractAuthTokensFromCookieHeaders = ( + headers: IncomingHttpHeaders, +) => { + const cookieKV = extractCookieAsKeyValuesFromHeaders(headers); + + if ( + !cookieKV[AuthTokenType.ACCESS_TOKEN] || + !cookieKV[AuthTokenType.REFRESH_TOKEN] + ) { + throw new HttpException(COOKIES_NOT_FOUND, 400, { + cause: new Error(COOKIES_NOT_FOUND), + }); + } + + return { + access_token: cookieKV[AuthTokenType.ACCESS_TOKEN], + refresh_token: cookieKV[AuthTokenType.REFRESH_TOKEN], + }; +}; + +/** + * Extract access tokens from cookie headers of a request + * @param headers HTTP request headers containing access tokens + * @returns AccessTokens for JWT strategy to use + */ +export const extractAccessTokensFromCookieHeaders = ( + headers: IncomingHttpHeaders, +) => { + const cookieKV = extractCookieAsKeyValuesFromHeaders(headers); + + if (!cookieKV[AuthTokenType.ACCESS_TOKEN]) { + throw new HttpException(COOKIES_NOT_FOUND, 400, { + cause: new Error(COOKIES_NOT_FOUND), + }); + } + + return { + access_token: cookieKV[AuthTokenType.ACCESS_TOKEN], + }; +}; + +/** + * Extract access token from authorization header + * @param headers HTTP request headers containing bearer token + * @returns AccessTokens for JWT strategy + */ +export const extractAccessTokenFromAuthRecords = ( + headers: IncomingHttpHeaders, +) => { + const authHeader = headers['authorization'] || headers['Authorization']; + if (!authHeader) { + throw new HttpException(AUTH_HEADER_NOT_FOUND, 400, { + cause: new Error(AUTH_HEADER_NOT_FOUND), + }); + } + + const headerValue = Array.isArray(authHeader) ? authHeader[0] : authHeader; + const [bearer, access_token] = headerValue.split(' '); + + if (bearer !== 'Bearer' || !access_token) { + throw new HttpException(INVALID_AUTH_HEADER, 400, { + cause: new Error(INVALID_AUTH_HEADER), + }); + } + + return access_token; +}; diff --git a/packages/hoppscotch-backend/src/auth/redirect-uri.validator.spec.ts b/packages/hoppscotch-backend/src/auth/redirect-uri.validator.spec.ts new file mode 100644 index 0000000..cb06004 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/redirect-uri.validator.spec.ts @@ -0,0 +1,119 @@ +import { isValidLocalhostRedirectUri } from './redirect-uri.validator'; + +describe('isValidLocalhostRedirectUri', () => { + describe('valid loopback URIs', () => { + test('Should return true for http://localhost with a port', () => { + expect( + isValidLocalhostRedirectUri('http://localhost:3000/device-token'), + ).toBe(true); + }); + + test('Should return true for http://localhost without a port', () => { + expect(isValidLocalhostRedirectUri('http://localhost/callback')).toBe( + true, + ); + }); + + test('Should return true for http://127.0.0.1 with a port', () => { + expect( + isValidLocalhostRedirectUri('http://127.0.0.1:8080/callback'), + ).toBe(true); + }); + + test('Should return true for http://[::1] IPv6 loopback', () => { + expect(isValidLocalhostRedirectUri('http://[::1]:9090/callback')).toBe( + true, + ); + }); + }); + + describe('DNS wildcard bypass vectors', () => { + test('Should reject localhost subdomain via sslip.io wildcard DNS', () => { + expect( + isValidLocalhostRedirectUri( + 'http://localhost.1.2.3.4.sslip.io:3000/steal', + ), + ).toBe(false); + }); + + test('Should reject localhost subdomain via nip.io wildcard DNS', () => { + expect( + isValidLocalhostRedirectUri('http://localhost.10.0.0.1.nip.io/steal'), + ).toBe(false); + }); + + test('Should reject localhost as a subdomain of attacker domain', () => { + expect( + isValidLocalhostRedirectUri('http://localhost.evil.com/steal'), + ).toBe(false); + }); + + test('Should reject localhost with trailing dot FQDN trick', () => { + expect(isValidLocalhostRedirectUri('http://localhost./callback')).toBe( + false, + ); + }); + + test('Should reject domain that starts with localhost string', () => { + expect( + isValidLocalhostRedirectUri('http://localhostevil.com/callback'), + ).toBe(false); + }); + }); + + describe('protocol enforcement', () => { + test('Should reject https since loopback listeners do not serve TLS', () => { + expect( + isValidLocalhostRedirectUri('https://localhost:3000/callback'), + ).toBe(false); + }); + + test('Should reject ftp protocol', () => { + expect(isValidLocalhostRedirectUri('ftp://localhost/callback')).toBe( + false, + ); + }); + }); + + describe('credential and remote host rejection', () => { + test('Should reject URL with embedded credentials', () => { + expect( + isValidLocalhostRedirectUri('http://user:pass@localhost:3000/callback'), + ).toBe(false); + }); + + test('Should reject an arbitrary remote host', () => { + expect( + isValidLocalhostRedirectUri('http://attacker.com:3000/callback'), + ).toBe(false); + }); + + test('Should reject 0.0.0.0 since it is not a loopback address', () => { + expect(isValidLocalhostRedirectUri('http://0.0.0.0:3000/callback')).toBe( + false, + ); + }); + }); + + describe('malformed and empty input', () => { + test('Should return false for empty string', () => { + expect(isValidLocalhostRedirectUri('')).toBe(false); + }); + + test('Should return false for undefined', () => { + expect(isValidLocalhostRedirectUri(undefined)).toBe(false); + }); + + test('Should return false for null', () => { + expect(isValidLocalhostRedirectUri(null)).toBe(false); + }); + + test('Should return false for a plain string that is not a URL', () => { + expect(isValidLocalhostRedirectUri('not-a-url')).toBe(false); + }); + + test('Should return false for a relative path', () => { + expect(isValidLocalhostRedirectUri('/device-token')).toBe(false); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/auth/redirect-uri.validator.ts b/packages/hoppscotch-backend/src/auth/redirect-uri.validator.ts new file mode 100644 index 0000000..e82a174 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/redirect-uri.validator.ts @@ -0,0 +1,24 @@ +// Only true loopback addresses are safe for native app redirects +const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']; + +export function isValidLocalhostRedirectUri( + uri: string | undefined | null, +): boolean { + if (!uri) return false; + + let url: URL; + try { + url = new URL(uri); + } catch { + return false; + } + + // no https — desktop loopback listeners don't serve TLS + if (url.protocol !== 'http:') return false; + + // block credential-stuffed URIs like http://user:pass@localhost + if (url.username || url.password) return false; + + // exact match only + return LOOPBACK_HOSTS.indexOf(url.hostname) !== -1; +} diff --git a/packages/hoppscotch-backend/src/auth/stateless-state-store.ts b/packages/hoppscotch-backend/src/auth/stateless-state-store.ts new file mode 100644 index 0000000..c511269 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/stateless-state-store.ts @@ -0,0 +1,270 @@ +import * as crypto from 'crypto'; + +/** + * Module-level fallback secret for single-instance deployments + * where INFRA.SESSION_SECRET is not configured. + */ +const FALLBACK_SECRET = crypto.randomBytes(32).toString('hex'); + +/** + * Maximum serialized payload size in bytes. + * Prevents oversized state parameters that could break provider redirects + * or exceed URL length limits. + */ +const MAX_PAYLOAD_BYTES = 2048; + +/** + * Default cookie name used to bind the OAuth state to a specific browser session. + * Prevents login CSRF by ensuring the callback can only be completed + * by the same browser that initiated the OAuth flow. + */ +const DEFAULT_COOKIE_NAME = '__oauth_nonce'; + +/** + * A stateless OAuth state store for passport strategies. + * + * Instead of storing state in express-session (MemoryStore), this encodes + * the state as a signed, time-limited token passed through the OAuth `state` + * query parameter. The token format is: `base64url(payload).base64url(hmac)`. + * + * CSRF protection: A random nonce is generated per OAuth flow, stored in a + * SameSite=Lax HttpOnly cookie, and mixed into the HMAC signature. On + * callback, the nonce from the cookie is used to verify the signature, + * ensuring the same browser that started the flow completes it. + * + * This eliminates all server-side session state from the OAuth flow, making + * it fully compatible with horizontal scaling (multiple backend instances + * behind a load balancer). + * + * Supports both passport-oauth2 (Google, GitHub, Microsoft) which dispatches + * by `.length`, and passport-openidconnect (OIDC) which always calls with 5 args. + * + * IMPORTANT: For multi-instance deployments, `INFRA.SESSION_SECRET` must be + * set to the same value across all instances. + */ +export class StatelessStateStore { + private readonly secret: string; + private readonly maxAgeMs: number; + private readonly cookieName: string; + private readonly secureCookies: boolean; + + constructor( + secret?: string, + maxAgeMs: number = 600_000, + cookieName?: string, + secureCookies?: boolean, + ) { + if (!secret) { + if (process.env.NODE_ENV === 'production') { + throw new Error( + 'StatelessStateStore: INFRA.SESSION_SECRET must be set in production. Without a shared secret, OAuth callbacks will fail across load-balanced instances.', + ); + } + console.warn( + '[StatelessStateStore] INFRA.SESSION_SECRET not set; using ephemeral fallback. OAuth will fail in any multi-instance deployment.', + ); + } + this.secret = secret || FALLBACK_SECRET; + this.maxAgeMs = maxAgeMs; + this.cookieName = cookieName || DEFAULT_COOKIE_NAME; + this.secureCookies = secureCookies ?? false; + } + + /** + * Called by passport before redirecting to the OAuth provider. + * + * passport-oauth2 dispatches by `.length`: + * arity 5: store(req, verifier, state, meta, cb) — PKCE flow + * arity 4: store(req, state, meta, cb) — standard flow + * arity 3: store(req, meta, cb) — no state + * + * passport-openidconnect always calls with 5 args: + * store(req, ctx, appState, meta, cb) + * + * We declare 5 params so `.length === 5` satisfies both libraries. + * + * The token payload stores both `ctx` (OIDC context / PKCE verifier) and + * `state` (the app state, e.g. { redirect_uri }) so that verify() can + * return them both correctly to each library's restore/loaded callback. + */ + store( + req: any, + ctxOrState: any, + stateOrMeta: any, + metaOrCb: any, + cb?: Function, + ): void { + let ctx: any; + let state: any; + let callback: Function; + + if (typeof cb === 'function') { + // 5-arg: (req, verifier/ctx, state, meta, cb) + // For passport-oauth2 PKCE: verifier is a string, state is the app state + // For passport-openidconnect: ctx has { maxAge, nonce, issued }, state is app state + ctx = ctxOrState; + state = stateOrMeta; + callback = cb; + } else if (typeof metaOrCb === 'function') { + // 4-arg: (req, state, meta, cb) + ctx = undefined; + state = ctxOrState; + callback = metaOrCb; + } else if (typeof stateOrMeta === 'function') { + // 3-arg: (req, meta, cb) + ctx = undefined; + state = {}; + callback = stateOrMeta; + } else { + throw new Error('StatelessStateStore.store: invalid arguments'); + } + + try { + // Generate a browser-bound nonce for CSRF protection + const nonce = crypto.randomBytes(16).toString('hex'); + + const payload: any = { + state: state, + exp: Date.now() + this.maxAgeMs, + nonce: nonce, + }; + // Store ctx only if defined (OIDC context or PKCE verifier) + if (ctx !== undefined && ctx !== null) { + payload.ctx = ctx; + } + + const serialized = JSON.stringify(payload); + if (Buffer.byteLength(serialized) > MAX_PAYLOAD_BYTES) { + return callback( + new Error('OAuth state payload exceeds maximum allowed size'), + ); + } + + const encoded = this.encode(payload); + + // Set the nonce as a SameSite cookie to bind the OAuth flow to this browser + if (req.res) { + req.res.cookie(this.cookieName, nonce, { + httpOnly: true, + sameSite: 'lax', + secure: this.secureCookies, + maxAge: this.maxAgeMs, + path: '/', + }); + } + + callback(null, encoded); + } catch (err) { + callback(err); + } + } + + /** + * Called by passport on the callback to verify the state parameter. + * + * passport-oauth2 dispatches by `.length`: + * arity 4: verify(req, state, meta, cb) + * arity 3: verify(req, state, cb) + * + * passport-openidconnect always calls with 3 args: + * verify(req, handle, cb) → restored(err, ctx, state) + * + * We declare 3 params (matching both) since passport-oauth2 will dispatch + * to arity 3 when `.length === 3`. + * + * Return semantics (second arg → third arg of cb): + * passport-oauth2: cb(null, ok:truthy|string, appState) + * passport-openidconnect: cb(null, ctx:{maxAge,nonce,issued}, appState) + * + * We return the stored ctx (or `true` when ctx was not stored) as the + * second arg, and the app state as the third arg. This satisfies both: + * - passport-oauth2 just needs truthy (or a PKCE verifier string) + * - passport-openidconnect needs the OIDC context object + */ + verify(req: any, providedState: string, cb: Function): void { + try { + // Read the browser-bound nonce from the cookie + const cookieNonce = req.cookies?.[this.cookieName]; + + // Clear the nonce cookie regardless of outcome + if (req.res) { + req.res.clearCookie(this.cookieName, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: this.secureCookies, + }); + } + + if (!cookieNonce) { + return cb(null, false, { message: 'Missing OAuth nonce cookie' }); + } + + const payload = this.decode(providedState, cookieNonce); + if (!payload) { + return cb(null, false, { message: 'Invalid state signature' }); + } + + if (payload.exp && Date.now() > payload.exp) { + return cb(null, false, { message: 'State has expired' }); + } + + // Return ctx (OIDC context or PKCE verifier) as second arg, + // falling back to `true` for plain OAuth2 flows without ctx. + // Return the app state (e.g. { redirect_uri }) as third arg. + const ctx = payload.ctx !== undefined ? payload.ctx : true; + cb(null, ctx, payload.state); + } catch (err) { + cb(err); + } + } + + /** + * Decode and verify a signed state token. + * Returns null if the token is missing, malformed, or the signature is invalid. + */ + private decode(token: string, nonce?: string): any | null { + try { + if (!token) return null; + + const dotIndex = token.indexOf('.'); + if (dotIndex === -1) return null; + + const payloadStr = token.substring(0, dotIndex); + const signature = token.substring(dotIndex + 1); + + const expectedSignature = this.sign(payloadStr, nonce); + + if ( + signature.length !== expectedSignature.length || + !crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature), + ) + ) { + return null; + } + + return JSON.parse(Buffer.from(payloadStr, 'base64url').toString()); + } catch { + return null; + } + } + + private encode(payload: any): string { + const payloadStr = Buffer.from(JSON.stringify(payload)).toString( + 'base64url', + ); + const signature = this.sign(payloadStr, payload.nonce); + return `${payloadStr}.${signature}`; + } + + private sign(data: string, nonce?: string): string { + const hmac = crypto.createHmac('sha256', this.secret); + hmac.update(data); + if (nonce) { + hmac.update(nonce); + } + return hmac.digest('base64url'); + } +} diff --git a/packages/hoppscotch-backend/src/auth/strategies/github.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/github.strategy.ts new file mode 100644 index 0000000..17de336 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/strategies/github.strategy.ts @@ -0,0 +1,87 @@ +import { Strategy, Profile } from 'passport-github2'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthService } from '../auth.service'; +import { UserService } from 'src/user/user.service'; +import * as O from 'fp-ts/Option'; +import * as E from 'fp-ts/Either'; +import { ConfigService } from '@nestjs/config'; +import { validateEmail } from 'src/utils'; +import { AUTH_EMAIL_NOT_PROVIDED_BY_OAUTH } from 'src/errors'; +import { StatelessStateStore } from '../stateless-state-store'; + +@Injectable() +export class GithubStrategy extends PassportStrategy(Strategy) { + constructor( + private authService: AuthService, + private usersService: UserService, + private configService: ConfigService, + ) { + super({ + clientID: configService.get('INFRA.GITHUB_CLIENT_ID'), + clientSecret: configService.get('INFRA.GITHUB_CLIENT_SECRET'), + callbackURL: configService.get('INFRA.GITHUB_CALLBACK_URL'), + scope: [configService.get('INFRA.GITHUB_SCOPE')], + store: new StatelessStateStore( + configService.get('INFRA.SESSION_SECRET'), + undefined, + (configService.get('INFRA.SESSION_COOKIE_NAME') || + '__oauth_nonce') + '_github', + configService.get('INFRA.ALLOW_SECURE_COOKIES') === 'true', + ), + }); + } + + async validate( + accessToken: string, + refreshToken: string, + profile: Profile, + done, + ) { + const email = profile.emails?.[0].value; + + if (!validateEmail(email)) + throw new UnauthorizedException(AUTH_EMAIL_NOT_PROVIDED_BY_OAUTH); + + const user = await this.usersService.findUserByEmail(email); + + if (O.isNone(user)) { + const createdUser = await this.usersService.createUserSSO( + accessToken, + refreshToken, + profile, + ); + return createdUser; + } + + /** + * displayName and photoURL maybe null if user logged-in via magic-link before SSO + */ + if (!user.value.displayName || !user.value.photoURL) { + const updatedUser = await this.usersService.updateUserDetails( + user.value, + profile, + ); + if (E.isLeft(updatedUser)) { + throw new UnauthorizedException(updatedUser.left); + } + } + + /** + * Check to see if entry for Github is present in the Account table for user + * If user was created with another provider findUserByEmail may return true + */ + const providerAccountExists = + await this.authService.checkIfProviderAccountExists(user.value, profile); + + if (O.isNone(providerAccountExists)) + await this.usersService.createProviderAccount( + user.value, + accessToken, + refreshToken, + profile, + ); + + return user.value; + } +} diff --git a/packages/hoppscotch-backend/src/auth/strategies/google.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/google.strategy.ts new file mode 100644 index 0000000..7b5e115 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/strategies/google.strategy.ts @@ -0,0 +1,90 @@ +import { Strategy, VerifyCallback, Profile } from 'passport-google-oauth20'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { UserService } from 'src/user/user.service'; +import * as O from 'fp-ts/Option'; +import { AuthService } from '../auth.service'; +import * as E from 'fp-ts/Either'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; +import { validateEmail } from 'src/utils'; +import { AUTH_EMAIL_NOT_PROVIDED_BY_OAUTH } from 'src/errors'; +import { StatelessStateStore } from '../stateless-state-store'; + +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy) { + constructor( + private usersService: UserService, + private authService: AuthService, + private configService: ConfigService, + ) { + super({ + clientID: configService.get('INFRA.GOOGLE_CLIENT_ID'), + clientSecret: configService.get('INFRA.GOOGLE_CLIENT_SECRET'), + callbackURL: configService.get('INFRA.GOOGLE_CALLBACK_URL'), + scope: configService.get('INFRA.GOOGLE_SCOPE').split(','), + passReqToCallback: true, + store: new StatelessStateStore( + configService.get('INFRA.SESSION_SECRET'), + undefined, + (configService.get('INFRA.SESSION_COOKIE_NAME') || + '__oauth_nonce') + '_google', + configService.get('INFRA.ALLOW_SECURE_COOKIES') === 'true', + ), + }); + } + + async validate( + req: Request, + accessToken: string, + refreshToken: string, + profile: Profile, + done: VerifyCallback, + ) { + const email = profile.emails?.[0].value; + + if (!validateEmail(email)) + throw new UnauthorizedException(AUTH_EMAIL_NOT_PROVIDED_BY_OAUTH); + + const user = await this.usersService.findUserByEmail(email); + + if (O.isNone(user)) { + const createdUser = await this.usersService.createUserSSO( + accessToken, + refreshToken, + profile, + ); + return createdUser; + } + + /** + * displayName and photoURL maybe null if user logged-in via magic-link before SSO + */ + if (!user.value.displayName || !user.value.photoURL) { + const updatedUser = await this.usersService.updateUserDetails( + user.value, + profile, + ); + if (E.isLeft(updatedUser)) { + throw new UnauthorizedException(updatedUser.left); + } + } + + /** + * Check to see if entry for Google is present in the Account table for user + * If user was created with another provider findUserByEmail may return true + */ + const providerAccountExists = + await this.authService.checkIfProviderAccountExists(user.value, profile); + + if (O.isNone(providerAccountExists)) + await this.usersService.createProviderAccount( + user.value, + accessToken, + refreshToken, + profile, + ); + + return user.value; + } +} diff --git a/packages/hoppscotch-backend/src/auth/strategies/jwt.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..3b9b97c --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,110 @@ +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { + Injectable, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; +import { AccessTokenPayload } from 'src/types/AuthTokens'; +import { UserService } from 'src/user/user.service'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; +import * as O from 'fp-ts/Option'; +import * as E from 'fp-ts/Either'; +import { pipe } from 'fp-ts/function'; +import { + COOKIES_NOT_FOUND, + INVALID_ACCESS_TOKEN, + USER_NOT_FOUND, +} from 'src/errors'; + +/** + * Extracts an access token from a cookie in the request. + * + * @param request - Express Request object + * @returns Option containing the token if found + */ +const extractFromCookie = (request: Request): O.Option => + pipe( + O.fromNullable(request.cookies), + O.chain((cookies) => O.fromNullable(cookies['access_token'])), + ); + +/** + * Extracts an access token from the Authorization header. + * Expects the header to be in the format: 'Bearer '. + * + * @param request - Express Request object + * @returns Option containing the token if found + */ +const extractFromAuthHeaders = (request: Request): O.Option => + pipe( + // First try headers.authorization, then fall back to root level authorization + // see `gql-auth.guard` for more info. + O.fromNullable( + request?.headers?.authorization || + (request && 'authorization' in request + ? request['authorization'] + : undefined), + ), + O.chain((auth) => + typeof auth === 'string' && auth.startsWith('Bearer ') + ? O.some(auth.slice(7)) + : O.none, + ), + ); + +/** + * Combines cookie and header token extraction strategies. + * Attempts to extract from cookie first, then falls back to Authorization header. + * + * @param request - Express Request object + * @returns Either containing the token or an error + */ +const extractToken = (request: Request): E.Either => + pipe( + extractFromCookie(request), + O.alt(() => extractFromAuthHeaders(request)), + // Neither `Authorization` header nor `Cookie` were found with the request, + // `COOKIES_NOT_FOUND` for backwards compatibility. + E.fromOption(() => { + return new ForbiddenException(COOKIES_NOT_FOUND); + }), + ); + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor( + private usersService: UserService, + private configService: ConfigService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([ + (request: Request) => + pipe( + extractToken(request), + E.fold( + (error) => { + throw error; + }, + (token) => { + return token; + }, + ), + ), + ]), + secretOrKey: configService.get('INFRA.JWT_SECRET'), + }); + } + + async validate(payload: AccessTokenPayload) { + if (!payload) throw new ForbiddenException(INVALID_ACCESS_TOKEN); + + const user = await this.usersService.findUserById(payload.sub); + if (O.isNone(user)) { + throw new UnauthorizedException(USER_NOT_FOUND); + } + + return user.value; + } +} diff --git a/packages/hoppscotch-backend/src/auth/strategies/microsoft.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/microsoft.strategy.ts new file mode 100644 index 0000000..262630e --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/strategies/microsoft.strategy.ts @@ -0,0 +1,83 @@ +import { Strategy } from 'passport-microsoft'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthService } from '../auth.service'; +import { UserService } from 'src/user/user.service'; +import * as O from 'fp-ts/Option'; +import * as E from 'fp-ts/Either'; +import { ConfigService } from '@nestjs/config'; +import { validateEmail } from 'src/utils'; +import { AUTH_EMAIL_NOT_PROVIDED_BY_OAUTH } from 'src/errors'; +import { StatelessStateStore } from '../stateless-state-store'; + +@Injectable() +export class MicrosoftStrategy extends PassportStrategy(Strategy) { + constructor( + private authService: AuthService, + private usersService: UserService, + private configService: ConfigService, + ) { + super({ + clientID: configService.get('INFRA.MICROSOFT_CLIENT_ID'), + clientSecret: configService.get('INFRA.MICROSOFT_CLIENT_SECRET'), + callbackURL: configService.get('INFRA.MICROSOFT_CALLBACK_URL'), + scope: configService.get('INFRA.MICROSOFT_SCOPE').split(','), + tenant: configService.get('INFRA.MICROSOFT_TENANT'), + store: new StatelessStateStore( + configService.get('INFRA.SESSION_SECRET'), + undefined, + (configService.get('INFRA.SESSION_COOKIE_NAME') || + '__oauth_nonce') + '_microsoft', + configService.get('INFRA.ALLOW_SECURE_COOKIES') === 'true', + ), + }); + } + + async validate(accessToken: string, refreshToken: string, profile, done) { + const email = profile?.emails?.[0]?.value; + + if (!validateEmail(email)) + throw new UnauthorizedException(AUTH_EMAIL_NOT_PROVIDED_BY_OAUTH); + + const user = await this.usersService.findUserByEmail(email); + + if (O.isNone(user)) { + const createdUser = await this.usersService.createUserSSO( + accessToken, + refreshToken, + profile, + ); + return createdUser; + } + + /** + * displayName and photoURL maybe null if user logged-in via magic-link before SSO + */ + if (!user.value.displayName || !user.value.photoURL) { + const updatedUser = await this.usersService.updateUserDetails( + user.value, + profile, + ); + if (E.isLeft(updatedUser)) { + throw new UnauthorizedException(updatedUser.left); + } + } + + /** + * Check to see if entry for Microsoft is present in the Account table for user + * If user was created with another provider findUserByEmail may return true + */ + const providerAccountExists = + await this.authService.checkIfProviderAccountExists(user.value, profile); + + if (O.isNone(providerAccountExists)) + await this.usersService.createProviderAccount( + user.value, + accessToken, + refreshToken, + profile, + ); + + return user.value; + } +} diff --git a/packages/hoppscotch-backend/src/auth/strategies/rt-jwt.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/rt-jwt.strategy.ts new file mode 100644 index 0000000..e87725c --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/strategies/rt-jwt.strategy.ts @@ -0,0 +1,50 @@ +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { + Injectable, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; +import { UserService } from 'src/user/user.service'; +import { Request } from 'express'; +import { RefreshTokenPayload } from 'src/types/AuthTokens'; +import { + COOKIES_NOT_FOUND, + INVALID_REFRESH_TOKEN, + USER_NOT_FOUND, +} from 'src/errors'; +import * as O from 'fp-ts/Option'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { + constructor( + private usersService: UserService, + private configService: ConfigService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([ + (request: Request) => { + const RTCookie = request.cookies?.['refresh_token']; + if (!RTCookie) { + console.error('`refresh_token` not found'); + throw new ForbiddenException(COOKIES_NOT_FOUND); + } + return RTCookie; + }, + ]), + secretOrKey: configService.get('INFRA.JWT_SECRET'), + }); + } + + async validate(payload: RefreshTokenPayload) { + if (!payload) throw new ForbiddenException(INVALID_REFRESH_TOKEN); + + const user = await this.usersService.findUserById(payload.sub); + if (O.isNone(user)) { + throw new UnauthorizedException(USER_NOT_FOUND); + } + + return user.value; + } +} diff --git a/packages/hoppscotch-backend/src/decorators/bearer-token.decorator.ts b/packages/hoppscotch-backend/src/decorators/bearer-token.decorator.ts new file mode 100644 index 0000000..648ed54 --- /dev/null +++ b/packages/hoppscotch-backend/src/decorators/bearer-token.decorator.ts @@ -0,0 +1,15 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +/** + ** Decorator to fetch refresh_token from cookie + */ +export const BearerToken = createParamDecorator( + (data: unknown, context: ExecutionContext) => { + const request = context.switchToHttp().getRequest(); + + // authorization token will be "Bearer " + const authorization = request.headers['authorization']; + // Remove "Bearer " and return the token only + return authorization.split(' ')[1]; + }, +); diff --git a/packages/hoppscotch-backend/src/decorators/gql-user.decorator.ts b/packages/hoppscotch-backend/src/decorators/gql-user.decorator.ts new file mode 100644 index 0000000..71042e0 --- /dev/null +++ b/packages/hoppscotch-backend/src/decorators/gql-user.decorator.ts @@ -0,0 +1,10 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; + +export const GqlUser = createParamDecorator( + (data: unknown, context: ExecutionContext) => { + const ctx = GqlExecutionContext.create(context); + const { req, headers } = ctx.getContext(); + return headers ? headers.user : req.user; + }, +); diff --git a/packages/hoppscotch-backend/src/decorators/rt-cookie.decorator.ts b/packages/hoppscotch-backend/src/decorators/rt-cookie.decorator.ts new file mode 100644 index 0000000..a0d12eb --- /dev/null +++ b/packages/hoppscotch-backend/src/decorators/rt-cookie.decorator.ts @@ -0,0 +1,12 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; + +/** + ** Decorator to fetch refresh_token from cookie + */ +export const RTCookie = createParamDecorator( + (data: unknown, context: ExecutionContext) => { + const ctx = GqlExecutionContext.create(context); + return ctx.getContext().req.cookies['refresh_token']; + }, +); diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts new file mode 100644 index 0000000..fac3473 --- /dev/null +++ b/packages/hoppscotch-backend/src/errors.ts @@ -0,0 +1,993 @@ +export const INVALID_EMAIL = 'invalid/email' as const; + +export const EMAIL_FAILED = 'email/failed' as const; +export const DUPLICATE_EMAIL = 'email/both_emails_cannot_be_same' as const; + +/** + * Only one admin account found in infra + * (AdminService) + */ +export const ONLY_ONE_ADMIN_ACCOUNT = + 'admin/only_one_admin_account_found' as const; + +/** + * Admin user can not be deleted + * To delete the admin user, first make the Admin user a normal user + * (AdminService) + */ +export const ADMIN_CAN_NOT_BE_DELETED = + 'admin/admin_can_not_be_deleted' as const; + +/** + * Token Authorization failed (Check 'Authorization' Header) + * (GqlAuthGuard) + */ +export const AUTH_FAIL = 'auth/fail' as const; + +/** + * Invalid JSON + * (Utils) + */ +export const JSON_INVALID = 'json_invalid' as const; + +/** + * Auth Provider not specified + * (Auth) + */ +export const AUTH_PROVIDER_NOT_SPECIFIED = + 'auth/provider_not_specified' as const; + +/** + * Email not provided by OAuth provider + * (SSO Strategies) + */ +export const AUTH_EMAIL_NOT_PROVIDED_BY_OAUTH = + 'auth/email_not_provided_by_oauth'; + +/** + * Environment variable "DATA_ENCRYPTION_KEY" is not present in .env file + */ +export const ENV_NOT_FOUND_KEY_DATA_ENCRYPTION_KEY = + '"DATA_ENCRYPTION_KEY" is not present in .env file'; + +/** + * Environment variable "DATA_ENCRYPTION_KEY" is changed in .env file + */ +export const ENV_INVALID_DATA_ENCRYPTION_KEY = + '"DATA_ENCRYPTION_KEY" value changed in .env file. Please undo the changes and restart the server'; + +/** + * Tried to delete a user data document from fb firestore but failed. + * (FirebaseService) + */ +export const USER_FB_DOCUMENT_DELETION_FAILED = + 'fb/firebase_document_deletion_failed' as const; + +/** + * Tried to do an action on a user where user is not found + */ +export const USER_NOT_FOUND = 'user/not_found' as const; + +/** + * User is already invited by admin + */ +export const USER_ALREADY_INVITED = 'admin/user_already_invited' as const; + +/** + * User update failure + * (UserService) + */ +export const USER_UPDATE_FAILED = 'user/update_failed' as const; + +/** + * User display name validation failure + * (UserService) + */ +export const USER_SHORT_DISPLAY_NAME = 'user/short_display_name' as const; + +/** + * User deletion failure + * (UserService) + */ +export const USER_DELETION_FAILED = 'user/deletion_failed' as const; + +/** + * Users not found + * (UserService) + */ +export const USERS_NOT_FOUND = 'user/users_not_found' as const; + +/** + * User deletion failure error due to user being a team owner + * (UserService) + */ +export const USER_IS_OWNER = 'user/is_owner' as const; +/** + * User deletion failure error due to user being an admin + * (UserService) + */ +export const USER_IS_ADMIN = 'user/is_admin' as const; + +/** + * User invite deletion failure error due to invitation not found + * (AdminService) + */ +export const USER_INVITATION_DELETION_FAILED = + 'user/invitation_deletion_failed' as const; + +/** + * Teams not found + * (TeamsService) + */ +export const TEAMS_NOT_FOUND = 'user/teams_not_found' as const; + +/** + * Tried to find user collection but failed + * (UserRequestService) + */ +export const USER_COLLECTION_NOT_FOUND = 'user_collection/not_found' as const; + +/** + * Tried to reorder user request but failed + * (UserRequestService) + */ +export const USER_REQUEST_CREATION_FAILED = + 'user_request/creation_failed' as const; + +/** + * Tried to do an action on a user request but user request is not matched with user collection + * (UserRequestService) + */ +export const USER_REQUEST_INVALID_TYPE = 'user_request/type_mismatch' as const; + +/** + * Tried to do an action on a user request where user request is not found + * (UserRequestService) + */ +export const USER_REQUEST_NOT_FOUND = 'user_request/not_found' as const; + +/** + * Tried to reorder user request but failed + * (UserRequestService) + */ +export const USER_REQUEST_REORDERING_FAILED = + 'user_request/reordering_failed' as const; + +/** + * Tried to perform action on a team which they are not a member of + * (GqlTeamMemberGuard) + */ +export const TEAM_MEMBER_NOT_FOUND = 'team/member_not_found' as const; + +/** + * Tried to perform action on a team that doesn't accept their member role level + * (GqlTeamMemberGuard) + */ +export const TEAM_NOT_REQUIRED_ROLE = 'team/not_required_role' as const; + +/** + * Team name validation failure + * (TeamService) + */ +export const TEAM_NAME_INVALID = 'team/name_invalid' as const; + +/** + * Couldn't find the sync data from the user + * (TeamCollectionService) + */ +export const TEAM_USER_NO_FB_SYNCDATA = 'team/user_no_fb_syncdata'; + +/** + * There was a problem resolving the firebase collection path + * (TeamCollectionService) + */ +export const TEAM_FB_COLL_PATH_RESOLVE_FAIL = 'team/fb_coll_path_resolve_fail'; + +/** + * Could not find the team in the database + * (TeamCollectionService) + */ +export const TEAM_COLL_NOT_FOUND = 'team_coll/collection_not_found'; + +/** + * Could not find the team in the database + * (TeamCollectionService) + */ +export const TEAM_COLL_CREATION_FAILED = 'team_coll/creation_failed'; + +/** + * Cannot make parent collection a child of a collection that a child of itself + * (TeamCollectionService) + */ +export const TEAM_COLL_IS_PARENT_COLL = 'team_coll/collection_is_parent_coll'; + +/** + * Target and Parent collections are not from the same team + * (TeamCollectionService) + */ +export const TEAM_COLL_NOT_SAME_TEAM = 'team_coll/collections_not_same_team'; + +/** + * Target and Parent collections are the same + * (TeamCollectionService) + */ +export const TEAM_COLL_DEST_SAME = + 'team_coll/target_and_destination_collection_are_same'; + +/** + * Collection is already a root collection + * (TeamCollectionService) + */ +export const TEAM_COL_ALREADY_ROOT = + 'team_coll/target_collection_is_already_root_collection'; + +/** + * Collections have different parents + * (TeamCollectionService) + */ +export const TEAM_COL_NOT_SAME_PARENT = + 'team_coll/team_collections_have_different_parents'; + +/** + * Collection and next Collection are the same + * (TeamCollectionService) + */ +export const TEAM_COL_SAME_NEXT_COLL = + 'team_coll/collection_and_next_collection_are_same'; + +/** + * Team Collection search failed + * (TeamCollectionService) + */ +export const TEAM_COL_SEARCH_FAILED = 'team_coll/team_collection_search_failed'; + +/** + * Team Collection Re-Ordering Failed + * (TeamCollectionService) + */ +export const TEAM_COL_REORDERING_FAILED = 'team_coll/reordering_failed'; + +/** + * Tried to update the team to a state it doesn't have any owners + * (TeamService) + */ +export const TEAM_ONLY_ONE_OWNER = 'team/only_one_owner'; + +/** + * Invalid or non-existent Team ID + * (TeamService) + */ +export const TEAM_INVALID_ID = 'team/invalid_id' as const; + +/** + * Invalid or non-existent collection id + * (GqlCollectionTeamMemberGuard) + */ +export const TEAM_INVALID_COLL_ID = 'team/invalid_coll_id' as const; + +/** + * Invalid team id or user id + * (TeamService) + */ +export const TEAM_INVALID_ID_OR_USER = 'team/invalid_id_or_user'; + +/** + * The provided title for the team collection is short (less than 3 characters) + * (TeamCollectionService) + */ +export const TEAM_COLL_SHORT_TITLE = 'team_coll/short_title'; + +/** + * The JSON used is not valid + * (TeamCollectionService) + */ +export const TEAM_COLL_INVALID_JSON = 'team_coll/invalid_json'; + +/** + * The Team Collection does not belong to the team + * (TeamCollectionService) + */ +export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const; + +/** + * The Team Collection data is not valid + * (TeamCollectionService) + */ +export const TEAM_COLL_DATA_INVALID = + 'team_coll/team_coll_data_invalid' as const; + +/** + * Team Collection parent tree generation failed + * (TeamCollectionService) + */ +export const TEAM_COLL_PARENT_TREE_GEN_FAILED = + 'team_coll/team_coll_parent_tree_generation_failed'; + +/** + * Tried to perform an action on a request that doesn't accept their member role level + * (GqlRequestTeamMemberGuard) + */ +export const TEAM_REQ_NOT_REQUIRED_ROLE = 'team_req/not_required_role'; + +/** + * Tried to operate on a request which does not exist + * (TeamRequestService) + */ +export const TEAM_REQ_NOT_FOUND = 'team_req/not_found' as const; + +/** + * Invalid or non-existent collection id + * (TeamRequestService) + */ +export const TEAM_REQ_INVALID_TARGET_COLL_ID = + 'team_req/invalid_target_id' as const; + +/** + * Tried to reorder team request but failed + * (TeamRequestService) + */ +export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const; + +/** + * Team Request search failed + * (TeamRequestService) + */ +export const TEAM_REQ_SEARCH_FAILED = 'team_req/team_request_search_failed'; + +/** + * Team Request parent tree generation failed + * (TeamRequestService) + */ +export const TEAM_REQ_PARENT_TREE_GEN_FAILED = + 'team_req/team_req_parent_tree_generation_failed'; + +/** + * No Postmark Sender Email defined + * (AuthService) + */ +export const SENDER_EMAIL_INVALID = 'mailer/sender_email_invalid' as const; + +/** + * Tried to perform an action on a request when the user is not even a member of the team + * (GqlRequestTeamMemberGuard, GqlCollectionTeamMemberGuard) + */ +export const TEAM_REQ_NOT_MEMBER = 'team_req/not_member'; + +export const TEAM_INVITE_MEMBER_HAS_INVITE = + 'team_invite/member_has_invite' as const; + +export const TEAM_INVITE_NO_INVITE_FOUND = + 'team_invite/no_invite_found' as const; + +export const TEAM_INVITE_ALREADY_MEMBER = 'team_invite/already_member' as const; + +export const TEAM_INVITE_EMAIL_DO_NOT_MATCH = + 'team_invite/email_do_not_match' as const; + +export const TEAM_INVITE_NOT_VALID_VIEWER = + 'team_invite/not_valid_viewer' as const; + +/** + * No team invitations found + * (TeamInvitationService) + */ +export const TEAM_INVITATION_NOT_FOUND = + 'team_invite/invitations_not_found' as const; + +/** + * ShortCode not found in DB + * (ShortcodeService) + */ +export const SHORTCODE_NOT_FOUND = 'shortcode/not_found' as const; + +/** + * Invalid or non-existent TEAM ENVIRONMENT ID + * (TeamEnvironmentsService) + */ +export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const; + +/** + * Invalid TEAM ENVIRONMENT name + * (TeamEnvironmentsService) + */ +export const TEAM_ENVIRONMENT_SHORT_NAME = + 'team_environment/short_name' as const; + +/** + * The user is not a member of the team of the given environment + * (GqlTeamEnvTeamGuard) + */ +export const TEAM_ENVIRONMENT_NOT_TEAM_MEMBER = + 'team_environment/not_team_member' as const; + +/** + * User setting not found for a user + * (UserSettingsService) + */ +export const USER_SETTINGS_NOT_FOUND = 'user_settings/not_found' as const; + +/** + * User setting already exists for a user + * (UserSettingsService) + */ +export const USER_SETTINGS_ALREADY_EXISTS = + 'user_settings/settings_already_exists' as const; + +/** + * User setting invalid (null) settings + * (UserSettingsService) + */ +export const USER_SETTINGS_NULL_SETTINGS = + 'user_settings/null_settings' as const; + +/* + * Global environment doesn't exist for the user + * (UserEnvironmentsService) + */ +export const USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXIST = + 'user_environment/global_env_does_not_exist' as const; + +/** + * Global environment already exists for the user + * (UserEnvironmentsService) + */ +export const USER_ENVIRONMENT_GLOBAL_ENV_EXISTS = + 'user_environment/global_env_already_exists' as const; + +/** + * User environment doesn't exist for the user + * (UserEnvironmentsService) + */ +export const USER_ENVIRONMENT_ENV_DOES_NOT_EXIST = + 'user_environment/user_env_does_not_exist' as const; + +/** + * Cannot delete the global user environment + * (UserEnvironmentsService) + */ +export const USER_ENVIRONMENT_GLOBAL_ENV_DELETION_FAILED = + 'user_environment/user_env_global_env_deletion_failed' as const; + +/** + * User environment is not a global environment + * (UserEnvironmentsService) + */ +export const USER_ENVIRONMENT_IS_NOT_GLOBAL = + 'user_environment/user_env_is_not_global' as const; + +/** + * User environment update failed + * (UserEnvironmentsService) + */ +export const USER_ENVIRONMENT_UPDATE_FAILED = + 'user_environment/user_env_update_failed' as const; + +/** + * User environment not found for the user + * (UserEnvironmentsService) + */ +export const USER_ENVIRONMENT_NOT_FOUND = 'user_environment/not_found' as const; + +/** + * User environment invalid environment name + * (UserEnvironmentsService) + */ +export const USER_ENVIRONMENT_INVALID_ENVIRONMENT_NAME = + 'user_environment/user_env_invalid_env_name' as const; + +/** + * User history not found + * (UserHistoryService) + */ +export const USER_HISTORY_NOT_FOUND = 'user_history/history_not_found' as const; + +/** + * User history deletion failed + * (UserHistoryService) + */ +export const USER_HISTORY_DELETION_FAILED = + 'user_history/deletion_failed' as const; + +/** + * User history feature flag is disabled + * (UserHistoryService) + */ +export const USER_HISTORY_FEATURE_FLAG_DISABLED = + 'user_history/feature_flag_disabled'; + +/** + * Invalid Request Type in History + * (UserHistoryService) + */ +export const USER_HISTORY_INVALID_REQ_TYPE = + 'user_history/req_type_invalid' as const; + +/* + + |------------------------------------| + |Server errors that are actually bugs| + |------------------------------------| + +*/ + +/** + * Couldn't find user data from the GraphQL context (Check if GqlAuthGuard is applied) + * (GqlTeamMemberGuard, GqlCollectionTeamMemberGuard) + */ +export const BUG_AUTH_NO_USER_CTX = 'bug/auth/auth_no_user_ctx' as const; + +/** + * Couldn't find teamID parameter in the attached GraphQL operation. (Check if teamID is present) + * (GqlTeamMemberGuard, GQLEAAdminGuard, GqlCollectionTeamMemberGuard) + */ +export const BUG_TEAM_NO_TEAM_ID = 'bug/team/no_team_id'; + +/** + * Couldn't find RequireTeamRole decorator. (Check if it is applied) + * (GqlTeamMemberGuard) + */ +export const BUG_TEAM_NO_REQUIRE_TEAM_ROLE = 'bug/team/no_require_team_role'; + +/** + * Couldn't find 'collectionID' param to the attached GQL operation. (Check if exists) + * (GqlCollectionTeamMemberGuard) + */ +export const BUG_TEAM_COLL_NO_COLL_ID = 'bug/team_coll/no_coll_id'; + +/** + * Couldn't find 'requestID' param to the attached GQL operation. (Check if exists) + * (GqlRequestTeamMemberGuard) + */ +export const BUG_TEAM_REQ_NO_REQ_ID = 'bug/team_req/no_req_id'; + +export const BUG_TEAM_INVITE_NO_INVITE_ID = + 'bug/team_invite/no_invite_id' as const; + +/** + * Couldn't find RequireTeamRole decorator. (Check if it is applied) + * (GqlTeamEnvTeamGuard) + */ +export const BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES = + 'bug/team_env/guard_no_require_roles' as const; + +/** + * Couldn't find 'id' param to the operation. (Check if it is applied) + * (GqlTeamEnvTeamGuard) + */ +export const BUG_TEAM_ENV_GUARD_NO_ENV_ID = + 'bug/team_env/guard_no_env_id' as const; + +/** + * The data sent to the verify route are invalid + * (AuthService) + */ +export const INVALID_MAGIC_LINK_DATA = 'auth/magic_link_invalid_data' as const; + +/** + * Could not find VerificationToken entry in the db + * (AuthService) + */ +export const VERIFICATION_TOKEN_DATA_NOT_FOUND = + 'auth/verification_token_data_not_found' as const; + +/** + * Auth Tokens expired + * (AuthService) + */ +export const TOKEN_EXPIRED = 'auth/token_expired' as const; + +/** + * VerificationToken Tokens expired i.e. magic-link expired + * (AuthService) + */ +export const MAGIC_LINK_EXPIRED = 'auth/magic_link_expired' as const; + +/** + * Auth header was NOT found in the auth request + * (AuthService) + */ +export const AUTH_HEADER_NOT_FOUND = 'auth/auth_header_not_found' as const; + +/** + * Auth header was found but the format was invalid + * (AuthService) + */ +export const INVALID_AUTH_HEADER = 'auth/invalid_auth_header' as const; + +/** + * No cookies were found in the auth request + * (AuthService) + */ +export const COOKIES_NOT_FOUND = 'auth/cookies_not_found' as const; + +/** + * Access Token is malformed or invalid + * (AuthService) + */ +export const INVALID_ACCESS_TOKEN = 'auth/invalid_access_token' as const; + +/** + * Refresh Token is malformed or invalid + * (AuthService) + */ +export const INVALID_REFRESH_TOKEN = 'auth/invalid_refresh_token' as const; + +/** + * The provided title for the user collection is short (less than 3 characters) + * (UserCollectionService) + */ +export const USER_COLL_SHORT_TITLE = 'user_coll/short_title' as const; + +/** + * User Collection could not be found + * (UserCollectionService) + */ +export const USER_COLL_NOT_FOUND = 'user_coll/not_found' as const; + +/** + * UserCollection is already a root collection + * (UserCollectionService) + */ +export const USER_COLL_ALREADY_ROOT = + 'user_coll/target_user_collection_is_already_root_user_collection' as const; + +/** + * Target and Parent user collections are the same + * (UserCollectionService) + */ +export const USER_COLL_DEST_SAME = + 'user_coll/target_and_destination_user_collection_are_same' as const; + +/** + * Target and Parent user collections are not from the same user + * (UserCollectionService) + */ +export const USER_COLL_NOT_SAME_USER = 'user_coll/not_same_user' as const; + +/** + * Target and Parent user collections are not from the same type + * (UserCollectionService) + */ +export const USER_COLL_NOT_SAME_TYPE = 'user_coll/type_mismatch' as const; + +/** + * Cannot make a parent user collection a child of itself + * (UserCollectionService) + */ +export const USER_COLL_IS_PARENT_COLL = + 'user_coll/user_collection_is_parent_coll' as const; + +/** + * User Collection Creation Failed + * (UserCollectionService) + */ +export const USER_COLLECTION_CREATION_FAILED = + 'user_collection/creation_failed' as const; + +/** + * User Collection Re-Ordering Failed + * (UserCollectionService) + */ +export const USER_COLL_REORDERING_FAILED = + 'user_coll/reordering_failed' as const; + +/** + * The Collection and Next User Collection are the same + * (UserCollectionService) + */ +export const USER_COLL_SAME_NEXT_COLL = + 'user_coll/user_collection_and_next_user_collection_are_same' as const; + +/** + * The User Collection data is not valid + * (UserCollectionService) + */ +export const USER_COLL_DATA_INVALID = + 'user_coll/user_coll_data_invalid' as const; + +/** + * The User Collection does not belong to the logged-in user + * (UserCollectionService) + */ +export const USER_NOT_OWNER = 'user_coll/user_not_owner' as const; + +/** + * The JSON used is not valid + * (UserCollectionService) + */ +export const USER_COLL_INVALID_JSON = 'user_coll/invalid_json'; + +/* + * MAILER_SMTP_URL environment variable is not defined + * (MailerModule) + */ +export const MAILER_SMTP_URL_UNDEFINED = 'mailer/smtp_url_undefined' as const; + +/** + * MAILER_ADDRESS_FROM environment variable is not defined + * (MailerModule) + */ +export const MAILER_FROM_ADDRESS_UNDEFINED = + 'mailer/from_address_undefined' as const; + +/** + * MAILER_SMTP_USER environment variable is not defined + * (MailerModule) + */ +export const MAILER_SMTP_USER_UNDEFINED = 'mailer/smtp_user_undefined' as const; + +/** + * MAILER_SMTP_PASSWORD environment variable is not defined + * (MailerModule) + */ +export const MAILER_SMTP_PASSWORD_UNDEFINED = + 'mailer/smtp_password_undefined' as const; + +/** + * SharedRequest invalid request JSON format + * (ShortcodeService) + */ +export const SHORTCODE_INVALID_REQUEST_JSON = + 'shortcode/request_invalid_format' as const; + +/** + * SharedRequest invalid properties JSON format + * (ShortcodeService) + */ +export const SHORTCODE_INVALID_PROPERTIES_JSON = + 'shortcode/properties_invalid_format' as const; + +/** + * SharedRequest invalid properties not found + * (ShortcodeService) + */ +export const SHORTCODE_PROPERTIES_NOT_FOUND = + 'shortcode/properties_not_found' as const; + +/** + * Infra Config not found + * (InfraConfigService) + */ +export const INFRA_CONFIG_NOT_FOUND = 'infra_config/not_found' as const; + +/** + * Infra Config update failed + * (InfraConfigService) + */ +export const INFRA_CONFIG_UPDATE_FAILED = 'infra_config/update_failed' as const; + +/** + * Infra Config not listed for onModuleInit creation + * (InfraConfigService) + */ +export const INFRA_CONFIG_NOT_LISTED = + 'infra_config/properly_not_listed' as const; + +/** + * Infra Config reset failed + * (InfraConfigService) + */ +export const INFRA_CONFIG_RESET_FAILED = 'infra_config/reset_failed' as const; + +/** + * Infra Config invalid input for Config variable + * (InfraConfigService) + */ +export const INFRA_CONFIG_INVALID_INPUT = 'infra_config/invalid_input' as const; + +/** + * Infra Config service (auth provider/mailer/audit logs) not configured + * (InfraConfigService) + */ +export const INFRA_CONFIG_SERVICE_NOT_CONFIGURED = + 'infra_config/service_not_configured' as const; + +/** + * Infra Config update/fetch operation not allowed + * (InfraConfigService) + */ +export const INFRA_CONFIG_OPERATION_NOT_ALLOWED = + 'infra_config/operation_not_allowed'; + +/** + * Error message for when the onboarding status fetch fails + * (InfraConfigService) + */ +export const INFRA_CONFIG_FETCH_FAILED = 'infra_config/fetch_failed' as const; + +/** + * Onboarding has already been completed and cannot be re-run + * (OnboardingController) + */ +export const ONBOARDING_CANNOT_BE_RERUN = 'onboarding/cannot_be_rerun' as const; + +/** + * Error message for when the database table does not exist + * (InfraConfigService) + */ +export const DATABASE_TABLE_NOT_EXIST = + 'Database migration not found. Please check the documentation for assistance: https://docs.hoppscotch.io/documentation/self-host/community-edition/install-and-build#running-migrations'; + +/** + * PostHog client is not initialized + * (InfraConfigService) + */ +export const POSTHOG_CLIENT_NOT_INITIALIZED = 'posthog/client_not_initialized'; + +/** + * Inputs supplied are invalid + */ +export const INVALID_PARAMS = 'invalid_parameters' as const; + +/** + * The provided label for the access-token is short (less than 3 characters) + * (AccessTokenService) + */ +export const ACCESS_TOKEN_LABEL_SHORT = 'access_token/label_too_short'; + +/** + * The provided expiryInDays value is not valid + * (AccessTokenService) + */ +export const ACCESS_TOKEN_EXPIRY_INVALID = 'access_token/expiry_days_invalid'; + +/** + * The provided PAT ID is invalid + * (AccessTokenService) + */ +export const ACCESS_TOKEN_NOT_FOUND = 'access_token/access_token_not_found'; + +/** + * AccessTokens is expired + * (AccessTokenService) + */ +export const ACCESS_TOKEN_EXPIRED = 'TOKEN_EXPIRED'; + +/** + * AccessTokens is invalid + * (AccessTokenService) + */ +export const ACCESS_TOKEN_INVALID = 'TOKEN_INVALID'; + +/** + * AccessTokens is invalid + * (AccessTokenService) + */ +export const ACCESS_TOKENS_INVALID_DATA_ID = 'INVALID_ID'; + +/** + * The provided label for the infra-token is short (less than 3 characters) + * (InfraTokenService) + */ +export const INFRA_TOKEN_LABEL_SHORT = 'infra_token/label_too_short'; + +/** + * The provided expiryInDays value is not valid + * (InfraTokenService) + */ +export const INFRA_TOKEN_EXPIRY_INVALID = 'infra_token/expiry_days_invalid'; + +/** + * The provided Infra Token ID is invalid + * (InfraTokenService) + */ +export const INFRA_TOKEN_NOT_FOUND = 'infra_token/infra_token_not_found'; + +/** + * Authorization missing in header (Check 'Authorization' Header) + * (InfraTokenGuard) + */ +export const INFRA_TOKEN_HEADER_MISSING = + 'infra_token/authorization_token_missing'; + +/** + * Infra Token is invalid + * (InfraTokenGuard) + */ +export const INFRA_TOKEN_INVALID_TOKEN = 'infra_token/invalid_token'; + +/** + * Infra Token is expired + * (InfraTokenGuard) + */ +export const INFRA_TOKEN_EXPIRED = 'infra_token/expired'; + +/** + * Token creator not found + * (InfraTokenService) + */ +export const INFRA_TOKEN_CREATOR_NOT_FOUND = 'infra_token/creator_not_found'; + +/** + * Mock server not found + * (MockServerService) + */ +export const MOCK_SERVER_NOT_FOUND = 'mock_server/not_found'; + +/** + * Mock server invalid collection + * (MockServerService) + */ +export const MOCK_SERVER_INVALID_COLLECTION = 'mock_server/invalid_collection'; + +/** + * Mock server collection creation failed + * (MockServerService) + */ +export const MOCK_SERVER_COLLECTION_CREATION_FAILED = + 'mock_server/collection_creation_failed'; + +/** + * Mock server already exists for this collection + * (MockServerService) + */ +export const MOCK_SERVER_ALREADY_EXISTS = 'mock_server/already_exists'; + +/** + * Mock server creation failed + * (MockServerService) + */ +export const MOCK_SERVER_CREATION_FAILED = 'mock_server/creation_failed'; + +/** + * Mock server update failed + * (MockServerService) + */ +export const MOCK_SERVER_UPDATE_FAILED = 'mock_server/update_failed'; + +/** + * Mock server deletion failed + * (MockServerService) + */ +export const MOCK_SERVER_DELETION_FAILED = 'mock_server/deletion_failed'; + +/** + * Mock server log not found + * (MockServerService) + */ +export const MOCK_SERVER_LOG_NOT_FOUND = 'mock_server/log_not_found'; + +/** + * Mock server log deletion failed + * (MockServerService) + */ +export const MOCK_SERVER_LOG_DELETION_FAILED = + 'mock_server/log_deletion_failed'; + +/** + * Published Docs invalid collection + * (PublishedDocsService) + */ +export const PUBLISHED_DOCS_INVALID_COLLECTION = + 'published_docs/invalid_collection'; + +/** + * Published Docs creation failed + * (PublishedDocsService) + */ +export const PUBLISHED_DOCS_CREATION_FAILED = 'published_docs/creation_failed'; + +/** + * Published Docs update failed + * (PublishedDocsService) + */ +export const PUBLISHED_DOCS_UPDATE_FAILED = 'published_docs/update_failed'; + +/** + * Published Docs deletion failed + * (PublishedDocsService) + */ +export const PUBLISHED_DOCS_DELETION_FAILED = 'published_docs/deletion_failed'; + +/** + * Published Docs invalid environment + * (PublishedDocsService) + */ +export const PUBLISHED_DOCS_FORBIDDEN_ENVIRONMENT_ACCESS = + 'published_docs/forbidden_environment_access'; + +/** + * Published Docs not found + * (PublishedDocsService) + */ +export const PUBLISHED_DOCS_NOT_FOUND = 'published_docs/not_found'; diff --git a/packages/hoppscotch-backend/src/gql-schema.ts b/packages/hoppscotch-backend/src/gql-schema.ts new file mode 100644 index 0000000..1e278e3 --- /dev/null +++ b/packages/hoppscotch-backend/src/gql-schema.ts @@ -0,0 +1,122 @@ +import { NestFactory } from '@nestjs/core'; +import { + GraphQLSchemaBuilderModule, + GraphQLSchemaFactory, +} from '@nestjs/graphql'; +import { printSchema } from 'graphql/utilities'; +import * as path from 'path'; +import * as fs from 'fs'; +import { ShortcodeResolver } from './shortcode/shortcode.resolver'; +import { TeamCollectionResolver } from './team-collection/team-collection.resolver'; +import { TeamEnvironmentsResolver } from './team-environments/team-environments.resolver'; +import { TeamInvitationResolver } from './team-invitation/team-invitation.resolver'; +import { TeamRequestResolver } from './team-request/team-request.resolver'; +import { TeamMemberResolver } from './team/team-member.resolver'; +import { TeamResolver } from './team/team.resolver'; +import { UserCollectionResolver } from './user-collection/user-collection.resolver'; +import { UserEnvironmentsResolver } from './user-environment/user-environments.resolver'; +import { UserHistoryResolver } from './user-history/user-history.resolver'; +import { UserRequestResolver } from './user-request/resolvers/user-request.resolver'; +import { UserSettingsResolver } from './user-settings/user-settings.resolver'; +import { UserResolver } from './user/user.resolver'; +import { Logger } from '@nestjs/common'; +import { AdminResolver } from './admin/admin.resolver'; +import { TeamEnvsTeamResolver } from './team-environments/team.resolver'; +import { TeamTeamInviteExtResolver } from './team-invitation/team-teaminvite-ext.resolver'; +import { UserRequestUserCollectionResolver } from './user-request/resolvers/user-collection.resolver'; +import { UserEnvsUserResolver } from './user-environment/user.resolver'; +import { UserHistoryUserResolver } from './user-history/user.resolver'; +import { UserSettingsUserResolver } from './user-settings/user.resolver'; +import { InfraResolver } from './admin/infra.resolver'; +import { InfraConfigResolver } from './infra-config/infra-config.resolver'; +import { InfraTokenResolver } from './infra-token/infra-token.resolver'; +import { SortTeamCollectionResolver } from './orchestration/sort/sort-team-collection.resolver'; +import { SortUserCollectionResolver } from './orchestration/sort/sort-user-collection.resolver'; +import { MockServerResolver } from './mock-server/mock-server.resolver'; +import { PublishedDocsResolver } from './published-docs/published-docs.resolver'; + +/** + * All the resolvers present in the application. + * + * NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate + */ +const RESOLVERS = [ + InfraResolver, + AdminResolver, + ShortcodeResolver, + TeamResolver, + TeamMemberResolver, + TeamCollectionResolver, + TeamTeamInviteExtResolver, + TeamEnvironmentsResolver, + TeamEnvsTeamResolver, + TeamInvitationResolver, + TeamRequestResolver, + UserResolver, + UserEnvironmentsResolver, + UserEnvsUserResolver, + UserHistoryUserResolver, + UserHistoryResolver, + UserCollectionResolver, + UserRequestResolver, + UserRequestUserCollectionResolver, + UserSettingsResolver, + UserSettingsUserResolver, + InfraConfigResolver, + InfraTokenResolver, + SortUserCollectionResolver, + SortTeamCollectionResolver, + MockServerResolver, + PublishedDocsResolver, +]; + +/** + * All the custom scalars present in the application. + * + * NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate + */ +const SCALARS = []; + +/** + * Generates the GraphQL Schema SDL definition and writes it into the location + * specified by the `GQL_SCHEMA_EMIT_LOCATION` environment variable. + */ +export async function emitGQLSchemaFile() { + const logger = new Logger('emitGQLSchemaFile'); + + try { + const destination = path.resolve( + __dirname, + process.env.GQL_SCHEMA_EMIT_LOCATION ?? '../gen/schema.gql', + ); + + logger.log(`GQL_SCHEMA_EMIT_LOCATION: ${destination}`); + + const app = await NestFactory.create(GraphQLSchemaBuilderModule); + await app.init(); + + const gqlSchemaFactory = app.get(GraphQLSchemaFactory); + + logger.log( + `Generating Schema against ${RESOLVERS.length} resolvers and ${SCALARS.length} custom scalars`, + ); + + const schema = await gqlSchemaFactory.create(RESOLVERS, SCALARS, { + numberScalarMode: 'integer', + }); + + const schemaString = printSchema(schema); + + logger.log(`Writing schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`); + + // Generating folders if required to emit to the given output + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync(destination, schemaString); + + logger.log(`Wrote schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`); + } catch (e) { + logger.error( + `Failed writing schema to GQL_SCHEMA_EMIT_LOCATION. Reason: ${e}`, + ); + } +} diff --git a/packages/hoppscotch-backend/src/guards/gql-auth.guard.ts b/packages/hoppscotch-backend/src/guards/gql-auth.guard.ts new file mode 100644 index 0000000..6d99529 --- /dev/null +++ b/packages/hoppscotch-backend/src/guards/gql-auth.guard.ts @@ -0,0 +1,12 @@ +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class GqlAuthGuard extends AuthGuard('jwt') { + getRequest(context: ExecutionContext) { + const ctx = GqlExecutionContext.create(context); + const { req, headers } = ctx.getContext(); + return headers ? headers : req; + } +} diff --git a/packages/hoppscotch-backend/src/guards/gql-throttler.guard.ts b/packages/hoppscotch-backend/src/guards/gql-throttler.guard.ts new file mode 100644 index 0000000..b51db4c --- /dev/null +++ b/packages/hoppscotch-backend/src/guards/gql-throttler.guard.ts @@ -0,0 +1,15 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { ThrottlerGuard } from '@nestjs/throttler'; + +@Injectable() +export class GqlThrottlerGuard extends ThrottlerGuard { + getRequestResponse(context: ExecutionContext) { + const gqlCtx = GqlExecutionContext.create(context); + const ctx = gqlCtx.getContext(); + return { req: ctx.req, res: ctx.res }; + } + protected async getTracker(req: Record): Promise { + return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs + } +} diff --git a/packages/hoppscotch-backend/src/guards/infra-token.guard.ts b/packages/hoppscotch-backend/src/guards/infra-token.guard.ts new file mode 100644 index 0000000..f2e7c5c --- /dev/null +++ b/packages/hoppscotch-backend/src/guards/infra-token.guard.ts @@ -0,0 +1,46 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { + INFRA_TOKEN_EXPIRED, + INFRA_TOKEN_HEADER_MISSING, + INFRA_TOKEN_INVALID_TOKEN, +} from 'src/errors'; + +@Injectable() +export class InfraTokenGuard implements CanActivate { + constructor(private readonly prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authorization = request.headers['authorization']; + + if (!authorization) + throw new UnauthorizedException(INFRA_TOKEN_HEADER_MISSING); + + if (!authorization.startsWith('Bearer ')) + throw new UnauthorizedException(INFRA_TOKEN_INVALID_TOKEN); + + const token = authorization.split(' ')[1]; + + if (!token) throw new UnauthorizedException(INFRA_TOKEN_INVALID_TOKEN); + + const infraToken = await this.prisma.infraToken.findUnique({ + where: { token }, + }); + + if (infraToken === null) + throw new UnauthorizedException(INFRA_TOKEN_INVALID_TOKEN); + + // Check if token has expired (if expiresOn is set) + if (infraToken.expiresOn && new Date() > infraToken.expiresOn) { + throw new UnauthorizedException(INFRA_TOKEN_EXPIRED); + } + + return true; + } +} diff --git a/packages/hoppscotch-backend/src/guards/rest-pat-auth.guard.ts b/packages/hoppscotch-backend/src/guards/rest-pat-auth.guard.ts new file mode 100644 index 0000000..199943d --- /dev/null +++ b/packages/hoppscotch-backend/src/guards/rest-pat-auth.guard.ts @@ -0,0 +1,53 @@ +import { + BadRequestException, + CanActivate, + ExecutionContext, + Injectable, +} from '@nestjs/common'; +import { Request } from 'express'; +import { AccessTokenService } from 'src/access-token/access-token.service'; +import * as E from 'fp-ts/Either'; +import { ACCESS_TOKEN_EXPIRED, ACCESS_TOKEN_INVALID } from 'src/errors'; +import { createCLIErrorResponse } from 'src/access-token/helper'; +@Injectable() +export class PATAuthGuard implements CanActivate { + constructor(private accessTokenService: AccessTokenService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + if (!token) { + throw new BadRequestException( + createCLIErrorResponse(ACCESS_TOKEN_INVALID), + ); + } + + const userAccessToken = await this.accessTokenService.getUserPAT(token); + if (E.isLeft(userAccessToken)) + throw new BadRequestException( + createCLIErrorResponse(ACCESS_TOKEN_INVALID), + ); + + request.user = userAccessToken.right.user; + const accessToken = userAccessToken.right; + + // If token has no expiration, it's valid + if (accessToken.expiresOn === null) { + return true; + } + + // Check if token has expired + if (new Date() > accessToken.expiresOn) { + throw new BadRequestException( + createCLIErrorResponse(ACCESS_TOKEN_EXPIRED), + ); + } + + return true; + } + + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/packages/hoppscotch-backend/src/guards/throttler-behind-proxy.guard.ts b/packages/hoppscotch-backend/src/guards/throttler-behind-proxy.guard.ts new file mode 100644 index 0000000..7ff2e44 --- /dev/null +++ b/packages/hoppscotch-backend/src/guards/throttler-behind-proxy.guard.ts @@ -0,0 +1,9 @@ +import { ThrottlerGuard } from '@nestjs/throttler'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ThrottlerBehindProxyGuard extends ThrottlerGuard { + protected async getTracker(req: Record): Promise { + return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs + } +} diff --git a/packages/hoppscotch-backend/src/health/health.controller.ts b/packages/hoppscotch-backend/src/health/health.controller.ts new file mode 100644 index 0000000..3c704f8 --- /dev/null +++ b/packages/hoppscotch-backend/src/health/health.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { + HealthCheck, + HealthCheckService, + PrismaHealthIndicator, +} from '@nestjs/terminus'; +import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard'; +import { PrismaService } from 'src/prisma/prisma.service'; + +@Controller('health') +@UseGuards(ThrottlerBehindProxyGuard) +export class HealthController { + constructor( + private health: HealthCheckService, + private prismaHealth: PrismaHealthIndicator, + private prisma: PrismaService, + ) {} + + @Get() + @HealthCheck() + check() { + return this.health.check([ + async () => this.prismaHealth.pingCheck('database', this.prisma), + ]); + } +} diff --git a/packages/hoppscotch-backend/src/health/health.module.ts b/packages/hoppscotch-backend/src/health/health.module.ts new file mode 100644 index 0000000..deedde5 --- /dev/null +++ b/packages/hoppscotch-backend/src/health/health.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; +import { TerminusModule } from '@nestjs/terminus'; + +@Module({ + imports: [TerminusModule], + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/packages/hoppscotch-backend/src/infra-config/dto/onboarding.dto.ts b/packages/hoppscotch-backend/src/infra-config/dto/onboarding.dto.ts new file mode 100644 index 0000000..9397e38 --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-config/dto/onboarding.dto.ts @@ -0,0 +1,252 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { InfraConfigEnum } from 'src/types/InfraConfig'; + +export class GetOnboardingStatusResponse { + @ApiProperty() + @Expose() + onboardingCompleted: boolean; + @ApiProperty() + @Expose() + canReRunOnboarding: boolean; +} + +export class SaveOnboardingConfigRequest { + @ApiProperty() + @IsString() + @IsNotEmpty() + [InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS]: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.GOOGLE_CLIENT_ID]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.GOOGLE_CLIENT_SECRET]: string; + @ApiPropertyOptional() + @IsOptional() + [InfraConfigEnum.GOOGLE_CALLBACK_URL]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.GOOGLE_SCOPE]: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.GITHUB_CLIENT_ID]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.GITHUB_CLIENT_SECRET]: string; + @ApiPropertyOptional() + @IsOptional() + [InfraConfigEnum.GITHUB_CALLBACK_URL]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.GITHUB_SCOPE]: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MICROSOFT_CLIENT_ID]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MICROSOFT_CLIENT_SECRET]: string; + @ApiPropertyOptional() + @IsOptional() + [InfraConfigEnum.MICROSOFT_CALLBACK_URL]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MICROSOFT_SCOPE]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MICROSOFT_TENANT]: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_ENABLE]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_ADDRESS_FROM]: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_URL]: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_HOST]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_PORT]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_SECURE]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_USER]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_PASSWORD]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_IGNORE_TLS]: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_AUTH_TYPE]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_USER]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_ID]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_SECRET]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_REFRESH_TOKEN]: string; + @ApiPropertyOptional() + @IsOptional() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL]: string; +} + +export class SaveOnboardingConfigResponse { + @ApiProperty() + @Expose() + token: string; +} + +export class GetOnboardingConfigResponse { + @ApiProperty() + @Expose() + [InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS]: string; + + @ApiProperty({ default: null }) + @Expose() + [InfraConfigEnum.GOOGLE_CLIENT_ID]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.GOOGLE_CLIENT_SECRET]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.GOOGLE_CALLBACK_URL]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.GOOGLE_SCOPE]: string; + + @ApiProperty() + @Expose() + [InfraConfigEnum.GITHUB_CLIENT_ID]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.GITHUB_CLIENT_SECRET]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.GITHUB_CALLBACK_URL]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.GITHUB_SCOPE]: string; + + @ApiProperty() + @Expose() + [InfraConfigEnum.MICROSOFT_CLIENT_ID]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MICROSOFT_CLIENT_SECRET]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MICROSOFT_CALLBACK_URL]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MICROSOFT_SCOPE]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MICROSOFT_TENANT]: string; + + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_ENABLE]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_ADDRESS_FROM]: string; + + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_URL]: string; + + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_HOST]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_PORT]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_SECURE]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_USER]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_PASSWORD]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_IGNORE_TLS]: string; + + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_AUTH_TYPE]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_USER]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_ID]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_SECRET]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_REFRESH_TOKEN]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL]: string; +} diff --git a/packages/hoppscotch-backend/src/infra-config/helper.ts b/packages/hoppscotch-backend/src/infra-config/helper.ts new file mode 100644 index 0000000..d005373 --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-config/helper.ts @@ -0,0 +1,628 @@ +import { AuthProvider } from 'src/auth/helper'; +import { ENV_INVALID_DATA_ENCRYPTION_KEY } from 'src/errors'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { InfraConfigEnum } from 'src/types/InfraConfig'; +import { SMTPAuthType } from 'src/mailer/helper'; +import { decrypt, encrypt } from 'src/utils'; +import { randomBytes } from 'crypto'; +import { InfraConfig } from 'src/generated/prisma/client'; + +export enum ServiceStatus { + ENABLE = 'ENABLE', + DISABLE = 'DISABLE', +} + +const SYNC_ONLY_VARIABLES = [InfraConfigEnum.PROXY_APP_URL]; + +type DefaultInfraConfig = { + name: InfraConfigEnum; + value: string; + isEncrypted: boolean; +}; + +// Singleton PrismaService instance for infra config operations +let sharedPrismaInstance: PrismaService | null = null; + +/** + * Get or create a shared PrismaService instance for infra config operations + */ +function getSharedPrismaInstance(): PrismaService { + if (!sharedPrismaInstance) { + sharedPrismaInstance = new PrismaService(); + } + return sharedPrismaInstance; +} + +/** + * Disconnect the shared Prisma instance during application shutdown + */ +export async function disconnectSharedPrismaInstance(): Promise { + if (sharedPrismaInstance) { + await sharedPrismaInstance.onModuleDestroy(); + sharedPrismaInstance = null; + } +} + +/** + * Returns a mapping of authentication providers to their required configuration keys based on the current environment configuration. + */ +export function getAuthProviderRequiredKeys( + env: Record, +): Record { + return { + [AuthProvider.GOOGLE]: [ + InfraConfigEnum.GOOGLE_CLIENT_ID, + InfraConfigEnum.GOOGLE_CLIENT_SECRET, + InfraConfigEnum.GOOGLE_CALLBACK_URL, + InfraConfigEnum.GOOGLE_SCOPE, + ], + [AuthProvider.GITHUB]: [ + InfraConfigEnum.GITHUB_CLIENT_ID, + InfraConfigEnum.GITHUB_CLIENT_SECRET, + InfraConfigEnum.GITHUB_CALLBACK_URL, + InfraConfigEnum.GITHUB_SCOPE, + ], + [AuthProvider.MICROSOFT]: [ + InfraConfigEnum.MICROSOFT_CLIENT_ID, + InfraConfigEnum.MICROSOFT_CLIENT_SECRET, + InfraConfigEnum.MICROSOFT_CALLBACK_URL, + InfraConfigEnum.MICROSOFT_SCOPE, + InfraConfigEnum.MICROSOFT_TENANT, + ], + [AuthProvider.EMAIL]: + env['INFRA'].MAILER_USE_CUSTOM_CONFIGS === 'true' + ? [ + InfraConfigEnum.MAILER_SMTP_HOST, + InfraConfigEnum.MAILER_SMTP_PORT, + InfraConfigEnum.MAILER_SMTP_SECURE, + InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED, + InfraConfigEnum.MAILER_ADDRESS_FROM, + ] + : [ + InfraConfigEnum.MAILER_SMTP_URL, + InfraConfigEnum.MAILER_ADDRESS_FROM, + ], + }; +} + +/** + * Load environment variables from the database and set them in the process + * + * @Description Fetch the 'infra_config' table from the database and return it as an object + * (ConfigModule will set the environment variables in the process) + */ +export async function loadInfraConfiguration() { + const prisma = getSharedPrismaInstance(); + try { + const infraConfigs = await prisma.infraConfig.findMany(); + + const environmentObject: Record = {}; + infraConfigs.forEach((infraConfig) => { + if (infraConfig.isEncrypted) { + environmentObject[infraConfig.name] = decrypt(infraConfig.value); + } else { + environmentObject[infraConfig.name] = infraConfig.value; + } + }); + + return { INFRA: environmentObject }; + } catch (error) { + if (error.code === 'ERR_OSSL_BAD_DECRYPT') + throw new Error(ENV_INVALID_DATA_ENCRYPTION_KEY); + + // Prisma throw error if 'Can't reach at database server' OR 'Table does not exist' + // Reason for not throwing error is, we want successful build during 'postinstall' and generate dist files + console.error('Error from loadInfraConfiguration', error); + return { INFRA: {} }; + } +} + +/** + * Read the default values from .env file and return them as an array + * @returns Array of default infra configs + */ +export async function getDefaultInfraConfigs(): Promise { + const prisma = getSharedPrismaInstance(); + + // Prepare rows for 'infra_config' table with default values (from .env) for each 'name' + const onboardingCompleteStatus = await isOnboardingCompleted(); + const generatedAnalyticsUserId = generateAnalyticsUserId(); + const isSecureCookies = determineAllowSecureCookies( + process.env.VITE_BASE_URL, + ); + + const infraConfigDefaultObjs: DefaultInfraConfig[] = [ + { + name: InfraConfigEnum.ONBOARDING_COMPLETED, + value: onboardingCompleteStatus.toString(), + isEncrypted: false, + }, + { + name: InfraConfigEnum.ONBOARDING_RECOVERY_TOKEN, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.JWT_SECRET, + value: encrypt(randomBytes(32).toString('hex')), + isEncrypted: true, + }, + { + name: InfraConfigEnum.SESSION_SECRET, + value: encrypt(randomBytes(32).toString('hex')), + isEncrypted: true, + }, + { + name: InfraConfigEnum.SESSION_COOKIE_NAME, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.TOKEN_SALT_COMPLEXITY, + value: '10', + isEncrypted: false, + }, + { + name: InfraConfigEnum.MAGIC_LINK_TOKEN_VALIDITY, + value: '24', // 24 hours + isEncrypted: false, + }, + { + name: InfraConfigEnum.REFRESH_TOKEN_VALIDITY, + value: '604800000', // 7 days in milliseconds + isEncrypted: false, + }, + { + name: InfraConfigEnum.ACCESS_TOKEN_VALIDITY, + value: '86400000', // 1 day in milliseconds + isEncrypted: false, + }, + { + name: InfraConfigEnum.ALLOW_SECURE_COOKIES, + value: isSecureCookies.toString(), + isEncrypted: false, + }, + { + name: InfraConfigEnum.RATE_LIMIT_TTL, + value: '10000', // in milliseconds (10 seconds) + isEncrypted: false, + }, + { + name: InfraConfigEnum.RATE_LIMIT_MAX, + value: '100', // 100 requests per IP per RATE_LIMIT_TTL + isEncrypted: false, + }, + { + name: InfraConfigEnum.MAILER_SMTP_ENABLE, + value: 'false', + isEncrypted: false, + }, + { + name: InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.MAILER_SMTP_URL, + value: null, + isEncrypted: true, + }, + { + name: InfraConfigEnum.MAILER_ADDRESS_FROM, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.MAILER_SMTP_HOST, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.MAILER_SMTP_PORT, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.MAILER_SMTP_SECURE, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.MAILER_SMTP_USER, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.MAILER_SMTP_PASSWORD, + value: null, + isEncrypted: true, + }, + { + name: InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.MAILER_SMTP_IGNORE_TLS, + value: 'false', + isEncrypted: false, + }, + { + name: InfraConfigEnum.MAILER_SMTP_AUTH_TYPE, + value: SMTPAuthType.LOGIN, + isEncrypted: false, + }, + { + name: InfraConfigEnum.MAILER_SMTP_OAUTH2_USER, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_ID, + value: null, + isEncrypted: true, + }, + { + name: InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_SECRET, + value: null, + isEncrypted: true, + }, + { + name: InfraConfigEnum.MAILER_SMTP_OAUTH2_REFRESH_TOKEN, + value: null, + isEncrypted: true, + }, + { + name: InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.GOOGLE_CLIENT_ID, + value: null, + isEncrypted: true, + }, + { + name: InfraConfigEnum.GOOGLE_CLIENT_SECRET, + value: null, + isEncrypted: true, + }, + { + name: InfraConfigEnum.GOOGLE_CALLBACK_URL, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.GOOGLE_SCOPE, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.GITHUB_CLIENT_ID, + value: null, + isEncrypted: true, + }, + { + name: InfraConfigEnum.GITHUB_CLIENT_SECRET, + value: null, + isEncrypted: true, + }, + { + name: InfraConfigEnum.GITHUB_CALLBACK_URL, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.GITHUB_SCOPE, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.MICROSOFT_CLIENT_ID, + value: null, + isEncrypted: true, + }, + { + name: InfraConfigEnum.MICROSOFT_CLIENT_SECRET, + value: null, + isEncrypted: true, + }, + { + name: InfraConfigEnum.MICROSOFT_CALLBACK_URL, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.MICROSOFT_SCOPE, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.MICROSOFT_TENANT, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.PROXY_APP_URL, + value: process.env.PROXY_APP_URL || 'https://proxy.hoppscotch.io', + isEncrypted: false, + }, + { + name: InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION, + value: false.toString(), + isEncrypted: false, + }, + { + name: InfraConfigEnum.ANALYTICS_USER_ID, + value: generatedAnalyticsUserId, + isEncrypted: false, + }, + { + name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP, + value: (await prisma.infraConfig.count()) === 0 ? 'true' : 'false', + isEncrypted: false, + }, + { + name: InfraConfigEnum.USER_HISTORY_STORE_ENABLED, + value: 'true', + isEncrypted: false, + }, + { + name: InfraConfigEnum.MOCK_SERVER_WILDCARD_DOMAIN, + value: null, + isEncrypted: false, + }, + ]; + + return infraConfigDefaultObjs; +} + +/** + * Get the missing entries in the 'infra_config' table + * @returns Array of InfraConfig + */ +export async function getMissingInfraConfigEntries( + infraConfigDefaultObjs: DefaultInfraConfig[], +) { + const prisma = getSharedPrismaInstance(); + const dbInfraConfigs = await prisma.infraConfig.findMany(); + + const missingEntries = infraConfigDefaultObjs.filter( + (config) => + !dbInfraConfigs.some((dbConfig) => dbConfig.name === config.name), + ); + + return missingEntries; +} + +/** + * Get the encryption required entries in the 'infra_config' table + * @returns Array of InfraConfig + */ +export async function getEncryptionRequiredInfraConfigEntries( + infraConfigDefaultObjs: DefaultInfraConfig[], +) { + const prisma = getSharedPrismaInstance(); + const dbInfraConfigs = await prisma.infraConfig.findMany(); + + const requiredEncryption = dbInfraConfigs.filter((dbConfig) => { + const defaultConfig = infraConfigDefaultObjs.find( + (config) => config.name === dbConfig.name, + ); + if (!defaultConfig) return false; + return defaultConfig.isEncrypted !== dbConfig.isEncrypted; + }); + + return requiredEncryption; +} + +/** + * Sync the 'infra_config' table with .env file + * @returns Array of InfraConfig + */ +export async function syncInfraConfigWithEnvFile() { + const prisma = getSharedPrismaInstance(); + const dbInfraConfigs = await prisma.infraConfig.findMany({ + where: { name: { in: SYNC_ONLY_VARIABLES } }, + }); + + const updateRequiredObjs: (Partial & { id: string })[] = []; + + for (const dbConfig of dbInfraConfigs) { + const envValue = process.env[dbConfig.name]; + + // If the env var is unset, leave the admin-set DB value alone. Otherwise + // an admin's later override would be wiped on every restart. + if (envValue === undefined) continue; + + // lastSyncedEnvFileValue null check for backward compatibility from 2024.10.2 and below + if (!dbConfig.lastSyncedEnvFileValue) { + const configValue = dbConfig.isEncrypted ? encrypt(envValue) : envValue; + updateRequiredObjs.push({ + id: dbConfig.id, + value: dbConfig.value === null ? configValue : undefined, + lastSyncedEnvFileValue: configValue, + }); + continue; + } + + // If the value in the database is different from the value in the .env file, means the value in the .env file has been updated + const rawLastSyncedEnvFileValue = dbConfig.isEncrypted + ? decrypt(dbConfig.lastSyncedEnvFileValue) + : dbConfig.lastSyncedEnvFileValue; + + if (rawLastSyncedEnvFileValue !== envValue) { + const configValue = dbConfig.isEncrypted ? encrypt(envValue) : envValue; + updateRequiredObjs.push({ + id: dbConfig.id, + value: configValue, + lastSyncedEnvFileValue: configValue, + }); + } + } + + return updateRequiredObjs; +} + +/** + * Verify if 'infra_config' table is loaded with all entries + * @returns boolean + */ +export async function isInfraConfigTablePopulated(): Promise { + try { + const defaultInfraConfigs = await getDefaultInfraConfigs(); + const propsRemainingToInsert = + await getMissingInfraConfigEntries(defaultInfraConfigs); + + if (propsRemainingToInsert.length > 0) { + console.log( + 'Infra Config table is not populated with all entries. Populating now...', + ); + return false; + } + + return true; + } catch (error) { + return false; + } +} + +/** + * Stop the app after 5 seconds with graceful shutdown + * (Sends SIGTERM to trigger NestJS graceful shutdown, then Docker container stops) + */ +export function stopApp() { + console.log('Stopping app in 5 seconds...'); + + setTimeout(() => { + console.log('Stopping app now with graceful shutdown...'); + // Send SIGTERM to the current process to trigger graceful shutdown + // This will call app.close() which triggers onModuleDestroy lifecycle hooks + process.kill(process.pid, 'SIGTERM'); + }, 5000); +} + +/** + * Get the configured SSO providers from 'infra_config' table. + * @description Usage every time the app starts by AuthModule to initiate Strategies. + * @returns Array of configured SSO providers + */ +export async function getConfiguredSSOProvidersFromInfraConfig() { + const prisma = getSharedPrismaInstance(); + const env = await loadInfraConfiguration(); + const providerConfigKeys = getAuthProviderRequiredKeys(env); + + const allowedAuthProviders: string[] = + env['INFRA'].VITE_ALLOWED_AUTH_PROVIDERS?.split(',') ?? []; + + const configuredAuthProviders = allowedAuthProviders.filter((provider) => { + const requiredKeys = providerConfigKeys[provider]; + return requiredKeys?.every((key) => env['INFRA'][key]); + }); + + if (configuredAuthProviders.length === 0) { + await prisma.infraConfig.update({ + where: { name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS }, + data: { value: null }, + }); + return ''; + } else if (allowedAuthProviders.length !== configuredAuthProviders.length) { + await prisma.infraConfig.update({ + where: { name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS }, + data: { value: configuredAuthProviders.join(',') }, + }); + stopApp(); + console.log( + `${configuredAuthProviders.join(',')} SSO auth provider(s) are configured properly. To enable other SSO providers, configure them from Admin Dashboard.`, + ); + } + + return configuredAuthProviders.join(','); +} + +/** + * Check if the onboarding is completed by verifying if the allowed auth providers are configured + * @returns boolean + */ +export async function isOnboardingCompleted(): Promise { + const prisma = getSharedPrismaInstance(); + const allowedProviders = await prisma.infraConfig.findUnique({ + where: { name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS }, + select: { value: true }, + }); + + if (!allowedProviders?.value || allowedProviders.value === '') { + return false; + } + + return true; +} + +/** + * Generate a hashed valued for analytics + * @returns Generated hashed value + */ +export function generateAnalyticsUserId() { + const hashedUserID = randomBytes(20).toString('hex'); + return hashedUserID; +} + +/** + * Determine if ALLOW_SECURE_COOKIES should be true or false + * @returns boolean + */ +export function determineAllowSecureCookies(appBaseUrl: string) { + return appBaseUrl.startsWith('https'); +} + +/** + * Builds a map of environment variables that are derived from other configuration values + * @returns Record + */ +export async function buildDerivedEnv() { + const envConfigMap = await loadInfraConfiguration(); + const derivedEnv: Record = {}; + + // Normalize URLs + const baseUrl = process.env.VITE_BASE_URL || ''; + const backendUrl = process.env.VITE_BACKEND_API_URL || ''; + const normalizedBackendUrl = backendUrl?.replace(/\/+$/, ''); // remove trailing slash + + // Set ALLOW_SECURE_COOKIES based on base URL protocol + const expectedSecure = determineAllowSecureCookies(baseUrl).toString(); + const currentSecure = envConfigMap.INFRA.ALLOW_SECURE_COOKIES; + if (currentSecure !== expectedSecure) { + derivedEnv.ALLOW_SECURE_COOKIES = expectedSecure; + } + + // Set GOOGLE_CALLBACK_URL, MICROSOFT_CALLBACK_URL, and GITHUB_CALLBACK_URL based on backend URL (self healing) if user changed the backend URL + // Callback URL definitions + const callbackConfigs = [ + { key: InfraConfigEnum.GOOGLE_CALLBACK_URL, path: '/auth/google/callback' }, + { + key: InfraConfigEnum.MICROSOFT_CALLBACK_URL, + path: '/auth/microsoft/callback', + }, + { key: InfraConfigEnum.GITHUB_CALLBACK_URL, path: '/auth/github/callback' }, + ]; + // Update callback URLs if they don't match the backend + for (const { key, path } of callbackConfigs) { + const currentCallback = envConfigMap.INFRA[key]; + const expectedCallback = `${normalizedBackendUrl}${path}`; + + if ( + backendUrl.length > 0 && + currentCallback && + currentCallback !== expectedCallback + ) { + derivedEnv[key] = expectedCallback; + } + } + + return derivedEnv; +} diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.controller.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.controller.ts new file mode 100644 index 0000000..e0b11d7 --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.controller.ts @@ -0,0 +1,47 @@ +import { Controller, Get, HttpStatus, Put, UseGuards } from '@nestjs/common'; +import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard'; +import { InfraConfigService } from './infra-config.service'; +import * as E from 'fp-ts/Either'; +import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; +import { RESTAdminGuard } from 'src/admin/guards/rest-admin.guard'; +import { RESTError } from 'src/types/RESTError'; +import { InfraConfigEnum } from 'src/types/InfraConfig'; +import { throwHTTPErr } from 'src/utils'; + +@UseGuards(ThrottlerBehindProxyGuard) +@Controller({ path: 'site', version: '1' }) +export class SiteController { + constructor(private infraConfigService: InfraConfigService) {} + + @Get('setup') + @UseGuards(JwtAuthGuard, RESTAdminGuard) + async fetchSetupInfo() { + const status = await this.infraConfigService.get( + InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP, + ); + + if (E.isLeft(status)) + throwHTTPErr({ + message: status.left, + statusCode: HttpStatus.NOT_FOUND, + }); + return status.right; + } + + @Put('setup') + @UseGuards(JwtAuthGuard, RESTAdminGuard) + async setSetupAsComplete() { + const res = await this.infraConfigService.update( + InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP, + false.toString(), + false, + ); + + if (E.isLeft(res)) + throwHTTPErr({ + message: res.left, + statusCode: HttpStatus.FORBIDDEN, + }); + return res.right; + } +} diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.model.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.model.ts new file mode 100644 index 0000000..7962c69 --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.model.ts @@ -0,0 +1,29 @@ +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; +import { AuthProvider } from 'src/auth/helper'; +import { InfraConfigEnum } from 'src/types/InfraConfig'; +import { ServiceStatus } from './helper'; + +@ObjectType() +export class InfraConfig { + @Field({ + description: 'Infra Config Name', + }) + name: InfraConfigEnum; + + @Field({ + description: 'Infra Config Value', + }) + value: string; +} + +registerEnumType(InfraConfigEnum, { + name: 'InfraConfigEnum', +}); + +registerEnumType(AuthProvider, { + name: 'AuthProvider', +}); + +registerEnumType(ServiceStatus, { + name: 'ServiceStatus', +}); diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.module.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.module.ts new file mode 100644 index 0000000..7cae136 --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { InfraConfigService } from './infra-config.service'; +import { SiteController } from './infra-config.controller'; +import { InfraConfigResolver } from './infra-config.resolver'; +import { UserModule } from 'src/user/user.module'; +import { OnboardingController } from './onboarding.controller'; + +@Module({ + imports: [UserModule], + controllers: [SiteController, OnboardingController], + providers: [InfraConfigResolver, InfraConfigService], + exports: [InfraConfigService], +}) +export class InfraConfigModule {} diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.resolver.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.resolver.ts new file mode 100644 index 0000000..1da74f8 --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.resolver.ts @@ -0,0 +1,71 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Query, Resolver, Subscription } from '@nestjs/graphql'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { InfraConfig } from './infra-config.model'; +import { InfraConfigService } from './infra-config.service'; +import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; +import { SkipThrottle } from '@nestjs/throttler'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { InfraConfigEnum } from 'src/types/InfraConfig'; +import * as E from 'fp-ts/Either'; +import { throwErr } from 'src/utils'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => InfraConfig) +export class InfraConfigResolver { + constructor( + private infraConfigService: InfraConfigService, + private pubsub: PubSubService, + ) {} + + /* Query */ + + @Query(() => Boolean, { + description: 'Check if the SMTP is enabled or not', + }) + @UseGuards(GqlAuthGuard) + isSMTPEnabled() { + return this.infraConfigService.isSMTPEnabled(); + } + + @Query(() => InfraConfig, { + description: 'Check if user history is enabled or not', + }) + @UseGuards(GqlAuthGuard) + async isUserHistoryEnabled() { + const isEnabled = await this.infraConfigService.isUserHistoryEnabled(); + if (E.isLeft(isEnabled)) throwErr(isEnabled.left); + return isEnabled.right; + } + + @Query(() => InfraConfig, { + description: 'Get the proxy app URL', + }) + // Public by design: the unauthenticated selfhost client needs this URL before login. + async proxyAppUrl() { + const infraConfig = await this.infraConfigService.get( + InfraConfigEnum.PROXY_APP_URL, + ); + if (E.isLeft(infraConfig)) throwErr(infraConfig.left); + return infraConfig.right; + } + + /* Subscriptions */ + + @Subscription(() => String, { + description: 'Subscription for infra config update', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + infraConfigUpdate( + @Args({ + name: 'configName', + description: 'Infra config key', + type: () => InfraConfigEnum, + }) + configName: string, + ) { + return this.pubsub.asyncIterator(`infra_config/${configName}/updated`); + } +} diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts new file mode 100644 index 0000000..99f782a --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts @@ -0,0 +1,568 @@ +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { InfraConfigService } from './infra-config.service'; +import { InfraConfigEnum } from 'src/types/InfraConfig'; +import { + INFRA_CONFIG_INVALID_INPUT, + INFRA_CONFIG_NOT_FOUND, + INFRA_CONFIG_OPERATION_NOT_ALLOWED, + INFRA_CONFIG_UPDATE_FAILED, +} from 'src/errors'; +import { ConfigService } from '@nestjs/config'; +import * as helper from './helper'; +import { InfraConfig as dbInfraConfig } from 'src/generated/prisma/client'; +import { InfraConfig } from './infra-config.model'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { ServiceStatus } from './helper'; +import * as E from 'fp-ts/Either'; +import { UserService } from 'src/user/user.service'; + +const mockPrisma = mockDeep(); +const mockConfigService = mockDeep(); +const mockPubsub = mockDeep(); +const mockUserService = mockDeep(); + +const infraConfigService = new InfraConfigService( + mockPrisma, + mockConfigService, + mockPubsub, + mockUserService, +); + +const INITIALIZED_DATE_CONST = new Date(); +const dbInfraConfigs: dbInfraConfig[] = [ + { + id: '3', + name: InfraConfigEnum.GOOGLE_CLIENT_ID, + value: 'abcdefghijkl', + lastSyncedEnvFileValue: 'abcdefghijkl', + isEncrypted: false, + createdOn: INITIALIZED_DATE_CONST, + updatedOn: INITIALIZED_DATE_CONST, + }, + { + id: '4', + name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS, + value: 'google', + lastSyncedEnvFileValue: 'google', + isEncrypted: false, + createdOn: INITIALIZED_DATE_CONST, + updatedOn: INITIALIZED_DATE_CONST, + }, +]; +const infraConfigs: InfraConfig[] = [ + { + name: dbInfraConfigs[0].name as InfraConfigEnum, + value: dbInfraConfigs[0].value, + }, + { + name: dbInfraConfigs[1].name as InfraConfigEnum, + value: dbInfraConfigs[1].value, + }, +]; + +beforeEach(() => { + mockReset(mockPrisma); +}); + +describe('InfraConfigService', () => { + describe('update', () => { + it('should update the infra config without backend server restart', async () => { + const name = dbInfraConfigs[0].name; + const value = 'newValue'; + + mockPrisma.infraConfig.findUnique.mockResolvedValueOnce( + dbInfraConfigs[0], + ); + mockPrisma.infraConfig.update.mockResolvedValueOnce({ + ...dbInfraConfigs[0], + name, + value, + }); + + jest.spyOn(helper, 'stopApp').mockReturnValueOnce(); + const result = await infraConfigService.update( + name as InfraConfigEnum, + value, + ); + + expect(helper.stopApp).not.toHaveBeenCalled(); + expect(result).toEqualRight({ name, value }); + }); + + it('should update the infra config with backend server restart', async () => { + const name = dbInfraConfigs[0].name; + const value = 'newValue'; + + mockPrisma.infraConfig.findUnique.mockResolvedValueOnce( + dbInfraConfigs[0], + ); + mockPrisma.infraConfig.update.mockResolvedValueOnce({ + ...dbInfraConfigs[0], + name, + value, + }); + jest.spyOn(helper, 'stopApp').mockReturnValueOnce(); + + const result = await infraConfigService.update( + name as InfraConfigEnum, + value, + true, + ); + + expect(helper.stopApp).toHaveBeenCalledTimes(1); + expect(result).toEqualRight({ name, value }); + }); + + it('should update the infra config', async () => { + const name = dbInfraConfigs[0].name; + const value = 'newValue'; + + mockPrisma.infraConfig.findUnique.mockResolvedValueOnce( + dbInfraConfigs[0], + ); + mockPrisma.infraConfig.update.mockResolvedValueOnce({ + ...dbInfraConfigs[0], + name, + value, + }); + jest.spyOn(helper, 'stopApp').mockReturnValueOnce(); + + const result = await infraConfigService.update( + name as InfraConfigEnum, + value, + ); + expect(result).toEqualRight({ name, value }); + }); + + it('should pass correct params to prisma update', async () => { + const name = dbInfraConfigs[0].name; + const value = 'newValue'; + + mockPrisma.infraConfig.findUnique.mockResolvedValueOnce( + dbInfraConfigs[0], + ); + + jest.spyOn(helper, 'stopApp').mockReturnValueOnce(); + + await infraConfigService.update(name as InfraConfigEnum, value); + + expect(mockPrisma.infraConfig.update).toHaveBeenCalledWith({ + where: { name }, + data: { value }, + }); + expect(mockPrisma.infraConfig.update).toHaveBeenCalledTimes(1); + }); + + it('should throw an error if the infra config update failed', async () => { + const name = InfraConfigEnum.GOOGLE_CLIENT_ID; + const value = 'true'; + + mockPrisma.infraConfig.update.mockRejectedValueOnce('null'); + + const result = await infraConfigService.update(name, value); + expect(result).toEqualLeft(INFRA_CONFIG_UPDATE_FAILED); + }); + }); + + describe('get', () => { + it('should get the infra config', async () => { + const name = dbInfraConfigs[0].name; + const value = dbInfraConfigs[0].value; + + mockPrisma.infraConfig.findUniqueOrThrow.mockResolvedValueOnce( + dbInfraConfigs[0], + ); + const result = await infraConfigService.get(name as InfraConfigEnum); + expect(result).toEqualRight({ name, value }); + }); + + it('should pass correct params to prisma findUnique', async () => { + const name = InfraConfigEnum.GOOGLE_CLIENT_ID; + + await infraConfigService.get(name); + + expect(mockPrisma.infraConfig.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { name }, + }); + expect(mockPrisma.infraConfig.findUniqueOrThrow).toHaveBeenCalledTimes(1); + }); + + it('should throw an error if the infra config does not exist', async () => { + const name = InfraConfigEnum.GOOGLE_CLIENT_ID; + + mockPrisma.infraConfig.findUniqueOrThrow.mockRejectedValueOnce('null'); + + const result = await infraConfigService.get(name); + expect(result).toEqualLeft(INFRA_CONFIG_NOT_FOUND); + }); + }); + + describe('getMany', () => { + it('should throw error if any disallowed names are provided', async () => { + const disallowedNames = [InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS]; + const result = await infraConfigService.getMany(disallowedNames); + + expect(result).toEqualLeft(INFRA_CONFIG_OPERATION_NOT_ALLOWED); + }); + it('should resolve right with disallowed names if `checkDisallowed` parameter passed', async () => { + const disallowedNames = [InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS]; + + const dbInfraConfigResponses = dbInfraConfigs.filter((dbConfig) => + disallowedNames.includes(dbConfig.name as InfraConfigEnum), + ); + mockPrisma.infraConfig.findMany.mockResolvedValueOnce( + dbInfraConfigResponses, + ); + + const result = await infraConfigService.getMany(disallowedNames, false); + + expect(result).toEqualRight( + infraConfigs.filter((i) => disallowedNames.includes(i.name)), + ); + }); + + it('should return right with infraConfigs if Prisma query succeeds', async () => { + const allowedNames = [InfraConfigEnum.GOOGLE_CLIENT_ID]; + + const dbInfraConfigResponses = dbInfraConfigs.filter((dbConfig) => + allowedNames.includes(dbConfig.name as InfraConfigEnum), + ); + mockPrisma.infraConfig.findMany.mockResolvedValueOnce( + dbInfraConfigResponses, + ); + + const result = await infraConfigService.getMany(allowedNames); + expect(result).toEqualRight( + infraConfigs.filter((i) => allowedNames.includes(i.name)), + ); + }); + }); + + describe('toggleServiceStatus', () => { + it('should toggle the service status', async () => { + const configName = infraConfigs[0].name; + const configStatus = ServiceStatus.DISABLE; + + jest + .spyOn(infraConfigService, 'update') + .mockResolvedValueOnce( + E.right({ name: configName, value: configStatus }), + ); + + expect( + await infraConfigService.toggleServiceStatus(configName, configStatus), + ).toEqualRight(true); + }); + it('should publish the updated config value', async () => { + const configName = infraConfigs[0].name; + const configStatus = ServiceStatus.DISABLE; + + jest + .spyOn(infraConfigService, 'update') + .mockResolvedValueOnce( + E.right({ name: configName, value: configStatus }), + ); + + await infraConfigService.toggleServiceStatus(configName, configStatus); + + expect(mockPubsub.publish).toHaveBeenCalledTimes(1); + expect(mockPubsub.publish).toHaveBeenCalledWith( + 'infra_config/GOOGLE_CLIENT_ID/updated', + configStatus, + ); + }); + }); + + describe('updateOnboardingConfig (allowlist filtering)', () => { + it('should drop keys outside ONBOARDING_ALLOWED_KEYS before persisting', async () => { + // Pretend the DTO has extra disallowed keys (mimicking a bypass of the + // ValidationPipe, e.g. an internal caller). The service must still not + // persist keys like JWT_SECRET / SESSION_SECRET / ALLOW_SECURE_COOKIES. + const dto = { + [InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS]: 'GOOGLE', + [InfraConfigEnum.GOOGLE_CLIENT_ID]: 'gid', + [InfraConfigEnum.GOOGLE_CLIENT_SECRET]: 'gsecret', + [InfraConfigEnum.GOOGLE_CALLBACK_URL]: 'https://example.com/cb', + [InfraConfigEnum.GOOGLE_SCOPE]: 'email', + [InfraConfigEnum.JWT_SECRET]: 'ATTACKER', + [InfraConfigEnum.SESSION_SECRET]: 'ATTACKER', + [InfraConfigEnum.ALLOW_SECURE_COOKIES]: 'true', + } as any; + + const updateManySpy = jest + .spyOn(infraConfigService, 'updateMany') + .mockResolvedValueOnce(E.right([] as any)); + + await infraConfigService.updateOnboardingConfig(dto); + + expect(updateManySpy).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS, + value: 'GOOGLE', + }), + expect.objectContaining({ + name: InfraConfigEnum.GOOGLE_CLIENT_ID, + value: 'gid', + }), + expect.objectContaining({ + name: InfraConfigEnum.GOOGLE_CLIENT_SECRET, + value: 'gsecret', + }), + expect.objectContaining({ + name: InfraConfigEnum.GOOGLE_CALLBACK_URL, + value: 'https://example.com/cb', + }), + expect.objectContaining({ + name: InfraConfigEnum.GOOGLE_SCOPE, + value: 'email', + }), + + expect.objectContaining({ + name: InfraConfigEnum.ONBOARDING_COMPLETED, + value: 'true', + }), + expect.objectContaining({ + name: InfraConfigEnum.ONBOARDING_RECOVERY_TOKEN, + value: expect.any(String), + }), + ]), + false, + ); + + updateManySpy.mockRestore(); + }); + }); + + describe('isUserHistoryEnabled', () => { + it('should return true if the user history is enabled', async () => { + const response = { + name: InfraConfigEnum.USER_HISTORY_STORE_ENABLED, + value: ServiceStatus.ENABLE, + }; + + jest.spyOn(infraConfigService, 'get').mockResolvedValueOnce( + E.right({ + name: InfraConfigEnum.USER_HISTORY_STORE_ENABLED, + value: ServiceStatus.ENABLE, + }), + ); + + expect(await infraConfigService.isUserHistoryEnabled()).toEqualRight( + response, + ); + }); + }); + + describe('validateEnvValues', () => { + describe('MAILER_SMTP_AUTH_TYPE', () => { + it('should accept an empty value (defaults to login at runtime)', () => { + const result = infraConfigService.validateEnvValues([ + { name: InfraConfigEnum.MAILER_SMTP_AUTH_TYPE, value: '' }, + ]); + expect(result).toEqualRight(true); + }); + + it('should accept a known auth type', () => { + const result = infraConfigService.validateEnvValues([ + { name: InfraConfigEnum.MAILER_SMTP_AUTH_TYPE, value: 'oauth2' }, + ]); + expect(result).toEqualRight(true); + }); + + it('should reject an unknown auth type', () => { + const result = infraConfigService.validateEnvValues([ + { name: InfraConfigEnum.MAILER_SMTP_AUTH_TYPE, value: 'kerberos' }, + ]); + expect(result).toEqualLeft(INFRA_CONFIG_INVALID_INPUT); + }); + }); + + describe('MAILER_SMTP_OAUTH2_ACCESS_URL', () => { + it('should accept an empty value', () => { + const result = infraConfigService.validateEnvValues([ + { name: InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL, value: '' }, + ]); + expect(result).toEqualRight(true); + }); + + it('should accept a valid HTTPS URL', () => { + const result = infraConfigService.validateEnvValues([ + { + name: InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL, + value: 'https://oauth2.googleapis.com/token', + }, + ]); + expect(result).toEqualRight(true); + }); + + it('should reject a malformed URL', () => { + const result = infraConfigService.validateEnvValues([ + { + name: InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL, + value: 'not-a-url', + }, + ]); + expect(result).toEqualLeft(INFRA_CONFIG_INVALID_INPUT); + }); + }); + }); + + describe('getOnboardingConfig', () => { + const RECOVERY_TOKEN = 'valid-recovery-token-123'; + + const mockConfigs = [ + { + name: InfraConfigEnum.ONBOARDING_RECOVERY_TOKEN, + value: RECOVERY_TOKEN, + }, + { name: InfraConfigEnum.GOOGLE_CLIENT_ID, value: 'google-id' }, + { + name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS, + value: 'google', + }, + ]; + + it('should return config values when token is valid', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(mockConfigs)); + + const result = + await infraConfigService.getOnboardingConfig(RECOVERY_TOKEN); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.ONBOARDING_RECOVERY_TOKEN]).toBe( + RECOVERY_TOKEN, + ); + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBe( + 'google-id', + ); + } + }); + + it('should return null values when token does not match', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(mockConfigs)); + + const result = + await infraConfigService.getOnboardingConfig('wrong-token'); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBeNull(); + } + }); + + it('should return null values when token is empty string', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(mockConfigs)); + + const result = await infraConfigService.getOnboardingConfig(''); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBeNull(); + } + }); + + it('should return null values when token is whitespace only', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(mockConfigs)); + + const result = await infraConfigService.getOnboardingConfig(' '); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBeNull(); + } + }); + + it('should return null values when token is null', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(mockConfigs)); + + const result = await infraConfigService.getOnboardingConfig(null); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBeNull(); + } + }); + + it('should return null values when token is undefined', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(mockConfigs)); + + const result = await infraConfigService.getOnboardingConfig(undefined); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBeNull(); + } + }); + + it('should return null values when token is an array', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(mockConfigs)); + + // @ts-expect-error Testing invalid input + const result = await infraConfigService.getOnboardingConfig([]); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBeNull(); + } + }); + + it('should return null values when token is a number', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(mockConfigs)); + + // @ts-expect-error Testing invalid input + const result = await infraConfigService.getOnboardingConfig(42); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBeNull(); + } + }); + + it('should return null values when ONBOARDING_RECOVERY_TOKEN is absent from configs', async () => { + const configsWithoutToken = mockConfigs.filter( + (c) => c.name !== InfraConfigEnum.ONBOARDING_RECOVERY_TOKEN, + ); + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(configsWithoutToken)); + + const result = + await infraConfigService.getOnboardingConfig(RECOVERY_TOKEN); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBeNull(); + } + }); + + it('should return left with error when getMany fails', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.left(INFRA_CONFIG_NOT_FOUND)); + + const result = + await infraConfigService.getOnboardingConfig(RECOVERY_TOKEN); + + expect(result).toEqualLeft(INFRA_CONFIG_NOT_FOUND); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts new file mode 100644 index 0000000..15b90a4 --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts @@ -0,0 +1,855 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { InfraConfig } from './infra-config.model'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { InfraConfig as DBInfraConfig } from 'src/generated/prisma/client'; +import * as E from 'fp-ts/Either'; +import { InfraConfigEnum } from 'src/types/InfraConfig'; +import { SMTPAuthType } from 'src/mailer/helper'; +import { + AUTH_PROVIDER_NOT_SPECIFIED, + DATABASE_TABLE_NOT_EXIST, + INFRA_CONFIG_FETCH_FAILED, + INFRA_CONFIG_INVALID_INPUT, + INFRA_CONFIG_NOT_FOUND, + INFRA_CONFIG_RESET_FAILED, + INFRA_CONFIG_UPDATE_FAILED, + INFRA_CONFIG_SERVICE_NOT_CONFIGURED, + INFRA_CONFIG_OPERATION_NOT_ALLOWED, +} from 'src/errors'; +import { + decrypt, + encrypt, + throwErr, + validateSMTPEmail, + validateSMTPUrl, + validateUrl, +} from 'src/utils'; +import { ConfigService } from '@nestjs/config'; +import { + ServiceStatus, + buildDerivedEnv, + disconnectSharedPrismaInstance, + getDefaultInfraConfigs, + getEncryptionRequiredInfraConfigEntries, + getMissingInfraConfigEntries, + stopApp, + syncInfraConfigWithEnvFile, +} from './helper'; +import { EnableAndDisableSSOArgs, InfraConfigArgs } from './input-args'; +import { AuthProvider } from 'src/auth/helper'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { UserService } from 'src/user/user.service'; +import { + GetOnboardingConfigResponse, + GetOnboardingStatusResponse, + SaveOnboardingConfigRequest, + SaveOnboardingConfigResponse, +} from './dto/onboarding.dto'; +import * as crypto from 'crypto'; +import { PrismaError } from 'src/prisma/prisma-error-codes'; + +@Injectable() +export class InfraConfigService implements OnModuleInit, OnModuleDestroy { + constructor( + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + private readonly pubsub: PubSubService, + private readonly userService: UserService, + ) {} + + // Following fields are not updatable by `infraConfigs` Mutation. Use dedicated mutations for these fields instead. + EXCLUDE_FROM_UPDATE_CONFIGS = [ + InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS, + InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION, + InfraConfigEnum.ANALYTICS_USER_ID, + InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP, + InfraConfigEnum.MAILER_SMTP_ENABLE, + InfraConfigEnum.USER_HISTORY_STORE_ENABLED, + ]; + // Following fields can not be fetched by `infraConfigs` Query. Use dedicated queries for these fields instead. + EXCLUDE_FROM_FETCH_CONFIGS = [ + InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS, + InfraConfigEnum.ANALYTICS_USER_ID, + InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP, + ]; + + async onModuleInit() { + await this.initializeInfraConfigTable(); + } + async onModuleDestroy() { + await disconnectSharedPrismaInstance(); + } + + /** + * Initialize the 'infra_config' table with values from .env + * @description This function create rows 'infra_config' in very first time (only once) + */ + async initializeInfraConfigTable() { + try { + const defaultInfraConfigs = await getDefaultInfraConfigs(); + + // Adding missing InfraConfigs to the database (with encrypted values) + const propsToInsert = + await getMissingInfraConfigEntries(defaultInfraConfigs); + + if (propsToInsert.length > 0) { + await this.prisma.infraConfig.createMany({ data: propsToInsert }); + } + + // Encrypting previous InfraConfigs that are required to be encrypted + const encryptionRequiredEntries = + await getEncryptionRequiredInfraConfigEntries(defaultInfraConfigs); + + if (encryptionRequiredEntries.length > 0) { + const dbOperations = encryptionRequiredEntries.map((dbConfig) => { + return this.prisma.infraConfig.update({ + where: { name: dbConfig.name }, + data: { value: encrypt(dbConfig.value), isEncrypted: true }, + }); + }); + + await Promise.allSettled(dbOperations); + } + + // Derive env variables programmatically if they don't exist or need to be updated + const derivedEnv = await buildDerivedEnv(); + + if (Object.keys(derivedEnv).length > 0) { + const dbOperations = Object.entries(derivedEnv).map(([name, value]) => { + return this.prisma.infraConfig.update({ + where: { name: name as InfraConfigEnum }, + data: { value }, + }); + }); + await Promise.allSettled(dbOperations); + } + + // Sync the InfraConfigs with the .env file, if .env file updates later on + const envFileChangesRequired = await syncInfraConfigWithEnvFile(); + if (envFileChangesRequired.length > 0) { + const dbOperations = envFileChangesRequired.map((dbConfig) => { + const { id, ...dataObj } = dbConfig; + return this.prisma.infraConfig.update({ + where: { id: dbConfig.id }, + data: dataObj, + }); + }); + await Promise.allSettled(dbOperations); + } + + // Restart the app if needed. Metadata-only sync writes (where `value` + // is undefined because only `lastSyncedEnvFileValue` is being persisted) + // don't change runtime config, so they shouldn't trigger a restart. + const envValueChanged = envFileChangesRequired.some( + (c) => c.value !== undefined, + ); + if ( + propsToInsert.length > 0 || + encryptionRequiredEntries.length > 0 || + Object.keys(derivedEnv).length > 0 || + envValueChanged + ) { + stopApp(); + } + } catch (error) { + if (error.code === PrismaError.DATABASE_UNREACHABLE) { + // Prisma error code for 'Can't reach at database server' + // We're not throwing error here because we want to allow the app to run 'pnpm install' + } else if (error.code === PrismaError.TABLE_DOES_NOT_EXIST) { + // Prisma error code for 'Table does not exist' + throwErr(DATABASE_TABLE_NOT_EXIST); + } else { + console.error(error); + throwErr(error); + } + } + } + + /** + * Typecast a database InfraConfig to a InfraConfig model + * @param dbInfraConfig database InfraConfig + * @returns InfraConfig model + */ + private cast(dbInfraConfig: DBInfraConfig) { + switch (dbInfraConfig.name) { + case InfraConfigEnum.USER_HISTORY_STORE_ENABLED: + dbInfraConfig.value = + dbInfraConfig.value === 'true' + ? ServiceStatus.ENABLE + : ServiceStatus.DISABLE; + break; + default: + break; + } + + const plainValue = dbInfraConfig.isEncrypted + ? decrypt(dbInfraConfig.value) + : dbInfraConfig.value; + + return { + name: dbInfraConfig.name, + value: plainValue ?? '', + }; + } + + /** + * Get all the InfraConfigs as map + * @returns InfraConfig map + */ + async getInfraConfigsMap() { + const infraConfigs = await this.prisma.infraConfig.findMany(); + + const infraConfigMap: Record = {}; + infraConfigs.forEach((config) => { + if (config.isEncrypted) { + infraConfigMap[config.name] = decrypt(config.value); + } else { + infraConfigMap[config.name] = config.value; + } + }); + + return infraConfigMap; + } + + /** + * Update InfraConfig by name + * @param name Name of the InfraConfig + * @param value Value of the InfraConfig + * @param restartEnabled If true, restart the app after updating the InfraConfig + * @returns InfraConfig model + */ + async update(name: InfraConfigEnum, value: string, restartEnabled = false) { + const isValidate = this.validateEnvValues([{ name, value }]); + if (E.isLeft(isValidate)) return E.left(isValidate.left); + + try { + const { isEncrypted } = await this.prisma.infraConfig.findUnique({ + where: { name }, + select: { isEncrypted: true }, + }); + + const infraConfig = await this.prisma.infraConfig.update({ + where: { name }, + data: { value: isEncrypted ? encrypt(value) : value }, + }); + + if (restartEnabled) stopApp(); + + return E.right(this.cast(infraConfig)); + } catch (e) { + return E.left(INFRA_CONFIG_UPDATE_FAILED); + } + } + + /** + * Update InfraConfigs by name + * @param infraConfigs InfraConfigs to update + * @returns InfraConfig model + */ + async updateMany( + infraConfigs: InfraConfigArgs[], + checkDisallowedKeys: boolean = true, + ) { + if (checkDisallowedKeys) { + // Check if the names are allowed to update by client + for (let i = 0; i < infraConfigs.length; i++) { + if (this.EXCLUDE_FROM_UPDATE_CONFIGS.includes(infraConfigs[i].name)) + return E.left(INFRA_CONFIG_OPERATION_NOT_ALLOWED); + } + } + + const isValidate = this.validateEnvValues(infraConfigs); + if (E.isLeft(isValidate)) return E.left(isValidate.left); + + // Validate SMTP credentials pair against effective post-update state + const smtpPairCheck = await this.validateSmtpCredentialPair(infraConfigs); + if (E.isLeft(smtpPairCheck)) return E.left(smtpPairCheck.left); + + try { + const dbInfraConfig = await this.prisma.infraConfig.findMany({ + select: { name: true, isEncrypted: true }, + }); + + await this.prisma.$transaction(async (tx) => { + for (let i = 0; i < infraConfigs.length; i++) { + const isEncrypted = dbInfraConfig.find( + (p) => p.name === infraConfigs[i].name, + )?.isEncrypted; + + await tx.infraConfig.update({ + where: { name: infraConfigs[i].name }, + data: { + value: isEncrypted + ? encrypt(infraConfigs[i].value) + : infraConfigs[i].value, + }, + }); + } + }); + + stopApp(); + + return E.right(infraConfigs); + } catch (e) { + return E.left(INFRA_CONFIG_UPDATE_FAILED); + } + } + + /** + * Check if the service is configured or not + * @param service Service can be Auth Provider, Mailer, Audit Log etc. + * @param configMap Map of all the infra configs + * @returns Either true or false + */ + isServiceConfigured( + service: AuthProvider, + configMap: Record, + ) { + switch (service) { + case AuthProvider.GOOGLE: + return ( + configMap.GOOGLE_CLIENT_ID && + configMap.GOOGLE_CLIENT_SECRET && + configMap.GOOGLE_CALLBACK_URL && + configMap.GOOGLE_SCOPE + ); + case AuthProvider.GITHUB: + return ( + configMap.GITHUB_CLIENT_ID && + configMap.GITHUB_CLIENT_SECRET && + configMap.GITHUB_CALLBACK_URL && + configMap.GITHUB_SCOPE + ); + case AuthProvider.MICROSOFT: + return ( + configMap.MICROSOFT_CLIENT_ID && + configMap.MICROSOFT_CLIENT_SECRET && + configMap.MICROSOFT_CALLBACK_URL && + configMap.MICROSOFT_SCOPE && + configMap.MICROSOFT_TENANT + ); + case AuthProvider.EMAIL: + if (configMap.MAILER_SMTP_ENABLE !== 'true') return false; + if (configMap.MAILER_USE_CUSTOM_CONFIGS === 'true') { + return ( + configMap.MAILER_SMTP_HOST && + configMap.MAILER_SMTP_PORT && + configMap.MAILER_SMTP_SECURE && + configMap.MAILER_TLS_REJECT_UNAUTHORIZED && + configMap.MAILER_ADDRESS_FROM + ); + } else { + return configMap.MAILER_SMTP_URL && configMap.MAILER_ADDRESS_FROM; + } + default: + return false; + } + } + + /** + * Enable or Disable Analytics Collection + * + * @param status Status to enable or disable + * @returns Boolean of status of analytics collection + */ + async toggleAnalyticsCollection(status: ServiceStatus) { + const isUpdated = await this.update( + InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION, + status === ServiceStatus.ENABLE ? 'true' : 'false', + ); + + if (E.isLeft(isUpdated)) return E.left(isUpdated.left); + return E.right(isUpdated.right.value === 'true'); + } + + /** + * Enable or Disable SMTP + * @param status Status to enable or disable + * @returns Either true or an error + */ + async enableAndDisableSMTP(status: ServiceStatus) { + const isUpdated = await this.toggleServiceStatus( + InfraConfigEnum.MAILER_SMTP_ENABLE, + status, + true, + ); + if (E.isLeft(isUpdated)) return E.left(isUpdated.left); + + if (status === ServiceStatus.DISABLE) { + this.enableAndDisableSSO([{ provider: AuthProvider.EMAIL, status }]); + } + return E.right(true); + } + + /** + * Enable or Disable Service (i.e. ALLOW_AUDIT_LOGS, ALLOW_ANALYTICS_COLLECTION, ALLOW_DOMAIN_WHITELISTING, SITE_PROTECTION) + * @param configName Name of the InfraConfigEnum + * @param status Status to enable or disable + * @param restartEnabled If true, restart the app after updating the InfraConfig + * @returns Either true or an error + */ + async toggleServiceStatus( + configName: InfraConfigEnum, + status: ServiceStatus, + restartEnabled = false, + ) { + const isUpdated = await this.update( + configName, + status === ServiceStatus.ENABLE ? 'true' : 'false', + restartEnabled, + ); + if (E.isLeft(isUpdated)) return E.left(isUpdated.left); + + this.pubsub.publish( + `infra_config/${configName}/updated`, + isUpdated.right.value, + ); + + return E.right(true); + } + + /** + * Enable or Disable SSO for login/signup + * @param provider Auth Provider to enable or disable + * @param status Status to enable or disable + * @returns Either true or an error + */ + async enableAndDisableSSO(providerInfo: EnableAndDisableSSOArgs[]) { + const infra = await this.get(InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS); + if (E.isLeft(infra)) return E.left(infra.left); + + const allowedAuthProviders = infra.right.value?.split(',') ?? []; + let updatedAuthProviders = allowedAuthProviders; + + const infraConfigMap = await this.getInfraConfigsMap(); + + providerInfo.forEach(({ provider, status }) => { + if (status === ServiceStatus.ENABLE) { + const isConfigured = this.isServiceConfigured(provider, infraConfigMap); + if (!isConfigured) { + throwErr(INFRA_CONFIG_SERVICE_NOT_CONFIGURED); + } + updatedAuthProviders.push(provider); + } else if (status === ServiceStatus.DISABLE) { + updatedAuthProviders = updatedAuthProviders.filter( + (p) => p !== provider, + ); + } + }); + + updatedAuthProviders = [...new Set(updatedAuthProviders)]; + + if (updatedAuthProviders.length === 0) { + return E.left(AUTH_PROVIDER_NOT_SPECIFIED); + } + + const isUpdated = await this.update( + InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS, + updatedAuthProviders.join(','), + true, + ); + if (E.isLeft(isUpdated)) return E.left(isUpdated.left); + + return E.right(true); + } + + /** + * Get InfraConfig by name + * @param name Name of the InfraConfig + * @returns InfraConfig model + */ + async get(name: InfraConfigEnum) { + try { + const infraConfig = await this.prisma.infraConfig.findUniqueOrThrow({ + where: { name }, + }); + + return E.right(this.cast(infraConfig)); + } catch (e) { + return E.left(INFRA_CONFIG_NOT_FOUND); + } + } + + /** + * Get InfraConfigs by names + * @param names Names of the InfraConfigs + * @param checkDisallowedKeys If true, check if the names are allowed to fetch by client + * @returns InfraConfig model + */ + async getMany(names: InfraConfigEnum[], checkDisallowedKeys: boolean = true) { + if (checkDisallowedKeys) { + // Check if the names are allowed to fetch by client + for (let i = 0; i < names.length; i++) { + if (this.EXCLUDE_FROM_FETCH_CONFIGS.includes(names[i])) + return E.left(INFRA_CONFIG_OPERATION_NOT_ALLOWED); + } + } + + try { + const infraConfigs = await this.prisma.infraConfig.findMany({ + where: { name: { in: names } }, + }); + + return E.right(infraConfigs.map((p) => this.cast(p))); + } catch (e) { + return E.left(INFRA_CONFIG_NOT_FOUND); + } + } + + /** + * Get allowed auth providers for login/signup + * @returns string[] + */ + getAllowedAuthProviders() { + return ( + this.configService + .get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS') + ?.split(',') ?? [] + ); + } + + /** + * Check if SMTP is enabled or not + * @returns boolean + */ + isSMTPEnabled() { + return ( + this.configService.get('INFRA.MAILER_SMTP_ENABLE') === 'true' + ); + } + + /** + * Check if user history is enabled or not + * @returns InfraConfig model + */ + async isUserHistoryEnabled() { + const infraConfig = await this.get( + InfraConfigEnum.USER_HISTORY_STORE_ENABLED, + ); + + if (E.isLeft(infraConfig)) return E.left(infraConfig.left); + return E.right(infraConfig.right); + } + + /** + * Get onboarding status + * @returns GetOnboardingStatusResponse + */ + async getOnboardingStatus() { + try { + const configMap = await this.getInfraConfigsMap(); + const usersCount = await this.userService.getUsersCount(); + + return E.right({ + onboardingCompleted: configMap.ONBOARDING_COMPLETED === 'true', + canReRunOnboarding: usersCount === 0, + } as GetOnboardingStatusResponse); + } catch { + return E.left(INFRA_CONFIG_FETCH_FAILED); + } + } + + /** + * Update the onboarding configuration + * @param dto SaveOnboardingConfigRequest + */ + async updateOnboardingConfig(dto: SaveOnboardingConfigRequest) { + const onboardingRecoveryToken = crypto.randomUUID(); + + const configEntries: InfraConfigArgs[] = [ + ...Object.entries(dto) + .filter( + ([key, value]) => + value !== undefined && + Object.keys(new SaveOnboardingConfigRequest()).includes(key), + ) + .map(([key, value]) => ({ + name: key as InfraConfigEnum, + value, + })), + { + name: InfraConfigEnum.ONBOARDING_COMPLETED, + value: 'true', + }, + { + name: InfraConfigEnum.ONBOARDING_RECOVERY_TOKEN, + value: onboardingRecoveryToken, + }, + ]; + + const isValidated = this.validateEnvValues(configEntries); + if (E.isLeft(isValidated)) return E.left(isValidated.left); + + // Verify MAILER_SMTP_ENABLE + if ( + dto[InfraConfigEnum.MAILER_SMTP_ENABLE] === 'true' && + !this.isServiceConfigured( + AuthProvider.EMAIL, + dto as unknown as Record, + ) + ) { + return E.left(INFRA_CONFIG_SERVICE_NOT_CONFIGURED); + } + + // Verify VITE_ALLOWED_AUTH_PROVIDERS + const allowedAuthProviders = + dto[InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS].split(','); + + if (allowedAuthProviders.length === 0) { + return E.left(AUTH_PROVIDER_NOT_SPECIFIED); + } + for (const provider of allowedAuthProviders) { + if ( + !Object.values(AuthProvider).includes(provider as AuthProvider) || + !this.isServiceConfigured( + provider as AuthProvider, + dto as unknown as Record, + ) + ) { + return E.left(INFRA_CONFIG_SERVICE_NOT_CONFIGURED); + } + } + + // Move forward with updating the InfraConfigs + const isUpdated = await this.updateMany(configEntries, false); + if (E.isLeft(isUpdated)) return E.left(isUpdated.left); + + return E.right({ + token: onboardingRecoveryToken, + } as SaveOnboardingConfigResponse); + } + + /** + * Get onboarding configuration + * @param token Onboarding recovery token + * @returns GetOnboardingConfigResponse + */ + async getOnboardingConfig(token: string) { + const configs = await this.getMany(Object.values(InfraConfigEnum), false); + if (E.isLeft(configs)) return E.left(configs.left); + + // Check if the onboarding recovery token is valid + const recoveryToken = configs.right.find( + (config) => config.name === InfraConfigEnum.ONBOARDING_RECOVERY_TOKEN, + )?.value; + + const tokenIsValid = + typeof token === 'string' && + token.trim().length > 0 && + token === recoveryToken; + + const onboardingConfig = configs.right.reduce((acc, config) => { + acc[config.name] = tokenIsValid ? config.value : null; + return acc; + }, {} as GetOnboardingConfigResponse); + + return E.right(onboardingConfig); + } + + /** + * Reset all the InfraConfigs to their default values (from .env) + */ + async reset() { + // These are all the infra-configs that should not be reset + const RESET_EXCLUSION_LIST = [ + InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP, + InfraConfigEnum.ANALYTICS_USER_ID, + InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION, + ]; + try { + const defaultConfigs = await getDefaultInfraConfigs(); + + const configsToReset = defaultConfigs.filter( + (p) => RESET_EXCLUSION_LIST.includes(p.name) === false, + ); + + // Update ONBOARDING_COMPLETED value to false + const onboardingCompletedIndex = configsToReset.findIndex( + (p) => p.name === InfraConfigEnum.ONBOARDING_COMPLETED, + ); + if (onboardingCompletedIndex !== -1) { + configsToReset[onboardingCompletedIndex].value = 'false'; + } + + await this.prisma.infraConfig.deleteMany({ + where: { name: { in: configsToReset.map((p) => p.name) } }, + }); + + await this.prisma.infraConfig.createMany({ + data: configsToReset, + }); + + stopApp(); + + return E.right(true); + } catch (e) { + return E.left(INFRA_CONFIG_RESET_FAILED); + } + } + + /** + * Validate that SMTP user and password are both provided or both empty, + * checking the effective post-update state (incoming merged with DB). + */ + private async validateSmtpCredentialPair( + infraConfigs: { name: InfraConfigEnum; value: string }[], + ) { + const incoming = new Map(infraConfigs.map((c) => [c.name, c.value])); + const smtpKeys = [ + InfraConfigEnum.MAILER_SMTP_USER, + InfraConfigEnum.MAILER_SMTP_PASSWORD, + ]; + + if (!smtpKeys.some((key) => incoming.has(key))) { + return E.right(true); + } + + const missingKeys = smtpKeys.filter((key) => !incoming.has(key)); + + const dbRows = + missingKeys.length === 0 + ? [] + : await this.prisma.infraConfig.findMany({ + where: { name: { in: missingKeys } }, + select: { name: true, value: true, isEncrypted: true }, + }); + + const dbValues = new Map( + dbRows.map((row) => [ + row.name, + row.value ? (row.isEncrypted ? decrypt(row.value) : row.value) : '', + ]), + ); + + const smtpUser = + incoming.get(InfraConfigEnum.MAILER_SMTP_USER) ?? + dbValues.get(InfraConfigEnum.MAILER_SMTP_USER) ?? + ''; + + const smtpPass = + incoming.get(InfraConfigEnum.MAILER_SMTP_PASSWORD) ?? + dbValues.get(InfraConfigEnum.MAILER_SMTP_PASSWORD) ?? + ''; + + const hasUser = smtpUser.trim() !== ''; + const hasPass = smtpPass.trim() !== ''; + + return hasUser !== hasPass + ? E.left(INFRA_CONFIG_INVALID_INPUT) + : E.right(true); + } + + /** + * Validate the values of the InfraConfigs + */ + validateEnvValues( + infraConfigs: { + name: InfraConfigEnum; + value: string; + }[], + ) { + for (const config of infraConfigs) { + const { name, value } = config; + + const fail = () => { + console.error(`[Infra Validation Failed] Key: ${name}`); + return E.left(INFRA_CONFIG_INVALID_INPUT); + }; + + switch (name) { + case InfraConfigEnum.MAILER_SMTP_ENABLE: + case InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS: + case InfraConfigEnum.MAILER_SMTP_SECURE: + case InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED: + case InfraConfigEnum.MAILER_SMTP_IGNORE_TLS: + if (value !== 'true' && value !== 'false') return fail(); + break; + + case InfraConfigEnum.MAILER_SMTP_AUTH_TYPE: + if ( + value && + !Object.values(SMTPAuthType).includes(value as SMTPAuthType) + ) + return fail(); + break; + + case InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL: + if (value && !validateUrl(value)) return fail(); + break; + + case InfraConfigEnum.MAILER_SMTP_URL: + if (!validateSMTPUrl(value)) return fail(); + break; + + case InfraConfigEnum.MAILER_ADDRESS_FROM: + if (!validateSMTPEmail(value)) return fail(); + break; + + case InfraConfigEnum.MOCK_SERVER_WILDCARD_DOMAIN: + if (!value) break; // Allow empty value + + if (!value.startsWith('*.mock.')) return fail(); + // Validate domain format after *.mock. + const domainPart = value.substring(7); // Remove '*.mock.' + const domainRegex = + /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + if (!domainPart || !domainRegex.test(domainPart)) return fail(); + break; + + case InfraConfigEnum.MAILER_SMTP_HOST: + case InfraConfigEnum.MAILER_SMTP_PORT: + case InfraConfigEnum.GOOGLE_CLIENT_ID: + case InfraConfigEnum.GOOGLE_CLIENT_SECRET: + case InfraConfigEnum.GOOGLE_SCOPE: + case InfraConfigEnum.GITHUB_CLIENT_ID: + case InfraConfigEnum.GITHUB_CLIENT_SECRET: + case InfraConfigEnum.GITHUB_SCOPE: + case InfraConfigEnum.MICROSOFT_CLIENT_ID: + case InfraConfigEnum.MICROSOFT_CLIENT_SECRET: + case InfraConfigEnum.MICROSOFT_SCOPE: + case InfraConfigEnum.MICROSOFT_TENANT: + if (!value) return fail(); + break; + + case InfraConfigEnum.GOOGLE_CALLBACK_URL: + case InfraConfigEnum.GITHUB_CALLBACK_URL: + case InfraConfigEnum.MICROSOFT_CALLBACK_URL: + case InfraConfigEnum.PROXY_APP_URL: + if (!validateUrl(value)) return fail(); + break; + + case InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS: + const allowedAuthProviders = value.split(','); + if ( + allowedAuthProviders.length === 0 || + allowedAuthProviders.some( + (p) => !Object.values(AuthProvider).includes(p as AuthProvider), + ) + ) { + return fail(); + } + break; + + case InfraConfigEnum.TOKEN_SALT_COMPLEXITY: + case InfraConfigEnum.MAGIC_LINK_TOKEN_VALIDITY: + case InfraConfigEnum.ACCESS_TOKEN_VALIDITY: + case InfraConfigEnum.REFRESH_TOKEN_VALIDITY: + case InfraConfigEnum.RATE_LIMIT_TTL: + case InfraConfigEnum.RATE_LIMIT_MAX: + if (!Number.isInteger(Number(value)) || Number(value) < 1) + return fail(); + break; + + case InfraConfigEnum.SESSION_COOKIE_NAME: + // Allow empty to fall back to default; otherwise enforce allowed characters + if (value && !/^[A-Za-z0-9_-]+$/.test(value)) return fail(); + break; + + default: + break; + } + } + + return E.right(true); + } +} diff --git a/packages/hoppscotch-backend/src/infra-config/input-args.ts b/packages/hoppscotch-backend/src/infra-config/input-args.ts new file mode 100644 index 0000000..0f09418 --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-config/input-args.ts @@ -0,0 +1,35 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { InfraConfigEnum } from 'src/types/InfraConfig'; +import { ServiceStatus } from './helper'; +import { AuthProvider } from 'src/auth/helper'; +import { IsEnum, IsString } from 'class-validator'; + +@InputType() +export class InfraConfigArgs { + @IsEnum(InfraConfigEnum) + @Field(() => InfraConfigEnum, { + description: 'Infra Config Name', + }) + name: InfraConfigEnum; + + @IsString() + @Field({ + description: 'Infra Config Value', + }) + value: string; +} + +@InputType() +export class EnableAndDisableSSOArgs { + @IsEnum(AuthProvider) + @Field(() => AuthProvider, { + description: 'Auth Provider', + }) + provider: AuthProvider; + + @IsEnum(ServiceStatus) + @Field(() => ServiceStatus, { + description: 'Auth Provider Status', + }) + status: ServiceStatus; +} diff --git a/packages/hoppscotch-backend/src/infra-config/onboarding.controller.ts b/packages/hoppscotch-backend/src/infra-config/onboarding.controller.ts new file mode 100644 index 0000000..404ed6b --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-config/onboarding.controller.ts @@ -0,0 +1,143 @@ +import { + Body, + Controller, + Get, + HttpStatus, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { InfraConfigService } from './infra-config.service'; +import { RESTError } from 'src/types/RESTError'; +import { throwHTTPErr } from 'src/utils'; +import * as E from 'fp-ts/Either'; +import { ONBOARDING_CANNOT_BE_RERUN } from 'src/errors'; +import { + GetOnboardingConfigResponse, + GetOnboardingStatusResponse, + SaveOnboardingConfigRequest, + SaveOnboardingConfigResponse, +} from './dto/onboarding.dto'; +import { ApiCreatedResponse, ApiOkResponse } from '@nestjs/swagger'; +import { plainToInstance } from 'class-transformer'; +import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard'; + +@Controller({ path: 'onboarding', version: '1' }) +@UseGuards(ThrottlerBehindProxyGuard) +export class OnboardingController { + constructor(private infraConfigService: InfraConfigService) {} + + @Get('status') + @ApiOkResponse({ + description: 'Get onboarding status', + type: GetOnboardingStatusResponse, + }) + async getOnboardingStatus(): Promise { + const onboardingStatus = + await this.infraConfigService.getOnboardingStatus(); + + if (E.isLeft(onboardingStatus)) + throwHTTPErr({ + message: onboardingStatus.left, + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }); + + return plainToInstance( + GetOnboardingStatusResponse, + { + onboardingCompleted: onboardingStatus.right.onboardingCompleted, + canReRunOnboarding: onboardingStatus.right.canReRunOnboarding, + }, + { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }, + ); + } + + @Post('config') + @ApiCreatedResponse({ + description: 'Onboarding configuration updated successfully', + type: SaveOnboardingConfigResponse, + }) + async updateOnboardingConfig(@Body() dto: SaveOnboardingConfigRequest) { + const onboardingStatus = + await this.infraConfigService.getOnboardingStatus(); + + if (E.isLeft(onboardingStatus)) + throwHTTPErr({ + message: onboardingStatus.left, + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }); + + if ( + onboardingStatus.right.onboardingCompleted && + !onboardingStatus.right.canReRunOnboarding + ) + throwHTTPErr({ + message: ONBOARDING_CANNOT_BE_RERUN, + statusCode: HttpStatus.BAD_REQUEST, + }); + + const updateConfigResult = + await this.infraConfigService.updateOnboardingConfig(dto); + + if (E.isLeft(updateConfigResult)) + throwHTTPErr({ + message: updateConfigResult.left, + statusCode: HttpStatus.BAD_REQUEST, + }); + + return plainToInstance( + SaveOnboardingConfigResponse, + updateConfigResult.right, + { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }, + ); + } + + @Get('config') + @ApiOkResponse({ + description: 'Get onboarding configuration', + type: GetOnboardingConfigResponse, + }) + async getOnboardingConfig(@Query('token') token: string) { + const onboardingStatus = + await this.infraConfigService.getOnboardingStatus(); + + if (E.isLeft(onboardingStatus)) + throwHTTPErr({ + message: onboardingStatus.left, + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }); + + if ( + onboardingStatus.right.onboardingCompleted && + !onboardingStatus.right.canReRunOnboarding + ) + throwHTTPErr({ + message: ONBOARDING_CANNOT_BE_RERUN, + statusCode: HttpStatus.BAD_REQUEST, + }); + + const onboardingConfig = + await this.infraConfigService.getOnboardingConfig(token); + + if (E.isLeft(onboardingConfig)) + throwHTTPErr({ + message: onboardingConfig.left, + statusCode: HttpStatus.BAD_REQUEST, + }); + + return plainToInstance( + GetOnboardingConfigResponse, + onboardingConfig.right, + { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }, + ); + } +} diff --git a/packages/hoppscotch-backend/src/infra-token/infra-token.controller.ts b/packages/hoppscotch-backend/src/infra-token/infra-token.controller.ts new file mode 100644 index 0000000..21e55ed --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-token/infra-token.controller.ts @@ -0,0 +1,302 @@ +import { + Body, + Controller, + Delete, + Get, + HttpStatus, + Param, + Patch, + Post, + Query, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { AdminService } from 'src/admin/admin.service'; +import { InfraTokenGuard } from 'src/guards/infra-token.guard'; +import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard'; +import { + DeleteUserInvitationRequest, + DeleteUserInvitationResponse, + ExceptionResponse, + GetUserInvitationResponse, + GetUsersRequestQuery, + GetUserResponse, + UpdateUserRequest, + UpdateUserAdminStatusRequest, + UpdateUserAdminStatusResponse, + CreateUserInvitationRequest, + CreateUserInvitationResponse, + DeleteUserResponse, + GetUserWorkspacesResponse, +} from './request-response.dto'; +import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; +import { + ApiBadRequestResponse, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { throwHTTPErr } from 'src/utils'; +import { UserService } from 'src/user/user.service'; +import { + INFRA_TOKEN_CREATOR_NOT_FOUND, + USER_NOT_FOUND, + USERS_NOT_FOUND, +} from 'src/errors'; +import { InfraTokenService } from './infra-token.service'; +import { InfraTokenInterceptor } from 'src/interceptors/infra-token.interceptor'; +import { BearerToken } from 'src/decorators/bearer-token.decorator'; + +@ApiTags('User Management API') +@ApiSecurity('infra-token') +@UseGuards(ThrottlerBehindProxyGuard, InfraTokenGuard) +@UseInterceptors(InfraTokenInterceptor) +@Controller({ path: 'infra', version: '1' }) +export class InfraTokensController { + constructor( + private readonly infraTokenService: InfraTokenService, + private readonly adminService: AdminService, + private readonly userService: UserService, + ) {} + + @Post('user-invitations') + @ApiCreatedResponse({ + description: 'Create a user invitation', + type: CreateUserInvitationResponse, + }) + @ApiBadRequestResponse({ type: ExceptionResponse }) + @ApiNotFoundResponse({ type: ExceptionResponse }) + async createUserInvitation( + @BearerToken() token: string, + @Body() dto: CreateUserInvitationRequest, + ) { + const createdInvitations = + await this.infraTokenService.createUserInvitation(token, dto); + + if (E.isLeft(createdInvitations)) { + const statusCode = + (createdInvitations.left as string) === INFRA_TOKEN_CREATOR_NOT_FOUND + ? HttpStatus.NOT_FOUND + : HttpStatus.BAD_REQUEST; + + throwHTTPErr({ message: createdInvitations.left, statusCode }); + } + + return plainToInstance( + CreateUserInvitationResponse, + { invitationLink: process.env.VITE_BASE_URL }, + { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }, + ); + } + + @Get('user-invitations') + @ApiOkResponse({ + description: 'Get pending user invitations', + type: [GetUserInvitationResponse], + }) + async getPendingUserInvitation( + @Query() paginationQuery: OffsetPaginationArgs, + ) { + const pendingInvitedUsers = + await this.adminService.fetchInvitedUsers(paginationQuery); + + return plainToInstance(GetUserInvitationResponse, pendingInvitedUsers, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + } + + @Delete('user-invitations') + @ApiOkResponse({ + description: 'Delete a pending user invitation', + type: DeleteUserInvitationResponse, + }) + @ApiBadRequestResponse({ type: ExceptionResponse }) + async deleteUserInvitation(@Body() dto: DeleteUserInvitationRequest) { + const isDeleted = await this.adminService.revokeUserInvitations( + dto.inviteeEmails, + ); + + if (E.isLeft(isDeleted)) { + throwHTTPErr({ + message: isDeleted.left, + statusCode: HttpStatus.BAD_REQUEST, + }); + } + + return plainToInstance( + DeleteUserInvitationResponse, + { message: isDeleted.right }, + { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }, + ); + } + + @Get('users') + @ApiOkResponse({ + description: 'Get users list', + type: [GetUserResponse], + }) + async getUsers(@Query() query: GetUsersRequestQuery) { + const users = await this.userService.fetchAllUsersV2(query.searchString, { + take: query.take, + skip: query.skip, + }); + + return plainToInstance(GetUserResponse, users, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + } + + @Get('users/:uid') + @ApiOkResponse({ + description: 'Get user details', + type: GetUserResponse, + }) + @ApiNotFoundResponse({ type: ExceptionResponse }) + async getUser(@Param('uid') uid: string) { + const user = await this.userService.findUserById(uid); + + if (O.isNone(user)) { + throwHTTPErr({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + } + + return plainToInstance(GetUserResponse, user.value, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + } + + @Patch('users/:uid') + @ApiOkResponse({ + description: 'Update user display name', + type: GetUserResponse, + }) + @ApiBadRequestResponse({ type: ExceptionResponse }) + @ApiNotFoundResponse({ type: ExceptionResponse }) + async updateUser(@Param('uid') uid: string, @Body() body: UpdateUserRequest) { + const updatedUser = await this.userService.updateUserDisplayName( + uid, + body.displayName, + ); + + if (E.isLeft(updatedUser)) { + const statusCode = + (updatedUser.left as string) === USER_NOT_FOUND + ? HttpStatus.NOT_FOUND + : HttpStatus.BAD_REQUEST; + + throwHTTPErr({ message: updatedUser.left, statusCode }); + } + + return plainToInstance(GetUserResponse, updatedUser.right, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + } + + @Delete('users/:uid') + @ApiOkResponse({ + description: 'Delete a user from the instance', + type: DeleteUserResponse, + }) + @ApiBadRequestResponse({ type: ExceptionResponse }) + @ApiNotFoundResponse({ type: ExceptionResponse }) + async deleteUser(@Param('uid') uid: string) { + const deletedUser = await this.adminService.removeUserAccount(uid); + + if (E.isLeft(deletedUser)) { + const statusCode = + (deletedUser.left as string) === USER_NOT_FOUND + ? HttpStatus.NOT_FOUND + : HttpStatus.BAD_REQUEST; + + throwHTTPErr({ message: deletedUser.left, statusCode }); + } + + return plainToInstance( + DeleteUserResponse, + { message: deletedUser.right }, + { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }, + ); + } + + @Patch('users/:uid/admin-status') + @ApiOkResponse({ + description: 'Update user admin status', + type: UpdateUserAdminStatusResponse, + }) + @ApiBadRequestResponse({ type: ExceptionResponse }) + @ApiNotFoundResponse({ type: ExceptionResponse }) + async updateUserAdminStatus( + @Param('uid') uid: string, + @Body() body: UpdateUserAdminStatusRequest, + ) { + let updatedUser; + + if (body.isAdmin) { + updatedUser = await this.adminService.makeUsersAdmin([uid]); + } else { + updatedUser = await this.adminService.demoteUsersByAdmin([uid]); + } + + if (E.isLeft(updatedUser)) { + const statusCode = + (updatedUser.left as string) === USERS_NOT_FOUND + ? HttpStatus.NOT_FOUND + : HttpStatus.BAD_REQUEST; + + throwHTTPErr({ message: updatedUser.left as string, statusCode }); + } + + return plainToInstance( + UpdateUserAdminStatusResponse, + { message: updatedUser.right }, + { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }, + ); + } + + @Get('users/:uid/workspaces') + @ApiOkResponse({ + description: 'Get user workspaces', + type: [GetUserWorkspacesResponse], + }) + @ApiNotFoundResponse({ type: ExceptionResponse }) + async getUserWorkspaces(@Param('uid') uid: string) { + const userWorkspaces = await this.userService.fetchUserWorkspaces(uid); + + if (E.isLeft(userWorkspaces)) { + const statusCode = + userWorkspaces.left === USER_NOT_FOUND + ? HttpStatus.NOT_FOUND + : HttpStatus.BAD_REQUEST; + + throwHTTPErr({ message: userWorkspaces.left, statusCode }); + } + + return plainToInstance(GetUserWorkspacesResponse, userWorkspaces.right, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + } +} diff --git a/packages/hoppscotch-backend/src/infra-token/infra-token.model.ts b/packages/hoppscotch-backend/src/infra-token/infra-token.model.ts new file mode 100644 index 0000000..9790830 --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-token/infra-token.model.ts @@ -0,0 +1,43 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class InfraToken { + @Field(() => ID, { + description: 'ID of the infra token', + }) + id: string; + + @Field(() => String, { + description: 'Label of the infra token', + }) + label: string; + + @Field(() => Date, { + description: 'Date when the infra token was created', + }) + createdOn: Date; + + @Field(() => Date, { + description: 'Date when the infra token expires', + nullable: true, + }) + expiresOn: Date; + + @Field(() => Date, { + description: 'Date when the infra token was last used', + }) + lastUsedOn: Date; +} + +@ObjectType() +export class CreateInfraTokenResponse { + @Field(() => String, { + description: 'The infra token', + }) + token: string; + + @Field(() => InfraToken, { + description: 'Infra token info', + }) + info: InfraToken; +} diff --git a/packages/hoppscotch-backend/src/infra-token/infra-token.module.ts b/packages/hoppscotch-backend/src/infra-token/infra-token.module.ts new file mode 100644 index 0000000..b12c209 --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-token/infra-token.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { InfraTokenResolver } from './infra-token.resolver'; +import { InfraTokenService } from './infra-token.service'; +import { InfraTokensController } from './infra-token.controller'; +import { AdminModule } from 'src/admin/admin.module'; +import { UserModule } from 'src/user/user.module'; + +@Module({ + imports: [AdminModule, UserModule], + controllers: [InfraTokensController], + providers: [InfraTokenResolver, InfraTokenService], +}) +export class InfraTokenModule {} diff --git a/packages/hoppscotch-backend/src/infra-token/infra-token.resolver.ts b/packages/hoppscotch-backend/src/infra-token/infra-token.resolver.ts new file mode 100644 index 0000000..c342732 --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-token/infra-token.resolver.ts @@ -0,0 +1,68 @@ +import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { CreateInfraTokenResponse, InfraToken } from './infra-token.model'; +import { UseGuards } from '@nestjs/common'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { InfraTokenService } from './infra-token.service'; +import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; +import { GqlAdminGuard } from 'src/admin/guards/gql-admin.guard'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; +import { GqlAdmin } from 'src/admin/decorators/gql-admin.decorator'; +import { Admin } from 'src/admin/admin.model'; +import * as E from 'fp-ts/Either'; +import { throwErr } from 'src/utils'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => InfraToken) +export class InfraTokenResolver { + constructor(private readonly infraTokenService: InfraTokenService) {} + + /* Query */ + + @Query(() => [InfraToken], { + description: 'Get list of infra tokens', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + infraTokens(@Args() args: OffsetPaginationArgs) { + return this.infraTokenService.getAll(args.take, args.skip); + } + + /* Mutations */ + + @Mutation(() => CreateInfraTokenResponse, { + description: 'Create a new infra token', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async createInfraToken( + @GqlAdmin() admin: Admin, + @Args({ name: 'label', description: 'Label of the token' }) label: string, + @Args({ + name: 'expiryInDays', + description: 'Number of days the token is valid for', + nullable: true, + }) + expiryInDays: number, + ) { + const infraToken = await this.infraTokenService.create( + label, + expiryInDays, + admin, + ); + + if (E.isLeft(infraToken)) throwErr(infraToken.left); + return infraToken.right; + } + + @Mutation(() => Boolean, { + description: 'Revoke an infra token', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async revokeInfraToken( + @Args({ name: 'id', type: () => ID, description: 'ID of the infra token' }) + id: string, + ) { + const res = await this.infraTokenService.revoke(id); + + if (E.isLeft(res)) throwErr(res.left); + return res.right; + } +} diff --git a/packages/hoppscotch-backend/src/infra-token/infra-token.service.ts b/packages/hoppscotch-backend/src/infra-token/infra-token.service.ts new file mode 100644 index 0000000..f6d1b49 --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-token/infra-token.service.ts @@ -0,0 +1,160 @@ +import { Injectable } from '@nestjs/common'; +import { InfraToken as dbInfraToken } from 'src/generated/prisma/client'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { CreateInfraTokenResponse, InfraToken } from './infra-token.model'; +import { calculateExpirationDate, isValidLength } from 'src/utils'; +import { Admin } from 'src/admin/admin.model'; +import { + INFRA_TOKEN_CREATOR_NOT_FOUND, + INFRA_TOKEN_EXPIRY_INVALID, + INFRA_TOKEN_LABEL_SHORT, + INFRA_TOKEN_NOT_FOUND, +} from 'src/errors'; +import * as E from 'fp-ts/Either'; +import { CreateUserInvitationRequest } from './request-response.dto'; +import { AdminService } from 'src/admin/admin.service'; + +@Injectable() +export class InfraTokenService { + constructor( + private readonly prisma: PrismaService, + private readonly adminService: AdminService, + ) {} + + TITLE_LENGTH = 3; + VALID_TOKEN_DURATIONS = [7, 30, 60, 90]; + + /** + * Validate the expiration date of the token + * + * @param expiresOn Number of days the token is valid for + * @returns Boolean indicating if the expiration date is valid + */ + private validateExpirationDate(expiresOn: null | number) { + if (expiresOn === null || this.VALID_TOKEN_DURATIONS.includes(expiresOn)) + return true; + return false; + } + + /** + * Typecast a database InfraToken to a InfraToken model + * @param dbInfraToken database InfraToken + * @returns InfraToken model + */ + private cast(dbInfraToken: dbInfraToken): InfraToken { + return { + id: dbInfraToken.id, + label: dbInfraToken.label, + createdOn: dbInfraToken.createdOn, + expiresOn: dbInfraToken.expiresOn, + lastUsedOn: dbInfraToken.updatedOn, + }; + } + + /** + * Fetch all infra tokens with pagination + * @param take take for pagination + * @param skip skip for pagination + * @returns List of InfraToken models + */ + async getAll(take = 10, skip = 0) { + const infraTokens = await this.prisma.infraToken.findMany({ + take, + skip, + orderBy: { createdOn: 'desc' }, + }); + + return infraTokens.map((token) => this.cast(token)); + } + + /** + * Create a new infra token + * @param label label of the token + * @param expiryInDays expiry duration of the token + * @param admin admin who created the token + * @returns Either of error message or CreateInfraTokenResponse + */ + async create(label: string, expiryInDays: number, admin: Admin) { + if (!isValidLength(label, this.TITLE_LENGTH)) { + return E.left(INFRA_TOKEN_LABEL_SHORT); + } + + if (!this.validateExpirationDate(expiryInDays ?? null)) { + return E.left(INFRA_TOKEN_EXPIRY_INVALID); + } + + const createdInfraToken = await this.prisma.infraToken.create({ + data: { + creatorUid: admin.uid, + label, + expiresOn: calculateExpirationDate(expiryInDays ?? null) ?? undefined, + }, + }); + + const res: CreateInfraTokenResponse = { + token: createdInfraToken.token, + info: this.cast(createdInfraToken), + }; + + return E.right(res); + } + + /** + * Revoke an infra token + * @param id ID of the infra token + * @returns Either of error or true + */ + async revoke(id: string) { + try { + await this.prisma.infraToken.delete({ + where: { id }, + }); + } catch (error) { + return E.left(INFRA_TOKEN_NOT_FOUND); + } + return E.right(true); + } + + /** + * Update the last used on of an infra token + * @param token token to update + * @returns Either of error or InfraToken + */ + async updateLastUsedOn(token: string) { + try { + const infraToken = await this.prisma.infraToken.update({ + where: { token }, + data: { updatedOn: new Date() }, + }); + return E.right(this.cast(infraToken)); + } catch (error) { + return E.left(INFRA_TOKEN_NOT_FOUND); + } + } + + /** + * Create a user invitation using an infra token + * @param token token used to create the invitation + * @param dto CreateUserInvitationRequest + * @returns Either of error or InvitedUser + */ + async createUserInvitation(token: string, dto: CreateUserInvitationRequest) { + const infraToken = await this.prisma.infraToken.findUnique({ + where: { token }, + }); + + const tokenCreator = await this.prisma.user.findUnique({ + where: { uid: infraToken.creatorUid }, + }); + if (!tokenCreator) return E.left(INFRA_TOKEN_CREATOR_NOT_FOUND); + + const invitedUser = await this.adminService.inviteUserToSignInViaEmail( + tokenCreator.uid, + tokenCreator.email, + dto.inviteeEmail, + ); + if (E.isLeft(invitedUser)) return E.left(invitedUser.left); + + return E.right(invitedUser); + } +} diff --git a/packages/hoppscotch-backend/src/infra-token/request-response.dto.ts b/packages/hoppscotch-backend/src/infra-token/request-response.dto.ts new file mode 100644 index 0000000..8149334 --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-token/request-response.dto.ts @@ -0,0 +1,160 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsEmail, + IsOptional, + IsString, + MinLength, +} from 'class-validator'; +import { TeamAccessRole } from 'src/team/team.model'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; + +// POST v1/infra/user-invitations +export class CreateUserInvitationRequest { + @IsEmail() + @ApiProperty() + @Type(() => String) + inviteeEmail: string; +} +export class CreateUserInvitationResponse { + @ApiProperty() + @Expose() + invitationLink: string; +} + +// GET v1/infra/user-invitations +export class GetUserInvitationResponse { + @ApiProperty() + @Expose() + inviteeEmail: string; + + @ApiProperty() + @Expose() + invitedOn: Date; +} + +// DELETE v1/infra/user-invitations +export class DeleteUserInvitationRequest { + @IsArray() + @ArrayMinSize(1) + @IsEmail({}, { each: true }) + @Type(() => String) + @ApiProperty() + inviteeEmails: string[]; +} +export class DeleteUserInvitationResponse { + @ApiProperty() + @Expose() + message: string; +} + +// POST v1/infra/users +export class GetUsersRequestQuery extends OffsetPaginationArgs { + @IsString() + @IsOptional() + @MinLength(1) + @ApiPropertyOptional() + searchString: string; +} +export class GetUserResponse { + @ApiProperty() + @Expose() + uid: string; + + @ApiProperty() + @Expose() + displayName: string; + + @ApiProperty() + @Expose() + email: string; + + @ApiProperty() + @Expose() + photoURL: string; + + @ApiProperty() + @Expose() + isAdmin: boolean; + + @ApiProperty() + @Expose() + lastLoggedOn: Date; + + @ApiProperty() + @Expose() + lastActiveOn: Date; +} + +// PATCH v1/infra/users/:uid +export class UpdateUserRequest { + @IsOptional() + @IsString() + @MinLength(1) + @ApiPropertyOptional() + displayName: string; +} + +// PATCH v1/infra/users/:uid/admin-status +export class UpdateUserAdminStatusRequest { + @IsBoolean() + @ApiProperty() + isAdmin: boolean; +} +export class UpdateUserAdminStatusResponse { + @ApiProperty() + @Expose() + message: string; +} + +// Used for Swagger doc only, in codebase throwHTTPErr function is used to throw errors +export class ExceptionResponse { + @ApiProperty() + @Expose() + message: string; + + @ApiProperty() + @Expose() + statusCode: number; +} + +// Delete v1/infra/users/:uid +export class DeleteUserResponse { + @ApiProperty() + @Expose() + message: string; +} + +// GET v1/infra/users/:uid/workspaces +export class GetUserWorkspacesResponse { + @ApiProperty() + @Expose() + id: string; + + @ApiProperty() + @Expose() + name: string; + + @ApiProperty({ enum: TeamAccessRole }) + @Expose() + role: string; + + @ApiProperty() + @Expose() + owner_count: number; + + @ApiProperty() + @Expose() + editor_count: number; + + @ApiProperty() + @Expose() + viewer_count: number; + + @ApiProperty() + @Expose() + member_count: number; +} diff --git a/packages/hoppscotch-backend/src/interceptors/access-token.interceptor.ts b/packages/hoppscotch-backend/src/interceptors/access-token.interceptor.ts new file mode 100644 index 0000000..b9e5360 --- /dev/null +++ b/packages/hoppscotch-backend/src/interceptors/access-token.interceptor.ts @@ -0,0 +1,36 @@ +import { + BadRequestException, + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable, map } from 'rxjs'; +import { AccessTokenService } from 'src/access-token/access-token.service'; +import * as E from 'fp-ts/Either'; +import { ACCESS_TOKEN_NOT_FOUND } from 'src/errors'; + +@Injectable() +export class AccessTokenInterceptor implements NestInterceptor { + constructor(private readonly accessTokenService: AccessTokenService) {} + + intercept(context: ExecutionContext, handler: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + const authHeader = req.headers.authorization; + const token = authHeader && authHeader.split(' ')[1]; + if (!token) { + throw new BadRequestException(ACCESS_TOKEN_NOT_FOUND); + } + + return handler.handle().pipe( + map(async (data) => { + const userAccessToken = + await this.accessTokenService.updateLastUsedForPAT(token); + if (E.isLeft(userAccessToken)) + throw new BadRequestException(userAccessToken.left); + + return data; + }), + ); + } +} diff --git a/packages/hoppscotch-backend/src/interceptors/infra-token.interceptor.ts b/packages/hoppscotch-backend/src/interceptors/infra-token.interceptor.ts new file mode 100644 index 0000000..bfddda7 --- /dev/null +++ b/packages/hoppscotch-backend/src/interceptors/infra-token.interceptor.ts @@ -0,0 +1,30 @@ +import { + BadRequestException, + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { INFRA_TOKEN_NOT_FOUND } from 'src/errors'; +import { InfraTokenService } from 'src/infra-token/infra-token.service'; + +@Injectable() +export class InfraTokenInterceptor implements NestInterceptor { + constructor(private readonly infraTokenService: InfraTokenService) {} + + intercept(context: ExecutionContext, handler: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new BadRequestException(INFRA_TOKEN_NOT_FOUND); + } + + const token = authHeader.split(' ')[1]; + + this.infraTokenService.updateLastUsedOn(token); + + return handler.handle(); + } +} diff --git a/packages/hoppscotch-backend/src/interceptors/user-last-active-on.interceptor.ts b/packages/hoppscotch-backend/src/interceptors/user-last-active-on.interceptor.ts new file mode 100644 index 0000000..faba057 --- /dev/null +++ b/packages/hoppscotch-backend/src/interceptors/user-last-active-on.interceptor.ts @@ -0,0 +1,65 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql'; +import { Observable, throwError } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; +import { AuthUser } from 'src/types/AuthUser'; +import { UserService } from 'src/user/user.service'; + +@Injectable() +export class UserLastActiveOnInterceptor implements NestInterceptor { + constructor(private userService: UserService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (context.getType() === 'http') { + return this.restHandler(context, next); + } else if (context.getType() === 'graphql') { + return this.graphqlHandler(context, next); + } + } + + restHandler(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const user: AuthUser = request.user; + + return next.handle().pipe( + tap(() => { + if (user && typeof user === 'object') { + this.userService.updateUserLastActiveOn(user.uid); + } + }), + catchError((error) => { + if (user && typeof user === 'object') { + this.userService.updateUserLastActiveOn(user.uid); + } + return throwError(() => error); + }), + ); + } + + graphqlHandler( + context: ExecutionContext, + next: CallHandler, + ): Observable { + const contextObject = GqlExecutionContext.create(context).getContext(); + const user: AuthUser = contextObject?.req?.user; + + return next.handle().pipe( + tap(() => { + if (user && typeof user === 'object') { + this.userService.updateUserLastActiveOn(user.uid); + } + }), + catchError((error) => { + if (user && typeof user === 'object') { + this.userService.updateUserLastActiveOn(user.uid); + } + return throwError(() => error); + }), + ); + } +} diff --git a/packages/hoppscotch-backend/src/interceptors/user-last-login.interceptor.ts b/packages/hoppscotch-backend/src/interceptors/user-last-login.interceptor.ts new file mode 100644 index 0000000..db019d9 --- /dev/null +++ b/packages/hoppscotch-backend/src/interceptors/user-last-login.interceptor.ts @@ -0,0 +1,25 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { AuthUser } from 'src/types/AuthUser'; +import { UserService } from 'src/user/user.service'; + +@Injectable() +export class UserLastLoginInterceptor implements NestInterceptor { + constructor(private userService: UserService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const user: AuthUser = context.switchToHttp().getRequest().user; + + return next.handle().pipe( + tap(() => { + this.userService.updateUserLastLoggedOn(user.uid); + }), + ); + } +} diff --git a/packages/hoppscotch-backend/src/mailer/MailDescriptions.ts b/packages/hoppscotch-backend/src/mailer/MailDescriptions.ts new file mode 100644 index 0000000..6582e53 --- /dev/null +++ b/packages/hoppscotch-backend/src/mailer/MailDescriptions.ts @@ -0,0 +1,24 @@ +export type MailDescription = { + template: 'team-invitation'; + variables: { + invitee: string; + invite_team_name: string; + action_url: string; + }; +}; + +export type UserMagicLinkMailDescription = { + template: 'user-invitation'; + variables: { + inviteeEmail: string; + magicLink: string; + }; +}; + +export type AdminUserInvitationMailDescription = { + template: 'user-invitation'; + variables: { + inviteeEmail: string; + magicLink: string; + }; +}; diff --git a/packages/hoppscotch-backend/src/mailer/helper.ts b/packages/hoppscotch-backend/src/mailer/helper.ts new file mode 100644 index 0000000..9d4788e --- /dev/null +++ b/packages/hoppscotch-backend/src/mailer/helper.ts @@ -0,0 +1,84 @@ +import type { MailerOptions } from '@nestjs-modules/mailer'; +import type SMTPConnection from 'nodemailer/lib/smtp-connection'; +import { MAILER_SMTP_URL_UNDEFINED } from 'src/errors'; +import { throwErr } from 'src/utils'; + +type TransportType = NonNullable; + +export enum SMTPAuthType { + LOGIN = 'login', + OAUTH2 = 'oauth2', +} + +function isSMTPCustomConfigsEnabled(value) { + return value === 'true'; +} + +export function getMailerAddressFrom(env): string { + return env.INFRA.MAILER_ADDRESS_FROM ?? throwErr(MAILER_SMTP_URL_UNDEFINED); +} + +export function getTransportOption(env): TransportType { + const useCustomConfigs = isSMTPCustomConfigsEnabled( + env.INFRA.MAILER_USE_CUSTOM_CONFIGS, + ); + + if (!useCustomConfigs) { + console.log('Using simple mailer configuration'); + return env.INFRA.MAILER_SMTP_URL ?? throwErr(MAILER_SMTP_URL_UNDEFINED); + } else { + console.log('Using advanced mailer configuration'); + + const authType = env.INFRA.MAILER_SMTP_AUTH_TYPE?.trim(); + + let auth: SMTPConnection.AuthenticationType | undefined; + + if (authType === SMTPAuthType.OAUTH2) { + const oauth2User = env.INFRA.MAILER_SMTP_OAUTH2_USER?.trim() || undefined; + const oauth2ClientId = + env.INFRA.MAILER_SMTP_OAUTH2_CLIENT_ID?.trim() || undefined; + const oauth2ClientSecret = + env.INFRA.MAILER_SMTP_OAUTH2_CLIENT_SECRET?.trim() || undefined; + const oauth2RefreshToken = + env.INFRA.MAILER_SMTP_OAUTH2_REFRESH_TOKEN?.trim() || undefined; + const oauth2AccessUrl = + env.INFRA.MAILER_SMTP_OAUTH2_ACCESS_URL?.trim() || undefined; + + auth = { + type: SMTPAuthType.OAUTH2, + user: oauth2User, + clientId: oauth2ClientId, + clientSecret: oauth2ClientSecret, + refreshToken: oauth2RefreshToken, + accessUrl: oauth2AccessUrl, + }; + } else { + const smtpUser = env.INFRA.MAILER_SMTP_USER?.trim() || undefined; + const smtpPass = env.INFRA.MAILER_SMTP_PASSWORD?.trim() || undefined; + + const hasUser = !!smtpUser; + const hasPass = !!smtpPass; + if (hasUser !== hasPass) { + throw new Error( + 'SMTP auth requires both MAILER_SMTP_USER and MAILER_SMTP_PASSWORD. Provide both or leave both empty for unauthenticated relay.', + ); + } + + auth = + smtpUser && smtpPass + ? { type: SMTPAuthType.LOGIN, user: smtpUser, pass: smtpPass } + : undefined; + } + + return { + host: env.INFRA.MAILER_SMTP_HOST, + port: +env.INFRA.MAILER_SMTP_PORT, + secure: env.INFRA.MAILER_SMTP_SECURE === 'true', + ...(auth && { auth }), + ignoreTLS: env.INFRA.MAILER_SMTP_IGNORE_TLS === 'true', + tls: { + rejectUnauthorized: env.INFRA.MAILER_TLS_REJECT_UNAUTHORIZED === 'true', + }, + }; + } +} diff --git a/packages/hoppscotch-backend/src/mailer/mailer.module.ts b/packages/hoppscotch-backend/src/mailer/mailer.module.ts new file mode 100644 index 0000000..8c578c7 --- /dev/null +++ b/packages/hoppscotch-backend/src/mailer/mailer.module.ts @@ -0,0 +1,51 @@ +import { Global, Module } from '@nestjs/common'; +import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer'; +import { HandlebarsAdapter } from '@nestjs-modules/mailer/adapters/handlebars.adapter'; +import { MailerService } from './mailer.service'; +import { loadInfraConfiguration } from 'src/infra-config/helper'; +import { getMailerAddressFrom, getTransportOption } from './helper'; + +@Global() +@Module({ + imports: [], + providers: [MailerService], + exports: [MailerService], +}) +export class MailerModule { + static async register() { + if (process.env.GENERATE_GQL_SCHEMA) return { module: MailerModule }; + + const env = await loadInfraConfiguration(); + + // If mailer SMTP is DISABLED, return the module without any configuration (service, listener, etc.) + if (env.INFRA.MAILER_SMTP_ENABLE !== 'true') { + console.log('Mailer module is disabled'); + return { + module: MailerModule, + }; + } + + // If mailer is ENABLED, return the module with configuration (service, etc.) + + // Determine transport configuration based on custom config flag + const transportOption = getTransportOption(env); + // Get mailer address from environment or config + const mailerAddressFrom = getMailerAddressFrom(env); + + return { + module: MailerModule, + imports: [ + NestMailerModule.forRoot({ + transport: transportOption, + defaults: { + from: mailerAddressFrom, + }, + template: { + dir: __dirname + '/templates', + adapter: new HandlebarsAdapter(), + }, + }), + ], + }; + } +} diff --git a/packages/hoppscotch-backend/src/mailer/mailer.service.ts b/packages/hoppscotch-backend/src/mailer/mailer.service.ts new file mode 100644 index 0000000..89eaa69 --- /dev/null +++ b/packages/hoppscotch-backend/src/mailer/mailer.service.ts @@ -0,0 +1,89 @@ +import { Injectable, Optional } from '@nestjs/common'; +import { + AdminUserInvitationMailDescription, + MailDescription, + UserMagicLinkMailDescription, +} from './MailDescriptions'; +import { throwErr } from 'src/utils'; +import { EMAIL_FAILED } from 'src/errors'; +import { MailerService as NestMailerService } from '@nestjs-modules/mailer'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class MailerService { + constructor( + @Optional() private readonly nestMailerService: NestMailerService, + private readonly configService: ConfigService, + ) {} + + /** + * Takes an input mail description and spits out the Email subject required for it + * @param mailDesc The mail description to get subject for + * @returns The subject of the email + */ + private resolveSubjectForMailDesc( + mailDesc: + | MailDescription + | UserMagicLinkMailDescription + | AdminUserInvitationMailDescription, + ): string { + switch (mailDesc.template) { + case 'team-invitation': + return `A user has invited you to join a team workspace in Hoppscotch`; + + case 'user-invitation': + return 'Sign in to Hoppscotch'; + } + } + + /** + * Sends an email to the given email address given a mail description + * @param to Receiver's email id + * @param mailDesc Definition of what email to be sent + * @returns Response if email was send successfully or not + */ + async sendEmail( + to: string, + mailDesc: MailDescription | UserMagicLinkMailDescription, + ) { + if (this.configService.get('INFRA.MAILER_SMTP_ENABLE') !== 'true') return; + + try { + await this.nestMailerService.sendMail({ + to, + template: mailDesc.template, + subject: this.resolveSubjectForMailDesc(mailDesc), + context: mailDesc.variables, + }); + } catch (error) { + console.error('Error from sendEmail:', error); + return throwErr(EMAIL_FAILED); + } + } + + /** + * + * @param to Receiver's email id + * @param mailDesc Details of email to be sent for user invitation + * @returns Response if email was send successfully or not + */ + async sendUserInvitationEmail( + to: string, + mailDesc: AdminUserInvitationMailDescription, + ) { + if (this.configService.get('INFRA.MAILER_SMTP_ENABLE') !== 'true') return; + + try { + const res = await this.nestMailerService.sendMail({ + to, + template: mailDesc.template, + subject: this.resolveSubjectForMailDesc(mailDesc), + context: mailDesc.variables, + }); + return res; + } catch (error) { + console.error('Error from sendUserInvitationEmail:', error); + return throwErr(EMAIL_FAILED); + } + } +} diff --git a/packages/hoppscotch-backend/src/mailer/templates/team-invitation.hbs b/packages/hoppscotch-backend/src/mailer/templates/team-invitation.hbs new file mode 100644 index 0000000..17133ad --- /dev/null +++ b/packages/hoppscotch-backend/src/mailer/templates/team-invitation.hbs @@ -0,0 +1,526 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-backend/src/mailer/templates/user-invitation.hbs b/packages/hoppscotch-backend/src/mailer/templates/user-invitation.hbs new file mode 100644 index 0000000..80ae702 --- /dev/null +++ b/packages/hoppscotch-backend/src/mailer/templates/user-invitation.hbs @@ -0,0 +1,532 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-backend/src/main.ts b/packages/hoppscotch-backend/src/main.ts new file mode 100644 index 0000000..0244015 --- /dev/null +++ b/packages/hoppscotch-backend/src/main.ts @@ -0,0 +1,108 @@ +import { NestFactory } from '@nestjs/core'; +import { json } from 'express'; +import { AppModule } from './app.module'; +import cookieParser from 'cookie-parser'; +import { ValidationPipe, VersioningType } from '@nestjs/common'; +import { emitGQLSchemaFile } from './gql-schema'; +import morgan from 'morgan'; +import { ConfigService } from '@nestjs/config'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { InfraTokenModule } from './infra-token/infra-token.module'; +import { NestExpressApplication } from '@nestjs/platform-express'; + +function setupSwagger( + app: NestExpressApplication, + isProduction: boolean, +): void { + const swaggerDocPath = '/api-docs'; + + const config = new DocumentBuilder() + .setTitle('Hoppscotch API Documentation') + .setDescription('APIs for external integration') + .addApiKey( + { + type: 'apiKey', + name: 'Authorization', + in: 'header', + scheme: 'bearer', + bearerFormat: 'Bearer', + }, + 'infra-token', + ) + .build(); + + const document = SwaggerModule.createDocument(app, config, { + include: isProduction ? [InfraTokenModule] : [], + }); + SwaggerModule.setup(swaggerDocPath, app, document, { + swaggerOptions: { persistAuthorization: true, ignoreGlobalPrefix: true }, + }); +} + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + const configService = app.get(ConfigService); + const isProduction = configService.get('PRODUCTION') === 'true'; + + console.log(`Running in production: ${isProduction}`); + console.log(`Port: ${configService.get('PORT')}`); + + // Increase file upload limit to 100MB + app.use( + json({ + limit: '100mb', + }), + ); + + if (isProduction) { + console.log('Enabling CORS with production settings'); + app.enableCors({ + origin: configService.get('WHITELISTED_ORIGINS').split(','), + credentials: true, + }); + } else { + console.log('Enabling CORS with development settings'); + app.enableCors({ + origin: true, + credentials: true, + }); + } + + app.enableVersioning({ + type: VersioningType.URI, + }); + app.use(cookieParser()); + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + }), + ); + + if (configService.get('TRUST_PROXY') === 'true') { + console.log('Enabling trust proxy'); + app.set('trust proxy', true); + } + + app.use(morgan(':remote-addr :method :url :status - :response-time ms')); + + await setupSwagger(app, isProduction); + + await app.listen(configService.get('PORT') || 3170); + + // Graceful shutdown + process.on('SIGTERM', async () => { + console.info('SIGTERM signal received, initiating graceful shutdown...'); + await app.close(); + console.info('Application closed successfully'); + process.exit(0); + }); +} + +if (!process.env.GENERATE_GQL_SCHEMA) { + bootstrap(); +} else { + emitGQLSchemaFile(); +} diff --git a/packages/hoppscotch-backend/src/mock-server/constants/mock-server-coll-request-example.ts b/packages/hoppscotch-backend/src/mock-server/constants/mock-server-coll-request-example.ts new file mode 100644 index 0000000..6a0412b --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/constants/mock-server-coll-request-example.ts @@ -0,0 +1,781 @@ +import { randomUUID } from 'crypto'; + +const generateRefId = () => `${Date.now().toString(36)}_${randomUUID()}`; + +export const mockServerCollRequestExample = ( + collectionName: string = 'Hoppscotch API Mock example', +) => { + const baseEnv = '<>'; + return [ + { + v: 10, + name: collectionName, + folders: [], + requests: [ + { + v: '16', + _ref_id: `req_${generateRefId()}`, + name: 'addPet', + method: 'POST', + endpoint: baseEnv + '/v2/pet', + params: [], + headers: [], + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: 'application/json', + body: '{\n\t"id": 1,\n\t"category": {\n\t\t"id": 1,\n\t\t"name": "string"\n\t},\n\t"name": "doggie",\n\t"photoUrls": [\n\t\t"string"\n\t],\n\t"tags": [],\n\t"status": "available"\n}', + }, + preRequestScript: '', + testScript: '', + requestVariables: [], + responses: { + 'Invalid input': { + name: 'Invalid input', + status: 'Method Not Allowed', + code: 405, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'addPet', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: 'application/json', + body: '{\n\t"id": 1,\n\t"category": {\n\t\t"id": 1,\n\t\t"name": "string"\n\t},\n\t"name": "doggie",\n\t"photoUrls": [\n\t\t"string"\n\t],\n\t"tags": [],\n\t"status": "available"\n}', + }, + endpoint: baseEnv + '/v2/pet', + params: [], + headers: [], + method: 'POST', + requestVariables: [], + }, + }, + }, + }, + { + v: '16', + _ref_id: `req_${generateRefId()}`, + name: 'updatePet', + method: 'PUT', + endpoint: baseEnv + '/v2/pet', + params: [], + headers: [], + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: 'application/json', + body: '{\n\t"id": 1,\n\t"category": {\n\t\t"id": 1,\n\t\t"name": "string"\n\t},\n\t"name": "doggie",\n\t"photoUrls": [\n\t\t"string"\n\t],\n\t"tags": [],\n\t"status": "available"\n}', + }, + preRequestScript: '', + testScript: '', + requestVariables: [], + responses: { + 'Invalid ID supplied': { + name: 'Invalid ID supplied', + status: 'Bad Request', + code: 400, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'updatePet', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: 'application/json', + body: '{\n\t"id": 1,\n\t"category": {\n\t\t"id": 1,\n\t\t"name": "string"\n\t},\n\t"name": "doggie",\n\t"photoUrls": [\n\t\t"string"\n\t],\n\t"tags": [],\n\t"status": "available"\n}', + }, + endpoint: baseEnv + '/v2/pet', + params: [], + headers: [], + method: 'PUT', + requestVariables: [], + }, + }, + 'Pet not found': { + name: 'Pet not found', + status: 'Not Found', + code: 404, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'updatePet', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: 'application/json', + body: '{\n\t"id": 1,\n\t"category": {\n\t\t"id": 1,\n\t\t"name": "string"\n\t},\n\t"name": "doggie",\n\t"photoUrls": [\n\t\t"string"\n\t],\n\t"tags": [],\n\t"status": "available"\n}', + }, + endpoint: baseEnv + '/v2/pet', + params: [], + headers: [], + method: 'PUT', + requestVariables: [], + }, + }, + 'Validation exception': { + name: 'Validation exception', + status: 'Method Not Allowed', + code: 405, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'updatePet', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: 'application/json', + body: '{\n\t"id": 1,\n\t"category": {\n\t\t"id": 1,\n\t\t"name": "string"\n\t},\n\t"name": "doggie",\n\t"photoUrls": [\n\t\t"string"\n\t],\n\t"tags": [],\n\t"status": "available"\n}', + }, + endpoint: baseEnv + '/v2/pet', + params: [], + headers: [], + method: 'PUT', + requestVariables: [], + }, + }, + }, + }, + { + v: '16', + _ref_id: `req_${generateRefId()}`, + name: 'findPetsByStatus', + method: 'GET', + endpoint: baseEnv + '/v2/pet/findByStatus', + params: [ + { + key: 'status', + value: '', + active: true, + description: + 'Status values that need to be considered for filter', + }, + ], + headers: [], + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: null, + body: null, + }, + preRequestScript: '', + testScript: '', + requestVariables: [], + responses: { + 'successful operation': { + name: 'successful operation', + status: 'OK', + code: 200, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'findPetsByStatus', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: null, + body: null, + }, + endpoint: 'petstore.swagger.io/v2/pet/findByStatus', + params: [ + { + key: 'status', + value: '', + active: true, + description: + 'Status values that need to be considered for filter', + }, + ], + headers: [], + method: 'GET', + requestVariables: [], + }, + }, + 'Invalid status value': { + name: 'Invalid status value', + status: 'Bad Request', + code: 400, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'findPetsByStatus', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: null, + body: null, + }, + endpoint: 'petstore.swagger.io/v2/pet/findByStatus', + params: [ + { + key: 'status', + value: '', + active: true, + description: + 'Status values that need to be considered for filter', + }, + ], + headers: [], + method: 'GET', + requestVariables: [], + }, + }, + }, + }, + { + v: '16', + _ref_id: `req_${generateRefId()}`, + name: 'getPetById', + method: 'GET', + endpoint: baseEnv + '/v2/pet/<>', + params: [], + headers: [], + auth: { + authType: 'api-key', + addTo: 'HEADERS', + authActive: true, + key: 'api_key', + value: '', + }, + body: { + contentType: null, + body: null, + }, + preRequestScript: '', + testScript: '', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + responses: { + 'successful operation': { + name: 'successful operation', + status: 'OK', + code: 200, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'getPetById', + auth: { + authType: 'api-key', + addTo: 'HEADERS', + authActive: true, + key: 'api_key', + value: '', + }, + body: { + contentType: null, + body: null, + }, + endpoint: 'petstore.swagger.io/v2/pet/<>', + params: [], + headers: [], + method: 'GET', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + }, + }, + 'Invalid ID supplied': { + name: 'Invalid ID supplied', + status: 'Bad Request', + code: 400, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'getPetById', + auth: { + authType: 'api-key', + addTo: 'HEADERS', + authActive: true, + key: 'api_key', + value: '', + }, + body: { + contentType: null, + body: null, + }, + endpoint: 'petstore.swagger.io/v2/pet/<>', + params: [], + headers: [], + method: 'GET', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + }, + }, + 'Pet not found': { + name: 'Pet not found', + status: 'Not Found', + code: 404, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'getPetById', + auth: { + authType: 'api-key', + addTo: 'HEADERS', + authActive: true, + key: 'api_key', + value: '', + }, + body: { + contentType: null, + body: null, + }, + endpoint: 'petstore.swagger.io/v2/pet/<>', + params: [], + headers: [], + method: 'GET', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + }, + }, + }, + }, + { + v: '16', + _ref_id: `req_${generateRefId()}`, + name: 'updatePetWithForm', + method: 'POST', + endpoint: baseEnv + '/v2/pet/<>', + params: [], + headers: [], + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: 'application/x-www-form-urlencoded', + body: 'name: \nstatus: ', + }, + preRequestScript: '', + testScript: '', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + responses: { + 'Invalid input': { + name: 'Invalid input', + status: 'Method Not Allowed', + code: 405, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'updatePetWithForm', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: 'application/x-www-form-urlencoded', + body: 'name: \nstatus: ', + }, + endpoint: 'petstore.swagger.io/v2/pet/<>', + params: [], + headers: [], + method: 'POST', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + }, + }, + }, + }, + { + v: '16', + _ref_id: `req_${generateRefId()}`, + name: 'deletePet', + method: 'DELETE', + endpoint: baseEnv + '/v2/pet/<>', + params: [], + headers: [ + { + key: 'api_key', + value: '', + active: true, + description: '', + }, + ], + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: null, + body: null, + }, + preRequestScript: '', + testScript: '', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + responses: { + 'Invalid ID supplied': { + name: 'Invalid ID supplied', + status: 'Bad Request', + code: 400, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'deletePet', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: null, + body: null, + }, + endpoint: 'petstore.swagger.io/v2/pet/<>', + params: [], + headers: [ + { + key: 'api_key', + value: '', + active: true, + description: '', + }, + ], + method: 'DELETE', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + }, + }, + 'Pet not found': { + name: 'Pet not found', + status: 'Not Found', + code: 404, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'deletePet', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: null, + body: null, + }, + endpoint: 'petstore.swagger.io/v2/pet/<>', + params: [], + headers: [ + { + key: 'api_key', + value: '', + active: true, + description: '', + }, + ], + method: 'DELETE', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + }, + }, + }, + }, + ], + data: { + auth: { + authType: 'inherit', + authActive: true, + }, + headers: [], + _ref_id: `coll_${generateRefId()}`, + }, + }, + ]; +}; diff --git a/packages/hoppscotch-backend/src/mock-server/mock-request.guard.ts b/packages/hoppscotch-backend/src/mock-server/mock-request.guard.ts new file mode 100644 index 0000000..525dc4e --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-request.guard.ts @@ -0,0 +1,276 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + BadRequestException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { Request } from 'express'; +import { MockServerService } from './mock-server.service'; +import * as E from 'fp-ts/Either'; +import { AccessTokenService } from 'src/access-token/access-token.service'; +import { TeamService } from 'src/team/team.service'; +import { WorkspaceType } from 'src/generated/prisma/client'; + +/** + * Guard to extract and validate mock server ID from either: + * 1. Subdomain pattern: mock-server-id.mock.hopp.io/product + * 2. Route pattern: backend.hopp.io/mock/mock-server-id/product + */ +@Injectable() +export class MockRequestGuard implements CanActivate { + constructor( + private readonly mockServerService: MockServerService, + private readonly accessTokenService: AccessTokenService, + private readonly teamService: TeamService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + // Extract mock server ID from either subdomain or route + const mockServerSubdomain = this.extractMockServerSubdomain(request); + + if (!mockServerSubdomain) { + throw new BadRequestException( + 'Invalid mock server request. Mock server ID not found in subdomain or path.', + ); + } + + // Validate mock server exists (including inactive ones) + const mockServerResult = + await this.mockServerService.getMockServerBySubdomain( + mockServerSubdomain, + true, // includeInactive = true + ); + + if (E.isLeft(mockServerResult)) { + console.warn( + `Mock server lookup failed for subdomain: ${String(mockServerSubdomain).replace(/\r|\n/g, '')}, error: ${mockServerResult.left}`, + ); + throw new NotFoundException( + `Mock server '${mockServerSubdomain}' not found`, + ); + } + + const mockServer = mockServerResult.right; + + // Check if mock server is active and throw proper error if not + if (!mockServer.isActive) { + throw new BadRequestException( + `Mock server '${mockServerSubdomain}' is currently inactive`, + ); + } + + if (!mockServer.isPublic) { + const apiKey = request.get('x-api-key'); + + if (!apiKey) { + throw new BadRequestException( + 'API key is required. Please provide x-api-key header.', + ); + } + + // Validate the Personal Access Token (PAT) + await this.validatePAT(apiKey, mockServer); + } + + // Attach mock server info to request for downstream use + (request as any).mockServer = mockServer; + (request as any).mockServerId = mockServer.id; + + return true; + } + + /** + * Extract mock server ID from request using either subdomain or route-based pattern + * + * Supports two patterns: + * 1. Subdomain: mock-server-id.mock.hopp.io/product → mock-server-id (from host) + * After Caddy rewrite: path becomes /mock/product + * 2. Route: backend.hopp.io/mock/mock-server-id/product → mock-server-id (from path) + * + * @param request Express request object + * @returns Mock server ID or null if not found + */ + private extractMockServerSubdomain(request: Request): string | null { + const host = request.get('host') || ''; + const path = request.path || '/'; + + // Try subdomain pattern first (Option 1) + // For subdomain-based requests, Caddy rewrites path to /mock/... + // but the mock server ID comes from the subdomain, not the path + const subdomainId = this.extractFromSubdomain(host); + if (subdomainId) { + (request as any).isSubdomainAccess = true; + return subdomainId; + } + + // Try route-based pattern (Option 2) + // Only use route extraction if there's no subdomain match + // Route pattern: /mock/mock-server-id/... + const routeId = this.extractFromRoute(path); + if (routeId) { + (request as any).isSubdomainAccess = false; + return routeId; + } + + return null; + } + + /** + * Extract mock server ID from subdomain pattern + * Supports: mock-server-id.mock.hopp.io or mock-server-id.mock.localhost + * + * @param host Host header value + * @returns Mock server ID or null + */ + private extractFromSubdomain(host: string): string | null { + // Remove port if present + const hostname = host.split(':')[0]; + + // Split by dots + const parts = hostname.split('.'); + + // Check if this is a mock subdomain pattern + // For: mock-server-id.mock.hopp.io → ['mock-server-id', 'mock', 'hopp', 'io'] + // For: mock-server-id.mock.localhost → ['mock-server-id', 'mock', 'localhost'] + + if (parts.length >= 3) { + // Check if second part is 'mock' + if (parts[1] === 'mock') { + const mockServerId = parts[0]; + + // Validate it's not empty and follows a reasonable pattern + if (mockServerId && mockServerId.length > 0) { + return mockServerId; + } + } + } + + // Also support: mock-server-id.localhost (for simpler local dev) + if (parts.length === 2 && parts[1] === 'localhost') { + const mockServerId = parts[0]; + if (mockServerId && mockServerId.length > 0) { + return mockServerId; + } + } + + return null; + } + + /** + * Extract mock server ID from route pattern + * Supports: /mock/mock-server-id/product → mock-server-id + * Note: Caddy prepends /mock to subdomain requests, so subdomain pattern + * mock-server-id.mock.hopp.io/product becomes /mock/product + * + * @param path Request path + * @returns Mock server ID or null + */ + private extractFromRoute(path: string): string | null { + // Pattern: /mock/mock-server-id/... + // We need to match: /mock/{id} or /mock/{id}/... + + const mockPathRegex = /^\/mock\/([^\/]+)/; + const match = path.match(mockPathRegex); + + if (match && match[1]) { + const mockServerId = match[1]; + + // Validate it's not empty and not the word 'mock' itself + if (mockServerId && mockServerId !== 'mock' && mockServerId.length > 0) { + return mockServerId; + } + } + + return null; + } + + /** + * Validate Personal Access Token (PAT) for private mock server access + * + * Rules: + * - If mock server is in USER workspace: PAT must belong to that user + * - If mock server is in TEAM workspace: PAT creator must be a member of that team + * + * @param apiKey The x-api-key header value (PAT) + * @param mockServer The mock server being accessed + * @throws UnauthorizedException if PAT is invalid or user lacks access + */ + private async validatePAT(apiKey: string, mockServer: any): Promise { + // Get the PAT and associated user + const patResult = await this.accessTokenService.getUserPAT(apiKey); + + if (E.isLeft(patResult)) { + throw new UnauthorizedException( + 'Invalid or expired API key. Please provide a valid Personal Access Token.', + ); + } + + const pat = patResult.right; + const userUid = pat.user.uid; + + // Check if PAT has expired + if (pat.expiresOn !== null && new Date() > pat.expiresOn) { + throw new UnauthorizedException( + 'API key has expired. Please generate a new Personal Access Token.', + ); + } + + // Validate based on workspace type + if (mockServer.workspaceType === WorkspaceType.USER) { + // For USER workspace: PAT must belong to the workspace owner + if (userUid !== mockServer.workspaceID) { + throw new UnauthorizedException( + 'Access denied. This Personal Access Token does not have permission to access this mock server.', + ); + } + } else if (mockServer.workspaceType === WorkspaceType.TEAM) { + // For TEAM workspace: PAT creator must be a member of the team + const teamMember = await this.teamService.getTeamMember( + mockServer.workspaceID, + userUid, + ); + + if (!teamMember) { + throw new UnauthorizedException( + 'Access denied. You must be a member of the team to access this mock server.', + ); + } + } else { + throw new BadRequestException('Invalid workspace type for mock server.'); + } + + // Update last used timestamp for the PAT + await this.accessTokenService.updateLastUsedForPAT(apiKey); + } + + /** + * Get the actual path without the /mock/mock-server-id prefix + * This is useful for route-based pattern to get the actual endpoint path + * + * @param fullPath Full request path + * @param mockServerId Mock server ID + * @returns Clean path for the mock endpoint + */ + static getCleanPath(fullPath: string, mockServerId: string): string { + // If route-based: /mock/mock-server-id/product → /product + const routePrefix = `/mock/${mockServerId}`; + if (fullPath.startsWith(routePrefix)) { + const cleanPath = fullPath.substring(routePrefix.length); + return cleanPath || '/'; + } + + // If subdomain-based: Caddy rewrites to /mock/product → /product + // Strip the /mock prefix added by Caddy + if (fullPath.startsWith('/mock/')) { + const cleanPath = fullPath.substring(5); // Remove '/mock' + return cleanPath || '/'; + } + + // Fallback: return as-is + return fullPath; + } +} diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server-analytics.service.ts b/packages/hoppscotch-backend/src/mock-server/mock-server-analytics.service.ts new file mode 100644 index 0000000..2b615ef --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-server-analytics.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { + MockServer as dbMockServer, + MockServerAction, +} from 'src/generated/prisma/client'; + +@Injectable() +export class MockServerAnalyticsService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Record mock server activity + * @param mockServer The mock server database object + * @param action The action being performed (CREATED, ACTIVATED, DEACTIVATED, DELETED, UPDATED) + * @param performedBy Optional userUid who performed the action + */ + async recordActivity( + mockServer: dbMockServer, + action: MockServerAction, + performedBy?: string, + ): Promise { + try { + // Skip if trying to activate an already active server + if (action === MockServerAction.ACTIVATED && mockServer.isActive) { + return; + } + + // Skip if trying to deactivate an already inactive server + if (action === MockServerAction.DEACTIVATED && !mockServer.isActive) { + return; + } + + await this.prisma.mockServerActivity.create({ + data: { + mockServerID: mockServer.id, + action: action, + performedBy: performedBy || null, + }, + }); + } catch (error) { + // Log error but don't throw - analytics shouldn't break main flow + console.error('Failed to record mock server activity:', error); + } + } +} diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server-logging.interceptor.ts b/packages/hoppscotch-backend/src/mock-server/mock-server-logging.interceptor.ts new file mode 100644 index 0000000..a5de085 --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-server-logging.interceptor.ts @@ -0,0 +1,169 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { Request, Response } from 'express'; +import { MockServer } from 'src/generated/prisma/client'; +import { MockServerService } from './mock-server.service'; + +@Injectable() +export class MockServerLoggingInterceptor implements NestInterceptor { + constructor(private readonly mockServerService: MockServerService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const httpContext = context.switchToHttp(); + const request = httpContext.getRequest(); + const response = httpContext.getResponse(); + + // Capture request start time + const startTime = Date.now(); + + // Extract mock server info (attached by MockRequestGuard) + const mockServer = (request as any).mockServer as MockServer; + const mockServerId = (request as any).mockServerId as string; + + // If no mock server info, skip logging + if (!mockServer || !mockServerId) { + return next.handle(); + } + + // Capture request details + const requestMethod = request.method; + const requestPath = request.path; + const requestHeaders = this.extractHeaders(request); + const requestBody = request.body || {}; + const requestQuery = this.extractQueryParams(request); + + if (!requestBody || typeof requestBody !== 'object') { + console.warn('Request body is not properly parsed'); + } + + // Extract client info + const ipAddress = + (request.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || + request.socket.remoteAddress || + undefined; + const userAgent = request.headers['user-agent'] as string | undefined; + + // Capture response - use finalize to ensure logging happens regardless of success/error + return next.handle().pipe( + tap({ + next: () => { + // Success case - log after response is sent + const responseTime = Date.now() - startTime; + const responseStatus = response.statusCode || 200; + const responseHeaders = this.extractResponseHeaders(response); + + // Log the request asynchronously (fire and forget) + this.mockServerService + .logRequest({ + mockServerID: mockServerId, + requestMethod, + requestPath, + requestHeaders, + requestBody, + requestQuery, + responseStatus, + responseHeaders, + responseTime, + ipAddress, + userAgent, + }) + .catch((err) => console.error('Failed to log mock request:', err)); + + // Increment hit count asynchronously (fire and forget) + this.mockServerService + .incrementHitCount(mockServerId) + .catch((err) => + console.error('Failed to increment hit count:', err), + ); + }, + error: (error) => { + // Error case - log the error but let it propagate to user + const responseTime = Date.now() - startTime; + const responseStatus = error.status || 500; + + // Log error response asynchronously + this.mockServerService + .logRequest({ + mockServerID: mockServerId, + requestMethod, + requestPath, + requestHeaders, + requestBody, + requestQuery, + responseStatus, + responseHeaders: {}, + responseTime, + ipAddress, + userAgent, + }) + .catch((err) => + console.error('Failed to log mock request error:', err), + ); + + // Still increment hit count even for errors + this.mockServerService + .incrementHitCount(mockServerId) + .catch((err) => + console.error('Failed to increment hit count:', err), + ); + + // Error will automatically propagate to user + // No need to re-throw, tap operator handles this + }, + }), + ); + } + + /** + * Extract request headers as a plain object + */ + private extractHeaders(request: Request): Record { + const headers: Record = {}; + Object.keys(request.headers).forEach((key) => { + const value = request.headers[key]; + if (typeof value === 'string') { + headers[key.toLowerCase()] = value; + } else if (Array.isArray(value)) { + headers[key.toLowerCase()] = value[0]; + } + }); + return headers; + } + + /** + * Extract query parameters as a plain object + */ + private extractQueryParams( + request: Request, + ): Record | undefined { + const queryParams = request.query as Record; + return Object.keys(queryParams).length > 0 ? queryParams : undefined; + } + + /** + * Extract response headers as a plain object + */ + private extractResponseHeaders(response: Response): Record { + const headers: Record = {}; + const headerNames = response.getHeaderNames(); + + headerNames.forEach((name) => { + const value = response.getHeader(name); + if (typeof value === 'string') { + headers[name.toLowerCase()] = value; + } else if (typeof value === 'number') { + headers[name.toLowerCase()] = value.toString(); + } else if (Array.isArray(value)) { + headers[name.toLowerCase()] = value.join(', '); + } + }); + + return headers; + } +} diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.controller.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.controller.ts new file mode 100644 index 0000000..8872bba --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.controller.ts @@ -0,0 +1,194 @@ +import { + Controller, + All, + Req, + Res, + HttpStatus, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { MockServerService } from './mock-server.service'; +import { MockServerLoggingInterceptor } from './mock-server-logging.interceptor'; +import * as E from 'fp-ts/Either'; +import { MockRequestGuard } from './mock-request.guard'; +import { MockServer } from 'src/generated/prisma/client'; +import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard'; + +/** Security-sensitive headers that mock responses must not override. */ +const SECURITY_HEADER_BLOCKLIST = new Set([ + 'content-security-policy', + 'x-content-type-options', + 'x-frame-options', + 'content-disposition', + 'set-cookie', +]); + +/** MIME types that can execute scripts and must be downgraded on same-origin (subpath) responses. */ +const ACTIVE_CONTENT_TYPES = new Set([ + 'application/javascript', + 'application/xhtml+xml', + 'application/xml', + 'image/svg+xml', + 'text/html', + 'text/javascript', + 'text/xml', + 'text/xsl', +]); + +/** + * Mock server controller with dual routing support: + * 1. Subdomain pattern: mock-server-id.mock.hopp.io/product + * 2. Route pattern: backend.hopp.io/mock/mock-server-id/product + * + * The MockRequestGuard handles extraction of mock server ID from both patterns + * The MockServerLoggingInterceptor handles logging of all requests + */ +@UseGuards(ThrottlerBehindProxyGuard) +@Controller({ path: 'mock' }) +export class MockServerController { + constructor(private readonly mockServerService: MockServerService) {} + + @All('*path') + @UseGuards(MockRequestGuard) + @UseInterceptors(MockServerLoggingInterceptor) + async handleMockRequest(@Req() req: Request, @Res() res: Response) { + // Mock server ID and info are attached by the guard + const mockServerId = (req as any).mockServerId as string; + const mockServer = (req as any).mockServer as MockServer; + const isSubdomainAccess = (req as any).isSubdomainAccess === true; + + if (!mockServerId) { + return res.status(HttpStatus.NOT_FOUND).json({ + error: 'Not found', + message: 'Mock server ID not found', + }); + } + + const method = req.method; + // Get clean path (removes /mock/mock-server-id prefix for route-based pattern) + const path = MockRequestGuard.getCleanPath( + req.path || '/', + mockServer.subdomain, + ); + + // Extract query parameters + const queryParams = req.query as Record; + + // Extract request headers (convert to lowercase for case-insensitive matching) + const requestHeaders: Record = {}; + Object.keys(req.headers).forEach((key) => { + const value = req.headers[key]; + if (typeof value === 'string') { + requestHeaders[key.toLowerCase()] = value; + } else if (Array.isArray(value)) { + requestHeaders[key.toLowerCase()] = value[0]; + } + }); + + try { + const result = await this.mockServerService.handleMockRequest( + mockServer, + path, + method, + queryParams, + requestHeaders, + ); + + if (E.isLeft(result)) { + return res.status(HttpStatus.NOT_FOUND).json({ + error: 'Endpoint not found', + message: result.left, + }); + } + + const mockResponse = result.right; + + // Set response headers if any, but exclude security-sensitive headers + // that could be abused for XSS + if (mockResponse.headers) { + try { + const headers = JSON.parse(mockResponse.headers); + if ( + headers !== null && + typeof headers === 'object' && + !Array.isArray(headers) + ) { + Object.keys(headers).forEach((key) => { + if (!SECURITY_HEADER_BLOCKLIST.has(key.toLowerCase())) { + const rawValue = headers[key]; + // Only allow string and number values to prevent type bypass attacks + if ( + typeof rawValue === 'string' || + typeof rawValue === 'number' + ) { + res.setHeader(key, String(rawValue)); + } + } + }); + } + } catch (error) { + console.error('Error parsing response headers:', error); + } + } + + // Add delay if specified + if (mockServer.delayInMs && mockServer.delayInMs > 0) { + await new Promise((resolve) => + setTimeout(resolve, mockServer.delayInMs), + ); + } + + // Prevent XSS on path-based (same-origin) requests by downgrading + // script-capable content types to text/plain. + // Subdomain-based requests are on a different origin, so HTML is safe. + if (!isSubdomainAccess) { + const rawContentType = res.getHeader('Content-Type'); + if (typeof rawContentType === 'string') { + // Normalize: trim, strip parameters (e.g. charset), lowercase + const mimeType = rawContentType.split(';')[0].trim().toLowerCase(); + if (ACTIVE_CONTENT_TYPES.has(mimeType) || mimeType.endsWith('+xml')) { + res.setHeader('Content-Type', 'text/plain'); + } + } + } + + // Only set Content-Type if not already set + if (!res.getHeader('Content-Type')) { + let defaultContentType = 'text/plain'; + + // Check if body is a string and try to parse it to determine content type + if (typeof mockResponse.body === 'string') { + try { + JSON.parse(mockResponse.body); + // If parsing succeeds, it's JSON + defaultContentType = 'application/json'; + } catch { + // If parsing fails, it's plain text + defaultContentType = 'text/plain'; + } + } else if (typeof mockResponse.body === 'object') { + // If it's already an object, it's JSON + defaultContentType = 'application/json'; + } + + res.setHeader('Content-Type', defaultContentType); + } + // Security headers to prevent XSS via mock responses + res.setHeader('X-Content-Type-Options', 'nosniff'); + if (!isSubdomainAccess) { + res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox"); + res.setHeader('X-Frame-Options', 'DENY'); + } + + // Send response + return res.status(mockResponse.statusCode).send(mockResponse.body); + } catch (error) { + console.error('Error handling mock request:', error); + return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + error: 'Internal server error', + message: 'Failed to process mock request', + }); + } + } +} diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.model.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.model.ts new file mode 100644 index 0000000..787c336 --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.model.ts @@ -0,0 +1,357 @@ +import { + Field, + ID, + ObjectType, + ArgsType, + InputType, + registerEnumType, +} from '@nestjs/graphql'; +import { + IsBoolean, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; +import { WorkspaceType } from 'src/types/WorkspaceTypes'; + +// Regex pattern for mock server name validation +// Allows letters, numbers, spaces, dots, brackets, underscores, and hyphens +const MOCK_SERVER_NAME_PATTERN = /^[a-zA-Z0-9 .()[\]{}<>_-]+$/; +const MOCK_SERVER_NAME_ERROR_MESSAGE = + 'Name can only contain letters, numbers, spaces, dots, brackets, underscores, and hyphens'; + +@ObjectType() +export class MockServer { + @Field(() => ID, { + description: 'ID of the mock server', + }) + id: string; + + @Field({ + description: 'Name of the mock server', + }) + name: string; + + @Field({ + description: 'Subdomain for the mock server (e.g., mock-1234)', + }) + subdomain: string; + + @Field({ + nullable: true, + description: + 'Server URL for the mock server using subdomain pattern (e.g., https://1234.mock.backend-hoppscotch.io)', + }) + serverUrlDomainBased: string; + + @Field({ + description: + 'Server URL for the mock server using path pattern (e.g., https://backend.hoppscotch.io/mock/1234)', + }) + serverUrlPathBased: string; + + @Field(() => WorkspaceType, { + description: 'Type of workspace: USER or TEAM', + }) + workspaceType: WorkspaceType; + + @Field({ + nullable: true, + description: + 'ID of the workspace (user or team) to associate with the mock server', + }) + workspaceID?: string; + + @Field({ + description: 'Delay in milliseconds before responding', + }) + delayInMs: number; + + @Field({ + description: 'Whether the mock server is active', + }) + isActive: boolean; + + @Field({ + description: 'Whether the mock server is publicly accessible', + }) + isPublic: boolean; + + @Field({ + description: 'Date and time when the mock server was created', + }) + createdOn: Date; + + @Field({ + description: 'Date and time when the mock server was last updated', + }) + updatedOn: Date; +} + +@ObjectType() +export class MockServerCollection { + @Field(() => ID, { + description: 'ID of the collection', + }) + id: string; + + @Field({ + description: 'Title of the collection', + }) + title: string; +} + +@InputType() +export class CreateMockServerInput { + @Field({ + description: 'Name of the mock server', + }) + @IsString() + @MinLength(1) + @MaxLength(255) + @Matches(MOCK_SERVER_NAME_PATTERN, { + message: MOCK_SERVER_NAME_ERROR_MESSAGE, + }) + name: string; + + @IsString() + @IsOptional() + @Field({ + nullable: true, + description: + 'ID of the (team or user) collection to associate with the mock server', + }) + collectionID?: string; + + @IsBoolean() + @IsOptional() + @Field({ + nullable: true, + description: + 'Whether to auto-create a collection for the mock server if collectionID is not provided', + }) + autoCreateCollection?: boolean; + + @IsBoolean() + @IsOptional() + @Field({ + nullable: true, + description: + 'Whether to auto-create request examples in the collection for the mock server', + }) + autoCreateRequestExample?: boolean; + + @IsEnum(WorkspaceType) + @Field(() => WorkspaceType, { + description: 'Type of workspace: USER or TEAM', + }) + workspaceType: WorkspaceType; + + @IsOptional() + @IsString() + @Field({ + nullable: true, + description: + 'ID of the workspace (user or team) to associate with the mock server', + }) + workspaceID?: string; + + @IsNumber() + @IsOptional() + @Min(0) + @Max(60000) + @Field({ + nullable: true, + defaultValue: 0, + description: 'Delay in milliseconds before responding', + }) + delayInMs?: number; + + @IsBoolean() + @IsOptional() + @Field({ + nullable: true, + description: + 'Whether the mock server is publicly accessible (defaults to false/private if omitted)', + }) + isPublic?: boolean; +} + +@InputType() +export class UpdateMockServerInput { + @Field({ + nullable: true, + description: 'Name of the mock server', + }) + @IsString() + @IsOptional() + @MinLength(1) + @MaxLength(255) + @Matches(MOCK_SERVER_NAME_PATTERN, { + message: MOCK_SERVER_NAME_ERROR_MESSAGE, + }) + name?: string; + + @Field({ + nullable: true, + description: 'Delay in milliseconds before responding', + }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(60000) + delayInMs?: number; + + @Field({ + nullable: true, + description: 'Whether the mock server is active', + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @Field({ + nullable: true, + description: 'Whether the mock server is publicly accessible', + }) + @IsOptional() + @IsBoolean() + isPublic?: boolean; +} + +@ObjectType() +export class MockServerResponse { + @Field({ + description: 'HTTP status code to return', + }) + statusCode: number; + + @Field({ + nullable: true, + description: 'Response body to return', + }) + body?: string; + + @Field({ + nullable: true, + description: 'Response headers as JSON string', + }) + headers?: string; + + @Field({ + defaultValue: 0, + description: 'Delay in milliseconds before response', + }) + delay: number; +} + +@ArgsType() +export class MockServerMutationArgs { + @Field(() => ID, { + description: 'ID of the mock server', + }) + @IsString() + @IsNotEmpty() + id: string; +} + +@ArgsType() +export class FetchTeamMockServersArgs extends OffsetPaginationArgs { + @Field(() => ID, { + name: 'teamID', + description: 'Id of the team to add to', + }) + @IsString() + @IsNotEmpty() + teamID: string; +} + +@ObjectType() +export class MockServerLog { + @Field(() => ID, { + description: 'ID of the log entry', + }) + id: string; + + @Field(() => ID, { + description: 'ID of the mock server', + }) + mockServerID: string; + + @Field({ + description: 'HTTP method of the request', + }) + requestMethod: string; + + @Field({ + description: 'Path of the request', + }) + requestPath: string; + + @Field({ + description: 'Request headers as JSON string', + }) + requestHeaders: string; + + @Field({ + nullable: true, + description: 'Request body as JSON string', + }) + requestBody?: string; + + @Field({ + nullable: true, + description: 'Request query parameters as JSON string', + }) + requestQuery?: string; + + @Field({ + description: 'HTTP status code of the response', + }) + responseStatus: number; + + @Field({ + description: 'Response headers as JSON string', + }) + responseHeaders: string; + + @Field({ + nullable: true, + description: 'Response body as JSON string', + }) + responseBody?: string; + + @Field({ + description: 'Response time in milliseconds', + }) + responseTime: number; + + @Field({ + nullable: true, + description: 'IP address of the requester', + }) + ipAddress?: string; + + @Field({ + nullable: true, + description: 'User agent of the requester', + }) + userAgent?: string; + + @Field({ + description: 'Date and time when the request was executed', + }) + executedAt: Date; +} + +registerEnumType(WorkspaceType, { + name: 'WorkspaceType', +}); diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.module.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.module.ts new file mode 100644 index 0000000..fa100ff --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from 'src/prisma/prisma.module'; +import { MockServerService } from './mock-server.service'; +import { MockServerAnalyticsService } from './mock-server-analytics.service'; +import { MockServerLoggingInterceptor } from './mock-server-logging.interceptor'; +import { MockServerResolver } from './mock-server.resolver'; +import { TeamModule } from 'src/team/team.module'; +import { TeamRequestModule } from 'src/team-request/team-request.module'; +import { MockServerController } from './mock-server.controller'; +import { AccessTokenModule } from 'src/access-token/access-token.module'; +import { TeamCollectionModule } from 'src/team-collection/team-collection.module'; +import { UserCollectionModule } from 'src/user-collection/user-collection.module'; + +@Module({ + imports: [ + PrismaModule, + UserCollectionModule, + TeamModule, + TeamCollectionModule, + TeamRequestModule, + AccessTokenModule, + ], + controllers: [MockServerController], + providers: [ + MockServerService, + MockServerAnalyticsService, + MockServerLoggingInterceptor, + MockServerResolver, + ], +}) +export class MockServerModule {} diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.resolver.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.resolver.ts new file mode 100644 index 0000000..cba6f30 --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.resolver.ts @@ -0,0 +1,226 @@ +import { + Resolver, + Query, + Mutation, + Args, + ID, + ResolveField, + Parent, +} from '@nestjs/graphql'; +import { UseGuards } from '@nestjs/common'; +import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; +import { GqlUser } from 'src/decorators/gql-user.decorator'; +import { User } from 'src/user/user.model'; +import { MockServerService } from './mock-server.service'; +import { + MockServer, + CreateMockServerInput, + UpdateMockServerInput, + MockServerMutationArgs, + MockServerCollection, + MockServerLog, + FetchTeamMockServersArgs, +} from './mock-server.model'; +import * as E from 'fp-ts/Either'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; +import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard'; +import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator'; +import { TeamAccessRole } from 'src/team/team.model'; +import { throwErr } from 'src/utils'; +import { AuthUser } from 'src/types/AuthUser'; +import { INVALID_PARAMS } from 'src/errors'; + +@Resolver(() => MockServer) +export class MockServerResolver { + constructor(private readonly mockServerService: MockServerService) {} + + // Resolve Fields + + @ResolveField(() => User, { + nullable: true, + description: 'Returns the creator of the mock server', + }) + async creator(@Parent() mockServer: MockServer): Promise { + const creator = await this.mockServerService.getMockServerCreator( + mockServer.id, + ); + + if (E.isLeft(creator)) throwErr(creator.left); + return { + ...creator.right, + currentGQLSession: null, + currentRESTSession: null, + }; + } + + @ResolveField(() => MockServerCollection, { + nullable: true, + description: 'Returns the collection of the mock server', + }) + async collection( + @Parent() mockServer: MockServer, + ): Promise { + const collection = await this.mockServerService.getMockServerCollection( + mockServer.id, + ); + + if (E.isLeft(collection)) throwErr(collection.left); + return collection.right; + } + + // Queries + + @Query(() => [MockServer], { + description: 'Get all mock servers for the authenticated user', + }) + @UseGuards(GqlAuthGuard) + async myMockServers( + @GqlUser() user: AuthUser, + @Args() args: OffsetPaginationArgs, + ): Promise { + return this.mockServerService.getUserMockServers(user.uid, args); + } + + @Query(() => [MockServer], { + description: 'Get all mock servers for a specific team', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.VIEWER, + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + ) + async teamMockServers( + @Args() args: FetchTeamMockServersArgs, + ): Promise { + return this.mockServerService.getTeamMockServers(args.teamID, { + skip: args.skip, + take: args.take, + }); + } + + @Query(() => MockServer, { + description: 'Get a specific mock server by ID', + }) + @UseGuards(GqlAuthGuard) + async mockServer( + @GqlUser() user: AuthUser, + @Args({ + name: 'id', + type: () => ID, + description: 'Id of the mock server to retrieve', + }) + id: string, + ): Promise { + const result = await this.mockServerService.getMockServer(id, user.uid); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } + + @Query(() => [MockServerLog], { + description: + 'Get logs for a specific mock server with pagination, sorted by execution time (most recent first)', + }) + @UseGuards(GqlAuthGuard) + async mockServerLogs( + @GqlUser() user: AuthUser, + @Args({ + name: 'mockServerID', + type: () => ID, + description: 'ID of the mock server', + }) + mockServerID: string, + @Args() args: OffsetPaginationArgs, + ): Promise { + const result = await this.mockServerService.getMockServerLogs( + mockServerID, + user.uid, + args, + ); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } + + // Mutations + + @Mutation(() => MockServer, { + description: 'Create a new mock server', + }) + @UseGuards(GqlAuthGuard) + async createMockServer( + @Args('input') input: CreateMockServerInput, + @GqlUser() user: AuthUser, + ): Promise { + if ( + (input.collectionID && input.autoCreateCollection) || + (!input.collectionID && !input.autoCreateCollection) + ) { + throwErr(INVALID_PARAMS); + } + + const result = await this.mockServerService.createMockServer(user, input); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } + + @Mutation(() => MockServer, { + description: 'Update a mock server', + }) + @UseGuards(GqlAuthGuard) + async updateMockServer( + @GqlUser() user: AuthUser, + @Args() args: MockServerMutationArgs, + @Args('input') input: UpdateMockServerInput, + ): Promise { + const result = await this.mockServerService.updateMockServer( + args.id, + user.uid, + input, + ); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } + + @Mutation(() => Boolean, { + description: 'Delete a mock server', + }) + @UseGuards(GqlAuthGuard) + async deleteMockServer( + @GqlUser() user: AuthUser, + @Args() args: MockServerMutationArgs, + ): Promise { + const result = await this.mockServerService.deleteMockServer( + args.id, + user.uid, + ); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } + + @Mutation(() => Boolean, { + description: 'Delete a mock server log by log ID', + }) + @UseGuards(GqlAuthGuard) + async deleteMockServerLog( + @GqlUser() user: AuthUser, + @Args({ + name: 'logID', + type: () => ID, + description: 'Id of the log to delete', + }) + logID: string, + ): Promise { + const result = await this.mockServerService.deleteMockServerLog( + logID, + user.uid, + ); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } +} diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.service.spec.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.service.spec.ts new file mode 100644 index 0000000..4f6ba1a --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.service.spec.ts @@ -0,0 +1,2073 @@ +import { MockServerService } from './mock-server.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { ConfigService } from '@nestjs/config'; +import { MockServerAnalyticsService } from './mock-server-analytics.service'; +import { TeamCollectionService } from '../team-collection/team-collection.service'; +import { UserCollectionService } from '../user-collection/user-collection.service'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import * as E from 'fp-ts/Either'; +import { + MOCK_SERVER_NOT_FOUND, + MOCK_SERVER_INVALID_COLLECTION, + TEAM_INVALID_ID, + MOCK_SERVER_LOG_NOT_FOUND, +} from '../errors'; +import { + MockServer as dbMockServer, + TeamAccessRole, + MockServerAction, + UserCollection, + TeamCollection, + UserRequest, + User, +} from 'src/generated/prisma/client'; +import { WorkspaceType } from '../types/WorkspaceTypes'; +import { + CreateMockServerInput, + UpdateMockServerInput, +} from './mock-server.model'; + +const mockPrisma = mockDeep(); +const mockAnalyticsService = mockDeep(); +const mockConfigService = mockDeep(); +const mockTeamCollectionService = mockDeep(); +const mockUserCollectionService = mockDeep(); + +const mockServerService = new MockServerService( + mockConfigService, + mockPrisma, + mockAnalyticsService, + mockTeamCollectionService, + mockUserCollectionService, +); + +beforeEach(() => { + mockReset(mockPrisma); + mockReset(mockAnalyticsService); + mockReset(mockConfigService); + mockReset(mockTeamCollectionService); + mockReset(mockUserCollectionService); + + // Default config values + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'VITE_BACKEND_API_URL') return 'http://localhost:3170/v1'; + if (key === 'INFRA.MOCK_SERVER_WILDCARD_DOMAIN') return '*.mock.hopp.io'; + if (key === 'INFRA.ALLOW_SECURE_COOKIES') return 'false'; + return undefined; + }); +}); + +const currentTime = new Date(); + +const user: User = { + uid: 'user123', + displayName: 'Test User', + email: 'test@example.com', + photoURL: null, + isAdmin: false, + refreshToken: null, + currentGQLSession: '{}', + currentRESTSession: '{}', + createdOn: currentTime, + lastLoggedOn: currentTime, + lastActiveOn: currentTime, +}; + +const dbMockServer: dbMockServer = { + id: 'mock123', + name: 'Test Mock Server', + subdomain: 'test-subdomain', + creatorUid: user.uid, + collectionID: 'coll123', + workspaceType: WorkspaceType.USER, + workspaceID: user.uid, + delayInMs: 0, + isPublic: true, + isActive: true, + hitCount: 0, + lastHitAt: null, + createdOn: currentTime, + updatedOn: currentTime, + deletedAt: null, +}; + +const userCollection: UserCollection = { + id: 'coll123', + title: 'Test Collection', + userUid: user.uid, + parentID: null, + orderIndex: 1, + type: 'REST', + data: {}, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const teamCollection: TeamCollection = { + id: 'team-coll123', + title: 'Team Collection', + teamID: 'team123', + parentID: null, + orderIndex: 1, + data: {}, + createdOn: currentTime, + updatedOn: currentTime, +}; + +describe('MockServerService', () => { + describe('getUserMockServers', () => { + test('should return user mock servers with pagination', async () => { + mockPrisma.mockServer.findMany.mockResolvedValue([dbMockServer]); + + const result = await mockServerService.getUserMockServers(user.uid, { + take: 10, + skip: 0, + }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(dbMockServer.id); + expect(result[0].name).toBe(dbMockServer.name); + expect(mockPrisma.mockServer.findMany).toHaveBeenCalledWith({ + where: { + workspaceType: WorkspaceType.USER, + creatorUid: user.uid, + deletedAt: null, + }, + orderBy: { createdOn: 'desc' }, + take: 10, + skip: 0, + }); + }); + + test('should return empty array when no mock servers exist', async () => { + mockPrisma.mockServer.findMany.mockResolvedValue([]); + + const result = await mockServerService.getUserMockServers(user.uid, { + take: 10, + skip: 0, + }); + + expect(result).toHaveLength(0); + }); + }); + + describe('cast - server URL generation', () => { + test('should not append /backend to serverUrlDomainBased when ENABLE_SUBPATH_BASED_ACCESS is not set', async () => { + mockPrisma.mockServer.findMany.mockResolvedValue([dbMockServer]); + + const result = await mockServerService.getUserMockServers(user.uid, { + take: 10, + skip: 0, + }); + + expect(result[0].serverUrlDomainBased).toBe( + 'http://test-subdomain.mock.hopp.io', + ); + }); + + test('should not append /backend to serverUrlDomainBased when ENABLE_SUBPATH_BASED_ACCESS is "false"', async () => { + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'VITE_BACKEND_API_URL') return 'http://localhost:3170/v1'; + if (key === 'INFRA.MOCK_SERVER_WILDCARD_DOMAIN') + return '*.mock.hopp.io'; + if (key === 'INFRA.ALLOW_SECURE_COOKIES') return 'false'; + if (key === 'ENABLE_SUBPATH_BASED_ACCESS') return 'false'; + return undefined; + }); + mockPrisma.mockServer.findMany.mockResolvedValue([dbMockServer]); + + const result = await mockServerService.getUserMockServers(user.uid, { + take: 10, + skip: 0, + }); + + expect(result[0].serverUrlDomainBased).toBe( + 'http://test-subdomain.mock.hopp.io', + ); + }); + + test('should append /backend to serverUrlDomainBased when ENABLE_SUBPATH_BASED_ACCESS is "true"', async () => { + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'VITE_BACKEND_API_URL') return 'http://localhost:3170/v1'; + if (key === 'INFRA.MOCK_SERVER_WILDCARD_DOMAIN') + return '*.mock.hopp.io'; + if (key === 'INFRA.ALLOW_SECURE_COOKIES') return 'false'; + if (key === 'ENABLE_SUBPATH_BASED_ACCESS') return 'true'; + return undefined; + }); + mockPrisma.mockServer.findMany.mockResolvedValue([dbMockServer]); + + const result = await mockServerService.getUserMockServers(user.uid, { + take: 10, + skip: 0, + }); + + expect(result[0].serverUrlDomainBased).toBe( + 'http://test-subdomain.mock.hopp.io/backend', + ); + }); + + test('should use https protocol and append /backend when secure cookies and subpath access are enabled', async () => { + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'VITE_BACKEND_API_URL') return 'https://localhost:3170/v1'; + if (key === 'INFRA.MOCK_SERVER_WILDCARD_DOMAIN') + return '*.mock.hopp.io'; + if (key === 'INFRA.ALLOW_SECURE_COOKIES') return 'true'; + if (key === 'ENABLE_SUBPATH_BASED_ACCESS') return 'true'; + return undefined; + }); + mockPrisma.mockServer.findMany.mockResolvedValue([dbMockServer]); + + const result = await mockServerService.getUserMockServers(user.uid, { + take: 10, + skip: 0, + }); + + expect(result[0].serverUrlDomainBased).toBe( + 'https://test-subdomain.mock.hopp.io/backend', + ); + }); + + test('should leave serverUrlDomainBased null and not append /backend when wildcard domain is not configured', async () => { + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'VITE_BACKEND_API_URL') return 'http://localhost:3170/v1'; + if (key === 'INFRA.MOCK_SERVER_WILDCARD_DOMAIN') return undefined; + if (key === 'INFRA.ALLOW_SECURE_COOKIES') return 'false'; + if (key === 'ENABLE_SUBPATH_BASED_ACCESS') return 'true'; + return undefined; + }); + mockPrisma.mockServer.findMany.mockResolvedValue([dbMockServer]); + + const result = await mockServerService.getUserMockServers(user.uid, { + take: 10, + skip: 0, + }); + + expect(result[0].serverUrlDomainBased).toBeNull(); + }); + + test('should strip trailing slashes from wildcard domain before appending subpath suffix', async () => { + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'VITE_BACKEND_API_URL') return 'http://localhost:3170/v1'; + if (key === 'INFRA.MOCK_SERVER_WILDCARD_DOMAIN') + return '*.mock.hopp.io/'; // Domain with trailing slash + if (key === 'INFRA.ALLOW_SECURE_COOKIES') return 'false'; + if (key === 'ENABLE_SUBPATH_BASED_ACCESS') return 'true'; + return undefined; + }); + mockPrisma.mockServer.findMany.mockResolvedValue([dbMockServer]); + + const result = await mockServerService.getUserMockServers(user.uid, { + take: 10, + skip: 0, + }); + + expect(result[0].serverUrlDomainBased).toBe( + 'http://test-subdomain.mock.hopp.io/backend', + ); + }); + }); + + describe('getTeamMockServers', () => { + test('should return team mock servers with pagination', async () => { + const teamMockServer = { + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + }; + mockPrisma.mockServer.findMany.mockResolvedValue([teamMockServer]); + + const result = await mockServerService.getTeamMockServers('team123', { + take: 10, + skip: 0, + }); + + expect(result).toHaveLength(1); + expect(result[0].workspaceType).toBe(WorkspaceType.TEAM); + expect(mockPrisma.mockServer.findMany).toHaveBeenCalledWith({ + where: { + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + deletedAt: null, + }, + orderBy: { createdOn: 'desc' }, + take: 10, + skip: 0, + }); + }); + }); + + describe('getMockServer', () => { + test('should return mock server when user has access', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + + const result = await mockServerService.getMockServer( + dbMockServer.id, + user.uid, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).id).toBe(dbMockServer.id); + expect((result.right as any).name).toBe(dbMockServer.name); + } + }); + + test('should return error when mock server not found', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(null); + + const result = await mockServerService.getMockServer( + 'invalid-id', + user.uid, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + + test('should return error when user does not have access', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + + const result = await mockServerService.getMockServer( + dbMockServer.id, + 'different-user', + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + + test('should allow team member access to team mock server', async () => { + const teamMockServer = { + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + }; + mockPrisma.mockServer.findFirst.mockResolvedValue(teamMockServer); + mockPrisma.team.findFirst.mockResolvedValue({ + id: 'team123', + name: 'Test Team', + } as any); + + const result = await mockServerService.getMockServer( + teamMockServer.id, + user.uid, + ); + + expect(E.isRight(result)).toBe(true); + }); + }); + + describe('getMockServerBySubdomain', () => { + test('should return active mock server by subdomain', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + + const result = await mockServerService.getMockServerBySubdomain( + dbMockServer.subdomain, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).id).toBe(dbMockServer.id); + } + expect(mockPrisma.mockServer.findFirst).toHaveBeenCalledWith({ + where: { + subdomain: { equals: dbMockServer.subdomain, mode: 'insensitive' }, + isActive: true, + deletedAt: null, + }, + }); + }); + + test('should return error when mock server not found', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(null); + + const result = + await mockServerService.getMockServerBySubdomain('non-existent'); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + }); + + describe('getMockServerCreator', () => { + test('should return mock server creator', async () => { + mockPrisma.mockServer.findUnique.mockResolvedValue({ + ...dbMockServer, + user: user as any, + } as any); + + const result = await mockServerService.getMockServerCreator( + dbMockServer.id, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).uid).toBe(user.uid); + } + }); + + test('should return error when mock server not found', async () => { + mockPrisma.mockServer.findUnique.mockResolvedValue(null); + + const result = await mockServerService.getMockServerCreator('invalid-id'); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + }); + + describe('getMockServerCollection', () => { + test('should return user collection for user workspace', async () => { + mockPrisma.mockServer.findUnique.mockResolvedValue(dbMockServer); + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + + const result = await mockServerService.getMockServerCollection( + dbMockServer.id, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).id).toBe(userCollection.id); + expect((result.right as any).title).toBe(userCollection.title); + } + }); + + test('should return team collection for team workspace', async () => { + const teamMockServer = { + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + collectionID: teamCollection.id, + }; + mockPrisma.mockServer.findUnique.mockResolvedValue(teamMockServer); + mockPrisma.teamCollection.findUnique.mockResolvedValue(teamCollection); + + const result = await mockServerService.getMockServerCollection( + teamMockServer.id, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).id).toBe(teamCollection.id); + expect((result.right as any).title).toBe(teamCollection.title); + } + }); + + test('should return null when collection not found', async () => { + mockPrisma.mockServer.findUnique.mockResolvedValue(dbMockServer); + mockPrisma.userCollection.findUnique.mockResolvedValue(null); + + const result = await mockServerService.getMockServerCollection( + dbMockServer.id, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toBe(null); + } + }); + }); + + describe('createMockServer', () => { + const createInput: CreateMockServerInput = { + name: 'New Mock Server', + collectionID: userCollection.id, + workspaceType: WorkspaceType.USER, + workspaceID: undefined, + delayInMs: 0, + }; + + test('should create user mock server successfully', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.mockServer.findUnique.mockResolvedValue(null); + mockPrisma.mockServer.create.mockResolvedValue(dbMockServer); + + const result = await mockServerService.createMockServer( + user, + createInput, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).name).toBe(dbMockServer.name); + } + expect(mockAnalyticsService.recordActivity).toHaveBeenCalledWith( + dbMockServer, + MockServerAction.CREATED, + user.uid, + ); + }); + + // Regression: GHSA-c68f-wr5p-j6jf — isPublic from input must be persisted, + // and must default to private (false) when omitted. + test('should persist isPublic: false when input requests a private server', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.mockServer.findUnique.mockResolvedValue(null); + mockPrisma.mockServer.create.mockResolvedValue({ + ...dbMockServer, + isPublic: false, + }); + + const result = await mockServerService.createMockServer(user, { + ...createInput, + isPublic: false, + }); + + expect(mockPrisma.mockServer.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ isPublic: false }), + }), + ); + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).isPublic).toBe(false); + } + }); + + test('should persist isPublic: true when input requests a public server', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.mockServer.findUnique.mockResolvedValue(null); + mockPrisma.mockServer.create.mockResolvedValue({ + ...dbMockServer, + isPublic: true, + }); + + const result = await mockServerService.createMockServer(user, { + ...createInput, + isPublic: true, + }); + + expect(mockPrisma.mockServer.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ isPublic: true }), + }), + ); + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).isPublic).toBe(true); + } + }); + + test('should default to private (isPublic: false) when input omits isPublic', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.mockServer.findUnique.mockResolvedValue(null); + mockPrisma.mockServer.create.mockResolvedValue({ + ...dbMockServer, + isPublic: false, + }); + + await mockServerService.createMockServer(user, createInput); + + expect(mockPrisma.mockServer.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ isPublic: false }), + }), + ); + }); + + test('should create team mock server successfully', async () => { + const teamInput: CreateMockServerInput = { + name: 'Team Mock Server', + collectionID: teamCollection.id, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + delayInMs: 0, + }; + const teamMockServer = { + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + }; + + mockPrisma.team.findFirst.mockResolvedValue({ id: 'team123' } as any); + mockPrisma.teamCollection.findUnique.mockResolvedValue(teamCollection); + mockPrisma.mockServer.findUnique.mockResolvedValue(null); + mockPrisma.mockServer.create.mockResolvedValue(teamMockServer); + + const result = await mockServerService.createMockServer(user, teamInput); + + expect(E.isRight(result)).toBe(true); + }); + + test('should return error when collection not found', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValue(null); + + const result = await mockServerService.createMockServer( + user, + createInput, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_INVALID_COLLECTION); + } + }); + + test('should return error when team is invalid', async () => { + const teamInput: CreateMockServerInput = { + name: 'Team Mock Server', + collectionID: teamCollection.id, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'invalid-team', + delayInMs: 0, + }; + + mockPrisma.team.findFirst.mockResolvedValue(null); + + const result = await mockServerService.createMockServer(user, teamInput); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(TEAM_INVALID_ID); + } + }); + + test('should retry subdomain generation on conflict', async () => { + const PrismaError = { UNIQUE_CONSTRAINT_VIOLATION: 'P2002' }; + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.mockServer.create + .mockRejectedValueOnce({ + code: PrismaError.UNIQUE_CONSTRAINT_VIOLATION, + }) // First attempt conflicts + .mockResolvedValueOnce(dbMockServer); // Second attempt succeeds + + const result = await mockServerService.createMockServer( + user, + createInput, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockPrisma.mockServer.create).toHaveBeenCalledTimes(2); + }); + + test('should return creation failed error on non-constraint errors', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.mockServer.create.mockRejectedValue( + new Error('Database error'), + ); + + const result = await mockServerService.createMockServer( + user, + createInput, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe('mock_server/creation_failed'); + } + }); + + describe('auto-create collection', () => { + test('should auto-create user collection without request example', async () => { + const autoCreateInput: CreateMockServerInput = { + name: 'Auto Mock Server', + workspaceType: WorkspaceType.USER, + workspaceID: undefined, + delayInMs: 0, + autoCreateCollection: true, + autoCreateRequestExample: false, + }; + + const createdCollection = { ...userCollection, id: 'new-coll-123' }; + mockUserCollectionService.createUserCollection.mockResolvedValue( + E.right(createdCollection as any), + ); + mockPrisma.mockServer.create.mockResolvedValue({ + ...dbMockServer, + collectionID: 'new-coll-123', + }); + + const result = await mockServerService.createMockServer( + user, + autoCreateInput, + ); + + expect(E.isRight(result)).toBe(true); + expect( + mockUserCollectionService.createUserCollection, + ).toHaveBeenCalledWith(user, autoCreateInput.name, null, null, 'REST'); + expect(mockPrisma.mockServer.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + collectionID: 'new-coll-123', + }), + }), + ); + }); + + test('should auto-create user collection with request example', async () => { + const autoCreateInput: CreateMockServerInput = { + name: 'Auto Mock Server', + workspaceType: WorkspaceType.USER, + workspaceID: undefined, + delayInMs: 0, + autoCreateCollection: true, + autoCreateRequestExample: true, + }; + + mockUserCollectionService.importCollectionsFromJSON.mockResolvedValue( + E.right({ + exportedCollection: JSON.stringify([{ id: 'imported-coll-123' }]), + } as any), + ); + mockPrisma.mockServer.create.mockResolvedValue({ + ...dbMockServer, + collectionID: 'imported-coll-123', + }); + + const result = await mockServerService.createMockServer( + user, + autoCreateInput, + ); + + expect(E.isRight(result)).toBe(true); + expect( + mockUserCollectionService.importCollectionsFromJSON, + ).toHaveBeenCalled(); + expect(mockPrisma.mockServer.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + collectionID: 'imported-coll-123', + }), + }), + ); + }); + + test('should auto-create team collection without request example', async () => { + const autoCreateInput: CreateMockServerInput = { + name: 'Team Auto Mock', + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + delayInMs: 0, + autoCreateCollection: true, + autoCreateRequestExample: false, + }; + + const createdTeamColl = { ...teamCollection, id: 'new-team-coll-123' }; + mockPrisma.team.findFirst.mockResolvedValue({ id: 'team123' } as any); + mockTeamCollectionService.createCollection.mockResolvedValue( + E.right(createdTeamColl as any), + ); + mockPrisma.mockServer.create.mockResolvedValue({ + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + collectionID: 'new-team-coll-123', + }); + + const result = await mockServerService.createMockServer( + user, + autoCreateInput, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockTeamCollectionService.createCollection).toHaveBeenCalledWith( + 'team123', + autoCreateInput.name, + null, + null, + ); + expect(mockPrisma.mockServer.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + collectionID: 'new-team-coll-123', + }), + }), + ); + }); + + test('should auto-create team collection with request example', async () => { + const autoCreateInput: CreateMockServerInput = { + name: 'Team Auto Mock', + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + delayInMs: 0, + autoCreateCollection: true, + autoCreateRequestExample: true, + }; + + mockPrisma.team.findFirst.mockResolvedValue({ id: 'team123' } as any); + mockTeamCollectionService.importCollectionsFromJSON.mockResolvedValue( + E.right([{ id: 'imported-team-coll-123' }] as any), + ); + mockPrisma.mockServer.create.mockResolvedValue({ + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + collectionID: 'imported-team-coll-123', + }); + + const result = await mockServerService.createMockServer( + user, + autoCreateInput, + ); + + expect(E.isRight(result)).toBe(true); + expect( + mockTeamCollectionService.importCollectionsFromJSON, + ).toHaveBeenCalled(); + expect(mockPrisma.mockServer.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + collectionID: 'imported-team-coll-123', + }), + }), + ); + }); + + test('should return error when auto-create user collection fails', async () => { + const autoCreateInput: CreateMockServerInput = { + name: 'Auto Mock Server', + workspaceType: WorkspaceType.USER, + workspaceID: undefined, + delayInMs: 0, + autoCreateCollection: true, + autoCreateRequestExample: false, + }; + + mockUserCollectionService.createUserCollection.mockResolvedValue( + E.left('user_collection/creation_failed'), + ); + + const result = await mockServerService.createMockServer( + user, + autoCreateInput, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe('user_collection/creation_failed'); + } + }); + + test('should return error when auto-create team collection fails', async () => { + const autoCreateInput: CreateMockServerInput = { + name: 'Team Auto Mock', + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + delayInMs: 0, + autoCreateCollection: true, + autoCreateRequestExample: false, + }; + + mockPrisma.team.findFirst.mockResolvedValue({ id: 'team123' } as any); + mockTeamCollectionService.createCollection.mockResolvedValue( + E.left('team_coll/short_title'), + ); + + const result = await mockServerService.createMockServer( + user, + autoCreateInput, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe('team_coll/short_title'); + } + }); + + test('should rollback collection on mock server creation failure', async () => { + const autoCreateInput: CreateMockServerInput = { + name: 'Auto Mock Server', + workspaceType: WorkspaceType.USER, + workspaceID: undefined, + delayInMs: 0, + autoCreateCollection: true, + autoCreateRequestExample: false, + }; + + const createdCollection = { + ...userCollection, + id: 'rollback-coll-123', + }; + mockUserCollectionService.createUserCollection.mockResolvedValue( + E.right(createdCollection as any), + ); + mockPrisma.mockServer.create.mockRejectedValue( + new Error('Database error'), + ); + mockUserCollectionService.deleteUserCollection.mockResolvedValue( + E.right(true), + ); + + const result = await mockServerService.createMockServer( + user, + autoCreateInput, + ); + + expect(E.isLeft(result)).toBe(true); + expect( + mockUserCollectionService.deleteUserCollection, + ).toHaveBeenCalledWith('rollback-coll-123', user.uid); + }); + + test('should rollback team collection on mock server creation failure', async () => { + const autoCreateInput: CreateMockServerInput = { + name: 'Team Auto Mock', + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + delayInMs: 0, + autoCreateCollection: true, + autoCreateRequestExample: false, + }; + + const createdTeamColl = { + ...teamCollection, + id: 'rollback-team-coll-123', + }; + mockPrisma.team.findFirst.mockResolvedValue({ id: 'team123' } as any); + mockTeamCollectionService.createCollection.mockResolvedValue( + E.right(createdTeamColl as any), + ); + mockPrisma.mockServer.create.mockRejectedValue( + new Error('Database error'), + ); + mockTeamCollectionService.deleteCollection.mockResolvedValue( + E.right(true), + ); + + const result = await mockServerService.createMockServer( + user, + autoCreateInput, + ); + + expect(E.isLeft(result)).toBe(true); + expect(mockTeamCollectionService.deleteCollection).toHaveBeenCalledWith( + 'rollback-team-coll-123', + ); + }); + }); + }); + + describe('updateMockServer', () => { + const updateInput: UpdateMockServerInput = { + name: 'Updated Name', + isActive: false, + delayInMs: 100, + }; + + test('should update mock server successfully', async () => { + const updatedMockServer = { ...dbMockServer, ...updateInput }; + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + mockPrisma.mockServer.update.mockResolvedValue(updatedMockServer); + + const result = await mockServerService.updateMockServer( + dbMockServer.id, + user.uid, + updateInput, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).name).toBe(updateInput.name); + } + expect(mockPrisma.mockServer.update).toHaveBeenCalledWith({ + where: { id: dbMockServer.id }, + data: updateInput, + }); + }); + + test('should record deactivation analytics', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + mockPrisma.mockServer.update.mockResolvedValue({ + ...dbMockServer, + isActive: false, + }); + + await mockServerService.updateMockServer(dbMockServer.id, user.uid, { + isActive: false, + }); + + expect(mockAnalyticsService.recordActivity).toHaveBeenCalledWith( + dbMockServer, + MockServerAction.DEACTIVATED, + user.uid, + ); + }); + + test('should record activation analytics', async () => { + const inactiveMockServer = { ...dbMockServer, isActive: false }; + mockPrisma.mockServer.findFirst.mockResolvedValue(inactiveMockServer); + mockPrisma.mockServer.update.mockResolvedValue({ + ...inactiveMockServer, + isActive: true, + }); + + await mockServerService.updateMockServer(dbMockServer.id, user.uid, { + isActive: true, + }); + + expect(mockAnalyticsService.recordActivity).toHaveBeenCalledWith( + inactiveMockServer, + MockServerAction.ACTIVATED, + user.uid, + ); + }); + + test('should return error when mock server not found', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(null); + + const result = await mockServerService.updateMockServer( + 'invalid-id', + user.uid, + updateInput, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + + test('should return error when user lacks permission', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + + const result = await mockServerService.updateMockServer( + dbMockServer.id, + 'different-user', + updateInput, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + }); + + describe('deleteMockServer', () => { + test('should soft delete mock server successfully', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + mockPrisma.mockServer.update.mockResolvedValue({ + ...dbMockServer, + isActive: false, + deletedAt: currentTime, + }); + + const result = await mockServerService.deleteMockServer( + dbMockServer.id, + user.uid, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toBe(true); + } + expect(mockPrisma.mockServer.update).toHaveBeenCalledWith({ + where: { id: dbMockServer.id }, + data: { isActive: false, deletedAt: expect.any(Date) }, + }); + expect(mockAnalyticsService.recordActivity).toHaveBeenCalledWith( + dbMockServer, + MockServerAction.DELETED, + user.uid, + ); + }); + + test('should return error when mock server not found', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(null); + + const result = await mockServerService.deleteMockServer( + 'invalid-id', + user.uid, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + + test('should return error when user lacks permission', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + + const result = await mockServerService.deleteMockServer( + dbMockServer.id, + 'different-user', + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + }); + + describe('logRequest', () => { + const logParams = { + mockServerID: dbMockServer.id, + requestMethod: 'GET', + requestPath: '/api/users', + requestHeaders: { 'content-type': 'application/json' }, + requestBody: { test: 'data' }, + requestQuery: { page: '1' }, + responseStatus: 200, + responseHeaders: { 'content-type': 'application/json' }, + responseTime: 150, + ipAddress: '127.0.0.1', + userAgent: 'Mozilla/5.0', + }; + + test('should log request successfully', async () => { + mockPrisma.mockServerLog.create.mockResolvedValue({ + id: 'log123', + ...logParams, + responseBody: null, + executedAt: currentTime, + } as any); + + await mockServerService.logRequest(logParams); + + expect(mockPrisma.mockServerLog.create).toHaveBeenCalledWith({ + data: { + mockServerID: logParams.mockServerID, + requestMethod: logParams.requestMethod, + requestPath: logParams.requestPath, + requestHeaders: logParams.requestHeaders, + requestBody: logParams.requestBody, + requestQuery: logParams.requestQuery, + responseStatus: logParams.responseStatus, + responseHeaders: logParams.responseHeaders, + responseBody: null, + responseTime: logParams.responseTime, + ipAddress: logParams.ipAddress, + userAgent: logParams.userAgent, + }, + }); + }); + + test('should handle logging errors gracefully', async () => { + mockPrisma.mockServerLog.create.mockRejectedValue(new Error('DB Error')); + + // Should not throw + await expect( + mockServerService.logRequest(logParams), + ).resolves.not.toThrow(); + }); + }); + + describe('getMockServerLogs', () => { + const mockLog = { + id: 'log123', + mockServerID: dbMockServer.id, + requestMethod: 'GET', + requestPath: '/api/users', + requestHeaders: { 'content-type': 'application/json' }, + requestBody: null, + requestQuery: { page: '1' }, + responseStatus: 200, + responseHeaders: { 'content-type': 'application/json' }, + responseBody: null, + responseTime: 150, + ipAddress: '127.0.0.1', + userAgent: 'Mozilla/5.0', + executedAt: currentTime, + }; + + test('should return logs with pagination', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + mockPrisma.mockServerLog.findMany.mockResolvedValue([mockLog] as any); + + const result = await mockServerService.getMockServerLogs( + dbMockServer.id, + user.uid, + { take: 10, skip: 0 }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right as any).toHaveLength(1); + expect((result.right as any)[0].requestMethod).toBe('GET'); + expect((result.right as any)[0].requestHeaders).toBe( + JSON.stringify(mockLog.requestHeaders), + ); + } + expect(mockPrisma.mockServerLog.findMany).toHaveBeenCalledWith({ + where: { mockServerID: dbMockServer.id }, + orderBy: { executedAt: 'desc' }, + take: 10, + skip: 0, + }); + }); + + test('should return error when mock server not found', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(null); + + const result = await mockServerService.getMockServerLogs( + 'invalid-id', + user.uid, + { take: 10, skip: 0 }, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + + test('should return error when user lacks access', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + + const result = await mockServerService.getMockServerLogs( + dbMockServer.id, + 'different-user', + { take: 10, skip: 0 }, + ); + + expect(E.isLeft(result)).toBe(true); + }); + }); + + describe('deleteMockServerLog', () => { + const mockLog = { + id: 'log123', + mockServerID: dbMockServer.id, + mockServer: dbMockServer, + }; + + test('should delete log successfully', async () => { + mockPrisma.mockServerLog.findUnique.mockResolvedValue(mockLog as any); + mockPrisma.mockServerLog.delete.mockResolvedValue(mockLog as any); + + const result = await mockServerService.deleteMockServerLog( + 'log123', + user.uid, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toBe(true); + } + expect(mockPrisma.mockServerLog.delete).toHaveBeenCalledWith({ + where: { id: 'log123' }, + }); + }); + + test('should return error when log not found', async () => { + mockPrisma.mockServerLog.findUnique.mockResolvedValue(null); + + const result = await mockServerService.deleteMockServerLog( + 'invalid-id', + user.uid, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_LOG_NOT_FOUND); + } + }); + + test('should return error when user lacks permission', async () => { + mockPrisma.mockServerLog.findUnique.mockResolvedValue(mockLog as any); + + const result = await mockServerService.deleteMockServerLog( + 'log123', + 'different-user', + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_LOG_NOT_FOUND); + } + }); + }); + + describe('incrementHitCount', () => { + test('should increment hit count and update last hit timestamp', async () => { + mockPrisma.mockServer.update.mockResolvedValue({ + ...dbMockServer, + hitCount: 1, + lastHitAt: currentTime, + }); + + await mockServerService.incrementHitCount(dbMockServer.id); + + expect(mockPrisma.mockServer.update).toHaveBeenCalledWith({ + where: { id: dbMockServer.id }, + data: { + hitCount: { increment: 1 }, + lastHitAt: expect.any(Date), + }, + }); + }); + + test('should handle errors gracefully', async () => { + mockPrisma.mockServer.update.mockRejectedValue(new Error('DB Error')); + + // Should not throw + await expect( + mockServerService.incrementHitCount(dbMockServer.id), + ).resolves.not.toThrow(); + }); + }); + + describe('handleMockRequest', () => { + const mockExample = { + key: 'example1', + name: 'Success Response', + method: 'GET', + endpoint: 'http://api.example.com/users?page=1', + statusCode: 200, + statusText: 'OK', + responseBody: '{"success": true}', + responseHeaders: [{ key: 'content-type', value: 'application/json' }], + headers: [], + }; + + const userRequest: UserRequest = { + id: 'req123', + collectionID: userCollection.id, + teamID: null, + title: 'Get Users', + request: {}, + mockExamples: { + examples: [mockExample], + }, + orderIndex: 1, + createdOn: currentTime, + updatedOn: currentTime, + } as any; + + test('should return example by ID header', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.userCollection.findMany.mockResolvedValue([]); // No child collections + mockPrisma.userRequest.findMany.mockResolvedValue([userRequest] as any); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/users', + 'GET', + {}, + { 'x-mock-response-id': 'example1' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).statusCode).toBe(200); + expect((result.right as any).body).toBe('{"success": true}'); + } + }); + + test('should return example by name header', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.userCollection.findMany.mockResolvedValue([]); // No child collections + mockPrisma.userRequest.findMany.mockResolvedValue([userRequest] as any); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/users', + 'GET', + {}, + { 'x-mock-response-name': 'Success Response' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).statusCode).toBe(200); + } + }); + + test('should filter by status code header', async () => { + const example404 = { + ...mockExample, + key: 'example2', + endpoint: 'http://api.example.com/users', // Same endpoint + statusCode: 404, + statusText: 'Not Found', + responseBody: '{"error": "not found"}', + }; + const requestWith404 = { + ...userRequest, + mockExamples: { + examples: [mockExample, example404], + }, + }; + + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.userCollection.findMany.mockResolvedValue([]); // No child collections + mockPrisma.userRequest.findMany.mockResolvedValue([ + requestWith404, + ] as any); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/users', + 'GET', + {}, + { 'x-mock-response-code': '404' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).statusCode).toBe(404); + expect((result.right as any).body).toBe('{"error": "not found"}'); + } + }); + + test('should match exact path', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.userRequest.findMany.mockResolvedValue([userRequest] as any); + mockPrisma.userCollection.findMany.mockResolvedValue([]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/users', + 'GET', + { page: '1' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).statusCode).toBe(200); + } + }); + + test('should match path with variables', async () => { + const variableExample = { + ...mockExample, + endpoint: 'http://api.example.com/users/<>', + }; + const variableRequest = { + ...userRequest, + mockExamples: { + examples: [variableExample], + }, + }; + + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.userRequest.findMany.mockResolvedValue([ + variableRequest, + ] as any); + mockPrisma.userCollection.findMany.mockResolvedValue([]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/users/123', + 'GET', + ); + + expect(E.isRight(result)).toBe(true); + }); + + test('should return error when no examples found', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.userRequest.findMany.mockResolvedValue([]); + mockPrisma.userCollection.findMany.mockResolvedValue([]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/users', + 'GET', + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toContain('No examples found'); + } + }); + + test('should prefer 200 status when scores are equal', async () => { + const example200 = { ...mockExample, statusCode: 200 }; + const example404 = { ...mockExample, key: 'example2', statusCode: 404 }; + const multipleExamples = { + ...userRequest, + mockExamples: { + examples: [example404, example200], // 404 first, but 200 should be preferred + }, + }; + + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.userRequest.findMany.mockResolvedValue([ + multipleExamples, + ] as any); + mockPrisma.userCollection.findMany.mockResolvedValue([]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/users', + 'GET', + { page: '1' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).statusCode).toBe(200); + } + }); + + test('should include delay in response', async () => { + const delayedMockServer = { ...dbMockServer, delayInMs: 500 }; + const simpleRequest = { + ...userRequest, + mockExamples: { + examples: [ + { + ...mockExample, + endpoint: 'http://api.example.com/users', // Remove query params + }, + ], + }, + }; + + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.userCollection.findMany.mockResolvedValue([]); // No child collections + mockPrisma.userRequest.findMany.mockResolvedValue([simpleRequest] as any); + + const result = await mockServerService.handleMockRequest( + delayedMockServer, + '/users', + 'GET', + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).delay).toBe(500); + } + }); + + test('should work with team collections', async () => { + const teamMockServer = { + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + collectionID: teamCollection.id, + }; + const teamRequest = { + ...userRequest, + collectionID: teamCollection.id, + mockExamples: { + examples: [ + { + ...mockExample, + endpoint: 'http://api.example.com/users', // Remove query params + }, + ], + }, + }; + + mockPrisma.teamCollection.findUnique.mockResolvedValue(teamCollection); + mockPrisma.teamCollection.findMany.mockResolvedValue([]); // No child collections + mockPrisma.teamRequest.findMany.mockResolvedValue([teamRequest] as any); + + const result = await mockServerService.handleMockRequest( + teamMockServer, + '/users', + 'GET', + ); + + expect(E.isRight(result)).toBe(true); + }); + }); + + describe('checkMockServerAccess', () => { + test('should allow user access to their own mock server', async () => { + const hasAccess = await mockServerService.checkMockServerAccess( + dbMockServer, + user.uid, + ); + + expect(hasAccess).toBe(true); + }); + + test('should deny user access to other users mock server', async () => { + const hasAccess = await mockServerService.checkMockServerAccess( + dbMockServer, + 'different-user', + ); + + expect(hasAccess).toBe(false); + }); + + test('should allow team member access to team mock server', async () => { + const teamMockServer = { + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + }; + + mockPrisma.team.findFirst.mockResolvedValue({ id: 'team123' } as any); + + const hasAccess = await mockServerService.checkMockServerAccess( + teamMockServer, + user.uid, + ); + + expect(hasAccess).toBe(true); + }); + + test('should deny non-member access to team mock server', async () => { + const teamMockServer = { + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + }; + + mockPrisma.team.findFirst.mockResolvedValue(null); + + const hasAccess = await mockServerService.checkMockServerAccess( + teamMockServer, + user.uid, + ); + + expect(hasAccess).toBe(false); + }); + + test('should respect role restrictions', async () => { + const teamMockServer = { + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + }; + + mockPrisma.team.findFirst.mockResolvedValue(null); + + const hasAccess = await mockServerService.checkMockServerAccess( + teamMockServer, + user.uid, + [TeamAccessRole.OWNER], // Only owners + ); + + expect(hasAccess).toBe(false); + }); + }); + + describe('parseExample (private method)', () => { + const requestId = 'req123'; + + test('should parse basic example with path only', () => { + const exampleData = { + key: 'example1', + name: 'Get Users', + method: 'GET', + endpoint: 'http://api.example.com/users', + statusCode: 200, + statusText: 'OK', + responseBody: '{"success": true}', + responseHeaders: [{ key: 'content-type', value: 'application/json' }], + headers: [], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.id).toBe('example1'); + expect(result.name).toBe('Get Users'); + expect(result.method).toBe('GET'); + expect(result.endpoint).toBe('http://api.example.com/users'); + expect(result.path).toBe('/users'); + expect(result.queryParams).toEqual({}); + expect(result.statusCode).toBe(200); + expect(result.statusText).toBe('OK'); + expect(result.responseBody).toBe('{"success": true}'); + expect(result.responseHeaders).toHaveLength(1); + expect(result.requestHeaders).toHaveLength(0); + }); + + test('should parse example with query parameters', () => { + const exampleData = { + key: 'example2', + name: 'Search Users', + method: 'GET', + endpoint: 'http://api.example.com/users?page=1&limit=10&sort=name', + statusCode: 200, + statusText: 'OK', + responseBody: '[]', + responseHeaders: [], + headers: [], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.path).toBe('/users'); + expect(result.queryParams).toEqual({ + page: '1', + limit: '10', + sort: 'name', + }); + }); + + test('should parse example with path variables', () => { + const exampleData = { + key: 'example3', + name: 'Get User By ID', + method: 'GET', + endpoint: 'http://api.example.com/users/<>', + statusCode: 200, + statusText: 'OK', + responseBody: '{"id": "123"}', + responseHeaders: [], + headers: [], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.path).toBe('/users/<>'); + expect(result.queryParams).toEqual({}); + }); + + test('should parse example with path variables and query params', () => { + const exampleData = { + key: 'example4', + name: 'Update User', + method: 'PUT', + endpoint: 'http://api.example.com/users/<>?notify=true', + statusCode: 200, + statusText: 'OK', + responseBody: '{"updated": true}', + responseHeaders: [], + headers: [], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.path).toBe('/users/<>'); + expect(result.queryParams).toEqual({ notify: 'true' }); + }); + + test('should handle endpoint starting with <<', () => { + const exampleData = { + key: 'example5', + name: 'Dynamic Base', + method: 'GET', + endpoint: '<>/api/users', + statusCode: 200, + statusText: 'OK', + responseBody: '[]', + responseHeaders: [], + headers: [], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.path).toBe('/api/users'); + }); + + test('should handle endpoint without domain', () => { + const exampleData = { + key: 'example6', + name: 'Relative Path', + method: 'POST', + endpoint: '/api/users', + statusCode: 201, + statusText: 'Created', + responseBody: '{"id": "new"}', + responseHeaders: [], + headers: [], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.path).toBe('/api/users'); + }); + + test('should remove domain from endpoint', () => { + const exampleData = { + key: 'example7', + name: 'Full URL', + method: 'GET', + endpoint: 'https://subdomain.example.com/api/v1/users', + statusCode: 200, + statusText: 'OK', + responseBody: '[]', + responseHeaders: [], + headers: [], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.path).toBe('/api/v1/users'); + }); + + test('should use default values when fields are missing', () => { + const exampleData = { + endpoint: '/users', + responseBody: '{}', + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.id).toBe(`${requestId}-undefined`); + expect(result.method).toBe('GET'); + expect(result.statusCode).toBe(200); + expect(result.statusText).toBe('OK'); + expect(result.responseHeaders).toEqual([]); + expect(result.requestHeaders).toEqual([]); + }); + + test('should generate ID from requestId and name when key is missing', () => { + const exampleData = { + name: 'Test Example', + endpoint: '/test', + responseBody: '{}', + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.id).toBe(`${requestId}-Test Example`); + }); + + test('should handle complex path with multiple segments', () => { + const exampleData = { + key: 'example8', + name: 'Nested Resource', + method: 'GET', + endpoint: + 'http://api.example.com/organizations/<>/teams/<>/members', + statusCode: 200, + statusText: 'OK', + responseBody: '[]', + responseHeaders: [], + headers: [], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.path).toBe( + '/organizations/<>/teams/<>/members', + ); + }); + + test('should preserve special characters in query parameters', () => { + const exampleData = { + key: 'example9', + name: 'Special Chars', + method: 'GET', + endpoint: + 'http://api.example.com/search?q=hello+world&filter=name:john', + statusCode: 200, + statusText: 'OK', + responseBody: '[]', + responseHeaders: [], + headers: [], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.queryParams.q).toBe('hello world'); + expect(result.queryParams.filter).toBe('name:john'); + }); + + test('should handle root path', () => { + const exampleData = { + key: 'example10', + name: 'Root', + method: 'GET', + endpoint: 'http://api.example.com/', + statusCode: 200, + statusText: 'OK', + responseBody: '{"status": "ok"}', + responseHeaders: [], + headers: [], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.path).toBe('/'); + }); + + test('should handle empty endpoint gracefully', () => { + const exampleData = { + key: 'example11', + name: 'Empty Endpoint', + method: 'GET', + endpoint: '', + statusCode: 200, + statusText: 'OK', + responseBody: '{}', + responseHeaders: [], + headers: [], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.path).toBe('/'); + }); + + test('should handle encoded characters in path', () => { + const exampleData = { + key: 'example12', + name: 'Encoded Path', + method: 'GET', + endpoint: 'http://api.example.com/users/%3C%3CuserId%3E%3E', + statusCode: 200, + statusText: 'OK', + responseBody: '{}', + responseHeaders: [], + headers: [], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.path).toBe('/users/<>'); + }); + + test('should handle multiple query parameters with same key', () => { + const exampleData = { + key: 'example13', + name: 'Multiple Query Values', + method: 'GET', + endpoint: 'http://api.example.com/users?id=1&id=2&id=3', + statusCode: 200, + statusText: 'OK', + responseBody: '[]', + responseHeaders: [], + headers: [], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + // URLSearchParams keeps last value when keys duplicate + expect(result.queryParams.id).toBe('3'); + }); + + test('should handle POST method with request body', () => { + const exampleData = { + key: 'example14', + name: 'Create User', + method: 'POST', + endpoint: 'http://api.example.com/users', + statusCode: 201, + statusText: 'Created', + responseBody: '{"id": "123", "name": "John"}', + responseHeaders: [{ key: 'location', value: '/users/123' }], + headers: [{ key: 'content-type', value: 'application/json' }], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.method).toBe('POST'); + expect(result.statusCode).toBe(201); + expect(result.requestHeaders).toHaveLength(1); + expect(result.responseHeaders).toHaveLength(1); + }); + + test('should return null on parsing error', () => { + // Create an object that will cause URL parsing to fail + const exampleData = { + key: 'bad-example', + name: 'Invalid', + endpoint: 'http://a bc.com', // This should cause an error + responseBody: '{}', + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).toBeNull(); + }); + + test('should handle endpoint with port number', () => { + const exampleData = { + key: 'example15', + name: 'With Port', + method: 'GET', + endpoint: 'http://api.example.com:8080/users', + statusCode: 200, + statusText: 'OK', + responseBody: '[]', + responseHeaders: [], + headers: [], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.path).toBe('/users'); + }); + + test('should handle different HTTP methods', () => { + const methods = [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'HEAD', + 'OPTIONS', + ]; + + methods.forEach((method) => { + const exampleData = { + key: `example-${method}`, + name: `Test ${method}`, + method: method, + endpoint: 'http://api.example.com/test', + statusCode: 200, + statusText: 'OK', + responseBody: '{}', + responseHeaders: [], + headers: [], + }; + + const result = mockServerService['parseExample']( + exampleData, + requestId, + ); + + expect(result).not.toBeNull(); + expect(result.method).toBe(method); + }); + }); + + test('should preserve endpoint in result', () => { + const endpoint = 'http://api.example.com/users/<>?page=1'; + const exampleData = { + key: 'example16', + name: 'Preserve Endpoint', + method: 'GET', + endpoint: endpoint, + statusCode: 200, + statusText: 'OK', + responseBody: '{}', + responseHeaders: [], + headers: [], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.endpoint).toBe(endpoint); + }); + + test('should handle empty query string', () => { + const exampleData = { + key: 'example17', + name: 'Empty Query', + method: 'GET', + endpoint: 'http://api.example.com/users?', + statusCode: 200, + statusText: 'OK', + responseBody: '[]', + responseHeaders: [], + headers: [], + }; + + const result = mockServerService['parseExample'](exampleData, requestId); + + expect(result).not.toBeNull(); + expect(result.path).toBe('/users'); + expect(result.queryParams).toEqual({}); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.service.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.service.ts new file mode 100644 index 0000000..6efbb8a --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.service.ts @@ -0,0 +1,1199 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { + CreateMockServerInput, + UpdateMockServerInput, + MockServerResponse, + MockServer, + MockServerCollection, + MockServerLog, +} from './mock-server.model'; +import * as E from 'fp-ts/Either'; +import { + MOCK_SERVER_NOT_FOUND, + MOCK_SERVER_INVALID_COLLECTION, + TEAM_INVALID_ID, + MOCK_SERVER_CREATION_FAILED, + MOCK_SERVER_UPDATE_FAILED, + MOCK_SERVER_DELETION_FAILED, + MOCK_SERVER_LOG_NOT_FOUND, + MOCK_SERVER_LOG_DELETION_FAILED, + MOCK_SERVER_COLLECTION_CREATION_FAILED, +} from 'src/errors'; +import { randomBytes } from 'crypto'; +import { WorkspaceType } from 'src/types/WorkspaceTypes'; +import { + MockServerAction, + TeamAccessRole, + MockServer as dbMockServer, +} from 'src/generated/prisma/client'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; +import { ConfigService } from '@nestjs/config'; +import { MockServerAnalyticsService } from './mock-server-analytics.service'; +import { PrismaError } from 'src/prisma/prisma-error-codes'; +import { TeamCollectionService } from 'src/team-collection/team-collection.service'; +import { UserCollectionService } from 'src/user-collection/user-collection.service'; +import { ReqType } from 'src/types/RequestTypes'; +import { AuthUser } from 'src/types/AuthUser'; +import { mockServerCollRequestExample } from './constants/mock-server-coll-request-example'; + +@Injectable() +export class MockServerService { + constructor( + private readonly configService: ConfigService, + private readonly prisma: PrismaService, + private readonly mockServerAnalyticsService: MockServerAnalyticsService, + private readonly teamCollectionService: TeamCollectionService, + private readonly userCollectionService: UserCollectionService, + ) {} + + /** + * Cast database model to GraphQL model + */ + private cast(dbMockServer: dbMockServer): MockServer { + // Generate path based mock server URL + const backendUrl = this.configService.get('VITE_BACKEND_API_URL'); + const base = backendUrl.substring(0, backendUrl.lastIndexOf('/')); // "http(s)://localhost:3170" + const serverUrlPathBased = base + '/mock/' + dbMockServer.subdomain; + + // Generate domain based mock server URL + // MOCK_SERVER_WILDCARD_DOMAIN = '*.mock.hopp.io' + const wildcardDomain = this.configService.get( + 'INFRA.MOCK_SERVER_WILDCARD_DOMAIN', + ); + const isSecure = + this.configService.get('INFRA.ALLOW_SECURE_COOKIES') === 'true'; + const protocol = isSecure ? 'https://' : 'http://'; + + // ENABLE_SUBPATH_BASED_ACCESS is a flat config key (no INFRA. prefix) to support flexible deployment strategies + const SUBPATH_BACKEND_SUFFIX = '/backend'; + const subpathSuffix = + this.configService.get('ENABLE_SUBPATH_BASED_ACCESS') === 'true' + ? SUBPATH_BACKEND_SUFFIX + : ''; + + const domainPart = wildcardDomain + ? dbMockServer.subdomain + wildcardDomain.substring(1) + : null; + + const serverUrlDomainBased = domainPart + ? `${protocol}${domainPart.replace(/\/+$/, '')}${subpathSuffix}` + : null; + + return { + id: dbMockServer.id, + name: dbMockServer.name, + subdomain: dbMockServer.subdomain, + serverUrlPathBased, + serverUrlDomainBased, + workspaceType: dbMockServer.workspaceType, + workspaceID: dbMockServer.workspaceID, + delayInMs: dbMockServer.delayInMs, + isActive: dbMockServer.isActive, + isPublic: dbMockServer.isPublic, + createdOn: dbMockServer.createdOn, + updatedOn: dbMockServer.updatedOn, + } as MockServer; + } + + /** + * Get mock servers for a user + */ + async getUserMockServers(userUid: string, args: OffsetPaginationArgs) { + const mockServers = await this.prisma.mockServer.findMany({ + where: { + workspaceType: WorkspaceType.USER, + creatorUid: userUid, + deletedAt: null, + }, + orderBy: { createdOn: 'desc' }, + take: args?.take, + skip: args?.skip, + }); + + return mockServers.map((ms) => this.cast(ms)); + } + + /** + * Get mock servers for a team + */ + async getTeamMockServers(teamID: string, args: OffsetPaginationArgs) { + const mockServers = await this.prisma.mockServer.findMany({ + where: { + workspaceType: WorkspaceType.TEAM, + workspaceID: teamID, + deletedAt: null, + }, + orderBy: { createdOn: 'desc' }, + take: args?.take, + skip: args?.skip, + }); + + return mockServers.map((ms) => this.cast(ms)); + } + + /** + * Check if user has access to a team with specific roles + */ + private async checkTeamAccess( + teamId: string, + userUid: string, + requiredRoles: TeamAccessRole[], + ): Promise { + const team = await this.prisma.team.findFirst({ + where: { + id: teamId, + members: { + some: { + userUid, + role: { in: requiredRoles }, + }, + }, + }, + }); + return !!team; + } + + /** + * Check if user has access to a mock server with specific roles + */ + async checkMockServerAccess( + mockServer: dbMockServer, + userUid: string, + requiredRoles: TeamAccessRole[] = [ + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ], + ): Promise { + if (mockServer.workspaceType === WorkspaceType.USER) { + return mockServer.creatorUid === userUid; + } else if (mockServer.workspaceType === WorkspaceType.TEAM) { + return this.checkTeamAccess( + mockServer.workspaceID, + userUid, + requiredRoles, + ); + } + return false; + } + + /** + * Get a specific mock server by ID + */ + async getMockServer(id: string, userUid: string) { + const mockServer = await this.prisma.mockServer.findFirst({ + where: { id, deletedAt: null }, + }); + if (!mockServer) return E.left(MOCK_SERVER_NOT_FOUND); + + // Check access permissions + const hasAccess = await this.checkMockServerAccess(mockServer, userUid); + if (!hasAccess) return E.left(MOCK_SERVER_NOT_FOUND); + + return E.right(this.cast(mockServer)); + } + + /** + * Get a mock server by subdomain (for incoming mock requests) + * Returns database model with collectionID for internal use + * @param subdomain - The subdomain of the mock server + * @param includeInactive - If true, returns mock server regardless of active status (default: false) + */ + async getMockServerBySubdomain(subdomain: string, includeInactive = false) { + const mockServer = await this.prisma.mockServer.findFirst({ + where: { + subdomain: { equals: subdomain, mode: 'insensitive' }, + ...(includeInactive ? {} : { isActive: true }), + deletedAt: null, + }, + }); + if (!mockServer) return E.left(MOCK_SERVER_NOT_FOUND); + + // Return database model directly (includes collectionID) + return E.right(mockServer); + } + + /** + * (Field resolver) + * Get the creator of a mock server + */ + async getMockServerCreator(mockServerId: string) { + const mockServer = await this.prisma.mockServer.findUnique({ + where: { id: mockServerId, deletedAt: null }, + include: { user: true }, + }); + if (!mockServer) return E.left(MOCK_SERVER_NOT_FOUND); + return E.right(mockServer.user); + } + + /** + * (Field resolver) + * Get the collection of a mock server + */ + async getMockServerCollection(mockServerId: string) { + const mockServer = await this.prisma.mockServer.findUnique({ + where: { id: mockServerId, deletedAt: null }, + }); + if (!mockServer) return E.left(MOCK_SERVER_NOT_FOUND); + + if (mockServer.workspaceType === WorkspaceType.USER) { + const collection = await this.prisma.userCollection.findUnique({ + where: { id: mockServer.collectionID }, + }); + if (!collection) return E.right(null); + return E.right({ + id: collection.id, + title: collection.title, + } as MockServerCollection); + } else if (mockServer.workspaceType === WorkspaceType.TEAM) { + const collection = await this.prisma.teamCollection.findUnique({ + where: { id: mockServer.collectionID }, + }); + if (!collection) return E.right(null); + return E.right({ + id: collection.id, + title: collection.title, + } as MockServerCollection); + } + + return E.left(MOCK_SERVER_INVALID_COLLECTION); + } + + /** + * Generate a unique subdomain for the mock server + */ + private generateMockServerSubdomain(): string { + const id = randomBytes(10).toString('base64url').substring(0, 13); + return `${id}`; + } + + /** + * Validate workspace access permission and existence + */ + private async validateWorkspace( + user: AuthUser, + input: CreateMockServerInput, + ) { + if (input.workspaceType === WorkspaceType.TEAM) { + if (!input.workspaceID) return E.left(TEAM_INVALID_ID); + + const hasAccess = await this.checkTeamAccess( + input.workspaceID, + user.uid, + [TeamAccessRole.OWNER, TeamAccessRole.EDITOR], + ); + + if (!hasAccess) return E.left(TEAM_INVALID_ID); + } + + return E.right(true); + } + + /** + * Validate collection exists and user has access + */ + private async validateCollection( + user: AuthUser, + input: CreateMockServerInput, + ) { + if (!input.collectionID) return E.left(MOCK_SERVER_INVALID_COLLECTION); + + if (input.workspaceType === WorkspaceType.TEAM) { + const collection = await this.prisma.teamCollection.findUnique({ + where: { id: input.collectionID, teamID: input.workspaceID }, + }); + return collection + ? E.right(collection) + : E.left(MOCK_SERVER_INVALID_COLLECTION); + } else if (input.workspaceType === WorkspaceType.USER) { + const collection = await this.prisma.userCollection.findUnique({ + where: { id: input.collectionID, userUid: user.uid }, + }); + return collection + ? E.right(collection) + : E.left(MOCK_SERVER_INVALID_COLLECTION); + } + + return E.left(MOCK_SERVER_INVALID_COLLECTION); + } + + private async createAutoCollection( + user: AuthUser, + input: CreateMockServerInput, + ) { + if (input.workspaceType === WorkspaceType.USER) { + if (!input.autoCreateRequestExample) { + // create only a collection + const userColl = await this.userCollectionService.createUserCollection( + user, + input.name, + null, + null, + ReqType.REST, + ); + + if (E.isLeft(userColl)) return E.left(userColl.left); + return E.right({ id: userColl.right.id }); + } else { + // create collection with a request example + const importedUserColl = + await this.userCollectionService.importCollectionsFromJSON( + JSON.stringify(mockServerCollRequestExample(input.name)), + user.uid, + null, + ReqType.REST, + ); + if (E.isLeft(importedUserColl)) return E.left(importedUserColl.left); + if (JSON.parse(importedUserColl.right.exportedCollection).length === 0) + return E.left(MOCK_SERVER_COLLECTION_CREATION_FAILED); + + return E.right({ + id: JSON.parse(importedUserColl.right.exportedCollection)[0].id, + }); + } + } else if (input.workspaceType === WorkspaceType.TEAM) { + if (!input.workspaceID) return E.left(TEAM_INVALID_ID); + + if (!input.autoCreateRequestExample) { + const teamColl = await this.teamCollectionService.createCollection( + input.workspaceID, + input.name, + null, + null, + ); + + if (E.isLeft(teamColl)) return E.left(teamColl.left); + return E.right({ id: teamColl.right.id }); + } else { + const importedTeamColl = + await this.teamCollectionService.importCollectionsFromJSON( + JSON.stringify(mockServerCollRequestExample(input.name)), + input.workspaceID, + null, + ); + + if (E.isLeft(importedTeamColl)) return E.left(importedTeamColl.left); + if (importedTeamColl.right.length === 0) + return E.left(MOCK_SERVER_COLLECTION_CREATION_FAILED); + + return E.right({ + id: importedTeamColl.right[0].id, + }); + } + } + + return E.left(MOCK_SERVER_COLLECTION_CREATION_FAILED); + } + + /** + * Create a new mock server + */ + async createMockServer( + user: AuthUser, + input: CreateMockServerInput, + ): Promise> { + let collectionID: string | undefined = input.collectionID; + try { + // Validate workspace type and ID + + const workspaceValidation = await this.validateWorkspace(user, input); + if (E.isLeft(workspaceValidation)) { + return E.left(workspaceValidation.left); + } + + if (!input.autoCreateCollection) { + // Validate collection exists and user has access + const collectionValidation = await this.validateCollection(user, input); + if (E.isLeft(collectionValidation)) { + return E.left(collectionValidation.left); + } + } + + // Auto-create collection if needed + if (input.autoCreateCollection) { + const newCollection = await this.createAutoCollection(user, input); + if (E.isLeft(newCollection)) { + return E.left(newCollection.left); + } + collectionID = newCollection.right.id; + } + + // Create mock server + const subdomain: string = this.generateMockServerSubdomain(); + const mockServer = await this.prisma.mockServer.create({ + data: { + name: input.name, + subdomain, + creatorUid: user.uid, + collectionID: input.collectionID ?? collectionID, + workspaceType: input.workspaceType, + workspaceID: + input.workspaceType === WorkspaceType.TEAM + ? input.workspaceID + : user.uid, + delayInMs: input.delayInMs, + isPublic: input.isPublic ?? false, + }, + }); + this.mockServerAnalyticsService.recordActivity( + mockServer, + MockServerAction.CREATED, + user.uid, + ); + + return E.right(this.cast(mockServer)); + } catch (error) { + if (input.autoCreateCollection && collectionID) { + if (input.workspaceType === WorkspaceType.USER) { + await this.userCollectionService.deleteUserCollection( + collectionID, + user.uid, + ); + } else if (input.workspaceType === WorkspaceType.TEAM) { + await this.teamCollectionService.deleteCollection(collectionID); + } + } + + if (error.code === PrismaError.UNIQUE_CONSTRAINT_VIOLATION) { + return this.createMockServer(user, input); // Retry on subdomain conflict + } + + console.error('Error creating mock server:', error); + return E.left(MOCK_SERVER_CREATION_FAILED); + } + } + + /** + * Update a mock server + */ + async updateMockServer( + id: string, + userUid: string, + input: UpdateMockServerInput, + ) { + try { + const mockServer = await this.prisma.mockServer.findFirst({ + where: { id, deletedAt: null }, + }); + if (!mockServer) return E.left(MOCK_SERVER_NOT_FOUND); + + // Check access permissions (only OWNER and EDITOR can update) + const hasAccess = await this.checkMockServerAccess(mockServer, userUid, [ + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + ]); + if (!hasAccess) return E.left(MOCK_SERVER_NOT_FOUND); + + // Update the mock server + const updated = await this.prisma.mockServer.update({ + where: { id }, + data: input, + }); + if (input.isActive !== undefined) { + this.mockServerAnalyticsService.recordActivity( + mockServer, // use pre-update state to determine action + input.isActive + ? MockServerAction.ACTIVATED + : MockServerAction.DEACTIVATED, + userUid, + ); + } + + return E.right(this.cast(updated)); + } catch (error) { + console.error('Error updating mock server:', error); + return E.left(MOCK_SERVER_UPDATE_FAILED); + } + } + + /** + * Delete a mock server + */ + async deleteMockServer(id: string, userUid: string) { + try { + const mockServer = await this.prisma.mockServer.findFirst({ + where: { id, deletedAt: null }, + }); + if (!mockServer) return E.left(MOCK_SERVER_NOT_FOUND); + + // Check access permissions (only OWNER and EDITOR can delete) + const hasAccess = await this.checkMockServerAccess(mockServer, userUid, [ + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + ]); + if (!hasAccess) return E.left(MOCK_SERVER_NOT_FOUND); + + // Soft delete the mock server + await this.prisma.mockServer.update({ + where: { id }, + data: { isActive: false, deletedAt: new Date() }, + }); + this.mockServerAnalyticsService.recordActivity( + mockServer, // use pre-update state to determine action + MockServerAction.DELETED, + userUid, + ); + + return E.right(true); + } catch (error) { + console.error('Error deleting mock server:', error); + return E.left(MOCK_SERVER_DELETION_FAILED); + } + } + + /** + * Log a mock server request and response + */ + async logRequest(params: { + mockServerID: string; + requestMethod: string; + requestPath: string; + requestHeaders: Record; + requestBody?: any; + requestQuery?: Record; + responseStatus: number; + responseHeaders: Record; + responseTime: number; + ipAddress?: string; + userAgent?: string; + }): Promise { + try { + await this.prisma.mockServerLog.create({ + data: { + mockServerID: params.mockServerID, + requestMethod: params.requestMethod, + requestPath: params.requestPath, + requestHeaders: params.requestHeaders, + requestBody: params.requestBody || null, + requestQuery: params.requestQuery || null, + responseStatus: params.responseStatus, + responseHeaders: params.responseHeaders, + responseBody: null, // We'll capture response body separately if needed + responseTime: params.responseTime, + ipAddress: params.ipAddress || null, + userAgent: params.userAgent || null, + }, + }); + } catch (error) { + console.error('Error logging request:', error); + // Don't throw error - analytics shouldn't break the main flow + } + } + + /** + * Get logs for a mock server with pagination + * Logs are sorted by execution time in descending order (most recent first) + */ + async getMockServerLogs( + mockServerId: string, + userUid: string, + args: OffsetPaginationArgs, + ) { + try { + // First, get the mock server and verify it exists + const mockServer = await this.prisma.mockServer.findFirst({ + where: { id: mockServerId, deletedAt: null }, + }); + + if (!mockServer) return E.left(MOCK_SERVER_NOT_FOUND); + + // Check access permissions - user must have access to the mock server + const hasAccess = await this.checkMockServerAccess(mockServer, userUid, [ + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ]); + if (!hasAccess) return E.left(MOCK_SERVER_NOT_FOUND); + + // Fetch logs with pagination, sorted by executedAt descending + const logs = await this.prisma.mockServerLog.findMany({ + where: { mockServerID: mockServerId }, + orderBy: { executedAt: 'desc' }, + take: args?.take, + skip: args?.skip, + }); + + // Convert JSON fields to strings for GraphQL + const formattedLogs = logs.map( + (log) => + ({ + id: log.id, + mockServerID: log.mockServerID, + requestMethod: log.requestMethod, + requestPath: log.requestPath, + requestHeaders: JSON.stringify(log.requestHeaders), + requestBody: log.requestBody + ? JSON.stringify(log.requestBody) + : null, + requestQuery: log.requestQuery + ? JSON.stringify(log.requestQuery) + : null, + responseStatus: log.responseStatus, + responseHeaders: JSON.stringify(log.responseHeaders), + responseBody: log.responseBody + ? JSON.stringify(log.responseBody) + : null, + responseTime: log.responseTime, + ipAddress: log.ipAddress, + userAgent: log.userAgent, + executedAt: log.executedAt, + }) as MockServerLog, + ); + + return E.right(formattedLogs); + } catch (error) { + console.error('Error fetching mock server logs:', error); + return E.left(MOCK_SERVER_NOT_FOUND); + } + } + + /** + * Delete a mock server log by logId + */ + async deleteMockServerLog(logId: string, userUid: string) { + try { + // First, find the log and verify it exists + const log = await this.prisma.mockServerLog.findUnique({ + where: { id: logId }, + include: { mockServer: true }, + }); + + if (!log) return E.left(MOCK_SERVER_LOG_NOT_FOUND); + + // Check access permissions - user must have access to the mock server + const hasAccess = await this.checkMockServerAccess( + log.mockServer, + userUid, + [TeamAccessRole.OWNER, TeamAccessRole.EDITOR], + ); + if (!hasAccess) return E.left(MOCK_SERVER_LOG_NOT_FOUND); + + // Delete the log + await this.prisma.mockServerLog.delete({ + where: { id: logId }, + }); + + return E.right(true); + } catch (error) { + console.error('Error deleting mock server log:', error); + return E.left(MOCK_SERVER_LOG_DELETION_FAILED); + } + } + + /** + * Increment hit count and update last hit timestamp for a mock server + */ + async incrementHitCount(mockServerID: string): Promise { + try { + await this.prisma.mockServer.update({ + where: { id: mockServerID }, + data: { + hitCount: { increment: 1 }, + lastHitAt: new Date(), + }, + }); + } catch (error) { + console.error('Error incrementing hit count:', error); + // Don't throw error - analytics shouldn't break the main flow + } + } + + /** + * Handle mock request - find matching request in collection and return response + * Optimized implementation with database-level filtering: + * 1. Fetch collection IDs once (used for all subsequent queries) + * 2. Check custom headers first (fastest path) + * 3. Fetch only relevant requests from DB (filtered by collection) + * 4. Filter and score examples in-memory + * 5. Return highest scoring example + */ + async handleMockRequest( + mockServer: dbMockServer, + path: string, + method: string, + queryParams?: Record, + requestHeaders?: Record, + ): Promise> { + try { + // OPTIMIZATION: Fetch collection IDs once (recursive DB query) + // This is used by both custom header lookup and candidate fetching + const collectionIds = await this.getCollectionIds(mockServer); + + if (collectionIds.length === 0) { + return E.left( + `The collection associated with this mock has been deleted.`, + ); + } + + // OPTIMIZATION: Fetch all requests with examples once (single DB query) + // This is shared between custom header lookup and candidate matching + const requests = await this.fetchRequestsWithExamples( + mockServer, + collectionIds, + ); + + // OPTIMIZATION: Check for custom headers first (fastest path) + // If user specified exact example, return it immediately without scoring + if (requestHeaders) { + const mockResponseId = requestHeaders['x-mock-response-id']; + const mockResponseName = requestHeaders['x-mock-response-name']; + + if (mockResponseId || mockResponseName) { + const exactMatch = this.findExampleByIdOrName( + requests, + mockResponseId, + mockResponseName, + method, + ); + if (exactMatch) { + return this.formatExampleResponse(exactMatch, mockServer.delayInMs); + } + } + } + + // OPTIMIZATION: Fetch only requests with mockExamples (database-level filter) + // This is much faster than loading all requests and filtering in memory + const candidateExamples = this.fetchCandidateExamples( + requests, + method, + path, + ); + + if (candidateExamples.length === 0) { + return E.left(`No examples found for ${method.toUpperCase()} ${path}`); + } + + // OPTIMIZATION: Filter by status code if header provided + let filteredExamples = candidateExamples; + if (requestHeaders?.['x-mock-response-code']) { + const statusCode = parseInt(requestHeaders['x-mock-response-code'], 10); + const codeFiltered = candidateExamples.filter( + (ex) => ex.statusCode === statusCode, + ); + if (codeFiltered.length > 0) { + filteredExamples = codeFiltered; + } + } + + // OPTIMIZATION: Score examples based on URL and query parameter matching + const scoredExamples = filteredExamples + .map((example) => ({ + example, + score: this.calculateMatchScore(example, path, queryParams || {}), + })) + .filter((scored) => scored.score > 0) // Remove non-matching examples + .sort((a, b) => b.score - a.score); // Sort by score descending + + if (scoredExamples.length === 0) { + return E.left( + `No matching examples found for ${method.toUpperCase()} ${path}`, + ); + } + + // Step 6: Return highest scoring example + // If multiple examples have same high score, prefer 200 status code + const highestScore = scoredExamples[0].score; + const topExamples = scoredExamples.filter( + (scored) => scored.score === highestScore, + ); + + const selectedExample = + topExamples.find((scored) => scored.example.statusCode === 200) || + topExamples[0]; + + return this.formatExampleResponse( + selectedExample.example, + mockServer.delayInMs, + ); + } catch (error) { + console.error('Error handling mock request:', error); + return E.left('Failed to handle mock request'); + } + } + + /** + * Fetch all requests with mock examples from the database + * Shared helper to avoid code duplication + */ + private async fetchRequestsWithExamples( + mockServer: dbMockServer, + collectionIds: string[], + ) { + return mockServer.workspaceType === WorkspaceType.USER + ? await this.prisma.userRequest.findMany({ + where: { + collectionID: { in: collectionIds }, + mockExamples: { not: null }, + }, + select: { + id: true, + mockExamples: true, + }, + }) + : await this.prisma.teamRequest.findMany({ + where: { + collectionID: { in: collectionIds }, + mockExamples: { not: null }, + }, + select: { + id: true, + mockExamples: true, + }, + }); + } + + /** + * OPTIMIZED: Find example by ID or name from already-fetched requests + * This avoids loading all examples when user specifies exact match + */ + private findExampleByIdOrName( + requests: Array<{ id: string; mockExamples: any }>, + exampleId?: string, + exampleName?: string, + method?: string, + ) { + // Search through examples + for (const request of requests) { + const mockExamples = request.mockExamples as any; + if (mockExamples?.examples && Array.isArray(mockExamples.examples)) { + for (const exampleData of mockExamples.examples) { + // Check if method matches (if specified) + if ( + method && + exampleData.method?.toUpperCase() !== method.toUpperCase() + ) { + continue; + } + + const parsedExample = this.parseExample(exampleData, request.id); + if (!parsedExample) continue; + + // Check for ID match + if (exampleId && parsedExample.id === exampleId) { + return parsedExample; + } + + // Check for name match + if (exampleName && parsedExample.name === exampleName) { + return parsedExample; + } + } + } + } + + return null; + } + + /** + * OPTIMIZED: Fetch only candidate examples that could match the request + * Uses in-memory filtering from already-fetched requests + */ + private fetchCandidateExamples( + requests: Array<{ id: string; mockExamples: any }>, + method: string, + path: string, + ) { + interface Example { + id: string; + name: string; + method: string; + endpoint: string; + path: string; + queryParams: Record; + statusCode: number; + statusText: string; + responseBody: string; + responseHeaders: Array<{ key: string; value: string }>; + requestHeaders?: Array<{ key: string; value: string }>; + } + + const examples: Example[] = []; + + // Parse and filter examples + for (const request of requests) { + const mockExamples = request.mockExamples as any; + if (mockExamples?.examples && Array.isArray(mockExamples.examples)) { + for (const exampleData of mockExamples.examples) { + // OPTIMIZATION: Filter by method immediately + if (exampleData.method?.toUpperCase() !== method.toUpperCase()) { + continue; + } + + const parsedExample = this.parseExample(exampleData, request.id); + if (!parsedExample) continue; + + // OPTIMIZATION: Quick path match check before adding to candidates + // This reduces the number of examples we need to score + if (this.couldPathMatch(parsedExample.path, path)) { + examples.push(parsedExample); + } + } + } + } + + return examples; + } + + /** + * OPTIMIZED: Quick check if paths could potentially match + * Returns true if we should include this example for scoring + */ + private couldPathMatch(examplePath: string, requestPath: string): boolean { + // Exact match + if (examplePath === requestPath) return true; + + // Check if path structure could match (same number of segments) + const exampleParts = examplePath.split('/').filter(Boolean); + const requestParts = requestPath.split('/').filter(Boolean); + + if (exampleParts.length !== requestParts.length) { + return false; // Different structure, can't match + } + + // Quick check: if example has variables (Hoppscotch uses <> syntax), it could match + if (examplePath.includes('<<')) { + return true; // Has variables, needs full scoring + } + + // No variables and not exact match = no match + return false; + } + + /** + * Get collection IDs for the mock server (no caching) + */ + private async getCollectionIds(mockServer: dbMockServer): Promise { + return mockServer.workspaceType === WorkspaceType.USER + ? await this.getAllUserCollectionIds(mockServer.collectionID) + : await this.getAllTeamCollectionIds(mockServer.collectionID); + } + + /** + * Get all collection IDs including children (recursive) + */ + private async getAllUserCollectionIds( + rootCollectionId: string, + ): Promise { + // First verify the root collection exists + const rootCollection = await this.prisma.userCollection.findUnique({ + where: { id: rootCollectionId }, + }); + + if (!rootCollection) return []; // Collection doesn't exist + + const ids = [rootCollectionId]; + const children = await this.prisma.userCollection.findMany({ + where: { parentID: rootCollectionId }, + select: { id: true }, + }); + + for (const child of children) { + const childIds = await this.getAllUserCollectionIds(child.id); + ids.push(...childIds); + } + + return ids; + } + + /** + * Get all team collection IDs including children (recursive) + */ + private async getAllTeamCollectionIds( + rootCollectionId: string, + ): Promise { + // First verify the root collection exists + const rootCollection = await this.prisma.teamCollection.findUnique({ + where: { id: rootCollectionId }, + }); + + if (!rootCollection) return []; // Collection doesn't exist + + const ids = [rootCollectionId]; + const children = await this.prisma.teamCollection.findMany({ + where: { parentID: rootCollectionId }, + select: { id: true }, + }); + + for (const child of children) { + const childIds = await this.getAllTeamCollectionIds(child.id); + ids.push(...childIds); + } + + return ids; + } + + /** + * Parse example from database format to internal format + */ + private parseExample(exampleData: any, requestId: string) { + try { + // Parse endpoint to extract path and query parameters + let endpointString = String(exampleData.endpoint ?? ''); + let path = '/'; + const queryParams: Record = {}; + + // If endpoint starts with '<<', then cut the string after '>>' + if (endpointString.startsWith('<<')) { + const endIndex = endpointString.indexOf('>>'); + if (endIndex !== -1) { + endpointString = endpointString.slice(endIndex + 2); + } + } + + // Remove domain if present + endpointString = endpointString.replace( + /^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}/, + '', + ); + + // Use URL to parse path and query parameters + const url = new URL( + endpointString, + 'http://dummy.com', // Base URL for parsing + ); + + // Decode the pathname to preserve Hoppscotch variable syntax (<>) + path = decodeURIComponent(url.pathname); + + // Extract query parameters + url.searchParams.forEach((value, key) => { + queryParams[key] = value; + }); + + return { + id: exampleData.key || `${requestId}-${exampleData.name}`, + name: exampleData.name, + method: exampleData.method || 'GET', + endpoint: exampleData.endpoint, + path, + queryParams, + statusCode: exampleData.statusCode || 200, + statusText: exampleData.statusText || 'OK', + responseBody: exampleData.responseBody || '', + responseHeaders: exampleData.responseHeaders || [], + requestHeaders: exampleData.headers || [], + }; + } catch (error) { + console.error('Error parsing example:', error); + return null; + } + } + + /** + * Calculate match score for an example based on Postman's algorithm + * Starting score: 100 + * URL path match: exact match keeps 100, no match = 0 + * Query parameters: percentage based on matches + */ + private calculateMatchScore( + example: any, + requestPath: string, + requestQueryParams: Record, + ): number { + let score = 100; + + // URL Path matching + if (example.path !== requestPath) { + // Try wildcard matching (basic implementation) + const examplePathParts = example.path.split('/').filter(Boolean); + const requestPathParts = requestPath.split('/').filter(Boolean); + + if (examplePathParts.length !== requestPathParts.length) { + return 0; // Path structure doesn't match + } + + // Check each segment + let pathMatches = true; + for (let i = 0; i < examplePathParts.length; i++) { + const examplePart = examplePathParts[i]; + const requestPart = requestPathParts[i]; + + // Check if it's a variable (Hoppscotch uses <> syntax) + if ( + examplePart === requestPart || + examplePart.startsWith('<<') || + examplePart.includes('<<') + ) { + continue; // Match + } else { + pathMatches = false; + break; + } + } + + if (!pathMatches) { + return 0; // No path match + } + + // Path has variables, reduce score slightly + score -= 5; + } + + // Query parameter matching + const exampleParams = example.queryParams || {}; + const exampleParamKeys = Object.keys(exampleParams); + const requestParamKeys = Object.keys(requestQueryParams); + + if (exampleParamKeys.length > 0 || requestParamKeys.length > 0) { + let paramMatches = 0; + let partialMatches = 0; + let missingParams = 0; + + // Check for matches + exampleParamKeys.forEach((key) => { + if (requestQueryParams[key] !== undefined) { + if (requestQueryParams[key] === exampleParams[key]) { + paramMatches++; + } else { + partialMatches++; + } + } else { + missingParams++; + } + }); + + // Check for extra params in request + requestParamKeys.forEach((key) => { + if (exampleParams[key] === undefined) { + missingParams++; + } + }); + + // Calculate parameter matching percentage + const totalParams = paramMatches + partialMatches + missingParams; + if (totalParams > 0) { + const matchPercentage = (paramMatches / totalParams) * 100; + // Adjust score based on parameter matching + score = score * (matchPercentage / 100); + } + } + + return score; + } + + /** + * Format example response for return + */ + private formatExampleResponse( + example: any, + delayInMs: number, + ): E.Either { + // Convert response headers array to object + const headersObj: Record = {}; + if (example.responseHeaders && Array.isArray(example.responseHeaders)) { + example.responseHeaders.forEach((header: any) => { + if (header.key && header.value) { + headersObj[header.key] = header.value; + } + }); + } + + return E.right({ + statusCode: example.statusCode || 200, + body: example.responseBody || '', + headers: JSON.stringify(headersObj), + delay: delayInMs || 0, + }); + } +} diff --git a/packages/hoppscotch-backend/src/orchestration/sort/sort-team-collection.resolver.ts b/packages/hoppscotch-backend/src/orchestration/sort/sort-team-collection.resolver.ts new file mode 100644 index 0000000..f850b59 --- /dev/null +++ b/packages/hoppscotch-backend/src/orchestration/sort/sort-team-collection.resolver.ts @@ -0,0 +1,104 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, ID, Mutation, Resolver, Subscription } from '@nestjs/graphql'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { TeamCollection } from 'src/team-collection/team-collection.model'; +import { SortService } from './sort.service'; +import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; +import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator'; +import { TeamAccessRole } from 'src/team/team.model'; +import { SortOptions } from 'src/types/SortOptions'; +import * as E from 'fp-ts/Either'; +import { SkipThrottle } from '@nestjs/throttler'; +import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard'; +import { PubSubService } from 'src/pubsub/pubsub.service'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => TeamCollection) +export class SortTeamCollectionResolver { + constructor( + private readonly sortService: SortService, + private readonly pubSub: PubSubService, + ) {} + + // Mutations + @Mutation(() => Boolean, { + description: 'Sort team collections', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.OWNER, TeamAccessRole.EDITOR) + async sortTeamCollections( + @Args({ + name: 'teamID', + description: 'ID of the team', + type: () => ID, + }) + teamID: string, + @Args({ + name: 'parentCollectionID', + description: 'ID of the parent collection', + type: () => ID, + nullable: true, + }) + parentCollectionID: string | null, + @Args({ + name: 'sortOption', + description: 'Sorting option', + type: () => SortOptions, + }) + sortOption: SortOptions, + ): Promise { + const result = await this.sortService.sortTeamCollections( + teamID, + parentCollectionID, + sortOption, + ); + + if (E.isLeft(result)) return false; + return true; + } + + // Subscriptions + @Subscription(() => Boolean, { + description: 'Listen for Team Root Collection Sort Events', + resolve: (value) => value, + }) + @RequiresTeamRole( + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + teamRootCollectionsSorted( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ) { + return this.pubSub.asyncIterator(`team_coll_root/${teamID}/sorted`); + } + + @Subscription(() => ID, { + description: 'Listen for Team Child Collection Sort Events', + resolve: (value) => value, + }) + @RequiresTeamRole( + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + teamChildCollectionsSorted( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ) { + return this.pubSub.asyncIterator(`team_coll_child/${teamID}/sorted`); + } +} diff --git a/packages/hoppscotch-backend/src/orchestration/sort/sort-user-collection.resolver.ts b/packages/hoppscotch-backend/src/orchestration/sort/sort-user-collection.resolver.ts new file mode 100644 index 0000000..ee44ea9 --- /dev/null +++ b/packages/hoppscotch-backend/src/orchestration/sort/sort-user-collection.resolver.ts @@ -0,0 +1,74 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, ID, Mutation, Resolver, Subscription } from '@nestjs/graphql'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { SortService } from './sort.service'; +import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; +import { SortOptions } from 'src/types/SortOptions'; +import * as E from 'fp-ts/Either'; +import { UserCollection } from 'src/user-collection/user-collections.model'; +import { GqlUser } from 'src/decorators/gql-user.decorator'; +import { AuthUser } from 'src/types/AuthUser'; +import { SkipThrottle } from '@nestjs/throttler'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { UserCollectionSortData } from './sort.model'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => UserCollection) +export class SortUserCollectionResolver { + constructor( + private readonly sortService: SortService, + private readonly pubSub: PubSubService, + ) {} + + // Mutations + @Mutation(() => Boolean, { + description: 'Sort user collections', + }) + @UseGuards(GqlAuthGuard) + async sortUserCollections( + @GqlUser() user: AuthUser, + @Args({ + name: 'parentCollectionID', + description: 'ID of the parent collection', + type: () => ID, + nullable: true, + }) + parentCollectionID: string | null, + @Args({ + name: 'sortOption', + description: 'Sorting option', + type: () => SortOptions, + }) + sortOption: SortOptions, + ): Promise { + const result = await this.sortService.sortUserCollections( + user.uid, + parentCollectionID, + sortOption, + ); + + if (E.isLeft(result)) return false; + return true; + } + + // Subscriptions + @Subscription(() => UserCollectionSortData, { + description: 'Listen for User Root Collection Sort Events', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userRootCollectionsSorted(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_coll_root/${user.uid}/sorted`); + } + + @Subscription(() => UserCollectionSortData, { + description: 'Listen for User Child Collection Sort Events', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userChildCollectionsSorted(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_coll_child/${user.uid}/sorted`); + } +} diff --git a/packages/hoppscotch-backend/src/orchestration/sort/sort.model.ts b/packages/hoppscotch-backend/src/orchestration/sort/sort.model.ts new file mode 100644 index 0000000..acd13fd --- /dev/null +++ b/packages/hoppscotch-backend/src/orchestration/sort/sort.model.ts @@ -0,0 +1,16 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; +import { SortOptions } from 'src/types/SortOptions'; + +@ObjectType() +export class UserCollectionSortData { + @Field(() => ID, { + description: 'ID of the parent collection', + nullable: true, + }) + parentCollectionID: string; + + @Field(() => SortOptions, { + description: 'Sorting option', + }) + sortOption: SortOptions; +} diff --git a/packages/hoppscotch-backend/src/orchestration/sort/sort.module.ts b/packages/hoppscotch-backend/src/orchestration/sort/sort.module.ts new file mode 100644 index 0000000..94ff56c --- /dev/null +++ b/packages/hoppscotch-backend/src/orchestration/sort/sort.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { SortTeamCollectionResolver } from './sort-team-collection.resolver'; +import { SortService } from './sort.service'; +import { TeamCollectionModule } from 'src/team-collection/team-collection.module'; +import { TeamRequestModule } from 'src/team-request/team-request.module'; +import { SortUserCollectionResolver } from './sort-user-collection.resolver'; +import { UserCollectionModule } from 'src/user-collection/user-collection.module'; +import { UserRequestModule } from 'src/user-request/user-request.module'; +import { TeamModule } from 'src/team/team.module'; + +@Module({ + imports: [ + UserCollectionModule, + UserRequestModule, + TeamModule, + TeamCollectionModule, + TeamRequestModule, + ], + providers: [ + SortUserCollectionResolver, + SortTeamCollectionResolver, + SortService, + ], +}) +export class SortModule {} diff --git a/packages/hoppscotch-backend/src/orchestration/sort/sort.service.spec.ts b/packages/hoppscotch-backend/src/orchestration/sort/sort.service.spec.ts new file mode 100644 index 0000000..8a1d1e8 --- /dev/null +++ b/packages/hoppscotch-backend/src/orchestration/sort/sort.service.spec.ts @@ -0,0 +1,180 @@ +import { mockDeep } from 'jest-mock-extended'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { TeamCollectionService } from 'src/team-collection/team-collection.service'; +import { TeamRequestService } from 'src/team-request/team-request.service'; +import { UserRequestService } from 'src/user-request/user-request.service'; +import { UserCollectionService } from 'src/user-collection/user-collection.service'; +import { SortService } from './sort.service'; +import { SortOptions } from 'src/types/SortOptions'; +import * as E from 'fp-ts/Either'; +import { + TEAM_COL_REORDERING_FAILED, + TEAM_REQ_REORDERING_FAILED, +} from 'src/errors'; + +const mockUserRequestService = mockDeep(); +const mockUserCollectionService = mockDeep(); +const mockTeamCollectionService = mockDeep(); +const mockTeamRequestService = mockDeep(); +const mockPubSub = mockDeep(); + +const sortService = new SortService( + mockUserCollectionService, + mockUserRequestService, + mockTeamCollectionService, + mockTeamRequestService, + mockPubSub, +); + +beforeEach(() => { + mockPubSub.publish.mockClear(); +}); + +describe('sortTeamCollections', () => { + it('should return left if teamCollectionService.sortTeamCollections fails', async () => { + mockTeamCollectionService.sortTeamCollections.mockResolvedValue( + E.left(TEAM_COL_REORDERING_FAILED), + ); + const result = await sortService.sortTeamCollections( + 'teamID', + 'parentCollectionID', + SortOptions.TITLE_ASC, + ); + expect(result).toEqual(E.left(TEAM_COL_REORDERING_FAILED)); + expect(mockTeamCollectionService.sortTeamCollections).toHaveBeenCalledWith( + 'teamID', + 'parentCollectionID', + SortOptions.TITLE_ASC, + ); + }); + it('should return left if teamRequestService.sortTeamRequests fails', async () => { + mockTeamCollectionService.sortTeamCollections.mockResolvedValue( + E.right(true), + ); + mockTeamRequestService.sortTeamRequests.mockResolvedValue( + E.left(TEAM_REQ_REORDERING_FAILED), + ); + const result = await sortService.sortTeamCollections( + 'teamID', + 'parentCollectionID', + SortOptions.TITLE_ASC, + ); + expect(result).toEqual(E.left(TEAM_REQ_REORDERING_FAILED)); + expect(mockTeamRequestService.sortTeamRequests).toHaveBeenCalledWith( + 'teamID', + 'parentCollectionID', + SortOptions.TITLE_ASC, + ); + }); + it('should publish root event if parentCollectionID is falsy', async () => { + mockTeamCollectionService.sortTeamCollections.mockResolvedValue( + E.right(true), + ); + mockTeamRequestService.sortTeamRequests.mockResolvedValue(E.right(true)); + const result = await sortService.sortTeamCollections( + 'teamID', + null, + SortOptions.TITLE_ASC, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_coll_root/teamID/sorted`, + true, + ); + expect(result).toEqual(E.right(true)); + }); + it('should publish child event if parentCollectionID is truthy', async () => { + mockTeamCollectionService.sortTeamCollections.mockResolvedValue( + E.right(true), + ); + mockTeamRequestService.sortTeamRequests.mockResolvedValue(E.right(true)); + const result = await sortService.sortTeamCollections( + 'teamID', + 'parentCollectionID', + SortOptions.TITLE_ASC, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_coll_child/teamID/sorted`, + 'parentCollectionID', + ); + expect(result).toEqual(E.right(true)); + }); +}); + +describe('sortUserCollections', () => { + it('should return left if userCollectionService.sortUserCollections fails', async () => { + mockUserCollectionService.sortUserCollections.mockResolvedValue( + E.left('user_coll/reordering_failed'), + ); + const result = await sortService.sortUserCollections( + 'userID', + 'parentCollectionID', + SortOptions.TITLE_ASC, + ); + expect(result).toEqual(E.left('user_coll/reordering_failed')); + expect(mockUserCollectionService.sortUserCollections).toHaveBeenCalledWith( + 'userID', + 'parentCollectionID', + SortOptions.TITLE_ASC, + ); + }); + + it('should return left if userRequestService.sortUserRequests fails', async () => { + mockUserCollectionService.sortUserCollections.mockResolvedValue( + E.right(true), + ); + mockUserRequestService.sortUserRequests.mockResolvedValue( + E.left('user_coll/reordering_failed'), + ); + const result = await sortService.sortUserCollections( + 'userID', + 'parentCollectionID', + SortOptions.TITLE_ASC, + ); + expect(result).toEqual(E.left('user_coll/reordering_failed')); + expect(mockUserRequestService.sortUserRequests).toHaveBeenCalledWith( + 'userID', + 'parentCollectionID', + SortOptions.TITLE_ASC, + ); + }); + + it('should publish root event if parentCollectionID is falsy', async () => { + mockUserCollectionService.sortUserCollections.mockResolvedValue( + E.right(true), + ); + mockUserRequestService.sortUserRequests.mockResolvedValue(E.right(true)); + const result = await sortService.sortUserCollections( + 'userID', + null, + SortOptions.TITLE_ASC, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll_root/userID/sorted`, + { + parentCollectionID: null, + sortOption: SortOptions.TITLE_ASC, + }, + ); + expect(result).toEqual(E.right(true)); + }); + + it('should publish child event if parentCollectionID is truthy', async () => { + mockUserCollectionService.sortUserCollections.mockResolvedValue( + E.right(true), + ); + mockUserRequestService.sortUserRequests.mockResolvedValue(E.right(true)); + const result = await sortService.sortUserCollections( + 'userID', + 'parentCollectionID', + SortOptions.TITLE_ASC, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll_child/userID/sorted`, + { + parentCollectionID: 'parentCollectionID', + sortOption: SortOptions.TITLE_ASC, + }, + ); + expect(result).toEqual(E.right(true)); + }); +}); diff --git a/packages/hoppscotch-backend/src/orchestration/sort/sort.service.ts b/packages/hoppscotch-backend/src/orchestration/sort/sort.service.ts new file mode 100644 index 0000000..7f057e2 --- /dev/null +++ b/packages/hoppscotch-backend/src/orchestration/sort/sort.service.ts @@ -0,0 +1,92 @@ +import { Injectable } from '@nestjs/common'; +import { TeamCollectionService } from 'src/team-collection/team-collection.service'; +import * as E from 'fp-ts/Either'; +import { SortOptions } from 'src/types/SortOptions'; +import { TeamRequestService } from 'src/team-request/team-request.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { UserRequestService } from 'src/user-request/user-request.service'; +import { UserCollectionService } from 'src/user-collection/user-collection.service'; + +@Injectable() +export class SortService { + constructor( + private readonly userCollectionService: UserCollectionService, + private readonly userRequestService: UserRequestService, + private readonly teamCollectionService: TeamCollectionService, + private readonly teamRequestService: TeamRequestService, + private readonly pubsub: PubSubService, + ) {} + + async sortTeamCollections( + teamID: string, + parentCollectionID: string, + sortOption: SortOptions, + ) { + const isCollectionSorted = + await this.teamCollectionService.sortTeamCollections( + teamID, + parentCollectionID, + sortOption, + ); + + if (E.isLeft(isCollectionSorted)) return E.left(isCollectionSorted.left); + + const isRequestSorted = await this.teamRequestService.sortTeamRequests( + teamID, + parentCollectionID, + sortOption, + ); + + if (E.isLeft(isRequestSorted)) return E.left(isRequestSorted.left); + + // Publish the sort event + if (!parentCollectionID) { + this.pubsub.publish(`team_coll_root/${teamID}/sorted`, true); + } else { + this.pubsub.publish( + `team_coll_child/${teamID}/sorted`, + parentCollectionID, + ); + } + + return E.right(true); + } + + async sortUserCollections( + userID: string, + parentCollectionID: string, + sortOption: SortOptions, + ) { + const isCollectionSorted = + await this.userCollectionService.sortUserCollections( + userID, + parentCollectionID, + sortOption, + ); + + if (E.isLeft(isCollectionSorted)) return E.left(isCollectionSorted.left); + + const isRequestSorted = await this.userRequestService.sortUserRequests( + userID, + parentCollectionID, + sortOption, + ); + + if (E.isLeft(isRequestSorted)) return E.left(isRequestSorted.left); + + // Publish the sort event + if (!parentCollectionID) { + this.pubsub.publish(`user_coll_root/${userID}/sorted`, { + parentCollectionID, + sortOption, + }); + } else { + this.pubsub.publish(`user_coll_child/${userID}/sorted`, { + parentCollectionID, + sortOption, + }); + } + + return E.right(true); + } +} diff --git a/packages/hoppscotch-backend/src/plugins/GQLComplexityPlugin.ts b/packages/hoppscotch-backend/src/plugins/GQLComplexityPlugin.ts new file mode 100644 index 0000000..31655fa --- /dev/null +++ b/packages/hoppscotch-backend/src/plugins/GQLComplexityPlugin.ts @@ -0,0 +1,54 @@ +import { GraphQLSchemaHost } from '@nestjs/graphql'; +import { + ApolloServerPlugin, + BaseContext, + GraphQLRequestListener, +} from '@apollo/server'; +import { Plugin } from '@nestjs/apollo'; +import { GraphQLError } from 'graphql'; +import { + ComplexityEstimatorArgs, + fieldExtensionsEstimator, + getComplexity, + simpleEstimator, +} from 'graphql-query-complexity'; + +const COMPLEXITY_LIMIT = 50; + +@Plugin() +export class GQLComplexityPlugin implements ApolloServerPlugin { + constructor(private gqlSchemaHost: GraphQLSchemaHost) {} + + async requestDidStart(): Promise> { + const { schema } = this.gqlSchemaHost; + + return { + async didResolveOperation({ request, document }) { + const complexity = getComplexity({ + schema, + operationName: request.operationName, + query: document, + variables: request.variables, + estimators: [ + // Custom estimator for introspection fields + (args: ComplexityEstimatorArgs) => { + const fieldName = args.field.name; + if (fieldName.startsWith('__')) { + return 0; // Return 0 complexity for introspection fields + } + return; + }, + fieldExtensionsEstimator(), + simpleEstimator({ defaultComplexity: 1 }), + ], + }); + if (complexity > COMPLEXITY_LIMIT) { + throw new GraphQLError( + `Query is too complex: ${complexity}. Maximum allowed complexity: ${COMPLEXITY_LIMIT}`, + ); + } + console.log('Query Complexity:', complexity); + }, + }; + } +} diff --git a/packages/hoppscotch-backend/src/posthog/posthog.module.ts b/packages/hoppscotch-backend/src/posthog/posthog.module.ts new file mode 100644 index 0000000..6e20851 --- /dev/null +++ b/packages/hoppscotch-backend/src/posthog/posthog.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { PostHogService } from './posthog.service'; + +@Module({ + providers: [PostHogService], +}) +export class PostHogModule {} diff --git a/packages/hoppscotch-backend/src/posthog/posthog.service.ts b/packages/hoppscotch-backend/src/posthog/posthog.service.ts new file mode 100644 index 0000000..b1ff84d --- /dev/null +++ b/packages/hoppscotch-backend/src/posthog/posthog.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { PostHog } from 'posthog-node'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { POSTHOG_CLIENT_NOT_INITIALIZED } from 'src/errors'; +import { throwErr } from 'src/utils'; + +@Injectable() +export class PostHogService { + private postHogClient: PostHog; + private POSTHOG_API_KEY = 'phc_9CipPajQC22mSkk2wxe2TXsUA0Ysyupe8dt5KQQELqx'; + + constructor( + private readonly configService: ConfigService, + private readonly prisma: PrismaService, + ) {} + + async onModuleInit() { + if (this.configService.get('INFRA.ALLOW_ANALYTICS_COLLECTION') === 'true') { + console.log('Initializing PostHog'); + this.postHogClient = new PostHog(this.POSTHOG_API_KEY, { + host: 'https://eu.posthog.com', + }); + } + } + + @Cron(CronExpression.EVERY_WEEK) + async handleCron() { + if (this.configService.get('INFRA.ALLOW_ANALYTICS_COLLECTION') === 'true') { + await this.capture(); + } + } + + async capture() { + if (!this.postHogClient) { + throwErr(POSTHOG_CLIENT_NOT_INITIALIZED); + } + + this.postHogClient.capture({ + distinctId: this.configService.get('INFRA.ANALYTICS_USER_ID'), + event: 'sh_instance', + properties: { + type: 'COMMUNITY', + total_user_count: await this.prisma.user.count(), + total_workspace_count: await this.prisma.team.count(), + version: this.configService.get('npm_package_version'), + }, + }); + console.log('Sent event to PostHog'); + } +} diff --git a/packages/hoppscotch-backend/src/prisma/prisma-error-codes.ts b/packages/hoppscotch-backend/src/prisma/prisma-error-codes.ts new file mode 100644 index 0000000..432a571 --- /dev/null +++ b/packages/hoppscotch-backend/src/prisma/prisma-error-codes.ts @@ -0,0 +1,8 @@ +export enum PrismaError { + DATABASE_UNREACHABLE = 'P1001', + TABLE_DOES_NOT_EXIST = 'P2021', + UNIQUE_CONSTRAINT_VIOLATION = 'P2002', + RECORD_NOT_FOUND = 'P2025', + TRANSACTION_TIMEOUT = 'P2028', + TRANSACTION_DEADLOCK = 'P2034', // write conflict or a deadlock +} diff --git a/packages/hoppscotch-backend/src/prisma/prisma.module.ts b/packages/hoppscotch-backend/src/prisma/prisma.module.ts new file mode 100644 index 0000000..baddda2 --- /dev/null +++ b/packages/hoppscotch-backend/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common/decorators'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/packages/hoppscotch-backend/src/prisma/prisma.service.ts b/packages/hoppscotch-backend/src/prisma/prisma.service.ts new file mode 100644 index 0000000..fe87192 --- /dev/null +++ b/packages/hoppscotch-backend/src/prisma/prisma.service.ts @@ -0,0 +1,198 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient, Prisma } from 'src/generated/prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; +import pg from 'pg'; +import { parseIntSafe } from 'src/utils'; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + private readonly pool: pg.Pool; + + constructor() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is not set'); + } + + const parsed = PrismaService.parseDatabaseUrl(databaseUrl); + + // Generic SSL configuration for all database environments + // Supports: AWS Aurora, Docker, local PostgreSQL, managed databases + const sslConfig = PrismaService.getSSLConfig(parsed.sslMode); + + const pool = new pg.Pool({ + connectionString: parsed.connectionString, + max: parsed.connectionLimit ?? 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: parsed.connectTimeout ?? 10000, + ssl: sslConfig, + }); + + const adapter = new PrismaPg(pool, { + schema: parsed.schema, + }); + + super({ + adapter, + transactionOptions: { + maxWait: 5000, + timeout: 10000, + }, + }); + + this.pool = pool; + } + + /** + * --- SSL Configuration --- + * Generic SSL handling for various database environments + * - Local/Docker: No SSL (sslmode=disable or no sslmode) + * - AWS Aurora/RDS: SSL with relaxed validation (common for managed databases) + * - Custom: Set sslmode=verify-full for strict certificate validation + */ + private static getSSLConfig( + sslMode?: string, + ): false | { rejectUnauthorized: boolean } { + if (!sslMode || sslMode === 'disable') { + // Local PostgreSQL, Docker containers - no SSL + return false; + } + + if (sslMode === 'require' || sslMode === 'prefer' || sslMode === 'allow') { + // AWS Aurora, managed databases - SSL with relaxed validation + // This is a pragmatic approach for cloud databases where: + // - Connection is encrypted (prevents eavesdropping) + // - Network isolation (VPC/firewall) provides additional security + // - Certificate validation issues are common with managed services + return { rejectUnauthorized: false }; + } + + if (sslMode === 'verify-ca' || sslMode === 'verify-full') { + // Strict certificate validation - requires proper CA certificates + // Note: May require additional configuration for Prisma v7 + adapter-pg + return { rejectUnauthorized: true }; + } + + // Default to no SSL for unknown modes + return false; + } + + /** + * --- DATABASE_URL Parser --- + * Accepts: + * ?schema=custom + * ?connection_limit=10 + * ?connect_timeout=5000 + * ?sslmode=disable|prefer|require|verify-ca|verify-full + */ + private static parseDatabaseUrl(databaseUrl: string): { + connectionString: string; + schema: string; + connectionLimit?: number; + connectTimeout?: number; + sslMode?: string; + } { + try { + const url = new URL(databaseUrl); + const schema = url.searchParams.get('schema') || 'public'; + const connectionLimit = parseIntSafe( + url.searchParams.get('connection_limit'), + ); + const connectTimeout = parseIntSafe( + url.searchParams.get('connect_timeout'), + ); + const sslMode = url.searchParams.get('sslmode'); + + // Remove all custom parameters including sslmode + // We handle SSL configuration programmatically via the ssl option + url.searchParams.delete('schema'); + url.searchParams.delete('connection_limit'); + url.searchParams.delete('connect_timeout'); + url.searchParams.delete('sslmode'); + + return { + connectionString: url.toString(), + schema, + connectionLimit, + connectTimeout, + sslMode: sslMode || undefined, + }; + } catch (error) { + throw new Error( + `Invalid DATABASE_URL format: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + async onModuleInit() { + try { + // Verify pool connectivity + const client = await this.pool.connect(); + client.release(); + + await this.$connect(); + } catch (error) { + throw new Error(`Database connection failed: ${error.message}`); + } + } + + async onModuleDestroy() { + await this.$disconnect(); + await this.pool.end(); + } + + /** + * Locks rows in TeamCollection for a specific teamId and parentID. + */ + async lockTeamCollectionByTeamAndParent( + tx: Prisma.TransactionClient, + teamId: string, + parentID: string | null, + ) { + const lockQuery = parentID + ? Prisma.sql`SELECT "orderIndex" FROM "TeamCollection" WHERE "teamID" = ${teamId} AND "parentID" = ${parentID} FOR UPDATE` + : Prisma.sql`SELECT "orderIndex" FROM "TeamCollection" WHERE "teamID" = ${teamId} AND "parentID" IS NULL FOR UPDATE`; + return tx.$executeRaw(lockQuery); + } + + /** + * Locks rows in TeamRequest for specific teamID and collectionIDs. + */ + async lockTeamRequestByCollections( + tx: Prisma.TransactionClient, + teamID: string, + collectionIDs: string[], + ) { + const lockQuery = Prisma.sql`SELECT "orderIndex" FROM "TeamRequest" WHERE "teamID" = ${teamID} AND "collectionID" IN (${Prisma.join(collectionIDs)}) FOR UPDATE`; + return tx.$executeRaw(lockQuery); + } + + /** + * Locks rows in UserCollection for a specific userUid and parentID. + */ + async lockUserCollectionByParent( + tx: Prisma.TransactionClient, + userUid: string, + parentID: string | null, + ) { + const lockQuery = parentID + ? Prisma.sql`SELECT "orderIndex" FROM "UserCollection" WHERE "userUid" = ${userUid} AND "parentID" = ${parentID} FOR UPDATE` + : Prisma.sql`SELECT "orderIndex" FROM "UserCollection" WHERE "userUid" = ${userUid} AND "parentID" IS NULL FOR UPDATE`; + return tx.$executeRaw(lockQuery); + } + + /** + * Locks rows in UserRequest for specific userUid and collectionIDs. + */ + async lockUserRequestByCollections( + tx: Prisma.TransactionClient, + userUid: string, + collectionIDs: string[] = [], + ) { + const lockQuery = Prisma.sql`SELECT "orderIndex" FROM "UserRequest" WHERE "userUid" = ${userUid} AND "collectionID" IN (${Prisma.join(collectionIDs)}) FOR UPDATE`; + return tx.$executeRaw(lockQuery); + } +} diff --git a/packages/hoppscotch-backend/src/published-docs/input-type.args.ts b/packages/hoppscotch-backend/src/published-docs/input-type.args.ts new file mode 100644 index 0000000..f0bd316 --- /dev/null +++ b/packages/hoppscotch-backend/src/published-docs/input-type.args.ts @@ -0,0 +1,156 @@ +import { InputType, Field, ArgsType, ID } from '@nestjs/graphql'; +import { + IsBoolean, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + Matches, +} from 'class-validator'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; +import { WorkspaceType } from 'src/types/WorkspaceTypes'; + +@ArgsType() +export class FetchPublishedDocsArgs extends OffsetPaginationArgs { + @IsNotEmpty() + @Field(() => ID, { + name: 'teamID', + description: 'ID of the team', + }) + teamID: string; + + @Field(() => ID, { + name: 'collectionID', + description: 'Id of the collection to add to', + nullable: true, + }) + @IsString() + @IsOptional() + collectionID: string | undefined; +} + +@InputType() +export class CreatePublishedDocsArgs { + @IsString() + @IsNotEmpty() + @Field({ + name: 'title', + description: 'Title of the published document', + }) + title: string; + + @IsString() + @IsNotEmpty() + @Field({ + name: 'version', + description: 'Version of the published document', + }) + @Matches(/^[a-zA-Z0-9.-]+$/, { + message: + 'Version must only contain alphanumeric characters, dots, and hyphens', + }) + version: string; + + @IsBoolean() + @Field({ + name: 'autoSync', + description: + 'Whether the published document should auto-sync with the source', + }) + autoSync: boolean; + + @IsEnum(WorkspaceType) + @Field(() => WorkspaceType, { + name: 'workspaceType', + description: 'Type of the workspace (e.g., personal, team)', + }) + workspaceType: WorkspaceType; + + @IsString() + @Field({ + name: 'workspaceID', + description: 'ID of the workspace', + }) + workspaceID: string; + + @IsString() + @IsNotEmpty() + @Field({ + name: 'collectionID', + description: + 'ID of the source (personal/team) collection from which to publish', + }) + collectionID: string; + + @IsString() + @IsNotEmpty() + @Field({ + name: 'metadata', + description: 'Metadata associated with the published document', + }) + metadata: string; + + @Field({ + name: 'environmentID', + description: + 'ID of the environment to associate with the published document', + nullable: true, + }) + @IsOptional() + @IsString() + environmentID?: string; +} + +@InputType() +export class UpdatePublishedDocsArgs { + @Field({ + name: 'title', + description: 'Title of the published document', + nullable: true, + }) + @IsString() + @IsOptional() + title?: string; + + @Field({ + name: 'version', + description: 'Version of the published document', + nullable: true, + }) + @IsString() + @IsOptional() + @Matches(/^[a-zA-Z0-9.-]+$/, { + message: + 'Version must only contain alphanumeric characters, dots, and hyphens', + }) + version?: string; + + @Field({ + name: 'autoSync', + description: + 'Whether the published document should auto-sync with the source', + nullable: true, + }) + @IsBoolean() + @IsOptional() + autoSync?: boolean; + + @Field({ + name: 'metadata', + description: 'Metadata associated with the published document', + nullable: true, + }) + @IsString() + @IsOptional() + metadata?: string; + + @Field({ + name: 'environmentID', + description: + 'ID of the environment to associate with the published document. Pass null to remove the environment.', + nullable: true, + }) + @IsString() + @IsOptional() + environmentID?: string; +} diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.controller.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.controller.ts new file mode 100644 index 0000000..25b0778 --- /dev/null +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.controller.ts @@ -0,0 +1,82 @@ +import { + Controller, + Get, + Param, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { PublishedDocsService } from './published-docs.service'; +import * as E from 'fp-ts/Either'; +import { throwHTTPErr } from 'src/utils'; +import { PublishedDocs } from './published-docs.model'; +import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard'; + +@ApiTags('Published Docs') +@Controller({ version: '1', path: 'published-docs' }) +@UseGuards(ThrottlerBehindProxyGuard) +export class PublishedDocsController { + constructor(private readonly publishedDocsService: PublishedDocsService) {} + + @Get(':slug') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get latest published documentation by slug', + description: + 'Returns the latest version of published collection documentation by slug for unauthenticated users.', + }) + @ApiResponse({ + status: 200, + description: 'Successfully retrieved published documentation', + type: () => PublishedDocs, + }) + @ApiResponse({ + status: 404, + description: 'Published documentation not found', + }) + async getPublishedDocsBySlugLatest(@Param('slug') slug: string) { + const result = await this.publishedDocsService.getPublishedDocBySlugPublic( + slug, + null, + ); + + if (E.isLeft(result)) { + throwHTTPErr({ message: result.left, statusCode: HttpStatus.NOT_FOUND }); + } + + return result.right; + } + + @Get(':slug/:version') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get published documentation by slug and version', + description: + 'Returns published collection documentation by slug and version for unauthenticated users.', + }) + @ApiResponse({ + status: 200, + description: 'Successfully retrieved published documentation', + type: () => PublishedDocs, + }) + @ApiResponse({ + status: 404, + description: 'Published documentation not found', + }) + async getPublishedDocsBySlug( + @Param('slug') slug: string, + @Param('version') version: string, + ) { + const result = await this.publishedDocsService.getPublishedDocBySlugPublic( + slug, + version, + ); + + if (E.isLeft(result)) { + throwHTTPErr({ message: result.left, statusCode: HttpStatus.NOT_FOUND }); + } + + return result.right; + } +} diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.model.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.model.ts new file mode 100644 index 0000000..08ee1b9 --- /dev/null +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.model.ts @@ -0,0 +1,242 @@ +import { ObjectType, Field, ID } from '@nestjs/graphql'; +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; + +@ObjectType() +export class PublishedDocsVersion { + @Field(() => ID, { + description: 'ID of the published document version', + }) + @ApiProperty({ + description: 'ID of the published document version', + example: 'doc_12345', + }) + @Expose() + id: string; + + @Field(() => String, { + description: 'Slug of the published document', + }) + @ApiProperty({ + description: 'Slug of the published document', + example: 'abc-123-uuid', + }) + @Expose() + slug: string; + + @Field(() => String, { + description: 'Version string', + }) + @ApiProperty({ + description: 'Version string', + example: '1.0.0', + }) + @Expose() + version: string; + + @Field(() => String, { + description: 'Title of the API documentation', + }) + @ApiProperty({ + description: 'Title of the API documentation', + example: 'API Documentation v1.0', + }) + @Expose() + title: string; + + @Field(() => Boolean, { + description: 'Indicates if the documentation is set to auto-sync', + }) + @ApiProperty({ + description: 'Indicates if the documentation is set to auto-sync', + example: true, + }) + @Expose() + autoSync: boolean; + + @Field(() => String, { + description: 'URL where the published API documentation can be accessed', + }) + @ApiProperty({ + description: 'URL where the published API documentation can be accessed', + example: 'https://docs.example.com/api/v1.0', + }) + @Expose() + url: string; +} + +@ObjectType() +export class PublishedDocs { + @Field(() => ID, { + description: 'ID of the published API documentation', + }) + @ApiProperty({ + description: 'ID of the published API documentation', + example: 'doc_12345', + }) + @Expose() + id: string; + + @Field(() => ID, { + description: + 'Slug of the published API documentation (unique with version)', + }) + @ApiProperty({ + description: + 'Slug of the published API documentation (unique with version)', + example: 'my-api-docs', + }) + @Expose() + slug: string; + + @Field({ description: 'Title of the published API documentation' }) + @ApiProperty({ + description: 'Title of the published API documentation', + example: 'My API Documentation', + }) + @Expose() + title: string; + + @Field({ + description: 'URL where the published API documentation can be accessed', + }) + @ApiProperty({ + description: 'URL where the published API documentation can be accessed', + example: 'https://docs.example.com/api', + }) + @Expose() + url: string; + + @Field({ description: 'Version of the published API documentation' }) + @ApiProperty({ + description: 'Version of the published API documentation', + example: '1.0.0', + }) + @Expose() + version: string; + + @Field({ description: 'Indicates if the documentation is set to auto-sync' }) + @ApiProperty({ + description: 'Indicates if the documentation is set to auto-sync', + example: true, + }) + @Expose() + autoSync: boolean; + + @Field({ + description: 'Document tree structure associated with the documentation', + }) + @ApiProperty({ + description: 'Document tree structure associated with the documentation', + example: + '{"id": "string", "name": "string", "folders": [], "requests": [], "data": "string"}', + }) + @Expose() + documentTree: string; + + @Field({ + description: + 'Type of workspace associated with the published documentation', + }) + @ApiProperty({ + description: + 'Type of workspace associated with the published documentation', + example: 'team', + }) + workspaceType: string; + + @Field({ + description: + 'Workspace ID (of team/user ID) associated with the published documentation', + }) + @ApiProperty({ + description: + 'Workspace ID (of team/user ID) associated with the published documentation', + example: 'workspace_12345', + }) + workspaceID: string; + + @Field({ description: 'Metadata of the documentation' }) + @ApiProperty({ + description: 'Metadata of the documentation', + example: '{"author": "John Doe", "tags": ["api", "rest"]}', + }) + @Expose() + metadata: string; + + @Field({ + description: 'Name of the environment associated with the documentation', + nullable: true, + }) + @ApiProperty({ + description: 'Name of the environment associated with the documentation', + example: 'Production', + nullable: true, + }) + @Expose() + environmentName?: string; + + @Field({ + description: + 'Stringified JSON of the environment variables associated with the documentation', + nullable: true, + }) + @ApiProperty({ + description: + 'Stringified JSON of the environment variables associated with the documentation', + example: + '[{"key":"base_url","secret":false,"currentValue":"","initialValue":"http://hoppscotch.com"}]', + nullable: true, + }) + @Expose() + environmentVariables?: string; + + @Field({ description: 'Timestamp when the documentation was created' }) + @ApiProperty({ + description: 'Timestamp when the documentation was created', + example: '2024-01-01T00:00:00.000Z', + }) + @Expose() + createdOn: Date; + + @Field({ description: 'Timestamp when the documentation was last updated' }) + @ApiProperty({ + description: 'Timestamp when the documentation was last updated', + example: '2024-01-15T12:30:00.000Z', + }) + @Expose() + updatedOn: Date; + + @Field(() => [PublishedDocsVersion], { + description: 'All available versions of this published documentation', + nullable: true, + }) + @ApiProperty({ + description: 'All available versions of this published documentation', + type: [PublishedDocsVersion], + }) + @Expose() + @Type(() => PublishedDocsVersion) + versions?: PublishedDocsVersion[]; +} + +@ObjectType() +export class PublishedDocsCollection { + @Field(() => ID, { + description: 'ID of the collection', + }) + @ApiProperty({ + description: 'ID of the collection', + example: 'collection_12345', + }) + id: string; + + @Field({ + description: 'Title of the collection', + }) + @ApiProperty({ + description: 'Title of the collection', + example: 'My API Collection', + }) + title: string; +} diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.module.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.module.ts new file mode 100644 index 0000000..78edf37 --- /dev/null +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { PublishedDocsResolver } from './published-docs.resolver'; +import { PublishedDocsService } from './published-docs.service'; +import { TeamModule } from 'src/team/team.module'; +import { PublishedDocsController } from './published-docs.controller'; +import { UserCollectionModule } from 'src/user-collection/user-collection.module'; +import { TeamCollectionModule } from 'src/team-collection/team-collection.module'; + +@Module({ + imports: [UserCollectionModule, TeamModule, TeamCollectionModule], + controllers: [PublishedDocsController], + providers: [PublishedDocsResolver, PublishedDocsService], +}) +export class PublishedDocsModule {} diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.resolver.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.resolver.ts new file mode 100644 index 0000000..4a4d3f9 --- /dev/null +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.resolver.ts @@ -0,0 +1,210 @@ +import { UseGuards } from '@nestjs/common'; +import { + Args, + ID, + Mutation, + Parent, + ResolveField, + Resolver, + Query, +} from '@nestjs/graphql'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { + PublishedDocs, + PublishedDocsCollection, + PublishedDocsVersion, +} from './published-docs.model'; +import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; +import { GqlUser } from 'src/decorators/gql-user.decorator'; +import { + CreatePublishedDocsArgs, + FetchPublishedDocsArgs, + UpdatePublishedDocsArgs, +} from './input-type.args'; +import { User } from 'src/user/user.model'; +import { PublishedDocsService } from './published-docs.service'; +import * as E from 'fp-ts/Either'; +import { throwErr } from 'src/utils'; +import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; +import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator'; +import { TeamAccessRole } from 'src/team/team.model'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => PublishedDocs) +export class PublishedDocsResolver { + constructor(private readonly publishedDocsService: PublishedDocsService) {} + + // Resolve Fields + + @ResolveField(() => User, { + description: 'Returns the creator of the published document', + nullable: true, + }) + async creator(@Parent() publishedDocs: PublishedDocs): Promise { + const creator = await this.publishedDocsService.getPublishedDocsCreator( + publishedDocs.id, + ); + + if (E.isLeft(creator)) throwErr(creator.left); + return creator.right; + } + + @ResolveField(() => PublishedDocsCollection, { + description: 'Returns the collection of the published document', + }) + async collection( + @Parent() publishedDocs: PublishedDocs, + ): Promise { + const collection = + await this.publishedDocsService.getPublishedDocsCollection( + publishedDocs.id, + ); + + if (E.isLeft(collection)) throwErr(collection.left); + return collection.right; + } + + @ResolveField(() => [PublishedDocsVersion], { + description: 'Returns all versions of the published document (same slug)', + }) + async versions( + @Parent() publishedDocs: PublishedDocs, + ): Promise { + const versions = await this.publishedDocsService.getPublishedDocsVersions( + publishedDocs.slug, + ); + + if (E.isLeft(versions)) throwErr(versions.left); + return versions.right; + } + + // Queries + + @Query(() => PublishedDocs, { + description: 'Get a published document by ID', + }) + @UseGuards(GqlAuthGuard) + async publishedDoc( + @GqlUser() user: User, + @Args({ + name: 'id', + type: () => ID, + description: 'Id of the published document to fetch', + }) + id: string, + ) { + const doc = await this.publishedDocsService.getPublishedDocByID(id, user); + + if (E.isLeft(doc)) throwErr(doc.left); + return doc.right; + } + + @Query(() => [PublishedDocs], { + description: 'Get all published documents of a user', + }) + @UseGuards(GqlAuthGuard) + async userPublishedDocsList( + @GqlUser() user: User, + @Args() args: OffsetPaginationArgs, + ) { + const docs = await this.publishedDocsService.getAllUserPublishedDocs( + user.uid, + args, + ); + return docs; + } + + @Query(() => [PublishedDocs], { + description: 'Get all published documents', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.VIEWER, + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + ) + async teamPublishedDocsList( + @Args() + args: FetchPublishedDocsArgs, + ) { + const docs = await this.publishedDocsService.getAllTeamPublishedDocs( + args.teamID, + args.collectionID, + { skip: args.skip, take: args.take }, + ); + return docs; + } + + // Mutations + + @Mutation(() => PublishedDocs, { + description: 'Create a new published document', + }) + @UseGuards(GqlAuthGuard) + async createPublishedDoc( + @GqlUser() user: User, + @Args({ + name: 'args', + type: () => CreatePublishedDocsArgs, + description: 'Arguments for creating a published document', + }) + args: CreatePublishedDocsArgs, + ) { + const newDoc = await this.publishedDocsService.createPublishedDoc( + args, + user, + ); + + if (E.isLeft(newDoc)) throwErr(newDoc.left); + return newDoc.right; + } + + @Mutation(() => PublishedDocs, { + description: 'Update an existing published document', + }) + @UseGuards(GqlAuthGuard) + async updatePublishedDoc( + @GqlUser() user: User, + @Args({ + name: 'id', + description: 'ID of the published document to update', + type: () => ID, + }) + id: string, + @Args({ + name: 'args', + type: () => UpdatePublishedDocsArgs, + description: 'Arguments for updating a published document', + }) + args: UpdatePublishedDocsArgs, + ) { + const updatedDoc = await this.publishedDocsService.updatePublishedDoc( + id, + args, + user, + ); + + if (E.isLeft(updatedDoc)) throwErr(updatedDoc.left); + return updatedDoc.right; + } + + @Mutation(() => Boolean, { + description: 'Delete a published document by ID', + }) + @UseGuards(GqlAuthGuard) + async deletePublishedDoc( + @GqlUser() user: User, + @Args({ + name: 'id', + description: 'ID of the published document to delete', + type: () => ID, + }) + id: string, + ) { + const result = await this.publishedDocsService.deletePublishedDoc(id, user); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } +} diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.service.spec.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.service.spec.ts new file mode 100644 index 0000000..3b43b6f --- /dev/null +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.service.spec.ts @@ -0,0 +1,1976 @@ +import { PublishedDocs as DBPublishedDocs } from 'src/generated/prisma/client'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { + PUBLISHED_DOCS_CREATION_FAILED, + PUBLISHED_DOCS_DELETION_FAILED, + PUBLISHED_DOCS_INVALID_COLLECTION, + PUBLISHED_DOCS_NOT_FOUND, + PUBLISHED_DOCS_UPDATE_FAILED, + TEAM_ENVIRONMENT_NOT_FOUND, + TEAM_INVALID_ID, + USER_ENVIRONMENT_NOT_FOUND, +} from 'src/errors'; +import * as E from 'fp-ts/Either'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { User } from 'src/user/user.model'; +import { WorkspaceType } from 'src/types/WorkspaceTypes'; +import { PublishedDocsService } from './published-docs.service'; +import { PublishedDocs } from './published-docs.model'; +import { UserCollectionService } from 'src/user-collection/user-collection.service'; +import { TeamCollectionService } from 'src/team-collection/team-collection.service'; +import { + CreatePublishedDocsArgs, + UpdatePublishedDocsArgs, +} from './input-type.args'; +import { TeamAccessRole } from 'src/team/team.model'; +import { ConfigService } from '@nestjs/config'; + +const mockPrisma = mockDeep(); +const mockUserCollectionService = mockDeep(); +const mockTeamCollectionService = mockDeep(); +const mockConfigService = mockDeep(); + +const publishedDocsService = new PublishedDocsService( + mockPrisma, + mockUserCollectionService, + mockTeamCollectionService, + mockConfigService, +); + +const currentTime = new Date(); + +const user: User = { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, + currentGQLSession: {} as any, + currentRESTSession: {} as any, +}; + +const userPublishedDoc: DBPublishedDocs = { + id: 'pub_doc_1', + slug: 'slug-collection-1', + title: 'User API Documentation', + version: '1.0.0', + autoSync: true, + documentTree: {}, + workspaceType: WorkspaceType.USER, + workspaceID: user.uid, + collectionID: 'collection_1', + creatorUid: user.uid, + metadata: {}, + environmentID: null, + environmentName: null, + environmentVariables: null, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const userPublishedDocCasted: PublishedDocs = { + id: userPublishedDoc.id, + slug: userPublishedDoc.slug, + title: userPublishedDoc.title, + version: userPublishedDoc.version, + autoSync: userPublishedDoc.autoSync, + documentTree: JSON.stringify(userPublishedDoc.documentTree), + workspaceType: userPublishedDoc.workspaceType, + workspaceID: userPublishedDoc.workspaceID, + metadata: JSON.stringify(userPublishedDoc.metadata), + environmentName: null, + environmentVariables: null, + createdOn: userPublishedDoc.createdOn, + updatedOn: userPublishedDoc.updatedOn, + url: `${mockConfigService.get('VITE_BASE_URL')}/view/${userPublishedDoc.slug}/${userPublishedDoc.version}`, +}; + +const teamPublishedDoc: DBPublishedDocs = { + id: 'pub_doc_2', + slug: 'slug-team-collection-1', + title: 'Team API Documentation', + version: '1.0.0', + autoSync: true, + documentTree: {}, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team_1', + collectionID: 'team_collection_1', + creatorUid: user.uid, + metadata: {}, + environmentID: null, + environmentName: null, + environmentVariables: null, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const teamPublishedDocCasted: PublishedDocs = { + id: teamPublishedDoc.id, + slug: teamPublishedDoc.slug, + title: teamPublishedDoc.title, + version: teamPublishedDoc.version, + autoSync: teamPublishedDoc.autoSync, + documentTree: JSON.stringify(teamPublishedDoc.documentTree), + workspaceType: teamPublishedDoc.workspaceType, + workspaceID: teamPublishedDoc.workspaceID, + metadata: JSON.stringify(teamPublishedDoc.metadata), + environmentName: null, + environmentVariables: null, + createdOn: teamPublishedDoc.createdOn, + updatedOn: teamPublishedDoc.updatedOn, + url: `${mockConfigService.get('VITE_BASE_URL')}/view/${teamPublishedDoc.slug}/${teamPublishedDoc.version}`, +}; + +beforeEach(() => { + mockReset(mockPrisma); +}); + +describe('getPublishedDocByID', () => { + test('should return a published document with valid ID and user access', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + + const result = await publishedDocsService.getPublishedDocByID( + userPublishedDoc.id, + user, + ); + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toMatchObject(userPublishedDocCasted); + } + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when ID is invalid', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.getPublishedDocByID( + 'invalid_id', + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when user does not have access', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...userPublishedDoc, + creatorUid: 'different_user', + }); + + const result = await publishedDocsService.getPublishedDocByID( + userPublishedDoc.id, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); + + test('should return team published document when user has team access', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + + const result = await publishedDocsService.getPublishedDocByID( + teamPublishedDoc.id, + user, + ); + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toMatchObject(teamPublishedDocCasted); + } + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when user does not have team access', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce(null); + + const result = await publishedDocsService.getPublishedDocByID( + teamPublishedDoc.id, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); +}); + +describe('getAllUserPublishedDocs', () => { + test('should return a list of user published documents with pagination', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([userPublishedDoc]); + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + { id: 'collection_1' }, + ] as any); + + const result = await publishedDocsService.getAllUserPublishedDocs( + user.uid, + { skip: 0, take: 10 }, + ); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject(userPublishedDocCasted); + }); + + test('should return an empty array when no documents found', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([]); + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + + const result = await publishedDocsService.getAllUserPublishedDocs( + user.uid, + { skip: 0, take: 10 }, + ); + expect(result).toEqual([]); + }); + + test('should return paginated results correctly', async () => { + const docs = [userPublishedDoc, { ...userPublishedDoc, id: 'pub_doc_3' }]; + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([docs[0]]); + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + { id: 'collection_1' }, + ] as any); + + const result = await publishedDocsService.getAllUserPublishedDocs( + user.uid, + { skip: 0, take: 1 }, + ); + expect(result).toHaveLength(1); + }); + + test('should filter out published docs with non-existent collections', async () => { + const doc1 = { + ...userPublishedDoc, + id: 'pub_doc_1', + collectionID: 'collection_1', + }; + const doc2 = { + ...userPublishedDoc, + id: 'pub_doc_2', + collectionID: 'collection_2', + }; + const doc3 = { + ...userPublishedDoc, + id: 'pub_doc_3', + collectionID: 'collection_3', + }; + + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([doc1, doc2, doc3]); + // Only collection_1 and collection_3 exist + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + { id: 'collection_1' }, + { id: 'collection_3' }, + ] as any); + + const result = await publishedDocsService.getAllUserPublishedDocs( + user.uid, + { skip: 0, take: 10 }, + ); + + // Should only return docs with existing collections + expect(result).toHaveLength(2); + expect(result.map((d) => d.id)).toEqual(['pub_doc_1', 'pub_doc_3']); + }); + + test('should delete published docs with non-existent collections', async () => { + const doc1 = { + ...userPublishedDoc, + id: 'pub_doc_1', + collectionID: 'collection_1', + }; + const doc2 = { + ...userPublishedDoc, + id: 'pub_doc_2', + collectionID: 'collection_deleted', + }; + + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([doc1, doc2]); + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + { id: 'collection_1' }, + ] as any); + mockPrisma.publishedDocs.deleteMany.mockResolvedValueOnce({ + count: 1, + } as any); + + await publishedDocsService.getAllUserPublishedDocs(user.uid, { + skip: 0, + take: 10, + }); + + expect(mockPrisma.publishedDocs.deleteMany).toHaveBeenCalledWith({ + where: { + id: { in: ['pub_doc_2'] }, + }, + }); + }); + + test('should not call deleteMany when all collections exist', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([userPublishedDoc]); + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + { id: 'collection_1' }, + ] as any); + + await publishedDocsService.getAllUserPublishedDocs(user.uid, { + skip: 0, + take: 10, + }); + + expect(mockPrisma.publishedDocs.deleteMany).not.toHaveBeenCalled(); + }); +}); + +describe('getAllTeamPublishedDocs', () => { + test('should return a list of team published documents with pagination', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([teamPublishedDoc]); + mockPrisma.teamCollection.findMany.mockResolvedValueOnce([ + { id: 'team_collection_1' }, + ] as any); + + const result = await publishedDocsService.getAllTeamPublishedDocs( + 'team_1', + 'team_collection_1', + { skip: 0, take: 10 }, + ); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject(teamPublishedDocCasted); + }); + + test('should return an empty array when no team documents found', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([]); + mockPrisma.teamCollection.findMany.mockResolvedValueOnce([]); + + const result = await publishedDocsService.getAllTeamPublishedDocs( + 'team_1', + 'team_collection_1', + { skip: 0, take: 10 }, + ); + expect(result).toEqual([]); + }); + + test('should filter by teamID and collectionID correctly', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([teamPublishedDoc]); + mockPrisma.teamCollection.findMany.mockResolvedValueOnce([ + { id: 'team_collection_1' }, + ] as any); + + await publishedDocsService.getAllTeamPublishedDocs( + 'team_1', + 'team_collection_1', + { skip: 0, take: 10 }, + ); + + expect(mockPrisma.publishedDocs.findMany).toHaveBeenCalledWith({ + where: { + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team_1', + collectionID: 'team_collection_1', + }, + skip: 0, + take: 10, + orderBy: { + createdOn: 'desc', + }, + }); + }); + + test('should filter out published docs with non-existent team collections', async () => { + const doc1 = { + ...teamPublishedDoc, + id: 'pub_doc_1', + collectionID: 'team_collection_1', + }; + const doc2 = { + ...teamPublishedDoc, + id: 'pub_doc_2', + collectionID: 'team_collection_2', + }; + const doc3 = { + ...teamPublishedDoc, + id: 'pub_doc_3', + collectionID: 'team_collection_3', + }; + + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([doc1, doc2, doc3]); + // Only team_collection_1 and team_collection_3 exist + mockPrisma.teamCollection.findMany.mockResolvedValueOnce([ + { id: 'team_collection_1' }, + { id: 'team_collection_3' }, + ] as any); + + const result = await publishedDocsService.getAllTeamPublishedDocs( + 'team_1', + undefined, + { skip: 0, take: 10 }, + ); + + // Should only return docs with existing collections + expect(result).toHaveLength(2); + expect(result.map((d) => d.id)).toEqual(['pub_doc_1', 'pub_doc_3']); + }); + + test('should delete published docs with non-existent team collections', async () => { + const doc1 = { + ...teamPublishedDoc, + id: 'pub_doc_1', + collectionID: 'team_collection_1', + }; + const doc2 = { + ...teamPublishedDoc, + id: 'pub_doc_2', + collectionID: 'team_collection_deleted', + }; + + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([doc1, doc2]); + mockPrisma.teamCollection.findMany.mockResolvedValueOnce([ + { id: 'team_collection_1' }, + ] as any); + mockPrisma.publishedDocs.deleteMany.mockResolvedValueOnce({ + count: 1, + } as any); + + await publishedDocsService.getAllTeamPublishedDocs('team_1', undefined, { + skip: 0, + take: 10, + }); + + expect(mockPrisma.publishedDocs.deleteMany).toHaveBeenCalledWith({ + where: { + id: { in: ['pub_doc_2'] }, + }, + }); + }); + + test('should not call deleteMany when all team collections exist', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([teamPublishedDoc]); + mockPrisma.teamCollection.findMany.mockResolvedValueOnce([ + { id: 'team_collection_1' }, + ] as any); + + await publishedDocsService.getAllTeamPublishedDocs( + 'team_1', + 'team_collection_1', + { skip: 0, take: 10 }, + ); + + expect(mockPrisma.publishedDocs.deleteMany).not.toHaveBeenCalled(); + }); +}); + +describe('createPublishedDoc', () => { + const createArgs: CreatePublishedDocsArgs = { + title: 'New API Documentation', + version: '1.0.0', + autoSync: true, + workspaceType: WorkspaceType.USER, + workspaceID: user.uid, + collectionID: 'collection_1', + metadata: '{}', + }; + + test('should successfully create a user published document with valid inputs', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.create.mockResolvedValueOnce(userPublishedDoc); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toMatchObject(userPublishedDocCasted); + } + }); + + test('should successfully create a team published document with valid inputs', async () => { + const teamArgs: CreatePublishedDocsArgs = { + ...createArgs, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team_1', + collectionID: 'team_collection_1', + }; + + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce({ + id: 'team_collection_1', + teamID: 'team_1', + } as any); + mockPrisma.publishedDocs.create.mockResolvedValueOnce(teamPublishedDoc); + + const result = await publishedDocsService.createPublishedDoc( + teamArgs, + user, + ); + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toMatchObject(teamPublishedDocCasted); + } + }); + + test('should throw TEAM_INVALID_ID when team ID is invalid', async () => { + const teamArgs: CreatePublishedDocsArgs = { + ...createArgs, + workspaceType: WorkspaceType.TEAM, + workspaceID: '', + }; + + const result = await publishedDocsService.createPublishedDoc( + teamArgs, + user, + ); + expect(result).toEqualLeft(TEAM_INVALID_ID); + }); + + test('should throw TEAM_INVALID_ID when user does not have team access', async () => { + const teamArgs: CreatePublishedDocsArgs = { + ...createArgs, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team_1', + }; + + mockPrisma.team.findFirst.mockResolvedValueOnce(null); + + const result = await publishedDocsService.createPublishedDoc( + teamArgs, + user, + ); + expect(result).toEqualLeft(TEAM_INVALID_ID); + }); + + test('should throw PUBLISHED_DOCS_INVALID_COLLECTION when user collection is invalid', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_COLLECTION); + }); + + test('should throw PUBLISHED_DOCS_INVALID_COLLECTION when team collection is invalid', async () => { + const teamArgs: CreatePublishedDocsArgs = { + ...createArgs, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team_1', + collectionID: 'invalid_collection', + }; + + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.createPublishedDoc( + teamArgs, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_COLLECTION); + }); + + test('should throw PUBLISHED_DOCS_INVALID_COLLECTION when collection does not belong to user', async () => { + // When Prisma queries with where: { id: 'collection_1', userUid: user.uid } + // and the collection doesn't belong to the user, it returns null + mockPrisma.userCollection.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_COLLECTION); + }); + + test('should throw error when metadata is invalid JSON', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + + const result = await publishedDocsService.createPublishedDoc( + { ...createArgs, metadata: '{invalid' }, + user, + ); + expect(E.isLeft(result)).toBe(true); + }); + + test('should throw PUBLISHED_DOCS_CREATION_FAILED on database error', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.create.mockRejectedValueOnce( + new Error('Database error'), + ); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_CREATION_FAILED); + }); +}); + +describe('updatePublishedDoc', () => { + const updateArgs: UpdatePublishedDocsArgs = { + title: 'Updated API Documentation', + version: '2.0.0', + autoSync: false, + metadata: '{"key": "value"}', + }; + + test('should successfully update a published document with valid inputs', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + // autoSync switching from true → false requires exporting collection snapshot + mockUserCollectionService.exportUserCollectionToJSONObject.mockResolvedValueOnce( + E.right({} as any), + ); + mockPrisma.publishedDocs.update.mockResolvedValueOnce({ + ...userPublishedDoc, + title: updateArgs.title, + version: updateArgs.version, + autoSync: updateArgs.autoSync, + }); + + const result = await publishedDocsService.updatePublishedDoc( + userPublishedDoc.id, + updateArgs, + user, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.title).toBe(updateArgs.title); + expect(result.right.version).toBe(updateArgs.version); + expect(result.right.autoSync).toBe(updateArgs.autoSync); + } + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when document ID is invalid', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.updatePublishedDoc( + 'invalid_id', + updateArgs, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); + + test('should throw PUBLISHED_DOCS_UPDATE_FAILED when user does not have access', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...userPublishedDoc, + creatorUid: 'different_user', + }); + + const result = await publishedDocsService.updatePublishedDoc( + userPublishedDoc.id, + updateArgs, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_UPDATE_FAILED); + }); + + test('should throw PUBLISHED_DOCS_UPDATE_FAILED when user is not OWNER or EDITOR of team', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce(null); + + const result = await publishedDocsService.updatePublishedDoc( + teamPublishedDoc.id, + updateArgs, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_UPDATE_FAILED); + }); + + test('should successfully update team published document when user has OWNER role', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + // autoSync switching from true → false requires exporting collection snapshot + mockTeamCollectionService.exportCollectionToJSONObject.mockResolvedValueOnce( + E.right({} as any), + ); + mockPrisma.publishedDocs.update.mockResolvedValueOnce({ + ...teamPublishedDoc, + title: updateArgs.title, + }); + + const result = await publishedDocsService.updatePublishedDoc( + teamPublishedDoc.id, + updateArgs, + user, + ); + + expect(E.isRight(result)).toBe(true); + }); + + test('should successfully update team published document when user has EDITOR role', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + // autoSync switching from true → false requires exporting collection snapshot + mockTeamCollectionService.exportCollectionToJSONObject.mockResolvedValueOnce( + E.right({} as any), + ); + mockPrisma.publishedDocs.update.mockResolvedValueOnce({ + ...teamPublishedDoc, + title: updateArgs.title, + }); + + const result = await publishedDocsService.updatePublishedDoc( + teamPublishedDoc.id, + updateArgs, + user, + ); + + expect(E.isRight(result)).toBe(true); + }); + + test('should throw error when metadata is invalid JSON', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + + const result = await publishedDocsService.updatePublishedDoc( + userPublishedDoc.id, + { ...updateArgs, metadata: '{invalid' }, + user, + ); + expect(E.isLeft(result)).toBe(true); + }); + + test('should update only provided fields', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.publishedDocs.update.mockResolvedValueOnce({ + ...userPublishedDoc, + title: 'Only Title Updated', + }); + + const result = await publishedDocsService.updatePublishedDoc( + userPublishedDoc.id, + { title: 'Only Title Updated' }, + user, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.title).toBe('Only Title Updated'); + } + }); + + test('should throw PUBLISHED_DOCS_UPDATE_FAILED on database error', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.publishedDocs.update.mockRejectedValueOnce( + new Error('Database error'), + ); + + const result = await publishedDocsService.updatePublishedDoc( + userPublishedDoc.id, + updateArgs, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_UPDATE_FAILED); + }); +}); + +describe('deletePublishedDoc', () => { + test('should successfully delete a user published document', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.publishedDocs.delete.mockResolvedValueOnce(userPublishedDoc); + + const result = await publishedDocsService.deletePublishedDoc( + userPublishedDoc.id, + user, + ); + expect(result).toEqualRight(true); + }); + + test('should successfully delete a team published document when user has OWNER role', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + mockPrisma.publishedDocs.delete.mockResolvedValueOnce(teamPublishedDoc); + + const result = await publishedDocsService.deletePublishedDoc( + teamPublishedDoc.id, + user, + ); + expect(result).toEqualRight(true); + }); + + test('should successfully delete a team published document when user has EDITOR role', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + mockPrisma.publishedDocs.delete.mockResolvedValueOnce(teamPublishedDoc); + + const result = await publishedDocsService.deletePublishedDoc( + teamPublishedDoc.id, + user, + ); + expect(result).toEqualRight(true); + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when document ID is invalid', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.deletePublishedDoc( + 'invalid_id', + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); + + test('should throw PUBLISHED_DOCS_DELETION_FAILED when user does not have access', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...userPublishedDoc, + creatorUid: 'different_user', + }); + + const result = await publishedDocsService.deletePublishedDoc( + userPublishedDoc.id, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_DELETION_FAILED); + }); + + test('should throw PUBLISHED_DOCS_DELETION_FAILED when user is not OWNER or EDITOR of team', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce(null); + + const result = await publishedDocsService.deletePublishedDoc( + teamPublishedDoc.id, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_DELETION_FAILED); + }); + + test('should throw PUBLISHED_DOCS_DELETION_FAILED on database error', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.publishedDocs.delete.mockRejectedValueOnce( + new Error('Database error'), + ); + + const result = await publishedDocsService.deletePublishedDoc( + userPublishedDoc.id, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_DELETION_FAILED); + }); +}); + +describe('getPublishedDocsCreator', () => { + test('should return the creator of a published document', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.user.findUnique.mockResolvedValueOnce(user as any); + + const result = await publishedDocsService.getPublishedDocsCreator( + userPublishedDoc.id, + ); + + const expectedUser = { + ...user, + currentGQLSession: null, + currentRESTSession: null, + }; + + expect(result).toEqualRight(expectedUser); + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when document ID is invalid', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null); + + const result = + await publishedDocsService.getPublishedDocsCreator('invalid_id'); + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); +}); + +describe('getPublishedDocsCollection', () => { + test('should return user collection for user workspace published document', async () => { + const userCollection = { + id: 'collection_1', + userUid: user.uid, + title: 'My Collection', + }; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.userCollection.findUnique.mockResolvedValueOnce( + userCollection as any, + ); + + const result = await publishedDocsService.getPublishedDocsCollection( + userPublishedDoc.id, + ); + expect(result).toEqualRight(userCollection); + }); + + test('should return team collection for team workspace published document', async () => { + const teamCollection = { + id: 'team_collection_1', + teamID: 'team_1', + title: 'Team Collection', + }; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce( + teamCollection as any, + ); + + const result = await publishedDocsService.getPublishedDocsCollection( + teamPublishedDoc.id, + ); + expect(result).toEqualRight(teamCollection); + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when document ID is invalid', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null); + + const result = + await publishedDocsService.getPublishedDocsCollection('invalid_id'); + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); + + test('should throw PUBLISHED_DOCS_INVALID_COLLECTION when user collection is not found', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.userCollection.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.getPublishedDocsCollection( + userPublishedDoc.id, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_COLLECTION); + }); + + test('should throw PUBLISHED_DOCS_INVALID_COLLECTION when team collection is not found', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.getPublishedDocsCollection( + teamPublishedDoc.id, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_COLLECTION); + }); +}); + +describe('checkPublishedDocsAccess', () => { + test('should return true for user workspace when user is the creator', async () => { + const result = await publishedDocsService.checkPublishedDocsAccess( + userPublishedDoc, + user.uid, + ); + expect(result).toBe(true); + }); + + test('should return false for user workspace when user is not the creator', async () => { + const result = await publishedDocsService.checkPublishedDocsAccess( + userPublishedDoc, + 'different_user', + ); + expect(result).toBe(false); + }); + + test('should return true for team workspace when user has required role', async () => { + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + + const result = await publishedDocsService.checkPublishedDocsAccess( + teamPublishedDoc, + user.uid, + [TeamAccessRole.OWNER], + ); + expect(result).toBe(true); + }); + + test('should return false for team workspace when user does not have required role', async () => { + mockPrisma.team.findFirst.mockResolvedValueOnce(null); + + const result = await publishedDocsService.checkPublishedDocsAccess( + teamPublishedDoc, + user.uid, + [TeamAccessRole.OWNER], + ); + expect(result).toBe(false); + }); + + test('should check for VIEWER role by default', async () => { + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + + const result = await publishedDocsService.checkPublishedDocsAccess( + teamPublishedDoc, + user.uid, + ); + expect(result).toBe(true); + + expect(mockPrisma.team.findFirst).toHaveBeenCalledWith({ + where: { + id: 'team_1', + members: { + some: { + userUid: user.uid, + role: { + in: [ + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ], + }, + }, + }, + }, + }); + }); +}); + +describe('getPublishedDocsVersions', () => { + test('should return all versions for a given slug ordered by autoSync and createdOn', async () => { + const mockDbRecords = [ + { + id: 'pub_doc_1', + slug: 'slug-collection-1', + version: '1.0.0', + title: 'API Docs v1', + autoSync: true, + collectionID: 'coll_1', + creatorUid: 'user_1', + workspaceType: 'USER' as any, + workspaceID: 'workspace_1', + documentTree: { folders: [] }, + metadata: { description: 'v1' }, + environmentID: null, + environmentName: null, + environmentVariables: null, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'pub_doc_2', + slug: 'slug-collection-1', + version: '2.0.0', + title: 'API Docs v2', + autoSync: true, + collectionID: 'coll_1', + creatorUid: 'user_1', + workspaceType: 'USER' as any, + workspaceID: 'workspace_1', + documentTree: { folders: [] }, + metadata: { description: 'v2' }, + environmentID: null, + environmentName: null, + environmentVariables: null, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'pub_doc_3', + slug: 'slug-collection-1', + version: '3.0.0', + title: 'API Docs v3', + autoSync: false, + collectionID: 'coll_1', + creatorUid: 'user_1', + workspaceType: 'USER' as any, + workspaceID: 'workspace_1', + documentTree: { folders: [] }, + metadata: { description: 'v3' }, + environmentID: null, + environmentName: null, + environmentVariables: null, + createdOn: new Date(), + updatedOn: new Date(), + }, + ]; + + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce( + mockDbRecords as any, + ); + + const result = + await publishedDocsService.getPublishedDocsVersions('slug-collection-1'); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + // cast() adds versions array, stringifies documentTree/metadata, and adds url + expect(result.right).toHaveLength(3); + expect(result.right[0]).toMatchObject({ + id: 'pub_doc_1', + slug: 'slug-collection-1', + version: '1.0.0', + title: 'API Docs v1', + autoSync: true, + }); + expect(result.right[0].url).toContain('/view/slug-collection-1/1.0.0'); + expect(result.right[0].versions).toEqual([]); + } + }); + + test('should return PUBLISHED_DOCS_NOT_FOUND when no versions found', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([]); + + const result = + await publishedDocsService.getPublishedDocsVersions('non-existent-slug'); + + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); + + test('should query with correct orderBy clause for autoSync priority', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([]); + + await publishedDocsService.getPublishedDocsVersions('test-slug'); + + expect(mockPrisma.publishedDocs.findMany).toHaveBeenCalledWith({ + where: { slug: 'test-slug' }, + orderBy: [{ autoSync: 'desc' }, { createdOn: 'desc' }], + }); + }); +}); + +describe('getPublishedDocBySlugPublic', () => { + test('should return published document by slug and version with autoSync enabled', async () => { + const collectionData = { + id: 'collection_1', + name: 'Test Collection', + folders: [], + requests: [], + }; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...userPublishedDoc, + autoSync: true, + }); + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + { + id: userPublishedDoc.id, + slug: userPublishedDoc.slug, + version: userPublishedDoc.version, + title: userPublishedDoc.title, + autoSync: userPublishedDoc.autoSync, + }, + ] as any); + mockUserCollectionService.exportUserCollectionToJSONObject.mockResolvedValueOnce( + E.right(collectionData as any), + ); + + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'slug-collection-1', + '1.0.0', + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.slug).toBe('slug-collection-1'); + expect(result.right.version).toBe('1.0.0'); + expect(result.right.documentTree).toBe(JSON.stringify(collectionData)); + } + }); + + test('should return published document with stored documentTree when autoSync is false', async () => { + const storedDocTree = { folders: [], requests: [] }; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...userPublishedDoc, + autoSync: false, + documentTree: storedDocTree, + }); + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + { + id: userPublishedDoc.id, + slug: userPublishedDoc.slug, + version: userPublishedDoc.version, + title: userPublishedDoc.title, + autoSync: false, + }, + ] as any); + + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'slug-collection-1', + '1.0.0', + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.documentTree).toBe(JSON.stringify(storedDocTree)); + } + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when slug and version combination not found', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([]); + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'non-existent-slug', + '1.0.0', + ); + + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); + + test('should use unique constraint slug_version for lookup', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + { + id: 'v1', + slug: 'test-slug', + version: '2.0.0', + title: 'V1', + autoSync: true, + }, + ] as any); + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null); + + await publishedDocsService.getPublishedDocBySlugPublic( + 'test-slug', + '2.0.0', + ); + + expect(mockPrisma.publishedDocs.findUnique).toHaveBeenCalledWith({ + where: { + slug_version: { + slug: 'test-slug', + version: '2.0.0', + }, + }, + }); + }); + + test('should fetch all versions for the slug', async () => { + const allVersions = [ + { + id: 'v1', + slug: 'test-slug', + version: '1.0.0', + title: 'V1', + autoSync: true, + }, + { + id: 'v2', + slug: 'test-slug', + version: '2.0.0', + title: 'V2', + autoSync: true, + }, + ]; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...userPublishedDoc, + autoSync: false, + }); + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce(allVersions as any); + + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'test-slug', + '1.0.0', + ); + + expect(E.isRight(result)).toBe(true); + }); + + test('should use first version as default when no version specified and versions exist', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + { + id: 'v1', + slug: 'test-slug', + version: 'CURRENT', + title: 'V1', + autoSync: true, + }, + ] as any); + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...userPublishedDoc, + version: 'CURRENT', + autoSync: false, + }); + + await publishedDocsService.getPublishedDocBySlugPublic('test-slug', null); + + expect(mockPrisma.publishedDocs.findUnique).toHaveBeenCalledWith({ + where: { + slug_version: { + slug: 'test-slug', + version: 'CURRENT', + }, + }, + }); + }); +}); + +describe('createPublishedDoc - slug generation and race conditions', () => { + const createArgs: CreatePublishedDocsArgs = { + title: 'New API Documentation', + version: '1.0.0', + autoSync: true, + workspaceType: WorkspaceType.USER, + workspaceID: user.uid, + collectionID: 'collection_1', + metadata: '{}', + }; + + test('should generate new slug for first version of a collection', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + // No existing docs for this collection + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockPrisma.publishedDocs.create.mockResolvedValueOnce({ + ...userPublishedDoc, + slug: expect.any(String), + }); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockPrisma.publishedDocs.findFirst).toHaveBeenCalledWith({ + where: { + collectionID: 'collection_1', + workspaceType: WorkspaceType.USER, + workspaceID: user.uid, + }, + orderBy: { + createdOn: 'asc', + }, + }); + }); + + test('should reuse existing slug for subsequent versions of same collection', async () => { + const existingSlug = 'existing-slug-abc'; + const existingDoc = { + ...userPublishedDoc, + slug: existingSlug, + version: '1.0.0', + }; + + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(existingDoc); + mockPrisma.publishedDocs.create.mockResolvedValueOnce({ + ...userPublishedDoc, + slug: existingSlug, + version: '2.0.0', + }); + + const result = await publishedDocsService.createPublishedDoc( + { ...createArgs, version: '2.0.0' }, + user, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.slug).toBe(existingSlug); + } + }); + + test('should retry on race condition (P2002 error) up to 3 times', async () => { + const uniqueConstraintError = { + code: 'P2002', + meta: { target: ['slug', 'version'] }, + }; + + mockPrisma.userCollection.findUnique.mockResolvedValue({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValue(null); + + // First two attempts fail with P2002, third succeeds + mockPrisma.publishedDocs.create + .mockRejectedValueOnce(uniqueConstraintError) + .mockRejectedValueOnce(uniqueConstraintError) + .mockResolvedValueOnce(userPublishedDoc); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockPrisma.publishedDocs.create).toHaveBeenCalledTimes(3); + }); + + test('should fail after max retries (3 attempts)', async () => { + const uniqueConstraintError = { + code: 'P2002', + meta: { target: ['slug', 'version'] }, + }; + + mockPrisma.userCollection.findUnique.mockResolvedValue({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValue(null); + + // All attempts fail with P2002 + mockPrisma.publishedDocs.create.mockRejectedValue(uniqueConstraintError); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + + expect(result).toEqualLeft(PUBLISHED_DOCS_CREATION_FAILED); + expect(mockPrisma.publishedDocs.create).toHaveBeenCalledTimes(3); + }); + + test('should not retry on non-P2002 errors', async () => { + const otherError = new Error('Database connection failed'); + + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockPrisma.publishedDocs.create.mockRejectedValueOnce(otherError); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + + expect(result).toEqualLeft(PUBLISHED_DOCS_CREATION_FAILED); + expect(mockPrisma.publishedDocs.create).toHaveBeenCalledTimes(1); + }); + + test('should store null documentTree when autoSync is true', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockPrisma.publishedDocs.create.mockResolvedValueOnce(userPublishedDoc); + + await publishedDocsService.createPublishedDoc( + { ...createArgs, autoSync: true }, + user, + ); + + expect(mockPrisma.publishedDocs.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + documentTree: null, + }), + }), + ); + }); + + test('should fetch and store documentTree when autoSync is false', async () => { + const collectionData = { folders: [], requests: [] }; + + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockUserCollectionService.exportUserCollectionToJSONObject.mockResolvedValueOnce( + E.right(collectionData as any), + ); + mockPrisma.publishedDocs.create.mockResolvedValueOnce({ + ...userPublishedDoc, + documentTree: collectionData, + }); + + await publishedDocsService.createPublishedDoc( + { ...createArgs, autoSync: false }, + user, + ); + + expect( + mockUserCollectionService.exportUserCollectionToJSONObject, + ).toHaveBeenCalledWith(user.uid, 'collection_1'); + }); +}); + +describe('createPublishedDoc - environment support', () => { + const createArgs: CreatePublishedDocsArgs = { + title: 'New API Documentation', + version: '1.0.0', + autoSync: true, + workspaceType: WorkspaceType.USER, + workspaceID: user.uid, + collectionID: 'collection_1', + metadata: '{}', + }; + + test('should create published doc with environment for user workspace', async () => { + const envData = { + id: 'env_1', + userUid: user.uid, + name: 'Production', + variables: [{ key: 'BASE_URL', value: 'https://api.example.com' }], + isGlobal: false, + }; + + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockPrisma.userEnvironment.findUnique.mockResolvedValueOnce(envData as any); + mockPrisma.publishedDocs.create.mockResolvedValueOnce({ + ...userPublishedDoc, + environmentID: 'env_1', + environmentName: 'Production', + environmentVariables: envData.variables, + }); + + const result = await publishedDocsService.createPublishedDoc( + { ...createArgs, environmentID: 'env_1' }, + user, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockPrisma.publishedDocs.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + environmentID: 'env_1', + environmentName: 'Production', + environmentVariables: envData.variables, + }), + }), + ); + }); + + test('should create published doc with environment for team workspace', async () => { + const teamArgs: CreatePublishedDocsArgs = { + ...createArgs, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team_1', + collectionID: 'team_collection_1', + environmentID: 'team_env_1', + }; + const envData = { + id: 'team_env_1', + teamID: 'team_1', + name: 'Staging', + variables: [{ key: 'API_KEY', value: 'abc123' }], + }; + + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce({ + id: 'team_collection_1', + teamID: 'team_1', + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockPrisma.teamEnvironment.findUnique.mockResolvedValueOnce(envData as any); + mockPrisma.publishedDocs.create.mockResolvedValueOnce({ + ...teamPublishedDoc, + environmentID: 'team_env_1', + environmentName: 'Staging', + environmentVariables: envData.variables, + }); + + const result = await publishedDocsService.createPublishedDoc( + teamArgs, + user, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockPrisma.publishedDocs.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + environmentID: 'team_env_1', + environmentName: 'Staging', + environmentVariables: envData.variables, + }), + }), + ); + }); + + test('should return error when user environment ID is invalid', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockPrisma.userEnvironment.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.createPublishedDoc( + { ...createArgs, environmentID: 'invalid_env' }, + user, + ); + + expect(result).toEqualLeft(USER_ENVIRONMENT_NOT_FOUND); + }); + + test('should return error when team environment ID is invalid', async () => { + const teamArgs: CreatePublishedDocsArgs = { + ...createArgs, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team_1', + collectionID: 'team_collection_1', + environmentID: 'invalid_env', + }; + + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce({ + id: 'team_collection_1', + teamID: 'team_1', + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockPrisma.teamEnvironment.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.createPublishedDoc( + teamArgs, + user, + ); + + expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); + }); + + test('should create published doc without environment when environmentID is not provided', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockPrisma.publishedDocs.create.mockResolvedValueOnce(userPublishedDoc); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockPrisma.publishedDocs.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + environmentID: null, + environmentName: null, + environmentVariables: null, + }), + }), + ); + }); +}); + +describe('updatePublishedDoc - environment support', () => { + test('should update published doc with new environment', async () => { + const envData = { + id: 'env_2', + userUid: user.uid, + name: 'Staging', + variables: [{ key: 'API_URL', value: 'https://staging.example.com' }], + isGlobal: false, + }; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.userEnvironment.findUnique.mockResolvedValueOnce(envData as any); + mockPrisma.publishedDocs.update.mockResolvedValueOnce({ + ...userPublishedDoc, + environmentID: 'env_2', + environmentName: 'Staging', + environmentVariables: envData.variables, + }); + + const result = await publishedDocsService.updatePublishedDoc( + userPublishedDoc.id, + { environmentID: 'env_2' }, + user, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.environmentName).toBe('Staging'); + expect(result.right.environmentVariables).toBe( + JSON.stringify(envData.variables), + ); + } + }); + + test('should remove environment when environmentID is set to null', async () => { + const docWithEnv = { + ...userPublishedDoc, + environmentID: 'env_1', + environmentName: 'Production', + environmentVariables: [ + { key: 'BASE_URL', value: 'https://api.example.com' }, + ], + }; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(docWithEnv); + mockPrisma.publishedDocs.update.mockResolvedValueOnce({ + ...docWithEnv, + environmentID: null, + environmentName: null, + environmentVariables: null, + }); + + const result = await publishedDocsService.updatePublishedDoc( + docWithEnv.id, + { environmentID: null }, + user, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockPrisma.publishedDocs.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + environmentID: null, + environmentName: null, + environmentVariables: null, + }), + }), + ); + }); + + test('should return error when updating with invalid environment ID', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.userEnvironment.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.updatePublishedDoc( + userPublishedDoc.id, + { environmentID: 'invalid_env' }, + user, + ); + + expect(result).toEqualLeft(USER_ENVIRONMENT_NOT_FOUND); + }); + + test('should not change environment when environmentID is not provided in update args', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.publishedDocs.update.mockResolvedValueOnce(userPublishedDoc); + + await publishedDocsService.updatePublishedDoc( + userPublishedDoc.id, + { title: 'Updated Title' }, + user, + ); + + expect(mockPrisma.publishedDocs.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + environmentID: undefined, + environmentName: undefined, + environmentVariables: undefined, + }), + }), + ); + }); + + test('should update environment for team published doc', async () => { + const envData = { + id: 'team_env_1', + teamID: 'team_1', + name: 'Team Staging', + variables: [{ key: 'TOKEN', value: 'xyz789' }], + }; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + mockPrisma.teamEnvironment.findUnique.mockResolvedValueOnce(envData as any); + mockPrisma.publishedDocs.update.mockResolvedValueOnce({ + ...teamPublishedDoc, + environmentID: 'team_env_1', + environmentName: 'Team Staging', + environmentVariables: envData.variables, + }); + + const result = await publishedDocsService.updatePublishedDoc( + teamPublishedDoc.id, + { environmentID: 'team_env_1' }, + user, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.environmentName).toBe('Team Staging'); + } + }); +}); + +describe('getPublishedDocBySlugPublic - environment support', () => { + test('should re-fetch environment when autoSync is true and environmentID is set', async () => { + const collectionData = { + id: 'collection_1', + name: 'Test Collection', + folders: [], + requests: [], + }; + const envData = { + id: 'env_1', + userUid: user.uid, + name: 'Updated Env Name', + variables: [{ key: 'BASE_URL', value: 'https://updated.example.com' }], + isGlobal: false, + }; + const docWithEnv = { + ...userPublishedDoc, + autoSync: true, + environmentID: 'env_1', + environmentName: 'Old Env Name', + environmentVariables: [ + { key: 'BASE_URL', value: 'https://old.example.com' }, + ], + }; + + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + docWithEnv, + ] as any); + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(docWithEnv); + mockUserCollectionService.exportUserCollectionToJSONObject.mockResolvedValueOnce( + E.right(collectionData as any), + ); + mockPrisma.userEnvironment.findUnique.mockResolvedValueOnce(envData as any); + + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'slug-collection-1', + '1.0.0', + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.environmentName).toBe('Updated Env Name'); + expect(result.right.environmentVariables).toBe( + JSON.stringify(envData.variables), + ); + } + }); + + test('should not re-fetch environment when autoSync is false', async () => { + const docWithEnv = { + ...userPublishedDoc, + autoSync: false, + environmentID: 'env_1', + environmentName: 'Production', + environmentVariables: [ + { key: 'BASE_URL', value: 'https://api.example.com' }, + ], + }; + + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + docWithEnv, + ] as any); + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(docWithEnv); + + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'slug-collection-1', + '1.0.0', + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.environmentName).toBe('Production'); + expect(result.right.environmentVariables).toBe( + JSON.stringify(docWithEnv.environmentVariables), + ); + } + // Should not attempt to fetch environment + expect(mockPrisma.userEnvironment.findFirst).not.toHaveBeenCalled(); + expect(mockPrisma.teamEnvironment.findFirst).not.toHaveBeenCalled(); + }); + + test('should not re-fetch environment when autoSync is true but no environmentID', async () => { + const collectionData = { + id: 'collection_1', + name: 'Test Collection', + folders: [], + requests: [], + }; + + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + userPublishedDoc, + ] as any); + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockUserCollectionService.exportUserCollectionToJSONObject.mockResolvedValueOnce( + E.right(collectionData as any), + ); + + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'slug-collection-1', + '1.0.0', + ); + + expect(E.isRight(result)).toBe(true); + expect(mockPrisma.userEnvironment.findFirst).not.toHaveBeenCalled(); + expect(mockPrisma.teamEnvironment.findFirst).not.toHaveBeenCalled(); + }); + + test('should return error when re-fetch of environment fails', async () => { + const collectionData = { + id: 'collection_1', + name: 'Test Collection', + folders: [], + requests: [], + }; + const docWithEnv = { + ...userPublishedDoc, + autoSync: true, + environmentID: 'env_deleted', + environmentName: 'Deleted Env', + environmentVariables: [{ key: 'OLD', value: 'data' }], + }; + + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + docWithEnv, + ] as any); + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(docWithEnv); + mockUserCollectionService.exportUserCollectionToJSONObject.mockResolvedValueOnce( + E.right(collectionData as any), + ); + // Environment not found — fetchEnvironment returns Left + mockPrisma.userEnvironment.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'slug-collection-1', + '1.0.0', + ); + + expect(result).toEqualLeft(USER_ENVIRONMENT_NOT_FOUND); + }); + + test('should return null environment fields when no environment is associated', async () => { + const storedDocTree = { folders: [], requests: [] }; + + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + userPublishedDoc, + ] as any); + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...userPublishedDoc, + autoSync: false, + documentTree: storedDocTree, + }); + + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'slug-collection-1', + '1.0.0', + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.environmentName).toBeNull(); + expect(result.right.environmentVariables).toBeNull(); + } + }); +}); + +describe('cast - environment stringification', () => { + test('should stringify environmentVariables in cast output', () => { + const docWithEnv: DBPublishedDocs = { + ...userPublishedDoc, + environmentID: 'env_1', + environmentName: 'Production', + environmentVariables: [ + { key: 'BASE_URL', value: 'https://api.example.com' }, + ], + }; + + // Access private cast via getPublishedDocByID which calls cast internally + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(docWithEnv); + + return publishedDocsService + .getPublishedDocByID(docWithEnv.id, user) + .then((result) => { + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.environmentName).toBe('Production'); + expect(typeof result.right.environmentVariables).toBe('string'); + expect(result.right.environmentVariables).toBe( + JSON.stringify([ + { key: 'BASE_URL', value: 'https://api.example.com' }, + ]), + ); + } + }); + }); + + test('should return null environmentVariables when not set', () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + + return publishedDocsService + .getPublishedDocByID(userPublishedDoc.id, user) + .then((result) => { + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.environmentName).toBeNull(); + expect(result.right.environmentVariables).toBeNull(); + } + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.service.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.service.ts new file mode 100644 index 0000000..14b1da5 --- /dev/null +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.service.ts @@ -0,0 +1,787 @@ +import { Injectable } from '@nestjs/common'; +import * as crypto from 'crypto'; +import { + CreatePublishedDocsArgs, + UpdatePublishedDocsArgs, +} from './input-type.args'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { PublishedDocs as DbPublishedDocs } from 'src/generated/prisma/client'; +import { TeamAccessRole } from 'src/team/team.model'; +import { User } from 'src/user/user.model'; +import { WorkspaceType } from 'src/types/WorkspaceTypes'; +import { + PUBLISHED_DOCS_CREATION_FAILED, + PUBLISHED_DOCS_DELETION_FAILED, + PUBLISHED_DOCS_INVALID_COLLECTION, + PUBLISHED_DOCS_FORBIDDEN_ENVIRONMENT_ACCESS, + PUBLISHED_DOCS_NOT_FOUND, + PUBLISHED_DOCS_UPDATE_FAILED, + TEAM_ENVIRONMENT_NOT_FOUND, + TEAM_INVALID_COLL_ID, + TEAM_INVALID_ID, + USER_COLL_NOT_FOUND, + USER_ENVIRONMENT_NOT_FOUND, +} from 'src/errors'; +import * as E from 'fp-ts/Either'; +import { PublishedDocs, PublishedDocsVersion } from './published-docs.model'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; +import { stringToJson } from 'src/utils'; +import { UserCollectionService } from 'src/user-collection/user-collection.service'; +import { TeamCollectionService } from 'src/team-collection/team-collection.service'; +import { ConfigService } from '@nestjs/config'; +import { PrismaError } from 'src/prisma/prisma-error-codes'; +import { CollectionFolder } from 'src/types/CollectionFolder'; +import { plainToInstance } from 'class-transformer'; +import { JsonValue } from '@prisma/client/runtime/client'; + +@Injectable() +export class PublishedDocsService { + constructor( + private readonly prisma: PrismaService, + private readonly userCollectionService: UserCollectionService, + private readonly teamCollectionService: TeamCollectionService, + private readonly configService: ConfigService, + ) {} + + /** + * Get or generate slug for a collection + * - For existing published docs with the same collectionID, reuse the slug + * - For new collections, generate a new UUID-based slug + */ + private async getOrGenerateSlug( + collectionID: string, + workspaceType: WorkspaceType, + workspaceID: string, + ): Promise { + // Check if there's already a published doc for this collection + const existingDoc = await this.prisma.publishedDocs.findFirst({ + where: { + collectionID, + workspaceType, + workspaceID, + }, + orderBy: { + createdOn: 'asc', // Get the oldest one + }, + }); + + // If exists, reuse its slug + if (existingDoc) { + return existingDoc.slug; + } + + // Otherwise, generate a new slug using crypto.randomUUID() + return crypto.randomUUID(); + } + + /** + * Cast database PublishedDocs to GraphQL PublishedDocs + */ + private cast( + doc: DbPublishedDocs, + versions: PublishedDocsVersion[] = [], + ): PublishedDocs { + return { + ...doc, + versions, + documentTree: JSON.stringify(doc.documentTree), + metadata: JSON.stringify(doc.metadata), + environmentName: doc.environmentName ?? null, + environmentVariables: doc.environmentVariables + ? JSON.stringify(doc.environmentVariables) + : null, + url: `${this.configService.get('VITE_BASE_URL')}/view/${doc.slug}/${doc.version}`, + }; + } + + /** + * Fetch environment by ID based on workspace type + * Returns the environment name and variables, or an error if not found + */ + private async fetchEnvironment( + environmentID: string, + workspaceType: WorkspaceType, + workspaceID: string, + ): Promise> { + // TEAM workspace environment + if (workspaceType === WorkspaceType.TEAM) { + const env = await this.prisma.teamEnvironment.findUnique({ + where: { id: environmentID }, + }); + if (!env) return E.left(TEAM_ENVIRONMENT_NOT_FOUND); + + // Validate environment exists and belongs to the correct team + if (env.teamID !== workspaceID) + return E.left(PUBLISHED_DOCS_FORBIDDEN_ENVIRONMENT_ACCESS); + + return E.right({ + name: env.name, + variables: env.variables, + }); + } + + // USER workspace environment + if (workspaceType === WorkspaceType.USER) { + const env = await this.prisma.userEnvironment.findUnique({ + where: { id: environmentID }, + }); + if (!env) return E.left(USER_ENVIRONMENT_NOT_FOUND); + + // Validate environment exists and belongs to the correct user + if (env.userUid !== workspaceID) { + return E.left(PUBLISHED_DOCS_FORBIDDEN_ENVIRONMENT_ACCESS); + } + + return E.right({ + name: env.name ?? '', + variables: env.variables, + }); + } + + return E.left(PUBLISHED_DOCS_FORBIDDEN_ENVIRONMENT_ACCESS); + } + + /** + * Check if user has access to a team with specific roles + */ + private async checkTeamAccess( + teamId: string, + userUid: string, + requiredRoles: TeamAccessRole[], + ): Promise { + const team = await this.prisma.team.findFirst({ + where: { + id: teamId, + members: { + some: { userUid, role: { in: requiredRoles } }, + }, + }, + }); + return team ? true : false; + } + + /** + * Validate workspace access permission and existence + */ + private async validateWorkspace( + user: User, + input: { workspaceType: WorkspaceType; workspaceID: string }, + ) { + if (input.workspaceType === WorkspaceType.TEAM) { + if (!input.workspaceID) return E.left(TEAM_INVALID_ID); + + const hasAccess = await this.checkTeamAccess( + input.workspaceID, + user.uid, + [TeamAccessRole.OWNER, TeamAccessRole.EDITOR], + ); + + if (!hasAccess) return E.left(TEAM_INVALID_ID); + } + + return E.right(true); + } + + /** + * Validate collection exists and user has access + */ + private async validateCollection( + user: User, + input: { + workspaceType: WorkspaceType; + workspaceID: string; + collectionID: string; + }, + ) { + if (input.workspaceType === WorkspaceType.TEAM) { + const collection = await this.prisma.teamCollection.findUnique({ + where: { id: input.collectionID, teamID: input.workspaceID }, + }); + return collection + ? E.right(collection) + : E.left(PUBLISHED_DOCS_INVALID_COLLECTION); + } else if (input.workspaceType === WorkspaceType.USER) { + const collection = await this.prisma.userCollection.findUnique({ + where: { id: input.collectionID, userUid: user.uid }, + }); + return collection + ? E.right(collection) + : E.left(PUBLISHED_DOCS_INVALID_COLLECTION); + } + + return E.left(PUBLISHED_DOCS_INVALID_COLLECTION); + } + + /** + * Check if user has access to a published docs with specific roles + */ + async checkPublishedDocsAccess( + publishedDocs: DbPublishedDocs, + userUid: string, + requiredRoles: TeamAccessRole[] = [ + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ], + ): Promise { + if (publishedDocs.workspaceType === WorkspaceType.USER) { + return publishedDocs.creatorUid === userUid; + } else if (publishedDocs.workspaceType === WorkspaceType.TEAM) { + return this.checkTeamAccess( + publishedDocs.workspaceID, + userUid, + requiredRoles, + ); + } + return false; + } + + /** + * (Field resolver) + * Get the creator of a mock server + */ + async getPublishedDocsCreator(id: string) { + const publishedDocs = await this.prisma.publishedDocs.findUnique({ + where: { id }, + }); + if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND); + + const user = await this.prisma.user.findUnique({ + where: { uid: publishedDocs.creatorUid }, + }); + + const creator = user + ? { + ...user, + currentGQLSession: null, + currentRESTSession: null, + } + : null; + + return E.right(creator); + } + + /** + * (Field resolver) + * Get the collection of a published document + */ + async getPublishedDocsCollection(id: string) { + const publishedDocs = await this.prisma.publishedDocs.findUnique({ + where: { id }, + }); + if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND); + + if (publishedDocs.workspaceType === WorkspaceType.USER) { + const collection = await this.prisma.userCollection.findUnique({ + where: { id: publishedDocs.collectionID }, + }); + if (!collection) return E.left(PUBLISHED_DOCS_INVALID_COLLECTION); + return E.right(collection); + } else if (publishedDocs.workspaceType === WorkspaceType.TEAM) { + const collection = await this.prisma.teamCollection.findUnique({ + where: { id: publishedDocs.collectionID }, + }); + if (!collection) return E.left(PUBLISHED_DOCS_INVALID_COLLECTION); + return E.right(collection); + } + + return E.left(PUBLISHED_DOCS_INVALID_COLLECTION); + } + + /** + * (Field resolver) + * Get all versions of a published document by slug + */ + async getPublishedDocsVersions(slug: string) { + const allVersions = await this.prisma.publishedDocs.findMany({ + where: { slug }, + orderBy: [{ autoSync: 'desc' }, { createdOn: 'desc' }], + }); + + if (allVersions.length === 0) return E.left(PUBLISHED_DOCS_NOT_FOUND); + + return E.right(allVersions.map((doc) => this.cast(doc))); + } + + /** + * Get a published document by ID + */ + async getPublishedDocByID(id: string, user: User) { + const publishedDocs = await this.prisma.publishedDocs.findUnique({ + where: { id }, + }); + if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND); + + // Check access permissions + const hasAccess = await this.checkPublishedDocsAccess( + publishedDocs, + user.uid, + ); + if (!hasAccess) return E.left(PUBLISHED_DOCS_NOT_FOUND); + + return E.right(this.cast(publishedDocs)); + } + + /** + * Get a published document by slug and version for public access (unauthenticated) + * @param slug - The slug of the published document + * @param version - The version of the published document + */ + async getPublishedDocBySlugPublic( + slug: string, + version: string | null, + ): Promise> { + const allVersions = await this.getPublishedDocsVersions(slug); + if (E.isLeft(allVersions)) return E.left(allVersions.left); + + const publishedDocs = await this.prisma.publishedDocs.findUnique({ + where: { + slug_version: { + slug, + version: version ? version : allVersions.right[0].version, // If version is not specified, get the latest version + }, + }, + }); + if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND); + + let docToReturn = publishedDocs; + + // if autoSync is enabled, fetch from the collection directly + if (publishedDocs.autoSync) { + const collectionResult = + publishedDocs.workspaceType === WorkspaceType.USER + ? await this.userCollectionService.exportUserCollectionToJSONObject( + publishedDocs.creatorUid, + publishedDocs.collectionID, + ) + : await this.teamCollectionService.exportCollectionToJSONObject( + publishedDocs.workspaceID, + publishedDocs.collectionID, + ); + + if (E.isLeft(collectionResult)) { + // Delete the published doc if its collection is missing + const isCollectionNotFound = + collectionResult.left === USER_COLL_NOT_FOUND || + collectionResult.left === TEAM_INVALID_COLL_ID; + + if (isCollectionNotFound) { + await this.prisma.publishedDocs.delete({ + where: { id: publishedDocs.id }, + }); + } + + return E.left(collectionResult.left); + } + + // Re-fetch environment if environmentID is set + let environmentName = publishedDocs.environmentName; + let environmentVariables = publishedDocs.environmentVariables; + + if (publishedDocs.environmentID) { + const workspaceID = + publishedDocs.workspaceType === WorkspaceType.USER + ? publishedDocs.creatorUid + : publishedDocs.workspaceID; + + const envResult = await this.fetchEnvironment( + publishedDocs.environmentID, + publishedDocs.workspaceType as WorkspaceType, + workspaceID, + ); + if (E.isLeft(envResult)) return E.left(envResult.left); + + environmentName = envResult.right.name; + environmentVariables = envResult.right.variables; + } + + docToReturn = { + ...publishedDocs, + documentTree: collectionResult.right as unknown as JsonValue, + environmentName, + environmentVariables, + }; + } + + return E.right( + plainToInstance( + PublishedDocs, + this.cast(docToReturn, allVersions.right), + { excludeExtraneousValues: true, enableCircularCheck: true }, + ), + ); + } + + /** + * Cleanup orphaned published documents whose collections no longer exist + */ + private async cleanupOrphanedPublishedDocs< + T extends { id: string; collectionID: string }, + >(docs: T[], existingCollectionIDs: Set): Promise { + const docsToDelete = docs.filter( + (doc) => !existingCollectionIDs.has(doc.collectionID), + ); + + if (docsToDelete.length > 0) { + const idsToDelete = docsToDelete.map((doc) => doc.id); + await this.prisma.publishedDocs.deleteMany({ + where: { id: { in: idsToDelete } }, + }); + } + + return docs.filter((doc) => existingCollectionIDs.has(doc.collectionID)); + } + + /** + * Get all published documents for a user with pagination + * @param userUid - The UID of the user + * @param args - Pagination arguments + */ + async getAllUserPublishedDocs(userUid: string, args: OffsetPaginationArgs) { + const docs = await this.prisma.publishedDocs.findMany({ + where: { + workspaceType: WorkspaceType.USER, + creatorUid: userUid, + }, + skip: args.skip, + take: args.take, + orderBy: { + createdOn: 'desc', + }, + }); + + if (docs.length === 0) return []; + + // Cross-check if all collections exist + const collectionIDs = docs.map((doc) => doc.collectionID); + const existingCollections = await this.prisma.userCollection.findMany({ + where: { + id: { in: collectionIDs }, + userUid, + }, + select: { id: true }, + }); + + const existingCollectionIDs = new Set( + existingCollections.map((col) => col.id), + ); + + const validDocs = await this.cleanupOrphanedPublishedDocs( + docs, + existingCollectionIDs, + ); + + // Return only docs with existing collections + return validDocs.map((doc) => this.cast(doc)); + } + + /** + * Get all published documents for a team and collection with pagination + */ + async getAllTeamPublishedDocs( + teamID: string, + collectionID: string | undefined, + args: OffsetPaginationArgs, + ) { + const docs = await this.prisma.publishedDocs.findMany({ + where: { + workspaceType: WorkspaceType.TEAM, + workspaceID: teamID, + collectionID: collectionID, + }, + skip: args.skip, + take: args.take, + orderBy: { + createdOn: 'desc', + }, + }); + + if (docs.length === 0) return []; + + // Cross-check if all collections exist + const collectionIDs = docs.map((doc) => doc.collectionID); + const existingCollections = await this.prisma.teamCollection.findMany({ + where: { + id: { in: collectionIDs }, + teamID, + }, + select: { id: true }, + }); + + const existingCollectionIDs = new Set( + existingCollections.map((col) => col.id), + ); + + const validDocs = await this.cleanupOrphanedPublishedDocs( + docs, + existingCollectionIDs, + ); + + // Return only docs with existing collections + return validDocs.map((doc) => this.cast(doc)); + } + + /** + * Create a new published document + * @param args - Arguments for creating the published document + * @param user - The user creating the published document + */ + async createPublishedDoc( + args: CreatePublishedDocsArgs, + user: User, + retryCount: number = 0, + ): Promise> { + try { + // Validate workspace type and ID + const workspaceValidation = await this.validateWorkspace(user, { + workspaceType: args.workspaceType, + workspaceID: args.workspaceID, + }); + if (E.isLeft(workspaceValidation)) { + return E.left(workspaceValidation.left); + } + + // Validate collection exists and user has access + const collectionValidation = await this.validateCollection(user, { + workspaceType: args.workspaceType, + workspaceID: args.workspaceID, + collectionID: args.collectionID, + }); + if (E.isLeft(collectionValidation)) { + return E.left(collectionValidation.left); + } + + // Parse metadata + const metadata = stringToJson(args.metadata); + if (E.isLeft(metadata)) return E.left(metadata.left); + + // Get or generate slug for this collection + const workspaceID = + args.workspaceType === WorkspaceType.TEAM ? args.workspaceID : user.uid; + + // Get or generate slug + const slug = await this.getOrGenerateSlug( + args.collectionID, + args.workspaceType, + workspaceID, + ); + + let documentTree: CollectionFolder | null = null; + // If autoSync is disabled, fetch the latest collection data for snapshot + if (!args.autoSync) { + const collectionResult = + args.workspaceType === WorkspaceType.USER + ? await this.userCollectionService.exportUserCollectionToJSONObject( + user.uid, + args.collectionID, + ) + : await this.teamCollectionService.exportCollectionToJSONObject( + args.workspaceID, + args.collectionID, + ); + + if (E.isLeft(collectionResult)) { + return E.left(collectionResult.left); + } + + documentTree = collectionResult.right; + } + + // Fetch environment if environmentID is provided + let environmentName: string | null = null; + let environmentVariables: JsonValue | null = null; + + if (args.environmentID) { + const envResult = await this.fetchEnvironment( + args.environmentID, + args.workspaceType, + workspaceID, + ); + if (E.isLeft(envResult)) return E.left(envResult.left); + + environmentName = envResult.right.name; + environmentVariables = envResult.right.variables; + } + + // Attempt to create the published document + const newPublishedDoc = await this.prisma.publishedDocs.create({ + data: { + title: args.title, + slug: slug, + collectionID: args.collectionID, + creatorUid: user.uid, + version: args.version, + autoSync: args.autoSync, + workspaceType: args.workspaceType, + workspaceID: workspaceID, + documentTree: documentTree as unknown as JsonValue, + metadata: metadata.right, + environmentID: args.environmentID ?? null, + environmentName, + environmentVariables, + }, + }); + + return E.right(this.cast(newPublishedDoc)); + } catch (error) { + // Check if it's a unique constraint violation on [slug, version] + // Allow up to 3 total attempts (initial + 2 retries) + const maxRetries = 2; + if ( + error.code === PrismaError.UNIQUE_CONSTRAINT_VIOLATION && + retryCount < maxRetries + ) { + // Race condition detected: retry with fresh slug generation + return this.createPublishedDoc(args, user, retryCount + 1); + } + + console.error('Error creating published document:', error); + return E.left(PUBLISHED_DOCS_CREATION_FAILED); + } + } + + /** + * Update an existing published document + * @param id - The ID of the published document to update + * @param args - Arguments for updating the published document + * @param user - The user updating the published document + */ + async updatePublishedDoc( + id: string, + args: UpdatePublishedDocsArgs, + user: User, + ): Promise> { + try { + const publishedDocs = await this.prisma.publishedDocs.findUnique({ + where: { id }, + }); + if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND); + + // Check access permissions based on workspace type (only OWNER and EDITOR can update) + const hasAccess = await this.checkPublishedDocsAccess( + publishedDocs, + user.uid, + [TeamAccessRole.OWNER, TeamAccessRole.EDITOR], + ); + if (!hasAccess) return E.left(PUBLISHED_DOCS_UPDATE_FAILED); + + //Parse metadata if provided + let metadata: E.Either; + if (args.metadata) { + metadata = stringToJson(args.metadata); + if (E.isLeft(metadata)) return E.left(metadata.left); + } + + // Determine documentTree based on autoSync value + let documentTree: CollectionFolder | null | undefined = undefined; // undefined = no change + + if (args.autoSync === true) { + // autoSync enabled → clear documentTree (will be generated dynamically) + documentTree = null; + } else if (args.autoSync === false && publishedDocs.autoSync === true) { + // Switching from autoSync true → false: generate a snapshot of the collection + const collectionResult = + publishedDocs.workspaceType === WorkspaceType.USER + ? await this.userCollectionService.exportUserCollectionToJSONObject( + publishedDocs.creatorUid, + publishedDocs.collectionID, + ) + : await this.teamCollectionService.exportCollectionToJSONObject( + publishedDocs.workspaceID, + publishedDocs.collectionID, + ); + + if (E.isLeft(collectionResult)) { + return E.left(collectionResult.left); + } + + documentTree = collectionResult.right; + } + + // Handle environment update if environmentID is provided + let environmentName: string | null | undefined = undefined; // undefined = no change + let environmentVariables: JsonValue | undefined = undefined; + let environmentID: string | null | undefined = undefined; + + if (args.environmentID !== undefined) { + if (args.environmentID === null) { + // Explicitly removing environment + environmentID = null; + environmentName = null; + environmentVariables = null; + } else { + // Fetch environment data + const envResult = await this.fetchEnvironment( + args.environmentID, + publishedDocs.workspaceType as WorkspaceType, + publishedDocs.workspaceID, + ); + if (E.isLeft(envResult)) return E.left(envResult.left); + + environmentID = args.environmentID; + environmentName = envResult.right.name; + environmentVariables = envResult.right.variables; + } + } + + // Update published document + const updatedPublishedDoc = await this.prisma.publishedDocs.update({ + where: { id }, + data: { + title: args.title, + version: args.version, + autoSync: args.autoSync, + documentTree: + documentTree !== undefined + ? (documentTree as unknown as JsonValue) + : undefined, + metadata: + metadata && E.isRight(metadata) ? metadata.right : undefined, + environmentID: + environmentID !== undefined ? environmentID : undefined, + environmentName: + environmentName !== undefined ? environmentName : undefined, + environmentVariables: + environmentVariables !== undefined + ? environmentVariables + : undefined, + }, + }); + + return E.right(this.cast(updatedPublishedDoc)); + } catch (error) { + console.error('Error updating published document:', error); + return E.left(PUBLISHED_DOCS_UPDATE_FAILED); + } + } + + /** Delete a published document + * @param id - The ID of the published document to delete + * @param user - The user deleting the published document + */ + async deletePublishedDoc(id: string, user: User) { + try { + const publishedDocs = await this.prisma.publishedDocs.findUnique({ + where: { id }, + }); + if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND); + + // Check access permissions based on workspace type (only OWNER and EDITOR can update) + const hasAccess = await this.checkPublishedDocsAccess( + publishedDocs, + user.uid, + [TeamAccessRole.OWNER, TeamAccessRole.EDITOR], + ); + if (!hasAccess) return E.left(PUBLISHED_DOCS_DELETION_FAILED); + + await this.prisma.publishedDocs.delete({ + where: { id }, + }); + + return E.right(true); + } catch (error) { + console.error('Error deleting published document:', error); + return E.left(PUBLISHED_DOCS_DELETION_FAILED); + } + } +} diff --git a/packages/hoppscotch-backend/src/pubsub/pubsub.module.ts b/packages/hoppscotch-backend/src/pubsub/pubsub.module.ts new file mode 100644 index 0000000..3cc0f33 --- /dev/null +++ b/packages/hoppscotch-backend/src/pubsub/pubsub.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PubSubService } from './pubsub.service'; + +@Global() +@Module({ + providers: [PubSubService], + exports: [PubSubService], +}) +export class PubSubModule {} diff --git a/packages/hoppscotch-backend/src/pubsub/pubsub.service.ts b/packages/hoppscotch-backend/src/pubsub/pubsub.service.ts new file mode 100644 index 0000000..5cdf57b --- /dev/null +++ b/packages/hoppscotch-backend/src/pubsub/pubsub.service.ts @@ -0,0 +1,27 @@ +import { OnModuleInit, Injectable } from '@nestjs/common'; +import { PubSub as LocalPubSub } from 'graphql-subscriptions'; +import { TopicDef } from './topicsDefs'; + +/* + * Figure out which PubSub to use (simple/local for dev and Redis for production) + * and expose it + */ + +@Injectable() +export class PubSubService implements OnModuleInit { + private pubsub: LocalPubSub; + + onModuleInit() { + console.log('Initialize PubSub'); + + this.pubsub = new LocalPubSub(); + } + + asyncIterator(topic: string | string[]): AsyncIterator { + return this.pubsub.asyncIterableIterator(topic); + } + + async publish(topic: T, payload: TopicDef[T]) { + await this.pubsub.publish(topic, payload); + } +} diff --git a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts new file mode 100644 index 0000000..7c87595 --- /dev/null +++ b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts @@ -0,0 +1,83 @@ +import { + UserRequest, + UserRequestReorderData, +} from 'src/user-request/user-request.model'; +import { User } from 'src/user/user.model'; +import { UserSettings } from 'src/user-settings/user-settings.model'; +import { UserEnvironment } from '../user-environment/user-environments.model'; +import { + UserHistory, + UserHistoryDeletedManyData, +} from '../user-history/user-history.model'; +import { TeamMember } from 'src/team/team.model'; +import { TeamEnvironment } from 'src/team-environments/team-environments.model'; +import { + CollectionReorderData, + TeamCollection, +} from 'src/team-collection/team-collection.model'; +import { + RequestReorderData, + TeamRequest, +} from 'src/team-request/team-request.model'; +import { TeamInvitation } from 'src/team-invitation/team-invitation.model'; +import { InvitedUser } from '../admin/invited-user.model'; +import { + UserCollection, + UserCollectionDuplicatedData, + UserCollectionRemovedData, + UserCollectionReorderData, +} from 'src/user-collection/user-collections.model'; +import { Shortcode } from 'src/shortcode/shortcode.model'; +import { UserCollectionSortData } from 'src/orchestration/sort/sort.model'; + +// A custom message type that defines the topic and the corresponding payload. +// For every module that publishes a subscription add its type def and the possible subscription type. +export type TopicDef = { + [topic: `admin/${string}/${'invited'}`]: InvitedUser; + [topic: `user/${string}/${'updated' | 'deleted'}`]: User; + [topic: `user_settings/${string}/${'created' | 'updated'}`]: UserSettings; + [ + topic: `user_environment/${string}/${'created' | 'updated' | 'deleted'}` + ]: UserEnvironment; + [topic: `user_environment/${string}/deleted_many`]: number; + [ + topic: `user_request/${string}/${'created' | 'updated' | 'deleted'}` + ]: UserRequest; + [topic: `user_request/${string}/${'moved'}`]: UserRequestReorderData; + [topic: `user_history/${string}/${'created' | 'updated' | 'deleted'}`]: + | UserHistory + | boolean; + [topic: `user_history/${string}/deleted_many`]: UserHistoryDeletedManyData; + [ + topic: `user_coll/${string}/${'created' | 'updated' | 'moved'}` + ]: UserCollection; + [topic: `user_coll/${string}/${'duplicated'}`]: UserCollectionDuplicatedData; + [topic: `user_coll/${string}/${'deleted'}`]: UserCollectionRemovedData; + [topic: `user_coll/${string}/${'order_updated'}`]: UserCollectionReorderData; + [topic: `user_coll_root/${string}/${'sorted'}`]: UserCollectionSortData; + [topic: `user_coll_child/${string}/${'sorted'}`]: UserCollectionSortData; + [topic: `team/${string}/member_removed`]: string; + [topic: `team/${string}/${'member_added' | 'member_updated'}`]: TeamMember; + [ + topic: `team_environment/${string}/${'created' | 'updated' | 'deleted'}` + ]: TeamEnvironment; + [ + topic: `team_coll/${string}/${'coll_added' | 'coll_updated'}` + ]: TeamCollection; + [topic: `team_coll/${string}/${'coll_removed'}`]: string; + [topic: `team_coll/${string}/${'coll_moved'}`]: TeamCollection; + [topic: `team_coll/${string}/${'coll_order_updated'}`]: CollectionReorderData; + [topic: `team_coll_root/${string}/${'sorted'}`]: boolean; + [topic: `team_coll_child/${string}/${'sorted'}`]: string; + [ + topic: `team_req/${string}/${'req_created' | 'req_updated' | 'req_moved'}` + ]: TeamRequest; + [topic: `team_req/${string}/req_order_updated`]: RequestReorderData; + [topic: `team_req/${string}/req_deleted`]: string; + [topic: `team/${string}/invite_added`]: TeamInvitation; + [topic: `team/${string}/invite_removed`]: string; + [ + topic: `shortcode/${string}/${'created' | 'revoked' | 'updated'}` + ]: Shortcode; + [topic: `infra_config/${string}/${'updated'}`]: string; +}; diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.model.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.model.ts new file mode 100644 index 0000000..635312a --- /dev/null +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.model.ts @@ -0,0 +1,68 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class Shortcode { + @Field(() => ID, { + description: 'The 12 digit alphanumeric code', + }) + id: string; + + @Field({ + description: 'JSON string representing the request data', + }) + request: string; + + @Field({ + description: 'JSON string representing the properties for an embed', + nullable: true, + }) + properties: string; + + @Field({ + description: 'Timestamp of when the Shortcode was created', + }) + createdOn: Date; +} + +@ObjectType() +export class ShortcodeCreator { + @Field({ + description: 'Uid of user who created the shortcode', + }) + uid: string; + + @Field({ + description: 'Email of user who created the shortcode', + }) + email: string; +} + +@ObjectType() +export class ShortcodeWithUserEmail { + @Field(() => ID, { + description: 'The 12 digit alphanumeric code', + }) + id: string; + + @Field({ + description: 'JSON string representing the request data', + }) + request: string; + + @Field({ + description: 'JSON string representing the properties for an embed', + nullable: true, + }) + properties: string; + + @Field({ + description: 'Timestamp of when the Shortcode was created', + }) + createdOn: Date; + + @Field({ + description: 'Details of user who created the shortcode', + nullable: true, + }) + creator: ShortcodeCreator; +} diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.module.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.module.ts new file mode 100644 index 0000000..ad28fb7 --- /dev/null +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { UserModule } from 'src/user/user.module'; +import { ShortcodeResolver } from './shortcode.resolver'; +import { ShortcodeService } from './shortcode.service'; + +@Module({ + imports: [UserModule], + providers: [ShortcodeService, ShortcodeResolver], + exports: [ShortcodeService], +}) +export class ShortcodeModule {} diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.resolver.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.resolver.ts new file mode 100644 index 0000000..063aa0c --- /dev/null +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.resolver.ts @@ -0,0 +1,164 @@ +import { + Args, + ID, + Mutation, + Query, + Resolver, + Subscription, +} from '@nestjs/graphql'; +import * as E from 'fp-ts/Either'; +import { UseGuards } from '@nestjs/common'; +import { Shortcode } from './shortcode.model'; +import { ShortcodeService } from './shortcode.service'; +import { throwErr } from 'src/utils'; +import { GqlUser } from 'src/decorators/gql-user.decorator'; +import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; +import { User } from 'src/user/user.model'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { AuthUser } from '../types/AuthUser'; +import { PaginationArgs } from 'src/types/input-types.args'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { SkipThrottle } from '@nestjs/throttler'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => Shortcode) +export class ShortcodeResolver { + constructor( + private readonly shortcodeService: ShortcodeService, + private readonly pubsub: PubSubService, + ) {} + + /* Queries */ + @Query(() => Shortcode, { + description: 'Resolves and returns a shortcode data', + nullable: true, + }) + async shortcode( + @Args({ + name: 'code', + type: () => ID, + description: 'The shortcode to resolve', + }) + code: string, + ) { + const result = await this.shortcodeService.getShortCode(code); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } + + @Query(() => [Shortcode], { + description: 'List all shortcodes the current user has generated', + }) + @UseGuards(GqlAuthGuard) + async myShortcodes(@GqlUser() user: AuthUser, @Args() args: PaginationArgs) { + return this.shortcodeService.fetchUserShortCodes(user.uid, args); + } + + /* Mutations */ + @Mutation(() => Shortcode, { + description: 'Create a shortcode for the given request.', + }) + @UseGuards(GqlAuthGuard) + async createShortcode( + @GqlUser() user: AuthUser, + @Args({ + name: 'request', + description: 'JSON string of the request object', + }) + request: string, + @Args({ + name: 'properties', + description: 'JSON string of the properties of the embed', + nullable: true, + }) + properties: string, + ) { + const result = await this.shortcodeService.createShortcode( + request, + properties, + user, + ); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } + + @Mutation(() => Shortcode, { + description: 'Update a user generated Shortcode', + }) + @UseGuards(GqlAuthGuard) + async updateEmbedProperties( + @GqlUser() user: AuthUser, + @Args({ + name: 'code', + type: () => ID, + description: 'The Shortcode to update', + }) + code: string, + @Args({ + name: 'properties', + description: 'JSON string of the properties of the embed', + }) + properties: string, + ) { + const result = await this.shortcodeService.updateEmbedProperties( + code, + user.uid, + properties, + ); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } + + @Mutation(() => Boolean, { + description: 'Revoke a user generated shortcode', + }) + @UseGuards(GqlAuthGuard) + async revokeShortcode( + @GqlUser() user: User, + @Args({ + name: 'code', + type: () => ID, + description: 'The shortcode to remove', + }) + code: string, + ) { + const result = await this.shortcodeService.revokeShortCode(code, user.uid); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } + + /* Subscriptions */ + @Subscription(() => Shortcode, { + description: 'Listen for shortcode creation', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + myShortcodesCreated(@GqlUser() user: AuthUser) { + return this.pubsub.asyncIterator(`shortcode/${user.uid}/created`); + } + + @Subscription(() => Shortcode, { + description: 'Listen for Shortcode updates', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + myShortcodesUpdated(@GqlUser() user: AuthUser) { + return this.pubsub.asyncIterator(`shortcode/${user.uid}/updated`); + } + + @Subscription(() => Shortcode, { + description: 'Listen for shortcode deletion', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + myShortcodesRevoked(@GqlUser() user: AuthUser): AsyncIterator { + return this.pubsub.asyncIterator(`shortcode/${user.uid}/revoked`); + } +} diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts new file mode 100644 index 0000000..672dfd8 --- /dev/null +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts @@ -0,0 +1,564 @@ +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { PrismaService } from '../prisma/prisma.service'; +import { + SHORTCODE_INVALID_PROPERTIES_JSON, + SHORTCODE_INVALID_REQUEST_JSON, + SHORTCODE_NOT_FOUND, + SHORTCODE_PROPERTIES_NOT_FOUND, +} from 'src/errors'; +import { Shortcode, ShortcodeWithUserEmail } from './shortcode.model'; +import { ShortcodeService } from './shortcode.service'; +import { UserService } from 'src/user/user.service'; +import { AuthUser } from 'src/types/AuthUser'; +import { PubSubService } from 'src/pubsub/pubsub.service'; + +const mockPrisma = mockDeep(); +const mockPubSub = mockDeep(); +const mockUserService = mockDeep(); + +const shortcodeService = new ShortcodeService( + mockPrisma, + mockPubSub, + mockUserService, +); + +beforeEach(() => { + mockReset(mockPrisma); + mockPubSub.publish.mockClear(); +}); +const createdOn = new Date(); + +const user: AuthUser = { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: createdOn, + lastActiveOn: createdOn, + createdOn: createdOn, + currentGQLSession: {}, + currentRESTSession: {}, +}; + +const mockEmbed = { + id: '123', + request: '{}', + embedProperties: '{}', + createdOn: createdOn, + creatorUid: user.uid, + updatedOn: createdOn, +}; + +const mockShortcode = { + id: '123', + request: '{}', + embedProperties: null, + createdOn: createdOn, + creatorUid: user.uid, + updatedOn: createdOn, +}; + +const shortcodes = [ + { + id: 'blablabla', + request: { + hello: 'there', + }, + embedProperties: { + foo: 'bar', + }, + creatorUid: user.uid, + createdOn: new Date(), + updatedOn: createdOn, + }, + { + id: 'blablabla1', + request: { + hello: 'there', + }, + embedProperties: { + foo: 'bar', + }, + creatorUid: user.uid, + createdOn: new Date(), + updatedOn: createdOn, + }, +]; + +const shortcodesWithUserEmail = [ + { + id: 'blablabla', + request: { + hello: 'there', + }, + embedProperties: { + foo: 'bar', + }, + creatorUid: user.uid, + createdOn: new Date(), + updatedOn: createdOn, + User: user, + }, + { + id: 'blablabla1', + request: { + hello: 'there', + }, + embedProperties: { + foo: 'bar', + }, + creatorUid: user.uid, + createdOn: new Date(), + updatedOn: createdOn, + User: user, + }, +]; + +describe('ShortcodeService', () => { + describe('getShortCode', () => { + test('should return a valid Shortcode with valid Shortcode ID', async () => { + mockPrisma.shortcode.findFirstOrThrow.mockResolvedValueOnce(mockEmbed); + + const result = await shortcodeService.getShortCode(mockEmbed.id); + expect(result).toEqualRight({ + id: mockEmbed.id, + createdOn: mockEmbed.createdOn, + request: JSON.stringify(mockEmbed.request), + properties: JSON.stringify(mockEmbed.embedProperties), + }); + }); + + test('should throw SHORTCODE_NOT_FOUND error when shortcode ID is invalid', async () => { + mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await shortcodeService.getShortCode('invalidID'); + expect(result).toEqualLeft(SHORTCODE_NOT_FOUND); + }); + }); + + describe('fetchUserShortCodes', () => { + test('should return list of Shortcode with valid inputs and no cursor', async () => { + mockPrisma.shortcode.findMany.mockResolvedValueOnce(shortcodes); + + const result = await shortcodeService.fetchUserShortCodes(user.uid, { + cursor: null, + take: 10, + }); + expect(result).toEqual([ + { + id: shortcodes[0].id, + request: JSON.stringify(shortcodes[0].request), + properties: JSON.stringify(shortcodes[0].embedProperties), + createdOn: shortcodes[0].createdOn, + }, + { + id: shortcodes[1].id, + request: JSON.stringify(shortcodes[1].request), + properties: JSON.stringify(shortcodes[1].embedProperties), + createdOn: shortcodes[1].createdOn, + }, + ]); + }); + + test('should return list of Shortcode with valid inputs and cursor', async () => { + mockPrisma.shortcode.findMany.mockResolvedValue([shortcodes[1]]); + + const result = await shortcodeService.fetchUserShortCodes(user.uid, { + cursor: 'blablabla', + take: 10, + }); + expect(result).toEqual([ + { + id: shortcodes[1].id, + request: JSON.stringify(shortcodes[1].request), + properties: JSON.stringify(shortcodes[1].embedProperties), + createdOn: shortcodes[1].createdOn, + }, + ]); + }); + + test('should return an empty array for an invalid cursor', async () => { + mockPrisma.shortcode.findMany.mockResolvedValue([]); + + const result = await shortcodeService.fetchUserShortCodes(user.uid, { + cursor: 'invalidcursor', + take: 10, + }); + + expect(result).toHaveLength(0); + }); + + test('should return an empty array for an invalid user id and null cursor', async () => { + mockPrisma.shortcode.findMany.mockResolvedValue([]); + + const result = await shortcodeService.fetchUserShortCodes('invalidid', { + cursor: null, + take: 10, + }); + + expect(result).toHaveLength(0); + }); + + test('should return an empty array for an invalid user id and an invalid cursor', async () => { + mockPrisma.shortcode.findMany.mockResolvedValue([]); + + const result = await shortcodeService.fetchUserShortCodes('invalidid', { + cursor: 'invalidcursor', + take: 10, + }); + + expect(result).toHaveLength(0); + }); + }); + + describe('createShortcode', () => { + test('should throw SHORTCODE_INVALID_REQUEST_JSON error if incoming request data is invalid', async () => { + const result = await shortcodeService.createShortcode( + 'invalidRequest', + null, + user, + ); + expect(result).toEqualLeft(SHORTCODE_INVALID_REQUEST_JSON); + }); + + test('should throw SHORTCODE_INVALID_PROPERTIES_JSON error if incoming properties data is invalid', async () => { + const result = await shortcodeService.createShortcode( + '{}', + 'invalid_data', + user, + ); + expect(result).toEqualLeft(SHORTCODE_INVALID_PROPERTIES_JSON); + }); + + test('should successfully create a new Embed with valid user uid', async () => { + // generateUniqueShortCodeID --> getShortcode + mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + mockPrisma.shortcode.create.mockResolvedValueOnce(mockEmbed); + + const result = await shortcodeService.createShortcode('{}', '{}', user); + expect(result).toEqualRight({ + id: mockEmbed.id, + createdOn: mockEmbed.createdOn, + request: JSON.stringify(mockEmbed.request), + properties: JSON.stringify(mockEmbed.embedProperties), + }); + }); + + test('should successfully create a new ShortCode with valid user uid', async () => { + // generateUniqueShortCodeID --> getShortcode + mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + mockPrisma.shortcode.create.mockResolvedValueOnce(mockShortcode); + + const result = await shortcodeService.createShortcode('{}', null, user); + expect(result).toEqualRight({ + id: mockShortcode.id, + createdOn: mockShortcode.createdOn, + request: JSON.stringify(mockShortcode.request), + properties: mockShortcode.embedProperties, + }); + }); + + test('should send pubsub message to `shortcode/{uid}/created` on successful creation of a Shortcode', async () => { + // generateUniqueShortCodeID --> getShortcode + mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + mockPrisma.shortcode.create.mockResolvedValueOnce(mockShortcode); + + await shortcodeService.createShortcode('{}', null, user); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `shortcode/${mockShortcode.creatorUid}/created`, + { + id: mockShortcode.id, + createdOn: mockShortcode.createdOn, + request: JSON.stringify(mockShortcode.request), + properties: mockShortcode.embedProperties, + }, + ); + }); + + test('should send pubsub message to `shortcode/{uid}/created` on successful creation of an Embed', async () => { + // generateUniqueShortCodeID --> getShortcode + mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + mockPrisma.shortcode.create.mockResolvedValueOnce(mockEmbed); + + await shortcodeService.createShortcode('{}', '{}', user); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `shortcode/${mockEmbed.creatorUid}/created`, + { + id: mockEmbed.id, + createdOn: mockEmbed.createdOn, + request: JSON.stringify(mockEmbed.request), + properties: JSON.stringify(mockEmbed.embedProperties), + }, + ); + }); + }); + + describe('revokeShortCode', () => { + test('should return true on successful deletion of Shortcode with valid inputs', async () => { + mockPrisma.shortcode.delete.mockResolvedValueOnce(mockEmbed); + + const result = await shortcodeService.revokeShortCode( + mockEmbed.id, + mockEmbed.creatorUid, + ); + + expect(mockPrisma.shortcode.delete).toHaveBeenCalledWith({ + where: { + creator_uid_shortcode_unique: { + creatorUid: mockEmbed.creatorUid, + id: mockEmbed.id, + }, + }, + }); + + expect(result).toEqualRight(true); + }); + + test('should return SHORTCODE_NOT_FOUND error when Shortcode is invalid and user uid is valid', async () => { + mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); + expect( + shortcodeService.revokeShortCode('invalid', 'testuser'), + ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); + }); + + test('should return SHORTCODE_NOT_FOUND error when Shortcode is valid and user uid is invalid', async () => { + mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); + expect( + shortcodeService.revokeShortCode('blablablabla', 'invalidUser'), + ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); + }); + + test('should return SHORTCODE_NOT_FOUND error when both Shortcode and user uid are invalid', async () => { + mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); + expect( + shortcodeService.revokeShortCode('invalid', 'invalid'), + ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); + }); + + test('should send pubsub message to `shortcode/{uid}/revoked` on successful deletion of Shortcode', async () => { + mockPrisma.shortcode.delete.mockResolvedValueOnce(mockEmbed); + + await shortcodeService.revokeShortCode( + mockEmbed.id, + mockEmbed.creatorUid, + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `shortcode/${mockEmbed.creatorUid}/revoked`, + { + id: mockEmbed.id, + createdOn: mockEmbed.createdOn, + request: JSON.stringify(mockEmbed.request), + properties: JSON.stringify(mockEmbed.embedProperties), + }, + ); + }); + }); + + describe('deleteUserShortCodes', () => { + test('should successfully delete all users Shortcodes with valid user uid', async () => { + mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 1 }); + + const result = await shortcodeService.deleteUserShortCodes( + mockEmbed.creatorUid, + ); + expect(result).toEqual(1); + }); + + test('should return 0 when user uid is invalid', async () => { + mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 0 }); + + const result = await shortcodeService.deleteUserShortCodes( + mockEmbed.creatorUid, + ); + expect(result).toEqual(0); + }); + }); + + describe('updateShortcode', () => { + test('should return SHORTCODE_PROPERTIES_NOT_FOUND error when updatedProps in invalid', async () => { + const result = await shortcodeService.updateEmbedProperties( + mockEmbed.id, + user.uid, + '', + ); + expect(result).toEqualLeft(SHORTCODE_PROPERTIES_NOT_FOUND); + }); + + test('should return SHORTCODE_PROPERTIES_NOT_FOUND error when updatedProps in invalid JSON format', async () => { + const result = await shortcodeService.updateEmbedProperties( + mockEmbed.id, + user.uid, + '{kk', + ); + expect(result).toEqualLeft(SHORTCODE_INVALID_PROPERTIES_JSON); + }); + + test('should return SHORTCODE_NOT_FOUND error when Shortcode ID is invalid', async () => { + mockPrisma.shortcode.update.mockRejectedValue('RecordNotFound'); + const result = await shortcodeService.updateEmbedProperties( + 'invalidID', + user.uid, + '{}', + ); + expect(result).toEqualLeft(SHORTCODE_NOT_FOUND); + }); + + test('should successfully update a Shortcodes with valid inputs', async () => { + mockPrisma.shortcode.update.mockResolvedValueOnce({ + ...mockEmbed, + embedProperties: '{"foo":"bar"}', + }); + + const result = await shortcodeService.updateEmbedProperties( + mockEmbed.id, + user.uid, + '{"foo":"bar"}', + ); + expect(result).toEqualRight({ + id: mockEmbed.id, + createdOn: mockEmbed.createdOn, + request: JSON.stringify(mockEmbed.request), + properties: JSON.stringify('{"foo":"bar"}'), + }); + }); + + test('should send pubsub message to `shortcode/{uid}/updated` on successful Update of Shortcode', async () => { + mockPrisma.shortcode.update.mockResolvedValueOnce({ + ...mockEmbed, + embedProperties: '{"foo":"bar"}', + }); + + await shortcodeService.updateEmbedProperties( + mockEmbed.id, + user.uid, + '{"foo":"bar"}', + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `shortcode/${mockEmbed.creatorUid}/updated`, + { + id: mockEmbed.id, + createdOn: mockEmbed.createdOn, + request: JSON.stringify(mockEmbed.request), + properties: JSON.stringify('{"foo":"bar"}'), + }, + ); + }); + }); + + describe('deleteShortcode', () => { + test('should return true on successful deletion of Shortcode with valid inputs', async () => { + mockPrisma.shortcode.delete.mockResolvedValueOnce(mockEmbed); + + const result = await shortcodeService.deleteShortcode(mockEmbed.id); + expect(result).toEqualRight(true); + }); + + test('should return SHORTCODE_NOT_FOUND error when Shortcode is invalid', async () => { + mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); + + expect(shortcodeService.deleteShortcode('invalid')).resolves.toEqualLeft( + SHORTCODE_NOT_FOUND, + ); + }); + }); + + describe('fetchAllShortcodes', () => { + test('should return list of Shortcodes with valid inputs and no cursor', async () => { + mockPrisma.shortcode.findMany.mockResolvedValueOnce( + shortcodesWithUserEmail, + ); + + const result = await shortcodeService.fetchAllShortcodes( + { + cursor: null, + take: 10, + }, + user.email, + ); + expect(result).toEqual([ + { + id: shortcodesWithUserEmail[0].id, + request: JSON.stringify(shortcodesWithUserEmail[0].request), + properties: JSON.stringify( + shortcodesWithUserEmail[0].embedProperties, + ), + createdOn: shortcodesWithUserEmail[0].createdOn, + creator: { + uid: user.uid, + email: user.email, + }, + }, + { + id: shortcodesWithUserEmail[1].id, + request: JSON.stringify(shortcodesWithUserEmail[1].request), + properties: JSON.stringify( + shortcodesWithUserEmail[1].embedProperties, + ), + createdOn: shortcodesWithUserEmail[1].createdOn, + creator: { + uid: user.uid, + email: user.email, + }, + }, + ]); + }); + + test('should return list of Shortcode with valid inputs and cursor', async () => { + mockPrisma.shortcode.findMany.mockResolvedValue([ + shortcodesWithUserEmail[1], + ]); + + const result = await shortcodeService.fetchAllShortcodes( + { + cursor: 'blablabla', + take: 10, + }, + user.email, + ); + expect(result).toEqual([ + { + id: shortcodesWithUserEmail[1].id, + request: JSON.stringify(shortcodesWithUserEmail[1].request), + properties: JSON.stringify( + shortcodesWithUserEmail[1].embedProperties, + ), + createdOn: shortcodesWithUserEmail[1].createdOn, + creator: { + uid: user.uid, + email: user.email, + }, + }, + ]); + }); + + test('should return an empty array for an invalid cursor', async () => { + mockPrisma.shortcode.findMany.mockResolvedValue([]); + + const result = await shortcodeService.fetchAllShortcodes( + { + cursor: 'invalidcursor', + take: 10, + }, + user.email, + ); + + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts new file mode 100644 index 0000000..70a59c5 --- /dev/null +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts @@ -0,0 +1,342 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import * as T from 'fp-ts/Task'; +import * as TO from 'fp-ts/TaskOption'; +import * as E from 'fp-ts/Either'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { + SHORTCODE_INVALID_PROPERTIES_JSON, + SHORTCODE_INVALID_REQUEST_JSON, + SHORTCODE_NOT_FOUND, + SHORTCODE_PROPERTIES_NOT_FOUND, +} from 'src/errors'; +import { UserDataHandler } from 'src/user/user.data.handler'; +import { Shortcode, ShortcodeWithUserEmail } from './shortcode.model'; +import { Shortcode as DBShortCode } from 'src/generated/prisma/client'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { UserService } from 'src/user/user.service'; +import { stringToJson } from 'src/utils'; +import { PaginationArgs } from 'src/types/input-types.args'; +import { AuthUser } from '../types/AuthUser'; + +const SHORT_CODE_LENGTH = 12; +const SHORT_CODE_CHARS = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + +@Injectable() +export class ShortcodeService implements UserDataHandler, OnModuleInit { + constructor( + private readonly prisma: PrismaService, + private readonly pubsub: PubSubService, + private readonly userService: UserService, + ) {} + + onModuleInit() { + this.userService.registerUserDataHandler(this); + } + + canAllowUserDeletion(user: AuthUser): TO.TaskOption { + return TO.none; + } + + onUserDelete(user: AuthUser): T.Task { + return async () => { + await this.deleteUserShortCodes(user.uid); + }; + } + + /** + * Converts a Prisma Shortcode type into the Shortcode model + * + * @param shortcodeInfo Prisma Shortcode type + * @returns GQL Shortcode + */ + private cast(shortcodeInfo: DBShortCode): Shortcode { + return { + id: shortcodeInfo.id, + request: JSON.stringify(shortcodeInfo.request), + properties: + shortcodeInfo.embedProperties != null + ? JSON.stringify(shortcodeInfo.embedProperties) + : null, + createdOn: shortcodeInfo.createdOn, + }; + } + + /** + * Generate a shortcode + * + * @returns generated shortcode + */ + private generateShortCodeID(): string { + let result = ''; + for (let i = 0; i < SHORT_CODE_LENGTH; i++) { + result += + SHORT_CODE_CHARS[Math.floor(Math.random() * SHORT_CODE_CHARS.length)]; + } + return result; + } + + /** + * Check to see if ShortCode is already present in DB + * + * @returns Shortcode + */ + private async generateUniqueShortCodeID() { + while (true) { + const code = this.generateShortCodeID(); + + const data = await this.getShortCode(code); + + if (E.isLeft(data)) return E.right(code); + } + } + + /** + * Fetch details regarding a ShortCode + * + * @param shortcode ShortCode + * @returns Either of ShortCode details or error + */ + async getShortCode(shortcode: string) { + try { + const shortcodeInfo = await this.prisma.shortcode.findFirstOrThrow({ + where: { id: shortcode }, + }); + return E.right(this.cast(shortcodeInfo)); + } catch (error) { + return E.left(SHORTCODE_NOT_FOUND); + } + } + + /** + * Create a new ShortCode + * + * @param request JSON string of request details + * @param userInfo user UI + * @param properties JSON string of embed properties, if present + * @returns Either of ShortCode or error + */ + async createShortcode( + request: string, + properties: string | null = null, + userInfo: AuthUser, + ) { + const requestData = stringToJson(request); + if (E.isLeft(requestData) || !requestData.right) + return E.left(SHORTCODE_INVALID_REQUEST_JSON); + + const parsedProperties = stringToJson(properties); + if (E.isLeft(parsedProperties)) + return E.left(SHORTCODE_INVALID_PROPERTIES_JSON); + + const generatedShortCode = await this.generateUniqueShortCodeID(); + if (E.isLeft(generatedShortCode)) return E.left(generatedShortCode.left); + + const createdShortCode = await this.prisma.shortcode.create({ + data: { + id: generatedShortCode.right, + request: requestData.right, + embedProperties: parsedProperties.right ?? undefined, + creatorUid: userInfo.uid, + }, + }); + + // Only publish event if creator is not null + if (createdShortCode.creatorUid) { + this.pubsub.publish( + `shortcode/${createdShortCode.creatorUid}/created`, + this.cast(createdShortCode), + ); + } + + return E.right(this.cast(createdShortCode)); + } + + /** + * Fetch ShortCodes created by a User + * + * @param uid User Uid + * @param args Pagination arguments + * @returns Array of ShortCodes + */ + async fetchUserShortCodes(uid: string, args: PaginationArgs) { + const shortCodes = await this.prisma.shortcode.findMany({ + where: { + creatorUid: uid, + }, + orderBy: { + createdOn: 'desc', + }, + skip: args.cursor ? 1 : 0, + take: args.take, + cursor: args.cursor ? { id: args.cursor } : undefined, + }); + + const fetchedShortCodes: Shortcode[] = shortCodes.map((code) => + this.cast(code), + ); + + return fetchedShortCodes; + } + + /** + * Delete a ShortCode created by User of uid + * + * @param shortcode ShortCode + * @param uid User Uid + * @returns Boolean on successful deletion + */ + async revokeShortCode(shortcode: string, uid: string) { + try { + const deletedShortCodes = await this.prisma.shortcode.delete({ + where: { + creator_uid_shortcode_unique: { + creatorUid: uid, + id: shortcode, + }, + }, + }); + + this.pubsub.publish( + `shortcode/${deletedShortCodes.creatorUid}/revoked`, + this.cast(deletedShortCodes), + ); + + return E.right(true); + } catch (error) { + return E.left(SHORTCODE_NOT_FOUND); + } + } + + /** + * Delete all the Users ShortCodes + * @param uid User Uid + * @returns number of all deleted user ShortCodes + */ + async deleteUserShortCodes(uid: string) { + const deletedShortCodes = await this.prisma.shortcode.deleteMany({ + where: { + creatorUid: uid, + }, + }); + + return deletedShortCodes.count; + } + + /** + * Delete a Shortcode + * + * @param shortcodeID ID of Shortcode being deleted + * @returns Boolean on successful deletion + */ + async deleteShortcode(shortcodeID: string) { + try { + await this.prisma.shortcode.delete({ + where: { + id: shortcodeID, + }, + }); + + return E.right(true); + } catch (error) { + return E.left(SHORTCODE_NOT_FOUND); + } + } + + /** + * Update a created Shortcode + * @param shortcodeID Shortcode ID + * @param uid User Uid + * @returns Updated Shortcode + */ + async updateEmbedProperties( + shortcodeID: string, + uid: string, + updatedProps: string, + ) { + if (!updatedProps) return E.left(SHORTCODE_PROPERTIES_NOT_FOUND); + + const parsedProperties = stringToJson(updatedProps); + if (E.isLeft(parsedProperties) || !parsedProperties.right) + return E.left(SHORTCODE_INVALID_PROPERTIES_JSON); + + try { + const updatedShortcode = await this.prisma.shortcode.update({ + where: { + creator_uid_shortcode_unique: { + creatorUid: uid, + id: shortcodeID, + }, + }, + data: { + embedProperties: parsedProperties.right, + }, + }); + + this.pubsub.publish( + `shortcode/${updatedShortcode.creatorUid}/updated`, + this.cast(updatedShortcode), + ); + + return E.right(this.cast(updatedShortcode)); + } catch (error) { + return E.left(SHORTCODE_NOT_FOUND); + } + } + + /** + * Fetch all created ShortCodes + * + * @param args Pagination arguments + * @param userEmail User email + * @returns ShortcodeWithUserEmail + */ + async fetchAllShortcodes( + args: PaginationArgs, + userEmail: string | null = null, + ) { + const shortCodes = await this.prisma.shortcode.findMany({ + where: userEmail + ? { + User: { + email: { + equals: userEmail, + mode: 'insensitive', + }, + }, + } + : undefined, + orderBy: { + createdOn: 'desc', + }, + skip: args.cursor ? 1 : 0, + take: args.take, + cursor: args.cursor ? { id: args.cursor } : undefined, + include: { + User: true, + }, + }); + + const fetchedShortCodes: ShortcodeWithUserEmail[] = shortCodes.map( + (code) => { + return { + id: code.id, + request: JSON.stringify(code.request), + properties: + code.embedProperties != null + ? JSON.stringify(code.embedProperties) + : null, + createdOn: code.createdOn, + creator: code.User + ? { + uid: code.User.uid, + email: code.User.email, + } + : null, + }; + }, + ); + + return fetchedShortCodes; + } +} diff --git a/packages/hoppscotch-backend/src/team-collection/guards/gql-collection-team-member.guard.ts b/packages/hoppscotch-backend/src/team-collection/guards/gql-collection-team-member.guard.ts new file mode 100644 index 0000000..7f82190 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-collection/guards/gql-collection-team-member.guard.ts @@ -0,0 +1,51 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { TeamCollectionService } from '../team-collection.service'; +import { TeamService } from '../../team/team.service'; +import { TeamAccessRole } from '../../team/team.model'; +import { + BUG_TEAM_NO_REQUIRE_TEAM_ROLE, + BUG_AUTH_NO_USER_CTX, + BUG_TEAM_COLL_NO_COLL_ID, + TEAM_INVALID_COLL_ID, + TEAM_REQ_NOT_MEMBER, +} from 'src/errors'; +import * as E from 'fp-ts/Either'; + +@Injectable() +export class GqlCollectionTeamMemberGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly teamService: TeamService, + private readonly teamCollectionService: TeamCollectionService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requireRoles = this.reflector.get( + 'requiresTeamRole', + context.getHandler(), + ); + if (!requireRoles) throw new Error(BUG_TEAM_NO_REQUIRE_TEAM_ROLE); + + const gqlExecCtx = GqlExecutionContext.create(context); + + const { user } = gqlExecCtx.getContext().req; + if (user == undefined) throw new Error(BUG_AUTH_NO_USER_CTX); + + const { collectionID } = gqlExecCtx.getArgs<{ collectionID: string }>(); + if (!collectionID) throw new Error(BUG_TEAM_COLL_NO_COLL_ID); + + const collection = + await this.teamCollectionService.getCollection(collectionID); + if (E.isLeft(collection)) throw new Error(TEAM_INVALID_COLL_ID); + + const member = await this.teamService.getTeamMember( + collection.right.teamID, + user.uid, + ); + if (!member) throw new Error(TEAM_REQ_NOT_MEMBER); + + return requireRoles.includes(member.role); + } +} diff --git a/packages/hoppscotch-backend/src/team-collection/helper.ts b/packages/hoppscotch-backend/src/team-collection/helper.ts new file mode 100644 index 0000000..e57c3b3 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-collection/helper.ts @@ -0,0 +1,25 @@ +import { TeamRequest } from 'src/generated/prisma/client'; + +// Type of data returned from the query to obtain all search results +export type SearchQueryReturnType = { + id: string; + title: string; + type: 'collection' | 'request'; + method?: string; +}; + +// Type of data returned from the query to obtain all parents +export type ParentTreeQueryReturnType = { + id: string; + parentID: string; + title: string; +}; +// Type of data returned from the query to fetch collection details from CLI +export type GetCollectionResponse = { + id: string; + data: string | null; + title: string; + parentID: string | null; + folders: GetCollectionResponse[]; + requests: TeamRequest[]; +}; diff --git a/packages/hoppscotch-backend/src/team-collection/input-type.args.ts b/packages/hoppscotch-backend/src/team-collection/input-type.args.ts new file mode 100644 index 0000000..5a87f7e --- /dev/null +++ b/packages/hoppscotch-backend/src/team-collection/input-type.args.ts @@ -0,0 +1,147 @@ +import { ArgsType, Field, ID } from '@nestjs/graphql'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { PaginationArgs } from 'src/types/input-types.args'; + +@ArgsType() +export class GetRootTeamCollectionsArgs extends PaginationArgs { + @Field(() => ID, { name: 'teamID', description: 'ID of the team' }) + @IsString() + @IsNotEmpty() + teamID: string; +} + +@ArgsType() +export class CreateRootTeamCollectionArgs { + @Field(() => ID, { name: 'teamID', description: 'ID of the team' }) + @IsString() + @IsNotEmpty() + teamID: string; + + @Field({ name: 'title', description: 'Title of the new collection' }) + @IsString() + @IsNotEmpty() + title: string; + + @Field({ + name: 'data', + description: 'JSON string representing the collection data', + nullable: true, + }) + @IsString() + @IsOptional() + data: string; +} + +@ArgsType() +export class CreateChildTeamCollectionArgs { + @Field(() => ID, { + name: 'collectionID', + description: 'ID of the parent to the new collection', + }) + @IsString() + @IsNotEmpty() + collectionID: string; + + @Field({ name: 'childTitle', description: 'Title of the new collection' }) + @IsString() + @IsNotEmpty() + childTitle: string; + + @Field({ + name: 'data', + description: 'JSON string representing the collection data', + nullable: true, + }) + @IsString() + @IsOptional() + data: string; +} + +@ArgsType() +export class RenameTeamCollectionArgs { + @Field(() => ID, { + name: 'collectionID', + description: 'ID of the collection', + }) + @IsString() + @IsNotEmpty() + collectionID: string; + + @Field({ + name: 'newTitle', + description: 'The updated title of the collection', + }) + @IsString() + @IsNotEmpty() + newTitle: string; +} + +@ArgsType() +export class MoveTeamCollectionArgs { + @Field(() => ID, { + name: 'parentCollectionID', + description: 'ID of the parent to the new collection', + nullable: true, + }) + @IsString() + @IsOptional() + parentCollectionID: string; + + @Field(() => ID, { + name: 'collectionID', + description: 'ID of the collection', + }) + @IsString() + @IsNotEmpty() + collectionID: string; +} + +@ArgsType() +export class UpdateTeamCollectionOrderArgs { + @Field(() => ID, { + name: 'collectionID', + description: 'ID of the collection', + }) + @IsString() + @IsNotEmpty() + collectionID: string; + + @Field(() => ID, { + name: 'destCollID', + description: + 'ID of the collection that comes after the updated collection in its new position', + nullable: true, + }) + @IsString() + @IsOptional() + destCollID: string; +} + +@ArgsType() +export class UpdateTeamCollectionArgs { + @Field(() => ID, { + name: 'collectionID', + description: 'ID of the collection', + }) + @IsString() + @IsNotEmpty() + collectionID: string; + + @Field({ + name: 'newTitle', + description: 'The updated title of the collection', + nullable: true, + }) + @IsString() + @IsOptional() + newTitle: string; + + @Field({ + name: 'data', + description: 'JSON string representing the collection data', + nullable: true, + }) + @IsString() + @IsOptional() + data: string; +} diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.controller.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.controller.ts new file mode 100644 index 0000000..2db8c94 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.controller.ts @@ -0,0 +1,54 @@ +import { + Controller, + Get, + HttpStatus, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { TeamCollectionService } from './team-collection.service'; +import * as E from 'fp-ts/Either'; +import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard'; +import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; +import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator'; +import { TeamAccessRole } from 'src/generated/prisma/client'; +import { RESTTeamMemberGuard } from 'src/team/guards/rest-team-member.guard'; +import { throwHTTPErr } from 'src/utils'; +import { RESTError } from 'src/types/RESTError'; +import { INVALID_PARAMS } from 'src/errors'; + +@UseGuards(ThrottlerBehindProxyGuard) +@Controller({ path: 'team-collection', version: '1' }) +export class TeamCollectionController { + constructor(private readonly teamCollectionService: TeamCollectionService) {} + + @Get('search/:teamID') + @RequiresTeamRole( + TeamAccessRole.VIEWER, + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + ) + @UseGuards(JwtAuthGuard, RESTTeamMemberGuard) + async searchByTitle( + @Query('searchQuery') searchQuery: string, + @Param('teamID') teamID: string, + @Query('take') take: string, + @Query('skip') skip: string, + ) { + if (!teamID || !searchQuery) { + return { + message: INVALID_PARAMS, + statusCode: HttpStatus.BAD_REQUEST, + }; + } + + const res = await this.teamCollectionService.searchByTitle( + searchQuery.trim(), + teamID, + parseInt(take), + parseInt(skip), + ); + if (E.isLeft(res)) throwHTTPErr(res.left); + return res.right; + } +} diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.model.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.model.ts new file mode 100644 index 0000000..d34a290 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.model.ts @@ -0,0 +1,41 @@ +import { ObjectType, Field, ID } from '@nestjs/graphql'; + +@ObjectType() +export class TeamCollection { + @Field(() => ID, { + description: 'ID of the collection', + }) + id: string; + + @Field({ + description: 'Displayed title of the collection', + }) + title: string; + + @Field({ + description: 'JSON string representing the collection data', + nullable: true, + }) + data: string; + + @Field(() => ID, { + description: 'ID of the collection', + nullable: true, + }) + parentID: string; +} + +@ObjectType() +export class CollectionReorderData { + @Field({ + description: 'Team Collection being moved', + }) + collection: TeamCollection; + + @Field({ + description: + 'Team Collection succeeding the collection being moved in its new position', + nullable: true, + }) + nextCollection?: TeamCollection; +} diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.module.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.module.ts new file mode 100644 index 0000000..5f1a819 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TeamCollectionService } from './team-collection.service'; +import { TeamCollectionResolver } from './team-collection.resolver'; +import { GqlCollectionTeamMemberGuard } from './guards/gql-collection-team-member.guard'; +import { TeamModule } from '../team/team.module'; +import { UserModule } from '../user/user.module'; +import { TeamCollectionController } from './team-collection.controller'; + +@Module({ + imports: [TeamModule, UserModule], + providers: [ + TeamCollectionService, + TeamCollectionResolver, + GqlCollectionTeamMemberGuard, + ], + exports: [TeamCollectionService, GqlCollectionTeamMemberGuard], + controllers: [TeamCollectionController], +}) +export class TeamCollectionModule {} diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.resolver.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.resolver.ts new file mode 100644 index 0000000..25e89dc --- /dev/null +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.resolver.ts @@ -0,0 +1,474 @@ +import { + Resolver, + ResolveField, + Parent, + Args, + Query, + Mutation, + Subscription, + ID, +} from '@nestjs/graphql'; +import { CollectionReorderData, TeamCollection } from './team-collection.model'; +import { Team, TeamAccessRole } from '../team/team.model'; +import { TeamCollectionService } from './team-collection.service'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { GqlTeamMemberGuard } from '../team/guards/gql-team-member.guard'; +import { UseGuards } from '@nestjs/common'; +import { RequiresTeamRole } from '../team/decorators/requires-team-role.decorator'; +import { GqlCollectionTeamMemberGuard } from './guards/gql-collection-team-member.guard'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { PaginationArgs } from 'src/types/input-types.args'; +import { + CreateChildTeamCollectionArgs, + CreateRootTeamCollectionArgs, + GetRootTeamCollectionsArgs, + MoveTeamCollectionArgs, + RenameTeamCollectionArgs, + UpdateTeamCollectionArgs, + UpdateTeamCollectionOrderArgs, +} from './input-type.args'; +import * as E from 'fp-ts/Either'; +import { throwErr } from 'src/utils'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { SkipThrottle } from '@nestjs/throttler'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => TeamCollection) +export class TeamCollectionResolver { + constructor( + private readonly teamCollectionService: TeamCollectionService, + private readonly pubsub: PubSubService, + ) {} + + // Field resolvers + @ResolveField(() => Team, { + description: 'Team the collection belongs to', + complexity: 5, + }) + async team(@Parent() collection: TeamCollection) { + const team = await this.teamCollectionService.getTeamOfCollection( + collection.id, + ); + if (E.isLeft(team)) throwErr(team.left); + return team.right; + } + + @ResolveField(() => TeamCollection, { + description: 'Return the parent Team Collection (null if root )', + nullable: true, + complexity: 3, + }) + async parent(@Parent() collection: TeamCollection) { + return this.teamCollectionService.getParentOfCollection(collection.id); + } + + @ResolveField(() => [TeamCollection], { + description: 'List of children Team Collections', + complexity: 3, + }) + async children( + @Parent() collection: TeamCollection, + @Args() args: PaginationArgs, + ) { + return this.teamCollectionService.getChildrenOfCollection( + collection.id, + args.cursor, + args.take, + ); + } + + // Queries + + @Query(() => String, { + description: + 'Returns the JSON string giving the collections and their contents of the team', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.VIEWER, + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + ) + async exportCollectionsToJSON( + @Args({ name: 'teamID', description: 'ID of the team', type: () => ID }) + teamID: string, + ) { + const jsonString = + await this.teamCollectionService.exportCollectionsToJSON(teamID); + + if (E.isLeft(jsonString)) throwErr(jsonString.left as string); + return jsonString.right; + } + + @Query(() => String, { + description: + 'Returns a JSON string of all the contents of a Team Collection', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.VIEWER, + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + ) + async exportCollectionToJSON( + @Args({ name: 'teamID', description: 'ID of the team', type: () => ID }) + teamID: string, + @Args({ + name: 'collectionID', + description: 'ID of the collection', + type: () => ID, + }) + collectionID: string, + ) { + const collectionData = + await this.teamCollectionService.exportCollectionToJSONObject( + teamID, + collectionID, + ); + + if (E.isLeft(collectionData)) throwErr(collectionData.left as string); + return JSON.stringify(collectionData.right); + } + + @Query(() => [TeamCollection], { + description: 'Returns the collections of a team', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.VIEWER, + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + ) + async rootCollectionsOfTeam(@Args() args: GetRootTeamCollectionsArgs) { + return this.teamCollectionService.getTeamRootCollections( + args.teamID, + args.cursor, + args.take, + ); + } + + @Query(() => TeamCollection, { + description: 'Get a Team Collection with ID or null (if not exists)', + nullable: true, + }) + @UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.VIEWER, + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + ) + async collection( + @Args({ + name: 'collectionID', + description: 'ID of the collection', + type: () => ID, + }) + collectionID: string, + ) { + const teamCollections = + await this.teamCollectionService.getCollection(collectionID); + + if (E.isLeft(teamCollections)) throwErr(teamCollections.left); + return { + id: teamCollections.right.id, + title: teamCollections.right.title, + parentID: teamCollections.right.parentID, + data: !teamCollections.right.data + ? null + : JSON.stringify(teamCollections.right.data), + }; + } + + // Mutations + @Mutation(() => TeamCollection, { + description: + 'Creates a collection at the root of the team hierarchy (no parent collection)', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.OWNER, TeamAccessRole.EDITOR) + async createRootCollection(@Args() args: CreateRootTeamCollectionArgs) { + const teamCollection = await this.teamCollectionService.createCollection( + args.teamID, + args.title, + args.data, + null, + ); + + if (E.isLeft(teamCollection)) throwErr(teamCollection.left); + return teamCollection.right; + } + + @Mutation(() => Boolean, { + description: 'Import collections from JSON string to the specified Team', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.OWNER, TeamAccessRole.EDITOR) + async importCollectionsFromJSON( + @Args({ + name: 'teamID', + type: () => ID, + description: 'Id of the team to add to', + }) + teamID: string, + @Args({ + name: 'jsonString', + description: 'JSON string to import', + }) + jsonString: string, + @Args({ + name: 'parentCollectionID', + type: () => ID, + description: + 'ID to the collection to which to import to (null if to import to the root of team)', + nullable: true, + }) + parentCollectionID?: string, + ): Promise { + const importedCollection = + await this.teamCollectionService.importCollectionsFromJSON( + jsonString, + teamID, + parentCollectionID ?? null, + ); + if (E.isLeft(importedCollection)) throwErr(importedCollection.left); + return true; + } + + @Mutation(() => TeamCollection, { + description: 'Create a collection that has a parent collection', + }) + @UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.OWNER, TeamAccessRole.EDITOR) + async createChildCollection(@Args() args: CreateChildTeamCollectionArgs) { + const team = await this.teamCollectionService.getTeamOfCollection( + args.collectionID, + ); + if (E.isLeft(team)) throwErr(team.left); + + const teamCollection = await this.teamCollectionService.createCollection( + team.right.id, + args.childTitle, + args.data, + args.collectionID, + ); + + if (E.isLeft(teamCollection)) throwErr(teamCollection.left); + return teamCollection.right; + } + + @Mutation(() => TeamCollection, { + description: 'Rename a collection', + deprecationReason: 'Switch to updateTeamCollection mutation instead', + }) + @UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.OWNER, TeamAccessRole.EDITOR) + async renameCollection(@Args() args: RenameTeamCollectionArgs) { + const updatedTeamCollection = + await this.teamCollectionService.renameCollection( + args.collectionID, + args.newTitle, + ); + + if (E.isLeft(updatedTeamCollection)) throwErr(updatedTeamCollection.left); + return updatedTeamCollection.right; + } + + @Mutation(() => Boolean, { + description: 'Delete a collection', + }) + @UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.OWNER, TeamAccessRole.EDITOR) + async deleteCollection( + @Args({ + name: 'collectionID', + description: 'ID of the collection', + type: () => ID, + }) + collectionID: string, + ) { + const result = + await this.teamCollectionService.deleteCollection(collectionID); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } + + @Mutation(() => TeamCollection, { + description: + 'Move a collection into a new parent collection or the root of the team', + }) + @UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.OWNER, TeamAccessRole.EDITOR) + async moveCollection(@Args() args: MoveTeamCollectionArgs) { + const res = await this.teamCollectionService.moveCollection( + args.collectionID, + args.parentCollectionID, + ); + if (E.isLeft(res)) throwErr(res.left); + return res.right; + } + + @Mutation(() => Boolean, { + description: 'Update the order of collections', + }) + @UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.OWNER, TeamAccessRole.EDITOR) + async updateCollectionOrder(@Args() args: UpdateTeamCollectionOrderArgs) { + const request = await this.teamCollectionService.updateCollectionOrder( + args.collectionID, + args.destCollID, + ); + if (E.isLeft(request)) throwErr(request.left); + return request.right; + } + + @Mutation(() => TeamCollection, { + description: 'Update Team Collection details', + }) + @UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.OWNER, TeamAccessRole.EDITOR) + async updateTeamCollection(@Args() args: UpdateTeamCollectionArgs) { + const updatedTeamCollection = + await this.teamCollectionService.updateTeamCollection( + args.collectionID, + args.data, + args.newTitle, + ); + + if (E.isLeft(updatedTeamCollection)) throwErr(updatedTeamCollection.left); + return updatedTeamCollection.right; + } + + @Mutation(() => Boolean, { + description: 'Duplicate a Team Collection', + }) + @UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.OWNER, TeamAccessRole.EDITOR) + async duplicateTeamCollection( + @Args({ + name: 'collectionID', + description: 'ID of the collection', + }) + collectionID: string, + ) { + const duplicatedTeamCollection = + await this.teamCollectionService.duplicateTeamCollection(collectionID); + + if (E.isLeft(duplicatedTeamCollection)) + throwErr(duplicatedTeamCollection.left); + return duplicatedTeamCollection.right; + } + + // Subscriptions + + @Subscription(() => TeamCollection, { + description: + 'Listen to when a collection has been added to a team. The emitted value is the team added', + resolve: (value) => value, + }) + @RequiresTeamRole( + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + teamCollectionAdded( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ) { + return this.pubsub.asyncIterator(`team_coll/${teamID}/coll_added`); + } + + @Subscription(() => TeamCollection, { + description: 'Listen to when a collection has been updated.', + resolve: (value) => value, + }) + @RequiresTeamRole( + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + teamCollectionUpdated( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ) { + return this.pubsub.asyncIterator(`team_coll/${teamID}/coll_updated`); + } + + @Subscription(() => ID, { + description: 'Listen to when a collection has been removed', + resolve: (value) => value, + }) + @RequiresTeamRole( + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + teamCollectionRemoved( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ) { + return this.pubsub.asyncIterator(`team_coll/${teamID}/coll_removed`); + } + + @Subscription(() => TeamCollection, { + description: 'Listen to when a collection has been moved', + resolve: (value) => value, + }) + @RequiresTeamRole( + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + teamCollectionMoved( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ) { + return this.pubsub.asyncIterator(`team_coll/${teamID}/coll_moved`); + } + + @Subscription(() => CollectionReorderData, { + description: 'Listen to when a collections position has changed', + resolve: (value) => value, + }) + @RequiresTeamRole( + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + collectionOrderUpdated( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ) { + return this.pubsub.asyncIterator(`team_coll/${teamID}/coll_order_updated`); + } +} diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts new file mode 100644 index 0000000..8488da3 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts @@ -0,0 +1,2135 @@ +import { + Team, + TeamCollection as DBTeamCollection, +} from 'src/generated/prisma/client'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { + TEAM_COLL_DATA_INVALID, + TEAM_COLL_DEST_SAME, + TEAM_COLL_INVALID_JSON, + TEAM_COLL_IS_PARENT_COLL, + TEAM_COLL_NOT_FOUND, + TEAM_COLL_NOT_SAME_TEAM, + TEAM_COLL_SHORT_TITLE, + TEAM_COL_ALREADY_ROOT, + TEAM_COL_REORDERING_FAILED, + TEAM_COL_SAME_NEXT_COLL, + TEAM_INVALID_COLL_ID, + TEAM_MEMBER_NOT_FOUND, + TEAM_NOT_OWNER, +} from 'src/errors'; +import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { AuthUser } from 'src/types/AuthUser'; +import { TeamCollectionService } from './team-collection.service'; +import { TeamCollection } from './team-collection.model'; +import { TeamService } from 'src/team/team.service'; +import { SortOptions } from 'src/types/SortOptions'; + +const mockPrisma = mockDeep(); +const mockPubSub = mockDeep(); +const mockTeamService = mockDeep(); + +const teamCollectionService = new TeamCollectionService( + mockPrisma, + mockPubSub as any, + mockTeamService, +); + +const currentTime = new Date(); + +const user: AuthUser = { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, + currentGQLSession: {}, + currentRESTSession: {}, +}; + +const team: Team = { + id: 'team_1', + name: 'Team 1', +}; + +const rootTeamCollection: DBTeamCollection = { + id: '123', + orderIndex: 1, + parentID: null, + data: {}, + title: 'Root Collection 1', + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const rootTeamCollectionsCasted: TeamCollection = { + id: rootTeamCollection.id, + title: rootTeamCollection.title, + parentID: rootTeamCollection.parentID, + data: JSON.stringify(rootTeamCollection.data), +}; + +const rootTeamCollection_2: DBTeamCollection = { + id: 'erv', + orderIndex: 2, + parentID: null, + data: {}, + title: 'Root Collection 1', + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const childTeamCollection: DBTeamCollection = { + id: 'rfe', + orderIndex: 1, + parentID: rootTeamCollection.id, + data: {}, + title: 'Child Collection 1', + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const childTeamCollectionCasted: TeamCollection = { + id: 'rfe', + parentID: rootTeamCollection.id, + data: JSON.stringify(childTeamCollection.data), + title: 'Child Collection 1', +}; + +const childTeamCollection_2: DBTeamCollection = { + id: 'bgdz', + orderIndex: 1, + data: {}, + parentID: rootTeamCollection_2.id, + title: 'Child Collection 1', + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const childTeamCollection_2Casted: TeamCollection = { + id: 'bgdz', + data: JSON.stringify(childTeamCollection_2.data), + parentID: rootTeamCollection_2.id, + title: 'Child Collection 1', +}; + +const rootTeamCollectionList: DBTeamCollection[] = [ + { + id: 'fdv', + orderIndex: 1, + parentID: null, + data: {}, + + title: 'Root Collection 1', + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: 'fbbg', + orderIndex: 2, + parentID: null, + title: 'Root Collection 1', + data: {}, + + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: 'fgbfg', + orderIndex: 3, + parentID: null, + title: 'Root Collection 1', + data: {}, + + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: 'bre3', + orderIndex: 4, + parentID: null, + data: {}, + + title: 'Root Collection 1', + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: 'hghgf', + orderIndex: 5, + parentID: null, + title: 'Root Collection 1', + data: {}, + + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '123', + orderIndex: 6, + parentID: null, + title: 'Root Collection 1', + teamID: team.id, + data: {}, + + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '54tyh', + orderIndex: 7, + parentID: null, + title: 'Root Collection 1', + teamID: team.id, + data: {}, + + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '234re', + orderIndex: 8, + parentID: null, + title: 'Root Collection 1', + data: {}, + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '34rtg', + orderIndex: 9, + parentID: null, + title: 'Root Collection 1', + data: {}, + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '45tgh', + orderIndex: 10, + parentID: null, + title: 'Root Collection 1', + teamID: team.id, + data: {}, + createdOn: currentTime, + updatedOn: currentTime, + }, +]; + +const rootTeamCollectionListCasted: TeamCollection[] = [ + { + id: 'fdv', + parentID: null, + title: 'Root Collection 1', + data: JSON.stringify(rootTeamCollection.data), + }, + { + id: 'fbbg', + parentID: null, + title: 'Root Collection 1', + data: JSON.stringify(rootTeamCollection.data), + }, + { + id: 'fgbfg', + parentID: null, + title: 'Root Collection 1', + data: JSON.stringify(rootTeamCollection.data), + }, + { + id: 'bre3', + parentID: null, + data: JSON.stringify(rootTeamCollection.data), + title: 'Root Collection 1', + }, + { + id: 'hghgf', + parentID: null, + title: 'Root Collection 1', + data: JSON.stringify(rootTeamCollection.data), + }, + { + id: '123', + parentID: null, + title: 'Root Collection 1', + data: JSON.stringify(rootTeamCollection.data), + }, + { + id: '54tyh', + parentID: null, + title: 'Root Collection 1', + data: JSON.stringify(rootTeamCollection.data), + }, + { + id: '234re', + parentID: null, + title: 'Root Collection 1', + data: JSON.stringify(rootTeamCollection.data), + }, + { + id: '34rtg', + parentID: null, + title: 'Root Collection 1', + data: JSON.stringify(rootTeamCollection.data), + }, + { + id: '45tgh', + parentID: null, + title: 'Root Collection 1', + data: JSON.stringify(rootTeamCollection.data), + }, +]; + +const childTeamCollectionList: DBTeamCollection[] = [ + { + id: '123', + orderIndex: 1, + parentID: rootTeamCollection.id, + title: 'Root Collection 1', + data: {}, + + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '345', + orderIndex: 2, + parentID: rootTeamCollection.id, + title: 'Root Collection 1', + data: {}, + + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '456', + orderIndex: 3, + parentID: rootTeamCollection.id, + title: 'Root Collection 1', + data: {}, + + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '567', + orderIndex: 4, + parentID: rootTeamCollection.id, + data: {}, + + title: 'Root Collection 1', + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '123', + orderIndex: 5, + parentID: rootTeamCollection.id, + data: {}, + + title: 'Root Collection 1', + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '678', + orderIndex: 6, + parentID: rootTeamCollection.id, + data: {}, + + title: 'Root Collection 1', + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '789', + orderIndex: 7, + parentID: rootTeamCollection.id, + data: {}, + + title: 'Root Collection 1', + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '890', + orderIndex: 8, + parentID: rootTeamCollection.id, + data: {}, + + title: 'Root Collection 1', + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '012', + orderIndex: 9, + parentID: rootTeamCollection.id, + data: {}, + title: 'Root Collection 1', + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '0bhu', + orderIndex: 10, + parentID: rootTeamCollection.id, + data: {}, + + title: 'Root Collection 1', + teamID: team.id, + createdOn: currentTime, + updatedOn: currentTime, + }, +]; + +const childTeamCollectionListCasted: TeamCollection[] = [ + { + id: '123', + parentID: rootTeamCollection.id, + title: 'Root Collection 1', + data: JSON.stringify({}), + }, + { + id: '345', + parentID: rootTeamCollection.id, + title: 'Root Collection 1', + data: JSON.stringify({}), + }, + { + id: '456', + parentID: rootTeamCollection.id, + title: 'Root Collection 1', + data: JSON.stringify({}), + }, + { + id: '567', + parentID: rootTeamCollection.id, + data: JSON.stringify({}), + + title: 'Root Collection 1', + }, + { + id: '123', + parentID: rootTeamCollection.id, + data: JSON.stringify({}), + + title: 'Root Collection 1', + }, + { + id: '678', + parentID: rootTeamCollection.id, + data: JSON.stringify({}), + + title: 'Root Collection 1', + }, + { + id: '789', + parentID: rootTeamCollection.id, + data: JSON.stringify({}), + + title: 'Root Collection 1', + }, + { + id: '890', + parentID: rootTeamCollection.id, + data: JSON.stringify({}), + + title: 'Root Collection 1', + }, + { + id: '012', + parentID: rootTeamCollection.id, + data: JSON.stringify({}), + title: 'Root Collection 1', + }, + { + id: '0bhu', + parentID: rootTeamCollection.id, + data: JSON.stringify({}), + + title: 'Root Collection 1', + }, +]; + +beforeEach(() => { + mockReset(mockPrisma); + mockPubSub.publish.mockClear(); +}); + +describe('getTeamOfCollection', () => { + test('should return the team of a collection successfully with valid collectionID', async () => { + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce({ + ...rootTeamCollection, + team: team, + } as any); + + const result = await teamCollectionService.getTeamOfCollection( + rootTeamCollection.id, + ); + expect(result).toEqualRight(team); + }); + + test('should throw TEAM_INVALID_COLL_ID when collectionID is invalid', async () => { + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce(null); + + const result = await teamCollectionService.getTeamOfCollection( + rootTeamCollection.id, + ); + expect(result).toEqualLeft(TEAM_INVALID_COLL_ID); + }); +}); + +describe('getParentOfCollection', () => { + test('should return the parent collection successfully for a child collection with valid collectionID', async () => { + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce({ + ...childTeamCollection, + parent: rootTeamCollection, + } as any); + + const result = await teamCollectionService.getParentOfCollection( + childTeamCollection.id, + ); + expect(result).toEqual(rootTeamCollectionsCasted); + }); + + test('should return null successfully for a root collection with valid collectionID', async () => { + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce({ + ...rootTeamCollection, + parent: null, + } as any); + + const result = await teamCollectionService.getParentOfCollection( + rootTeamCollection.id, + ); + expect(result).toEqual(null); + }); + + test('should return null with invalid collectionID', async () => { + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce(null); + + const result = + await teamCollectionService.getParentOfCollection('invalidID'); + expect(result).toEqual(null); + }); +}); + +describe('getChildrenOfCollection', () => { + test('should return a list of child collections successfully with valid inputs', async () => { + mockPrisma.teamCollection.findMany.mockResolvedValueOnce( + childTeamCollectionList, + ); + + const result = await teamCollectionService.getChildrenOfCollection( + rootTeamCollection.id, + null, + 10, + ); + expect(result).toEqual(childTeamCollectionListCasted); + }); + + test('should return a list of 3 child collections successfully with cursor being equal to the 7th item in the list', async () => { + mockPrisma.teamCollection.findMany.mockResolvedValueOnce([ + { ...childTeamCollectionList[7] }, + { ...childTeamCollectionList[8] }, + { ...childTeamCollectionList[9] }, + ]); + + const result = await teamCollectionService.getChildrenOfCollection( + rootTeamCollection.id, + childTeamCollectionList[6].id, + 10, + ); + expect(result).toEqual([ + { ...childTeamCollectionListCasted[7] }, + { ...childTeamCollectionListCasted[8] }, + { ...childTeamCollectionListCasted[9] }, + ]); + }); + + test('should return a empty array with invalid inputs', async () => { + mockPrisma.teamCollection.findMany.mockResolvedValueOnce([]); + + const result = await teamCollectionService.getChildrenOfCollection( + 'invalidID', + null, + 10, + ); + expect(result).toEqual([]); + }); +}); + +describe('getTeamRootCollections', () => { + test('should return a list of root collections successfully with valid inputs', async () => { + mockPrisma.teamCollection.findMany.mockResolvedValueOnce( + rootTeamCollectionList, + ); + + const result = await teamCollectionService.getTeamRootCollections( + rootTeamCollection.teamID, + null, + 10, + ); + expect(result).toEqual(rootTeamCollectionListCasted); + }); + + test('should return a list of 3 root collections successfully with cursor being equal to the 7th item in the list', async () => { + mockPrisma.teamCollection.findMany.mockResolvedValueOnce([ + { ...rootTeamCollectionList[7] }, + { ...rootTeamCollectionList[8] }, + { ...rootTeamCollectionList[9] }, + ]); + + const result = await teamCollectionService.getTeamRootCollections( + rootTeamCollection.teamID, + rootTeamCollectionList[6].id, + 10, + ); + expect(result).toEqual([ + { ...rootTeamCollectionListCasted[7] }, + { ...rootTeamCollectionListCasted[8] }, + { ...rootTeamCollectionListCasted[9] }, + ]); + }); + + test('should return a empty array with invalid inputs', async () => { + mockPrisma.teamCollection.findMany.mockResolvedValueOnce([]); + + const result = await teamCollectionService.getTeamRootCollections( + 'invalidTeamID', + null, + 10, + ); + expect(result).toEqual([]); + }); +}); + +describe('getCollection', () => { + test('should return a root TeamCollection with valid collectionID', async () => { + mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootTeamCollection, + ); + + const result = await teamCollectionService.getCollection( + rootTeamCollection.id, + ); + expect(result).toEqualRight(rootTeamCollection); + }); + + test('should return a child TeamCollection with valid collectionID', async () => { + mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce( + childTeamCollection, + ); + + const result = await teamCollectionService.getCollection( + childTeamCollection.id, + ); + expect(result).toEqualRight(childTeamCollection); + }); + + test('should throw TEAM_COLL_NOT_FOUND when collectionID is invalid', async () => { + mockPrisma.teamCollection.findUniqueOrThrow.mockRejectedValue( + 'NotFoundError', + ); + + const result = await teamCollectionService.getCollection('123'); + expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND); + }); +}); + +describe('createCollection', () => { + test('should throw TEAM_COLL_SHORT_TITLE when title is less than 1 character', async () => { + const result = await teamCollectionService.createCollection( + rootTeamCollection.teamID, + '', + JSON.stringify(rootTeamCollection.data), + rootTeamCollection.id, + ); + expect(result).toEqualLeft(TEAM_COLL_SHORT_TITLE); + }); + + test('should throw TEAM_NOT_OWNER when parent TeamCollection does not belong to the team', async () => { + jest + .spyOn(teamCollectionService as any, 'isOwnerCheck') + .mockResolvedValueOnce(O.none); + + const result = await teamCollectionService.createCollection( + rootTeamCollection.teamID, + 'abcd', + JSON.stringify(rootTeamCollection.data), + rootTeamCollection.id, + ); + expect(result).toEqualLeft(TEAM_NOT_OWNER); + }); + + test('should throw TEAM_COLL_DATA_INVALID when parent TeamCollection does not belong to the team', async () => { + jest + .spyOn(teamCollectionService as any, 'isOwnerCheck') + .mockResolvedValueOnce(O.some(true)); + + const result = await teamCollectionService.createCollection( + rootTeamCollection.teamID, + 'abcd', + '{', + rootTeamCollection.id, + ); + expect(result).toEqualLeft(TEAM_COLL_DATA_INVALID); + }); + + test('should successfully create a new root TeamCollection with valid inputs', async () => { + mockPrisma.$transaction.mockImplementationOnce(async (fn) => + fn(mockPrisma), + ); + mockPrisma.$executeRaw.mockResolvedValueOnce(null); + mockPrisma.teamCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.teamCollection.create.mockResolvedValueOnce(rootTeamCollection); + + const result = await teamCollectionService.createCollection( + rootTeamCollection.teamID, + rootTeamCollection.title, + JSON.stringify(rootTeamCollection.data), + null, + ); + expect(result).toEqualRight(rootTeamCollectionsCasted); + }); + + test('should successfully create a new child TeamCollection with valid inputs', async () => { + jest + .spyOn(teamCollectionService as any, 'isOwnerCheck') + .mockResolvedValueOnce(O.some(true)); + mockPrisma.$transaction.mockImplementationOnce(async (fn) => + fn(mockPrisma), + ); + mockPrisma.$executeRaw.mockResolvedValueOnce(null); + mockPrisma.teamCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.teamCollection.create.mockResolvedValueOnce(childTeamCollection); + + const result = await teamCollectionService.createCollection( + childTeamCollection.teamID, + childTeamCollection.title, + JSON.stringify(childTeamCollection.data), + childTeamCollection.parentID, + ); + expect(result).toEqualRight(childTeamCollectionCasted); + }); + + test('should send pubsub message to "team_coll//coll_added" if child TeamCollection is created successfully', async () => { + jest + .spyOn(teamCollectionService as any, 'isOwnerCheck') + .mockResolvedValueOnce(O.some(true)); + mockPrisma.$transaction.mockImplementationOnce(async (fn) => + fn(mockPrisma), + ); + mockPrisma.$executeRaw.mockResolvedValueOnce(null); + mockPrisma.teamCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.teamCollection.create.mockResolvedValueOnce(childTeamCollection); + + await teamCollectionService.createCollection( + childTeamCollection.teamID, + childTeamCollection.title, + JSON.stringify(childTeamCollection.data), + childTeamCollection.parentID, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_coll/${childTeamCollection.teamID}/coll_added`, + childTeamCollectionCasted, + ); + }); + + test('should send pubsub message to "team_coll//coll_added" if root TeamCollection is created successfully', async () => { + mockPrisma.$transaction.mockImplementationOnce(async (fn) => + fn(mockPrisma), + ); + mockPrisma.$executeRaw.mockResolvedValueOnce(null); + mockPrisma.teamCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.teamCollection.create.mockResolvedValueOnce(rootTeamCollection); + + await teamCollectionService.createCollection( + rootTeamCollection.teamID, + rootTeamCollection.title, + JSON.stringify(rootTeamCollection.data), + null, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_coll/${rootTeamCollection.teamID}/coll_added`, + rootTeamCollectionsCasted, + ); + }); +}); + +describe('renameCollection', () => { + test('should throw TEAM_COLL_SHORT_TITLE when title is less than 1 character', async () => { + const result = await teamCollectionService.renameCollection( + rootTeamCollection.id, + '', + ); + expect(result).toEqualLeft(TEAM_COLL_SHORT_TITLE); + }); + + test('should successfully update a TeamCollection with valid inputs', async () => { + // isOwnerCheck + mockPrisma.teamCollection.findFirstOrThrow.mockResolvedValueOnce( + rootTeamCollection, + ); + + mockPrisma.teamCollection.update.mockResolvedValueOnce({ + ...rootTeamCollection, + title: 'NewTitle', + }); + + const result = await teamCollectionService.renameCollection( + rootTeamCollection.id, + 'NewTitle', + ); + expect(result).toEqualRight({ + ...rootTeamCollectionsCasted, + title: 'NewTitle', + }); + }); + + test('should throw TEAM_COLL_NOT_FOUND when collectionID is invalid', async () => { + // isOwnerCheck + mockPrisma.teamCollection.findFirstOrThrow.mockResolvedValueOnce( + rootTeamCollection, + ); + + mockPrisma.teamCollection.update.mockRejectedValueOnce('RecordNotFound'); + + const result = await teamCollectionService.renameCollection( + 'invalidID', + 'NewTitle', + ); + expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND); + }); + + test('should send pubsub message to "team_coll//coll_updated" if TeamCollection title is updated successfully', async () => { + // isOwnerCheck + mockPrisma.teamCollection.findFirstOrThrow.mockResolvedValueOnce( + rootTeamCollection, + ); + + mockPrisma.teamCollection.update.mockResolvedValueOnce({ + ...rootTeamCollection, + title: 'NewTitle', + }); + + await teamCollectionService.renameCollection( + rootTeamCollection.id, + 'NewTitle', + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_coll/${rootTeamCollection.teamID}/coll_updated`, + { + ...rootTeamCollectionsCasted, + title: 'NewTitle', + }, + ); + }); +}); + +describe('deleteCollection', () => { + test('should successfully delete a TeamCollection with valid inputs', async () => { + // getCollection + mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootTeamCollection, + ); + // deleteCollectionData + // deleteCollectionData --> FindMany query 1st time + mockPrisma.teamCollection.findMany.mockResolvedValueOnce([]); + // deleteCollectionData --> FindMany query 2nd time + mockPrisma.teamCollection.findMany.mockResolvedValueOnce([]); + // deleteCollectionData --> DeleteMany query + mockPrisma.teamRequest.deleteMany.mockResolvedValueOnce({ count: 0 }); + // deleteCollectionData --> updateOrderIndex + mockPrisma.teamCollection.updateMany.mockResolvedValueOnce({ count: 0 }); + // deleteCollectionData --> removeUserCollection + mockPrisma.teamCollection.delete.mockResolvedValueOnce(rootTeamCollection); + + const result = await teamCollectionService.deleteCollection( + rootTeamCollection.id, + ); + expect(result).toEqualRight(true); + }); + + test('should throw TEAM_COLL_NOT_FOUND when collectionID is invalid ', async () => { + // getCollection + mockPrisma.teamCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + const result = await teamCollectionService.deleteCollection( + rootTeamCollection.id, + ); + expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND); + }); + + test('should throw TEAM_COL_REORDERING_FAILED when deleteCollectionAndUpdateSiblingsOrderIndex fails', async () => { + jest + .spyOn(teamCollectionService, 'getCollection') + .mockResolvedValueOnce(E.right(rootTeamCollection)); + jest + .spyOn( + teamCollectionService as any, + 'deleteCollectionAndUpdateSiblingsOrderIndex', + ) + .mockResolvedValueOnce(E.left(TEAM_COL_REORDERING_FAILED)); + + const result = await teamCollectionService.deleteCollection( + rootTeamCollection.id, + ); + expect(result).toEqualLeft(TEAM_COL_REORDERING_FAILED); + }); + + test('should send pubsub message to "team_coll//coll_removed" if TeamCollection is deleted successfully', async () => { + // getCollection + mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootTeamCollection, + ); + // deleteCollectionData + // deleteCollectionData --> FindMany query 1st time + mockPrisma.teamCollection.findMany.mockResolvedValueOnce([]); + // deleteCollectionData --> FindMany query 2nd time + mockPrisma.teamCollection.findMany.mockResolvedValueOnce([]); + // deleteCollectionData --> DeleteMany query + mockPrisma.userRequest.deleteMany.mockResolvedValueOnce({ count: 0 }); + // deleteCollectionData --> updateOrderIndex + mockPrisma.teamCollection.updateMany.mockResolvedValueOnce({ count: 0 }); + // deleteCollectionData --> removeUserCollection + mockPrisma.teamCollection.delete.mockResolvedValueOnce(rootTeamCollection); + + await teamCollectionService.deleteCollection(rootTeamCollection.id); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_coll/${rootTeamCollection.teamID}/coll_removed`, + rootTeamCollection.id, + ); + }); +}); + +describe('moveCollection', () => { + test('should throw TEAM_COLL_NOT_FOUND if collectionID is invalid', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + jest + .spyOn(teamCollectionService, 'getCollection') + .mockResolvedValueOnce(E.left(TEAM_COLL_NOT_FOUND)); + + const result = await teamCollectionService.moveCollection('234', '009'); + expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND); + }); + + test('should throw TEAM_COLL_DEST_SAME if collectionID and destCollectionID is the same', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + jest + .spyOn(teamCollectionService, 'getCollection') + .mockResolvedValueOnce(E.right(rootTeamCollection)); + + const result = await teamCollectionService.moveCollection( + rootTeamCollection.id, + rootTeamCollection.id, + ); + expect(result).toEqualLeft(TEAM_COLL_DEST_SAME); + }); + + test('should throw TEAM_COLL_NOT_FOUND if destCollectionID is invalid', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + jest + .spyOn(teamCollectionService, 'getCollection') + .mockResolvedValueOnce(E.right(rootTeamCollection)) + .mockResolvedValueOnce(E.left(TEAM_COLL_NOT_FOUND)); + + const result = await teamCollectionService.moveCollection( + 'invalidID', + rootTeamCollection.id, + ); + expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND); + }); + + test('should throw TEAM_COLL_NOT_SAME_TEAM if collectionID and destCollectionID are not from the same team', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + jest + .spyOn(teamCollectionService, 'getCollection') + .mockResolvedValueOnce(E.right(rootTeamCollection)) + .mockResolvedValueOnce( + E.right({ ...childTeamCollection_2, teamID: 'anotherTeamID' }), + ); + + const result = await teamCollectionService.moveCollection( + rootTeamCollection.id, + childTeamCollection_2.id, + ); + expect(result).toEqualLeft(TEAM_COLL_NOT_SAME_TEAM); + }); + + test('should throw TEAM_COLL_IS_PARENT_COLL if collectionID is parent of destCollectionID ', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + jest + .spyOn(teamCollectionService, 'getCollection') + .mockResolvedValueOnce(E.right(rootTeamCollection)) + .mockResolvedValueOnce(E.right(childTeamCollection)); + + const result = await teamCollectionService.moveCollection( + rootTeamCollection.id, + childTeamCollection.id, + ); + expect(result).toEqualLeft(TEAM_COLL_IS_PARENT_COLL); + }); + + test('should throw TEAM_COL_ALREADY_ROOT when moving root TeamCollection to root', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + jest + .spyOn(teamCollectionService, 'getCollection') + .mockResolvedValueOnce(E.right(rootTeamCollection)); + + const result = await teamCollectionService.moveCollection( + rootTeamCollection.id, + null, + ); + expect(result).toEqualLeft(TEAM_COL_ALREADY_ROOT); + }); + + test('should successfully move a child TeamCollection into root', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + jest + .spyOn(teamCollectionService, 'getCollection') + .mockResolvedValueOnce(E.right(childTeamCollection)); + jest + .spyOn(teamCollectionService as any, 'changeParentAndUpdateOrderIndex') + .mockResolvedValueOnce( + E.right({ ...childTeamCollectionCasted, parentID: null }), + ); + + const result = await teamCollectionService.moveCollection( + childTeamCollection.id, + null, + ); + expect(result).toEqualRight({ + ...childTeamCollectionCasted, + parentID: null, + }); + }); + + test('should throw TEAM_COLL_NOT_FOUND when trying to change parent of collection with invalid collectionID', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + jest + .spyOn(teamCollectionService, 'getCollection') + .mockResolvedValueOnce(E.right(childTeamCollection)); + jest + .spyOn(teamCollectionService as any, 'changeParentAndUpdateOrderIndex') + .mockResolvedValueOnce(E.left(TEAM_COLL_NOT_FOUND)); + + const result = await teamCollectionService.moveCollection( + childTeamCollection.id, + null, + ); + expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND); + }); + + test('should send pubsub message to "team_coll//coll_moved" when a child TeamCollection is moved to root successfully', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + jest + .spyOn(teamCollectionService, 'getCollection') + .mockResolvedValueOnce(E.right(childTeamCollection)); + jest + .spyOn(teamCollectionService as any, 'changeParentAndUpdateOrderIndex') + .mockResolvedValueOnce( + E.right({ ...childTeamCollectionCasted, parentID: null }), + ); + + await teamCollectionService.moveCollection(childTeamCollection.id, null); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_coll/${childTeamCollection.teamID}/coll_moved`, + { + ...childTeamCollectionCasted, + parentID: null, + }, + ); + }); + + test('should successfully move a root TeamCollection into a child TeamCollection', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + jest + .spyOn(teamCollectionService, 'getCollection') + .mockResolvedValueOnce(E.right(rootTeamCollection)) + .mockResolvedValueOnce(E.right(childTeamCollection)); + jest + .spyOn(teamCollectionService as any, 'isParent') + .mockResolvedValueOnce(O.some(true)); + jest + .spyOn(teamCollectionService as any, 'changeParentAndUpdateOrderIndex') + .mockResolvedValueOnce( + E.right({ + ...rootTeamCollectionsCasted, + parentID: childTeamCollection.id, + }), + ); + + const result = await teamCollectionService.moveCollection( + rootTeamCollection.id, + childTeamCollection.id, + ); + expect(result).toEqualRight({ + ...rootTeamCollectionsCasted, + parentID: childTeamCollection.id, + }); + }); + + test('should send pubsub message to "team_coll//coll_moved" when root TeamCollection is moved into another child TeamCollection successfully', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + jest + .spyOn(teamCollectionService, 'getCollection') + .mockResolvedValueOnce(E.right(rootTeamCollection)) + .mockResolvedValueOnce(E.right(childTeamCollection)); + jest + .spyOn(teamCollectionService as any, 'isParent') + .mockResolvedValueOnce(O.some(true)); + jest + .spyOn(teamCollectionService as any, 'changeParentAndUpdateOrderIndex') + .mockResolvedValueOnce( + E.right({ + ...rootTeamCollectionsCasted, + parentID: childTeamCollection.id, + }), + ); + + await teamCollectionService.moveCollection( + rootTeamCollection.id, + childTeamCollection.id, + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_coll/${childTeamCollection.teamID}/coll_moved`, + { + ...rootTeamCollectionsCasted, + parentID: childTeamCollectionCasted.id, + }, + ); + }); + + test('should successfully move a child TeamCollection into another child TeamCollection', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + jest + .spyOn(teamCollectionService, 'getCollection') + .mockResolvedValueOnce(E.right(childTeamCollection)) + .mockResolvedValueOnce(E.right(childTeamCollection_2)); + jest + .spyOn(teamCollectionService as any, 'isParent') + .mockResolvedValueOnce(O.some(true)); + jest + .spyOn(teamCollectionService as any, 'changeParentAndUpdateOrderIndex') + .mockResolvedValueOnce( + E.right({ + ...childTeamCollectionCasted, + parentID: childTeamCollection_2.id, + }), + ); + + const result = await teamCollectionService.moveCollection( + childTeamCollection.id, + childTeamCollection_2.id, + ); + expect(result).toEqualRight({ + ...childTeamCollectionCasted, + parentID: childTeamCollection_2Casted.id, + }); + }); + + test('should send pubsub message to "team_coll//coll_moved" when child TeamCollection is moved into another child TeamCollection successfully', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + jest + .spyOn(teamCollectionService, 'getCollection') + .mockResolvedValueOnce(E.right(childTeamCollection)) + .mockResolvedValueOnce(E.right(childTeamCollection_2)); + jest + .spyOn(teamCollectionService as any, 'isParent') + .mockResolvedValueOnce(O.some(true)); + jest + .spyOn(teamCollectionService as any, 'changeParentAndUpdateOrderIndex') + .mockResolvedValueOnce( + E.right({ + ...childTeamCollectionCasted, + parentID: childTeamCollection_2.id, + }), + ); + + await teamCollectionService.moveCollection( + childTeamCollection.id, + childTeamCollection_2.id, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_coll/${childTeamCollection.teamID}/coll_moved`, + { + ...childTeamCollectionCasted, + parentID: childTeamCollection_2Casted.id, + }, + ); + }); +}); + +describe('updateCollectionOrder', () => { + test('should throw TEAM_COL_SAME_NEXT_COLL if collectionID and nextCollectionID are the same', async () => { + const result = await teamCollectionService.updateCollectionOrder( + childTeamCollection.id, + childTeamCollection.id, + ); + expect(result).toEqualLeft(TEAM_COL_SAME_NEXT_COLL); + }); + + test('should throw TEAM_COLL_NOT_FOUND if collectionID is invalid', async () => { + // getCollection; + mockPrisma.teamCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await teamCollectionService.updateCollectionOrder( + childTeamCollectionList[4].id, + null, + ); + expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND); + }); + + test('should successfully move the child TeamCollection to the end of the list', async () => { + // getCollection; + mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce( + childTeamCollectionList[4], + ); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + mockPrisma.teamCollection.findFirst.mockResolvedValueOnce( + childTeamCollectionList[4], + ); + mockPrisma.teamCollection.updateMany.mockResolvedValueOnce({ count: 10 }); + mockPrisma.teamCollection.count.mockResolvedValueOnce( + childTeamCollectionList.length, + ); + mockPrisma.teamCollection.update.mockResolvedValueOnce({ + ...childTeamCollectionList[4], + orderIndex: childTeamCollectionList.length, + }); + + const result = await teamCollectionService.updateCollectionOrder( + childTeamCollectionList[4].id, + null, + ); + expect(result).toEqualRight(true); + }); + + test('should successfully move the root TeamCollection to the end of the list', async () => { + // getCollection; + mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootTeamCollectionList[4], + ); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + mockPrisma.teamCollection.findFirst.mockResolvedValueOnce( + rootTeamCollectionList[4], + ); + mockPrisma.teamCollection.updateMany.mockResolvedValueOnce({ count: 10 }); + mockPrisma.teamCollection.count.mockResolvedValueOnce( + rootTeamCollectionList.length, + ); + mockPrisma.teamCollection.update.mockResolvedValueOnce({ + ...rootTeamCollectionList[4], + orderIndex: rootTeamCollectionList.length, + }); + + const result = await teamCollectionService.updateCollectionOrder( + rootTeamCollectionList[4].id, + null, + ); + expect(result).toEqualRight(true); + }); + + test('should throw TEAM_COL_REORDERING_FAILED when re-ordering operation failed for child TeamCollection list', async () => { + // getCollection; + mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce( + childTeamCollectionList[4], + ); + mockPrisma.$transaction.mockRejectedValueOnce('RecordNotFound'); + + const result = await teamCollectionService.updateCollectionOrder( + childTeamCollectionList[4].id, + null, + ); + expect(result).toEqualLeft(TEAM_COL_REORDERING_FAILED); + }); + + test('should send pubsub message to "team_coll//coll_order_updated" when TeamCollection order is updated successfully for a root TeamCollection', async () => { + // getCollection; + mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootTeamCollectionList[4], + ); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + mockPrisma.teamCollection.findFirst.mockResolvedValueOnce( + rootTeamCollectionList[4], + ); + mockPrisma.teamCollection.updateMany.mockResolvedValueOnce({ count: 10 }); + mockPrisma.teamCollection.count.mockResolvedValueOnce( + rootTeamCollectionList.length, + ); + mockPrisma.teamCollection.update.mockResolvedValueOnce({ + ...rootTeamCollectionList[4], + orderIndex: rootTeamCollectionList.length, + }); + + await teamCollectionService.updateCollectionOrder( + rootTeamCollectionList[4].id, + null, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_coll/${rootTeamCollectionList[4].teamID}/coll_order_updated`, + { + collection: rootTeamCollectionListCasted[4], + nextCollection: null, + }, + ); + }); + + test('should throw TEAM_COLL_NOT_SAME_TEAM when collectionID and nextCollectionID do not belong to the same team', async () => { + // getCollection; + mockPrisma.teamCollection.findUniqueOrThrow + .mockResolvedValueOnce(childTeamCollectionList[4]) + .mockResolvedValueOnce({ + ...childTeamCollection_2, + teamID: 'differendTeamID', + }); + + const result = await teamCollectionService.updateCollectionOrder( + childTeamCollectionList[4].id, + childTeamCollection_2.id, + ); + expect(result).toEqualLeft(TEAM_COLL_NOT_SAME_TEAM); + }); + + test('should successfully update the order of the child TeamCollection list', async () => { + // getCollection; + mockPrisma.teamCollection.findUniqueOrThrow + .mockResolvedValueOnce(childTeamCollectionList[4]) + .mockResolvedValueOnce(childTeamCollectionList[2]); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + mockPrisma.teamCollection.findFirst + .mockResolvedValueOnce(childTeamCollectionList[4]) + .mockResolvedValueOnce(childTeamCollectionList[2]); + mockPrisma.teamCollection.updateMany.mockResolvedValueOnce({ count: 3 }); + mockPrisma.teamCollection.update.mockResolvedValueOnce({ + ...childTeamCollectionList[4], + orderIndex: 2, + }); + + const result = await teamCollectionService.updateCollectionOrder( + childTeamCollectionList[4].id, + childTeamCollectionList[2].id, + ); + expect(result).toEqualRight(true); + }); + + test('should successfully update the order of the root TeamCollection list', async () => { + // getCollection; + mockPrisma.teamCollection.findUniqueOrThrow + .mockResolvedValueOnce(rootTeamCollectionList[4]) + .mockResolvedValueOnce(rootTeamCollectionList[2]); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + mockPrisma.teamCollection.findFirst + .mockResolvedValueOnce(rootTeamCollectionList[4]) + .mockResolvedValueOnce(rootTeamCollectionList[2]); + mockPrisma.teamCollection.updateMany.mockResolvedValueOnce({ count: 3 }); + mockPrisma.teamCollection.update.mockResolvedValueOnce({ + ...rootTeamCollectionList[4], + orderIndex: 2, + }); + + const result = await teamCollectionService.updateCollectionOrder( + rootTeamCollectionList[4].id, + rootTeamCollectionList[2].id, + ); + expect(result).toEqualRight(true); + }); + + test('should throw TEAM_COL_REORDERING_FAILED when re-ordering operation failed with nextCollection', async () => { + // getCollection; + mockPrisma.teamCollection.findUniqueOrThrow + .mockResolvedValueOnce(childTeamCollectionList[4]) + .mockResolvedValueOnce(childTeamCollectionList[2]); + + mockPrisma.$transaction.mockRejectedValueOnce('RecordNotFound'); + + const result = await teamCollectionService.updateCollectionOrder( + childTeamCollectionList[4].id, + childTeamCollectionList[2].id, + ); + expect(result).toEqualLeft(TEAM_COL_REORDERING_FAILED); + }); + + test('should send pubsub message to "team_coll//coll_order_updated" when TeamCollection order is updated successfully', async () => { + // getCollection; + mockPrisma.teamCollection.findUniqueOrThrow + .mockResolvedValueOnce(childTeamCollectionList[4]) + .mockResolvedValueOnce(childTeamCollectionList[2]); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + mockPrisma.teamCollection.findFirst + .mockResolvedValueOnce(childTeamCollectionList[4]) + .mockResolvedValueOnce(childTeamCollectionList[2]); + mockPrisma.teamCollection.updateMany.mockResolvedValueOnce({ count: 3 }); + mockPrisma.teamCollection.update.mockResolvedValueOnce({ + ...childTeamCollectionList[4], + orderIndex: 2, + }); + + await teamCollectionService.updateCollectionOrder( + childTeamCollectionList[4].id, + childTeamCollectionList[2].id, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_coll/${childTeamCollectionList[4].teamID}/coll_order_updated`, + { + collection: childTeamCollectionListCasted[4], + nextCollection: childTeamCollectionListCasted[2], + }, + ); + }); +}); + +const jsonString = + '[{"requests":[],"name":"Root Collection 1","folders":[],"v":1}]'; + +describe('importCollectionsFromJSON', () => { + test('should throw TEAM_COLL_INVALID_JSON when the jsonString is invalid', async () => { + const result = await teamCollectionService.importCollectionsFromJSON( + 'invalidString', + rootTeamCollection.teamID, + null, + ); + expect(result).toEqualLeft(TEAM_COLL_INVALID_JSON); + }); + + test('should throw TEAM_COLL_INVALID_JSON when the parsed jsonString is not an array', async () => { + const result = await teamCollectionService.importCollectionsFromJSON( + '{}', + rootTeamCollection.teamID, + null, + ); + expect(result).toEqualLeft(TEAM_COLL_INVALID_JSON); + }); + + test('should successfully create new TeamCollections in root and TeamRequests with valid inputs', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.teamCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.teamCollection.create.mockResolvedValueOnce(rootTeamCollection); + + const result = await teamCollectionService.importCollectionsFromJSON( + jsonString, + rootTeamCollection.teamID, + null, + ); + expect(result).toEqualRight([rootTeamCollection]); + }); + + test('should successfully create new TeamCollections in a child collection and TeamRequests with valid inputs', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.teamCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.teamCollection.create.mockResolvedValueOnce(rootTeamCollection); + + const result = await teamCollectionService.importCollectionsFromJSON( + jsonString, + rootTeamCollection.teamID, + rootTeamCollection.id, + ); + expect(result).toEqualRight([rootTeamCollection]); + }); + + test('should send pubsub message to "team_coll//coll_added" on successful creation from jsonString', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.teamCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.teamCollection.create.mockResolvedValueOnce(rootTeamCollection); + + await teamCollectionService.importCollectionsFromJSON( + jsonString, + rootTeamCollection.teamID, + null, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_coll/${rootTeamCollection.teamID}/coll_added`, + rootTeamCollectionsCasted, + ); + }); +}); + +describe('totalCollectionsInTeam', () => { + test('should resolve right and return a total team colls count ', async () => { + mockPrisma.teamCollection.count.mockResolvedValueOnce(2); + const result = await teamCollectionService.totalCollectionsInTeam('id1'); + expect(mockPrisma.teamCollection.count).toHaveBeenCalledWith({ + where: { + teamID: 'id1', + }, + }); + expect(result).toEqual(2); + }); + test('should resolve left and return an error when no team colls found', async () => { + mockPrisma.teamCollection.count.mockResolvedValueOnce(0); + const result = await teamCollectionService.totalCollectionsInTeam('id1'); + expect(mockPrisma.teamCollection.count).toHaveBeenCalledWith({ + where: { + teamID: 'id1', + }, + }); + expect(result).toEqual(0); + }); + + describe('getTeamCollectionsCount', () => { + test('should return count of all Team Collections in the organization', async () => { + mockPrisma.teamCollection.count.mockResolvedValueOnce(10); + + const result = await teamCollectionService.getTeamCollectionsCount(); + expect(result).toEqual(10); + }); + }); +}); + +describe('updateTeamCollection', () => { + test('should throw TEAM_COLL_SHORT_TITLE if title is invalid', async () => { + const result = await teamCollectionService.updateTeamCollection( + rootTeamCollection.id, + JSON.stringify(rootTeamCollection.data), + '', + ); + expect(result).toEqualLeft(TEAM_COLL_SHORT_TITLE); + }); + + test('should throw TEAM_COLL_DATA_INVALID is collection data is invalid', async () => { + const result = await teamCollectionService.updateTeamCollection( + rootTeamCollection.id, + '{', + rootTeamCollection.title, + ); + expect(result).toEqualLeft(TEAM_COLL_DATA_INVALID); + }); + + test('should throw TEAM_COLL_NOT_FOUND is collectionID is invalid', async () => { + mockPrisma.teamCollection.update.mockRejectedValueOnce('RecordNotFound'); + + const result = await teamCollectionService.updateTeamCollection( + 'invalid_id', + JSON.stringify(rootTeamCollection.data), + rootTeamCollection.title, + ); + expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND); + }); + + test('should successfully update a collection', async () => { + mockPrisma.teamCollection.update.mockResolvedValueOnce(rootTeamCollection); + + const result = await teamCollectionService.updateTeamCollection( + rootTeamCollection.id, + JSON.stringify({ foo: 'bar' }), + 'new_title', + ); + expect(result).toEqualRight({ + data: JSON.stringify({ foo: 'bar' }), + title: 'new_title', + ...rootTeamCollectionsCasted, + }); + }); + + test('should send pubsub message to "team_coll//coll_updated" if TeamCollection is updated successfully', async () => { + mockPrisma.teamCollection.update.mockResolvedValueOnce(rootTeamCollection); + + await teamCollectionService.updateTeamCollection( + rootTeamCollection.id, + JSON.stringify(rootTeamCollection.data), + rootTeamCollection.title, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_coll/${rootTeamCollection.teamID}/coll_updated`, + rootTeamCollectionsCasted, + ); + }); +}); + +describe('sortTeamCollections', () => { + it('should sort collections by TITLE_ASC', async () => { + const parentID = null; + const teamID = team.id; + + mockPrisma.$transaction.mockImplementation(async (cb) => cb(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + mockPrisma.teamCollection.findMany.mockResolvedValueOnce( + rootTeamCollectionList, + ); + + const result = await teamCollectionService.sortTeamCollections( + teamID, + parentID, + SortOptions.TITLE_ASC, + ); + + expect(result).toEqual(E.right(true)); + expect(mockPrisma.teamCollection.findMany).toHaveBeenCalledWith({ + where: { teamID, parentID }, + orderBy: { title: 'asc' }, + select: { id: true }, + }); + expect(mockPrisma.teamCollection.update).toHaveBeenCalledTimes( + rootTeamCollectionList.length, + ); + }); + + it('should sort collections by TITLE_DESC', async () => { + const parentID = null; + const teamID = team.id; + + mockPrisma.$transaction.mockImplementation(async (cb) => cb(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + mockPrisma.teamCollection.findMany.mockResolvedValueOnce( + rootTeamCollectionList, + ); + + const result = await teamCollectionService.sortTeamCollections( + teamID, + parentID, + SortOptions.TITLE_DESC, + ); + + expect(result).toEqual(E.right(true)); + expect(mockPrisma.teamCollection.findMany).toHaveBeenCalledWith({ + where: { teamID, parentID }, + orderBy: { title: 'desc' }, + select: { id: true }, + }); + expect(mockPrisma.teamCollection.update).toHaveBeenCalledTimes( + rootTeamCollectionList.length, + ); + }); + + it('should return left(TEAM_COL_REORDERING_FAILED) on error', async () => { + const parentID = null; + const teamID = team.id; + + mockPrisma.$transaction.mockRejectedValueOnce(new Error('fail')); + const result = await teamCollectionService.sortTeamCollections( + teamID, + parentID, + SortOptions.TITLE_ASC, + ); + expect(result).toEqual(E.left(TEAM_COL_REORDERING_FAILED)); + }); +}); + +describe('FIX: updateMany queries now include teamID filter for root collections', () => { + /** + * These tests verify that the bug has been fixed where updateMany queries with parentID: null + * now correctly filter by teamID, ensuring operations only affect the specific team's collections. + * + * The fix was applied to: + * - deleteCollectionAndUpdateSiblingsOrderIndex (line 565-572) + * - updateCollectionOrder (line 894-905, 976-985) + * - getCollectionCount (line 851-856) + */ + + beforeEach(() => { + mockReset(mockPrisma); + }); + + test('FIX: deleteCollection - updateMany now correctly filters by teamID for root collections', async () => { + /** + * Scenario: Team 1 deletes a root collection + * The sibling orderIndex update should ONLY affect Team 1's root collections + * + * FIX: The query now includes teamID filter, ensuring isolation between teams + */ + + const team1RootToDelete: DBTeamCollection = { + id: 'team1-root-to-delete', + orderIndex: 2, + parentID: null, + title: 'Team 1 Root To Delete', + teamID: team.id, + data: {}, + createdOn: currentTime, + updatedOn: currentTime, + }; + + // getCollection + mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce( + team1RootToDelete, + ); + + // deleteCollectionAndUpdateSiblingsOrderIndex transaction + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + mockPrisma.teamCollection.delete.mockResolvedValueOnce(team1RootToDelete); + mockPrisma.teamCollection.updateMany.mockResolvedValueOnce({ count: 3 }); + + await teamCollectionService.deleteCollection(team1RootToDelete.id); + + // Get the updateMany call from the transaction + const updateManyCall = + mockPrisma.teamCollection.updateMany.mock.calls[0][0]; + + // FIX VERIFICATION: The query now correctly includes teamID + // This ensures only Team 1's root collections are affected + expect(updateManyCall.where).toEqual({ + teamID: team.id, // FIX: teamID is now included! + parentID: null, + orderIndex: { gt: team1RootToDelete.orderIndex }, + }); + }); + + test('FIX: updateCollectionOrder (to end) - updateMany now correctly filters by teamID', async () => { + /** + * Scenario: Team 1 reorders a root collection to the end + * FIX: The query now includes teamID, ensuring only Team 1's collections are affected + */ + + const team1RootCollection: DBTeamCollection = { + id: 'team1-root-coll', + orderIndex: 2, + parentID: null, + title: 'Team 1 Root Collection', + teamID: team.id, + data: {}, + createdOn: currentTime, + updatedOn: currentTime, + }; + + // getCollection + mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce( + team1RootCollection, + ); + + // Mock the transaction + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + mockPrisma.teamCollection.findFirst.mockResolvedValueOnce( + team1RootCollection, + ); + mockPrisma.teamCollection.updateMany.mockResolvedValueOnce({ count: 3 }); + mockPrisma.teamCollection.count.mockResolvedValueOnce(5); + mockPrisma.teamCollection.update.mockResolvedValueOnce({ + ...team1RootCollection, + orderIndex: 5, + }); + + await teamCollectionService.updateCollectionOrder( + team1RootCollection.id, + null, // Move to end of list + ); + + // Get the actual updateMany call arguments + const updateManyCall = + mockPrisma.teamCollection.updateMany.mock.calls[0][0]; + + // FIX VERIFICATION: The query now correctly includes teamID + expect(updateManyCall.where).toEqual({ + teamID: team.id, // FIX: teamID is now included! + parentID: null, + orderIndex: { gte: team1RootCollection.orderIndex + 1 }, + }); + }); + + test('FIX: updateCollectionOrder (with nextCollection) - updateMany now correctly filters by teamID', async () => { + /** + * Scenario: Team 1 reorders root collections + * FIX: The updateMany now correctly filters by teamID + */ + + const team1RootCollection1: DBTeamCollection = { + id: 'team1-root-1', + orderIndex: 1, + parentID: null, + title: 'Team 1 Root 1', + teamID: team.id, + data: {}, + createdOn: currentTime, + updatedOn: currentTime, + }; + + const team1RootCollection2: DBTeamCollection = { + id: 'team1-root-2', + orderIndex: 4, + parentID: null, + title: 'Team 1 Root 2', + teamID: team.id, + data: {}, + createdOn: currentTime, + updatedOn: currentTime, + }; + + // getCollection for both collections + mockPrisma.teamCollection.findUniqueOrThrow + .mockResolvedValueOnce(team1RootCollection1) + .mockResolvedValueOnce(team1RootCollection2); + + // Mock the transaction + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + mockPrisma.teamCollection.findFirst + .mockResolvedValueOnce(team1RootCollection1) + .mockResolvedValueOnce(team1RootCollection2); + mockPrisma.teamCollection.updateMany.mockResolvedValueOnce({ count: 2 }); + mockPrisma.teamCollection.update.mockResolvedValueOnce({ + ...team1RootCollection1, + orderIndex: 3, + }); + + await teamCollectionService.updateCollectionOrder( + team1RootCollection1.id, + team1RootCollection2.id, // Move before this collection + ); + + // Get the actual updateMany call arguments + const updateManyCall = + mockPrisma.teamCollection.updateMany.mock.calls[0][0]; + + // FIX VERIFICATION: The query now correctly includes teamID + expect(updateManyCall.where).toEqual({ + teamID: team.id, // FIX: teamID is now included! + parentID: null, + orderIndex: { + gte: team1RootCollection1.orderIndex + 1, + lte: team1RootCollection2.orderIndex - 1, + }, + }); + }); + + test('FIX: getCollectionCount - now correctly filters by teamID for root collections', async () => { + /** + * Scenario: Getting count of root collections for a team + * FIX: The count query now requires and filters by teamID + */ + + mockPrisma.teamCollection.count.mockResolvedValueOnce(5); + + await teamCollectionService.getCollectionCount(null, team.id); + + // FIX VERIFICATION: The count query now filters by teamID + expect(mockPrisma.teamCollection.count).toHaveBeenCalledWith({ + where: { parentID: null, teamID: team.id }, // FIX: teamID is now included! + }); + }); +}); + +describe('SCENARIO: Two teams performing concurrent operations on root collections', () => { + /** + * Scenario tests to verify operations are correctly isolated between teams + */ + + beforeEach(() => { + mockReset(mockPrisma); + }); + + const team2: Team = { + id: 'team_2', + name: 'Team 2', + }; + + const team1RootCollection: DBTeamCollection = { + id: 'team1-root', + orderIndex: 2, + parentID: null, + title: 'Team 1 Root', + teamID: team.id, + data: {}, + createdOn: currentTime, + updatedOn: currentTime, + }; + + const team2RootCollection: DBTeamCollection = { + id: 'team2-root', + orderIndex: 2, + parentID: null, + title: 'Team 2 Root', + teamID: team2.id, + data: {}, + createdOn: currentTime, + updatedOn: currentTime, + }; + + test('SCENARIO: Team 1 deletes root collection - operations are now isolated from Team 2', async () => { + /** + * With the fix: + * - Team 1 deletes root collection (orderIndex 2) + * - Only Team 1's root collections with orderIndex > 2 are decremented + * - Team 2's collections are NOT affected (correct behavior) + */ + + // === Team 1 deletes their root collection === + mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce( + team1RootCollection, + ); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + mockPrisma.teamCollection.delete.mockResolvedValueOnce(team1RootCollection); + mockPrisma.teamCollection.updateMany.mockResolvedValueOnce({ count: 3 }); + + await teamCollectionService.deleteCollection(team1RootCollection.id); + + const deleteUpdateManyCall = + mockPrisma.teamCollection.updateMany.mock.calls[0][0]; + + // FIX VERIFICATION: The updateMany now correctly includes teamID + // Only Team 1's collections are affected + expect(deleteUpdateManyCall.where.teamID).toBe(team.id); // FIX: teamID is now included! + expect(deleteUpdateManyCall.where.parentID).toBe(null); + expect(deleteUpdateManyCall.where.orderIndex).toEqual({ + gt: team1RootCollection.orderIndex, + }); + }); + + test('SCENARIO: Team 2 reorders root collection - operations are isolated from Team 1', async () => { + /** + * With the fix: + * - Team 2 reorders a root collection to the end + * - Only Team 2's root collections are affected + * - Team 1's collections are NOT affected (correct behavior) + */ + + // === Team 2 reorders their root collection to the end === + mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce( + team2RootCollection, + ); + + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + mockPrisma.teamCollection.findFirst.mockResolvedValueOnce( + team2RootCollection, + ); + mockPrisma.teamCollection.updateMany.mockResolvedValueOnce({ count: 2 }); + mockPrisma.teamCollection.count.mockResolvedValueOnce(4); // Team 2 has 4 root collections + mockPrisma.teamCollection.update.mockResolvedValueOnce({ + ...team2RootCollection, + orderIndex: 4, + }); + + await teamCollectionService.updateCollectionOrder( + team2RootCollection.id, + null, // Move to end + ); + + const reorderUpdateManyCall = + mockPrisma.teamCollection.updateMany.mock.calls[0][0]; + + // FIX VERIFICATION: The updateMany now correctly includes teamID + // Only Team 2's collections are affected + expect(reorderUpdateManyCall.where.teamID).toBe(team2.id); // FIX: teamID is now included! + expect(reorderUpdateManyCall.where.parentID).toBe(null); + expect(reorderUpdateManyCall.where.orderIndex).toEqual({ + gte: team2RootCollection.orderIndex + 1, + }); + }); + + test('SCENARIO: Both teams reorder collections concurrently - each team isolated', async () => { + /** + * Simulates concurrent operations from two different teams + * Each team's reorder operation should only affect their own collections + */ + + const team1Root1: DBTeamCollection = { + id: 'team1-root-1', + orderIndex: 1, + parentID: null, + title: 'Team 1 Root 1', + teamID: team.id, + data: {}, + createdOn: currentTime, + updatedOn: currentTime, + }; + + const team1Root2: DBTeamCollection = { + id: 'team1-root-2', + orderIndex: 3, + parentID: null, + title: 'Team 1 Root 2', + teamID: team.id, + data: {}, + createdOn: currentTime, + updatedOn: currentTime, + }; + + // === Team 1 reorders collection === + mockPrisma.teamCollection.findUniqueOrThrow + .mockResolvedValueOnce(team1Root1) + .mockResolvedValueOnce(team1Root2); + + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + mockPrisma.teamCollection.findFirst + .mockResolvedValueOnce(team1Root1) + .mockResolvedValueOnce(team1Root2); + mockPrisma.teamCollection.updateMany.mockResolvedValueOnce({ count: 1 }); + mockPrisma.teamCollection.update.mockResolvedValueOnce({ + ...team1Root1, + orderIndex: 2, + }); + + await teamCollectionService.updateCollectionOrder( + team1Root1.id, + team1Root2.id, + ); + + const team1UpdateManyCall = + mockPrisma.teamCollection.updateMany.mock.calls[0][0]; + + // Verify Team 1's operation is correctly scoped + expect(team1UpdateManyCall.where.teamID).toBe(team.id); + expect(team1UpdateManyCall.where.parentID).toBe(null); + + // Reset mocks for Team 2's operation + mockReset(mockPrisma); + + const team2Root1: DBTeamCollection = { + id: 'team2-root-1', + orderIndex: 1, + parentID: null, + title: 'Team 2 Root 1', + teamID: team2.id, + data: {}, + createdOn: currentTime, + updatedOn: currentTime, + }; + + const team2Root2: DBTeamCollection = { + id: 'team2-root-2', + orderIndex: 5, + parentID: null, + title: 'Team 2 Root 2', + teamID: team2.id, + data: {}, + createdOn: currentTime, + updatedOn: currentTime, + }; + + // === Team 2 reorders collection === + mockPrisma.teamCollection.findUniqueOrThrow + .mockResolvedValueOnce(team2Root1) + .mockResolvedValueOnce(team2Root2); + + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockTeamCollectionByTeamAndParent.mockResolvedValue(undefined); + mockPrisma.teamCollection.findFirst + .mockResolvedValueOnce(team2Root1) + .mockResolvedValueOnce(team2Root2); + mockPrisma.teamCollection.updateMany.mockResolvedValueOnce({ count: 3 }); + mockPrisma.teamCollection.update.mockResolvedValueOnce({ + ...team2Root1, + orderIndex: 4, + }); + + await teamCollectionService.updateCollectionOrder( + team2Root1.id, + team2Root2.id, + ); + + const team2UpdateManyCall = + mockPrisma.teamCollection.updateMany.mock.calls[0][0]; + + // Verify Team 2's operation is correctly scoped + expect(team2UpdateManyCall.where.teamID).toBe(team2.id); + expect(team2UpdateManyCall.where.parentID).toBe(null); + + // Both operations are isolated - Team 1's operation only affects Team 1's collections, + // and Team 2's operation only affects Team 2's collections + }); +}); + +//ToDo: write test cases for exportCollectionsToJSON + +describe('getCollectionForCLI', () => { + test('should throw TEAM_COLL_NOT_FOUND if collectionID is invalid', async () => { + mockPrisma.teamCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await teamCollectionService.getCollectionForCLI( + 'invalidID', + user.uid, + ); + expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND); + }); + + test('should throw TEAM_MEMBER_NOT_FOUND if user not in same team', async () => { + mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootTeamCollection, + ); + mockTeamService.getTeamMember.mockResolvedValue(null); + + const result = await teamCollectionService.getCollectionForCLI( + rootTeamCollection.id, + user.uid, + ); + expect(result).toEqualLeft(TEAM_MEMBER_NOT_FOUND); + }); + + // test('should return the TeamCollection data for CLI', async () => { + // mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce( + // rootTeamCollection, + // ); + // mockTeamService.getTeamMember.mockResolvedValue({ + // membershipID: 'sdc3sfdv', + // userUid: user.uid, + // role: TeamAccessRole.OWNER, + // }); + + // const result = await teamCollectionService.getCollectionForCLI( + // rootTeamCollection.id, + // user.uid, + // ); + // expect(result).toEqualRight({ + // id: rootTeamCollection.id, + // data: JSON.stringify(rootTeamCollection.data), + // title: rootTeamCollection.title, + // parentID: rootTeamCollection.parentID, + // folders: [ + // { + // id: childTeamCollection.id, + // data: JSON.stringify(childTeamCollection.data), + // title: childTeamCollection.title, + // parentID: childTeamCollection.parentID, + // folders: [], + // requests: [], + // }, + // ], + // requests: [], + // }); + // }); +}); diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts new file mode 100644 index 0000000..40a98e7 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts @@ -0,0 +1,1581 @@ +import { ConflictException, HttpStatus, Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { TeamCollection } from './team-collection.model'; +import { + TEAM_COLL_SHORT_TITLE, + TEAM_COLL_INVALID_JSON, + TEAM_INVALID_COLL_ID, + TEAM_NOT_OWNER, + TEAM_COLL_NOT_FOUND, + TEAM_COL_ALREADY_ROOT, + TEAM_COLL_DEST_SAME, + TEAM_COLL_NOT_SAME_TEAM, + TEAM_COLL_IS_PARENT_COLL, + TEAM_COL_SAME_NEXT_COLL, + TEAM_COL_REORDERING_FAILED, + TEAM_COLL_DATA_INVALID, + TEAM_REQ_SEARCH_FAILED, + TEAM_COL_SEARCH_FAILED, + TEAM_REQ_PARENT_TREE_GEN_FAILED, + TEAM_COLL_PARENT_TREE_GEN_FAILED, + TEAM_MEMBER_NOT_FOUND, + TEAM_COLL_CREATION_FAILED, +} from '../errors'; +import { PubSubService } from '../pubsub/pubsub.service'; +import { + delay, + escapeSqlLikeString, + isValidLength, + transformCollectionData, + stringToJson, +} from 'src/utils'; +import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; +import { + Prisma, + TeamCollection as DBTeamCollection, + TeamRequest, +} from 'src/generated/prisma/client'; +import { CollectionFolder } from 'src/types/CollectionFolder'; +import { CollectionSearchNode } from 'src/types/CollectionSearchNode'; +import { + GetCollectionResponse, + ParentTreeQueryReturnType, + SearchQueryReturnType, +} from './helper'; +import { RESTError } from 'src/types/RESTError'; +import { TeamService } from 'src/team/team.service'; +import { PrismaError } from 'src/prisma/prisma-error-codes'; +import { SortOptions } from 'src/types/SortOptions'; + +@Injectable() +export class TeamCollectionService { + constructor( + private readonly prisma: PrismaService, + private readonly pubsub: PubSubService, + private readonly teamService: TeamService, + ) {} + + TITLE_LENGTH = 1; + MAX_RETRIES = 5; // Maximum number of retries for database transactions + + /** + * Generate a Prisma query object representation of a collection and its child collections and requests + * + * @param folder CollectionFolder from client + * @param teamID The Team ID + * @param orderIndex Initial OrderIndex of + * @returns A Prisma query object to create a collection, its child collections and requests + */ + private generatePrismaQueryObjForFBCollFolder( + folder: CollectionFolder, + teamID: string, + orderIndex: number, + ): Prisma.TeamCollectionCreateInput { + return { + title: folder.name, + team: { + connect: { + id: teamID, + }, + }, + requests: { + create: folder.requests.map((r, index) => ({ + title: r.name, + team: { + connect: { + id: teamID, + }, + }, + request: r, + orderIndex: index + 1, + })), + }, + orderIndex: orderIndex, + children: { + create: folder.folders.map((f, index) => + this.generatePrismaQueryObjForFBCollFolder(f, teamID, index + 1), + ), + }, + data: folder.data ?? undefined, + }; + } + + /** + * Generate a JSON containing all the contents of a collection + * + * @param teamID The Team ID + * @param collectionID The Collection ID + * @returns A JSON string containing all the contents of a collection + */ + async exportCollectionToJSONObject( + teamID: string, + collectionID: string, + ): Promise | E.Left> { + const collection = await this.getCollection(collectionID); + if (E.isLeft(collection)) return E.left(TEAM_INVALID_COLL_ID); + + const childrenCollectionObjects = []; + + const childrenCollection = await this.prisma.teamCollection.findMany({ + where: { + teamID, + parentID: collectionID, + }, + orderBy: { + orderIndex: 'asc', + }, + }); + + for (const coll of childrenCollection) { + const result = await this.exportCollectionToJSONObject(teamID, coll.id); + if (E.isLeft(result)) return E.left(result.left); + + childrenCollectionObjects.push(result.right); + } + + const requests = await this.prisma.teamRequest.findMany({ + where: { + teamID, + collectionID, + }, + orderBy: { + orderIndex: 'asc', + }, + }); + + const data = transformCollectionData(collection.right.data); + + const result: CollectionFolder = { + id: collection.right.id, + name: collection.right.title, + folders: childrenCollectionObjects, + requests: requests.map((x) => { + const requestData = + typeof x.request === 'string' ? JSON.parse(x.request) : x.request; + return { + ...requestData, + id: x.id, + }; + }), + data, + }; + + return E.right(result); + } + + /** + * Generate a JSON containing all the contents of collections and requests of a team + * + * @param teamID The Team ID + * @returns A JSON string containing all the contents of collections and requests of a team + */ + async exportCollectionsToJSON(teamID: string) { + const rootCollections = await this.prisma.teamCollection.findMany({ + where: { + teamID, + parentID: null, + }, + }); + + const rootCollectionObjects = []; + for (const coll of rootCollections) { + const result = await this.exportCollectionToJSONObject(teamID, coll.id); + if (E.isLeft(result)) return E.left(result.left); + + rootCollectionObjects.push(result.right); + } + + return E.right(JSON.stringify(rootCollectionObjects)); + } + + /** + * Create new TeamCollections and TeamRequests from JSON string + * + * @param jsonString The JSON string of the content + * @param teamID Team ID, where the collections will be created + * @param parentID Collection ID, where the collections will be created under + * @returns An Either of a Boolean if the creation operation was successful + */ + async importCollectionsFromJSON( + jsonString: string, + teamID: string, + parentID: string | null, + ) { + // Check to see if jsonString is valid + const collectionsList = stringToJson(jsonString); + if (E.isLeft(collectionsList)) return E.left(TEAM_COLL_INVALID_JSON); + + // Check to see if parsed jsonString is an array + if (!Array.isArray(collectionsList.right)) + return E.left(TEAM_COLL_INVALID_JSON); + + let teamCollections: DBTeamCollection[] = []; + let queryList: Prisma.TeamCollectionCreateInput[] = []; + try { + await this.prisma.$transaction(async (tx) => { + try { + // lock the rows + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + teamID, + parentID, + ); + + // Get the last order index + const lastEntry = await tx.teamCollection.findFirst({ + where: { teamID, parentID }, + orderBy: { orderIndex: 'desc' }, + select: { orderIndex: true }, + }); + let lastOrderIndex = lastEntry ? lastEntry.orderIndex : 0; + + // Generate Prisma Query Object for all child collections in collectionsList + queryList = collectionsList.right.map((x) => + this.generatePrismaQueryObjForFBCollFolder( + x, + teamID, + ++lastOrderIndex, + ), + ); + + const promises = queryList.map((query) => + tx.teamCollection.create({ + data: { + ...query, + parent: parentID ? { connect: { id: parentID } } : undefined, + }, + }), + ); + teamCollections = await Promise.all(promises); + } catch (error) { + throw new ConflictException(error); + } + }); + } catch (error) { + console.error( + 'Error from TeamCollectionService.importCollectionsFromJSON', + error, + ); + return E.left(TEAM_COLL_CREATION_FAILED); + } + + teamCollections.forEach((collection) => + this.pubsub.publish( + `team_coll/${teamID}/coll_added`, + this.cast(collection), + ), + ); + + return E.right(teamCollections); + } + + /** + * Typecast a database TeamCollection to a TeamCollection model + * + * @param teamCollection database TeamCollection + * @returns TeamCollection model + */ + private cast(teamCollection: DBTeamCollection): TeamCollection { + const data = transformCollectionData(teamCollection.data); + + return { + id: teamCollection.id, + title: teamCollection.title, + parentID: teamCollection.parentID, + data, + }; + } + + /** + * Get Team of given Collection ID + * + * @param collectionID The collection ID + * @returns Team of given Collection ID + */ + async getTeamOfCollection(collectionID: string) { + try { + const teamCollection = await this.prisma.teamCollection.findUnique({ + where: { + id: collectionID, + }, + include: { + team: true, + }, + }); + + return E.right(teamCollection.team); + } catch (error) { + return E.left(TEAM_INVALID_COLL_ID); + } + } + + /** + * Get parent of given Collection ID + * + * @param collectionID The collection ID + * @returns Parent TeamCollection of given Collection ID + */ + async getParentOfCollection(collectionID: string) { + const teamCollection = await this.prisma.teamCollection.findUnique({ + where: { + id: collectionID, + }, + include: { + parent: true, + }, + }); + if (!teamCollection) return null; + + return !teamCollection.parent ? null : this.cast(teamCollection.parent); + } + + /** + * Get child collections of given Collection ID + * + * @param collectionID The collection ID + * @param cursor collectionID for pagination + * @param take Number of items we want returned + * @returns A list of child collections + */ + async getChildrenOfCollection( + collectionID: string, + cursor: string | null, + take: number, + ) { + const res = await this.prisma.teamCollection.findMany({ + where: { + parentID: collectionID, + }, + orderBy: { + orderIndex: 'asc', + }, + take: take, // default: 10 + skip: cursor ? 1 : 0, + cursor: cursor ? { id: cursor } : undefined, + }); + + const childCollections = res.map((teamCollection) => + this.cast(teamCollection), + ); + + return childCollections; + } + + /** + * Get root collections of given Collection ID + * + * @param teamID The Team ID + * @param cursor collectionID for pagination + * @param take Number of items we want returned + * @returns A list of root TeamCollections + */ + async getTeamRootCollections( + teamID: string, + cursor: string | null, + take: number, + ) { + const res = await this.prisma.teamCollection.findMany({ + where: { + teamID, + parentID: null, + }, + orderBy: { + orderIndex: 'asc', + }, + take: take, // default: 10 + skip: cursor ? 1 : 0, + cursor: cursor ? { id: cursor } : undefined, + }); + + const teamCollections = res.map((teamCollection) => + this.cast(teamCollection), + ); + + return teamCollections; + } + + /** + * Get collection details + * + * @param collectionID The collection ID + * @returns An Either of the Collection details + */ + async getCollection( + collectionID: string, + tx: Prisma.TransactionClient | null = null, + ) { + try { + const teamCollection = await ( + tx || this.prisma + ).teamCollection.findUniqueOrThrow({ + where: { + id: collectionID, + }, + }); + return E.right(teamCollection); + } catch (error) { + return E.left(TEAM_COLL_NOT_FOUND); + } + } + + /** + * Check to see if Collection belongs to Team + * + * @param collectionID getChildCollectionsCount + * @param teamID The Team ID + * @returns An Option of a Boolean + */ + private async isOwnerCheck(collectionID: string, teamID: string) { + try { + await this.prisma.teamCollection.findFirstOrThrow({ + where: { + id: collectionID, + teamID, + }, + }); + + return O.some(true); + } catch (error) { + return O.none; + } + } + + /** + * Create a new TeamCollection + * + * @param teamID The Team ID + * @param title The title of new TeamCollection + * @param parentID The parent collectionID (null if root collection) + * @returns An Either of TeamCollection + */ + async createCollection( + teamID: string, + title: string, + data: string | null = null, + parentID: string | null, + ) { + const isTitleValid = isValidLength(title, this.TITLE_LENGTH); + if (!isTitleValid) return E.left(TEAM_COLL_SHORT_TITLE); + + // Check to see if parentTeamCollectionID belongs to this Team + if (parentID !== null) { + const isOwner = await this.isOwnerCheck(parentID, teamID); + if (O.isNone(isOwner)) return E.left(TEAM_NOT_OWNER); + } + + if (data === '') return E.left(TEAM_COLL_DATA_INVALID); + if (data) { + const jsonReq = stringToJson(data); + if (E.isLeft(jsonReq)) return E.left(TEAM_COLL_DATA_INVALID); + data = jsonReq.right; + } + + let teamCollection: DBTeamCollection | null = null; + try { + teamCollection = await this.prisma.$transaction(async (tx) => { + try { + // lock the rows + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + teamID, + parentID, + ); + + // fetch last collection + const lastCollection = await tx.teamCollection.findFirst({ + where: { teamID, parentID }, + orderBy: { orderIndex: 'desc' }, + select: { orderIndex: true }, + }); + + // create new collection + return tx.teamCollection.create({ + data: { + title, + teamID, + parentID: parentID ? parentID : undefined, + data: data ?? undefined, + orderIndex: lastCollection ? lastCollection.orderIndex + 1 : 1, + }, + }); + } catch (error) { + throw new ConflictException(error); + } + }); + } catch (error) { + console.error('Error from TeamCollectionService.createCollection', error); + return E.left(TEAM_COLL_CREATION_FAILED); + } + + this.pubsub.publish( + `team_coll/${teamID}/coll_added`, + this.cast(teamCollection), + ); + + return E.right(this.cast(teamCollection)); + } + + /** + * @deprecated Use updateTeamCollection method instead + * Update the title of a TeamCollection + * + * @param collectionID The Collection ID + * @param newTitle The new title of collection + * @returns An Either of the updated TeamCollection + */ + async renameCollection(collectionID: string, newTitle: string) { + const isTitleValid = isValidLength(newTitle, this.TITLE_LENGTH); + if (!isTitleValid) return E.left(TEAM_COLL_SHORT_TITLE); + + try { + const updatedTeamCollection = await this.prisma.teamCollection.update({ + where: { id: collectionID }, + data: { title: newTitle }, + }); + + this.pubsub.publish( + `team_coll/${updatedTeamCollection.teamID}/coll_updated`, + this.cast(updatedTeamCollection), + ); + + return E.right(this.cast(updatedTeamCollection)); + } catch (error) { + return E.left(TEAM_COLL_NOT_FOUND); + } + } + + /** + * Update the OrderIndex of all collections in given parentID + * + * @param collection The collection to delete + * @param orderIndexCondition Condition to decide what collections will be updated + * @param dataCondition Increment/Decrement OrderIndex condition + */ + private async deleteCollectionAndUpdateSiblingsOrderIndex( + collection: DBTeamCollection, + orderIndexCondition: Prisma.IntFilter, + dataCondition: Prisma.IntFieldUpdateOperationsInput, + ) { + let retryCount = 0; + while (retryCount < this.MAX_RETRIES) { + try { + await this.prisma.$transaction(async (tx) => { + try { + // lock the rows + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + collection.teamID, + collection.parentID, + ); + + try { + await tx.teamCollection.delete({ + where: { id: collection.id }, + }); + } catch (deleteError) { + // P2025: Record not found — already deleted by a concurrent transaction + if (deleteError?.code === PrismaError.RECORD_NOT_FOUND) return; + throw deleteError; + } + + await tx.teamCollection.updateMany({ + where: { + teamID: collection.teamID, + parentID: collection.parentID, + orderIndex: orderIndexCondition, + }, + data: { orderIndex: dataCondition }, + }); + } catch (error) { + throw new ConflictException(error); + } + }); + + break; + } catch (error) { + console.error( + 'Error from TeamCollectionService.updateOrderIndex', + error, + ); + retryCount++; + if ( + retryCount >= this.MAX_RETRIES || + (error.code !== PrismaError.UNIQUE_CONSTRAINT_VIOLATION && + error.code !== PrismaError.TRANSACTION_DEADLOCK && + error.code !== PrismaError.TRANSACTION_TIMEOUT) // return for all DB error except deadlocks, unique constraint violations, transaction timeouts + ) + return E.left(TEAM_COL_REORDERING_FAILED); + + await delay(retryCount * 100); + console.debug(`Retrying updateOrderIndex... (${retryCount})`); + } + } + + return E.right(true); + } + + /** + * Delete a TeamCollection + * + * @param collectionID The Collection Id + * @returns An Either of Boolean of deletion status + */ + async deleteCollection(collectionID: string) { + const collection = await this.getCollection(collectionID); + if (E.isLeft(collection)) return E.left(collection.left); + + // Delete all child collections and requests in the collection + const isDeleted = await this.deleteCollectionAndUpdateSiblingsOrderIndex( + collection.right, + { gt: collection.right.orderIndex }, + { decrement: 1 }, + ); + if (E.isLeft(isDeleted)) return E.left(isDeleted.left); + + this.pubsub.publish( + `team_coll/${collection.right.teamID}/coll_removed`, + collection.right.id, + ); + + return E.right(true); + } + + /** + * Change parentID of TeamCollection's + * + * @param collection The collection that is being moved + * @param newParentID The new parent's collection ID or change to root collection + * @returns If successful return an Either of collection or error message + */ + private async changeParentAndUpdateOrderIndex( + tx: Prisma.TransactionClient, + collection: DBTeamCollection, + newParentID: string | null, + ) { + // fetch last collection + const lastCollectionUnderNewParent = await tx.teamCollection.findFirst({ + where: { teamID: collection.teamID, parentID: newParentID }, + orderBy: { orderIndex: 'desc' }, + }); + + // decrement orderIndex of all next sibling collections from original collection + await tx.teamCollection.updateMany({ + where: { + teamID: collection.teamID, + parentID: collection.parentID, + orderIndex: { gt: collection.orderIndex }, + }, + data: { + orderIndex: { decrement: 1 }, + }, + }); + + // update collection's parentID and orderIndex + const updatedCollection = await tx.teamCollection.update({ + where: { id: collection.id }, + data: { + // if parentCollectionID == null, collection becomes root collection + // if parentCollectionID != null, collection becomes child collection + parentID: newParentID, + orderIndex: lastCollectionUnderNewParent + ? lastCollectionUnderNewParent.orderIndex + 1 + : 1, + }, + }); + + return E.right(this.cast(updatedCollection)); + } + + /** + * Check if collection is parent of destCollection + * + * @param collection The ID of collection being moved + * @param destCollection The ID of collection into which we are moving target collection into + * @returns An Option of boolean, is parent or not + */ + private async isParent( + collection: DBTeamCollection, + destCollection: DBTeamCollection, + tx: Prisma.TransactionClient | null = null, + ): Promise> { + //* Recursively check if collection is a parent by going up the tree of child-parent collections until we reach a root collection i.e parentID === null + //* Valid condition, isParent returns false + //* Consider us moving Collection_E into Collection_D + //* Collection_A [parent:null !== Collection_E] return false, exit + //* |--> Collection_B [parent:Collection_A !== Collection_E] call isParent(Collection_E,Collection_A) + //* |--> Collection_C [parent:Collection_B !== Collection_E] call isParent(Collection_E,Collection_B) + //* |--> Collection_D [parent:Collection_C !== Collection_E] call isParent(Collection_E,Collection_C) + //* Invalid condition, isParent returns true + //* Consider us moving Collection_B into Collection_D + //* Collection_A + //* |--> Collection_B + //* |--> Collection_C [parent:Collection_B === Collection_B] return true, exit + //* |--> Collection_D [parent:Collection_C !== Collection_B] call isParent(Collection_B,Collection_C) + + // Check if collection and destCollection are same + if (collection === destCollection) { + return O.none; + } + if (destCollection.parentID !== null) { + // Check if ID of collection is same as parent of destCollection + if (destCollection.parentID === collection.id) { + return O.none; + } + // Get collection details of collection one step above in the tree i.e the parent collection + const parentCollection = await this.getCollection( + destCollection.parentID, + tx, + ); + if (E.isLeft(parentCollection)) { + return O.none; + } + // Call isParent again now with parent collection + return await this.isParent(collection, parentCollection.right, tx); + } else { + return O.some(true); + } + } + + /** + * Move TeamCollection into root or another collection + * + * @param collectionID The ID of collection being moved + * @param destCollectionID The ID of collection the target collection is being moved into or move target collection to root + * @returns An Either of the moved TeamCollection + */ + async moveCollection(collectionID: string, destCollectionID: string | null) { + try { + return await this.prisma.$transaction(async (tx) => { + // Get collection details of collectionID + const collection = await this.getCollection(collectionID, tx); + if (E.isLeft(collection)) return E.left(collection.left); + + // destCollectionID == null i.e move collection to root + if (!destCollectionID) { + if (!collection.right.parentID) { + // collection is a root collection + // Throw error if collection is already a root collection + return E.left(TEAM_COL_ALREADY_ROOT); + } + + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + collection.right.teamID, + collection.right.parentID, + ); + + // Change parent from child to root i.e child collection becomes a root collection + // Move child collection into root and update orderIndexes for root teamCollections + const updatedCollection = await this.changeParentAndUpdateOrderIndex( + tx, + collection.right, + null, + ); + if (E.isLeft(updatedCollection)) + return E.left(updatedCollection.left); + + this.pubsub.publish( + `team_coll/${collection.right.teamID}/coll_moved`, + updatedCollection.right, + ); + + return E.right(updatedCollection.right); + } + + // destCollectionID != null i.e move into another collection + if (collectionID === destCollectionID) { + // Throw error if collectionID and destCollectionID are the same + return E.left(TEAM_COLL_DEST_SAME); + } + + // Get collection details of destCollectionID + const destCollection = await this.getCollection(destCollectionID, tx); + if (E.isLeft(destCollection)) return E.left(TEAM_COLL_NOT_FOUND); + + // Check if collection and destCollection belong to the same user account + if (collection.right.teamID !== destCollection.right.teamID) { + return E.left(TEAM_COLL_NOT_SAME_TEAM); + } + + // Check if collection is present on the parent tree for destCollection + const checkIfParent = await this.isParent( + collection.right, + destCollection.right, + tx, + ); + if (O.isNone(checkIfParent)) { + return E.left(TEAM_COLL_IS_PARENT_COLL); + } + + // Acquire locks in deterministic order (sorted by parentID) to prevent deadlocks + // when two concurrent moves happen in opposite directions + const srcParentID = collection.right.parentID ?? ''; + const destParentID = destCollection.right.parentID ?? ''; + const teamID = collection.right.teamID; + + if (srcParentID === destParentID) { + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + teamID, + collection.right.parentID, + ); + } else if (srcParentID < destParentID) { + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + teamID, + collection.right.parentID, + ); + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + teamID, + destCollection.right.parentID, + ); + } else { + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + teamID, + destCollection.right.parentID, + ); + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + teamID, + collection.right.parentID, + ); + } + + // Change parent from null to teamCollection i.e collection becomes a child collection + // Move root/child collection into another child collection and update orderIndexes of the previous parent + const updatedCollection = await this.changeParentAndUpdateOrderIndex( + tx, + collection.right, + destCollection.right.id, + ); + if (E.isLeft(updatedCollection)) return E.left(updatedCollection.left); + + this.pubsub.publish( + `team_coll/${collection.right.teamID}/coll_moved`, + updatedCollection.right, + ); + + return E.right(updatedCollection.right); + }); + } catch (error) { + console.error('Error from TeamCollectionService.moveCollection', error); + return E.left(TEAM_COL_REORDERING_FAILED); + } + } + + /** + * Find the number of child collections present in collectionID + * + * @param collectionID The Collection ID + * @param teamID The Team ID (required when collectionID is null for root collections) + * @returns Number of collections + */ + getCollectionCount( + collectionID: string, + teamID: string, + tx: Prisma.TransactionClient | null = null, + ): Promise { + return (tx || this.prisma).teamCollection.count({ + where: { parentID: collectionID, teamID: teamID }, + }); + } + + /** + * Update order of root or child collectionID's + * + * @param collectionID The ID of collection being re-ordered + * @param nextCollectionID The ID of collection that is after the moved collection in its new position + * @returns If successful return an Either of true + */ + async updateCollectionOrder( + collectionID: string, + nextCollectionID: string | null, + ) { + // Throw error if collectionID and nextCollectionID are the same + if (collectionID === nextCollectionID) + return E.left(TEAM_COL_SAME_NEXT_COLL); + + // Get collection details of collectionID + const collection = await this.getCollection(collectionID); + if (E.isLeft(collection)) return E.left(collection.left); + + if (!nextCollectionID) { + // nextCollectionID == null i.e move collection to the end of the list + try { + await this.prisma.$transaction(async (tx) => { + try { + // Step 0: lock the rows + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + collection.right.teamID, + collection.right.parentID, + ); + + const collectionInTx = await tx.teamCollection.findFirst({ + where: { id: collection.right.id }, + select: { orderIndex: true }, + }); + + // if collection is found, update orderIndexes of siblings + // if collection was deleted before the transaction started (race condition), do not update siblings orderIndexes + if (collectionInTx) { + // Step 1: Decrement orderIndex of all items that come after collection.orderIndex till end of list of items + await tx.teamCollection.updateMany({ + where: { + teamID: collection.right.teamID, + parentID: collection.right.parentID, + orderIndex: { + gte: collectionInTx.orderIndex + 1, + }, + }, + data: { + orderIndex: { decrement: 1 }, + }, + }); + + // Step 2: Update orderIndex of collection to length of list + await tx.teamCollection.update({ + where: { id: collection.right.id }, + data: { + orderIndex: await this.getCollectionCount( + collection.right.parentID, + collection.right.teamID, + tx, + ), + }, + }); + } + } catch (error) { + throw new ConflictException(error); + } + }); + + this.pubsub.publish( + `team_coll/${collection.right.teamID}/coll_order_updated`, + { + collection: this.cast(collection.right), + nextCollection: null, + }, + ); + + return E.right(true); + } catch (error) { + return E.left(TEAM_COL_REORDERING_FAILED); + } + } + + // nextCollectionID != null i.e move to a certain position + // Get collection details of nextCollectionID + const subsequentCollection = await this.getCollection(nextCollectionID); + if (E.isLeft(subsequentCollection)) return E.left(TEAM_COLL_NOT_FOUND); + + // Check if collection and subsequentCollection belong to the same collection team + if (collection.right.teamID !== subsequentCollection.right.teamID) + return E.left(TEAM_COLL_NOT_SAME_TEAM); + + try { + await this.prisma.$transaction(async (tx) => { + try { + // Step 0: lock the rows + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + collection.right.teamID, + collection.right.parentID, + ); + + const collectionInTx = await tx.teamCollection.findFirst({ + where: { id: collectionID }, + select: { orderIndex: true }, + }); + const subsequentCollectionInTx = await tx.teamCollection.findFirst({ + where: { id: nextCollectionID }, + select: { orderIndex: true }, + }); + + // if collection and subsequentCollection are found, update orderIndexes of siblings + // if collection or subsequentCollection was deleted before the transaction started (race condition), do not update siblings orderIndexes + if (collectionInTx && subsequentCollectionInTx) { + // Step 1: Determine if we are moving collection up or down the list + const isMovingUp = + subsequentCollectionInTx.orderIndex < collectionInTx.orderIndex; + + // Step 2: Update OrderIndex of items in list depending on moving up or down + const updateFrom = isMovingUp + ? subsequentCollectionInTx.orderIndex + : collectionInTx.orderIndex + 1; + + const updateTo = isMovingUp + ? collectionInTx.orderIndex - 1 + : subsequentCollectionInTx.orderIndex - 1; + + await tx.teamCollection.updateMany({ + where: { + teamID: collection.right.teamID, + parentID: collection.right.parentID, + orderIndex: { gte: updateFrom, lte: updateTo }, + }, + data: { + orderIndex: isMovingUp ? { increment: 1 } : { decrement: 1 }, + }, + }); + + // Step 3: Update OrderIndex of collection + await tx.teamCollection.update({ + where: { id: collection.right.id }, + data: { + orderIndex: isMovingUp + ? subsequentCollectionInTx.orderIndex + : subsequentCollectionInTx.orderIndex - 1, + }, + }); + } + } catch (error) { + throw new ConflictException(error); + } + }); + + this.pubsub.publish( + `team_coll/${collection.right.teamID}/coll_order_updated`, + { + collection: this.cast(collection.right), + nextCollection: this.cast(subsequentCollection.right), + }, + ); + + return E.right(true); + } catch (error) { + return E.left(TEAM_COL_REORDERING_FAILED); + } + } + + /** + * Fetch list of all the Team Collections in DB for a particular team + * @param teamID Team ID + * @returns number of Team Collections in the DB + */ + async totalCollectionsInTeam(teamID: string) { + const collCount = await this.prisma.teamCollection.count({ + where: { + teamID: teamID, + }, + }); + + return collCount; + } + + /** + * Fetch list of all the Team Collections in DB + * + * @returns number of Team Collections in the DB + */ + async getTeamCollectionsCount() { + const teamCollectionsCount = this.prisma.teamCollection.count(); + return teamCollectionsCount; + } + + /** + * Update Team Collection details + * + * @param collectionID Collection ID + * @param collectionData new header data in a JSONified string form + * @param newTitle New title of the collection + * @returns Updated TeamCollection + */ + async updateTeamCollection( + collectionID: string, + collectionData: string = null, + newTitle: string = null, + ) { + try { + if (newTitle != null) { + const isTitleValid = isValidLength(newTitle, this.TITLE_LENGTH); + if (!isTitleValid) return E.left(TEAM_COLL_SHORT_TITLE); + } + + if (collectionData === '') return E.left(TEAM_COLL_DATA_INVALID); + if (collectionData) { + const jsonReq = stringToJson(collectionData); + if (E.isLeft(jsonReq)) return E.left(TEAM_COLL_DATA_INVALID); + collectionData = jsonReq.right; + } + + const updatedTeamCollection = await this.prisma.teamCollection.update({ + where: { id: collectionID }, + data: { + data: collectionData ?? undefined, + title: newTitle ?? undefined, + }, + }); + + this.pubsub.publish( + `team_coll/${updatedTeamCollection.teamID}/coll_updated`, + this.cast(updatedTeamCollection), + ); + + return E.right(this.cast(updatedTeamCollection)); + } catch (e) { + return E.left(TEAM_COLL_NOT_FOUND); + } + } + + /** + * Search for TeamCollections and TeamRequests by title + * + * @param searchQuery The search query + * @param teamID The Team ID + * @param take Number of items we want returned + * @param skip Number of items we want to skip + * @returns An Either of the search results + */ + async searchByTitle( + searchQuery: string, + teamID: string, + take = 10, + skip = 0, + ) { + // Fetch all collections and requests that match the search query + const searchResults: SearchQueryReturnType[] = []; + + const matchedCollections = await this.searchCollections( + searchQuery, + teamID, + take, + skip, + ); + if (E.isLeft(matchedCollections)) + return E.left({ + message: matchedCollections.left, + statusCode: HttpStatus.NOT_FOUND, + }); + searchResults.push(...matchedCollections.right); + + const matchedRequests = await this.searchRequests( + searchQuery, + teamID, + take, + skip, + ); + if (E.isLeft(matchedRequests)) + return E.left({ + message: matchedRequests.left, + statusCode: HttpStatus.NOT_FOUND, + }); + searchResults.push(...matchedRequests.right); + + // Generate the parent tree for searchResults + const searchResultsWithTree: CollectionSearchNode[] = []; + + for (let i = 0; i < searchResults.length; i++) { + const fetchedParentTree = await this.fetchParentTree(searchResults[i]); + if (E.isLeft(fetchedParentTree)) + return E.left({ + message: fetchedParentTree.left, + statusCode: HttpStatus.NOT_FOUND, + }); + searchResultsWithTree.push({ + type: searchResults[i].type, + title: searchResults[i].title, + method: searchResults[i].method, + id: searchResults[i].id, + path: !fetchedParentTree + ? [] + : (fetchedParentTree.right as CollectionSearchNode[]), + }); + } + + return E.right({ data: searchResultsWithTree }); + } + + /** + * Search for TeamCollections by title + * + * @param searchQuery The search query + * @param teamID The Team ID + * @param take Number of items we want returned + * @param skip Number of items we want to skip + * @returns An Either of the search results + */ + private async searchCollections( + searchQuery: string, + teamID: string, + take: number, + skip: number, + ) { + const query = Prisma.sql` + SELECT + id,title,'collection' AS type + FROM + "TeamCollection" + WHERE + "TeamCollection"."teamID"=${teamID} + AND + title ILIKE ${`%${escapeSqlLikeString(searchQuery)}%`} + ORDER BY + similarity(title, ${searchQuery}) + LIMIT ${take} + OFFSET ${skip === 0 ? 0 : (skip - 1) * take}; + `; + + try { + const res = await this.prisma.$queryRaw(query); + return E.right(res); + } catch (error) { + return E.left(TEAM_COL_SEARCH_FAILED); + } + } + + /** + * Search for TeamRequests by title + * + * @param searchQuery The search query + * @param teamID The Team ID + * @param take Number of items we want returned + * @param skip Number of items we want to skip + * @returns An Either of the search results + */ + private async searchRequests( + searchQuery: string, + teamID: string, + take: number, + skip: number, + ) { + const query = Prisma.sql` + SELECT + id,title,request->>'method' as method,'request' AS type + FROM + "TeamRequest" + WHERE + "TeamRequest"."teamID"=${teamID} + AND + title ILIKE ${`%${escapeSqlLikeString(searchQuery)}%`} + ORDER BY + similarity(title, ${searchQuery}) + LIMIT ${take} + OFFSET ${skip === 0 ? 0 : (skip - 1) * take}; + `; + + try { + const res = await this.prisma.$queryRaw(query); + return E.right(res); + } catch (error) { + return E.left(TEAM_REQ_SEARCH_FAILED); + } + } + + /** + * Generate the parent tree of a search result + * + * @param searchResult The search result for which we want to generate the parent tree + * @returns The parent tree of the search result + */ + private async fetchParentTree(searchResult: SearchQueryReturnType) { + return searchResult.type === 'collection' + ? await this.fetchCollectionParentTree(searchResult.id) + : await this.fetchRequestParentTree(searchResult.id); + } + + /** + * Generate the parent tree of a collection + * + * @param id The ID of the collection + * @returns The parent tree of the collection + */ + private async fetchCollectionParentTree(id: string) { + try { + const query = Prisma.sql` + WITH RECURSIVE collection_tree AS ( + SELECT tc.id, tc."parentID", tc.title + FROM "TeamCollection" AS tc + JOIN "TeamCollection" AS tr ON tc.id = tr."parentID" + WHERE tr.id = ${id} + + UNION ALL + + SELECT parent.id, parent."parentID", parent.title + FROM "TeamCollection" AS parent + JOIN collection_tree AS ct ON parent.id = ct."parentID" + ) + SELECT * FROM collection_tree; + `; + const res = + await this.prisma.$queryRaw(query); + + const collectionParentTree = this.generateParentTree(res); + return E.right(collectionParentTree); + } catch (error) { + E.left(TEAM_COLL_PARENT_TREE_GEN_FAILED); + } + } + + /** + * Generate the parent tree from the collections + * + * @param parentCollections The parent collections + * @returns The parent tree of the parent collections + */ + private generateParentTree(parentCollections: ParentTreeQueryReturnType[]) { + function findChildren(id: string): CollectionSearchNode[] { + const collection = parentCollections.filter((item) => item.id === id)[0]; + if (collection.parentID == null) { + return [ + { + id: collection.id, + title: collection.title, + type: 'collection' as const, + path: [], + }, + ]; + } + + const res = [ + { + id: collection.id, + title: collection.title, + type: 'collection' as const, + path: findChildren(collection.parentID), + }, + ]; + return res; + } + + if (parentCollections.length > 0) { + if (parentCollections[0].parentID == null) { + return [ + { + id: parentCollections[0].id, + title: parentCollections[0].title, + type: 'collection', + path: [], + }, + ]; + } + + return [ + { + id: parentCollections[0].id, + title: parentCollections[0].title, + type: 'collection', + path: findChildren(parentCollections[0].parentID), + }, + ]; + } + + return []; + } + + /** + * Generate the parent tree of a request + * + * @param id The ID of the request + * @returns The parent tree of the request + */ + private async fetchRequestParentTree(id: string) { + try { + const query = Prisma.sql` + WITH RECURSIVE request_collection_tree AS ( + SELECT tc.id, tc."parentID", tc.title + FROM "TeamCollection" AS tc + JOIN "TeamRequest" AS tr ON tc.id = tr."collectionID" + WHERE tr.id = ${id} + + UNION ALL + + SELECT parent.id, parent."parentID", parent.title + FROM "TeamCollection" AS parent + JOIN request_collection_tree AS ct ON parent.id = ct."parentID" + ) + SELECT * FROM request_collection_tree; + + `; + const res = + await this.prisma.$queryRaw(query); + + const requestParentTree = this.generateParentTree(res); + return E.right(requestParentTree); + } catch (error) { + return E.left(TEAM_REQ_PARENT_TREE_GEN_FAILED); + } + } + + /** + * Get all requests in a collection + * + * @param collectionID The Collection ID + * @returns A list of all requests in the collection + */ + private async getAllRequestsInCollection(collectionID: string) { + const dbTeamRequests = await this.prisma.teamRequest.findMany({ + where: { + collectionID: collectionID, + }, + orderBy: { + orderIndex: 'asc', + }, + }); + + const teamRequests = dbTeamRequests.map((tr) => { + return { + id: tr.id, + collectionID: tr.collectionID, + teamID: tr.teamID, + title: tr.title, + request: JSON.stringify(tr.request), + }; + }); + + return teamRequests; + } + + /** + * Get Collection Tree for CLI + * + * @param parentID The parent Collection ID + * @returns Collection tree for CLI + */ + private async getCollectionTreeForCLI(parentID: string | null) { + const childCollections = await this.prisma.teamCollection.findMany({ + where: { parentID }, + orderBy: { orderIndex: 'asc' }, + }); + + const response: GetCollectionResponse[] = []; + + for (const collection of childCollections) { + const folder: GetCollectionResponse = { + id: collection.id, + data: collection.data === null ? null : JSON.stringify(collection.data), + title: collection.title, + parentID: collection.parentID, + folders: await this.getCollectionTreeForCLI(collection.id), + requests: await this.getAllRequestsInCollection(collection.id), + }; + + response.push(folder); + } + + return response; + } + + /** + * Get Collection for CLI + * + * @param collectionID The Collection ID + * @param userUid The User UID + * @returns An Either of the Collection details + */ + async getCollectionForCLI(collectionID: string, userUid: string) { + try { + const collection = await this.prisma.teamCollection.findUniqueOrThrow({ + where: { id: collectionID }, + }); + + const teamMember = await this.teamService.getTeamMember( + collection.teamID, + userUid, + ); + if (!teamMember) return E.left(TEAM_MEMBER_NOT_FOUND); + + return E.right({ + id: collection.id, + data: collection.data === null ? null : JSON.stringify(collection.data), + title: collection.title, + parentID: collection.parentID, + folders: await this.getCollectionTreeForCLI(collection.id), + requests: await this.getAllRequestsInCollection(collection.id), + }); + } catch (error) { + return E.left(TEAM_COLL_NOT_FOUND); + } + } + + /** + * Duplicate a Team Collection + * + * @param collectionID The Collection ID + * @returns Boolean of duplication status + */ + async duplicateTeamCollection(collectionID: string) { + const collection = await this.getCollection(collectionID); + if (E.isLeft(collection)) return E.left(TEAM_INVALID_COLL_ID); + + const collectionJSONObject = await this.exportCollectionToJSONObject( + collection.right.teamID, + collectionID, + ); + if (E.isLeft(collectionJSONObject)) return E.left(TEAM_INVALID_COLL_ID); + + const result = await this.importCollectionsFromJSON( + JSON.stringify([ + { + ...collectionJSONObject.right, + name: `${collection.right.title} - Duplicate`, + }, + ]), + collection.right.teamID, + collection.right.parentID, + ); + if (E.isLeft(result)) return E.left(result.left as string); + + return E.right(true); + } + + /** + * Sort Team Collections in a parent collection + * + * @param teamID The Team ID + * @param parentID The Parent Collection ID + * @param sortBy The sort option + * @returns Boolean of sorting status + */ + async sortTeamCollections( + teamID: string, + parentID: string, + sortBy: SortOptions, + ) { + // Handle all sort options, including a default + let orderBy: Prisma.Enumerable; + if (sortBy === SortOptions.TITLE_ASC) { + orderBy = { title: 'asc' }; + } else if (sortBy === SortOptions.TITLE_DESC) { + orderBy = { title: 'desc' }; + } else { + orderBy = { orderIndex: 'asc' }; + } + + try { + await this.prisma.$transaction(async (tx) => { + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + teamID, + parentID, + ); + + const collections = await tx.teamCollection.findMany({ + where: { teamID, parentID }, + orderBy, + select: { id: true }, + }); + + // Update the orderIndex of each collection based on the new order + const promises = collections.map((collection, i) => + tx.teamCollection.update({ + where: { id: collection.id }, + data: { orderIndex: i + 1 }, + }), + ); + await Promise.all(promises); + }); + } catch (error) { + console.error( + 'Error from TeamCollectionService.sortTeamCollections', + error, + ); + return E.left(TEAM_COL_REORDERING_FAILED); + } + + return E.right(true); + } +} diff --git a/packages/hoppscotch-backend/src/team-environments/gql-team-env-team.guard.ts b/packages/hoppscotch-backend/src/team-environments/gql-team-env-team.guard.ts new file mode 100644 index 0000000..d4535da --- /dev/null +++ b/packages/hoppscotch-backend/src/team-environments/gql-team-env-team.guard.ts @@ -0,0 +1,57 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { TeamEnvironmentsService } from './team-environments.service'; +import { + BUG_AUTH_NO_USER_CTX, + BUG_TEAM_ENV_GUARD_NO_ENV_ID, + BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES, + TEAM_ENVIRONMENT_NOT_TEAM_MEMBER, + TEAM_ENVIRONMENT_NOT_FOUND, +} from 'src/errors'; +import { TeamService } from 'src/team/team.service'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import * as E from 'fp-ts/Either'; +import { TeamAccessRole } from 'src/generated/prisma/client'; +import { throwErr } from 'src/utils'; + +/** + * A guard which checks whether the caller of a GQL Operation + * is in the team which owns the environment. + * This guard also requires the RequireRole decorator for access control + */ +@Injectable() +export class GqlTeamEnvTeamGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly teamEnvironmentService: TeamEnvironmentsService, + private readonly teamService: TeamService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requireRoles = this.reflector.get( + 'requiresTeamRole', + context.getHandler(), + ); + if (!requireRoles) throw new Error(BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES); + + const gqlExecCtx = GqlExecutionContext.create(context); + + const { user } = gqlExecCtx.getContext().req; + if (user == undefined) throw new Error(BUG_AUTH_NO_USER_CTX); + + const { id } = gqlExecCtx.getArgs<{ id: string }>(); + if (!id) throwErr(BUG_TEAM_ENV_GUARD_NO_ENV_ID); + + const teamEnvironment = + await this.teamEnvironmentService.getTeamEnvironment(id); + if (E.isLeft(teamEnvironment)) throwErr(TEAM_ENVIRONMENT_NOT_FOUND); + + const member = await this.teamService.getTeamMember( + teamEnvironment.right.teamID, + user.uid, + ); + if (!member) throwErr(TEAM_ENVIRONMENT_NOT_TEAM_MEMBER); + + return requireRoles.includes(member.role); + } +} diff --git a/packages/hoppscotch-backend/src/team-environments/input-type.args.ts b/packages/hoppscotch-backend/src/team-environments/input-type.args.ts new file mode 100644 index 0000000..a58d9af --- /dev/null +++ b/packages/hoppscotch-backend/src/team-environments/input-type.args.ts @@ -0,0 +1,56 @@ +import { ArgsType, Field, ID } from '@nestjs/graphql'; +import { IsString, IsNotEmpty } from 'class-validator'; + +@ArgsType() +export class CreateTeamEnvironmentArgs { + @Field({ + name: 'name', + description: 'Name of the Team Environment', + }) + @IsString() + @IsNotEmpty() + name: string; + + @Field(() => ID, { + name: 'teamID', + description: 'ID of the Team', + }) + @IsString() + @IsNotEmpty() + teamID: string; + + @Field({ + name: 'variables', + description: 'JSON string of the variables object', + }) + @IsString() + @IsNotEmpty() + variables: string; +} + +@ArgsType() +export class UpdateTeamEnvironmentArgs { + @Field(() => ID, { + name: 'id', + description: 'ID of the Team Environment', + }) + @IsString() + @IsNotEmpty() + id: string; + + @Field({ + name: 'name', + description: 'Name of the Team Environment', + }) + @IsString() + @IsNotEmpty() + name: string; + + @Field({ + name: 'variables', + description: 'JSON string of the variables object', + }) + @IsString() + @IsNotEmpty() + variables: string; +} diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.model.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.model.ts new file mode 100644 index 0000000..9fa9820 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.model.ts @@ -0,0 +1,24 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class TeamEnvironment { + @Field(() => ID, { + description: 'ID of the Team Environment', + }) + id: string; + + @Field(() => ID, { + description: 'ID of the team this environment belongs to', + }) + teamID: string; + + @Field({ + description: 'Name of the environment', + }) + name: string; + + @Field({ + description: 'All variables present in the environment', + }) + variables: string; // JSON string of the variables object (format:[{ key: "bla", value: "bla_val" }, ...] ) which will be received from the client +} diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.module.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.module.ts new file mode 100644 index 0000000..61b5a12 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TeamEnvironmentsService } from './team-environments.service'; +import { TeamEnvironmentsResolver } from './team-environments.resolver'; +import { UserModule } from 'src/user/user.module'; +import { TeamModule } from 'src/team/team.module'; +import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard'; +import { TeamEnvsTeamResolver } from './team.resolver'; + +@Module({ + imports: [UserModule, TeamModule], + providers: [ + TeamEnvironmentsResolver, + TeamEnvironmentsService, + GqlTeamEnvTeamGuard, + TeamEnvsTeamResolver, + ], + exports: [TeamEnvironmentsService, GqlTeamEnvTeamGuard], +}) +export class TeamEnvironmentsModule {} diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.resolver.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.resolver.ts new file mode 100644 index 0000000..a49da6a --- /dev/null +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.resolver.ts @@ -0,0 +1,199 @@ +import { UseGuards } from '@nestjs/common'; +import { Resolver, Mutation, Args, Subscription, ID } from '@nestjs/graphql'; +import { SkipThrottle } from '@nestjs/throttler'; +import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator'; +import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard'; +import { TeamAccessRole } from 'src/team/team.model'; +import { throwErr } from 'src/utils'; +import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard'; +import { TeamEnvironment } from './team-environments.model'; +import { TeamEnvironmentsService } from './team-environments.service'; +import * as E from 'fp-ts/Either'; +import { + CreateTeamEnvironmentArgs, + UpdateTeamEnvironmentArgs, +} from './input-type.args'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => 'TeamEnvironment') +export class TeamEnvironmentsResolver { + constructor( + private readonly teamEnvironmentsService: TeamEnvironmentsService, + private readonly pubsub: PubSubService, + ) {} + + /* Mutations */ + + @Mutation(() => TeamEnvironment, { + description: 'Create a new Team Environment for given Team ID', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.OWNER, TeamAccessRole.EDITOR) + async createTeamEnvironment( + @Args() args: CreateTeamEnvironmentArgs, + ): Promise { + const teamEnvironment = + await this.teamEnvironmentsService.createTeamEnvironment( + args.name, + args.teamID, + args.variables, + ); + + if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left); + return teamEnvironment.right; + } + + @Mutation(() => Boolean, { + description: 'Delete a Team Environment for given Team ID', + }) + @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) + @RequiresTeamRole(TeamAccessRole.OWNER, TeamAccessRole.EDITOR) + async deleteTeamEnvironment( + @Args({ + name: 'id', + description: 'ID of the Team Environment', + type: () => ID, + }) + id: string, + ): Promise { + const isDeleted = + await this.teamEnvironmentsService.deleteTeamEnvironment(id); + + if (E.isLeft(isDeleted)) throwErr(isDeleted.left); + return isDeleted.right; + } + + @Mutation(() => TeamEnvironment, { + description: + 'Add/Edit a single environment variable or variables to a Team Environment', + }) + @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) + @RequiresTeamRole(TeamAccessRole.OWNER, TeamAccessRole.EDITOR) + async updateTeamEnvironment( + @Args() + args: UpdateTeamEnvironmentArgs, + ): Promise { + const updatedTeamEnvironment = + await this.teamEnvironmentsService.updateTeamEnvironment( + args.id, + args.name, + args.variables, + ); + + if (E.isLeft(updatedTeamEnvironment)) throwErr(updatedTeamEnvironment.left); + return updatedTeamEnvironment.right; + } + + @Mutation(() => TeamEnvironment, { + description: 'Delete all variables from a Team Environment', + }) + @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) + @RequiresTeamRole(TeamAccessRole.OWNER, TeamAccessRole.EDITOR) + async deleteAllVariablesFromTeamEnvironment( + @Args({ + name: 'id', + description: 'ID of the Team Environment', + type: () => ID, + }) + id: string, + ): Promise { + const teamEnvironment = + await this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( + id, + ); + + if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left); + return teamEnvironment.right; + } + + @Mutation(() => TeamEnvironment, { + description: 'Create a duplicate of an existing environment', + }) + @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) + @RequiresTeamRole(TeamAccessRole.OWNER, TeamAccessRole.EDITOR) + async createDuplicateEnvironment( + @Args({ + name: 'id', + description: 'ID of the Team Environment', + type: () => ID, + }) + id: string, + ): Promise { + const res = + await this.teamEnvironmentsService.createDuplicateEnvironment(id); + + if (E.isLeft(res)) throwErr(res.left); + return res.right; + } + + /* Subscriptions */ + + @Subscription(() => TeamEnvironment, { + description: 'Listen for Team Environment Updates', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ) + teamEnvironmentUpdated( + @Args({ + name: 'teamID', + description: 'ID of the Team', + type: () => ID, + }) + teamID: string, + ) { + return this.pubsub.asyncIterator(`team_environment/${teamID}/updated`); + } + + @Subscription(() => TeamEnvironment, { + description: 'Listen for Team Environment Creation Messages', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ) + teamEnvironmentCreated( + @Args({ + name: 'teamID', + description: 'ID of the Team', + type: () => ID, + }) + teamID: string, + ) { + return this.pubsub.asyncIterator(`team_environment/${teamID}/created`); + } + + @Subscription(() => TeamEnvironment, { + description: 'Listen for Team Environment Deletion Messages', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ) + teamEnvironmentDeleted( + @Args({ + name: 'teamID', + description: 'ID of the Team', + type: () => ID, + }) + teamID: string, + ) { + return this.pubsub.asyncIterator(`team_environment/${teamID}/deleted`); + } +} diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts new file mode 100644 index 0000000..318b8f5 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts @@ -0,0 +1,426 @@ +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { TeamEnvironment } from './team-environments.model'; +import { TeamEnvironmentsService } from './team-environments.service'; +import { + TEAM_ENVIRONMENT_NOT_FOUND, + TEAM_ENVIRONMENT_SHORT_NAME, + TEAM_MEMBER_NOT_FOUND, +} from 'src/errors'; +import { TeamService } from 'src/team/team.service'; +import { TeamAccessRole } from 'src/team/team.model'; + +const mockPrisma = mockDeep(); + +const mockPubSub = { + publish: jest.fn().mockResolvedValue(null), +}; +const mockTeamService = mockDeep(); + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const teamEnvironmentsService = new TeamEnvironmentsService( + mockPrisma, + mockPubSub as any, + mockTeamService, +); + +const teamEnvironment = { + id: '123', + name: 'test', + teamID: 'abc123', + variables: [{}], +}; + +beforeEach(() => { + mockReset(mockPrisma); + mockPubSub.publish.mockClear(); +}); + +describe('TeamEnvironmentsService', () => { + describe('getTeamEnvironment', () => { + test('should successfully return a TeamEnvironment with valid ID', async () => { + mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce( + teamEnvironment, + ); + + const result = await teamEnvironmentsService.getTeamEnvironment( + teamEnvironment.id, + ); + expect(result).toEqualRight(teamEnvironment); + }); + + test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => { + mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce( + 'RejectOnNotFound', + ); + + const result = await teamEnvironmentsService.getTeamEnvironment( + teamEnvironment.id, + ); + expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); + }); + }); + + describe('createTeamEnvironment', () => { + test('should successfully create and return a new team environment given valid inputs', async () => { + mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment); + + const result = await teamEnvironmentsService.createTeamEnvironment( + teamEnvironment.name, + teamEnvironment.teamID, + JSON.stringify(teamEnvironment.variables), + ); + + expect(result).toEqualRight({ + ...teamEnvironment, + variables: JSON.stringify(teamEnvironment.variables), + }); + }); + + test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => { + const result = await teamEnvironmentsService.createTeamEnvironment( + '', + teamEnvironment.teamID, + JSON.stringify(teamEnvironment.variables), + ); + + expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME); + }); + + test('should send pubsub message to "team_environment//created" if team environment is created successfully', async () => { + mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment); + + await teamEnvironmentsService.createTeamEnvironment( + teamEnvironment.name, + teamEnvironment.teamID, + JSON.stringify(teamEnvironment.variables), + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_environment/${teamEnvironment.teamID}/created`, + { + ...teamEnvironment, + variables: JSON.stringify(teamEnvironment.variables), + }, + ); + }); + }); + + describe('deleteTeamEnvironment', () => { + test('should successfully delete a TeamEnvironment with a valid ID', async () => { + mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment); + + const result = await teamEnvironmentsService.deleteTeamEnvironment( + teamEnvironment.id, + ); + + expect(result).toEqualRight(true); + }); + + test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if given id is invalid', async () => { + mockPrisma.teamEnvironment.delete.mockRejectedValue('RecordNotFound'); + + const result = + await teamEnvironmentsService.deleteTeamEnvironment('invalidid'); + + expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); + }); + + test('should send pubsub message to "team_environment//deleted" if team environment is deleted successfully', async () => { + mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment); + + await teamEnvironmentsService.deleteTeamEnvironment(teamEnvironment.id); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_environment/${teamEnvironment.teamID}/deleted`, + { + ...teamEnvironment, + variables: JSON.stringify(teamEnvironment.variables), + }, + ); + }); + }); + + describe('updateVariablesInTeamEnvironment', () => { + test('should successfully add new variable to a team environment', async () => { + mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ + ...teamEnvironment, + variables: [{ key: 'value' }], + }); + + const result = await teamEnvironmentsService.updateTeamEnvironment( + teamEnvironment.id, + teamEnvironment.name, + JSON.stringify([{ key: 'value' }]), + ); + + expect(result).toEqualRight({ + ...teamEnvironment, + variables: JSON.stringify([{ key: 'value' }]), + }); + }); + + test('should successfully add new variable to already existing list of variables in a team environment', async () => { + mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ + ...teamEnvironment, + variables: [{ key: 'value' }, { key_2: 'value_2' }], + }); + + const result = await teamEnvironmentsService.updateTeamEnvironment( + teamEnvironment.id, + teamEnvironment.name, + JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]), + ); + + expect(result).toEqualRight({ + ...teamEnvironment, + variables: JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]), + }); + }); + + test('should successfully edit existing variables in a team environment', async () => { + mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ + ...teamEnvironment, + variables: [{ key: '1234' }], + }); + + const result = await teamEnvironmentsService.updateTeamEnvironment( + teamEnvironment.id, + teamEnvironment.name, + JSON.stringify([{ key: '1234' }]), + ); + + expect(result).toEqualRight({ + ...teamEnvironment, + variables: JSON.stringify([{ key: '1234' }]), + }); + }); + + test('should successfully edit name of an existing team environment', async () => { + mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ + ...teamEnvironment, + variables: [{ key: '123' }], + }); + + const result = await teamEnvironmentsService.updateTeamEnvironment( + teamEnvironment.id, + teamEnvironment.name, + JSON.stringify([{ key: '123' }]), + ); + + expect(result).toEqualRight({ + ...teamEnvironment, + variables: JSON.stringify([{ key: '123' }]), + }); + }); + + test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => { + const result = await teamEnvironmentsService.updateTeamEnvironment( + teamEnvironment.id, + '', + JSON.stringify([{ key: 'value' }]), + ); + + expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME); + }); + + test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { + mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound'); + + const result = await teamEnvironmentsService.updateTeamEnvironment( + 'invalidid', + teamEnvironment.name, + JSON.stringify(teamEnvironment.variables), + ); + + expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); + }); + + test('should send pubsub message to "team_environment//updated" if team environment is updated successfully', async () => { + mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment); + + await teamEnvironmentsService.updateTeamEnvironment( + teamEnvironment.id, + teamEnvironment.name, + JSON.stringify([{ key: 'value' }]), + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_environment/${teamEnvironment.teamID}/updated`, + { + ...teamEnvironment, + variables: JSON.stringify(teamEnvironment.variables), + }, + ); + }); + }); + + describe('deleteAllVariablesFromTeamEnvironment', () => { + test('should successfully delete all variables in a team environment', async () => { + mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment); + + const result = + await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( + teamEnvironment.id, + ); + + expect(result).toEqualRight({ + ...teamEnvironment, + variables: JSON.stringify([{}]), + }); + }); + + test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { + mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound'); + + const result = + await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( + 'invalidid', + ); + + expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); + }); + + test('should send pubsub message to "team_environment//updated" if team environment is updated successfully', async () => { + mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment); + + await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( + teamEnvironment.id, + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_environment/${teamEnvironment.teamID}/updated`, + { + ...teamEnvironment, + variables: JSON.stringify([{}]), + }, + ); + }); + }); + + describe('createDuplicateEnvironment', () => { + test('should successfully duplicate an existing team environment', async () => { + mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce( + teamEnvironment, + ); + + mockPrisma.teamEnvironment.create.mockResolvedValueOnce({ + id: 'newid', + ...teamEnvironment, + }); + + const result = await teamEnvironmentsService.createDuplicateEnvironment( + teamEnvironment.id, + ); + + expect(result).toEqualRight({ + id: 'newid', + ...teamEnvironment, + variables: JSON.stringify(teamEnvironment.variables), + }); + }); + + test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { + mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValue( + 'NotFoundError', + ); + + const result = await teamEnvironmentsService.createDuplicateEnvironment( + teamEnvironment.id, + ); + + expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); + }); + + test('should send pubsub message to "team_environment//created" if team environment is updated successfully', async () => { + mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce( + teamEnvironment, + ); + + mockPrisma.teamEnvironment.create.mockResolvedValueOnce({ + id: 'newid', + ...teamEnvironment, + }); + + await teamEnvironmentsService.createDuplicateEnvironment( + teamEnvironment.id, + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_environment/${teamEnvironment.teamID}/created`, + { + id: 'newid', + ...teamEnvironment, + variables: JSON.stringify([{}]), + }, + ); + }); + }); + + describe('totalEnvsInTeam', () => { + test('should resolve right and return a total team envs count ', async () => { + mockPrisma.teamEnvironment.count.mockResolvedValueOnce(2); + const result = await teamEnvironmentsService.totalEnvsInTeam('id1'); + expect(mockPrisma.teamEnvironment.count).toHaveBeenCalledWith({ + where: { + teamID: 'id1', + }, + }); + expect(result).toEqual(2); + }); + test('should resolve left and return an error when no team envs found', async () => { + mockPrisma.teamEnvironment.count.mockResolvedValueOnce(0); + const result = await teamEnvironmentsService.totalEnvsInTeam('id1'); + expect(mockPrisma.teamEnvironment.count).toHaveBeenCalledWith({ + where: { + teamID: 'id1', + }, + }); + expect(result).toEqual(0); + }); + }); + + describe('getTeamEnvironmentForCLI', () => { + test('should successfully return a TeamEnvironment with valid ID', async () => { + mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce( + teamEnvironment, + ); + mockTeamService.getTeamMember.mockResolvedValue({ + membershipID: 'sdc3sfdv', + userUid: '123454', + role: TeamAccessRole.OWNER, + }); + + const result = await teamEnvironmentsService.getTeamEnvironmentForCLI( + teamEnvironment.id, + '123454', + ); + expect(result).toEqualRight(teamEnvironment); + }); + + test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => { + mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce( + 'RejectOnNotFound', + ); + + const result = await teamEnvironmentsService.getTeamEnvironment( + teamEnvironment.id, + ); + expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); + }); + + test('should throw TEAM_MEMBER_NOT_FOUND if user not in same team', async () => { + mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce( + teamEnvironment, + ); + mockTeamService.getTeamMember.mockResolvedValue(null); + + const result = await teamEnvironmentsService.getTeamEnvironmentForCLI( + teamEnvironment.id, + '333', + ); + expect(result).toEqualLeft(TEAM_MEMBER_NOT_FOUND); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts new file mode 100644 index 0000000..c43720d --- /dev/null +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts @@ -0,0 +1,278 @@ +import { Injectable } from '@nestjs/common'; +import { + TeamEnvironment as DBTeamEnvironment, + Prisma, +} from 'src/generated/prisma/client'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { TeamEnvironment } from './team-environments.model'; +import { + TEAM_ENVIRONMENT_NOT_FOUND, + TEAM_ENVIRONMENT_SHORT_NAME, + TEAM_MEMBER_NOT_FOUND, +} from 'src/errors'; +import * as E from 'fp-ts/Either'; +import { isValidLength } from 'src/utils'; +import { TeamService } from 'src/team/team.service'; +@Injectable() +export class TeamEnvironmentsService { + constructor( + private readonly prisma: PrismaService, + private readonly pubsub: PubSubService, + private readonly teamService: TeamService, + ) {} + + TITLE_LENGTH = 1; + + /** + * TeamEnvironments are saved in the DB in the following way + * [{ key: value }, { key: value },....] + * + */ + + /** + * Typecast a database TeamEnvironment to a TeamEnvironment model + * @param teamEnvironment database TeamEnvironment + * @returns TeamEnvironment model + */ + private cast(teamEnvironment: DBTeamEnvironment): TeamEnvironment { + const { id, name, teamID } = teamEnvironment; + return { + id, + name, + teamID, + variables: JSON.stringify(teamEnvironment.variables), + }; + } + + /** + * Get details of a TeamEnvironment. + * + * @param id TeamEnvironment ID + * @returns Either of a TeamEnvironment or error message + */ + async getTeamEnvironment(id: string) { + try { + const teamEnvironment = + await this.prisma.teamEnvironment.findFirstOrThrow({ + where: { id }, + }); + return E.right(teamEnvironment); + } catch (error) { + return E.left(TEAM_ENVIRONMENT_NOT_FOUND); + } + } + + /** + * Create a new TeamEnvironment. + * + * @param name name of new TeamEnvironment + * @param teamID teamID of new TeamEnvironment + * @param variables JSONified string of contents of new TeamEnvironment + * @returns Either of a TeamEnvironment or error message + */ + async createTeamEnvironment(name: string, teamID: string, variables: string) { + const isTitleValid = isValidLength(name, this.TITLE_LENGTH); + if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME); + + const result = await this.prisma.teamEnvironment.create({ + data: { + name, + teamID, + variables: JSON.parse(variables), + }, + }); + + const createdTeamEnvironment = this.cast(result); + + this.pubsub.publish( + `team_environment/${createdTeamEnvironment.teamID}/created`, + createdTeamEnvironment, + ); + + return E.right(createdTeamEnvironment); + } + + /** + * Delete a TeamEnvironment. + * + * @param id TeamEnvironment ID + * @returns Either of boolean or error message + */ + async deleteTeamEnvironment(id: string) { + try { + const result = await this.prisma.teamEnvironment.delete({ + where: { + id, + }, + }); + + const deletedTeamEnvironment = this.cast(result); + + this.pubsub.publish( + `team_environment/${deletedTeamEnvironment.teamID}/deleted`, + deletedTeamEnvironment, + ); + + return E.right(true); + } catch (error) { + return E.left(TEAM_ENVIRONMENT_NOT_FOUND); + } + } + + /** + * Update a TeamEnvironment. + * + * @param id TeamEnvironment ID + * @param name TeamEnvironment name + * @param variables JSONified string of contents of new TeamEnvironment + * @returns Either of a TeamEnvironment or error message + */ + async updateTeamEnvironment(id: string, name: string, variables: string) { + try { + const isTitleValid = isValidLength(name, this.TITLE_LENGTH); + if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME); + + const result = await this.prisma.teamEnvironment.update({ + where: { id }, + data: { + name, + variables: JSON.parse(variables), + }, + }); + + const updatedTeamEnvironment = this.cast(result); + + this.pubsub.publish( + `team_environment/${updatedTeamEnvironment.teamID}/updated`, + updatedTeamEnvironment, + ); + + return E.right(updatedTeamEnvironment); + } catch (error) { + return E.left(TEAM_ENVIRONMENT_NOT_FOUND); + } + } + + /** + * Clear contents of a TeamEnvironment. + * + * @param id TeamEnvironment ID + * @returns Either of a TeamEnvironment or error message + */ + async deleteAllVariablesFromTeamEnvironment(id: string) { + try { + const result = await this.prisma.teamEnvironment.update({ + where: { id: id }, + data: { + variables: [], + }, + }); + + const teamEnvironment = this.cast(result); + + this.pubsub.publish( + `team_environment/${teamEnvironment.teamID}/updated`, + teamEnvironment, + ); + + return E.right(teamEnvironment); + } catch (error) { + return E.left(TEAM_ENVIRONMENT_NOT_FOUND); + } + } + + /** + * Create a duplicate of a existing TeamEnvironment. + * + * @param id TeamEnvironment ID + * @returns Either of a TeamEnvironment or error message + */ + async createDuplicateEnvironment(id: string) { + try { + const environment = await this.prisma.teamEnvironment.findFirstOrThrow({ + where: { + id: id, + }, + }); + + const result = await this.prisma.teamEnvironment.create({ + data: { + name: `${environment.name} - Duplicate`, + teamID: environment.teamID, + variables: environment.variables as Prisma.JsonArray, + }, + }); + + const duplicatedTeamEnvironment = this.cast(result); + + this.pubsub.publish( + `team_environment/${duplicatedTeamEnvironment.teamID}/created`, + duplicatedTeamEnvironment, + ); + + return E.right(duplicatedTeamEnvironment); + } catch (error) { + return E.left(TEAM_ENVIRONMENT_NOT_FOUND); + } + } + + /** + * Fetch all TeamEnvironments of a team. + * + * @param teamID teamID of new TeamEnvironment + * @returns List of TeamEnvironments + */ + async fetchAllTeamEnvironments(teamID: string) { + const result = await this.prisma.teamEnvironment.findMany({ + where: { + teamID: teamID, + }, + }); + const teamEnvironments = result.map((item) => { + return this.cast(item); + }); + + return teamEnvironments; + } + + /** + * Fetch the count of environments for a given team. + * @param teamID team id + * @returns a count of team envs + */ + async totalEnvsInTeam(teamID: string) { + const envCount = await this.prisma.teamEnvironment.count({ + where: { + teamID: teamID, + }, + }); + return envCount; + } + + /** + * Get details of a TeamEnvironment for CLI. + * + * @param id TeamEnvironment ID + * @param userUid User UID + * @returns Either of a TeamEnvironment or error message + */ + async getTeamEnvironmentForCLI(id: string, userUid: string) { + try { + const teamEnvironment = + await this.prisma.teamEnvironment.findFirstOrThrow({ + where: { id }, + }); + + const teamMember = await this.teamService.getTeamMember( + teamEnvironment.teamID, + userUid, + ); + if (!teamMember) return E.left(TEAM_MEMBER_NOT_FOUND); + + return E.right(teamEnvironment); + } catch (error) { + return E.left(TEAM_ENVIRONMENT_NOT_FOUND); + } + } +} diff --git a/packages/hoppscotch-backend/src/team-environments/team.resolver.ts b/packages/hoppscotch-backend/src/team-environments/team.resolver.ts new file mode 100644 index 0000000..5416c4d --- /dev/null +++ b/packages/hoppscotch-backend/src/team-environments/team.resolver.ts @@ -0,0 +1,16 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { Team } from 'src/team/team.model'; +import { TeamEnvironment } from './team-environments.model'; +import { TeamEnvironmentsService } from './team-environments.service'; + +@Resolver(() => Team) +export class TeamEnvsTeamResolver { + constructor(private teamEnvironmentService: TeamEnvironmentsService) {} + + @ResolveField(() => [TeamEnvironment], { + description: 'Returns all Team Environments for the given Team', + }) + teamEnvironments(@Parent() team: Team): Promise { + return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id); + } +} diff --git a/packages/hoppscotch-backend/src/team-invitation/input-type.args.ts b/packages/hoppscotch-backend/src/team-invitation/input-type.args.ts new file mode 100644 index 0000000..5d3c23c --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/input-type.args.ts @@ -0,0 +1,25 @@ +import { ArgsType, Field, ID } from '@nestjs/graphql'; +import { IsEmail, IsEnum, IsNotEmpty, IsString } from 'class-validator'; +import { TeamAccessRole } from 'src/team/team.model'; + +@ArgsType() +export class CreateTeamInvitationArgs { + @Field(() => ID, { + name: 'teamID', + description: 'ID of the Team ID to invite from', + }) + @IsString() + @IsNotEmpty() + teamID: string; + + @Field({ name: 'inviteeEmail', description: 'Email of the user to invite' }) + @IsEmail() + inviteeEmail: string; + + @Field(() => TeamAccessRole, { + name: 'inviteeRole', + description: 'Role to be given to the user', + }) + @IsEnum(TeamAccessRole) + inviteeRole: TeamAccessRole; +} diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invitation.model.ts b/packages/hoppscotch-backend/src/team-invitation/team-invitation.model.ts new file mode 100644 index 0000000..89efd9d --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/team-invitation.model.ts @@ -0,0 +1,30 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; +import { TeamAccessRole } from '../team/team.model'; + +@ObjectType() +export class TeamInvitation { + @Field(() => ID, { + description: 'ID of the invite', + }) + id: string; + + @Field(() => ID, { + description: 'ID of the team the invite is to', + }) + teamID: string; + + @Field(() => ID, { + description: 'UID of the creator of the invite', + }) + creatorUid: string; + + @Field({ + description: 'Email of the invitee', + }) + inviteeEmail: string; + + @Field(() => TeamAccessRole, { + description: 'The role that will be given to the invitee', + }) + inviteeRole: TeamAccessRole; +} diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invitation.module.ts b/packages/hoppscotch-backend/src/team-invitation/team-invitation.module.ts new file mode 100644 index 0000000..4ad9613 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/team-invitation.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { TeamModule } from 'src/team/team.module'; +import { UserModule } from 'src/user/user.module'; +import { TeamInvitationResolver } from './team-invitation.resolver'; +import { TeamInvitationService } from './team-invitation.service'; +import { TeamInviteTeamOwnerGuard } from './team-invite-team-owner.guard'; +import { TeamInviteViewerGuard } from './team-invite-viewer.guard'; +import { TeamInviteeGuard } from './team-invitee.guard'; +import { TeamTeamInviteExtResolver } from './team-teaminvite-ext.resolver'; + +@Module({ + imports: [TeamModule, UserModule], + providers: [ + TeamInvitationService, + TeamInvitationResolver, + TeamTeamInviteExtResolver, + TeamInviteeGuard, + TeamInviteViewerGuard, + TeamInviteTeamOwnerGuard, + ], + exports: [TeamInvitationService], +}) +export class TeamInvitationModule {} diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invitation.resolver.ts b/packages/hoppscotch-backend/src/team-invitation/team-invitation.resolver.ts new file mode 100644 index 0000000..5520cec --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/team-invitation.resolver.ts @@ -0,0 +1,197 @@ +import { + Args, + ID, + Mutation, + Parent, + Query, + ResolveField, + Resolver, + Subscription, +} from '@nestjs/graphql'; +import { TeamInvitation } from './team-invitation.model'; +import { TeamInvitationService } from './team-invitation.service'; +import { pipe } from 'fp-ts/function'; +import * as TE from 'fp-ts/TaskEither'; +import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; +import { Team, TeamMember, TeamAccessRole } from 'src/team/team.model'; +import { TEAM_INVITE_NO_INVITE_FOUND, USER_NOT_FOUND } from 'src/errors'; +import { GqlUser } from 'src/decorators/gql-user.decorator'; +import { User } from 'src/user/user.model'; +import { UseGuards } from '@nestjs/common'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { TeamService } from 'src/team/team.service'; +import { throwErr } from 'src/utils'; +import { TeamInviteeGuard } from './team-invitee.guard'; +import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard'; +import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator'; +import { TeamInviteViewerGuard } from './team-invite-viewer.guard'; +import { TeamInviteTeamOwnerGuard } from './team-invite-team-owner.guard'; +import { UserService } from 'src/user/user.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { SkipThrottle } from '@nestjs/throttler'; +import { AuthUser } from 'src/types/AuthUser'; +import { CreateTeamInvitationArgs } from './input-type.args'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => TeamInvitation) +export class TeamInvitationResolver { + constructor( + private readonly userService: UserService, + private readonly teamService: TeamService, + private readonly teamInvitationService: TeamInvitationService, + private readonly pubsub: PubSubService, + ) {} + + @ResolveField(() => Team, { + complexity: 5, + description: 'Get the team associated to the invite', + }) + async team(@Parent() teamInvitation: TeamInvitation): Promise { + return pipe( + this.teamService.getTeamWithIDTE(teamInvitation.teamID), + TE.getOrElse(throwErr), + )(); + } + + @ResolveField(() => User, { + complexity: 5, + description: 'Get the creator of the invite', + }) + async creator(@Parent() teamInvitation: TeamInvitation): Promise { + const user = await this.userService.findUserById(teamInvitation.creatorUid); + if (O.isNone(user)) throwErr(USER_NOT_FOUND); + + return { + ...user.value, + currentGQLSession: null, + currentRESTSession: null, + }; + } + + @Query(() => TeamInvitation, { + description: + 'Gets the Team Invitation with the given ID, or null if not exists', + }) + @UseGuards(GqlAuthGuard, TeamInviteViewerGuard) + async teamInvitation( + @GqlUser() user: AuthUser, + @Args({ + name: 'inviteID', + description: 'ID of the Team Invitation to lookup', + type: () => ID, + }) + inviteID: string, + ): Promise { + const teamInvitation = + await this.teamInvitationService.getInvitation(inviteID); + if (O.isNone(teamInvitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND); + return teamInvitation.value; + } + + @Mutation(() => TeamInvitation, { + description: 'Creates a Team Invitation', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.OWNER) + async createTeamInvitation( + @GqlUser() user: AuthUser, + @Args() args: CreateTeamInvitationArgs, + ): Promise { + const teamInvitation = await this.teamInvitationService.createInvitation( + user, + args.teamID, + args.inviteeEmail, + args.inviteeRole, + ); + + if (E.isLeft(teamInvitation)) throwErr(teamInvitation.left); + return teamInvitation.right; + } + + @Mutation(() => Boolean, { + description: 'Revokes an invitation and deletes it', + }) + @UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard) + @RequiresTeamRole(TeamAccessRole.OWNER) + async revokeTeamInvitation( + @Args({ + name: 'inviteID', + type: () => ID, + description: 'ID of the invite to revoke', + }) + inviteID: string, + ): Promise { + const isRevoked = + await this.teamInvitationService.revokeInvitation(inviteID); + if (E.isLeft(isRevoked)) throwErr(isRevoked.left); + return true; + } + + @Mutation(() => TeamMember, { + description: 'Accept an Invitation', + }) + @UseGuards(GqlAuthGuard, TeamInviteeGuard) + async acceptTeamInvitation( + @GqlUser() user: AuthUser, + @Args({ + name: 'inviteID', + type: () => ID, + description: 'ID of the Invite to accept', + }) + inviteID: string, + ): Promise { + const teamMember = await this.teamInvitationService.acceptInvitation( + inviteID, + user, + ); + if (E.isLeft(teamMember)) throwErr(teamMember.left); + return teamMember.right; + } + + // Subscriptions + @Subscription(() => TeamInvitation, { + description: 'Listens to when a Team Invitation is added', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ) + teamInvitationAdded( + @Args({ + name: 'teamID', + type: () => ID, + description: 'ID of the Team to listen to', + }) + teamID: string, + ): AsyncIterator { + return this.pubsub.asyncIterator(`team/${teamID}/invite_added`); + } + + @Subscription(() => ID, { + description: 'Listens to when a Team Invitation is removed', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ) + teamInvitationRemoved( + @Args({ + name: 'teamID', + type: () => ID, + description: 'ID of the Team to listen to', + }) + teamID: string, + ): AsyncIterator { + return this.pubsub.asyncIterator(`team/${teamID}/invite_removed`); + } +} diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invitation.service.ts b/packages/hoppscotch-backend/src/team-invitation/team-invitation.service.ts new file mode 100644 index 0000000..562f218 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/team-invitation.service.ts @@ -0,0 +1,254 @@ +import { Injectable } from '@nestjs/common'; +import * as O from 'fp-ts/Option'; +import * as E from 'fp-ts/Either'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { TeamInvitation as DBTeamInvitation } from 'src/generated/prisma/client'; +import { TeamMember, TeamAccessRole } from 'src/team/team.model'; +import { TeamService } from 'src/team/team.service'; +import { + INVALID_EMAIL, + TEAM_INVALID_ID, + TEAM_INVITE_ALREADY_MEMBER, + TEAM_INVITE_EMAIL_DO_NOT_MATCH, + TEAM_INVITE_MEMBER_HAS_INVITE, + TEAM_INVITE_NO_INVITE_FOUND, + TEAM_MEMBER_NOT_FOUND, +} from 'src/errors'; +import { TeamInvitation } from './team-invitation.model'; +import { MailerService } from 'src/mailer/mailer.service'; +import { UserService } from 'src/user/user.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { validateEmail } from '../utils'; +import { AuthUser } from 'src/types/AuthUser'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class TeamInvitationService { + constructor( + private readonly prisma: PrismaService, + private readonly userService: UserService, + private readonly teamService: TeamService, + private readonly mailerService: MailerService, + private readonly pubsub: PubSubService, + private readonly configService: ConfigService, + ) {} + + /** + * Cast a DBTeamInvitation to a TeamInvitation + * @param dbTeamInvitation database TeamInvitation + * @returns TeamInvitation model + */ + private cast(dbTeamInvitation: DBTeamInvitation): TeamInvitation { + return { + ...dbTeamInvitation, + inviteeRole: TeamAccessRole[dbTeamInvitation.inviteeRole], + }; + } + + /** + * Get the team invite + * @param inviteID invite id + * @returns an Option of team invitation or none + */ + async getInvitation(inviteID: string) { + try { + const dbInvitation = await this.prisma.teamInvitation.findUniqueOrThrow({ + where: { + id: inviteID, + }, + }); + + return O.some(this.cast(dbInvitation)); + } catch (e) { + return O.none; + } + } + + /** + * Get the team invite for an invitee with email and teamID. + * @param inviteeEmail invitee email + * @param teamID team id + * @returns an Either of team invitation for the invitee or error + */ + async getTeamInviteByEmailAndTeamID(inviteeEmail: string, teamID: string) { + const isEmailValid = validateEmail(inviteeEmail); + if (!isEmailValid) return E.left(INVALID_EMAIL); + + try { + const teamInvite = await this.prisma.teamInvitation.findFirstOrThrow({ + where: { + inviteeEmail: { + equals: inviteeEmail, + mode: 'insensitive', + }, + teamID, + }, + }); + + return E.right(teamInvite); + } catch (e) { + return E.left(TEAM_INVITE_NO_INVITE_FOUND); + } + } + + /** + * Create a team invitation + * @param creator creator of the invitation + * @param teamID team id + * @param inviteeEmail invitee email + * @param inviteeRole invitee role + * @returns an Either of team invitation or error message + */ + async createInvitation( + creator: AuthUser, + teamID: string, + inviteeEmail: string, + inviteeRole: TeamAccessRole, + ) { + // validate email + const isEmailValid = validateEmail(inviteeEmail); + if (!isEmailValid) return E.left(INVALID_EMAIL); + + // team ID should valid + const team = await this.teamService.getTeamWithID(teamID); + if (!team) return E.left(TEAM_INVALID_ID); + + // invitation creator should be a TeamMember + const isTeamMember = await this.teamService.getTeamMember( + team.id, + creator.uid, + ); + if (!isTeamMember) return E.left(TEAM_MEMBER_NOT_FOUND); + + // Checking to see if the invitee is already part of the team or not + const inviteeUser = await this.userService.findUserByEmail(inviteeEmail); + if (O.isSome(inviteeUser)) { + // invitee should not already a member + const isTeamMember = await this.teamService.getTeamMember( + team.id, + inviteeUser.value.uid, + ); + if (isTeamMember) return E.left(TEAM_INVITE_ALREADY_MEMBER); + } + + // check invitee already invited earlier or not + const teamInvitation = await this.getTeamInviteByEmailAndTeamID( + inviteeEmail, + team.id, + ); + if (E.isRight(teamInvitation)) return E.left(TEAM_INVITE_MEMBER_HAS_INVITE); + + // create the invitation + const dbInvitation = await this.prisma.teamInvitation.create({ + data: { + teamID: team.id, + inviteeEmail, + inviteeRole, + creatorUid: creator.uid, + }, + }); + + await this.mailerService.sendEmail(inviteeEmail, { + template: 'team-invitation', + variables: { + invitee: creator.displayName ?? 'A Hoppscotch User', + action_url: `${this.configService.get('VITE_BASE_URL')}/join-team?id=${ + dbInvitation.id + }`, + invite_team_name: team.name, + }, + }); + + const invitation = this.cast(dbInvitation); + this.pubsub.publish(`team/${invitation.teamID}/invite_added`, invitation); + + return E.right(invitation); + } + + /** + * Revoke a team invitation + * @param inviteID invite id + * @returns an Either of true or error message + */ + async revokeInvitation(inviteID: string) { + // check if the invite exists + const invitation = await this.getInvitation(inviteID); + if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND); + + // delete the invite + await this.prisma.teamInvitation.delete({ + where: { + id: inviteID, + }, + }); + + this.pubsub.publish( + `team/${invitation.value.teamID}/invite_removed`, + invitation.value.id, + ); + + return E.right(true); + } + + /** + * Accept a team invitation + * @param inviteID invite id + * @param acceptedBy user who accepted the invitation + * @returns an Either of team member or error message + */ + async acceptInvitation(inviteID: string, acceptedBy: AuthUser) { + // check if the invite exists + const invitation = await this.getInvitation(inviteID); + if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND); + + // make sure the user is not already a member of the team + const teamMemberInvitee = await this.teamService.getTeamMember( + invitation.value.teamID, + acceptedBy.uid, + ); + if (teamMemberInvitee) return E.left(TEAM_INVITE_ALREADY_MEMBER); + + // make sure the user is the same as the invitee + if ( + acceptedBy.email.toLowerCase() !== + invitation.value.inviteeEmail.toLowerCase() + ) + return E.left(TEAM_INVITE_EMAIL_DO_NOT_MATCH); + + // add the user to the team + let teamMember: TeamMember; + try { + teamMember = await this.teamService.addMemberToTeam( + invitation.value.teamID, + acceptedBy.uid, + invitation.value.inviteeRole, + ); + } catch (e) { + return E.left(TEAM_INVITE_ALREADY_MEMBER); + } + + // delete the invite + await this.revokeInvitation(inviteID); + + return E.right(teamMember); + } + + /** + * Fetch all team invitations for a given team. + * @param teamID team id + * @returns array of team invitations for a team + */ + async getTeamInvitations(teamID: string) { + const dbInvitations = await this.prisma.teamInvitation.findMany({ + where: { + teamID: teamID, + }, + }); + + const invitations: TeamInvitation[] = dbInvitations.map((dbInvitation) => + this.cast(dbInvitation), + ); + + return invitations; + } +} diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invite-team-owner.guard.ts b/packages/hoppscotch-backend/src/team-invitation/team-invite-team-owner.guard.ts new file mode 100644 index 0000000..a8a18ce --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/team-invite-team-owner.guard.ts @@ -0,0 +1,53 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { TeamService } from 'src/team/team.service'; +import { TeamInvitationService } from './team-invitation.service'; +import * as O from 'fp-ts/Option'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { + BUG_AUTH_NO_USER_CTX, + BUG_TEAM_INVITE_NO_INVITE_ID, + TEAM_INVITE_NO_INVITE_FOUND, + TEAM_MEMBER_NOT_FOUND, + TEAM_NOT_REQUIRED_ROLE, +} from 'src/errors'; +import { throwErr } from 'src/utils'; +import { TeamAccessRole } from 'src/team/team.model'; + +/** + * This guard only allows team owner to execute the resolver + */ +@Injectable() +export class TeamInviteTeamOwnerGuard implements CanActivate { + constructor( + private readonly teamService: TeamService, + private readonly teamInviteService: TeamInvitationService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + // Get GQL context + const gqlExecCtx = GqlExecutionContext.create(context); + + // Get user + const { user } = gqlExecCtx.getContext().req; + if (!user) throwErr(BUG_AUTH_NO_USER_CTX); + + // Get the invite + const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>(); + if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID); + + const invitation = await this.teamInviteService.getInvitation(inviteID); + if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND); + + // Fetch team member details of this user + const teamMember = await this.teamService.getTeamMember( + invitation.value.teamID, + user.uid, + ); + + if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND); + if (teamMember.role !== TeamAccessRole.OWNER) + throwErr(TEAM_NOT_REQUIRED_ROLE); + + return true; + } +} diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invite-viewer.guard.ts b/packages/hoppscotch-backend/src/team-invitation/team-invite-viewer.guard.ts new file mode 100644 index 0000000..ad4f08d --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/team-invite-viewer.guard.ts @@ -0,0 +1,57 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { TeamInvitationService } from './team-invitation.service'; +import * as O from 'fp-ts/Option'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { + BUG_AUTH_NO_USER_CTX, + BUG_TEAM_INVITE_NO_INVITE_ID, + TEAM_INVITE_NO_INVITE_FOUND, + TEAM_MEMBER_NOT_FOUND, +} from 'src/errors'; +import { throwErr } from 'src/utils'; +import { TeamService } from 'src/team/team.service'; + +/** + * This guard only allows user to execute the resolver + * 1. If user is invitee, allow + * 2. Or else, if user is team member, allow + * + * TLDR: Allow if user is invitee or team member + */ +@Injectable() +export class TeamInviteViewerGuard implements CanActivate { + constructor( + private readonly teamInviteService: TeamInvitationService, + private readonly teamService: TeamService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + // Get GQL context + const gqlExecCtx = GqlExecutionContext.create(context); + + // Get user + const { user } = gqlExecCtx.getContext().req; + if (!user) throwErr(BUG_AUTH_NO_USER_CTX); + + // Get the invite + const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>(); + if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID); + + const invitation = await this.teamInviteService.getInvitation(inviteID); + if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND); + + // Check if the user and the invite email match, else if user is a team member + if ( + user.email?.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase() + ) { + const teamMember = await this.teamService.getTeamMember( + invitation.value.teamID, + user.uid, + ); + + if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND); + } + + return true; + } +} diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invitee.guard.ts b/packages/hoppscotch-backend/src/team-invitation/team-invitee.guard.ts new file mode 100644 index 0000000..c1ce61d --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/team-invitee.guard.ts @@ -0,0 +1,45 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { TeamInvitationService } from './team-invitation.service'; +import * as O from 'fp-ts/Option'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { + BUG_AUTH_NO_USER_CTX, + BUG_TEAM_INVITE_NO_INVITE_ID, + TEAM_INVITE_EMAIL_DO_NOT_MATCH, + TEAM_INVITE_NO_INVITE_FOUND, +} from 'src/errors'; +import { throwErr } from 'src/utils'; + +/** + * This guard only allows the invitee to execute the resolver + * + * REQUIRES GqlAuthGuard + */ +@Injectable() +export class TeamInviteeGuard implements CanActivate { + constructor(private readonly teamInviteService: TeamInvitationService) {} + + async canActivate(context: ExecutionContext): Promise { + // Get GQL Context + const gqlExecCtx = GqlExecutionContext.create(context); + + // Get user + const { user } = gqlExecCtx.getContext().req; + if (!user) throwErr(BUG_AUTH_NO_USER_CTX); + + // Get the invite + const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>(); + if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID); + + const invitation = await this.teamInviteService.getInvitation(inviteID); + if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND); + + if ( + user.email.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase() + ) { + throwErr(TEAM_INVITE_EMAIL_DO_NOT_MATCH); + } + + return true; + } +} diff --git a/packages/hoppscotch-backend/src/team-invitation/team-teaminvite-ext.resolver.ts b/packages/hoppscotch-backend/src/team-invitation/team-teaminvite-ext.resolver.ts new file mode 100644 index 0000000..c579c08 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/team-teaminvite-ext.resolver.ts @@ -0,0 +1,17 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { Team } from 'src/team/team.model'; +import { TeamInvitation } from './team-invitation.model'; +import { TeamInvitationService } from './team-invitation.service'; + +@Resolver(() => Team) +export class TeamTeamInviteExtResolver { + constructor(private readonly teamInviteService: TeamInvitationService) {} + + @ResolveField(() => [TeamInvitation], { + description: 'Get all the active invites in the team', + complexity: 10, + }) + teamInvitations(@Parent() team: Team): Promise { + return this.teamInviteService.getTeamInvitations(team.id); + } +} diff --git a/packages/hoppscotch-backend/src/team-request/guards/gql-request-team-member.guard.ts b/packages/hoppscotch-backend/src/team-request/guards/gql-request-team-member.guard.ts new file mode 100644 index 0000000..75ed727 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-request/guards/gql-request-team-member.guard.ts @@ -0,0 +1,54 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { TeamRequestService } from '../team-request.service'; +import { TeamService } from '../../team/team.service'; +import { Reflector } from '@nestjs/core'; +import { TeamAccessRole } from '../../team/team.model'; +import { + BUG_AUTH_NO_USER_CTX, + BUG_TEAM_REQ_NO_REQ_ID, + TEAM_REQ_NOT_REQUIRED_ROLE, + TEAM_REQ_NOT_MEMBER, + TEAM_REQ_NOT_FOUND, +} from 'src/errors'; +import { throwErr } from 'src/utils'; +import * as O from 'fp-ts/Option'; + +@Injectable() +export class GqlRequestTeamMemberGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly teamRequestService: TeamRequestService, + private readonly teamService: TeamService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requireRoles = this.reflector.get( + 'requiresTeamRole', + context.getHandler(), + ); + + const gqlExecCtx = GqlExecutionContext.create(context); + + const { user } = gqlExecCtx.getContext().req; + if (!user) throw new Error(BUG_AUTH_NO_USER_CTX); + + const { requestID } = gqlExecCtx.getArgs<{ requestID: string }>(); + if (!requestID) throw new Error(BUG_TEAM_REQ_NO_REQ_ID); + + const team = + await this.teamRequestService.getTeamOfRequestFromID(requestID); + if (O.isNone(team)) throw new Error(TEAM_REQ_NOT_FOUND); + + const member = await this.teamService.getTeamMember( + team.value.id, + user.uid, + ); + if (!member) throwErr(TEAM_REQ_NOT_MEMBER); + + if (!(requireRoles && requireRoles.includes(member.role))) + throw new Error(TEAM_REQ_NOT_REQUIRED_ROLE); + + return true; + } +} diff --git a/packages/hoppscotch-backend/src/team-request/input-type.args.ts b/packages/hoppscotch-backend/src/team-request/input-type.args.ts new file mode 100644 index 0000000..d79ec79 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-request/input-type.args.ts @@ -0,0 +1,135 @@ +import { Field, ID, InputType, ArgsType } from '@nestjs/graphql'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { PaginationArgs } from 'src/types/input-types.args'; + +@InputType() +export class CreateTeamRequestInput { + @Field(() => ID, { + description: 'ID of the team the collection belongs to', + }) + @IsString() + @IsNotEmpty() + teamID: string; + + @Field({ + description: 'JSON string representing the request data', + }) + @IsString() + @IsNotEmpty() + request: string; + + @Field({ + description: 'Displayed title of the request', + }) + @IsString() + @IsNotEmpty() + title: string; +} + +@InputType() +export class UpdateTeamRequestInput { + @Field({ + description: 'JSON string representing the request data', + nullable: true, + }) + @IsString() + @IsOptional() + request?: string; + + @Field({ + description: 'Displayed title of the request', + nullable: true, + }) + @IsString() + @IsOptional() + title?: string; +} + +@ArgsType() +export class SearchTeamRequestArgs extends PaginationArgs { + @Field(() => ID, { + description: 'ID of the team to look in', + }) + @IsString() + @IsNotEmpty() + teamID: string; + + @Field({ + description: 'The title to search for', + }) + @IsString() + @IsNotEmpty() + searchTerm: string; +} + +@ArgsType() +export class MoveTeamRequestArgs { + @Field(() => ID, { + // for backward compatibility, this field is nullable and undefined as default + nullable: true, + defaultValue: undefined, + description: 'ID of the collection, the request belong to', + }) + @IsString() + @IsOptional() + srcCollID: string; + + @Field(() => ID, { + description: 'ID of the request to move', + }) + @IsString() + @IsNotEmpty() + requestID: string; + + @Field(() => ID, { + description: 'ID of the collection, where the request is moving to', + }) + @IsString() + @IsNotEmpty() + destCollID: string; + + @Field(() => ID, { + nullable: true, + description: + 'ID of the request that comes after the updated request in its new position', + }) + @IsString() + @IsOptional() + nextRequestID: string; +} + +@ArgsType() +export class UpdateLookUpRequestOrderArgs { + @Field(() => ID, { + description: 'ID of the collection', + }) + @IsString() + @IsNotEmpty() + collectionID: string; + + @Field(() => ID, { + nullable: true, + description: + 'ID of the request that comes after the updated request in its new position', + }) + @IsString() + @IsOptional() + nextRequestID: string; + + @Field(() => ID, { + description: 'ID of the request to move', + }) + @IsString() + @IsNotEmpty() + requestID: string; +} + +@ArgsType() +export class GetTeamRequestInCollectionArgs extends PaginationArgs { + @Field(() => ID, { + description: 'ID of the collection to look in', + }) + @IsString() + @IsNotEmpty() + collectionID: string; +} diff --git a/packages/hoppscotch-backend/src/team-request/team-request.model.ts b/packages/hoppscotch-backend/src/team-request/team-request.model.ts new file mode 100644 index 0000000..79363b4 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-request/team-request.model.ts @@ -0,0 +1,44 @@ +import { ObjectType, Field, ID } from '@nestjs/graphql'; + +@ObjectType() +export class TeamRequest { + @Field(() => ID, { + description: 'ID of the request', + }) + id: string; + + @Field(() => ID, { + description: 'ID of the collection the request belongs to.', + }) + collectionID: string; + + @Field(() => ID, { + description: 'ID of the team the request belongs to.', + }) + teamID: string; + + @Field({ + description: 'JSON string representing the request data', + }) + request: string; + + @Field({ + description: 'Displayed title of the request', + }) + title: string; +} + +@ObjectType() +export class RequestReorderData { + @Field({ + description: 'Team Request being moved', + }) + request: TeamRequest; + + @Field({ + description: + 'Team Request succeeding the request being moved in its new position', + nullable: true, + }) + nextRequest?: TeamRequest; +} diff --git a/packages/hoppscotch-backend/src/team-request/team-request.module.ts b/packages/hoppscotch-backend/src/team-request/team-request.module.ts new file mode 100644 index 0000000..78bc5ea --- /dev/null +++ b/packages/hoppscotch-backend/src/team-request/team-request.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TeamRequestService } from './team-request.service'; +import { TeamRequestResolver } from './team-request.resolver'; +import { TeamModule } from '../team/team.module'; +import { TeamCollectionModule } from '../team-collection/team-collection.module'; +import { GqlRequestTeamMemberGuard } from './guards/gql-request-team-member.guard'; +import { UserModule } from '../user/user.module'; + +@Module({ + imports: [TeamModule, TeamCollectionModule, UserModule], + providers: [ + TeamRequestService, + TeamRequestResolver, + GqlRequestTeamMemberGuard, + ], + exports: [TeamRequestService, GqlRequestTeamMemberGuard], +}) +export class TeamRequestModule {} diff --git a/packages/hoppscotch-backend/src/team-request/team-request.resolver.ts b/packages/hoppscotch-backend/src/team-request/team-request.resolver.ts new file mode 100644 index 0000000..da9bfc5 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-request/team-request.resolver.ts @@ -0,0 +1,354 @@ +import { + Resolver, + ResolveField, + Parent, + Args, + Query, + Mutation, + Subscription, + ID, +} from '@nestjs/graphql'; +import { RequestReorderData, TeamRequest } from './team-request.model'; +import { + CreateTeamRequestInput, + UpdateTeamRequestInput, + SearchTeamRequestArgs, + GetTeamRequestInCollectionArgs, + MoveTeamRequestArgs, + UpdateLookUpRequestOrderArgs, +} from './input-type.args'; +import { Team, TeamAccessRole } from '../team/team.model'; +import { TeamRequestService } from './team-request.service'; +import { TeamCollection } from '../team-collection/team-collection.model'; +import { UseGuards } from '@nestjs/common'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { GqlRequestTeamMemberGuard } from './guards/gql-request-team-member.guard'; +import { GqlCollectionTeamMemberGuard } from '../team-collection/guards/gql-collection-team-member.guard'; +import { RequiresTeamRole } from '../team/decorators/requires-team-role.decorator'; +import { GqlTeamMemberGuard } from '../team/guards/gql-team-member.guard'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; +import { throwErr } from 'src/utils'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { SkipThrottle } from '@nestjs/throttler'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => TeamRequest) +export class TeamRequestResolver { + constructor( + private readonly teamRequestService: TeamRequestService, + private readonly pubsub: PubSubService, + ) {} + + // Field resolvers + @ResolveField(() => Team, { + description: 'Team the request belongs to', + complexity: 3, + }) + async team(@Parent() req: TeamRequest) { + const team = await this.teamRequestService.getTeamOfRequest(req); + if (E.isLeft(team)) throwErr(team.left); + return team.right; + } + + @ResolveField(() => TeamCollection, { + description: 'Collection the request belongs to', + complexity: 3, + }) + async collection(@Parent() req: TeamRequest) { + const teamCollection = + await this.teamRequestService.getCollectionOfRequest(req); + if (E.isLeft(teamCollection)) throwErr(teamCollection.left); + return teamCollection.right; + } + + // Query + @Query(() => [TeamRequest], { + description: 'Search the team for a specific request with title', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + TeamAccessRole.VIEWER, + ) + async searchForRequest(@Args() args: SearchTeamRequestArgs) { + return this.teamRequestService.searchRequest( + args.teamID, + args.searchTerm, + args.cursor, + args.take, + ); + } + + @Query(() => TeamRequest, { + description: 'Gives a request with the given ID or null (if not exists)', + nullable: true, + }) + @UseGuards(GqlAuthGuard, GqlRequestTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + TeamAccessRole.VIEWER, + ) + async request( + @Args({ + name: 'requestID', + description: 'ID of the request', + type: () => ID, + }) + requestID: string, + ) { + const teamRequest = await this.teamRequestService.getRequest(requestID); + if (O.isNone(teamRequest)) return null; + return teamRequest.value; + } + + @Query(() => [TeamRequest], { + description: 'Gives a paginated list of requests in the collection', + }) + @UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + TeamAccessRole.VIEWER, + ) + async requestsInCollection(@Args() input: GetTeamRequestInCollectionArgs) { + return this.teamRequestService.getRequestsInCollection( + input.collectionID, + input.cursor, + input.take, + ); + } + + // Mutation + @Mutation(() => TeamRequest, { + description: 'Create a team request in the given collection.', + }) + @UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.EDITOR, TeamAccessRole.OWNER) + async createRequestInCollection( + @Args({ + name: 'collectionID', + description: 'ID of the collection', + type: () => ID, + }) + collectionID: string, + @Args({ + name: 'data', + type: () => CreateTeamRequestInput, + description: + 'The request data (stringified JSON of Hoppscotch request object)', + }) + data: CreateTeamRequestInput, + ) { + const teamRequest = await this.teamRequestService.createTeamRequest( + collectionID, + data.teamID, + data.title, + data.request, + ); + if (E.isLeft(teamRequest)) throwErr(teamRequest.left); + return teamRequest.right; + } + + @Mutation(() => TeamRequest, { + description: 'Update a request with the given ID', + }) + @UseGuards(GqlAuthGuard, GqlRequestTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.EDITOR, TeamAccessRole.OWNER) + async updateRequest( + @Args({ + name: 'requestID', + description: 'ID of the request', + type: () => ID, + }) + requestID: string, + @Args({ + name: 'data', + type: () => UpdateTeamRequestInput, + description: + 'The updated request data (stringified JSON of Hoppscotch request object)', + }) + data: UpdateTeamRequestInput, + ) { + const teamRequest = await this.teamRequestService.updateTeamRequest( + requestID, + data.title, + data.request, + ); + if (E.isLeft(teamRequest)) throwErr(teamRequest.left); + return teamRequest.right; + } + + @Mutation(() => Boolean, { + description: 'Delete a request with the given ID', + }) + @UseGuards(GqlAuthGuard, GqlRequestTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.EDITOR, TeamAccessRole.OWNER) + async deleteRequest( + @Args({ + name: 'requestID', + description: 'ID of the request', + type: () => ID, + }) + requestID: string, + ) { + const isDeleted = + await this.teamRequestService.deleteTeamRequest(requestID); + if (E.isLeft(isDeleted)) throwErr(isDeleted.left); + return isDeleted.right; + } + + @Mutation(() => Boolean, { + description: 'Update the order of requests in the lookup table', + }) + @UseGuards(GqlAuthGuard, GqlRequestTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.EDITOR, TeamAccessRole.OWNER) + async updateLookUpRequestOrder( + @Args() + args: UpdateLookUpRequestOrderArgs, + ) { + const teamRequest = await this.teamRequestService.moveRequest( + args.collectionID, + args.requestID, + args.collectionID, + args.nextRequestID, + 'updateLookUpRequestOrder', + ); + if (E.isLeft(teamRequest)) throwErr(teamRequest.left); + return true; + } + + @Mutation(() => TeamRequest, { + description: 'Move a request to the given collection', + }) + @UseGuards(GqlAuthGuard, GqlRequestTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.EDITOR, TeamAccessRole.OWNER) + async moveRequest(@Args() args: MoveTeamRequestArgs) { + const teamRequest = await this.teamRequestService.moveRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + 'moveRequest', + ); + if (E.isLeft(teamRequest)) throwErr(teamRequest.left); + return teamRequest.right; + } + + // Subscriptions + @Subscription(() => TeamRequest, { + description: 'Emits when a new request is added to a team', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.VIEWER, + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + ) + teamRequestAdded( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ) { + return this.pubsub.asyncIterator(`team_req/${teamID}/req_created`); + } + + @Subscription(() => TeamRequest, { + description: 'Emitted when a request has been updated', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.VIEWER, + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + ) + teamRequestUpdated( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ) { + return this.pubsub.asyncIterator(`team_req/${teamID}/req_updated`); + } + + @Subscription(() => ID, { + description: + 'Emitted when a request has been deleted. Only the id of the request is emitted.', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.VIEWER, + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + ) + teamRequestDeleted( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ) { + return this.pubsub.asyncIterator(`team_req/${teamID}/req_deleted`); + } + + @Subscription(() => RequestReorderData, { + description: + 'Emitted when a requests position has been changed in its collection', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.VIEWER, + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + ) + requestOrderUpdated( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ) { + return this.pubsub.asyncIterator(`team_req/${teamID}/req_order_updated`); + } + + @Subscription(() => TeamRequest, { + description: + 'Emitted when a request has been moved from one collection into another', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.VIEWER, + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + ) + requestMoved( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ) { + return this.pubsub.asyncIterator(`team_req/${teamID}/req_moved`); + } +} diff --git a/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts b/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts new file mode 100644 index 0000000..04a22fa --- /dev/null +++ b/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts @@ -0,0 +1,867 @@ +import { PrismaService } from '../prisma/prisma.service'; +import { TeamCollectionService } from '../team-collection/team-collection.service'; +import { TeamService } from '../team/team.service'; +import { TeamRequestService } from './team-request.service'; +import { + TEAM_REQ_INVALID_TARGET_COLL_ID, + TEAM_INVALID_COLL_ID, + TEAM_INVALID_ID, + TEAM_REQ_NOT_FOUND, + TEAM_REQ_REORDERING_FAILED, + TEAM_COLL_NOT_FOUND, +} from 'src/errors'; +import * as E from 'fp-ts/Either'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { TeamRequest } from './team-request.model'; +import { MoveTeamRequestArgs } from './input-type.args'; +import { + TeamRequest as DbTeamRequest, + Team as DbTeam, + TeamCollection as DbTeamCollection, +} from 'src/generated/prisma/client'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { SortOptions } from 'src/types/SortOptions'; + +const mockPrisma = mockDeep(); +const mockTeamService = mockDeep(); +const mockTeamCollectionService = mockDeep(); +const mockPubSub = mockDeep(); + +const teamRequestService = new TeamRequestService( + mockPrisma, + mockTeamService, + mockTeamCollectionService, + mockPubSub, +); + +const team: DbTeam = { + id: 'team-a', + name: 'Team A', +}; +const teamCollection: DbTeamCollection = { + id: 'team-coll-1', + parentID: null, + teamID: team.id, + data: {}, + title: 'Team Collection 1', + orderIndex: 1, + createdOn: new Date(), + updatedOn: new Date(), +}; +const dbTeamRequests: DbTeamRequest[] = []; +for (let i = 1; i <= 10; i++) { + dbTeamRequests.push({ + id: `test-request-${i}`, + collectionID: teamCollection.id, + teamID: team.id, + request: {}, + mockExamples: {}, + title: `Test Request ${i}`, + orderIndex: i, + createdOn: new Date(), + updatedOn: new Date(), + }); +} +const teamRequests: TeamRequest[] = dbTeamRequests.map((tr) => ({ + id: tr.id, + collectionID: tr.collectionID, + teamID: tr.teamID, + title: tr.title, + request: JSON.stringify(tr.request), +})); + +beforeEach(async () => { + mockReset(mockPrisma); +}); + +describe('updateTeamRequest', () => { + test('resolves correctly if title not given in parameter', async () => { + const dbRequest = dbTeamRequests[0]; + mockPrisma.teamRequest.update.mockResolvedValue(dbRequest); + + await expect( + teamRequestService.updateTeamRequest( + dbRequest.id, + undefined, // title + JSON.stringify(dbRequest.request), // request + ), + ).resolves.toBeDefined(); + }); + + test('resolves correctly if request not given in parameter', async () => { + const dbRequest = dbTeamRequests[0]; + mockPrisma.teamRequest.update.mockResolvedValue(dbRequest); + + await expect( + teamRequestService.updateTeamRequest( + dbRequest.id, + dbRequest.title, + undefined, + ), + ).resolves.toBeDefined(); + }); + + test('resolves correctly if both request and title are null', async () => { + const dbRequest = dbTeamRequests[0]; + mockPrisma.teamRequest.update.mockResolvedValue(dbRequest); + + await expect( + teamRequestService.updateTeamRequest(dbRequest.id, undefined, undefined), + ).resolves.toBeDefined(); + }); + + test('resolves correctly for non-null request and title', async () => { + const dbRequest = dbTeamRequests[0]; + mockPrisma.teamRequest.update.mockResolvedValue(dbRequest); + + await expect( + teamRequestService.updateTeamRequest( + dbRequest.id, + dbRequest.title, + JSON.stringify(dbRequest.request), + ), + ).resolves.toBeDefined(); + }); + + test('rejects for invalid request id', async () => { + mockPrisma.teamRequest.update.mockRejectedValue('RecordNotFound'); + + await expect( + teamRequestService.updateTeamRequest( + 'invalidtestreq', + undefined, + undefined, + ), + ).resolves.toEqualLeft(TEAM_REQ_NOT_FOUND); + }); + + test('resolves for valid request id', async () => { + const dbRequest = dbTeamRequests[0]; + mockPrisma.teamRequest.update.mockResolvedValue(dbRequest); + + await expect( + teamRequestService.updateTeamRequest(dbRequest.id, undefined, undefined), + ).resolves.toBeDefined(); + }); + + test('publishes update to pubsub topic "team_req//req_updated"', async () => { + const dbRequest = dbTeamRequests[0]; + const request = teamRequests[0]; + mockPrisma.teamRequest.update.mockResolvedValue(dbRequest); + + await teamRequestService.updateTeamRequest( + dbRequest.id, + undefined, + undefined, + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_req/${dbRequest.teamID}/req_updated`, + request, + ); + }); +}); + +describe('searchRequest', () => { + test('resolves with the correct info with a null cursor', async () => { + const dbRequest = dbTeamRequests[0]; + mockPrisma.teamRequest.findMany.mockResolvedValue(dbTeamRequests); + + await expect( + teamRequestService.searchRequest( + dbRequest.teamID, + dbRequest.title, + null, + 10, + ), + ).resolves.toBeDefined(); + }); + + test('resolves with an empty array when a match with the search term is not found', async () => { + const dbRequest = dbTeamRequests[0]; + mockPrisma.teamRequest.findMany.mockResolvedValue([]); + + await expect( + teamRequestService.searchRequest( + dbRequest.teamID, + 'unknown_title', + null, + 10, + ), + ).resolves.toBeDefined(); + }); + + test('resolves with the correct info with a set cursor', async () => { + const dbRequest = dbTeamRequests[0]; + mockPrisma.teamRequest.findMany.mockResolvedValue(dbTeamRequests); + + await expect( + teamRequestService.searchRequest( + dbRequest.teamID, + dbRequest.title, + dbRequest.id, + 10, + ), + ).resolves.toBeDefined(); + }); +}); + +describe('deleteTeamRequest', () => { + test('rejects if the request id is not found', async () => { + mockPrisma.teamRequest.findFirst.mockResolvedValue(null as any); + + const response = teamRequestService.deleteTeamRequest('invalidrequest'); + + expect(response).resolves.toEqualLeft(TEAM_REQ_NOT_FOUND); + expect(mockPrisma.teamRequest.delete).not.toHaveBeenCalled(); + }); + + test('resolves for a valid request id', async () => { + const dbRequest = dbTeamRequests[0]; + mockPrisma.teamRequest.findFirst.mockResolvedValue(dbRequest); + mockPrisma.teamRequest.delete.mockResolvedValue(dbRequest); + + await expect( + teamRequestService.deleteTeamRequest(dbRequest.id), + ).resolves.toEqualRight(true); + }); + + test('publishes deletion to pubsub topic "team_req//req_deleted"', async () => { + const dbRequest = dbTeamRequests[0]; + mockPrisma.teamRequest.findFirst.mockResolvedValue(dbRequest); + mockPrisma.teamRequest.delete.mockResolvedValue(dbRequest); + + await teamRequestService.deleteTeamRequest(dbRequest.id); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_req/${dbRequest.teamID}/req_deleted`, + dbRequest.id, + ); + }); +}); + +describe('createTeamRequest', () => { + test('rejects for invalid collection id', async () => { + jest + .spyOn(mockTeamCollectionService, 'getTeamOfCollection') + .mockResolvedValue(E.left(TEAM_INVALID_COLL_ID)); + + const response = await teamRequestService.createTeamRequest( + 'invalidcollid', + team.id, + 'Test Request', + '{}', + ); + + expect(response).toEqualLeft(TEAM_INVALID_COLL_ID); + expect(mockPrisma.teamRequest.create).not.toHaveBeenCalled(); + }); + + test('resolves for valid collection id', async () => { + const dbRequest = dbTeamRequests[0]; + const teamRequest = teamRequests[0]; + + jest + .spyOn(mockTeamCollectionService, 'getTeamOfCollection') + .mockResolvedValue(E.right(team)); + mockPrisma.$transaction.mockImplementation(async (fn) => { + return fn(mockPrisma); + }); + mockPrisma.teamRequest.findFirst.mockResolvedValue(null); + mockPrisma.teamRequest.create.mockResolvedValue(dbRequest); + + const response = teamRequestService.createTeamRequest( + teamRequest.title, + team.id, + teamRequest.title, + teamRequest.request, + ); + + expect(response).resolves.toEqualRight(teamRequest); + }); + + test('publishes creation to pubsub topic "team_req//req_created"', async () => { + const dbRequest = dbTeamRequests[0]; + const teamRequest = teamRequests[0]; + + jest + .spyOn(mockTeamCollectionService, 'getTeamOfCollection') + .mockResolvedValue(E.right(team)); + mockPrisma.$transaction.mockImplementation(async (fn) => { + return fn(mockPrisma); + }); + mockPrisma.teamRequest.findFirst.mockResolvedValue(null); + mockPrisma.teamRequest.create.mockResolvedValue(dbRequest); + + await teamRequestService.createTeamRequest( + teamRequest.title, + team.id, + teamRequest.title, + teamRequest.request, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_req/${dbRequest.teamID}/req_created`, + teamRequest, + ); + }); +}); + +describe('getRequestsInCollection', () => { + test('resolves with an empty array if the collection id does not exist', async () => { + mockPrisma.teamRequest.findMany.mockResolvedValue([]); + + await expect( + teamRequestService.getRequestsInCollection('invalidCollID', null, 10), + ).resolves.toEqual([]); + }); + + test('resolves with the correct info for the collection id and null cursor', async () => { + mockPrisma.teamRequest.findMany.mockResolvedValue(dbTeamRequests); + + const response = await teamRequestService.getRequestsInCollection( + 'testcoll', + null, + 10, + ); + + expect(response).toEqual(teamRequests); + }); + + test('resolves with the correct info for the collection id and a valid cursor', async () => { + mockPrisma.teamRequest.findMany.mockResolvedValue([dbTeamRequests[1]]); + + const response = teamRequestService.getRequestsInCollection( + dbTeamRequests[1].collectionID, + dbTeamRequests[0].id, + 1, + ); + + expect(response).resolves.toEqual([teamRequests[1]]); + }); +}); + +describe('getRequest', () => { + test('resolves with the correct request info for valid request id', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValue(dbTeamRequests[0]); + + expect(teamRequestService.getRequest('testrequest')).resolves.toEqualSome( + expect.objectContaining(teamRequests[0]), + ); + }); + + test('resolves with null if the request id does not exist', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValue(null as any); + + await expect( + teamRequestService.getRequest('testrequest'), + ).resolves.toBeNone(); + }); +}); + +describe('getTeamOfRequest', () => { + test('rejects for invalid team id', async () => { + mockTeamService.getTeamWithID.mockResolvedValue(null as any); + + expect( + teamRequestService.getTeamOfRequest(teamRequests[0]), + ).resolves.toEqualLeft(TEAM_INVALID_ID); + }); + + test('resolves for valid team id', async () => { + mockTeamService.getTeamWithID.mockResolvedValue(team); + + expect( + teamRequestService.getTeamOfRequest(teamRequests[0]), + ).resolves.toEqualRight(expect.objectContaining(team)); + }); +}); + +describe('getCollectionOfRequest', () => { + test('rejects for invalid collection id', async () => { + mockTeamCollectionService.getCollection.mockResolvedValue( + E.left(TEAM_COLL_NOT_FOUND), + ); + + expect( + teamRequestService.getCollectionOfRequest(teamRequests[0]), + ).resolves.toEqualLeft(TEAM_INVALID_COLL_ID); + }); + + test('resolves for valid collection id', async () => { + mockTeamCollectionService.getCollection.mockResolvedValue( + E.right(teamCollection), + ); + + expect( + teamRequestService.getCollectionOfRequest(teamRequests[0]), + ).resolves.toEqualRight(expect.objectContaining(teamCollection)); + }); +}); + +describe('getTeamOfRequestFromID', () => { + test('rejects for invalid request id', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValue(null as any); + + expect( + teamRequestService.getTeamOfRequestFromID('invalidrequest'), + ).resolves.toBeNone(); + }); + + test('resolves for valid request id', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValue(dbTeamRequests[0]); + + expect( + teamRequestService.getTeamOfRequestFromID('testrequest'), + ).resolves.toBeDefined(); + }); +}); + +describe('reorderRequests', () => { + test('Should resolve left if transaction throws an error', async () => { + const srcCollID = dbTeamRequests[0].collectionID; + const request = dbTeamRequests[0]; + const destCollID = dbTeamRequests[4].collectionID; + const nextRequest = dbTeamRequests[4]; + + mockPrisma.$transaction.mockRejectedValueOnce(new Error()); + const result = await (teamRequestService as any).reorderRequests( + request, + srcCollID, + nextRequest, + destCollID, + ); + expect(result).toEqual(E.left(TEAM_REQ_REORDERING_FAILED)); + }); + test('Should resolve right and call transaction with the correct data', async () => { + const srcCollID = dbTeamRequests[0].collectionID; + const request = dbTeamRequests[0]; + const destCollID = dbTeamRequests[4].collectionID; + const nextRequest = dbTeamRequests[4]; + + const updatedReq: DbTeamRequest = { + ...request, + collectionID: destCollID, + orderIndex: nextRequest.orderIndex, + }; + + mockPrisma.$transaction.mockResolvedValueOnce(E.right(updatedReq)); + const result = await (teamRequestService as any).reorderRequests( + request, + srcCollID, + nextRequest, + destCollID, + ); + expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Function)); + expect(result).toEqual(E.right(updatedReq)); + }); +}); + +describe('findRequestAndNextRequest', () => { + test('Should resolve right if the request and the next request are found', async () => { + const args: MoveTeamRequestArgs = { + srcCollID: teamRequests[0].collectionID, + destCollID: teamRequests[4].collectionID, + requestID: teamRequests[0].id, + nextRequestID: teamRequests[4].id, + }; + + mockPrisma.teamRequest.findFirst + .mockResolvedValueOnce(dbTeamRequests[0]) + .mockResolvedValueOnce(dbTeamRequests[4]); + + const result = await (teamRequestService as any).findRequestAndNextRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + ); + + expect(result).toEqualRight({ + request: dbTeamRequests[0], + nextRequest: dbTeamRequests[4], + }); + }); + test('Should resolve right if the request and next request null', async () => { + const args: MoveTeamRequestArgs = { + srcCollID: teamRequests[0].collectionID, + destCollID: teamRequests[4].collectionID, + requestID: teamRequests[0].id, + nextRequestID: null, + }; + + mockPrisma.teamRequest.findFirst.mockResolvedValueOnce(dbTeamRequests[0]); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce(teamCollection); + + const result = await (teamRequestService as any).findRequestAndNextRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + ); + + expect(result).toEqualRight({ + request: dbTeamRequests[0], + nextRequest: null, + }); + }); + test('Should resolve left if the destination collection does not exist when nextRequestID is null', async () => { + const args: MoveTeamRequestArgs = { + srcCollID: teamRequests[0].collectionID, + destCollID: 'non-existent-coll', + requestID: teamRequests[0].id, + nextRequestID: null, + }; + + mockPrisma.teamRequest.findFirst.mockResolvedValueOnce(dbTeamRequests[0]); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce(null); + + const result = await (teamRequestService as any).findRequestAndNextRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + ); + + expect(result).toEqualLeft(TEAM_INVALID_COLL_ID); + }); + test('Should resolve left if the destination collection belongs to a different team when nextRequestID is null', async () => { + const args: MoveTeamRequestArgs = { + srcCollID: teamRequests[0].collectionID, + destCollID: 'cross-team-coll', + requestID: teamRequests[0].id, + nextRequestID: null, + }; + + mockPrisma.teamRequest.findFirst.mockResolvedValueOnce(dbTeamRequests[0]); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce({ + ...teamCollection, + id: 'cross-team-coll', + teamID: 'different-team-id', + }); + + const result = await (teamRequestService as any).findRequestAndNextRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + ); + + expect(result).toEqualLeft(TEAM_REQ_INVALID_TARGET_COLL_ID); + }); + test('Should resolve left if the request is not found', () => { + const args: MoveTeamRequestArgs = { + srcCollID: teamRequests[0].collectionID, + destCollID: teamRequests[4].collectionID, + requestID: 'invalid', + nextRequestID: null, + }; + + mockPrisma.teamRequest.findFirst.mockResolvedValueOnce(null); + + const result = (teamRequestService as any).findRequestAndNextRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + ); + + expect(result).resolves.toEqualLeft(TEAM_REQ_NOT_FOUND); + }); + test('Should resolve left if the nextRequest is not found', () => { + const args: MoveTeamRequestArgs = { + srcCollID: teamRequests[0].collectionID, + destCollID: teamRequests[1].collectionID, + requestID: teamRequests[0].id, + nextRequestID: 'invalid', + }; + + mockPrisma.teamRequest.findFirst + .mockResolvedValueOnce(dbTeamRequests[0]) + .mockResolvedValueOnce(null); + + const result = (teamRequestService as any).findRequestAndNextRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + ); + + expect(result).resolves.toEqualLeft(TEAM_REQ_NOT_FOUND); + }); +}); + +describe('moveRequest', () => { + test('Should resolve right and the request', () => { + const args: MoveTeamRequestArgs = { + srcCollID: teamRequests[0].collectionID, + destCollID: teamRequests[0].collectionID, + requestID: teamRequests[0].id, + nextRequestID: null, + }; + + jest + .spyOn(teamRequestService as any, 'findRequestAndNextRequest') + .mockResolvedValue( + E.right({ request: dbTeamRequests[0], nextRequest: null }), + ); + jest + .spyOn(teamRequestService as any, 'reorderRequests') + .mockResolvedValue(E.right(dbTeamRequests[0])); + + const result = teamRequestService.moveRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + 'moveRequest', + ); + + expect(result).resolves.toEqualRight(teamRequests[0]); + }); + + test('Should resolve right and publish message to pubnub if callerFunction is moveRequest', async () => { + const args: MoveTeamRequestArgs = { + srcCollID: teamRequests[0].collectionID, + destCollID: teamRequests[0].collectionID, + requestID: teamRequests[0].id, + nextRequestID: null, + }; + + jest + .spyOn(teamRequestService as any, 'findRequestAndNextRequest') + .mockResolvedValue( + E.right({ request: dbTeamRequests[0], nextRequest: null }), + ); + jest + .spyOn(teamRequestService as any, 'reorderRequests') + .mockResolvedValue(E.right(dbTeamRequests[0])); + + await teamRequestService.moveRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + 'moveRequest', + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_req/${teamRequests[0].teamID}/req_moved`, + teamRequests[0], + ); + }); + + test('Should resolve right and publish message to pubnub if callerFunction is updateLookUpRequestOrder', async () => { + const args: MoveTeamRequestArgs = { + srcCollID: teamRequests[0].collectionID, + destCollID: teamRequests[0].collectionID, + requestID: teamRequests[0].id, + nextRequestID: null, + }; + + jest + .spyOn(teamRequestService as any, 'findRequestAndNextRequest') + .mockResolvedValue( + E.right({ request: dbTeamRequests[0], nextRequest: null }), + ); + jest + .spyOn(teamRequestService as any, 'reorderRequests') + .mockResolvedValue(E.right(dbTeamRequests[0])); + + await teamRequestService.moveRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + 'updateLookUpRequestOrder', + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_req/${teamRequests[0].teamID}/req_order_updated`, + { request: teamRequests[0], nextRequest: null }, + ); + }); + + test('Should resolve left if finding the requests fails', () => { + const args: MoveTeamRequestArgs = { + srcCollID: teamRequests[0].collectionID, + destCollID: teamRequests[0].collectionID, + requestID: teamRequests[0].id, + nextRequestID: null, + }; + + jest + .spyOn(teamRequestService as any, 'findRequestAndNextRequest') + .mockResolvedValue(E.left(TEAM_REQ_NOT_FOUND)); + + expect( + teamRequestService.moveRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + 'moveRequest', + ), + ).resolves.toEqualLeft(TEAM_REQ_NOT_FOUND); + }); + + test('Should resolve left if mismatch team/collection of requests fails', () => { + const args: MoveTeamRequestArgs = { + srcCollID: teamRequests[0].collectionID, + destCollID: teamRequests[0].collectionID, + requestID: teamRequests[0].id, + nextRequestID: null, + }; + + jest + .spyOn(teamRequestService as any, 'findRequestAndNextRequest') + .mockResolvedValue(E.left(TEAM_REQ_INVALID_TARGET_COLL_ID)); + + expect( + teamRequestService.moveRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + 'moveRequest', + ), + ).resolves.toEqualLeft(TEAM_REQ_INVALID_TARGET_COLL_ID); + }); + + test('Should resolve left if reorder fails', () => { + const args: MoveTeamRequestArgs = { + srcCollID: teamRequests[0].collectionID, + destCollID: teamRequests[0].collectionID, + requestID: teamRequests[0].id, + nextRequestID: null, + }; + + jest + .spyOn(teamRequestService as any, 'findRequestAndNextRequest') + .mockResolvedValue( + E.right({ request: dbTeamRequests[0], nextRequest: null }), + ); + + jest + .spyOn(teamRequestService as any, 'reorderRequests') + .mockResolvedValue(E.left(TEAM_REQ_REORDERING_FAILED)); + + expect( + teamRequestService.moveRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + 'moveRequest', + ), + ).resolves.toEqualLeft(TEAM_REQ_REORDERING_FAILED); + }); +}); + +describe('totalRequestsInATeam', () => { + test('should resolve right and return a total team reqs count ', async () => { + mockPrisma.teamRequest.count.mockResolvedValueOnce(2); + const result = await teamRequestService.totalRequestsInATeam('id1'); + expect(mockPrisma.teamRequest.count).toHaveBeenCalledWith({ + where: { + teamID: 'id1', + }, + }); + expect(result).toEqual(2); + }); + test('should resolve left and return an error when no team reqs found', async () => { + mockPrisma.teamRequest.count.mockResolvedValueOnce(0); + const result = await teamRequestService.totalRequestsInATeam('id1'); + expect(mockPrisma.teamRequest.count).toHaveBeenCalledWith({ + where: { + teamID: 'id1', + }, + }); + expect(result).toEqual(0); + }); +}); + +describe('getTeamRequestsCount', () => { + test('should return count of all Team Collections in the organization', async () => { + mockPrisma.teamRequest.count.mockResolvedValueOnce(10); + + const result = await teamRequestService.getTeamRequestsCount(); + expect(result).toEqual(10); + }); +}); + +describe('sortTeamRequests', () => { + test('should resolve right if collectionID is null', async () => { + const teamID = team.id; + const result = await teamRequestService.sortTeamRequests( + teamID, + null, + SortOptions.TITLE_ASC, + ); + expect(result).toEqual(E.right(true)); + }); + + test('should resolve right and sorts team requests by TITLE_ASC', async () => { + const teamID = team.id; + const collectionID = teamCollection.id; + + mockPrisma.$transaction.mockImplementation(async (cb) => cb(mockPrisma)); + mockPrisma.lockTeamRequestByCollections.mockResolvedValue(undefined); + mockPrisma.teamRequest.findMany.mockResolvedValue(dbTeamRequests); + + const result = await teamRequestService.sortTeamRequests( + teamID, + collectionID, + SortOptions.TITLE_ASC, + ); + + expect(result).toEqual(E.right(true)); + expect(mockPrisma.$transaction).toHaveBeenCalled(); + expect(mockPrisma.teamRequest.findMany).toHaveBeenCalledWith({ + where: { teamID, collectionID }, + orderBy: { title: 'asc' }, + select: { id: true }, + }); + expect(mockPrisma.teamRequest.update).toHaveBeenCalledTimes( + dbTeamRequests.length, + ); + }); + + test('should resolve right and sorts team requests by TITLE_DESC', async () => { + const teamID = team.id; + const collectionID = teamCollection.id; + + mockPrisma.$transaction.mockImplementation(async (cb) => cb(mockPrisma)); + mockPrisma.lockTeamRequestByCollections.mockResolvedValue(undefined); + mockPrisma.teamRequest.findMany.mockResolvedValue(dbTeamRequests); + + const result = await teamRequestService.sortTeamRequests( + teamID, + collectionID, + SortOptions.TITLE_DESC, + ); + + expect(result).toEqual(E.right(true)); + expect(mockPrisma.$transaction).toHaveBeenCalled(); + expect(mockPrisma.teamRequest.findMany).toHaveBeenCalledWith({ + where: { teamID, collectionID }, + orderBy: { title: 'desc' }, + select: { id: true }, + }); + expect(mockPrisma.teamRequest.update).toHaveBeenCalledTimes( + dbTeamRequests.length, + ); + }); + + test('should returns left(TEAM_REQ_REORDERING_FAILED) on error', async () => { + const teamID = team.id; + const collectionID = teamCollection.id; + + mockPrisma.$transaction.mockRejectedValue(new Error('fail')); + const result = await teamRequestService.sortTeamRequests( + teamID, + collectionID, + SortOptions.TITLE_ASC, + ); + expect(result).toEqual(E.left(TEAM_REQ_REORDERING_FAILED)); + }); +}); diff --git a/packages/hoppscotch-backend/src/team-request/team-request.service.ts b/packages/hoppscotch-backend/src/team-request/team-request.service.ts new file mode 100644 index 0000000..13c0e90 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-request/team-request.service.ts @@ -0,0 +1,595 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { TeamService } from '../team/team.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { TeamRequest } from './team-request.model'; +import { TeamCollectionService } from '../team-collection/team-collection.service'; +import { + TEAM_REQ_INVALID_TARGET_COLL_ID, + TEAM_INVALID_COLL_ID, + TEAM_INVALID_ID, + TEAM_REQ_NOT_FOUND, + TEAM_REQ_REORDERING_FAILED, + TEAM_COLL_CREATION_FAILED, +} from 'src/errors'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { stringToJson } from 'src/utils'; +import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; +import { + Prisma, + TeamRequest as DbTeamRequest, +} from 'src/generated/prisma/client'; +import { SortOptions } from 'src/types/SortOptions'; +import { PrismaError } from 'src/prisma/prisma-error-codes'; + +@Injectable() +export class TeamRequestService { + constructor( + private readonly prisma: PrismaService, + private readonly teamService: TeamService, + private readonly teamCollectionService: TeamCollectionService, + private readonly pubsub: PubSubService, + ) {} + + /** + * A helper function to cast the Prisma TeamRequest model to the TeamRequest model + * @param tr TeamRequest model from Prisma + */ + private cast(tr: DbTeamRequest) { + return { + id: tr.id, + collectionID: tr.collectionID, + teamID: tr.teamID, + title: tr.title, + request: JSON.stringify(tr.request), + }; + } + + /** + * Update team request + * @param requestID Request ID, which is updating + * @param title Title of the request + * @param request Request body of the request + */ + async updateTeamRequest(requestID: string, title: string, request: string) { + try { + const updateInput: Prisma.TeamRequestUpdateInput = { title }; + if (request) { + const jsonReq = stringToJson(request); + if (E.isLeft(jsonReq)) return E.left(jsonReq.left); + updateInput.request = jsonReq.right; + } + + const updatedTeamReq = await this.prisma.teamRequest.update({ + where: { id: requestID }, + data: updateInput, + }); + + const teamRequest: TeamRequest = this.cast(updatedTeamReq); + + this.pubsub.publish( + `team_req/${teamRequest.teamID}/req_updated`, + teamRequest, + ); + + return E.right(teamRequest); + } catch (e) { + return E.left(TEAM_REQ_NOT_FOUND); + } + } + + /** + * Search team requests + * @param teamID Team ID to search in + * @param searchTerm Search term for the request title + * @param cursor Cursor for pagination + * @param take Number of requests to fetch + */ + async searchRequest( + teamID: string, + searchTerm: string, + cursor: string, + take = 10, + ) { + const fetchedRequests = await this.prisma.teamRequest.findMany({ + take: take, + skip: cursor ? 1 : 0, + cursor: cursor ? { id: cursor } : undefined, + where: { + teamID: teamID, + title: { + contains: searchTerm, + }, + }, + }); + + const teamRequests = fetchedRequests.map((tr) => this.cast(tr)); + return teamRequests; + } + + /** + * Delete team request + * @param requestID Request ID to delete + */ + async deleteTeamRequest(requestID: string) { + const dbTeamReq = await this.prisma.teamRequest.findFirst({ + where: { id: requestID }, + }); + if (!dbTeamReq) return E.left(TEAM_REQ_NOT_FOUND); + + try { + await this.prisma.$transaction(async (tx) => { + try { + // lock the rows + await this.prisma.lockTeamRequestByCollections(tx, dbTeamReq.teamID, [ + dbTeamReq.collectionID, + ]); + + try { + await tx.teamRequest.delete({ + where: { id: requestID }, + }); + } catch (deleteError) { + // P2025: Record not found — already deleted by a concurrent transaction + if (deleteError?.code === PrismaError.RECORD_NOT_FOUND) return; + throw deleteError; + } + + await tx.teamRequest.updateMany({ + where: { + collectionID: dbTeamReq.collectionID, + orderIndex: { gte: dbTeamReq.orderIndex }, + }, + data: { orderIndex: { decrement: 1 } }, + }); + } catch (error) { + throw new ConflictException(error); + } + }); + } catch (error) { + console.error('Error from TeamRequestService.deleteTeamRequest', error); + return E.left(TEAM_REQ_NOT_FOUND); + } + + this.pubsub.publish(`team_req/${dbTeamReq.teamID}/req_deleted`, requestID); + + return E.right(true); + } + + /** + * Create team request + * @param collectionID Collection ID to create the request in + * @param teamID Team ID to create the request in + * @param title Title of the request + * @param request Request body of the request + */ + async createTeamRequest( + collectionID: string, + teamID: string, + title: string, + request: string, + ) { + const team = + await this.teamCollectionService.getTeamOfCollection(collectionID); + if (E.isLeft(team)) return E.left(team.left); + if (team.right.id !== teamID) return E.left(TEAM_INVALID_ID); + + let jsonReq = null; + if (request) { + const parsedReq = stringToJson(request); + if (E.isLeft(parsedReq)) return E.left(parsedReq.left); + jsonReq = parsedReq.right; + } + + let dbTeamRequest: DbTeamRequest = null; + try { + dbTeamRequest = await this.prisma.$transaction(async (tx) => { + try { + // lock the rows + await this.prisma.lockTeamRequestByCollections(tx, teamID, [ + collectionID, + ]); + + // fetch last team request + const lastTeamRequest = await tx.teamRequest.findFirst({ + where: { collectionID }, + orderBy: { orderIndex: 'desc' }, + select: { orderIndex: true }, + }); + + // create the team request + return tx.teamRequest.create({ + data: { + request: jsonReq, + title, + orderIndex: lastTeamRequest ? lastTeamRequest.orderIndex + 1 : 1, + team: { connect: { id: team.right.id } }, + collection: { connect: { id: collectionID } }, + }, + }); + } catch (error) { + throw new ConflictException(error); + } + }); + } catch (error) { + return E.left(TEAM_COLL_CREATION_FAILED); + } + + const teamRequest = this.cast(dbTeamRequest); + this.pubsub.publish( + `team_req/${teamRequest.teamID}/req_created`, + teamRequest, + ); + + return E.right(teamRequest); + } + + /** + * Fetch team requests by Collection ID + * @param collectionID Collection ID to fetch requests in + * @param cursor Cursor for pagination + * @param take Take number of requests + * @returns + */ + async getRequestsInCollection( + collectionID: string, + cursor: string, + take = 10, + ) { + const dbTeamRequests = await this.prisma.teamRequest.findMany({ + cursor: cursor ? { id: cursor } : undefined, + take: take, + skip: cursor ? 1 : 0, + where: { + collectionID: collectionID, + }, + orderBy: { + orderIndex: 'asc', + }, + }); + + const teamRequests = dbTeamRequests.map((tr) => this.cast(tr)); + return teamRequests; + } + + /** + * Fetch team request by ID + * @param reqID Request ID to fetch + */ + async getRequest(reqID: string) { + try { + const teamRequest = await this.prisma.teamRequest.findUnique({ + where: { id: reqID }, + }); + return O.some(this.cast(teamRequest)); + } catch (e) { + return O.none; + } + } + + /** + * Fetch team by team request + * @param teamRequest Team Request to fetch + */ + async getTeamOfRequest(req: TeamRequest) { + const team = await this.teamService.getTeamWithID(req.teamID); + if (!team) return E.left(TEAM_INVALID_ID); + return E.right(team); + } + + /** + * Fetch team collection by team request + * @param teamRequest Team Request to fetch + */ + async getCollectionOfRequest(req: TeamRequest) { + const teamCollection = await this.teamCollectionService.getCollection( + req.collectionID, + ); + if (E.isLeft(teamCollection)) return E.left(TEAM_INVALID_COLL_ID); + return E.right(teamCollection.right); + } + + /** + * Fetch team by team request ID + * @param reqID Team Request ID to fetch + */ + async getTeamOfRequestFromID(reqID: string) { + const teamRequest = await this.prisma.teamRequest.findUnique({ + where: { id: reqID }, + include: { team: true }, + }); + if (!teamRequest?.team) return O.none; + return O.some(teamRequest.team); + } + + /** + * Move or re-order a request to same/another collection + * @param srcCollID Collection ID, where the request is currently in. For backward compatibility, srcCollID is optional (can be undefined) + * @param requestID ID of the request to be moved + * @param destCollID Collection ID, where the request is to be moved to + * @param nextRequestID ID of the request, which is after the request to be moved. If the request is to be moved to the end of the collection, nextRequestID should be null + */ + async moveRequest( + srcCollID: string, + requestID: string, + destCollID: string, + nextRequestID: string, + callerFunction: 'moveRequest' | 'updateLookUpRequestOrder', + ) { + // step 1: validation and find the request and next request + const twoRequests = await this.findRequestAndNextRequest( + srcCollID, + requestID, + destCollID, + nextRequestID, + ); + if (E.isLeft(twoRequests)) return E.left(twoRequests.left); + const { request, nextRequest } = twoRequests.right; + + if (!srcCollID) srcCollID = request.collectionID; // if srcCollID is not provided (for backward compatibility), use the collectionID of the request + + // step 2: perform reordering + const updatedRequest = await this.reorderRequests( + request, + srcCollID, + nextRequest, + destCollID, + ); + if (E.isLeft(updatedRequest)) return E.left(updatedRequest.left); + + const teamReq = this.cast(updatedRequest.right); + + // step 3: publish the event + if (callerFunction === 'moveRequest') { + this.pubsub.publish(`team_req/${teamReq.teamID}/req_moved`, teamReq); + } else if (callerFunction === 'updateLookUpRequestOrder') { + this.pubsub.publish(`team_req/${request.teamID}/req_order_updated`, { + request: this.cast(updatedRequest.right), + nextRequest: nextRequest ? this.cast(nextRequest) : null, + }); + } + + return E.right(teamReq); + } + + /** + * A helper function to find the request and next request + * @param srcCollID Collection ID, where the request is currently in + * @param requestID ID of the request to be moved + * @param destCollID Collection ID, where the request is to be moved to + * @param nextRequestID ID of the request, which is after the request to be moved. If the request is to be moved to the end of the collection, nextRequestID should be null + */ + private async findRequestAndNextRequest( + srcCollID: string, + requestID: string, + destCollID: string, + nextRequestID: string, + ) { + const request = await this.prisma.teamRequest.findFirst({ + where: { id: requestID, collectionID: srcCollID }, + }); + if (!request) return E.left(TEAM_REQ_NOT_FOUND); + + let nextRequest = null; + if (nextRequestID) { + nextRequest = await this.prisma.teamRequest.findFirst({ + where: { id: nextRequestID }, + }); + if (!nextRequest) return E.left(TEAM_REQ_NOT_FOUND); + + if ( + nextRequest.collectionID !== destCollID || + request.teamID !== nextRequest.teamID + ) { + return E.left(TEAM_REQ_INVALID_TARGET_COLL_ID); + } + } else { + // When nextRequestID is null, validate that the destination collection + // belongs to the same team as the request to prevent cross-team moves + const destCollection = await this.prisma.teamCollection.findUnique({ + where: { id: destCollID }, + select: { teamID: true }, + }); + if (!destCollection) return E.left(TEAM_INVALID_COLL_ID); + + if (destCollection.teamID !== request.teamID) { + return E.left(TEAM_REQ_INVALID_TARGET_COLL_ID); + } + } + + return E.right({ request, nextRequest }); + } + + /** + * A helper function to get the number of requests in a collection + * @param collectionID Collection ID to fetch + */ + private async getRequestsCountInCollection( + collectionID: string, + tx: Prisma.TransactionClient | null = null, + ) { + return (tx || this.prisma).teamRequest.count({ + where: { collectionID }, + }); + } + + /** + * A helper function to reorder requests + * @param request The request to be moved + * @param srcCollID Collection ID, where the request is currently in + * @param nextRequest The request, which is after the request to be moved. If the request is to be moved to the end of the collection, nextRequest should be null + * @param destCollID Collection ID, where the request is to be moved to + */ + private async reorderRequests( + request: DbTeamRequest, + srcCollID: string, + nextRequest: DbTeamRequest, + destCollID: string, + ) { + try { + return await this.prisma.$transaction< + E.Left | E.Right + >(async (tx) => { + // lock the rows + await this.prisma.lockTeamRequestByCollections(tx, request.teamID, [ + srcCollID, + destCollID, + ]); + + request = await tx.teamRequest.findUnique({ + where: { id: request.id }, + }); + nextRequest = nextRequest + ? await tx.teamRequest.findUnique({ + where: { id: nextRequest.id }, + }) + : null; + + // if request is found in transaction, update orderIndexes of siblings + // if request was deleted before the transaction started (race condition), do not update siblings orderIndexes + if (request) { + const isSameCollection = srcCollID === destCollID; + const isMovingUp = nextRequest?.orderIndex < request.orderIndex; // false, if nextRequest is null + + const nextReqOrderIndex = nextRequest?.orderIndex; + const reqCountInDestColl = nextRequest + ? undefined + : await this.getRequestsCountInCollection(destCollID, tx); + + // Updating order indexes of other requests in collection(s) + if (isSameCollection) { + const updateFrom = isMovingUp + ? nextReqOrderIndex + : request.orderIndex + 1; + const updateTo = isMovingUp + ? request.orderIndex + : nextReqOrderIndex; + + await tx.teamRequest.updateMany({ + where: { + collectionID: srcCollID, + orderIndex: { gte: updateFrom, lt: updateTo }, + }, + data: { + orderIndex: isMovingUp ? { increment: 1 } : { decrement: 1 }, + }, + }); + } else { + await tx.teamRequest.updateMany({ + where: { + collectionID: srcCollID, + orderIndex: { gte: request.orderIndex }, + }, + data: { orderIndex: { decrement: 1 } }, + }); + + if (nextRequest) { + await tx.teamRequest.updateMany({ + where: { + collectionID: destCollID, + orderIndex: { gte: nextReqOrderIndex }, + }, + data: { orderIndex: { increment: 1 } }, + }); + } + } + + // Updating order index of the request + let adjust: number; + if (isSameCollection) + adjust = nextRequest ? (isMovingUp ? 0 : -1) : 0; + else adjust = nextRequest ? 0 : 1; + + const newOrderIndex = + (nextReqOrderIndex ?? reqCountInDestColl) + adjust; + + const updatedRequest = await tx.teamRequest.update({ + where: { id: request.id }, + data: { orderIndex: newOrderIndex, collectionID: destCollID }, + }); + + return E.right(updatedRequest); + } + }); + } catch (err) { + return E.left(TEAM_REQ_REORDERING_FAILED); + } + } + + /** + * Return count of total requests in a team + * @param teamID team ID + */ + async totalRequestsInATeam(teamID: string) { + const requestsCount = await this.prisma.teamRequest.count({ + where: { + teamID: teamID, + }, + }); + + return requestsCount; + } + + /** + * Fetch list of all the Team Requests in DB + * + * @returns number of Team Requests in the DB + */ + async getTeamRequestsCount() { + const teamRequestsCount = this.prisma.teamRequest.count(); + return teamRequestsCount; + } + + /** + * Sort Team Requests in a Collection based on the Sort Option + * + * @param teamID The Team ID + * @param collectionID The Collection ID + * @param sortOption The Sort Option + * @returns An Either of a Boolean if the sorting operation was successful + */ + async sortTeamRequests( + teamID: string, + collectionID: string, + sortBy: SortOptions, + ) { + if (!collectionID) return E.right(true); // No sorting for requests in root collection + + let orderBy: Prisma.Enumerable; + if (sortBy === SortOptions.TITLE_ASC) { + orderBy = { title: 'asc' }; + } else if (sortBy === SortOptions.TITLE_DESC) { + orderBy = { title: 'desc' }; + } else { + orderBy = { orderIndex: 'asc' }; + } + + try { + await this.prisma.$transaction(async (tx) => { + // lock the rows + await this.prisma.lockTeamRequestByCollections(tx, teamID, [ + collectionID, + ]); + const teamRequests = await tx.teamRequest.findMany({ + where: { teamID, collectionID }, + orderBy, + select: { id: true }, + }); + + // Update the orderIndex of each request based on the new order (parallel) + const promises = teamRequests.map((request, i) => + tx.teamRequest.update({ + where: { id: request.id }, + data: { orderIndex: i + 1 }, + }), + ); + await Promise.all(promises); + }); + } catch (error) { + console.error('Error from TeamRequestService.sortTeamRequests', error); + return E.left(TEAM_REQ_REORDERING_FAILED); + } + + return E.right(true); + } +} diff --git a/packages/hoppscotch-backend/src/team/decorators/requires-team-role.decorator.ts b/packages/hoppscotch-backend/src/team/decorators/requires-team-role.decorator.ts new file mode 100644 index 0000000..9ca8f58 --- /dev/null +++ b/packages/hoppscotch-backend/src/team/decorators/requires-team-role.decorator.ts @@ -0,0 +1,5 @@ +import { TeamAccessRole } from 'src/generated/prisma/client'; +import { SetMetadata } from '@nestjs/common'; + +export const RequiresTeamRole = (...roles: TeamAccessRole[]) => + SetMetadata('requiresTeamRole', roles); diff --git a/packages/hoppscotch-backend/src/team/guards/gql-team-member.guard.ts b/packages/hoppscotch-backend/src/team/guards/gql-team-member.guard.ts new file mode 100644 index 0000000..4fd13e9 --- /dev/null +++ b/packages/hoppscotch-backend/src/team/guards/gql-team-member.guard.ts @@ -0,0 +1,44 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { TeamService } from '../team.service'; +import { TeamAccessRole } from '../team.model'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { + TEAM_NOT_REQUIRED_ROLE, + BUG_AUTH_NO_USER_CTX, + BUG_TEAM_NO_REQUIRE_TEAM_ROLE, + BUG_TEAM_NO_TEAM_ID, + TEAM_MEMBER_NOT_FOUND, +} from 'src/errors'; + +@Injectable() +export class GqlTeamMemberGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly teamService: TeamService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requireRoles = this.reflector.get( + 'requiresTeamRole', + context.getHandler(), + ); + if (!requireRoles) throw new Error(BUG_TEAM_NO_REQUIRE_TEAM_ROLE); + + const gqlExecCtx = GqlExecutionContext.create(context); + const { req, headers } = gqlExecCtx.getContext(); + const user = headers ? headers.user : req.user; + + if (user == undefined) throw new Error(BUG_AUTH_NO_USER_CTX); + + const { teamID } = gqlExecCtx.getArgs<{ teamID: string }>(); + if (!teamID) throw new Error(BUG_TEAM_NO_TEAM_ID); + + const teamMember = await this.teamService.getTeamMember(teamID, user.uid); + if (!teamMember) throw new Error(TEAM_MEMBER_NOT_FOUND); + + if (requireRoles.includes(teamMember.role)) return true; + + throw new Error(TEAM_NOT_REQUIRED_ROLE); + } +} diff --git a/packages/hoppscotch-backend/src/team/guards/rest-team-member.guard.ts b/packages/hoppscotch-backend/src/team/guards/rest-team-member.guard.ts new file mode 100644 index 0000000..4355cb0 --- /dev/null +++ b/packages/hoppscotch-backend/src/team/guards/rest-team-member.guard.ts @@ -0,0 +1,47 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { TeamService } from '../../team/team.service'; +import { TeamAccessRole } from '../../team/team.model'; +import { + BUG_TEAM_NO_REQUIRE_TEAM_ROLE, + BUG_AUTH_NO_USER_CTX, + BUG_TEAM_NO_TEAM_ID, + TEAM_MEMBER_NOT_FOUND, + TEAM_NOT_REQUIRED_ROLE, +} from 'src/errors'; +import { throwHTTPErr } from 'src/utils'; + +@Injectable() +export class RESTTeamMemberGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly teamService: TeamService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requireRoles = this.reflector.get( + 'requiresTeamRole', + context.getHandler(), + ); + if (!requireRoles) + throwHTTPErr({ message: BUG_TEAM_NO_REQUIRE_TEAM_ROLE, statusCode: 400 }); + + const request = context.switchToHttp().getRequest(); + + const { user } = request; + if (user == undefined) + throwHTTPErr({ message: BUG_AUTH_NO_USER_CTX, statusCode: 400 }); + + const teamID = request.params.teamID; + if (!teamID) + throwHTTPErr({ message: BUG_TEAM_NO_TEAM_ID, statusCode: 400 }); + + const teamMember = await this.teamService.getTeamMember(teamID, user.uid); + if (!teamMember) + throwHTTPErr({ message: TEAM_MEMBER_NOT_FOUND, statusCode: 404 }); + + if (requireRoles.includes(teamMember.role)) return true; + + throwHTTPErr({ message: TEAM_NOT_REQUIRED_ROLE, statusCode: 403 }); + } +} diff --git a/packages/hoppscotch-backend/src/team/team-member.resolver.ts b/packages/hoppscotch-backend/src/team/team-member.resolver.ts new file mode 100644 index 0000000..595c9de --- /dev/null +++ b/packages/hoppscotch-backend/src/team/team-member.resolver.ts @@ -0,0 +1,24 @@ +import { Resolver, ResolveField, Parent } from '@nestjs/graphql'; +import { TeamMember } from './team.model'; +import { UserService } from 'src/user/user.service'; +import { User } from '../user/user.model'; +import { throwErr } from 'src/utils'; +import { USER_NOT_FOUND } from 'src/errors'; +import * as O from 'fp-ts/Option'; + +@Resolver(() => TeamMember) +export class TeamMemberResolver { + constructor(private readonly userService: UserService) {} + + @ResolveField(() => User) + async user(@Parent() teamMember: TeamMember): Promise { + const member = await this.userService.findUserById(teamMember.userUid); + if (O.isNone(member)) throwErr(USER_NOT_FOUND); + + return { + ...member.value, + currentRESTSession: null, + currentGQLSession: null, + }; + } +} diff --git a/packages/hoppscotch-backend/src/team/team.model.ts b/packages/hoppscotch-backend/src/team/team.model.ts new file mode 100644 index 0000000..fcae19d --- /dev/null +++ b/packages/hoppscotch-backend/src/team/team.model.ts @@ -0,0 +1,39 @@ +import { ObjectType, Field, ID, registerEnumType } from '@nestjs/graphql'; + +@ObjectType() +export class Team { + @Field(() => ID, { + description: 'ID of the team', + }) + id: string; + + @Field(() => String, { + description: 'Displayed name of the team', + }) + name: string; +} + +@ObjectType() +export class TeamMember { + @Field(() => ID, { + description: 'Membership ID of the Team Member', + }) + membershipID: string; + + userUid: string; + + @Field(() => TeamAccessRole, { + description: 'Role of the given team member in the given team', + }) + role: TeamAccessRole; +} + +export enum TeamAccessRole { + OWNER = 'OWNER', + VIEWER = 'VIEWER', + EDITOR = 'EDITOR', +} + +registerEnumType(TeamAccessRole, { + name: 'TeamAccessRole', +}); diff --git a/packages/hoppscotch-backend/src/team/team.module.ts b/packages/hoppscotch-backend/src/team/team.module.ts new file mode 100644 index 0000000..4097785 --- /dev/null +++ b/packages/hoppscotch-backend/src/team/team.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TeamService } from './team.service'; +import { TeamResolver } from './team.resolver'; +import { UserModule } from '../user/user.module'; +import { TeamMemberResolver } from './team-member.resolver'; +import { GqlTeamMemberGuard } from './guards/gql-team-member.guard'; + +@Module({ + imports: [UserModule], + providers: [ + TeamService, + TeamResolver, + TeamMemberResolver, + GqlTeamMemberGuard, + ], + exports: [TeamService, GqlTeamMemberGuard], +}) +export class TeamModule {} diff --git a/packages/hoppscotch-backend/src/team/team.resolver.ts b/packages/hoppscotch-backend/src/team/team.resolver.ts new file mode 100644 index 0000000..91c6bf1 --- /dev/null +++ b/packages/hoppscotch-backend/src/team/team.resolver.ts @@ -0,0 +1,373 @@ +import { Team, TeamMember, TeamAccessRole } from './team.model'; +import { + Resolver, + ResolveField, + Args, + Parent, + Query, + Mutation, + Int, + Subscription, + ID, +} from '@nestjs/graphql'; +import { TeamService } from './team.service'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { GqlUser } from '../decorators/gql-user.decorator'; +import { UseGuards } from '@nestjs/common'; +import { RequiresTeamRole } from './decorators/requires-team-role.decorator'; +import { GqlTeamMemberGuard } from './guards/gql-team-member.guard'; +import { PubSubService } from '../pubsub/pubsub.service'; +import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; +import { throwErr } from 'src/utils'; +import { AuthUser } from 'src/types/AuthUser'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { SkipThrottle } from '@nestjs/throttler'; +import { GqlAdminGuard } from 'src/admin/guards/gql-admin.guard'; +import { UserService } from 'src/user/user.service'; +import { USER_NOT_FOUND } from 'src/errors'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => Team) +export class TeamResolver { + constructor( + private readonly teamService: TeamService, + private readonly pubsub: PubSubService, + private readonly userService: UserService, + ) {} + + // Field Resolvers + // TODO: Deprecate this + @ResolveField(() => [TeamMember], { + description: 'Returns the list of members of a team', + complexity: 10, + }) + members( + @Parent() team: Team, + @Args({ + name: 'cursor', + type: () => ID, + description: + 'The ID of the last returned team member entry (used for pagination)', + nullable: true, + }) + cursor?: string, + ): Promise { + return this.teamService.getMembersOfTeam(team.id, cursor ?? null); + } + + @ResolveField(() => [TeamMember], { + description: 'Returns the list of members of a team', + complexity: 10, + }) + teamMembers(@Parent() team: Team): Promise { + return this.teamService.getTeamMembers(team.id); + } + + @ResolveField(() => TeamAccessRole, { + description: 'The role of the current user in the team', + nullable: true, + }) + @UseGuards(GqlAuthGuard) + myRole( + @Parent() team: Team, + @GqlUser() user: AuthUser, + ): Promise { + return this.teamService.getRoleOfUserInTeam(team.id, user.uid); + } + + @ResolveField(() => Int, { + description: 'The number of users with the OWNER role in the team', + }) + ownersCount(@Parent() team: Team): Promise { + return this.teamService.getCountOfUsersWithRoleInTeam( + team.id, + TeamAccessRole.OWNER, + ); + } + + @ResolveField(() => Int, { + description: 'The number of users with the EDITOR role in the team', + }) + editorsCount(@Parent() team: Team): Promise { + return this.teamService.getCountOfUsersWithRoleInTeam( + team.id, + TeamAccessRole.EDITOR, + ); + } + + @ResolveField(() => Int, { + description: 'The number of users with the VIEWER role in the team', + }) + viewersCount(@Parent() team: Team): Promise { + return this.teamService.getCountOfUsersWithRoleInTeam( + team.id, + TeamAccessRole.VIEWER, + ); + } + + // Query + @Query(() => [Team], { + description: 'List of teams that the executing user belongs to.', + }) + @UseGuards(GqlAuthGuard) + myTeams( + @GqlUser() user: AuthUser, + @Args({ + name: 'cursor', + type: () => ID, + description: + 'The ID of the last returned team entry (used for pagination)', + nullable: true, + }) + cursor?: string, + ): Promise { + return this.teamService.getTeamsOfUser(user.uid, cursor ?? null); + } + + @Query(() => Team, { + description: 'Returns the detail of the team with the given ID', + nullable: true, + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.VIEWER, + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + ) + team( + @Args({ + name: 'teamID', + type: () => ID, + description: 'ID of the team to check', + }) + teamID: string, + ): Promise { + return this.teamService.getTeamWithID(teamID); + } + + @Query(() => [Team], { + description: 'Returns the list of teams a user is a member of (admin-only)', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async teamsOfUserByAdmin( + @Args({ + name: 'userUid', + type: () => ID, + description: 'UID of the user to fetch teams for', + }) + userUid: string, + @Args({ + name: 'cursor', + type: () => ID, + description: + 'The ID of the last returned team entry (used for pagination)', + nullable: true, + }) + cursor?: string, + @Args({ + name: 'take', + type: () => Int, + description: 'Number of teams to return per page', + nullable: true, + defaultValue: 10, + }) + take?: number, + ): Promise { + const user = await this.userService.findUserById(userUid); + if (O.isNone(user)) throwErr(USER_NOT_FOUND); + return this.teamService.getTeamsOfUser(userUid, cursor ?? null, take); + } + + // Mutation + @Mutation(() => Team, { + description: 'Creates a team owned by the executing user', + }) + @UseGuards(GqlAuthGuard) + async createTeam( + @GqlUser() user: AuthUser, + @Args({ name: 'name', description: 'Displayed name of the team' }) + name: string, + ): Promise { + const team = await this.teamService.createTeam(name, user.uid); + if (E.isLeft(team)) throwErr(team.left); + return team.right; + } + + @Mutation(() => Boolean, { + description: 'Leaves a team the executing user is a part of', + }) + @UseGuards(GqlAuthGuard) + async leaveTeam( + @GqlUser() user: AuthUser, + @Args({ + name: 'teamID', + description: 'ID of the Team to leave', + type: () => ID, + }) + teamID: string, + ): Promise { + const isUserLeft = await this.teamService.leaveTeam(teamID, user.uid); + if (E.isLeft(isUserLeft)) throwErr(isUserLeft.left); + return isUserLeft.right; + } + + @Mutation(() => Boolean, { + description: 'Removes the team member from the team', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.OWNER) + async removeTeamMember( + @GqlUser() _user: AuthUser, + @Args({ + name: 'teamID', + description: 'ID of the Team to remove user from', + type: () => ID, + }) + teamID: string, + @Args({ + name: 'userUid', + description: 'ID of the user to remove from the given team', + type: () => ID, + }) + userUid: string, + ): Promise { + const isRemoved = await this.teamService.leaveTeam(teamID, userUid); + if (E.isLeft(isRemoved)) throwErr(isRemoved.left); + return isRemoved.right; + } + + @Mutation(() => Team, { + description: 'Renames a team', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.OWNER) + async renameTeam( + @Args({ name: 'teamID', description: 'ID of the team', type: () => ID }) + teamID: string, + @Args({ name: 'newName', description: 'The updated name of the team' }) + newName: string, + ): Promise { + const team = await this.teamService.renameTeam(teamID, newName); + if (E.isLeft(team)) throwErr(team.left); + return team.right; + } + + @Mutation(() => Boolean, { + description: 'Deletes the team', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole(TeamAccessRole.OWNER) + async deleteTeam( + @Args({ name: 'teamID', description: 'ID of the team', type: () => ID }) + teamID: string, + ): Promise { + const isDeleted = await this.teamService.deleteTeam(teamID); + if (E.isLeft(isDeleted)) throwErr(isDeleted.left); + return isDeleted.right; + } + + @Mutation(() => TeamMember, { + description: 'Update role of a team member the executing user owns', + }) + @RequiresTeamRole(TeamAccessRole.OWNER) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + async updateTeamAccessRole( + @Args({ + name: 'teamID', + description: 'ID of the affected team', + type: () => ID, + }) + teamID: string, + @Args({ + name: 'userUid', + description: 'UID of the affected user', + type: () => ID, + }) + userUid: string, + @Args({ + name: 'newRole', + description: 'Updated role value', + type: () => TeamAccessRole, + }) + newRole: TeamAccessRole, + ): Promise { + const teamMember = await this.teamService.updateTeamAccessRole( + teamID, + userUid, + newRole, + ); + if (E.isLeft(teamMember)) throwErr(teamMember.left); + return teamMember.right; + } + + // Subscriptions + @Subscription(() => TeamMember, { + description: + 'Listen to when a new team member being added to the team. The emitted value is the new team member added.', + resolve: (value) => value, + }) + @RequiresTeamRole( + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + teamMemberAdded( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ): AsyncIterator { + return this.pubsub.asyncIterator(`team/${teamID}/member_added`); + } + + @Subscription(() => TeamMember, { + description: + 'Listen to when a team member status has been updated. The emitted value is the new team member status', + resolve: (value) => value, + }) + @RequiresTeamRole( + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + teamMemberUpdated( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ): AsyncIterator { + return this.pubsub.asyncIterator(`team/${teamID}/member_updated`); + } + + @Subscription(() => ID, { + description: + 'Listen to when a team member has been removed. The emitted value is the uid of the user removed', + resolve: (value) => value, + }) + @RequiresTeamRole( + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ) + @SkipThrottle() + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + teamMemberRemoved( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ): AsyncIterator { + return this.pubsub.asyncIterator(`team/${teamID}/member_removed`); + } +} diff --git a/packages/hoppscotch-backend/src/team/team.service.spec.ts b/packages/hoppscotch-backend/src/team/team.service.spec.ts new file mode 100644 index 0000000..c70e2f3 --- /dev/null +++ b/packages/hoppscotch-backend/src/team/team.service.spec.ts @@ -0,0 +1,1076 @@ +import { TeamService } from './team.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { Team, TeamMember, TeamAccessRole } from './team.model'; +import { TeamMember as DbTeamMember } from 'src/generated/prisma/client'; +import { + USER_NOT_FOUND, + TEAM_INVALID_ID, + TEAM_NAME_INVALID, + TEAM_ONLY_ONE_OWNER, + TEAM_INVALID_ID_OR_USER, +} from '../errors'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import * as O from 'fp-ts/Option'; + +const mockPrisma = mockDeep(); + +const mockUserService = { + findUserByEmail: jest.fn(), + getUserForUID: jest.fn(), + authenticateWithIDToken: jest.fn(), +}; + +const mockPubSub = { + publish: jest.fn().mockResolvedValue(null), +}; + +const teamService = new TeamService( + mockPrisma as any, + mockUserService as any, + mockPubSub as any, +); + +beforeEach(async () => { + mockReset(mockPrisma); +}); + +const team: Team = { + id: 'teamID', + name: 'teamName', +}; + +const teams: Team[] = [ + { + id: 'teamID', + name: 'teamName', + }, + { + id: 'teamID2', + name: 'teamName2', + }, +]; +const dbTeamMember: DbTeamMember = { + id: 'teamMemberID', + role: TeamAccessRole.VIEWER, + userUid: 'userUid', + teamID: team.id, +}; +const teamMember: TeamMember = { + membershipID: dbTeamMember.id, + role: TeamAccessRole[dbTeamMember.role], + userUid: dbTeamMember.userUid, +}; + +describe('getCountOfUsersWithRoleInTeam', () => { + test('resolves to the correct count of owners in a team', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + mockPrisma.teamMember.count.mockResolvedValue(2); + + await expect( + teamService.getCountOfUsersWithRoleInTeam( + dbTeamMember.teamID, + TeamAccessRole.OWNER, + ), + ).resolves.toEqual(2); + + expect(mockPrisma.teamMember.count).toHaveBeenCalledWith({ + where: { + teamID: dbTeamMember.teamID, + role: TeamAccessRole.OWNER, + }, + }); + }); + + test('resolves to the correct count of viewers in a team', async () => { + mockPrisma.teamMember.count.mockResolvedValue(2); + + await expect( + teamService.getCountOfUsersWithRoleInTeam( + dbTeamMember.teamID, + TeamAccessRole.VIEWER, + ), + ).resolves.toEqual(2); + + expect(mockPrisma.teamMember.count).toHaveBeenCalledWith({ + where: { + teamID: dbTeamMember.teamID, + role: TeamAccessRole.VIEWER, + }, + }); + }); + + test('resolves to the correct count of editors in a team', async () => { + mockPrisma.teamMember.count.mockResolvedValue(2); + + await expect( + teamService.getCountOfUsersWithRoleInTeam( + dbTeamMember.teamID, + TeamAccessRole.EDITOR, + ), + ).resolves.toEqual(2); + + expect(mockPrisma.teamMember.count).toHaveBeenCalledWith({ + where: { + teamID: dbTeamMember.teamID, + role: TeamAccessRole.EDITOR, + }, + }); + }); +}); + +describe('addMemberToTeam', () => { + test('resolves when proper team id is given', () => { + mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember); + + expect( + teamService.addMemberToTeam( + dbTeamMember.teamID, + dbTeamMember.userUid, + TeamAccessRole[dbTeamMember.role], + ), + ).resolves.toEqual(expect.objectContaining(teamMember)); + }); + + test('makes the update in the database', async () => { + mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember); + + await teamService.addMemberToTeam( + dbTeamMember.teamID, + dbTeamMember.userUid, + TeamAccessRole[dbTeamMember.role], + ); + + expect(mockPrisma.teamMember.create).toHaveBeenCalledWith({ + data: { + userUid: dbTeamMember.userUid, + team: { + connect: { + id: dbTeamMember.teamID, + }, + }, + role: TeamAccessRole[dbTeamMember.role], + }, + }); + }); + + test('fires "team//member_added" pubsub message with correct payload', async () => { + mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember); + + const member = await teamService.addMemberToTeam( + dbTeamMember.teamID, + dbTeamMember.userUid, + TeamAccessRole[dbTeamMember.role], + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team/${dbTeamMember.teamID}/member_added`, + member, + ); + }); +}); + +describe('addMemberToTeamWithEmail', () => { + afterEach(() => { + mockUserService.findUserByEmail.mockClear(); + mockUserService.authenticateWithIDToken.mockClear(); + mockUserService.authenticateWithIDToken.mockClear(); + }); + + test('resolves when user with email exists', () => { + mockUserService.findUserByEmail.mockResolvedValueOnce( + O.some({ + uid: dbTeamMember.userUid, + }), + ); + mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember); + + const result = teamService.addMemberToTeamWithEmail( + dbTeamMember.teamID, + 'test@hoppscotch.io', + TeamAccessRole[dbTeamMember.role], + ); + return expect(result).resolves.toBeDefined(); + }); + + test("rejects with user with email doesn't exist with USER_NOT_FOUND", () => { + mockUserService.findUserByEmail.mockResolvedValue(O.none); + + const result = teamService.addMemberToTeamWithEmail( + dbTeamMember.teamID, + 'test@hoppscotch.io', + TeamAccessRole[dbTeamMember.role], + ); + return expect(result).resolves.toEqualLeft(USER_NOT_FOUND); + }); + + test('makes update in the database', async () => { + mockUserService.findUserByEmail.mockResolvedValueOnce( + O.some({ + uid: dbTeamMember.userUid, + }), + ); + mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember); + + await teamService.addMemberToTeamWithEmail( + dbTeamMember.teamID, + 'test@hoppscotch.io', + TeamAccessRole[dbTeamMember.role], + ); + + expect(mockPrisma.teamMember.create).toHaveBeenCalledWith({ + data: { + userUid: dbTeamMember.userUid, + team: { + connect: { + id: dbTeamMember.teamID, + }, + }, + role: TeamAccessRole[dbTeamMember.role], + }, + }); + }); + + test('fires "team//member_added" pubsub message with correct payload', async () => { + mockUserService.findUserByEmail.mockResolvedValueOnce( + O.some({ + uid: dbTeamMember.userUid, + }), + ); + mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember); + + await teamService.addMemberToTeamWithEmail( + dbTeamMember.teamID, + 'test@hoppscotch.io', + TeamAccessRole[dbTeamMember.role], + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team/${dbTeamMember.teamID}/member_added`, + teamMember, + ); + }); +}); + +describe('deleteTeam', () => { + test('resolves for proper deletion', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + mockPrisma.team.findUnique.mockResolvedValue(team); + mockPrisma.teamMember.deleteMany.mockResolvedValue({ + count: 10, + }); + mockPrisma.team.delete.mockResolvedValue(team); + + const result = await teamService.deleteTeam(team.id); + return expect(result).toEqualRight(true); + }); + + test('performs deletion on database', async () => { + mockPrisma.team.findUnique.mockResolvedValue(team); + mockPrisma.teamMember.deleteMany.mockResolvedValue({ + count: 10, + }); + mockPrisma.team.delete.mockResolvedValue(team); + + await teamService.deleteTeam(team.id); + + expect(mockPrisma.team.delete).toHaveBeenCalledWith({ + where: { + id: team.id, + }, + }); + }); + + test('rejects for invalid team id', async () => { + mockPrisma.team.findUnique.mockResolvedValue(null); + + // If invalid team ID, team member deletes nothing (count 0) + mockPrisma.teamMember.deleteMany.mockResolvedValue({ + count: 0, + }); + + // TODO: Confirm RecordNotFound works like this + mockPrisma.team.delete.mockRejectedValue('RecordNotFound'); + + // Team will not find and reject + const result = await teamService.deleteTeam(team.id); + return expect(result).toEqualLeft(TEAM_INVALID_ID); + }); +}); + +describe('renameTeam', () => { + test('resolves for proper rename', () => { + const newTeamName = 'Rename'; + + mockPrisma.team.update.mockResolvedValue({ + ...team, + name: newTeamName, + }); + + return expect( + teamService.renameTeam(team.id, newTeamName), + ).resolves.toBeDefined(); + }); + + test('resolves with team structure', () => { + const newTeamName = 'Rename'; + + mockPrisma.team.update.mockResolvedValue({ + ...team, + name: newTeamName, + }); + + return expect( + teamService.renameTeam(team.id, newTeamName), + ).resolves.toEqualRight( + expect.objectContaining({ + ...team, + name: newTeamName, + }), + ); + }); + + test('performs rename on database', async () => { + const newTeamName = 'Rename'; + + mockPrisma.team.update.mockResolvedValue({ + ...team, + name: newTeamName, + }); + + await teamService.renameTeam(team.id, newTeamName); + + expect(mockPrisma.team.update).toHaveBeenCalledWith({ + where: { + id: team.id, + }, + data: { + name: newTeamName, + }, + }); + }); + + test('rejects for invalid team id with TEAM_INVALID_ID', () => { + const newTeamName = 'Rename'; + // If invalid team id, update fails with RecordNotFound + mockPrisma.team.update.mockRejectedValue('RecordNotFound'); + + return expect( + teamService.renameTeam(team.id, newTeamName), + ).resolves.toEqualLeft(TEAM_INVALID_ID); + }); + + test('rejects for new team name empty with TEAM_NAME_INVALID', () => { + const newTeamName = ''; + + // Prisma doesn't care about the team name length, so it will resolve + mockPrisma.team.update.mockResolvedValue({ + ...team, + name: newTeamName, + }); + + return expect( + teamService.renameTeam(team.id, newTeamName), + ).resolves.toEqualLeft(TEAM_NAME_INVALID); + }); +}); + +describe('updateTeamAccessRole', () => { + /** + * Test Scenario: + * 3 users (testuid1 thru 3) having each of the roles + * (OWNER, VIEWER, EDITOR) + * in Team with id 3170 + */ + + test('updates the role', async () => { + const newRole = TeamAccessRole.EDITOR; + + mockPrisma.teamMember.count.mockResolvedValue(1); + mockPrisma.teamMember.findUnique.mockResolvedValue({ + ...dbTeamMember, + role: TeamAccessRole[dbTeamMember.role], + }); + mockPrisma.teamMember.update.mockResolvedValue({ + ...dbTeamMember, + role: newRole, + }); + + await teamService.updateTeamAccessRole( + dbTeamMember.teamID, + dbTeamMember.userUid, + newRole, + ); + + expect(mockPrisma.teamMember.update).toHaveBeenCalledWith({ + where: { + teamID_userUid: { + teamID: dbTeamMember.teamID, + userUid: dbTeamMember.userUid, + }, + }, + data: { + role: newRole, + }, + }); + }); + + test('returns the updated details', () => { + const newRole = TeamAccessRole.EDITOR; + + mockPrisma.teamMember.count.mockResolvedValue(1); + mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember); + mockPrisma.teamMember.update.mockResolvedValue({ + ...dbTeamMember, + role: newRole, + }); + + return expect( + teamService.updateTeamAccessRole( + dbTeamMember.teamID, + dbTeamMember.userUid, + newRole, + ), + ).resolves.toEqualRight({ ...teamMember, role: newRole }); + }); + + test('rejects if you change the status of the sole owner to non-owner status with TEAM_ONLY_ONE_OWNER', () => { + mockPrisma.teamMember.count.mockResolvedValue(1); + mockPrisma.teamMember.findUnique.mockResolvedValue({ + ...dbTeamMember, + role: TeamAccessRole.OWNER, + }); + + // Prisma doesn't care if it goes through + mockPrisma.teamMember.update.mockResolvedValue(dbTeamMember); + + return expect( + teamService.updateTeamAccessRole( + dbTeamMember.teamID, + dbTeamMember.userUid, + TeamAccessRole[dbTeamMember.role], + ), + ).resolves.toEqualLeft(TEAM_ONLY_ONE_OWNER); + }); + + test('resolves if you change the status of the sole owner to owner status (no change)', () => { + mockPrisma.teamMember.count.mockResolvedValue(1); + mockPrisma.teamMember.findUnique.mockResolvedValue({ + ...dbTeamMember, + role: TeamAccessRole.OWNER, + }); + mockPrisma.teamMember.update.mockResolvedValue({ + ...dbTeamMember, + role: TeamAccessRole.OWNER, + }); + + return expect( + teamService.updateTeamAccessRole( + dbTeamMember.teamID, + dbTeamMember.userUid, + TeamAccessRole[TeamAccessRole.OWNER], + ), + ).resolves.toBeDefined(); + }); + + test('resolves if you change the status of an owner but there are other owners', async () => { + mockPrisma.teamMember.count.mockResolvedValue(2); + mockPrisma.teamMember.findUnique.mockResolvedValue({ + ...dbTeamMember, + role: TeamAccessRole.OWNER, + }); + mockPrisma.teamMember.update.mockResolvedValue(dbTeamMember); + + // Set another user as the owner + await teamService.updateTeamAccessRole( + dbTeamMember.teamID, + 'testuid2', + TeamAccessRole.OWNER, + ); + + await expect( + teamService.updateTeamAccessRole( + dbTeamMember.teamID, + dbTeamMember.userUid, + TeamAccessRole[dbTeamMember.role], + ), + ).resolves.toBeDefined(); + }); + + test('fires "team//member_updated" pubsub message with correct payload', async () => { + const newRole = TeamAccessRole.EDITOR; + + mockPrisma.teamMember.count.mockResolvedValue(2); + mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember); + mockPrisma.teamMember.update.mockResolvedValue({ + ...dbTeamMember, + role: newRole, + }); + + await teamService.updateTeamAccessRole( + dbTeamMember.teamID, + dbTeamMember.userUid, + newRole, + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team/${dbTeamMember.teamID}/member_updated`, + { + ...teamMember, + role: newRole, + }, + ); + }); +}); + +describe('leaveTeam', () => { + /* + Same scenario as above: + 3 users (testuid1 thru 3) with respectively + OWNER, VIEWER and EDITOR roles in team with id 3170 + */ + + test('removes the user if valid credentials given', async () => { + mockPrisma.teamMember.count.mockResolvedValue(2); + mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember); + mockPrisma.teamMember.delete.mockResolvedValue(dbTeamMember); + + await teamService.leaveTeam(dbTeamMember.teamID, dbTeamMember.userUid); + + expect(mockPrisma.teamMember.delete).toHaveBeenCalledWith({ + where: { + teamID_userUid: { + teamID: dbTeamMember.teamID, + userUid: dbTeamMember.userUid, + }, + }, + }); + }); + + test('rejects if invalid teamId with TEAM_INVALID_ID_OR_USER', () => { + // Invalid team id will return 0 count + mockPrisma.teamMember.count.mockResolvedValue(0); + + // getTeamMember returns null if no match + mockPrisma.teamMember.findUnique.mockResolvedValue(null); + + // Deletion rejects with RecordNotFound when no match + mockPrisma.teamMember.delete.mockRejectedValue('RecordNotFound'); + + return expect( + teamService.leaveTeam('31700', dbTeamMember.userUid), + ).resolves.toEqualLeft(TEAM_INVALID_ID_OR_USER); + }); + + test('rejects if invalid userUid with TEAM_INVALID_ID_OR_USER', () => { + // Invalid team id will return proper count + mockPrisma.teamMember.count.mockResolvedValue(1); + + // getTeamMember returns null if no match + mockPrisma.teamMember.findUnique.mockResolvedValue(null); + + // Deletion rejects with RecordNotFound when no match + mockPrisma.teamMember.delete.mockRejectedValue('RecordNotFound'); + + return expect( + teamService.leaveTeam(dbTeamMember.teamID, 'testuid3'), + ).resolves.toEqualLeft(TEAM_INVALID_ID_OR_USER); + }); + + test('rejects if the removed user is the sole owner of the team with TEAM_ONLY_ONE_OWNER', () => { + mockPrisma.teamMember.count.mockResolvedValue(1); + mockPrisma.teamMember.findUnique.mockResolvedValue({ + ...dbTeamMember, + role: TeamAccessRole.OWNER, + }); + + // Prisma does not care + mockPrisma.teamMember.delete.mockResolvedValue({ + ...dbTeamMember, + role: TeamAccessRole.OWNER, + }); + + return expect( + teamService.leaveTeam(dbTeamMember.teamID, dbTeamMember.userUid), + ).resolves.toEqualLeft(TEAM_ONLY_ONE_OWNER); + }); + + test('resolves if the removed user is an owner (but not the sole) of the team', async () => { + mockPrisma.teamMember.count.mockResolvedValue(2); + mockPrisma.teamMember.findUnique.mockResolvedValue({ + ...dbTeamMember, + role: TeamAccessRole.OWNER, + }); + mockPrisma.teamMember.delete.mockResolvedValue({ + ...dbTeamMember, + role: TeamAccessRole.OWNER, + }); + + await expect( + teamService.leaveTeam(dbTeamMember.teamID, dbTeamMember.userUid), + ).resolves.toEqualRight(true); + }); + + test('fires "team//member_removed" pubsub message with correct payload', async () => { + mockPrisma.teamMember.count.mockResolvedValue(2); + mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember); + mockPrisma.teamMember.delete.mockResolvedValue(dbTeamMember); + + await teamService.leaveTeam(dbTeamMember.teamID, dbTeamMember.userUid); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team/${dbTeamMember.teamID}/member_removed`, + dbTeamMember.userUid, + ); + }); +}); + +describe('createTeam', () => { + test('adds the new team to the db', async () => { + mockPrisma.team.create.mockResolvedValue(team); + + await teamService.createTeam(team.name, dbTeamMember.userUid); + + expect(mockPrisma.team.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: team.name, + }), + }), + ); + }); + + test('adds the creator to team and set them as OWNER', async () => { + mockPrisma.team.create.mockResolvedValue(team); + + await teamService.createTeam(team.name, dbTeamMember.userUid); + + expect(mockPrisma.team.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + members: { + create: { + userUid: dbTeamMember.userUid, + role: TeamAccessRole.OWNER, + }, + }, + }), + }), + ); + }); + + test('resolves with the team info', () => { + mockPrisma.team.create.mockResolvedValue(team); + + return expect( + teamService.createTeam(team.name, dbTeamMember.userUid), + ).resolves.toEqualRight(expect.objectContaining(team)); + }); + + test('rejects for team name empty with TEAM_NAME_INVALID', () => { + const newName = ''; + + // Prisma doesn't care + mockPrisma.team.create.mockResolvedValue({ + ...team, + name: newName, + }); + + return expect( + teamService.createTeam(newName, dbTeamMember.userUid), + ).resolves.toEqualLeft(TEAM_NAME_INVALID); + }); +}); + +describe('getTeamWithID', () => { + test('resolves for a proper team id with the proper details', () => { + mockPrisma.team.findUnique.mockResolvedValue(team); + + return expect(teamService.getTeamWithID(team.id)).resolves.toEqual( + expect.objectContaining(team), + ); + }); + + test('resolves for a invalid team id as null', () => { + // Prisma would reject with RecordNotFound + mockPrisma.team.findUnique.mockRejectedValue('RecordNotFound'); + + return expect(teamService.getTeamWithID('3171')).resolves.toBeNull(); + }); +}); + +describe('getTeamMember', () => { + test('resolves for a proper team id and user uid and returns the info', () => { + mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember); + + return expect( + teamService.getTeamMember(dbTeamMember.teamID, dbTeamMember.userUid), + ).resolves.toEqual(expect.objectContaining(teamMember)); + }); + + test('resolves for a invalid team id and proper uid and returns null', () => { + // If not found, prisma rejects with RecordNotFound + mockPrisma.teamMember.findUnique.mockRejectedValue('RecordNotFound'); + + return expect( + teamService.getTeamMember(dbTeamMember.teamID, 'testuid'), + ).resolves.toBeNull(); + }); +}); + +describe('getRoleOfUserInTeam', () => { + test('resolves with the correct role value', () => { + mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember); + + return expect( + teamService.getRoleOfUserInTeam( + dbTeamMember.teamID, + dbTeamMember.userUid, + ), + ).resolves.toEqual(dbTeamMember.role); + }); + + test('resolves with null if user is not found in team', () => { + mockPrisma.teamMember.findUnique.mockRejectedValue('RecordNotFound'); + + return expect( + teamService.getRoleOfUserInTeam(dbTeamMember.teamID, 'nottestuid'), + ).resolves.toBeNull(); + }); + + test('resolves with null if team does not exist', () => { + mockPrisma.teamMember.findUnique.mockRejectedValue('RecordNotFound'); + + return expect( + teamService.getRoleOfUserInTeam('invalidteam', dbTeamMember.userUid), + ).resolves.toBeNull(); + }); +}); + +describe('getMembersOfTeam', () => { + test('resolves for the team id and null cursor with the first page', async () => { + mockPrisma.teamMember.findMany.mockResolvedValue([]); + await teamService.getMembersOfTeam(team.id, null); + + expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ + take: 10, + where: { + teamID: team.id, + }, + }); + }); + + test('resolves for the team id and proper cursor with pagination', async () => { + const cursor = 'secondpage'; + + mockPrisma.teamMember.findMany.mockResolvedValue([]); + await teamService.getMembersOfTeam(team.id, cursor); + + expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ + take: 10, + skip: 1, + cursor: { + id: cursor, + }, + where: { + teamID: team.id, + }, + }); + }); + + test('resolves with an empty array for invalid team id and null cursor', () => { + // findMany returns an empty array if no matches are found + mockPrisma.teamMember.findMany.mockResolvedValue([]); + + return expect( + teamService.getMembersOfTeam('invalidteamid', null), + ).resolves.toHaveLength(0); + }); + + test('resolves with an empty array for an invalid team id and invalid cursor', () => { + // findMany returns an empty array if no matches are found + mockPrisma.teamMember.findMany.mockResolvedValue([]); + + return expect( + teamService.getMembersOfTeam('invalidteamid', 'invalidcursor'), + ).resolves.toHaveLength(0); + }); +}); + +describe('getTeamsOfUser', () => { + test('resolves with the first 10 elements when no cursor is given', async () => { + mockPrisma.team.findMany.mockResolvedValue([]); + + await teamService.getTeamsOfUser(dbTeamMember.userUid, null); + + expect(mockPrisma.team.findMany).toHaveBeenCalledWith({ + take: 10, + skip: 0, + cursor: undefined, + where: { + members: { + some: { + userUid: dbTeamMember.userUid, + }, + }, + }, + }); + }); + + test('resolves as expected for paginated requests with cursor', async () => { + const cursor = 'secondpage'; + + mockPrisma.team.findMany.mockResolvedValue([]); + await teamService.getTeamsOfUser(dbTeamMember.userUid, cursor); + + expect(mockPrisma.team.findMany).toHaveBeenCalledWith({ + take: 10, + skip: 1, + cursor: { + id: cursor, + }, + where: { + members: { + some: { + userUid: dbTeamMember.userUid, + }, + }, + }, + }); + }); + + test('resolves with an empty array for an invalid cursor', () => { + // Invalid cursors return an empty array + mockPrisma.team.findMany.mockResolvedValue([]); + + return expect( + teamService.getTeamsOfUser(dbTeamMember.userUid, 'invalidcursor'), + ).resolves.toHaveLength(0); + }); + + test('resolves with an empty array for invalid user id and null cursor', () => { + mockPrisma.team.findMany.mockResolvedValue([]); + + return expect( + teamService.getTeamsOfUser('invalidid', null), + ).resolves.toHaveLength(0); + }); + + test('resolves with an empty array for invalid user id and invalid cursor', () => { + mockPrisma.team.findMany.mockResolvedValue([]); + + return expect( + teamService.getTeamsOfUser('invalidId', 'invalidCursor'), + ).resolves.toHaveLength(0); + }); +}); + +describe('deleteUserFromAllTeams', () => { + test('should return undefined when a valid uid is passed and user is deleted from all teams', async () => { + mockPrisma.teamMember.findMany.mockResolvedValue([dbTeamMember]); + mockPrisma.teamMember.count.mockResolvedValue(2); + mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember); + + const result = await teamService.deleteUserFromAllTeams( + dbTeamMember.userUid, + )(); + + expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ + where: { + userUid: dbTeamMember.userUid, + }, + }); + + expect(result).toBeUndefined(); + }); + + test('should return undefined when user has no data or the uid is invalid', async () => { + mockPrisma.teamMember.findMany.mockResolvedValue([]); + + const result = await teamService.deleteUserFromAllTeams( + dbTeamMember.userUid, + )(); + + expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ + where: { + userUid: dbTeamMember.userUid, + }, + }); + + expect(result).toBeUndefined(); + }); + + test('should reject when user is an OWNER in a team with only 1 member', async () => { + mockPrisma.teamMember.findMany.mockResolvedValue([dbTeamMember]); + mockPrisma.teamMember.count.mockResolvedValue(1); + mockPrisma.teamMember.findUnique.mockResolvedValue({ + ...dbTeamMember, + role: TeamAccessRole.OWNER, + }); + + const result = teamService.deleteUserFromAllTeams(dbTeamMember.userUid)(); + + await expect(result).rejects.toThrow(TEAM_ONLY_ONE_OWNER); + expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ + where: { + userUid: dbTeamMember.userUid, + }, + }); + }); + + test('should reject when a valid uid is passed but fetching teamMember details errors out', async () => { + mockPrisma.teamMember.findMany.mockResolvedValue([ + { + ...dbTeamMember, + role: TeamAccessRole.OWNER, + }, + ]); + mockPrisma.teamMember.count.mockResolvedValue(2); + + // findUnique while getTeamMember() is called errors out + mockPrisma.teamMember.findUnique.mockRejectedValueOnce('NotFoundError'); + + const result = teamService.deleteUserFromAllTeams(dbTeamMember.userUid); + + await expect(result).rejects.toThrow(TEAM_INVALID_ID_OR_USER); + expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ + where: { + userUid: dbTeamMember.userUid, + }, + }); + }); +}); + +describe('fetchAllTeams', () => { + test('should resolve right and return 20 teams when cursor is null', async () => { + mockPrisma.team.findMany.mockResolvedValueOnce(teams); + + const result = await teamService.fetchAllTeams(null, 20); + expect(result).toEqual(teams); + }); + test('should resolve right and return next 20 teams when cursor is provided', async () => { + mockPrisma.team.findMany.mockResolvedValueOnce(teams); + + const result = await teamService.fetchAllTeams('teamID', 20); + expect(result).toEqual(teams); + }); + test('should resolve left and return an empty array when users not found', async () => { + mockPrisma.team.findMany.mockResolvedValueOnce([]); + + const result = await teamService.fetchAllTeams(null, 20); + expect(result).toEqual([]); + }); +}); + +describe('fetchAllTeamsV2', () => { + test('should return teams with offset pagination when no search string', async () => { + mockPrisma.team.findMany.mockResolvedValueOnce(teams); + + const result = await teamService.fetchAllTeamsV2('', { + skip: 0, + take: 20, + }); + + expect(result).toEqual(teams); + expect(mockPrisma.team.findMany).toHaveBeenCalledWith({ + skip: 0, + take: 20, + where: undefined, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + }); + }); + + test('should search by name and id when search string is provided', async () => { + mockPrisma.team.findMany.mockResolvedValueOnce([teams[0]]); + + const result = await teamService.fetchAllTeamsV2('team', { + skip: 0, + take: 20, + }); + + expect(result).toEqual([teams[0]]); + expect(mockPrisma.team.findMany).toHaveBeenCalledWith({ + skip: 0, + take: 20, + where: { + OR: [ + { name: { contains: 'team', mode: 'insensitive' } }, + { id: { contains: 'team', mode: 'insensitive' } }, + ], + }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + }); + }); + + test('should apply skip for pagination', async () => { + mockPrisma.team.findMany.mockResolvedValueOnce(teams); + + const result = await teamService.fetchAllTeamsV2('', { + skip: 20, + take: 20, + }); + + expect(result).toEqual(teams); + expect(mockPrisma.team.findMany).toHaveBeenCalledWith({ + skip: 20, + take: 20, + where: undefined, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + }); + }); + + test('should return empty array when no teams match', async () => { + mockPrisma.team.findMany.mockResolvedValueOnce([]); + + const result = await teamService.fetchAllTeamsV2('nonexistent', { + skip: 0, + take: 20, + }); + + expect(result).toEqual([]); + expect(mockPrisma.team.findMany).toHaveBeenCalledWith({ + skip: 0, + take: 20, + where: { + OR: [ + { name: { contains: 'nonexistent', mode: 'insensitive' } }, + { id: { contains: 'nonexistent', mode: 'insensitive' } }, + ], + }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + }); + }); +}); + +describe('getCountOfMembersInTeam', () => { + test('should resolve right and return a total team member count ', async () => { + mockPrisma.teamMember.count.mockResolvedValueOnce(2); + const result = await teamService.getCountOfMembersInTeam(team.id); + expect(mockPrisma.teamMember.count).toHaveBeenCalledWith({ + where: { + teamID: team.id, + }, + }); + expect(result).toEqual(2); + }); + test('should resolve left and return an error when no team members found', async () => { + mockPrisma.teamMember.count.mockResolvedValueOnce(0); + const result = await teamService.getCountOfMembersInTeam(team.id); + expect(mockPrisma.teamMember.count).toHaveBeenCalledWith({ + where: { + teamID: team.id, + }, + }); + expect(result).toEqual(0); + }); + + describe('getTeamsCount', () => { + test('should return count of all teams in the organization', async () => { + mockPrisma.team.count.mockResolvedValueOnce(10); + + const result = await teamService.getTeamsCount(); + expect(result).toEqual(10); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/team/team.service.ts b/packages/hoppscotch-backend/src/team/team.service.ts new file mode 100644 index 0000000..cb5bc31 --- /dev/null +++ b/packages/hoppscotch-backend/src/team/team.service.ts @@ -0,0 +1,561 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { TeamMember, TeamAccessRole, Team } from './team.model'; +import { PrismaService } from '../prisma/prisma.service'; +import { TeamMember as DbTeamMember } from 'src/generated/prisma/client'; +import { UserService } from '../user/user.service'; +import { UserDataHandler } from 'src/user/user.data.handler'; +import { + TEAM_NAME_INVALID, + TEAM_ONLY_ONE_OWNER, + USER_NOT_FOUND, + TEAM_INVALID_ID, + TEAM_INVALID_ID_OR_USER, + TEAM_MEMBER_NOT_FOUND, + USER_IS_OWNER, +} from '../errors'; +import { PubSubService } from '../pubsub/pubsub.service'; +import { flow, pipe } from 'fp-ts/function'; +import * as TE from 'fp-ts/TaskEither'; +import * as TO from 'fp-ts/TaskOption'; +import * as O from 'fp-ts/Option'; +import * as E from 'fp-ts/Either'; +import * as T from 'fp-ts/Task'; +import * as A from 'fp-ts/Array'; +import { isValidLength, throwErr } from 'src/utils'; +import { AuthUser } from '../types/AuthUser'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; + +@Injectable() +export class TeamService implements UserDataHandler, OnModuleInit { + constructor( + private readonly prisma: PrismaService, + private readonly userService: UserService, + private readonly pubsub: PubSubService, + ) {} + + TITLE_LENGTH = 1; + + onModuleInit() { + this.userService.registerUserDataHandler(this); + } + + canAllowUserDeletion(user: AuthUser): TO.TaskOption { + return pipe( + this.isUserSoleOwnerInAnyTeam(user.uid), + TO.fromTask, + TO.chain((isOwner) => (isOwner ? TO.some(USER_IS_OWNER) : TO.none)), + ); + } + + onUserDelete(user: AuthUser): T.Task { + return this.deleteUserFromAllTeams(user.uid); + } + + async getCountOfUsersWithRoleInTeam( + teamID: string, + role: TeamAccessRole, + ): Promise { + return await this.prisma.teamMember.count({ + where: { + teamID, + role, + }, + }); + } + + async addMemberToTeamWithEmail( + teamID: string, + email: string, + role: TeamAccessRole, + ): Promise | E.Right> { + const user = await this.userService.findUserByEmail(email); + if (O.isNone(user)) return E.left(USER_NOT_FOUND); + + const teamMember = await this.addMemberToTeam(teamID, user.value.uid, role); + return E.right(teamMember); + } + + async addMemberToTeam( + teamID: string, + uid: string, + role: TeamAccessRole, + ): Promise { + const teamMember = await this.prisma.teamMember.create({ + data: { + userUid: uid, + team: { + connect: { + id: teamID, + }, + }, + role: role, + }, + }); + + const member: TeamMember = { + membershipID: teamMember.id, + userUid: teamMember.userUid, + role: TeamAccessRole[teamMember.role], + }; + + this.pubsub.publish(`team/${teamID}/member_added`, member); + + return member; + } + + async deleteTeam(teamID: string): Promise | E.Right> { + const team = await this.prisma.team.findUnique({ + where: { + id: teamID, + }, + }); + if (!team) return E.left(TEAM_INVALID_ID); + + await this.prisma.teamMember.deleteMany({ + where: { + teamID: teamID, + }, + }); + + await this.prisma.team.delete({ + where: { + id: teamID, + }, + }); + + return E.right(true); + } + + async renameTeam( + teamID: string, + newName: string, + ): Promise | E.Right> { + const isValidTitle = isValidLength(newName, this.TITLE_LENGTH); + if (!isValidTitle) return E.left(TEAM_NAME_INVALID); + + try { + const updatedTeam = await this.prisma.team.update({ + where: { + id: teamID, + }, + data: { + name: newName, + }, + }); + return E.right(updatedTeam); + } catch (e) { + // Prisma update errors out if it can't find the record + return E.left(TEAM_INVALID_ID); + } + } + + async updateTeamAccessRole( + teamID: string, + userUid: string, + newRole: TeamAccessRole, + ): Promise | E.Right> { + const ownerCount = await this.prisma.teamMember.count({ + where: { + teamID, + role: TeamAccessRole.OWNER, + }, + }); + + const member = await this.prisma.teamMember.findUnique({ + where: { + teamID_userUid: { + teamID, + userUid, + }, + }, + }); + + if (!member) return E.left(TEAM_MEMBER_NOT_FOUND); + if ( + member.role === TeamAccessRole.OWNER && + newRole != TeamAccessRole.OWNER && + ownerCount === 1 + ) { + return E.left(TEAM_ONLY_ONE_OWNER); + } + + const result = await this.prisma.teamMember.update({ + where: { + teamID_userUid: { + teamID, + userUid, + }, + }, + data: { + role: newRole, + }, + }); + + const updatedMember: TeamMember = { + membershipID: result.id, + userUid: result.userUid, + role: TeamAccessRole[result.role], + }; + + this.pubsub.publish(`team/${teamID}/member_updated`, updatedMember); + + return E.right(updatedMember); + } + + async leaveTeam( + teamID: string, + userUid: string, + ): Promise | E.Right> { + const ownerCount = await this.prisma.teamMember.count({ + where: { + teamID, + role: TeamAccessRole.OWNER, + }, + }); + + const member = await this.getTeamMember(teamID, userUid); + if (!member) return E.left(TEAM_INVALID_ID_OR_USER); + + if (ownerCount === 1 && member.role === TeamAccessRole.OWNER) { + return E.left(TEAM_ONLY_ONE_OWNER); + } + + try { + await this.prisma.teamMember.delete({ + where: { + teamID_userUid: { + userUid, + teamID, + }, + }, + }); + } catch (e) { + // Record not found + return E.left(TEAM_INVALID_ID_OR_USER); + } + + this.pubsub.publish(`team/${teamID}/member_removed`, userUid); + + return E.right(true); + } + + async createTeam( + name: string, + creatorUid: string, + ): Promise | E.Right> { + const isValidName = isValidLength(name, this.TITLE_LENGTH); + if (!isValidName) return E.left(TEAM_NAME_INVALID); + + const team = await this.prisma.team.create({ + data: { + name: name, + members: { + create: { + userUid: creatorUid, + role: TeamAccessRole.OWNER, + }, + }, + }, + }); + + return E.right(team); + } + + async getTeamsOfUser( + uid: string, + cursor: string | null, + take = 10, + ): Promise { + const teams = await this.prisma.team.findMany({ + take, + skip: cursor ? 1 : 0, + cursor: cursor ? { id: cursor } : undefined, + where: { + members: { + some: { + userUid: uid, + }, + }, + }, + }); + + return teams; + } + + async getTeamWithID(teamID: string): Promise { + try { + const team = await this.prisma.team.findUnique({ + where: { + id: teamID, + }, + }); + + return team; + } catch (_e) { + return null; + } + } + + getTeamWithIDTE(teamID: string): TE.TaskEither<'team/invalid_id', Team> { + return pipe( + () => this.getTeamWithID(teamID), + TE.fromTask, + TE.chain( + TE.fromPredicate( + (x): x is Team => !!x, + () => TEAM_INVALID_ID, + ), + ), + ); + } + + /** + * Filters out team members that we weren't able to match + * (also deletes the membership) + * @param members Members to filter against + */ + async filterMismatchedUsers( + teamID: string, + members: TeamMember[], + ): Promise { + const memberUsers = await Promise.all( + members.map(async (member) => { + const user = await this.userService.findUserById(member.userUid); + + // // TODO:Investigate if a race condition exists that deletes user from teams. + // // Delete the membership if the user doesnt exist + // if (!user) this.leaveTeam(teamID, member.userUid); + + if (O.isSome(user)) return member; + else return null; + }), + ); + + return memberUsers.filter((x) => x !== null) as TeamMember[]; + } + + async getTeamMember( + teamID: string, + userUid: string, + ): Promise { + try { + const teamMember = await this.prisma.teamMember.findUnique({ + where: { + teamID_userUid: { + teamID, + userUid, + }, + }, + }); + + if (!teamMember) return null; + + return { + membershipID: teamMember.id, + userUid: userUid, + role: TeamAccessRole[teamMember.role], + }; + } catch (e) { + return null; + } + } + + getTeamMemberTE(teamID: string, userUid: string) { + return pipe( + () => this.getTeamMember(teamID, userUid), + TE.fromTask, + TE.chain( + TE.fromPredicate( + (x): x is TeamMember => !!x, + () => TEAM_MEMBER_NOT_FOUND, + ), + ), + ); + } + + async getRoleOfUserInTeam( + teamID: string, + userUid: string, + ): Promise { + const teamMember = await this.getTeamMember(teamID, userUid); + return teamMember ? teamMember.role : null; + } + + isUserSoleOwnerInAnyTeam(uid: string): T.Task { + return async () => { + // Find all teams where the user is an OWNER + const userOwnedTeams = await this.prisma.teamMember.findMany({ + where: { + userUid: uid, + role: TeamAccessRole.OWNER, + }, + select: { + teamID: true, + }, + }); + + for (const userOwnedTeam of userOwnedTeams) { + const ownerCount = await this.prisma.teamMember.count({ + where: { + teamID: userOwnedTeam.teamID, + role: TeamAccessRole.OWNER, + }, + }); + + // early return true if the user is the sole owner + if (ownerCount === 1) return true; + } + + // return false if the user is not the sole owner in any team + return false; + }; + } + + deleteUserFromAllTeams(uid: string) { + return pipe( + () => + this.prisma.teamMember.findMany({ + where: { + userUid: uid, + }, + }), + T.chainFirst( + flow( + A.map((member) => async () => { + const res = await this.leaveTeam(member.teamID, uid); + if (E.isLeft(res)) throwErr(res.left); + return E.right(res); + }), + T.sequenceArray, + ), + ), + T.map(() => undefined), + ); + } + + async getTeamMembers(teamID: string): Promise { + const dbTeamMembers = await this.prisma.teamMember.findMany({ + where: { + teamID, + }, + }); + + const members = dbTeamMembers.map( + (entry) => + { + membershipID: entry.id, + userUid: entry.userUid, + role: TeamAccessRole[entry.role], + }, + ); + + return this.filterMismatchedUsers(teamID, members); + } + + /** + * Get a count of members in a team + * @param teamID Team ID + * @returns a count of members in a team + */ + async getCountOfMembersInTeam(teamID: string) { + const memberCount = await this.prisma.teamMember.count({ + where: { + teamID: teamID, + }, + }); + + return memberCount; + } + + async getMembersOfTeam( + teamID: string, + cursor: string | null, + ): Promise { + let teamMembers: DbTeamMember[]; + + if (!cursor) { + teamMembers = await this.prisma.teamMember.findMany({ + take: 10, + where: { + teamID, + }, + }); + } else { + teamMembers = await this.prisma.teamMember.findMany({ + take: 10, + skip: 1, + cursor: { + id: cursor, + }, + where: { + teamID, + }, + }); + } + + const members = teamMembers.map( + (entry) => + { + membershipID: entry.id, + userUid: entry.userUid, + role: TeamAccessRole[entry.role], + }, + ); + + return this.filterMismatchedUsers(teamID, members); + } + + /** + * Fetch all the teams in the `Team` table based on cursor + * @param cursorID string of teamID or undefined + * @param take number of items to query + * @returns an array of `Team` object + * @deprecated use fetchAllTeamsV2 instead + */ + async fetchAllTeams(cursorID: string, take: number) { + const options = { + skip: cursorID ? 1 : 0, + take: take, + cursor: cursorID ? { id: cursorID } : undefined, + }; + + const fetchedTeams = await this.prisma.team.findMany(options); + return fetchedTeams; + } + + /** + * Fetch all the teams in the `Team` table with offset pagination and search + * @param searchString search on team name or ID + * @param paginationOption pagination options + * @returns an array of `Team` object + */ + async fetchAllTeamsV2( + searchString: string, + paginationOption: OffsetPaginationArgs, + ) { + const fetchedTeams = await this.prisma.team.findMany({ + skip: paginationOption.skip, + take: paginationOption.take, + where: searchString + ? { + OR: [ + { name: { contains: searchString, mode: 'insensitive' } }, + { id: { contains: searchString, mode: 'insensitive' } }, + ], + } + : undefined, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + }); + return fetchedTeams; + } + + /** + * Fetch list of all the Teams in the DB + * + * @returns number of teams in the org + */ + async getTeamsCount() { + const teamsCount = await this.prisma.team.count(); + return teamsCount; + } +} diff --git a/packages/hoppscotch-backend/src/types/AccessToken.ts b/packages/hoppscotch-backend/src/types/AccessToken.ts new file mode 100644 index 0000000..b467eaf --- /dev/null +++ b/packages/hoppscotch-backend/src/types/AccessToken.ts @@ -0,0 +1,7 @@ +export type AccessToken = { + id: string; + label: string; + createdOn: Date; + lastUsedOn: Date; + expiresOn: null | Date; +}; diff --git a/packages/hoppscotch-backend/src/types/AuthTokens.ts b/packages/hoppscotch-backend/src/types/AuthTokens.ts new file mode 100644 index 0000000..52bdb7e --- /dev/null +++ b/packages/hoppscotch-backend/src/types/AuthTokens.ts @@ -0,0 +1,26 @@ +/** + * @see https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims#registered-claims + **/ +export interface AccessTokenPayload { + iss: string; // iss:issuer + sub: string; // sub:subject + aud: [string]; // aud:audience + iat?: number; // iat:issued at time +} + +export interface RefreshTokenPayload { + iss: string; + sub: string; + aud: [string]; + iat?: number; +} + +export type AuthTokens = { + access_token: string; + refresh_token: string; +}; + +export enum AuthTokenType { + ACCESS_TOKEN = 'access_token', + REFRESH_TOKEN = 'refresh_token', +} diff --git a/packages/hoppscotch-backend/src/types/AuthUser.ts b/packages/hoppscotch-backend/src/types/AuthUser.ts new file mode 100644 index 0000000..8008e87 --- /dev/null +++ b/packages/hoppscotch-backend/src/types/AuthUser.ts @@ -0,0 +1,12 @@ +import { User } from 'src/generated/prisma/client'; + +export type AuthUser = User; + +export interface SSOProviderProfile { + provider: string; + id: string; +} + +export type IsAdmin = { + isAdmin: boolean; +}; diff --git a/packages/hoppscotch-backend/src/types/CollectionFolder.ts b/packages/hoppscotch-backend/src/types/CollectionFolder.ts new file mode 100644 index 0000000..e7e25aa --- /dev/null +++ b/packages/hoppscotch-backend/src/types/CollectionFolder.ts @@ -0,0 +1,34 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +// This class defines how data will be received from the app when we are importing Hoppscotch collections +export class CollectionFolder { + @ApiPropertyOptional({ + description: 'Unique identifier for the collection folder', + example: 'folder_12345', + }) + id?: string; + + @ApiProperty({ + description: 'List of subfolders', + type: () => [CollectionFolder], + }) + folders: CollectionFolder[]; + + @ApiProperty({ + description: 'List of requests in the collection folder', + type: [Object], + }) + requests: any[]; + + @ApiProperty({ + description: 'Name of the collection folder', + example: 'My Collection Folder', + }) + name: string; + + @ApiPropertyOptional({ + description: 'Additional data for the collection folder', + type: String, + }) + data?: string; +} diff --git a/packages/hoppscotch-backend/src/types/CollectionSearchNode.ts b/packages/hoppscotch-backend/src/types/CollectionSearchNode.ts new file mode 100644 index 0000000..77cf80d --- /dev/null +++ b/packages/hoppscotch-backend/src/types/CollectionSearchNode.ts @@ -0,0 +1,17 @@ +// Response type of results from the search query +export type CollectionSearchNode = { + /** Encodes the hierarchy of where the node is **/ + path: CollectionSearchNode[]; +} & ( + | { + type: 'request'; + title: string; + method: string; + id: string; + } + | { + type: 'collection'; + title: string; + id: string; + } +); diff --git a/packages/hoppscotch-backend/src/types/Email.ts b/packages/hoppscotch-backend/src/types/Email.ts new file mode 100644 index 0000000..fd4144a --- /dev/null +++ b/packages/hoppscotch-backend/src/types/Email.ts @@ -0,0 +1,17 @@ +import * as t from 'io-ts'; + +interface EmailBrand { + readonly Email: unique symbol; +} + +// The validation branded type for an email +export const EmailCodec = t.brand( + t.string, + (x): x is t.Branded => + /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + x, + ), + 'Email', +); + +export type Email = t.TypeOf; diff --git a/packages/hoppscotch-backend/src/types/InfraConfig.ts b/packages/hoppscotch-backend/src/types/InfraConfig.ts new file mode 100644 index 0000000..04876eb --- /dev/null +++ b/packages/hoppscotch-backend/src/types/InfraConfig.ts @@ -0,0 +1,64 @@ +export enum InfraConfigEnum { + ONBOARDING_COMPLETED = 'ONBOARDING_COMPLETED', + ONBOARDING_RECOVERY_TOKEN = 'ONBOARDING_RECOVERY_TOKEN', + + JWT_SECRET = 'JWT_SECRET', + SESSION_SECRET = 'SESSION_SECRET', + SESSION_COOKIE_NAME = 'SESSION_COOKIE_NAME', + TOKEN_SALT_COMPLEXITY = 'TOKEN_SALT_COMPLEXITY', + MAGIC_LINK_TOKEN_VALIDITY = 'MAGIC_LINK_TOKEN_VALIDITY', + REFRESH_TOKEN_VALIDITY = 'REFRESH_TOKEN_VALIDITY', + ACCESS_TOKEN_VALIDITY = 'ACCESS_TOKEN_VALIDITY', + ALLOW_SECURE_COOKIES = 'ALLOW_SECURE_COOKIES', + + RATE_LIMIT_TTL = 'RATE_LIMIT_TTL', + RATE_LIMIT_MAX = 'RATE_LIMIT_MAX', + + MAILER_SMTP_ENABLE = 'MAILER_SMTP_ENABLE', + MAILER_USE_CUSTOM_CONFIGS = 'MAILER_USE_CUSTOM_CONFIGS', + MAILER_SMTP_URL = 'MAILER_SMTP_URL', + MAILER_ADDRESS_FROM = 'MAILER_ADDRESS_FROM', + + MAILER_SMTP_HOST = 'MAILER_SMTP_HOST', + MAILER_SMTP_PORT = 'MAILER_SMTP_PORT', + MAILER_SMTP_SECURE = 'MAILER_SMTP_SECURE', + MAILER_SMTP_USER = 'MAILER_SMTP_USER', + MAILER_SMTP_PASSWORD = 'MAILER_SMTP_PASSWORD', + MAILER_TLS_REJECT_UNAUTHORIZED = 'MAILER_TLS_REJECT_UNAUTHORIZED', + MAILER_SMTP_IGNORE_TLS = 'MAILER_SMTP_IGNORE_TLS', + + MAILER_SMTP_AUTH_TYPE = 'MAILER_SMTP_AUTH_TYPE', + MAILER_SMTP_OAUTH2_USER = 'MAILER_SMTP_OAUTH2_USER', + MAILER_SMTP_OAUTH2_CLIENT_ID = 'MAILER_SMTP_OAUTH2_CLIENT_ID', + MAILER_SMTP_OAUTH2_CLIENT_SECRET = 'MAILER_SMTP_OAUTH2_CLIENT_SECRET', + MAILER_SMTP_OAUTH2_REFRESH_TOKEN = 'MAILER_SMTP_OAUTH2_REFRESH_TOKEN', + MAILER_SMTP_OAUTH2_ACCESS_URL = 'MAILER_SMTP_OAUTH2_ACCESS_URL', + + GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID', + GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET', + GOOGLE_CALLBACK_URL = 'GOOGLE_CALLBACK_URL', + GOOGLE_SCOPE = 'GOOGLE_SCOPE', + + GITHUB_CLIENT_ID = 'GITHUB_CLIENT_ID', + GITHUB_CLIENT_SECRET = 'GITHUB_CLIENT_SECRET', + GITHUB_CALLBACK_URL = 'GITHUB_CALLBACK_URL', + GITHUB_SCOPE = 'GITHUB_SCOPE', + + MICROSOFT_CLIENT_ID = 'MICROSOFT_CLIENT_ID', + MICROSOFT_CLIENT_SECRET = 'MICROSOFT_CLIENT_SECRET', + MICROSOFT_CALLBACK_URL = 'MICROSOFT_CALLBACK_URL', + MICROSOFT_SCOPE = 'MICROSOFT_SCOPE', + MICROSOFT_TENANT = 'MICROSOFT_TENANT', + + VITE_ALLOWED_AUTH_PROVIDERS = 'VITE_ALLOWED_AUTH_PROVIDERS', + + PROXY_APP_URL = 'PROXY_APP_URL', + + ALLOW_ANALYTICS_COLLECTION = 'ALLOW_ANALYTICS_COLLECTION', + ANALYTICS_USER_ID = 'ANALYTICS_USER_ID', + IS_FIRST_TIME_INFRA_SETUP = 'IS_FIRST_TIME_INFRA_SETUP', + + USER_HISTORY_STORE_ENABLED = 'USER_HISTORY_STORE_ENABLED', + + MOCK_SERVER_WILDCARD_DOMAIN = 'MOCK_SERVER_WILDCARD_DOMAIN', +} diff --git a/packages/hoppscotch-backend/src/types/Passwordless.ts b/packages/hoppscotch-backend/src/types/Passwordless.ts new file mode 100644 index 0000000..47117d9 --- /dev/null +++ b/packages/hoppscotch-backend/src/types/Passwordless.ts @@ -0,0 +1,3 @@ +export type DeviceIdentifierToken = { + deviceIdentifier: string; +}; diff --git a/packages/hoppscotch-backend/src/types/RESTError.ts b/packages/hoppscotch-backend/src/types/RESTError.ts new file mode 100644 index 0000000..4aa7d1b --- /dev/null +++ b/packages/hoppscotch-backend/src/types/RESTError.ts @@ -0,0 +1,10 @@ +import { HttpStatus } from '@nestjs/common'; + +/** + ** Custom interface to handle errors for REST modules such as Auth, Admin modules + ** Since its REST we need to return the HTTP status code along with the error message + */ +export type RESTError = { + message: string | Record; + statusCode: HttpStatus; +}; diff --git a/packages/hoppscotch-backend/src/types/RequestTypes.ts b/packages/hoppscotch-backend/src/types/RequestTypes.ts new file mode 100644 index 0000000..717db6a --- /dev/null +++ b/packages/hoppscotch-backend/src/types/RequestTypes.ts @@ -0,0 +1,4 @@ +export enum ReqType { + REST = 'REST', + GQL = 'GQL', +} diff --git a/packages/hoppscotch-backend/src/types/SortOptions.ts b/packages/hoppscotch-backend/src/types/SortOptions.ts new file mode 100644 index 0000000..9b1b0fb --- /dev/null +++ b/packages/hoppscotch-backend/src/types/SortOptions.ts @@ -0,0 +1,10 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum SortOptions { + TITLE_ASC = 'TITLE_ASC', + TITLE_DESC = 'TITLE_DESC', +} + +registerEnumType(SortOptions, { + name: 'SortOptions', +}); diff --git a/packages/hoppscotch-backend/src/types/WorkspaceTypes.ts b/packages/hoppscotch-backend/src/types/WorkspaceTypes.ts new file mode 100644 index 0000000..ea85b41 --- /dev/null +++ b/packages/hoppscotch-backend/src/types/WorkspaceTypes.ts @@ -0,0 +1,4 @@ +export enum WorkspaceType { + USER = 'USER', + TEAM = 'TEAM', +} diff --git a/packages/hoppscotch-backend/src/types/input-types.args.ts b/packages/hoppscotch-backend/src/types/input-types.args.ts new file mode 100644 index 0000000..226694a --- /dev/null +++ b/packages/hoppscotch-backend/src/types/input-types.args.ts @@ -0,0 +1,56 @@ +import { ArgsType, Field, ID, InputType } from '@nestjs/graphql'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Min } from 'class-validator'; + +@ArgsType() +@InputType() +export class PaginationArgs { + @Field(() => ID, { + nullable: true, + defaultValue: undefined, + description: 'Cursor for pagination, ID of the last item in the list', + }) + @IsString() + @IsOptional() + cursor: string; + + @Field({ + nullable: true, + defaultValue: 10, + description: 'Number of items to fetch', + }) + @IsInt() + @Min(1) + @IsOptional() + @Type(() => Number) + take: number = 10; +} + +@ArgsType() +@InputType() +export class OffsetPaginationArgs { + @IsOptional() + @IsInt() + @Min(0) + @Type(() => Number) + @ApiPropertyOptional() + @Field({ + nullable: true, + defaultValue: 0, + description: 'Number of items to skip', + }) + skip: number = 0; + + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + @ApiPropertyOptional() + @Field({ + nullable: true, + defaultValue: 10, + description: 'Number of items to fetch', + }) + take: number = 10; +} diff --git a/packages/hoppscotch-backend/src/user-collection/input-type.args.ts b/packages/hoppscotch-backend/src/user-collection/input-type.args.ts new file mode 100644 index 0000000..e93a383 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-collection/input-type.args.ts @@ -0,0 +1,172 @@ +import { Field, ID, ArgsType } from '@nestjs/graphql'; +import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { ReqType } from 'src/types/RequestTypes'; +import { PaginationArgs } from 'src/types/input-types.args'; + +@ArgsType() +export class CreateRootUserCollectionArgs { + @Field({ name: 'title', description: 'Title of the new user collection' }) + @IsString() + @IsNotEmpty() + title: string; + + @Field({ + name: 'data', + description: 'JSON string representing the collection data', + nullable: true, + }) + @IsString() + @IsOptional() + data: string; +} +@ArgsType() +export class CreateChildUserCollectionArgs { + @Field({ name: 'title', description: 'Title of the new user collection' }) + @IsString() + @IsNotEmpty() + title: string; + + @Field(() => ID, { + name: 'parentUserCollectionID', + description: 'ID of the parent to the new user collection', + }) + @IsString() + @IsOptional() + parentUserCollectionID: string; + + @Field({ + name: 'data', + description: 'JSON string representing the collection data', + nullable: true, + }) + @IsString() + @IsOptional() + data: string; +} + +@ArgsType() +export class GetUserChildCollectionArgs extends PaginationArgs { + @Field(() => ID, { + name: 'userCollectionID', + description: 'ID of the parent to the user collection', + }) + @IsString() + @IsNotEmpty() + userCollectionID: string; +} + +@ArgsType() +export class RenameUserCollectionsArgs { + @Field(() => ID, { + name: 'userCollectionID', + description: 'ID of the user collection', + }) + @IsString() + @IsNotEmpty() + userCollectionID: string; + + @Field({ + name: 'newTitle', + description: 'The updated title of the user collection', + }) + @IsString() + @IsNotEmpty() + newTitle: string; +} + +@ArgsType() +export class UpdateUserCollectionArgs { + @Field(() => ID, { + name: 'collectionID', + description: 'ID of collection being moved', + }) + @IsString() + @IsNotEmpty() + collectionID: string; + + @Field(() => ID, { + name: 'nextCollectionID', + nullable: true, + description: 'ID of collection being moved', + }) + @IsString() + @IsOptional() + nextCollectionID: string; +} + +@ArgsType() +export class MoveUserCollectionArgs { + @Field(() => ID, { + name: 'destCollectionID', + description: 'ID of the parent to the new collection', + nullable: true, + }) + @IsString() + @IsOptional() + destCollectionID: string; + + @Field(() => ID, { + name: 'userCollectionID', + description: 'ID of the collection', + }) + @IsString() + @IsNotEmpty() + userCollectionID: string; +} + +@ArgsType() +export class ImportUserCollectionsFromJSONArgs { + @Field({ + name: 'jsonString', + description: 'JSON string to import', + }) + @IsString() + @IsNotEmpty() + jsonString: string; + + @Field(() => ReqType, { + name: 'reqType', + description: 'Type of UserCollection', + }) + @IsEnum(ReqType) + reqType: ReqType; + + @Field(() => ID, { + name: 'parentCollectionID', + description: + 'ID to the collection to which to import into (null if to import into the root of the user)', + nullable: true, + }) + @IsString() + @IsOptional() + parentCollectionID?: string; +} + +@ArgsType() +export class UpdateUserCollectionsArgs { + @Field(() => ID, { + name: 'userCollectionID', + description: 'ID of the user collection', + }) + @IsString() + @IsNotEmpty() + userCollectionID: string; + + @Field({ + name: 'newTitle', + description: 'The updated title of the user collection', + nullable: true, + }) + @IsString() + @IsOptional() + newTitle: string; + + @Field({ + name: 'data', + description: 'JSON string representing the collection data', + nullable: true, + }) + @IsString() + @IsOptional() + data: string; +} diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.module.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.module.ts new file mode 100644 index 0000000..ee075fe --- /dev/null +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { UserCollectionService } from './user-collection.service'; +import { UserCollectionResolver } from './user-collection.resolver'; +import { UserModule } from 'src/user/user.module'; + +@Module({ + imports: [UserModule], + providers: [UserCollectionService, UserCollectionResolver], + exports: [UserCollectionService], +}) +export class UserCollectionModule {} diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.resolver.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.resolver.ts new file mode 100644 index 0000000..0bc8d92 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.resolver.ts @@ -0,0 +1,510 @@ +import { UseGuards } from '@nestjs/common'; +import { + Resolver, + Mutation, + Args, + ID, + Query, + ResolveField, + Parent, + Subscription, +} from '@nestjs/graphql'; +import { GqlUser } from 'src/decorators/gql-user.decorator'; +import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { AuthUser } from 'src/types/AuthUser'; +import { UserCollectionService } from './user-collection.service'; +import { + UserCollection, + UserCollectionDuplicatedData, + UserCollectionExportJSONData, + UserCollectionRemovedData, + UserCollectionReorderData, +} from './user-collections.model'; +import { throwErr } from 'src/utils'; +import { User } from 'src/user/user.model'; +import { PaginationArgs } from 'src/types/input-types.args'; +import { + CreateChildUserCollectionArgs, + CreateRootUserCollectionArgs, + ImportUserCollectionsFromJSONArgs, + MoveUserCollectionArgs, + RenameUserCollectionsArgs, + UpdateUserCollectionArgs, + UpdateUserCollectionsArgs, +} from './input-type.args'; +import { ReqType } from 'src/types/RequestTypes'; +import * as E from 'fp-ts/Either'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { SkipThrottle } from '@nestjs/throttler'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => UserCollection) +export class UserCollectionResolver { + constructor( + private readonly userCollectionService: UserCollectionService, + private readonly pubSub: PubSubService, + ) {} + + // Field Resolvers + @ResolveField(() => User, { + description: 'User the collection belongs to', + }) + async user(@GqlUser() user: AuthUser) { + return user; + } + + @ResolveField(() => UserCollection, { + description: 'Parent user collection (null if root)', + nullable: true, + }) + async parent(@Parent() collection: UserCollection) { + return this.userCollectionService.getParentOfUserCollection(collection.id); + } + + @ResolveField(() => [UserCollection], { + description: 'List of children REST user collection', + complexity: 3, + }) + childrenREST( + @Parent() collection: UserCollection, + @Args() args: PaginationArgs, + ) { + return this.userCollectionService.getChildrenOfUserCollection( + collection.id, + args.cursor, + args.take, + ReqType.REST, + ); + } + + @ResolveField(() => [UserCollection], { + description: 'List of children GraphQL user collection', + complexity: 3, + }) + childrenGQL( + @Parent() collection: UserCollection, + @Args() args: PaginationArgs, + ) { + return this.userCollectionService.getChildrenOfUserCollection( + collection.id, + args.cursor, + args.take, + ReqType.GQL, + ); + } + + // Queries + @Query(() => [UserCollection], { + description: 'Get the root REST user collections for a user', + }) + @UseGuards(GqlAuthGuard) + rootRESTUserCollections( + @GqlUser() user: AuthUser, + @Args() args: PaginationArgs, + ) { + return this.userCollectionService.getUserRootCollections( + user, + args.cursor, + args.take, + ReqType.REST, + ); + } + + @Query(() => [UserCollection], { + description: 'Get the root GraphQL user collections for a user', + }) + @UseGuards(GqlAuthGuard) + rootGQLUserCollections( + @GqlUser() user: AuthUser, + @Args() args: PaginationArgs, + ) { + return this.userCollectionService.getUserRootCollections( + user, + args.cursor, + args.take, + ReqType.GQL, + ); + } + + @Query(() => UserCollection, { + description: 'Get user collection with ID', + }) + @UseGuards(GqlAuthGuard) + async userCollection( + @GqlUser() user: AuthUser, + @Args({ + type: () => ID, + name: 'userCollectionID', + description: 'ID of the user collection', + }) + userCollectionID: string, + ) { + const userCollection = await this.userCollectionService.getUserCollection( + userCollectionID, + user.uid, + ); + + if (E.isLeft(userCollection)) throwErr(userCollection.left); + return { + ...userCollection.right, + userID: userCollection.right.userUid, + data: !userCollection.right.data + ? null + : JSON.stringify(userCollection.right.data), + }; + } + + @Query(() => UserCollectionExportJSONData, { + description: + 'Returns the JSON string giving the collections and their contents of a user', + }) + @UseGuards(GqlAuthGuard) + async exportUserCollectionsToJSON( + @GqlUser() user: AuthUser, + @Args({ + type: () => ID, + name: 'collectionID', + description: 'ID of the user collection', + nullable: true, + defaultValue: null, + }) + collectionID: string, + @Args({ + type: () => ReqType, + name: 'collectionType', + description: 'Type of the user collection', + }) + collectionType: ReqType, + ) { + const jsonString = + await this.userCollectionService.exportUserCollectionsToJSON( + user.uid, + collectionID, + collectionType, + ); + + if (E.isLeft(jsonString)) throwErr(jsonString.left as string); + return jsonString.right; + } + + @Query(() => String, { + description: + 'Returns a JSON string of all the contents of a User Collection', + }) + @UseGuards(GqlAuthGuard) + async exportUserCollectionToJSON( + @GqlUser() user: AuthUser, + @Args({ + type: () => ID, + name: 'collectionID', + description: 'ID of the user collection', + }) + collectionID: string, + ) { + const jsonString = + await this.userCollectionService.exportUserCollectionToJSONObject( + user.uid, + collectionID, + ); + + if (E.isLeft(jsonString)) throwErr(jsonString.left as string); + return JSON.stringify(jsonString.right); + } + + // Mutations + @Mutation(() => UserCollection, { + description: 'Creates root REST user collection(no parent user collection)', + }) + @UseGuards(GqlAuthGuard) + async createRESTRootUserCollection( + @GqlUser() user: AuthUser, + @Args() args: CreateRootUserCollectionArgs, + ) { + const userCollection = + await this.userCollectionService.createUserCollection( + user, + args.title, + args.data, + null, + ReqType.REST, + ); + + if (E.isLeft(userCollection)) throwErr(userCollection.left); + return userCollection.right; + } + + @Mutation(() => UserCollection, { + description: + 'Creates root GraphQL user collection(no parent user collection)', + }) + @UseGuards(GqlAuthGuard) + async createGQLRootUserCollection( + @GqlUser() user: AuthUser, + @Args() args: CreateRootUserCollectionArgs, + ) { + const userCollection = + await this.userCollectionService.createUserCollection( + user, + args.title, + args.data, + null, + ReqType.GQL, + ); + + if (E.isLeft(userCollection)) throwErr(userCollection.left); + return userCollection.right; + } + + @Mutation(() => UserCollection, { + description: 'Creates a new child GraphQL user collection', + }) + @UseGuards(GqlAuthGuard) + async createGQLChildUserCollection( + @GqlUser() user: AuthUser, + @Args() args: CreateChildUserCollectionArgs, + ) { + const userCollection = + await this.userCollectionService.createUserCollection( + user, + args.title, + args.data, + args.parentUserCollectionID, + ReqType.GQL, + ); + + if (E.isLeft(userCollection)) throwErr(userCollection.left); + return userCollection.right; + } + + @Mutation(() => UserCollection, { + description: 'Creates a new child REST user collection', + }) + @UseGuards(GqlAuthGuard) + async createRESTChildUserCollection( + @GqlUser() user: AuthUser, + @Args() args: CreateChildUserCollectionArgs, + ) { + const userCollection = + await this.userCollectionService.createUserCollection( + user, + args.title, + args.data, + args.parentUserCollectionID, + ReqType.REST, + ); + + if (E.isLeft(userCollection)) throwErr(userCollection.left); + return userCollection.right; + } + + @Mutation(() => UserCollection, { + description: 'Rename a user collection', + }) + @UseGuards(GqlAuthGuard) + async renameUserCollection( + @GqlUser() user: AuthUser, + @Args() args: RenameUserCollectionsArgs, + ) { + const updatedUserCollection = + await this.userCollectionService.renameUserCollection( + args.newTitle, + args.userCollectionID, + user.uid, + ); + + if (E.isLeft(updatedUserCollection)) throwErr(updatedUserCollection.left); + return updatedUserCollection.right; + } + + @Mutation(() => Boolean, { + description: 'Delete a user collection', + }) + @UseGuards(GqlAuthGuard) + async deleteUserCollection( + @Args({ + name: 'userCollectionID', + description: 'ID of the user collection', + type: () => ID, + }) + userCollectionID: string, + @GqlUser() user: AuthUser, + ) { + const result = await this.userCollectionService.deleteUserCollection( + userCollectionID, + user.uid, + ); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } + + @Mutation(() => UserCollection, { + description: 'Move user collection into new parent or root', + }) + @UseGuards(GqlAuthGuard) + async moveUserCollection( + @Args() args: MoveUserCollectionArgs, + @GqlUser() user: AuthUser, + ) { + const res = await this.userCollectionService.moveUserCollection( + args.userCollectionID, + args.destCollectionID, + user.uid, + ); + if (E.isLeft(res)) { + throwErr(res.left); + } + return res.right; + } + + @Mutation(() => Boolean, { + description: + 'Update the order of UserCollections inside parent collection or in root', + }) + @UseGuards(GqlAuthGuard) + async updateUserCollectionOrder( + @Args() args: UpdateUserCollectionArgs, + @GqlUser() user: AuthUser, + ) { + const res = await this.userCollectionService.updateUserCollectionOrder( + args.collectionID, + args.nextCollectionID, + user.uid, + ); + if (E.isLeft(res)) { + throwErr(res.left); + } + return res.right; + } + + @Mutation(() => UserCollectionExportJSONData, { + description: 'Import collections from JSON string to the specified Team', + }) + @UseGuards(GqlAuthGuard) + async importUserCollectionsFromJSON( + @Args() args: ImportUserCollectionsFromJSONArgs, + @GqlUser() user: AuthUser, + ) { + const importedCollection = + await this.userCollectionService.importCollectionsFromJSON( + args.jsonString, + user.uid, + args.parentCollectionID, + args.reqType, + ); + if (E.isLeft(importedCollection)) throwErr(importedCollection.left); + return importedCollection.right; + } + + @Mutation(() => UserCollection, { + description: 'Update a UserCollection', + }) + @UseGuards(GqlAuthGuard) + async updateUserCollection( + @GqlUser() user: AuthUser, + @Args() args: UpdateUserCollectionsArgs, + ) { + const updatedUserCollection = + await this.userCollectionService.updateUserCollection( + args.newTitle, + args.data, + args.userCollectionID, + user.uid, + ); + + if (E.isLeft(updatedUserCollection)) throwErr(updatedUserCollection.left); + return updatedUserCollection.right; + } + + @Mutation(() => UserCollectionExportJSONData, { + description: 'Duplicate a User Collection', + }) + @UseGuards(GqlAuthGuard) + async duplicateUserCollection( + @GqlUser() user: AuthUser, + @Args({ + name: 'collectionID', + description: 'ID of the collection', + }) + collectionID: string, + @Args({ + name: 'reqType', + description: 'Type of UserCollection', + type: () => ReqType, + }) + reqType: ReqType, + ) { + const duplicatedUserCollection = + await this.userCollectionService.duplicateUserCollection( + collectionID, + user.uid, + reqType, + ); + + if (E.isLeft(duplicatedUserCollection)) + throwErr(duplicatedUserCollection.left); + return duplicatedUserCollection.right; + } + + // Subscriptions + @Subscription(() => UserCollection, { + description: 'Listen for User Collection Creation', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userCollectionCreated(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_coll/${user.uid}/created`); + } + + @Subscription(() => UserCollection, { + description: 'Listen to when a User Collection has been updated.', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userCollectionUpdated(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_coll/${user.uid}/updated`); + } + + @Subscription(() => UserCollectionRemovedData, { + description: 'Listen to when a User Collection has been deleted', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userCollectionRemoved(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_coll/${user.uid}/deleted`); + } + + @Subscription(() => UserCollection, { + description: 'Listen to when a User Collection has been moved', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userCollectionMoved(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_coll/${user.uid}/moved`); + } + + @Subscription(() => UserCollectionReorderData, { + description: 'Listen to when a User Collections position has changed', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userCollectionOrderUpdated(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_coll/${user.uid}/order_updated`); + } + + @Subscription(() => UserCollectionDuplicatedData, { + description: 'Listen to when a User Collection has been duplicated', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userCollectionDuplicated(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_coll/${user.uid}/duplicated`); + } +} diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts new file mode 100644 index 0000000..e826800 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts @@ -0,0 +1,2538 @@ +import { UserCollection as DBUserCollection } from 'src/generated/prisma/client'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { + USER_COLL_DEST_SAME, + USER_COLL_IS_PARENT_COLL, + USER_COLL_NOT_FOUND, + USER_COLL_NOT_SAME_TYPE, + USER_COLL_NOT_SAME_USER, + USER_COLL_REORDERING_FAILED, + USER_COLL_SAME_NEXT_COLL, + USER_COLL_SHORT_TITLE, + USER_COLL_ALREADY_ROOT, + USER_NOT_OWNER, + USER_COLL_DATA_INVALID, + USER_COLLECTION_CREATION_FAILED, +} from 'src/errors'; +import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { AuthUser } from 'src/types/AuthUser'; +import { ReqType } from 'src/types/RequestTypes'; +import { UserCollectionService } from './user-collection.service'; +import { UserCollection } from './user-collections.model'; + +const mockPrisma = mockDeep(); +const mockPubSub = mockDeep(); + +const userCollectionService = new UserCollectionService(mockPrisma, mockPubSub); + +const currentTime = new Date(); + +const user: AuthUser = { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, + currentGQLSession: {}, + currentRESTSession: {}, +}; + +const rootRESTUserCollection: DBUserCollection = { + id: '123', + orderIndex: 1, + parentID: null, + title: 'Root Collection 1', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, +}; + +const rootRESTUserCollectionCasted: UserCollection = { + id: '123', + parentID: null, + userID: user.uid, + title: 'Root Collection 1', + type: ReqType.REST, + data: JSON.stringify(rootRESTUserCollection.data), +}; + +const rootGQLUserCollection: DBUserCollection = { + id: '123', + orderIndex: 1, + parentID: null, + title: 'Root Collection 1', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, +}; + +const rootGQLUserCollectionCasted: UserCollection = { + id: '123', + parentID: null, + title: 'Root Collection 1', + userID: user.uid, + type: ReqType.GQL, + data: JSON.stringify(rootGQLUserCollection.data), +}; + +const rootRESTUserCollection_2: DBUserCollection = { + id: '4gf', + orderIndex: 2, + parentID: null, + title: 'Root Collection 2', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, +}; + +const childRESTUserCollection: DBUserCollection = { + id: '234', + orderIndex: 1, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 1', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, +}; + +const childRESTUserCollectionCasted: UserCollection = { + id: '234', + parentID: rootRESTUserCollection.id, + title: 'Child Collection 1', + userID: user.uid, + type: ReqType.REST, + data: JSON.stringify({}), +}; + +const childGQLUserCollection: DBUserCollection = { + id: '234', + orderIndex: 1, + parentID: rootGQLUserCollection.id, + title: 'Child Collection 1', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, +}; + +const childGQLUserCollectionCasted: UserCollection = { + id: '234', + parentID: rootRESTUserCollection.id, + title: 'Child Collection 1', + userID: user.uid, + type: ReqType.GQL, + data: JSON.stringify({}), +}; + +const childRESTUserCollection_2: DBUserCollection = { + id: '0kn', + orderIndex: 2, + parentID: rootRESTUserCollection_2.id, + title: 'Child Collection 2', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, +}; + +const childRESTUserCollection_2Casted: UserCollection = { + id: '0kn', + parentID: rootRESTUserCollection_2.id, + title: 'Child Collection 2', + userID: user.uid, + type: ReqType.REST, + data: JSON.stringify({}), +}; + +const childRESTUserCollectionList: DBUserCollection[] = [ + { + id: '234', + orderIndex: 1, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 1', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, + { + id: '345', + orderIndex: 2, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 2', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, + { + id: '456', + orderIndex: 3, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 3', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, + { + id: '567', + orderIndex: 4, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 4', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, + { + id: '678', + orderIndex: 5, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 5', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, +]; + +const childRESTUserCollectionListCasted: UserCollection[] = [ + { + id: '234', + parentID: rootRESTUserCollection.id, + title: 'Child Collection 1', + userID: user.uid, + type: ReqType.REST, + data: JSON.stringify({}), + }, + { + id: '345', + parentID: rootRESTUserCollection.id, + title: 'Child Collection 2', + userID: user.uid, + type: ReqType.REST, + data: JSON.stringify({}), + }, + { + id: '456', + parentID: rootRESTUserCollection.id, + title: 'Child Collection 3', + userID: user.uid, + type: ReqType.REST, + data: JSON.stringify({}), + }, + { + id: '567', + parentID: rootRESTUserCollection.id, + title: 'Child Collection 4', + userID: user.uid, + type: ReqType.REST, + data: JSON.stringify({}), + }, + { + id: '678', + parentID: rootRESTUserCollection.id, + title: 'Child Collection 5', + userID: user.uid, + type: ReqType.REST, + data: JSON.stringify({}), + }, +]; + +const childGQLUserCollectionList: DBUserCollection[] = [ + { + id: '234', + orderIndex: 1, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 1', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, + { + id: '345', + orderIndex: 2, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 2', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, + { + id: '456', + orderIndex: 3, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 3', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, + { + id: '567', + orderIndex: 4, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 4', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, + { + id: '678', + orderIndex: 5, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 5', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, +]; + +const childGQLUserCollectionListCasted: UserCollection[] = [ + { + id: '234', + parentID: rootRESTUserCollection.id, + title: 'Child Collection 1', + userID: user.uid, + type: ReqType.GQL, + data: JSON.stringify({}), + }, + { + id: '345', + parentID: rootRESTUserCollection.id, + title: 'Child Collection 2', + userID: user.uid, + type: ReqType.GQL, + data: JSON.stringify({}), + }, + { + id: '456', + parentID: rootRESTUserCollection.id, + title: 'Child Collection 3', + userID: user.uid, + type: ReqType.GQL, + data: JSON.stringify({}), + }, + { + id: '567', + parentID: rootRESTUserCollection.id, + title: 'Child Collection 4', + userID: user.uid, + type: ReqType.GQL, + data: JSON.stringify({}), + }, + { + id: '678', + parentID: rootRESTUserCollection.id, + title: 'Child Collection 5', + userID: user.uid, + type: ReqType.GQL, + data: JSON.stringify({}), + }, +]; + +const rootRESTUserCollectionList: DBUserCollection[] = [ + { + id: '123', + orderIndex: 1, + parentID: null, + title: 'Root Collection 1', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, + { + id: '234', + orderIndex: 2, + parentID: null, + title: 'Root Collection 2', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, + { + id: '345', + orderIndex: 3, + parentID: null, + title: 'Root Collection 3', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, + { + id: '456', + orderIndex: 4, + parentID: null, + title: 'Root Collection 4', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, + { + id: '567', + orderIndex: 5, + parentID: null, + title: 'Root Collection 5', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, +]; + +const rootRESTUserCollectionListCasted: UserCollection[] = [ + { + id: '123', + parentID: null, + title: 'Root Collection 1', + userID: user.uid, + type: ReqType.REST, + data: JSON.stringify({}), + }, + { + id: '234', + parentID: null, + title: 'Root Collection 2', + userID: user.uid, + type: ReqType.REST, + data: JSON.stringify({}), + }, + { + id: '345', + parentID: null, + title: 'Root Collection 3', + userID: user.uid, + type: ReqType.REST, + data: JSON.stringify({}), + }, + { + id: '456', + parentID: null, + title: 'Root Collection 4', + userID: user.uid, + type: ReqType.REST, + data: JSON.stringify({}), + }, + { + id: '567', + parentID: null, + title: 'Root Collection 5', + userID: user.uid, + type: ReqType.REST, + data: JSON.stringify({}), + }, +]; + +const rootGQLUserCollectionList: DBUserCollection[] = [ + { + id: '123', + orderIndex: 1, + parentID: null, + title: 'Root Collection 1', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, + { + id: '234', + orderIndex: 2, + parentID: null, + title: 'Root Collection 2', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, + { + id: '345', + orderIndex: 3, + parentID: null, + title: 'Root Collection 3', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, + { + id: '456', + orderIndex: 4, + parentID: null, + title: 'Root Collection 4', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, + { + id: '567', + orderIndex: 5, + parentID: null, + title: 'Root Collection 5', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }, +]; + +const rootGQLUserCollectionListCasted: UserCollection[] = [ + { + id: '123', + parentID: null, + title: 'Root Collection 1', + userID: user.uid, + type: ReqType.GQL, + data: JSON.stringify({}), + }, + { + id: '234', + parentID: null, + title: 'Root Collection 2', + userID: user.uid, + type: ReqType.GQL, + data: JSON.stringify({}), + }, + { + id: '345', + parentID: null, + title: 'Root Collection 3', + userID: user.uid, + type: ReqType.GQL, + data: JSON.stringify({}), + }, + { + id: '456', + parentID: null, + title: 'Root Collection 4', + userID: user.uid, + type: ReqType.GQL, + data: JSON.stringify({}), + }, + { + id: '567', + parentID: null, + title: 'Root Collection 5', + userID: user.uid, + type: ReqType.GQL, + data: JSON.stringify({}), + }, +]; + +beforeEach(() => { + mockReset(mockPrisma); + mockPubSub.publish.mockClear(); +}); + +describe('getParentOfUserCollection', () => { + test('should return a user-collection successfully with valid collectionID', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + ...childRESTUserCollection, + parent: rootRESTUserCollection, + } as any); + + const result = await userCollectionService.getParentOfUserCollection( + childRESTUserCollection.id, + ); + expect(result).toEqual(rootRESTUserCollectionCasted); + }); + test('should return null with invalid collectionID', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce( + childRESTUserCollection, + ); + + const result = + await userCollectionService.getParentOfUserCollection('invalidId'); + //TODO: check it not null + expect(result).toEqual(null); + }); +}); + +describe('getChildrenOfUserCollection', () => { + test('should return a list of paginated child REST user-collections with valid collectionID', async () => { + mockPrisma.userCollection.findMany.mockResolvedValueOnce( + childRESTUserCollectionList, + ); + + const result = await userCollectionService.getChildrenOfUserCollection( + rootRESTUserCollection.id, + null, + 10, + ReqType.REST, + ); + expect(result).toEqual(childRESTUserCollectionListCasted); + }); + test('should return a list of paginated child GQL user-collections with valid collectionID', async () => { + mockPrisma.userCollection.findMany.mockResolvedValueOnce( + childGQLUserCollectionList, + ); + + const result = await userCollectionService.getChildrenOfUserCollection( + rootGQLUserCollection.id, + null, + 10, + ReqType.REST, + ); + expect(result).toEqual(childGQLUserCollectionListCasted); + }); + test('should return a empty array with a invalid REST collectionID', async () => { + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + + const result = await userCollectionService.getChildrenOfUserCollection( + 'invalidID', + null, + 10, + ReqType.REST, + ); + expect(result).toEqual([]); + }); + test('should return a empty array with a invalid GQL collectionID', async () => { + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + + const result = await userCollectionService.getChildrenOfUserCollection( + 'invalidID', + null, + 10, + ReqType.GQL, + ); + expect(result).toEqual([]); + }); +}); + +describe('getUserCollection', () => { + test('should return a user-collection with valid collectionID', async () => { + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + const result = await userCollectionService.getUserCollection( + rootRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualRight(rootRESTUserCollection); + }); + test('should throw USER_COLL_NOT_FOUND when collectionID is invalid', async () => { + mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValue( + 'NotFoundError', + ); + + const result = await userCollectionService.getUserCollection( + '123', + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + test('should throw USER_COLL_NOT_FOUND when collectionID belongs to a different user', async () => { + mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + const result = await userCollectionService.getUserCollection( + rootRESTUserCollection.id, + 'another-user', + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); +}); + +describe('getUserRootCollections', () => { + test('should return a list of paginated root REST user-collections with valid collectionID', async () => { + mockPrisma.userCollection.findMany.mockResolvedValueOnce( + rootRESTUserCollectionList, + ); + + const result = await userCollectionService.getUserRootCollections( + user, + null, + 10, + ReqType.REST, + ); + expect(result).toEqual(rootRESTUserCollectionListCasted); + }); + test('should return a list of paginated root GQL user-collections with valid collectionID', async () => { + mockPrisma.userCollection.findMany.mockResolvedValueOnce( + rootGQLUserCollectionList, + ); + + const result = await userCollectionService.getUserRootCollections( + user, + null, + 10, + ReqType.GQL, + ); + expect(result).toEqual(rootGQLUserCollectionListCasted); + }); + test('should return a empty array with a invalid REST collectionID', async () => { + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + + const result = await userCollectionService.getUserRootCollections( + { ...user, uid: 'invalidID' }, + null, + 10, + ReqType.REST, + ); + expect(result).toEqual([]); + }); + test('should return a empty array with a invalid GQL collectionID', async () => { + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + + const result = await userCollectionService.getUserRootCollections( + { ...user, uid: 'invalidID' }, + null, + 10, + ReqType.GQL, + ); + expect(result).toEqual([]); + }); +}); + +describe('createUserCollection', () => { + test('should throw USER_COLL_SHORT_TITLE when title is an empty string', async () => { + const result = await userCollectionService.createUserCollection( + user, + '', + JSON.stringify(rootRESTUserCollection.data), + rootRESTUserCollection.id, + ReqType.REST, + ); + expect(result).toEqualLeft(USER_COLL_SHORT_TITLE); + }); + + test('should throw USER_COLLECTION_CREATION_FAILED when user is not the owner of the parent collection', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + jest + .spyOn(userCollectionService, 'getUserCollection') + .mockResolvedValueOnce(E.left(USER_COLL_NOT_FOUND)); + + const result = await userCollectionService.createUserCollection( + user, + rootRESTUserCollection.title, + JSON.stringify(rootRESTUserCollection.data), + rootRESTUserCollection.id, + ReqType.REST, + ); + expect(result).toEqualLeft(USER_COLLECTION_CREATION_FAILED); + }); + + test('should successfully create a new root REST user-collection with valid inputs', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.userCollection.create.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + const result = await userCollectionService.createUserCollection( + user, + rootRESTUserCollection.title, + JSON.stringify(rootRESTUserCollection.data), + null, + ReqType.REST, + ); + expect(result).toEqualRight(rootRESTUserCollectionCasted); + }); + + test('should successfully create a new root GQL user-collection with valid inputs', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.userCollection.create.mockResolvedValueOnce( + rootGQLUserCollection, + ); + + const result = await userCollectionService.createUserCollection( + user, + rootGQLUserCollection.title, + JSON.stringify(rootGQLUserCollection.data), + null, + ReqType.GQL, + ); + expect(result).toEqualRight(rootGQLUserCollectionCasted); + }); + + test('should successfully create a new child REST user-collection with valid inputs', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + jest + .spyOn(userCollectionService, 'getUserCollection') + .mockResolvedValueOnce(E.right(rootRESTUserCollection)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.userCollection.create.mockResolvedValueOnce( + childRESTUserCollection, + ); + + const result = await userCollectionService.createUserCollection( + user, + childRESTUserCollection.title, + JSON.stringify(childRESTUserCollection.data), + childRESTUserCollection.parentID, + ReqType.REST, + ); + expect(result).toEqualRight(childRESTUserCollectionCasted); + }); + + test('should successfully create a new child GQL user-collection with valid inputs', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + jest + .spyOn(userCollectionService, 'getUserCollection') + .mockResolvedValueOnce(E.right(rootGQLUserCollection)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.userCollection.create.mockResolvedValueOnce( + childGQLUserCollection, + ); + + const result = await userCollectionService.createUserCollection( + user, + childGQLUserCollection.title, + JSON.stringify(childGQLUserCollection.data), + childGQLUserCollection.parentID, + ReqType.GQL, + ); + expect(result).toEqualRight(childGQLUserCollectionCasted); + }); + + test('should send pubsub message to "user_coll//created" if child REST user-collection is created successfully', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + jest + .spyOn(userCollectionService, 'getUserCollection') + .mockResolvedValueOnce(E.right(rootRESTUserCollection)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.userCollection.create.mockResolvedValueOnce( + childRESTUserCollection, + ); + + await userCollectionService.createUserCollection( + user, + childRESTUserCollection.title, + JSON.stringify(childRESTUserCollection.data), + childRESTUserCollection.parentID, + ReqType.REST, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/created`, + childRESTUserCollectionCasted, + ); + }); + + test('should send pubsub message to "user_coll//created" if child GQL user-collection is created successfully', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + jest + .spyOn(userCollectionService, 'getUserCollection') + .mockResolvedValueOnce(E.right(rootGQLUserCollection)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.userCollection.create.mockResolvedValueOnce( + childGQLUserCollection, + ); + + await userCollectionService.createUserCollection( + user, + childGQLUserCollection.title, + JSON.stringify(childGQLUserCollection.data), + childGQLUserCollection.parentID, + ReqType.GQL, + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/created`, + childGQLUserCollectionCasted, + ); + }); + + test('should send pubsub message to "user_coll//created" if REST root user-collection is created successfully', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.userCollection.create.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + await userCollectionService.createUserCollection( + user, + rootRESTUserCollection.title, + JSON.stringify(rootRESTUserCollection.data), + null, + ReqType.REST, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/created`, + rootRESTUserCollectionCasted, + ); + }); + + test('should send pubsub message to "user_coll//created" if GQL root user-collection is created successfully', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.userCollection.create.mockResolvedValueOnce( + rootGQLUserCollection, + ); + + await userCollectionService.createUserCollection( + user, + rootGQLUserCollection.title, + JSON.stringify(rootGQLUserCollection.data), + null, + ReqType.GQL, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/created`, + rootGQLUserCollectionCasted, + ); + }); +}); + +describe('renameUserCollection', () => { + test('should throw USER_COLL_SHORT_TITLE when title is empty', async () => { + const result = await userCollectionService.renameUserCollection( + '', + rootRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_SHORT_TITLE); + }); + test('should throw USER_NOT_OWNER when user is not the owner of the collection', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await userCollectionService.renameUserCollection( + 'validTitle', + rootRESTUserCollection.id, + 'op09', + ); + expect(result).toEqualLeft(USER_NOT_OWNER); + }); + test('should successfully update a user-collection with valid inputs', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...rootRESTUserCollection, + title: 'NewTitle', + }); + + const result = await userCollectionService.renameUserCollection( + 'NewTitle', + rootRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualRight({ + ...rootRESTUserCollectionCasted, + title: 'NewTitle', + }); + }); + test('should throw USER_COLL_NOT_FOUND when userCollectionID is invalid', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + mockPrisma.userCollection.update.mockRejectedValueOnce('RecordNotFound'); + + const result = await userCollectionService.renameUserCollection( + 'NewTitle', + 'invalidID', + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + test('should send pubsub message to "user_coll//updated" if user-collection title is updated successfully', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...rootRESTUserCollection, + title: 'NewTitle', + }); + + await userCollectionService.renameUserCollection( + 'NewTitle', + rootRESTUserCollection.id, + user.uid, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/updated`, + { + ...rootRESTUserCollectionCasted, + title: 'NewTitle', + }, + ); + }); +}); + +describe('deleteUserCollection', () => { + test('should successfully delete a user-collection with valid inputs', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + // deleteCollectionData + // deleteCollectionData --> FindMany query 1st time + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + // deleteCollectionData --> FindMany query 2nd time + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + // deleteCollectionData --> DeleteMany query + mockPrisma.userRequest.deleteMany.mockResolvedValueOnce({ count: 0 }); + // deleteCollectionData --> updateOrderIndex + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 0 }); + // deleteCollectionData --> removeUserCollection + mockPrisma.userCollection.delete.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + const result = await userCollectionService.deleteUserCollection( + rootRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualRight(true); + }); + test('should throw USER_COLL_NOT_FOUND when collectionID is invalid ', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + const result = await userCollectionService.deleteUserCollection( + rootRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + test('should throw USER_COLL_NOT_FOUND when collectionID belongs to a different user', async () => { + // getUserCollection (userUid is now part of the where clause, so it rejects for wrong user) + mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + const result = await userCollectionService.deleteUserCollection( + rootRESTUserCollection.id, + 'op09', + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + test('should throw USER_COLL_REORDERING_FAILED when removeCollectionAndUpdateSiblingsOrderIndex fails', async () => { + jest + .spyOn(userCollectionService, 'getUserCollection') + .mockResolvedValueOnce(E.right(rootRESTUserCollection)); + jest + .spyOn( + userCollectionService as any, + 'removeCollectionAndUpdateSiblingsOrderIndex', + ) + .mockResolvedValueOnce(E.left(USER_COLL_REORDERING_FAILED)); + + const result = await userCollectionService.deleteUserCollection( + rootRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_REORDERING_FAILED); + }); + test('should send pubsub message to "user_coll//deleted" if user-collection is deleted successfully', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + // deleteCollectionData + // deleteCollectionData --> FindMany query 1st time + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + // deleteCollectionData --> FindMany query 2nd time + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + // deleteCollectionData --> DeleteMany query + mockPrisma.userRequest.deleteMany.mockResolvedValueOnce({ count: 0 }); + // deleteCollectionData --> updateOrderIndex + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 0 }); + // deleteCollectionData --> removeUserCollection + mockPrisma.userCollection.delete.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + await userCollectionService.deleteUserCollection( + rootRESTUserCollection.id, + user.uid, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/deleted`, + { + id: rootRESTUserCollection.id, + type: rootRESTUserCollection.type, + }, + ); + }); +}); + +describe('moveUserCollection', () => { + test('should throw USER_COLL_NOT_FOUND if userCollectionID is invalid', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await userCollectionService.moveUserCollection( + '234', + '009', + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + + test('should throw USER_COLL_NOT_FOUND if user is not owner of collection', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + // getUserCollection (userUid is now part of the where clause, so it rejects for wrong user) + mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await userCollectionService.moveUserCollection( + '234', + '009', + 'op09', + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + + test('should throw USER_COLL_DEST_SAME if userCollectionID and destCollectionID is the same', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + const result = await userCollectionService.moveUserCollection( + rootRESTUserCollection.id, + rootRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_DEST_SAME); + }); + + test('should throw USER_COLL_NOT_FOUND if destCollectionID is invalid', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + // getUserCollection for destCollection + mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await userCollectionService.moveUserCollection( + 'invalidID', + rootRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + + test('should throw USER_COLL_NOT_SAME_TYPE if userCollectionID and destCollectionID are not the same collection type', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + // getUserCollection for destCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + childGQLUserCollection, + ); + + const result = await userCollectionService.moveUserCollection( + rootRESTUserCollection.id, + childGQLUserCollection.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_SAME_TYPE); + }); + + test('should throw USER_COLL_NOT_FOUND if destCollectionID belongs to a different user', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + // getUserCollection for source collection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + // getUserCollection for destCollection (userUid is now part of the where clause, so it rejects for wrong user) + mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await userCollectionService.moveUserCollection( + rootRESTUserCollection.id, + childRESTUserCollection_2.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + + test('should throw USER_COLL_IS_PARENT_COLL if userCollectionID is parent of destCollectionID ', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + // getUserCollection for destCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + childRESTUserCollection, + ); + + const result = await userCollectionService.moveUserCollection( + rootRESTUserCollection.id, + childRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_IS_PARENT_COLL); + }); + + test('should throw USER_COL_ALREADY_ROOT when moving root user-collection to root', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + const result = await userCollectionService.moveUserCollection( + rootRESTUserCollection.id, + null, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_ALREADY_ROOT); + }); + + test('should successfully move a child user-collection into root', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + jest + .spyOn(userCollectionService, 'getUserCollection') + .mockResolvedValueOnce(E.right(childRESTUserCollection)); + jest + .spyOn(userCollectionService as any, 'changeParentAndUpdateOrderIndex') + .mockResolvedValueOnce( + E.right({ ...childRESTUserCollection, parentID: null }), + ); + + const result = await userCollectionService.moveUserCollection( + childRESTUserCollection.id, + null, + user.uid, + ); + expect(result).toEqualRight({ + ...childRESTUserCollectionCasted, + parentID: null, + }); + }); + + test('should throw USER_COLL_NOT_FOUND when trying to change parent of collection with invalid collectionID', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + jest + .spyOn(userCollectionService, 'getUserCollection') + .mockResolvedValueOnce(E.right(childRESTUserCollection)); + jest + .spyOn(userCollectionService as any, 'changeParentAndUpdateOrderIndex') + .mockResolvedValueOnce(E.left(USER_COLL_NOT_FOUND)); + + const result = await userCollectionService.moveUserCollection( + childRESTUserCollection.id, + null, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + + test('should send pubsub message to "user_coll//moved" when user-collection is moved to root successfully', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + jest + .spyOn(userCollectionService, 'getUserCollection') + .mockResolvedValueOnce(E.right(childRESTUserCollection)); + jest + .spyOn(userCollectionService as any, 'changeParentAndUpdateOrderIndex') + .mockResolvedValueOnce( + E.right({ ...childRESTUserCollection, parentID: null }), + ); + + await userCollectionService.moveUserCollection( + childRESTUserCollection.id, + null, + user.uid, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/moved`, + { + ...childRESTUserCollectionCasted, + parentID: null, + }, + ); + }); + + test('should successfully move a root user-collection into a child user-collection', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + jest + .spyOn(userCollectionService, 'getUserCollection') + .mockResolvedValueOnce(E.right(rootRESTUserCollection)) + .mockResolvedValueOnce(E.right(childRESTUserCollection_2)); + jest + .spyOn(userCollectionService as any, 'isParent') + .mockResolvedValueOnce(O.some(true)); + jest + .spyOn(userCollectionService as any, 'changeParentAndUpdateOrderIndex') + .mockResolvedValueOnce( + E.right({ + ...rootRESTUserCollection, + parentID: childRESTUserCollection_2.id, + }), + ); + + const result = await userCollectionService.moveUserCollection( + rootRESTUserCollection.id, + childRESTUserCollection_2.id, + user.uid, + ); + expect(result).toEqualRight({ + ...rootRESTUserCollectionCasted, + parentID: childRESTUserCollection_2Casted.id, + }); + }); + + test('should successfully move a child user-collection into another child user-collection', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + jest + .spyOn(userCollectionService, 'getUserCollection') + .mockResolvedValueOnce(E.right(rootRESTUserCollection)) + .mockResolvedValueOnce(E.right(childRESTUserCollection_2)); + jest + .spyOn(userCollectionService as any, 'isParent') + .mockResolvedValueOnce(O.some(true)); + jest + .spyOn(userCollectionService as any, 'changeParentAndUpdateOrderIndex') + .mockResolvedValueOnce( + E.right({ + ...rootRESTUserCollection, + parentID: childRESTUserCollection.id, + }), + ); + + const result = await userCollectionService.moveUserCollection( + rootRESTUserCollection.id, + childRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualRight({ + ...rootRESTUserCollectionCasted, + parentID: childRESTUserCollectionCasted.id, + }); + }); + + test('should send pubsub message to "user_coll//moved" when user-collection is moved into another child user-collection successfully', async () => { + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + jest + .spyOn(userCollectionService, 'getUserCollection') + .mockResolvedValueOnce(E.right(rootRESTUserCollection)) + .mockResolvedValueOnce(E.right(childRESTUserCollection_2)); + jest + .spyOn(userCollectionService as any, 'isParent') + .mockResolvedValueOnce(O.some(true)); + jest + .spyOn(userCollectionService as any, 'changeParentAndUpdateOrderIndex') + .mockResolvedValueOnce( + E.right({ + ...rootRESTUserCollection, + parentID: childRESTUserCollection.id, + }), + ); + + await userCollectionService.moveUserCollection( + rootRESTUserCollection.id, + childRESTUserCollection.id, + user.uid, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/moved`, + { + ...rootRESTUserCollectionCasted, + parentID: childRESTUserCollectionCasted.id, + }, + ); + }); +}); + +describe('updateUserCollectionOrder', () => { + test('should throw USER_COLL_SAME_NEXT_COLL if collectionID and nextCollectionID are the same', async () => { + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[0].id, + childRESTUserCollectionList[0].id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_SAME_NEXT_COLL); + }); + + test('should throw USER_COLL_NOT_FOUND if collectionID is invalid', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + null, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + + test('should throw USER_COLL_NOT_FOUND if userUID is of a different user', async () => { + // getUserCollection (userUid is now part of the where clause, so it rejects for wrong user) + mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + null, + 'op09', + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + + test('should successfully move the child user-collection to the end of the list', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + childRESTUserCollectionList[4], + ); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce( + childRESTUserCollectionList[4], + ); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 4 }); + mockPrisma.userCollection.count.mockResolvedValueOnce( + childRESTUserCollectionList.length, + ); + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...childRESTUserCollectionList[4], + orderIndex: childRESTUserCollectionList.length, + }); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + null, + user.uid, + ); + expect(result).toEqualRight(true); + }); + + test('should successfully move the root user-collection to the end of the list', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollectionList[4], + ); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce( + rootRESTUserCollectionList[4], + ); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 4 }); + mockPrisma.userCollection.count.mockResolvedValueOnce( + rootRESTUserCollectionList.length, + ); + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...rootRESTUserCollectionList[4], + orderIndex: rootRESTUserCollectionList.length, + }); + + const result = await userCollectionService.updateUserCollectionOrder( + rootRESTUserCollectionList[4].id, + null, + user.uid, + ); + expect(result).toEqualRight(true); + }); + + test('should throw USER_COLL_REORDERING_FAILED when re-ordering operation failed for child user-collection list', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + childRESTUserCollectionList[4], + ); + mockPrisma.$transaction.mockRejectedValueOnce('RecordNotFound'); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + null, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_REORDERING_FAILED); + }); + + test('should send pubsub message to "user_coll//order_updated" when user-collection order is updated successfully', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + childRESTUserCollectionList[4], + ); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce( + childRESTUserCollectionList[4], + ); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 4 }); + mockPrisma.userCollection.count.mockResolvedValueOnce( + childRESTUserCollectionList.length, + ); + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...childRESTUserCollectionList[4], + orderIndex: childRESTUserCollectionList.length, + }); + + await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + null, + user.uid, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/order_updated`, + { + userCollection: { + ...childRESTUserCollectionListCasted[4], + userID: childRESTUserCollectionListCasted[4].userID, + }, + nextUserCollection: null, + }, + ); + }); + + test('should throw USER_COLL_NOT_SAME_USER when collectionID and nextCollectionID do not belong to the same user account', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce(childRESTUserCollectionList[4]) + .mockResolvedValueOnce({ + ...childRESTUserCollection_2, + userUid: 'differendUID', + }); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + childRESTUserCollection_2.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_SAME_USER); + }); + + test('should throw USER_COLL_NOT_SAME_TYPE when collectionID and nextCollectionID do not belong to the same collection type', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce(childRESTUserCollectionList[4]) + .mockResolvedValueOnce({ + ...childRESTUserCollection_2, + type: ReqType.GQL, + }); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + childRESTUserCollection_2.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_SAME_TYPE); + }); + + test('should successfully update the order of the child user-collection list', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce(childRESTUserCollectionList[4]) + .mockResolvedValueOnce(childRESTUserCollectionList[2]); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst + .mockResolvedValueOnce(childRESTUserCollectionList[4]) + .mockResolvedValueOnce(childRESTUserCollectionList[2]); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 2 }); + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...childRESTUserCollectionList[4], + orderIndex: 2, + }); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + childRESTUserCollectionList[2].id, + user.uid, + ); + expect(result).toEqualRight(true); + }); + + test('should throw USER_COLL_REORDERING_FAILED when re-ordering operation failed for child user-collection list', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce(childRESTUserCollectionList[4]) + .mockResolvedValueOnce(childRESTUserCollectionList[2]); + + mockPrisma.$transaction.mockRejectedValueOnce('RecordNotFound'); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + childRESTUserCollectionList[2].id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_REORDERING_FAILED); + }); + + test('should send pubsub message to "user_coll//order_updated" when user-collection order is updated successfully', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce(childRESTUserCollectionList[4]) + .mockResolvedValueOnce(childRESTUserCollectionList[2]); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst + .mockResolvedValueOnce(childRESTUserCollectionList[4]) + .mockResolvedValueOnce(childRESTUserCollectionList[2]); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 2 }); + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...childRESTUserCollectionList[4], + orderIndex: 2, + }); + + await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + childRESTUserCollectionList[2].id, + user.uid, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/order_updated`, + { + userCollection: { + ...childRESTUserCollectionListCasted[4], + userID: childRESTUserCollectionListCasted[4].userID, + }, + nextUserCollection: { + ...childRESTUserCollectionListCasted[2], + userID: childRESTUserCollectionListCasted[2].userID, + }, + }, + ); + }); +}); + +describe('FIX: updateMany queries now include userUid filter for root collections', () => { + /** + * These tests verify the fix for the bug where updateMany queries with parentID: null + * were not filtering by userUid, potentially affecting other users' root collections. + * + * The fix adds userUid filter to: + * - changeParentAndUpdateOrderIndex + * - removeCollectionAndUpdateSiblingsOrderIndex + * - updateUserCollectionOrder (both cases) + * - getCollectionCount + */ + + beforeEach(() => { + mockReset(mockPrisma); + }); + + describe('SCENARIO: Two users performing concurrent operations on root collections', () => { + /** + * This scenario test simulates two users (Alice and Bob) each having multiple + * root collections and performing various operations. It verifies that: + * 1. All updateMany calls include the correct userUid filter + * 2. Alice's operations never affect Bob's collections and vice versa + */ + + const alice: AuthUser = { + uid: 'alice-uid', + email: 'alice@example.com', + displayName: 'Alice', + photoURL: null, + isAdmin: false, + refreshToken: 'alice-token', + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, + currentGQLSession: {}, + currentRESTSession: {}, + }; + + const bob: AuthUser = { + uid: 'bob-uid', + email: 'bob@example.com', + displayName: 'Bob', + photoURL: null, + isAdmin: false, + refreshToken: 'bob-token', + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, + currentGQLSession: {}, + currentRESTSession: {}, + }; + + // Alice's root collections (orderIndex: 1, 2, 3) + const aliceCollection1: DBUserCollection = { + id: 'alice-coll-1', + orderIndex: 1, + parentID: null, + title: 'Alice Collection 1', + userUid: alice.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }; + + const aliceCollection2: DBUserCollection = { + id: 'alice-coll-2', + orderIndex: 2, + parentID: null, + title: 'Alice Collection 2', + userUid: alice.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }; + + const aliceCollection3: DBUserCollection = { + id: 'alice-coll-3', + orderIndex: 3, + parentID: null, + title: 'Alice Collection 3', + userUid: alice.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }; + + // Bob's root collections (orderIndex: 1, 2, 3) + const bobCollection1: DBUserCollection = { + id: 'bob-coll-1', + orderIndex: 1, + parentID: null, + title: 'Bob Collection 1', + userUid: bob.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }; + + const bobCollection2: DBUserCollection = { + id: 'bob-coll-2', + orderIndex: 2, + parentID: null, + title: 'Bob Collection 2', + userUid: bob.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }; + + const bobCollection3: DBUserCollection = { + id: 'bob-coll-3', + orderIndex: 3, + parentID: null, + title: 'Bob Collection 3', + userUid: bob.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }; + + test('SCENARIO: Alice deletes collection, Bob reorders - operations are isolated', async () => { + /** + * Scenario: + * 1. Alice deletes her collection #2 (orderIndex: 2) + * - Expected: Alice's collection #3 becomes orderIndex: 2 + * - Bob's collections should be UNCHANGED + * + * 2. Bob reorders his collection #1 to the end + * - Expected: Bob's collections become: #2->1, #3->2, #1->3 + * - Alice's collections should be UNCHANGED + * + * Without the fix, both operations would affect ALL root collections + * because parentID: null matches everyone's root collections. + */ + + // === STEP 1: Alice deletes her collection #2 === + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + aliceCollection2, + ); + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); // No children + mockPrisma.userRequest.deleteMany.mockResolvedValueOnce({ count: 0 }); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.userCollection.delete.mockResolvedValueOnce(aliceCollection2); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 1 }); + + await userCollectionService.deleteUserCollection( + aliceCollection2.id, + alice.uid, + ); + + // Verify Alice's delete only affected Alice's collections + const aliceDeleteCall = + mockPrisma.userCollection.updateMany.mock.calls[0][0]; + expect(aliceDeleteCall.where.userUid).toBe(alice.uid); + expect(aliceDeleteCall.where.parentID).toBe(null); + expect(aliceDeleteCall.where.orderIndex).toEqual({ + gt: aliceCollection2.orderIndex, + }); + + // Reset mocks for Bob's operation + mockReset(mockPrisma); + + // === STEP 2: Bob reorders his collection #1 to the end === + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + bobCollection1, + ); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce(bobCollection1); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 2 }); + mockPrisma.userCollection.count.mockResolvedValueOnce(3); + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...bobCollection1, + orderIndex: 3, + }); + + await userCollectionService.updateUserCollectionOrder( + bobCollection1.id, + null, + bob.uid, + ); + + // Verify Bob's reorder only affected Bob's collections + const bobReorderCall = + mockPrisma.userCollection.updateMany.mock.calls[0][0]; + expect(bobReorderCall.where.userUid).toBe(bob.uid); + expect(bobReorderCall.where.parentID).toBe(null); + }); + + test('SCENARIO: Both users reorder collections simultaneously - no cross-contamination', async () => { + /** + * Scenario: Both Alice and Bob reorder their collections at the "same time" + * + * Alice: Moves collection #3 before collection #1 (to position 1) + * Before: [#1, #2, #3] -> After: [#3, #1, #2] + * + * Bob: Moves collection #1 before collection #3 (to position 3) + * Before: [#1, #2, #3] -> After: [#2, #3, #1] + * + * Without the fix: All 6 root collections (3 Alice + 3 Bob) would be + * affected by each operation, causing data corruption. + */ + + // === Alice moves collection #3 to position 1 (before #1) === + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce(aliceCollection3) + .mockResolvedValueOnce(aliceCollection1); + + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst + .mockResolvedValueOnce(aliceCollection3) + .mockResolvedValueOnce(aliceCollection1); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 2 }); + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...aliceCollection3, + orderIndex: 1, + }); + + await userCollectionService.updateUserCollectionOrder( + aliceCollection3.id, + aliceCollection1.id, + alice.uid, + ); + + // Verify Alice's operation is scoped to Alice + const aliceReorderCall = + mockPrisma.userCollection.updateMany.mock.calls[0][0]; + expect(aliceReorderCall.where.userUid).toBe(alice.uid); + expect(aliceReorderCall.where.parentID).toBe(null); + + // Reset for Bob + mockReset(mockPrisma); + + // === Bob moves collection #1 to position 3 (before #3) === + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce(bobCollection1) + .mockResolvedValueOnce(bobCollection3); + + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst + .mockResolvedValueOnce(bobCollection1) + .mockResolvedValueOnce(bobCollection3); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 1 }); + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...bobCollection1, + orderIndex: 2, + }); + + await userCollectionService.updateUserCollectionOrder( + bobCollection1.id, + bobCollection3.id, + bob.uid, + ); + + // Verify Bob's operation is scoped to Bob + const bobReorderCall = + mockPrisma.userCollection.updateMany.mock.calls[0][0]; + expect(bobReorderCall.where.userUid).toBe(bob.uid); + expect(bobReorderCall.where.parentID).toBe(null); + }); + + test('SCENARIO: Alice moves child to root while Bob deletes root - isolated operations', async () => { + /** + * Complex scenario: + * 1. Alice has a child collection under aliceCollection1 + * 2. Alice moves that child to root (becomes a new root collection) + * 3. Bob deletes his bobCollection2 + * + * Both operations update orderIndex of root collections (parentID: null) + * Without the fix, they would interfere with each other. + */ + + const aliceChildCollection: DBUserCollection = { + id: 'alice-child', + orderIndex: 1, + parentID: aliceCollection1.id, + title: 'Alice Child Collection', + userUid: alice.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }; + + // === Alice moves child collection to root === + jest + .spyOn(userCollectionService, 'getUserCollection') + .mockResolvedValueOnce(E.right(aliceChildCollection)); + + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce( + aliceCollection3, + ); // Last root + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...aliceChildCollection, + parentID: null, + orderIndex: 4, + }); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 0 }); + + await userCollectionService.moveUserCollection( + aliceChildCollection.id, + null, // Move to root + alice.uid, + ); + + // Verify Alice's move-to-root only affects Alice's collections + const aliceMoveCall = + mockPrisma.userCollection.updateMany.mock.calls[0][0]; + expect(aliceMoveCall.where.userUid).toBe(alice.uid); + expect(aliceMoveCall.where.parentID).toBe(aliceChildCollection.parentID); + + // Reset mocks + mockReset(mockPrisma); + jest.restoreAllMocks(); + + // === Bob deletes his collection #2 === + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + bobCollection2, + ); + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + mockPrisma.userRequest.deleteMany.mockResolvedValueOnce({ count: 0 }); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.userCollection.delete.mockResolvedValueOnce(bobCollection2); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 1 }); + + await userCollectionService.deleteUserCollection( + bobCollection2.id, + bob.uid, + ); + + // Verify Bob's delete only affects Bob's collections + const bobDeleteCall = + mockPrisma.userCollection.updateMany.mock.calls[0][0]; + expect(bobDeleteCall.where.userUid).toBe(bob.uid); + expect(bobDeleteCall.where.parentID).toBe(null); + expect(bobDeleteCall.where.orderIndex).toEqual({ + gt: bobCollection2.orderIndex, + }); + }); + }); + + test('FIXED: moveUserCollection (child to root) - updateMany now filters by userUid', async () => { + const user1ChildCollection: DBUserCollection = { + id: 'user1-child-coll', + orderIndex: 2, + parentID: 'user1-parent-id', + title: 'User 1 Child Collection', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }; + + jest + .spyOn(userCollectionService, 'getUserCollection') + .mockResolvedValueOnce(E.right(user1ChildCollection)); + + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...user1ChildCollection, + parentID: null, + orderIndex: 1, + }); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 1 }); + + await userCollectionService.moveUserCollection( + user1ChildCollection.id, + null, + user.uid, + ); + + expect(mockPrisma.userCollection.updateMany).toHaveBeenCalled(); + const updateManyCall = + mockPrisma.userCollection.updateMany.mock.calls[0][0]; + + // FIXED: The where clause now includes userUid to prevent cross-user data corruption + expect(updateManyCall.where).toEqual({ + userUid: user1ChildCollection.userUid, + parentID: user1ChildCollection.parentID, + orderIndex: { gt: user1ChildCollection.orderIndex }, + }); + }); + + test('FIXED: updateUserCollectionOrder (to end) - updateMany now filters by userUid', async () => { + const user1RootCollection: DBUserCollection = { + id: 'user1-root-coll', + orderIndex: 2, + parentID: null, + title: 'User 1 Root Collection', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }; + + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + user1RootCollection, + ); + + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce( + user1RootCollection, + ); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 3 }); + mockPrisma.userCollection.count.mockResolvedValueOnce(5); + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...user1RootCollection, + orderIndex: 5, + }); + + await userCollectionService.updateUserCollectionOrder( + user1RootCollection.id, + null, + user.uid, + ); + + const updateManyCall = + mockPrisma.userCollection.updateMany.mock.calls[0][0]; + + // FIXED: Now includes userUid - only affects current user's root collections + expect(updateManyCall.where).toEqual({ + userUid: user1RootCollection.userUid, + parentID: null, + orderIndex: { gte: user1RootCollection.orderIndex + 1 }, + }); + }); + + test('FIXED: updateUserCollectionOrder (with nextCollection) - updateMany now filters by userUid', async () => { + const user1RootCollection1: DBUserCollection = { + id: 'user1-root-1', + orderIndex: 1, + parentID: null, + title: 'User 1 Root 1', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }; + + const user1RootCollection2: DBUserCollection = { + id: 'user1-root-2', + orderIndex: 4, + parentID: null, + title: 'User 1 Root 2', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }; + + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce(user1RootCollection1) + .mockResolvedValueOnce(user1RootCollection2); + + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst + .mockResolvedValueOnce(user1RootCollection1) + .mockResolvedValueOnce(user1RootCollection2); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 2 }); + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...user1RootCollection1, + orderIndex: 3, + }); + + await userCollectionService.updateUserCollectionOrder( + user1RootCollection1.id, + user1RootCollection2.id, + user.uid, + ); + + const updateManyCall = + mockPrisma.userCollection.updateMany.mock.calls[0][0]; + + // FIXED: Now includes userUid - only affects current user's root collections + expect(updateManyCall.where).toEqual({ + userUid: user1RootCollection1.userUid, + parentID: null, + orderIndex: { + gte: user1RootCollection1.orderIndex + 1, + lte: user1RootCollection2.orderIndex - 1, + }, + }); + }); + + test('FIXED: deleteUserCollection - removeCollectionAndUpdateSiblingsOrderIndex now filters by userUid', async () => { + const user1RootToDelete: DBUserCollection = { + id: 'user1-root-to-delete', + orderIndex: 2, + parentID: null, + title: 'User 1 Root To Delete', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: {}, + }; + + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + user1RootToDelete, + ); + + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + mockPrisma.userRequest.deleteMany.mockResolvedValueOnce({ count: 0 }); + + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.userCollection.delete.mockResolvedValueOnce(user1RootToDelete); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 3 }); + + await userCollectionService.deleteUserCollection( + user1RootToDelete.id, + user.uid, + ); + + const updateManyCall = + mockPrisma.userCollection.updateMany.mock.calls[0][0]; + + // FIXED: Now includes userUid - only affects current user's root collections + expect(updateManyCall.where).toEqual({ + userUid: user1RootToDelete.userUid, + parentID: null, + orderIndex: { gt: user1RootToDelete.orderIndex }, + }); + }); + + test('FIXED: getCollectionCount - now requires userUid parameter and filters by it', async () => { + mockPrisma.userCollection.count.mockResolvedValueOnce(10); + + await userCollectionService.getCollectionCount(null, user.uid); + + // FIXED: Now filters by userUid - only counts current user's collections + expect(mockPrisma.userCollection.count).toHaveBeenCalledWith({ + where: { + parentID: null, + userUid: user.uid, + }, + }); + }); +}); + +describe('updateUserCollection', () => { + test('should throw USER_COLL_DATA_INVALID is collection data is invalid', async () => { + const result = await userCollectionService.updateUserCollection( + rootRESTUserCollection.title, + '{', + rootRESTUserCollection.id, + rootRESTUserCollection.userUid, + ); + expect(result).toEqualLeft(USER_COLL_DATA_INVALID); + }); + + test('should throw USER_COLL_SHORT_TITLE if title is invalid', async () => { + const result = await userCollectionService.updateUserCollection( + '', + JSON.stringify(rootRESTUserCollection.data), + rootRESTUserCollection.id, + rootRESTUserCollection.userUid, + ); + + expect(result).toEqualLeft(USER_COLL_SHORT_TITLE); + }); + + test('should throw USER_NOT_OWNER is user is not owner of collection', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await userCollectionService.updateUserCollection( + rootRESTUserCollection.title, + JSON.stringify(rootRESTUserCollection.data), + rootRESTUserCollection.id, + rootRESTUserCollection.userUid, + ); + + expect(result).toEqualLeft(USER_NOT_OWNER); + }); + + test('should throw USER_COLL_NOT_FOUND is collectionID is invalid', async () => { + // isOwnerCheck + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce({ + ...rootRESTUserCollection, + }); + mockPrisma.userCollection.update.mockRejectedValueOnce('RecordNotFound'); + + const result = await userCollectionService.updateUserCollection( + rootRESTUserCollection.title, + JSON.stringify(rootRESTUserCollection.data), + 'invalid_id', + rootRESTUserCollection.userUid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + + test('should successfully update a collection', async () => { + // isOwnerCheck + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce({ + ...rootRESTUserCollection, + }); + mockPrisma.userCollection.update.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + const result = await userCollectionService.updateUserCollection( + 'new_title', + JSON.stringify({ foo: 'bar' }), + rootRESTUserCollection.id, + rootRESTUserCollection.userUid, + ); + + expect(result).toEqualRight({ + data: JSON.stringify({ foo: 'bar' }), + title: 'new_title', + ...rootRESTUserCollectionCasted, + }); + }); + + test('should send pubsub message to "user_coll//updated" when UserCollection is updated successfully', async () => { + // isOwnerCheck + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce({ + ...rootRESTUserCollection, + }); + mockPrisma.userCollection.update.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + await userCollectionService.updateUserCollection( + 'new_title', + JSON.stringify({ foo: 'bar' }), + rootRESTUserCollection.id, + rootRESTUserCollection.userUid, + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${rootRESTUserCollectionCasted.userID}/updated`, + { + data: JSON.stringify({ foo: 'bar' }), + title: 'new_title', + ...rootRESTUserCollectionCasted, + }, + ); + }); +}); + +describe('exportUserCollectionToJSONObject', () => { + test('should use DB row id and title over conflicting values in stored request payload', async () => { + const dbRowId = 'db-row-cuid-001'; + const dbRowTitle = 'My Request'; + const payloadId = 'stale-payload-id-from-original'; + const payloadName = 'stale-payload-name-from-original'; + + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce({ + ...rootRESTUserCollection, + }); + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + mockPrisma.userRequest.findMany.mockResolvedValueOnce([ + { + id: dbRowId, + title: dbRowTitle, + collectionID: rootRESTUserCollection.id, + userUid: user.uid, + type: ReqType.REST, + orderIndex: 1, + createdOn: currentTime, + updatedOn: currentTime, + mockExamples: null, + request: { + id: payloadId, + name: payloadName, + v: '12', + endpoint: 'https://example.com', + method: 'GET', + params: [], + headers: [], + preRequestScript: '', + testScript: '', + auth: { authType: 'none', authActive: false }, + body: { contentType: null, body: null }, + requestVariables: [], + responses: {}, + }, + }, + ]); + + const result = await userCollectionService.exportUserCollectionToJSONObject( + user.uid, + rootRESTUserCollection.id, + ); + + expect(result).toEqualRight( + expect.objectContaining({ + requests: [expect.objectContaining({ id: dbRowId, name: dbRowTitle })], + }), + ); + }); + + test('should throw USER_COLL_NOT_FOUND when collectionID is invalid', async () => { + mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValueOnce( + new Error('NotFoundError'), + ); + + const result = await userCollectionService.exportUserCollectionToJSONObject( + user.uid, + 'non-existent-id', + ); + + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); +}); + +describe('importCollectionsFromJSON — collection-level script fields', () => { + // The backend treats `data` as an opaque JSON blob, so the script fields + // ride through transparently. The test asserts on both ends: the create + // call payload must carry script fields (proving import wrote them), and + // the export payload must surface them unchanged. Guards against any + // future refactor that destructures `data` and drops scripts on either + // side. + test('preRequestScript and testScript on root and folder survive import → export round-trip', async () => { + const importJSON = JSON.stringify([ + { + name: 'root-with-scripts', + folders: [ + { + name: 'child-folder', + folders: [], + requests: [], + data: JSON.stringify({ + auth: { authType: 'inherit', authActive: true }, + headers: [], + variables: [], + preRequestScript: 'pw.env.set("FOLDER_RAN", "yes");', + testScript: 'pw.test("folder", () => {});', + }), + }, + ], + requests: [], + data: JSON.stringify({ + auth: { authType: 'none', authActive: false }, + headers: [], + variables: [], + preRequestScript: 'pw.env.set("ROOT_RAN", "yes");', + testScript: 'pw.test("root", () => {});', + }), + }, + ]); + + const rootRowId = 'imported-root-id'; + const folderRowId = 'imported-folder-id'; + + // Capture what generatePrismaQueryObj writes into Prisma so the export + // path sees the same blob shape the import wrote. + const rootDataAtCreate = { + auth: { authType: 'none', authActive: false }, + headers: [], + variables: [], + preRequestScript: 'pw.env.set("ROOT_RAN", "yes");', + testScript: 'pw.test("root", () => {});', + }; + const folderDataAtCreate = { + auth: { authType: 'inherit', authActive: true }, + headers: [], + variables: [], + preRequestScript: 'pw.env.set("FOLDER_RAN", "yes");', + testScript: 'pw.test("folder", () => {});', + }; + + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.userCollection.create.mockResolvedValueOnce({ + id: rootRowId, + orderIndex: 1, + parentID: null, + title: 'root-with-scripts', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: rootDataAtCreate, + }); + + // Export-side mocks: root resolves once, then its child folder resolves. + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce({ + id: rootRowId, + orderIndex: 1, + parentID: null, + title: 'root-with-scripts', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: rootDataAtCreate, + }) + .mockResolvedValueOnce({ + id: folderRowId, + orderIndex: 1, + parentID: rootRowId, + title: 'child-folder', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: folderDataAtCreate, + }); + + mockPrisma.userCollection.findMany + .mockResolvedValueOnce([ + { + id: folderRowId, + orderIndex: 1, + parentID: rootRowId, + title: 'child-folder', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: folderDataAtCreate, + }, + ]) + .mockResolvedValueOnce([]); + + mockPrisma.userRequest.findMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + const result = await userCollectionService.importCollectionsFromJSON( + importJSON, + user.uid, + null, + ReqType.REST, + ); + + expect(E.isRight(result)).toBe(true); + + // Import side: `userCollection.create` must receive script fields inside + // its `data` payload (root) and inside `children.create[0].data` (folder). + // Asserting against the create call args proves import preserved scripts; + // export-side mocks alone would only round-trip the values we pre-loaded. + const createCallArg = mockPrisma.userCollection.create.mock.calls[0][0] + .data as any; + expect(createCallArg.data.preRequestScript).toBe( + 'pw.env.set("ROOT_RAN", "yes");', + ); + expect(createCallArg.data.testScript).toBe('pw.test("root", () => {});'); + const childCreateArg = createCallArg.children.create[0]; + expect(childCreateArg.data.preRequestScript).toBe( + 'pw.env.set("FOLDER_RAN", "yes");', + ); + expect(childCreateArg.data.testScript).toBe('pw.test("folder", () => {});'); + + if (E.isRight(result)) { + const exported = JSON.parse(result.right.exportedCollection); + // `data` is JSON-stringified by transformCollectionData on export. + const rootData = JSON.parse(exported[0].data); + const folderData = JSON.parse(exported[0].folders[0].data); + expect(rootData.preRequestScript).toBe('pw.env.set("ROOT_RAN", "yes");'); + expect(rootData.testScript).toBe('pw.test("root", () => {});'); + expect(folderData.preRequestScript).toBe( + 'pw.env.set("FOLDER_RAN", "yes");', + ); + expect(folderData.testScript).toBe('pw.test("folder", () => {});'); + } + }); +}); diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts new file mode 100644 index 0000000..85212be --- /dev/null +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts @@ -0,0 +1,1433 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { + USER_COLL_DEST_SAME, + USER_COLL_IS_PARENT_COLL, + USER_COLL_NOT_FOUND, + USER_COLL_NOT_SAME_TYPE, + USER_COLL_NOT_SAME_USER, + USER_COLL_REORDERING_FAILED, + USER_COLL_SAME_NEXT_COLL, + USER_COLL_SHORT_TITLE, + USER_COLL_ALREADY_ROOT, + USER_NOT_FOUND, + USER_NOT_OWNER, + USER_COLL_INVALID_JSON, + USER_COLL_DATA_INVALID, + USER_COLLECTION_CREATION_FAILED, +} from 'src/errors'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { AuthUser } from 'src/types/AuthUser'; +import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { + Prisma, + UserCollection, + ReqType as DBReqType, +} from 'src/generated/prisma/client'; +import { + UserCollection as UserCollectionModel, + UserCollectionExportJSONData, + UserCollectionDuplicatedData, +} from './user-collections.model'; +import { ReqType } from 'src/types/RequestTypes'; +import { + delay, + isValidLength, + stringToJson, + transformCollectionData, +} from 'src/utils'; +import { CollectionFolder } from 'src/types/CollectionFolder'; +import { PrismaError } from 'src/prisma/prisma-error-codes'; +import { SortOptions } from 'src/types/SortOptions'; + +@Injectable() +export class UserCollectionService { + constructor( + private readonly prisma: PrismaService, + private readonly pubsub: PubSubService, + ) {} + + TITLE_LENGTH = 1; + MAX_RETRIES = 5; // Maximum number of retries for database transactions + + /** + * Typecast a database UserCollection to a UserCollection model + * @param userCollection database UserCollection + * @returns UserCollection model + */ + private cast(collection: UserCollection) { + const data = transformCollectionData(collection.data); + + return { + id: collection.id, + title: collection.title, + type: collection.type, + parentID: collection.parentID, + userID: collection.userUid, + data, + }; + } + + /** + * Check to see if Collection belongs to User + * + * @param collectionID The collection ID + * @param userID The User ID + * @returns An Option of a Boolean + */ + private async isOwnerCheck(collectionID: string, userID: string) { + try { + await this.prisma.userCollection.findFirstOrThrow({ + where: { + id: collectionID, + userUid: userID, + }, + }); + + return O.some(true); + } catch (error) { + return O.none; + } + } + + /** + * Get User of given Collection ID + * + * @param collectionID The collection ID + * @returns User of given Collection ID + */ + async getUserOfCollection(collectionID: string) { + try { + const userCollection = await this.prisma.userCollection.findUniqueOrThrow( + { + where: { + id: collectionID, + }, + include: { + user: true, + }, + }, + ); + return E.right(userCollection.user); + } catch (error) { + return E.left(USER_NOT_FOUND); + } + } + + /** + * Get parent of given Collection ID + * + * @param collectionID The collection ID + * @returns Parent UserCollection of given Collection ID + */ + async getParentOfUserCollection(collectionID: string) { + const { parent } = await this.prisma.userCollection.findUnique({ + where: { + id: collectionID, + }, + include: { + parent: true, + }, + }); + + return !parent ? null : this.cast(parent); + } + + /** + * Get child collections of given Collection ID + * + * @param collectionID The collection ID + * @param cursor collectionID for pagination + * @param take Number of items we want returned + * @param type Type of UserCollection + * @returns A list of child collections + */ + async getChildrenOfUserCollection( + collectionID: string, + cursor: string | null, + take: number, + type: ReqType, + ) { + const res = await this.prisma.userCollection.findMany({ + where: { + parentID: collectionID, + type: type, + }, + orderBy: { + orderIndex: 'asc', + }, + take: take, // default: 10 + skip: cursor ? 1 : 0, + cursor: cursor ? { id: cursor } : undefined, + }); + + const childCollections = res.map((childCollection) => + this.cast(childCollection), + ); + + return childCollections; + } + + /** + * Get collection details + * + * @param collectionID The collection ID + * @returns An Either of the Collection details + */ + async getUserCollection( + collectionID: string, + userUid: string, + tx: Prisma.TransactionClient | null = null, + ) { + try { + const userCollection = await ( + tx || this.prisma + ).userCollection.findUniqueOrThrow({ + where: { id: collectionID, userUid }, + }); + return E.right(userCollection); + } catch (error) { + return E.left(USER_COLL_NOT_FOUND); + } + } + + /** + * Create a new UserCollection + * + * @param user The User object + * @param title The title of new UserCollection + * @param parentID The parent collectionID (null if root collection) + * @param type Type of Collection we want to create (REST/GQL) + * @returns + */ + async createUserCollection( + user: AuthUser, + title: string, + data: string | null = null, + parentID: string | null, + type: ReqType, + ) { + const isTitleValid = isValidLength(title, this.TITLE_LENGTH); + if (!isTitleValid) return E.left(USER_COLL_SHORT_TITLE); + + if (data === '') return E.left(USER_COLL_DATA_INVALID); + if (data) { + const jsonReq = stringToJson(data); + if (E.isLeft(jsonReq)) return E.left(USER_COLL_DATA_INVALID); + data = jsonReq.right; + } + + let userCollection: UserCollection = null; + try { + userCollection = await this.prisma.$transaction(async (tx) => { + try { + // If creating a child collection + if (parentID !== null) { + const parentCollection = await this.getUserCollection( + parentID, + user.uid, + tx, + ); + if (E.isLeft(parentCollection)) throw Error(parentCollection.left); + + // Check to see if parent collection is of the same type of new collection being created + if (parentCollection.right.type !== type) + throw Error(USER_COLL_NOT_SAME_TYPE); + } + // lock the rows + await this.prisma.lockUserCollectionByParent(tx, user.uid, parentID); + + // fetch last user collection + const lastUserCollection = await tx.userCollection.findFirst({ + where: { userUid: user.uid, parentID }, + orderBy: { orderIndex: 'desc' }, + select: { orderIndex: true }, + }); + + // create new user collection + return tx.userCollection.create({ + data: { + title: title, + type: type, + user: { connect: { uid: user.uid } }, + parent: parentID ? { connect: { id: parentID } } : undefined, + data: data ?? undefined, + orderIndex: lastUserCollection + ? lastUserCollection.orderIndex + 1 + : 1, + }, + }); + } catch (error) { + throw new ConflictException(error); + } + }); + } catch (error) { + console.error( + 'Error from UserCollectionService.createUserCollection', + error, + ); + return E.left(USER_COLLECTION_CREATION_FAILED); + } + + await this.pubsub.publish( + `user_coll/${user.uid}/created`, + this.cast(userCollection), + ); + + return E.right(this.cast(userCollection)); + } + + /** + * + * @param user The User Object + * @param cursor collectionID for pagination + * @param take Number of items we want returned + * @param type Type of UserCollection + * @returns A list of root UserCollections + */ + async getUserRootCollections( + user: AuthUser, + cursor: string | null, + take: number, + type: ReqType, + ) { + const res = await this.prisma.userCollection.findMany({ + where: { + userUid: user.uid, + parentID: null, + type: type, + }, + orderBy: { + orderIndex: 'asc', + }, + take: take, // default: 10 + skip: cursor ? 1 : 0, + cursor: cursor ? { id: cursor } : undefined, + }); + + const userCollections = res.map((childCollection) => + this.cast(childCollection), + ); + + return userCollections; + } + + /** + * + * @param user The User Object + * @param userCollectionID The User UID + * @param cursor collectionID for pagination + * @param take Number of items we want returned + * @param type Type of UserCollection + * @returns A list of child UserCollections + */ + async getUserChildCollections( + user: AuthUser, + userCollectionID: string, + cursor: string | null, + take: number, + type: ReqType, + ) { + const res = await this.prisma.userCollection.findMany({ + where: { + userUid: user.uid, + parentID: userCollectionID, + type: type, + }, + take: take, // default: 10 + skip: cursor ? 1 : 0, + cursor: cursor ? { id: cursor } : undefined, + }); + + const childCollections = res.map((childCollection) => + this.cast(childCollection), + ); + + return childCollections; + } + + /** + * @deprecated Use updateUserCollection method instead + * Update the title of a UserCollection + * + * @param newTitle The new title of collection + * @param userCollectionID The Collection Id + * @param userID The User UID + * @returns An Either of the updated UserCollection + */ + async renameUserCollection( + newTitle: string, + userCollectionID: string, + userID: string, + ) { + const isTitleValid = isValidLength(newTitle, this.TITLE_LENGTH); + if (!isTitleValid) return E.left(USER_COLL_SHORT_TITLE); + + // Check to see is the collection belongs to the user + const isOwner = await this.isOwnerCheck(userCollectionID, userID); + if (O.isNone(isOwner)) return E.left(USER_NOT_OWNER); + + try { + const updatedUserCollection = await this.prisma.userCollection.update({ + where: { id: userCollectionID }, + data: { title: newTitle }, + }); + + this.pubsub.publish( + `user_coll/${updatedUserCollection.userUid}/updated`, + this.cast(updatedUserCollection), + ); + + return E.right(this.cast(updatedUserCollection)); + } catch (error) { + return E.left(USER_COLL_NOT_FOUND); + } + } + + /** + * Delete a UserCollection + * + * @param collectionID The Collection Id + * @param userID The User UID + * @returns An Either of Boolean of deletion status + */ + async deleteUserCollection(collectionID: string, userID: string) { + // Get collection details of collectionID + const collection = await this.getUserCollection(collectionID, userID); + if (E.isLeft(collection)) return E.left(USER_COLL_NOT_FOUND); + + // Delete all child collections and requests in the collection + const isDeleted = await this.removeCollectionAndUpdateSiblingsOrderIndex( + collection.right, + { gt: collection.right.orderIndex }, + { decrement: 1 }, + userID, + ); + if (E.isLeft(isDeleted)) return E.left(isDeleted.left); + + this.pubsub.publish(`user_coll/${collection.right.userUid}/deleted`, { + id: collection.right.id, + type: ReqType[collection.right.type], + }); + + return E.right(true); + } + + /** + * Change parentID of UserCollection's + + * @param collection The collection that is being moved + * @param newParentID The new parent's collection ID or change to root collection + * @returns If successful return an Either of collection or error message + */ + private async changeParentAndUpdateOrderIndex( + tx: Prisma.TransactionClient, + collection: UserCollection, + newParentID: string | null, + ) { + // fetch last collection + const lastCollectionUnderNewParent = await tx.userCollection.findFirst({ + where: { userUid: collection.userUid, parentID: newParentID }, + orderBy: { orderIndex: 'desc' }, + }); + + // update collection's parentID and orderIndex + const updatedCollection = await tx.userCollection.update({ + where: { id: collection.id }, + data: { + // if parentCollectionID == null, collection becomes root collection + // if parentCollectionID != null, collection becomes child collection + parentID: newParentID, + orderIndex: lastCollectionUnderNewParent + ? lastCollectionUnderNewParent.orderIndex + 1 + : 1, + }, + }); + + // decrement orderIndex of all next sibling collections from original collection + await tx.userCollection.updateMany({ + where: { + userUid: collection.userUid, + parentID: collection.parentID, + orderIndex: { gt: collection.orderIndex }, + }, + data: { orderIndex: { decrement: 1 } }, + }); + + return E.right(updatedCollection); + } + + /** + * Check if collection is parent of destCollection + * + * @param collection The ID of collection being moved + * @param destCollection The ID of collection into which we are moving target collection into + * @returns An Option of boolean, is parent or not + */ + private async isParent( + collection: UserCollection, + destCollection: UserCollection, + tx: Prisma.TransactionClient | null = null, + ): Promise> { + // Check if collection and destCollection are same + if (collection === destCollection) { + return O.none; + } + if (destCollection.parentID !== null) { + // Check if ID of collection is same as parent of destCollection + if (destCollection.parentID === collection.id) { + return O.none; + } + // Get collection details of collection one step above in the tree i.e the parent collection + const parentCollection = await this.getUserCollection( + destCollection.parentID, + destCollection.userUid, + tx, + ); + if (E.isLeft(parentCollection)) { + return O.none; + } + // Call isParent again now with parent collection + return await this.isParent(collection, parentCollection.right, tx); + } else { + return O.some(true); + } + } + + /** + * Delete collection and Update the OrderIndex of all collections in given parentID + * @param collection The collection to delete + * @param orderIndexCondition Condition to decide what collections will be updated + * @param dataCondition Increment/Decrement OrderIndex condition + * @returns A Collection with updated OrderIndexes + */ + private async removeCollectionAndUpdateSiblingsOrderIndex( + collection: UserCollection, + orderIndexCondition: Prisma.IntFilter, + dataCondition: Prisma.IntFieldUpdateOperationsInput, + userID: string, + ) { + let retryCount = 0; + while (retryCount < this.MAX_RETRIES) { + try { + await this.prisma.$transaction(async (tx) => { + try { + // lock the rows + await this.prisma.lockUserCollectionByParent( + tx, + userID, + collection.parentID, + ); + + try { + await tx.userCollection.delete({ + where: { id: collection.id }, + }); + } catch (deleteError) { + // P2025: Record not found — already deleted by a concurrent transaction + if (deleteError?.code === PrismaError.RECORD_NOT_FOUND) return; + throw deleteError; + } + + await tx.userCollection.updateMany({ + where: { + userUid: collection.userUid, + parentID: collection.parentID, + orderIndex: orderIndexCondition, + }, + data: { orderIndex: dataCondition }, + }); + } catch (error) { + throw new ConflictException(error); + } + }); + + break; + } catch (error) { + console.error( + 'Error from UserCollectionService.updateOrderIndex:', + error, + ); + retryCount++; + if ( + retryCount >= this.MAX_RETRIES || + (error.code !== PrismaError.UNIQUE_CONSTRAINT_VIOLATION && + error.code !== PrismaError.TRANSACTION_DEADLOCK && + error.code !== PrismaError.TRANSACTION_TIMEOUT) // return for all DB error except deadlocks, unique constraint violations, transaction timeouts + ) + return E.left(USER_COLL_REORDERING_FAILED); + + await delay(retryCount * 100); + console.debug(`Retrying... (${retryCount})`); + } + } + + return E.right(true); + } + + /** + * Move UserCollection into root or another collection + * + * @param userCollectionID The ID of collection being moved + * @param destCollectionID The ID of collection the target collection is being moved into or move target collection to root + * @param userID The User UID + * @returns An Either of the moved UserCollection + */ + async moveUserCollection( + userCollectionID: string, + destCollectionID: string | null, + userID: string, + ) { + try { + return await this.prisma.$transaction(async (tx) => { + // Get collection details of collectionID + const collection = await this.getUserCollection( + userCollectionID, + userID, + tx, + ); + if (E.isLeft(collection)) return E.left(USER_COLL_NOT_FOUND); + + // destCollectionID == null i.e move collection to root + if (!destCollectionID) { + if (!collection.right.parentID) { + // collection is a root collection + // Throw error if collection is already a root collection + return E.left(USER_COLL_ALREADY_ROOT); + } + + await this.prisma.lockUserCollectionByParent( + tx, + userID, + collection.right.parentID, + ); + + // Change parent from child to root i.e child collection becomes a root collection + // Move child collection into root and update orderIndexes for child userCollections + const updatedCollection = await this.changeParentAndUpdateOrderIndex( + tx, + collection.right, + null, + ); + if (E.isLeft(updatedCollection)) + return E.left(updatedCollection.left); + + this.pubsub.publish( + `user_coll/${collection.right.userUid}/moved`, + this.cast(updatedCollection.right), + ); + + return E.right(this.cast(updatedCollection.right)); + } + + // destCollectionID != null i.e move into another collection + if (userCollectionID === destCollectionID) { + // Throw error if collectionID and destCollectionID are the same + return E.left(USER_COLL_DEST_SAME); + } + + // Get collection details of destCollectionID + const destCollection = await this.getUserCollection( + destCollectionID, + userID, + tx, + ); + if (E.isLeft(destCollection)) return E.left(USER_COLL_NOT_FOUND); + + // Check if collection and destCollection belong to the same collection type + if (collection.right.type !== destCollection.right.type) { + return E.left(USER_COLL_NOT_SAME_TYPE); + } + + // Check if collection is present on the parent tree for destCollection + const checkIfParent = await this.isParent( + collection.right, + destCollection.right, + tx, + ); + if (O.isNone(checkIfParent)) { + return E.left(USER_COLL_IS_PARENT_COLL); + } + + // Acquire locks in deterministic order (sorted by parentID) to prevent deadlocks + const srcParentID = collection.right.parentID ?? ''; + const destParentID = destCollection.right.parentID ?? ''; + + if (srcParentID === destParentID) { + await this.prisma.lockUserCollectionByParent( + tx, + userID, + collection.right.parentID, + ); + } else if (srcParentID < destParentID) { + await this.prisma.lockUserCollectionByParent( + tx, + userID, + collection.right.parentID, + ); + await this.prisma.lockUserCollectionByParent( + tx, + userID, + destCollection.right.parentID, + ); + } else { + await this.prisma.lockUserCollectionByParent( + tx, + userID, + destCollection.right.parentID, + ); + await this.prisma.lockUserCollectionByParent( + tx, + userID, + collection.right.parentID, + ); + } + + // Move root/child collection into another child collection and update orderIndexes of the previous parent + const updatedCollection = await this.changeParentAndUpdateOrderIndex( + tx, + collection.right, + destCollection.right.id, + ); + if (E.isLeft(updatedCollection)) return E.left(updatedCollection.left); + + this.pubsub.publish( + `user_coll/${collection.right.userUid}/moved`, + this.cast(updatedCollection.right), + ); + + return E.right(this.cast(updatedCollection.right)); + }); + } catch (error) { + console.error( + 'Error from UserCollectionService.moveUserCollection', + error, + ); + return E.left(USER_COLL_REORDERING_FAILED); + } + } + + /** + * Find the number of child collections present in collectionID for a specific user + * + * @param collectionID The Collection ID (parent collection ID, or null for root) + * @param userUid The User UID + * @returns Number of collections + */ + getCollectionCount( + collectionID: string, + userUid: string, + tx: Prisma.TransactionClient | null = null, + ): Promise { + return (tx || this.prisma).userCollection.count({ + where: { + parentID: collectionID, + userUid: userUid, + }, + }); + } + + /** + * Update order of root or child collectionID's + * + * @param collectionID The ID of collection being re-ordered + * @param nextCollectionID The ID of collection that is after the moved collection in its new position + * @param userID The User UID + * @returns If successful return an Either of true + */ + async updateUserCollectionOrder( + collectionID: string, + nextCollectionID: string | null, + userID: string, + ) { + // Throw error if collectionID and nextCollectionID are the same + if (collectionID === nextCollectionID) + return E.left(USER_COLL_SAME_NEXT_COLL); + + // Get collection details of collectionID + const collection = await this.getUserCollection(collectionID, userID); + if (E.isLeft(collection)) return E.left(USER_COLL_NOT_FOUND); + + if (!nextCollectionID) { + // nextCollectionID == null i.e move collection to the end of the list + try { + await this.prisma.$transaction(async (tx) => { + try { + // Step 0: lock the rows + await this.prisma.lockUserCollectionByParent( + tx, + userID, + collection.right.parentID, + ); + + const collectionInTx = await tx.userCollection.findFirst({ + where: { id: collectionID }, + select: { orderIndex: true }, + }); + + // if collection is found, update orderIndexes of siblings + // if collection was deleted before the transaction started (race condition), do not update siblings orderIndexes + if (collectionInTx) { + // Step 1: Decrement orderIndex of all items that come after collection.orderIndex till end of list of items + await tx.userCollection.updateMany({ + where: { + userUid: collection.right.userUid, + parentID: collection.right.parentID, + orderIndex: { gte: collectionInTx.orderIndex + 1 }, + }, + data: { orderIndex: { decrement: 1 } }, + }); + + // Step 2: Update orderIndex of collection to length of list + await tx.userCollection.update({ + where: { id: collection.right.id }, + data: { + orderIndex: await this.getCollectionCount( + collection.right.parentID, + collection.right.userUid, + tx, + ), + }, + }); + } + } catch (error) { + throw new ConflictException(error); + } + }); + + this.pubsub.publish( + `user_coll/${collection.right.userUid}/order_updated`, + { + userCollection: this.cast(collection.right), + nextUserCollection: null, + }, + ); + + return E.right(true); + } catch (error) { + return E.left(USER_COLL_REORDERING_FAILED); + } + } + + // nextCollectionID != null i.e move to a certain position + // Get collection details of nextCollectionID + const subsequentCollection = await this.getUserCollection( + nextCollectionID, + userID, + ); + if (E.isLeft(subsequentCollection)) return E.left(USER_COLL_NOT_FOUND); + + if (collection.right.userUid !== subsequentCollection.right.userUid) + return E.left(USER_COLL_NOT_SAME_USER); + + // Check if collection and subsequentCollection belong to the same collection type + if (collection.right.type !== subsequentCollection.right.type) { + return E.left(USER_COLL_NOT_SAME_TYPE); + } + + try { + await this.prisma.$transaction(async (tx) => { + try { + // Step 0: lock the rows + await this.prisma.lockUserCollectionByParent( + tx, + userID, + subsequentCollection.right.parentID, + ); + + // subsequentCollectionInTx and subsequentCollection are same, just to make sure, orderIndex value is concrete + const collectionInTx = await tx.userCollection.findFirst({ + where: { id: collectionID }, + select: { orderIndex: true }, + }); + const subsequentCollectionInTx = await tx.userCollection.findFirst({ + where: { id: nextCollectionID }, + select: { orderIndex: true }, + }); + + // if collection and subsequentCollection are found, update orderIndexes of siblings + // if collection or subsequentCollection was deleted before the transaction started (race condition), do not update siblings orderIndexes + if (collectionInTx && subsequentCollectionInTx) { + // Step 1: Determine if we are moving collection up or down the list + const isMovingUp = + subsequentCollectionInTx.orderIndex < collectionInTx.orderIndex; + + // Step 2: Update OrderIndex of items in list depending on moving up or down + const updateFrom = isMovingUp + ? subsequentCollectionInTx.orderIndex + : collectionInTx.orderIndex + 1; + + const updateTo = isMovingUp + ? collectionInTx.orderIndex - 1 + : subsequentCollectionInTx.orderIndex - 1; + + await tx.userCollection.updateMany({ + where: { + userUid: collection.right.userUid, + parentID: collection.right.parentID, + orderIndex: { gte: updateFrom, lte: updateTo }, + }, + data: { + orderIndex: isMovingUp ? { increment: 1 } : { decrement: 1 }, + }, + }); + + // Step 3: Update OrderIndex of collection + await tx.userCollection.update({ + where: { id: collection.right.id }, + data: { + orderIndex: isMovingUp + ? subsequentCollectionInTx.orderIndex + : subsequentCollectionInTx.orderIndex - 1, + }, + }); + } + } catch (error) { + throw new ConflictException(error); + } + }); + + this.pubsub.publish( + `user_coll/${collection.right.userUid}/order_updated`, + { + userCollection: this.cast(collection.right), + nextUserCollection: this.cast(subsequentCollection.right), + }, + ); + + return E.right(true); + } catch (error) { + return E.left(USER_COLL_REORDERING_FAILED); + } + } + + /** + * Generate a JSON containing all the contents of a collection + * + * @param userUID The User UID + * @param collectionID The Collection ID + * @returns A JSON string containing all the contents of a collection + */ + async exportUserCollectionToJSONObject( + userUID: string, + collectionID: string, + ): Promise | E.Right> { + // Get Collection details + const collection = await this.getUserCollection(collectionID, userUID); + if (E.isLeft(collection)) return E.left(collection.left); + + const childrenCollectionObjects: CollectionFolder[] = []; + + // Get all child collections whose parentID === collectionID + const childCollectionList = await this.prisma.userCollection.findMany({ + where: { + parentID: collectionID, + userUid: userUID, + }, + orderBy: { + orderIndex: 'asc', + }, + }); + + // Create a list of child collection and request data ready for export + for (const coll of childCollectionList) { + const result = await this.exportUserCollectionToJSONObject( + userUID, + coll.id, + ); + if (E.isLeft(result)) return E.left(result.left); + + childrenCollectionObjects.push(result.right); + } + + // Fetch all child requests that belong to collectionID + const requests = await this.prisma.userRequest.findMany({ + where: { + userUid: userUID, + collectionID, + }, + orderBy: { + orderIndex: 'asc', + }, + }); + + const data = transformCollectionData(collection.right.data); + + const result: CollectionFolder = { + id: collection.right.id, + name: collection.right.title, + folders: childrenCollectionObjects, + requests: requests.map((x) => { + return { + ...(x.request as Record), // type casting x.request of type Prisma.JSONValue to an object to enable spread + id: x.id, + name: x.title, + }; + }), + data, + }; + + return E.right(result); + } + + /** + * Generate a JSON containing all the contents of collections and requests of a team + * + * @param userUID The User UID + * @returns A JSON string containing all the contents of collections and requests of a team + */ + async exportUserCollectionsToJSON( + userUID: string, + collectionID: string | null, + reqType: ReqType, + ) { + // Get all child collections details + const childCollectionList = await this.prisma.userCollection.findMany({ + where: { + userUid: userUID, + parentID: collectionID, + type: reqType, + }, + orderBy: { + orderIndex: 'asc', + }, + }); + + // Create a list of child collection and request data ready for export + const collectionListObjects: CollectionFolder[] = []; + for (const coll of childCollectionList) { + const result = await this.exportUserCollectionToJSONObject( + userUID, + coll.id, + ); + if (E.isLeft(result)) return E.left(result.left); + + collectionListObjects.push(result.right); + } + + // If collectionID is not null, return JSON stringified data for specific collection + if (collectionID) { + // Get Details of collection + const parentCollection = await this.getUserCollection( + collectionID, + userUID, + ); + if (E.isLeft(parentCollection)) return E.left(parentCollection.left); + + if (parentCollection.right.type !== reqType) + return E.left(USER_COLL_NOT_SAME_TYPE); + + // Fetch all child requests that belong to collectionID + const requests = await this.prisma.userRequest.findMany({ + where: { + userUid: userUID, + collectionID: parentCollection.right.id, + }, + orderBy: { + orderIndex: 'asc', + }, + }); + + return E.right({ + exportedCollection: JSON.stringify({ + id: parentCollection.right.id, + name: parentCollection.right.title, + folders: collectionListObjects, + requests: requests.map((x) => { + return { + ...(x.request as Record), // type casting x.request of type Prisma.JSONValue to an object to enable spread + id: x.id, + name: x.title, + }; + }), + data: JSON.stringify(parentCollection.right.data), + }), + collectionType: parentCollection.right.type, + }); + } + + return E.right({ + exportedCollection: JSON.stringify(collectionListObjects), + collectionType: reqType, + }); + } + + /** + * Generate a Prisma query object representation of a collection and its child collections and requests + * + * @param folder CollectionFolder from client + * @param userID The User ID + * @param orderIndex Initial OrderIndex of + * @param reqType The Type of Collection + * @returns A Prisma query object to create a collection, its child collections and requests + */ + private generatePrismaQueryObj( + folder: CollectionFolder, + userID: string, + orderIndex: number, + reqType: DBReqType, + ): Prisma.UserCollectionCreateInput { + // Parse collection data if it exists + let data = null; + if (folder.data) { + try { + data = + typeof folder.data === 'string' + ? JSON.parse(folder.data) + : folder.data; + } catch (error) { + // If data parsing fails, log error and continue without data + console.error('Failed to parse collection data:', error); + } + } + + return { + title: folder.name, + data, + user: { + connect: { + uid: userID, + }, + }, + requests: { + create: folder.requests.map((r, index) => ({ + title: r.name, + user: { + connect: { + uid: userID, + }, + }, + type: reqType, + request: r, + orderIndex: index + 1, + })), + }, + orderIndex: orderIndex, + type: reqType, + children: { + create: folder.folders.map((f, index) => + this.generatePrismaQueryObj(f, userID, index + 1, reqType), + ), + }, + }; + } + + /** + * Create new UserCollections and UserRequests from JSON string + * + * @param jsonString The JSON string of the content + * @param userID The User ID + * @param destCollectionID The Collection ID + * @param reqType The Type of Collection + * @param isCollectionDuplication Boolean to publish collection create event on designated channel + * @returns An Either of a Boolean if the creation operation was successful + */ + async importCollectionsFromJSON( + jsonString: string, + userID: string, + destCollectionID: string | null, + reqType: DBReqType, + isCollectionDuplication = false, + ) { + // Check to see if jsonString is valid + const collectionsList = stringToJson(jsonString); + if (E.isLeft(collectionsList)) return E.left(USER_COLL_INVALID_JSON); + + // Check to see if parsed jsonString is an array + if (!Array.isArray(collectionsList.right)) + return E.left(USER_COLL_INVALID_JSON); + + // Check to see if destCollectionID belongs to this User + if (destCollectionID) { + const parentCollection = await this.getUserCollection( + destCollectionID, + userID, + ); + if (E.isLeft(parentCollection)) return E.left(parentCollection.left); + + // Check to see if parent collection is of the same type of new collection being created + if (parentCollection.right.type !== reqType) + return E.left(USER_COLL_NOT_SAME_TYPE); + } + + let userCollections: UserCollection[] = []; + + try { + await this.prisma.$transaction(async (tx) => { + try { + // lock the rows + await this.prisma.lockUserCollectionByParent( + tx, + userID, + destCollectionID, + ); + + // Get the last order index + const lastCollection = await tx.userCollection.findFirst({ + where: { userUid: userID, parentID: destCollectionID }, + orderBy: { orderIndex: 'desc' }, + }); + let lastOrderIndex = lastCollection ? lastCollection.orderIndex : 0; + + // Generate Prisma Query Object for all child collections in collectionsList + const queryList = collectionsList.right.map((x) => + this.generatePrismaQueryObj(x, userID, ++lastOrderIndex, reqType), + ); + + const parent = destCollectionID + ? { connect: { id: destCollectionID } } + : undefined; + + const promises = queryList.map((query) => + tx.userCollection.create({ + data: { ...query, parent }, + }), + ); + + userCollections = await Promise.all(promises); + } catch (error) { + throw new ConflictException(error); + } + }); + } catch (error) { + return E.left(USER_COLLECTION_CREATION_FAILED); + } + + // Fetch nested collections after transaction is committed + const importedCollectionsWithChildren: CollectionFolder[] = []; + for (const userCollection of userCollections) { + const exportedCollectionJSON = + await this.exportUserCollectionToJSONObject(userID, userCollection.id); + if (E.isLeft(exportedCollectionJSON)) + return E.left(exportedCollectionJSON.left); + importedCollectionsWithChildren.push(exportedCollectionJSON.right); + } + + if (isCollectionDuplication) { + const duplicatedCollectionData = await this.fetchCollectionData( + userCollections[0].id, + userID, + ); + if (E.isRight(duplicatedCollectionData)) { + this.pubsub.publish( + `user_coll/${userID}/duplicated`, + duplicatedCollectionData.right, + ); + } + } else { + userCollections.forEach((collection) => + this.pubsub.publish( + `user_coll/${userID}/created`, + this.cast(collection), + ), + ); + } + + return E.right({ + exportedCollection: JSON.stringify(importedCollectionsWithChildren), + collectionType: reqType, + } as UserCollectionExportJSONData); + } + + /** + * Update a UserCollection + * + * @param newTitle The new title of collection + * @param userCollectionID The Collection Id + * @param userID The User UID + * @returns An Either of the updated UserCollection + */ + async updateUserCollection( + newTitle: string = null, + collectionData: string | null = null, + userCollectionID: string, + userID: string, + ) { + if (collectionData === '') return E.left(USER_COLL_DATA_INVALID); + + if (collectionData) { + const jsonReq = stringToJson(collectionData); + if (E.isLeft(jsonReq)) return E.left(USER_COLL_DATA_INVALID); + collectionData = jsonReq.right; + } + + if (newTitle != null) { + const isTitleValid = isValidLength(newTitle, this.TITLE_LENGTH); + if (!isTitleValid) return E.left(USER_COLL_SHORT_TITLE); + } + + // Check to see is the collection belongs to the user + const isOwner = await this.isOwnerCheck(userCollectionID, userID); + if (O.isNone(isOwner)) return E.left(USER_NOT_OWNER); + + try { + const updatedUserCollection = await this.prisma.userCollection.update({ + where: { + id: userCollectionID, + }, + data: { + data: collectionData ?? undefined, + title: newTitle ?? undefined, + }, + }); + + this.pubsub.publish( + `user_coll/${updatedUserCollection.userUid}/updated`, + this.cast(updatedUserCollection), + ); + + return E.right(this.cast(updatedUserCollection)); + } catch (error) { + return E.left(USER_COLL_NOT_FOUND); + } + } + + /** + * Duplicate a User Collection + * + * @param collectionID The Collection ID + * @returns An Either of the duplicated UserCollectionExportJSONData + */ + async duplicateUserCollection( + collectionID: string, + userID: string, + reqType: DBReqType, + ) { + const collection = await this.getUserCollection(collectionID, userID); + if (E.isLeft(collection)) return E.left(USER_COLL_NOT_FOUND); + + if (collection.right.type !== reqType) + return E.left(USER_COLL_NOT_SAME_TYPE); + + const collectionJSONObject = await this.exportUserCollectionToJSONObject( + collection.right.userUid, + collectionID, + ); + if (E.isLeft(collectionJSONObject)) + return E.left(collectionJSONObject.left); + + const result = await this.importCollectionsFromJSON( + JSON.stringify([ + { + ...collectionJSONObject.right, + name: `${collection.right.title} - Duplicate`, + }, + ]), + userID, + collection.right.parentID, + reqType, + true, + ); + if (E.isLeft(result)) return E.left(result.left as string); + + return E.right(result.right); + } + + /** + * Generates a JSON containing all the contents of a collection + * + * @param collection Collection whose details we want to fetch + * @returns A JSON string containing all the contents of a collection + */ + private async fetchCollectionData( + collectionID: string, + userID: string, + ): Promise | E.Right> { + const collection = await this.getUserCollection(collectionID, userID); + if (E.isLeft(collection)) return E.left(collection.left); + + const { id, title, data, type, parentID, userUid } = collection.right; + const orderIndex = 'asc'; + + const [childCollections, requests] = await Promise.all([ + this.prisma.userCollection.findMany({ + where: { parentID: id }, + orderBy: { orderIndex }, + }), + this.prisma.userRequest.findMany({ + where: { collectionID: id }, + orderBy: { orderIndex }, + }), + ]); + + const childCollectionDataList = await Promise.all( + childCollections.map(({ id }) => this.fetchCollectionData(id, userID)), + ); + + const failedChildData = childCollectionDataList.find(E.isLeft); + if (failedChildData) return E.left(failedChildData.left); + + const childCollectionsJSONStr = JSON.stringify( + (childCollectionDataList as E.Right[]).map( + (childCollection) => childCollection.right, + ), + ); + + const transformedRequests = requests.map((requestObj) => ({ + ...requestObj, + request: JSON.stringify(requestObj.request), + })); + + return E.right({ + id, + title, + data, + type, + parentID, + userID: userUid, + childCollections: childCollectionsJSONStr, + requests: transformedRequests, + }); + } + + /** + * Sort collections in a parent collection + * @param userID The User UID + * @param parentID The ID of the parent collection or null for root collections + * @param sortBy The sorting option + * @returns An Either of a Boolean if the sorting operation was successful + */ + async sortUserCollections( + userID: string, + parentID: string | null, + sortBy: SortOptions, + ) { + // Handle all sort options, including a default + let orderBy: Prisma.Enumerable; + if (sortBy === SortOptions.TITLE_ASC) { + orderBy = { title: 'asc' }; + } else if (sortBy === SortOptions.TITLE_DESC) { + orderBy = { title: 'desc' }; + } else { + orderBy = { orderIndex: 'asc' }; + } + + try { + await this.prisma.$transaction(async (tx) => { + await this.prisma.lockUserCollectionByParent(tx, userID, parentID); + + const collections = await tx.userCollection.findMany({ + where: { userUid: userID, parentID }, + orderBy, + select: { id: true }, + }); + + const promises = collections.map((coll, index) => + tx.userCollection.update({ + where: { id: coll.id }, + data: { orderIndex: index + 1 }, + }), + ); + await Promise.all(promises); + }); + } catch (error) { + console.error('Error from UserCollectionService.sortUserCollections:', { + error, + }); + return E.left(USER_COLL_REORDERING_FAILED); + } + + return E.right(true); + } +} diff --git a/packages/hoppscotch-backend/src/user-collection/user-collections.model.ts b/packages/hoppscotch-backend/src/user-collection/user-collections.model.ts new file mode 100644 index 0000000..c7947c9 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-collection/user-collections.model.ts @@ -0,0 +1,130 @@ +import { ObjectType, Field, ID } from '@nestjs/graphql'; +import { ReqType } from 'src/types/RequestTypes'; +import { UserRequest } from 'src/user-request/user-request.model'; + +@ObjectType() +export class UserCollection { + @Field(() => ID, { + description: 'ID of the user collection', + }) + id: string; + + @Field({ + description: 'Displayed title of the user collection', + }) + title: string; + + @Field({ + description: 'JSON string representing the collection data', + nullable: true, + }) + data: string; + + @Field(() => ReqType, { + description: 'Type of the user collection', + }) + type: ReqType; + + parentID: string | null; + + userID: string; +} + +@ObjectType() +export class UserCollectionReorderData { + @Field({ + description: 'User Collection being moved', + }) + userCollection: UserCollection; + + @Field({ + description: + 'User Collection succeeding the collection being moved in its new position', + nullable: true, + }) + nextUserCollection?: UserCollection; +} + +@ObjectType() +export class UserCollectionRemovedData { + @Field(() => ID, { + description: 'ID of User Collection being removed', + }) + id: string; + + @Field(() => ReqType, { + description: 'Type of the user collection', + }) + type: ReqType; +} + +@ObjectType() +export class UserCollectionExportJSONData { + @Field(() => ID, { + description: 'Stringified contents of the collection', + }) + exportedCollection: string; + + @Field(() => ReqType, { + description: 'Type of the user collection', + }) + collectionType: ReqType; +} + +@ObjectType() +export class UserCollectionDuplicatedData { + @Field(() => ID, { + description: 'ID of the user collection', + }) + id: string; + + @Field({ + description: 'Displayed title of the user collection', + }) + title: string; + + @Field({ + description: 'JSON string representing the collection data', + nullable: true, + }) + data: string; + + @Field(() => ReqType, { + description: 'Type of the user collection', + }) + type: ReqType; + + @Field({ + description: 'Parent ID of the duplicated User Collection', + nullable: true, + }) + parentID: string | null; + + @Field({ + description: 'User ID of the duplicated User Collection', + }) + userID: string; + + @Field({ + description: 'Child collections of the duplicated User Collection', + }) + childCollections: string; + + @Field(() => [UserRequest], { + description: 'Requests of the duplicated User Collection', + }) + requests: UserRequest[]; +} + +@ObjectType() +export class UserCollectionImportResult { + @Field(() => [UserCollection], { + description: 'Flat array of all collections', + }) + collections: UserCollection[]; + + @Field(() => [UserRequest], { + description: 'Flat array of all requests', + }) + requests: UserRequest[]; +} diff --git a/packages/hoppscotch-backend/src/user-environment/user-environments.model.ts b/packages/hoppscotch-backend/src/user-environment/user-environments.model.ts new file mode 100644 index 0000000..be99821 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user-environments.model.ts @@ -0,0 +1,30 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class UserEnvironment { + @Field(() => ID, { + description: 'ID of the User Environment', + }) + id: string; + + @Field(() => ID, { + description: 'ID of the user this environment belongs to', + }) + userUid: string; + + @Field(() => String, { + nullable: true, + description: 'Name of the environment', + }) + name: string | null | undefined; // types have a union to avoid TS warnings and field is nullable when it is global env + + @Field({ + description: 'All variables present in the environment', + }) + variables: string; // JSON string of the variables object (format:[{ key: "bla", value: "bla_val" }, ...] ) which will be received from the client + + @Field({ + description: 'Flag to indicate the environment is global or not', + }) + isGlobal: boolean; +} diff --git a/packages/hoppscotch-backend/src/user-environment/user-environments.module.ts b/packages/hoppscotch-backend/src/user-environment/user-environments.module.ts new file mode 100644 index 0000000..fc929d1 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user-environments.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { UserModule } from '../user/user.module'; +import { UserEnvsUserResolver } from './user.resolver'; +import { UserEnvironmentsResolver } from './user-environments.resolver'; +import { UserEnvironmentsService } from './user-environments.service'; + +@Module({ + imports: [UserModule], + providers: [ + UserEnvironmentsResolver, + UserEnvironmentsService, + UserEnvsUserResolver, + ], + exports: [UserEnvironmentsService], +}) +export class UserEnvironmentsModule {} diff --git a/packages/hoppscotch-backend/src/user-environment/user-environments.resolver.ts b/packages/hoppscotch-backend/src/user-environment/user-environments.resolver.ts new file mode 100644 index 0000000..e0559a2 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user-environments.resolver.ts @@ -0,0 +1,202 @@ +import { Args, ID, Mutation, Resolver, Subscription } from '@nestjs/graphql'; +import { PubSubService } from '../pubsub/pubsub.service'; +import { UserEnvironment } from './user-environments.model'; +import { UseGuards } from '@nestjs/common'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { GqlUser } from '../decorators/gql-user.decorator'; +import { User } from '../user/user.model'; +import { UserEnvironmentsService } from './user-environments.service'; +import * as E from 'fp-ts/Either'; +import { throwErr } from 'src/utils'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { SkipThrottle } from '@nestjs/throttler'; + +@UseGuards(GqlThrottlerGuard) +@Resolver() +export class UserEnvironmentsResolver { + constructor( + private readonly userEnvironmentsService: UserEnvironmentsService, + private readonly pubsub: PubSubService, + ) {} + + /* Mutations */ + + @Mutation(() => UserEnvironment, { + description: 'Create a new personal user environment for given user uid', + }) + @UseGuards(GqlAuthGuard) + async createUserEnvironment( + @GqlUser() user: User, + @Args({ + name: 'name', + description: + 'Name of the User Environment, if global send an empty string', + }) + name: string, + @Args({ + name: 'variables', + description: 'JSON string of the variables object', + }) + variables: string, + ): Promise { + const isGlobal = false; + const userEnvironment = + await this.userEnvironmentsService.createUserEnvironment( + user.uid, + name, + variables, + isGlobal, + ); + if (E.isLeft(userEnvironment)) throwErr(userEnvironment.left); + return userEnvironment.right; + } + + @Mutation(() => UserEnvironment, { + description: 'Create a new global user environment for given user uid', + }) + @UseGuards(GqlAuthGuard) + async createUserGlobalEnvironment( + @GqlUser() user: User, + @Args({ + name: 'variables', + description: 'JSON string of the variables object', + }) + variables: string, + ): Promise { + const isGlobal = true; + const userEnvironment = + await this.userEnvironmentsService.createUserEnvironment( + user.uid, + null, + variables, + isGlobal, + ); + if (E.isLeft(userEnvironment)) throwErr(userEnvironment.left); + return userEnvironment.right; + } + + @Mutation(() => UserEnvironment, { + description: 'Updates a users personal or global environment', + }) + @UseGuards(GqlAuthGuard) + async updateUserEnvironment( + @GqlUser() user: User, + @Args({ + name: 'id', + description: 'ID of the user environment', + type: () => ID, + }) + id: string, + @Args({ + name: 'name', + description: + 'Name of the User Environment, if global send an empty string', + }) + name: string, + @Args({ + name: 'variables', + description: 'JSON string of the variables object', + }) + variables: string, + ): Promise { + const userEnvironment = + await this.userEnvironmentsService.updateUserEnvironment( + id, + name, + variables, + user, + ); + if (E.isLeft(userEnvironment)) throwErr(userEnvironment.left); + return userEnvironment.right; + } + + @Mutation(() => Boolean, { + description: 'Deletes a users personal environment', + }) + @UseGuards(GqlAuthGuard) + async deleteUserEnvironment( + @GqlUser() user: User, + @Args({ + name: 'id', + description: 'ID of the user environment', + type: () => ID, + }) + id: string, + ): Promise { + const userEnvironment = + await this.userEnvironmentsService.deleteUserEnvironment(user.uid, id); + if (E.isLeft(userEnvironment)) throwErr(userEnvironment.left); + return userEnvironment.right; + } + + @Mutation(() => Number, { + description: 'Deletes all of users personal environments', + }) + @UseGuards(GqlAuthGuard) + async deleteUserEnvironments(@GqlUser() user: User): Promise { + return await this.userEnvironmentsService.deleteUserEnvironments(user.uid); + } + + @Mutation(() => UserEnvironment, { + description: 'Deletes all variables inside a users global environment', + }) + @UseGuards(GqlAuthGuard) + async clearGlobalEnvironments( + @GqlUser() user: User, + @Args({ + name: 'id', + description: 'ID of the users global environment', + type: () => ID, + }) + id: string, + ): Promise { + const userEnvironment = + await this.userEnvironmentsService.clearGlobalEnvironments(user.uid, id); + if (E.isLeft(userEnvironment)) throwErr(userEnvironment.left); + return userEnvironment.right; + } + + /* Subscriptions */ + + @Subscription(() => UserEnvironment, { + description: 'Listen for User Environment Creation', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userEnvironmentCreated(@GqlUser() user: User) { + return this.pubsub.asyncIterator(`user_environment/${user.uid}/created`); + } + + @Subscription(() => UserEnvironment, { + description: 'Listen for User Environment updates', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userEnvironmentUpdated(@GqlUser() user: User) { + return this.pubsub.asyncIterator(`user_environment/${user.uid}/updated`); + } + + @Subscription(() => UserEnvironment, { + description: 'Listen for User Environment deletion', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userEnvironmentDeleted(@GqlUser() user: User) { + return this.pubsub.asyncIterator(`user_environment/${user.uid}/deleted`); + } + + @Subscription(() => Number, { + description: 'Listen for User Environment DeleteMany', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userEnvironmentDeleteMany(@GqlUser() user: User) { + return this.pubsub.asyncIterator( + `user_environment/${user.uid}/deleted_many`, + ); + } +} diff --git a/packages/hoppscotch-backend/src/user-environment/user-environments.service.spec.ts b/packages/hoppscotch-backend/src/user-environment/user-environments.service.spec.ts new file mode 100644 index 0000000..6ac16e9 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user-environments.service.spec.ts @@ -0,0 +1,585 @@ +import { UserEnvironment } from './user-environments.model'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { PrismaService } from '../prisma/prisma.service'; +import { UserEnvironmentsService } from './user-environments.service'; +import { + USER_ENVIRONMENT_ENV_DOES_NOT_EXIST, + USER_ENVIRONMENT_GLOBAL_ENV_DELETION_FAILED, + USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXIST, + USER_ENVIRONMENT_GLOBAL_ENV_EXISTS, + USER_ENVIRONMENT_INVALID_ENVIRONMENT_NAME, +} from '../errors'; +import { PubSubService } from '../pubsub/pubsub.service'; +import { User } from '../user/user.model'; + +const mockPrisma = mockDeep(); +const mockPubSub = mockDeep(); + +const mockUser: User = { + uid: 'abc123', + displayName: 'Test User', + email: 'support@example.com', + photoURL: 'https://example.com/profile.jpg', + isAdmin: false, + lastLoggedOn: new Date(), + lastActiveOn: new Date(), + createdOn: new Date(), + currentRESTSession: JSON.stringify({}), + currentGQLSession: JSON.stringify({}), +}; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const userEnvironmentsService = new UserEnvironmentsService( + mockPrisma, + mockPubSub as any, +); + +const userPersonalEnvironments = [ + { + userUiD: 'abc123', + id: '123', + name: 'test', + variables: [{}], + isGlobal: false, + }, + { + userUiD: 'abc123', + id: '1234', + name: 'test2', + variables: [{}], + isGlobal: false, + }, +]; + +beforeEach(() => { + mockReset(mockPrisma); + mockPubSub.publish.mockClear(); +}); + +describe('UserEnvironmentsService', () => { + describe('fetchUserEnvironments', () => { + test('Should return a list of users personal environments', async () => { + mockPrisma.userEnvironment.findMany.mockResolvedValueOnce([ + { + userUid: 'abc123', + id: '123', + name: 'test', + variables: [{}], + isGlobal: false, + }, + { + userUid: 'abc123', + id: '1234', + name: 'test2', + variables: [{}], + isGlobal: false, + }, + ]); + + const userEnvironments: UserEnvironment[] = [ + { + userUid: userPersonalEnvironments[0].userUiD, + id: userPersonalEnvironments[0].id, + name: userPersonalEnvironments[0].name, + variables: JSON.stringify(userPersonalEnvironments[0].variables), + isGlobal: userPersonalEnvironments[0].isGlobal, + }, + { + userUid: userPersonalEnvironments[1].userUiD, + id: userPersonalEnvironments[1].id, + name: userPersonalEnvironments[1].name, + variables: JSON.stringify(userPersonalEnvironments[1].variables), + isGlobal: userPersonalEnvironments[1].isGlobal, + }, + ]; + return expect( + await userEnvironmentsService.fetchUserEnvironments('abc123'), + ).toEqual(userEnvironments); + }); + + test('Should return an empty list of users personal environments', async () => { + mockPrisma.userEnvironment.findMany.mockResolvedValueOnce([]); + + return expect( + await userEnvironmentsService.fetchUserEnvironments('testuser'), + ).toEqual([]); + }); + + test('Should return an empty list of users personal environments if user uid is invalid', async () => { + mockPrisma.userEnvironment.findMany.mockResolvedValueOnce([]); + + return expect( + await userEnvironmentsService.fetchUserEnvironments('invaliduid'), + ).toEqual([]); + }); + }); + + describe('fetchUserGlobalEnvironment', () => { + test('Should resolve right and return a Global Environment for the uid', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce({ + id: 'genv1', + userUid: 'abc', + name: '', + variables: [{}], + isGlobal: true, + }); + + expect( + await userEnvironmentsService.fetchUserGlobalEnvironment('abc'), + ).toEqualRight({ + id: 'genv1', + userUid: 'abc', + name: '', + variables: JSON.stringify([{}]), + isGlobal: true, + }); + }); + + test('Should resolve left and return an error if global env it doesnt exists', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce(null); + + expect( + await userEnvironmentsService.fetchUserGlobalEnvironment('abc'), + ).toEqualLeft(USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXIST); + }); + }); + + describe('createUserEnvironment', () => { + test('Should resolve right and create a users personal environment and return a `UserEnvironment` object ', async () => { + mockPrisma.userEnvironment.create.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: 'test', + variables: [{}], + isGlobal: false, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: '123', + name: 'test', + variables: JSON.stringify([{}]), + isGlobal: false, + }; + + return expect( + await userEnvironmentsService.createUserEnvironment( + 'abc123', + 'test', + '[{}]', + false, + ), + ).toEqualRight(result); + }); + + test('Should resolve right and create a new users global environment and return a `UserEnvironment` object ', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce(null); + + mockPrisma.userEnvironment.create.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: null, + variables: [{}], + isGlobal: true, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: '123', + name: null, + variables: JSON.stringify([{}]), + isGlobal: true, + }; + + return expect( + await userEnvironmentsService.createUserEnvironment( + 'abc123', + null, + '[{}]', + true, + ), + ).toEqualRight(result); + }); + + test('Should resolve left and not create a new users global environment if existing global env exists ', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: null, + variables: [{}], + isGlobal: true, + }); + + return expect( + await userEnvironmentsService.createUserEnvironment( + 'abc123', + null, + '[{}]', + true, + ), + ).toEqualLeft(USER_ENVIRONMENT_GLOBAL_ENV_EXISTS); + }); + + test('Should resolve left when an invalid personal environment name has been passed', async () => { + return expect( + await userEnvironmentsService.createUserEnvironment( + 'abc123', + null, + '[{}]', + false, + ), + ).toEqualLeft(USER_ENVIRONMENT_INVALID_ENVIRONMENT_NAME); + }); + + test('Should create a users personal environment and publish a created subscription', async () => { + mockPrisma.userEnvironment.create.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: 'test', + variables: [{}], + isGlobal: false, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: '123', + name: 'test', + variables: JSON.stringify([{}]), + isGlobal: false, + }; + + await userEnvironmentsService.createUserEnvironment( + 'abc123', + 'test', + '[{}]', + false, + ); + + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_environment/${result.userUid}/created`, + result, + ); + }); + + test('Should create a users global environment and publish a created subscription', async () => { + mockPrisma.userEnvironment.create.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: '', + variables: [{}], + isGlobal: true, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: '123', + name: '', + variables: JSON.stringify([{}]), + isGlobal: true, + }; + + await userEnvironmentsService.createUserEnvironment( + 'abc123', + '', + '[{}]', + true, + ); + + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_environment/${result.userUid}/created`, + result, + ); + }); + }); + + describe('UpdateUserEnvironment', () => { + test('Should resolve right and update a users personal or environment and return a `UserEnvironment` object ', async () => { + mockPrisma.userEnvironment.update.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: 'test', + variables: [{}], + isGlobal: false, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: '123', + name: 'test', + variables: JSON.stringify([{}]), + isGlobal: false, + }; + + return expect( + await userEnvironmentsService.updateUserEnvironment( + 'abc123', + 'test', + '[{}]', + mockUser, + ), + ).toEqualRight(result); + }); + + test('Should resolve right and update a users global environment and return a `UserEnvironment` object ', async () => { + mockPrisma.userEnvironment.update.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: null, + variables: [{}], + isGlobal: true, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: '123', + name: null, + variables: JSON.stringify([{}]), + isGlobal: true, + }; + + return expect( + await userEnvironmentsService.updateUserEnvironment( + 'abc123', + null, + '[{}]', + mockUser, + ), + ).toEqualRight(result); + }); + + test('Should resolve left and not update a users environment if env doesnt exist ', async () => { + mockPrisma.userEnvironment.update.mockRejectedValueOnce( + 'RejectOnNotFound', + ); + + return expect( + await userEnvironmentsService.updateUserEnvironment( + 'abc123', + 'test', + '[{}]', + mockUser, + ), + ).toEqualLeft(USER_ENVIRONMENT_ENV_DOES_NOT_EXIST); + }); + + test('Should update a users personal environment and publish an updated subscription ', async () => { + mockPrisma.userEnvironment.update.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: 'test', + variables: [{}], + isGlobal: false, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: '123', + name: 'test', + variables: JSON.stringify([{}]), + isGlobal: false, + }; + + await userEnvironmentsService.updateUserEnvironment( + 'abc123', + 'test', + '[{}]', + mockUser, + ); + + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_environment/${result.userUid}/updated`, + result, + ); + }); + + test('Should update a users global environment and publish an updated subscription ', async () => { + mockPrisma.userEnvironment.update.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: null, + variables: [{}], + isGlobal: true, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: '123', + name: null, + variables: JSON.stringify([{}]), + isGlobal: true, + }; + + await userEnvironmentsService.updateUserEnvironment( + 'abc123', + null, + '[{}]', + mockUser, + ); + + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_environment/${result.userUid}/updated`, + result, + ); + }); + }); + + describe('deleteUserEnvironment', () => { + test('Should resolve right and delete a users personal environment and return a `UserEnvironment` object ', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce(null); + mockPrisma.userEnvironment.delete.mockResolvedValueOnce({ + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: [{}], + isGlobal: false, + }); + + return expect( + await userEnvironmentsService.deleteUserEnvironment('abc123', 'env1'), + ).toEqualRight(true); + }); + + test('Should resolve left and return an error when deleting a global user environment', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce({ + userUid: 'abc123', + id: 'genv1', + name: 'en1', + variables: [{}], + isGlobal: true, + }); + + return expect( + await userEnvironmentsService.deleteUserEnvironment('abc123', 'genv1'), + ).toEqualLeft(USER_ENVIRONMENT_GLOBAL_ENV_DELETION_FAILED); + }); + + test('Should resolve left and return an error when deleting an invalid user environment', async () => { + mockPrisma.userEnvironment.delete.mockResolvedValueOnce(null); + + return expect( + await userEnvironmentsService.deleteUserEnvironment('abc123', 'env1'), + ).toEqualLeft(USER_ENVIRONMENT_ENV_DOES_NOT_EXIST); + }); + + test('Should resolve right, delete a users personal environment and publish a deleted subscription', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce(null); + mockPrisma.userEnvironment.delete.mockResolvedValueOnce({ + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: [{}], + isGlobal: false, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: JSON.stringify([{}]), + isGlobal: false, + }; + + await userEnvironmentsService.deleteUserEnvironment('abc123', 'env1'); + + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_environment/${result.userUid}/deleted`, + result, + ); + }); + }); + + describe('deleteUserEnvironments', () => { + test('Should publish a subscription with a count of deleted environments', async () => { + mockPrisma.userEnvironment.deleteMany.mockResolvedValueOnce({ + count: 1, + }); + + await userEnvironmentsService.deleteUserEnvironments('abc123'); + + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_environment/${'abc123'}/deleted_many`, + 1, + ); + }); + }); + + describe('clearGlobalEnvironments', () => { + test('Should resolve right and delete all variables inside users global environment and return a `UserEnvironment` object', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce({ + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: [{}], + isGlobal: true, + }); + + mockPrisma.userEnvironment.update.mockResolvedValueOnce({ + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: [], + isGlobal: true, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: JSON.stringify([]), + isGlobal: true, + }; + + return expect( + await userEnvironmentsService.clearGlobalEnvironments('abc123', 'env1'), + ).toEqualRight(result); + }); + + test('Should resolve left and return an error if global environment id and passed id dont match', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce({ + userUid: 'abc123', + id: 'genv2', + name: 'en1', + variables: [{}], + isGlobal: true, + }); + + return expect( + await userEnvironmentsService.deleteUserEnvironment('abc123', 'genv1'), + ).toEqualLeft(USER_ENVIRONMENT_ENV_DOES_NOT_EXIST); + }); + + test('Should resolve right,delete all variables inside users global environment and publish an updated subscription', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce({ + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: [{}], + isGlobal: true, + }); + + mockPrisma.userEnvironment.update.mockResolvedValueOnce({ + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: [], + isGlobal: true, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: JSON.stringify([]), + isGlobal: true, + }; + + await userEnvironmentsService.clearGlobalEnvironments('abc123', 'env1'); + + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_environment/${result.userUid}/updated`, + result, + ); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/user-environment/user-environments.service.ts b/packages/hoppscotch-backend/src/user-environment/user-environments.service.ts new file mode 100644 index 0000000..e91d294 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user-environments.service.ts @@ -0,0 +1,286 @@ +import { Injectable } from '@nestjs/common'; +import { UserEnvironment } from './user-environments.model'; +import { PrismaService } from '../prisma/prisma.service'; +import { PubSubService } from '../pubsub/pubsub.service'; +import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; +import { + USER_ENVIRONMENT_ENV_DOES_NOT_EXIST, + USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXIST, + USER_ENVIRONMENT_GLOBAL_ENV_DELETION_FAILED, + USER_ENVIRONMENT_GLOBAL_ENV_EXISTS, + USER_ENVIRONMENT_IS_NOT_GLOBAL, + USER_ENVIRONMENT_UPDATE_FAILED, + USER_ENVIRONMENT_INVALID_ENVIRONMENT_NAME, +} from '../errors'; +import { stringToJson } from '../utils'; +import { User } from '../user/user.model'; + +@Injectable() +export class UserEnvironmentsService { + constructor( + private readonly prisma: PrismaService, + private readonly pubsub: PubSubService, + ) {} + + /** + * Fetch personal user environments + * @param uid Users uid + * @returns array of users personal environments + */ + async fetchUserEnvironments(uid: string) { + const environments = await this.prisma.userEnvironment.findMany({ + where: { + userUid: uid, + isGlobal: false, + }, + }); + + const userEnvironments: UserEnvironment[] = []; + environments.forEach((environment) => { + userEnvironments.push({ + userUid: environment.userUid, + id: environment.id, + name: environment.name, + variables: JSON.stringify(environment.variables), + isGlobal: environment.isGlobal, + }); + }); + return userEnvironments; + } + + /** + * Fetch users global environment + * @param uid Users uid + * @returns an `UserEnvironment` object + */ + async fetchUserGlobalEnvironment(uid: string) { + const globalEnvironment = await this.prisma.userEnvironment.findFirst({ + where: { + userUid: uid, + isGlobal: true, + }, + }); + + if (globalEnvironment != null) { + return E.right({ + userUid: globalEnvironment.userUid, + id: globalEnvironment.id, + name: globalEnvironment.name, + variables: JSON.stringify(globalEnvironment.variables), + isGlobal: globalEnvironment.isGlobal, + }); + } + + return E.left(USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXIST); + } + + /** + * Create a personal or global user environment + * @param uid Users uid + * @param name environments name, null if the environment is global + * @param variables environment variables + * @param isGlobal flag to indicate type of environment to create + * @returns an `UserEnvironment` object + */ + async createUserEnvironment( + uid: string, + name: string, + variables: string, + isGlobal: boolean, + ) { + // Check for existing global env for a user if exists error out to avoid recreation + if (isGlobal) { + const globalEnvExists = await this.checkForExistingGlobalEnv(uid); + if (!O.isNone(globalEnvExists)) + return E.left(USER_ENVIRONMENT_GLOBAL_ENV_EXISTS); + } + if (name === null && !isGlobal) + return E.left(USER_ENVIRONMENT_INVALID_ENVIRONMENT_NAME); + + const envVariables = stringToJson(variables); + if (E.isLeft(envVariables)) return E.left(envVariables.left); + const createdEnvironment = await this.prisma.userEnvironment.create({ + data: { + userUid: uid, + name: name, + variables: envVariables.right, + isGlobal: isGlobal, + }, + }); + + const userEnvironment: UserEnvironment = { + userUid: createdEnvironment.userUid, + id: createdEnvironment.id, + name: createdEnvironment.name, + variables: JSON.stringify(createdEnvironment.variables), + isGlobal: createdEnvironment.isGlobal, + }; + // Publish subscription for environment creation + await this.pubsub.publish( + `user_environment/${userEnvironment.userUid}/created`, + userEnvironment, + ); + return E.right(userEnvironment); + } + + /** + * Update an existing personal or global user environment + * @param id environment id + * @param name environments name + * @param variables environment variables + * @param user User object for authorization + * @returns an Either of `UserEnvironment` or error + */ + async updateUserEnvironment( + id: string, + name: string, + variables: string, + user: User, + ) { + const envVariables = stringToJson(variables); + if (E.isLeft(envVariables)) return E.left(envVariables.left); + try { + const updatedEnvironment = await this.prisma.userEnvironment.update({ + where: { id: id, userUid: user.uid }, + data: { + name: name, + variables: envVariables.right, + }, + }); + + const updatedUserEnvironment: UserEnvironment = { + userUid: updatedEnvironment.userUid, + id: updatedEnvironment.id, + name: updatedEnvironment.name, + variables: JSON.stringify(updatedEnvironment.variables), + isGlobal: updatedEnvironment.isGlobal, + }; + // Publish subscription for environment update + await this.pubsub.publish( + `user_environment/${updatedUserEnvironment.userUid}/updated`, + updatedUserEnvironment, + ); + return E.right(updatedUserEnvironment); + } catch (e) { + return E.left(USER_ENVIRONMENT_ENV_DOES_NOT_EXIST); + } + } + + /** + * Delete an existing personal user environment based on environment id + * @param uid users uid + * @param id environment id + * @returns an Either of deleted `UserEnvironment` or error + */ + async deleteUserEnvironment(uid: string, id: string) { + try { + // check if id is of a global environment if it is, don't delete and error out + const globalEnvExists = await this.checkForExistingGlobalEnv(uid); + if (O.isSome(globalEnvExists)) { + const globalEnv = globalEnvExists.value; + if (globalEnv.id === id) { + return E.left(USER_ENVIRONMENT_GLOBAL_ENV_DELETION_FAILED); + } + } + const deletedEnvironment = await this.prisma.userEnvironment.delete({ + where: { + id: id, + userUid: uid, + }, + }); + + const deletedUserEnvironment: UserEnvironment = { + userUid: deletedEnvironment.userUid, + id: deletedEnvironment.id, + name: deletedEnvironment.name, + variables: JSON.stringify(deletedEnvironment.variables), + isGlobal: deletedEnvironment.isGlobal, + }; + + // Publish subscription for environment deletion + await this.pubsub.publish( + `user_environment/${deletedUserEnvironment.userUid}/deleted`, + deletedUserEnvironment, + ); + return E.right(true); + } catch (e) { + return E.left(USER_ENVIRONMENT_ENV_DOES_NOT_EXIST); + } + } + + /** + * Deletes all existing personal user environments + * @param uid user uid + * @returns a count of environments deleted + */ + async deleteUserEnvironments(uid: string) { + const deletedEnvironments = await this.prisma.userEnvironment.deleteMany({ + where: { + userUid: uid, + isGlobal: false, + }, + }); + + // Publish subscription for multiple environment deletions + await this.pubsub.publish( + `user_environment/${uid}/deleted_many`, + deletedEnvironments.count, + ); + + return deletedEnvironments.count; + } + + /** + * Removes all existing variables in a users global environment + * @param uid users uid + * @param id environment id + * @returns an `` of environments deleted + */ + async clearGlobalEnvironments(uid: string, id: string) { + const globalEnvExists = await this.checkForExistingGlobalEnv(uid); + if (O.isNone(globalEnvExists)) + return E.left(USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXIST); + + const env = globalEnvExists.value; + if (env.id === id) { + try { + const updatedEnvironment = await this.prisma.userEnvironment.update({ + where: { id: id, userUid: uid }, + data: { + variables: [], + }, + }); + const updatedUserEnvironment: UserEnvironment = { + userUid: updatedEnvironment.userUid, + id: updatedEnvironment.id, + name: updatedEnvironment.name, + variables: JSON.stringify(updatedEnvironment.variables), + isGlobal: updatedEnvironment.isGlobal, + }; + + // Publish subscription for environment update + await this.pubsub.publish( + `user_environment/${updatedUserEnvironment.userUid}/updated`, + updatedUserEnvironment, + ); + return E.right(updatedUserEnvironment); + } catch (e) { + return E.left(USER_ENVIRONMENT_UPDATE_FAILED); + } + } else return E.left(USER_ENVIRONMENT_IS_NOT_GLOBAL); + } + + // Method to check for existing global environments for a given user uid + private async checkForExistingGlobalEnv(uid: string) { + const globalEnv = await this.prisma.userEnvironment.findFirst({ + where: { + userUid: uid, + isGlobal: true, + }, + }); + + if (globalEnv == null) return O.none; + return O.some(globalEnv); + } +} diff --git a/packages/hoppscotch-backend/src/user-environment/user.resolver.ts b/packages/hoppscotch-backend/src/user-environment/user.resolver.ts new file mode 100644 index 0000000..e4bab77 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user.resolver.ts @@ -0,0 +1,44 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { UseGuards } from '@nestjs/common'; +import { User } from 'src/user/user.model'; +import { UserEnvironment } from './user-environments.model'; +import { UserEnvironmentsService } from './user-environments.service'; +import * as E from 'fp-ts/Either'; +import { throwErr } from '../utils'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { GqlUser } from '../decorators/gql-user.decorator'; +import { USER_ENVIRONMENT_ENV_DOES_NOT_EXIST } from '../errors'; + +@Resolver(() => User) +export class UserEnvsUserResolver { + constructor(private userEnvironmentsService: UserEnvironmentsService) {} + + @ResolveField(() => [UserEnvironment], { + description: 'Returns a list of users personal environments', + }) + @UseGuards(GqlAuthGuard) + async environments( + @Parent() user: User, + @GqlUser() requestingUser: User, + ): Promise { + if (requestingUser?.uid !== user.uid) return []; + return await this.userEnvironmentsService.fetchUserEnvironments(user.uid); + } + + @ResolveField(() => UserEnvironment, { + description: 'Returns the users global environments', + }) + @UseGuards(GqlAuthGuard) + async globalEnvironments( + @Parent() user: User, + @GqlUser() requestingUser: User, + ): Promise { + if (requestingUser?.uid !== user.uid) { + throwErr(USER_ENVIRONMENT_ENV_DOES_NOT_EXIST); + } + const userEnvironment = + await this.userEnvironmentsService.fetchUserGlobalEnvironment(user.uid); + if (E.isLeft(userEnvironment)) throwErr(userEnvironment.left); + return userEnvironment.right; + } +} diff --git a/packages/hoppscotch-backend/src/user-history/user-history-feature-flag.guard.ts b/packages/hoppscotch-backend/src/user-history/user-history-feature-flag.guard.ts new file mode 100644 index 0000000..d878b4a --- /dev/null +++ b/packages/hoppscotch-backend/src/user-history/user-history-feature-flag.guard.ts @@ -0,0 +1,21 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { USER_HISTORY_FEATURE_FLAG_DISABLED } from 'src/errors'; +import { InfraConfigService } from 'src/infra-config/infra-config.service'; +import { throwErr } from 'src/utils'; +import * as E from 'fp-ts/Either'; +import { ServiceStatus } from 'src/infra-config/helper'; + +@Injectable() +export class UserHistoryFeatureFlagGuard implements CanActivate { + constructor(private readonly infraConfigService: InfraConfigService) {} + + async canActivate(context: ExecutionContext): Promise { + const isEnabled = await this.infraConfigService.isUserHistoryEnabled(); + if (E.isLeft(isEnabled)) throwErr(isEnabled.left); + + if (isEnabled.right.value !== ServiceStatus.ENABLE) + throwErr(USER_HISTORY_FEATURE_FLAG_DISABLED); + + return true; + } +} diff --git a/packages/hoppscotch-backend/src/user-history/user-history.model.ts b/packages/hoppscotch-backend/src/user-history/user-history.model.ts new file mode 100644 index 0000000..c384391 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-history/user-history.model.ts @@ -0,0 +1,57 @@ +import { Field, ID, ObjectType, registerEnumType } from '@nestjs/graphql'; +import { ReqType } from 'src/types/RequestTypes'; + +@ObjectType() +export class UserHistory { + @Field(() => ID, { + description: 'ID of the user request in history', + }) + id: string; + + @Field(() => ID, { + description: 'ID of the user this history belongs to', + }) + userUid: string; + + @Field(() => ReqType, { + description: 'Type of the request in the history', + }) + reqType: ReqType; + + @Field({ + description: 'JSON string representing the request data', + }) + request: string; + + @Field({ + description: 'JSON string representing the response meta-data', + }) + responseMetadata: string; + + @Field({ + description: 'If the request in the history starred', + }) + isStarred: boolean; + + @Field({ + description: + 'Timestamp of when the request was executed or history was created', + }) + executedOn: Date; +} + +@ObjectType() +export class UserHistoryDeletedManyData { + @Field(() => Number, { + description: 'Number of user histories deleted', + }) + count: number; + @Field(() => ReqType, { + description: 'Type of the request in the history', + }) + reqType: ReqType; +} + +registerEnumType(ReqType, { + name: 'ReqType', +}); diff --git a/packages/hoppscotch-backend/src/user-history/user-history.module.ts b/packages/hoppscotch-backend/src/user-history/user-history.module.ts new file mode 100644 index 0000000..314d6b8 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-history/user-history.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { UserModule } from '../user/user.module'; +import { UserHistoryUserResolver } from './user.resolver'; +import { UserHistoryResolver } from './user-history.resolver'; +import { UserHistoryService } from './user-history.service'; +import { InfraConfigModule } from 'src/infra-config/infra-config.module'; + +@Module({ + imports: [UserModule, InfraConfigModule], + providers: [UserHistoryResolver, UserHistoryService, UserHistoryUserResolver], + exports: [UserHistoryService], +}) +export class UserHistoryModule {} diff --git a/packages/hoppscotch-backend/src/user-history/user-history.resolver.ts b/packages/hoppscotch-backend/src/user-history/user-history.resolver.ts new file mode 100644 index 0000000..13551a1 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-history/user-history.resolver.ts @@ -0,0 +1,170 @@ +import { Args, ID, Mutation, Resolver, Subscription } from '@nestjs/graphql'; +import { UserHistoryService } from './user-history.service'; +import { PubSubService } from '../pubsub/pubsub.service'; +import { UserHistory, UserHistoryDeletedManyData } from './user-history.model'; +import { ReqType } from 'src/types/RequestTypes'; +import { UseGuards } from '@nestjs/common'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { GqlUser } from '../decorators/gql-user.decorator'; +import { User } from '../user/user.model'; +import { throwErr } from '../utils'; +import * as E from 'fp-ts/Either'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { SkipThrottle } from '@nestjs/throttler'; +import { UserHistoryFeatureFlagGuard } from './user-history-feature-flag.guard'; + +@UseGuards(GqlThrottlerGuard, UserHistoryFeatureFlagGuard) +@Resolver() +export class UserHistoryResolver { + constructor( + private readonly userHistoryService: UserHistoryService, + private readonly pubsub: PubSubService, + ) {} + + /* Mutations */ + + @Mutation(() => UserHistory, { + description: 'Adds a new REST/GQL request to user history', + }) + @UseGuards(GqlAuthGuard) + async createUserHistory( + @GqlUser() user: User, + @Args({ + name: 'reqData', + description: 'JSON string of the request data', + }) + reqData: string, + @Args({ + name: 'resMetadata', + description: 'JSON string of the response metadata', + }) + resMetadata: string, + @Args({ + name: 'reqType', + description: 'Request type, REST or GQL', + type: () => ReqType, + }) + reqType: ReqType, + ): Promise { + const createdHistory = await this.userHistoryService.createUserHistory( + user.uid, + reqData, + resMetadata, + reqType, + ); + if (E.isLeft(createdHistory)) throwErr(createdHistory.left); + return createdHistory.right; + } + + @Mutation(() => UserHistory, { + description: 'Stars/Unstars a REST/GQL request in user history', + }) + @UseGuards(GqlAuthGuard) + async toggleHistoryStarStatus( + @GqlUser() user: User, + @Args({ + name: 'id', + description: 'ID of User History', + type: () => ID, + }) + id: string, + ): Promise { + const updatedHistory = + await this.userHistoryService.toggleHistoryStarStatus(user.uid, id); + if (E.isLeft(updatedHistory)) throwErr(updatedHistory.left); + return updatedHistory.right; + } + + @Mutation(() => UserHistory, { + description: 'Removes a REST/GQL request from user history', + }) + @UseGuards(GqlAuthGuard) + async removeRequestFromHistory( + @GqlUser() user: User, + @Args({ + name: 'id', + description: 'ID of User History', + type: () => ID, + }) + id: string, + ): Promise { + const deletedHistory = + await this.userHistoryService.removeRequestFromHistory(user.uid, id); + if (E.isLeft(deletedHistory)) throwErr(deletedHistory.left); + return deletedHistory.right; + } + + @Mutation(() => UserHistoryDeletedManyData, { + description: + 'Deletes all REST/GQL history for a user based on Request type', + }) + @UseGuards(GqlAuthGuard) + async deleteAllUserHistory( + @GqlUser() user: User, + @Args({ + name: 'reqType', + description: 'Request type, REST or GQL', + type: () => ReqType, + }) + reqType: ReqType, + ): Promise { + const deletedHistory = await this.userHistoryService.deleteAllUserHistory( + user.uid, + reqType, + ); + if (E.isLeft(deletedHistory)) throwErr(deletedHistory.left); + return deletedHistory.right; + } + + /* Subscriptions */ + + @Subscription(() => UserHistory, { + description: 'Listen for User History Creation', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userHistoryCreated(@GqlUser() user: User) { + return this.pubsub.asyncIterator(`user_history/${user.uid}/created`); + } + + @Subscription(() => UserHistory, { + description: 'Listen for User History update', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userHistoryUpdated(@GqlUser() user: User) { + return this.pubsub.asyncIterator(`user_history/${user.uid}/updated`); + } + + @Subscription(() => UserHistory, { + description: 'Listen for User History deletion', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userHistoryDeleted(@GqlUser() user: User) { + return this.pubsub.asyncIterator(`user_history/${user.uid}/deleted`); + } + + @Subscription(() => UserHistoryDeletedManyData, { + description: 'Listen for User History deleted many', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userHistoryDeletedMany(@GqlUser() user: User) { + return this.pubsub.asyncIterator(`user_history/${user.uid}/deleted_many`); + } + + @Subscription(() => Boolean, { + description: 'Listen for All User History deleted', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userHistoryAllDeleted() { + return this.pubsub.asyncIterator(`user_history/all/deleted`); + } +} diff --git a/packages/hoppscotch-backend/src/user-history/user-history.service.spec.ts b/packages/hoppscotch-backend/src/user-history/user-history.service.spec.ts new file mode 100644 index 0000000..bf27ff4 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-history/user-history.service.spec.ts @@ -0,0 +1,616 @@ +import { UserHistoryService } from './user-history.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { PubSubService } from '../pubsub/pubsub.service'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { UserHistory } from './user-history.model'; +import { ReqType } from 'src/types/RequestTypes'; +import { + USER_HISTORY_INVALID_REQ_TYPE, + USER_HISTORY_NOT_FOUND, +} from '../errors'; + +const mockPrisma = mockDeep(); +const mockPubSub = mockDeep(); + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const userHistoryService = new UserHistoryService( + mockPrisma, + mockPubSub as any, +); + +beforeEach(() => { + mockReset(mockPrisma); + mockPubSub.publish.mockClear(); +}); + +const date = new Date(); + +describe('UserHistoryService', () => { + describe('fetchUserHistory', () => { + test('Should return a list of users REST history if exists', async () => { + const executedOn = new Date(); + mockPrisma.userHistory.findMany.mockResolvedValueOnce([ + { + userUid: 'abc', + id: '1', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.REST, + executedOn: executedOn, + isStarred: false, + }, + { + userUid: 'abc', + id: '2', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.REST, + executedOn: executedOn, + isStarred: true, + }, + ]); + + const userHistory: UserHistory[] = [ + { + userUid: 'abc', + id: '1', + request: JSON.stringify([{}]), + responseMetadata: JSON.stringify([{}]), + reqType: ReqType.REST, + executedOn: executedOn, + isStarred: false, + }, + { + userUid: 'abc', + id: '2', + request: JSON.stringify([{}]), + responseMetadata: JSON.stringify([{}]), + reqType: ReqType.REST, + executedOn: executedOn, + isStarred: true, + }, + ]; + + return expect( + await userHistoryService.fetchUserHistory('abc', 2, ReqType.REST), + ).toEqual(userHistory); + }); + test('Should return a list of users GQL history if exists', async () => { + const executedOn = new Date(); + mockPrisma.userHistory.findMany.mockResolvedValueOnce([ + { + userUid: 'abc', + id: '1', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.GQL, + executedOn: executedOn, + isStarred: false, + }, + { + userUid: 'abc', + id: '2', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.GQL, + executedOn: executedOn, + isStarred: true, + }, + ]); + + const userHistory: UserHistory[] = [ + { + userUid: 'abc', + id: '1', + request: JSON.stringify([{}]), + responseMetadata: JSON.stringify([{}]), + reqType: ReqType.GQL, + executedOn: executedOn, + isStarred: false, + }, + { + userUid: 'abc', + id: '2', + request: JSON.stringify([{}]), + responseMetadata: JSON.stringify([{}]), + reqType: ReqType.GQL, + executedOn: executedOn, + isStarred: true, + }, + ]; + return expect( + await userHistoryService.fetchUserHistory('abc', 2, ReqType.GQL), + ).toEqual(userHistory); + }); + test('Should return an empty list of users REST history if doesnt exists', async () => { + mockPrisma.userHistory.findMany.mockResolvedValueOnce([]); + + const userHistory: UserHistory[] = []; + return expect( + await userHistoryService.fetchUserHistory('abc', 2, ReqType.REST), + ).toEqual(userHistory); + }); + test('Should return an empty list of users GQL history if doesnt exists', async () => { + mockPrisma.userHistory.findMany.mockResolvedValueOnce([]); + + const userHistory: UserHistory[] = []; + return expect( + await userHistoryService.fetchUserHistory('abc', 2, ReqType.GQL), + ).toEqual(userHistory); + }); + }); + describe('createUserHistory', () => { + test('Should resolve right and create a REST request to users history and return a `UserHistory` object', async () => { + const executedOn = new Date(); + + mockPrisma.userHistory.create.mockResolvedValueOnce({ + userUid: 'abc', + id: '1', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.REST, + executedOn, + isStarred: false, + }); + + const userHistory: UserHistory = { + userUid: 'abc', + id: '1', + request: JSON.stringify([{}]), + responseMetadata: JSON.stringify([{}]), + reqType: ReqType.REST, + executedOn, + isStarred: false, + }; + + return expect( + await userHistoryService.createUserHistory( + 'abc', + JSON.stringify([{}]), + JSON.stringify([{}]), + 'REST', + ), + ).toEqualRight(userHistory); + }); + test('Should resolve right and create a GQL request to users history and return a `UserHistory` object', async () => { + const executedOn = new Date(); + + mockPrisma.userHistory.create.mockResolvedValueOnce({ + userUid: 'abc', + id: '1', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.GQL, + executedOn, + isStarred: false, + }); + + const userHistory: UserHistory = { + userUid: 'abc', + id: '1', + request: JSON.stringify([{}]), + responseMetadata: JSON.stringify([{}]), + reqType: ReqType.GQL, + executedOn, + isStarred: false, + }; + + return expect( + await userHistoryService.createUserHistory( + 'abc', + JSON.stringify([{}]), + JSON.stringify([{}]), + 'GQL', + ), + ).toEqualRight(userHistory); + }); + test('Should resolve left when invalid ReqType is passed', async () => { + return expect( + await userHistoryService.createUserHistory( + 'abc', + JSON.stringify([{}]), + JSON.stringify([{}]), + 'INVALID', + ), + ).toEqualLeft(USER_HISTORY_INVALID_REQ_TYPE); + }); + test('Should create a GQL request to users history and publish a created subscription', async () => { + const executedOn = new Date(); + + mockPrisma.userHistory.create.mockResolvedValueOnce({ + userUid: 'abc', + id: '1', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.GQL, + executedOn, + isStarred: false, + }); + + const userHistory: UserHistory = { + userUid: 'abc', + id: '1', + request: JSON.stringify([{}]), + responseMetadata: JSON.stringify([{}]), + reqType: ReqType.GQL, + executedOn, + isStarred: false, + }; + + await userHistoryService.createUserHistory( + 'abc', + JSON.stringify([{}]), + JSON.stringify([{}]), + 'GQL', + ); + + return expect(await mockPubSub.publish).toHaveBeenCalledWith( + `user_history/${userHistory.userUid}/created`, + userHistory, + ); + }); + test('Should create a REST request to users history and publish a created subscription', async () => { + const executedOn = new Date(); + + mockPrisma.userHistory.create.mockResolvedValueOnce({ + userUid: 'abc', + id: '1', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.REST, + executedOn, + isStarred: false, + }); + + const userHistory: UserHistory = { + userUid: 'abc', + id: '1', + request: JSON.stringify([{}]), + responseMetadata: JSON.stringify([{}]), + reqType: ReqType.REST, + executedOn, + isStarred: false, + }; + + await userHistoryService.createUserHistory( + 'abc', + JSON.stringify([{}]), + JSON.stringify([{}]), + 'REST', + ); + + return expect(await mockPubSub.publish).toHaveBeenCalledWith( + `user_history/${userHistory.userUid}/created`, + userHistory, + ); + }); + }); + describe('toggleHistoryStarStatus', () => { + test('Should resolve right and star/unstar a request in the history', async () => { + const createdOnDate = new Date(); + mockPrisma.userHistory.findFirst.mockResolvedValueOnce({ + userUid: 'abc', + id: '1', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.REST, + executedOn: createdOnDate, + isStarred: false, + }); + + mockPrisma.userHistory.update.mockResolvedValueOnce({ + userUid: 'abc', + id: '1', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.REST, + executedOn: createdOnDate, + isStarred: true, + }); + + const userHistory: UserHistory = { + userUid: 'abc', + id: '1', + request: JSON.stringify([{}]), + responseMetadata: JSON.stringify([{}]), + reqType: ReqType.REST, + executedOn: createdOnDate, + isStarred: true, + }; + + return expect( + await userHistoryService.toggleHistoryStarStatus('abc', '1'), + ).toEqualRight(userHistory); + }); + test('Should resolve left and error out due to invalid user history request ID', async () => { + mockPrisma.userHistory.findFirst.mockResolvedValueOnce(null); + + return expect( + await userHistoryService.toggleHistoryStarStatus('abc', '1'), + ).toEqualLeft(USER_HISTORY_NOT_FOUND); + }); + test('Should star/unstar a request in the history and publish a updated subscription', async () => { + const executedOn = new Date(); + + mockPrisma.userHistory.findFirst.mockResolvedValueOnce({ + userUid: 'abc', + id: '1', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.REST, + executedOn, + isStarred: false, + }); + + mockPrisma.userHistory.update.mockResolvedValueOnce({ + userUid: 'abc', + id: '1', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.REST, + executedOn, + isStarred: true, + }); + + const userHistory: UserHistory = { + userUid: 'abc', + id: '1', + request: JSON.stringify([{}]), + responseMetadata: JSON.stringify([{}]), + reqType: ReqType.REST, + executedOn, + isStarred: true, + }; + + await userHistoryService.toggleHistoryStarStatus('abc', '1'); + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_history/${userHistory.userUid}/updated`, + userHistory, + ); + }); + test('Should scope the lookup and the update to the requesting user (ownership enforcement)', async () => { + const executedOn = new Date(); + + mockPrisma.userHistory.findFirst.mockResolvedValueOnce({ + userUid: 'abc', + id: '1', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.REST, + executedOn, + isStarred: false, + }); + mockPrisma.userHistory.update.mockResolvedValueOnce({ + userUid: 'abc', + id: '1', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.REST, + executedOn, + isStarred: true, + }); + + await userHistoryService.toggleHistoryStarStatus('abc', '1'); + + expect(mockPrisma.userHistory.findFirst).toHaveBeenCalledWith({ + where: { id: '1', userUid: 'abc' }, + }); + expect(mockPrisma.userHistory.update).toHaveBeenCalledWith({ + where: { id: '1', userUid: 'abc' }, + data: { isStarred: true }, + }); + }); + test('Should resolve left when the history entry is not owned by the requesting user', async () => { + // Scoped lookup finds nothing because the entry belongs to another user + mockPrisma.userHistory.findFirst.mockResolvedValueOnce(null); + + const result = await userHistoryService.toggleHistoryStarStatus( + 'attacker', + '1', + ); + + expect(result).toEqualLeft(USER_HISTORY_NOT_FOUND); + // The lookup must be scoped to the requesting user's uid + expect(mockPrisma.userHistory.findFirst).toHaveBeenCalledWith({ + where: { id: '1', userUid: 'attacker' }, + }); + }); + }); + describe('removeRequestFromHistory', () => { + test('Should resolve right and delete request from users history', async () => { + const executedOn = new Date(); + + mockPrisma.userHistory.delete.mockResolvedValueOnce({ + userUid: 'abc', + id: '1', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.REST, + executedOn: executedOn, + isStarred: false, + }); + + const userHistory: UserHistory = { + userUid: 'abc', + id: '1', + request: JSON.stringify([{}]), + responseMetadata: JSON.stringify([{}]), + reqType: ReqType.REST, + executedOn: executedOn, + isStarred: false, + }; + + return expect( + await userHistoryService.removeRequestFromHistory('abc', '1'), + ).toEqualRight(userHistory); + }); + test('Should resolve left and error out when req id is invalid', async () => { + mockPrisma.userHistory.delete.mockResolvedValueOnce(null); + + return expect( + await userHistoryService.removeRequestFromHistory('abc', '1'), + ).toEqualLeft(USER_HISTORY_NOT_FOUND); + }); + test('Should delete request from users history and publish deleted subscription', async () => { + mockPrisma.userHistory.delete.mockResolvedValueOnce({ + userUid: 'abc', + id: '1', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.REST, + executedOn: date, + isStarred: false, + }); + + const userHistory: UserHistory = { + userUid: 'abc', + id: '1', + request: JSON.stringify([{}]), + responseMetadata: JSON.stringify([{}]), + reqType: ReqType.REST, + executedOn: date, + isStarred: false, + }; + + await userHistoryService.removeRequestFromHistory('abc', '1'); + + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_history/${userHistory.userUid}/deleted`, + userHistory, + ); + }); + test('Should scope the delete to the requesting user (ownership enforcement)', async () => { + mockPrisma.userHistory.delete.mockResolvedValueOnce({ + userUid: 'abc', + id: '1', + request: [{}], + responseMetadata: [{}], + reqType: ReqType.REST, + executedOn: date, + isStarred: false, + }); + + await userHistoryService.removeRequestFromHistory('abc', '1'); + + expect(mockPrisma.userHistory.delete).toHaveBeenCalledWith({ + where: { id: '1', userUid: 'abc' }, + }); + }); + test('Should resolve left when deleting a history entry not owned by the requesting user', async () => { + // Prisma throws when no row matches the user-scoped where clause + mockPrisma.userHistory.delete.mockRejectedValueOnce(new Error('P2025')); + + const result = await userHistoryService.removeRequestFromHistory( + 'attacker', + '1', + ); + + expect(result).toEqualLeft(USER_HISTORY_NOT_FOUND); + // The delete must be scoped to the requesting user's uid + expect(mockPrisma.userHistory.delete).toHaveBeenCalledWith({ + where: { id: '1', userUid: 'attacker' }, + }); + }); + }); + describe('deleteAllUserHistory', () => { + test('Should resolve right and delete all user REST history for a request type', async () => { + mockPrisma.userHistory.deleteMany.mockResolvedValueOnce({ + count: 2, + }); + + return expect( + await userHistoryService.deleteAllUserHistory('abc', 'REST'), + ).toEqualRight({ + count: 2, + reqType: ReqType.REST, + }); + }); + test('Should resolve right and delete all user GQL history for a request type', async () => { + mockPrisma.userHistory.deleteMany.mockResolvedValueOnce({ + count: 2, + }); + + return expect( + await userHistoryService.deleteAllUserHistory('abc', 'GQL'), + ).toEqualRight({ + count: 2, + reqType: ReqType.GQL, + }); + }); + test('Should resolve left and error when ReqType is invalid', async () => { + return expect( + await userHistoryService.deleteAllUserHistory('abc', 'INVALID'), + ).toEqualLeft(USER_HISTORY_INVALID_REQ_TYPE); + }); + test('Should delete all user REST history for a request type and publish deleted many subscription', async () => { + mockPrisma.userHistory.deleteMany.mockResolvedValueOnce({ + count: 2, + }); + + await userHistoryService.deleteAllUserHistory('abc', 'REST'); + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_history/abc/deleted_many`, + { + count: 2, + reqType: ReqType.REST, + }, + ); + }); + test('Should delete all user GQL history for a request type and publish deleted many subscription', async () => { + mockPrisma.userHistory.deleteMany.mockResolvedValueOnce({ + count: 2, + }); + + await userHistoryService.deleteAllUserHistory('abc', 'GQL'); + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_history/abc/deleted_many`, + { + count: 2, + reqType: ReqType.GQL, + }, + ); + }); + }); + describe('deleteAllHistories', () => { + test('Should resolve right and delete all user history', async () => { + mockPrisma.userHistory.deleteMany.mockResolvedValueOnce({ + count: 2, + }); + + return expect(await userHistoryService.deleteAllHistories()).toEqualRight( + true, + ); + }); + test('Should publish all user history delete event', async () => { + mockPrisma.userHistory.deleteMany.mockResolvedValueOnce({ + count: 2, + }); + + await userHistoryService.deleteAllHistories(); + + expect(mockPubSub.publish).toHaveBeenCalledTimes(1); + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_history/all/deleted`, + true, + ); + }); + }); + describe('validateReqType', () => { + test('Should resolve right when a valid REST ReqType is provided', async () => { + return expect(userHistoryService.validateReqType('REST')).toEqualRight( + ReqType.REST, + ); + }); + test('Should resolve right when a valid GQL ReqType is provided', async () => { + return expect(userHistoryService.validateReqType('GQL')).toEqualRight( + ReqType.GQL, + ); + }); + test('Should resolve left and error out when a invalid ReqType is provided', async () => { + return expect(userHistoryService.validateReqType('INVALID')).toEqualLeft( + USER_HISTORY_INVALID_REQ_TYPE, + ); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/user-history/user-history.service.ts b/packages/hoppscotch-backend/src/user-history/user-history.service.ts new file mode 100644 index 0000000..c16978d --- /dev/null +++ b/packages/hoppscotch-backend/src/user-history/user-history.service.ts @@ -0,0 +1,237 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { PubSubService } from '../pubsub/pubsub.service'; +import { UserHistory } from './user-history.model'; +import { ReqType } from 'src/types/RequestTypes'; +import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; +import { + USER_HISTORY_DELETION_FAILED, + USER_HISTORY_INVALID_REQ_TYPE, + USER_HISTORY_NOT_FOUND, +} from '../errors'; + +@Injectable() +export class UserHistoryService { + constructor( + private readonly prisma: PrismaService, + private readonly pubsub: PubSubService, + ) {} + + /** + * Fetch users REST or GraphQL history based on ReqType param. + * @param uid Users uid + * @param take items to fetch + * @param reqType request Type to fetch i.e. GraphQL or REST + * @returns an array of user history + */ + async fetchUserHistory(uid: string, take: number, reqType: ReqType) { + const userHistory = await this.prisma.userHistory.findMany({ + where: { + userUid: uid, + reqType: reqType, + }, + take: take, + orderBy: { + executedOn: 'desc', + }, + }); + + const userHistoryColl: UserHistory[] = userHistory.map( + (history) => + { + ...history, + request: JSON.stringify(history.request), + responseMetadata: JSON.stringify(history.responseMetadata), + }, + ); + + return userHistoryColl; + } + + /** + * Creates a user history. + * @param uid Users uid + * @param reqData the request data + * @param resMetadata the response metadata + * @param reqType request Type to fetch i.e. GraphQL or REST + * @returns a `UserHistory` object + */ + async createUserHistory( + uid: string, + reqData: string, + resMetadata: string, + reqType: string, + ) { + const requestType = this.validateReqType(reqType); + if (E.isLeft(requestType)) return E.left(requestType.left); + + const history = await this.prisma.userHistory.create({ + data: { + userUid: uid, + request: JSON.parse(reqData), + responseMetadata: JSON.parse(resMetadata), + reqType: requestType.right, + isStarred: false, + }, + }); + + const userHistory = { + ...history, + reqType: history.reqType, + request: JSON.stringify(history.request), + responseMetadata: JSON.stringify(history.responseMetadata), + }; + + // Publish created user history subscription + await this.pubsub.publish( + `user_history/${userHistory.userUid}/created`, + userHistory, + ); + + return E.right(userHistory); + } + + /** + * Toggles star status of a user history + * @param uid Users uid + * @param id id of the request in the history + * @returns an Either of updated `UserHistory` or Error + */ + async toggleHistoryStarStatus(uid: string, id: string) { + const userHistory = await this.fetchUserHistoryByID(id, uid); + if (O.isNone(userHistory)) { + return E.left(USER_HISTORY_NOT_FOUND); + } + + try { + const updatedHistory = await this.prisma.userHistory.update({ + where: { + id: id, + userUid: uid, + }, + data: { + isStarred: !userHistory.value.isStarred, + }, + }); + + const updatedUserHistory = { + ...updatedHistory, + request: JSON.stringify(updatedHistory.request), + responseMetadata: JSON.stringify(updatedHistory.responseMetadata), + }; + + // Publish updated user history subscription + await this.pubsub.publish( + `user_history/${updatedUserHistory.userUid}/updated`, + updatedUserHistory, + ); + return E.right(updatedUserHistory); + } catch (e) { + return E.left(USER_HISTORY_NOT_FOUND); + } + } + + /** + * Removes a REST/GraphQL request from the history + * @param uid Users uid + * @param id id of the request + * @returns an Either of deleted `UserHistory` or Error + */ + async removeRequestFromHistory(uid: string, id: string) { + try { + const delUserHistory = await this.prisma.userHistory.delete({ + where: { + id: id, + userUid: uid, + }, + }); + + const deletedUserHistory = { + ...delUserHistory, + request: JSON.stringify(delUserHistory.request), + responseMetadata: JSON.stringify(delUserHistory.responseMetadata), + }; + + // Publish deleted user history subscription + await this.pubsub.publish( + `user_history/${deletedUserHistory.userUid}/deleted`, + deletedUserHistory, + ); + return E.right(deletedUserHistory); + } catch (e) { + return E.left(USER_HISTORY_NOT_FOUND); + } + } + + /** + * Delete all REST/GraphQl user history based on ReqType + * @param uid Users uid + * @param reqType request type to be deleted i.e. REST or GraphQL + * @returns a count of deleted history + */ + async deleteAllUserHistory(uid: string, reqType: string) { + const requestType = this.validateReqType(reqType); + if (E.isLeft(requestType)) return E.left(requestType.left); + + const deletedCount = await this.prisma.userHistory.deleteMany({ + where: { + userUid: uid, + reqType: requestType.right, + }, + }); + + const deletionInfo = { + count: deletedCount.count, + reqType: requestType.right, + }; + + // Publish multiple user history deleted subscription + await this.pubsub.publish(`user_history/${uid}/deleted_many`, deletionInfo); + return E.right(deletionInfo); + } + + /** + * Delete all user history from DB + * @returns a boolean + */ + async deleteAllHistories() { + try { + await this.prisma.userHistory.deleteMany(); + } catch (error) { + return E.left(USER_HISTORY_DELETION_FAILED); + } + + this.pubsub.publish('user_history/all/deleted', true); + return E.right(true); + } + + /** + * Fetch a user history based on history ID, scoped to its owner. + * @param id User History ID + * @param uid UID of the user the history entry must belong to + * @returns an `UserHistory` object owned by the given user, or `O.none` + */ + async fetchUserHistoryByID(id: string, uid: string) { + const userHistory = await this.prisma.userHistory.findFirst({ + where: { + id: id, + userUid: uid, + }, + }); + if (userHistory == null) return O.none; + + return O.some(userHistory); + } + + /** + * Takes a request type argument as string and validates against `ReqType` + * @param reqType request type to be validated i.e. REST or GraphQL + * @returns an either of `ReqType` or error + */ + validateReqType(reqType: string) { + if (reqType == ReqType.REST) return E.right(ReqType.REST); + else if (reqType == ReqType.GQL) return E.right(ReqType.GQL); + return E.left(USER_HISTORY_INVALID_REQ_TYPE); + } +} diff --git a/packages/hoppscotch-backend/src/user-history/user.resolver.ts b/packages/hoppscotch-backend/src/user-history/user.resolver.ts new file mode 100644 index 0000000..3c46481 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-history/user.resolver.ts @@ -0,0 +1,47 @@ +import { Args, Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { UseGuards } from '@nestjs/common'; +import { User } from '../user/user.model'; +import { UserHistoryService } from './user-history.service'; +import { UserHistory } from './user-history.model'; +import { ReqType } from 'src/types/RequestTypes'; +import { PaginationArgs } from '../types/input-types.args'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { GqlUser } from '../decorators/gql-user.decorator'; + +@Resolver(() => User) +export class UserHistoryUserResolver { + constructor(private userHistoryService: UserHistoryService) {} + + @ResolveField(() => [UserHistory], { + description: 'Returns a users REST history', + }) + @UseGuards(GqlAuthGuard) + async RESTHistory( + @Parent() user: User, + @GqlUser() requestingUser: User, + @Args() args: PaginationArgs, + ): Promise { + if (requestingUser?.uid !== user.uid) return []; + return await this.userHistoryService.fetchUserHistory( + user.uid, + args.take, + ReqType.REST, + ); + } + @ResolveField(() => [UserHistory], { + description: 'Returns a users GraphQL history', + }) + @UseGuards(GqlAuthGuard) + async GQLHistory( + @Parent() user: User, + @GqlUser() requestingUser: User, + @Args() args: PaginationArgs, + ): Promise { + if (requestingUser?.uid !== user.uid) return []; + return await this.userHistoryService.fetchUserHistory( + user.uid, + args.take, + ReqType.GQL, + ); + } +} diff --git a/packages/hoppscotch-backend/src/user-request/input-type.args.ts b/packages/hoppscotch-backend/src/user-request/input-type.args.ts new file mode 100644 index 0000000..8386269 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-request/input-type.args.ts @@ -0,0 +1,100 @@ +import { Field, ID, ArgsType } from '@nestjs/graphql'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { PaginationArgs } from 'src/types/input-types.args'; +import { ReqType } from 'src/types/RequestTypes'; + +@ArgsType() +export class GetUserRequestArgs extends PaginationArgs { + @Field(() => ID, { + nullable: true, + defaultValue: undefined, + description: 'Collection ID of the user request', + }) + @IsString() + @IsOptional() + collectionID?: string; +} + +@ArgsType() +export class MoveUserRequestArgs { + @Field(() => ID, { + description: 'ID of the collection, where the request is belongs to', + }) + @IsString() + @IsNotEmpty() + sourceCollectionID: string; + + @Field(() => ID, { + description: 'ID of the request being moved', + }) + @IsString() + @IsNotEmpty() + requestID: string; + + @Field(() => ID, { + description: 'ID of the collection, where the request is moving to', + }) + @IsString() + @IsNotEmpty() + destinationCollectionID: string; + + @Field(() => ID, { + nullable: true, + description: + 'ID of the request that comes after the updated request in its new position', + }) + @IsString() + @IsOptional() + nextRequestID: string; +} + +@ArgsType() +export class CreateUserRequestArgs { + @Field(() => ID, { + description: 'Collection ID of the user request', + }) + @IsString() + @IsNotEmpty() + collectionID: string; + + @Field({ description: 'Title of the user request' }) + @IsString() + @IsNotEmpty() + title: string; + + @Field({ description: 'content/body of the user request' }) + @IsString() + @IsNotEmpty() + request: string; + + @IsOptional() + type?: ReqType; +} + +@ArgsType() +export class UpdateUserRequestArgs { + @Field(() => ID, { + description: 'ID of the user request', + }) + @IsString() + @IsNotEmpty() + id: string; + + @Field({ + nullable: true, + defaultValue: undefined, + description: 'Title of the user request', + }) + @IsString() + @IsOptional() + title: string; + + @Field({ + nullable: true, + defaultValue: undefined, + description: 'content/body of the user request', + }) + @IsString() + @IsOptional() + request: string; +} diff --git a/packages/hoppscotch-backend/src/user-request/resolvers/user-collection.resolver.ts b/packages/hoppscotch-backend/src/user-request/resolvers/user-collection.resolver.ts new file mode 100644 index 0000000..44699ef --- /dev/null +++ b/packages/hoppscotch-backend/src/user-request/resolvers/user-collection.resolver.ts @@ -0,0 +1,32 @@ +import { Args, Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import * as E from 'fp-ts/Either'; +import { throwErr } from 'src/utils'; +import { UserRequestService } from '../user-request.service'; +import { UserRequest } from '../user-request.model'; +import { AuthUser } from 'src/types/AuthUser'; +import { UserCollection } from 'src/user-collection/user-collections.model'; +import { PaginationArgs } from 'src/types/input-types.args'; + +@Resolver(() => UserCollection) +export class UserRequestUserCollectionResolver { + constructor(private readonly userRequestService: UserRequestService) {} + + @ResolveField(() => [UserRequest], { + description: 'Returns user requests of a user collection', + }) + async requests( + @Parent() user: AuthUser, + @Parent() collection: UserCollection, + @Args() args: PaginationArgs, + ) { + const requests = await this.userRequestService.fetchUserRequests( + collection.id, + collection.type, + args.cursor, + args.take, + user, + ); + if (E.isLeft(requests)) throwErr(requests.left); + return requests.right; + } +} diff --git a/packages/hoppscotch-backend/src/user-request/resolvers/user-request.resolver.ts b/packages/hoppscotch-backend/src/user-request/resolvers/user-request.resolver.ts new file mode 100644 index 0000000..bad953e --- /dev/null +++ b/packages/hoppscotch-backend/src/user-request/resolvers/user-request.resolver.ts @@ -0,0 +1,260 @@ +import { UseGuards } from '@nestjs/common'; +import { + Args, + ID, + Mutation, + Query, + ResolveField, + Resolver, + Subscription, +} from '@nestjs/graphql'; +import { GqlUser } from 'src/decorators/gql-user.decorator'; +import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import * as E from 'fp-ts/Either'; +import { throwErr } from 'src/utils'; +import { UserRequest, UserRequestReorderData } from '../user-request.model'; +import { UserRequestService } from '../user-request.service'; +import { + GetUserRequestArgs, + CreateUserRequestArgs, + UpdateUserRequestArgs, + MoveUserRequestArgs, +} from '../input-type.args'; +import { AuthUser } from 'src/types/AuthUser'; +import { User } from 'src/user/user.model'; +import { ReqType } from 'src/types/RequestTypes'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { SkipThrottle } from '@nestjs/throttler'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => UserRequest) +export class UserRequestResolver { + constructor( + private readonly userRequestService: UserRequestService, + private readonly pubSub: PubSubService, + ) {} + + @ResolveField(() => User, { + description: 'Returns the user of the user request', + }) + async user(@GqlUser() user: AuthUser) { + return user; + } + + /* Queries */ + + @Query(() => [UserRequest], { + description: 'Get REST user requests', + }) + @UseGuards(GqlAuthGuard) + async userRESTRequests( + @GqlUser() user: AuthUser, + @Args() args: GetUserRequestArgs, + ): Promise { + const requests = await this.userRequestService.fetchUserRequests( + args.collectionID, + ReqType.REST, + args.cursor, + args.take, + user, + ); + if (E.isLeft(requests)) throwErr(requests.left); + return requests.right; + } + + @Query(() => [UserRequest], { + description: 'Get GraphQL user requests', + }) + @UseGuards(GqlAuthGuard) + async userGQLRequests( + @GqlUser() user: AuthUser, + @Args() args: GetUserRequestArgs, + ): Promise { + const requests = await this.userRequestService.fetchUserRequests( + args.collectionID, + ReqType.GQL, + args.cursor, + args.take, + user, + ); + if (E.isLeft(requests)) throwErr(requests.left); + return requests.right; + } + + @Query(() => UserRequest, { + description: 'Get a user request by ID', + }) + @UseGuards(GqlAuthGuard) + async userRequest( + @GqlUser() user: AuthUser, + @Args({ + name: 'id', + type: () => ID, + description: 'ID of the user request', + }) + id: string, + ): Promise { + const request = await this.userRequestService.fetchUserRequest(id, user); + if (E.isLeft(request)) throwErr(request.left); + return request.right; + } + + /* Mutations */ + + @Mutation(() => UserRequest, { + description: 'Create a new user REST request', + }) + @UseGuards(GqlAuthGuard) + async createRESTUserRequest( + @GqlUser() user: AuthUser, + @Args() args: CreateUserRequestArgs, + ) { + const request = await this.userRequestService.createRequest( + args.collectionID, + args.title, + args.request, + ReqType.REST, + user, + ); + if (E.isLeft(request)) throwErr(request.left); + return request.right; + } + + @Mutation(() => UserRequest, { + description: 'Create a new user GraphQL request', + }) + @UseGuards(GqlAuthGuard) + async createGQLUserRequest( + @GqlUser() user: AuthUser, + @Args() args: CreateUserRequestArgs, + ) { + const request = await this.userRequestService.createRequest( + args.collectionID, + args.title, + args.request, + ReqType.GQL, + user, + ); + if (E.isLeft(request)) throwErr(request.left); + return request.right; + } + + @Mutation(() => UserRequest, { + description: 'Update a user REST request', + }) + @UseGuards(GqlAuthGuard) + async updateRESTUserRequest( + @GqlUser() user: AuthUser, + @Args() args: UpdateUserRequestArgs, + ) { + const request = await this.userRequestService.updateRequest( + args.id, + args.title, + ReqType.REST, + args.request, + user, + ); + if (E.isLeft(request)) throwErr(request.left); + return request.right; + } + + @Mutation(() => UserRequest, { + description: 'Update a user GraphQL request', + }) + @UseGuards(GqlAuthGuard) + async updateGQLUserRequest( + @GqlUser() user: AuthUser, + @Args() args: UpdateUserRequestArgs, + ) { + const request = await this.userRequestService.updateRequest( + args.id, + args.title, + ReqType.GQL, + args.request, + user, + ); + if (E.isLeft(request)) throwErr(request.left); + return request.right; + } + + @Mutation(() => Boolean, { + description: 'Delete a user request', + }) + @UseGuards(GqlAuthGuard) + async deleteUserRequest( + @GqlUser() user: AuthUser, + @Args({ + name: 'id', + description: 'ID of the user request', + type: () => ID, + }) + id: string, + ): Promise { + const isDeleted = await this.userRequestService.deleteRequest(id, user); + if (E.isLeft(isDeleted)) throwErr(isDeleted.left); + return isDeleted.right; + } + + @Mutation(() => UserRequest, { + description: + 'Move and re-order of a user request within same or across collection', + }) + @UseGuards(GqlAuthGuard) + async moveUserRequest( + @GqlUser() user: AuthUser, + @Args() args: MoveUserRequestArgs, + ): Promise { + const request = await this.userRequestService.moveRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + if (E.isLeft(request)) throwErr(request.left); + return request.right; + } + + /* Subscriptions */ + + @Subscription(() => UserRequest, { + description: 'Listen for User Request Creation', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userRequestCreated(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_request/${user.uid}/created`); + } + + @Subscription(() => UserRequest, { + description: 'Listen for User Request Update', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userRequestUpdated(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_request/${user.uid}/updated`); + } + + @Subscription(() => UserRequest, { + description: 'Listen for User Request Deletion', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userRequestDeleted(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_request/${user.uid}/deleted`); + } + + @Subscription(() => UserRequestReorderData, { + description: 'Listen for User Request Moved', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userRequestMoved(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_request/${user.uid}/moved`); + } +} diff --git a/packages/hoppscotch-backend/src/user-request/user-request.model.ts b/packages/hoppscotch-backend/src/user-request/user-request.model.ts new file mode 100644 index 0000000..eb2987f --- /dev/null +++ b/packages/hoppscotch-backend/src/user-request/user-request.model.ts @@ -0,0 +1,50 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; +import { ReqType } from 'src/types/RequestTypes'; + +@ObjectType() +export class UserRequest { + @Field(() => ID, { + description: 'ID of the user request', + }) + id: string; + + @Field(() => ID, { + description: 'ID of the parent collection ID', + }) + collectionID: string; + + @Field({ + description: 'Title of the user request', + }) + title: string; + + @Field({ + description: 'Content/Body of the user request', + }) + request: string; + + @Field(() => ReqType, { + description: 'Type (GRAPHQL/REST) of the user request', + }) + type: ReqType; + + @Field(() => Date, { + description: 'Date of the user request creation', + }) + createdOn: Date; +} + +@ObjectType() +export class UserRequestReorderData { + @Field({ + description: 'User request being moved', + }) + request: UserRequest; + + @Field({ + description: + 'User request succeeding the request being moved in its new position', + nullable: true, + }) + nextRequest?: UserRequest; +} diff --git a/packages/hoppscotch-backend/src/user-request/user-request.module.ts b/packages/hoppscotch-backend/src/user-request/user-request.module.ts new file mode 100644 index 0000000..1022956 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-request/user-request.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { UserCollectionModule } from 'src/user-collection/user-collection.module'; +import { UserRequestUserCollectionResolver } from './resolvers/user-collection.resolver'; +import { UserRequestResolver } from './resolvers/user-request.resolver'; +import { UserRequestService } from './user-request.service'; + +@Module({ + imports: [UserCollectionModule], + providers: [ + UserRequestResolver, + UserRequestUserCollectionResolver, + UserRequestService, + ], + exports: [UserRequestService], +}) +export class UserRequestModule {} diff --git a/packages/hoppscotch-backend/src/user-request/user-request.service.spec.ts b/packages/hoppscotch-backend/src/user-request/user-request.service.spec.ts new file mode 100644 index 0000000..bc645c0 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-request/user-request.service.spec.ts @@ -0,0 +1,863 @@ +import { + ReqType as DbRequestType, + UserRequest as DbUserRequest, +} from 'src/generated/prisma/client'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { + JSON_INVALID, + USER_REQUEST_NOT_FOUND, + USER_REQUEST_REORDERING_FAILED, +} from 'src/errors'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import * as E from 'fp-ts/Either'; +import { CreateUserRequestArgs, GetUserRequestArgs } from './input-type.args'; +import { MoveUserRequestArgs } from './input-type.args'; +import { UpdateUserRequestArgs } from './input-type.args'; +import { UserRequest } from './user-request.model'; +import { UserRequestService } from './user-request.service'; +import { AuthUser } from 'src/types/AuthUser'; +import { ReqType } from 'src/types/RequestTypes'; +import { UserCollectionService } from 'src/user-collection/user-collection.service'; + +const mockPrisma = mockDeep(); +const mockPubSub = mockDeep(); +const mockUserCollectionService = mockDeep(); + +const userRequestService = new UserRequestService( + mockPrisma, + mockPubSub, + mockUserCollectionService, +); + +const user: AuthUser = { + uid: 'user-uid', + email: 'test@gmail.com', + displayName: 'Test User', + photoURL: 'https://example.com/photo.png', + isAdmin: false, + refreshToken: null, + lastLoggedOn: new Date(), + lastActiveOn: new Date(), + createdOn: new Date(), + currentGQLSession: null, + currentRESTSession: null, +}; +const dbUserRequests: DbUserRequest[] = [ + { + id: 'user-request-id-11', + collectionID: 'collection-id-1', + orderIndex: 1, + userUid: user.uid, + title: 'Request 1', + request: {}, + mockExamples: {}, + type: DbRequestType.REST, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'user-request-id-12', + collectionID: 'collection-id-1', + orderIndex: 2, + userUid: user.uid, + title: 'Request 2', + request: {}, + mockExamples: {}, + type: DbRequestType.REST, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'user-request-id-13', + collectionID: 'collection-id-1', + orderIndex: 3, + userUid: user.uid, + title: 'Request 3', + request: {}, + mockExamples: {}, + type: DbRequestType.REST, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'user-request-id-14', + collectionID: 'collection-id-1', + orderIndex: 4, + userUid: user.uid, + title: 'Request 4', + request: {}, + mockExamples: {}, + type: DbRequestType.REST, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'user-request-id-21', + collectionID: 'collection-id-2', + orderIndex: 1, + userUid: user.uid, + title: 'Request 1', + request: {}, + mockExamples: {}, + type: DbRequestType.REST, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'user-request-id-22', + collectionID: 'collection-id-2', + orderIndex: 2, + userUid: user.uid, + title: 'Request 2', + request: {}, + mockExamples: {}, + type: DbRequestType.REST, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'user-request-id-23', + collectionID: 'collection-id-2', + orderIndex: 3, + userUid: user.uid, + title: 'Request 3', + request: {}, + mockExamples: {}, + type: DbRequestType.REST, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'user-request-id-24', + collectionID: 'collection-id-2', + orderIndex: 4, + userUid: user.uid, + title: 'Request 4', + request: {}, + mockExamples: {}, + type: DbRequestType.REST, + createdOn: new Date(), + updatedOn: new Date(), + }, +]; +const userRequests: UserRequest[] = dbUserRequests.map((r) => { + return { + ...r, + request: JSON.stringify(r.request), + type: ReqType[r.type], + }; +}); + +beforeEach(() => { + mockReset(mockPrisma); + mockPubSub.publish.mockClear(); + mockUserCollectionService.getUserCollection.mockClear(); +}); + +describe('UserRequestService', () => { + describe('fetchUserRequests', () => { + test('Should resolve right and fetch user requests (with collection ID)', () => { + const args: GetUserRequestArgs = { + collectionID: 'collection-id-1', + cursor: undefined, + take: undefined, + }; + const expectedDbUserRequests = dbUserRequests.filter( + (r) => r.collectionID === args.collectionID, + ); + const expectedUserRequests = userRequests.filter( + (r) => r.collectionID === args.collectionID, + ); + + mockPrisma.userRequest.findMany.mockResolvedValue(expectedDbUserRequests); + const result = userRequestService.fetchUserRequests( + args.collectionID, + ReqType.REST, + args.cursor, + args.take, + user, + ); + + expect(result).resolves.toEqualRight(expectedUserRequests); + }); + + test('Should resolve right and fetch user requests (with collection ID and take)', () => { + const args: GetUserRequestArgs = { + collectionID: 'collection-id-1', + cursor: undefined, + take: 2, + }; + const expectedDbUserRequests = dbUserRequests.filter( + (r) => r.collectionID === args.collectionID, + ); + const expectedUserRequests = userRequests.filter( + (r) => r.collectionID === args.collectionID, + ); + + mockPrisma.userRequest.findMany.mockResolvedValue(expectedDbUserRequests); + const result = userRequestService.fetchUserRequests( + args.collectionID, + ReqType.REST, + args.cursor, + args.take, + user, + ); + + expect(result).resolves.toEqualRight(expectedUserRequests); + }); + test('Should resolve right and fetch user requests (with all params)', () => { + const args: GetUserRequestArgs = { + collectionID: 'collection-id-1', + cursor: 'user-request-id-12', + take: 2, + }; + const expectedDbUserRequests = dbUserRequests.filter( + (r) => r.collectionID === args.collectionID, + ); + const expectedUserRequests = userRequests.filter( + (r) => r.collectionID === args.collectionID, + ); + + mockPrisma.userRequest.findMany.mockResolvedValue(expectedDbUserRequests); + const result = userRequestService.fetchUserRequests( + args.collectionID, + ReqType.REST, + args.cursor, + args.take, + user, + ); + + expect(result).resolves.toEqualRight(expectedUserRequests); + }); + }); + + describe('fetchUserRequest', () => { + test('Should resolve right and fetch user request', () => { + const expectedDbUserRequest = dbUserRequests[0]; + const expectedUserRequest = userRequests[0]; + + mockPrisma.userRequest.findUnique.mockResolvedValue( + expectedDbUserRequest, + ); + const result = userRequestService.fetchUserRequest( + expectedUserRequest.id, + user, + ); + + expect(result).resolves.toEqualRight(expectedUserRequest); + }); + + test('Should resolve left if user request not exist', () => { + mockPrisma.userRequest.findUnique.mockResolvedValue(null); + const result = userRequestService.fetchUserRequest( + userRequests[0].id, + user, + ); + + expect(result).resolves.toEqualLeft(USER_REQUEST_NOT_FOUND); + }); + + test('Should resolve left if another users user-request asked', () => { + mockPrisma.userRequest.findUnique.mockResolvedValue({ + ...dbUserRequests[0], + userUid: 'another-user', + }); + const result = userRequestService.fetchUserRequest( + userRequests[0].id, + user, + ); + + expect(result).resolves.toEqualLeft(USER_REQUEST_NOT_FOUND); + }); + }); + + describe('createRequest', () => { + test('Should resolve right and create user request', () => { + const args: CreateUserRequestArgs = { + collectionID: userRequests[0].collectionID, + title: userRequests[0].title, + request: userRequests[0].request, + type: userRequests[0].type, + }; + + mockUserCollectionService.getUserCollection.mockResolvedValue( + E.right({ type: userRequests[0].type, userUid: user.uid } as any), + ); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.userRequest.create.mockResolvedValue(dbUserRequests[0]); + + const result = userRequestService.createRequest( + args.collectionID, + args.title, + args.request, + args.type, + user, + ); + + expect(result).resolves.toEqualRight(userRequests[0]); + }); + test('Should execute prisma.create() with correct params', async () => { + const args: CreateUserRequestArgs = { + collectionID: userRequests[0].collectionID, + title: userRequests[0].title, + request: userRequests[0].request, + type: userRequests[0].type, + }; + + mockUserCollectionService.getUserCollection.mockResolvedValue( + E.right({ type: userRequests[0].type, userUid: user.uid } as any), + ); + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.userRequest.create.mockResolvedValue(dbUserRequests[0]); + + await userRequestService.createRequest( + args.collectionID, + args.title, + args.request, + args.type, + user, + ); + + expect(mockPrisma.userRequest.create).toHaveBeenCalledWith({ + data: { + ...args, + request: JSON.parse(args.request), + type: DbRequestType[args.type], + orderIndex: dbUserRequests[0].orderIndex, + userUid: user.uid, + }, + }); + }); + test('Should publish user request created message in pubnub', async () => { + const args: CreateUserRequestArgs = { + collectionID: userRequests[0].collectionID, + title: userRequests[0].title, + request: userRequests[0].request, + type: userRequests[0].type, + }; + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.userRequest.create.mockResolvedValue(dbUserRequests[0]); + + await userRequestService.createRequest( + args.collectionID, + args.title, + args.request, + args.type, + user, + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_request/${dbUserRequests[0].userUid}/created`, + userRequests[0], + ); + }); + test('Should resolve left for json-invalid request', () => { + const args: CreateUserRequestArgs = { + collectionID: userRequests[0].collectionID, + title: userRequests[0].title, + request: 'invalid json', + type: userRequests[0].type, + }; + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.userRequest.create.mockResolvedValue(dbUserRequests[0]); + + const result = userRequestService.createRequest( + args.collectionID, + args.title, + args.request, + args.type, + user, + ); + + expect(result).resolves.toEqualLeft(JSON_INVALID); + }); + }); + + describe('updateRequest', () => { + test('Should resolve right and update user request', () => { + const id = userRequests[0].id; + const type = userRequests[0].type; + const args: UpdateUserRequestArgs = { + id, + title: userRequests[0].title, + request: userRequests[0].request, + }; + + mockPrisma.userRequest.findFirst.mockResolvedValueOnce(dbUserRequests[0]); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce({} as any); + mockPrisma.userRequest.update.mockResolvedValue(dbUserRequests[0]); + + const result = userRequestService.updateRequest( + id, + args.title, + type, + args.request, + user, + ); + + expect(result).resolves.toEqualRight(userRequests[0]); + }); + test('Should resolve right and perform prisma.update with correct param', async () => { + const id = userRequests[0].id; + const type = userRequests[0].type; + const args: UpdateUserRequestArgs = { + id, + title: userRequests[0].title, + request: userRequests[0].request, + }; + + mockPrisma.userRequest.findFirst.mockResolvedValueOnce(dbUserRequests[0]); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce({} as any); + mockPrisma.userRequest.update.mockResolvedValue(dbUserRequests[0]); + + await userRequestService.updateRequest( + id, + args.title, + type, + args.request, + user, + ); + + expect(mockPrisma.userRequest.update).toHaveBeenCalledWith({ + where: { id }, + data: { + title: args.title, + request: JSON.parse(args.request), + }, + }); + }); + test('Should resolve right and publish to pubnub with correct param', async () => { + const id = userRequests[0].id; + const type = userRequests[0].type; + const args: UpdateUserRequestArgs = { + id, + title: userRequests[0].title, + request: userRequests[0].request, + }; + + mockPrisma.userRequest.findFirst.mockResolvedValueOnce(dbUserRequests[0]); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce({} as any); + mockPrisma.userRequest.update.mockResolvedValue(dbUserRequests[0]); + + await userRequestService.updateRequest( + id, + args.title, + type, + args.request, + user, + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_request/${dbUserRequests[0].userUid}/updated`, + userRequests[0], + ); + }); + test('Should resolve left if user request not found', () => { + const id = userRequests[0].id; + const type = userRequests[0].type; + const args: UpdateUserRequestArgs = { + id, + title: userRequests[0].title, + request: userRequests[0].request, + }; + + mockPrisma.userRequest.findFirst.mockResolvedValue(null); + + const result = userRequestService.updateRequest( + id, + args.title, + type, + args.request, + user, + ); + + expect(result).resolves.toEqualLeft(USER_REQUEST_NOT_FOUND); + }); + test('Should resolve left if stringToJson returns error', () => { + const id = userRequests[0].id; + const type = userRequests[0].type; + const args: UpdateUserRequestArgs = { + id, + title: userRequests[0].title, + request: 'invalid json', + }; + + mockPrisma.userRequest.findFirst.mockResolvedValueOnce(dbUserRequests[0]); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce({} as any); + + const result = userRequestService.updateRequest( + id, + args.title, + type, + args.request, + user, + ); + + expect(result).resolves.toEqualLeft(JSON_INVALID); + }); + }); + + describe('deleteRequest', () => { + test('Should resolve right and delete user request', () => { + const id = userRequests[0].id; + + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.userRequest.findFirst.mockResolvedValue(dbUserRequests[0]); + mockPrisma.userRequest.updateMany.mockResolvedValue(null); + mockPrisma.userRequest.delete.mockResolvedValue(dbUserRequests[0]); + + const result = userRequestService.deleteRequest(id, user); + + expect(result).resolves.toEqualRight(true); + }); + test('Should resolve right and perform prisma.delete with correct param', async () => { + const id = userRequests[0].id; + + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.userRequest.findFirst.mockResolvedValue(dbUserRequests[0]); + mockPrisma.userRequest.updateMany.mockResolvedValue(null); + mockPrisma.userRequest.delete.mockResolvedValue(dbUserRequests[0]); + + await userRequestService.deleteRequest(id, user); + + expect(mockPrisma.userRequest.delete).toHaveBeenCalledWith({ + where: { id }, + }); + }); + test('Should resolve right and perform prisma.updateMany with correct param', async () => { + const id = userRequests[0].id; + + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.userRequest.findFirst.mockResolvedValue(dbUserRequests[0]); + mockPrisma.userRequest.updateMany.mockResolvedValue(null); + mockPrisma.userRequest.delete.mockResolvedValue(dbUserRequests[0]); + + await userRequestService.deleteRequest(id, user); + + expect(mockPrisma.userRequest.updateMany).toHaveBeenCalledWith({ + where: { + collectionID: dbUserRequests[0].collectionID, + orderIndex: { gt: dbUserRequests[0].orderIndex }, + }, + data: { orderIndex: { decrement: 1 } }, + }); + }); + test('Should resolve and publish message to pubnub', async () => { + const id = userRequests[0].id; + + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.userRequest.findFirst.mockResolvedValue(dbUserRequests[0]); + mockPrisma.userRequest.updateMany.mockResolvedValue(null); + mockPrisma.userRequest.delete.mockResolvedValue(dbUserRequests[0]); + + await userRequestService.deleteRequest(id, user); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_request/${dbUserRequests[0].userUid}/deleted`, + userRequests[0], + ); + }); + test('Should resolve error if the user request is not found', () => { + const id = userRequests[0].id; + mockPrisma.userRequest.findFirst.mockResolvedValue(null); + + const result = userRequestService.deleteRequest(id, user); + + expect(result).resolves.toEqualLeft(USER_REQUEST_NOT_FOUND); + expect(mockPrisma.userRequest.findFirst).toHaveBeenCalledWith({ + where: { id, userUid: dbUserRequests[0].userUid }, + }); + expect(mockPrisma.userRequest.updateMany).not.toHaveBeenCalled(); + expect(mockPrisma.userRequest.delete).not.toHaveBeenCalled(); + expect(mockPubSub.publish).not.toHaveBeenCalled(); + }); + }); + + describe('reorderRequests', () => { + test('Should resolve left if transaction throws an error', async () => { + const srcCollID = dbUserRequests[0].collectionID; + const request = dbUserRequests[0]; + const destCollID = dbUserRequests[4].collectionID; + const nextRequest = dbUserRequests[4]; + + mockPrisma.$transaction.mockRejectedValueOnce(new Error()); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + const result = await (userRequestService as any).reorderRequests( + srcCollID, + request, + destCollID, + nextRequest, + ); + expect(result).toEqual(E.left(USER_REQUEST_REORDERING_FAILED)); + }); + test('Should resolve right and call transaction with the correct data', async () => { + const srcCollID = dbUserRequests[0].collectionID; + const request = dbUserRequests[0]; + const destCollID = dbUserRequests[4].collectionID; + const nextRequest = dbUserRequests[4]; + + const updatedReq: DbUserRequest = { + ...request, + collectionID: destCollID, + orderIndex: nextRequest.orderIndex, + }; + + mockPrisma.$transaction.mockResolvedValueOnce(E.right(updatedReq)); + const result = await (userRequestService as any).reorderRequests( + srcCollID, + request, + destCollID, + nextRequest, + ); + expect(mockPrisma.$transaction).toHaveBeenCalledWith( + expect.any(Function), + ); + expect(result).toEqual(E.right(updatedReq)); + }); + }); + + describe('findRequestAndNextRequest', () => { + test('Should resolve right if the request and the next request are found', async () => { + const args: MoveUserRequestArgs = { + sourceCollectionID: userRequests[0].collectionID, + destinationCollectionID: userRequests[4].collectionID, + requestID: userRequests[0].id, + nextRequestID: userRequests[4].id, + }; + + mockPrisma.userRequest.findFirst + .mockResolvedValueOnce(dbUserRequests[0]) + .mockResolvedValueOnce(dbUserRequests[4]); + + const result = await userRequestService.findRequestAndNextRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + + expect(result).toEqualRight({ + request: dbUserRequests[0], + nextRequest: dbUserRequests[4], + }); + }); + test('Should resolve right if the request and next request null', () => { + const args: MoveUserRequestArgs = { + sourceCollectionID: userRequests[0].collectionID, + destinationCollectionID: userRequests[1].collectionID, + requestID: userRequests[0].id, + nextRequestID: null, + }; + + mockPrisma.userRequest.findFirst + .mockResolvedValueOnce(dbUserRequests[0]) + .mockResolvedValueOnce(null); + + const result = userRequestService.findRequestAndNextRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + + expect(result).resolves.toEqualRight({ + request: dbUserRequests[0], + nextRequest: null, + }); + }); + test('Should resolve left if the request is not found', () => { + const args: MoveUserRequestArgs = { + sourceCollectionID: userRequests[0].collectionID, + destinationCollectionID: userRequests[1].collectionID, + requestID: 'invalid', + nextRequestID: null, + }; + + mockPrisma.userRequest.findFirst.mockResolvedValueOnce(null); + + const result = userRequestService.findRequestAndNextRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + + expect(result).resolves.toEqualLeft(USER_REQUEST_NOT_FOUND); + }); + test('Should resolve left if the nextRequest is not found', () => { + const args: MoveUserRequestArgs = { + sourceCollectionID: userRequests[0].collectionID, + destinationCollectionID: userRequests[1].collectionID, + requestID: userRequests[0].id, + nextRequestID: 'invalid', + }; + + mockPrisma.userRequest.findFirst + .mockResolvedValueOnce(dbUserRequests[0]) + .mockResolvedValueOnce(null); + + const result = userRequestService.findRequestAndNextRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + + expect(result).resolves.toEqualLeft(USER_REQUEST_NOT_FOUND); + }); + }); + + describe('moveRequest', () => { + test('Should resolve right and the request', () => { + const args: MoveUserRequestArgs = { + sourceCollectionID: userRequests[0].collectionID, + destinationCollectionID: userRequests[0].collectionID, + requestID: userRequests[0].id, + nextRequestID: null, + }; + + jest + .spyOn(userRequestService, 'findRequestAndNextRequest') + .mockResolvedValue( + E.right({ request: dbUserRequests[0], nextRequest: null }), + ); + jest + .spyOn(userRequestService as any, 'reorderRequests') + .mockResolvedValue(E.right(dbUserRequests[0])); + jest + .spyOn(userRequestService, 'validateTypeEqualityForMoveRequest') + .mockResolvedValue(E.right(true)); + + const result = userRequestService.moveRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + + expect(result).resolves.toEqualRight(userRequests[0]); + }); + test('Should resolve right and publish message to pubnub', async () => { + const args: MoveUserRequestArgs = { + sourceCollectionID: userRequests[0].collectionID, + destinationCollectionID: userRequests[0].collectionID, + requestID: userRequests[0].id, + nextRequestID: null, + }; + + jest + .spyOn(userRequestService, 'findRequestAndNextRequest') + .mockResolvedValue( + E.right({ request: dbUserRequests[0], nextRequest: null }), + ); + jest + .spyOn(userRequestService as any, 'reorderRequests') + .mockResolvedValue(E.right(dbUserRequests[0])); + jest + .spyOn(userRequestService, 'validateTypeEqualityForMoveRequest') + .mockResolvedValue(E.right(true)); + + await userRequestService.moveRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_request/${dbUserRequests[0].userUid}/moved`, + { request: userRequests[0], nextRequest: null }, + ); + }); + test('Should resolve left if finding the requests fails', () => { + const args: MoveUserRequestArgs = { + sourceCollectionID: userRequests[0].collectionID, + destinationCollectionID: userRequests[0].collectionID, + requestID: userRequests[0].id, + nextRequestID: null, + }; + + jest + .spyOn(userRequestService, 'findRequestAndNextRequest') + .mockResolvedValue(E.left(USER_REQUEST_NOT_FOUND)); + jest + .spyOn(userRequestService, 'validateTypeEqualityForMoveRequest') + .mockResolvedValue(E.right(true)); + + const result = userRequestService.moveRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + + expect(result).resolves.toEqualLeft(USER_REQUEST_NOT_FOUND); + }); + test('Should resolve left if reordering the request fails', async () => { + const args: MoveUserRequestArgs = { + sourceCollectionID: userRequests[0].collectionID, + destinationCollectionID: userRequests[0].collectionID, + requestID: userRequests[0].id, + nextRequestID: null, + }; + + jest + .spyOn(userRequestService, 'findRequestAndNextRequest') + .mockResolvedValue( + E.right({ + request: dbUserRequests[0], + nextRequest: null, + }), + ); + jest + .spyOn(userRequestService as any, 'reorderRequests') + .mockResolvedValue(E.left(USER_REQUEST_REORDERING_FAILED)); + jest + .spyOn(userRequestService, 'validateTypeEqualityForMoveRequest') + .mockResolvedValue(E.right(true)); + + const result = await userRequestService.moveRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + + expect(result).toEqualLeft(USER_REQUEST_REORDERING_FAILED); + }); + }); + + describe('validateTypeEqualityForMoveRequest', () => { + test('Should resolve right if the types are equal', () => { + const srcCollID = 'srcCollID'; + const destCollID = 'destCollID'; + + mockUserCollectionService.getUserCollection.mockResolvedValueOnce( + E.right({ type: userRequests[0].type, userUid: user.uid } as any), + ); + mockUserCollectionService.getUserCollection.mockResolvedValueOnce( + E.right({ type: userRequests[1].type, userUid: user.uid } as any), + ); + + const result = userRequestService.validateTypeEqualityForMoveRequest( + srcCollID, + destCollID, + userRequests[0], + userRequests[1], + user, + ); + + expect(result).resolves.toEqualRight(true); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/user-request/user-request.service.ts b/packages/hoppscotch-backend/src/user-request/user-request.service.ts new file mode 100644 index 0000000..27f069a --- /dev/null +++ b/packages/hoppscotch-backend/src/user-request/user-request.service.ts @@ -0,0 +1,562 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { PubSubService } from '../pubsub/pubsub.service'; +import * as E from 'fp-ts/Either'; +import { UserRequest } from './user-request.model'; +import { + Prisma, + UserRequest as DbUserRequest, +} from 'src/generated/prisma/client'; +import { + USER_REQUEST_CREATION_FAILED, + USER_REQUEST_INVALID_TYPE, + USER_REQUEST_NOT_FOUND, + USER_REQUEST_REORDERING_FAILED, +} from 'src/errors'; +import { stringToJson } from 'src/utils'; +import { AuthUser } from 'src/types/AuthUser'; +import { ReqType } from 'src/types/RequestTypes'; +import { UserCollectionService } from 'src/user-collection/user-collection.service'; +import { SortOptions } from 'src/types/SortOptions'; + +@Injectable() +export class UserRequestService { + constructor( + private readonly prisma: PrismaService, + private readonly pubsub: PubSubService, + private readonly userCollectionService: UserCollectionService, + ) {} + + /** + * Typecast a database user request to a user request + * @param dbRequest Database user request + * @returns User request + */ + private cast(dbRequest: DbUserRequest): UserRequest { + return { + ...dbRequest, + type: ReqType[dbRequest.type], + request: JSON.stringify(dbRequest.request), + }; + } + + /** + * Get paginated user requests + * @param collectionID ID of the collection to which the request belongs + * @param take Number of requests to fetch + * @param cursor ID of the request after which to fetch requests + * @param user User who owns the requests + * @returns Either of an Array of user requests + */ + async fetchUserRequests( + collectionID: string, + type: ReqType, + cursor: string, + take: number, + user: AuthUser, + ) { + const dbRequests = await this.prisma.userRequest.findMany({ + where: { + userUid: user.uid, + collectionID: collectionID, + type, + }, + take: take, // default: 10 + skip: cursor ? 1 : 0, + cursor: cursor ? { id: cursor } : undefined, + orderBy: { orderIndex: 'asc' }, + }); + + const userRequests: UserRequest[] = dbRequests.map((r) => this.cast(r)); + + return E.right(userRequests); + } + + /** + * Get a user request by ID + * @param id ID of the request to fetch + * @param user User who owns the request + * @returns Either of the user request + */ + async fetchUserRequest( + id: string, + user: AuthUser, + ): Promise | E.Right> { + const dbRequest = await this.prisma.userRequest.findUnique({ + where: { id }, + }); + if (!dbRequest || dbRequest.userUid !== user.uid) { + return E.left(USER_REQUEST_NOT_FOUND); + } + + return E.right(this.cast(dbRequest)); + } + + /** + * Get the number of requests in a collection + * @param collectionID ID of the collection to which the request belongs + * @param user User who owns the collection + * @returns Number of requests in the collection + */ + getRequestsCountInCollection( + collectionID: string, + tx: Prisma.TransactionClient | null = null, + ): Promise { + return (tx || this.prisma).userRequest.count({ + where: { collectionID }, + }); + } + + /** + * Create a user request + * @param collectionID ID of the collection to which the request belongs + * @param title title of the request + * @param request request to create + * @param type type of the request + * @param user User who owns the request + * @returns Either of the created user request + */ + async createRequest( + collectionID: string, + title: string, + request: string, + type: ReqType, + user: AuthUser, + ): Promise | E.Right> { + const jsonRequest = stringToJson(request); + if (E.isLeft(jsonRequest)) return E.left(jsonRequest.left); + + const collection = await this.userCollectionService.getUserCollection( + collectionID, + user.uid, + ); + if (E.isLeft(collection)) return E.left(collection.left); + + if (collection.right.type !== ReqType[type]) + return E.left(USER_REQUEST_INVALID_TYPE); + + let newRequest: DbUserRequest = null; + try { + newRequest = await this.prisma.$transaction(async (tx) => { + try { + // lock the rows + await this.prisma.lockUserRequestByCollections(tx, user.uid, [ + collectionID, + ]); + + // fetch last user request + const lastUserRequest = await tx.userRequest.findFirst({ + where: { userUid: user.uid, collectionID }, + orderBy: { orderIndex: 'desc' }, + }); + + return tx.userRequest.create({ + data: { + collectionID, + title, + request: jsonRequest.right, + type: ReqType[type], + orderIndex: lastUserRequest ? lastUserRequest.orderIndex + 1 : 1, + userUid: user.uid, + }, + }); + } catch (error) { + throw new ConflictException(error); + } + }); + } catch (error) { + console.error('Error from UserRequestService.createRequest', error); + return E.left(USER_REQUEST_CREATION_FAILED); + } + + const userRequest = this.cast(newRequest); + await this.pubsub.publish(`user_request/${user.uid}/created`, userRequest); + return E.right(userRequest); + } + + /** + * Update a user request + * @param id ID of the request to update + * @param title title of the request to update + * @param type type of the request to update + * @param request request to update + * @param user User who owns the request + */ + async updateRequest( + id: string, + title: string, + type: ReqType, + request: string, + user: AuthUser, + ): Promise | E.Right> { + const existRequest = await this.prisma.userRequest.findFirst({ + where: { id, userUid: user.uid }, + }); + if (!existRequest) return E.left(USER_REQUEST_NOT_FOUND); + + if (existRequest.type !== ReqType[type]) + return E.left(USER_REQUEST_INVALID_TYPE); + + let jsonRequest = undefined; + if (request) { + const jsonRequestE = stringToJson(request); + if (E.isLeft(jsonRequestE)) return E.left(jsonRequestE.left); + jsonRequest = jsonRequestE.right; + } + + const updatedRequest = await this.prisma.userRequest.update({ + where: { id }, + data: { + title, + request: jsonRequest, + }, + }); + + const userRequest: UserRequest = this.cast(updatedRequest); + + await this.pubsub.publish(`user_request/${user.uid}/updated`, userRequest); + + return E.right(userRequest); + } + + /** + * Delete a user request + * @param id ID of the request to delete + * @param user User who owns the request + * @returns Either of a boolean + */ + async deleteRequest( + id: string, + user: AuthUser, + ): Promise | E.Right> { + const request = await this.prisma.userRequest.findFirst({ + where: { id, userUid: user.uid }, + }); + if (!request) return E.left(USER_REQUEST_NOT_FOUND); + + try { + await this.prisma.$transaction(async (tx) => { + try { + // lock the rows + await this.prisma.lockUserRequestByCollections(tx, user.uid, [ + request.collectionID, + ]); + + const deletedRequest = await tx.userRequest.delete({ where: { id } }); + + // if request is found, update orderIndexes of siblings + // if request was deleted before the transaction started (race condition), do not update siblings orderIndexes + if (deletedRequest) { + await tx.userRequest.updateMany({ + where: { + collectionID: request.collectionID, + orderIndex: { gt: request.orderIndex }, + }, + data: { orderIndex: { decrement: 1 } }, + }); + } + } catch (error) { + throw new ConflictException(error); + } + }); + } catch (error) { + return E.left(USER_REQUEST_NOT_FOUND); + } + + await this.pubsub.publish( + `user_request/${user.uid}/deleted`, + this.cast(request), + ); + + return E.right(true); + } + + /** + * Move a request for re-ordering inside/across collections + * @param srcCollID ID of the source collection + * @param destCollID ID of the destination collection + * @param requestID ID of the request to move + * @param nextRequestID ID of the request after which the request should be moved + * @param user User who owns the request + * @returns Either of the updated request + */ + async moveRequest( + srcCollID: string, + destCollID: string, + requestID: string, + nextRequestID: string, + user: AuthUser, + ): Promise | E.Right> { + const twoRequests = await this.findRequestAndNextRequest( + srcCollID, + destCollID, + requestID, + nextRequestID, + user, + ); + if (E.isLeft(twoRequests)) return twoRequests; + const { request: dbRequest, nextRequest: dbNextRequest } = + twoRequests.right; + + const isTypeValidate = await this.validateTypeEqualityForMoveRequest( + srcCollID, + destCollID, + dbRequest, + dbNextRequest, + user, + ); + if (E.isLeft(isTypeValidate)) return E.left(isTypeValidate.left); + + const updatedRequest = await this.reorderRequests( + srcCollID, + dbRequest, + destCollID, + dbNextRequest, + ); + if (E.isLeft(updatedRequest)) return updatedRequest; + + const userRequest: UserRequest = this.cast(updatedRequest.right); + + await this.pubsub.publish(`user_request/${user.uid}/moved`, { + request: userRequest, + nextRequest: dbNextRequest ? this.cast(dbNextRequest) : null, + }); + + return E.right(userRequest); + } + + /** + * This function validate/ensure the same type (REST/GQL) in the source and destination collection and the request + * @param srcCollID ID of the source collection + * @param destCollID ID of the destination collection + * @param request Request to move + * @param nextRequest Request after which the request should be moved + * @returns Either of a boolean + */ + async validateTypeEqualityForMoveRequest( + srcCollID, + destCollID, + request, + nextRequest, + user: AuthUser, + ) { + const collections = await Promise.all([ + this.userCollectionService.getUserCollection(srcCollID, user.uid), + this.userCollectionService.getUserCollection(destCollID, user.uid), + ]); + + const srcColl = collections[0]; + if (E.isLeft(srcColl)) return E.left(srcColl.left); + + const destColl = collections[1]; + if (E.isLeft(destColl)) return E.left(destColl.left); + + if ( + srcColl.right.type !== destColl.right.type || + (nextRequest && request.type !== nextRequest.type) + ) { + return E.left(USER_REQUEST_INVALID_TYPE); + } + + return E.right(true); + } + + /** + * A helper function. + * Find the request and the next request(destination collection) + * @param srcCollID Source collection ID + * @param destCollID Destination collection ID + * @param requestID Request ID + * @param nextRequestID Next request ID + * @param user User who owns the collection + * @returns Either Left with error message or Right with the request and the next request + */ + async findRequestAndNextRequest( + srcCollID: string, + destCollID: string, + requestID: string, + nextRequestID: string | null, + user: AuthUser, + ): Promise< + | E.Left + | E.Right<{ + request: DbUserRequest; + nextRequest: DbUserRequest | null; + }> + > { + const request = await this.prisma.userRequest.findFirst({ + where: { id: requestID, collectionID: srcCollID, userUid: user.uid }, + }); + if (!request) return E.left(USER_REQUEST_NOT_FOUND); + + let nextRequest: DbUserRequest = null; + if (nextRequestID) { + nextRequest = await this.prisma.userRequest.findFirst({ + where: { + id: nextRequestID, + collectionID: destCollID, + userUid: user.uid, + }, + }); + if (!nextRequest) return E.left(USER_REQUEST_NOT_FOUND); + } + + return E.right({ request, nextRequest }); + } + + /** + * Update order indexes of requests in collection + * @param srcCollID - id of collection, where the request is moving from + * @param request - request to be moved + * @param destCollID - id of collection, where the request is moving to + * @param nextRequest - request that comes after the updated request in its new position + * @returns Promise of an Either of `DbUserRequest` object or error message + */ + private async reorderRequests( + srcCollID: string, + request: DbUserRequest, + destCollID: string, + nextRequest: DbUserRequest, + ): Promise | E.Right> { + try { + return await this.prisma.$transaction< + E.Left | E.Right + >(async (tx) => { + // lock the rows + await this.prisma.lockUserRequestByCollections(tx, request.userUid, [ + srcCollID, + destCollID, + ]); + + request = await tx.userRequest.findUnique({ + where: { id: request.id }, + }); + nextRequest = nextRequest + ? await tx.userRequest.findUnique({ + where: { id: nextRequest.id }, + }) + : null; + + // Check again if request is found in transaction, update orderIndexes of siblings + // if request was deleted before the transaction started (race condition), do not update siblings orderIndexes + if (request) { + const isSameCollection = srcCollID === destCollID; + const isMovingUp = nextRequest?.orderIndex < request.orderIndex; // false, if nextRequest is null + + const nextReqOrderIndex = nextRequest?.orderIndex; + const reqCountInDestColl = nextRequest + ? undefined + : await this.getRequestsCountInCollection(destCollID); + + // Updating order indexes of other requests in collection(s) + if (isSameCollection) { + const updateFrom = isMovingUp + ? nextReqOrderIndex + : request.orderIndex + 1; + const updateTo = isMovingUp + ? request.orderIndex + : nextReqOrderIndex; + + await tx.userRequest.updateMany({ + where: { + collectionID: srcCollID, + orderIndex: { gte: updateFrom, lt: updateTo }, + }, + data: { + orderIndex: isMovingUp ? { increment: 1 } : { decrement: 1 }, + }, + }); + } else { + await tx.userRequest.updateMany({ + where: { + collectionID: srcCollID, + orderIndex: { gte: request.orderIndex }, + }, + data: { orderIndex: { decrement: 1 } }, + }); + + if (nextRequest) { + await tx.userRequest.updateMany({ + where: { + collectionID: destCollID, + orderIndex: { gte: nextReqOrderIndex }, + }, + data: { orderIndex: { increment: 1 } }, + }); + } + } + + // Updating order index of the request + let adjust: number; + if (isSameCollection) + adjust = nextRequest ? (isMovingUp ? 0 : -1) : 0; + else adjust = nextRequest ? 0 : 1; + + const newOrderIndex = + (nextReqOrderIndex ?? reqCountInDestColl) + adjust; + + const updatedRequest = await tx.userRequest.update({ + where: { id: request.id }, + data: { orderIndex: newOrderIndex, collectionID: destCollID }, + }); + return E.right(updatedRequest); + } + }); + } catch (error) { + console.error('Error from UserRequestService.reorderRequests', error); + return E.left(USER_REQUEST_REORDERING_FAILED); + } + } + + /** + * Sort user requests inside a collection + * @param userUid UID of the user who owns the collection + * @param collectionID ID of the collection to which the requests belong + * @param sortBy Sorting option + * @returns Either of a boolean + */ + async sortUserRequests( + userUid: string, + collectionID: string, + sortBy: SortOptions, + ): Promise | E.Right> { + if (!collectionID) return E.right(true); + + let orderBy: Prisma.Enumerable; + if (sortBy === SortOptions.TITLE_ASC) { + orderBy = { title: 'asc' }; + } else if (sortBy === SortOptions.TITLE_DESC) { + orderBy = { title: 'desc' }; + } else { + orderBy = { orderIndex: 'asc' }; + } + + try { + await this.prisma.$transaction(async (tx) => { + await this.prisma.lockUserRequestByCollections(tx, userUid, [ + collectionID, + ]); + + const userRequests = await tx.userRequest.findMany({ + where: { userUid, collectionID }, + orderBy, + select: { id: true }, + }); + + // Update the orderIndex of each request based on the new order (parallel) + const promises = userRequests.map((request, i) => + tx.userRequest.update({ + where: { id: request.id }, + data: { orderIndex: i + 1 }, + }), + ); + await Promise.all(promises); + }); + } catch (error) { + console.error('Error from UserRequestService.sortUserRequests', error); + return E.left(USER_REQUEST_REORDERING_FAILED); + } + + return E.right(true); + } +} diff --git a/packages/hoppscotch-backend/src/user-settings/user-settings.model.ts b/packages/hoppscotch-backend/src/user-settings/user-settings.model.ts new file mode 100644 index 0000000..81d1af8 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-settings/user-settings.model.ts @@ -0,0 +1,24 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class UserSettings { + @Field(() => ID, { + description: 'ID of the User Setting', + }) + id: string; + + @Field(() => ID, { + description: 'ID of the user this setting belongs to', + }) + userUid: string; + + @Field({ + description: 'Stringified JSON settings object', + }) + properties: string; // JSON string of the userSettings object (format:[{ key: "background", value: "system" }, ...] ) which will be received from the client + + @Field({ + description: 'Last updated on', + }) + updatedOn: Date; +} diff --git a/packages/hoppscotch-backend/src/user-settings/user-settings.module.ts b/packages/hoppscotch-backend/src/user-settings/user-settings.module.ts new file mode 100644 index 0000000..ac5204b --- /dev/null +++ b/packages/hoppscotch-backend/src/user-settings/user-settings.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { UserModule } from 'src/user/user.module'; +import { UserSettingsResolver } from './user-settings.resolver'; +import { UserSettingsService } from './user-settings.service'; +import { UserSettingsUserResolver } from './user.resolver'; + +@Module({ + imports: [UserModule], + providers: [ + UserSettingsResolver, + UserSettingsService, + UserSettingsUserResolver, + ], +}) +export class UserSettingsModule {} diff --git a/packages/hoppscotch-backend/src/user-settings/user-settings.resolver.ts b/packages/hoppscotch-backend/src/user-settings/user-settings.resolver.ts new file mode 100644 index 0000000..58083b5 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-settings/user-settings.resolver.ts @@ -0,0 +1,84 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver, Subscription } from '@nestjs/graphql'; +import { GqlUser } from 'src/decorators/gql-user.decorator'; +import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; +import { User } from 'src/user/user.model'; +import * as E from 'fp-ts/Either'; +import { throwErr } from 'src/utils'; +import { UserSettings } from './user-settings.model'; +import { UserSettingsService } from './user-settings.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { AuthUser } from 'src/types/AuthUser'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { SkipThrottle } from '@nestjs/throttler'; + +@UseGuards(GqlThrottlerGuard) +@Resolver() +export class UserSettingsResolver { + constructor( + private readonly userSettingsService: UserSettingsService, + private readonly pubsub: PubSubService, + ) {} + + /* Mutations */ + + @Mutation(() => UserSettings, { + description: 'Creates a new user setting', + }) + @UseGuards(GqlAuthGuard) + async createUserSettings( + @GqlUser() user: AuthUser, + @Args({ + name: 'properties', + description: 'Stringified JSON settings object', + }) + properties: string, + ) { + const createdUserSettings = + await this.userSettingsService.createUserSettings(user, properties); + + if (E.isLeft(createdUserSettings)) throwErr(createdUserSettings.left); + return createdUserSettings.right; + } + + @Mutation(() => UserSettings, { + description: 'Update user setting for a given user', + }) + @UseGuards(GqlAuthGuard) + async updateUserSettings( + @GqlUser() user: AuthUser, + @Args({ + name: 'properties', + description: 'Stringified JSON settings object', + }) + properties: string, + ) { + const updatedUserSettings = + await this.userSettingsService.updateUserSettings(user, properties); + + if (E.isLeft(updatedUserSettings)) throwErr(updatedUserSettings.left); + return updatedUserSettings.right; + } + + /* Subscriptions */ + + @Subscription(() => UserSettings, { + description: 'Listen for user setting creation', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userSettingsCreated(@GqlUser() user: User) { + return this.pubsub.asyncIterator(`user_settings/${user.uid}/created`); + } + + @Subscription(() => UserSettings, { + description: 'Listen for user setting updates', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userSettingsUpdated(@GqlUser() user: User) { + return this.pubsub.asyncIterator(`user_settings/${user.uid}/updated`); + } +} diff --git a/packages/hoppscotch-backend/src/user-settings/user-settings.service.spec.ts b/packages/hoppscotch-backend/src/user-settings/user-settings.service.spec.ts new file mode 100644 index 0000000..48d60da --- /dev/null +++ b/packages/hoppscotch-backend/src/user-settings/user-settings.service.spec.ts @@ -0,0 +1,135 @@ +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { UserSettingsService } from './user-settings.service'; +import { JSON_INVALID, USER_SETTINGS_NULL_SETTINGS } from 'src/errors'; +import { UserSettings } from './user-settings.model'; +import { AuthUser } from 'src/types/AuthUser'; + +const mockPrisma = mockDeep(); +const mockPubSub = mockDeep(); + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const userSettingsService = new UserSettingsService( + mockPrisma, + mockPubSub as any, +); + +const currentTime = new Date(); + +const user: AuthUser = { + uid: 'aabb22ccdd', + displayName: 'user-display-name', + email: 'user-email', + photoURL: 'user-photo-url', + isAdmin: false, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + currentGQLSession: {}, + currentRESTSession: {}, + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, +}; + +const settings: UserSettings = { + id: '1', + userUid: user.uid, + properties: JSON.stringify({ key: 'k', value: 'v' }), + updatedOn: new Date('2022-12-19T12:43:18.635Z'), +}; + +beforeEach(() => { + mockReset(mockPrisma); + mockPubSub.publish.mockClear(); +}); + +describe('UserSettingsService', () => { + describe('createUserSettings', () => { + test('Should resolve right and create an user setting with valid user and properties', async () => { + mockPrisma.userSettings.create.mockResolvedValue({ + ...settings, + properties: JSON.parse(settings.properties), + }); + + const result = await userSettingsService.createUserSettings( + user, + settings.properties, + ); + + expect(result).toEqualRight(settings); + }); + test('Should reject user settings creation for invalid properties', async () => { + const result = await userSettingsService.createUserSettings( + user, + 'invalid-settings', + ); + + expect(result).toEqualLeft(JSON_INVALID); + }); + test('Should reject user settings creation for null properties', async () => { + const result = await userSettingsService.createUserSettings( + user, + null as any, + ); + + expect(result).toEqualLeft(USER_SETTINGS_NULL_SETTINGS); + }); + test('Should publish pubsub message on successful user settings create', async () => { + mockPrisma.userSettings.create.mockResolvedValue({ + ...settings, + properties: JSON.parse(settings.properties), + }); + + await userSettingsService.createUserSettings(user, settings.properties); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_settings/${user.uid}/created`, + settings, + ); + }); + }); + describe('updateUserSettings', () => { + test('Should update a user setting for valid user and settings', async () => { + mockPrisma.userSettings.update.mockResolvedValue({ + ...settings, + properties: JSON.parse(settings.properties), + }); + + const result = await userSettingsService.updateUserSettings( + user, + settings.properties, + ); + + expect(result).toEqualRight(settings); + }); + test('Should reject user settings updation for invalid stringified JSON settings', async () => { + const result = await userSettingsService.updateUserSettings( + user, + 'invalid-settings', + ); + + expect(result).toEqualLeft(JSON_INVALID); + }); + test('Should reject user settings updation for null properties', async () => { + const result = await userSettingsService.updateUserSettings( + user, + null as any, + ); + expect(result).toEqualLeft(USER_SETTINGS_NULL_SETTINGS); + }); + test('Should publish pubsub message on successful user settings update', async () => { + mockPrisma.userSettings.update.mockResolvedValue({ + ...settings, + properties: JSON.parse(settings.properties), + }); + + await userSettingsService.updateUserSettings(user, settings.properties); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_settings/${user.uid}/updated`, + settings, + ); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/user-settings/user-settings.service.ts b/packages/hoppscotch-backend/src/user-settings/user-settings.service.ts new file mode 100644 index 0000000..6557ac1 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-settings/user-settings.service.ts @@ -0,0 +1,110 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { User } from 'src/user/user.model'; +import * as E from 'fp-ts/Either'; +import { stringToJson } from 'src/utils'; +import { UserSettings as DbUserSettings } from 'src/generated/prisma/client'; +import { UserSettings } from './user-settings.model'; +import { + USER_SETTINGS_ALREADY_EXISTS, + USER_SETTINGS_NULL_SETTINGS, + USER_SETTINGS_NOT_FOUND, +} from 'src/errors'; +import { AuthUser } from 'src/types/AuthUser'; + +@Injectable() +export class UserSettingsService { + constructor( + private readonly prisma: PrismaService, + private readonly pubsub: PubSubService, + ) {} + + private castToUserSettings(userSettings: DbUserSettings): UserSettings { + return { + ...userSettings, + properties: JSON.stringify(userSettings.properties), + }; + } + + /** + * Fetch user settings for a given user + * @param user User object + * @returns Promise of an Either of `UserSettings` or error + */ + async fetchUserSettings(user: User) { + try { + const userSettings = await this.prisma.userSettings.findUniqueOrThrow({ + where: { userUid: user.uid }, + }); + + const settings = this.castToUserSettings(userSettings); + + return E.right(settings); + } catch (e) { + return E.left(USER_SETTINGS_NOT_FOUND); + } + } + + /** + * Create user setting for a given user + * @param user User object + * @param properties stringified user settings properties + * @returns an Either of `UserSettings` or error + */ + async createUserSettings(user: AuthUser, properties: string) { + if (!properties) return E.left(USER_SETTINGS_NULL_SETTINGS); + + const jsonProperties = stringToJson(properties); + if (E.isLeft(jsonProperties)) return E.left(jsonProperties.left); + + try { + const userSettings = await this.prisma.userSettings.create({ + data: { + properties: jsonProperties.right, + userUid: user.uid, + }, + }); + + const settings = this.castToUserSettings(userSettings); + + // Publish subscription for user settings creation + await this.pubsub.publish(`user_settings/${user.uid}/created`, settings); + + return E.right(settings); + } catch (e) { + return E.left(USER_SETTINGS_ALREADY_EXISTS); + } + } + + /** + * Update user setting for a given user + * @param user User object + * @param properties stringified user settings + * @returns Promise of an Either of `UserSettings` or error + */ + async updateUserSettings(user: AuthUser, properties: string) { + if (!properties) return E.left(USER_SETTINGS_NULL_SETTINGS); + + const jsonProperties = stringToJson(properties); + if (E.isLeft(jsonProperties)) return E.left(jsonProperties.left); + + try { + const updatedUserSettings = await this.prisma.userSettings.update({ + where: { userUid: user.uid }, + data: { + properties: jsonProperties.right, + }, + }); + + const settings = this.castToUserSettings(updatedUserSettings); + + // Publish subscription for user settings update + await this.pubsub.publish(`user_settings/${user.uid}/updated`, settings); + + return E.right(settings); + } catch (e) { + return E.left(USER_SETTINGS_NOT_FOUND); + } + } +} diff --git a/packages/hoppscotch-backend/src/user-settings/user.resolver.ts b/packages/hoppscotch-backend/src/user-settings/user.resolver.ts new file mode 100644 index 0000000..d191e03 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-settings/user.resolver.ts @@ -0,0 +1,27 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { UseGuards } from '@nestjs/common'; +import { User } from 'src/user/user.model'; +import { UserSettings } from './user-settings.model'; +import { UserSettingsService } from './user-settings.service'; +import * as E from 'fp-ts/Either'; +import { throwErr } from 'src/utils'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { GqlUser } from '../decorators/gql-user.decorator'; +import { USER_SETTINGS_NOT_FOUND } from '../errors'; + +@Resolver(() => User) +export class UserSettingsUserResolver { + constructor(private readonly userSettingsService: UserSettingsService) {} + + @ResolveField(() => UserSettings, { + description: 'Returns user settings', + }) + @UseGuards(GqlAuthGuard) + async settings(@Parent() user: User, @GqlUser() requestingUser: User) { + if (requestingUser?.uid !== user.uid) throwErr(USER_SETTINGS_NOT_FOUND); + const userSettings = await this.userSettingsService.fetchUserSettings(user); + + if (E.isLeft(userSettings)) throwErr(userSettings.left); + return userSettings.right; + } +} diff --git a/packages/hoppscotch-backend/src/user/user.data.handler.ts b/packages/hoppscotch-backend/src/user/user.data.handler.ts new file mode 100644 index 0000000..ce62157 --- /dev/null +++ b/packages/hoppscotch-backend/src/user/user.data.handler.ts @@ -0,0 +1,11 @@ +import * as T from 'fp-ts/Task'; +import * as TO from 'fp-ts/TaskOption'; +import { AuthUser } from '../types/AuthUser'; + +/** + * Defines how external services should handle User Data and User data related operations and actions. + */ +export interface UserDataHandler { + canAllowUserDeletion: (user: AuthUser) => TO.TaskOption; + onUserDelete: (user: AuthUser) => T.Task; +} diff --git a/packages/hoppscotch-backend/src/user/user.model.ts b/packages/hoppscotch-backend/src/user/user.model.ts new file mode 100644 index 0000000..4cec5ce --- /dev/null +++ b/packages/hoppscotch-backend/src/user/user.model.ts @@ -0,0 +1,89 @@ +import { ObjectType, ID, Field, registerEnumType } from '@nestjs/graphql'; + +@ObjectType() +export class User { + @Field(() => ID, { + description: 'UID of the user', + }) + uid: string; + + @Field({ + nullable: true, + description: 'Name of the user (if fetched)', + }) + displayName?: string; + + @Field({ + nullable: true, + description: 'Email of the user', + }) + email?: string; + + @Field({ + nullable: true, + description: 'URL to the profile photo of the user (if fetched)', + }) + photoURL?: string; + + @Field({ + description: 'Flag to determine if user is an Admin or not', + }) + isAdmin: boolean; + + @Field({ + nullable: true, + description: 'Date when the user last logged in', + }) + lastLoggedOn: Date; + + @Field({ + nullable: true, + description: 'Date when the user last interacted with the app', + }) + lastActiveOn: Date; + + @Field({ + description: 'Date when the user account was created', + }) + createdOn: Date; + + @Field({ + nullable: true, + description: 'Stringified current REST session for logged-in User', + }) + currentRESTSession?: string; + + @Field({ + nullable: true, + description: 'Stringified current GraphQL session for logged-in User', + }) + currentGQLSession?: string; +} + +export enum SessionType { + REST = 'REST', + GQL = 'GQL', +} + +registerEnumType(SessionType, { + name: 'SessionType', +}); + +@ObjectType() +export class UserDeletionResult { + @Field(() => ID, { + description: 'UID of the user', + }) + userUID: string; + + @Field(() => Boolean, { + description: 'Flag to determine if user deletion was successful or not', + }) + isDeleted: boolean; + + @Field({ + nullable: true, + description: 'Error message if user deletion was not successful', + }) + errorMessage: string; +} diff --git a/packages/hoppscotch-backend/src/user/user.module.ts b/packages/hoppscotch-backend/src/user/user.module.ts new file mode 100644 index 0000000..bdd2ccf --- /dev/null +++ b/packages/hoppscotch-backend/src/user/user.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { UserResolver } from './user.resolver'; +import { UserService } from './user.service'; + +@Module({ + providers: [UserResolver, UserService], + exports: [UserService], +}) +export class UserModule {} diff --git a/packages/hoppscotch-backend/src/user/user.resolver.ts b/packages/hoppscotch-backend/src/user/user.resolver.ts new file mode 100644 index 0000000..da83e86 --- /dev/null +++ b/packages/hoppscotch-backend/src/user/user.resolver.ts @@ -0,0 +1,117 @@ +import { Resolver, Query, Mutation, Args, Subscription } from '@nestjs/graphql'; +import { SessionType, User } from './user.model'; +import { UseGuards } from '@nestjs/common'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { GqlUser } from '../decorators/gql-user.decorator'; +import { UserService } from './user.service'; +import { throwErr } from 'src/utils'; +import * as E from 'fp-ts/lib/Either'; +import * as TE from 'fp-ts/TaskEither'; +import { pipe } from 'fp-ts/function'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { AuthUser } from 'src/types/AuthUser'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { SkipThrottle } from '@nestjs/throttler'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => User) +export class UserResolver { + constructor( + private readonly userService: UserService, + private readonly pubsub: PubSubService, + ) {} + + @Query(() => User, { + description: + "Gives details of the user executing this query (pass Authorization 'Bearer' header)", + }) + @UseGuards(GqlAuthGuard) + me(@GqlUser() user: AuthUser) { + return this.userService.convertDbUserToUser(user); + } + + /* Mutations */ + + @Mutation(() => User, { + description: 'Update user sessions', + }) + @UseGuards(GqlAuthGuard) + async updateUserSessions( + @GqlUser() user: AuthUser, + @Args({ + name: 'currentSession', + description: 'JSON string of the saved REST/GQL session', + }) + currentSession: string, + @Args({ + name: 'sessionType', + description: 'Type of the session', + type: () => SessionType, + }) + sessionType: SessionType, + ): Promise { + const updatedUser = await this.userService.updateUserSessions( + user, + currentSession, + sessionType, + ); + if (E.isLeft(updatedUser)) throwErr(updatedUser.left); + return updatedUser.right; + } + + @Mutation(() => User, { + description: 'Update a users display name', + }) + @UseGuards(GqlAuthGuard) + async updateDisplayName( + @GqlUser() user: AuthUser, + @Args({ + name: 'updatedDisplayName', + description: 'New name of user', + type: () => String, + }) + updatedDisplayName: string, + ) { + const updatedUser = await this.userService.updateUserDisplayName( + user.uid, + updatedDisplayName, + ); + + if (E.isLeft(updatedUser)) throwErr(updatedUser.left); + return updatedUser.right; + } + + @Mutation(() => Boolean, { + description: 'Delete an user account', + }) + @UseGuards(GqlAuthGuard) + deleteUser(@GqlUser() user: AuthUser): Promise { + return pipe( + this.userService.deleteUserByUID(user), + TE.map(() => true), + TE.mapLeft((message) => message.toString()), + TE.getOrElse(throwErr), + )(); + } + + /* Subscriptions */ + + @Subscription(() => User, { + description: 'Listen for user updates', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + userUpdated(@GqlUser() user: User) { + return this.pubsub.asyncIterator(`user/${user.uid}/updated`); + } + + @Subscription(() => User, { + description: 'Listen for user deletion', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userDeleted(@GqlUser() user: User): AsyncIterator { + return this.pubsub.asyncIterator(`user/${user.uid}/deleted`); + } +} diff --git a/packages/hoppscotch-backend/src/user/user.service.spec.ts b/packages/hoppscotch-backend/src/user/user.service.spec.ts new file mode 100644 index 0000000..72c129b --- /dev/null +++ b/packages/hoppscotch-backend/src/user/user.service.spec.ts @@ -0,0 +1,727 @@ +import { + JSON_INVALID, + USERS_NOT_FOUND, + USER_NOT_FOUND, + USER_SHORT_DISPLAY_NAME, +} from 'src/errors'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { AuthUser } from 'src/types/AuthUser'; +import { User } from './user.model'; +import { UserService } from './user.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import * as TO from 'fp-ts/TaskOption'; +import * as T from 'fp-ts/Task'; + +const mockPrisma = mockDeep(); +const mockPubSub = mockDeep(); +let service: UserService; + +const handler1 = { + canAllowUserDeletion: jest.fn(), + onUserDelete: jest.fn(), +}; + +const handler2 = { + canAllowUserDeletion: jest.fn(), + onUserDelete: jest.fn(), +}; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const userService = new UserService(mockPrisma, mockPubSub as any); + +const currentTime = new Date(); + +const user: AuthUser = { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + currentRESTSession: {}, + currentGQLSession: {}, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, +}; + +const adminUser: AuthUser = { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: true, + currentRESTSession: {}, + currentGQLSession: {}, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, +}; + +const users: AuthUser[] = [ + { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + currentRESTSession: {}, + currentGQLSession: {}, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, + }, + { + uid: '5555', + email: 'abc@dundermifflin.com', + displayName: 'abc Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + currentRESTSession: {}, + currentGQLSession: {}, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, + }, + { + uid: '6666', + email: 'def@dundermifflin.com', + displayName: 'def Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + currentRESTSession: {}, + currentGQLSession: {}, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, + }, +]; + +const adminUsers: AuthUser[] = [ + { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: true, + currentRESTSession: {}, + currentGQLSession: {}, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, + }, + { + uid: '5555', + email: 'abc@dundermifflin.com', + displayName: 'abc Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: true, + currentRESTSession: {}, + currentGQLSession: {}, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, + }, + { + uid: '6666', + email: 'def@dundermifflin.com', + displayName: 'def Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: true, + currentRESTSession: {}, + currentGQLSession: {}, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, + }, +]; + +const exampleSSOProfileData = { + id: '123rfedvd', + emails: [{ value: 'dwight@dundermifflin.com' }], + displayName: 'Dwight Schrute', + provider: 'google', + photos: 'https://en.wikipedia.org/wiki/Dwight_Schrute', +}; + +beforeAll(() => { + process.env.DATA_ENCRYPTION_KEY = '12345678901234567890123456789012'; +}); + +afterAll(() => { + delete process.env.DATA_ENCRYPTION_KEY; // Clean up after tests +}); + +beforeEach(() => { + mockReset(mockPrisma); + mockPubSub.publish.mockClear(); + service = new UserService(mockPrisma, mockPubSub as any); + + service.registerUserDataHandler(handler1); + service.registerUserDataHandler(handler2); +}); + +describe('UserService', () => { + describe('findUserByEmail', () => { + test('should successfully return a valid user given a valid email', async () => { + mockPrisma.user.findFirst.mockResolvedValueOnce(user); + + const result = await userService.findUserByEmail( + 'dwight@dundermifflin.com', + ); + expect(result).toEqualSome(user); + }); + + test('should return a null user given a invalid email', async () => { + mockPrisma.user.findFirst.mockResolvedValueOnce(null); + + const result = await userService.findUserByEmail('jim@dundermifflin.com'); + expect(result).resolves.toBeNone; + }); + }); + + describe('findUserById', () => { + test('should successfully return a valid user given a valid user uid', async () => { + mockPrisma.user.findUniqueOrThrow.mockResolvedValueOnce(user); + + const result = await userService.findUserById('123344'); + expect(result).toEqualSome(user); + }); + + test('should return a null user given a invalid user uid', async () => { + mockPrisma.user.findUniqueOrThrow.mockRejectedValueOnce('NotFoundError'); + + const result = await userService.findUserById('sdcvbdbr'); + expect(result).resolves.toBeNone; + }); + }); + + describe('findUsersByIds', () => { + test('should successfully return users given valid user UIDs', async () => { + mockPrisma.user.findMany.mockResolvedValueOnce(users); + + const result = await userService.findUsersByIds([ + '123344', + '5555', + '6666', + ]); + expect(result).toEqual(users); + }); + + test('should return empty array of users given a invalid user UIDs', async () => { + mockPrisma.user.findMany.mockResolvedValueOnce([]); + + const result = await userService.findUsersByIds(['sdcvbdbr']); + expect(result).toEqual([]); + }); + }); + + describe('createUserViaMagicLink', () => { + test('should successfully create user and account for magic-link given valid inputs', async () => { + mockPrisma.user.create.mockResolvedValueOnce(user); + + const result = await userService.createUserViaMagicLink( + 'dwight@dundermifflin.com', + ); + expect(result).toEqual(user); + }); + }); + + describe('createUserSSO', () => { + test('should successfully create user and account for SSO provider given valid inputs ', async () => { + mockPrisma.user.create.mockResolvedValueOnce(user); + + const result = await userService.createUserSSO( + 'sdcsdcsdc', + 'dscsdc', + exampleSSOProfileData, + ); + expect(result).toEqual(user); + }); + + test('should successfully create user and account for SSO provider given no displayName ', async () => { + mockPrisma.user.create.mockResolvedValueOnce({ + ...user, + displayName: null, + }); + + const result = await userService.createUserSSO('sdcsdcsdc', 'dscsdc', { + ...exampleSSOProfileData, + displayName: null, + }); + + expect(result).toEqual({ + ...user, + displayName: null, + }); + }); + + test('should successfully create user and account for SSO provider given no photoURL ', async () => { + mockPrisma.user.create.mockResolvedValueOnce({ + ...user, + photoURL: null, + }); + + const result = await userService.createUserSSO('sdcsdcsdc', 'dscsdc', { + ...exampleSSOProfileData, + photoURL: null, + }); + + expect(result).toEqual({ + ...user, + photoURL: null, + }); + }); + }); + + describe('createProviderAccount', () => { + test('should successfully create ProviderAccount for user given valid inputs ', async () => { + mockPrisma.account.create.mockResolvedValueOnce({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: 'dscsdc', + providerAccessToken: 'sdcsdcsdc', + providerScope: 'user.email', + loggedIn: currentTime, + }); + + const result = await userService.createProviderAccount( + user, + 'sdcsdcsdc', + 'dscsdc', + exampleSSOProfileData, + ); + expect(result).toEqual({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: 'dscsdc', + providerAccessToken: 'sdcsdcsdc', + providerScope: 'user.email', + loggedIn: currentTime, + }); + }); + + test('should successfully create ProviderAccount for user given no accessToken ', async () => { + mockPrisma.account.create.mockResolvedValueOnce({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: 'dscsdc', + providerAccessToken: null, + providerScope: 'user.email', + loggedIn: currentTime, + }); + + const result = await userService.createProviderAccount( + user, + 'sdcsdcsdc', + 'dscsdc', + exampleSSOProfileData, + ); + expect(result).toEqual({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: 'dscsdc', + providerAccessToken: null, + providerScope: 'user.email', + loggedIn: currentTime, + }); + }); + + test('should successfully create ProviderAccount for user given no refreshToken', async () => { + mockPrisma.account.create.mockResolvedValueOnce({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: null, + providerAccessToken: 'sdcsdcsdc', + providerScope: 'user.email', + loggedIn: currentTime, + }); + + const result = await userService.createProviderAccount( + user, + 'sdcsdcsdc', + 'dscsdc', + exampleSSOProfileData, + ); + expect(result).toEqual({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: null, + providerAccessToken: 'sdcsdcsdc', + providerScope: 'user.email', + loggedIn: currentTime, + }); + }); + }); + + describe('updateUserSessions', () => { + test('Should resolve right and update users GQL session', async () => { + const sessionData = user.currentGQLSession; + + mockPrisma.user.update.mockResolvedValue({ + ...user, + currentGQLSession: sessionData, + currentRESTSession: null, + }); + + const result = await userService.updateUserSessions( + user, + JSON.stringify(sessionData), + 'GQL', + ); + + expect(result).toEqualRight({ + ...user, + currentGQLSession: JSON.stringify(sessionData), + currentRESTSession: null, + }); + }); + test('Should resolve right and update users REST session', async () => { + const sessionData = user.currentGQLSession; + mockPrisma.user.update.mockResolvedValue({ + ...user, + currentGQLSession: null, + currentRESTSession: sessionData, + }); + + const result = await userService.updateUserSessions( + user, + JSON.stringify(sessionData), + 'REST', + ); + + expect(result).toEqualRight({ + ...user, + currentGQLSession: null, + currentRESTSession: JSON.stringify(sessionData), + }); + }); + test('Should reject left and update user for invalid GQL session', async () => { + const sessionData = 'invalid json'; + + const result = await userService.updateUserSessions( + user, + sessionData, + 'GQL', + ); + + expect(result).toEqualLeft(JSON_INVALID); + }); + test('Should reject left and update user for invalid REST session', async () => { + const sessionData = 'invalid json'; + + const result = await userService.updateUserSessions( + user, + sessionData, + 'REST', + ); + + expect(result).toEqualLeft(JSON_INVALID); + }); + + test('Should publish pubsub message on user update sessions', async () => { + mockPrisma.user.update.mockResolvedValue({ + ...user, + }); + + await userService.updateUserSessions( + user, + JSON.stringify(user.currentGQLSession), + 'GQL', + ); + + expect(mockPubSub.publish).toHaveBeenCalledTimes(1); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user/${user.uid}/updated`, + { + ...user, + currentGQLSession: JSON.stringify(user.currentGQLSession), + currentRESTSession: JSON.stringify(user.currentRESTSession), + }, + ); + }); + }); + + describe('updateUserDisplayName', () => { + test('should resolve right and update user display name', async () => { + const newDisplayName = 'New Name'; + mockPrisma.user.update.mockResolvedValueOnce({ + ...user, + displayName: newDisplayName, + }); + + const result = await userService.updateUserDisplayName( + user.uid, + newDisplayName, + ); + expect(result).toEqualRight({ + ...user, + displayName: newDisplayName, + currentGQLSession: JSON.stringify(user.currentGQLSession), + currentRESTSession: JSON.stringify(user.currentRESTSession), + }); + }); + test('should resolve right and publish user updated subscription', async () => { + const newDisplayName = 'New Name'; + mockPrisma.user.update.mockResolvedValueOnce({ + ...user, + displayName: newDisplayName, + }); + + await userService.updateUserDisplayName(user.uid, user.displayName); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user/${user.uid}/updated`, + { + ...user, + displayName: newDisplayName, + currentGQLSession: JSON.stringify(user.currentGQLSession), + currentRESTSession: JSON.stringify(user.currentRESTSession), + }, + ); + }); + test('should resolve left and error when invalid user uid is passed', async () => { + mockPrisma.user.update.mockRejectedValueOnce('NotFoundError'); + + const result = await userService.updateUserDisplayName( + 'invalidUserUid', + user.displayName, + ); + expect(result).toEqualLeft(USER_NOT_FOUND); + }); + test('should resolve left and error when short display name is passed', async () => { + const newDisplayName = ''; + const result = await userService.updateUserDisplayName( + user.uid, + newDisplayName, + ); + expect(result).toEqualLeft(USER_SHORT_DISPLAY_NAME); + }); + }); + + describe('updateUserLastLoggedOn', () => { + test('should resolve right and update user last logged on', async () => { + const currentTime = new Date(); + mockPrisma.user.update.mockResolvedValueOnce({ + ...user, + lastLoggedOn: currentTime, + }); + + const result = await userService.updateUserLastLoggedOn(user.uid); + expect(result).toEqualRight(true); + }); + + test('should resolve left and error when invalid user uid is passed', async () => { + mockPrisma.user.update.mockRejectedValueOnce('NotFoundError'); + + const result = await userService.updateUserLastLoggedOn('invalidUserUid'); + expect(result).toEqualLeft(USER_NOT_FOUND); + }); + }); + + describe('fetchAllUsers', () => { + test('should resolve right and return 20 users when cursor is null', async () => { + mockPrisma.user.findMany.mockResolvedValueOnce(users); + + const result = await userService.fetchAllUsers(null, 20); + expect(result).toEqual(users); + }); + test('should resolve right and return next 20 users when cursor is provided', async () => { + mockPrisma.user.findMany.mockResolvedValueOnce(users); + + const result = await userService.fetchAllUsers('123344', 20); + expect(result).toEqual(users); + }); + test('should resolve left and return an empty array when users not found', async () => { + mockPrisma.user.findMany.mockResolvedValueOnce([]); + + const result = await userService.fetchAllUsers(null, 20); + expect(result).toEqual([]); + }); + }); + + describe('fetchAllUsersV2', () => { + test('should resolve right and return first 20 users when searchString is null', async () => { + mockPrisma.user.findMany.mockResolvedValueOnce(users); + + const result = await userService.fetchAllUsersV2(null, { + take: 20, + skip: 0, + }); + expect(result).toEqual(users); + }); + test('should resolve right and return next 20 users when searchString is provided', async () => { + mockPrisma.user.findMany.mockResolvedValueOnce(users); + + const result = await userService.fetchAllUsersV2('.com', { + take: 20, + skip: 0, + }); + expect(result).toEqual(users); + }); + test('should resolve left and return an empty array when users not found', async () => { + mockPrisma.user.findMany.mockResolvedValueOnce([]); + + const result = await userService.fetchAllUsersV2('Unknown entry', { + take: 20, + skip: 0, + }); + expect(result).toEqual([]); + }); + }); + + describe('fetchAdminUsers', () => { + test('should return a list of admin users', async () => { + mockPrisma.user.findMany.mockResolvedValueOnce(adminUsers); + const result = await userService.fetchAdminUsers(); + expect(mockPrisma.user.findMany).toHaveBeenCalledWith({ + where: { + isAdmin: true, + }, + }); + expect(result).toEqual(adminUsers); + }); + test('should return null when no admin users found', async () => { + mockPrisma.user.findMany.mockResolvedValueOnce(null); + const result = await userService.fetchAdminUsers(); + expect(mockPrisma.user.findMany).toHaveBeenCalledWith({ + where: { + isAdmin: true, + }, + }); + expect(result).toEqual(null); + }); + }); + + describe('makeAdmin', () => { + test('should resolve right and return a user object after making a user admin', async () => { + mockPrisma.user.update.mockResolvedValueOnce(adminUser); + const result = await userService.makeAdmin(user.uid); + expect(mockPrisma.user.update).toHaveBeenCalledWith({ + where: { + uid: user.uid, + }, + data: { + isAdmin: true, + }, + }); + expect(result).toEqualRight(adminUser); + }); + test('should resolve left and error when invalid user uid is passed', async () => { + mockPrisma.user.update.mockRejectedValueOnce('NotFoundError'); + const result = await userService.makeAdmin(user.uid); + expect(mockPrisma.user.update).toHaveBeenCalledWith({ + where: { + uid: user.uid, + }, + data: { + isAdmin: true, + }, + }); + expect(result).toEqualLeft(USER_NOT_FOUND); + }); + }); + + describe('deleteUserByID', () => { + test('should resolve right for valid user uid and perform successful user deletion', () => { + // For a successful deletion, the handlers should allow user deletion + handler1.canAllowUserDeletion.mockImplementation(() => TO.none); + handler2.canAllowUserDeletion.mockImplementation(() => TO.none); + handler1.onUserDelete.mockImplementation(() => T.of(undefined)); + handler2.onUserDelete.mockImplementation(() => T.of(undefined)); + mockPrisma.user.delete.mockResolvedValueOnce(user); + + const result = service.deleteUserByUID(user)(); + return expect(result).resolves.toBeRight(); + }); + test('should resolve right for successful deletion and publish user deleted subscription', async () => { + // For a successful deletion, the handlers should allow user deletion + handler1.canAllowUserDeletion.mockImplementation(() => TO.none); + handler2.canAllowUserDeletion.mockImplementation(() => TO.none); + handler1.onUserDelete.mockImplementation(() => T.of(undefined)); + handler2.onUserDelete.mockImplementation(() => T.of(undefined)); + + mockPrisma.user.delete.mockResolvedValueOnce(user); + const result = service.deleteUserByUID(user)(); + await expect(result).resolves.toBeRight(); + + // fire the subscription for user deletion + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user/${user.uid}/deleted`, + { + uid: user.uid, + displayName: user.displayName, + email: user.email, + photoURL: user.photoURL, + isAdmin: user.isAdmin, + currentRESTSession: user.currentRESTSession, + currentGQLSession: user.currentGQLSession, + createdOn: user.createdOn, + }, + ); + }); + test("should resolve left when one or both the handlers don't allow userDeletion", () => { + // Handlers don't allow user deletion + handler1.canAllowUserDeletion.mockImplementation(() => TO.some); + handler2.canAllowUserDeletion.mockImplementation(() => TO.some); + + const result = service.deleteUserByUID(user)(); + return expect(result).resolves.toBeLeft(); + }); + test('should resolve left when there is an unsuccessful deletion of userdata from firestore', () => { + // Handlers allow deletion to proceed + handler1.canAllowUserDeletion.mockImplementation(() => TO.none); + handler2.canAllowUserDeletion.mockImplementation(() => TO.none); + handler1.onUserDelete.mockImplementation(() => T.of(undefined)); + handler2.onUserDelete.mockImplementation(() => T.of(undefined)); + + // Deleting users errors out + mockPrisma.user.delete.mockRejectedValueOnce('NotFoundError'); + + const result = service.deleteUserByUID(user)(); + return expect(result).resolves.toBeLeft(); + }); + }); + + describe('getUsersCount', () => { + test('should return count of all users in the organization', async () => { + mockPrisma.user.count.mockResolvedValueOnce(10); + + const result = await userService.getUsersCount(); + expect(result).toEqual(10); + }); + }); + + describe('removeUsersAsAdmin', () => { + test('should resolve right and return true for valid user UIDs', async () => { + mockPrisma.user.updateMany.mockResolvedValueOnce({ count: 1 }); + const result = await userService.removeUsersAsAdmin(['123344']); + expect(result).toEqualRight(true); + }); + test('should resolve right and return false for invalid user UIDs', async () => { + mockPrisma.user.updateMany.mockResolvedValueOnce({ count: 0 }); + const result = await userService.removeUsersAsAdmin(['123344']); + expect(result).toEqualLeft(USERS_NOT_FOUND); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/user/user.service.ts b/packages/hoppscotch-backend/src/user/user.service.ts new file mode 100644 index 0000000..35a76de --- /dev/null +++ b/packages/hoppscotch-backend/src/user/user.service.ts @@ -0,0 +1,651 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; +import * as O from 'fp-ts/Option'; +import * as E from 'fp-ts/Either'; +import * as TO from 'fp-ts/TaskOption'; +import * as TE from 'fp-ts/TaskEither'; +import * as T from 'fp-ts/Task'; +import * as A from 'fp-ts/Array'; +import { pipe, constVoid } from 'fp-ts/function'; +import { AuthUser } from 'src/types/AuthUser'; +import { + USERS_NOT_FOUND, + USER_NOT_FOUND, + USER_SHORT_DISPLAY_NAME, + USER_UPDATE_FAILED, +} from 'src/errors'; +import { SessionType, User } from './user.model'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { encrypt, stringToJson, taskEitherValidateArraySeq } from 'src/utils'; +import { UserDataHandler } from './user.data.handler'; +import { User as DbUser } from 'src/generated/prisma/client'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; +import { GetUserWorkspacesResponse } from 'src/infra-token/request-response.dto'; +import { TeamAccessRole } from 'src/team/team.model'; + +@Injectable() +export class UserService { + constructor( + private readonly prisma: PrismaService, + private readonly pubsub: PubSubService, + ) {} + + private userDataHandlers: UserDataHandler[] = []; + + registerUserDataHandler(handler: UserDataHandler) { + this.userDataHandlers.push(handler); + } + + /** + * Converts a prisma user object to a user object + * + * @param dbUser Prisma User object + * @returns User object + */ + convertDbUserToUser(dbUser: DbUser): User { + const dbCurrentRESTSession = dbUser.currentRESTSession; + const dbCurrentGQLSession = dbUser.currentGQLSession; + + return { + ...dbUser, + currentRESTSession: dbCurrentRESTSession + ? JSON.stringify(dbCurrentRESTSession) + : null, + currentGQLSession: dbCurrentGQLSession + ? JSON.stringify(dbCurrentGQLSession) + : null, + }; + } + + /** + * Find User with given email id + * + * @param email User's email + * @returns Option of found User + */ + async findUserByEmail(email: string): Promise> { + const user = await this.prisma.user.findFirst({ + where: { + email: { + equals: email, + mode: 'insensitive', + }, + }, + }); + if (!user) return O.none; + return O.some(user); + } + + /** + * Find User with given ID + * + * @param userUid User ID + * @returns Option of found User + */ + async findUserById(userUid: string): Promise> { + try { + const user = await this.prisma.user.findUniqueOrThrow({ + where: { + uid: userUid, + }, + }); + return O.some(user); + } catch (error) { + return O.none; + } + } + + /** + * Find users with given IDs + * @param userUIDs User IDs + * @returns Array of found Users + */ + async findUsersByIds(userUIDs: string[]): Promise { + const users = await this.prisma.user.findMany({ + where: { + uid: { in: userUIDs }, + }, + }); + return users; + } + + /** + * Update User with new generated hashed refresh token + * + * @param refreshTokenHash Hash of newly generated refresh token + * @param userUid User uid + * @returns Either of User with updated refreshToken + */ + async updateUserRefreshToken(refreshTokenHash: string, userUid: string) { + try { + const user = await this.prisma.user.update({ + where: { + uid: userUid, + }, + data: { + refreshToken: refreshTokenHash, + }, + }); + + return E.right(user); + } catch (error) { + return E.left(USER_NOT_FOUND); + } + } + + /** + * Create a new User when logged in via a Magic Link + * + * @param email User's Email + * @returns Created User + */ + async createUserViaMagicLink(email: string) { + const createdUser = await this.prisma.user.create({ + data: { + email: email, + providerAccounts: { + create: { + provider: 'magic', + providerAccountId: email, + }, + }, + }, + }); + + return createdUser; + } + + /** + * Create a new User when logged in via a SSO provider + * + * @param accessTokenSSO User's access token generated by providers + * @param refreshTokenSSO User's refresh token generated by providers + * @param profile Data received from SSO provider on the users account + * @returns Created User + */ + async createUserSSO( + accessTokenSSO: string, + refreshTokenSSO: string, + profile, + ) { + const userDisplayName = !profile.displayName ? null : profile.displayName; + const userPhotoURL = !profile.photos ? null : profile.photos[0].value; + + const createdUser = await this.prisma.user.create({ + data: { + displayName: userDisplayName, + email: profile.emails[0].value, + photoURL: userPhotoURL, + lastLoggedOn: new Date(), + providerAccounts: { + create: { + provider: profile.provider, + providerAccountId: profile.id, + providerRefreshToken: refreshTokenSSO, + providerAccessToken: accessTokenSSO, + }, + }, + }, + }); + + return createdUser; + } + + /** + * Create a new Account for a given User + * + * @param user User object + * @param accessToken User's access token generated by providers + * @param refreshToken User's refresh token generated by providers + * @param profile Data received from SSO provider on the users account + * @returns Created Account + */ + async createProviderAccount( + user: AuthUser, + accessToken: string, + refreshToken: string, + profile, + ) { + const createdProvider = await this.prisma.account.create({ + data: { + provider: profile.provider, + providerAccountId: profile.id, + providerRefreshToken: refreshToken ? encrypt(refreshToken) : null, + providerAccessToken: accessToken ? encrypt(accessToken) : null, + user: { + connect: { + uid: user.uid, + }, + }, + }, + }); + + return createdProvider; + } + + /** + * Update User displayName and photoURL when logged in via a SSO provider + * + * @param user User object + * @param profile Data received from SSO provider on the users account + * @returns Updated user object + */ + async updateUserDetails(user: AuthUser, profile) { + try { + const updatedUser = await this.prisma.user.update({ + where: { + uid: user.uid, + }, + data: { + displayName: !profile.displayName ? null : profile.displayName, + photoURL: !profile.photos ? null : profile.photos[0].value, + lastLoggedOn: new Date(), + }, + }); + return E.right(updatedUser); + } catch (error) { + return E.left(USER_NOT_FOUND); + } + } + + /** + * Update a user's sessions + * @param user User object + * @param currentRESTSession user's current REST session + * @param currentGQLSession user's current GQL session + * @returns a Either of User or error + */ + async updateUserSessions( + user: AuthUser, + currentSession: string, + sessionType: string, + ): Promise | E.Left> { + const validatedSession = await this.validateSession(currentSession); + if (E.isLeft(validatedSession)) return E.left(validatedSession.left); + + try { + const sessionObj = {}; + switch (sessionType) { + case SessionType.GQL: + sessionObj['currentGQLSession'] = validatedSession.right; + break; + case SessionType.REST: + sessionObj['currentRESTSession'] = validatedSession.right; + break; + default: + return E.left(USER_UPDATE_FAILED); + } + + const dbUpdatedUser = await this.prisma.user.update({ + where: { uid: user.uid }, + data: sessionObj, + }); + + const updatedUser = this.convertDbUserToUser(dbUpdatedUser); + + // Publish subscription for user updates + await this.pubsub.publish(`user/${updatedUser.uid}/updated`, updatedUser); + + return E.right(updatedUser); + } catch (e) { + return E.left(USER_UPDATE_FAILED); + } + } + + /** + * Update a user's displayName + * @param userUID User UID + * @param displayName User's displayName + * @returns a Either of User or error + */ + async updateUserDisplayName(userUID: string, displayName: string) { + if (!displayName || displayName.length === 0) { + return E.left(USER_SHORT_DISPLAY_NAME); + } + + try { + const dbUpdatedUser = await this.prisma.user.update({ + where: { uid: userUID }, + data: { displayName }, + }); + + const updatedUser = this.convertDbUserToUser(dbUpdatedUser); + + // Publish subscription for user updates + await this.pubsub.publish(`user/${updatedUser.uid}/updated`, updatedUser); + + return E.right(updatedUser); + } catch (error) { + return E.left(USER_NOT_FOUND); + } + } + + /** + * Update user's lastLoggedOn timestamp + * @param userUID User UID + */ + async updateUserLastLoggedOn(userUid: string) { + try { + await this.prisma.user.update({ + where: { uid: userUid }, + data: { lastLoggedOn: new Date() }, + }); + return E.right(true); + } catch (e) { + return E.left(USER_NOT_FOUND); + } + } + + /** + * Update user's lastActiveOn timestamp + * @param userUID User UID + */ + async updateUserLastActiveOn(userUid: string) { + try { + await this.prisma.user.update({ + where: { uid: userUid }, + data: { lastActiveOn: new Date() }, + }); + return E.right(true); + } catch (e) { + return E.left(USER_NOT_FOUND); + } + } + + /** + * Validate and parse currentRESTSession and currentGQLSession + * @param sessionData string of the session + * @returns a Either of JSON object or error + */ + async validateSession(sessionData: string) { + const jsonSession = stringToJson(sessionData); + if (E.isLeft(jsonSession)) return E.left(jsonSession.left); + + return E.right(jsonSession.right); + } + + /** + * Fetch all the users in the `User` table based on cursor + * @param cursorID string of userUID or null + * @param take number of users to query + * @returns an array of `User` object + * @deprecated use fetchAllUsersV2 instead + */ + async fetchAllUsers(cursorID: string, take: number) { + const fetchedUsers = await this.prisma.user.findMany({ + skip: cursorID ? 1 : 0, + take: take, + cursor: cursorID ? { uid: cursorID } : undefined, + }); + return fetchedUsers; + } + + /** + * Fetch all the users in the `User` table based on cursor + * @param searchString search on user's displayName or email + * @param paginationOption pagination options + * @returns an array of `User` object + */ + async fetchAllUsersV2( + searchString: string, + paginationOption: OffsetPaginationArgs, + ) { + const fetchedUsers = await this.prisma.user.findMany({ + skip: paginationOption.skip, + take: paginationOption.take, + where: searchString + ? { + OR: [ + { + displayName: { + contains: searchString, + mode: 'insensitive', + }, + }, + { + email: { + contains: searchString, + mode: 'insensitive', + }, + }, + ], + } + : undefined, + orderBy: [{ isAdmin: 'desc' }, { displayName: 'asc' }], + }); + + return fetchedUsers; + } + + /** + * Fetch the number of users in db + * @returns a count (Int) of user records in DB + */ + async getUsersCount() { + const usersCount = await this.prisma.user.count(); + return usersCount; + } + + /** + * Change a user to an admin by toggling isAdmin param to true + * @param userUID user UID + * @returns a Either of `User` object or error + */ + async makeAdmin(userUID: string) { + try { + const elevatedUser = await this.prisma.user.update({ + where: { + uid: userUID, + }, + data: { + isAdmin: true, + }, + }); + return E.right(elevatedUser); + } catch (error) { + return E.left(USER_NOT_FOUND); + } + } + + /** + * Change users to admins by toggling isAdmin param to true + * @param userUID user UIDs + * @returns a Either of true or error + */ + async makeAdmins(userUIDs: string[]) { + try { + await this.prisma.user.updateMany({ + where: { uid: { in: userUIDs } }, + data: { isAdmin: true }, + }); + return E.right(true); + } catch (error) { + return E.left(USER_UPDATE_FAILED); + } + } + + /** + * Fetch all the admin users + * @returns an array of admin users + */ + async fetchAdminUsers() { + const admins = this.prisma.user.findMany({ + where: { + isAdmin: true, + }, + }); + + return admins; + } + + /** + * Deletes a user account by UID + * @param uid User UID + * @returns an Either of string or boolean + */ + async deleteUserAccount(uid: string) { + try { + await this.prisma.user.delete({ + where: { + uid: uid, + }, + }); + return E.right(true); + } catch (e) { + return E.left(USER_NOT_FOUND); + } + } + + /** + * Get user deletion error messages when the data handlers are initialised in respective modules + * @param user User Object + * @returns an TaskOption of string array + */ + getUserDeletionErrors(user: AuthUser): TO.TaskOption { + return pipe( + this.userDataHandlers, + A.map((handler) => + pipe( + handler.canAllowUserDeletion(user), + TO.matchE( + () => TE.right(undefined), + (error) => TE.left(error), + ), + ), + ), + taskEitherValidateArraySeq, + TE.matchE( + (e) => TO.some(e), + () => TO.none, + ), + ); + } + + /** + * Deletes a user by UID + * @param user User Object + * @returns an TaskEither of string or boolean + */ + deleteUserByUID(user: AuthUser) { + return pipe( + this.getUserDeletionErrors(user), + TO.matchEW( + () => + pipe( + this.userDataHandlers, + A.map((handler) => handler.onUserDelete(user)), + T.sequenceArray, + T.map(constVoid), + TE.fromTask, + ) as TE.TaskEither, + (errors): TE.TaskEither => TE.left(errors), + ), + + TE.chainW(() => () => this.deleteUserAccount(user.uid)), + + TE.chainFirst(() => + TE.fromTask(() => + this.pubsub.publish(`user/${user.uid}/deleted`, { + uid: user.uid, + displayName: user.displayName, + email: user.email, + photoURL: user.photoURL, + isAdmin: user.isAdmin, + createdOn: user.createdOn, + currentGQLSession: user.currentGQLSession, + currentRESTSession: user.currentRESTSession, + }), + ), + ), + + TE.mapLeft((errors) => errors.toString()), + ); + } + + /** + * Change the user from an admin by toggling isAdmin param to false + * @param userUID user UID + * @returns a Either of `User` object or error + */ + async removeUserAsAdmin(userUID: string) { + try { + const user = await this.prisma.user.update({ + where: { + uid: userUID, + }, + data: { + isAdmin: false, + }, + }); + return E.right(user); + } catch (error) { + return E.left(USER_NOT_FOUND); + } + } + + /** + * Change users from an admin by toggling isAdmin param to false + * @param userUIDs user UIDs + * @returns a Either of true or error + */ + async removeUsersAsAdmin(userUIDs: string[]) { + const data = await this.prisma.user.updateMany({ + where: { uid: { in: userUIDs } }, + data: { isAdmin: false }, + }); + + if (data.count === 0) { + return E.left(USERS_NOT_FOUND); + } + + return E.right(true); + } + + async fetchUserWorkspaces(userUid: string) { + const user = await this.prisma.user.findUnique({ where: { uid: userUid } }); + if (!user) return E.left(USER_NOT_FOUND); + + const team = await this.prisma.team.findMany({ + where: { + members: { + some: { + userUid, + }, + }, + }, + include: { + members: { + select: { + userUid: true, + role: true, + }, + }, + }, + }); + + const workspaces: GetUserWorkspacesResponse[] = []; + team.forEach((t) => { + const ownerCount = t.members.filter( + (m) => m.role === TeamAccessRole.OWNER, + ).length; + const editorCount = t.members.filter( + (m) => m.role === TeamAccessRole.EDITOR, + ).length; + const viewerCount = t.members.filter( + (m) => m.role === TeamAccessRole.VIEWER, + ).length; + const memberCount = t.members.length; + + workspaces.push({ + id: t.id, + name: t.name, + role: t.members.find((m) => m.userUid === userUid)?.role, + owner_count: ownerCount, + editor_count: editorCount, + viewer_count: viewerCount, + member_count: memberCount, + }); + }); + return E.right(workspaces); + } +} diff --git a/packages/hoppscotch-backend/src/utils.ts b/packages/hoppscotch-backend/src/utils.ts new file mode 100644 index 0000000..f2c0901 --- /dev/null +++ b/packages/hoppscotch-backend/src/utils.ts @@ -0,0 +1,387 @@ +import { ExecutionContext, HttpException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { Prisma } from 'src/generated/prisma/client'; +import * as A from 'fp-ts/Array'; +import * as E from 'fp-ts/Either'; +import { pipe } from 'fp-ts/lib/function'; +import * as O from 'fp-ts/Option'; +import * as T from 'fp-ts/Task'; +import * as TE from 'fp-ts/TaskEither'; +import { ENV_NOT_FOUND_KEY_DATA_ENCRYPTION_KEY, JSON_INVALID } from './errors'; +import { TeamAccessRole } from './team/team.model'; +import { RESTError } from './types/RESTError'; +import * as crypto from 'crypto'; + +/** + * Delays the execution for a given number of milliseconds. + * @param ms The number of milliseconds to delay + * @returns A promise that resolves after the delay + */ +export function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Safely parses a string to an integer, returning undefined if the value is null, undefined, or results in NaN. + * @param value The string value to parse + * @returns The parsed integer or undefined + */ +export function parseIntSafe( + value: string | null | undefined, +): number | undefined { + if (!value) return undefined; + const parsed = parseInt(value, 10); + return !isNaN(parsed) ? parsed : undefined; +} + +/** + * A workaround to throw an exception in an expression. + * JS throw keyword creates a statement not an expression. + * This function allows throw to be used as an expression + * @param errMessage Message present in the error message + */ +export function throwErr(errMessage: string): never { + throw new Error(errMessage); +} + +/** + * This function allows throw to be used as an expression + * @param errMessage Message present in the error message + */ +export function throwHTTPErr(errorData: RESTError): never { + const { message, statusCode } = errorData; + throw new HttpException(message, statusCode); +} + +/** + * Prints the given value to log and returns the same value. + * Used for debugging functional pipelines. + * @param val The value to print + * @returns `val` itself + */ +export const trace = (val: T) => { + console.log(val); + return val; +}; + +/** + * Similar to `trace` but allows for labels and also an + * optional transform function. + * @param name The label to given to the trace log (log outputs like this ": ") + * @param transform An optional function to transform the log output value (useful for checking specific aspects or transforms (duh)) + * @returns A function which takes a value, and is traced. + */ +export const namedTrace = + (name: string, transform?: (val: T) => unknown) => + (val: T) => { + console.log(`${name}:`, transform ? transform(val) : val); + + return val; + }; + +/** + * Returns the list of required roles annotated on a GQL Operation + * @param reflector NestJS Reflector instance + * @param context NestJS Execution Context + * @returns An Option which contains the defined roles + */ +export const getAnnotatedRequiredRoles = ( + reflector: Reflector, + context: ExecutionContext, +) => + pipe( + reflector.get('requiresTeamRole', context.getHandler()), + O.fromNullable, + ); + +/** + * Gets the user from the NestJS GQL Execution Context. + * Usually used within guards. + * @param ctx The Execution Context to use to get it + * @returns An Option of the user + */ +export const getUserFromGQLContext = (ctx: ExecutionContext) => + pipe( + ctx, + GqlExecutionContext.create, + (ctx) => ctx.getContext().req, + ({ user }) => user, + O.fromNullable, + ); + +/** + * Gets a GQL Argument in the defined operation. + * Usually used in guards. + * @param argName The name of the argument to get + * @param ctx The NestJS Execution Context to use to get it. + * @returns The argument value typed as `unknown` + */ +export const getGqlArg = ( + argName: ArgName, + ctx: ExecutionContext, +) => + pipe( + ctx, + GqlExecutionContext.create, + (ctx) => ctx.getArgs(), + // We are not sure if this thing will even exist + // We pass that worry to the caller + (args) => args[argName as any] as unknown, + ); + +/** + * To the daring adventurer who has stumbled upon this relic of code... welcome. + * Many have gazed upon its depths, yet few have returned with answers. + * I could have deleted it, but that felt... too easy, too final. + * + * If you're still reading, perhaps you're the one destined to unravel its secrets. + * Or, maybe you're like me—content to let it linger, a puzzle for the ages. + * The choice is yours, but beware... once you start, there is no turning back. + * + * PLEASE, NO ONE KNOWS HOW THIS WORKS... + * -- Balu, whispering from the great beyond... probably still trying to understand this damn thing. + * + * Sequences an array of TaskEither values while maintaining an array of all the error values + * @param arr Array of TaskEithers + * @returns A TaskEither saying all the errors possible on the left or all the success values on the right + */ +export const taskEitherValidateArraySeq = ( + arr: TE.TaskEither[], +): TE.TaskEither => + pipe( + arr, + A.map(TE.mapLeft(A.of)), + A.sequence( + TE.getApplicativeTaskValidation(T.ApplicativeSeq, A.getMonoid()), + ), + ); + +/** + * Checks to see if the email is valid or not + * @param email The email + * @see https://emailregex.com/ for information on email regex + * @returns A Boolean depending on the format of the email + */ +export const validateEmail = (email: string) => { + return new RegExp( + /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, + ).test(email); +}; + +// Regular expressions for supported address object formats by nodemailer +// check out for more info https://nodemailer.com/message/addresses +const emailRegex1 = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; +const emailRegex2 = + /^[\w\s]* <([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>$/; +const emailRegex3 = + /^"[\w\s]+" <([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>$/; + +/** + * Checks to see if the SMTP email is valid or not + * @param email + * @returns A Boolean depending on the format of the email + */ +export const validateSMTPEmail = (email: string) => { + // Check if the input matches any of the formats + return ( + emailRegex1.test(email) || + emailRegex2.test(email) || + emailRegex3.test(email) + ); +}; + +/** + * Checks to see if the URL is valid or not + * @param url The URL to validate + * @returns boolean + */ +export const validateSMTPUrl = (url: string) => { + // Possible valid formats + // smtp(s)://mail.example.com + // smtp(s)://user:pass@mail.example.com + // smtp(s)://mail.example.com:587 + // smtp(s)://user:pass@mail.example.com:587 + + if (!url || url.length === 0) return false; + + if (/[\s\x00-\x1f\x7f]/.test(url)) return false; + if (/[?#\\]/.test(url)) return false; + if (url.endsWith(':')) return false; + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + + // Only the SMTP schemes are permitted. + if (parsed.protocol !== 'smtp:' && parsed.protocol !== 'smtps:') return false; + if (parsed.pathname !== '' && parsed.pathname !== '/') return false; + if (parsed.search !== '' || parsed.hash !== '') return false; + + // A hostname is required and must not start with a dot. + if (!parsed.hostname || parsed.hostname.startsWith('.')) return false; + + // Port, when present, must be numeric (the URL parser already guarantees this). + return true; +}; + +/** + * Checks to see if the URL is valid or not + * @param url The URL to validate + * @returns boolean + */ +export const validateUrl = (url: string) => { + const urlRegex = /^(http|https):\/\/[^ "]+$/; + return urlRegex.test(url); +}; + +/** + * String to JSON parser + * @param {str} str The string to parse + * @returns {E.Right | E.Left<"json_invalid">} An Either of the parsed JSON + */ +export function stringToJson( + str: string, +): E.Right | E.Left { + try { + return E.right(JSON.parse(str)); + } catch (err) { + return E.left(JSON_INVALID); + } +} +/** + * + * @param title string whose length we need to check + * @param length minimum length the title needs to be + * @returns boolean if title is of valid length or not + */ +export function isValidLength(title: string, length: number) { + if (!title || title.trim() === '' || title.length < length) { + return false; + } + + return true; +} + +/** + * Adds escape backslashes to the input so that it can be used inside + * SQL LIKE/ILIKE queries. Inspired by PHP's `mysql_real_escape_string` + * function. + * + * Eg. "100%" -> "100\\%" + * + * Source: https://stackoverflow.com/a/32648526 + */ +export function escapeSqlLikeString(str: string) { + if (typeof str != 'string') return str; + + return str.replace(/[\0\x08\x09\x1a\n\r"'\\\%]/g, function (char) { + switch (char) { + case '\0': + return '\\0'; + case '\x08': + return '\\b'; + case '\x09': + return '\\t'; + case '\x1a': + return '\\z'; + case '\n': + return '\\n'; + case '\r': + return '\\r'; + case '"': + case "'": + case '\\': + case '%': + return '\\' + char; // prepends a backslash to backslash, percent, + // and double/single quotes + } + }); +} + +/** + * Calculate the expiration date of the token + * + * @param expiresInDays Number of days the token is valid for + * @returns Date object of the expiration date + */ +export function calculateExpirationDate(expiresInDays: null | number) { + if (expiresInDays === null) return null; + return new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000); +} + +/* + * Transforms the collection level properties (authorization & headers) under the `data` field. + * Preserves `null` values and prevents duplicate stringification. + * + * @param {Prisma.JsonValue} collectionData - The team collection data to transform. + * @returns {string | null} The transformed team collection data as a string. + */ +export function transformCollectionData( + collectionData: Prisma.JsonValue, +): string | null { + if (!collectionData) { + return null; + } + + return typeof collectionData === 'string' + ? collectionData + : JSON.stringify(collectionData); +} + +// Encrypt and Decrypt functions. InfraConfig and Account table uses these functions to encrypt and decrypt the data. +const ENCRYPTION_ALGORITHM = 'aes-256-cbc'; + +/** + * Encrypts a text using a key + * @param text The text to encrypt + * @param key The key to use for encryption + * @returns The encrypted text + */ +export function encrypt(text: string, key = process.env.DATA_ENCRYPTION_KEY) { + if (!key) throw new Error(ENV_NOT_FOUND_KEY_DATA_ENCRYPTION_KEY); + + if (!text || text === '') return text; + + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv( + ENCRYPTION_ALGORITHM, + Buffer.from(key), + iv, + ); + let encrypted = cipher.update(text); + encrypted = Buffer.concat([encrypted, cipher.final()]); + return iv.toString('hex') + ':' + encrypted.toString('hex'); +} + +/** + * Decrypts a text using a key + * @param text The text to decrypt + * @param key The key to use for decryption + * @returns The decrypted text + */ +export function decrypt( + encryptedData: string, + key = process.env.DATA_ENCRYPTION_KEY, +) { + if (!key) throw new Error(ENV_NOT_FOUND_KEY_DATA_ENCRYPTION_KEY); + + if (!encryptedData || encryptedData === '') { + return encryptedData; + } + + const textParts = encryptedData.split(':'); + const iv = Buffer.from(textParts.shift(), 'hex'); + const encryptedText = Buffer.from(textParts.join(':'), 'hex'); + const decipher = crypto.createDecipheriv( + ENCRYPTION_ALGORITHM, + Buffer.from(key), + iv, + ); + let decrypted = decipher.update(encryptedText); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return decrypted.toString(); +} diff --git a/packages/hoppscotch-backend/test/app.e2e-spec.ts b/packages/hoppscotch-backend/test/app.e2e-spec.ts new file mode 100644 index 0000000..50cda62 --- /dev/null +++ b/packages/hoppscotch-backend/test/app.e2e-spec.ts @@ -0,0 +1,24 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from './../src/app.module'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => { + return request(app.getHttpServer()) + .get('/') + .expect(200) + .expect('Hello World!'); + }); +}); diff --git a/packages/hoppscotch-backend/test/jest-e2e.json b/packages/hoppscotch-backend/test/jest-e2e.json new file mode 100644 index 0000000..e9d912f --- /dev/null +++ b/packages/hoppscotch-backend/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/packages/hoppscotch-backend/tsconfig.build.json b/packages/hoppscotch-backend/tsconfig.build.json new file mode 100644 index 0000000..ffc7919 --- /dev/null +++ b/packages/hoppscotch-backend/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"], + "compilerOptions": { + "declaration": false, + "sourceMap": false + } +} diff --git a/packages/hoppscotch-backend/tsconfig.json b/packages/hoppscotch-backend/tsconfig.json new file mode 100644 index 0000000..32cdc90 --- /dev/null +++ b/packages/hoppscotch-backend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2023", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strict": false, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "moduleResolution": "node" + } +} diff --git a/packages/hoppscotch-cli/.gitignore b/packages/hoppscotch-cli/.gitignore new file mode 100644 index 0000000..a0bc82a --- /dev/null +++ b/packages/hoppscotch-cli/.gitignore @@ -0,0 +1,146 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/node +# Edit at https://www.toptal.com/developers/gitignore?templates=node + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.production + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# End of https://www.toptal.com/developers/gitignore/api/node + +# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode +# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode diff --git a/packages/hoppscotch-cli/.prettierrc b/packages/hoppscotch-cli/.prettierrc new file mode 100644 index 0000000..5c6bc94 --- /dev/null +++ b/packages/hoppscotch-cli/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 80, + "useTabs": false, + "tabWidth": 2 +} diff --git a/packages/hoppscotch-cli/CONTRIBUTING.md b/packages/hoppscotch-cli/CONTRIBUTING.md new file mode 100644 index 0000000..15d6bc8 --- /dev/null +++ b/packages/hoppscotch-cli/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing + +When contributing to this repository, please first discuss the change you wish to make via issue, +email, or any other method with the owners of this repository before making a change. + +Please note we have a code of conduct, please follow it in all your interactions with the project. + +## Pull Request Process + +1. Ensure any install or build dependencies are removed before the end of the layer when doing a + build. +2. Update the README.md with details of changes to the interface, this includes new environment + variables, exposed ports, useful file locations and container parameters. +3. Increase the version numbers in any examples files and the README.md to the new version that this + Pull Request would represent. The versioning scheme we use is [SemVer](https://semver.org). +4. You may merge the Pull Request once you have the sign-off of two other developers, or if you + do not have permission to do that, you may request the second reviewer merge it for you. + +## Set Up The Development Environment + +1. After cloning the repository, execute the following commands: + + ```bash + pnpm install + pnpm run build + ``` + +2. In order to test locally, you can use two types of package linking: + + 1. The 'pnpm exec' way (preferred since it does not hamper your original installation of the CLI): + + ```bash + pnpm link @hoppscotch/cli + + // Then to use or test the CLI: + pnpm exec hopp + + // After testing, to remove the package linking: + pnpm rm @hoppscotch/cli + ``` + + 2. The 'global' way (warning: this might override the globally installed CLI, if exists): + + ```bash + sudo pnpm link --global + + // Then to use or test the CLI: + hopp + + // After testing, to remove the package linking: + sudo pnpm rm --global @hoppscotch/cli + ``` + +3. To use the Typescript watch scripts: + ```bash + pnpm run dev + ``` diff --git a/packages/hoppscotch-cli/README.md b/packages/hoppscotch-cli/README.md new file mode 100644 index 0000000..8a36a42 --- /dev/null +++ b/packages/hoppscotch-cli/README.md @@ -0,0 +1,211 @@ +# Hoppscotch CLI ALPHA + +A CLI to run Hoppscotch Test Scripts in CI environments. + +### **Commands:** + +- `hopp test [options] [file]`: testing hoppscotch collection.json file + +### **Usage:** + +```bash +hopp [options or commands] arguments +``` + +### **Options:** + +- `-v`, `--ver`: see the current version of the CLI +- `-h`, `--help`: display help for command + +## **Command Descriptions:** + +1. #### **`hopp -v` / `hopp --ver`** + + - Prints out the current version of the Hoppscotch CLI + +2. #### **`hopp -h` / `hopp --help`** + + - Displays the help text + +3. #### **`hopp test [options] `** + + - Interactive CLI to accept Hoppscotch collection JSON path + - Parses the collection JSON and executes each requests + - Executes pre-request script. + - Outputs the response of each request. + - Executes and outputs test-script response. + + #### Options: + + ##### `-e, --env ` + + - Accepts path to env.json with contents in below format: + + ```json + { + "ENV1": "value1", + "ENV2": "value2" + } + ``` + + - You can now access those variables using `pw.env.get('')` + + Taking the above example, `pw.env.get("ENV1")` will return `"value1"` + + #### `-d, --delay ` + + - Used to defer the execution of requests in a collection. + + #### `--token ` + + - Expects a personal access token to be passed for establishing connection with your Hoppscotch account. + + #### `--server ` + + - URL of your self-hosted instance, if your collections are on a self-hosted instance. + + #### `--reporter-junit [path]` + + - Expects a file path to store the JUnit Report. + + ##### `--iteration-count ` + + - Accepts the number of iterations to run the collection + + ##### `--iteration-data ` + + - Accepts the path to a CSV file with contents in the below format: + + ```text + key1,key2,key3 + value1,value2,value3 + value4,value5,value6 + ``` + + For every iteration the values will be replaced with the respective keys in the environment. For iteration 1 the value1,value2,value3 will be replaced and for iteration 2 value4,value5,value6 will be replaced and so on. + + #### `--legacy-sandbox` + + - Opt out from the experimental scripting sandbox. + +## Versioning + +The Hoppscotch CLI follows **pre-1.0 semantic versioning** conventions while in alpha (version `< 1.0.0`): + +- **Feature releases** (e.g., `0.20.0` → `0.21.0`): New features, enhancements, or improvements +- **Patch releases** (e.g., `0.20.0` → `0.20.1`): Bug fixes, security patches, and minor improvements +- **Breaking changes** (e.g., `0.21.0` → `0.30.0`): Major version-like bumps for backwards-incompatible changes + +> Once the CLI reaches stability and a mature feature set, we will transition to standard semantic versioning starting with `1.0.0`. + +## Install + +- Before you install Hoppscotch CLI you need to make sure you have the dependencies it requires to run. + + - **Windows & macOS**: You will need `node-gyp` installed. Find instructions here: https://github.com/nodejs/node-gyp + - **Debian/Ubuntu derivatives**: + ```sh + sudo apt-get install python g++ build-essential + ``` + - **Alpine Linux**: + ```sh + sudo apk add python3 make g++ + ``` + - **Amazon Linux (AMI)** + ```sh + sudo yum install gcc72 gcc72-c++ + ``` + - **Arch Linux** + ```sh + sudo pacman -S make gcc python + ``` + - **RHEL/Fedora derivatives**: + ```sh + sudo dnf install python3 make gcc gcc-c++ zlib-devel brotli-devel openssl-devel libuv-devel + ``` + +- Once the dependencies are installed, install [@hoppscotch/cli](https://www.npmjs.com/package/@hoppscotch/cli) from npm by running: + ``` + npm i -g @hoppscotch/cli + ``` + +## **Developing:** + +1. Clone the repository, make sure you've installed latest [pnpm](https://pnpm.io). +2. `pnpm install` +3. Build required workspace dependencies (if needed): + ```bash + # These auto-build via postinstall hooks during 'pnpm install' + # Rebuild manually only when you make changes to these packages: + pnpm --filter @hoppscotch/data run build + pnpm --filter @hoppscotch/js-sandbox run build + ``` +4. `cd packages/hoppscotch-cli` +5. `pnpm run build` +6. `sudo pnpm link --global` +7. Test the installation by executing `hopp` + +## **Contributing:** + +When contributing to this repository, please first discuss the change you wish to make via issue, +email, or any other method with the owners of this repository before making a change. + +Please note we have a code of conduct, please follow it in all your interactions with the project. + +## Pull Request Process + +1. Ensure any install or build dependencies are removed before the end of the layer when doing a + build. +2. Update the README.md with details of changes to the interface, this includes new environment + variables, exposed ports, useful file locations and container parameters. +3. Increase the version numbers in any examples files and the README.md to the new version that this + Pull Request would represent. The versioning scheme we use is [SemVer](https://semver.org). +4. You may merge the Pull Request once you have the sign-off of two other developers, or if you + do not have permission to do that, you may request the second reviewer merge it for you. + +## Set Up The Development Environment + +1. After cloning the repository, execute the following commands: + + ```bash + pnpm install + # Build required workspace dependencies (if needed) + # These auto-build via postinstall hooks during 'pnpm install' + # Rebuild manually only when you make changes to these packages: + pnpm --filter @hoppscotch/data run build + pnpm --filter @hoppscotch/js-sandbox run build + # Then build the CLI + cd packages/hoppscotch-cli && pnpm run build + ``` + +2. In order to test locally, you can use two types of package linking: + + 1. The 'pnpm exec' way (preferred since it does not hamper your original installation of the CLI): + + ```bash + pnpm link @hoppscotch/cli + + // Then to use or test the CLI: + pnpm exec hopp + + // After testing, to remove the package linking: + pnpm rm @hoppscotch/cli + ``` + + 2. The 'global' way (warning: this might override the globally installed CLI, if exists): + + ```bash + sudo pnpm link --global + + // Then to use or test the CLI: + hopp + + // After testing, to remove the package linking: + sudo pnpm rm --global @hoppscotch/cli + ``` + +3. To use the Typescript watch scripts: + + ```bash + pnpm run dev + ``` diff --git a/packages/hoppscotch-cli/bin/hopp.js b/packages/hoppscotch-cli/bin/hopp.js new file mode 100755 index 0000000..238480e --- /dev/null +++ b/packages/hoppscotch-cli/bin/hopp.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// * The entry point of the CLI +// @ts-check + +import chalk from "chalk"; +import { spawnSync } from "child_process"; +import fs from "fs"; +import { cloneDeep } from "lodash-es"; +import semver from "semver"; +import { fileURLToPath } from "url"; + +const highlightVersion = (version) => chalk.black.bgYellow(`v${version}`); + +const packageJsonPath = fileURLToPath( + new URL("../package.json", import.meta.url) +); +const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); + +const requiredNodeVersionRange = packageJson.engines?.node || ">=22"; + +// Extract the major version from the start of the range +const requiredNodeVersion = semver.major( + semver.minVersion(requiredNodeVersionRange) ?? "22" +); + +const currentNodeVersion = process.versions.node; + +// Last supported version of the CLI for Node.js v20 +const lastSupportedVersion = "0.26.0"; + +if (!semver.satisfies(currentNodeVersion, requiredNodeVersionRange)) { + console.error( + `${chalk.greenBright("Hoppscotch CLI")} requires Node.js ${highlightVersion(requiredNodeVersion)} or higher and you're on Node.js ${highlightVersion(currentNodeVersion)}.` + ); + + console.error( + `\nIf you prefer staying on Node.js ${highlightVersion("20")}, you can install the last supported version of the CLI:\n` + + `${chalk.green(`npm install -g @hoppscotch/cli@${lastSupportedVersion}`)} alongside ${highlightVersion("2025.10.1")} of the Hoppscotch app.\n` + ); + process.exit(1); +} + +// Dynamically importing the module after the Node.js version check prevents errors due to unrecognized APIs in older Node.js versions +const { cli } = await import("../dist/index.js"); + +// As per isolated-vm documentation, we need to supply `--no-node-snapshot` for node >= 20 +// src: https://github.com/laverdet/isolated-vm?tab=readme-ov-file#requirements +if (!process.execArgv.includes("--no-node-snapshot")) { + const argCopy = cloneDeep(process.argv); + + // Replace first argument with --no-node-snapshot + // We can get argv[0] from process.argv0 + argCopy[0] = "--no-node-snapshot"; + + const result = spawnSync(process.argv0, argCopy, { stdio: "inherit" }); + + // Exit with the same status code as the spawned process + process.exit(result.status ?? 0); +} else { + cli(process.argv); +} diff --git a/packages/hoppscotch-cli/package.json b/packages/hoppscotch-cli/package.json new file mode 100644 index 0000000..722442e --- /dev/null +++ b/packages/hoppscotch-cli/package.json @@ -0,0 +1,74 @@ +{ + "name": "@hoppscotch/cli", + "version": "0.31.3", + "description": "A CLI to run Hoppscotch test scripts in CI environments.", + "homepage": "https://hoppscotch.io", + "type": "module", + "main": "dist/index.js", + "bin": { + "hopp": "bin/hopp.js" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "pnpm exec tsup", + "dev": "pnpm exec tsup --watch", + "debugger": "node debugger.js 9999", + "prepublish": "pnpm exec tsup", + "prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write", + "test": "pnpm run build && vitest run", + "do-typecheck": "pnpm exec tsc --noEmit", + "do-test": "pnpm run test" + }, + "keywords": [ + "cli", + "hoppscotch", + "hopp-cli" + ], + "author": "Hoppscotch (support@hoppscotch.io)", + "repository": { + "type": "git", + "url": "https://github.com/hoppscotch/hoppscotch.git" + }, + "bugs": { + "url": "https://github.com/hoppscotch/hoppscotch/issues", + "email": "support@hoppscotch.io" + }, + "license": "MIT", + "private": false, + "dependencies": { + "aws4fetch": "1.0.20", + "axios": "1.18.0", + "axios-cookiejar-support": "6.0.5", + "chalk": "5.6.2", + "commander": "14.0.3", + "isolated-vm": "6.1.2", + "js-md5": "0.8.3", + "jsonc-parser": "3.3.1", + "lodash-es": "4.18.1", + "papaparse": "5.5.4", + "qs": "6.15.2", + "semver": "7.8.5", + "tough-cookie": "6.0.1", + "verzod": "0.4.0", + "xmlbuilder2": "4.0.3", + "zod": "3.25.32" + }, + "devDependencies": { + "@hoppscotch/data": "workspace:^", + "@hoppscotch/js-sandbox": "workspace:^", + "@relmify/jest-fp-ts": "2.1.1", + "@types/lodash-es": "4.17.12", + "@types/papaparse": "5.5.2", + "@types/qs": "6.15.1", + "fp-ts": "2.16.11", + "prettier": "3.8.4", + "tsup": "8.5.1", + "typescript": "5.9.3", + "vitest": "4.1.9" + } +} diff --git a/packages/hoppscotch-cli/setupFiles.ts b/packages/hoppscotch-cli/setupFiles.ts new file mode 100644 index 0000000..0a1bcdc --- /dev/null +++ b/packages/hoppscotch-cli/setupFiles.ts @@ -0,0 +1,15 @@ +// Vitest doesn't work without globals +// Ref: https://github.com/relmify/jest-fp-ts/issues/11 + +import decodeMatchers from "@relmify/jest-fp-ts/dist/decodeMatchers"; +import eitherMatchers from "@relmify/jest-fp-ts/dist/eitherMatchers"; +import optionMatchers from "@relmify/jest-fp-ts/dist/optionMatchers"; +import theseMatchers from "@relmify/jest-fp-ts/dist/theseMatchers"; +import eitherOrTheseMatchers from "@relmify/jest-fp-ts/dist/eitherOrTheseMatchers"; +import { expect } from "vitest"; + +expect.extend(decodeMatchers.matchers); +expect.extend(eitherMatchers.matchers); +expect.extend(optionMatchers.matchers); +expect.extend(theseMatchers.matchers); +expect.extend(eitherOrTheseMatchers.matchers); diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/commands/__snapshots__/test.spec.ts.snap b/packages/hoppscotch-cli/src/__tests__/e2e/commands/__snapshots__/test.spec.ts.snap new file mode 100644 index 0000000..9c3e21c --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/commands/__snapshots__/test.spec.ts.snap @@ -0,0 +1,529 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`hopp test [options] > Test\`hopp test --env --reporter-junit [path] > Generates a JUnit report at the default path 1`] = ` +" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + >" +} (ENV_EXPAND_LOOP)]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`hopp test [options] > Test\`hopp test --env --reporter-junit [path] > Generates a JUnit report at the specified path 1`] = ` +" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + >" +} (ENV_EXPAND_LOOP)]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`hopp test [options] > Test\`hopp test --env --reporter-junit [path] > Generates a JUnit report for a collection referring to environment variables 1`] = ` +" + + + + + + + + + + + + + + + +" +`; + +exports[`hopp test [options] > Test\`hopp test --env --reporter-junit [path] > Generates a JUnit report for a collection with authorization/headers set at the collection level 1`] = ` +" + + + + + + + + + + + + + + + + + + + + + + + + + +" +`; diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts new file mode 100644 index 0000000..48b80b6 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts @@ -0,0 +1,1517 @@ +import { ExecException } from "child_process"; +import fs from "fs"; +import path from "path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { HoppErrorCode } from "../../../types/errors"; +import { + getErrorCode, + getTestJsonFilePath, + runCLI, + runCLIWithNetworkRetry, +} from "../../utils"; + +describe("hopp test [options] ", { timeout: 100000 }, () => { + const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`; + + describe("Test `hopp test ` command:", () => { + describe("Argument parsing", () => { + test("Errors with the code `INVALID_ARGUMENT` for not supplying enough arguments", async () => { + const args = "test"; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ARGUMENT"); + }); + + test("Errors with the code `INVALID_ARGUMENT` for an invalid command", async () => { + const args = "invalid-arg"; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ARGUMENT"); + }); + }); + + describe("Supplied collection export file validations", () => { + test("Errors with the code `FILE_NOT_FOUND` if the supplied collection export file doesn't exist", async () => { + const args = "test notfound.json"; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("FILE_NOT_FOUND"); + }); + + test("Errors with the code UNKNOWN_ERROR if the supplied collection export file content isn't valid JSON", async () => { + const args = `test ${getTestJsonFilePath("malformed-coll.json", "collection")}`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("UNKNOWN_ERROR"); + }); + + test("Errors with the code `MALFORMED_COLLECTION` if the supplied collection export file content is malformed", async () => { + const args = `test ${getTestJsonFilePath("malformed-coll-2.json", "collection")}`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("MALFORMED_COLLECTION"); + }); + + test("Errors with the code `INVALID_FILE_TYPE` if the supplied collection export file doesn't end with the `.json` extension", async () => { + const args = `test ${getTestJsonFilePath("notjson-coll.txt", "collection")}`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_FILE_TYPE"); + }); + + test("Fails if the collection file includes scripts with incorrect API usage and failed assertions", async () => { + const args = `test ${getTestJsonFilePath("fails-coll.json", "collection")}`; + const { error } = await runCLI(args); + + expect(error).not.toBeNull(); + expect(error).toMatchObject({ + code: 1, + }); + }); + }); + + describe("Versioned entities", () => { + describe("Collections & Requests", () => { + const testFixtures = [ + { fileName: "coll-v1-req-v0.json", collVersion: 1, reqVersion: 0 }, + { fileName: "coll-v1-req-v1.json", collVersion: 1, reqVersion: 1 }, + { fileName: "coll-v2-req-v2.json", collVersion: 2, reqVersion: 2 }, + { fileName: "coll-v2-req-v3.json", collVersion: 2, reqVersion: 3 }, + ]; + + testFixtures.forEach(({ collVersion, fileName, reqVersion }) => { + test(`Successfully processes a supplied collection export file where the collection is based on the "v${collVersion}" schema and the request following the "v${reqVersion}" schema`, async () => { + const args = `test ${getTestJsonFilePath(fileName, "collection")}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + }); + + describe("Mixed versions", () => { + test("Successfully processes children based on valid version ranges", async () => { + const args = `test ${getTestJsonFilePath("valid-mixed-versions-coll.json", "collection")}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + + test("Errors with the code `MALFORMED_COLLECTION` if the children fall out of valid version ranges", async () => { + const args = `test ${getTestJsonFilePath("invalid-mixed-versions-coll.json", "collection")}`; + + const { stderr } = await runCLI(args); + const out = getErrorCode(stderr); + + expect(out).toBe("MALFORMED_COLLECTION"); + }); + }); + }); + + describe("Environments", () => { + const testFixtures = [ + { fileName: "env-v0.json", version: 0 }, + { fileName: "env-v1.json", version: 1 }, + { fileName: "env-v2.json", version: 2 }, + ]; + + testFixtures.forEach(({ fileName, version }) => { + test(`Successfully processes the supplied collection and environment export files where the environment is based on the "v${version}" schema`, async () => { + const ENV_PATH = getTestJsonFilePath(fileName, "environment"); + const args = `test ${getTestJsonFilePath("sample-coll.json", "collection")} --env ${ENV_PATH}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + }); + }); + }); + + test("Successfully processes a supplied collection export file of the expected format", async () => { + const args = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + + test("Successfully inherits/overrides authorization and headers specified at the root collection at deeply nested collections", async () => { + const args = `test ${getTestJsonFilePath( + "collection-level-auth-headers-coll.json", + "collection" + )}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + + test("Successfully inherits/overrides authorization and headers at each level with multiple child collections", async () => { + const args = `test ${getTestJsonFilePath( + "multiple-child-collections-auth-headers-coll.json", + "collection" + )}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + + test("Successfully inherits collection variables into folders without their own variables", async () => { + const args = `test ${getTestJsonFilePath( + "collection-with-variables.json", + "collection" + )}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + + test("Persists environment variables set in the pre-request script for consumption in the test script", async () => { + const args = `test ${getTestJsonFilePath( + "pre-req-script-env-var-persistence-coll.json", + "collection" + )}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + + test("The `Content-Type` header takes priority over the value set at the request body", async () => { + const args = `test ${getTestJsonFilePath( + "content-type-header-scenarios.json", + "collection" + )}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + + test("Strips comments from JSONC request bodies", async () => { + const args = `test ${getTestJsonFilePath( + "jsonc-body-coll.json", + "collection" + )}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + + describe("OAuth 2 Authorization type with Authorization Code Grant Type", () => { + test("Successfully translates the authorization information to headers/query params and sends it along with the request", async () => { + const args = `test ${getTestJsonFilePath( + "oauth2-auth-code-coll.json", + "collection" + )}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + }); + + describe("multipart/form-data content type", () => { + test("Successfully derives the relevant headers based and sends the form data in the request body", async () => { + const args = `test ${getTestJsonFilePath( + "oauth2-auth-code-coll.json", + "collection" + )}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + }); + + test("Successfully display console logs and recognizes platform APIs in the experimental scripting sandbox", async () => { + const args = `test ${getTestJsonFilePath( + "test-scripting-sandbox-modes-coll.json", + "collection" + )}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + + expect(result.error).toBeNull(); + + const expectedStaticParts = [ + "https://example.com/path?foo=bar&baz=qux", + "'0': 72", + "'12': 33", + "Decoded: Hello, world!", + "Hello after 1s", + ]; + + expectedStaticParts.forEach((part) => { + expect(result.stdout).toContain(part); + }); + + const every500msCount = (result.stdout.match(/Every 500ms/g) || []) + .length; + expect(every500msCount).toBeGreaterThanOrEqual(3); + }); + + test("Fails to display console logs and recognize platform APIs in the legacy scripting sandbox", async () => { + const args = `test ${getTestJsonFilePath( + "test-scripting-sandbox-modes-coll.json", + "collection" + )} --legacy-sandbox`; + const { error, stdout } = await runCLI(args); + + expect(error).toBeTruthy(); + expect(stdout).not.toContain("https://example.com/path?foo=bar&baz=qux"); + expect(stdout).not.toContain("Encoded"); + }); + + test("Ensures tests run in sequence order based on request path", async () => { + // Expected order of collection runs + const expectedOrder = [ + "root-collection-request", + "folder-1/folder-1-request", + "folder-1/folder-11/folder-11-request", + "folder-1/folder-12/folder-12-request", + "folder-1/folder-13/folder-13-request", + "folder-2/folder-2-request", + "folder-2/folder-21/folder-21-request", + "folder-2/folder-22/folder-22-request", + "folder-2/folder-23/folder-23-request", + "folder-3/folder-3-request", + "folder-3/folder-31/folder-31-request", + "folder-3/folder-32/folder-32-request", + "folder-3/folder-33/folder-33-request", + ]; + + const normalizePath = (path: string) => path.replace(/\\/g, "/"); + + const extractRunningOrder = (stdout: string): string[] => + [...stdout.matchAll(/Running:.*?\/(.*?)\r?\n/g)].map( + ([, path]) => normalizePath(path.replace(/\x1b\[\d+m/g, "")) // Remove ANSI codes and normalize paths + ); + + const args = `test ${getTestJsonFilePath( + "multiple-child-collections-auth-headers-coll.json", + "collection" + )}`; + + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + + expect(extractRunningOrder(result.stdout)).toStrictEqual(expectedOrder); + + expect(result.error).toBeNull(); + }); + + /** + * Tests pm.sendRequest() functionality with external HTTP endpoints. + * + * Network Resilience Strategy: + * - Retries once (2 total attempts) on transient network errors + * - Detects and logs specific errors (ECONNRESET, ETIMEDOUT, etc.) + * - Validates JUnit XML completeness (60+ test suites) before accepting success + * - Auto-skips on network failures to prevent blocking PRs + */ + test("Supports the new scripting API method additions under the `hopp` and `pm` namespaces and validates JUnit report structure", async () => { + // First, run without JUnit report to ensure basic functionality works + const basicArgs = `test ${getTestJsonFilePath( + "scripting-revamp-coll.json", + "collection" + )}`; + const basicResult = await runCLIWithNetworkRetry(basicArgs); + if (basicResult === null) return; + expect(basicResult.error).toBeNull(); + + // Then, run with JUnit report and validate structure + const junitPath = path.join( + __dirname, + "scripting-revamp-snapshot-junit.xml" + ); + + if (fs.existsSync(junitPath)) { + fs.unlinkSync(junitPath); + } + + const junitArgs = `test ${getTestJsonFilePath( + "scripting-revamp-coll.json", + "collection" + )} --reporter-junit ${junitPath}`; + + // Enhanced retry for JUnit run - also validate output completeness + const runWithValidation = async () => { + const minExpectedTestSuites = 60; // Should have 67+ test suites + const maxAttempts = 2; // Only retry once (2 total attempts) + + const extractNetworkError = (output: string): string => { + const econnresetMatch = output.match(/ECONNRESET/i); + const eaiAgainMatch = output.match(/EAI_AGAIN/i); + const enotfoundMatch = output.match(/ENOTFOUND/i); + const etimedoutMatch = output.match(/ETIMEDOUT/i); + const econnrefusedMatch = output.match(/ECONNREFUSED/i); + + if (econnresetMatch) return "ECONNRESET (connection reset by peer)"; + if (eaiAgainMatch) return "EAI_AGAIN (DNS lookup timeout)"; + if (enotfoundMatch) return "ENOTFOUND (DNS lookup failed)"; + if (etimedoutMatch) return "ETIMEDOUT (connection timeout)"; + if (econnrefusedMatch) return "ECONNREFUSED (connection refused)"; + return "Unknown network error"; + }; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (fs.existsSync(junitPath)) { + fs.unlinkSync(junitPath); + } + + const result = await runCLI(junitArgs); + + // Check for transient errors in output (network or httpbin 5xx) + const output = `${result.stdout}\n${result.stderr}`; + const hasNetworkError = + /ECONNRESET|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|ECONNREFUSED|REQUEST_ERROR/i.test( + output + ); + const hasHttpbin5xx = + /httpbin\.org is down \(5xx\)|httpbin\.org is down \(503\)/i.test( + output + ); + + // If successful and JUnit file exists, validate completeness + if (!result.error && fs.existsSync(junitPath)) { + const xml = fs.readFileSync(junitPath, "utf-8"); + const testsuiteCount = (xml.match(/= minExpectedTestSuites && !hasHttpbin5xx) { + return result; + } + + // Incomplete output or httpbin issues - retry once if transient + if ( + (hasNetworkError || hasHttpbin5xx) && + attempt < maxAttempts - 1 + ) { + const errorDetail = hasHttpbin5xx + ? "httpbin.org 5xx response" + : `incomplete output (${testsuiteCount}/${minExpectedTestSuites} test suites) with ${extractNetworkError(output)}`; + console.log( + `⚠️ Transient error detected: ${errorDetail}. Retrying once...` + ); + await new Promise((r) => setTimeout(r, 2000)); + continue; + } + } + + // Non-transient error - fail fast + if (result.error && !hasNetworkError && !hasHttpbin5xx) { + return result; + } + + // Transient error - retry once + const isLastAttempt = attempt === maxAttempts - 1; + if (!isLastAttempt) { + const errorDetail = hasHttpbin5xx + ? "httpbin.org 5xx response" + : extractNetworkError(output); + console.log( + `⚠️ Transient error detected: ${errorDetail}. Retrying once...` + ); + await new Promise((r) => setTimeout(r, 2000)); + continue; + } + + // Last attempt exhausted due to transient issues - skip test to avoid blocking PR + const errorDetail = hasHttpbin5xx + ? "httpbin.org service degradation (5xx)" + : extractNetworkError(output); + console.warn( + `⚠️ Skipping test: Retry exhausted due to ${errorDetail}. External services may be unavailable.` + ); + return null; // Signal to skip test + } + + // Should never reach here - all paths above should return + throw new Error("Unexpected: retry loop completed without returning"); + }; + + const junitResult = await runWithValidation(); + if (junitResult === null) return; + expect(junitResult.error).toBeNull(); + + const junitXml = fs.readFileSync(junitPath, "utf-8"); + + // Validate structural invariants using regex parsing. + // Validate no testcases have "root" as name (would indicate assertions at root level). + const testcaseRootPattern = /]*name="root"/; + expect(junitXml).not.toMatch(testcaseRootPattern); + + // Validate test structure: testcases should have meaningful names from test blocks + const testcasePattern = / m[1] + ); + + // Ensure we have testcases + expect(testcaseNames.length).toBeGreaterThan(0); + + // Ensure no empty testcase names + for (const name of testcaseNames) { + expect(name.length).toBeGreaterThan(0); + expect(name).not.toBe("root"); + } + + // Validate presence of key test groups instead of snapshot comparison + // This is more reliable for CI as network responses can vary + + // 1. Correct number of test suites + const testsuitePattern = / { + const args = `test ${getTestJsonFilePath( + "collection-level-scripts-coll.json", + "collection" + )}`; + + const defaultResult = await runCLIWithNetworkRetry(args); + if (defaultResult === null) return; + expect(defaultResult.error).toBeNull(); + }); + + // The legacy sandbox uses a non-module evaluator that rejects top-level + // ESM imports at parse time, so it runs against a pruned fixture that + // omits the import-using request. + test("Inherited collection-level scripts run in order on the legacy sandbox", async () => { + const args = `test ${getTestJsonFilePath( + "collection-level-scripts-legacy-coll.json", + "collection" + )} --legacy-sandbox`; + + const legacyResult = await runCLIWithNetworkRetry(args); + if (legacyResult === null) return; + expect(legacyResult.error).toBeNull(); + }); + + test("Surfaces a SyntaxError when the same import binding appears in multiple scripts in a request's cascade", async () => { + const args = `test ${getTestJsonFilePath( + "collection-level-scripts-duplicate-import-coll.json", + "collection" + )}`; + const { error, stderr } = await runCLI(args); + + expect(error).not.toBeNull(); + expect(stderr).toContain("PRE_REQUEST_SCRIPT_ERROR"); + expect(stderr).toContain( + "'dup' is imported from different sources across scripts in this request's chain" + ); + }); + }); + + describe("Test `hopp test --env ` command:", () => { + describe("Supplied environment export file validations", () => { + describe("Argument parsing", () => { + test("Errors with the code `INVALID_ARGUMENT` if no file is supplied", async () => { + const args = `${VALID_TEST_ARGS} --env`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ARGUMENT"); + }); + }); + + test("Errors with the code `INVALID_FILE_TYPE` if the supplied environment export file doesn't end with the `.json` extension", async () => { + const args = `${VALID_TEST_ARGS} --env ${getTestJsonFilePath( + "notjson-coll.txt", + "collection" + )}`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_FILE_TYPE"); + }); + + test("Errors with the code `FILE_NOT_FOUND` if the supplied environment export file doesn't exist", async () => { + const args = `${VALID_TEST_ARGS} --env notfound.json`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("FILE_NOT_FOUND"); + }); + + test("Errors with the code `MALFORMED_ENV_FILE` on supplying a malformed environment export file", async () => { + const ENV_PATH = getTestJsonFilePath( + "malformed-envs.json", + "environment" + ); + const args = `${VALID_TEST_ARGS} --env ${ENV_PATH}`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("MALFORMED_ENV_FILE"); + }); + + test("Errors with the code `BULK_ENV_FILE` on supplying an environment export file based on the bulk environment export format", async () => { + const ENV_PATH = getTestJsonFilePath("bulk-envs.json", "environment"); + const args = `${VALID_TEST_ARGS} --env ${ENV_PATH}`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("BULK_ENV_FILE"); + }); + }); + + test("Successfully resolves values from the supplied environment export file", async () => { + const COLL_PATH = getTestJsonFilePath( + "env-flag-tests-coll.json", + "collection" + ); + const ENV_PATH = getTestJsonFilePath("env-flag-envs.json", "environment"); + const args = `test ${COLL_PATH} --env ${ENV_PATH}`; + + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + + test("Successfully resolves environment variables referenced in the request body", async () => { + const COLL_PATH = getTestJsonFilePath( + "req-body-env-vars-coll.json", + "collection" + ); + const ENV_PATH = getTestJsonFilePath( + "req-body-env-vars-envs.json", + "environment" + ); + const args = `test ${COLL_PATH} --env ${ENV_PATH}`; + + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + + test("Works with short `-e` flag", async () => { + const COLL_PATH = getTestJsonFilePath( + "env-flag-tests-coll.json", + "collection" + ); + const ENV_PATH = getTestJsonFilePath("env-flag-envs.json", "environment"); + const args = `test ${COLL_PATH} -e ${ENV_PATH}`; + + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + + describe("Secret environment variables", () => { + // Reads secret environment values from system environment + test("Successfully picks the values for secret environment variables from `process.env` and persists the variables set from the pre-request script", async () => { + const env = { + ...process.env, + secretBearerToken: "test-token", + secretBasicAuthUsername: "test-user", + secretBasicAuthPassword: "test-pass", + secretQueryParamValue: "secret-query-param-value", + secretBodyValue: "secret-body-value", + secretHeaderValue: "secret-header-value", + }; + + const COLL_PATH = getTestJsonFilePath( + "secret-envs-coll.json", + "collection" + ); + const ENV_PATH = getTestJsonFilePath("secret-envs.json", "environment"); + const args = `test ${COLL_PATH} --env ${ENV_PATH}`; + + const result = await runCLIWithNetworkRetry(args, { env }); + if (result === null) return; + + expect(result.stdout).toContain( + "https://httpbin.org/basic-auth/*********/*********" + ); + expect(result.error).toBeNull(); + }); + + test("Successfully picks the values for secret environment variables set directly in the environment export file and persists the environment variables set from the pre-request script", async () => { + const COLL_PATH = getTestJsonFilePath( + "secret-envs-coll.json", + "collection" + ); + const ENV_PATH = getTestJsonFilePath( + "secret-supplied-values-envs.json", + "environment" + ); + const args = `test ${COLL_PATH} --env ${ENV_PATH}`; + + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + + expect(result.stdout).toContain( + "https://httpbin.org/basic-auth/*********/*********" + ); + expect(result.error).toBeNull(); + }); + + test("Setting values for secret environment variables from the pre-request script overrides values set at the supplied environment export file", async () => { + const COLL_PATH = getTestJsonFilePath( + "secret-envs-persistence-coll.json", + "collection" + ); + const ENV_PATH = getTestJsonFilePath( + "secret-supplied-values-envs.json", + "environment" + ); + const args = `test ${COLL_PATH} --env ${ENV_PATH}`; + + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + + expect(result.stdout).toContain( + "https://httpbin.org/basic-auth/*********/*********" + ); + expect(result.error).toBeNull(); + }); + + test("Persists secret environment variable values set from the pre-request script for consumption in the request and post-request script context", async () => { + const COLL_PATH = getTestJsonFilePath( + "secret-envs-persistence-scripting-coll.json", + "collection" + ); + const ENV_PATH = getTestJsonFilePath( + "secret-envs-persistence-scripting-envs.json", + "environment" + ); + + const args = `test ${COLL_PATH} --env ${ENV_PATH}`; + + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + }); + + describe("Request variables", () => { + test("Picks active request variables and ignores inactive entries alongside the usage of environment variables", async () => { + const env = { + ...process.env, + secretBasicAuthPasswordEnvVar: "password", + }; + + const COLL_PATH = getTestJsonFilePath( + "request-vars-coll.json", + "collection" + ); + const ENV_PATH = getTestJsonFilePath( + "request-vars-envs.json", + "environment" + ); + + const args = `test ${COLL_PATH} --env ${ENV_PATH}`; + + const result = await runCLIWithNetworkRetry(args, { env }); + if (result === null) return; + expect(result.stdout).toContain( + "https://echo.hoppscotch.io/********/********" + ); + expect(result.error).toBeNull(); + }); + }); + + describe("AWS Signature Authorization type", () => { + test("Successfully translates the authorization information to headers/query params and sends it along with the request", async () => { + const env = { + ...process.env, + secretKey: "test-secret-key", + serviceToken: "test-token", + }; + + const COLL_PATH = getTestJsonFilePath( + "aws-signature-auth-coll.json", + "collection" + ); + const ENV_PATH = getTestJsonFilePath( + "aws-signature-auth-envs.json", + "environment" + ); + + const args = `test ${COLL_PATH} -e ${ENV_PATH}`; + const result = await runCLIWithNetworkRetry(args, { env }); + if (result === null) return; + + expect(result.error).toBeNull(); + }); + }); + + describe("Digest Authorization type", () => { + /** + * NOTE: This test is being skipped because the test endpoint is no longer resolving + * TODO: Find a reliable public endpoint that supports Digest Auth and re-enable this test + */ + test.skip("Successfully translates the authorization information to headers/query params and sends it along with the request", async () => { + const COLL_PATH = getTestJsonFilePath( + "digest-auth-success-coll.json", + "collection" + ); + const ENV_PATH = getTestJsonFilePath( + "digest-auth-envs.json", + "environment" + ); + + const args = `test ${COLL_PATH} -e ${ENV_PATH}`; + const { error } = await runCLI(args); + expect(error).toBeNull(); + }); + }); + + test("Supports disabling request retries", async () => { + const COLL_PATH = getTestJsonFilePath( + "digest-auth-failure-coll.json", + "collection" + ); + const ENV_PATH = getTestJsonFilePath( + "digest-auth-envs.json", + "environment" + ); + + const args = `test ${COLL_PATH} -e ${ENV_PATH}`; + const { error } = await runCLI(args); + + expect(error).toBeTruthy(); + }); + + describe("HAWK Authentication", () => { + test("Correctly generates and attaches authorization headers to the request ", async () => { + const COLL_PATH = getTestJsonFilePath( + "hawk-auth-success-coll.json", + "collection" + ); + const ENV_PATH = getTestJsonFilePath( + "hawk-auth-envs.json", + "environment" + ); + + const args = `test ${COLL_PATH} -e ${ENV_PATH}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + + expect(result.error).toBeNull(); + }); + }); + }); + + describe("Test `hopp test --delay ` command:", () => { + describe("Argument parsing", () => { + test("Errors with the code `INVALID_ARGUMENT` on not supplying a delay value", async () => { + const args = `${VALID_TEST_ARGS} --delay`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ARGUMENT"); + }); + + test("Errors with the code `INVALID_ARGUMENT` on supplying an invalid delay value", async () => { + const args = `${VALID_TEST_ARGS} --delay 'NaN'`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ARGUMENT"); + }); + }); + + test("Successfully performs delayed request execution for a valid delay value", async () => { + const args = `${VALID_TEST_ARGS} --delay 1`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + + test("Works with the short `-d` flag", async () => { + const args = `${VALID_TEST_ARGS} -d 1`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + expect(result.error).toBeNull(); + }); + }); + + // Future TODO: Enable once a proper e2e test environment is set up locally + describe.skip("Test `hopp test --env --token --server ` command:", () => { + const { + REQ_BODY_ENV_VARS_COLL_ID, + COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID, + REQ_BODY_ENV_VARS_ENVS_ID, + PERSONAL_ACCESS_TOKEN, + } = process.env; + + if ( + !REQ_BODY_ENV_VARS_COLL_ID || + !COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID || + !REQ_BODY_ENV_VARS_ENVS_ID || + !PERSONAL_ACCESS_TOKEN + ) { + return; + } + + const SERVER_URL = "https://stage-shc.hoppscotch.io/backend"; + + describe("Argument parsing", () => { + test("Errors with the code `INVALID_ARGUMENT` on not supplying a value for the `--token` flag", async () => { + const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --token`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ARGUMENT"); + }); + + test("Errors with the code `INVALID_ARGUMENT` on not supplying a value for the `--server` flag", async () => { + const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --server`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ARGUMENT"); + }); + }); + + describe("Workspace access validations", () => { + const INVALID_COLLECTION_ID = "invalid-coll-id"; + const INVALID_ENVIRONMENT_ID = "invalid-env-id"; + const INVALID_ACCESS_TOKEN = "invalid-token"; + + test("Errors with the code `TOKEN_INVALID` if the supplied access token is invalid", async () => { + const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --token ${INVALID_ACCESS_TOKEN} --server ${SERVER_URL}`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("TOKEN_INVALID"); + }); + + test("Errors with the code `INVALID_ID` if the supplied collection ID is invalid", async () => { + const args = `test ${INVALID_COLLECTION_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ID"); + }); + + test("Errors with the code `INVALID_ID` if the supplied environment ID is invalid", async () => { + const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${INVALID_ENVIRONMENT_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ID"); + }); + + test("Errors with the code `INVALID_SERVER_URL` if not supplying a valid SH instance server URL", async () => { + // FE URL of the staging SHC instance + const INVALID_SERVER_URL = "https://stage-shc.hoppscotch.io"; + const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${INVALID_SERVER_URL}`; + + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_SERVER_URL"); + }); + + test("Errors with the code `SERVER_CONNECTION_REFUSED` if supplying an SH instance server URL that doesn't follow URL semantics", async () => { + const INVALID_URL = "invalid-url"; + const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${INVALID_URL}`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("SERVER_CONNECTION_REFUSED"); + }); + }); + + test("Successfully retrieves a collection with the ID", async () => { + const args = `test ${COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; + + const { error } = await runCLI(args); + expect(error).toBeNull(); + }); + + test("Successfully retrieves collections and environments from a workspace using their respective IDs", async () => { + const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; + + const { error } = await runCLI(args); + expect(error).toBeNull(); + }); + + test("Supports specifying collection file path along with environment ID", async () => { + const COLL_PATH = getTestJsonFilePath( + "req-body-env-vars-coll.json", + "collection" + ); + const args = `test ${COLL_PATH} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; + + const { error } = await runCLI(args); + expect(error).toBeNull(); + }); + + test("Supports specifying environment file path along with collection ID", async () => { + const ENV_PATH = getTestJsonFilePath( + "req-body-env-vars-envs.json", + "environment" + ); + const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${ENV_PATH} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; + + const { error } = await runCLI(args); + expect(error).toBeNull(); + }); + + test("Supports specifying both collection and environment file paths", async () => { + const COLL_PATH = getTestJsonFilePath( + "req-body-env-vars-coll.json", + "collection" + ); + const ENV_PATH = getTestJsonFilePath( + "req-body-env-vars-envs.json", + "environment" + ); + const args = `test ${COLL_PATH} --env ${ENV_PATH} --token ${PERSONAL_ACCESS_TOKEN}`; + + const { error } = await runCLI(args); + expect(error).toBeNull(); + }); + }); + + describe("Test`hopp test --env --reporter-junit [path]", () => { + const genPath = path.resolve("hopp-cli-test"); + + // Helper function to replace dynamic values before generating test snapshots + // Currently scoped to JUnit report generation + const replaceDynamicValuesInStr = (input: string): string => + input + .replace(/(time|timestamp)="[^"]+"/g, (_, attr) => `${attr}="${attr}"`) + // Strip QuickJS GC assertion errors - these are non-deterministic + // and appear after script errors when scope disposal fails + // Pattern matches multi-line format ending with ]] + .replace( + /\n\s*Then, failed to dispose scope: Aborted\(Assertion failed[^\]]*\]\]/g, + "" + ); + + beforeAll(() => { + fs.mkdirSync(genPath); + }); + + afterAll(() => { + fs.rmdirSync(genPath, { recursive: true }); + }); + + test("Report export fails with the code `REPORT_EXPORT_FAILED` while encountering an error during path creation", async () => { + const exportPath = "hopp-junit-report.xml"; + + const COLL_PATH = getTestJsonFilePath("passes-coll.json", "collection"); + + const invalidPath = + process.platform === "win32" + ? "Z:/non-existent-path/report.xml" + : "/non-existent/report.xml"; + + const args = `test ${COLL_PATH} --reporter-junit ${invalidPath}`; + + const { stdout, stderr } = await runCLI(args, { + cwd: path.resolve("hopp-cli-test"), + }); + + const out = getErrorCode(stderr); + expect(out).toBe("REPORT_EXPORT_FAILED"); + + expect(stdout).not.toContain( + `Successfully exported the JUnit report to: ${exportPath}` + ); + }); + + test("Generates a JUnit report at the default path", async () => { + const exportPath = "hopp-junit-report.xml"; + + const COLL_PATH = getTestJsonFilePath( + "test-junit-report-export-coll.json", + "collection" + ); + + const args = `test ${COLL_PATH} --reporter-junit`; + + // Use retry logic to handle transient network errors (ECONNRESET, etc.) + // that can corrupt JUnit XML structure and cause snapshot mismatches + const maxAttempts = 2; // Only retry once (2 total attempts) + let lastResult: Awaited> | null = null; + let lastFileContents = ""; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + lastResult = await runCLI(args, { + cwd: path.resolve("hopp-cli-test"), + }); + + // Read JUnit XML file + const fileContents = fs + .readFileSync(path.resolve(genPath, exportPath)) + .toString(); + + lastFileContents = fileContents; + + const hasNetworkErrorInXML = + /ECONNRESET|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|ECONNREFUSED/i.test( + fileContents + ) || + (/REQUEST_ERROR/i.test(fileContents) && + !/Invalid URL/i.test(fileContents)); + + if (!hasNetworkErrorInXML) { + break; + } + + // Network error detected - retry once if not last attempt + if (attempt < maxAttempts - 1) { + console.log( + `⚠️ Network error detected in JUnit XML (ECONNRESET/DNS). Retrying once to get clean snapshot...` + ); + // Delete corrupted XML file before retry + try { + fs.unlinkSync(path.resolve(genPath, exportPath)); + } catch {} + await new Promise((r) => setTimeout(r, 2000)); + continue; + } + + // Last attempt exhausted - skip test to avoid false positive + console.warn( + `⚠️ Skipping snapshot test: Network errors persisted in JUnit XML after retry. External services may be degraded.` + ); + return; // Skip test - don't fail on infrastructure issues + } + + expect(lastResult?.stdout).not.toContain( + `Overwriting the pre-existing path: ${exportPath}` + ); + + expect(lastResult?.stdout).toContain( + `Successfully exported the JUnit report to: ${exportPath}` + ); + + expect(replaceDynamicValuesInStr(lastFileContents)).toMatchSnapshot(); + }); + + test("Generates a JUnit report at the specified path", async () => { + const exportPath = "outer-dir/inner-dir/report.xml"; + + const COLL_PATH = getTestJsonFilePath( + "test-junit-report-export-coll.json", + "collection" + ); + + const args = `test ${COLL_PATH} --reporter-junit ${exportPath}`; + + // Use retry logic to handle transient network errors (ECONNRESET, etc.) + // that can corrupt JUnit XML structure and cause snapshot mismatches + const maxAttempts = 2; // Only retry once (2 total attempts) + let lastResult: Awaited> | null = null; + let lastFileContents = ""; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + lastResult = await runCLI(args, { + cwd: path.resolve("hopp-cli-test"), + }); + + // Read JUnit XML file + const fileContents = fs + .readFileSync(path.resolve(genPath, exportPath)) + .toString(); + + lastFileContents = fileContents; + + const hasNetworkErrorInXML = + /ECONNRESET|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|ECONNREFUSED/i.test( + fileContents + ) || + (/REQUEST_ERROR/i.test(fileContents) && + !/Invalid URL/i.test(fileContents)); + + if (!hasNetworkErrorInXML) { + break; + } + + // Network error detected - retry once if not last attempt + if (attempt < maxAttempts - 1) { + console.log( + `⚠️ Network error detected in JUnit XML (ECONNRESET/DNS). Retrying once to get clean snapshot...` + ); + // Delete corrupted XML file before retry + try { + fs.unlinkSync(path.resolve(genPath, exportPath)); + } catch {} + await new Promise((r) => setTimeout(r, 2000)); + continue; + } + + // Last attempt exhausted - skip test to avoid false positive + console.warn( + `⚠️ Skipping snapshot test: Network errors persisted in JUnit XML after retry. External services may be degraded.` + ); + return; // Skip test - don't fail on infrastructure issues + } + + expect(lastResult?.stdout).not.toContain( + `Overwriting the pre-existing path: ${exportPath}` + ); + + expect(lastResult?.stdout).toContain( + `Successfully exported the JUnit report to: ${exportPath}` + ); + + expect(replaceDynamicValuesInStr(lastFileContents)).toMatchSnapshot(); + }); + + test("Generates a JUnit report for a collection with authorization/headers set at the collection level", async () => { + const exportPath = "hopp-junit-report.xml"; + + const COLL_PATH = getTestJsonFilePath( + "collection-level-auth-headers-coll.json", + "collection" + ); + + const args = `test ${COLL_PATH} --reporter-junit`; + + // Use retry logic to handle transient network errors (ECONNRESET, etc.) + // that can corrupt JUnit XML structure and cause snapshot mismatches + const maxAttempts = 2; // Only retry once (2 total attempts) + let lastResult: Awaited> | null = null; + let lastFileContents = ""; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + lastResult = await runCLI(args, { + cwd: path.resolve("hopp-cli-test"), + }); + + // Read JUnit XML file + const fileContents = fs + .readFileSync(path.resolve(genPath, exportPath)) + .toString(); + + lastFileContents = fileContents; + + const hasNetworkErrorInXML = + /ECONNRESET|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|ECONNREFUSED/i.test( + fileContents + ) || + (/REQUEST_ERROR/i.test(fileContents) && + !/Invalid URL/i.test(fileContents)); + + if (!hasNetworkErrorInXML) { + break; + } + + // Network error detected - retry once if not last attempt + if (attempt < maxAttempts - 1) { + console.log( + `⚠️ Network error detected in JUnit XML (ECONNRESET/DNS). Retrying once to get clean snapshot...` + ); + // Delete corrupted XML file before retry + try { + fs.unlinkSync(path.resolve(genPath, exportPath)); + } catch {} + await new Promise((r) => setTimeout(r, 2000)); + continue; + } + + // Last attempt exhausted - skip test to avoid false positive + console.warn( + `⚠️ Skipping snapshot test: Network errors persisted in JUnit XML after retry. External services may be degraded.` + ); + return; // Skip test - don't fail on infrastructure issues + } + + expect(lastResult?.stdout).toContain( + `Overwriting the pre-existing path: ${exportPath}` + ); + + expect(lastResult?.stdout).toContain( + `Successfully exported the JUnit report to: ${exportPath}` + ); + + expect(replaceDynamicValuesInStr(lastFileContents)).toMatchSnapshot(); + }); + + test("Generates a JUnit report for a collection referring to environment variables", async () => { + const exportPath = "hopp-junit-report.xml"; + + const COLL_PATH = getTestJsonFilePath( + "req-body-env-vars-coll.json", + "collection" + ); + const ENV_PATH = getTestJsonFilePath( + "req-body-env-vars-envs.json", + "environment" + ); + + const args = `test ${COLL_PATH} --env ${ENV_PATH} --reporter-junit`; + + // Use retry logic to handle transient network errors (ECONNRESET, etc.) + // that can corrupt JUnit XML structure and cause snapshot mismatches + const maxAttempts = 2; // Only retry once (2 total attempts) + let lastResult: Awaited> | null = null; + let lastFileContents = ""; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + lastResult = await runCLI(args, { + cwd: path.resolve("hopp-cli-test"), + }); + + // Read JUnit XML file + const fileContents = fs + .readFileSync(path.resolve(genPath, exportPath)) + .toString(); + + lastFileContents = fileContents; + + const hasNetworkErrorInXML = + /ECONNRESET|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|ECONNREFUSED/i.test( + fileContents + ) || + (/REQUEST_ERROR/i.test(fileContents) && + !/Invalid URL/i.test(fileContents)); + + if (!hasNetworkErrorInXML) { + break; + } + + // Network error detected - retry once if not last attempt + if (attempt < maxAttempts - 1) { + console.log( + `⚠️ Network error detected in JUnit XML (ECONNRESET/DNS). Retrying once to get clean snapshot...` + ); + // Delete corrupted XML file before retry + try { + fs.unlinkSync(path.resolve(genPath, exportPath)); + } catch {} + await new Promise((r) => setTimeout(r, 2000)); + continue; + } + + // Last attempt exhausted - skip test to avoid false positive + console.warn( + `⚠️ Skipping snapshot test: Network errors persisted in JUnit XML after retry. External services may be degraded.` + ); + return; // Skip test - don't fail on infrastructure issues + } + + expect(lastResult?.stdout).toContain( + `Overwriting the pre-existing path: ${exportPath}` + ); + + expect(lastResult?.stdout).toContain( + `Successfully exported the JUnit report to: ${exportPath}` + ); + + expect(replaceDynamicValuesInStr(lastFileContents)).toMatchSnapshot(); + }); + }); + + describe("Test `hopp test --iteration-count ` command:", () => { + const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`; + + test("Errors with the code `INVALID_ARGUMENT` on not supplying an iteration count", async () => { + const args = `${VALID_TEST_ARGS} --iteration-count`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ARGUMENT"); + }); + + test("Errors with the code `INVALID_ARGUMENT` on supplying an invalid iteration count", async () => { + const args = `${VALID_TEST_ARGS} --iteration-count NaN`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ARGUMENT"); + }); + + test("Errors with the code `INVALID_ARGUMENT` on supplying an iteration count below `1`", async () => { + const args = `${VALID_TEST_ARGS} --iteration-count -5`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ARGUMENT"); + }); + + test("Successfully executes all requests in the collection iteratively based on the specified iteration count", async () => { + const iterationCount = 3; + const args = `${VALID_TEST_ARGS} --iteration-count ${iterationCount}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + + Array.from({ length: 3 }).forEach((_, idx) => + expect(result.stdout).include(`Iteration: ${idx + 1}/${iterationCount}`) + ); + expect(result.error).toBeNull(); + }); + + test("Doesn't log iteration count if the value supplied is `1`", async () => { + const args = `${VALID_TEST_ARGS} --iteration-count 1`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + + expect(result.stdout).not.include(`Iteration: 1/1`); + + expect(result.error).toBeNull(); + }); + }); + + describe("Test `hopp test --iteration-data ` command:", () => { + describe("Supplied data export file validations", () => { + const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`; + + test("Errors with the code `INVALID_ARGUMENT` if no file is supplied", async () => { + const args = `${VALID_TEST_ARGS} --iteration-data`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ARGUMENT"); + }); + + test("Errors with the code `INVALID_DATA_FILE_TYPE` if the supplied data file doesn't end with the `.csv` extension", async () => { + const args = `${VALID_TEST_ARGS} --iteration-data ${getTestJsonFilePath( + "notjson-coll.txt", + "collection" + )}`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_DATA_FILE_TYPE"); + }); + + test("Errors with the code `FILE_NOT_FOUND` if the supplied data export file doesn't exist", async () => { + const args = `${VALID_TEST_ARGS} --iteration-data notfound.csv`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("FILE_NOT_FOUND"); + }); + }); + + test("Prioritizes values from the supplied data export file over environment variables with relevant fallbacks for missing entries", async () => { + const COLL_PATH = getTestJsonFilePath( + "iteration-data-tests-coll.json", + "collection" + ); + const ITERATION_DATA_PATH = getTestJsonFilePath( + "iteration-data-export.csv", + "environment" + ); + const ENV_PATH = getTestJsonFilePath( + "iteration-data-envs.json", + "environment" + ); + const args = `test ${COLL_PATH} --iteration-data ${ITERATION_DATA_PATH} -e ${ENV_PATH}`; + + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + + const iterationCount = 3; + + Array.from({ length: iterationCount }).forEach((_, idx) => + expect(result.stdout).include(`Iteration: ${idx + 1}/${iterationCount}`) + ); + + expect(result.error).toBeNull(); + }); + + test("Iteration count takes priority if supplied instead of inferring from the iteration data size", async () => { + const COLL_PATH = getTestJsonFilePath( + "iteration-data-tests-coll.json", + "collection" + ); + const ITERATION_DATA_PATH = getTestJsonFilePath( + "iteration-data-export.csv", + "environment" + ); + const ENV_PATH = getTestJsonFilePath( + "iteration-data-envs.json", + "environment" + ); + + const iterationCount = 5; + const args = `test ${COLL_PATH} --iteration-data ${ITERATION_DATA_PATH} -e ${ENV_PATH} --iteration-count ${iterationCount}`; + + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + + Array.from({ length: iterationCount }).forEach((_, idx) => + expect(result.stdout).include(`Iteration: ${idx + 1}/${iterationCount}`) + ); + + expect(result.error).toBeNull(); + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/aws-signature-auth-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/aws-signature-auth-coll.json new file mode 100644 index 0000000..eae88b7 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/aws-signature-auth-coll.json @@ -0,0 +1,101 @@ +{ + "v": 3, + "name": "AWS Signature Auth - collection", + "folders": [], + "requests": [ + { + "v": "7", + "id": "cm0dm70cw000687bnxi830zz7", + "auth": { + "addTo": "HEADERS", + "region": "<>", + "authType": "aws-signature", + "accessKey": "<>", + "secretKey": "<>", + "authActive": true, + "serviceName": "<>", + "serviceToken": "", + "grantTypeInfo": { + "token": "", + "isPKCE": false, + "clientID": "", + "grantType": "AUTHORIZATION_CODE", + "authEndpoint": "", + "clientSecret": "", + "tokenEndpoint": "", + "codeVerifierMethod": "S256" + } + }, + "body": { + "body": null, + "contentType": null + }, + "name": "aws-signature-auth-headers", + "method": "GET", + "params": [], + "headers": [], + "endpoint": "<>", + "testScript": "pw.test(\"Successfully sends relevant AWS signature information via headers\", ()=> {\n const { headers } = pw.response.body\n\n // Dynamic values, hence comparing the type.\n pw.expect(headers[\"authorization\"]).toBeType(\"string\");\n pw.expect(headers[\"x-amz-date\"]).toBeType(\"string\");\n \n pw.expect(headers[\"x-amz-content-sha256\"]).toBe(\"UNSIGNED-PAYLOAD\")\n \n // No session token supplied\n pw.expect(headers[\"x-amz-security-token\"]).toBe(undefined)\n \n});", + "preRequestScript": "", + "requestVariables": [ + { + "key": "secretVarKey", + "value": "<>", + "active": true + } + ] + }, + { + "v": "7", + "id": "cm0dm70cw000687bnxi830zz7", + "auth": { + "addTo": "QUERY_PARAMS", + "region": "<>", + "authType": "aws-signature", + "accessKey": "<>", + "secretKey": "<>", + "authActive": true, + "serviceName": "<>", + "serviceToken": "<>", + "grantTypeInfo": { + "token": "", + "isPKCE": false, + "clientID": "", + "grantType": "AUTHORIZATION_CODE", + "authEndpoint": "", + "clientSecret": "", + "tokenEndpoint": "", + "codeVerifierMethod": "S256" + } + }, + "body": { + "body": null, + "contentType": null + }, + "name": "aws-signature-auth-query-params", + "method": "GET", + "params": [], + "headers": [], + "endpoint": "<>", + "testScript": "pw.test(\"Successfully sends relevant AWS signature information via query params\", ()=> {\n const { args } = pw.response.body\n pw.expect(args[\"X-Amz-Algorithm\"]).toBe(\"AWS4-HMAC-SHA256\");\n pw.expect(args[\"X-Amz-Algorithm\"]).toBe(\"AWS4-HMAC-SHA256\");\n pw.expect(args[\"X-Amz-Credential\"]).toInclude(\"test-access-key\");\n pw.expect(args[\"X-Amz-Credential\"]).toInclude(\"eu-west-1/s3\");\n\n // Dynamic values, hence comparing the type.\n pw.expect(args[\"X-Amz-Date\"]).toBeType(\"string\");\n pw.expect(args[\"X-Amz-Signature\"]).toBeType(\"string\");\n\n pw.expect(args[\"X-Amz-Expires\"]).toBe(\"86400\")\n pw.expect(args[\"X-Amz-SignedHeaders\"]).toBe(\"host\")\n pw.expect(args[\"X-Amz-Security-Token\"]).toBe(\"test-token\")\n \n});", + "preRequestScript": "", + "requestVariables": [ + { + "key": "awsRegion", + "value": "eu-west-1", + "active": true + }, + { + "key": "secretKey", + "value": "test-secret-key-overriden", + "active": true + } + ] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/coll-v1-req-v0.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/coll-v1-req-v0.json new file mode 100644 index 0000000..bf63d2a --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/coll-v1-req-v0.json @@ -0,0 +1,55 @@ +{ + "v": 1, + "name": "coll-v1", + "folders": [ + { + "v": 1, + "name": "coll-v1-child", + "folders": [], + "requests": [ + { + "url": "https://echo.hoppscotch.io", + "path": "/get", + "headers": [ + { "key": "Inactive-Header", "value": "Inactive Header", "active": false }, + { "key": "Authorization", "value": "Bearer token123", "active": true } + ], + "params": [ + { "key": "key", "value": "value", "active": true }, + { "key": "inactive-key", "value": "inactive-param", "active": false } + ], + "name": "req-v0-II", + "method": "GET", + "preRequestScript": "", + "testScript": "pw.test(\"Asserts request params\", () => {\n pw.expect(pw.response.body.args.key).toBe(\"value\")\n pw.expect(pw.response.body.args[\"inactive-key\"]).toBe(undefined)\n})\n\npw.test(\"Asserts request headers\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer token123\")\n pw.expect(pw.response.body.headers[\"inactive-header\"]).toBe(undefined)\n})", + "contentType": "application/json", + "body": "", + "auth": "Bearer Token", + "bearerToken": "token123" + } + ] + } + ], + "requests": [ + { + "url": "https://echo.hoppscotch.io", + "path": "/get", + "headers": [ + { "key": "Inactive-Header", "value": "Inactive Header", "active": false }, + { "key": "Authorization", "value": "Bearer token123", "active": true } + ], + "params": [ + { "key": "key", "value": "value", "active": true }, + { "key": "inactive-key", "value": "inactive-param", "active": false } + ], + "name": "req-v0", + "method": "GET", + "preRequestScript": "", + "testScript": "pw.test(\"Asserts request params\", () => {\n pw.expect(pw.response.body.args.key).toBe(\"value\")\n pw.expect(pw.response.body.args[\"inactive-key\"]).toBe(undefined)\n})\n\npw.test(\"Asserts request headers\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer token123\")\n pw.expect(pw.response.body.headers[\"inactive-header\"]).toBe(undefined)\n})", + "contentType": "application/json", + "body": "", + "auth": "Bearer Token", + "bearerToken": "token123" + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/coll-v1-req-v1.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/coll-v1-req-v1.json new file mode 100644 index 0000000..9bfc979 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/coll-v1-req-v1.json @@ -0,0 +1,97 @@ +{ + "v": 1, + "name": "coll-v1", + "folders": [ + { + "v": 1, + "name": "coll-v1-child", + "folders": [], + "requests": [ + { + "v": "1", + "endpoint": "https://echo.hoppscotch.io", + "headers": [ + { + "key": "Inactive-Header", + "value": "Inactive Header", + "active": false + }, + { + "key": "Authorization", + "value": "Bearer token123", + "active": true + } + ], + "params": [ + { + "key": "key", + "value": "value", + "active": true + }, + { + "key": "inactive-key", + "value": "inactive-param", + "active": false + } + ], + "name": "req-v1-II", + "method": "GET", + "preRequestScript": "", + "testScript": "pw.test(\"Asserts request params\", () => {\n pw.expect(pw.response.body.args.key).toBe(\"value\")\n pw.expect(pw.response.body.args[\"inactive-key\"]).toBe(undefined)\n})\n\npw.test(\"Asserts request headers\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer token123\")\n pw.expect(pw.response.body.headers[\"inactive-header\"]).toBe(undefined)\n})", + "body": { + "contentType": null, + "body": null + }, + "auth": { + "authType": "bearer", + "authActive": true, + "token": "token123" + } + } + ] + } + ], + "requests": [ + { + "v": "1", + "endpoint": "https://echo.hoppscotch.io", + "headers": [ + { + "key": "Inactive-Header", + "value": "Inactive Header", + "active": false + }, + { + "key": "Authorization", + "value": "Bearer token123", + "active": true + } + ], + "params": [ + { + "key": "key", + "value": "value", + "active": true + }, + { + "key": "inactive-key", + "value": "inactive-param", + "active": false + } + ], + "name": "req-v1", + "method": "GET", + "preRequestScript": "", + "testScript": "pw.test(\"Asserts request params\", () => {\n pw.expect(pw.response.body.args.key).toBe(\"value\")\n pw.expect(pw.response.body.args[\"inactive-key\"]).toBe(undefined)\n})\n\npw.test(\"Asserts request headers\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer token123\")\n pw.expect(pw.response.body.headers[\"inactive-header\"]).toBe(undefined)\n})", + "body": { + "contentType": null, + "body": null + }, + "auth": { + "authType": "bearer", + "authActive": true, + "token": "token123" + } + } + ] +} \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/coll-v2-req-v2.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/coll-v2-req-v2.json new file mode 100644 index 0000000..0bcbd13 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/coll-v2-req-v2.json @@ -0,0 +1,109 @@ +{ + "v": 2, + "name": "coll-v2", + "folders": [ + { + "v": 2, + "name": "coll-v2-child", + "folders": [], + "requests": [ + { + "v": "2", + "endpoint": "https://echo.hoppscotch.io", + "headers": [ + { + "key": "Inactive-Header", + "value": "Inactive Header", + "active": false + }, + { + "key": "Authorization", + "value": "Bearer token123", + "active": true + } + ], + "params": [ + { + "key": "key", + "value": "value", + "active": true + }, + { + "key": "inactive-key", + "value": "inactive-param", + "active": false + } + ], + "name": "req-v2-II", + "method": "GET", + "preRequestScript": "", + "testScript": "pw.test(\"Asserts request params\", () => {\n pw.expect(pw.response.body.args.key).toBe(\"value\")\n pw.expect(pw.response.body.args[\"inactive-key\"]).toBe(undefined)\n})\n\npw.test(\"Asserts request headers\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer token123\")\n pw.expect(pw.response.body.headers[\"inactive-header\"]).toBe(undefined)\n})", + "body": { + "contentType": null, + "body": null + }, + "auth": { + "authType": "bearer", + "authActive": true, + "token": "token123" + }, + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] + } + ], + "requests": [ + { + "v": "2", + "endpoint": "https://echo.hoppscotch.io", + "headers": [ + { + "key": "Inactive-Header", + "value": "Inactive Header", + "active": false + }, + { + "key": "Authorization", + "value": "Bearer token123", + "active": true + } + ], + "params": [ + { + "key": "key", + "value": "value", + "active": true + }, + { + "key": "inactive-key", + "value": "inactive-param", + "active": false + } + ], + "name": "req-v2", + "method": "GET", + "preRequestScript": "", + "testScript": "pw.test(\"Asserts request params\", () => {\n pw.expect(pw.response.body.args.key).toBe(\"value\")\n pw.expect(pw.response.body.args[\"inactive-key\"]).toBe(undefined)\n})\n\npw.test(\"Asserts request headers\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer token123\")\n pw.expect(pw.response.body.headers[\"inactive-header\"]).toBe(undefined)\n})", + "body": { + "contentType": null, + "body": null + }, + "auth": { + "authType": "bearer", + "authActive": true, + "token": "token123" + }, + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] +} \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/coll-v2-req-v3.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/coll-v2-req-v3.json new file mode 100644 index 0000000..916e809 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/coll-v2-req-v3.json @@ -0,0 +1,109 @@ +{ + "v": 2, + "name": "coll-v2", + "folders": [ + { + "v": 2, + "name": "coll-v2-child", + "folders": [], + "requests": [ + { + "v": "3", + "endpoint": "https://echo.hoppscotch.io", + "headers": [ + { + "key": "Inactive-Header", + "value": "Inactive Header", + "active": false + }, + { + "key": "Authorization", + "value": "Bearer token123", + "active": true + } + ], + "params": [ + { + "key": "key", + "value": "value", + "active": true + }, + { + "key": "inactive-key", + "value": "inactive-param", + "active": false + } + ], + "name": "req-v3-II", + "method": "GET", + "preRequestScript": "", + "testScript": "pw.test(\"Asserts request params\", () => {\n pw.expect(pw.response.body.args.key).toBe(\"value\")\n pw.expect(pw.response.body.args[\"inactive-key\"]).toBe(undefined)\n})\n\npw.test(\"Asserts request headers\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer token123\")\n pw.expect(pw.response.body.headers[\"inactive-header\"]).toBe(undefined)\n})", + "body": { + "contentType": null, + "body": null + }, + "auth": { + "authType": "bearer", + "authActive": true, + "token": "token123" + }, + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] + } + ], + "requests": [ + { + "v": "3", + "endpoint": "https://echo.hoppscotch.io", + "headers": [ + { + "key": "Inactive-Header", + "value": "Inactive Header", + "active": false + }, + { + "key": "Authorization", + "value": "Bearer token123", + "active": true + } + ], + "params": [ + { + "key": "key", + "value": "value", + "active": true + }, + { + "key": "inactive-key", + "value": "inactive-param", + "active": false + } + ], + "name": "req-v3", + "method": "GET", + "preRequestScript": "", + "testScript": "pw.test(\"Asserts request params\", () => {\n pw.expect(pw.response.body.args.key).toBe(\"value\")\n pw.expect(pw.response.body.args[\"inactive-key\"]).toBe(undefined)\n})\n\npw.test(\"Asserts request headers\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer token123\")\n pw.expect(pw.response.body.headers[\"inactive-header\"]).toBe(undefined)\n})", + "body": { + "contentType": null, + "body": null + }, + "auth": { + "authType": "bearer", + "authActive": true, + "token": "token123" + }, + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] +} \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-auth-headers-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-auth-headers-coll.json new file mode 100644 index 0000000..bdca78c --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-auth-headers-coll.json @@ -0,0 +1,227 @@ +[ + { + "v": 2, + "name": "CollectionA", + "folders": [ + { + "v": 2, + "name": "FolderA", + "folders": [ + { + "v": 2, + "name": "FolderB", + "folders": [ + { + "v": 2, + "name": "FolderC", + "folders": [], + "requests": [ + { + "v": "3", + "endpoint": "https://echo.hoppscotch.io", + "name": "RequestD", + "params": [], + "headers": [ + { + "active": true, + "key": "X-Test-Header", + "value": "Overriden at RequestD" + } + ], + "method": "GET", + "auth": { + "authType": "basic", + "authActive": true, + "username": "username", + "password": "password" + }, + "preRequestScript": "", + "testScript": "pw.test(\"Overrides auth and headers set at the parent folder\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Overriden at RequestD\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Basic dXNlcm5hbWU6cGFzc3dvcmQ=\");\n});", + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] + } + ], + "requests": [ + { + "v": "3", + "endpoint": "https://echo.hoppscotch.io", + "name": "RequestC", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "inherit", + "authActive": true + }, + "preRequestScript": "", + "testScript": "pw.test(\"Correctly inherits auth and headers from the parent folder\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Overriden at FolderB\");\n pw.expect(pw.response.body.headers[\"key\"]).toBe(\"test-key\");\n});", + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [] + } + ], + "auth": { + "authType": "api-key", + "authActive": true, + "addTo": "HEADERS", + "key": "key", + "value": "test-key" + }, + "headers": [ + { + "active": true, + "key": "X-Test-Header", + "value": "Overriden at FolderB" + } + ] + } + ], + "requests": [ + { + "v": "3", + "endpoint": "https://echo.hoppscotch.io", + "name": "RequestB", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "inherit", + "authActive": true + }, + "preRequestScript": "", + "testScript": "pw.test(\"Correctly inherits auth and headers from the parent folder\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Set at root collection\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer BearerToken\");\n});", + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "id": "clpttpdq00003qp16kut6doqv" + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] + } + ], + "requests": [ + { + "v": "3", + "endpoint": "https://echo.hoppscotch.io", + "name": "RequestA", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "inherit", + "authActive": true + }, + "preRequestScript": "", + "testScript": "pw.test(\"Correctly inherits auth and headers from the root collection\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Set at root collection\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer BearerToken\");\n});", + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "id": "clpttpdq00003qp16kut6doqv" + } + ], + "headers": [ + { + "active": true, + "key": "X-Test-Header", + "value": "Set at root collection" + } + ], + "auth": { + "authType": "bearer", + "authActive": true, + "token": "BearerToken" + } + }, + { + "v": 2, + "name": "CollectionB", + "folders": [ + { + "v": 2, + "name": "FolderA", + "folders": [], + "requests": [ + { + "v": "3", + "endpoint": "https://echo.hoppscotch.io", + "name": "RequestB", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "inherit", + "authActive": true + }, + "preRequestScript": "", + "testScript": "pw.test(\"Correctly inherits auth and headers from the parent folder\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Set at root collection\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer BearerToken\");\n});", + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "id": "clpttpdq00003qp16kut6doqv" + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] + } + ], + "requests": [ + { + "v": "3", + "endpoint": "https://echo.hoppscotch.io", + "name": "RequestA", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "inherit", + "authActive": true + }, + "preRequestScript": "", + "testScript": "pw.test(\"Correctly inherits auth and headers from the root collection\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Set at root collection\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer BearerToken\");\n});", + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "id": "clpttpdq00003qp16kut6doqv" + } + ], + "headers": [ + { + "active": true, + "key": "X-Test-Header", + "value": "Set at root collection" + } + ], + "auth": { + "authType": "bearer", + "authActive": true, + "token": "BearerToken" + } + } +] diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-coll.json new file mode 100644 index 0000000..e090494 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-coll.json @@ -0,0 +1,158 @@ +{ + "v": 12, + "name": "collection-level-scripts-coll", + "variables": [], + "description": null, + "folders": [ + { + "v": 12, + "name": "target-folder", + "variables": [], + "description": null, + "folders": [], + "requests": [ + { + "v": "17", + "id": "cl-script-req-1", + "name": "target-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "pw.env.set(\"REQ_RAN\", \"yes\");\npw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->target-req\");", + "testScript": "pw.env.set(\"TEST_ORDER\", \"target-req\");\npw.env.set(\"ORDER_AT_REQ\", pw.env.get(\"TEST_ORDER\"));\npw.test(\"pre-script cascade ran in root->target-folder->target-req order\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->target-folder->target-req\");\n});\npw.test(\"all cascade pre-scripts committed env vars\", () => {\n pw.expect(pw.env.get(\"ROOT_RAN\")).toBe(\"yes\");\n pw.expect(pw.env.get(\"TARGET_FOLDER_RAN\")).toBe(\"yes\");\n pw.expect(pw.env.get(\"REQ_RAN\")).toBe(\"yes\");\n});\npw.test(\"request-level test observed request position in test-cascade\", () => {\n pw.expect(pw.env.get(\"ORDER_AT_REQ\")).toBe(\"target-req\");\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cl-script-req-2", + "name": "sibling-request-in-target-folder", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "pw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->sibling-req-in-target\");", + "testScript": "pw.env.set(\"TEST_ORDER\", \"sibling-req-in-target\");\npw.test(\"sibling request cascade is root->target-folder->this-request\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->target-folder->sibling-req-in-target\");\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cl-script-req-with-import", + "name": "request-with-top-level-import", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "import { value } from \"data:text/javascript,export const value = 'esm-import-ok'\";\npw.env.set(\"IMPORTED_VALUE\", value);\npw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->req-with-import\");", + "testScript": "pw.env.set(\"TEST_ORDER\", \"req-with-import\");\npw.test(\"top-level ESM import in pre-request script resolved\", () => {\n pw.expect(pw.env.get(\"IMPORTED_VALUE\")).toBe(\"esm-import-ok\");\n});\npw.test(\"cascade order preserved with import-using request\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->target-folder->req-with-import\");\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cl-script-req-with-test-import", + "name": "request-with-test-script-imports", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "pw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->req-with-test-import\");", + "testScript": "import lodash from \"data:text/javascript,export default { pick: (obj, keys) => keys.reduce((acc, k) => (k in obj ? Object.assign(acc, { [k]: obj[k] }) : acc), {}) }\";\nimport axios from \"data:text/javascript,export default { name: 'axios-stub', version: '1.6.0' }\";\nimport { format } from \"data:text/javascript,export const format = (_d, fmt) => fmt.replace('yyyy', '2026').replace('MM', '05').replace('dd', '07')\";\nimport * as ns from \"data:text/javascript,export const a = 1; export const b = 2\";\nimport combo, { tag } from \"data:text/javascript,export default 7; export const tag = 'mixed'\";\nconst picked = lodash.pick({ id: 1, name: \"hopp\", email: \"x@y.z\", extra: \"drop\" }, [\"id\", \"name\", \"email\"]);\npw.env.set(\"TEST_IMPORT_PICKED\", JSON.stringify(picked));\npw.env.set(\"TEST_IMPORT_AXIOS\", axios.name);\npw.env.set(\"TEST_IMPORT_FORMATTED\", format(new Date(), \"yyyy-MM-dd\"));\npw.env.set(\"TEST_IMPORT_NAMESPACE_SUM\", String(ns.a + ns.b));\npw.env.set(\"TEST_IMPORT_MIXED\", String(combo) + \"-\" + tag);\npw.env.set(\"TEST_ORDER\", \"req-with-test-import\");\npw.test(\"test-script default imports resolve\", () => {\n pw.expect(pw.env.get(\"TEST_IMPORT_AXIOS\")).toBe(\"axios-stub\");\n});\npw.test(\"test-script named import resolves\", () => {\n pw.expect(pw.env.get(\"TEST_IMPORT_FORMATTED\")).toBe(\"2026-05-07\");\n});\npw.test(\"test-script namespace import resolves\", () => {\n pw.expect(pw.env.get(\"TEST_IMPORT_NAMESPACE_SUM\")).toBe(\"3\");\n});\npw.test(\"test-script mixed default and named import resolves\", () => {\n pw.expect(pw.env.get(\"TEST_IMPORT_MIXED\")).toBe(\"7-mixed\");\n});\npw.test(\"test-script imports run alongside test logic\", () => {\n pw.expect(pw.env.get(\"TEST_IMPORT_PICKED\")).toBe(JSON.stringify({ id: 1, name: \"hopp\", email: \"x@y.z\" }));\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "preRequestScript": "pw.env.set(\"TARGET_FOLDER_RAN\", \"yes\");\npw.env.set(\"TARGET_FOLDER_RUN_COUNT\", String((parseInt(pw.env.get(\"TARGET_FOLDER_RUN_COUNT\") || \"0\", 10)) + 1));\npw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->target-folder\");", + "testScript": "pw.env.set(\"TEST_ORDER\", pw.env.get(\"TEST_ORDER\") + \"->target-folder\");\npw.env.set(\"ORDER_AT_TARGET_FOLDER\", pw.env.get(\"TEST_ORDER\"));" + }, + { + "v": 12, + "name": "sibling-folder", + "variables": [], + "description": null, + "folders": [], + "requests": [ + { + "v": "17", + "id": "cl-script-req-3", + "name": "sibling-request-in-sibling-folder", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "pw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->sibling-req-in-sibling\");", + "testScript": "pw.env.set(\"TEST_ORDER\", \"sibling-req-in-sibling\");\npw.test(\"sibling-folder cascade is root->sibling-folder->this-request (no target-folder leak)\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->sibling-folder->sibling-req-in-sibling\");\n});\npw.test(\"target-folder pre-script ran once per request in target-folder\", () => {\n pw.expect(pw.env.get(\"TARGET_FOLDER_RUN_COUNT\")).toBe(\"4\");\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "preRequestScript": "pw.env.set(\"SIBLING_FOLDER_RAN\", \"yes\");\npw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->sibling-folder\");", + "testScript": "pw.env.set(\"TEST_ORDER\", pw.env.get(\"TEST_ORDER\") + \"->sibling-folder\");" + } + ], + "requests": [], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "preRequestScript": "pw.env.set(\"ROOT_RAN\", \"yes\");\npw.env.set(\"PRE_ORDER\", \"root\");", + "testScript": "pw.env.set(\"TEST_ORDER\", pw.env.get(\"TEST_ORDER\") + \"->root\");\npw.test(\"test-script cascade ran in request->folder->root order for every request\", () => {\n pw.expect([\"target-req->target-folder->root\", \"sibling-req-in-target->target-folder->root\", \"req-with-import->target-folder->root\", \"req-with-test-import->target-folder->root\", \"sibling-req-in-sibling->sibling-folder->root\"].includes(pw.env.get(\"TEST_ORDER\"))).toBe(true);\n});" +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-duplicate-import-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-duplicate-import-coll.json new file mode 100644 index 0000000..ae5ba7f --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-duplicate-import-coll.json @@ -0,0 +1,38 @@ +{ + "v": 12, + "name": "collection-level-scripts-duplicate-import-coll", + "variables": [], + "description": null, + "folders": [], + "requests": [ + { + "v": "17", + "id": "cl-script-dup-req", + "name": "request-with-duplicate-import-binding", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "import dup from \"data:text/javascript,export default 2\";\npw.env.set(\"REQ_BINDING\", String(dup));", + "testScript": "", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "preRequestScript": "import dup from \"data:text/javascript,export default 1\";\npw.env.set(\"ROOT_BINDING\", String(dup));", + "testScript": "" +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-legacy-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-legacy-coll.json new file mode 100644 index 0000000..0d53b17 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-legacy-coll.json @@ -0,0 +1,114 @@ +{ + "v": 12, + "name": "collection-level-scripts-legacy-coll", + "variables": [], + "description": null, + "folders": [ + { + "v": 12, + "name": "target-folder", + "variables": [], + "description": null, + "folders": [], + "requests": [ + { + "v": "17", + "id": "cl-script-req-1", + "name": "target-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "pw.env.set(\"REQ_RAN\", \"yes\");\npw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->target-req\");", + "testScript": "pw.env.set(\"TEST_ORDER\", \"target-req\");\npw.env.set(\"ORDER_AT_REQ\", pw.env.get(\"TEST_ORDER\"));\npw.test(\"pre-script cascade ran in root->target-folder->target-req order\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->target-folder->target-req\");\n});\npw.test(\"all cascade pre-scripts committed env vars\", () => {\n pw.expect(pw.env.get(\"ROOT_RAN\")).toBe(\"yes\");\n pw.expect(pw.env.get(\"TARGET_FOLDER_RAN\")).toBe(\"yes\");\n pw.expect(pw.env.get(\"REQ_RAN\")).toBe(\"yes\");\n});\npw.test(\"request-level test observed request position in test-cascade\", () => {\n pw.expect(pw.env.get(\"ORDER_AT_REQ\")).toBe(\"target-req\");\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cl-script-req-2", + "name": "sibling-request-in-target-folder", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "pw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->sibling-req-in-target\");", + "testScript": "pw.env.set(\"TEST_ORDER\", \"sibling-req-in-target\");\npw.test(\"sibling request cascade is root->target-folder->this-request\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->target-folder->sibling-req-in-target\");\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "preRequestScript": "pw.env.set(\"TARGET_FOLDER_RAN\", \"yes\");\npw.env.set(\"TARGET_FOLDER_RUN_COUNT\", String((parseInt(pw.env.get(\"TARGET_FOLDER_RUN_COUNT\") || \"0\", 10)) + 1));\npw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->target-folder\");", + "testScript": "pw.env.set(\"TEST_ORDER\", pw.env.get(\"TEST_ORDER\") + \"->target-folder\");\npw.env.set(\"ORDER_AT_TARGET_FOLDER\", pw.env.get(\"TEST_ORDER\"));" + }, + { + "v": 12, + "name": "sibling-folder", + "variables": [], + "description": null, + "folders": [], + "requests": [ + { + "v": "17", + "id": "cl-script-req-3", + "name": "sibling-request-in-sibling-folder", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "pw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->sibling-req-in-sibling\");", + "testScript": "pw.env.set(\"TEST_ORDER\", \"sibling-req-in-sibling\");\npw.test(\"sibling-folder cascade is root->sibling-folder->this-request (no target-folder leak)\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->sibling-folder->sibling-req-in-sibling\");\n});\npw.test(\"target-folder pre-script ran exactly twice (one per request in target-folder)\", () => {\n pw.expect(pw.env.get(\"TARGET_FOLDER_RUN_COUNT\")).toBe(\"2\");\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "preRequestScript": "pw.env.set(\"SIBLING_FOLDER_RAN\", \"yes\");\npw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->sibling-folder\");", + "testScript": "pw.env.set(\"TEST_ORDER\", pw.env.get(\"TEST_ORDER\") + \"->sibling-folder\");" + } + ], + "requests": [], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "preRequestScript": "pw.env.set(\"ROOT_RAN\", \"yes\");\npw.env.set(\"PRE_ORDER\", \"root\");", + "testScript": "pw.env.set(\"TEST_ORDER\", pw.env.get(\"TEST_ORDER\") + \"->root\");\npw.test(\"test-script cascade ran in request->folder->root order for every request\", () => {\n pw.expect([\"target-req->target-folder->root\", \"sibling-req-in-target->target-folder->root\", \"sibling-req-in-sibling->sibling-folder->root\"].includes(pw.env.get(\"TEST_ORDER\"))).toBe(true);\n});" +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-with-variables.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-with-variables.json new file mode 100644 index 0000000..d5383da --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-with-variables.json @@ -0,0 +1,326 @@ +{ + "id": "cmeicx49r00xylb1jxektmknk", + "_ref_id": "coll_meicx3z7_a1cb5e72-cd1b-414b-adc2-7d601ca0936d", + "v": 10, + "name": "coll-with-variables", + "folders": [ + { + "id": "cmeicy6fu00xzlb1jqmmqbjdm", + "_ref_id": "coll_meie14lh_818ea8a2-9839-4a1c-8cce-cc7565b5f594", + "v": 10, + "name": "folder-1", + "folders": [], + "requests": [ + { + "v": "15", + "id": "cmeicyhnn00y1lb1j8d80g7ys", + "name": "request-1", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [ + { + "key": "collection-var-1", + "value": "<>", + "active": true, + "description": "" + }, + { + "key": "collection-var-2", + "value": "<>", + "active": true, + "description": "" + }, + { + "key": "folder-var-1", + "value": "<>", + "active": true, + "description": "" + }, + { + "key": "folder-var-2", + "value": "<>", + "active": true, + "description": "" + } + ], + "headers": [], + "preRequestScript": "", + "testScript": "pw.test('Correctly inherits collection variables from the parent collection and folder', () => {\n pw.expect([\n \"collection-var-1-initial-value\",\n \"collection-var-1-current-value\"\n ]).toInclude(pw.response.body.args[\"collection-var-1\"]);\n\n pw.expect([\n \"collection-var-2-initial-value\",\n \"collection-var-2-current-value\"\n ]).toInclude(pw.response.body.args[\"collection-var-2\"]);\n\n pw.expect([\n \"folder-variable-1-initial-value\",\n \"folder-variable-1-current-value\"\n ]).toInclude(pw.response.body.args[\"folder-var-1\"]);\n\n pw.expect([\n \"folder-variable-2-initial-value\",\n \"folder-variable-2-current-value\"\n ]).toInclude(pw.response.body.args[\"folder-var-2\"]);\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "variables": [ + { + "key": "folder-variable-1", + "secret": false, + "currentValue": "", + "initialValue": "folder-variable-1-initial-value" + }, + { + "key": "folder-variable-2", + "secret": false, + "currentValue": "", + "initialValue": "folder-variable-2-initial-value" + } + ] + }, + { + "id": "empty_folder_test", + "_ref_id": "coll_empty_folder", + "v": 10, + "name": "folder-without-variables", + "folders": [ + { + "id": "nested_folder_test", + "_ref_id": "coll_nested_folder", + "v": 10, + "name": "nested-folder", + "folders": [], + "requests": [ + { + "v": "15", + "id": "nested_request", + "name": "deeply-nested-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [ + { + "key": "nested-var-1", + "value": "<>", + "active": true, + "description": "" + }, + { + "key": "nested-var-2", + "value": "<>", + "active": true, + "description": "" + } + ], + "headers": [], + "preRequestScript": "", + "testScript": "pw.test('Nested folder should inherit variables from root collection through parent', () => {\n pw.expect([\n \"collection-var-1-initial-value\",\n \"collection-var-1-current-value\"\n ]).toInclude(pw.response.body.args[\"nested-var-1\"]);\n\n pw.expect([\n \"collection-var-2-initial-value\",\n \"collection-var-2-current-value\"\n ]).toInclude(pw.response.body.args[\"nested-var-2\"]);\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "variables": [] + } + ], + "requests": [ + { + "v": "15", + "id": "request_in_empty_folder", + "name": "request-inheriting-collection-vars", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [ + { + "key": "inherited-var-1", + "value": "<>", + "active": true, + "description": "" + }, + { + "key": "inherited-var-2", + "value": "<>", + "active": true, + "description": "" + } + ], + "headers": [], + "preRequestScript": "", + "testScript": "pw.test('Folder without variables should inherit from parent collection', () => {\n pw.expect([\n \"collection-var-1-initial-value\",\n \"collection-var-1-current-value\"\n ]).toInclude(pw.response.body.args[\"inherited-var-1\"]);\n\n pw.expect([\n \"collection-var-2-initial-value\",\n \"collection-var-2-current-value\"\n ]).toInclude(pw.response.body.args[\"inherited-var-2\"]);\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "variables": [] + }, + { + "id": "precedence_test_folder", + "_ref_id": "coll_precedence_folder", + "v": 10, + "name": "folder-with-override", + "folders": [], + "requests": [ + { + "v": "15", + "id": "request_with_override", + "name": "request-with-folder-override", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [ + { + "key": "overridden-var", + "value": "<>", + "active": true, + "description": "" + }, + { + "key": "non-overridden-var", + "value": "<>", + "active": true, + "description": "" + } + ], + "headers": [], + "preRequestScript": "", + "testScript": "pw.test('Folder variable should take precedence over collection variable with same key', () => {\n // collection-variable-1 is overridden by folder, should be 'folder-override-value'\n pw.expect(pw.response.body.args[\"overridden-var\"]).toBe(\"folder-override-value\");\n\n // collection-variable-2 is NOT overridden, should be from collection\n pw.expect([\n \"collection-var-2-initial-value\",\n \"collection-var-2-current-value\"\n ]).toInclude(pw.response.body.args[\"non-overridden-var\"]);\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "variables": [ + { + "key": "collection-variable-1", + "secret": false, + "currentValue": "", + "initialValue": "folder-override-value" + } + ] + } + ], + "requests": [ + { + "v": "15", + "id": "root_level_request", + "name": "root-level-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [ + { + "key": "root-var-1", + "value": "<>", + "active": true, + "description": "" + }, + { + "key": "root-var-2", + "value": "<>", + "active": true, + "description": "" + } + ], + "headers": [], + "preRequestScript": "", + "testScript": "pw.test('Root-level request should access collection variables directly', () => {\n pw.expect([\n \"collection-var-1-initial-value\",\n \"collection-var-1-current-value\"\n ]).toInclude(pw.response.body.args[\"root-var-1\"]);\n\n pw.expect([\n \"collection-var-2-initial-value\",\n \"collection-var-2-current-value\"\n ]).toInclude(pw.response.body.args[\"root-var-2\"]);\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "root_level_request_with_precedence", + "name": "root-request-variable-precedence", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [ + { + "key": "precedence-test", + "value": "<>", + "active": true, + "description": "" + } + ], + "headers": [], + "preRequestScript": "", + "testScript": "pw.test('Request variable takes precedence over collection variable with same key', () => {\n // collection-variable-1 exists at collection level with value \"collection-var-1-initial-value\"\n // but request variable overrides it with \"request-wins\"\n pw.expect(pw.response.body.args[\"precedence-test\"]).toBe(\"request-wins\");\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [ + { + "key": "collection-variable-1", + "value": "request-wins", + "active": true + } + ], + "responses": {} + } + ], + "auth": { + "authType": "none", + "authActive": true + }, + "headers": [], + "variables": [ + { + "key": "collection-variable-1", + "secret": false, + "currentValue": "", + "initialValue": "collection-var-1-initial-value" + }, + { + "key": "collection-variable-2", + "secret": false, + "currentValue": "", + "initialValue": "collection-var-2-initial-value" + } + ] +} \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/content-type-header-scenarios.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/content-type-header-scenarios.json new file mode 100644 index 0000000..9aea2c0 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/content-type-header-scenarios.json @@ -0,0 +1,171 @@ +{ + "v": 2, + "name": "content-type-header-scenarios", + "folders": [], + "requests": [ + { + "v": "6", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "body": "\n\n \n 12345\n John Doe\n john.doe@example.com\n \n \n 98765\n Sample Product\n 2\n \n\n", + "contentType": "text/xml" + }, + "name": "content-type-header-assignment", + "method": "POST", + "params": [], + "headers": [], + "endpoint": "https://echo.hoppscotch.io", + "testScript": "pw.test(\"The `Content-Type` header is assigned the content type value set at the request body level\", ()=> {\n pw.expect(pw.response.body.headers[\"content-type\"]).toBe(\"text/xml\");\n});", + "preRequestScript": "", + "requestVariables": [] + }, + { + "v": "6", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "body": "\n\n \n 12345\n John Doe\n john.doe@example.com\n \n \n 98765\n Sample Product\n 2\n \n\n", + "contentType": "application/json" + }, + "name": "content-type-header-override", + "method": "POST", + "params": [], + "headers": [ + { + "key": "Content-Type", + "value": "application/xml", + "active": true + } + ], + "endpoint": "https://echo.hoppscotch.io", + "testScript": "pw.test(\"The `Content-Type` header overrides the content type value set at the request body level\", ()=> {\n pw.expect(pw.response.body.headers[\"content-type\"]).toBe(\"application/xml\");\n});", + "preRequestScript": "", + "requestVariables": [] + }, + { + "v": "6", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "body": "\n\n \n 12345\n John Doe\n john.doe@example.com\n \n \n 98765\n Sample Product\n 2\n \n\n", + "contentType": "application/json" + }, + "name": "multiple-content-type-headers", + "method": "POST", + "params": [], + "headers": [ + { + "key": "Content-Type", + "value": "text/xml", + "active": true + }, + { + "key": "Content-Type", + "value": "application/json", + "active": true + }, + { + "key": "Content-Type", + "value": "application/xml", + "active": true + } + ], + "endpoint": "https://echo.hoppscotch.io", + "testScript": "pw.test(\"The last occurrence will be considered among multiple `Content-Type` headers\", ()=> {\n pw.expect(pw.response.body.headers[\"content-type\"]).toBe(\"application/xml\");\n});", + "preRequestScript": "", + "requestVariables": [] + }, + { + "v": "6", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "body": "\n\n \n 12345\n John Doe\n john.doe@example.com\n \n \n 98765\n Sample Product\n 2\n \n\n", + "contentType": null + }, + "name": "multiple-content-type-headers-different-casing", + "method": "POST", + "params": [], + "headers": [ + { + "key": "Content-Type", + "value": "text/xml", + "active": true + }, + { + "key": "content-Type", + "value": "application/json", + "active": true + }, + { + "key": "Content-type", + "value": "text/plain", + "active": true + }, + { + "key": "CONTENT-TYPE", + "value": "application/xml", + "active": true + } + ], + "endpoint": "https://echo.hoppscotch.io", + "testScript": "pw.test(\"The last occurrence will be considered among multiple `Content-Type` headers following different casing\", ()=> {\n pw.expect(pw.response.body.headers[\"content-type\"]).toBe(\"application/xml\");\n});", + "preRequestScript": "", + "requestVariables": [] + }, + { + "v": "6", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "body": "\n\n \n 12345\n John Doe\n john.doe@example.com\n \n \n 98765\n Sample Product\n 2\n \n\n", + "contentType": null + }, + "name": "multiple-content-type-headers-different-casing-without-value-set-at-body", + "method": "POST", + "params": [], + "headers": [ + { + "key": "Content-Type", + "value": "text/xml", + "active": true + }, + { + "key": "content-Type", + "value": "application/json", + "active": true + }, + { + "key": "Content-type", + "value": "text/plain", + "active": true + }, + { + "key": "CONTENT-TYPE", + "value": "application/xml", + "active": true + } + ], + "endpoint": "https://echo.hoppscotch.io", + "testScript": "pw.test(\"The content type is inferred from the `Content-Type` header if not set at the request body\", ()=> {\n pw.expect(pw.response.body.headers[\"content-type\"]).toBe(\"application/xml\");\n});", + "preRequestScript": "", + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] +} \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-failure-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-failure-coll.json new file mode 100644 index 0000000..d8ab5ef --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-failure-coll.json @@ -0,0 +1,43 @@ +{ + "v": 3, + "name": "Digest Auth (failure state) - collection", + "folders": [], + "requests": [ + { + "v": "8", + "id": "cm0dm70cw000687bnxi830zz7", + "auth": { + "authType": "digest", + "authActive": true, + "username": "<>", + "password": "<>", + "realm": "", + "nonce": "", + "algorithm": "MD5", + "qop": "auth", + "nc": "", + "cnonce": "", + "opaque": "", + "disableRetry": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "digest-auth-headers", + "method": "GET", + "params": [], + "headers": [], + "endpoint": "<>", + "testScript": "pw.test(\"Status code is not 200\", ()=> { pw.expect(pw.response.status).not.toBe(200);}); \n pw.test(\"Receives the www-authenticate header\", ()=> { pw.expect(pw.response.headers['www-authenticate']).not.toBeType('string');});", + "preRequestScript": "", + "responses": {}, + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-success-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-success-coll.json new file mode 100644 index 0000000..16f0b6f --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-success-coll.json @@ -0,0 +1,43 @@ +{ + "v": 3, + "name": "Digest Auth (success state) - collection", + "folders": [], + "requests": [ + { + "v": "8", + "id": "cm0dm70cw000687bnxi830zz7", + "auth": { + "authType": "digest", + "authActive": true, + "username": "<>", + "password": "<>", + "realm": "", + "nonce": "", + "algorithm": "MD5", + "qop": "auth", + "nc": "", + "cnonce": "", + "opaque": "", + "disableRetry": false + }, + "body": { + "body": null, + "contentType": null + }, + "name": "digest-auth-headers", + "method": "GET", + "params": [], + "headers": [], + "endpoint": "<>", + "testScript": "pw.test(\"Status code is 200\", ()=> { pw.expect(pw.response.status).toBe(200);}); \n pw.test(\"Receives the www-authenticate header\", ()=> { pw.expect(pw.response.headers['www-authenticate']).toBeType('string');});", + "preRequestScript": "", + "responses": {}, + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/env-flag-tests-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/env-flag-tests-coll.json new file mode 100644 index 0000000..ded538a --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/env-flag-tests-coll.json @@ -0,0 +1,23 @@ +{ + "v": 1, + "name": "env-flag-tests", + "folders": [], + "requests": [ + { + "v": "3", + "endpoint": "<>", + "name": "test1", + "params": [], + "headers": [], + "method": "POST", + "auth": { "authType": "none", "authActive": true }, + "preRequestScript": "", + "testScript": "const HOST = pw.env.get(\"HOST\");\nconst UNSET_ENV = pw.env.get(\"UNSET_ENV\");\nconst EXPECTED_URL = \"https://echo.hoppscotch.io\";\nconst URL = pw.env.get(\"URL\");\nconst BODY_VALUE = pw.env.get(\"BODY_VALUE\");\n\n// Check JSON response property\npw.test(\"Check headers properties.\", ()=> {\n pw.expect(pw.response.body.headers.host).toBe(HOST);\n});\n\npw.test(\"Check data properties.\", () => {\n\tconst DATA = pw.response.body.data;\n \n pw.expect(DATA).toBeType(\"string\");\n pw.expect(JSON.parse(DATA).body_key).toBe(BODY_VALUE);\n});\n\npw.test(\"Check request URL.\", () => {\n pw.expect(URL).toBe(EXPECTED_URL);\n})\n\npw.test(\"Check unset ENV.\", () => {\n pw.expect(UNSET_ENV).toBeType(\"undefined\");\n})", + "body": { + "contentType": "application/json", + "body": "{\n \"<>\":\"<>\"\n}" + }, + "requestVariables": [] + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/fails-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/fails-coll.json new file mode 100644 index 0000000..b817204 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/fails-coll.json @@ -0,0 +1,47 @@ +[ + { + "v": 1, + "name": "tests", + "folders": [], + "requests": [ + { + "v": "3", + "endpoint": "https://echo.hoppscotch.io/<>", + "name": "", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "none", + "authActive": true + }, + "preRequestScript": "pw.env.set(\"HEADERS_TYPE1\", \"devblin_local1\");", + "testScript": "// Check status code is 200\npwd.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"string\");\n});", + "body": { + "contentType": "application/json", + "body": "{\n\"test\": \"<>\"\n}" + }, + "requestVariables": [] + }, + { + "v": "3", + "endpoint": "https://echo.hoppscotch.dio/<>", + "name": "success", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "none", + "authActive": true + }, + "preRequestScript": "pw.env.setd(\"HEADERS_TYPE2\", \"devblin_local2\");", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(300);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"object\");\n});", + "body": { + "contentType": "application/json", + "body": "{\n\"test\": \"<>\"\n}" + }, + "requestVariables": [] + } + ] + } +] diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/hawk-auth-success-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/hawk-auth-success-coll.json new file mode 100644 index 0000000..fbf755f --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/hawk-auth-success-coll.json @@ -0,0 +1,43 @@ +{ + "v": 3, + "name": "HAWK Auth (success state) - collection", + "folders": [], + "requests": [ + { + "v": "12", + "id": "cm0dm70cw000687bnxi830zz2", + "auth": { + "authType": "hawk", + "authActive": true, + "authId": "<>", + "authKey": "<>", + "algorithm": "<>", + "includePayloadHash": false, + "user": "<>", + "nonce": "<>", + "ext": "<>", + "app": "<>", + "dlg": "<>", + "timestamp": "<>" + }, + "body": { + "body": null, + "contentType": null + }, + "name": "hawk-auth-headers", + "method": "GET", + "params": [], + "headers": [], + "endpoint": "<>", + "testScript": "pw.test(\"Status code is 200\", ()=> { pw.expect(pw.response.status).toBe(200);});", + "preRequestScript": "", + "responses": {}, + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/invalid-mixed-versions-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/invalid-mixed-versions-coll.json new file mode 100644 index 0000000..6b53a4f --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/invalid-mixed-versions-coll.json @@ -0,0 +1,48 @@ +{ + "v": 5, + "id": "cmbgcmyly10yhdjet63hiorps", + "name": "Invalid mixed versions collection", + "folders": [ + { + "v": 8, + "id": "cmbgnb4h100v613phgrz6vku9", + "name": "fold1", + "folders": [], + "requests": [ + { + "v": "13", + "name": "req", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "_ref_id": "coll_mbgnb8zy_eab190e2-6770-428c-8645-f49e88a3fe90" + } + ], + "requests": [], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "_ref_id": "coll_mbgnb8zy_7c3f6eca-bd94-44a5-90e3-3f7c2126d63e" +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/iteration-data-tests-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/iteration-data-tests-coll.json new file mode 100644 index 0000000..d70aa84 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/iteration-data-tests-coll.json @@ -0,0 +1,23 @@ +{ + "v": 1, + "name": "iteration-data-tests-coll", + "folders": [], + "requests": [ + { + "v": "3", + "endpoint": "<>", + "name": "test1", + "params": [], + "headers": [], + "method": "POST", + "auth": { "authType": "none", "authActive": true }, + "preRequestScript": "", + "testScript": "// Iteration data is prioritised over environment variables \n const { data, headers } = pw.response.body;\n pw.expect(headers['host']).toBe('echo.hoppscotch.io')\n // Falls back to environment variables for missing entries in data export\n pw.expect(data).toInclude('overriden-body-key-at-environment')\n pw.expect(data).toInclude('body_value')", + "body": { + "contentType": "application/json", + "body": "{\n \"<>\":\"<>\"\n}" + }, + "requestVariables": [] + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/jsonc-body-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/jsonc-body-coll.json new file mode 100644 index 0000000..247e367 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/jsonc-body-coll.json @@ -0,0 +1,74 @@ +{ + "v": 11, + "name": "JSONC Body Test Collection", + "folders": [], + "requests": [ + { + "v": "17", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "body": "{\n \"key1\": \"value1\", // inline comment\n \"key2\": \"value2\" // another comment\n}", + "contentType": "application/json" + }, + "name": "Echo with inline comments", + "method": "POST", + "params": [], + "headers": [], + "endpoint": "https://echo.hoppscotch.io", + "testScript": "hopp.test('Should successfully parse JSONC with comments', () => {\n hopp.expect(hopp.response.statusCode).toBe(200);\n const data = JSON.parse(hopp.response.body.asJSON().data);\n hopp.expect(data.key1).toBe('value1');\n hopp.expect(data.key2).toBe('value2');\n});", + "preRequestScript": "", + "requestVariables": [], + "responses": {} + }, + { + "v": "17", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "body": "{\n /* Multi-line comment\n should also work */\n \"message\": \"test\",\n \"nested\": {\n \"field\": \"value\" // another comment\n }\n}", + "contentType": "application/json" + }, + "name": "Echo with multiline comments", + "method": "POST", + "params": [], + "headers": [], + "endpoint": "https://echo.hoppscotch.io", + "testScript": "hopp.test('Should successfully parse JSONC with multiline comments', () => {\n hopp.expect(hopp.response.statusCode).toBe(200);\n const data = JSON.parse(hopp.response.body.asJSON().data);\n hopp.expect(data.message).toBe('test');\n hopp.expect(data.nested.field).toBe('value');\n});", + "preRequestScript": "", + "requestVariables": [], + "responses": {} + }, + { + "v": "17", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "body": "{\n \"key\": \"value\",\n \"count\": 42,\n}", + "contentType": "application/json" + }, + "name": "Echo with trailing commas", + "method": "POST", + "params": [], + "headers": [], + "endpoint": "https://echo.hoppscotch.io", + "testScript": "hopp.test('Should successfully parse JSONC with trailing commas', () => {\n hopp.expect(hopp.response.statusCode).toBe(200);\n const data = JSON.parse(hopp.response.body.asJSON().data);\n hopp.expect(data.key).toBe('value');\n hopp.expect(data.count).toBe(42);\n});", + "preRequestScript": "", + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "variables": [], + "description": "" +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/jwt-auth-headers-success-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/jwt-auth-headers-success-coll.json new file mode 100644 index 0000000..38d1571 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/jwt-auth-headers-success-coll.json @@ -0,0 +1,41 @@ +{ + "v": 3, + "name": "JWT Auth (headers) - collection", + "folders": [], + "requests": [ + { + "v": "13", + "id": "cm0dm70cw000687bnxi830zz3", + "auth": { + "authType": "jwt", + "authActive": true, + "addTo": "HEADERS", + "algorithm": "<>", + "secret": "<>", + "privateKey": "<>", + "payload": "<>", + "jwtHeaders": "<>", + "headerPrefix": "<>", + "isSecretBase64Encoded": false + }, + "body": { + "body": null, + "contentType": null + }, + "name": "jwt-auth-headers", + "method": "GET", + "params": [], + "headers": [], + "endpoint": "<>", + "testScript": "pw.test(\"Status code is 200\", ()=> { pw.expect(pw.response.status).toBe(200);});", + "preRequestScript": "", + "responses": {}, + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/jwt-auth-params-success-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/jwt-auth-params-success-coll.json new file mode 100644 index 0000000..25a9a3c --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/jwt-auth-params-success-coll.json @@ -0,0 +1,41 @@ +{ + "v": 3, + "name": "JWT Auth (params) - collection", + "folders": [], + "requests": [ + { + "v": "13", + "id": "cm0dm70cw000687bnxi830zz4", + "auth": { + "authType": "jwt", + "authActive": true, + "addTo": "QUERY_PARAMS", + "algorithm": "<>", + "secret": "<>", + "privateKey": "<>", + "payload": "<>", + "jwtHeaders": "<>", + "paramName": "<>", + "isSecretBase64Encoded": false + }, + "body": { + "body": null, + "contentType": null + }, + "name": "jwt-auth-params", + "method": "GET", + "params": [], + "headers": [], + "endpoint": "<>", + "testScript": "pw.test(\"Status code is 200\", ()=> { pw.expect(pw.response.status).toBe(200);});", + "preRequestScript": "", + "responses": {}, + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/malformed-coll-2.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/malformed-coll-2.json new file mode 100644 index 0000000..8df994d --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/malformed-coll-2.json @@ -0,0 +1,7 @@ +[ + { + "v": 1, + "name": "tests", + "folders": [] + } +] diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/malformed-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/malformed-coll.json new file mode 100644 index 0000000..a4c4672 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/malformed-coll.json @@ -0,0 +1,46 @@ +[ + { + "v": 1, + "folders": [], + "requests": + { + "v": "3", + "endpoint": "https://echo.hoppscotch.io/<>", + "name": "fail", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "none", + "authActive": true + }, + "preRequestScript": "pw.env.set(\"HEADERS_TYPE1\", \"devblin_local1\");", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"string\");\n});", + "body": { + "contentType": "application/json", + "body": "{\n\"test\": \"<>\"\n}" + }, + "requestVariables": [], + }, + { + "v": "3", + "endpoint": "https://echo.hoppscotch.io/<>", + "name": "success", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "none", + "authActive": true + }, + "preRequestScript": "pw.env.set(\"HEADERS_TYPE2\", \"devblin_local2\");", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(300);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"object\");\n});", + "body": { + "contentType": "application/json", + "body": "{\n\"test\": \"<>\"\n}" + }, + "requestVariables": [] + } + ] + } +] diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/multipart-form-data-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/multipart-form-data-coll.json new file mode 100644 index 0000000..37d419d --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/multipart-form-data-coll.json @@ -0,0 +1,55 @@ +{ + "v": 3, + "name": "Multipart form data content type - Collection", + "folders": [], + "requests": [ + { + "v": "7", + "endpoint": "https://echo.hoppscotch.io", + "name": "multipart-form-data-sample-req", + "params": [], + "headers": [], + "method": "POST", + "auth": { + "authType": "none", + "authActive": true, + "addTo": "HEADERS", + "grantTypeInfo": { + "authEndpoint": "test-authorization-endpoint", + "tokenEndpoint": "test-token-endpont", + "clientID": "test-client-id", + "clientSecret": "test-client-secret", + "isPKCE": true, + "codeVerifierMethod": "S256", + "grantType": "AUTHORIZATION_CODE", + "token": "test-token" + } + }, + "preRequestScript": "", + "testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully derives the relevant headers based on the content type\", () => {\n pw.expect(pw.response.body.headers['content-type']).toInclude(\"multipart/form-data\");\n});\n\npw.test(\"Successfully sends the form data in the request body\", () => {\n // Dynamic value\n pw.expect(pw.response.body.data).toBeType(\"string\");\n});", + "body": { + "contentType": "multipart/form-data", + "body": [ + { + "key": "key1", + "value": "value1", + "active": true, + "isFile": false + }, + { + "key": "key2", + "value": [{}], + "active": true, + "isFile": true + } + ] + }, + "requestVariables": [] + } + ], + "auth": { + "authType": "none", + "authActive": true + }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/multiple-child-collections-auth-headers-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/multiple-child-collections-auth-headers-coll.json new file mode 100644 index 0000000..f56863a --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/multiple-child-collections-auth-headers-coll.json @@ -0,0 +1,700 @@ +{ + "v": 6, + "id": "cm9wmuzj46s3imbs891pdamv4", + "name": "Multiple child collections with authorization, headers and variables set at each level", + "folders": [ + { + "v": 6, + "id": "cm9wmuzjp6s3lmbs878f67qr9", + "name": "folder-1", + "folders": [ + { + "v": 6, + "id": "cm9wmuzln6s3umbs8j8lasa3u", + "name": "folder-11", + "folders": [], + "requests": [ + { + "v": "11", + "name": "folder-11-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\")\n \n pw.expect(pw.response.body.headers[\"custom-header\"]).toBe(\"Custom header value overriden at folder-1\")\n pw.expect(pw.response.body.headers[\"inherited-header\"]).toBe(\"Inherited header at all levels\")\n})", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [ + { + "key": "key", + "value": "Set at folder-11", + "active": true, + "description": "" + } + ], + "_ref_id": "coll_m9wn4jl9_c1131ca7-4f49-4c25-b524-c00b0c94a9d5" + }, + { + "v": 6, + "id": "cm9wmuzm66s3xmbs8lzb76mkm", + "name": "folder-12", + "folders": [], + "requests": [ + { + "v": "11", + "name": "folder-12-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Custom-Header", + "value": "Custom header value overriden at folder-12-request", + "active": true, + "description": "" + }, + { + "key": "key", + "value": "Overriden at folder-12-request", + "active": true, + "description": "" + } + ], + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits/overrides authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(undefined)\n \n pw.expect(pw.response.body.headers[\"custom-header\"]).toBe(\"Custom header value overriden at folder-12-request\")\n pw.expect(pw.response.body.headers[\"inherited-header\"]).toBe(\"Inherited header at all levels\")\n pw.expect(pw.response.body.headers[\"key\"]).toBe(\"Overriden at folder-12-request\")\n})", + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "none", + "authActive": true + }, + "headers": [ + { + "key": "Custom-Header", + "value": "Custom header value overriden at folder-12", + "active": true, + "description": "" + }, + { + "key": "key", + "value": "Set at folder-12", + "active": true, + "description": "" + } + ], + "_ref_id": "coll_m9wn4jl9_2bcc342f-8817-4450-8d1e-4786cfad0d83" + }, + { + "v": 6, + "id": "cm9wmuzmp6s40mbs8lspc4162", + "name": "folder-13", + "folders": [], + "requests": [ + { + "v": "11", + "name": "folder-13-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Custom-Header-Request-Level", + "value": "New custom header added at the folder-13-request level", + "active": true, + "description": "" + }, + { + "key": "key", + "value": "Overriden at folder-13-request", + "active": true, + "description": "" + } + ], + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits/overrides authorization/header set at the parent collection level with new header addition\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\")\n \n pw.expect(pw.response.body.headers[\"custom-header\"]).toBe(\"Custom header value overriden at folder-13\")\n pw.expect(pw.response.body.headers[\"inherited-header\"]).toBe(\"Inherited header at all levels\")\n pw.expect(pw.response.body.headers[\"key\"]).toBe(\"Overriden at folder-13-request\")\n pw.expect(pw.response.body.headers[\"custom-header-request-level\"]).toBe(\"New custom header added at the folder-13-request level\")\n})", + "auth": { + "authType": "basic", + "username": "testuser", + "password": "testpass", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "bearer", + "token": "test-token", + "authActive": true + }, + "headers": [ + { + "key": "Custom-Header", + "value": "Custom header value overriden at folder-13", + "active": true, + "description": "" + }, + { + "key": "key", + "value": "Set at folder-13", + "active": true, + "description": "" + } + ], + "_ref_id": "coll_m9wn4jl9_cbab702b-28e6-4ec9-9b13-84f7b154fc1e" + } + ], + "requests": [ + { + "v": "11", + "name": "folder-1-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\")\n \n pw.expect(pw.response.body.headers[\"custom-header\"]).toBe(\"Custom header value overriden at folder-1\")\n pw.expect(pw.response.body.headers[\"inherited-header\"]).toBe(\"Inherited header at all levels\")\n})", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [ + { + "key": "Custom-Header", + "value": "Custom header value overriden at folder-1", + "active": true, + "description": "" + } + ], + "_ref_id": "coll_m9wn4jl9_d82c9136-95cd-4b72-b49d-f6980c153a9b" + }, + { + "v": 6, + "id": "cm9wmuzk96s3ombs80h6zpd5d", + "name": "folder-2", + "folders": [ + { + "v": 6, + "id": "cm9wmuzn96s43mbs8ty5ruesu", + "name": "folder-21", + "folders": [], + "requests": [ + { + "v": "11", + "name": "folder-21-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(undefined)\n \n pw.expect(pw.response.body.headers[\"custom-header\"]).toBe(\"Custom header value overriden at folder-2\")\n pw.expect(pw.response.body.headers[\"inherited-header\"]).toBe(\"Inherited header at all levels\")\n})", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [ + { + "key": "key", + "value": "Set at folder-21", + "active": true, + "description": "" + } + ], + "_ref_id": "coll_m9wn4jl9_a1ae4845-9eab-4963-9ba3-51f8887b8fdd" + }, + { + "v": 6, + "id": "cm9wmuznr6s46mbs8j6598bvx", + "name": "folder-22", + "folders": [], + "requests": [ + { + "v": "11", + "name": "folder-22-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Custom-Header", + "value": "Custom header value overriden at folder-22-request", + "active": true, + "description": "" + }, + { + "key": "key", + "value": "Overriden at folder-22-request", + "active": true, + "description": "" + } + ], + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits/overrides authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(undefined)\n \n pw.expect(pw.response.body.headers[\"custom-header\"]).toBe(\"Custom header value overriden at folder-22-request\")\n pw.expect(pw.response.body.headers[\"inherited-header\"]).toBe(\"Inherited header at all levels\")\n pw.expect(pw.response.body.headers[\"key\"]).toBe(\"Overriden at folder-22-request\")\n})", + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "none", + "authActive": true + }, + "headers": [ + { + "key": "Custom-Header", + "value": "Custom header value overriden at folder-22", + "active": true, + "description": "" + }, + { + "key": "key", + "value": "Set at folder-22", + "active": true, + "description": "" + } + ], + "_ref_id": "coll_m9wn4jl9_2215866f-b5d2-4a18-853a-c40ac9238729" + }, + { + "v": 6, + "id": "cm9wmuzo96s49mbs8ripqlpct", + "name": "folder-23", + "folders": [], + "requests": [ + { + "v": "11", + "name": "folder-23-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Custom-Header-Request-Level", + "value": "New custom header added at the folder-23-request level", + "active": true, + "description": "" + }, + { + "key": "key", + "value": "Overriden at folder-23-request", + "active": true, + "description": "" + } + ], + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits/overrides authorization/header set at the parent collection level with new header addition\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\")\n \n pw.expect(pw.response.body.headers[\"custom-header\"]).toBe(\"Custom header value overriden at folder-23\")\n pw.expect(pw.response.body.headers[\"inherited-header\"]).toBe(\"Inherited header at all levels\")\n pw.expect(pw.response.body.headers[\"key\"]).toBe(\"Overriden at folder-23-request\")\n pw.expect(pw.response.body.headers[\"custom-header-request-level\"]).toBe(\"New custom header added at the folder-23-request level\")\n})", + "auth": { + "authType": "basic", + "username": "testuser", + "password": "testpass", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "bearer", + "token": "test-token", + "authActive": true + }, + "headers": [ + { + "key": "Custom-Header", + "value": "Custom header value overriden at folder-23", + "active": true, + "description": "" + }, + { + "key": "key", + "value": "Set at folder-23", + "active": true, + "description": "" + } + ], + "_ref_id": "coll_m9wn4jl9_11b0fc1e-0ecc-45d2-a7e6-5e3600f2972f" + } + ], + "requests": [ + { + "v": "11", + "name": "folder-2-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Custom-Header", + "value": "Custom header value overriden at folder-2-request", + "active": true, + "description": "" + } + ], + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits/overrides authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(undefined)\n \n pw.expect(pw.response.body.headers[\"custom-header\"]).toBe(\"Custom header value overriden at folder-2-request\")\n pw.expect(pw.response.body.headers[\"inherited-header\"]).toBe(\"Inherited header at all levels\")\n})", + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "none", + "authActive": true + }, + "headers": [ + { + "key": "Custom-Header", + "value": "Custom header value overriden at folder-2", + "active": true, + "description": "" + } + ], + "_ref_id": "coll_m9wn4jl9_02b9a042-e935-4d0e-a30e-720b13ebf77b" + }, + { + "v": 6, + "id": "cm9wmuzl26s3rmbs8mdewgock", + "name": "folder-3", + "folders": [ + { + "v": 6, + "id": "cm9wmuzor6s4cmbs833gdoh57", + "name": "folder-31", + "folders": [], + "requests": [ + { + "v": "11", + "name": "folder-31-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\")\n \n pw.expect(pw.response.body.headers[\"custom-header\"]).toBe(\"Custom header value overriden at folder-3\")\n pw.expect(pw.response.body.headers[\"inherited-header\"]).toBe(\"Inherited header at all levels\")\n})", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [ + { + "key": "key", + "value": "Set at folder-31", + "active": true, + "description": "" + } + ], + "_ref_id": "coll_m9wn4jl9_875254bc-5d26-4235-acfa-e8e93638f828" + }, + { + "v": 6, + "id": "cm9wmuzp96s4fmbs8aj730ukh", + "name": "folder-32", + "folders": [], + "requests": [ + { + "v": "11", + "name": "folder-32-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Custom-Header", + "value": "Custom header value overriden at folder-32-request", + "active": true, + "description": "" + }, + { + "key": "key", + "value": "Overriden at folder-32-request", + "active": true, + "description": "" + } + ], + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits/overrides authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(undefined)\n \n pw.expect(pw.response.body.headers[\"custom-header\"]).toBe(\"Custom header value overriden at folder-32-request\")\n pw.expect(pw.response.body.headers[\"inherited-header\"]).toBe(\"Inherited header at all levels\")\n pw.expect(pw.response.body.headers[\"key\"]).toBe(\"Overriden at folder-32-request\")\n})", + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "none", + "authActive": true + }, + "headers": [ + { + "key": "Custom-Header", + "value": "Custom header value overriden at folder-32", + "active": true, + "description": "" + }, + { + "key": "key", + "value": "Set at folder-32", + "active": true, + "description": "" + } + ], + "_ref_id": "coll_m9wn4jl9_da0095e7-8a92-4187-ac56-d62ac9ea0ba8" + }, + { + "v": 6, + "id": "cm9wmuzpr6s4imbs8a2ludk99", + "name": "folder-33", + "folders": [], + "requests": [ + { + "v": "11", + "name": "folder-33-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Custom-Header-Request-Level", + "value": "New custom header added at the folder-33-request level", + "active": true, + "description": "" + }, + { + "key": "key", + "value": "Overriden at folder-33-request", + "active": true, + "description": "" + } + ], + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits/overrides authorization/header set at the parent collection level with new header addition\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\")\n \n pw.expect(pw.response.body.headers[\"custom-header\"]).toBe(\"Custom header value overriden at folder-33\")\n pw.expect(pw.response.body.headers[\"inherited-header\"]).toBe(\"Inherited header at all levels\")\n pw.expect(pw.response.body.headers[\"key\"]).toBe(\"Overriden at folder-33-request\")\n pw.expect(pw.response.body.headers[\"custom-header-request-level\"]).toBe(\"New custom header added at the folder-33-request level\")\n})", + "auth": { + "authType": "basic", + "username": "testuser", + "password": "testpass", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "bearer", + "token": "test-token", + "authActive": true + }, + "headers": [ + { + "key": "Custom-Header", + "value": "Custom header value overriden at folder-33", + "active": true, + "description": "" + }, + { + "key": "key", + "value": "Set at folder-33", + "active": true, + "description": "" + } + ], + "_ref_id": "coll_m9wn4jl9_992c8305-c7dc-4ec3-a940-3f1fd1ea5d36" + } + ], + "requests": [ + { + "v": "11", + "name": "folder-3-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Custom-Header-Request-Level", + "value": "New custom header added at the folder-3-request level", + "active": true, + "description": "" + }, + { + "key": "key", + "value": "Set at folder-3-request", + "active": true, + "description": "" + } + ], + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits/overrides authorization/header set at the parent collection level with new header addition\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\")\n \n pw.expect(pw.response.body.headers[\"custom-header\"]).toBe(\"Custom header value overriden at folder-3\")\n pw.expect(pw.response.body.headers[\"inherited-header\"]).toBe(\"Inherited header at all levels\")\n pw.expect(pw.response.body.headers[\"key\"]).toBe(\"Set at folder-3-request\")\n pw.expect(pw.response.body.headers[\"custom-header-request-level\"]).toBe(\"New custom header added at the folder-3-request level\")\n})", + "auth": { + "authType": "basic", + "username": "testuser", + "password": "testpass", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "basic", + "username": "testuser", + "password": "testpass", + "authActive": true + }, + "headers": [ + { + "key": "Custom-Header", + "value": "Custom header value overriden at folder-3", + "active": true, + "description": "" + } + ], + "_ref_id": "coll_m9wn4jl9_c3efcd2a-36d9-48d0-824e-ef5a8bdddab9" + } + ], + "requests": [ + { + "v": "11", + "name": "root-collection-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\")\n \n pw.expect(pw.response.body.headers[\"custom-header\"]).toBe(\"Custom header value set at the root collection\")\n pw.expect(pw.response.body.headers[\"inherited-header\"]).toBe(\"Inherited header at all levels\")\n})", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "basic", + "username": "testuser", + "password": "testpass", + "authActive": true + }, + "headers": [ + { + "key": "Custom-Header", + "value": "Custom header value set at the root collection", + "active": true, + "description": "" + }, + { + "key": "Inherited-Header", + "value": "Inherited header at all levels", + "active": true, + "description": "" + } + ], + "variables": [ + { + "key": "collection-variable", + "currentValue": "collection-variable-value", + "initialValue": "collection-variable-value", + "secret": false + } + ], + "_ref_id": "coll_m9wn4jl9_aa8a3bc2-a96f-4cac-86f3-2df4bb355cc8" +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/notjson-coll.txt b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/notjson-coll.txt new file mode 100644 index 0000000..bd97997 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/notjson-coll.txt @@ -0,0 +1,30 @@ +[ + { + "v": 1, + "folders": [], + "requests": + { + "v": "2", + "endpoint": "https://echo.hoppscotch.io/<>", + "name": "fail", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "none", + "authActive": true, + "addTo": "Headers", + "key": "", + "value": "" + }, + "preRequestScript": "pw.env.set(\"HEADERS_TYPE1\", \"devblin_local1\");", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"string\");\n});", + "body": { + "contentType": "application/json", + "body": "{\n\"test\": \"<>\"\n}" + }, + "requestVariables": [] + } + ] + } +] diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/oauth2-auth-code-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/oauth2-auth-code-coll.json new file mode 100644 index 0000000..12c91fb --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/oauth2-auth-code-coll.json @@ -0,0 +1,72 @@ +{ + "v": 3, + "name": "OAuth2 Authorization Code Grant Type - Collection", + "folders": [], + "requests": [ + { + "v": "7", + "endpoint": "https://echo.hoppscotch.io", + "name": "oauth2-auth-code-sample-req-pass-by-headers", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "oauth-2", + "authActive": true, + "addTo": "HEADERS", + "grantTypeInfo": { + "authEndpoint": "test-authorization-endpoint", + "tokenEndpoint": "test-token-endpont", + "clientID": "test-client-id", + "clientSecret": "test-client-secret", + "isPKCE": true, + "codeVerifierMethod": "S256", + "grantType": "AUTHORIZATION_CODE", + "token": "test-token" + } + }, + "preRequestScript": "", + "testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully derives Authorization header from the supplied fields\", ()=> {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBeType(\"string\");\n});", + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [] + }, + { + "v": "7", + "endpoint": "https://echo.hoppscotch.io", + "name": "oauth2-auth-code-sample-req-pass-by-query-params", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "oauth-2", + "authActive": true, + "addTo": "HEADERS", + "grantTypeInfo": { + "authEndpoint": "test-authorization-endpoint", + "tokenEndpoint": "test-token-endpont", + "clientID": "test-client-id", + "clientSecret": "test-client-secret", + "isPKCE": true, + "codeVerifierMethod": "S256", + "grantType": "AUTHORIZATION_CODE", + "token": "test-token" + } + }, + "preRequestScript": "", + "testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully derives Authorization header from the supplied fields\", ()=> {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBeType(\"string\");\n});", + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [] + } + ], + "auth": { + "authType": "none", + "authActive": true + }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/passes-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/passes-coll.json new file mode 100644 index 0000000..e958164 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/passes-coll.json @@ -0,0 +1,47 @@ +[ + { + "v": 1, + "name": "tests", + "folders": [], + "requests": [ + { + "v": "3", + "endpoint": "https://echo.hoppscotch.io/<>", + "name": "", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "none", + "authActive": true + }, + "preRequestScript": "pw.env.set(\"HEADERS_TYPE1\", \"devblin_local1\");", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"object\");\n});", + "body": { + "contentType": "application/json", + "body": "{\n\"test\": \"<>\"\n}" + }, + "requestVariables": [] + }, + { + "v": "3", + "endpoint": "https://echo.hoppscotch.io/<>", + "name": "success", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "none", + "authActive": true + }, + "preRequestScript": "pw.env.set(\"HEADERS_TYPE2\", \"devblin_local2\");", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"object\");\n});", + "body": { + "contentType": "application/json", + "body": "{\n\"test\": \"<>\"\n}" + }, + "requestVariables": [] + } + ] + } +] diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/pre-req-script-env-var-persistence-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/pre-req-script-env-var-persistence-coll.json new file mode 100644 index 0000000..0d625b8 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/pre-req-script-env-var-persistence-coll.json @@ -0,0 +1,22 @@ +{ + "v": 2, + "name": "pre-req-script-env-var-persistence-coll", + "folders": [], + "requests": [ + { + "v": "3", + "auth": { "authType": "none", "authActive": true }, + "body": { "body": null, "contentType": null }, + "name": "sample-req", + "method": "GET", + "params": [], + "headers": [], + "endpoint": "https://echo.hoppscotch.io", + "testScript": "pw.expect(pw.env.get(\"variable\")).toBe(\"value\")", + "preRequestScript": "pw.env.set(\"variable\", \"value\");", + "requestVariables": [] + } + ], + "auth": { "authType": "inherit", "authActive": true }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/req-body-env-vars-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/req-body-env-vars-coll.json new file mode 100644 index 0000000..0ddf3ef --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/req-body-env-vars-coll.json @@ -0,0 +1,31 @@ +{ + "v": 2, + "name": "Test environment variables in request body", + "folders": [], + "requests": [ + { + "v": "3", + "name": "test-request", + "endpoint": "https://echo.hoppscotch.io", + "method": "POST", + "headers": [], + "params": [], + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"firstName\": \"<>\",\n \"lastName\": \"<>\",\n \"greetText\": \"<>, <>\",\n \"fullName\": \"<>\",\n \"id\": \"<>\"\n}" + }, + "preRequestScript": "", + "testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully resolves environments recursively\", ()=> {\n pw.expect(pw.env.getResolve(\"recursiveVarX\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"recursiveVarY\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"salutation\")).toBe(\"Hello\")\n});\n\npw.test(\"Successfully resolves environments referenced in the request body\", () => {\n const expectedId = \"7\"\n const expectedFirstName = \"John\"\n const expectedLastName = \"Doe\"\n const expectedFullName = `${expectedFirstName} ${expectedLastName}`\n const expectedGreetText = `Hello, ${expectedFullName}`\n\n pw.expect(pw.env.getResolve(\"recursiveVarX\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"recursiveVarY\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"salutation\")).toBe(\"Hello\")\n\n const { id, firstName, lastName, fullName, greetText } = JSON.parse(pw.response.body.data)\n\n pw.expect(id).toBe(expectedId)\n pw.expect(expectedFirstName).toBe(firstName)\n pw.expect(expectedLastName).toBe(lastName)\n pw.expect(fullName).toBe(expectedFullName)\n pw.expect(greetText).toBe(expectedGreetText)\n});", + "requestVariables": [] + } + ], + "auth": { + "authType": "none", + "authActive": true + }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/request-vars-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/request-vars-coll.json new file mode 100644 index 0000000..7eff0a5 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/request-vars-coll.json @@ -0,0 +1,188 @@ +{ + "v": 2, + "name": "Request variables", + "folders": [], + "requests": [ + { + "v": "6", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "body": "{\n \"<>\": \"<>\"\n}", + "contentType": "application/json" + }, + "name": "request-variables-basic-usage", + "method": "POST", + "params": [ + { + "key": "<>", + "value": "<>", + "active": true + }, + { + "key": "inactive-query-param-key", + "value": "<>", + "active": true + } + ], + "headers": [ + { + "key": "<>", + "value": "<>", + "active": true + }, + { + "key": "inactive-header-key", + "value": "<>", + "active": true + } + ], + "endpoint": "<>", + "testScript": "pw.test(\"Accounts for active request variables\", ()=> {\n pw.expect(pw.response.body.args[\"query-param-key\"]).toBe(\"query-param-value\");\n\n const data = JSON.parse(pw.response.body.data)\n\n pw.expect(data[\"http-body-raw-key\"]).toBe(\"http-body-raw-value\")\n\n pw.expect(pw.response.body.headers[\"custom-header-key\"]).toBe(\"custom-header-value\");\n});\n\npw.test(\"Ignores inactive request variables\", () => {\n pw.expect(pw.response.body.args[\"inactive-query-param-key\"]).toBe(\"\")\n pw.expect(pw.response.body.args[\"inactive-header-key\"]).toBe(undefined)\n})", + "preRequestScript": "", + "requestVariables": [ + { + "key": "url", + "value": "https://echo.hoppscotch.io", + "active": true + }, + { + "key": "method", + "value": "POST", + "active": true + }, + { + "key": "httpBodyRawKey", + "value": "http-body-raw-key", + "active": true + }, + { + "key": "httpBodyRawValue", + "value": "http-body-raw-value", + "active": true + }, + { + "key": "customHeaderKey", + "value": "custom-header-key", + "active": true + }, + { + "key": "customHeaderValue", + "value": "custom-header-value", + "active": true + }, + { + "key": "queryParamKey", + "value": "query-param-key", + "active": true + }, + { + "key": "queryParamValue", + "value": "query-param-value", + "active": true + }, + { + "key": "inactiveQueryParamValue", + "value": "inactive-query-param-value", + "active": false + }, + { + "key": "inactiveHeaderValue", + "value": "inactive-header-value", + "active": false + } + ] + }, + { + "v": "6", + "auth": { + "authType": "none", + "password": "<>", + "username": "<>", + "authActive": true + }, + "body": { + "body": "{\n \"username\": \"<>\",\n \"password\": \"<>\"\n}", + "contentType": "application/json" + }, + "name": "request-variables-alongside-environment-variables", + "method": "POST", + "params": [ + { + "key": "method", + "value": "<>", + "active": true + } + ], + "headers": [ + { + "key": "test-header-key", + "value": "<>", + "active": true + } + ], + "endpoint": "<>/<>", + "testScript": "pw.test(\"The first occurrence is picked for multiple request variable occurrences with the same key.\", () => {\n pw.expect(pw.response.body.args.method).toBe(\"post\");\n});\n\npw.test(\"Request variables support recursive resolution and pick values from secret environment variables\", () => {\n const { username, password } = JSON.parse(pw.response.body.data)\n\n pw.expect(username).toBe(\"username\")\n pw.expect(password).toBe(\"password\")\n\n})\n\npw.test(\"Resolves request variables that are clubbed together\", () => {\n pw.expect(pw.response.body.path).toBe(\"/username/password\")\n})\n\npw.test(\"Request variables are prioritised over environment variables\", () => {\n pw.expect(pw.response.body.headers.host).toBe(\"echo.hoppscotch.io\")\n})\n\npw.test(\"Environment variable is picked if the request variable under the same name is empty\", () => {\n pw.expect(pw.response.body.headers[\"test-header-key\"]).toBe(\"test-header-value\")\n})", + "preRequestScript": "", + "requestVariables": [ + { + "key": "url", + "value": "https://echo.hoppscotch.io", + "active": true + }, + { + "key": "username", + "value": "<>", + "active": true + }, + { + "key": "recursiveBasicAuthUsernameReqVar", + "value": "<>", + "active": true + }, + { + "key": "password", + "value": "<>", + "active": true + }, + { + "key": "recursiveBasicAuthPasswordReqVar", + "value": "<>", + "active": true + }, + { + "key": "method", + "value": "post", + "active": true + }, + { + "key": "method", + "value": "get", + "active": true + }, + { + "key": "method", + "value": "put", + "active": true + }, + { + "key": "path", + "value": "<>/<>", + "active": true + }, + { + "key": "testHeaderValue", + "value": "", + "active": true + } + ] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/sample-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/sample-coll.json new file mode 100644 index 0000000..b0ca8ce --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/sample-coll.json @@ -0,0 +1,26 @@ +{ + "v": 1, + "name": "tests", + "folders": [], + "requests": [ + { + "v": "2", + "endpoint": "<>", + "name": "", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "none", + "authActive": true + }, + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"object\");\n});", + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [] + } + ] +} \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json new file mode 100644 index 0000000..f236cd7 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json @@ -0,0 +1,1686 @@ +{ + "v": 11, + "name": "scripting-revamp-coll", + "folders": [], + "requests": [ + { + "v": "17", + "id": "cmfhzf0oo0092qt0if5rvd2g4", + "name": "json-response-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Test-Header", + "value": "test", + "active": true, + "description": "test header" + } + ], + "preRequestScript": "", + "testScript": "export {};\nhopp.test(\"`hopp.response.body.asJSON()` parses response body as JSON\", () => {\n const parsedData = JSON.parse(hopp.response.body.asJSON().data)\n\n hopp.expect(parsedData.name).toBe('John Doe')\n hopp.expect(parsedData.age).toBeType(\"number\")\n})\n\npm.test(\"`pm.response.json()` parses response body as JSON\", () => {\n const parsedData = JSON.parse(pm.response.json().data)\n\n pm.expect(parsedData.name).toBe('John Doe')\n pm.expect(parsedData.age).toBeType(\"number\")\n})\n\nhopp.test(\"`hopp.response.body.asText()` parses response body as plain text\", () => {\n const textResponse = hopp.response.body.asText()\n hopp.expect(textResponse).toInclude('\\\"test-header\\\":\\\"test\\\"')\n})\n\npm.test(\"`pm.response.text()` parses response body as plain text\", () => {\n const textResponse = pm.response.text()\n pm.expect(textResponse).toInclude('\\\"test-header\\\":\\\"test\\\"')\n})\n\nhopp.test(\"hopp.response.bytes()` parses response body as raw bytes\", () => {\n const rawResponse = hopp.response.body.bytes()\n\n hopp.expect(rawResponse[0]).toBe(123)\n})\n\npm.test(\"pm.response.stream` parses response body as raw bytes\", () => {\n const rawResponse = pm.response.stream\n\n pm.expect(rawResponse[0]).toBe(123)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"name\": \"John Doe\",\n \"age\": 35\n}" + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op0093qt0ictgoxymy", + "name": "html-response-test", + "method": "GET", + "endpoint": "https://hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Test-Header", + "value": "test", + "active": true, + "description": "Test header" + } + ], + "preRequestScript": "", + "testScript": "hopp.test(\"`hopp.response.asText()` parses response body as plain text\", () => {\n const textResponse = hopp.response.body.asText()\n hopp.expect(textResponse).toInclude(\"Open source API development ecosystem\")\n})\n\npm.test(\"`pm.response.text()` parses response body as plain text\", () => {\n const textResponse = pm.response.text()\n pm.expect(textResponse).toInclude(\"Open source API development ecosystem\")\n})\n\nhopp.test(\"`hopp.response.body.bytes()` parses response body as raw bytes\", () => {\n const rawResponse = hopp.response.body.bytes()\n\n hopp.expect(rawResponse[0]).toBe(60)\n})\n\npm.test(\"`pm.response.stream` parses response body as raw bytes\", () => {\n const rawResponse = pm.response.stream\n\n pm.expect(rawResponse[0]).toBe(60)\n})\n\n\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op0094qt0ixbo9rqnw", + "name": "environment-variables-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "export {};\nhopp.env.set('test_key', 'test_value')\nhopp.env.set('recursive_key', '<>')\nhopp.env.global.set('global_key', 'global_value')\nhopp.env.active.set('active_key', 'active_value')\n\n// `pm` namespace equivalents\npm.variables.set('pm_test_key', 'pm_test_value')\npm.environment.set('pm_active_key', 'pm_active_value')\npm.globals.set('pm_global_key', 'pm_global_value')\n", + "testScript": "\nhopp.test('`hopp.env.get()` retrieves environment variables', () => {\n const value = hopp.env.get('test_key')\n hopp.expect(value).toBe('test_value')\n})\n\npm.test('`pm.variables.get()` retrieves environment variables', () => {\n const value = pm.variables.get('test_key')\n pm.expect(value).toBe('test_value')\n})\n\nhopp.test('`hopp.env.getRaw()` retrieves raw environment variables without resolution', () => {\n const rawValue = hopp.env.getRaw('recursive_key')\n hopp.expect(rawValue).toBe('<>')\n})\n\nhopp.test('`hopp.env.get()` resolves recursive environment variables', () => {\n const resolvedValue = hopp.env.get('recursive_key')\n hopp.expect(resolvedValue).toBe('test_value')\n})\n\npm.test('`pm.variables.replaceIn()` resolves template variables', () => {\n const resolved = pm.variables.replaceIn('Value is {{test_key}}')\n pm.expect(resolved).toBe('Value is test_value')\n})\n\nhopp.test('`hopp.env.global.get()` retrieves global environment variables', () => {\n const globalValue = hopp.env.global.get('global_key')\n\n // `hopp.env.global` would be empty for the CLI\n if (globalValue) {\n hopp.expect(globalValue).toBe('global_value')\n }\n})\n\npm.test('`pm.globals.get()` retrieves global environment variables', () => {\n const globalValue = pm.globals.get('global_key')\n\n // `pm.globals` would be empty for the CLI\n if (globalValue) {\n pm.expect(globalValue).toBe('global_value')\n }\n})\n\nhopp.test('`hopp.env.active.get()` retrieves active environment variables', () => {\n const activeValue = hopp.env.active.get('active_key')\n hopp.expect(activeValue).toBe('active_value')\n})\n\npm.test('`pm.environment.get()` retrieves active environment variables', () => {\n const activeValue = pm.environment.get('active_key')\n pm.expect(activeValue).toBe('active_value')\n})\n\nhopp.test('Environment methods return null for non-existent keys', () => {\n hopp.expect(hopp.env.get('non_existent')).toBe(null)\n hopp.expect(hopp.env.getRaw('non_existent')).toBe(null)\n hopp.expect(hopp.env.global.get('non_existent')).toBe(null)\n hopp.expect(hopp.env.active.get('non_existent')).toBe(null)\n})\n\npm.test('`pm` environment methods handle non-existent keys correctly', () => {\n pm.expect(pm.variables.get('non_existent')).toBe(undefined)\n pm.expect(pm.environment.get('non_existent')).toBe(undefined)\n pm.expect(pm.globals.get('non_existent')).toBe(undefined)\n pm.expect(pm.variables.has('non_existent')).toBe(false)\n pm.expect(pm.environment.has('non_existent')).toBe(false)\n pm.expect(pm.globals.has('non_existent')).toBe(false)\n})\n\npm.test('`pm` variables set in pre-request script are accessible', () => {\n pm.expect(pm.variables.get('pm_test_key')).toBe('pm_test_value')\n pm.expect(pm.environment.get('pm_active_key')).toBe('pm_active_value')\n\n const pmGlobalValue = hopp.env.global.get('pm_global_key')\n\n // `hopp.env.global` would be empty for the CLI\n if (pmGlobalValue) {\n hopp.expect(pmGlobalValue).toBe('pm_global_value')\n }\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op0095qt0ieogkxx1w", + "name": "request-modification-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [ + { + "key": "original_param", + "value": "original-param", + "active": true, + "description": "" + } + ], + "headers": [ + { + "key": "Original-Header", + "value": "original_value", + "active": true, + "description": "" + } + ], + "preRequestScript": "hopp.request.setUrl('https://echo.hoppscotch.io/modified')\nhopp.request.setMethod('POST')\nhopp.request.setHeader('Modified-Header', 'modified_value')\nhopp.request.setParam('new_param', 'new_value')\n\nhopp.request.setBody({\n contentType: 'application/json',\n body: JSON.stringify({ modified: true, timestamp: Date.now() })\n})\n\nhopp.request.setAuth({\n authType: 'bearer',\n token: 'test-bearer-token',\n authActive: true\n})", + "testScript": "\nhopp.test('Request URL was modified by pre-request script', () => {\n hopp.expect(hopp.request.url).toInclude('/modified')\n pm.expect(pm.request.url.toString()).toInclude('/modified')\n})\n\nhopp.test('Request method was modified by pre-request script', () => {\n hopp.expect(hopp.request.method).toBe('POST')\n pm.expect(pm.request.method).toBe('POST')\n})\n\nhopp.test('Request headers contain both original and modified headers', () => {\n const headers = hopp.request.headers\n const hasOriginal = headers.some(h => h.key === 'Original-Header')\n const hasModified = headers.some(h => h.key === 'Modified-Header')\n hopp.expect(hasOriginal).toBe(true)\n hopp.expect(hasModified).toBe(true)\n})\n\npm.test('PM request headers can be accessed and checked', () => {\n pm.expect(pm.request.headers.has('Original-Header')).toBe(true)\n pm.expect(pm.request.headers.has('Modified-Header')).toBe(true)\n pm.expect(pm.request.headers.get('Modified-Header')).toBe('modified_value')\n})\n\nhopp.test('Request parameters contain both original and new parameters', () => {\n const params = hopp.request.params\n const hasOriginal = params.some(p => p.key === 'original_param')\n const hasNew = params.some(p => p.key === 'new_param')\n hopp.expect(hasOriginal).toBe(true)\n hopp.expect(hasNew).toBe(true)\n})\n\nhopp.test('Request body was modified by pre-request script', () => {\n hopp.expect(hopp.request.body.contentType).toBe('application/json')\n pm.expect(pm.request.body.contentType).toBe('application/json')\n const bodyData = hopp.request.body\n\n if (typeof bodyData.body === \"string\") {\n hopp.expect(JSON.parse(bodyData.body).modified).toBe(true)\n pm.expect(JSON.parse(bodyData.body).modified).toBe(true)\n } else {\n throw new Error(`Unexpected body type: ${bodyData.body}`)\n }\n})\n\n\nhopp.test('Request auth was modified by pre-request script', () => {\n const auth = hopp.request.auth\n\n if (auth.authType === 'bearer') {\n hopp.expect(auth.token).toBe('test-bearer-token')\n pm.expect(auth.token).toBe('test-bearer-token')\n } else {\n throw new Error(`Unexpected auth type: ${auth.authType}`)\n }\n\n hopp.expect(auth.token).toBe('test-bearer-token')\n pm.expect(auth.token).toBe('test-bearer-token')\n})\n\n", + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op0096qt0i6wellfus", + "name": "response-parsing-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Content-Type", + "value": "application/json", + "active": true, + "description": "" + } + ], + "preRequestScript": "", + "testScript": "\nhopp.test('`hopp.response.statusCode` returns the response status code', () => {\n hopp.expect(hopp.response.statusCode).toBe(200)\n})\n\npm.test('`pm.response.code` returns the response status code', () => {\n pm.expect(pm.response.code).toBe(200)\n})\n\nhopp.test('`hopp.response.statusText` returns the response status text', () => {\n hopp.expect(hopp.response.statusText).toBeType('string')\n})\n\npm.test('`pm.response.status` returns the response status text', () => {\n pm.expect(pm.response.status).toBeType('string')\n})\n\nhopp.test('`hopp.response.headers` contains response headers', () => {\n const { headers } = hopp.response\n\n hopp.expect(headers).toBeType('object')\n hopp.expect(headers.length > 0).toBe(true)\n})\n\npm.test('`pm.response.headers` contains response headers', () => {\n const headersAll = pm.response.headers.all()\n pm.expect(headersAll).toBeType('object')\n pm.expect(Object.keys(headersAll).length > 0).toBe(true)\n})\n\nhopp.test('`hopp.response.responseTime` is a positive number', () => {\n hopp.expect(hopp.response.responseTime).toBeType('number')\n hopp.expect(hopp.response.responseTime > 0).toBe(true)\n})\n\npm.test('`pm.response.responseTime` is a positive number', () => {\n pm.expect(pm.response.responseTime).toBeType('number')\n pm.expect(pm.response.responseTime > 0).toBe(true)\n})\n\nhopp.test('`hopp.response.text()` returns response as text', () => {\n const responseText = hopp.response.body.asText()\n hopp.expect(responseText).toBeType('string')\n hopp.expect(responseText.length > 0).toBe(true)\n})\n\npm.test('`pm.response.text()` returns response as text', () => {\n const responseText = pm.response.text()\n pm.expect(responseText).toBeType('string')\n pm.expect(responseText.length > 0).toBe(true)\n})\n\nhopp.test('`hopp.response.json()` parses JSON response', () => {\n const responseJSON = hopp.response.body.asJSON()\n hopp.expect(responseJSON).toBeType('object')\n})\n\npm.test('`pm.response.json()` parses JSON response', () => {\n const responseJSON = pm.response.json()\n pm.expect(responseJSON).toBeType('object')\n})\n\n\nhopp.test('`hopp.response.bytes()` returns the raw response', () => {\n const responseBuffer = hopp.response.body.bytes()\n hopp.expect(responseBuffer).toBeType('object')\n hopp.expect(responseBuffer.constructor.name).toBe('Object')\n})\n\npm.test('`pm.response.stream` returns the raw response', () => {\n const responseBuffer = pm.response.stream\n pm.expect(responseBuffer).toBeType('object')\n pm.expect(responseBuffer.constructor.name).toBe('Object')\n})", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"test\": \"response parsing\",\n \"timestamp\": \"{{$timestamp}}\",\n \"data\": {\n \"nested\": true,\n \"value\": 42\n }\n}" + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op0097qt0ia4wf0lej", + "name": "request-variables-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "// Test request variables\nhopp.request.variables.set('dynamic_var', 'dynamic_value')\nhopp.request.variables.set('calculated_var', `timestamp_${Date.now()}`)", + "testScript": "\nhopp.test('`hopp.request.variables.get()` retrieves request variables', () => {\n const dynamicValue = hopp.request.variables.get('dynamic_var')\n hopp.expect(dynamicValue).toBe('dynamic_value')\n})\n\nhopp.test('Request variables can store calculated values', () => {\n const calculatedValue = hopp.request.variables.get('calculated_var')\n hopp.expect(calculatedValue).toInclude('timestamp_')\n})\n\nhopp.test('Request variables return null for non-existent keys', () => {\n const nonExistent = hopp.request.variables.get('non_existent_var')\n hopp.expect(nonExistent).toBe(null)\n})\n\nhopp.test('Pre-defined request variables are accessible', () => {\n const preDefinedVar = hopp.request.variables.get('req_var_1')\n hopp.expect(preDefinedVar).toBe('request_variable_value')\n})", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [ + { + "key": "req_var_1", + "value": "request_variable_value", + "active": true + }, + { + "key": "dynamic_var", + "value": "dynamic_value", + "active": true + }, + { + "key": "calculated_var", + "value": "timestamp_1757751657020", + "active": true + } + ], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op0098qt0ii9fguj6e", + "name": "info-context-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "\npm.test('`pm.info.eventName` indicates the script context', () => {\n pm.expect(pm.info.eventName).toBe('test')\n})\n\npm.test('`pm.info.requestName` returns the request name', () => {\n pm.expect(pm.info.requestName).toBe('info-context-test')\n})\n\npm.test('`pm.info.requestId` returns an optional request identifier', () => {\n const requestId = pm.info.requestId\n if (requestId) {\n pm.expect(requestId).toBeType('string')\n pm.expect(requestId?.length > 0).toBe(true)\n } else {\n pm.expect(requestId).toBe(undefined)\n }\n})", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op0099qt0iamthw97r", + "name": "pm-namespace-additional-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "// Test `pm` namespace specific features\npm.environment.set('pm_pre_key', 'pm_pre_value')\npm.globals.set('pm_global_pre', 'pm_global_pre_value')\npm.variables.set('pm_var_pre', 'pm_var_pre_value')\n", + "testScript": "\npm.test('`pm` namespace environment operations work correctly', () => {\n // Test environment has() method\n pm.expect(pm.environment.has('pm_pre_key')).toBe(true)\n pm.expect(pm.environment.has('non_existent_key')).toBe(false)\n \n // Test globals has() method\n const globalValue = pm.globals.has('pm_global_pre')\n // `pm.globals` would be empty for the CLI\n if (globalValue) {\n pm.expect(pm.globals.has('pm_global_pre')).toBe(true)\n }\n \n pm.expect(pm.globals.has('non_existent_global')).toBe(false)\n \n // Test variables has() method\n pm.expect(pm.variables.has('pm_var_pre')).toBe(true)\n pm.expect(pm.variables.has('non_existent_var')).toBe(false)\n})\n\npm.test('`pm` variables.replaceIn() handles template replacement', () => {\n const template = 'Hello {{pm_pre_key}}, global: {{pm_global_pre}}'\n const resolved = pm.variables.replaceIn(template)\n pm.expect(resolved).toInclude('pm_pre_value')\n pm.expect(resolved).toInclude('pm_global_pre_value')\n})\n\npm.test('`pm` request object provides URL as object with toString', () => {\n const url = pm.request.url\n pm.expect(url.toString()).toBeType('string')\n pm.expect(url.toString()).toInclude('echo.hoppscotch.io')\n})\n\npm.test('`pm` request headers object methods work correctly', () => {\n // Test headers.all() returns object\n const allHeaders = pm.request.headers.all()\n pm.expect(allHeaders).toBeType('object')\n \n // Test headers.has() and headers.get() methods\n if (Object.keys(allHeaders).length > 0) {\n const firstHeaderKey = Object.keys(allHeaders)[0]\n pm.expect(pm.request.headers.has(firstHeaderKey)).toBe(true)\n pm.expect(pm.request.headers.get(firstHeaderKey)).toBeType('string')\n }\n \n // Test non-existent header\n pm.expect(pm.request.headers.has('non-existent-header')).toBe(false)\n pm.expect(pm.request.headers.get('non-existent-header')).toBe(null)\n})\n\npm.test('`pm` response headers work correctly', () => {\n // Test response headers all() method\n const allResponseHeaders = pm.response.headers.all()\n pm.expect(allResponseHeaders).toBeType('object')\n \n // Test headers has() and get() for common headers\n if (Object.keys(allResponseHeaders).length > 0) {\n const firstKey = Object.keys(allResponseHeaders)[0]\n pm.expect(pm.response.headers.has(firstKey)).toBe(true)\n pm.expect(pm.response.headers.get(firstKey)).toBeType('string')\n }\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op009aqt0inw3j6dq9", + "name": "expectation-methods-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "\nhopp.test('Basic equality expectations work correctly', () => {\n hopp.expect(1).toBe(1)\n hopp.expect('test').toBe('test')\n hopp.expect(true).toBe(true)\n hopp.expect(null).toBe(null)\n})\n\npm.test('`pm` basic equality expectations work correctly', () => {\n pm.expect(1).toBe(1)\n pm.expect('test').toBe('test')\n pm.expect(true).toBe(true)\n pm.expect(null).toBe(null)\n})\n\nhopp.test('Type checking expectations work correctly', () => {\n hopp.expect(42).toBeType('number')\n hopp.expect('hello').toBeType('string')\n hopp.expect(true).toBeType('boolean')\n hopp.expect({}).toBeType('object')\n hopp.expect([]).toBeType('object')\n})\n\npm.test('`pm` type checking expectations work correctly', () => {\n pm.expect(42).toBeType('number')\n pm.expect('hello').toBeType('string')\n pm.expect(true).toBeType('boolean')\n pm.expect({}).toBeType('object')\n pm.expect([]).toBeType('object')\n})\n\n\nhopp.test('String and array inclusion expectations work correctly', () => {\n hopp.expect('hello world').toInclude('world')\n hopp.expect([1, 2, 3]).toInclude(2)\n})\n\npm.test('`pm` string and array inclusion expectations work correctly', () => {\n pm.expect('hello world').toInclude('world')\n pm.expect([1, 2, 3]).toInclude(2)\n})\n\n\nhopp.test('Length expectations work correctly', () => {\n hopp.expect('hello').toHaveLength(5)\n hopp.expect([1, 2, 3]).toHaveLength(3)\n})\n\npm.test('`pm` length expectations work correctly', () => {\n pm.expect('hello').toHaveLength(5)\n pm.expect([1, 2, 3]).toHaveLength(3)\n})\n\nhopp.test('Response-based expectations work correctly', () => {\n const responseData = hopp.response.body.asJSON()\n hopp.expect(responseData).toBeType('object')\n hopp.expect(hopp.response.statusCode).toBe(200)\n})\n\npm.test('`pm` response-based expectations work correctly', () => {\n const responseData = pm.response.json()\n pm.expect(responseData).toBeType('object')\n pm.expect(pm.response.code).toBe(200)\n})", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"message\": \"Test expectation methods\",\n \"numbers\": [1, 2, 3, 4, 5],\n \"metadata\": {\n \"timestamp\": \"{{$timestamp}}\",\n \"test\": true\n }\n}" + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00chai1qt0inext01", + "name": "chai-assertions-hopp-extended", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "\n// EQUALITY ASSERTIONS\nhopp.test('Chai equality - equal() method', () => {\n hopp.expect(5).to.equal(5)\n hopp.expect('hello').to.equal('hello')\n hopp.expect(true).to.equal(true)\n})\n\nhopp.test('Chai equality - eql() for deep equality', () => {\n hopp.expect({ a: 1 }).to.eql({ a: 1 })\n hopp.expect([1, 2, 3]).to.eql([1, 2, 3])\n})\n\nhopp.test('Chai equality - negation with .not', () => {\n hopp.expect(5).to.not.equal(10)\n hopp.expect('hello').to.not.equal('world')\n})\n\n// TYPE ASSERTIONS\nhopp.test('Chai type - .a() and .an() assertions', () => {\n hopp.expect('test').to.be.a('string')\n hopp.expect(42).to.be.a('number')\n hopp.expect([]).to.be.an('array')\n hopp.expect({}).to.be.an('object')\n})\n\nhopp.test('Chai type - instanceof assertions', () => {\n hopp.expect([1, 2, 3]).to.be.instanceof(Array)\n hopp.expect(new Date()).to.be.instanceof(Date)\n hopp.expect(new Error('test')).to.be.instanceof(Error)\n})\n\n// TRUTHINESS ASSERTIONS\nhopp.test('Chai truthiness - .true, .false, .null, .undefined', () => {\n hopp.expect(true).to.be.true\n hopp.expect(false).to.be.false\n hopp.expect(null).to.be.null\n hopp.expect(undefined).to.be.undefined\n})\n\nhopp.test('Chai truthiness - .ok and .exist', () => {\n hopp.expect(1).to.be.ok\n hopp.expect('string').to.exist\n hopp.expect(0).to.not.be.ok\n})\n\nhopp.test('Chai truthiness - .NaN assertion', () => {\n hopp.expect(NaN).to.be.NaN\n hopp.expect(42).to.not.be.NaN\n})\n\n// NUMERICAL COMPARISONS\nhopp.test('Chai numbers - .above() and .below()', () => {\n hopp.expect(10).to.be.above(5)\n hopp.expect(5).to.be.below(10)\n hopp.expect(5).to.not.be.above(10)\n})\n\nhopp.test('Chai numbers - aliases gt, lt, gte, lte', () => {\n hopp.expect(10).to.be.gt(5)\n hopp.expect(5).to.be.lt(10)\n hopp.expect(5).to.be.gte(5)\n hopp.expect(5).to.be.lte(5)\n})\n\nhopp.test('Chai numbers - .least() and .most()', () => {\n hopp.expect(10).to.be.at.least(10)\n hopp.expect(10).to.be.at.most(10)\n hopp.expect(15).to.be.at.least(10)\n})\n\nhopp.test('Chai numbers - .within() range', () => {\n hopp.expect(7).to.be.within(5, 10)\n hopp.expect(5).to.be.within(5, 10)\n hopp.expect(10).to.be.within(5, 10)\n})\n\nhopp.test('Chai numbers - .closeTo() with delta', () => {\n hopp.expect(10).to.be.closeTo(10.5, 0.6)\n hopp.expect(9.99).to.be.closeTo(10, 0.1)\n})\n\n// PROPERTY ASSERTIONS\nhopp.test('Chai properties - .property() checks', () => {\n const obj = { name: 'test', nested: { value: 42 } }\n hopp.expect(obj).to.have.property('name')\n hopp.expect(obj).to.have.property('name', 'test')\n hopp.expect(obj).to.have.nested.property('nested.value', 42)\n})\n\nhopp.test('Chai properties - .ownProperty() checks', () => {\n const obj = { own: 'value' }\n hopp.expect(obj).to.have.ownProperty('own')\n hopp.expect(obj).to.not.have.ownProperty('toString')\n})\n\n// LENGTH ASSERTIONS\nhopp.test('Chai length - .lengthOf() for arrays and strings', () => {\n hopp.expect([1, 2, 3]).to.have.lengthOf(3)\n hopp.expect('hello').to.have.lengthOf(5)\n hopp.expect([]).to.have.lengthOf(0)\n})\n\n// COLLECTION ASSERTIONS\nhopp.test('Chai collections - .keys() assertions', () => {\n const obj = { a: 1, b: 2, c: 3 }\n hopp.expect(obj).to.have.keys('a', 'b', 'c')\n hopp.expect(obj).to.have.all.keys('a', 'b', 'c')\n hopp.expect(obj).to.have.any.keys('a', 'd')\n})\n\nhopp.test('Chai collections - .members() for arrays', () => {\n hopp.expect([1, 2, 3]).to.have.members([3, 2, 1])\n hopp.expect([1, 2, 3]).to.include.members([1, 2])\n})\n\nhopp.test('Chai collections - .deep.members() for object arrays', () => {\n hopp.expect([{ a: 1 }, { b: 2 }]).to.have.deep.members([{ b: 2 }, { a: 1 }])\n})\n\nhopp.test('Chai collections - .oneOf() checks', () => {\n hopp.expect(2).to.be.oneOf([1, 2, 3])\n hopp.expect('a').to.be.oneOf(['a', 'b', 'c'])\n})\n\n// INCLUSION ASSERTIONS\nhopp.test('Chai inclusion - .include() for arrays and strings', () => {\n hopp.expect([1, 2, 3]).to.include(2)\n hopp.expect('hello world').to.include('world')\n})\n\nhopp.test('Chai inclusion - .deep.include() for objects', () => {\n hopp.expect([{ a: 1 }, { b: 2 }]).to.deep.include({ a: 1 })\n})\n\n// FUNCTION/ERROR ASSERTIONS\nhopp.test('Chai functions - .throw() assertions', () => {\n const throwFn = () => { throw new Error('test error') }\n const noThrow = () => { return 42 }\n \n hopp.expect(throwFn).to.throw()\n hopp.expect(throwFn).to.throw(Error)\n hopp.expect(throwFn).to.throw('test error')\n hopp.expect(noThrow).to.not.throw()\n})\n\nhopp.test('Chai functions - .respondTo() method checks', () => {\n const obj = { method: function() {} }\n hopp.expect(obj).to.respondTo('method')\n hopp.expect([]).to.respondTo('push')\n})\n\nhopp.test('Chai functions - .satisfy() custom matcher', () => {\n hopp.expect(10).to.satisfy((num) => num > 5)\n hopp.expect('hello').to.satisfy((str) => str.length === 5)\n})\n\n// OBJECT STATE ASSERTIONS\nhopp.test('Chai object state - .sealed, .frozen, .extensible', () => {\n const sealed = Object.seal({ a: 1 })\n const frozen = Object.freeze({ b: 2 })\n const extensible = { c: 3 }\n \n hopp.expect(sealed).to.be.sealed\n hopp.expect(frozen).to.be.frozen\n hopp.expect(extensible).to.be.extensible\n})\n\nhopp.test('Chai number state - .finite', () => {\n hopp.expect(42).to.be.finite\n hopp.expect(Infinity).to.not.be.finite\n})\n\n// EXOTIC OBJECTS\nhopp.test('Chai exotic - Set assertions', () => {\n const mySet = new Set([1, 2, 3])\n hopp.expect(mySet).to.be.instanceof(Set)\n hopp.expect(mySet).to.have.lengthOf(3)\n})\n\nhopp.test('Chai exotic - Map assertions', () => {\n const myMap = new Map([['key', 'value']])\n hopp.expect(myMap).to.be.instanceof(Map)\n hopp.expect(myMap).to.have.lengthOf(1)\n})\n\n// SIDE-EFFECT ASSERTIONS\nhopp.test('Chai side-effects - .change() assertions', () => {\n const obj = { count: 0 }\n const changeFn = () => { obj.count = 5 }\n hopp.expect(changeFn).to.change(obj, 'count')\n \n const noChangeFn = () => {} \n hopp.expect(noChangeFn).to.not.change(obj, 'count')\n})\n\nhopp.test('Chai side-effects - .change().by() delta', () => {\n const obj = { count: 10 }\n const addFive = () => { obj.count += 5 }\n hopp.expect(addFive).to.change(obj, 'count').by(5)\n})\n\nhopp.test('Chai side-effects - .increase() assertions', () => {\n const obj = { count: 0 }\n const incFn = () => { obj.count++ }\n hopp.expect(incFn).to.increase(obj, 'count')\n})\n\nhopp.test('Chai side-effects - .decrease() assertions', () => {\n const obj = { count: 10 }\n const decFn = () => { obj.count-- }\n hopp.expect(decFn).to.decrease(obj, 'count')\n})\n\n// LANGUAGE CHAINS AND MODIFIERS\nhopp.test('Chai chains - Complex chaining with multiple modifiers', () => {\n hopp.expect([1, 2, 3]).to.be.an('array').that.includes(2)\n hopp.expect({ a: 1, b: 2 }).to.be.an('object').that.has.property('a')\n})\n\nhopp.test('Chai modifiers - .deep with .equal()', () => {\n hopp.expect({ a: { b: 1 } }).to.deep.equal({ a: { b: 1 } })\n hopp.expect([{ a: 1 }]).to.deep.equal([{ a: 1 }])\n})\n\n// RESPONSE-BASED TESTS\nhopp.test('Chai with response - status code checks', () => {\n hopp.expect(hopp.response.statusCode).to.equal(200)\n hopp.expect(hopp.response.statusCode).to.be.within(200, 299)\n})\n\nhopp.test('Chai with response - body parsing', () => {\n const response = hopp.response.body.asJSON()\n hopp.expect(response).to.be.an('object')\n hopp.expect(response).to.have.property('data')\n \n const body = JSON.parse(response.data)\n hopp.expect(body).to.have.property('testData')\n hopp.expect(body.testData).to.have.property('number', 42)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"testData\": {\n \"number\": 42,\n \"string\": \"hello world\",\n \"array\": [1, 2, 3, 4, 5],\n \"object\": { \"nested\": { \"value\": true } },\n \"bool\": true,\n \"nullValue\": null\n }\n}" + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00chai2qt0inext02", + "name": "chai-assertions-pm-parity", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "\npm.test('PM Chai - equality assertions', () => {\n pm.expect(5).to.equal(5)\n pm.expect('test').to.not.equal('other')\n pm.expect({ a: 1 }).to.eql({ a: 1 })\n})\n\npm.test('PM Chai - type assertions', () => {\n pm.expect('string').to.be.a('string')\n pm.expect(42).to.be.a('number')\n pm.expect([]).to.be.an('array')\n})\n\npm.test('PM Chai - truthiness assertions', () => {\n pm.expect(true).to.be.true\n pm.expect(false).to.be.false\n pm.expect(null).to.be.null\n})\n\npm.test('PM Chai - numerical comparisons', () => {\n pm.expect(10).to.be.above(5)\n pm.expect(5).to.be.below(10)\n pm.expect(7).to.be.within(5, 10)\n})\n\npm.test('PM Chai - property and length assertions', () => {\n const obj = { name: 'test' }\n pm.expect(obj).to.have.property('name')\n pm.expect([1, 2, 3]).to.have.lengthOf(3)\n pm.expect('hello').to.have.lengthOf(5)\n})\n\npm.test('PM Chai - string and collection assertions', () => {\n pm.expect('hello world').to.include('world')\n pm.expect([1, 2, 3]).to.include(2)\n pm.expect({ a: 1, b: 2 }).to.have.keys('a', 'b')\n})\n\npm.test('PM Chai - function assertions', () => {\n const throwFn = () => { throw new Error('test') }\n pm.expect(throwFn).to.throw()\n pm.expect([]).to.respondTo('push')\n})\n\npm.test('PM Chai - response validation', () => {\n pm.expect(pm.response.code).to.equal(200)\n pm.expect(pm.response.responseTime).to.be.a('number')\n \n const response = pm.response.json()\n pm.expect(response).to.have.property('data')\n \n const body = JSON.parse(response.data)\n pm.expect(body).to.have.property('pmTest')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"pmTest\": {\n \"value\": 42,\n \"text\": \"postman compatible\"\n }\n}" + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00cookies01", + "name": "cookie-assertions-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Cookie", + "value": "session_id=abc123; user_token=xyz789; preferences=theme%3Ddark", + "active": true, + "description": "Test cookies" + } + ], + "preRequestScript": "", + "testScript": "\n// NOTE: Full cookie behavior with Set-Cookie headers is tested in js-sandbox unit tests\n// (see packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/cookies.spec.ts)\n// These CLI E2E tests verify API contracts and integration behavior\n\npm.test('pm.response.cookies API contract - all methods exist', () => {\n pm.expect(pm.response.cookies).to.be.an('object')\n pm.expect(typeof pm.response.cookies.get).to.equal('function')\n pm.expect(typeof pm.response.cookies.has).to.equal('function')\n pm.expect(typeof pm.response.cookies.toObject).to.equal('function')\n})\n\npm.test('pm.response.cookies.toObject() returns proper structure', () => {\n const allCookies = pm.response.cookies.toObject()\n pm.expect(allCookies).to.be.an('object')\n pm.expect(typeof allCookies).to.equal('object')\n})\n\npm.test('pm.response.cookies.has() returns boolean for cookie checks', () => {\n const hasCookie = pm.response.cookies.has('test_cookie_name')\n pm.expect(hasCookie).to.be.a('boolean')\n})\n\npm.test('pm.response.cookies.get() returns null for non-existent cookies', () => {\n const cookieValue = pm.response.cookies.get('non_existent_cookie_xyz')\n pm.expect(cookieValue).to.be.null\n})\n\npm.test('pm.response.cookies API integrates with response object', () => {\n pm.expect(pm.response.code).to.equal(200)\n \n // Verify cookies object is accessible from response\n pm.expect(pm.response).to.have.property('cookies')\n pm.expect(pm.response.cookies).to.not.be.null\n pm.expect(pm.response.cookies).to.not.be.undefined\n})\n\npm.test('Request cookies are properly sent via Cookie header', () => {\n const hasCookieHeader = pm.request.headers.has('Cookie')\n \n if (hasCookieHeader) {\n const cookieHeader = pm.request.headers.get('Cookie')\n pm.expect(cookieHeader).to.be.a('string')\n pm.expect(cookieHeader).to.include('session_id')\n pm.expect(cookieHeader).to.include('user_token')\n }\n})\n\npm.test('pm.response.to.have.cookie() assertion method exists', () => {\n // Verify the cookie assertion is defined in the type system\n pm.expect(typeof pm.response.to.have.cookie).to.equal('function')\n})\n\nhopp.test('hopp.cookies API contract matches pm.response.cookies', () => {\n hopp.expect(typeof hopp.cookies).toBe('object')\n hopp.expect(typeof hopp.cookies.get).toBe('function')\n hopp.expect(typeof hopp.cookies.has).toBe('function')\n hopp.expect(typeof hopp.cookies.getAll).toBe('function')\n hopp.expect(typeof hopp.cookies.set).toBe('function')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00schema01", + "name": "json-schema-validation-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "\npm.test('pm.response.to.have.jsonSchema() validates response structure', () => {\n const schema = {\n type: 'object',\n required: ['data'],\n properties: {\n data: { type: 'string' },\n headers: { type: 'object' }\n }\n }\n \n pm.response.to.have.jsonSchema(schema)\n \n // Explicit assertions to ensure schema validation passed\n const json = pm.response.json()\n pm.expect(json).to.have.property('data')\n pm.expect(json.data).to.be.a('string')\n})\n\npm.test('JSON Schema validation with nested properties', () => {\n const response = pm.response.json()\n const body = JSON.parse(response.data)\n \n const userSchema = {\n type: 'object',\n required: ['name', 'age'],\n properties: {\n name: { type: 'string' },\n age: { type: 'number', minimum: 0, maximum: 150 },\n email: { type: 'string' }\n }\n }\n \n pm.expect(body).to.have.jsonSchema(userSchema)\n \n // Explicit assertions to ensure schema validation passed\n pm.expect(body).to.have.property('name')\n pm.expect(body).to.have.property('age')\n pm.expect(body.name).to.equal('Alice Smith')\n pm.expect(body.age).to.equal(28)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"name\": \"Alice Smith\",\n \"age\": 28,\n \"email\": \"alice@example.com\"\n}" + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00charset01", + "name": "charset-validation-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "\n// NOTE: Full charset behavior with actual charset values is tested in js-sandbox unit tests\n// (see packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/advanced-assertions.spec.ts)\n// These CLI E2E tests verify API contracts and header parsing behavior\n\npm.test('pm.expect().to.have.charset() assertion API contract exists', () => {\n const testString = 'test'\n pm.expect(typeof pm.expect(testString).to.have.charset).to.equal('function')\n})\n\npm.test('pm.response.to.have.charset() assertion API contract exists', () => {\n pm.expect(typeof pm.response.to.have.charset).to.equal('function')\n})\n\npm.test('Response Content-Type header is accessible and parseable', () => {\n const contentType = pm.response.headers.get('content-type')\n pm.expect(contentType).to.be.a('string')\n pm.expect(contentType.length).to.be.above(0)\n pm.expect(contentType).to.include('application/')\n})\n\npm.test('Content-Type header parsing logic validates structure', () => {\n const contentType = pm.response.headers.get('content-type')\n \n // Test charset detection logic\n const hasCharset = contentType.includes('charset=')\n pm.expect(typeof hasCharset).to.equal('boolean')\n \n // Test charset extraction pattern\n const charsetMatch = contentType.match(/charset=([^;\\s]+)/i)\n if (hasCharset) {\n pm.expect(charsetMatch).to.be.an('array')\n pm.expect(charsetMatch[1]).to.be.a('string')\n } else {\n pm.expect(charsetMatch).to.be.null\n }\n})\n\npm.test('Charset handling works with or without explicit charset', () => {\n const contentType = pm.response.headers.get('content-type')\n const hasExplicitCharset = contentType.toLowerCase().includes('charset=')\n \n // Whether charset is present or not, response decoding should work\n const responseText = pm.response.text()\n pm.expect(responseText).to.be.a('string')\n pm.expect(responseText.length).to.be.above(0)\n})\n\npm.test('Response text decoding works with UTF-8 default', () => {\n const responseText = pm.response.text()\n pm.expect(responseText).to.be.a('string')\n \n // Verify JSON parsing works (implies correct encoding)\n const responseJson = pm.response.json()\n pm.expect(responseJson).to.be.an('object')\n pm.expect(responseJson).to.have.property('data')\n})\n\npm.test('Response headers integrate correctly with charset assertions', () => {\n const allHeaders = pm.response.headers.all()\n pm.expect(allHeaders).to.be.an('object')\n pm.expect(Object.keys(allHeaders).length).to.be.above(0)\n})\n\nhopp.test('hopp namespace handles response encoding with proper defaults', () => {\n const textResponse = hopp.response.body.asText()\n hopp.expect(textResponse).toBeType('string')\n hopp.expect(textResponse.length > 0).toBe(true)\n \n // Verify JSON parsing works with default encoding\n const jsonResponse = hopp.response.body.asJSON()\n hopp.expect(jsonResponse).toBeType('object')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00jsonpath01", + "name": "jsonpath-query-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "\npm.test('pm.response.to.have.jsonPath() queries nested JSON data', () => {\n const response = pm.response.json()\n const body = JSON.parse(response.data)\n \n pm.expect(body).to.have.jsonPath('$.users[0].name')\n pm.expect(body).to.have.jsonPath('$.users[*].active')\n pm.expect(body).to.have.jsonPath('$.metadata.version')\n})\n\npm.test('JSONPath with value validation', () => {\n const response = pm.response.json()\n const body = JSON.parse(response.data)\n \n pm.expect(body).to.have.jsonPath('$.users[0].name', 'John')\n pm.expect(body).to.have.jsonPath('$.metadata.version', '1.0')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"users\": [\n { \"name\": \"John\", \"active\": true },\n { \"name\": \"Jane\", \"active\": false }\n ],\n \"metadata\": {\n \"version\": \"1.0\",\n \"timestamp\": \"2025-01-15\"\n }\n}" + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00envext01", + "name": "environment-extensions-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "pm.environment.set('template_var', 'world')\npm.environment.set('user_id', '12345')\npm.globals.set('api_base', 'https://api.example.com')\npm.globals.set('version', 'v2')\n", + "testScript": "\npm.test('pm.environment.name returns environment identifier', () => {\n pm.expect(pm.environment.name).to.be.a('string')\n pm.expect(pm.environment.name).to.equal('active')\n})\n\npm.test('pm.environment.replaceIn() resolves template variables', () => {\n const template = 'Hello {{template_var}}, user {{user_id}}!'\n const resolved = pm.environment.replaceIn(template)\n pm.expect(resolved).to.equal('Hello world, user 12345!')\n})\n\npm.test('pm.globals.replaceIn() resolves global template variables', () => {\n const template = '{{api_base}}/{{version}}/users'\n const resolved = pm.globals.replaceIn(template)\n pm.expect(resolved).to.equal('https://api.example.com/v2/users')\n})\n\npm.test('pm.environment.toObject() returns all environment variables', () => {\n const allVars = pm.environment.toObject()\n pm.expect(allVars).to.be.an('object')\n pm.expect(allVars).to.have.property('template_var', 'world')\n pm.expect(allVars).to.have.property('user_id', '12345')\n})\n\npm.test('pm.globals.toObject() returns all global variables', () => {\n const allGlobals = pm.globals.toObject()\n pm.expect(allGlobals).to.be.an('object')\n \n // globals might be empty in CLI context\n if (Object.keys(allGlobals).length > 0) {\n pm.expect(allGlobals).to.have.property('api_base')\n }\n})\n\npm.test('pm.variables.toObject() returns combined variables with precedence', () => {\n const allVariables = pm.variables.toObject()\n pm.expect(allVariables).to.be.an('object')\n pm.expect(allVariables).to.have.property('template_var')\n})\n\npm.test('pm.environment.clear() removes all environment variables', () => {\n pm.environment.clear()\n const clearedVars = pm.environment.toObject()\n pm.expect(Object.keys(clearedVars).length).to.equal(0)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00respext01", + "name": "response-extensions-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "\npm.test('pm.response.responseSize returns response body size in bytes', () => {\n pm.expect(pm.response.responseSize).to.be.a('number')\n pm.expect(pm.response.responseSize).to.be.above(0)\n})\n\npm.test('pm.response.responseSize matches actual body length', () => {\n const bodyText = pm.response.text()\n // Use the same workaround as pm.response.responseSize for QuickJS\n const encoder = new TextEncoder()\n const encoded = encoder.encode(bodyText)\n // QuickJS represents Uint8Array as object with numeric keys\n const actualSize = encoded && typeof encoded.length === 'number' && encoded.length > 0\n ? encoded.length\n : Object.keys(encoded).filter(k => !isNaN(k)).length\n pm.expect(pm.response.responseSize).to.equal(actualSize)\n})\n\npm.test('Response size is calculated correctly for JSON payload', () => {\n const response = pm.response.json()\n pm.expect(pm.response.responseSize).to.be.a('number')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"message\": \"Testing response size calculation\",\n \"data\": {\n \"items\": [1, 2, 3, 4, 5],\n \"metadata\": {\n \"count\": 5,\n \"type\": \"numeric\"\n }\n }\n}" + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00execext01", + "name": "execution-context-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "\npm.test('pm.execution.location provides execution path', () => {\n pm.expect(pm.execution.location).to.be.an('array')\n pm.expect(pm.execution.location.length).to.be.above(0)\n})\n\npm.test('pm.execution.location.current returns current location', () => {\n pm.expect(pm.execution.location.current).to.be.a('string')\n pm.expect(pm.execution.location.current).to.equal('Hoppscotch')\n})\n\npm.test('pm.execution.location is immutable', () => {\n const location = pm.execution.location\n const throwFn = () => { location.push('test') }\n pm.expect(throwFn).to.throw()\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00bddassert01", + "name": "bdd-response-assertions-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Content-Type", + "value": "application/json", + "active": true, + "description": "" + } + ], + "preRequestScript": "", + "testScript": "\npm.test('pm.response.to.have.status() validates exact status code', () => {\n pm.response.to.have.status(200)\n pm.expect(pm.response.code).to.equal(200)\n})\n\npm.test('pm.response.to.be.ok validates 2xx status codes', () => {\n pm.response.to.be.ok()\n})\n\npm.test('pm.response.to.be.success validates 2xx status codes (alias)', () => {\n pm.response.to.be.success()\n})\n\npm.test('pm.response.to.have.header() validates response headers', () => {\n pm.response.to.have.header('content-type')\n pm.expect(pm.response.headers.has('content-type')).to.be.true\n})\n\npm.test('pm.response.to.have.jsonBody() validates JSON response', () => {\n pm.response.to.have.jsonBody()\n pm.response.to.have.jsonBody('data')\n \n const json = pm.response.json()\n pm.expect(json).to.have.property('data')\n})\n\npm.test('pm.response.to.be.json validates JSON content type', () => {\n pm.response.to.be.json()\n})\n\npm.test('pm.response.to.have.responseTime assertions', () => {\n pm.response.to.have.responseTime.below(5000)\n pm.expect(pm.response.responseTime).to.be.a('number')\n pm.expect(pm.response.responseTime).to.be.above(0)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"test\": \"BDD assertions\",\n \"status\": \"success\"\n}" + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00includecontain01", + "name": "include-contain-assertions-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "\npm.test('pm.expect().to.include() validates string inclusion', () => {\n pm.expect('hello world').to.include('world')\n pm.expect('hello world').to.include('hello')\n pm.expect('test string').to.not.include('missing')\n})\n\npm.test('pm.expect().to.contain() validates array inclusion', () => {\n pm.expect([1, 2, 3]).to.contain(2)\n pm.expect([1, 2, 3]).to.include(1)\n pm.expect(['a', 'b', 'c']).to.contain('b')\n})\n\npm.test('pm.expect().to.includes() alias works', () => {\n pm.expect('testing').to.includes('test')\n pm.expect([10, 20, 30]).to.includes(20)\n})\n\npm.test('pm.expect().to.contains() alias works', () => {\n pm.expect('contains test').to.contains('contains')\n pm.expect([true, false]).to.contains(true)\n})\n\npm.test('include/contain with response data', () => {\n const response = pm.response.json()\n pm.expect(response).to.have.property('data')\n \n const bodyText = pm.response.text()\n pm.expect(bodyText).to.include('includeTest')\n})\n\nhopp.test('hopp.expect() also supports toInclude()', () => {\n hopp.expect('hopp test').toInclude('hopp')\n hopp.expect([1, 2]).toInclude(1)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"includeTest\": \"This text should be found\",\n \"array\": [1, 2, 3]\n}" + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00envunsetclear01", + "name": "environment-unset-clear-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "pm.environment.set('to_unset1', 'value1')\npm.environment.set('to_unset2', 'value2')\npm.environment.set('to_clear1', 'clear_value1')\npm.environment.set('to_clear2', 'clear_value2')\npm.environment.set('to_clear3', 'clear_value3')\npm.globals.set('global_to_unset', 'global_value')\npm.globals.set('global_to_clear1', 'global_clear1')\npm.globals.set('global_to_clear2', 'global_clear2')\n", + "testScript": "\npm.test('pm.environment.unset() removes specific variables', () => {\n pm.expect(pm.environment.has('to_unset1')).to.be.true\n pm.environment.unset('to_unset1')\n pm.expect(pm.environment.has('to_unset1')).to.be.false\n pm.expect(pm.environment.get('to_unset1')).to.be.undefined\n})\n\npm.test('pm.environment.unset() handles non-existent keys gracefully', () => {\n pm.environment.unset('non_existent_key')\n pm.expect(pm.environment.has('non_existent_key')).to.be.false\n})\n\npm.test('pm.globals.unset() removes specific global variables', () => {\n const hasGlobal = pm.globals.has('global_to_unset')\n if (hasGlobal) {\n pm.globals.unset('global_to_unset')\n pm.expect(pm.globals.has('global_to_unset')).to.be.false\n }\n})\n\npm.test('pm.environment.clear() removes ALL environment variables', () => {\n // Verify variables exist before clear\n pm.expect(pm.environment.has('to_clear1')).to.be.true\n pm.expect(pm.environment.has('to_clear2')).to.be.true\n pm.expect(pm.environment.has('to_clear3')).to.be.true\n \n // Clear all environment variables\n pm.environment.clear()\n \n // Verify ALL variables are removed\n const allVars = pm.environment.toObject()\n pm.expect(Object.keys(allVars).length).to.equal(0)\n pm.expect(pm.environment.has('to_clear1')).to.be.false\n pm.expect(pm.environment.has('to_clear2')).to.be.false\n pm.expect(pm.environment.has('to_clear3')).to.be.false\n})\n\npm.test('pm.globals.clear() removes ALL global variables', () => {\n // Verify globals exist before clear (might be empty in CLI)\n const globalsBeforeClear = pm.globals.toObject()\n \n pm.globals.clear()\n \n // Verify all globals are removed\n const globalsAfterClear = pm.globals.toObject()\n pm.expect(Object.keys(globalsAfterClear).length).to.equal(0)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00pmmutate01", + "name": "pm-request-mutation-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io/original", + "params": [ + { + "key": "original_param", + "value": "original", + "active": true, + "description": "" + } + ], + "headers": [ + { + "key": "Original-Header", + "value": "original", + "active": true, + "description": "" + } + ], + "preRequestScript": "// Test PM namespace mutability - URL string assignment\npm.request.url = 'https://echo.hoppscotch.io/mutated-via-string'\n\n// Test method mutation\npm.request.method = 'POST'\n\n// Test header mutations\npm.request.headers.add({ key: 'Added-Header', value: 'added-value' })\npm.request.headers.upsert({ key: 'Original-Header', value: 'mutated-value' })\n\n// Test body mutation via update()\npm.request.body.update({\n mode: 'raw',\n raw: JSON.stringify({ pmMutated: true, timestamp: Date.now() }),\n options: { raw: { language: 'json' } }\n})\n\n// Test auth mutation\npm.request.auth = {\n authType: 'bearer',\n authActive: true,\n token: 'pm-bearer-token-123'\n}\n", + "testScript": "\npm.test('pm.request.url string assignment was applied', () => {\n const urlString = pm.request.url.toString()\n pm.expect(urlString).to.include('/mutated-via-string')\n pm.expect(urlString).to.not.include('/original')\n})\n\npm.test('pm.request.method mutation was applied', () => {\n pm.expect(pm.request.method).to.equal('POST')\n pm.expect(pm.request.method).to.not.equal('GET')\n})\n\npm.test('pm.request.headers.add() added new header', () => {\n pm.expect(pm.request.headers.has('Added-Header')).to.be.true\n pm.expect(pm.request.headers.get('Added-Header')).to.equal('added-value')\n})\n\npm.test('pm.request.headers.upsert() updated existing header', () => {\n pm.expect(pm.request.headers.has('Original-Header')).to.be.true\n pm.expect(pm.request.headers.get('Original-Header')).to.equal('mutated-value')\n pm.expect(pm.request.headers.get('Original-Header')).to.not.equal('original')\n})\n\npm.test('pm.request.body.update() changed body content', () => {\n pm.expect(pm.request.body.contentType).to.equal('application/json')\n const bodyString = typeof pm.request.body.body === 'string' \n ? pm.request.body.body \n : JSON.stringify(pm.request.body.body)\n pm.expect(bodyString).to.include('pmMutated')\n const bodyData = JSON.parse(bodyString)\n pm.expect(bodyData.pmMutated).to.be.true\n})\n\npm.test('pm.request.auth mutation was applied', () => {\n pm.expect(pm.request.auth.authType).to.equal('bearer')\n pm.expect(pm.request.auth.token).to.equal('pm-bearer-token-123')\n})\n\npm.test('pm.request.id and pm.request.name are accessible', () => {\n pm.expect(pm.request.id).to.be.a('string')\n pm.expect(pm.request.id.length).to.be.above(0)\n pm.expect(pm.request.name).to.equal('pm-request-mutation-test')\n})\n\nhopp.test('hopp.request reflects pm namespace mutations', () => {\n hopp.expect(hopp.request.url).toInclude('/mutated-via-string')\n hopp.expect(hopp.request.method).toBe('POST')\n const hasAddedHeader = hopp.request.headers.some(h => h.key === 'Added-Header')\n hopp.expect(hasAddedHeader).toBe(true)\n})\n", + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00urlmutate01", + "name": "pm-url-property-mutations-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io/original?old=value", + "params": [], + "headers": [], + "preRequestScript": "// Test URL object property mutations\npm.request.url.protocol = 'http'\npm.request.url.host = ['echo', 'hoppscotch', 'io']\npm.request.url.path = ['v2', 'test']\npm.request.url.port = '443'\npm.request.url.query.add({ key: 'new', value: 'param' })\npm.request.url.query.remove('old')\n", + "testScript": "\npm.test('URL protocol mutation works', () => {\n const url = pm.request.url\n pm.expect(url.protocol).to.equal('http')\n pm.expect(url.toString()).to.include('http://')\n})\n\npm.test('URL host mutation works', () => {\n const url = pm.request.url\n pm.expect(url.host).to.be.an('array')\n pm.expect(url.host.join('.')).to.equal('echo.hoppscotch.io')\n pm.expect(url.toString()).to.include('echo.hoppscotch.io')\n})\n\npm.test('URL path mutation works', () => {\n const url = pm.request.url\n pm.expect(url.path).to.be.an('array')\n pm.expect(url.path).to.include('v2')\n pm.expect(url.path).to.include('test')\n pm.expect(url.toString()).to.include('/v2/test')\n})\n\npm.test('URL query.add() adds parameter', () => {\n const allParams = pm.request.url.query.all()\n pm.expect(allParams).to.have.property('new', 'param')\n})\n\npm.test('URL query.remove() removes parameter', () => {\n const allParams = pm.request.url.query.all()\n pm.expect(allParams).to.not.have.property('old')\n})\n\npm.test('Full URL reflects all mutations', () => {\n const fullUrl = pm.request.url.toString()\n pm.expect(fullUrl).to.include('http://')\n pm.expect(fullUrl).to.include('echo.hoppscotch.io')\n pm.expect(fullUrl).to.include('/v2/test')\n pm.expect(fullUrl).to.include('new=param')\n pm.expect(fullUrl).to.not.include('old=value')\n})\n\nhopp.test('hopp.request reflects URL mutations', () => {\n hopp.expect(hopp.request.url).toInclude('echo.hoppscotch.io')\n hopp.expect(hopp.request.url).toInclude('/v2/test')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00unsupported01", + "name": "unsupported-features-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "\nconst unsupportedApis = [\n { api: 'pm.info.iteration', script: () => { const x = pm.info.iteration }, errorMessage: /Collection Runner feature/ },\n { api: 'pm.info.iterationCount', script: () => { const x = pm.info.iterationCount }, errorMessage: /Collection Runner feature/ },\n { api: 'pm.collectionVariables.get()', script: () => pm.collectionVariables.get('test'), errorMessage: /use environment or request variables instead/ },\n { api: 'pm.collectionVariables.set()', script: () => pm.collectionVariables.set('key', 'value'), errorMessage: /use environment or request variables instead/ },\n { api: 'pm.collectionVariables.unset()', script: () => pm.collectionVariables.unset('key'), errorMessage: /use environment or request variables instead/ },\n { api: 'pm.collectionVariables.has()', script: () => pm.collectionVariables.has('key'), errorMessage: /use environment or request variables instead/ },\n { api: 'pm.collectionVariables.clear()', script: () => pm.collectionVariables.clear(), errorMessage: /use environment or request variables instead/ },\n { api: 'pm.collectionVariables.toObject()', script: () => pm.collectionVariables.toObject(), errorMessage: /use environment or request variables instead/ },\n { api: 'pm.collectionVariables.replaceIn()', script: () => pm.collectionVariables.replaceIn('{{test}}'), errorMessage: /use environment or request variables instead/ },\n { api: 'pm.vault.get()', script: () => pm.vault.get('test'), errorMessage: /Postman Vault feature/ },\n { api: 'pm.vault.set()', script: () => pm.vault.set('key', 'value'), errorMessage: /Postman Vault feature/ },\n { api: 'pm.vault.unset()', script: () => pm.vault.unset('key'), errorMessage: /Postman Vault feature/ },\n { api: 'pm.iterationData.get()', script: () => pm.iterationData.get('test'), errorMessage: /Collection Runner feature/ },\n { api: 'pm.iterationData.set()', script: () => pm.iterationData.set('key', 'value'), errorMessage: /Collection Runner feature/ },\n { api: 'pm.iterationData.unset()', script: () => pm.iterationData.unset('key'), errorMessage: /Collection Runner feature/ },\n { api: 'pm.iterationData.has()', script: () => pm.iterationData.has('key'), errorMessage: /Collection Runner feature/ },\n { api: 'pm.iterationData.toObject()', script: () => pm.iterationData.toObject(), errorMessage: /Collection Runner feature/ },\n { api: 'pm.iterationData.toJSON()', script: () => pm.iterationData.toJSON(), errorMessage: /Collection Runner feature/ },\n { api: 'pm.execution.setNextRequest()', script: () => pm.execution.setNextRequest('next'), errorMessage: /Collection Runner feature/ },\n { api: 'pm.execution.skipRequest()', script: () => pm.execution.skipRequest(), errorMessage: /Collection Runner feature/ },\n { api: 'pm.execution.runRequest()', script: () => pm.execution.runRequest(), errorMessage: /Collection Runner feature/ },\n { api: 'pm.visualizer.set()', script: () => pm.visualizer.set('

Test

'), errorMessage: /Postman Visualizer feature/ },\n { api: 'pm.visualizer.clear()', script: () => pm.visualizer.clear(), errorMessage: /Postman Visualizer feature/ },\n { api: 'pm.require()', script: () => pm.require('lodash'), errorMessage: /not supported in Hoppscotch/ },\n]\n\nunsupportedApis.forEach(({ api, script, errorMessage }) => {\n pm.test(`${api} throws descriptive error`, () => {\n pm.expect(script).to.throw(errorMessage)\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00urlpropertylist01", + "name": "url-propertylist-helpers-test", + "method": "GET", + "endpoint": "https://api.example.com:8080/v1/users?filter=active&sort=name&tag=js&tag=ts#section1", + "params": [ + { + "key": "filter", + "value": "active", + "active": true, + "description": "" + }, + { + "key": "sort", + "value": "name", + "active": true, + "description": "" + }, + { + "key": "tag", + "value": "js", + "active": true, + "description": "" + }, + { + "key": "tag", + "value": "ts", + "active": true, + "description": "" + } + ], + "headers": [ + { + "key": "Content-Type", + "value": "application/json", + "active": true, + "description": "" + }, + { + "key": "Authorization", + "value": "Bearer test-token", + "active": true, + "description": "" + } + ], + "preRequestScript": "// Test URL helper methods\npm.request.url.update('https://echo.hoppscotch.io/updated?test=value')\npm.request.url.addQueryParams([{ key: 'page', value: '1' }, { key: 'limit', value: '20' }])\npm.request.url.removeQueryParams('test')\n\n// Test hostname and hash properties\npm.request.url.hostname = 'echo.hoppscotch.io'\npm.request.url.hash = 'results'\n\n// Test query PropertyList methods\npm.request.url.query.upsert({ key: 'status', value: 'published' })\npm.request.url.query.add({ key: 'include', value: 'metadata' })\n", + "testScript": "\npm.test('URL helper methods - getHost() returns hostname as string', () => {\n const host = pm.request.url.getHost()\n pm.expect(host).to.be.a('string')\n pm.expect(host).to.equal('echo.hoppscotch.io')\n})\n\npm.test('URL helper methods - getPath() returns path with leading slash', () => {\n const path = pm.request.url.getPath()\n pm.expect(path).to.be.a('string')\n pm.expect(path).to.include('/')\n pm.expect(path).to.equal('/updated')\n})\n\npm.test('URL helper methods - getPathWithQuery() includes query string', () => {\n const pathWithQuery = pm.request.url.getPathWithQuery()\n pm.expect(pathWithQuery).to.include('?')\n pm.expect(pathWithQuery).to.include('page=1')\n pm.expect(pathWithQuery).to.include('limit=20')\n})\n\npm.test('URL helper methods - getQueryString() returns query without ?', () => {\n const queryString = pm.request.url.getQueryString()\n pm.expect(queryString).to.be.a('string')\n pm.expect(queryString).to.not.include('?')\n pm.expect(queryString).to.include('page=1')\n})\n\npm.test('URL helper methods - getRemote() returns host with port', () => {\n const remote = pm.request.url.getRemote()\n pm.expect(remote).to.be.a('string')\n pm.expect(remote).to.equal('echo.hoppscotch.io')\n})\n\npm.test('URL helper methods - update() changes entire URL', () => {\n const url = pm.request.url.toString()\n pm.expect(url).to.include('echo.hoppscotch.io')\n pm.expect(url).to.include('/updated')\n})\n\npm.test('URL helper methods - addQueryParams() adds multiple params', () => {\n const allParams = pm.request.url.query.all()\n pm.expect(allParams).to.have.property('page', '1')\n pm.expect(allParams).to.have.property('limit', '20')\n})\n\npm.test('URL helper methods - removeQueryParams() removes params', () => {\n const allParams = pm.request.url.query.all()\n pm.expect(allParams).to.not.have.property('test')\n})\n\npm.test('URL properties - hostname getter returns string', () => {\n const hostname = pm.request.url.hostname\n pm.expect(hostname).to.be.a('string')\n pm.expect(hostname).to.equal('echo.hoppscotch.io')\n})\n\npm.test('URL properties - hostname matches host array', () => {\n const hostname = pm.request.url.hostname\n const hostString = pm.request.url.host.join('.')\n pm.expect(hostname).to.equal(hostString)\n})\n\npm.test('URL properties - hash getter returns string', () => {\n const hash = pm.request.url.hash\n pm.expect(hash).to.be.a('string')\n // Hash might not persist through URL mutations in E2E context\n})\n\npm.test('Query PropertyList - get() retrieves parameter value', () => {\n const pageValue = pm.request.url.query.get('page')\n pm.expect(pageValue).to.equal('1')\n})\n\npm.test('Query PropertyList - has() checks parameter existence', () => {\n pm.expect(pm.request.url.query.has('page')).to.be.true\n pm.expect(pm.request.url.query.has('nonexistent')).to.be.false\n})\n\npm.test('Query PropertyList - upsert() adds/updates parameter', () => {\n pm.expect(pm.request.url.query.has('status')).to.be.true\n pm.expect(pm.request.url.query.get('status')).to.equal('published')\n})\n\npm.test('Query PropertyList - count() returns parameter count', () => {\n const count = pm.request.url.query.count()\n pm.expect(count).to.be.a('number')\n pm.expect(count).to.be.above(0)\n})\n\npm.test('Query PropertyList - each() iterates over parameters', () => {\n let iterationCount = 0\n pm.request.url.query.each((param) => {\n pm.expect(param).to.have.property('key')\n pm.expect(param).to.have.property('value')\n iterationCount++\n })\n pm.expect(iterationCount).to.be.above(0)\n})\n\npm.test('Query PropertyList - map() transforms parameters', () => {\n const keys = pm.request.url.query.map((param) => param.key)\n pm.expect(keys).to.be.an('array')\n pm.expect(keys).to.include('page')\n pm.expect(keys).to.include('limit')\n})\n\npm.test('Query PropertyList - filter() filters parameters', () => {\n const filtered = pm.request.url.query.filter((param) => param.key === 'page')\n pm.expect(filtered).to.be.an('array')\n pm.expect(filtered.length).to.be.above(0)\n pm.expect(filtered[0].key).to.equal('page')\n})\n\npm.test('Query PropertyList - idx() accesses by index', () => {\n const firstParam = pm.request.url.query.idx(0)\n pm.expect(firstParam).to.be.an('object')\n pm.expect(firstParam).to.have.property('key')\n pm.expect(firstParam).to.have.property('value')\n})\n\npm.test('Query PropertyList - idx() returns null for out of bounds', () => {\n const param = pm.request.url.query.idx(999)\n pm.expect(param).to.be.null\n})\n\npm.test('Query PropertyList - toObject() returns object', () => {\n const obj = pm.request.url.query.toObject()\n pm.expect(obj).to.be.an('object')\n pm.expect(obj).to.have.property('page')\n})\n\npm.test('Headers PropertyList - each() iterates over headers', () => {\n let count = 0\n pm.request.headers.each((header) => {\n pm.expect(header).to.have.property('key')\n pm.expect(header).to.have.property('value')\n count++\n })\n pm.expect(count).to.be.above(0)\n})\n\npm.test('Headers PropertyList - map() transforms headers', () => {\n const keys = pm.request.headers.map((h) => h.key)\n pm.expect(keys).to.be.an('array')\n pm.expect(keys).to.include('Content-Type')\n})\n\npm.test('Headers PropertyList - filter() filters headers', () => {\n const filtered = pm.request.headers.filter((h) => h.key === 'Content-Type')\n pm.expect(filtered).to.be.an('array')\n pm.expect(filtered.length).to.be.above(0)\n})\n\npm.test('Headers PropertyList - count() returns header count', () => {\n const count = pm.request.headers.count()\n pm.expect(count).to.be.a('number')\n pm.expect(count).to.be.above(0)\n})\n\npm.test('Headers PropertyList - idx() accesses by index', () => {\n const firstHeader = pm.request.headers.idx(0)\n pm.expect(firstHeader).to.be.an('object')\n pm.expect(firstHeader).to.have.property('key')\n})\n\npm.test('Headers PropertyList - toObject() returns object', () => {\n const obj = pm.request.headers.toObject()\n pm.expect(obj).to.be.an('object')\n pm.expect(obj).to.have.property('Content-Type')\n})\n\nhopp.test('hopp namespace URL methods work identically', () => {\n const url = hopp.request.url\n hopp.expect(url).toInclude('echo.hoppscotch.io')\n hopp.expect(url).toInclude('/updated')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "name": "propertylist-advanced-methods-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io/propertylist", + "params": [ + { + "key": "filter", + "value": "active", + "active": true, + "description": "" + }, + { + "key": "sort", + "value": "name", + "active": true, + "description": "" + }, + { + "key": "page", + "value": "1", + "active": true, + "description": "" + } + ], + "headers": [ + { + "key": "Content-Type", + "value": "application/json", + "active": true, + "description": "" + }, + { + "key": "Authorization", + "value": "Bearer token123", + "active": true, + "description": "" + } + ], + "preRequestScript": "// Test query.insert() - insert limit before page\npm.request.url.query.insert({ key: 'limit', value: '10' }, 'page')\n\n// Test query.append() - add new param at end\npm.request.url.query.append({ key: 'offset', value: '0' })\n\n// Test query.assimilate() - merge params\npm.request.url.query.assimilate({ include: 'metadata', status: 'active' })\n\n// Test headers.insert() - insert before Authorization\npm.request.headers.insert({ key: 'X-API-Key', value: 'secret123' }, 'Authorization')\n\n// Test headers.append() - add at end\npm.request.headers.append({ key: 'X-Request-ID', value: 'req-456' })\n\n// Test headers.assimilate() - merge headers\npm.request.headers.assimilate({ 'X-Custom-Header': 'custom-value' })\n", + "testScript": "\npm.test('query.find() - finds param by string key', () => {\n const limitParam = pm.request.url.query.find('limit')\n if (limitParam) {\n pm.expect(limitParam).to.be.an('object')\n pm.expect(limitParam.key).to.equal('limit')\n } else {\n pm.expect(pm.request.url.query.has('limit')).to.be.true\n }\n})\n\npm.test('query.find() - finds param by predicate function', () => {\n const limitParam = pm.request.url.query.find((p) => p && p.key === 'limit')\n if (limitParam) {\n pm.expect(limitParam).to.be.an('object')\n pm.expect(limitParam.value).to.equal('10')\n } else {\n pm.expect(pm.request.url.query.get('limit')).to.equal('10')\n }\n})\n\npm.test('query.find() - returns null when not found', () => {\n const result = pm.request.url.query.find('nonexistent')\n pm.expect(result).to.be.null\n})\n\npm.test('query.indexOf() - returns index for existing params', () => {\n // Verify indexOf works - check params that exist in actual URL\n const allParams = pm.request.url.query.all()\n const keys = Object.keys(allParams)\n if (keys.length > 0) {\n const firstKey = keys[0]\n const idx = pm.request.url.query.indexOf(firstKey)\n pm.expect(idx).to.be.a('number')\n pm.expect(idx).to.be.at.least(0)\n }\n})\n\npm.test('query.indexOf() - returns index by object', () => {\n const allParams = pm.request.url.query.all()\n const keys = Object.keys(allParams)\n if (keys.length > 0) {\n const idx = pm.request.url.query.indexOf({ key: keys[0] })\n pm.expect(idx).to.be.a('number')\n pm.expect(idx).to.be.at.least(0)\n }\n})\n\npm.test('query.indexOf() - returns -1 when not found', () => {\n const idx = pm.request.url.query.indexOf('notfound')\n pm.expect(idx).to.equal(-1)\n})\n\npm.test('query.insert/append/assimilate - methods executed successfully', () => {\n // Verify the methods executed without errors in pre-request\n // Post-request sees actual sent URL, so we just verify params exist\n const allParams = pm.request.url.query.all()\n pm.expect(allParams).to.be.an('object')\n pm.expect(pm.request.url.query.has('limit')).to.be.true\n pm.expect(pm.request.url.query.has('offset')).to.be.true\n})\n\npm.test('query.append() - adds param at end', () => {\n const offsetIdx = pm.request.url.query.indexOf('offset')\n pm.expect(offsetIdx).to.be.at.least(0)\n pm.expect(pm.request.url.query.get('offset')).to.equal('0')\n})\n\npm.test('query.assimilate() - adds/updates params', () => {\n pm.expect(pm.request.url.query.has('include')).to.be.true\n pm.expect(pm.request.url.query.get('include')).to.equal('metadata')\n pm.expect(pm.request.url.query.has('status')).to.be.true\n pm.expect(pm.request.url.query.get('status')).to.equal('active')\n})\n\npm.test('headers.find() - finds header by string (case-insensitive)', () => {\n const ct = pm.request.headers.find('content-type')\n pm.expect(ct).to.be.an('object')\n pm.expect(ct.key).to.equal('Content-Type')\n})\n\npm.test('headers.find() - finds header by predicate function', () => {\n const auth = pm.request.headers.find((h) => h.key === 'Authorization')\n pm.expect(auth).to.be.an('object')\n pm.expect(auth.value).to.include('Bearer')\n})\n\npm.test('headers.find() - returns null when not found', () => {\n const result = pm.request.headers.find('nonexistent')\n pm.expect(result).to.be.null\n})\n\npm.test('headers.indexOf() - returns correct index (case-insensitive)', () => {\n const authIdx = pm.request.headers.indexOf('authorization')\n pm.expect(authIdx).to.be.a('number')\n pm.expect(authIdx).to.be.at.least(0)\n})\n\npm.test('headers.indexOf() - returns correct index by object', () => {\n const ctIdx = pm.request.headers.indexOf({ key: 'Content-Type' })\n pm.expect(ctIdx).to.be.a('number')\n pm.expect(ctIdx).to.be.at.least(0)\n})\n\npm.test('headers.indexOf() - returns -1 when not found', () => {\n const idx = pm.request.headers.indexOf('NotFound')\n pm.expect(idx).to.equal(-1)\n})\n\npm.test('headers.insert() - inserts header before specified header', () => {\n const apiKeyIdx = pm.request.headers.indexOf('X-API-Key')\n const authIdx = pm.request.headers.indexOf('Authorization')\n pm.expect(apiKeyIdx).to.be.below(authIdx)\n})\n\npm.test('headers.append() - adds header at end', () => {\n pm.expect(pm.request.headers.has('X-Request-ID')).to.be.true\n pm.expect(pm.request.headers.get('X-Request-ID')).to.equal('req-456')\n})\n\npm.test('headers.assimilate() - adds/updates headers', () => {\n pm.expect(pm.request.headers.has('X-Custom-Header')).to.be.true\n pm.expect(pm.request.headers.get('X-Custom-Header')).to.equal('custom-value')\n})\n\npm.test('query PropertyList - all methods work together', () => {\n const allParams = pm.request.url.query.all()\n pm.expect(allParams).to.be.an('object')\n // At minimum we should have the params added in pre-request\n pm.expect(Object.keys(allParams).length).to.be.at.least(4)\n})\n\npm.test('headers PropertyList - all methods work together', () => {\n const allHeaders = pm.request.headers.all()\n pm.expect(allHeaders).to.be.an('object')\n pm.expect(Object.keys(allHeaders).length).to.be.at.least(5)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "advanced-response-methods-test", + "name": "advanced-response-methods-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Content-Type", + "value": "application/json", + "active": true, + "description": "" + } + ], + "preRequestScript": "", + "testScript": "\n// Test pm.response.reason()\npm.test('pm.response.reason() returns HTTP reason phrase', () => {\n const reason = pm.response.reason()\n pm.expect(reason).to.be.a('string')\n pm.expect(reason).to.equal('OK')\n})\n\n// Test hopp.response.reason() for parity\npm.test('hopp.response.reason() returns HTTP reason phrase', () => {\n const reason = hopp.response.reason()\n hopp.expect(reason).toBeType('string')\n hopp.expect(reason).toBe('OK')\n})\n\n// Test pm.response.dataURI()\npm.test('pm.response.dataURI() converts response to data URI', () => {\n const dataURI = pm.response.dataURI()\n pm.expect(dataURI).to.be.a('string')\n pm.expect(dataURI).to.include('data:')\n pm.expect(dataURI).to.include('base64')\n})\n\n// Test hopp.response.dataURI() for parity\npm.test('hopp.response.dataURI() converts response to data URI', () => {\n const dataURI = hopp.response.dataURI()\n hopp.expect(dataURI).toBeType('string')\n hopp.expect(dataURI.startsWith('data:')).toBe(true)\n})\n\n// Test .nested property assertions\npm.test('pm.expect().to.have.nested.property() accesses nested properties', () => {\n const obj = { a: { b: { c: 'deep value' } } }\n pm.expect(obj).to.have.nested.property('a.b.c', 'deep value')\n pm.expect(obj).to.have.nested.property('a.b')\n})\n\n// Test hopp namespace nested property for parity\npm.test('hopp.expect().to.have.nested.property() accesses nested properties', () => {\n const obj = { x: { y: { z: 'nested' } } }\n hopp.expect(obj).to.have.nested.property('x.y.z', 'nested')\n hopp.expect(obj).to.have.nested.property('x.y')\n})\n\npm.test('pm.expect().to.have.nested.property() handles arrays', () => {\n const obj = { items: [{ name: 'first' }, { name: 'second' }] }\n pm.expect(obj).to.have.nested.property('items[0].name', 'first')\n pm.expect(obj).to.have.nested.property('items[1].name', 'second')\n})\n\npm.test('pm.expect().to.not.have.nested.property() negation works', () => {\n const obj = { a: { b: 'value' } }\n pm.expect(obj).to.not.have.nested.property('a.c')\n pm.expect(obj).to.not.have.nested.property('x.y.z')\n})\n\n// Test .by() chaining for change assertions\npm.test('pm.expect().to.change().by() validates exact delta', () => {\n const obj = { value: 10 }\n pm.expect(() => { obj.value = 25 }).to.change(obj, 'value').by(15)\n})\n\n// Test hopp namespace .by() chaining for parity\npm.test('hopp.expect().to.change().by() validates exact delta', () => {\n const obj = { val: 100 }\n hopp.expect(() => { obj.val = 150 }).to.change(obj, 'val').by(50)\n})\n\npm.test('pm.expect().to.increase().by() validates exact increase', () => {\n const obj = { count: 5 }\n pm.expect(() => { obj.count += 7 }).to.increase(obj, 'count').by(7)\n})\n\npm.test('pm.expect().to.decrease().by() validates exact decrease', () => {\n const obj = { score: 100 }\n pm.expect(() => { obj.score -= 30 }).to.decrease(obj, 'score').by(30)\n})\n\npm.test('pm.expect().to.change().by() with negative delta', () => {\n const obj = { value: 50 }\n pm.expect(() => { obj.value = 20 }).to.change(obj, 'value').by(-30)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\"test\": \"data\"}" + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "advanced-chai-map-set-test", + "name": "advanced-chai-map-set-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "export {};\n", + "testScript": "\n// Map & Set Assertions\npm.test('Map assertions - size property', () => {\n const map = new Map([['key1', 'value1'], ['key2', 'value2']])\n pm.expect(map).to.have.property('size', 2)\n pm.expect(map.size).to.equal(2)\n})\n\npm.test('Set assertions - size property', () => {\n const set = new Set([1, 2, 3, 4])\n pm.expect(set).to.have.property('size', 4)\n pm.expect(set.size).to.equal(4)\n})\n\npm.test('Map instanceOf assertion', () => {\n const map = new Map()\n pm.expect(map).to.be.instanceOf(Map)\n pm.expect(map).to.be.an.instanceOf(Map)\n})\n\npm.test('Set instanceOf assertion', () => {\n const set = new Set()\n pm.expect(set).to.be.instanceOf(Set)\n pm.expect(set).to.be.an.instanceOf(Set)\n})\n\n// Advanced Chai - closeTo\npm.test('closeTo - validates numbers within delta', () => {\n pm.expect(3.14159).to.be.closeTo(3.14, 0.01)\n pm.expect(10.5).to.be.closeTo(11, 1)\n})\n\npm.test('closeTo - negation works', () => {\n pm.expect(100).to.not.be.closeTo(50, 10)\n pm.expect(3.14).to.not.be.closeTo(10, 0.1)\n})\n\npm.test('approximately - alias for closeTo', () => {\n pm.expect(2.5).to.approximately(2.4, 0.2)\n pm.expect(99.99).to.approximately(100, 0.1)\n})\n\n// Advanced Chai - finite\npm.test('finite - validates finite numbers', () => {\n pm.expect(123).to.be.finite\n pm.expect(0).to.be.finite\n pm.expect(-456).to.be.finite\n})\n\npm.test('finite - negation for Infinity', () => {\n pm.expect(Infinity).to.not.be.finite\n pm.expect(-Infinity).to.not.be.finite\n pm.expect(NaN).to.not.be.finite\n})\n\n// Advanced Chai - satisfy\npm.test('satisfy - custom predicate function', () => {\n pm.expect(10).to.satisfy((num) => num > 5)\n pm.expect('hello').to.satisfy((str) => str.length === 5)\n})\n\npm.test('satisfy - complex validation', () => {\n const obj = { name: 'test', value: 100 }\n pm.expect(obj).to.satisfy((o) => o.value > 50 && o.name.length > 0)\n})\n\npm.test('satisfy - negation works', () => {\n pm.expect(5).to.not.satisfy((num) => num > 10)\n pm.expect('abc').to.not.satisfy((str) => str.length > 5)\n})\n\n// Advanced Chai - respondTo\npm.test('respondTo - validates method existence', () => {\n class TestClass {\n testMethod() { return 'test' }\n anotherMethod() { return 'another' }\n }\n pm.expect(TestClass).to.respondTo('testMethod')\n pm.expect(TestClass).to.respondTo('anotherMethod')\n})\n\npm.test('respondTo - with itself for static methods', () => {\n class MyClass {\n static staticMethod() { return 'static' }\n instanceMethod() { return 'instance' }\n }\n pm.expect(MyClass).itself.to.respondTo('staticMethod')\n pm.expect(MyClass).to.not.itself.respondTo('instanceMethod')\n pm.expect(MyClass).to.respondTo('instanceMethod')\n})\n\n// Property Ownership - own.property\npm.test('own.property - distinguishes own vs inherited', () => {\n const parent = { inherited: true }\n const obj = Object.create(parent)\n obj.own = true\n pm.expect(obj).to.have.own.property('own')\n pm.expect(obj).to.not.have.own.property('inherited')\n pm.expect(obj).to.have.property('inherited')\n})\n\npm.test('deep.own.property - deep check with ownership', () => {\n const proto = { shared: 'inherited' }\n const obj = Object.create(proto)\n obj.data = { nested: 'value' }\n pm.expect(obj).to.have.deep.own.property('data', { nested: 'value' })\n pm.expect(obj).to.not.have.deep.own.property('shared')\n})\n\npm.test('ownProperty - alias for own.property', () => {\n const obj = { prop: 'value' }\n pm.expect(obj).to.have.ownProperty('prop')\n pm.expect(obj).to.have.ownProperty('prop', 'value')\n})\n\n// Hopp namespace parity tests\npm.test('hopp.expect Map/Set support', () => {\n const map = new Map([['x', 1]])\n const set = new Set([1, 2])\n hopp.expect(map.size).toBe(1)\n hopp.expect(set.size).toBe(2)\n})\n\npm.test('hopp.expect closeTo support', () => {\n hopp.expect(3.14).to.be.closeTo(3.1, 0.1)\n hopp.expect(10).to.be.closeTo(10.5, 1)\n})\n\npm.test('hopp.expect finite support', () => {\n hopp.expect(42).to.be.finite\n hopp.expect(Infinity).to.not.be.finite\n})\n\npm.test('hopp.expect satisfy support', () => {\n hopp.expect(100).to.satisfy((n) => n > 50)\n hopp.expect('test').to.satisfy((s) => s.length === 4)\n})\n\npm.test('hopp.expect respondTo support', () => {\n class TestClass { method() {} }\n hopp.expect(TestClass).to.respondTo('method')\n})\n\npm.test('hopp.expect own.property support', () => {\n const obj = Object.create({ inherited: 1 })\n obj.own = 2\n hopp.expect(obj).to.have.own.property('own')\n hopp.expect(obj).to.not.have.own.property('inherited')\n})\n\npm.test('hopp.expect ordered.members support', () => {\n const arr = ['a', 'b', 'c']\n hopp.expect(arr).to.have.ordered.members(['a', 'b', 'c'])\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cmfhzf0op00typecoer01", + "name": "type-preservation-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "// For CLI E2E testing: We only set simple string values in pre-request\n// Complex types will be tested within the test script itself\n\npm.environment.set('string_value', 'hello')\n", + "testScript": "\n// ========================================\n// TYPE PRESERVATION TESTS (CLI Compatible)\n// ========================================\n\n// IMPORTANT NOTE: Type preservation works perfectly WITHIN script execution scope\n// Values persisted across request boundaries (pre-request → test) may be serialized\n// This is expected CLI behavior for environment persistence/display\n\n// Test values set from pre-request\npm.test('string values work across scripts', () => {\n pm.expect(pm.environment.get('string_value')).to.equal('hello')\n})\n\n// ========================================\n// TYPE PRESERVATION WITHIN SINGLE SCRIPT\n// (This is where type preservation really shines!)\n// ========================================\n\npm.test('numbers are preserved as numbers (same script)', () => {\n pm.environment.set('num', 42)\n const value = pm.environment.get('num')\n pm.expect(value).to.equal(42)\n pm.expect(typeof value).to.equal('number')\n})\n\npm.test('booleans are preserved as booleans (same script)', () => {\n pm.environment.set('bool_true', true)\n pm.environment.set('bool_false', false)\n pm.expect(pm.environment.get('bool_true')).to.equal(true)\n pm.expect(pm.environment.get('bool_false')).to.equal(false)\n pm.expect(typeof pm.environment.get('bool_true')).to.equal('boolean')\n})\n\npm.test('null is preserved as actual null (same script)', () => {\n pm.environment.set('null_val', null)\n const value = pm.environment.get('null_val')\n pm.expect(value).to.equal(null)\n pm.expect(value === null).to.be.true\n pm.expect(typeof value).to.equal('object')\n})\n\npm.test('undefined is preserved as actual undefined (same script)', () => {\n pm.environment.set('undef_val', undefined)\n const value = pm.environment.get('undef_val')\n pm.expect(value).to.equal(undefined)\n pm.expect(typeof value).to.equal('undefined')\n pm.expect(pm.environment.has('undef_val')).to.be.true\n})\n\npm.test('arrays are preserved with direct access', () => {\n pm.environment.set('arr', [1, 2, 3])\n const value = pm.environment.get('arr')\n\n pm.expect(Array.isArray(value)).to.be.true\n pm.expect(value.length).to.equal(3)\n pm.expect(value[0]).to.equal(1)\n pm.expect(value[2]).to.equal(3)\n})\n\npm.test('single-element arrays remain arrays', () => {\n pm.environment.set('single', [42])\n const value = pm.environment.get('single')\n\n pm.expect(Array.isArray(value)).to.be.true\n pm.expect(value.length).to.equal(1)\n pm.expect(value[0]).to.equal(42)\n})\n\npm.test('empty arrays are preserved', () => {\n pm.environment.set('empty_arr', [])\n const value = pm.environment.get('empty_arr')\n\n pm.expect(Array.isArray(value)).to.be.true\n pm.expect(value.length).to.equal(0)\n})\n\npm.test('string arrays preserve all elements', () => {\n pm.environment.set('str_arr', ['a', 'b', 'c'])\n const value = pm.environment.get('str_arr')\n\n pm.expect(Array.isArray(value)).to.be.true\n pm.expect(value).to.deep.equal(['a', 'b', 'c'])\n})\n\npm.test('objects are preserved with accessible properties', () => {\n pm.environment.set('obj', { key: 'value', num: 123 })\n const value = pm.environment.get('obj')\n\n pm.expect(typeof value).to.equal('object')\n pm.expect(value.key).to.equal('value')\n pm.expect(value.num).to.equal(123)\n})\n\npm.test('empty objects are preserved', () => {\n pm.environment.set('empty_obj', {})\n const value = pm.environment.get('empty_obj')\n\n pm.expect(typeof value).to.equal('object')\n pm.expect(Object.keys(value).length).to.equal(0)\n})\n\npm.test('nested objects preserve structure', () => {\n pm.environment.set('nested', { user: { name: 'John', id: 1 }, meta: { active: true } })\n const value = pm.environment.get('nested')\n\n pm.expect(value.user.name).to.equal('John')\n pm.expect(value.user.id).to.equal(1)\n pm.expect(value.meta.active).to.equal(true)\n})\n\npm.test('complex nested structures work', () => {\n const data = {\n users: [\n { id: 1, name: 'Alice', scores: [90, 85, 88] },\n { id: 2, name: 'Bob', scores: [75, 80, 82] }\n ],\n metadata: { count: 2, page: 1, filters: ['active', 'verified'] }\n }\n\n pm.environment.set('complex', data)\n const retrieved = pm.environment.get('complex')\n\n pm.expect(retrieved.users).to.be.an('array')\n pm.expect(retrieved.users.length).to.equal(2)\n pm.expect(retrieved.users[0].name).to.equal('Alice')\n pm.expect(retrieved.users[0].scores[0]).to.equal(90)\n pm.expect(retrieved.metadata.filters).to.deep.equal(['active', 'verified'])\n})\n\n// ========================================\n// NAMESPACE SEPARATION\n// ========================================\n\npm.test('hopp.env.set rejects non-string values', () => {\n let errorCount = 0\n\n try { hopp.env.set('test', undefined) } catch (e) { errorCount++ }\n try { hopp.env.set('test', null) } catch (e) { errorCount++ }\n try { hopp.env.set('test', 42) } catch (e) { errorCount++ }\n try { hopp.env.set('test', true) } catch (e) { errorCount++ }\n try { hopp.env.set('test', [1, 2]) } catch (e) { errorCount++ }\n try { hopp.env.set('test', {}) } catch (e) { errorCount++ }\n\n pm.expect(errorCount).to.equal(6)\n})\n\npm.test('hopp.env.set only accepts strings', () => {\n hopp.env.set('hopp_str', 'valid')\n pm.expect(hopp.env.get('hopp_str')).to.equal('valid')\n})\n\npm.test('pm/hopp cross-namespace reading works', () => {\n pm.environment.set('cross_test', [1, 2, 3])\n\n // hopp can read PM-set values\n const fromHopp = hopp.env.get('cross_test')\n pm.expect(Array.isArray(fromHopp)).to.be.true\n pm.expect(fromHopp.length).to.equal(3)\n})\n\n// ========================================\n// PRACTICAL USE CASES\n// ========================================\n\npm.test('no JSON.parse needed for response data storage', () => {\n // Simulate storing parsed response data\n const responseData = {\n id: 123,\n name: 'Test User',\n permissions: ['read', 'write'],\n settings: { theme: 'dark', notifications: true }\n }\n\n pm.environment.set('user_data', responseData)\n const stored = pm.environment.get('user_data')\n\n // Direct access - no JSON.parse needed!\n pm.expect(stored.id).to.equal(123)\n pm.expect(stored.permissions).to.include('write')\n pm.expect(stored.settings.theme).to.equal('dark')\n})\n\npm.test('array iteration works directly', () => {\n pm.environment.set('items', ['apple', 'banana', 'cherry'])\n const items = pm.environment.get('items')\n\n let concatenated = ''\n items.forEach(item => {\n concatenated += item\n })\n\n pm.expect(concatenated).to.equal('applebananacherry')\n pm.expect(items.map(i => i.toUpperCase())).to.deep.equal(['APPLE', 'BANANA', 'CHERRY'])\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "type_preservation_ui_compat", + "name": "type-preservation-ui-compatibility-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "// Type preservation tests run in test script scope", + "testScript": "\n// ====== Type Preservation & UI Compatibility Tests ======\n// NOTE: Testing in same script scope (CLI limitation: complex types\n// may not persist across pre-request → test boundary)\n\npm.test('PM namespace preserves array types (not String coercion)', () => {\n pm.environment.set('simpleArray', [1, 2, 3])\n const arr = pm.environment.get('simpleArray')\n\n // CRITICAL: Should be actual array, not string \"1,2,3\"\n pm.expect(Array.isArray(arr)).to.equal(true)\n pm.expect(arr).to.have.lengthOf(3)\n pm.expect(arr[0]).to.equal(1)\n pm.expect(arr[1]).to.equal(2)\n pm.expect(arr[2]).to.equal(3)\n})\n\npm.test('PM namespace preserves object types (not \"[object Object]\")', () => {\n pm.environment.set('simpleObject', { foo: 'bar', num: 42 })\n const obj = pm.environment.get('simpleObject')\n\n // CRITICAL: Should be actual object, not string \"[object Object]\"\n pm.expect(typeof obj).to.equal('object')\n pm.expect(obj).to.not.be.null\n pm.expect(obj.foo).to.equal('bar')\n pm.expect(obj.num).to.equal(42)\n})\n\npm.test('PM namespace preserves null correctly', () => {\n pm.environment.set('nullValue', null)\n const val = pm.environment.get('nullValue')\n\n pm.expect(val).to.be.null\n})\n\npm.test('PM namespace preserves undefined correctly', () => {\n pm.environment.set('undefinedValue', undefined)\n const val = pm.environment.get('undefinedValue')\n\n pm.expect(val).to.be.undefined\n})\n\npm.test('PM namespace preserves primitives correctly', () => {\n pm.environment.set('stringValue', 'hello')\n pm.environment.set('numberValue', 123)\n pm.environment.set('booleanValue', true)\n\n pm.expect(pm.environment.get('stringValue')).to.equal('hello')\n pm.expect(pm.environment.get('numberValue')).to.equal(123)\n pm.expect(pm.environment.get('booleanValue')).to.equal(true)\n})\n\npm.test('PM namespace preserves nested structures', () => {\n pm.environment.set('nestedStructure', {\n users: [\n { id: 1, name: 'Alice' },\n { id: 2, name: 'Bob' }\n ],\n meta: { count: 2, tags: ['active', 'verified'] }\n })\n const nested = pm.environment.get('nestedStructure')\n\n pm.expect(nested).to.be.an('object')\n pm.expect(nested.users).to.be.an('array')\n pm.expect(nested.users).to.have.lengthOf(2)\n pm.expect(nested.users[0].name).to.equal('Alice')\n pm.expect(nested.users[1].name).to.equal('Bob')\n pm.expect(nested.meta.count).to.equal(2)\n pm.expect(nested.meta.tags).to.have.members(['active', 'verified'])\n})\n\npm.test('PM namespace handles mixed arrays (regression test for UI crash)', () => {\n pm.environment.set('mixedArray', [\n 'string',\n 42,\n true,\n null,\n undefined,\n [1, 2],\n { key: 'value' }\n ])\n const mixed = pm.environment.get('mixedArray')\n\n // This is the exact case that caused the UI crash\n pm.expect(Array.isArray(mixed)).to.equal(true)\n pm.expect(mixed).to.have.lengthOf(7)\n pm.expect(mixed[0]).to.equal('string')\n pm.expect(mixed[1]).to.equal(42)\n pm.expect(mixed[2]).to.equal(true)\n pm.expect(mixed[3]).to.be.null\n // mixed[4] is undefined in array, becomes null during JSON serialization\n pm.expect(Array.isArray(mixed[5])).to.equal(true)\n pm.expect(mixed[5]).to.have.lengthOf(2)\n pm.expect(typeof mixed[6]).to.equal('object')\n pm.expect(mixed[6].key).to.equal('value')\n})\n\npm.test('PM globals preserve arrays and objects', () => {\n pm.globals.set('globalArray', [10, 20, 30])\n pm.globals.set('globalObject', { env: 'prod', port: 8080 })\n\n const globalArr = pm.globals.get('globalArray')\n const globalObj = pm.globals.get('globalObject')\n\n pm.expect(Array.isArray(globalArr)).to.equal(true)\n pm.expect(globalArr).to.deep.equal([10, 20, 30])\n\n pm.expect(typeof globalObj).to.equal('object')\n pm.expect(globalObj.env).to.equal('prod')\n pm.expect(globalObj.port).to.equal(8080)\n})\n\npm.test('PM variables preserve arrays and objects', () => {\n pm.variables.set('varArray', [5, 10, 15])\n pm.variables.set('varObject', { status: 'active', count: 100 })\n\n const varArr = pm.variables.get('varArray')\n const varObj = pm.variables.get('varObject')\n\n pm.expect(Array.isArray(varArr)).to.equal(true)\n pm.expect(varArr).to.deep.equal([5, 10, 15])\n\n pm.expect(typeof varObj).to.equal('object')\n pm.expect(varObj.status).to.equal('active')\n pm.expect(varObj.count).to.equal(100)\n})\n\npm.test('Type preservation works with Postman compatibility', () => {\n pm.environment.set('testArr', [1, 2, 3])\n pm.environment.set('testObj', { foo: 'bar', num: 42 })\n\n const arr = pm.environment.get('testArr')\n const obj = pm.environment.get('testObj')\n\n // Should work like Postman: runtime types preserved\n pm.expect(arr.length).to.equal(3)\n pm.expect(obj.foo).to.equal('bar')\n\n // Verify no String() coercion happened\n pm.expect(arr).to.not.equal('1,2,3')\n pm.expect(obj).to.not.equal('[object Object]')\n})\n\npm.test('Type preservation: UI compatibility regression test', () => {\n // This test validates the fix for the reported bug:\n // \"TypeError: a.match is not a function at details.vue:387:10\"\n\n pm.environment.set('mixedTest', [\n 'string', 42, true, null, undefined, [1, 2], { key: 'value' }\n ])\n\n const mixed = pm.environment.get('mixedTest')\n\n // Should NOT throw any errors\n let errorCount = 0\n try {\n // Access all elements\n mixed.forEach(item => {\n // Should work with all types\n const type = typeof item\n const validTypes = ['string', 'number', 'boolean', 'object']\n if (!validTypes.includes(type)) {\n errorCount++\n }\n })\n } catch (e) {\n errorCount++\n }\n\n pm.expect(errorCount).to.equal(0)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"test\": \"type preservation validation\"\n}" + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "fetch-get-basic", + "name": "hopp.fetch() - GET request basic", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io/status/200", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "hopp.test('hopp.fetch() should make successful GET request', async () => {\n const response = await hopp.fetch('https://echo.hoppscotch.io/status/200')\n hopp.expect(response.status).toBe(200)\n hopp.expect(response.ok).toBe(true)\n hopp.expect(response.statusText).toBeType('string')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "fetch-post-json", + "name": "hopp.fetch() - POST with JSON body", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "hopp.test('hopp.fetch() should POST JSON data', async () => {\n const response = await hopp.fetch('https://echo.hoppscotch.io/post', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ test: 'data', number: 42 })\n })\n hopp.expect(response.status).toBe(200)\n hopp.expect(response.ok).toBe(true)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "fetch-404-error", + "name": "hopp.fetch() - 404 error handling", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "hopp.test('hopp.fetch() should handle 404 errors', async () => {\n const response = await hopp.fetch('https://httpbin.org/status/404')\n // Fault-tolerant: Skip if httpbin is down (5xx)\n if (response.status >= 500 && response.status < 600) {\n console.log('httpbin.org is down (5xx), skipping assertions')\n return\n }\n hopp.expect(response.status).toBe(404)\n hopp.expect(response.ok).toBe(false)\n hopp.expect(response.statusText).toBeType('string')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "fetch-custom-headers", + "name": "hopp.fetch() - Custom headers", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "hopp.test('hopp.fetch() should send custom headers', async () => {\n const response = await hopp.fetch('https://echo.hoppscotch.io', {\n headers: {\n 'X-Custom-Header': 'test-value',\n 'X-Test-ID': '12345'\n }\n })\n hopp.expect(response.status).toBe(200)\n hopp.expect(response.headers).toBeType('object')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "fetch-env-url", + "name": "hopp.fetch() - Environment variable URL", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "hopp.env.set('API_BASE_URL', 'https://echo.hoppscotch.io')\nhopp.env.set('API_PATH', '/status/200')\n", + "testScript": "hopp.test('hopp.fetch() should work with environment variable URLs', async () => {\n const baseUrl = hopp.env.get('API_BASE_URL')\n const path = hopp.env.get('API_PATH')\n const fullUrl = baseUrl + path\n \n hopp.expect(fullUrl).toBe('https://echo.hoppscotch.io/status/200')\n \n const response = await hopp.fetch(fullUrl)\n hopp.expect(response.status).toBe(200)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "fetch-response-text", + "name": "hopp.fetch() - Response text parsing", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io/status/200", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "hopp.test('hopp.fetch() should parse response as text', async () => {\n const response = await hopp.fetch('https://echo.hoppscotch.io/status/200')\n const text = await response.text()\n hopp.expect(text).toBeType('string')\n hopp.expect(text.length > 0).toBe(true)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "fetch-http-methods", + "name": "hopp.fetch() - HTTP methods (PUT, DELETE, PATCH)", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "hopp.test('hopp.fetch() should support PUT method', async () => {\n const response = await hopp.fetch('https://echo.hoppscotch.io/put', {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ updated: true })\n })\n hopp.expect(response.status).toBe(200)\n})\n\nhopp.test('hopp.fetch() should support DELETE method', async () => {\n const response = await hopp.fetch('https://echo.hoppscotch.io/delete', {\n method: 'DELETE'\n })\n hopp.expect(response.status).toBe(200)\n})\n\nhopp.test('hopp.fetch() should support PATCH method', async () => {\n const response = await hopp.fetch('https://echo.hoppscotch.io/patch', {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ patched: true })\n })\n hopp.expect(response.status).toBe(200)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "pm-sendrequest-string-url", + "name": "pm.sendRequest() - String URL format", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "pm.test('pm.sendRequest() should work with string URL', () => {\n pm.sendRequest('https://echo.hoppscotch.io/status/200', (error, response) => {\n pm.expect(error).to.be.null\n pm.expect(response.code).to.equal(200)\n pm.expect(response.status).to.be.a('string')\n pm.expect(Array.isArray(response.headers)).to.be.true\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "pm-sendrequest-request-object", + "name": "pm.sendRequest() - Request object format", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "pm.test('pm.sendRequest() should work with request object', () => {\n pm.sendRequest({\n url: 'https://echo.hoppscotch.io/post',\n method: 'POST',\n header: [\n { key: 'Content-Type', value: 'application/json' },\n { key: 'X-Test-Header', value: 'test' }\n ],\n body: {\n mode: 'raw',\n raw: JSON.stringify({ name: 'test', value: 123 })\n }\n }, (error, response) => {\n pm.expect(error).to.be.null\n pm.expect(response.code).to.equal(200)\n pm.expect(typeof response.body).to.equal('string')\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "pm-sendrequest-urlencoded", + "name": "pm.sendRequest() - URL-encoded body", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "pm.test('pm.sendRequest() should handle URL-encoded body', () => {\n pm.sendRequest({\n url: 'https://echo.hoppscotch.io/post',\n method: 'POST',\n body: {\n mode: 'urlencoded',\n urlencoded: [\n { key: 'username', value: 'testuser' },\n { key: 'password', value: 'secret123' },\n { key: 'remember', value: 'true' }\n ]\n }\n }, (error, response) => {\n pm.expect(error).to.be.null\n pm.expect(response.code).to.equal(200)\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "pm-sendrequest-response-format", + "name": "pm.sendRequest() - Response format validation", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "pm.test('pm.sendRequest() response should have Postman format', () => {\n pm.sendRequest('https://echo.hoppscotch.io/status/200', (error, response) => {\n pm.expect(error).to.be.null\n \n // Validate Postman response structure\n pm.expect(response).to.have.property('code')\n pm.expect(response).to.have.property('status')\n pm.expect(response).to.have.property('headers')\n pm.expect(response).to.have.property('body')\n pm.expect(response).to.have.property('json')\n \n // Validate types\n pm.expect(response.code).to.be.a('number')\n pm.expect(response.status).to.be.a('string')\n pm.expect(Array.isArray(response.headers)).to.be.true\n pm.expect(typeof response.body).to.equal('string')\n pm.expect(typeof response.json).to.equal('function')\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "pm-sendrequest-error-codes", + "name": "pm.sendRequest() - HTTP error status codes", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "pm.test('pm.sendRequest() should handle network errors gracefully', () => {\n pm.sendRequest('https://httpbin.org/status/500', (error, response) => {\n pm.expect(error).to.be.null\n pm.expect(response.code).to.toBeLevel5xx()\n })\n})\n\npm.test('pm.sendRequest() should handle 404 error', () => {\n pm.sendRequest('https://httpbin.org/status/404', (error, response) => {\n pm.expect(error).to.be.null\n // Fault-tolerant: Skip if httpbin is down (5xx)\n if (response.code >= 500 && response.code < 600) {\n console.log('httpbin.org is down (5xx), skipping assertions')\n return\n }\n pm.expect(response.code).to.equal(404)\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "pm-sendrequest-env-integration", + "name": "pm.sendRequest() - Environment variable integration", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "pm.environment.set('API_ENDPOINT', 'https://echo.hoppscotch.io')\npm.environment.set('AUTH_TOKEN', 'Bearer secret-token-123')\n", + "testScript": "pm.test('pm.sendRequest() should use environment variables', () => {\n const apiEndpoint = pm.environment.get('API_ENDPOINT')\n const authToken = pm.environment.get('AUTH_TOKEN')\n \n pm.sendRequest({\n url: apiEndpoint + '/status/200',\n method: 'GET',\n header: [\n { key: 'Authorization', value: authToken }\n ]\n }, (error, response) => {\n pm.expect(error).to.be.null\n pm.expect(response.code).to.equal(200)\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "pm-sendrequest-store-response", + "name": "pm.sendRequest() - Store response in environment", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "pm.test('pm.sendRequest() should store response data in environment', () => {\n pm.sendRequest('https://echo.hoppscotch.io', (error, response) => {\n pm.expect(error).to.be.null\n \n // Store response data\n pm.environment.set('LAST_STATUS_CODE', response.code.toString())\n pm.environment.set('LAST_STATUS_TEXT', response.status)\n \n // Verify storage\n pm.expect(pm.environment.get('LAST_STATUS_CODE')).to.equal('200')\n pm.expect(pm.environment.get('LAST_STATUS_TEXT')).to.be.a('string')\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "pm-sendrequest-rfc-object-headers", + "name": "pm.sendRequest() - RFC pattern with object headers", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "pm.environment.set('token', 'test-bearer-token-12345')\n", + "testScript": "pm.test('pm.sendRequest() should support RFC pattern with object headers', () => {\n const requestObject = {\n url: 'https://echo.hoppscotch.io/post',\n method: 'POST',\n header: {\n 'Content-Type': 'application/json',\n 'Authorization': 'Bearer ' + pm.environment.get('token')\n },\n body: {\n mode: 'raw',\n raw: JSON.stringify({ name: 'John Doe', action: 'create' })\n }\n }\n\n pm.sendRequest(requestObject, (error, response) => {\n pm.expect(error).to.be.null\n pm.expect(response.code).to.equal(200)\n pm.expect(response.body).to.be.a('string')\n \n // Parse and validate response\n const jsonResponse = response.json()\n pm.expect(jsonResponse).to.be.an('object')\n pm.expect(jsonResponse.data).to.be.a('string')\n \n // Store user ID from response\n pm.environment.set('userId', 'user_' + response.code)\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "fetch-pm-interop", + "name": "hopp.fetch() and pm.sendRequest() - Interoperability", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "// Test that both hopp.fetch() and pm.sendRequest() work\nhopp.test('hopp.fetch() should work and store results', async () => {\n const fetchResponse = await hopp.fetch('https://echo.hoppscotch.io/status/200')\n hopp.expect(fetchResponse.status).toBe(200)\n \n // Store in environment\n hopp.env.set('FETCH_STATUS', fetchResponse.status.toString())\n \n // Verify it was stored\n const storedStatus = hopp.env.get('FETCH_STATUS')\n hopp.expect(storedStatus).toBe('200')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "fetch-json-parsing", + "name": "hopp.fetch() - JSON response parsing", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io/status/200", + "params": [], + "headers": [ + { + "key": "Accept", + "value": "application/json", + "active": true, + "description": "" + } + ], + "preRequestScript": "", + "testScript": "hopp.test('hopp.fetch() should parse JSON response', async () => {\n const response = await hopp.fetch('https://echo.hoppscotch.io/status/200', {\n headers: { 'Accept': 'application/json' }\n })\n\n hopp.expect(response.status).toBe(200)\n\n const json = await response.json()\n hopp.expect(typeof json).toBe('object')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "fetch-headers-access", + "name": "hopp.fetch() - Response headers access", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io/headers", + "params": [], + "headers": [ + { + "key": "X-Custom-Test", + "value": "test-value", + "active": true, + "description": "" + } + ], + "preRequestScript": "", + "testScript": "hopp.test('hopp.fetch() should access response headers', async () => {\n const response = await hopp.fetch('https://echo.hoppscotch.io/headers', {\n headers: { 'X-Custom-Test': 'test-value' }\n })\n\n hopp.expect(response.status).toBe(200)\n hopp.expect(response.headers).toBeType('object')\n\n const contentType = response.headers.get('content-type')\n if (contentType) {\n hopp.expect(typeof contentType).toBe('string')\n }\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "pm-sendrequest-formdata", + "name": "pm.sendRequest() - FormData body mode", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io/post", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "pm.test('pm.sendRequest() should handle FormData body', () => {\n pm.sendRequest({\n url: 'https://echo.hoppscotch.io/post',\n method: 'POST',\n body: {\n mode: 'formdata',\n formdata: [\n { key: 'field1', value: 'value1' },\n { key: 'field2', value: 'value2' },\n { key: 'username', value: 'testuser' }\n ]\n }\n }, (error, response) => {\n pm.expect(error).to.be.null\n pm.expect(response.code).to.equal(200)\n pm.expect(typeof response.body).to.equal('string')\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "pm-sendrequest-json-parsing", + "name": "pm.sendRequest() - JSON parsing method", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io/post", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "pm.test('pm.sendRequest() response.json() should parse JSON', () => {\n pm.sendRequest({\n url: 'https://echo.hoppscotch.io/post',\n method: 'POST',\n header: [\n { key: 'Content-Type', value: 'application/json' }\n ],\n body: {\n mode: 'raw',\n raw: JSON.stringify({ test: 'data', number: 42 })\n }\n }, (error, response) => {\n pm.expect(error).to.be.null\n pm.expect(response.code).to.equal(200)\n\n const data = response.json()\n pm.expect(data).to.be.an('object')\n pm.expect(data).to.not.be.null\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "pm-sendrequest-headers-extraction", + "name": "pm.sendRequest() - Response headers extraction", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io/headers", + "params": [], + "headers": [ + { + "key": "X-Test-Header", + "value": "test-123", + "active": true, + "description": "" + } + ], + "preRequestScript": "", + "testScript": "pm.test('pm.sendRequest() should extract specific headers', () => {\n pm.sendRequest({\n url: 'https://echo.hoppscotch.io/headers',\n header: [\n { key: 'X-Test-Header', value: 'test-123' }\n ]\n }, (error, response) => {\n pm.expect(error).to.be.null\n pm.expect(response.code).to.equal(200)\n pm.expect(Array.isArray(response.headers)).to.be.true\n\n const contentType = response.headers.find(h =>\n h.key.toLowerCase() === 'content-type'\n )\n pm.expect(contentType).to.exist\n pm.expect(contentType.value).to.be.a('string')\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "fetch-network-error", + "name": "hopp.fetch() - Network error handling", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io/status/200", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "hopp.test('hopp.fetch() should handle network errors', async () => {\n let errorCaught = false\n\n try {\n await hopp.fetch('https://this-domain-definitely-does-not-exist-12345.com')\n } catch (error) {\n errorCaught = true\n hopp.expect(error).toBeType('object')\n hopp.expect(error.message).toBeType('string')\n }\n\n hopp.expect(errorCaught).toBe(true)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "pm-sendrequest-network-error", + "name": "pm.sendRequest() - Network error callback", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io/status/200", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "pm.test('pm.sendRequest() should trigger error callback on network failure', () => {\n pm.sendRequest('https://this-domain-definitely-does-not-exist-12345.com', (error, response) => {\n pm.expect(error).to.not.be.null\n pm.expect(error.message).to.be.a('string')\n pm.expect(response).to.be.null\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "fetch-sequential-requests", + "name": "hopp.fetch() - Sequential requests chain", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io/status/200", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "hopp.test('hopp.fetch() should chain multiple requests', async () => {\n const response1 = await hopp.fetch('https://echo.hoppscotch.io/status/200')\n hopp.expect(response1.status).toBe(200)\n\n hopp.env.set('CHAIN_STATUS', response1.status.toString())\n\n const firstStatus = hopp.env.get('CHAIN_STATUS')\n const response2 = await hopp.fetch(`https://echo.hoppscotch.io/status/${firstStatus}`, {\n headers: { 'X-Chain-Step': '2' }\n })\n hopp.expect(response2.status).toBe(200)\n\n const response3 = await hopp.fetch('https://echo.hoppscotch.io/headers', {\n headers: { 'X-Chain-Step': '3', 'X-Previous-Status': firstStatus }\n })\n hopp.expect(response3.status).toBe(200)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "pm-sendrequest-nested", + "name": "pm.sendRequest() - Nested requests", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io/status/200", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "pm.test('pm.sendRequest() should support nested requests', () => {\n pm.sendRequest('https://echo.hoppscotch.io/status/200', (error1, response1) => {\n pm.expect(error1).to.be.null\n pm.expect(response1.code).to.equal(200)\n\n pm.environment.set('NESTED_STATUS', response1.code.toString())\n\n pm.sendRequest({\n url: 'https://echo.hoppscotch.io/headers',\n header: [\n { key: 'X-Parent-Status', value: pm.environment.get('NESTED_STATUS') }\n ]\n }, (error2, response2) => {\n pm.expect(error2).to.be.null\n pm.expect(response2.code).to.equal(200)\n })\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "fetch-binary-response", + "name": "hopp.fetch() - Binary response (arrayBuffer)", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io/bytes/100", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "hopp.test('hopp.fetch() should handle binary responses', async () => {\n const response = await hopp.fetch('https://echo.hoppscotch.io/bytes/100')\n hopp.expect(response.status).toBe(200)\n\n const buffer = await response.arrayBuffer()\n hopp.expect(typeof buffer).toBe('object')\n const size = (buffer && typeof buffer.byteLength === 'number') ? buffer.byteLength : Object.keys(buffer || {}).length\n hopp.expect(size > 0).toBe(true)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "pm-sendrequest-empty-response", + "name": "pm.sendRequest() - Empty response body (204)", + "method": "DELETE", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "pm.test('pm.sendRequest() should handle responses correctly', () => {\n pm.sendRequest({\n url: 'https://echo.hoppscotch.io',\n method: 'GET'\n }, (error, response) => {\n pm.expect(error).to.be.null\n pm.expect(response.code).to.satisfy(code => code >= 200 && code < 300)\n pm.expect(response.body).to.be.a('string')\n\n const jsonResult = response.json()\n pm.expect(jsonResult === null || typeof jsonResult === 'object').to.be.true\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "async_patterns_prereq", + "name": "Async Patterns - Pre-Request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "// Test 1: Top-level await (most common pattern)\nconst response1 = await hopp.fetch('https://echo.hoppscotch.io?test=toplevel-await')\nconst data1 = await response1.json()\nhopp.env.active.set('async_toplevel_status', response1.status.toString())\nhopp.env.active.set('async_toplevel_arg', data1.args.test)\n\n// Test 2: .then() chaining pattern\nhopp.fetch('https://echo.hoppscotch.io?test=then-chain')\n .then(response => {\n hopp.env.active.set('async_then_status', response.status.toString())\n return response.json()\n })\n .then(data => {\n hopp.env.active.set('async_then_arg', data.args.test)\n })\n\n// Test 3: Mixed pattern - await with .then()\nawait hopp.fetch('https://echo.hoppscotch.io?test=mixed')\n .then(async response => {\n hopp.env.active.set('async_mixed_status', response.status.toString())\n const data = await response.json()\n hopp.env.active.set('async_mixed_arg', data.args.test)\n })\n\n// Test 4: Promise.all with await\nconst [r1, r2] = await Promise.all([\n hopp.fetch('https://echo.hoppscotch.io?test=parallel1'),\n hopp.fetch('https://echo.hoppscotch.io?test=parallel2')\n])\nconst [d1, d2] = await Promise.all([r1.json(), r2.json()])\nhopp.env.active.set('async_parallel1', d1.args.test)\nhopp.env.active.set('async_parallel2', d2.args.test)\n", + "testScript": "hopp.test('Pre-request top-level await works', () => {\n hopp.expect(hopp.env.active.get('async_toplevel_status')).toBe('200')\n hopp.expect(hopp.env.active.get('async_toplevel_arg')).toBe('toplevel-await')\n})\n\nhopp.test('Pre-request .then() chain works', () => {\n hopp.expect(hopp.env.active.get('async_then_status')).toBe('200')\n hopp.expect(hopp.env.active.get('async_then_arg')).toBe('then-chain')\n})\n\nhopp.test('Pre-request mixed await/.then() works', () => {\n hopp.expect(hopp.env.active.get('async_mixed_status')).toBe('200')\n hopp.expect(hopp.env.active.get('async_mixed_arg')).toBe('mixed')\n})\n\nhopp.test('Pre-request Promise.all works', () => {\n hopp.expect(hopp.env.active.get('async_parallel1')).toBe('parallel1')\n hopp.expect(hopp.env.active.get('async_parallel2')).toBe('parallel2')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "async_patterns_test", + "name": "Async Patterns - Test Script", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "// Empty pre-request - all tests in test script\n", + "testScript": "// Test 1: Top-level await in test script\nconst response1 = await hopp.fetch('https://echo.hoppscotch.io?test=test-toplevel')\nconst data1 = await response1.json()\n\nhopp.test('Test script top-level await works', () => {\n hopp.expect(response1.status).toBe(200)\n hopp.expect(data1.args.test).toBe('test-toplevel')\n})\n\n// Test 2: await inside hopp.test callback\nhopp.test('Await inside test callback works', async () => {\n const response = await hopp.fetch('https://echo.hoppscotch.io?test=inside-callback')\n hopp.expect(response.status).toBe(200)\n const data = await response.json()\n hopp.expect(data.args.test).toBe('inside-callback')\n})\n\n// Test 3: .then() inside test callback\nhopp.test('.then() inside test callback works', () => {\n return hopp.fetch('https://echo.hoppscotch.io?test=then-callback')\n .then(response => {\n hopp.expect(response.status).toBe(200)\n return response.json()\n })\n .then(data => {\n hopp.expect(data.args.test).toBe('then-callback')\n })\n})\n\n// Test 4: Mixed pattern in test\nhopp.test('Mixed pattern in test works', async () => {\n await hopp.fetch('https://echo.hoppscotch.io?test=mixed-test')\n .then(response => response.json())\n .then(data => {\n hopp.expect(data.args.test).toBe('mixed-test')\n })\n})\n\n// Test 5: Promise.all in test callback\nhopp.test('Promise.all in test callback works', async () => {\n const responses = await Promise.all([\n hopp.fetch('https://echo.hoppscotch.io?id=1'),\n hopp.fetch('https://echo.hoppscotch.io?id=2')\n ])\n hopp.expect(responses[0].status).toBe(200)\n hopp.expect(responses[1].status).toBe(200)\n const dataArray = await Promise.all(responses.map(r => r.json()))\n hopp.expect(dataArray[0].args.id).toBe('1')\n hopp.expect(dataArray[1].args.id).toBe('2')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "workflow_patterns", + "name": "Workflow Patterns (Sequential, Parallel, Auth)", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "// Test 1: Sequential requests with .then chaining\nhopp.fetch('https://echo.hoppscotch.io?step=1')\n .then(r => r.json())\n .then(d1 => {\n hopp.env.active.set('seq_step1', d1.args.step)\n return hopp.fetch(`https://echo.hoppscotch.io?step=2&prev=${d1.args.step}`)\n })\n .then(r => r.json())\n .then(d2 => {\n hopp.env.active.set('seq_step2', d2.args.step)\n hopp.env.active.set('seq_prev', d2.args.prev)\n })\n\n// Test 2: Parallel with Promise.all and mixed patterns\nconst parallelPromises = [\n hopp.fetch('https://echo.hoppscotch.io?id=1').then(r => r.json()),\n hopp.fetch('https://echo.hoppscotch.io?id=2').then(r => r.json()),\n hopp.fetch('https://echo.hoppscotch.io?id=3').then(r => r.json())\n]\n\nawait Promise.all(parallelPromises).then(results => {\n hopp.env.active.set('parallel_id1', results[0].args.id)\n hopp.env.active.set('parallel_id2', results[1].args.id)\n hopp.env.active.set('parallel_id3', results[2].args.id)\n})\n\n// Test 3: Auth workflow\nconst authResp = await hopp.fetch('https://echo.hoppscotch.io?action=login&user=testuser')\nconst authData = await authResp.json()\nconst token = `${authData.args.action}_token_${authData.args.user}`\nhopp.env.active.set('workflow_token', token)\n\nconst dataResp = await hopp.fetch('https://echo.hoppscotch.io?action=fetch', {\n headers: { 'Authorization': `Bearer ${token}` }\n})\nconst data = await dataResp.json()\nhopp.env.active.set('workflow_auth_header', data.headers['authorization'])\n", + "testScript": "hopp.test('Sequential requests work', () => {\n hopp.expect(hopp.env.active.get('seq_step1')).toBe('1')\n hopp.expect(hopp.env.active.get('seq_step2')).toBe('2')\n hopp.expect(hopp.env.active.get('seq_prev')).toBe('1')\n})\n\nhopp.test('Parallel requests work', () => {\n hopp.expect(hopp.env.active.get('parallel_id1')).toBe('1')\n hopp.expect(hopp.env.active.get('parallel_id2')).toBe('2')\n hopp.expect(hopp.env.active.get('parallel_id3')).toBe('3')\n})\n\nhopp.test('Auth workflow works', () => {\n const token = hopp.env.active.get('workflow_token')\n hopp.expect(token).toInclude('login_token_testuser')\n hopp.expect(hopp.env.active.get('workflow_auth_header')).toBe(`Bearer ${token}`)\n})\n\n// Test 4: Complex workflow in test with mixed async\nhopp.test('Complex workflow in test works', async () => {\n // First request with await\n const r1 = await hopp.fetch('https://echo.hoppscotch.io?workflow=start')\n const d1 = await r1.json()\n const workflowId = d1.args.workflow\n \n // Second request with .then chaining\n await hopp.fetch(`https://echo.hoppscotch.io?workflow=${workflowId}&step=2`)\n .then(r => r.json())\n .then(d => {\n hopp.expect(d.args.workflow).toBe('start')\n hopp.expect(d.args.step).toBe('2')\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "error_handling_combined", + "name": "Error Handling & Edge Cases", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "// Test 1: Error handling with try/catch\nlet errorOccurred = false\ntry {\n const response = await hopp.fetch('https://echo.hoppscotch.io')\n if (!response.ok) {\n errorOccurred = true\n }\n hopp.env.active.set('fetch_success', 'true')\n} catch (error) {\n errorOccurred = true\n hopp.env.active.set('fetch_success', 'false')\n}\nhopp.env.active.set('error_occurred', errorOccurred.toString())\n\n// Test 2: Bearer token auth\nconst token = 'sample_bearer_token_abc123'\nconst authResp = await hopp.fetch('https://echo.hoppscotch.io', {\n headers: { 'Authorization': `Bearer ${token}` }\n})\nconst authData = await authResp.json()\nhopp.env.active.set('sent_auth_header', authData.headers['authorization'] || 'missing')\n\n// Test 3: Content negotiation headers\nconst contentResp = await hopp.fetch('https://echo.hoppscotch.io', {\n headers: {\n 'Accept': 'application/json, text/plain, */*',\n 'Accept-Language': 'en-US,en;q=0.9',\n 'Accept-Encoding': 'gzip, deflate, br'\n }\n})\nconst contentData = await contentResp.json()\nhopp.env.active.set('accept_header', contentData.headers['accept'] || 'missing')\n", + "testScript": "hopp.test('Error handling works', () => {\n hopp.expect(hopp.env.active.get('fetch_success')).toBe('true')\n hopp.expect(hopp.env.active.get('error_occurred')).toBe('false')\n})\n\nhopp.test('Bearer token auth works', () => {\n const token = 'sample_bearer_token_abc123'\n hopp.expect(hopp.env.active.get('sent_auth_header')).toBe(`Bearer ${token}`)\n})\n\nhopp.test('Content negotiation works', () => {\n hopp.expect(hopp.env.active.get('accept_header')).toInclude('application/json')\n})\n\n// Test error handling in test script with .then().catch()\nhopp.test('Error handling with .catch() works', () => {\n return hopp.fetch('https://echo.hoppscotch.io')\n .then(r => {\n hopp.expect(r.ok).toBe(true)\n return r.json()\n })\n .then(d => {\n hopp.expect(d.method).toBe('GET')\n })\n .catch(error => {\n hopp.expect(true).toBe(false) // Should not reach here\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "large_payload_formdata", + "name": "Large Payload & FormData", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "// Test 1: Large JSON payload with .then pattern\nconst largePayload = {\n items: Array.from({ length: 100 }, (_, i) => ({\n id: i,\n name: `Item ${i}`,\n description: `Description for item ${i}`,\n metadata: {\n created: new Date().toISOString(),\n index: i,\n active: i % 2 === 0\n }\n }))\n}\n\nhopp.fetch('https://echo.hoppscotch.io', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(largePayload)\n}).then(r => r.json()).then(d => {\n const receivedData = JSON.parse(d.data)\n hopp.env.active.set('large_count', receivedData.items.length.toString())\n hopp.env.active.set('large_first_id', receivedData.items[0].id.toString())\n hopp.env.active.set('large_last_id', receivedData.items[99].id.toString())\n})\n\n// Test 2: FormData handling (if available)\ntry {\n if (typeof FormData !== 'undefined') {\n const formData = new FormData()\n formData.append('field1', 'value1')\n formData.append('field2', 'value2')\n const formResp = await hopp.fetch('https://echo.hoppscotch.io', {\n method: 'POST',\n body: formData\n })\n const formRespData = await formResp.json()\n hopp.env.active.set('formdata_status', formResp.status.toString())\n } else {\n hopp.env.active.set('formdata_status', 'skipped')\n }\n} catch (error) {\n hopp.env.active.set('formdata_status', 'error')\n}\n", + "testScript": "hopp.test('Large JSON payload works', () => {\n hopp.expect(hopp.env.active.get('large_count')).toBe('100')\n hopp.expect(hopp.env.active.get('large_first_id')).toBe('0')\n hopp.expect(hopp.env.active.get('large_last_id')).toBe('99')\n})\n\nhopp.test('FormData handling works', () => {\n const status = hopp.env.active.get('formdata_status')\n if (status === 'skipped') {\n hopp.expect(status).toBe('skipped')\n } else {\n hopp.expect(status).toBe('200')\n }\n})\n\n// Test large payload in test script with async/await\nhopp.test('Large payload in test script works', async () => {\n const payload = {\n data: Array.from({ length: 50 }, (_, i) => ({ index: i, value: `test_${i}` }))\n }\n const response = await hopp.fetch('https://echo.hoppscotch.io', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload)\n })\n const data = await response.json()\n const received = JSON.parse(data.data)\n hopp.expect(received.data.length).toBe(50)\n hopp.expect(received.data[0].index).toBe(0)\n hopp.expect(received.data[49].value).toBe('test_49')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "get_methods_combined", + "name": "GET Methods (Query, Headers, URL)", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "// Test 1: Query parameters\nconst qResponse = await hopp.fetch('https://echo.hoppscotch.io?foo=bar&baz=qux&test=123')\nconst qData = await qResponse.json()\nhopp.env.active.set('query_foo', qData.args.foo || 'missing')\nhopp.env.active.set('query_baz', qData.args.baz || 'missing')\nhopp.env.active.set('query_test', qData.args.test || 'missing')\n\n// Test 2: Custom headers\nconst hResponse = await hopp.fetch('https://echo.hoppscotch.io', {\n headers: {\n 'X-Custom-Header': 'CustomValue123',\n 'X-API-Key': 'secret-key-456',\n 'User-Agent': 'HoppscotchTest/1.0'\n }\n})\nconst hData = await hResponse.json()\nhopp.env.active.set('custom_header', hData.headers['x-custom-header'] || 'missing')\nhopp.env.active.set('api_key_header', hData.headers['x-api-key'] || 'missing')\n\n// Test 3: URL object\nconst urlObj = new URL('https://echo.hoppscotch.io')\nurlObj.searchParams.append('url_test', 'url-object')\nurlObj.searchParams.append('value', '42')\nconst uResponse = await hopp.fetch(urlObj)\nconst uData = await uResponse.json()\nhopp.env.active.set('url_obj_test', uData.args.url_test)\nhopp.env.active.set('url_obj_value', uData.args.value)\n\n// Test 4: Special characters\nconst searchQuery = 'test & special = chars'\nconst encodedQuery = encodeURIComponent(searchQuery)\nconst sResponse = await hopp.fetch(`https://echo.hoppscotch.io?q=${encodedQuery}&other=value`)\nconst sData = await sResponse.json()\nhopp.env.active.set('special_chars_q', sData.args.q)\nhopp.env.active.set('special_chars_other', sData.args.other)\n", + "testScript": "hopp.test('Query parameters work', () => {\n hopp.expect(hopp.env.active.get('query_foo')).toBe('bar')\n hopp.expect(hopp.env.active.get('query_baz')).toBe('qux')\n hopp.expect(hopp.env.active.get('query_test')).toBe('123')\n})\n\nhopp.test('Custom headers work', () => {\n hopp.expect(hopp.env.active.get('custom_header')).toBe('CustomValue123')\n hopp.expect(hopp.env.active.get('api_key_header')).toBe('secret-key-456')\n})\n\nhopp.test('URL object works', () => {\n hopp.expect(hopp.env.active.get('url_obj_test')).toBe('url-object')\n hopp.expect(hopp.env.active.get('url_obj_value')).toBe('42')\n})\n\nhopp.test('Special characters in URL work', () => {\n hopp.expect(hopp.env.active.get('special_chars_q')).toBe('test & special = chars')\n hopp.expect(hopp.env.active.get('special_chars_other')).toBe('value')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "post_methods_combined", + "name": "POST Methods (JSON, URLEncoded, Binary)", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "// Test 1: POST with JSON body (await pattern)\nconst jsonBody = {\n name: 'John Doe',\n email: 'john@example.com',\n age: 30,\n active: true\n}\n\nconst jsonResponse = await hopp.fetch('https://echo.hoppscotch.io', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(jsonBody)\n})\nconst jsonData = await jsonResponse.json()\nconst receivedJson = JSON.parse(jsonData.data)\nhopp.env.active.set('post_json_name', receivedJson.name)\nhopp.env.active.set('post_json_email', receivedJson.email)\n\n// Test 2: POST with URL-encoded body (.then pattern)\nconst params = new URLSearchParams()\nparams.append('username', 'testuser')\nparams.append('password', 'testpass123')\n\nhopp.fetch('https://echo.hoppscotch.io', {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: params.toString()\n}).then(response => response.json())\n .then(data => {\n hopp.env.active.set('urlencoded_data', data.data || 'missing')\n hopp.env.active.set('urlencoded_ct', data.headers['content-type'] || 'missing')\n })\n\n// Test 3: Binary POST\nconst binaryData = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x21]) // \"Hello!\"\nawait hopp.fetch('https://echo.hoppscotch.io', {\n method: 'POST',\n headers: { 'Content-Type': 'application/octet-stream' },\n body: binaryData\n}).then(r => r.json()).then(d => {\n hopp.env.active.set('binary_method', d.method)\n hopp.env.active.set('binary_ct', d.headers['content-type'] || 'missing')\n})\n\n// Test 4: Empty body POST\nconst emptyResponse = await hopp.fetch('https://echo.hoppscotch.io', {\n method: 'POST'\n})\nconst emptyData = await emptyResponse.json()\nhopp.env.active.set('empty_post_method', emptyData.method)\n", + "testScript": "hopp.test('POST JSON body works', () => {\n hopp.expect(hopp.env.active.get('post_json_name')).toBe('John Doe')\n hopp.expect(hopp.env.active.get('post_json_email')).toBe('john@example.com')\n})\n\nhopp.test('POST URL-encoded body works', () => {\n hopp.expect(hopp.env.active.get('urlencoded_data')).toInclude('username=testuser')\n hopp.expect(hopp.env.active.get('urlencoded_ct')).toInclude('application/x-www-form-urlencoded')\n})\n\nhopp.test('Binary POST works', () => {\n hopp.expect(hopp.env.active.get('binary_method')).toBe('POST')\n hopp.expect(hopp.env.active.get('binary_ct')).toInclude('application/octet-stream')\n})\n\nhopp.test('Empty body POST works', () => {\n hopp.expect(hopp.env.active.get('empty_post_method')).toBe('POST')\n})\n\n// Test 5: POST in test script with .then()\nhopp.test('POST in test script works', () => {\n return hopp.fetch('https://echo.hoppscotch.io', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ test: 'from-test-script' })\n }).then(r => r.json()).then(d => {\n const body = JSON.parse(d.data)\n hopp.expect(body.test).toBe('from-test-script')\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "http_methods_combined", + "name": "HTTP Methods (PUT, PATCH, DELETE)", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "// Test PUT with mixed async\nawait hopp.fetch('https://echo.hoppscotch.io', {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ id: 123, name: 'Updated' })\n}).then(r => r.json()).then(d => {\n hopp.env.active.set('put_method', d.method)\n})\n\n// Test PATCH with await\nconst patchResp = await hopp.fetch('https://echo.hoppscotch.io', {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ field: 'updated' })\n})\nconst patchData = await patchResp.json()\nhopp.env.active.set('patch_method', patchData.method)\n\n// Test DELETE with .then\nhopp.fetch('https://echo.hoppscotch.io/resource/123', {\n method: 'DELETE'\n}).then(r => r.json()).then(d => {\n hopp.env.active.set('delete_method', d.method)\n hopp.env.active.set('delete_path', d.path || 'missing')\n})\n", + "testScript": "hopp.test('PUT method works', () => {\n hopp.expect(hopp.env.active.get('put_method')).toBe('PUT')\n})\n\nhopp.test('PATCH method works', () => {\n hopp.expect(hopp.env.active.get('patch_method')).toBe('PATCH')\n})\n\nhopp.test('DELETE method works', () => {\n hopp.expect(hopp.env.active.get('delete_method')).toBe('DELETE')\n hopp.expect(hopp.env.active.get('delete_path')).toInclude('/resource/123')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "response_parsing_combined", + "name": "Response Parsing (Headers, Status, Body)", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "const response = await hopp.fetch('https://echo.hoppscotch.io')\n\n// Test headers access\nconst contentType = response.headers.get('content-type')\nlet headerCount = 0\nfor (const [key, value] of response.headers.entries()) {\n headerCount++\n}\n\nif (contentType) {\n hopp.env.active.set('has_content_type', (contentType !== null).toString())\n}\n\nhopp.env.active.set('header_count', headerCount.toString())\n\n// Test status properties\nhopp.env.active.set('resp_status', response.status.toString())\nhopp.env.active.set('resp_ok', response.ok.toString())\nhopp.env.active.set('resp_status_text', response.statusText || 'empty')\n\n// Test text parsing\nconst text = await response.text()\nhopp.env.active.set('text_length', text.length.toString())\nhopp.env.active.set('is_string', (typeof text === 'string').toString())\n", + "testScript": "hopp.test('Response headers accessible', () => {\n // Agent interceptor doesn't return content type\n const hasContentType = hopp.env.active.get('has_content_type')\n if (hasContentType) {\n hopp.expect(hopp.env.active.get('has_content_type')).toBe('true')\n }\n\n const headerCount = parseInt(hopp.env.active.get('header_count'))\n hopp.expect(headerCount > 0).toBe(true)\n})\n\nhopp.test('Response status properties work', () => {\n hopp.expect(hopp.env.active.get('resp_status')).toBe('200')\n hopp.expect(hopp.env.active.get('resp_ok')).toBe('true')\n})\n\nhopp.test('response.text() works', () => {\n const textLength = parseInt(hopp.env.active.get('text_length'))\n hopp.expect(textLength > 0).toBe(true)\n hopp.expect(hopp.env.active.get('is_string')).toBe('true')\n})\n\n// Test async parsing in test script\nhopp.test('Async response parsing in test works', async () => {\n const response = await hopp.fetch('https://echo.hoppscotch.io?test=parse')\n const data = await response.json()\n hopp.expect(data.args.test).toBe('parse')\n\n // Agent interceptor doesn't return content type\n const contentType = response.headers.get('content-type')\n if (contentType) {\n hopp.expect(contentType).toInclude('json')\n }\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "dynamic_url_construction", + "name": "Dynamic URL Construction", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "// Dynamic URL building with template literals and mixed async\nconst baseUrl = 'https://echo.hoppscotch.io'\nconst endpoint = '/api/users'\nconst params = {\n page: 1,\n limit: 10,\n sort: 'name',\n filter: 'active'\n}\n\nconst queryString = Object.entries(params)\n .map(([key, value]) => `${key}=${value}`)\n .join('&')\n\nconst fullUrl = `${baseUrl}${endpoint}?${queryString}`\n\nawait hopp.fetch(fullUrl)\n .then(r => r.json())\n .then(d => {\n hopp.env.active.set('dynamic_path', d.path || 'missing')\n hopp.env.active.set('param_page', d.args.page)\n hopp.env.active.set('param_limit', d.args.limit)\n hopp.env.active.set('param_sort', d.args.sort)\n })\n", + "testScript": "hopp.test('Dynamic URL construction works', () => {\n hopp.expect(hopp.env.active.get('dynamic_path')).toInclude('/api/users')\n hopp.expect(hopp.env.active.get('param_page')).toBe('1')\n hopp.expect(hopp.env.active.get('param_limit')).toBe('10')\n hopp.expect(hopp.env.active.get('param_sort')).toBe('name')\n})\n\n// Test dynamic URL in test script with .then\nhopp.test('Dynamic URL in test script works', () => {\n const base = 'https://echo.hoppscotch.io'\n const path = '/test/path'\n const query = '?key=value'\n \n return hopp.fetch(`${base}${path}${query}`)\n .then(r => r.json())\n .then(d => {\n hopp.expect(d.path).toInclude('/test/path')\n hopp.expect(d.args.key).toBe('value')\n })\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "crypto_module_test", + "name": "crypto-module-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "// Hoppscotch Sandbox Crypto Module Tests\n// NOTE: The sandbox crypto API accepts plain arrays instead of TypedArrays.\n// Results include both .length and .byteLength for compatibility.\n// @ts-expect-error comments suppress Monaco's Web Crypto type checking for inputs.\n\n// Test crypto.randomUUID()\nconst uuid = crypto.randomUUID()\npm.environment.set('test_uuid', uuid)\n\n// Test crypto.getRandomValues()\nconst randomBytes = new Array(16).fill(0)\n// @ts-expect-error - Sandbox accepts plain arrays, not just TypedArrays\ncrypto.getRandomValues(randomBytes)\npm.environment.set('random_bytes_length', randomBytes.length)\npm.environment.set('has_random_values', randomBytes.some(v => v !== 0))\n\n// Test crypto.subtle.digest() with SHA-256\nconst testData = [72, 101, 108, 108, 111] // 'Hello' as bytes\n// @ts-expect-error - Sandbox accepts plain arrays as data input\nconst hash = await crypto.subtle.digest('SHA-256', testData)\npm.environment.set('hash_length', hash.byteLength)\n\n// Test crypto.subtle.generateKey() and sign/verify with HMAC\nconst hmacKey = await crypto.subtle.generateKey(\n { name: 'HMAC', hash: 'SHA-256' },\n true,\n ['sign', 'verify']\n)\npm.environment.set('hmac_key_type', hmacKey.type)\n\n// @ts-expect-error - Sandbox accepts plain arrays as data input\nconst signature = await crypto.subtle.sign('HMAC', hmacKey, testData)\npm.environment.set('signature_length', signature.byteLength)\n\n// @ts-expect-error - Sandbox accepts plain arrays for signature and data\nconst isValid = await crypto.subtle.verify('HMAC', hmacKey, signature, testData)\npm.environment.set('signature_valid', isValid)\n\n// Test crypto.subtle.generateKey() with AES-GCM\nconst aesKey = await crypto.subtle.generateKey(\n { name: 'AES-GCM', length: 256 },\n true,\n ['encrypt', 'decrypt']\n)\npm.environment.set('aes_key_type', aesKey.type)\n\n// Test encrypt/decrypt with AES-GCM\nconst iv = new Array(12).fill(0).map((_, i) => i)\nconst plaintext = [83, 101, 99, 114, 101, 116] // 'Secret' as bytes\n\n// @ts-expect-error - Sandbox accepts plain arrays for iv and plaintext\nconst ciphertext = await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv },\n aesKey,\n plaintext\n)\npm.environment.set('ciphertext_length', ciphertext.byteLength)\n\n// @ts-expect-error - Sandbox accepts plain arrays for iv and ciphertext\nconst decrypted = await crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: iv },\n aesKey,\n ciphertext\n)\n// Compare decrypted bytes to original plaintext\nlet decryptMatch = decrypted.length === plaintext.length\nif (decryptMatch) {\n for (let i = 0; i < plaintext.length; i++) {\n if (decrypted[i] !== plaintext[i]) { decryptMatch = false; break }\n }\n}\npm.environment.set('decrypted_matches', decryptMatch)\n\n// Test RSA-OAEP encryption/decryption\nconst rsaKeyPair = await crypto.subtle.generateKey(\n {\n name: 'RSA-OAEP',\n modulusLength: 2048,\n // @ts-expect-error - Sandbox accepts plain array for publicExponent\n publicExponent: [1, 0, 1],\n hash: 'SHA-256'\n },\n true,\n ['encrypt', 'decrypt']\n)\n\n// @ts-expect-error - Sandbox accepts plain arrays as data input\nconst rsaCiphertext = await crypto.subtle.encrypt(\n { name: 'RSA-OAEP' },\n rsaKeyPair.publicKey,\n testData\n)\npm.environment.set('rsa_ciphertext_length', rsaCiphertext.byteLength)\n\nconst rsaDecrypted = await crypto.subtle.decrypt(\n { name: 'RSA-OAEP' },\n rsaKeyPair.privateKey,\n rsaCiphertext\n)\n// Compare RSA decrypted bytes to original testData\nlet rsaMatch = rsaDecrypted.length === testData.length\nif (rsaMatch) {\n for (let i = 0; i < testData.length; i++) {\n if (rsaDecrypted[i] !== testData[i]) { rsaMatch = false; break }\n }\n}\npm.environment.set('rsa_decrypted_matches', rsaMatch)\n\n// Test PBKDF2 key derivation\n// @ts-expect-error - Sandbox accepts plain array as key data\nconst passwordKey = await crypto.subtle.importKey(\n 'raw',\n [112, 97, 115, 115, 119, 111, 114, 100], // 'password'\n { name: 'PBKDF2' },\n false,\n ['deriveKey']\n)\n\nconst derivedKey = await crypto.subtle.deriveKey(\n {\n name: 'PBKDF2',\n hash: 'SHA-256',\n // @ts-expect-error - Sandbox accepts plain array for salt\n salt: [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],\n iterations: 1000\n },\n passwordKey,\n { name: 'AES-GCM', length: 256 },\n true,\n ['encrypt', 'decrypt']\n)\npm.environment.set('pbkdf2_key_type', derivedKey.type)\n", + "testScript": "const getNum = (key) => {\n const v = pm.environment.get(key)\n return typeof v === 'number' ? v : parseInt(String(v), 10)\n}\n\nconst getBool = (key) => {\n const v = pm.environment.get(key)\n return String(v) === 'true'\n}\n\n// crypto.randomUUID() tests\npm.test('crypto.randomUUID() generates valid UUID v4 format', () => {\n const uuid = pm.environment.get('test_uuid')\n pm.expect(uuid).to.be.a('string')\n pm.expect(uuid.length).to.equal(36)\n \n // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx\n const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\n pm.expect(uuidPattern.test(uuid)).to.be.true\n})\n\n// crypto.getRandomValues() tests\npm.test('crypto.getRandomValues() fills array with random bytes', () => {\n pm.expect(getNum('random_bytes_length')).to.equal(16)\n pm.expect(getBool('has_random_values')).to.be.true\n})\n\n// crypto.subtle.digest() tests\npm.test('crypto.subtle.digest() produces SHA-256 hash', () => {\n pm.expect(getNum('hash_length')).to.equal(32) // SHA-256 = 32 bytes\n})\n\n// HMAC sign/verify tests\npm.test('crypto.subtle.generateKey() creates HMAC key', () => {\n pm.expect(pm.environment.get('hmac_key_type')).to.equal('secret')\n})\n\npm.test('crypto.subtle.sign() produces HMAC signature', () => {\n pm.expect(getNum('signature_length')).to.equal(32) // SHA-256 HMAC = 32 bytes\n})\n\npm.test('crypto.subtle.verify() validates HMAC signature', () => {\n pm.expect(getBool('signature_valid')).to.be.true\n})\n\n// AES-GCM encrypt/decrypt tests\npm.test('crypto.subtle.generateKey() creates AES-GCM key', () => {\n pm.expect(pm.environment.get('aes_key_type')).to.equal('secret')\n})\n\npm.test('crypto.subtle.encrypt() produces ciphertext', () => {\n pm.expect(getNum('ciphertext_length')).to.be.above(0)\n})\n\npm.test('crypto.subtle.decrypt() recovers original plaintext', () => {\n pm.expect(getBool('decrypted_matches')).to.be.true\n})\n\n// RSA-OAEP encrypt/decrypt tests\npm.test('crypto.subtle.encrypt() produces RSA ciphertext', () => {\n pm.expect(getNum('rsa_ciphertext_length')).to.be.above(0)\n})\n\npm.test('crypto.subtle.decrypt() recovers RSA plaintext', () => {\n pm.expect(getBool('rsa_decrypted_matches')).to.be.true\n})\n\n// PBKDF2 key derivation tests\npm.test('crypto.subtle.deriveKey() creates AES key from password', () => {\n pm.expect(pm.environment.get('pbkdf2_key_type')).to.equal('secret')\n})\n\n// Additional in-script crypto tests\npm.test('crypto.randomUUID() generates unique UUIDs', () => {\n const uuid1 = crypto.randomUUID()\n const uuid2 = crypto.randomUUID()\n pm.expect(uuid1).to.not.equal(uuid2)\n})\n\npm.test('crypto.getRandomValues() mutates array in place', () => {\n const arr = new Array(10).fill(0)\n // @ts-expect-error - Sandbox accepts plain arrays, not just TypedArrays\n const result = crypto.getRandomValues(arr)\n pm.expect(result).to.equal(arr)\n pm.expect(arr.some(v => v !== 0)).to.be.true\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + } + ], + "auth": { + "authType": "none", + "authActive": true + }, + "headers": [], + "variables": [], + "description": null +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/secret-envs-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/secret-envs-coll.json new file mode 100644 index 0000000..a79d5a2 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/secret-envs-coll.json @@ -0,0 +1,143 @@ +{ + "v": 2, + "name": "secret-envs-coll", + "folders": [], + "requests": [ + { + "v": "3", + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "test-secret-headers", + "method": "GET", + "params": [], + "headers": [ + { + "key": "Secret-Header-Key", + "value": "<>", + "active": true + } + ], + "requestVariables": [], + "endpoint": "<>/headers", + "testScript": "pw.test(\"Successfully parses secret variable holding the header value\", () => {\n const secretHeaderValue = pw.env.get(\"secretHeaderValue\")\n pw.expect(secretHeaderValue).toBe(\"secret-header-value\")\n \n if (secretHeaderValue) {\n pw.expect(pw.response.body.headers[\"secret-header-key\"]).toBe(secretHeaderValue)\n }\n\n pw.expect(pw.env.get(\"secretHeaderValueFromPreReqScript\")).toBe(\"secret-header-value\")\n})", + "preRequestScript": "const secretHeaderValueFromPreReqScript = pw.env.get(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)" + }, + { + "v": "3", + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "body": "{\n \"secretBodyKey\": \"<>\"\n}", + "contentType": "application/json" + }, + "name": "test-secret-body", + "method": "POST", + "params": [], + "headers": [], + "requestVariables": [], + "endpoint": "<>/post", + "testScript": "pw.test(\"Successfully parses secret variable holding the request body value\", () => {\n const secretBodyValue = pw.env.get(\"secretBodyValue\")\n pw.expect(secretBodyValue).toBe(\"secret-body-value\")\n \n if (secretBodyValue) {\n pw.expect(JSON.parse(pw.response.body.data).secretBodyKey).toBe(secretBodyValue)\n }\n\n pw.expect(pw.env.get(\"secretBodyValueFromPreReqScript\")).toBe(\"secret-body-value\")\n})", + "preRequestScript": "const secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)" + }, + { + "v": "3", + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "test-secret-query-params", + "method": "GET", + "params": [ + { + "key": "secretQueryParamKey", + "value": "<>", + "active": true + } + ], + "headers": [], + "requestVariables": [], + "endpoint": "<>", + "testScript": "pw.test(\"Successfully parses secret variable holding the query param value\", () => {\n const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n pw.expect(secretQueryParamValue).toBe(\"secret-query-param-value\")\n \n if (secretQueryParamValue) {\n pw.expect(pw.response.body.args.secretQueryParamKey).toBe(secretQueryParamValue)\n }\n\n pw.expect(pw.env.get(\"secretQueryParamValueFromPreReqScript\")).toBe(\"secret-query-param-value\")\n})", + "preRequestScript": "const secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)" + }, + { + "v": "3", + "auth": { + "authType": "basic", + "password": "<>", + "username": "<>", + "authActive": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "test-secret-basic-auth", + "method": "GET", + "params": [], + "headers": [], + "requestVariables": [], + "endpoint": "<>/basic-auth/<>/<>", + "testScript": "pw.test(\"Successfully parses secret variables holding basic auth credentials\", () => {\n\tconst secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n \tconst secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\n pw.expect(secretBasicAuthUsername).toBe(\"test-user\")\n pw.expect(secretBasicAuthPassword).toBe(\"test-pass\")\n\n // The endpoint at times results in a `502` bad gateway\n if (pw.response.status !== 200) {\n return\n }\n\n if (secretBasicAuthUsername && secretBasicAuthPassword) {\n const { authenticated, user } = pw.response.body\n pw.expect(authenticated).toBe(true)\n pw.expect(user).toBe(secretBasicAuthUsername)\n }\n});", + "preRequestScript": "" + }, + { + "v": "3", + "auth": { + "token": "<>", + "authType": "bearer", + "password": "testpassword", + "username": "testuser", + "authActive": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "test-secret-bearer-auth", + "method": "GET", + "params": [], + "headers": [], + "requestVariables": [], + "endpoint": "<>/bearer", + "testScript": "pw.test(\"Successfully parses secret variable holding the bearer token\", () => {\n const secretBearerToken = pw.env.get(\"secretBearerToken\")\n const preReqSecretBearerToken = pw.env.get(\"preReqSecretBearerToken\")\n\n pw.expect(secretBearerToken).toBe(\"test-token\")\n\n // Safeguard to prevent test failures due to the endpoint\n if (pw.response.status !== 200) {\n return\n }\n\n if (secretBearerToken) { \n pw.expect(pw.response.body.token).toBe(secretBearerToken)\n pw.expect(preReqSecretBearerToken).toBe(\"test-token\")\n }\n});", + "preRequestScript": "const secretBearerToken = pw.env.get(\"secretBearerToken\")\npw.env.set(\"preReqSecretBearerToken\", secretBearerToken)" + }, + { + "v": "3", + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "test-secret-fallback", + "method": "GET", + "params": [], + "headers": [], + "requestVariables": [], + "endpoint": "<>", + "testScript": "pw.test(\"Returns an empty string if the value for a secret environment variable is not found in the system environment\", () => {\n pw.expect(pw.env.get(\"nonExistentValueInSystemEnv\")).toBe(\"\")\n})", + "preRequestScript": "" + } + ], + "auth": { + "authType": "inherit", + "authActive": false + }, + "headers": [] +} \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/secret-envs-persistence-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/secret-envs-persistence-coll.json new file mode 100644 index 0000000..1de038c --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/secret-envs-persistence-coll.json @@ -0,0 +1,149 @@ +{ + "v": 2, + "name": "secret-envs-persistence-coll", + "folders": [], + "requests": [ + { + "v": "3", + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "test-secret-headers", + "method": "GET", + "params": [], + "requestVariables": [], + "headers": [ + { + "key": "Secret-Header-Key", + "value": "<>", + "active": true + } + ], + "endpoint": "<>", + "testScript": "pw.test(\"Successfully parses secret variable holding the header value\", () => {\n const secretHeaderValue = pw.env.getResolve(\"secretHeaderValue\")\n pw.expect(secretHeaderValue).toBe(\"secret-header-value\")\n \n if (secretHeaderValue) {\n pw.expect(pw.response.body.headers[\"secret-header-key\"]).toBe(secretHeaderValue)\n }\n\n pw.expect(pw.env.getResolve(\"secretHeaderValueFromPreReqScript\")).toBe(\"secret-header-value\")\n})", + "preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)" + }, + { + "v": "3", + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "test-secret-headers-overrides", + "method": "GET", + "params": [], + "requestVariables": [], + "headers": [ + { + "key": "Secret-Header-Key", + "value": "<>", + "active": true + } + ], + "endpoint": "<>", + "testScript": "pw.test(\"Value set at the pre-request script takes precedence\", () => {\n const secretHeaderValue = pw.env.getResolve(\"secretHeaderValue\")\n pw.expect(secretHeaderValue).toBe(\"secret-header-value-overriden\")\n \n if (secretHeaderValue) {\n pw.expect(pw.response.body.headers[\"secret-header-key\"]).toBe(secretHeaderValue)\n }\n\n pw.expect(pw.env.getResolve(\"secretHeaderValueFromPreReqScript\")).toBe(\"secret-header-value-overriden\")\n})", + "preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value-overriden\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)" + }, + { + "v": "3", + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "body": "{\n \"secretBodyKey\": \"<>\"\n}", + "contentType": "application/json" + }, + "name": "test-secret-body", + "method": "POST", + "params": [], + "requestVariables": [], + "headers": [], + "endpoint": "<>/post", + "testScript": "pw.test(\"Successfully parses secret variable holding the request body value\", () => {\n const secretBodyValue = pw.env.get(\"secretBodyValue\")\n pw.expect(secretBodyValue).toBe(\"secret-body-value\")\n \n if (secretBodyValue) {\n pw.expect(JSON.parse(pw.response.body.data).secretBodyKey).toBe(secretBodyValue)\n }\n\n pw.expect(pw.env.get(\"secretBodyValueFromPreReqScript\")).toBe(\"secret-body-value\")\n})", + "preRequestScript": "const secretBodyValue = pw.env.get(\"secretBodyValue\")\n\nif (!secretBodyValue) { \n pw.env.set(\"secretBodyValue\", \"secret-body-value\")\n}\n\nconst secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)" + }, + { + "v": "3", + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "test-secret-query-params", + "method": "GET", + "params": [ + { + "key": "secretQueryParamKey", + "value": "<>", + "active": true + } + ], + "requestVariables": [], + "headers": [], + "endpoint": "<>", + "testScript": "pw.test(\"Successfully parses secret variable holding the query param value\", () => {\n const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n pw.expect(secretQueryParamValue).toBe(\"secret-query-param-value\")\n \n if (secretQueryParamValue) {\n pw.expect(pw.response.body.args.secretQueryParamKey).toBe(secretQueryParamValue)\n }\n\n pw.expect(pw.env.get(\"secretQueryParamValueFromPreReqScript\")).toBe(\"secret-query-param-value\")\n})", + "preRequestScript": "const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n\nif (!secretQueryParamValue) {\n pw.env.set(\"secretQueryParamValue\", \"secret-query-param-value\")\n}\n\nconst secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)" + }, + { + "v": "3", + "auth": { + "authType": "basic", + "password": "<>", + "username": "<>", + "authActive": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "test-secret-basic-auth", + "method": "GET", + "params": [], + "requestVariables": [], + "headers": [], + "endpoint": "<>/basic-auth/<>/<>", + "testScript": "pw.test(\"Successfully parses secret variables holding basic auth credentials\", () => {\n\tconst secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n \tconst secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\n pw.expect(secretBasicAuthUsername).toBe(\"test-user\")\n pw.expect(secretBasicAuthPassword).toBe(\"test-pass\")\n\n // The endpoint at times results in a `502` bad gateway\n if (pw.response.status !== 200) {\n return\n }\n\n if (secretBasicAuthUsername && secretBasicAuthPassword) {\n const { authenticated, user } = pw.response.body\n pw.expect(authenticated).toBe(true)\n pw.expect(user).toBe(secretBasicAuthUsername)\n }\n});", + "preRequestScript": "let secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n\nlet secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\nif (!secretBasicAuthUsername) {\n pw.env.set(\"secretBasicAuthUsername\", \"test-user\")\n}\n\nif (!secretBasicAuthPassword) {\n pw.env.set(\"secretBasicAuthPassword\", \"test-pass\")\n}" + }, + { + "v": "3", + "auth": { + "token": "<>", + "authType": "bearer", + "password": "testpassword", + "username": "testuser", + "authActive": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "test-secret-bearer-auth", + "method": "GET", + "params": [], + "requestVariables": [], + "headers": [], + "endpoint": "<>/bearer", + "testScript": "pw.test(\"Successfully parses secret variable holding the bearer token\", () => {\n const secretBearerToken = pw.env.resolve(\"<>\")\n const preReqSecretBearerToken = pw.env.resolve(\"<>\")\n\n pw.expect(secretBearerToken).toBe(\"test-token\")\n\n // Safeguard to prevent test failures due to the endpoint\n if (pw.response.status !== 200) {\n return\n }\n\n if (secretBearerToken) { \n pw.expect(pw.response.body.token).toBe(secretBearerToken)\n pw.expect(preReqSecretBearerToken).toBe(\"test-token\")\n }\n});", + "preRequestScript": "let secretBearerToken = pw.env.resolve(\"<>\")\n\nif (!secretBearerToken) {\n pw.env.set(\"secretBearerToken\", \"test-token\")\n secretBearerToken = pw.env.resolve(\"<>\")\n}\n\npw.env.set(\"preReqSecretBearerToken\", secretBearerToken)" + } + ], + "auth": { + "authType": "inherit", + "authActive": false + }, + "headers": [] +} \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/secret-envs-persistence-scripting-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/secret-envs-persistence-scripting-coll.json new file mode 100644 index 0000000..6a103ed --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/secret-envs-persistence-scripting-coll.json @@ -0,0 +1,31 @@ +{ + "v": 2, + "name": "secret-envs-persistence-scripting-req", + "folders": [], + "requests": [ + { + "v": "3", + "endpoint": "https://echo.hoppscotch.io/post", + "name": "req", + "params": [], + "headers": [ + { + "active": true, + "key": "Custom-Header", + "value": "<>" + } + ], + "method": "POST", + "auth": { "authType": "none", "authActive": true }, + "preRequestScript": "pw.env.set(\"preReqVarOne\", \"pre-req-value-one\")\n\npw.env.set(\"preReqVarTwo\", \"pre-req-value-two\")\n\npw.env.set(\"customHeaderValueFromSecretVar\", \"custom-header-secret-value\")\n\npw.env.set(\"customBodyValue\", \"custom-body-value\")", + "testScript": "pw.test(\"Secret environment value set from the pre-request script takes precedence\", () => {\n pw.expect(pw.env.get(\"preReqVarOne\")).toBe(\"pre-req-value-one\")\n})\n\npw.test(\"Successfully sets initial value for the secret variable from the pre-request script\", () => {\n pw.env.set(\"postReqVarTwo\", \"post-req-value-two\")\n pw.expect(pw.env.get(\"postReqVarTwo\")).toBe(\"post-req-value-two\")\n})\n\npw.test(\"Successfully resolves secret variable values referred in request headers that are set in pre-request script\", () => {\n pw.expect(pw.response.body.headers[\"custom-header\"]).toBe(\"custom-header-secret-value\")\n})\n\npw.test(\"Successfully resolves secret variable values referred in request body that are set in pre-request script\", () => {\n pw.expect(JSON.parse(pw.response.body.data).key).toBe(\"custom-body-value\")\n})\n\npw.test(\"Secret environment variable set from the post-request script takes precedence\", () => {\n pw.env.set(\"postReqVarOne\", \"post-req-value-one\")\n pw.expect(pw.env.get(\"postReqVarOne\")).toBe(\"post-req-value-one\")\n})\n\npw.test(\"Successfully sets initial value for the secret variable from the post-request script\", () => {\n pw.env.set(\"postReqVarTwo\", \"post-req-value-two\")\n pw.expect(pw.env.get(\"postReqVarTwo\")).toBe(\"post-req-value-two\")\n})\n\npw.test(\"Successfully removes environment variables via the pw.env.unset method\", () => {\n pw.env.unset(\"preReqVarOne\")\n pw.env.unset(\"postReqVarTwo\")\n\n pw.expect(pw.env.get(\"preReqVarOne\")).toBe(undefined)\n pw.expect(pw.env.get(\"postReqVarTwo\")).toBe(undefined)\n})", + "body": { + "contentType": "application/json", + "body": "{\n \"key\": \"<>\"\n}" + }, + "requestVariables": [] + } + ], + "auth": { "authType": "inherit", "authActive": false }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/test-junit-report-export-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/test-junit-report-export-coll.json new file mode 100644 index 0000000..081f4f3 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/test-junit-report-export-coll.json @@ -0,0 +1,150 @@ +{ + "v": 2, + "name": "test-junit-report-export", + "folders": [ + { + "v": 2, + "name": "assertions", + "folders": [], + "requests": [ + { + "v": "5", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "error", + "method": "GET", + "params": [], + "headers": [], + "endpoint": "https://echo.hoppscotch.io", + "testScript": "pw.test(\"`toBeLevelxxx()` error scenarios\", ()=> {\n pw.expect(\"foo\").toBeLevel2xx();\n pw.expect(\"foo\").not.toBeLevel2xx();\n});\n\npw.test(\"`toBeType()` error scenarios\", () => {\n pw.expect(2).toBeType(\"foo\")\n pw.expect(\"2\").toBeType(\"bar\")\n pw.expect(true).toBeType(\"baz\")\n pw.expect({}).toBeType(\"qux\")\n pw.expect(undefined).toBeType(\"quux\")\n \n pw.expect(2).not.toBeType(\"foo\")\n pw.expect(\"2\").not.toBeType(\"bar\")\n pw.expect(true).not.toBeType(\"baz\")\n pw.expect({}).not.toBeType(\"qux\")\n pw.expect(undefined).not.toBeType(\"quux\")\n})\n\npw.test(\"`toHaveLength()` error scenarios\", () => {\n pw.expect(5).toHaveLength(0)\n pw.expect(true).toHaveLength(0)\n\n pw.expect(5).not.toHaveLength(0)\n pw.expect(true).not.toHaveLength(0)\n\n pw.expect([1, 2, 3, 4]).toHaveLength(\"a\")\n\n pw.expect([1, 2, 3, 4]).not.toHaveLength(\"a\")\n})\n\npw.test(\"`toInclude() error scenarios`\", () => {\n pw.expect(5).not.toInclude(0)\n pw.expect(true).not.toInclude(0)\n\n pw.expect([1, 2, 3, 4]).not.toInclude(null)\n\n pw.expect([1, 2, 3, 4]).not.toInclude(undefined)\n})", + "preRequestScript": "", + "requestVariables": [] + }, + { + "v": "5", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "success", + "method": "GET", + "params": [], + "headers": [], + "endpoint": "https://echo.hoppscotch.io", + "testScript": "\n\n// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check headers\npw.test(\"Check headers\", ()=> {\n pw.expect(pw.response.body.headers[\"accept\"]).toBe(\"application/json, text/plain, */*,image/webp\");\n pw.expect(pw.response.body.headers[\"host\"]).toBe(\"echo.hoppscotch.io\")\n pw.expect(pw.response.body.headers[\"custom-header\"]).toBe(undefined)\n});\n\n// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});", + "preRequestScript": "", + "requestVariables": [] + }, + { + "v": "5", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "failure", + "method": "GET", + "params": [], + "headers": [], + "endpoint": "https://echo.hoppscotch.io", + "testScript": "\n\n// Check status code is 200\npw.test(\"Simulating failure - Status code is 200\", ()=> {\n pw.expect(pw.response.status).not.toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Simulating failure - Check headers\", ()=> {\n pw.expect(pw.response.body.headers[\"accept\"]).not.toBe(\"application/json, text/plain, */*\");\n pw.expect(pw.response.body.headers[\"host\"]).not.toBe(\"httpbin.org\")\n pw.expect(pw.response.body.headers[\"custom-header\"]).not.toBe(\"value\")\n});\n\n// Check status code is 2xx\npw.test(\"Simulating failure - Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).not.toBeLevel2xx();\n});", + "preRequestScript": "", + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] + }, + { + "v": 2, + "name": "request-level-errors", + "folders": [], + "requests": [ + { + "v": "5", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "invalid-url", + "method": "GET", + "params": [], + "headers": [], + "endpoint": "invalid-url", + "testScript": "pw.test(\"`toBeLevelxxx()` error scenarios\", ()=> {\n pw.expect(\"foo\").toBeLevel2xx();\n pw.expect(\"foo\").not.toBeLevel2xx();\n});\n\npw.test(\"`toBeType()` error scenarios\", () => {\n pw.expect(2).toBeType(\"foo\")\n pw.expect(\"2\").toBeType(\"bar\")\n pw.expect(true).toBeType(\"baz\")\n pw.expect({}).toBeType(\"qux\")\n pw.expect(undefined).toBeType(\"quux\")\n \n pw.expect(2).not.toBeType(\"foo\")\n pw.expect(\"2\").not.toBeType(\"bar\")\n pw.expect(true).not.toBeType(\"baz\")\n pw.expect({}).not.toBeType(\"qux\")\n pw.expect(undefined).not.toBeType(\"quux\")\n})\n\npw.test(\"`toHaveLength()` error scenarios\", () => {\n pw.expect(5).toHaveLength(0)\n pw.expect(true).toHaveLength(0)\n\n pw.expect(5).not.toHaveLength(0)\n pw.expect(true).not.toHaveLength(0)\n\n pw.expect([1, 2, 3, 4]).toHaveLength(\"a\")\n\n pw.expect([1, 2, 3, 4]).not.toHaveLength(\"a\")\n})\n\npw.test(\"`toInclude() error scenarios`\", () => {\n pw.expect(5).not.toInclude(0)\n pw.expect(true).not.toInclude(0)\n\n pw.expect([1, 2, 3, 4]).not.toInclude(null)\n\n pw.expect([1, 2, 3, 4]).not.toInclude(undefined)\n})", + "preRequestScript": "", + "requestVariables": [] + }, + { + "v": "5", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "test-script-reference-error", + "method": "GET", + "params": [], + "headers": [], + "endpoint": "invalid-url", + "testScript": "pw.test(\"Reference error\", () => {\n pw.expect(status).toBe(200);\n})", + "preRequestScript": "", + "requestVariables": [] + }, + { + "v": "5", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "body": "{\n \"key\": \"<>\"\n}", + "contentType": "application/json" + }, + "name": "non-existent-env-var", + "method": "POST", + "params": [], + "headers": [], + "endpoint": "https://echo.hoppscotch.io", + "testScript": "pw.test(\"`toBeLevelxxx()` error scenarios\", ()=> {\n pw.expect(\"foo\").toBeLevel2xx();\n pw.expect(\"foo\").not.toBeLevel2xx();\n});\n\npw.test(\"`toBeType()` error scenarios\", () => {\n pw.expect(2).toBeType(\"foo\")\n pw.expect(\"2\").toBeType(\"bar\")\n pw.expect(true).toBeType(\"baz\")\n pw.expect({}).toBeType(\"qux\")\n pw.expect(undefined).toBeType(\"quux\")\n \n pw.expect(2).not.toBeType(\"foo\")\n pw.expect(\"2\").not.toBeType(\"bar\")\n pw.expect(true).not.toBeType(\"baz\")\n pw.expect({}).not.toBeType(\"qux\")\n pw.expect(undefined).not.toBeType(\"quux\")\n})\n\npw.test(\"`toHaveLength()` error scenarios\", () => {\n pw.expect(5).toHaveLength(0)\n pw.expect(true).toHaveLength(0)\n\n pw.expect(5).not.toHaveLength(0)\n pw.expect(true).not.toHaveLength(0)\n\n pw.expect([1, 2, 3, 4]).toHaveLength(\"a\")\n\n pw.expect([1, 2, 3, 4]).not.toHaveLength(\"a\")\n})\n\npw.test(\"`toInclude() error scenarios`\", () => {\n pw.expect(5).not.toInclude(0)\n pw.expect(true).not.toInclude(0)\n\n pw.expect([1, 2, 3, 4]).not.toInclude(null)\n\n pw.expect([1, 2, 3, 4]).not.toInclude(undefined)\n})", + "preRequestScript": "", + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] + } + ], + "requests": [], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] +} \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/test-scripting-sandbox-modes-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/test-scripting-sandbox-modes-coll.json new file mode 100644 index 0000000..548b9ce --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/test-scripting-sandbox-modes-coll.json @@ -0,0 +1,34 @@ +{ + "v": 7, + "id": "cmb4vtsqh00nxwvqryk6jmnaz", + "name": "test-scripting-sandbox-modes", + "folders": [], + "requests": [ + { + "v": "12", + "name": "sample-req", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "const url = new URL('https://example.com/path?foo=bar');\nurl.searchParams.set('baz', 'qux');\nurl.toString(); // 'https://example.com/path?foo=bar&baz=qux'\n\nconsole.debug(url)\n\nconst encoder = new TextEncoder();\n\nconst text = \"Hello, world!\";\n\nconst encoded = encoder.encode(text);\nconsole.log(\"Encoded:\", encoded);\n\nconst decoder = new TextDecoder();\n\nconst decoded = decoder.decode(encoded);\nconsole.log(\"Decoded:\", decoded);\n\nsetTimeout(() => console.log(\"Hello after 1s\"), 1000);\n \nconst intervalId = setInterval(() => console.log(\"Every 500ms\"), 500);\n\nsetTimeout(() => clearInterval(intervalId), 2000);", + "testScript": "console.log(JSON.stringify(pw, null, 2))\n\n\npw.test(\"Sample assertion\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx()\n \tconsole.log(\"Status code received is \", pw.response.status)\n});\n\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "_ref_id": "coll_mb4vvacd_e015964c-b5c8-4b90-a564-4a8b62bc1631" +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/valid-mixed-versions-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/valid-mixed-versions-coll.json new file mode 100644 index 0000000..db7df38 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/valid-mixed-versions-coll.json @@ -0,0 +1,91 @@ +{ + "v": 8, + "id": "cmbgcmyly10yhdjet63hiorps", + "name": "Valid mixed versions collection", + "folders": [ + { + "v": 7, + "id": "cmbgnb4h100v613phgrz6vku9", + "name": "fold1", + "folders": [ + { + "v": 6, + "id": "cmbhux44609zs2triccmql79r", + "name": "fold2", + "folders": [ + { + "v": 5, + "id": "cmbhux6es09zv2triton3kzv4", + "name": "fold3", + "folders": [ + { + "v": 4, + "id": "cmbhux9vo0b2t13ph6dwapwh3", + "name": "fold4", + "folders": [], + "requests": [ + { + "v": "13", + "name": "req", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "_ref_id": "coll_mbhuxoci_023b77eb-ed0c-4ec7-97ca-f46cf327cc54" + } + ], + "requests": [], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "_ref_id": "coll_mbhuxoci_d22767f4-fd0a-4264-a86a-ba4a87c009cb" + } + ], + "requests": [], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "_ref_id": "coll_mbhuxoci_0effd54f-8e08-441f-9b44-95d02ff1861e" + } + ], + "requests": [], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "_ref_id": "coll_mbhuxoci_d7eee2ab-a921-447f-931d-bdce8c715770" + } + ], + "requests": [], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "variables": [], + "_ref_id": "coll_mbhuxoci_a8fc710e-04c1-489c-a183-7f16946a7225" +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/aws-signature-auth-envs.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/aws-signature-auth-envs.json new file mode 100644 index 0000000..486e69c --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/aws-signature-auth-envs.json @@ -0,0 +1,43 @@ +{ + "v": 2, + "id": "cm0dsn3v70004p4qk3l9b7sjm", + "name": "AWS Signature - environments", + "variables": [ + { + "key": "awsRegion", + "currentValue": "us-east-1", + "initialValue": "us-east-1", + "secret": false + }, + { + "key": "serviceName", + "currentValue": "s3", + "initialValue": "s3", + "secret": false + }, + { + "key": "accessKey", + "currentValue": "test-access-key", + "initialValue": "test-access-key", + "secret": true + }, + { + "key": "secretKey", + "currentValue": "", + "initialValue": "", + "secret": true + }, + { + "key": "url", + "currentValue": "https://echo.hoppscotch.io", + "initialValue": "https://echo.hoppscotch.io", + "secret": false + }, + { + "key": "serviceToken", + "currentValue": "", + "initialValue": "", + "secret": true + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/bulk-envs.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/bulk-envs.json new file mode 100644 index 0000000..7c56338 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/bulk-envs.json @@ -0,0 +1,32 @@ +[ + { + "v": 0, + "name": "Env-I", + "variables": [ + { + "key": "firstName", + "value": "John" + }, + { + "key": "lastName", + "value": "Doe" + } + ] + }, + { + "v": 1, + "id": "2", + "name": "Env-II", + "variables": [ + { + "key": "baseUrl", + "value": "https://echo.hoppscotch.io", + "secret": false + }, + { + "key": "secretVar", + "secret": true + } + ] + } +] diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/digest-auth-envs.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/digest-auth-envs.json new file mode 100644 index 0000000..63e13d5 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/digest-auth-envs.json @@ -0,0 +1,25 @@ +{ + "v": 2, + "id": "cm0dsn3v70004p4qk3l9b7sjm", + "name": "Digest Auth - environments", + "variables": [ + { + "key": "username", + "currentValue": "", + "initialValue": "admin", + "secret": true + }, + { + "key": "password", + "currentValue": "", + "initialValue": "admin", + "secret": true + }, + { + "key": "url", + "currentValue": "", + "initialValue": "https://test.insightres.org/digest/", + "secret": false + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/env-flag-envs.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/env-flag-envs.json new file mode 100644 index 0000000..9d05aa4 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/env-flag-envs.json @@ -0,0 +1,6 @@ +{ + "URL": "https://echo.hoppscotch.io", + "HOST": "echo.hoppscotch.io", + "BODY_VALUE": "body_value", + "BODY_KEY": "body_key" +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/env-v0.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/env-v0.json new file mode 100644 index 0000000..16b2328 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/env-v0.json @@ -0,0 +1,9 @@ +{ + "name": "env-v0", + "variables": [ + { + "key": "baseURL", + "value": "https://echo.hoppscotch.io" + } + ] +} \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/env-v1.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/env-v1.json new file mode 100644 index 0000000..c6f7c91 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/env-v1.json @@ -0,0 +1,10 @@ +{ + "name": "env-v1", + "variables": [ + { + "key": "baseURL", + "value": "https://echo.hoppscotch.io", + "secret": false + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/env-v2.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/env-v2.json new file mode 100644 index 0000000..1c170db --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/env-v2.json @@ -0,0 +1,13 @@ +{ + "id": "env-v2", + "v": 2, + "name": "env-v2", + "variables": [ + { + "key": "baseURL", + "initialValue": "https://echo.hoppscotch.io", + "currentValue": "https://echo.hoppscotch.io", + "secret": false + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/hawk-auth-envs.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/hawk-auth-envs.json new file mode 100644 index 0000000..81ff74d --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/hawk-auth-envs.json @@ -0,0 +1,47 @@ +{ + "v": 1, + "id": "cm0dsn3v70004p4qk3l9b7sjm", + "name": "HAWK Auth - environments", + "variables": [ + { + "key": "url", + "value": "https://postman-echo.com/auth/hawk" + }, + { + "key": "id", + "value": "dh37fgj492je" + }, + { + "key": "key", + "value": "werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn" + }, + { + "key": "algorithm", + "value": "sha256" + }, + { + "key": "user", + "value": "" + }, + { + "key": "nonce", + "value": "" + }, + { + "key": "ext", + "value": "" + }, + { + "key": "app", + "value": "" + }, + { + "key": "dlg", + "value": "" + }, + { + "key": "timestamp", + "value": "" + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/iteration-data-envs.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/iteration-data-envs.json new file mode 100644 index 0000000..bea6393 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/iteration-data-envs.json @@ -0,0 +1,18 @@ +{ + "v": 0, + "name": "Iteration data environments", + "variables": [ + { + "key": "URL", + "value": "https://httpbin.org/get" + }, + { + "key": "BODY_KEY", + "value": "overriden-body-key-at-environment" + }, + { + "key": "BODY_VALUE", + "value": "overriden-body-value-at-environment" + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/iteration-data-export.csv b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/iteration-data-export.csv new file mode 100644 index 0000000..5caf994 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/iteration-data-export.csv @@ -0,0 +1,4 @@ +URL,BODY_KEY,BODY_VALUE +https://echo.hoppscotch.io/1,,body_value1 +https://echo.hoppscotch.io/2,,body_value2 +https://echo.hoppscotch.io/3,,body_value3 diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/jwt-auth-envs.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/jwt-auth-envs.json new file mode 100644 index 0000000..768bea0 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/jwt-auth-envs.json @@ -0,0 +1,43 @@ +{ + "v": 1, + "id": "cm0dsn3v70004p4qk3l9b7sjn", + "name": "JWT Auth - environments", + "variables": [ + { + "key": "url", + "value": "http://localhost:8888/auth/jwt" + }, + { + "key": "algorithm", + "value": "HS256" + }, + { + "key": "secret", + "value": "secret-1" + }, + { + "key": "privateKey", + "value": "" + }, + { + "key": "payload", + "value": "{\"user\":\"test\",\"role\":\"admin\"}" + }, + { + "key": "jwtHeaders", + "value": "{}" + }, + { + "key": "headerPrefix", + "value": "Bearer " + }, + { + "key": "paramName", + "value": "token" + }, + { + "key": "isSecretBase64Encoded", + "value": "false" + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/malformed-envs.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/malformed-envs.json new file mode 100644 index 0000000..7d07525 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/malformed-envs.json @@ -0,0 +1,16 @@ +{ + "id": 123, + "v": "1", + "name": "secret-envs", + "values": [ + { + "key": "secretVar", + "secret": true + }, + { + "key": "regularVar", + "secret": false, + "value": "regular-variable" + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/req-body-env-vars-envs.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/req-body-env-vars-envs.json new file mode 100644 index 0000000..eb3b908 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/req-body-env-vars-envs.json @@ -0,0 +1,38 @@ +{ + "v": 0, + "name": "Response body sample", + "variables": [ + { + "key": "firstName", + "value": "John" + }, + { + "key": "lastName", + "value": "Doe" + }, + { + "key": "id", + "value": "7" + }, + { + "key": "fullName", + "value": "<> <>" + }, + { + "key": "recursiveVarX", + "value": "<>" + }, + { + "key": "recursiveVarY", + "value": "<>" + }, + { + "key": "salutation", + "value": "Hello" + }, + { + "key": "greetText", + "value": "<> <>" + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/request-vars-envs.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/request-vars-envs.json new file mode 100644 index 0000000..ff96dce --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/request-vars-envs.json @@ -0,0 +1,43 @@ +{ + "v": 2, + "id": "cm00r7kpb0006mbd2nq1560w6", + "name": "Request variables alongside environment variables", + "variables": [ + { + "key": "url", + "initialValue": "https://echo.hoppscotch.io", + "currentValue": "https://echo.hoppscotch.io", + "secret": false + }, + { + "key": "secretBasicAuthPasswordEnvVar", + "initialValue": "", + "currentValue": "", + "secret": true + }, + { + "key": "secretBasicAuthUsernameEnvVar", + "initialValue": "username", + "currentValue": "", + "secret": true + }, + { + "key": "username", + "initialValue": "", + "currentValue": "", + "secret": true + }, + { + "key": "password", + "initialValue": "", + "currentValue": "", + "secret": true + }, + { + "key": "testHeaderValue", + "initialValue": "test-header-value", + "currentValue": "", + "secret": false + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/secret-envs-persistence-scripting-envs.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/secret-envs-persistence-scripting-envs.json new file mode 100644 index 0000000..e141145 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/secret-envs-persistence-scripting-envs.json @@ -0,0 +1,37 @@ +{ + "v": 2, + "id": "2", + "name": "secret-envs-persistence-scripting-envs", + "variables": [ + { + "key": "preReqVarOne", + "initialValue": "", + "currentValue": "", + "secret": true + }, + { + "key": "preReqVarTwo", + "initialValue": "", + "currentValue": "", + "secret": true + }, + { + "key": "postReqVarOne", + "initialValue": "", + "currentValue": "", + "secret": true + }, + { + "key": "preReqVarTwo", + "initialValue": "", + "currentValue": "", + "secret": true + }, + { + "key": "customHeaderValueFromSecretVar", + "initialValue": "", + "currentValue": "", + "secret": true + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/secret-envs.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/secret-envs.json new file mode 100644 index 0000000..1c4ae82 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/secret-envs.json @@ -0,0 +1,61 @@ +{ + "id": "2", + "v": 2, + "name": "secret-envs", + "variables": [ + { + "key": "secretBearerToken", + "initialValue": "", + "currentValue": "", + "secret": true + }, + { + "key": "secretBasicAuthUsername", + "initialValue": "", + "currentValue": "", + "secret": true + }, + { + "key": "secretBasicAuthPassword", + "initialValue": "", + "currentValue": "", + "secret": true + }, + { + "key": "secretQueryParamValue", + "initialValue": "", + "currentValue": "", + "secret": true + }, + { + "key": "secretBodyValue", + "initialValue": "", + "currentValue": "", + "secret": true + }, + { + "key": "secretHeaderValue", + "initialValue": "", + "currentValue": "", + "secret": true + }, + { + "key": "nonExistentValueInSystemEnv", + "initialValue": "", + "currentValue": "", + "secret": true + }, + { + "key": "echoHoppBaseURL", + "initialValue": "https://echo.hoppscotch.io", + "currentValue": "", + "secret": false + }, + { + "key": "httpbinBaseURL", + "initialValue": "https://httpbin.org", + "currentValue": "", + "secret": false + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/secret-supplied-values-envs.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/secret-supplied-values-envs.json new file mode 100644 index 0000000..9c331bd --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/secret-supplied-values-envs.json @@ -0,0 +1,61 @@ +{ + "v": 2, + "id": "2", + "name": "secret-values-envs", + "variables": [ + { + "key": "secretBearerToken", + "initialValue": "test-token", + "currentValue": "test-token", + "secret": true + }, + { + "key": "secretBasicAuthUsername", + "initialValue": "test-user", + "currentValue": "test-user", + "secret": true + }, + { + "key": "secretBasicAuthPassword", + "initialValue": "test-pass", + "currentValue": "test-pass", + "secret": true + }, + { + "key": "secretQueryParamValue", + "initialValue": "secret-query-param-value", + "currentValue": "secret-query-param-value", + "secret": true + }, + { + "key": "secretBodyValue", + "initialValue": "secret-body-value", + "currentValue": "secret-body-value", + "secret": true + }, + { + "key": "secretHeaderValue", + "initialValue": "secret-header-value", + "currentValue": "secret-header-value", + "secret": true + }, + { + "key": "nonExistentValueInSystemEnv", + "initialValue": "", + "currentValue": "", + "secret": true + }, + { + "key": "echoHoppBaseURL", + "initialValue": "https://echo.hoppscotch.io", + "currentValue": "https://echo.hoppscotch.io", + "secret": false + }, + { + "key": "httpbinBaseURL", + "initialValue": "https://httpbin.org", + "currentValue": "https://httpbin.org", + "secret": false + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/functions/checks/isHoppCLIError.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/checks/isHoppCLIError.spec.ts new file mode 100644 index 0000000..a12d127 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/checks/isHoppCLIError.spec.ts @@ -0,0 +1,19 @@ +import { isHoppCLIError } from "../../../utils/checks"; + +describe("isHoppCLIError", () => { + test("NULL error value.", () => { + expect(isHoppCLIError(null)).toBeFalsy(); + }); + + test("Non-existing code property.", () => { + expect(isHoppCLIError({ name: "name" })).toBeFalsy(); + }); + + test("Invalid code value.", () => { + expect(isHoppCLIError({ code: 2 })).toBeFalsy(); + }); + + test("Valid code value.", () => { + expect(isHoppCLIError({ code: "TEST_SCRIPT_ERROR" })).toBeTruthy(); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/functions/checks/isHoppErrnoException.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/checks/isHoppErrnoException.spec.ts new file mode 100644 index 0000000..1c1716b --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/checks/isHoppErrnoException.spec.ts @@ -0,0 +1,19 @@ +import { isHoppErrnoException } from "../../../utils/checks"; + +describe("isHoppErrnoException", () => { + test("NULL exception value.", () => { + expect(isHoppErrnoException(null)).toBeFalsy(); + }); + + test("Non-existing name property.", () => { + expect(isHoppErrnoException({ what: "what" })).toBeFalsy(); + }); + + test("Invalid name value.", () => { + expect(isHoppErrnoException({ name: 3 })).toBeFalsy(); + }); + + test("Valid name value.", () => { + expect(isHoppErrnoException({ name: "name" })).toBeTruthy(); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/functions/collection/collectionsRunner.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/collection/collectionsRunner.spec.ts new file mode 100644 index 0000000..4bdf114 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/collection/collectionsRunner.spec.ts @@ -0,0 +1,129 @@ +import { collectionsRunner } from "../../../utils/collections"; +import { HoppRESTRequest } from "@hoppscotch/data"; +import axios, { AxiosResponse } from "axios"; + +import "@relmify/jest-fp-ts"; + +jest.mock("axios"); + +const SAMPLE_HOPP_REQUEST = { + v: "1", + name: "request", + method: "GET", + endpoint: "https://example.com", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + auth: { + authActive: false, + authType: "none", + }, + body: { + contentType: null, + body: null, + }, +}; + +const SAMPLE_RESOLVED_RESPONSE = { + data: { body: 1 }, + status: 200, + statusText: "OK", + config: { + url: "https://example.com", + supported: true, + method: "GET", + }, + headers: [], +}; + +const SAMPLE_ENVS = { global: [], selected: [] }; + +describe("collectionsRunner", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test("Empty HoppCollection.", () => { + return expect( + collectionsRunner({ collections: [], envs: SAMPLE_ENVS }) + ).resolves.toStrictEqual([]); + }); + + test("Empty requests and folders in collection.", () => { + return expect( + collectionsRunner({ + collections: [ + { + v: 1, + name: "name", + folders: [], + requests: [], + }, + ], + envs: SAMPLE_ENVS, + }) + ).resolves.toMatchObject([]); + }); + + test("Non-empty requests in collection.", () => { + (axios as unknown as jest.Mock).mockResolvedValue(SAMPLE_RESOLVED_RESPONSE); + + return expect( + collectionsRunner({ + collections: [ + { + v: 1, + name: "collection", + folders: [], + requests: [SAMPLE_HOPP_REQUEST], + }, + ], + envs: SAMPLE_ENVS, + }) + ).resolves.toMatchObject([ + { + path: "collection/request", + tests: [], + errors: [], + result: true, + }, + ]); + }); + + test("Non-empty folders in collection.", () => { + (axios as unknown as jest.Mock).mockResolvedValue(SAMPLE_RESOLVED_RESPONSE); + + return expect( + collectionsRunner({ + collections: [ + { + v: 1, + name: "collection", + folders: [ + { + v: 1, + name: "folder", + folders: [], + requests: [SAMPLE_HOPP_REQUEST], + }, + ], + requests: [], + }, + ], + envs: SAMPLE_ENVS, + }) + ).resolves.toMatchObject([ + { + path: "collection/folder/request", + tests: [], + errors: [], + result: true, + }, + ]); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/functions/collection/collectionsRunnerResult.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/collection/collectionsRunnerResult.spec.ts new file mode 100644 index 0000000..fdc9a54 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/collection/collectionsRunnerResult.spec.ts @@ -0,0 +1,35 @@ +import { collectionsRunnerResult } from "../../../utils/collections"; + +const FALSE_RESULT_REPORT = { + path: "some_path", + tests: [], + errors: [], + result: false, + duration: { test: 1, request: 1, preRequest: 1 }, +}; + +const TRUE_RESULT_REPORT = { + path: "some_path", + tests: [], + errors: [], + result: true, + duration: { test: 1, request: 1, preRequest: 1 }, +}; + +describe("collectionsRunnerResult", () => { + test("Empty request-report.", () => { + expect(collectionsRunnerResult([])).toBeTruthy(); + }); + + test("Atleast 1 false result in request-report.", () => { + expect( + collectionsRunnerResult([FALSE_RESULT_REPORT, TRUE_RESULT_REPORT]) + ).toBeFalsy(); + }); + + test("All true result(s) in request-report.", () => { + expect( + collectionsRunnerResult([TRUE_RESULT_REPORT, TRUE_RESULT_REPORT]) + ).toBeTruthy(); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/functions/getters/getDurationInSeconds.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/getters/getDurationInSeconds.spec.ts new file mode 100644 index 0000000..edeb52c --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/getters/getDurationInSeconds.spec.ts @@ -0,0 +1,24 @@ +import { DEFAULT_DURATION_PRECISION } from "../../../utils/constants"; +import { getDurationInSeconds } from "../../../utils/getters"; + +describe("getDurationInSeconds", () => { + const testDurations = [ + { end: [1, 111111111], precision: 1, expected: 1.1 }, + { end: [2, 333333333], precision: 2, expected: 2.33 }, + { + end: [3, 555555555], + precision: DEFAULT_DURATION_PRECISION, + expected: 3.556, + }, + { end: [4, 777777777], precision: 4, expected: 4.7778 }, + ]; + + test.each(testDurations)( + "($end.0 s + $end.1 ns) rounded-off to $expected", + ({ end, precision, expected }) => { + expect(getDurationInSeconds(end as [number, number], precision)).toBe( + expected + ); + } + ); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/functions/getters/getEffectiveFinalMetaData.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/getters/getEffectiveFinalMetaData.spec.ts new file mode 100644 index 0000000..2a55b79 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/getters/getEffectiveFinalMetaData.spec.ts @@ -0,0 +1,44 @@ +import { Environment } from "@hoppscotch/data"; +import { getEffectiveFinalMetaData } from "../../../utils/getters"; + +import "@relmify/jest-fp-ts"; + +const DEFAULT_ENV = { + name: "name", + variables: [{ key: "PARAM", value: "parsed_param" }], +}; + +describe("getEffectiveFinalMetaData", () => { + test("Empty list of meta-data.", () => { + expect(getEffectiveFinalMetaData([], DEFAULT_ENV)).toSubsetEqualRight([]); + }); + + test("Non-empty active list of meta-data with unavailable ENV.", () => { + expect( + getEffectiveFinalMetaData( + [{ active: true, key: "<>", value: "<>" }], + DEFAULT_ENV + ) + ).toSubsetEqualRight([{ active: true, key: "", value: "" }]); + }); + + test("Inactive list of meta-data.", () => { + expect( + getEffectiveFinalMetaData( + [{ active: false, key: "KEY", value: "<>" }], + DEFAULT_ENV + ) + ).toSubsetEqualRight([]); + }); + + test("Active list of meta-data.", () => { + expect( + getEffectiveFinalMetaData( + [{ active: true, key: "PARAM", value: "<>" }], + DEFAULT_ENV + ) + ).toSubsetEqualRight([ + { active: true, key: "PARAM", value: "parsed_param" }, + ]); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/functions/mutators/parseCollectionData.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/mutators/parseCollectionData.spec.ts new file mode 100644 index 0000000..86edb4b --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/mutators/parseCollectionData.spec.ts @@ -0,0 +1,34 @@ +import { HoppCLIError } from "../../../types/errors"; +import { parseCollectionData } from "../../../utils/mutators"; + +describe("parseCollectionData", () => { + test("Reading non-existing file.", () => { + return expect( + parseCollectionData("./src/__tests__/samples/notexist.json") + ).rejects.toMatchObject({ + code: "FILE_NOT_FOUND", + }); + }); + + test("Unparseable JSON contents.", () => { + return expect( + parseCollectionData("./src/__tests__/samples/malformed-collection.json") + ).rejects.toMatchObject({ + code: "UNKNOWN_ERROR", + }); + }); + + test("Invalid HoppCollection.", () => { + return expect( + parseCollectionData("./src/__tests__/samples/malformed-collection2.json") + ).rejects.toMatchObject({ + code: "MALFORMED_COLLECTION", + }); + }); + + test("Valid HoppCollection.", () => { + return expect( + parseCollectionData("./src/__tests__/samples/passes.json") + ).resolves.toBeTruthy(); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/functions/pre-request/getEffectiveRESTRequest.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/pre-request/getEffectiveRESTRequest.spec.ts new file mode 100644 index 0000000..1ab4e97 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/pre-request/getEffectiveRESTRequest.spec.ts @@ -0,0 +1,150 @@ +import { Environment, HoppRESTRequest } from "@hoppscotch/data"; +import { EffectiveHoppRESTRequest } from "../../../interfaces/request"; +import { HoppCLIError } from "../../../types/errors"; +import { getEffectiveRESTRequest } from "../../../utils/pre-request"; + +import "@relmify/jest-fp-ts"; + +const DEFAULT_ENV = { + name: "name", + variables: [ + { + key: "HEADER", + value: "parsed_header", + }, + { key: "PARAM", value: "parsed_param" }, + { key: "TOKEN", value: "parsed_token" }, + { key: "BODY_PROP", value: "parsed_body_prop" }, + { key: "ENDPOINT", value: "https://parsed-endpoint.com" }, + ], +}; + +const DEFAULT_REQUEST = { + v: "1", + name: "name", + method: "GET", + endpoint: "https://example.com", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + auth: { + authActive: false, + authType: "none", + }, + body: { + contentType: null, + body: null, + }, +}; + +describe("getEffectiveRESTRequest", () => { + let SAMPLE_REQUEST = Object.assign({}, DEFAULT_REQUEST); + + beforeEach(() => { + SAMPLE_REQUEST = Object.assign({}, DEFAULT_REQUEST); + }); + + test("Endpoint, headers and params with unavailable ENV.", () => { + SAMPLE_REQUEST.headers = [ + { + key: "HEADER", + value: "<>", + active: true, + }, + ]; + SAMPLE_REQUEST.params = [ + { + key: "PARAM", + value: "<>", + active: true, + }, + ]; + SAMPLE_REQUEST.endpoint = "<>"; + + expect( + getEffectiveRESTRequest(SAMPLE_REQUEST, DEFAULT_ENV) + ).toSubsetEqualRight({ + effectiveFinalHeaders: [{ active: true, key: "HEADER", value: "" }], + effectiveFinalParams: [{ active: true, key: "PARAM", value: "" }], + effectiveFinalURL: "", + }); + }); + + test("Auth with unavailable ENV.", () => { + SAMPLE_REQUEST.auth = { + authActive: true, + authType: "bearer", + token: "<>", + }; + + expect( + getEffectiveRESTRequest(SAMPLE_REQUEST, DEFAULT_ENV) + ).toSubsetEqualRight({ + effectiveFinalHeaders: [ + { active: true, key: "Authorization", value: "Bearer " }, + ], + }); + }); + + test("Body with unavailable ENV.", () => { + SAMPLE_REQUEST.body = { + contentType: "text/plain", + body: "<>", + }; + + expect( + getEffectiveRESTRequest(SAMPLE_REQUEST, DEFAULT_ENV) + ).toSubsetEqualLeft({ + code: "PARSING_ERROR", + }); + }); + + test("Request meta-data with available ENVs.", () => { + SAMPLE_REQUEST.headers = [ + { + key: "HEADER", + value: "<
>", + active: true, + }, + ]; + SAMPLE_REQUEST.params = [ + { + key: "PARAM", + value: "<>", + active: true, + }, + ]; + SAMPLE_REQUEST.endpoint = "<>"; + SAMPLE_REQUEST.auth = { + authActive: true, + authType: "bearer", + token: "<>", + }; + SAMPLE_REQUEST.body = { + contentType: "text/plain", + body: "<>", + }; + + const vars = DEFAULT_ENV.variables; + + expect( + getEffectiveRESTRequest(SAMPLE_REQUEST, DEFAULT_ENV) + ).toSubsetEqualRight({ + effectiveFinalHeaders: [ + { active: true, key: "HEADER", value: vars[0].value }, + { + active: true, + key: "Authorization", + value: `Bearer ${vars[2].value}`, + }, + { active: true, key: "content-type", value: "text/plain" }, + ], + effectiveFinalParams: [ + { active: true, key: "PARAM", value: vars[1].value }, + ], + effectiveFinalURL: vars[4].value, + effectiveFinalBody: vars[3].value, + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/functions/pre-request/getPreRequestMetrics.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/pre-request/getPreRequestMetrics.spec.ts new file mode 100644 index 0000000..2f28bd0 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/pre-request/getPreRequestMetrics.spec.ts @@ -0,0 +1,24 @@ +import { PreRequestMetrics, RequestMetrics } from "../../../types/response"; +import { getPreRequestMetrics } from "../../../utils/pre-request"; + +describe("getPreRequestMetrics", () => { + test("With empty errors.", () => { + expect(getPreRequestMetrics([], 1)).toMatchObject({ + scripts: { failed: 0, passed: 1 }, + }); + }); + + test("With non-empty errors.", () => { + expect( + getPreRequestMetrics( + [ + { code: "REQUEST_ERROR", data: {} }, + { code: "PRE_REQUEST_SCRIPT_ERROR", data: {} }, + ], + 1 + ) + ).toMatchObject({ + scripts: { failed: 1, passed: 0 }, + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/functions/pre-request/preRequestScriptRunner.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/pre-request/preRequestScriptRunner.spec.ts new file mode 100644 index 0000000..f382f7f --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/pre-request/preRequestScriptRunner.spec.ts @@ -0,0 +1,71 @@ +import { HoppRESTRequest } from "@hoppscotch/data"; +import { HoppEnvs } from "../../../types/request"; +import * as E from "fp-ts/Either"; +import { HoppCLIError } from "../../../types/errors"; +import { EffectiveHoppRESTRequest } from "../../../interfaces/request"; +import { preRequestScriptRunner } from "../../../utils/pre-request"; + +import "@relmify/jest-fp-ts"; + +const SAMPLE_ENVS: HoppEnvs = { + global: [], + selected: [], +}; +const VALID_PRE_REQUEST_SCRIPT = ` + pw.env.set("ENDPOINT","https://example.com"); +`; +const INVALID_PRE_REQUEST_SCRIPT = "d"; +const SAMPLE_REQUEST: HoppRESTRequest = { + v: "1", + name: "request", + method: "GET", + endpoint: "<>", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + auth: { authActive: false, authType: "none" }, + body: { + contentType: null, + body: null, + }, +}; + +describe("preRequestScriptRunner", () => { + let SUCCESS_PRE_REQUEST_RUNNER: E.Either< + HoppCLIError, + EffectiveHoppRESTRequest + >, + FAILURE_PRE_REQUEST_RUNNER: E.Either< + HoppCLIError, + EffectiveHoppRESTRequest + >; + + beforeAll(async () => { + SAMPLE_REQUEST.preRequestScript = VALID_PRE_REQUEST_SCRIPT; + SUCCESS_PRE_REQUEST_RUNNER = await preRequestScriptRunner( + SAMPLE_REQUEST, + SAMPLE_ENVS + )(); + + SAMPLE_REQUEST.preRequestScript = INVALID_PRE_REQUEST_SCRIPT; + FAILURE_PRE_REQUEST_RUNNER = await preRequestScriptRunner( + SAMPLE_REQUEST, + SAMPLE_ENVS + )(); + }); + + test("Parsing of request endpoint with set ENV.", () => { + expect(SUCCESS_PRE_REQUEST_RUNNER).toSubsetEqualRight(< + EffectiveHoppRESTRequest + >{ + effectiveFinalURL: "https://example.com", + }); + }); + + test("Failed execution due to unknown variable error.", () => { + expect(FAILURE_PRE_REQUEST_RUNNER).toSubsetEqualLeft({ + code: "PRE_REQUEST_SCRIPT_ERROR", + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/functions/request/delayPromiseFunction.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/request/delayPromiseFunction.spec.ts new file mode 100644 index 0000000..5f6dd84 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/request/delayPromiseFunction.spec.ts @@ -0,0 +1,30 @@ +import { hrtime } from "process"; +import { getDurationInSeconds } from "../../../utils/getters"; +import { delayPromiseFunction } from "../../../utils/request"; + +describe("describePromiseFunction", () => { + let promiseFunc = (): Promise => new Promise((resolve) => resolve(2)); + beforeEach(() => { + promiseFunc = (): Promise => new Promise((resolve) => resolve(2)); + }); + + it("Should resolve the promise after 2 seconds.", async () => { + const start = hrtime(); + const res = await delayPromiseFunction(promiseFunc, 2000); + const end = hrtime(start); + const duration = getDurationInSeconds(end); + + expect(Math.floor(duration)).toEqual(2); + expect(typeof res).toBe("number"); + }); + + it("Should resolve the promise after 4 seconds.", async () => { + const start = hrtime(); + const res = await delayPromiseFunction(promiseFunc, 4000); + const end = hrtime(start); + const duration = getDurationInSeconds(end); + + expect(Math.floor(duration)).toEqual(4); + expect(typeof res).toBe("number"); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/functions/request/getRequestMetrics.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/request/getRequestMetrics.spec.ts new file mode 100644 index 0000000..98c5342 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/request/getRequestMetrics.spec.ts @@ -0,0 +1,24 @@ +import { RequestMetrics } from "../../../types/response"; +import { getRequestMetrics } from "../../../utils/request"; + +describe("getRequestMetrics", () => { + test("With empty errors.", () => { + expect(getRequestMetrics([], 1)).toMatchObject({ + requests: { failed: 0, passed: 1 }, + }); + }); + + test("With non-empty errors.", () => { + expect( + getRequestMetrics( + [ + { code: "REQUEST_ERROR", data: {} }, + { code: "PARSING_ERROR", data: {} }, + ], + 1 + ) + ).toMatchObject({ + requests: { failed: 1, passed: 0 }, + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/functions/request/processRequest.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/request/processRequest.spec.ts new file mode 100644 index 0000000..b2c9027 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/request/processRequest.spec.ts @@ -0,0 +1,119 @@ +import { HoppRESTRequest } from "@hoppscotch/data"; +import axios, { AxiosResponse } from "axios"; +import { processRequest } from "../../../utils/request"; +import { HoppEnvs } from "../../../types/request"; + +import "@relmify/jest-fp-ts"; + +jest.mock("axios"); + +const DEFAULT_REQUEST = { + v: "1", + name: "name", + method: "POST", + endpoint: "https://example.com", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + auth: { + authType: "none", + authActive: false, + }, + body: { + contentType: null, + body: null, + }, +}; + +const DEFAULT_RESPONSE = { + data: {}, + status: 200, + config: { + url: "https://example.com", + supported: true, + method: "POST", + }, + statusText: "OK", + headers: [], +}; + +const DEFAULT_ENVS = { + global: [], + selected: [], +}; + +describe("processRequest", () => { + let SAMPLE_REQUEST = DEFAULT_REQUEST; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + SAMPLE_REQUEST = DEFAULT_REQUEST; + }); + + test("With empty envs for 'true' result.", () => { + (axios as unknown as jest.Mock).mockResolvedValue(DEFAULT_RESPONSE); + + return expect( + processRequest({ + request: SAMPLE_REQUEST, + envs: DEFAULT_ENVS, + path: "fake/collection/path", + delay: 0, + })() + ).resolves.toMatchObject({ + report: { + result: true, + }, + }); + }); + + test("With non-empty envs, pre-request-script and test-script.", () => { + SAMPLE_REQUEST.preRequestScript = ` + pw.env.set("ENDPOINT", "https://example.com"); + `; + SAMPLE_REQUEST.testScript = ` + pw.test("check status.", () => { + pw.expect(pw.response.status).toBe(200); + }); + `; + + (axios as unknown as jest.Mock).mockResolvedValue(DEFAULT_RESPONSE); + + return expect( + processRequest({ + request: SAMPLE_REQUEST, + envs: DEFAULT_ENVS, + path: "fake/collection/path", + delay: 0, + })() + ).resolves.toMatchObject({ + envs: { + selected: [{ key: "ENDPOINT", value: "https://example.com" }], + }, + report: { + result: true, + }, + }); + }); + + test("With invalid-pre-request-script.", () => { + SAMPLE_REQUEST.preRequestScript = `invalid`; + + (axios as unknown as jest.Mock).mockResolvedValue(DEFAULT_RESPONSE); + + return expect( + processRequest({ + request: SAMPLE_REQUEST, + envs: DEFAULT_ENVS, + path: "fake/request/path", + delay: 0, + })() + ).resolves.toMatchObject({ + report: { result: false }, + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/functions/request/requestRunner.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/request/requestRunner.spec.ts new file mode 100644 index 0000000..7fb45ac --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/request/requestRunner.spec.ts @@ -0,0 +1,108 @@ +import axios, { AxiosError, AxiosResponse } from "axios"; +import { RequestConfig } from "../../../interfaces/request"; +import { requestRunner } from "../../../utils/request"; +import { RequestRunnerResponse } from "../../../interfaces/response"; + +import "@relmify/jest-fp-ts"; + +jest.mock("axios"); + +describe("requestRunner", () => { + let SAMPLE_REQUEST_CONFIG: RequestConfig = { + url: "https://example.com", + supported: false, + method: "GET", + }; + + beforeEach(() => { + SAMPLE_REQUEST_CONFIG.url = "https://example.com"; + SAMPLE_REQUEST_CONFIG.method = "GET"; + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it("Should handle axios-error with response info.", () => { + jest.spyOn(axios, "isAxiosError").mockReturnValue(true); + (axios as unknown as jest.Mock).mockRejectedValueOnce({ + name: "name", + message: "message", + config: SAMPLE_REQUEST_CONFIG, + isAxiosError: true, + response: { + data: "data", + status: 404, + statusText: "NOT FOUND", + headers: [], + config: SAMPLE_REQUEST_CONFIG, + }, + toJSON: () => Object({}), + }); + + return expect( + requestRunner(SAMPLE_REQUEST_CONFIG)() + ).resolves.toSubsetEqualRight({ + body: "data", + status: 404, + }); + }); + + it("Should handle axios-error for unsupported request.", () => { + jest.spyOn(axios, "isAxiosError").mockReturnValue(true); + (axios as unknown as jest.Mock).mockRejectedValueOnce({ + name: "name", + message: "message", + config: SAMPLE_REQUEST_CONFIG, + isAxiosError: true, + toJSON: () => Object({}), + }); + + return expect( + requestRunner(SAMPLE_REQUEST_CONFIG)() + ).resolves.toSubsetEqualRight({ + status: 501, + body: {}, + }); + }); + + it("Should handle axios-error with request info.", () => { + jest.spyOn(axios, "isAxiosError").mockReturnValue(true); + (axios as unknown as jest.Mock).mockRejectedValueOnce({ + name: "name", + message: "message", + config: SAMPLE_REQUEST_CONFIG, + isAxiosError: true, + request: {}, + toJSON: () => Object({}), + }); + + return expect(requestRunner(SAMPLE_REQUEST_CONFIG)()).resolves.toBeLeft(); + }); + + it("Should handle unknown error.", () => { + jest.spyOn(axios, "isAxiosError").mockReturnValue(false); + (axios as unknown as jest.Mock).mockRejectedValueOnce({}); + + return expect(requestRunner(SAMPLE_REQUEST_CONFIG)()).resolves.toBeLeft(); + }); + + it("Should successfully execute.", () => { + (axios as unknown as jest.Mock).mockResolvedValue({ + data: "data", + status: 200, + config: SAMPLE_REQUEST_CONFIG, + statusText: "OK", + headers: [], + }); + + return expect( + requestRunner(SAMPLE_REQUEST_CONFIG)() + ).resolves.toSubsetEqualRight({ + status: 200, + body: "data", + method: "GET", + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/functions/test/getTestMetrics.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/test/getTestMetrics.spec.ts new file mode 100644 index 0000000..e0115f9 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/test/getTestMetrics.spec.ts @@ -0,0 +1,55 @@ +import { TestMetrics } from "../../../types/response"; +import { getTestMetrics } from "../../../utils/test"; + +describe("getTestMetrics", () => { + test("With empty test-reports and errors.", () => { + expect(getTestMetrics([], 1, [])).toMatchObject({ + tests: { passed: 0, failed: 0 }, + testSuites: { failed: 0, passed: 0 }, + duration: 1, + scripts: { failed: 0, passed: 1 }, + }); + }); + + test("With non-empty test-reports and no test-script-error.", () => { + expect( + getTestMetrics( + [ + { + descriptor: "descriptor", + expectResults: [], + failed: 0, + passed: 2, + }, + { + descriptor: "descriptor", + expectResults: [], + failed: 2, + passed: 1, + }, + ], + 5, + [] + ) + ).toMatchObject({ + tests: { failed: 2, passed: 3 }, + testSuites: { failed: 1, passed: 1 }, + scripts: { failed: 0, passed: 1 }, + duration: 5, + }); + }); + + test("With empty test-reports and some test-script-error.", () => { + expect( + getTestMetrics([], 5, [ + { code: "TEST_SCRIPT_ERROR", data: {} }, + { code: "PRE_REQUEST_SCRIPT_ERROR", data: {} }, + ]) + ).toMatchObject({ + tests: { failed: 0, passed: 0 }, + testSuites: { failed: 0, passed: 0 }, + scripts: { failed: 1, passed: 0 }, + duration: 5, + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/functions/test/testDescriptorParser.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/test/testDescriptorParser.spec.ts new file mode 100644 index 0000000..21baa4f --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/test/testDescriptorParser.spec.ts @@ -0,0 +1,63 @@ +import { TestDescriptor } from "@hoppscotch/js-sandbox"; +import { testDescriptorParser, getTestMetrics } from "../../../utils/test"; +import { TestReport } from "../../../interfaces/response"; +import { TestMetrics } from "../../../types/response"; + +import "@relmify/jest-fp-ts"; + +const SAMPLE_TEST_DESCRIPTOR: TestDescriptor = { + descriptor: "Status code is 200", + expectResults: [ + { + status: "error", + message: "some_message", + }, + ], + children: [ + { + descriptor: "Check JSON response property", + expectResults: [ + { + status: "pass", + message: "some_message", + }, + ], + children: [], + }, + { + descriptor: "Check header property", + expectResults: [ + { + status: "fail", + message: "some_message", + }, + ], + children: [], + }, + ], +}; + +describe("testDescriptorParser", () => { + let TEST_REPORT: TestReport[]; + beforeAll(async () => { + TEST_REPORT = await testDescriptorParser(SAMPLE_TEST_DESCRIPTOR)(); + }); + + it("Should have 3 tests-report.", () => { + expect(TEST_REPORT).toEqual(expect.any(Array)); + expect(TEST_REPORT.length).toStrictEqual(3); + }); + + it("Should have 1 passed, 2 failed test-cases; 1 passed, 2 failed test-suite.", () => { + expect(getTestMetrics(TEST_REPORT, 1, [])).toMatchObject({ + tests: { + failed: 2, + passed: 1, + }, + testSuites: { + failed: 2, + passed: 1, + }, + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/functions/test/testRunner.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/test/testRunner.spec.ts new file mode 100644 index 0000000..0eeafa9 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/functions/test/testRunner.spec.ts @@ -0,0 +1,73 @@ +import { TestResponse } from "@hoppscotch/js-sandbox"; +import * as E from "fp-ts/Either"; +import { TestRunnerRes } from "../../../types/response"; +import { HoppCLIError } from "../../../types/errors"; +import { getTestMetrics, testRunner } from "../../../utils/test"; +import { HoppEnvs } from "../../../types/request"; + +import "@relmify/jest-fp-ts"; + +const SAMPLE_ENVS: HoppEnvs = { + global: [], + selected: [ + { + key: "DEVBLIN", + value: "set-by-devblin", + }, + ], +}; +const SAMPLE_RESPONSE: TestResponse = { + status: 200, + headers: [], + body: {}, +}; + +describe("testRunner", () => { + let SUCCESS_TEST_RUNNER_RES: E.Either, + FAILURE_TEST_RUNNER_RES: E.Either; + + beforeAll(async () => { + SUCCESS_TEST_RUNNER_RES = await testRunner({ + testScript: ` + // Check status code is 200 + pw.test("Status code is 200", ()=> { + pw.expect(pw.response.status).toBe(200); + }); + + // Check JSON response property + pw.test("Check JSON response property", ()=> { + pw.expect(pw.response.body).toBeType("string") + pw.expect(pw.response.body).toBe("body"); + }); + `, + envs: SAMPLE_ENVS, + response: SAMPLE_RESPONSE, + })(); + + FAILURE_TEST_RUNNER_RES = await testRunner({ + testScript: "a", + envs: SAMPLE_ENVS, + response: SAMPLE_RESPONSE, + })(); + }); + + it("Should have 2 failed, 1 passed test-cases; 1 failed, 1 passed test-suites.", () => { + expect(SUCCESS_TEST_RUNNER_RES).toBeRight(); + + if (E.isRight(SUCCESS_TEST_RUNNER_RES)) { + const { duration, testsReport } = SUCCESS_TEST_RUNNER_RES.right; + const { tests, testSuites } = getTestMetrics(testsReport, duration, []); + + expect(tests.failed).toStrictEqual(2); + expect(tests.passed).toStrictEqual(1); + expect(testSuites.failed).toStrictEqual(1); + expect(testSuites.passed).toStrictEqual(1); + } + }); + + it("Should fail to execute with test-script-error.", () => { + expect(FAILURE_TEST_RUNNER_RES).toSubsetEqualLeft({ + code: "TEST_SCRIPT_ERROR", + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/types.ts b/packages/hoppscotch-cli/src/__tests__/types.ts new file mode 100644 index 0000000..b84c6b7 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/types.ts @@ -0,0 +1,7 @@ +import { ExecException } from "child_process"; + +export type ExecResponse = { + error: ExecException | null; + stdout: string; + stderr: string; +}; diff --git a/packages/hoppscotch-cli/src/__tests__/unit/fixtures/workspace-access.mock.ts b/packages/hoppscotch-cli/src/__tests__/unit/fixtures/workspace-access.mock.ts new file mode 100644 index 0000000..774be70 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/unit/fixtures/workspace-access.mock.ts @@ -0,0 +1,1762 @@ +import { + CollectionSchemaVersion, + Environment, + EnvironmentSchemaVersion, + HoppCollection, + RESTReqSchemaVersion, +} from "@hoppscotch/data"; + +import { + WorkspaceCollection, + WorkspaceEnvironment, +} from "../../../utils/workspace-access"; + +export const WORKSPACE_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MOCK: WorkspaceCollection[] = + [ + { + id: "clx1ldkzs005t10f8rp5u60q7", + data: '{"auth":{"token":"BearerToken","authType":"bearer","authActive":true},"headers":[{"key":"X-Test-Header","value":"Set at root collection","active":true,"description":""}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: "CollectionA", + parentID: null, + folders: [ + { + id: "clx1ldkzs005v10f86b9wx4yc", + data: '{"auth":{"authType":"inherit","authActive":true},"headers":[],"variables":[]}', + title: "FolderA", + parentID: "clx1ldkzs005t10f8rp5u60q7", + folders: [ + { + id: "clx1ldkzt005x10f8i0u5lzgj", + data: '{"auth":{"key":"key","addTo":"HEADERS","value":"test-key","authType":"api-key","authActive":true},"headers":[{"key":"X-Test-Header","value":"Overriden at FolderB","active":true}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: "FolderB", + parentID: "clx1ldkzs005v10f86b9wx4yc", + folders: [ + { + id: "clx1ldkzu005z10f880zx17bg", + data: '{"auth":{"authType":"inherit","authActive":true},"headers":[],"variables":[]}', + title: "FolderC", + parentID: "clx1ldkzt005x10f8i0u5lzgj", + folders: [], + requests: [ + { + id: "clx1ldkzu006010f820vzy13v", + collectionID: "clx1ldkzu005z10f880zx17bg", + teamID: "clws3hg58000011o8h07glsb1", + title: "RequestD", + request: + '{"v":"3","auth":{"authType":"basic","password":"password","username":"username","authActive":true},"body":{"body":null,"contentType":null},"name":"RequestD","method":"GET","params":[],"headers":[{"key":"X-Test-Header","value":"Overriden at RequestD","active":true}],"endpoint":"https://echo.hoppscotch.io","testScript":"pw.test(\\"Overrides auth and headers set at the parent folder\\", ()=> {\\n pw.expect(pw.response.body.headers[\\"x-test-header\\"]).toBe(\\"Overriden at RequestD\\");\\n pw.expect(pw.response.body.headers[\\"authorization\\"]).toBe(\\"Basic dXNlcm5hbWU6cGFzc3dvcmQ=\\");\\n});","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + ], + requests: [ + { + id: "clx1ldkzt005y10f82dl8ni8d", + collectionID: "clx1ldkzt005x10f8i0u5lzgj", + teamID: "clws3hg58000011o8h07glsb1", + title: "RequestC", + request: + '{"v":"3","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"RequestC","method":"GET","params":[],"headers":[],"endpoint":"https://echo.hoppscotch.io","testScript":"pw.test(\\"Correctly inherits auth and headers from the parent folder\\", ()=> {\\n pw.expect(pw.response.body.headers[\\"x-test-header\\"]).toBe(\\"Overriden at FolderB\\");\\n pw.expect(pw.response.body.headers[\\"key\\"]).toBe(\\"test-key\\");\\n});","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + ], + requests: [ + { + id: "clx1ldkzs005w10f8pc2v2boh", + collectionID: "clx1ldkzs005v10f86b9wx4yc", + teamID: "clws3hg58000011o8h07glsb1", + title: "RequestB", + request: + '{"v":"3","id":"clpttpdq00003qp16kut6doqv","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"RequestB","method":"GET","params":[],"headers":[],"endpoint":"https://echo.hoppscotch.io","testScript":"pw.test(\\"Correctly inherits auth and headers from the parent folder\\", ()=> {\\n pw.expect(pw.response.body.headers[\\"x-test-header\\"]).toBe(\\"Set at root collection\\");\\n pw.expect(pw.response.body.headers[\\"authorization\\"]).toBe(\\"Bearer BearerToken\\");\\n});","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + ], + requests: [ + { + id: "clx1ldkzs005u10f82xd5ho3l", + collectionID: "clx1ldkzs005t10f8rp5u60q7", + teamID: "clws3hg58000011o8h07glsb1", + title: "RequestA", + request: `{"v":"${RESTReqSchemaVersion}","id":"clpttpdq00003qp16kut6doqv","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"RequestA","method":"GET","params":[],"headers":[],"endpoint":"https://echo.hoppscotch.io","testScript":"pw.test(\\"Correctly inherits auth and headers from the root collection\\", ()=> {\\n pw.expect(pw.response.body.headers[\\"x-test-header\\"]).toBe(\\"Set at root collection\\");\\n pw.expect(pw.response.body.headers[\\"authorization\\"]).toBe(\\"Bearer BearerToken\\");\\n});","preRequestScript":"","requestVariables":[],"responses":{},"description":""}`, + }, + ], + }, + ]; + +export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MOCK: HoppCollection[] = + [ + { + v: CollectionSchemaVersion, + id: "clx1ldkzs005t10f8rp5u60q7", + name: "CollectionA", + folders: [ + { + v: CollectionSchemaVersion, + id: "clx1ldkzs005v10f86b9wx4yc", + name: "FolderA", + folders: [ + { + v: CollectionSchemaVersion, + id: "clx1ldkzt005x10f8i0u5lzgj", + name: "FolderB", + folders: [ + { + v: CollectionSchemaVersion, + id: "clx1ldkzu005z10f880zx17bg", + name: "FolderC", + folders: [], + requests: [ + { + v: "3", + auth: { + authType: "basic", + password: "password", + username: "username", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "RequestD", + method: "GET", + params: [], + headers: [ + { + key: "X-Test-Header", + value: "Overriden at RequestD", + active: true, + }, + ], + endpoint: "https://echo.hoppscotch.io", + testScript: + 'pw.test("Overrides auth and headers set at the parent folder", ()=> {\n pw.expect(pw.response.body.headers["x-test-header"]).toBe("Overriden at RequestD");\n pw.expect(pw.response.body.headers["authorization"]).toBe("Basic dXNlcm5hbWU6cGFzc3dvcmQ=");\n});', + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + authType: "inherit", + authActive: true, + }, + headers: [], + variables: [], + description: null, + preRequestScript: "", + testScript: "", + }, + ], + requests: [ + { + v: "3", + auth: { + authType: "inherit", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "RequestC", + method: "GET", + params: [], + headers: [], + endpoint: "https://echo.hoppscotch.io", + testScript: + 'pw.test("Correctly inherits auth and headers from the parent folder", ()=> {\n pw.expect(pw.response.body.headers["x-test-header"]).toBe("Overriden at FolderB");\n pw.expect(pw.response.body.headers["key"]).toBe("test-key");\n});', + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + key: "key", + addTo: "HEADERS", + value: "test-key", + authType: "api-key", + authActive: true, + }, + headers: [ + { + key: "X-Test-Header", + value: "Overriden at FolderB", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + ], + requests: [ + { + v: "3", + id: "clpttpdq00003qp16kut6doqv", + auth: { + authType: "inherit", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "RequestB", + method: "GET", + params: [], + headers: [], + endpoint: "https://echo.hoppscotch.io", + testScript: + 'pw.test("Correctly inherits auth and headers from the parent folder", ()=> {\n pw.expect(pw.response.body.headers["x-test-header"]).toBe("Set at root collection");\n pw.expect(pw.response.body.headers["authorization"]).toBe("Bearer BearerToken");\n});', + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + authType: "inherit", + authActive: true, + }, + headers: [], + variables: [], + description: null, + preRequestScript: "", + testScript: "", + }, + ], + requests: [ + { + v: RESTReqSchemaVersion, + id: "clpttpdq00003qp16kut6doqv", + auth: { + authType: "inherit", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "RequestA", + method: "GET", + params: [], + headers: [], + endpoint: "https://echo.hoppscotch.io", + testScript: + 'pw.test("Correctly inherits auth and headers from the root collection", ()=> {\n pw.expect(pw.response.body.headers["x-test-header"]).toBe("Set at root collection");\n pw.expect(pw.response.body.headers["authorization"]).toBe("Bearer BearerToken");\n});', + preRequestScript: "", + requestVariables: [], + responses: {}, + description: "", + }, + ], + auth: { + token: "BearerToken", + authType: "bearer", + authActive: true, + }, + headers: [ + { + key: "X-Test-Header", + value: "Set at root collection", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + ]; + +export const WORKSPACE_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MOCK: WorkspaceCollection[] = + [ + { + id: "clx1f86hv000010f8szcfya0t", + data: '{"auth":{"authType":"basic","password":"testpass","username":"testuser","authActive":true},"headers":[{"key":"Custom-Header","value":"Custom header value set at the root collection","active":true},{"key":"Inherited-Header","value":"Inherited header at all levels","active":true}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: + "Multiple child collections with authorization, headers and variables set at each level", + parentID: null, + folders: [ + { + id: "clx1fjgah000110f8a5bs68gd", + data: '{"auth":{"authType":"inherit","authActive":true},"headers":[{"key":"Custom-Header","value":"Custom header value overriden at folder-1","active":true}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: "folder-1", + parentID: "clx1f86hv000010f8szcfya0t", + folders: [ + { + id: "clx1fjwmm000410f8l1gkkr1a", + data: '{"auth":{"authType":"inherit","authActive":true},"headers":[{"key":"key","value":"Set at folder-11","active":true}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: "folder-11", + parentID: "clx1fjgah000110f8a5bs68gd", + folders: [], + requests: [ + { + id: "clx1gjo1q000p10f8tc3x2u50", + collectionID: "clx1fjwmm000410f8l1gkkr1a", + teamID: "clws3hg58000011o8h07glsb1", + title: "folder-11-request", + request: + '{"v":"4","auth":{"authType":"inherit","password":"testpass","username":"testuser","authActive":true},"body":{"body":null,"contentType":null},"name":"folder-11-request","method":"GET","params":[],"headers":[],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits authorization/header set at the parent collection level\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(\\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\\")\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value overriden at folder-1\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n})","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + { + id: "clx1fjyxm000510f8pv90dt43", + data: '{"auth":{"authType":"none","authActive":true},"headers":[{"key":"Custom-Header","value":"Custom header value overriden at folder-12","active":true},{"key":"key","value":"Set at folder-12","active":true}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: "folder-12", + parentID: "clx1fjgah000110f8a5bs68gd", + folders: [], + requests: [ + { + id: "clx1glkt5000u10f88q51ioj8", + collectionID: "clx1fjyxm000510f8pv90dt43", + teamID: "clws3hg58000011o8h07glsb1", + title: "folder-12-request", + request: + '{"v":"4","auth":{"authType":"none","authActive":true},"body":{"body":null,"contentType":null},"name":"folder-12-request","method":"GET","params":[],"headers":[{"key":"Custom-Header","value":"Custom header value overriden at folder-12-request","active":true},{"key":"key","value":"Overriden at folder-12-request","active":true}],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits/overrides authorization/header set at the parent collection level\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(undefined)\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value overriden at folder-12-request\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n pw.expect(pw.response.body.headers[\\"Key\\"]).toBe(\\"Overriden at folder-12-request\\")\\n})","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + { + id: "clx1fk1cv000610f88kc3aupy", + data: '{"auth":{"token":"test-token","authType":"bearer","authActive":true},"headers":[{"key":"Custom-Header","value":"Custom header value overriden at folder-13","active":true},{"key":"key","value":"Set at folder-13","active":true}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: "folder-13", + parentID: "clx1fjgah000110f8a5bs68gd", + folders: [], + requests: [ + { + id: "clx1grfir001510f8c4ttiazq", + collectionID: "clx1fk1cv000610f88kc3aupy", + teamID: "clws3hg58000011o8h07glsb1", + title: "folder-13-request", + request: + '{"v":"4","auth":{"key":"api-key","addTo":"HEADERS","value":"api-key-value","authType":"basic","password":"testpass","username":"testuser","authActive":true,"grantTypeInfo":{"token":"","isPKCE":true,"clientID":"sfasfa","password":"","username":"","grantType":"AUTHORIZATION_CODE","authEndpoint":"asfafs","clientSecret":"sfasfasf","tokenEndpoint":"asfa","codeVerifierMethod":"S256"}},"body":{"body":null,"contentType":null},"name":"folder-13-request","method":"GET","params":[],"headers":[{"key":"Custom-Header-Request-Level","value":"New custom header added at the folder-13-request level","active":true},{"key":"key","value":"Overriden at folder-13-request","active":true}],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits/overrides authorization/header set at the parent collection level with new header addition\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(\\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\\")\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value overriden at folder-13\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n pw.expect(pw.response.body.headers[\\"Key\\"]).toBe(\\"Overriden at folder-13-request\\")\\n pw.expect(pw.response.body.headers[\\"Custom-Header-Request-Level\\"]).toBe(\\"New custom header added at the folder-13-request level\\")\\n})","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + ], + requests: [ + { + id: "clx1gebpx000k10f8andzw36z", + collectionID: "clx1fjgah000110f8a5bs68gd", + teamID: "clws3hg58000011o8h07glsb1", + title: "folder-1-request", + request: + '{"v":"4","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"folder-1-request","method":"GET","params":[],"headers":[],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits authorization/header set at the parent collection level\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(\\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\\")\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value overriden at folder-1\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n})","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + { + id: "clx1fjk9o000210f8j0573pls", + data: '{"auth":{"authType":"none","authActive":true},"headers":[{"key":"Custom-Header","value":"Custom header value overriden at folder-2","active":true}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: "folder-2", + parentID: "clx1f86hv000010f8szcfya0t", + folders: [ + { + id: "clx1fk516000710f87sfpw6bo", + data: '{"auth":{"authType":"inherit","authActive":true},"headers":[{"key":"key","value":"Set at folder-21","active":true}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: "folder-21", + parentID: "clx1fjk9o000210f8j0573pls", + folders: [], + requests: [ + { + id: "clx1hfegy001j10f8ywbozysk", + collectionID: "clx1fk516000710f87sfpw6bo", + teamID: "clws3hg58000011o8h07glsb1", + title: "folder-21-request", + request: + '{"v":"4","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"folder-21-request","method":"GET","params":[],"headers":[],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits authorization/header set at the parent collection level\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(undefined)\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value overriden at folder-2\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n})","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + { + id: "clx1fk72t000810f8gfwkpi5y", + data: '{"auth":{"authType":"none","authActive":true},"headers":[{"key":"Custom-Header","value":"Custom header value overriden at folder-22","active":true},{"key":"key","value":"Set at folder-22","active":true}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: "folder-22", + parentID: "clx1fjk9o000210f8j0573pls", + folders: [], + requests: [ + { + id: "clx1ibfre002k10f86brcb2aa", + collectionID: "clx1fk72t000810f8gfwkpi5y", + teamID: "clws3hg58000011o8h07glsb1", + title: "folder-22-request", + request: + '{"v":"4","auth":{"authType":"none","authActive":true},"body":{"body":null,"contentType":null},"name":"folder-22-request","method":"GET","params":[],"headers":[{"key":"Custom-Header","value":"Custom header value overriden at folder-22-request","active":true},{"key":"key","value":"Overriden at folder-22-request","active":true}],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits/overrides authorization/header set at the parent collection level\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(undefined)\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value overriden at folder-22-request\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n pw.expect(pw.response.body.headers[\\"Key\\"]).toBe(\\"Overriden at folder-22-request\\")\\n})","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + { + id: "clx1fk95g000910f8bunhaoo8", + data: '{"auth":{"token":"test-token","authType":"bearer","password":"testpass","username":"testuser","authActive":true},"headers":[{"key":"Custom-Header","value":"Custom header value overriden at folder-23","active":true},{"key":"key","value":"Set at folder-23","active":true}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: "folder-23", + parentID: "clx1fjk9o000210f8j0573pls", + folders: [], + requests: [ + { + id: "clx1if4w6002n10f8xe4gnf0w", + collectionID: "clx1fk95g000910f8bunhaoo8", + teamID: "clws3hg58000011o8h07glsb1", + title: "folder-23-request", + request: + '{"v":"4","auth":{"authType":"basic","password":"testpass","username":"testuser","authActive":true},"body":{"body":null,"contentType":null},"name":"folder-23-request","method":"GET","params":[],"headers":[{"key":"Custom-Header-Request-Level","value":"New custom header added at the folder-23-request level","active":true},{"key":"key","value":"Overriden at folder-23-request","active":true}],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits/overrides authorization/header set at the parent collection level with new header addition\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(\\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\\")\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value overriden at folder-23\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n pw.expect(pw.response.body.headers[\\"Key\\"]).toBe(\\"Overriden at folder-23-request\\")\\n pw.expect(pw.response.body.headers[\\"Custom-Header-Request-Level\\"]).toBe(\\"New custom header added at the folder-23-request level\\")\\n})","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + ], + requests: [ + { + id: "clx1hbtdj001g10f8y71y869s", + collectionID: "clx1fjk9o000210f8j0573pls", + teamID: "clws3hg58000011o8h07glsb1", + title: "folder-2-request", + request: + '{"v":"4","auth":{"authType":"none","authActive":true},"body":{"body":null,"contentType":null},"name":"folder-2-request","method":"GET","params":[],"headers":[{"key":"Custom-Header","value":"Custom header value overriden at folder-2-request","active":true}],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits/overrides authorization/header set at the parent collection level\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(undefined)\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value overriden at folder-2-request\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n})","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + { + id: "clx1fjmlq000310f86o4d3w2o", + data: '{"auth":{"key":"testuser","addTo":"HEADERS","value":"testpass","authType":"basic","password":"testpass","username":"testuser","authActive":true},"headers":[{"key":"Custom-Header","value":"Custom header value overriden at folder-3","active":true}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: "folder-3", + parentID: "clx1f86hv000010f8szcfya0t", + folders: [ + { + id: "clx1iwq0p003e10f8u8zg0p85", + data: '{"auth":{"authType":"inherit","authActive":true},"headers":[{"key":"key","value":"Set at folder-31","active":true}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: "folder-31", + parentID: "clx1fjmlq000310f86o4d3w2o", + folders: [], + requests: [ + { + id: "clx1ixdiv003f10f8j6ni375m", + collectionID: "clx1iwq0p003e10f8u8zg0p85", + teamID: "clws3hg58000011o8h07glsb1", + title: "folder-31-request", + request: + '{"v":"4","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"folder-31-request","method":"GET","params":[],"headers":[],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits authorization/header set at the parent collection level\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(\\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\\")\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value overriden at folder-3\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n})","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + { + id: "clx1izut7003m10f894ip59zg", + data: '{"auth":{"authType":"none","authActive":true},"headers":[{"key":"Custom-Header","value":"Custom header value overriden at folder-32","active":true},{"key":"key","value":"Set at folder-32","active":true}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: "folder-32", + parentID: "clx1fjmlq000310f86o4d3w2o", + folders: [], + requests: [ + { + id: "clx1j01dg003n10f8e34khl6v", + collectionID: "clx1izut7003m10f894ip59zg", + teamID: "clws3hg58000011o8h07glsb1", + title: "folder-32-request", + request: + '{"v":"4","auth":{"authType":"none","authActive":true},"body":{"body":null,"contentType":null},"name":"folder-32-request","method":"GET","params":[],"headers":[{"key":"Custom-Header","value":"Custom header value overriden at folder-32-request","active":true},{"key":"key","value":"Overriden at folder-32-request","active":true}],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits/overrides authorization/header set at the parent collection level\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(undefined)\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value overriden at folder-32-request\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n pw.expect(pw.response.body.headers[\\"Key\\"]).toBe(\\"Overriden at folder-32-request\\")\\n})","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + { + id: "clx1j2ka9003q10f8cdbzpgpg", + data: '{"auth":{"token":"test-token","authType":"bearer","password":"testpass","username":"testuser","authActive":true},"headers":[{"key":"Custom-Header","value":"Custom header value overriden at folder-33","active":true},{"key":"key","value":"Set at folder-33","active":true}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: "folder-33", + parentID: "clx1fjmlq000310f86o4d3w2o", + folders: [], + requests: [ + { + id: "clx1j361a003r10f8oly5m2n6", + collectionID: "clx1j2ka9003q10f8cdbzpgpg", + teamID: "clws3hg58000011o8h07glsb1", + title: "folder-33-request", + request: + '{"v":"4","auth":{"authType":"basic","password":"testpass","username":"testuser","authActive":true},"body":{"body":null,"contentType":null},"name":"folder-33-request","method":"GET","params":[],"headers":[{"key":"Custom-Header-Request-Level","value":"New custom header added at the folder-33-request level","active":true},{"key":"key","value":"Overriden at folder-33-request","active":true}],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits/overrides authorization/header set at the parent collection level with new header addition\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(\\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\\")\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value overriden at folder-33\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n pw.expect(pw.response.body.headers[\\"Key\\"]).toBe(\\"Overriden at folder-33-request\\")\\n pw.expect(pw.response.body.headers[\\"Custom-Header-Request-Level\\"]).toBe(\\"New custom header added at the folder-33-request level\\")\\n})","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + ], + requests: [ + { + id: "clx1jk1nq004y10f8fhtxvs02", + collectionID: "clx1fjmlq000310f86o4d3w2o", + teamID: "clws3hg58000011o8h07glsb1", + title: "folder-3-request", + request: + '{"v":"4","auth":{"authType":"basic","password":"testpass","username":"testuser","authActive":true},"body":{"body":null,"contentType":null},"name":"folder-3-request","method":"GET","params":[],"headers":[{"key":"Custom-Header-Request-Level","value":"New custom header added at the folder-3-request level","active":true},{"key":"key","value":"Set at folder-3-request","active":true}],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits/overrides authorization/header set at the parent collection level with new header addition\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(\\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\\")\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value overriden at folder-3\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n pw.expect(pw.response.body.headers[\\"Key\\"]).toBe(\\"Set at folder-3-request\\")\\n pw.expect(pw.response.body.headers[\\"Custom-Header-Request-Level\\"]).toBe(\\"New custom header added at the folder-3-request level\\")\\n})","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + ], + requests: [ + { + id: "clx1g2pnv000b10f80f0oyp79", + collectionID: "clx1f86hv000010f8szcfya0t", + teamID: "clws3hg58000011o8h07glsb1", + title: "root-collection-request", + request: `{"v":"${RESTReqSchemaVersion}","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"root-collection-request","method":"GET","params":[],"headers":[],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits authorization/header set at the parent collection level\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(\\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\\")\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value set at the root collection\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n})","preRequestScript":"","requestVariables":[],"responses":{}, "description": ""}`, + }, + ], + }, + ]; + +export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: HoppCollection[] = + [ + { + v: CollectionSchemaVersion, + id: "clx1f86hv000010f8szcfya0t", + name: "Multiple child collections with authorization, headers and variables set at each level", + folders: [ + { + v: CollectionSchemaVersion, + id: "clx1fjgah000110f8a5bs68gd", + name: "folder-1", + folders: [ + { + v: CollectionSchemaVersion, + id: "clx1fjwmm000410f8l1gkkr1a", + name: "folder-11", + folders: [], + requests: [ + { + v: "4", + auth: { + authType: "inherit", + password: "testpass", + username: "testuser", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "folder-11-request", + method: "GET", + params: [], + headers: [], + endpoint: "https://httpbin.org/get", + testScript: + '// Check status code is 200\npw.test("Status code is 200", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test("Successfully inherits authorization/header set at the parent collection level", () => {\n pw.expect(pw.response.body.headers["Authorization"]).toBe("Basic dGVzdHVzZXI6dGVzdHBhc3M=")\n \n pw.expect(pw.response.body.headers["Custom-Header"]).toBe("Custom header value overriden at folder-1")\n pw.expect(pw.response.body.headers["Inherited-Header"]).toBe("Inherited header at all levels")\n})', + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + authType: "inherit", + authActive: true, + }, + headers: [ + { + key: "key", + value: "Set at folder-11", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + { + v: CollectionSchemaVersion, + id: "clx1fjyxm000510f8pv90dt43", + name: "folder-12", + folders: [], + requests: [ + { + v: "4", + auth: { + authType: "none", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "folder-12-request", + method: "GET", + params: [], + headers: [ + { + key: "Custom-Header", + value: + "Custom header value overriden at folder-12-request", + active: true, + }, + { + key: "key", + value: "Overriden at folder-12-request", + active: true, + }, + ], + endpoint: "https://httpbin.org/get", + testScript: + '// Check status code is 200\npw.test("Status code is 200", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test("Successfully inherits/overrides authorization/header set at the parent collection level", () => {\n pw.expect(pw.response.body.headers["Authorization"]).toBe(undefined)\n \n pw.expect(pw.response.body.headers["Custom-Header"]).toBe("Custom header value overriden at folder-12-request")\n pw.expect(pw.response.body.headers["Inherited-Header"]).toBe("Inherited header at all levels")\n pw.expect(pw.response.body.headers["Key"]).toBe("Overriden at folder-12-request")\n})', + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + authType: "none", + authActive: true, + }, + headers: [ + { + key: "Custom-Header", + value: "Custom header value overriden at folder-12", + active: true, + description: "", + }, + { + key: "key", + value: "Set at folder-12", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + { + v: CollectionSchemaVersion, + id: "clx1fk1cv000610f88kc3aupy", + name: "folder-13", + folders: [], + requests: [ + { + v: "4", + auth: { + key: "api-key", + addTo: "HEADERS", + value: "api-key-value", + authType: "basic", + password: "testpass", + username: "testuser", + authActive: true, + grantTypeInfo: { + token: "", + isPKCE: true, + clientID: "sfasfa", + password: "", + username: "", + grantType: "AUTHORIZATION_CODE", + authEndpoint: "asfafs", + clientSecret: "sfasfasf", + tokenEndpoint: "asfa", + codeVerifierMethod: "S256", + }, + }, + body: { + body: null, + contentType: null, + }, + name: "folder-13-request", + method: "GET", + params: [], + headers: [ + { + key: "Custom-Header-Request-Level", + value: + "New custom header added at the folder-13-request level", + active: true, + }, + { + key: "key", + value: "Overriden at folder-13-request", + active: true, + }, + ], + endpoint: "https://httpbin.org/get", + testScript: + '// Check status code is 200\npw.test("Status code is 200", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test("Successfully inherits/overrides authorization/header set at the parent collection level with new header addition", () => {\n pw.expect(pw.response.body.headers["Authorization"]).toBe("Basic dGVzdHVzZXI6dGVzdHBhc3M=")\n \n pw.expect(pw.response.body.headers["Custom-Header"]).toBe("Custom header value overriden at folder-13")\n pw.expect(pw.response.body.headers["Inherited-Header"]).toBe("Inherited header at all levels")\n pw.expect(pw.response.body.headers["Key"]).toBe("Overriden at folder-13-request")\n pw.expect(pw.response.body.headers["Custom-Header-Request-Level"]).toBe("New custom header added at the folder-13-request level")\n})', + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + token: "test-token", + authType: "bearer", + authActive: true, + }, + headers: [ + { + key: "Custom-Header", + value: "Custom header value overriden at folder-13", + active: true, + description: "", + }, + { + key: "key", + value: "Set at folder-13", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + ], + requests: [ + { + v: "4", + auth: { + authType: "inherit", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "folder-1-request", + method: "GET", + params: [], + headers: [], + endpoint: "https://httpbin.org/get", + testScript: + '// Check status code is 200\npw.test("Status code is 200", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test("Successfully inherits authorization/header set at the parent collection level", () => {\n pw.expect(pw.response.body.headers["Authorization"]).toBe("Basic dGVzdHVzZXI6dGVzdHBhc3M=")\n \n pw.expect(pw.response.body.headers["Custom-Header"]).toBe("Custom header value overriden at folder-1")\n pw.expect(pw.response.body.headers["Inherited-Header"]).toBe("Inherited header at all levels")\n})', + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + authType: "inherit", + authActive: true, + }, + headers: [ + { + key: "Custom-Header", + value: "Custom header value overriden at folder-1", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + { + v: CollectionSchemaVersion, + id: "clx1fjk9o000210f8j0573pls", + name: "folder-2", + folders: [ + { + v: CollectionSchemaVersion, + id: "clx1fk516000710f87sfpw6bo", + name: "folder-21", + folders: [], + requests: [ + { + v: "4", + auth: { + authType: "inherit", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "folder-21-request", + method: "GET", + params: [], + headers: [], + endpoint: "https://httpbin.org/get", + testScript: + '// Check status code is 200\npw.test("Status code is 200", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test("Successfully inherits authorization/header set at the parent collection level", () => {\n pw.expect(pw.response.body.headers["Authorization"]).toBe(undefined)\n \n pw.expect(pw.response.body.headers["Custom-Header"]).toBe("Custom header value overriden at folder-2")\n pw.expect(pw.response.body.headers["Inherited-Header"]).toBe("Inherited header at all levels")\n})', + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + authType: "inherit", + authActive: true, + }, + headers: [ + { + key: "key", + value: "Set at folder-21", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + { + v: CollectionSchemaVersion, + id: "clx1fk72t000810f8gfwkpi5y", + name: "folder-22", + folders: [], + requests: [ + { + v: "4", + auth: { + authType: "none", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "folder-22-request", + method: "GET", + params: [], + headers: [ + { + key: "Custom-Header", + value: + "Custom header value overriden at folder-22-request", + active: true, + }, + { + key: "key", + value: "Overriden at folder-22-request", + active: true, + }, + ], + endpoint: "https://httpbin.org/get", + testScript: + '// Check status code is 200\npw.test("Status code is 200", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test("Successfully inherits/overrides authorization/header set at the parent collection level", () => {\n pw.expect(pw.response.body.headers["Authorization"]).toBe(undefined)\n \n pw.expect(pw.response.body.headers["Custom-Header"]).toBe("Custom header value overriden at folder-22-request")\n pw.expect(pw.response.body.headers["Inherited-Header"]).toBe("Inherited header at all levels")\n pw.expect(pw.response.body.headers["Key"]).toBe("Overriden at folder-22-request")\n})', + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + authType: "none", + authActive: true, + }, + headers: [ + { + key: "Custom-Header", + value: "Custom header value overriden at folder-22", + active: true, + description: "", + }, + { + key: "key", + value: "Set at folder-22", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + { + v: CollectionSchemaVersion, + id: "clx1fk95g000910f8bunhaoo8", + name: "folder-23", + folders: [], + requests: [ + { + v: "4", + auth: { + authType: "basic", + password: "testpass", + username: "testuser", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "folder-23-request", + method: "GET", + params: [], + headers: [ + { + key: "Custom-Header-Request-Level", + value: + "New custom header added at the folder-23-request level", + active: true, + }, + { + key: "key", + value: "Overriden at folder-23-request", + active: true, + }, + ], + endpoint: "https://httpbin.org/get", + testScript: + '// Check status code is 200\npw.test("Status code is 200", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test("Successfully inherits/overrides authorization/header set at the parent collection level with new header addition", () => {\n pw.expect(pw.response.body.headers["Authorization"]).toBe("Basic dGVzdHVzZXI6dGVzdHBhc3M=")\n \n pw.expect(pw.response.body.headers["Custom-Header"]).toBe("Custom header value overriden at folder-23")\n pw.expect(pw.response.body.headers["Inherited-Header"]).toBe("Inherited header at all levels")\n pw.expect(pw.response.body.headers["Key"]).toBe("Overriden at folder-23-request")\n pw.expect(pw.response.body.headers["Custom-Header-Request-Level"]).toBe("New custom header added at the folder-23-request level")\n})', + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + token: "test-token", + authType: "bearer", + password: "testpass", + username: "testuser", + authActive: true, + }, + headers: [ + { + key: "Custom-Header", + value: "Custom header value overriden at folder-23", + active: true, + description: "", + }, + { + key: "key", + value: "Set at folder-23", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + ], + requests: [ + { + v: "4", + auth: { + authType: "none", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "folder-2-request", + method: "GET", + params: [], + headers: [ + { + key: "Custom-Header", + value: "Custom header value overriden at folder-2-request", + active: true, + }, + ], + endpoint: "https://httpbin.org/get", + testScript: + '// Check status code is 200\npw.test("Status code is 200", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test("Successfully inherits/overrides authorization/header set at the parent collection level", () => {\n pw.expect(pw.response.body.headers["Authorization"]).toBe(undefined)\n \n pw.expect(pw.response.body.headers["Custom-Header"]).toBe("Custom header value overriden at folder-2-request")\n pw.expect(pw.response.body.headers["Inherited-Header"]).toBe("Inherited header at all levels")\n})', + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + authType: "none", + authActive: true, + }, + headers: [ + { + key: "Custom-Header", + value: "Custom header value overriden at folder-2", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + + { + v: CollectionSchemaVersion, + id: "clx1fjmlq000310f86o4d3w2o", + name: "folder-3", + folders: [ + { + v: CollectionSchemaVersion, + id: "clx1iwq0p003e10f8u8zg0p85", + name: "folder-31", + folders: [], + requests: [ + { + v: "4", + auth: { + authType: "inherit", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "folder-31-request", + method: "GET", + params: [], + headers: [], + endpoint: "https://httpbin.org/get", + testScript: + '// Check status code is 200\npw.test("Status code is 200", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test("Successfully inherits authorization/header set at the parent collection level", () => {\n pw.expect(pw.response.body.headers["Authorization"]).toBe("Basic dGVzdHVzZXI6dGVzdHBhc3M=")\n \n pw.expect(pw.response.body.headers["Custom-Header"]).toBe("Custom header value overriden at folder-3")\n pw.expect(pw.response.body.headers["Inherited-Header"]).toBe("Inherited header at all levels")\n})', + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + authType: "inherit", + authActive: true, + }, + headers: [ + { + key: "key", + value: "Set at folder-31", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + { + v: CollectionSchemaVersion, + id: "clx1izut7003m10f894ip59zg", + name: "folder-32", + folders: [], + requests: [ + { + v: "4", + auth: { + authType: "none", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "folder-32-request", + method: "GET", + params: [], + headers: [ + { + key: "Custom-Header", + value: + "Custom header value overriden at folder-32-request", + active: true, + }, + { + key: "key", + value: "Overriden at folder-32-request", + active: true, + }, + ], + endpoint: "https://httpbin.org/get", + testScript: + '// Check status code is 200\npw.test("Status code is 200", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test("Successfully inherits/overrides authorization/header set at the parent collection level", () => {\n pw.expect(pw.response.body.headers["Authorization"]).toBe(undefined)\n \n pw.expect(pw.response.body.headers["Custom-Header"]).toBe("Custom header value overriden at folder-32-request")\n pw.expect(pw.response.body.headers["Inherited-Header"]).toBe("Inherited header at all levels")\n pw.expect(pw.response.body.headers["Key"]).toBe("Overriden at folder-32-request")\n})', + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + authType: "none", + authActive: true, + }, + headers: [ + { + key: "Custom-Header", + value: "Custom header value overriden at folder-32", + active: true, + description: "", + }, + { + key: "key", + value: "Set at folder-32", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + { + v: CollectionSchemaVersion, + id: "clx1j2ka9003q10f8cdbzpgpg", + name: "folder-33", + folders: [], + requests: [ + { + v: "4", + auth: { + authType: "basic", + password: "testpass", + username: "testuser", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "folder-33-request", + method: "GET", + params: [], + headers: [ + { + key: "Custom-Header-Request-Level", + value: + "New custom header added at the folder-33-request level", + active: true, + }, + { + key: "key", + value: "Overriden at folder-33-request", + active: true, + }, + ], + endpoint: "https://httpbin.org/get", + testScript: + '// Check status code is 200\npw.test("Status code is 200", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test("Successfully inherits/overrides authorization/header set at the parent collection level with new header addition", () => {\n pw.expect(pw.response.body.headers["Authorization"]).toBe("Basic dGVzdHVzZXI6dGVzdHBhc3M=")\n \n pw.expect(pw.response.body.headers["Custom-Header"]).toBe("Custom header value overriden at folder-33")\n pw.expect(pw.response.body.headers["Inherited-Header"]).toBe("Inherited header at all levels")\n pw.expect(pw.response.body.headers["Key"]).toBe("Overriden at folder-33-request")\n pw.expect(pw.response.body.headers["Custom-Header-Request-Level"]).toBe("New custom header added at the folder-33-request level")\n})', + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + token: "test-token", + authType: "bearer", + password: "testpass", + username: "testuser", + authActive: true, + }, + headers: [ + { + key: "Custom-Header", + value: "Custom header value overriden at folder-33", + active: true, + description: "", + }, + { + key: "key", + value: "Set at folder-33", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + ], + requests: [ + { + v: "4", + auth: { + authType: "basic", + password: "testpass", + username: "testuser", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "folder-3-request", + method: "GET", + params: [], + headers: [ + { + key: "Custom-Header-Request-Level", + value: + "New custom header added at the folder-3-request level", + active: true, + }, + { + key: "key", + value: "Set at folder-3-request", + active: true, + }, + ], + endpoint: "https://httpbin.org/get", + testScript: + '// Check status code is 200\npw.test("Status code is 200", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test("Successfully inherits/overrides authorization/header set at the parent collection level with new header addition", () => {\n pw.expect(pw.response.body.headers["Authorization"]).toBe("Basic dGVzdHVzZXI6dGVzdHBhc3M=")\n \n pw.expect(pw.response.body.headers["Custom-Header"]).toBe("Custom header value overriden at folder-3")\n pw.expect(pw.response.body.headers["Inherited-Header"]).toBe("Inherited header at all levels")\n pw.expect(pw.response.body.headers["Key"]).toBe("Set at folder-3-request")\n pw.expect(pw.response.body.headers["Custom-Header-Request-Level"]).toBe("New custom header added at the folder-3-request level")\n})', + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + key: "testuser", + addTo: "HEADERS", + value: "testpass", + authType: "basic", + password: "testpass", + username: "testuser", + authActive: true, + }, + headers: [ + { + key: "Custom-Header", + value: "Custom header value overriden at folder-3", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + ], + requests: [ + { + v: RESTReqSchemaVersion, + auth: { + authType: "inherit", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "root-collection-request", + method: "GET", + params: [], + headers: [], + endpoint: "https://httpbin.org/get", + testScript: + '// Check status code is 200\npw.test("Status code is 200", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test("Successfully inherits authorization/header set at the parent collection level", () => {\n pw.expect(pw.response.body.headers["Authorization"]).toBe("Basic dGVzdHVzZXI6dGVzdHBhc3M=")\n \n pw.expect(pw.response.body.headers["Custom-Header"]).toBe("Custom header value set at the root collection")\n pw.expect(pw.response.body.headers["Inherited-Header"]).toBe("Inherited header at all levels")\n})', + preRequestScript: "", + requestVariables: [], + responses: {}, + description: "", + }, + ], + auth: { + authType: "basic", + password: "testpass", + username: "testuser", + authActive: true, + }, + headers: [ + { + key: "Custom-Header", + value: "Custom header value set at the root collection", + active: true, + description: "", + }, + { + key: "Inherited-Header", + value: "Inherited header at all levels", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + ]; + +// Collections with `data` field set to `null` at certain levels +export const WORKSPACE_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_LEVELS_MOCK: WorkspaceCollection[] = + [ + { + id: "clx1kxvao005m10f8luqivrf1", + data: null, + title: "Collection with no authorization/headers/variables set", + parentID: null, + folders: [ + { + id: "clx1kygjt005n10f8m1nkhjux", + data: null, + title: "folder-1", + parentID: "clx1kxvao005m10f8luqivrf1", + folders: [], + requests: [ + { + id: "clx1kz2gk005p10f8ll7ztbnj", + collectionID: "clx1kygjt005n10f8m1nkhjux", + teamID: "clws3hg58000011o8h07glsb1", + title: "req1", + request: + '{"v":"4","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"req1","method":"GET","params":[],"headers":[],"endpoint":"https://echo.hoppscotch.io","testScript":"","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + { + id: "clx1kym98005o10f8qg17t9o2", + data: '{"auth":{"authType":"none","authActive":true},"headers":[{"key":"Custom-Header","value":"Set at folder-2","active":true}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: "folder-2", + parentID: "clx1kxvao005m10f8luqivrf1", + folders: [], + requests: [ + { + id: "clx1kz3m7005q10f8lw3v09l4", + collectionID: "clx1kym98005o10f8qg17t9o2", + teamID: "clws3hg58000011o8h07glsb1", + title: "req2", + request: + '{"v":"4","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"req2","method":"GET","params":[],"headers":[],"endpoint":"https://echo.hoppscotch.io","testScript":"","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + { + id: "clx1l2bu6005r10f8daynohge", + data: null, + title: "folder-3", + parentID: "clx1kxvao005m10f8luqivrf1", + folders: [], + requests: [], + }, + { + id: "clx1l2eaz005s10f8loetbbeb", + data: '{"auth":{"authType":"none","authActive":true},"headers":[{"key":"Custom-Header","value":"Set at folder-4","active":true}],"variables":[{"key":"collection-variable","currentValue":"collection-variable-value","initialValue":"collection-variable-value","secret":false}]}', + title: "folder-4", + parentID: "clx1kxvao005m10f8luqivrf1", + folders: [], + requests: [], + }, + ], + requests: [], + }, + ]; + +export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_LEVELS_MOCK: HoppCollection[] = + [ + { + v: CollectionSchemaVersion, + id: "clx1kxvao005m10f8luqivrf1", + name: "Collection with no authorization/headers/variables set", + folders: [ + { + v: CollectionSchemaVersion, + id: "clx1kygjt005n10f8m1nkhjux", + name: "folder-1", + folders: [], + requests: [ + { + v: "4", + auth: { + authType: "inherit", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "req1", + method: "GET", + params: [], + headers: [], + endpoint: "https://echo.hoppscotch.io", + testScript: "", + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + authType: "inherit", + authActive: true, + }, + headers: [], + variables: [], + description: null, + preRequestScript: "", + testScript: "", + }, + { + v: CollectionSchemaVersion, + id: "clx1kym98005o10f8qg17t9o2", + name: "folder-2", + folders: [], + requests: [ + { + v: "4", + auth: { + authType: "inherit", + authActive: true, + }, + body: { + body: null, + contentType: null, + }, + name: "req2", + method: "GET", + params: [], + headers: [], + endpoint: "https://echo.hoppscotch.io", + testScript: "", + preRequestScript: "", + requestVariables: [], + }, + ], + auth: { + authType: "none", + authActive: true, + }, + headers: [ + { + key: "Custom-Header", + value: "Set at folder-2", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + { + v: CollectionSchemaVersion, + id: "clx1l2bu6005r10f8daynohge", + name: "folder-3", + folders: [], + requests: [], + auth: { + authType: "inherit", + authActive: true, + }, + headers: [], + variables: [], + description: null, + preRequestScript: "", + testScript: "", + }, + { + v: CollectionSchemaVersion, + id: "clx1l2eaz005s10f8loetbbeb", + name: "folder-4", + folders: [], + requests: [], + auth: { + authType: "none", + authActive: true, + }, + headers: [ + { + key: "Custom-Header", + value: "Set at folder-4", + active: true, + description: "", + }, + ], + variables: [ + { + key: "collection-variable", + currentValue: "collection-variable-value", + initialValue: "collection-variable-value", + secret: false, + }, + ], + description: null, + preRequestScript: "", + testScript: "", + }, + ], + requests: [], + auth: { + authType: "inherit", + authActive: true, + }, + headers: [], + variables: [], + description: null, + preRequestScript: "", + testScript: "", + }, + ]; + +export const WORKSPACE_ENVIRONMENT_V0_FORMAT_MOCK = { + id: "clwudd68q00079rufju8uo3om", + teamID: "clws3hg58000011o8h07glsb1", + name: "Workspace environment v0 format", + variables: [ + { + key: "firstName", + value: "John", + }, + { + key: "lastName", + value: "Doe", + }, + ], +}; + +export const TRANSFORMED_ENVIRONMENT_V0_FORMAT_MOCK: Environment = { + v: EnvironmentSchemaVersion, + id: "clwudd68q00079rufju8uo3om", + name: "Workspace environment v0 format", + variables: [ + { + key: "firstName", + initialValue: "John", + currentValue: "John", + secret: false, + }, + { + key: "lastName", + initialValue: "Doe", + currentValue: "Doe", + secret: false, + }, + ], +}; + +export const WORKSPACE_ENVIRONMENT_V1_FORMAT_MOCK = { + id: "clwudd68q00079rufju8uo3om", + teamID: "clws3hg58000011o8h07glsb1", + name: "Workspace environment v1 format", + variables: [ + { + key: "firstName", + value: "John", + secret: false, + }, + { + key: "lastName", + value: "Doe", + secret: false, + }, + ], +}; + +export const TRANSFORMED_ENVIRONMENT_V1_FORMAT_MOCK: Environment = { + v: EnvironmentSchemaVersion, + id: "clwudd68q00079rufju8uo3om", + name: "Workspace environment v1 format", + variables: [ + { + key: "firstName", + initialValue: "John", + currentValue: "John", + secret: false, + }, + { + key: "lastName", + initialValue: "Doe", + currentValue: "Doe", + secret: false, + }, + ], +}; + +export const WORKSPACE_ENVIRONMENT_V2_FORMAT_MOCK: WorkspaceEnvironment = { + id: "clwudd68q00079rufju8uo3on", + teamID: "clws3hg58000011o8h07glsb1", + name: "Response body sample", + variables: [ + { + key: "firstName", + initialValue: "John", + currentValue: "John", + secret: false, + }, + { + key: "lastName", + initialValue: "Doe", + currentValue: "Doe", + secret: false, + }, + { + key: "id", + initialValue: "7", + currentValue: "7", + secret: false, + }, + { + key: "fullName", + initialValue: "<> <>", + currentValue: "<> <>", + secret: false, + }, + { + key: "recursiveVarX", + initialValue: "<>", + currentValue: "<>", + secret: false, + }, + { + key: "recursiveVarY", + initialValue: "<>", + currentValue: "<>", + secret: false, + }, + { + key: "salutation", + initialValue: "Hello", + currentValue: "Hello", + secret: false, + }, + { + key: "greetText", + initialValue: "<> <>", + currentValue: "<> <>", + secret: false, + }, + ], +}; + +export const TRANSFORMED_ENVIRONMENT_V2_FORMAT_MOCK: Environment = { + v: EnvironmentSchemaVersion, + id: "clwudd68q00079rufju8uo3on", + name: "Response body sample", + variables: [ + { + key: "firstName", + initialValue: "John", + currentValue: "John", + secret: false, + }, + { + key: "lastName", + initialValue: "Doe", + currentValue: "Doe", + secret: false, + }, + { + key: "id", + initialValue: "7", + currentValue: "7", + secret: false, + }, + { + key: "fullName", + initialValue: "<> <>", + currentValue: "<> <>", + secret: false, + }, + { + key: "recursiveVarX", + initialValue: "<>", + currentValue: "<>", + secret: false, + }, + { + key: "recursiveVarY", + initialValue: "<>", + currentValue: "<>", + secret: false, + }, + { + key: "salutation", + initialValue: "Hello", + currentValue: "Hello", + secret: false, + }, + { + key: "greetText", + initialValue: "<> <>", + currentValue: "<> <>", + secret: false, + }, + ], +}; diff --git a/packages/hoppscotch-cli/src/__tests__/unit/getters.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/getters.spec.ts new file mode 100644 index 0000000..21304d5 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/unit/getters.spec.ts @@ -0,0 +1,507 @@ +import axios, { AxiosError, AxiosResponse } from "axios"; +import fs from "fs/promises"; +import { describe, expect, test, vi } from "vitest"; + +import { + CollectionSchemaVersion, + HoppCollection, + getDefaultRESTRequest, +} from "@hoppscotch/data"; + +import { DEFAULT_DURATION_PRECISION } from "../../utils/constants"; +import { + getDurationInSeconds, + getEffectiveFinalMetaData, + getResolvedVariables, + getResourceContents, +} from "../../utils/getters"; +import * as mutators from "../../utils/mutators"; + +import * as workspaceAccessHelpers from "../../utils/workspace-access"; + +describe("getters", () => { + describe("getDurationInSeconds", () => { + const testDurations = [ + { end: [1, 111111111], precision: 1, expected: 1.1 }, + { end: [2, 333333333], precision: 2, expected: 2.33 }, + { + end: [3, 555555555], + precision: DEFAULT_DURATION_PRECISION, + expected: 3.556, + }, + { end: [4, 777777777], precision: 4, expected: 4.7778 }, + ]; + + test.each(testDurations)( + "($end.0 s + $end.1 ns) rounded-off to $expected", + ({ end, precision, expected }) => { + expect(getDurationInSeconds(end as [number, number], precision)).toBe( + expected + ); + } + ); + }); + + describe("getEffectiveFinalMetaData", () => { + const environmentVariables = [ + { + key: "PARAM", + initialValue: "parsed_param", + currentValue: "parsed_param", + secret: false, + }, + ]; + + test("Empty list of meta-data", () => { + expect( + getEffectiveFinalMetaData([], environmentVariables) + ).toSubsetEqualRight([]); + }); + + test("Non-empty active list of meta-data with unavailable ENV", () => { + expect( + getEffectiveFinalMetaData( + [ + { + active: true, + key: "<>", + value: "<>", + description: "", + }, + ], + environmentVariables + ) + ).toSubsetEqualRight([{ active: true, key: "", value: "" }]); + }); + + test("Inactive list of meta-data", () => { + expect( + getEffectiveFinalMetaData( + [{ active: false, key: "KEY", value: "<>", description: "" }], + environmentVariables + ) + ).toSubsetEqualRight([]); + }); + + test("Active list of meta-data", () => { + expect( + getEffectiveFinalMetaData( + [{ active: true, key: "PARAM", value: "<>", description: "" }], + environmentVariables + ) + ).toSubsetEqualRight([ + { active: true, key: "PARAM", value: "parsed_param" }, + ]); + }); + }); + + describe("getResourceContents", () => { + describe("Network call failure", () => { + const args = { + pathOrId: "test-collection-id-or-path", + resourceType: "collection" as const, + accessToken: "test-token", + serverUrl: "test-url", + }; + + const cases = [ + { + description: + "Promise rejects with the code `SERVER_CONNECTION_REFUSED` if the network call fails with the code `ECONNREFUSED`", + args, + axiosMock: { + code: "ECONNREFUSED", + }, + expected: { + code: "SERVER_CONNECTION_REFUSED", + data: args.serverUrl, + }, + }, + { + description: + "Promise rejects with the code `INVALID_SERVER_URL` if the network call fails with the code `ERR_INVALID_URL`", + args, + axiosMock: { + code: "ERR_INVALID_URL", + }, + expected: { + code: "INVALID_SERVER_URL", + data: args.serverUrl, + }, + }, + { + description: + "Promise rejects with the code `INVALID_SERVER_URL` if the network call fails with the code `ENOTFOUND`", + args, + axiosMock: { + code: "ENOTFOUND", + }, + expected: { + code: "INVALID_SERVER_URL", + data: args.serverUrl, + }, + }, + { + description: + "Promise rejects with the code `INVALID_SERVER_URL` if the network call returns a response with a status code of `404`", + args, + axiosMock: { + response: { + status: 404, + }, + }, + expected: { + code: "INVALID_SERVER_URL", + data: args.serverUrl, + }, + }, + { + description: + "Promise rejects with the code `TOKEN_EXPIRED` if the network call fails for the same reason", + args, + axiosMock: { + response: { + data: { + reason: "TOKEN_EXPIRED", + }, + }, + }, + expected: { + code: "TOKEN_EXPIRED", + data: args.accessToken, + }, + }, + { + description: + "Promise rejects with the code `TOKEN_INVALID` if the network call fails for the same reason", + args, + axiosMock: { + response: { + data: { + reason: "TOKEN_INVALID", + }, + }, + }, + expected: { + code: "TOKEN_INVALID", + data: args.accessToken, + }, + }, + { + description: + "Promise rejects with the code `INVALID_ID` if the network call fails for the same reason when the supplied collection ID or path is invalid", + args, + axiosMock: { + response: { + data: { + reason: "INVALID_ID", + }, + }, + }, + expected: { + code: "INVALID_ID", + data: args.pathOrId, + }, + }, + { + description: + "Promise rejects with the code `INVALID_ID` if the network call fails for the same reason when the supplied environment ID or path is invalid", + args: { + ...args, + pathOrId: "test-environment-id-or-path", + resourceType: "environment" as const, + }, + axiosMock: { + response: { + data: { + reason: "INVALID_ID", + }, + }, + }, + expected: { + code: "INVALID_ID", + data: "test-environment-id-or-path", + }, + }, + ]; + + test.each(cases)("$description", ({ args, axiosMock, expected }) => { + const { code, response } = axiosMock; + const axiosErrMessage = code ?? response?.data?.reason; + + vi.spyOn(axios, "get").mockImplementation(() => + Promise.reject( + new AxiosError( + axiosErrMessage, + code, + undefined, + undefined, + response as AxiosResponse + ) + ) + ); + + expect(getResourceContents(args)).rejects.toEqual(expected); + }); + + test("Promise rejects with the code `INVALID_SERVER_URL` if the network call succeeds and the received response content type is not `application/json`", () => { + const expected = { + code: "INVALID_SERVER_URL", + data: args.serverUrl, + }; + + vi.spyOn(axios, "get").mockImplementation(() => + Promise.resolve({ + data: "", + headers: { "content-type": "text/html; charset=UTF-8" }, + }) + ); + + expect(getResourceContents(args)).rejects.toEqual(expected); + }); + + test("Promise rejects with the code `UNKNOWN_ERROR` while encountering an error that is not an instance of `AxiosError`", () => { + const expected = { + code: "UNKNOWN_ERROR", + data: new Error("UNKNOWN_ERROR"), + }; + + vi.spyOn(axios, "get").mockImplementation(() => + Promise.reject(new Error("UNKNOWN_ERROR")) + ); + + expect(getResourceContents(args)).rejects.toEqual(expected); + }); + }); + + describe("Success", () => { + test("Proceeds with reading from the file system if the supplied file exists in the path", async () => { + fs.access = vi.fn().mockResolvedValueOnce(undefined); + + const sampleCollectionContents: HoppCollection = { + v: CollectionSchemaVersion, + id: "valid-collection-id", + name: "valid-collection-title", + folders: [], + requests: [], + headers: [], + auth: { + authType: "none", + authActive: false, + }, + }; + + axios.get = vi.fn(); + + vi.spyOn(mutators, "readJsonFile").mockImplementation(() => + Promise.resolve(sampleCollectionContents) + ); + + const pathOrId = "valid-collection-file-path"; + const resourceType = "collection"; + const accessToken = "valid-access-token"; + const serverUrl = "valid-url"; + + const contents = await getResourceContents({ + pathOrId, + accessToken, + serverUrl, + resourceType, + }); + + expect(fs.access).toHaveBeenCalledWith(pathOrId); + expect(axios.get).not.toBeCalled(); + expect(mutators.readJsonFile).toHaveBeenCalledWith(pathOrId, true); + + expect(contents).toEqual(sampleCollectionContents); + }); + + test("Proceeds with the network call if a value for the access token is specified and the supplied path/id is not a valid file path", async () => { + fs.access = vi.fn().mockRejectedValueOnce(undefined); + + const sampleCollectionContents: HoppCollection = { + v: CollectionSchemaVersion, + name: "test-coll", + folders: [], + requests: [getDefaultRESTRequest()], + headers: [], + auth: { + authType: "none", + authActive: false, + }, + }; + + axios.get = vi.fn().mockImplementation(() => + Promise.resolve({ + data: { + id: "clx06ik0o00028t6uwywwnxgg", + data: null, + title: "test-coll", + parentID: null, + folders: [], + requests: [ + { + id: "clx06imin00038t6uynt5vyk4", + collectionID: "clx06ik0o00028t6uwywwnxgg", + teamID: "clwt6r6j10031kc6pu0b08y6e", + title: "req1", + request: + '{"v":"4","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"req1","method":"GET","params":[],"headers":[],"endpoint":"https://echo.hoppscotch.io","testScript":"","preRequestScript":"","requestVariables":[]}', + }, + ], + }, + headers: { + "content-type": "application/json", + }, + }) + ); + + const readJsonFileSpy = vi + .spyOn(mutators, "readJsonFile") + .mockImplementation(() => Promise.resolve(sampleCollectionContents)); + + vi.spyOn( + workspaceAccessHelpers, + "transformWorkspaceCollections" + ).mockImplementation(() => [sampleCollectionContents]); + + const pathOrId = "valid-collection-id"; + const resourceType = "collection"; + const accessToken = "valid-access-token"; + const serverUrl = "valid-url"; + + // Clear spy calls from setup + readJsonFileSpy.mockClear(); + + await getResourceContents({ + pathOrId, + accessToken, + serverUrl, + resourceType, + }); + + expect(fs.access).toHaveBeenCalledWith(pathOrId); + expect(axios.get).toBeCalledWith( + `${serverUrl}/v1/access-tokens/${resourceType}/${pathOrId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + expect( + workspaceAccessHelpers.transformWorkspaceCollections + ).toBeCalled(); + expect(readJsonFileSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe("getResolvedVariables", () => { + const requestVariables = [ + { + key: "SHARED_KEY_I", + value: "request-variable-shared-value-I", + active: true, + }, + { + key: "SHARED_KEY_II", + value: "", + active: true, + }, + { + key: "REQUEST_VAR_III", + value: "request-variable-value-III", + active: true, + }, + { + key: "REQUEST_VAR_IV", + value: "request-variable-value-IV", + active: false, + }, + { + key: "REQUEST_VAR_V", + value: "request-variable-value-V", + active: false, + }, + ]; + + const environmentVariables = [ + { + key: "SHARED_KEY_I", + initialValue: "environment-variable-shared-value-I", + currentValue: "environment-variable-shared-value-I", + secret: false, + }, + { + key: "SHARED_KEY_II", + initialValue: "environment-variable-shared-value-II", + currentValue: "environment-variable-shared-value-II", + secret: false, + }, + { + key: "ENV_VAR_III", + initialValue: "environment-variable-value-III", + currentValue: "environment-variable-value-III", + secret: false, + }, + { + key: "ENV_VAR_IV", + initialValue: "environment-variable-value-IV", + currentValue: "environment-variable-value-IV", + secret: false, + }, + { + key: "ENV_VAR_V", + initialValue: "environment-variable-value-V", + currentValue: "environment-variable-value-V", + secret: false, + }, + ]; + + test("Filters request variables by active status and value fields, then remove environment variables sharing the same keys", () => { + const expected = [ + { + key: "SHARED_KEY_I", + currentValue: "request-variable-shared-value-I", + initialValue: "request-variable-shared-value-I", + secret: false, + }, + { + key: "REQUEST_VAR_III", + currentValue: "request-variable-value-III", + initialValue: "request-variable-value-III", + secret: false, + }, + { + key: "SHARED_KEY_II", + currentValue: "environment-variable-shared-value-II", + initialValue: "environment-variable-shared-value-II", + secret: false, + }, + { + key: "ENV_VAR_III", + currentValue: "environment-variable-value-III", + initialValue: "environment-variable-value-III", + secret: false, + }, + { + key: "ENV_VAR_IV", + currentValue: "environment-variable-value-IV", + initialValue: "environment-variable-value-IV", + secret: false, + }, + { + key: "ENV_VAR_V", + currentValue: "environment-variable-value-V", + initialValue: "environment-variable-value-V", + secret: false, + }, + ]; + + expect( + getResolvedVariables(requestVariables, environmentVariables) + ).toEqual(expected); + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/unit/hopp-fetch.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/hopp-fetch.spec.ts new file mode 100644 index 0000000..5bf6bc2 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/unit/hopp-fetch.spec.ts @@ -0,0 +1,579 @@ +import { describe, expect, it, vi, beforeEach } from "vitest" + +// Mock modules before imports - NO external variable references in factory +vi.mock("axios", () => ({ + default: { + create: vi.fn(), + isAxiosError: vi.fn(), + }, +})) + +vi.mock("axios-cookiejar-support", () => ({ + wrapper: (instance: any) => instance, +})) + +vi.mock("tough-cookie", () => ({ + CookieJar: vi.fn(), +})) + +import { createHoppFetchHook } from "../../utils/hopp-fetch" +import axios from "axios" + +// Get the mocked functions to use in tests +const mockAxios = axios as any +const mockIsAxiosError = mockAxios.isAxiosError as ReturnType + +// Create the axios instance mock that will be returned by create() +const mockAxiosInstance = vi.fn() + +describe("CLI hopp-fetch", () => { + beforeEach(() => { + vi.clearAllMocks() + + // Set up axios.create to return our mockAxiosInstance + mockAxios.create.mockReturnValue(mockAxiosInstance) + + // Default successful response + mockAxiosInstance.mockResolvedValue({ + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + data: new ArrayBuffer(0), + }) + + // Reset isAxiosError mock + mockIsAxiosError.mockReturnValue(false) + }) + + describe("Request object property extraction", () => { + it("should extract method from Request object", async () => { + const hoppFetch = createHoppFetchHook() + + const request = new Request("https://api.example.com/data", { + method: "POST", + }) + + await hoppFetch(request) + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + }) + ) + }) + + it("should extract headers from Request object", async () => { + const hoppFetch = createHoppFetchHook() + + const request = new Request("https://api.example.com/data", { + headers: { + "X-Custom-Header": "test-value", + Authorization: "Bearer token123", + }, + }) + + await hoppFetch(request) + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + "x-custom-header": "test-value", + authorization: "Bearer token123", + }), + }) + ) + }) + + it("should extract body from Request object", async () => { + const hoppFetch = createHoppFetchHook() + + const request = new Request("https://api.example.com/data", { + method: "POST", + body: JSON.stringify({ key: "value" }), + }) + + await hoppFetch(request) + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.any(ArrayBuffer), // Body is converted to ArrayBuffer + }) + ) + }) + + it("should prefer init options over Request properties (method)", async () => { + const hoppFetch = createHoppFetchHook() + + const request = new Request("https://api.example.com/data", { + method: "POST", + }) + + // Init overrides Request method + await hoppFetch(request, { method: "PUT" }) + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + method: "PUT", + }) + ) + }) + + it("should prefer init headers over Request headers", async () => { + const hoppFetch = createHoppFetchHook() + + const request = new Request("https://api.example.com/data", { + headers: { "X-Custom": "from-request" }, + }) + + // Init overrides Request headers + await hoppFetch(request, { + headers: { "X-Custom": "from-init" }, + }) + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + "X-Custom": "from-init", + }), + }) + ) + }) + + it("should merge Request headers with init headers", async () => { + const hoppFetch = createHoppFetchHook() + + const request = new Request("https://api.example.com/data", { + headers: { "X-Request-Header": "value1" }, + }) + + await hoppFetch(request, { + headers: { "X-Init-Header": "value2" }, + }) + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + "x-request-header": "value1", + "X-Init-Header": "value2", + }), + }) + ) + }) + + it("should extract all properties from Request object", async () => { + const hoppFetch = createHoppFetchHook() + + const request = new Request("https://api.example.com/data", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-API-Key": "secret", + }, + body: JSON.stringify({ update: true }), + }) + + await hoppFetch(request) + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.example.com/data", + method: "PATCH", + headers: expect.objectContaining({ + "content-type": "application/json", + "x-api-key": "secret", + }), + data: expect.any(ArrayBuffer), + }) + ) + }) + }) + + describe("Standard fetch patterns", () => { + it("should handle string URLs", async () => { + const hoppFetch = createHoppFetchHook() + + await hoppFetch("https://api.example.com/data") + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.example.com/data", + method: "GET", + }) + ) + }) + + it("should handle URL objects", async () => { + const hoppFetch = createHoppFetchHook() + + const url = new URL("https://api.example.com/data") + await hoppFetch(url) + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.example.com/data", + }) + ) + }) + + it("should handle init options with string URL", async () => { + const hoppFetch = createHoppFetchHook() + + await hoppFetch("https://api.example.com/data", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ test: true }), + }) + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.example.com/data", + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/json", + }), + data: JSON.stringify({ test: true }), + }) + ) + }) + }) + + describe("Edge cases", () => { + it("should default to GET when no method specified", async () => { + const hoppFetch = createHoppFetchHook() + + await hoppFetch("https://api.example.com/data") + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + }) + ) + }) + + it("should handle Request with no headers", async () => { + const hoppFetch = createHoppFetchHook() + + const request = new Request("https://api.example.com/data") + + await hoppFetch(request) + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + headers: {}, + }) + ) + }) + + it("should handle Request with no body", async () => { + const hoppFetch = createHoppFetchHook() + + const request = new Request("https://api.example.com/data") + + await hoppFetch(request) + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + data: undefined, + }) + ) + }) + + it("should handle FormData body", async () => { + const hoppFetch = createHoppFetchHook() + + const formData = new FormData() + formData.append("key", "value") + + await hoppFetch("https://api.example.com/data", { + method: "POST", + body: formData, + }) + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + data: formData, + }) + ) + }) + + it("should handle Blob body", async () => { + const hoppFetch = createHoppFetchHook() + + const blob = new Blob(["test data"], { type: "text/plain" }) + + await hoppFetch("https://api.example.com/data", { + method: "POST", + body: blob, + }) + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + data: blob, + }) + ) + }) + + it("should handle ArrayBuffer body", async () => { + const hoppFetch = createHoppFetchHook() + + const buffer = new ArrayBuffer(8) + + await hoppFetch("https://api.example.com/data", { + method: "POST", + body: buffer, + }) + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + data: buffer, + }) + ) + }) + + it("should convert Headers object to plain object", async () => { + const hoppFetch = createHoppFetchHook() + + const headers = new Headers({ + "X-Custom": "value", + "Content-Type": "application/json", + }) + + await hoppFetch("https://api.example.com/data", { + headers, + }) + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + "x-custom": "value", + "content-type": "application/json", + }), + }) + ) + }) + + it("should convert headers array to plain object", async () => { + const hoppFetch = createHoppFetchHook() + + const headers: [string, string][] = [ + ["X-Custom", "value"], + ["Content-Type", "application/json"], + ] + + await hoppFetch("https://api.example.com/data", { + headers, + }) + + expect(mockAxiosInstance).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + "X-Custom": "value", + "Content-Type": "application/json", + }), + }) + ) + }) + }) + + describe("Response handling", () => { + it("should return response with correct status and statusText", async () => { + const hoppFetch = createHoppFetchHook() + + mockAxiosInstance.mockResolvedValue({ + status: 201, + statusText: "Created", + headers: {}, + data: new ArrayBuffer(0), + }) + + const response = await hoppFetch("https://api.example.com/data") + + expect(response.status).toBe(201) + expect(response.statusText).toBe("Created") + }) + + it("should set ok to true for 2xx status codes", async () => { + const hoppFetch = createHoppFetchHook() + + mockAxiosInstance.mockResolvedValue({ + status: 200, + statusText: "OK", + headers: {}, + data: new ArrayBuffer(0), + }) + + const response = await hoppFetch("https://api.example.com/data") + + expect(response.ok).toBe(true) + }) + + it("should set ok to false for non-2xx status codes", async () => { + const hoppFetch = createHoppFetchHook() + + mockAxiosInstance.mockResolvedValue({ + status: 404, + statusText: "Not Found", + headers: {}, + data: new ArrayBuffer(0), + }) + + const response = await hoppFetch("https://api.example.com/data") + + expect(response.ok).toBe(false) + }) + + it("should convert response headers to serializable format", async () => { + const hoppFetch = createHoppFetchHook() + + mockAxiosInstance.mockResolvedValue({ + status: 200, + statusText: "OK", + headers: { + "content-type": "application/json", + "x-custom-header": "value", + }, + data: new ArrayBuffer(0), + }) + + const response = await hoppFetch("https://api.example.com/data") + + expect(response.headers.get("content-type")).toBe("application/json") + expect(response.headers.get("x-custom-header")).toBe("value") + }) + + it("should handle Set-Cookie headers as array", async () => { + const hoppFetch = createHoppFetchHook() + + mockAxiosInstance.mockResolvedValue({ + status: 200, + statusText: "OK", + headers: { + "set-cookie": ["session=abc123", "token=xyz789"], + }, + data: new ArrayBuffer(0), + }) + + const response = await hoppFetch("https://api.example.com/data") + + expect(response.headers.getSetCookie()).toEqual([ + "session=abc123", + "token=xyz789", + ]) + }) + + it("should handle single Set-Cookie header as string", async () => { + const hoppFetch = createHoppFetchHook() + + mockAxiosInstance.mockResolvedValue({ + status: 200, + statusText: "OK", + headers: { + "set-cookie": "session=abc123", + }, + data: new ArrayBuffer(0), + }) + + const response = await hoppFetch("https://api.example.com/data") + + expect(response.headers.getSetCookie()).toEqual(["session=abc123"]) + }) + + it("should convert response body ArrayBuffer to byte array", async () => { + const hoppFetch = createHoppFetchHook() + + const data = new Uint8Array([72, 101, 108, 108, 111]) // "Hello" + mockAxiosInstance.mockResolvedValue({ + status: 200, + statusText: "OK", + headers: {}, + data: data.buffer, + }) + + const response = await hoppFetch("https://api.example.com/data") + + expect((response as any)._bodyBytes).toEqual([72, 101, 108, 108, 111]) + }) + + it("should handle response body text conversion", async () => { + const hoppFetch = createHoppFetchHook() + + const data = new TextEncoder().encode("Hello World") + mockAxiosInstance.mockResolvedValue({ + status: 200, + statusText: "OK", + headers: {}, + data: data.buffer, + }) + + const response = await hoppFetch("https://api.example.com/data") + const text = await response.text() + + expect(text).toBe("Hello World") + }) + + it("should handle response body json conversion", async () => { + const hoppFetch = createHoppFetchHook() + + const jsonData = { message: "success" } + const data = new TextEncoder().encode(JSON.stringify(jsonData)) + mockAxiosInstance.mockResolvedValue({ + status: 200, + statusText: "OK", + headers: {}, + data: data.buffer, + }) + + const response = await hoppFetch("https://api.example.com/data") + const json = await response.json() + + expect(json).toEqual(jsonData) + }) + }) + + describe("Error handling", () => { + it("should handle axios error with response", async () => { + const hoppFetch = createHoppFetchHook() + + const errorResponse = { + status: 500, + statusText: "Internal Server Error", + headers: {}, + data: new ArrayBuffer(0), + } + + mockAxiosInstance.mockRejectedValue({ + response: errorResponse, + isAxiosError: true, + }) + mockIsAxiosError.mockReturnValue(true) + + const response = await hoppFetch("https://api.example.com/data") + + expect(response.status).toBe(500) + expect(response.statusText).toBe("Internal Server Error") + }) + + it("should throw error for network failure without response", async () => { + const hoppFetch = createHoppFetchHook() + + const networkError = new Error("Network Error") + mockAxiosInstance.mockRejectedValue(networkError) + mockIsAxiosError.mockReturnValue(false) + + await expect(hoppFetch("https://api.example.com/data")).rejects.toThrow( + "Fetch failed: Network Error" + ) + }) + + it("should throw error for non-Error exceptions", async () => { + const hoppFetch = createHoppFetchHook() + + mockAxiosInstance.mockRejectedValue("String error") + mockIsAxiosError.mockReturnValue(false) + + await expect(hoppFetch("https://api.example.com/data")).rejects.toThrow( + "Fetch failed: Unknown error" + ) + }) + }) +}) diff --git a/packages/hoppscotch-cli/src/__tests__/unit/jsonc.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/jsonc.spec.ts new file mode 100644 index 0000000..9b4fcd7 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/unit/jsonc.spec.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "vitest"; +import { stripComments } from "../../utils/jsonc"; + +describe("stripComments", () => { + describe("handles inline comments", () => { + test("removes single inline comment", () => { + const input = '{"key": "value" // comment\n}'; + const result = stripComments(input); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ key: "value" }); + }); + + test("removes multiple inline comments", () => { + const input = '{\n "key1": "value1", // comment1\n "key2": "value2" // comment2\n}'; + const result = stripComments(input); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ key1: "value1", key2: "value2" }); + }); + }); + + describe("handles multiline comments", () => { + test("removes single multiline comment", () => { + const input = '{\n /* This is a comment */\n "key": "value"\n}'; + const result = stripComments(input); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ key: "value" }); + }); + + test("removes multiline comment spanning multiple lines", () => { + const input = '{\n /* This is\n a multiline\n comment */\n "key": "value"\n}'; + const result = stripComments(input); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ key: "value" }); + }); + }); + + describe("handles trailing commas", () => { + test("removes trailing comma in object", () => { + const input = '{"key": "value",}'; + const result = stripComments(input); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ key: "value" }); + }); + + test("removes trailing comma in array", () => { + const input = '["item1", "item2",]'; + const result = stripComments(input); + const parsed = JSON.parse(result); + expect(parsed).toEqual(["item1", "item2"]); + }); + + test("removes multiple trailing commas in nested structures", () => { + const input = '{"arr": ["a", "b",], "obj": {"key": "value",},}'; + const result = stripComments(input); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ arr: ["a", "b"], obj: { key: "value" } }); + }); + }); + + describe("handles combined cases", () => { + test("removes both comments and trailing commas", () => { + const input = '{\n "key1": "value1", // inline comment\n /* block comment */\n "key2": "value2",\n}'; + const result = stripComments(input); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ key1: "value1", key2: "value2" }); + }); + + test("handles nested objects with comments and trailing commas", () => { + const input = '{\n "outer": { // comment\n "inner": "value",\n },\n}'; + const result = stripComments(input); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ outer: { inner: "value" } }); + }); + }); + + describe("handles edge cases", () => { + test("returns empty string unchanged", () => { + const input = ""; + const result = stripComments(input); + expect(result).toBe(""); + }); + + test("returns whitespace-only string unchanged", () => { + const input = " \n \t "; + const result = stripComments(input); + expect(result).toBe(input); + }); + + test("handles valid JSON without comments", () => { + const input = '{"key": "value"}'; + const result = stripComments(input); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ key: "value" }); + }); + + test("preserves JSON strings containing comment-like sequences", () => { + const input = '{"url": "https://example.com//path"}'; + const result = stripComments(input); + const parsed = JSON.parse(result); + expect(parsed.url).toBe("https://example.com//path"); + }); + + test("handles deeply nested structures", () => { + const input = '{\n "a": {\n "b": {\n "c": {\n "d": "value", // nested comment\n },\n },\n },\n}'; + const result = stripComments(input); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ a: { b: { c: { d: "value" } } } }); + }); + + test("handles arrays with mixed content", () => { + const input = '[\n "string",\n 123, // number\n true, // boolean\n null, // null\n {"nested": "object",}, // object\n]'; + const result = stripComments(input); + const parsed = JSON.parse(result); + expect(parsed).toEqual(["string", 123, true, null, { nested: "object" }]); + }); + }); + + describe("handles null return from stripComments_", () => { + test("gracefully handles potential null from jsonc-parser", () => { + const input = '{"key": "value"}'; + const result = stripComments(input); + expect(result).toBeTruthy(); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ key: "value" }); + }); + }); + + describe("handles malformed JSON", () => { + test("attempts to parse malformed JSON and returns result", () => { + // jsonc-parser is lenient and tries to repair malformed JSON + const input = '{"key": "value"'; // missing closing brace + const result = stripComments(input); + // The parser will attempt to close the brace + expect(result).toBe('{"key":"value"}'); + }); + + test("gracefully handles completely invalid JSON", () => { + const input = 'this is not json at all {]}{]'; + const result = stripComments(input); + // jsonc-parser extracts what it can and returns an object (even if mostly empty) + expect(result).toBe('{}'); + }); + + test("handles JSON with syntax errors", () => { + const input = '{"key": undefined}'; // undefined is not valid JSON + const result = stripComments(input); + // Parser will handle this - exact behavior depends on jsonc-parser + expect(typeof result).toBe('string'); + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/unit/pre-request-inheritance.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/pre-request-inheritance.spec.ts new file mode 100644 index 0000000..d101b44 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/unit/pre-request-inheritance.spec.ts @@ -0,0 +1,216 @@ +import { describe, expect, test } from "vitest"; +import { makeRESTRequest } from "@hoppscotch/data"; +import * as E from "fp-ts/Either"; + +import { preRequestScriptRunner } from "../../utils/pre-request"; +import { HoppEnvs } from "../../types/request"; + +const SAMPLE_ENVS: HoppEnvs = { + global: [], + selected: [], +}; + +const SAMPLE_REQUEST = makeRESTRequest({ + name: "request", + method: "GET", + endpoint: "https://example.com", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + auth: { authActive: false, authType: "none" }, + body: { + contentType: null, + body: null, + }, + requestVariables: [], + description: null, + responses: {}, +}); + +describe("preRequestScriptRunner - inheritance", () => { + test("Inherited scripts execute in root → parent → request order", async () => { + const rootScript = `pw.env.set("ORDER", "root");`; + const parentScript = ` + const prev = pw.env.get("ORDER"); + pw.env.set("ORDER", prev + ",parent"); + `; + const request = makeRESTRequest({ + ...SAMPLE_REQUEST, + preRequestScript: ` + const prev = pw.env.get("ORDER"); + pw.env.set("ORDER", prev + ",request"); + `, + }); + + const result = await preRequestScriptRunner( + request, + SAMPLE_ENVS, + false, + undefined, + [rootScript, parentScript] + )(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const orderVar = result.right.updatedEnvs.selected.find( + (v) => v.key === "ORDER" + ); + expect(orderVar?.currentValue).toBe("root,parent,request"); + } + }); + + test("Inherited scripts set ENVs used in request endpoint resolution", async () => { + const rootScript = `pw.env.set("ENDPOINT", "https://example.com");`; + + const request = makeRESTRequest({ + ...SAMPLE_REQUEST, + endpoint: "<>", + preRequestScript: "", + }); + + const result = await preRequestScriptRunner( + request, + SAMPLE_ENVS, + false, + undefined, + [rootScript] + )(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + expect(result.right.effectiveRequest.effectiveFinalURL).toBe( + "https://example.com" + ); + } + }); + + test("Scripts with same local variable names do not collide (IIFE isolation)", async () => { + const rootScript = `const x = "root"; pw.env.set("ROOT_VAR", x);`; + const parentScript = `const x = "parent"; pw.env.set("PARENT_VAR", x);`; + + const request = makeRESTRequest({ + ...SAMPLE_REQUEST, + preRequestScript: `const x = "request"; pw.env.set("REQUEST_VAR", x);`, + }); + + const result = await preRequestScriptRunner( + request, + SAMPLE_ENVS, + false, + undefined, + [rootScript, parentScript] + )(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const envVars = result.right.updatedEnvs.selected; + expect(envVars.find((v) => v.key === "ROOT_VAR")?.currentValue).toBe( + "root" + ); + expect(envVars.find((v) => v.key === "PARENT_VAR")?.currentValue).toBe( + "parent" + ); + expect(envVars.find((v) => v.key === "REQUEST_VAR")?.currentValue).toBe( + "request" + ); + } + }); + + test("Empty inherited scripts are filtered out gracefully", async () => { + const validScript = `pw.env.set("ENDPOINT", "https://example.com");`; + + const request = makeRESTRequest({ + ...SAMPLE_REQUEST, + endpoint: "<>", + preRequestScript: "", + }); + + const result = await preRequestScriptRunner( + request, + SAMPLE_ENVS, + false, + undefined, + ["", " ", validScript, "\n"] + )(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + expect(result.right.effectiveRequest.effectiveFinalURL).toBe( + "https://example.com" + ); + } + }); + + test("Works correctly with no inherited scripts (backward compatibility)", async () => { + const request = makeRESTRequest({ + ...SAMPLE_REQUEST, + endpoint: "<>", + preRequestScript: `pw.env.set("ENDPOINT", "https://example.com");`, + }); + + const result = await preRequestScriptRunner( + request, + SAMPLE_ENVS, + false, + undefined, + [] + )(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + expect(result.right.effectiveRequest.effectiveFinalURL).toBe( + "https://example.com" + ); + } + }); + + // Regression: in the legacy (isolated-vm) sandbox, inherited scripts must + // execute sequentially in order. Pre-fix combineScriptsWithIIFE emitted an + // outer detached Promise that script.run did not await; sequential ordering + // was undefined. + // + // Note: the unit test exercises the sequential-ordering path. The + // post-`await` drop only surfaces with macrotask awaits (setTimeout) or + // cross-isolate Reference calls returning Promises — neither is exposed + // through the synchronous `pw.*` surface in node/legacy.ts, so it is not + // currently user-reachable in the CLI. The web worker path is where the + // post-`await` drop is user-reachable; covered by the smoke fixture in + // packages/hoppscotch-cli/src/__tests__/e2e/. + test("Legacy sandbox executes inherited scripts in order", async () => { + const rootScript = `pw.env.set("ORDER", "root");`; + const parentScript = ` + const prev = pw.env.get("ORDER"); + pw.env.set("ORDER", prev + ",parent"); + `; + const request = makeRESTRequest({ + ...SAMPLE_REQUEST, + preRequestScript: ` + const prev = pw.env.get("ORDER"); + pw.env.set("ORDER", prev + ",request"); + `, + }); + + const result = await preRequestScriptRunner( + request, + SAMPLE_ENVS, + true, + undefined, + [rootScript, parentScript] + )(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const orderVar = result.right.updatedEnvs.selected.find( + (v) => v.key === "ORDER" + ); + expect(orderVar?.currentValue).toBe("root,parent,request"); + } + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/unit/scripting.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/scripting.spec.ts new file mode 100644 index 0000000..ad9bedf --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/unit/scripting.spec.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from "vitest"; + +import { + combineScriptsWithIIFE, + stripModulePrefix, + MODULE_PREFIX, +} from "@hoppscotch/js-sandbox/scripting"; + +describe("scripting", () => { + describe("stripModulePrefix", () => { + test("strips 'export {};\\n' prefix", () => { + expect(stripModulePrefix("export {};\nconst x = 1;")).toBe( + "const x = 1;" + ); + }); + + test("strips 'export {};' prefix without newline", () => { + expect(stripModulePrefix("export {};const x = 1;")).toBe("const x = 1;"); + }); + + test("returns script unchanged if no prefix", () => { + expect(stripModulePrefix("const x = 1;")).toBe("const x = 1;"); + }); + + test("returns empty string unchanged", () => { + expect(stripModulePrefix("")).toBe(""); + }); + }); + + describe("combineScriptsWithIIFE", () => { + test("returns empty string for empty array", () => { + expect(combineScriptsWithIIFE([])).toBe(""); + }); + + test("returns empty string when all scripts are empty", () => { + expect(combineScriptsWithIIFE(["", " ", "\n"])).toBe(""); + }); + + test("wraps a single script in a sequential async IIFE", () => { + const result = combineScriptsWithIIFE(["const x = 1;"]); + + expect(result).toContain("async"); + expect(result).toContain("await"); + expect(result).toContain("const x = 1;"); + }); + + test("preserves script order (root → parent → child → request) for pre-request scripts", () => { + const rootScript = 'pw.env.set("token", "root");'; + const parentScript = 'pw.env.set("parent", "true");'; + const requestScript = 'pw.env.set("request", "true");'; + + const result = combineScriptsWithIIFE([ + rootScript, + parentScript, + requestScript, + ]); + + const rootIndex = result.indexOf(rootScript); + const parentIndex = result.indexOf(parentScript); + const requestIndex = result.indexOf(requestScript); + + expect(rootIndex).toBeLessThan(parentIndex); + expect(parentIndex).toBeLessThan(requestIndex); + }); + + test("preserves script order (request → child → parent → root) for test scripts", () => { + const requestScript = 'pw.test("request test", () => {});'; + const childScript = 'pw.test("child test", () => {});'; + const rootScript = 'pw.test("root test", () => {});'; + + // Simulates the reversal pattern used in test runner: + // combineScriptsWithIIFE([requestScript, ...inheritedTestScripts.slice().reverse()]) + const inheritedTestScripts = [rootScript, childScript]; + const result = combineScriptsWithIIFE([ + requestScript, + ...inheritedTestScripts.slice().reverse(), + ]); + + const requestIndex = result.indexOf(requestScript); + const childIndex = result.indexOf(childScript); + const rootIndex = result.indexOf(rootScript); + + expect(requestIndex).toBeLessThan(childIndex); + expect(childIndex).toBeLessThan(rootIndex); + }); + + test("filters out empty scripts while preserving non-empty ones", () => { + const script1 = "const a = 1;"; + const script2 = "const b = 2;"; + + const result = combineScriptsWithIIFE([script1, "", " ", script2]); + + expect(result).toContain(script1); + expect(result).toContain(script2); + + // Should only have 2 await statements (not 4) + const awaitCount = (result.match(/await/g) || []).length; + expect(awaitCount).toBe(2); + }); + + test("isolates variable scope between scripts (each wrapped in its own function)", () => { + const script1 = "const x = 1;"; + const script2 = "const x = 2;"; + + const result = combineScriptsWithIIFE([script1, script2]); + + // Both scripts should appear in separate async functions + const fnCount = (result.match(/async function\(\)/g) || []).length; + expect(fnCount).toBe(2); + }); + + test("strips module prefix from scripts before wrapping", () => { + const script = `${MODULE_PREFIX}const x = 1;`; + + const result = combineScriptsWithIIFE([script]); + + // The module prefix should be stripped + expect(result).not.toContain("export {};"); + expect(result).toContain("const x = 1;"); + }); + + test("experimental target generates sequential await chain wrapped in try/catch", () => { + const result = combineScriptsWithIIFE( + ["const a = 1;", "const b = 2;", "const c = 3;"], + "experimental" + ); + + // Outer wrapper captures the reporter lexically so user code that + // deletes the globalThis property cannot suppress error reporting. + expect(result).toMatch( + /^const __hoppReporter = globalThis\.__hoppReportScriptExecutionError;\s*try \{/ + ); + expect(result).toContain("await (async function() {"); + // Each script contributes one `await` in the body. + const awaitCount = (result.match(/\bawait\b/g) || []).length; + expect(awaitCount).toBe(3); + // Catch hands the error to the lexically captured reporter. + expect(result).toContain( + "} catch (__hoppScriptExecutionError) {" + ); + expect(result).toContain("__hoppReporter(__hoppScriptExecutionError);"); + }); + + test("legacy target generates sync IIFE chain with no await", () => { + const result = combineScriptsWithIIFE( + ["const a = 1;", "const b = 2;", "const c = 3;"], + "legacy" + ); + + // No `async` keyword, no `await` — legacy sandbox is sync-only. + expect(result).not.toContain("async"); + expect(result).not.toContain("await"); + // Leading `;` guards against ASI on the host script. + expect(result).toMatch(/^;\(function\(\) \{/); + // Each script wrapped in its own IIFE + const iifeCount = (result.match(/\.call\(this\);/g) || []).length; + expect(iifeCount).toBe(3); + }); + + test("default target is experimental (wrapped in try/catch)", () => { + const result = combineScriptsWithIIFE(["const x = 1;"]); + expect(result).toMatch( + /^const __hoppReporter = globalThis\.__hoppReportScriptExecutionError;\s*try \{/ + ); + expect(result).toContain("await (async function() {"); + }); + + test("hoists top-level imports outside the IIFE wrapper", () => { + const script = `import { value } from "data:text/javascript,export const value=1";\npw.env.set("x", value);`; + const result = combineScriptsWithIIFE([script]); + + const importIdx = result.indexOf("import { value }"); + const tryIdx = result.indexOf("try {"); + expect(importIdx).toBeGreaterThanOrEqual(0); + expect(importIdx).toBeLessThan(tryIdx); + expect(result).toContain('pw.env.set("x", value);'); + }); + + test("preserves imports across an inheritance chain", () => { + const root = `import { rootVal } from "data:text/javascript,export const rootVal=1";`; + const folder = `import { folderVal } from "data:text/javascript,export const folderVal=2";`; + const request = `import { reqVal } from "data:text/javascript,export const reqVal=3";\npw.env.set("sum", String(rootVal + folderVal + reqVal));`; + const result = combineScriptsWithIIFE([root, folder, request]); + + expect(result).toContain("import { rootVal }"); + expect(result).toContain("import { folderVal }"); + expect(result).toContain("import { reqVal }"); + + const tryIdx = result.indexOf("try {"); + expect(result.indexOf("import { rootVal }")).toBeLessThan(tryIdx); + expect(result.indexOf("import { folderVal }")).toBeLessThan(tryIdx); + expect(result.indexOf("import { reqVal }")).toBeLessThan(tryIdx); + }); + + test("dedupes identical imports across scripts to a single emit", () => { + const folder = `import lodash from "data:text/javascript,export default {}";`; + const request = `import lodash from "data:text/javascript,export default {}";`; + const result = combineScriptsWithIIFE([folder, request]); + + const importMatches = result.match(/^import lodash from /gm) ?? []; + expect(importMatches).toHaveLength(1); + expect(result).not.toContain("imported from different sources"); + }); + + test("emits a synthetic SyntaxError when same name imports clash across sources", () => { + const folder = `import lodash from "data:text/javascript,export default 'A'";`; + const request = `import lodash from "data:text/javascript,export default 'B'";`; + const result = combineScriptsWithIIFE([folder, request]); + + expect(result).toContain( + "'lodash' is imported from different sources across scripts in this request's chain" + ); + expect(result).not.toContain("import lodash"); + }); + + test("leaves output unchanged when no scripts use imports", () => { + const result = combineScriptsWithIIFE(["const x = 1;", "const y = 2;"]); + expect(result.startsWith("const __hoppReporter")).toBe(true); + expect(result).not.toContain("import "); + }); + + test("legacy target preserves original wrapping (no import hoisting)", () => { + const script = `import { value } from "data:text/javascript,export const value=1";`; + const result = combineScriptsWithIIFE([script], "legacy"); + expect(result).toContain("import { value }"); + expect(result).toMatch(/^;\(function\(\) \{/); + }); + + test("hoists imports even when the script body uses top-level return", () => { + // IIFE semantics let user scripts early-return; the AST parse must + // permit that or imports stay trapped inside the wrapper. + const script = `import { value } from "data:text/javascript,export const value=1";\nif (!value) return;\npw.env.set("OK", "yes");`; + const result = combineScriptsWithIIFE([script]); + const importIdx = result.indexOf("import { value }"); + const tryIdx = result.indexOf("try {"); + expect(importIdx).toBeGreaterThanOrEqual(0); + expect(importIdx).toBeLessThan(tryIdx); + expect(result).toContain("if (!value) return;"); + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/unit/test-runner-inheritance.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/test-runner-inheritance.spec.ts new file mode 100644 index 0000000..9e371aa --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/unit/test-runner-inheritance.spec.ts @@ -0,0 +1,234 @@ +import { describe, expect, test } from "vitest"; +import { makeRESTRequest } from "@hoppscotch/data"; +import * as E from "fp-ts/Either"; + +import { testRunner } from "../../utils/test"; +import { HoppEnvs } from "../../types/request"; + +const SAMPLE_ENVS: HoppEnvs = { + global: [], + selected: [], +}; + +const SAMPLE_RESPONSE = { + status: 200, + headers: [], + body: {}, + statusText: "OK", + responseTime: 100, +}; + +const SAMPLE_REQUEST = makeRESTRequest({ + name: "request", + method: "GET", + endpoint: "https://example.com", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + auth: { authActive: false, authType: "none" }, + body: { + contentType: null, + body: null, + }, + requestVariables: [], + description: null, + responses: {}, +}); + +describe("testRunner - inheritance", () => { + test("Inherited test scripts are executed and register test cases", async () => { + const rootTestScript = ` + pw.test("Root collection test", () => { + pw.expect(pw.response.status).toBe(200); + }); + `; + + const result = await testRunner({ + request: makeRESTRequest({ + ...SAMPLE_REQUEST, + testScript: ` + pw.test("Request test", () => { + pw.expect(pw.response.status).toBe(200); + }); + `, + }), + envs: SAMPLE_ENVS, + response: SAMPLE_RESPONSE, + legacySandbox: false, + inheritedTestScripts: [rootTestScript], + })(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const { testsReport } = result.right; + const descriptors = testsReport.map((r) => r.descriptor); + + expect(descriptors).toContain("Request test"); + expect(descriptors).toContain("Root collection test"); + } + }); + + test("Inherited test scripts execute in request → child → parent → root order", async () => { + const rootTestScript = ` + const prev = pw.env.get("ORDER"); + pw.env.set("ORDER", prev + ",root"); + `; + const parentTestScript = ` + const prev = pw.env.get("ORDER"); + pw.env.set("ORDER", prev + ",parent"); + `; + + const result = await testRunner({ + request: makeRESTRequest({ + ...SAMPLE_REQUEST, + testScript: `pw.env.set("ORDER", "request");`, + }), + envs: SAMPLE_ENVS, + response: SAMPLE_RESPONSE, + legacySandbox: false, + // Stored as root → parent, reversed to parent → root during execution + inheritedTestScripts: [rootTestScript, parentTestScript], + })(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const orderVar = result.right.envs.selected.find( + (v) => v.key === "ORDER" + ); + expect(orderVar?.currentValue).toBe("request,parent,root"); + } + }); + + test("Scripts with same local variable names do not collide (IIFE isolation)", async () => { + const rootTestScript = `const x = "root"; pw.env.set("ROOT_VAR", x);`; + const parentTestScript = `const x = "parent"; pw.env.set("PARENT_VAR", x);`; + + const result = await testRunner({ + request: makeRESTRequest({ + ...SAMPLE_REQUEST, + testScript: `const x = "request"; pw.env.set("REQUEST_VAR", x);`, + }), + envs: SAMPLE_ENVS, + response: SAMPLE_RESPONSE, + legacySandbox: false, + inheritedTestScripts: [rootTestScript, parentTestScript], + })(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const envVars = result.right.envs.selected; + expect(envVars.find((v) => v.key === "ROOT_VAR")?.currentValue).toBe( + "root" + ); + expect(envVars.find((v) => v.key === "PARENT_VAR")?.currentValue).toBe( + "parent" + ); + expect(envVars.find((v) => v.key === "REQUEST_VAR")?.currentValue).toBe( + "request" + ); + } + }); + + test("Empty inherited test scripts are filtered out gracefully", async () => { + const validScript = ` + pw.test("Valid inherited test", () => { + pw.expect(pw.response.status).toBe(200); + }); + `; + + const result = await testRunner({ + request: makeRESTRequest({ + ...SAMPLE_REQUEST, + testScript: ` + pw.test("Request test", () => { + pw.expect(pw.response.status).toBe(200); + }); + `, + }), + envs: SAMPLE_ENVS, + response: SAMPLE_RESPONSE, + legacySandbox: false, + inheritedTestScripts: ["", " ", validScript, "\n"], + })(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const { testsReport } = result.right; + const descriptors = testsReport.map((r) => r.descriptor); + + expect(descriptors).toContain("Request test"); + expect(descriptors).toContain("Valid inherited test"); + } + }); + + test("Works correctly with no inherited test scripts (backward compatibility)", async () => { + const result = await testRunner({ + request: makeRESTRequest({ + ...SAMPLE_REQUEST, + testScript: ` + pw.test("Solo request test", () => { + pw.expect(pw.response.status).toBe(200); + }); + `, + }), + envs: SAMPLE_ENVS, + response: SAMPLE_RESPONSE, + legacySandbox: false, + inheritedTestScripts: [], + })(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const { testsReport } = result.right; + expect(testsReport.map((r) => r.descriptor)).toContain( + "Solo request test" + ); + } + }); + + // Regression: in the legacy (isolated-vm) sandbox, all `pw.test` blocks + // declared in inherited test scripts must register before the runner + // captures results. Pre-fix combineScriptsWithIIFE emitted an outer + // detached Promise so the registration order was undefined when multiple + // inherited scripts were present. + // + // Note: the post-`await` drop is not currently user-reachable here either + // (see pre-request-inheritance.spec.ts for context). User-facing coverage + // for the web worker async path is in packages/hoppscotch-cli/src/__tests__/e2e/. + test("Legacy sandbox registers inherited test scripts", async () => { + const rootTestScript = ` + pw.test("Root collection test", () => { + pw.expect(pw.response.status).toBe(200); + }); + `; + + const result = await testRunner({ + request: makeRESTRequest({ + ...SAMPLE_REQUEST, + testScript: ` + pw.test("Request test", () => { + pw.expect(pw.response.status).toBe(200); + }); + `, + }), + envs: SAMPLE_ENVS, + response: SAMPLE_RESPONSE, + legacySandbox: true, + inheritedTestScripts: [rootTestScript], + })(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const descriptors = result.right.testsReport.map((r) => r.descriptor); + expect(descriptors).toContain("Request test"); + expect(descriptors).toContain("Root collection test"); + } + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/unit/workspace-access.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/workspace-access.spec.ts new file mode 100644 index 0000000..299eb3b --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/unit/workspace-access.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "vitest"; + +import { + transformWorkspaceCollections, + transformWorkspaceEnvironment, +} from "../../utils/workspace-access"; +import { + TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_LEVELS_MOCK, + TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MOCK, + TRANSFORMED_ENVIRONMENT_V0_FORMAT_MOCK, + TRANSFORMED_ENVIRONMENT_V1_FORMAT_MOCK, + TRANSFORMED_ENVIRONMENT_V2_FORMAT_MOCK, + TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK, + WORKSPACE_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_LEVELS_MOCK, + WORKSPACE_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MOCK, + WORKSPACE_ENVIRONMENT_V0_FORMAT_MOCK, + WORKSPACE_ENVIRONMENT_V1_FORMAT_MOCK, + WORKSPACE_ENVIRONMENT_V2_FORMAT_MOCK, + WORKSPACE_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MOCK, +} from "./fixtures/workspace-access.mock"; + +describe("workspace-access", () => { + describe("transformWorkspaceCollection", () => { + test("Successfully transforms collection data with deeply nested collections and authorization/headers set at each level to the `HoppCollection` format", () => { + expect( + transformWorkspaceCollections( + WORKSPACE_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MOCK + ) + ).toEqual( + TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MOCK + ); + }); + + test("Successfully transforms collection data with multiple child collections and authorization/headers set at each level to the `HoppCollection` format", () => { + expect( + transformWorkspaceCollections( + WORKSPACE_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MOCK + ) + ).toEqual(TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK); + }); + + test("Adds the default value for `auth` & `header` fields while transforming collections without authorization/headers set at certain levels", () => { + expect( + transformWorkspaceCollections( + WORKSPACE_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_LEVELS_MOCK + ) + ).toEqual( + TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_LEVELS_MOCK + ); + }); + }); + + describe("transformWorkspaceEnvironment", () => { + test("Successfully transforms environment data conforming to the `v0` format received from the network call to the `HoppEnvironment` format", () => { + expect( + // @ts-expect-error: Testing legacy format transformation + transformWorkspaceEnvironment(WORKSPACE_ENVIRONMENT_V0_FORMAT_MOCK) + ).toEqual(TRANSFORMED_ENVIRONMENT_V0_FORMAT_MOCK); + }); + + test("Successfully transforms environment data conforming to the `v1` format received from the network call to the `HoppEnvironment` format", () => { + expect( + // @ts-expect-error: Testing legacy format transformation + transformWorkspaceEnvironment(WORKSPACE_ENVIRONMENT_V1_FORMAT_MOCK) + ).toEqual(TRANSFORMED_ENVIRONMENT_V1_FORMAT_MOCK); + }); + + test("Successfully transforms environment data conforming to the `v2` format received from the network call to the `HoppEnvironment` format", () => { + expect( + transformWorkspaceEnvironment(WORKSPACE_ENVIRONMENT_V2_FORMAT_MOCK) + ).toEqual(TRANSFORMED_ENVIRONMENT_V2_FORMAT_MOCK); + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/utils.ts b/packages/hoppscotch-cli/src/__tests__/utils.ts new file mode 100644 index 0000000..8d534d4 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/utils.ts @@ -0,0 +1,140 @@ +import { exec } from "child_process"; +import { resolve } from "path"; + +import { ExecResponse } from "./types"; + +export const runCLI = (args: string, options = {}): Promise => { + const CLI_PATH = resolve(__dirname, "../../bin/hopp.js"); + const command = `node ${CLI_PATH} ${args}`; + + return new Promise((resolve) => + exec(command, options, (error, stdout, stderr) => + resolve({ error, stdout, stderr }) + ) + ); +}; + +export const trimAnsi = (target: string) => { + const ansiRegex = + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; + + return target.replace(ansiRegex, ""); +}; + +export const getErrorCode = (out: string) => { + const ansiTrimmedStr = trimAnsi(out); + return ansiTrimmedStr.split(" ")[0]; +}; + +export const getTestJsonFilePath = ( + file: string, + kind: "collection" | "environment" +) => { + const kindDir = { + collection: "collections", + environment: "environments", + }[kind]; + + const filePath = resolve( + __dirname, + `../../src/__tests__/e2e/fixtures/${kindDir}/${file}` + ); + return filePath; +}; + +/** + * Runs CLI with automatic retry for transient infrastructure failures. + * + * IMPORTANT: Only use this for tests that EXPECT SUCCESS. + * For tests that intentionally test error scenarios (bad URLs, script errors, etc.), + * use plain `runCLI()` instead to avoid false skips. + * + * Retries on: + * - Low-level network errors (ECONNRESET, DNS timeouts, connection refused) + * - Service degradation (httpbin.org 5xx) + * - Response undefined errors from network failures + * + * Does NOT retry on: + * - REQUEST_ERROR alone (could be intentional bad URL) + * - TEST_SCRIPT_ERROR alone (could be intentional script error) + */ +export const runCLIWithNetworkRetry = async ( + args: string, + options = {}, + maxAttempts = 2 +) => { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const result = await runCLI(args, options); + const combinedOutput = `${result.stdout}\n${result.stderr}`; + + // Only detect low-level TCP/DNS errors - these are always transient + const hasLowLevelNetworkError = + /ECONNRESET|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|ECONNREFUSED/i.test( + combinedOutput + ); + + // Special case: TEST_SCRIPT_ERROR when response is undefined due to REQUEST_ERROR + // This is the actual CI failure mode when external services go down + const hasTestScriptErrorFromNetworkFailure = + /TEST_SCRIPT_ERROR Script execution failed: TypeError: cannot read property/.test( + combinedOutput + ) && /REQUEST_ERROR/.test(combinedOutput); + + // Service degradation + const hasHttpbin5xx = + /httpbin\.org is down \(5xx\)|httpbin\.org is down \(503\)/i.test( + combinedOutput + ); + + // Success - return immediately + if (!result.error && !hasHttpbin5xx) { + return result; + } + + // Not a transient error - return immediately (don't mask real failures) + if ( + !hasLowLevelNetworkError && + !hasHttpbin5xx && + !hasTestScriptErrorFromNetworkFailure + ) { + return result; + } + + const extractErrorDetails = (output: string): string => { + if (/ECONNRESET/i.test(output)) return "ECONNRESET (connection reset)"; + if (/EAI_AGAIN/i.test(output)) return "EAI_AGAIN (DNS timeout)"; + if (/ENOTFOUND/i.test(output)) return "ENOTFOUND (DNS lookup failed)"; + if (/ETIMEDOUT/i.test(output)) return "ETIMEDOUT (connection timeout)"; + if (/ECONNREFUSED/i.test(output)) + return "ECONNREFUSED (connection refused)"; + if (/httpbin\.org is down/i.test(output)) + return "httpbin.org service degradation (5xx)"; + if (/TEST_SCRIPT_ERROR.*cannot read property/i.test(output)) + return "TEST_SCRIPT_ERROR (response undefined - likely REQUEST_ERROR)"; + return "Network failure"; + }; + + const errorDetail = extractErrorDetails(combinedOutput); + const argsPreview = + args.length > 100 ? `${args.substring(0, 100)}...` : args; + + const isLastAttempt = attempt === maxAttempts - 1; + if (!isLastAttempt) { + console.log( + `⚠️ Network error detected: ${errorDetail}\n Command: ${argsPreview}\n Retrying once...` + ); + await new Promise((resolve) => setTimeout(resolve, 2000)); + continue; + } + + console.warn( + `⚠️ Skipping test after retry exhausted\n` + + ` Error: ${errorDetail}\n` + + ` Command: ${argsPreview}\n` + + ` External services may be unavailable. Test will be skipped to avoid blocking CI.` + ); + return null; + } + + throw new Error("Unexpected: retry loop completed without returning"); +}; diff --git a/packages/hoppscotch-cli/src/commands/test.ts b/packages/hoppscotch-cli/src/commands/test.ts new file mode 100644 index 0000000..52fec8b --- /dev/null +++ b/packages/hoppscotch-cli/src/commands/test.ts @@ -0,0 +1,114 @@ +import fs from "fs"; +import { isSafeInteger } from "lodash-es"; +import Papa from "papaparse"; +import path from "path"; + +import { handleError } from "../handlers/error"; +import { parseDelayOption } from "../options/test/delay"; +import { parseEnvsData } from "../options/test/env"; +import { IterationDataItem } from "../types/collections"; +import { TestCmdEnvironmentOptions, TestCmdOptions } from "../types/commands"; +import { error } from "../types/errors"; +import { HoppEnvs } from "../types/request"; +import { isHoppCLIError } from "../utils/checks"; +import { + collectionsRunner, + collectionsRunnerExit, + collectionsRunnerResult, +} from "../utils/collections"; +import { parseCollectionData } from "../utils/mutators"; + +export const test = (pathOrId: string, options: TestCmdOptions) => async () => { + try { + const { + delay, + env, + iterationCount, + iterationData, + reporterJunit, + legacySandbox, + } = options; + + if ( + iterationCount !== undefined && + (iterationCount < 1 || !isSafeInteger(iterationCount)) + ) { + throw error({ + code: "INVALID_ARGUMENT", + data: "The value must be a positive integer", + }); + } + + const resolvedDelay = delay ? parseDelayOption(delay) : 0; + + const envs = env + ? await parseEnvsData(options as TestCmdEnvironmentOptions) + : { global: [], selected: [] }; + + let parsedIterationData: unknown[] | null = null; + let transformedIterationData: IterationDataItem[][] | undefined; + + const collections = await parseCollectionData(pathOrId, options); + + if (iterationData) { + // Check file existence + if (!fs.existsSync(iterationData)) { + throw error({ code: "FILE_NOT_FOUND", path: iterationData }); + } + + // Check the file extension + if (path.extname(iterationData) !== ".csv") { + throw error({ + code: "INVALID_DATA_FILE_TYPE", + data: iterationData, + }); + } + + const csvData = fs.readFileSync(iterationData, "utf8"); + parsedIterationData = Papa.parse(csvData, { header: true }).data; + + // Transform data into the desired format + transformedIterationData = parsedIterationData + .map((item) => { + const iterationDataItem = item as Record; + const keys = Object.keys(iterationDataItem); + + return ( + keys + // Ignore keys with empty string values + .filter((key) => iterationDataItem[key] !== "") + .map( + (key) => + { + key: key, + initialValue: iterationDataItem[key], + currentValue: iterationDataItem[key], + secret: false, + } + ) + ); + }) + // Ignore items that result in an empty array + .filter((item) => item.length > 0); + } + + const resolvedLegacySandbox = Boolean(legacySandbox); + + const report = await collectionsRunner({ + collections, + envs, + delay: resolvedDelay, + iterationData: transformedIterationData, + iterationCount, + legacySandbox: resolvedLegacySandbox, + }); + const hasSucceeded = collectionsRunnerResult(report, reporterJunit); + + collectionsRunnerExit(hasSucceeded); + } catch (e) { + if (isHoppCLIError(e)) { + handleError(e); + process.exit(1); + } else throw e; + } +}; diff --git a/packages/hoppscotch-cli/src/handlers/error.ts b/packages/hoppscotch-cli/src/handlers/error.ts new file mode 100644 index 0000000..72be3ac --- /dev/null +++ b/packages/hoppscotch-cli/src/handlers/error.ts @@ -0,0 +1,112 @@ +import * as S from "fp-ts/string"; +import { HoppError, HoppErrorCode } from "../types/errors"; +import { hasProperty, isSafeCommanderError } from "../utils/checks"; +import { parseErrorMessage } from "../utils/mutators"; +import { exceptionColors } from "../utils/getters"; +const { BG_FAIL } = exceptionColors; + +/** + * Parses unknown error data and narrows it to get information related to + * error in string format. + * @param e Error data to parse. + * @returns Information in string format appropriately parsed, based on error type. + */ +const parseErrorData = (e: unknown) => { + let parsedMsg: string; + + if (!!e && typeof e === "object") { + if (hasProperty(e, "message") && S.isString(e.message)) { + parsedMsg = e.message; + } else if (hasProperty(e, "data") && S.isString(e.data)) { + parsedMsg = e.data; + } else { + parsedMsg = JSON.stringify(e); + } + } else if (S.isString(e)) { + parsedMsg = e; + } else { + parsedMsg = JSON.stringify(e); + } + + return parsedMsg; +}; + +/** + * Handles HoppError to generate error messages based on data related + * to error code and exits program with exit code 1. + * @param error Error object with code of type HoppErrorCode. + */ +export const handleError = (error: HoppError) => { + const ERROR_CODE = BG_FAIL(error.code); + let ERROR_MSG; + + switch (error.code) { + case "FILE_NOT_FOUND": + ERROR_MSG = `File doesn't exist: ${error.path}`; + break; + case "UNKNOWN_COMMAND": + ERROR_MSG = `Unavailable command: ${error.command}`; + break; + case "MALFORMED_ENV_FILE": + ERROR_MSG = `The environment file is not of the correct format.`; + break; + case "BULK_ENV_FILE": + ERROR_MSG = `CLI doesn't support bulk environments export.`; + break; + case "MALFORMED_COLLECTION": + ERROR_MSG = `${error.path}\n${parseErrorData(error.data)}`; + break; + case "NO_FILE_PATH": + ERROR_MSG = `Please provide a hoppscotch-collection file path.`; + break; + case "PARSING_ERROR": + ERROR_MSG = `Unable to parse -\n${error.data}`; + break; + case "INVALID_FILE_TYPE": + ERROR_MSG = `Please provide file of extension type .json: ${error.data}`; + break; + case "INVALID_DATA_FILE_TYPE": + ERROR_MSG = `Please provide file of extension type .csv: ${error.data}`; + break; + case "REQUEST_ERROR": + case "TEST_SCRIPT_ERROR": + case "PRE_REQUEST_SCRIPT_ERROR": + ERROR_MSG = parseErrorData(error.data); + break; + case "INVALID_ARGUMENT": + case "UNKNOWN_ERROR": + case "SYNTAX_ERROR": + if (isSafeCommanderError(error.data)) { + ERROR_MSG = S.empty; + } else { + ERROR_MSG = parseErrorMessage(error.data); + } + break; + case "TESTS_FAILING": + ERROR_MSG = error.data; + break; + case "TOKEN_EXPIRED": + ERROR_MSG = `The specified access token is expired. Please provide a valid token: ${error.data}`; + break; + case "TOKEN_INVALID": + ERROR_MSG = `The specified access token is invalid. Please provide a valid token: ${error.data}`; + break; + case "INVALID_ID": + ERROR_MSG = `The specified collection/environment (ID or file path) is invalid or inaccessible. Please ensure the supplied ID or file path is correct: ${error.data}`; + break; + case "INVALID_SERVER_URL": + ERROR_MSG = `Please provide a valid SH instance server URL: ${error.data}`; + break; + case "SERVER_CONNECTION_REFUSED": + ERROR_MSG = `Unable to connect to the server. Please check your network connection or server instance URL and try again: ${error.data}`; + break; + case "REPORT_EXPORT_FAILED": + const moreInfo = error.data ? `: ${error.data}` : S.empty; + ERROR_MSG = `Failed to export the report at ${error.path}${moreInfo}`; + break; + } + + if (!S.isEmpty(ERROR_MSG)) { + console.error(ERROR_CODE, ERROR_MSG); + } +}; diff --git a/packages/hoppscotch-cli/src/index.ts b/packages/hoppscotch-cli/src/index.ts new file mode 100644 index 0000000..94d70ce --- /dev/null +++ b/packages/hoppscotch-cli/src/index.ts @@ -0,0 +1,108 @@ +import chalk from "chalk"; +import { Command } from "commander"; +import * as E from "fp-ts/Either"; + +import { version } from "../package.json"; +import { test } from "./commands/test"; +import { handleError } from "./handlers/error"; + +const accent = chalk.greenBright; + +/** + * * Program Default Configuration + */ +const CLI_BEFORE_ALL_TXT = `hopp: The ${accent( + "Hoppscotch" +)} CLI - Version ${version} (${accent( + "https://hoppscotch.io" +)}) ${chalk.black.bold.bgYellowBright(" ALPHA ")} \n`; + +const CLI_AFTER_ALL_TXT = `\nFor more help, head on to ${accent( + "https://docs.hoppscotch.io/documentation/clients/cli/overview" +)}`; + +const program = new Command(); + +program + .name("hopp") + .version(version, "-v, --ver", "see the current version of hopp-cli") + .usage("[options or commands] arguments") + .addHelpText("beforeAll", CLI_BEFORE_ALL_TXT) + .addHelpText("after", CLI_AFTER_ALL_TXT) + .configureHelp({ + optionTerm: (option) => accent(option.flags), + subcommandTerm: (cmd) => accent(cmd.name(), cmd.usage()), + argumentTerm: (arg) => accent(arg.name()), + }) + .addHelpCommand(false) + .showHelpAfterError(true); + +program.exitOverride().configureOutput({ + writeErr: (str) => program.help(), + outputError: (str, write) => + handleError({ code: "INVALID_ARGUMENT", data: E.toError(str) }), +}); + +/** + * * CLI Commands + */ +program + .command("test") + .argument( + "", + "path to a hoppscotch collection.json file or collection ID from a workspace for CI testing" + ) + .option( + "-e, --env ", + "path to an environment variables json file or environment ID from a workspace" + ) + .option( + "-d, --delay ", + "delay in milliseconds(ms) between consecutive requests within a collection" + ) + .option( + "--token ", + "personal access token to access collections/environments from a workspace" + ) + .option("--server ", "server URL for SH instance") + .option( + "--reporter-junit [path]", + "generate JUnit report optionally specifying the path" + ) + .option( + "--iteration-count ", + "number of iterations to run the test", + parseInt + ) + .option( + "--iteration-data ", + "path to a CSV file for data-driven testing" + ) + .option("--legacy-sandbox", "Opt out from the experimental scripting sandbox") + .allowExcessArguments(false) + .allowUnknownOption(false) + .description("running hoppscotch collection.json file") + .addHelpText( + "after", + `\nFor help, head on to ${accent( + "https://docs.hoppscotch.io/documentation/clients/cli/overview#commands" + )}` + ) + .action(async (pathOrId, options) => { + const overrides: Record = {}; + + // Choose `hopp-junit-report.xml` as the default value if `reporter-junit` flag is supplied without a value + if (options.reporterJunit === true) { + overrides.reporterJunit = "hopp-junit-report.xml"; + } + + const effectiveOptions = { ...options, ...overrides }; + + await test(pathOrId, effectiveOptions)(); + }); + +export const cli = async (args: string[]) => { + try { + await program.parseAsync(args); + } catch (e) {} +}; diff --git a/packages/hoppscotch-cli/src/interfaces/request.ts b/packages/hoppscotch-cli/src/interfaces/request.ts new file mode 100644 index 0000000..4aec5cb --- /dev/null +++ b/packages/hoppscotch-cli/src/interfaces/request.ts @@ -0,0 +1,47 @@ +import { AxiosPromise, AxiosRequestConfig } from "axios"; +import { HoppRESTRequest } from "@hoppscotch/data"; + +/** + * Provides definition to object returned by createRequest. + * @property {function} request Axios request promise, executed to get axios + * response promise. + * @property {string} path Path of request within collection file. + * @property {string} name Name of request within collection + * @property {string} testScript Stringified hoppscotch testScript, used while + * running testRunner. + */ +export interface RequestStack { + request: () => AxiosPromise; + path: string; +} + +/** + * Provides definition to axios request promise's request parameter. + * @property {boolean} supported - Boolean check for supported or unsupported requests. + */ +export interface RequestConfig extends AxiosRequestConfig { + displayUrl?: string; +} + +export interface EffectiveHoppRESTRequest extends HoppRESTRequest { + /** + * The effective final URL. + * + * This contains path, params and environment variables all applied to it + */ + effectiveFinalURL: string; + effectiveFinalDisplayURL?: string; + effectiveFinalHeaders: { + key: string; + value: string; + active: boolean; + description: string; + }[]; + effectiveFinalParams: { + key: string; + value: string; + active: boolean; + description: string; + }[]; + effectiveFinalBody: FormData | string | File | null; +} diff --git a/packages/hoppscotch-cli/src/interfaces/response.ts b/packages/hoppscotch-cli/src/interfaces/response.ts new file mode 100644 index 0000000..aa426af --- /dev/null +++ b/packages/hoppscotch-cli/src/interfaces/response.ts @@ -0,0 +1,73 @@ +import { TestResponse } from "@hoppscotch/js-sandbox"; +import { Method } from "axios"; +import { ExpectResult } from "../types/response"; +import { HoppEnvs } from "../types/request"; +import { HoppRESTRequest } from "@hoppscotch/data"; + +/** + * Defines column headers for table stream used to write table + * data on stdout. + * @property {string} path Path of request within collection file. + * @property {string} endpoint Endpoint from response config.url. + * @property {Method} method Method from response headers. + * @property {string} statusCode Template string concatenating status & statusText. + */ +export interface TableResponse { + endpoint: string; + method: Method; + statusCode: string; +} + +/** + * Describes additional details of HTTP response returned from + * requestRunner. + * @property {string} path Path of request within collection file. + * @property {string} endpoint Endpoint from response config.url. + * @property {Method} method Method from HTTP response headers. + * @property {string} statusText HTTP response status text. + */ +export interface RequestRunnerResponse extends TestResponse { + endpoint: string; + method: Method; + statusText: string; + duration: number; +} + +/** + * Describes test script details. + * @property {HoppRESTRequest} request Supplied request. + * @property {TestResponse} response Response structure for test script runner. + * @property {HoppEnvs} envs Environment variables for test script runner. + * @property {boolean} legacySandbox Whether to use the legacy sandbox. + * @property {string[]} inheritedTestScripts Test scripts inherited from parent collections. + */ +export interface TestScriptParams { + request: HoppRESTRequest; + response: TestResponse; + envs: HoppEnvs; + legacySandbox: boolean; + inheritedTestScripts?: string[]; +} + +/** + * Describe properties of test-report generated from test-runner. + * @property {string} descriptor Test description. + * @property {ExpectResult[]} expectResults Expected results for each + * test-case. + * @property {number} failed Total failed test-cases. + * @property {number} passed Total passed test-cases; + */ +export interface TestReport { + descriptor: string; + expectResults: ExpectResult[]; + failed: number; + passed: number; +} + +/** + * Describes error pair for failed HTTP requests. + * @example { 501: "Request Not Supported" } + */ +export interface ResponseErrorPair { + [key: number]: string; +} diff --git a/packages/hoppscotch-cli/src/options/test/delay.ts b/packages/hoppscotch-cli/src/options/test/delay.ts new file mode 100644 index 0000000..e9cf653 --- /dev/null +++ b/packages/hoppscotch-cli/src/options/test/delay.ts @@ -0,0 +1,14 @@ +import { error } from "../../types/errors"; + +export function parseDelayOption(delay: string): number { + const maybeInt = Number.parseInt(delay); + + if (!Number.isNaN(maybeInt)) { + return maybeInt; + } else { + throw error({ + code: "INVALID_ARGUMENT", + data: "Expected '-d, --delay' value to be number", + }); + } +} diff --git a/packages/hoppscotch-cli/src/options/test/env.ts b/packages/hoppscotch-cli/src/options/test/env.ts new file mode 100644 index 0000000..8bd3fa7 --- /dev/null +++ b/packages/hoppscotch-cli/src/options/test/env.ts @@ -0,0 +1,95 @@ +import { Environment, NonSecretEnvironment } from "@hoppscotch/data"; +import { entityReference } from "verzod"; +import { z } from "zod"; + +import { TestCmdEnvironmentOptions } from "../../types/commands"; +import { error } from "../../types/errors"; +import { + HoppEnvKeyPairObject, + HoppEnvPair, + HoppEnvs, +} from "../../types/request"; +import { getResourceContents } from "../../utils/getters"; + +/** + * Parses environment data from a given path or ID and returns the data conforming to the latest version of the `Environment` schema. + * + * @param {TestCmdEnvironmentOptions} options Supplied values for CLI flags. + * @param {string} options.env Path of the environment `.json` file to be parsed. + * @param {string} [options.token] Personal access token to fetch workspace environments. + * @param {string} [options.server] server URL for SH instance. + * @returns {Promise} A promise that resolves to the parsed environment object with global and selected environments. + */ +export async function parseEnvsData(options: TestCmdEnvironmentOptions) { + const { env: pathOrId, token: accessToken, server: serverUrl } = options; + + const contents = await getResourceContents({ + pathOrId, + accessToken, + serverUrl, + resourceType: "environment", + }); + + const envPairs: Array> = []; + + // The legacy key-value pair format that is still supported + const HoppEnvKeyPairResult = HoppEnvKeyPairObject.safeParse(contents); + + // Shape of the single environment export object that is exported from the app + const HoppEnvExportObjectResult = Environment.safeParse(contents); + + // Shape of the bulk environment export object that is exported from the app + const HoppBulkEnvExportObjectResult = z + .array(entityReference(Environment)) + .safeParse(contents); + + // CLI doesnt support bulk environments export + // Hence we check for this case and throw an error if it matches the format + if (HoppBulkEnvExportObjectResult.success) { + throw error({ code: "BULK_ENV_FILE", path: pathOrId, data: error }); + } + + // Checks if the environment file is of the correct format + // If it doesnt match either of them, we throw an error + if ( + !HoppEnvKeyPairResult.success && + HoppEnvExportObjectResult.type === "err" + ) { + throw error({ code: "MALFORMED_ENV_FILE", path: pathOrId, data: error }); + } + + if (HoppEnvKeyPairResult.success) { + for (const [key, value] of Object.entries(HoppEnvKeyPairResult.data)) { + envPairs.push({ + key, + initialValue: value, + currentValue: value, + secret: false, + }); + } + } else if (HoppEnvExportObjectResult.type === "ok") { + // Original environment variables from the supplied export file + const originalEnvVariables = (contents as Environment).variables; + + // Above environment variables conforming to the latest schema + // `value` fields if specified will be omitted for secret environment variables + const migratedEnvVariables = HoppEnvExportObjectResult.value.variables; + + // The values supplied for secret environment variables have to be considered in the CLI + // For each secret environment variable, include the value in case supplied + const resolvedEnvVariables = migratedEnvVariables.map((variable, idx) => { + if (variable.secret && originalEnvVariables[idx].initialValue) { + return { + ...variable, + initialValue: originalEnvVariables[idx].initialValue, + }; + } + + return variable; + }); + + envPairs.push(...resolvedEnvVariables); + } + + return { global: [], selected: envPairs }; +} diff --git a/packages/hoppscotch-cli/src/tsconfig.json b/packages/hoppscotch-cli/src/tsconfig.json new file mode 100644 index 0000000..78d41be --- /dev/null +++ b/packages/hoppscotch-cli/src/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "commonjs", + "outDir": "../dist", + "rootDir": ".", + "strict": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "references": [ + { + "path": "../" + } + ] +} diff --git a/packages/hoppscotch-cli/src/types/collections.ts b/packages/hoppscotch-cli/src/types/collections.ts new file mode 100644 index 0000000..c3d67eb --- /dev/null +++ b/packages/hoppscotch-cli/src/types/collections.ts @@ -0,0 +1,16 @@ +import { HoppCollection } from "@hoppscotch/data"; +import { HoppEnvPair, HoppEnvs } from "./request"; + +export type CollectionRunnerParam = { + collections: HoppCollection[]; + envs: HoppEnvs; + delay?: number; + iterationData?: IterationDataItem[][]; + iterationCount?: number; + legacySandbox: boolean; +}; + +export type HoppCollectionFileExt = "json"; + +// Indicates the shape each iteration data entry gets transformed into +export type IterationDataItem = Extract; diff --git a/packages/hoppscotch-cli/src/types/commands.ts b/packages/hoppscotch-cli/src/types/commands.ts new file mode 100644 index 0000000..f53a128 --- /dev/null +++ b/packages/hoppscotch-cli/src/types/commands.ts @@ -0,0 +1,20 @@ +export type TestCmdOptions = { + env?: string; + delay?: string; + token?: string; + server?: string; + reporterJunit?: string; + iterationCount?: number; + iterationData?: string; + legacySandbox?: boolean; +}; + +// Consumed in the collection `file_path_or_id` argument action handler +export type TestCmdCollectionOptions = Omit; + +// Consumed in the `--env, -e` flag action handler +export type TestCmdEnvironmentOptions = Omit & { + env: string; +}; + +export type HOPP_ENV_FILE_EXT = "json"; diff --git a/packages/hoppscotch-cli/src/types/errors.ts b/packages/hoppscotch-cli/src/types/errors.ts new file mode 100644 index 0000000..6122ff7 --- /dev/null +++ b/packages/hoppscotch-cli/src/types/errors.ts @@ -0,0 +1,45 @@ +type HoppErrorPath = { + path: string; +}; + +type HoppErrorCmd = { + command: string; +}; + +type HoppErrorData = { + data: any; +}; + +type HoppErrors = { + UNKNOWN_ERROR: HoppErrorData; + FILE_NOT_FOUND: HoppErrorPath; + UNKNOWN_COMMAND: HoppErrorCmd; + MALFORMED_COLLECTION: HoppErrorPath & HoppErrorData; + NO_FILE_PATH: {}; + PRE_REQUEST_SCRIPT_ERROR: HoppErrorData; + PARSING_ERROR: HoppErrorData; + TEST_SCRIPT_ERROR: HoppErrorData; + TESTS_FAILING: HoppErrorData; + SYNTAX_ERROR: HoppErrorData; + REQUEST_ERROR: HoppErrorData; + INVALID_ARGUMENT: HoppErrorData; + MALFORMED_ENV_FILE: HoppErrorPath & HoppErrorData; + BULK_ENV_FILE: HoppErrorPath & HoppErrorData; + INVALID_FILE_TYPE: HoppErrorData; + INVALID_DATA_FILE_TYPE: HoppErrorData; + TOKEN_EXPIRED: HoppErrorData; + TOKEN_INVALID: HoppErrorData; + INVALID_ID: HoppErrorData; + INVALID_SERVER_URL: HoppErrorData; + SERVER_CONNECTION_REFUSED: HoppErrorData; + REPORT_EXPORT_FAILED: HoppErrorPath & HoppErrorData; +}; + +export type HoppErrorCode = keyof HoppErrors; +export type HoppError = T extends null + ? { code: T } + : { code: T } & HoppErrors[T]; + +export const error = (error: HoppError) => error; +export type HoppCLIError = HoppError; +export type HoppErrnoException = NodeJS.ErrnoException; diff --git a/packages/hoppscotch-cli/src/types/request.ts b/packages/hoppscotch-cli/src/types/request.ts new file mode 100644 index 0000000..1aaca15 --- /dev/null +++ b/packages/hoppscotch-cli/src/types/request.ts @@ -0,0 +1,49 @@ +import { + Environment, + HoppCollection, + HoppCollectionVariable, + HoppRESTRequest, +} from "@hoppscotch/data"; +import { z } from "zod"; + +import { TestReport } from "../interfaces/response"; +import { HoppCLIError } from "./errors"; + +export type FormDataEntry = { + key: string; + value: string | Blob; + contentType?: string; +}; + +export type HoppEnvPair = Environment["variables"][number]; + +export const HoppEnvKeyPairObject = z.record(z.string(), z.string()); + +export type HoppEnvs = { + global: HoppEnvPair[]; + selected: HoppEnvPair[]; +}; + +export type CollectionQueue = { + path: string; + collection: HoppCollection; +}; + +export type RequestReport = { + path: string; + tests: TestReport[]; + errors: HoppCLIError[]; + result: boolean; + duration: { test: number; request: number; preRequest: number }; +}; + +export type ProcessRequestParams = { + request: HoppRESTRequest; + envs: HoppEnvs; + path: string; + delay: number; + legacySandbox?: boolean; + collectionVariables?: HoppCollectionVariable[]; + inheritedPreRequestScripts?: string[]; + inheritedTestScripts?: string[]; +}; diff --git a/packages/hoppscotch-cli/src/types/response.ts b/packages/hoppscotch-cli/src/types/response.ts new file mode 100644 index 0000000..126dca4 --- /dev/null +++ b/packages/hoppscotch-cli/src/types/response.ts @@ -0,0 +1,82 @@ +import { TestReport } from "../interfaces/response"; +import { HoppEnvs } from "./request"; + +/** + * The expectation failed (fail) or errored (error) + */ +export type ExpectResult = { + status: "pass" | "fail" | "error"; + message: string; +}; + +/** + * Stats describing number of failed and passed for test-cases/test-suites/ + * test-scripts/pre-request-scripts/request. + */ +export type Stats = { + failed: number; + passed: number; +}; + +export type PreRequestMetrics = { + /** + * Pre-request-script(s) failed and passed stats. + */ + scripts: Stats; + + /** + * Time taken (in seconds) to execute pre-request-script(s). + */ + duration: number; +}; + +export type RequestMetrics = { + /** + * Request(s) failed and passed stats. + */ + requests: Stats; + + /** + * Time taken (in seconds) to execute request(s). + */ + duration: number; +}; + +export type TestMetrics = { + /** + * Test-cases failed and passed stats. + */ + tests: Stats; + + /** + * Test-block(s)/test-suite(s) failed and passed stats. + */ + testSuites: Stats; + + /** + * Test script(s) execution failed and passed stats. + */ + scripts: Stats; + + /** + * Time taken (in seconds) to execute test-script(s). + */ + duration: number; +}; + +export type TestRunnerRes = { + /** + * Updated envs after running test-script. + */ + envs: HoppEnvs; + + /** + * Describes expected details for each test-suite. + */ + testsReport: TestReport[]; + + /** + * Time taken (in seconds) to execute the test-script. + */ + duration: number; +}; diff --git a/packages/hoppscotch-cli/src/utils/auth/digest.ts b/packages/hoppscotch-cli/src/utils/auth/digest.ts new file mode 100644 index 0000000..7c8d65d --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/auth/digest.ts @@ -0,0 +1,158 @@ +import axios from "axios"; +import { md5 } from "js-md5"; + +import { exceptionColors } from "../getters"; + +export interface DigestAuthParams { + username: string; + password: string; + realm: string; + nonce: string; + endpoint: string; + method: string; + algorithm: string; + qop: string; + nc?: string; + opaque?: string; + cnonce?: string; // client nonce (optional but typically required in qop='auth') + reqBody?: string; +} + +export interface DigestAuthInfo { + realm: string; + nonce: string; + qop: string; + opaque?: string; + algorithm: string; +} + +// Utility function to parse Digest auth header values +const parseDigestAuthHeader = ( + header: string +): { [key: string]: string } | null => { + const matches = header.match(/([a-z0-9]+)="([^"]+)"/gi); + if (!matches) return null; + + const authParams: { [key: string]: string } = {}; + matches.forEach((match) => { + const parts = match.split("="); + authParams[parts[0]] = parts[1].replace(/"/g, ""); + }); + + return authParams; +}; + +// Function to generate Digest Auth Header +export const generateDigestAuthHeader = async (params: DigestAuthParams) => { + const { + username, + password, + realm, + nonce, + endpoint, + method, + algorithm = "MD5", + qop, + nc = "00000001", + opaque, + cnonce, + reqBody = "", + } = params; + + const url = new URL(endpoint); + const uri = url.pathname + url.search; + + // Generate client nonce if not provided + const generatedCnonce = cnonce || md5(`${Math.random()}`); + + // Step 1: Hash the username, realm, password and any additional fields based on the algorithm + const ha1 = + algorithm === "MD5-sess" + ? md5( + `${md5(`${username}:${realm}:${password}`)}:${nonce}:${generatedCnonce}` + ) + : md5(`${username}:${realm}:${password}`); + + // Step 2: Hash the method and URI + const ha2 = + qop === "auth-int" + ? md5(`${method}:${uri}:${md5(reqBody)}`) // Entity body hash for `auth-int` + : md5(`${method}:${uri}`); + + // Step 3: Compute the response hash + const response = md5( + `${ha1}:${nonce}:${nc}:${generatedCnonce}:${qop}:${ha2}` + ); + + // Build the Digest header + let authHeader = `Digest username="${username}", realm="${realm}", nonce="${nonce}", uri="${uri}", algorithm="${algorithm}", response="${response}", qop=${qop}, nc=${nc}, cnonce="${generatedCnonce}"`; + + if (opaque) { + authHeader += `, opaque="${opaque}"`; + } + + return authHeader; +}; + +export const fetchInitialDigestAuthInfo = async ( + url: string, + method: string, + disableRetry: boolean +): Promise => { + try { + const initialResponse = await axios.request({ + url, + method, + validateStatus: () => true, // Allow handling of all status codes + }); + + if (disableRetry) { + throw new Error( + `Received status: ${initialResponse.status}. Retry is disabled as specified, so no further attempts will be made.` + ); + } + + // Check if the response status is 401 (which is expected in Digest Auth flow) + if (initialResponse.status === 401) { + const authHeaderEntry = Object.keys(initialResponse.headers).find( + (header) => header.toLowerCase() === "www-authenticate" + ); + + const authHeader = authHeaderEntry + ? (initialResponse.headers[authHeaderEntry] ?? null) + : null; + + if (authHeader) { + const authParams = parseDigestAuthHeader(authHeader); + if ( + authParams && + authParams.realm && + authParams.nonce && + authParams.qop + ) { + return { + realm: authParams.realm, + nonce: authParams.nonce, + qop: authParams.qop, + opaque: authParams.opaque, + algorithm: authParams.algorithm, + }; + } + } + throw new Error( + "Failed to parse authentication parameters from WWW-Authenticate header" + ); + } + + throw new Error(`Unexpected response: ${initialResponse.status}`); + } catch (error) { + const errMsg = error instanceof Error ? error.message : error; + + console.error( + exceptionColors.FAIL( + `\n Error fetching initial digest auth info: ${errMsg} \n` + ) + ); + throw error; // Re-throw the error to handle it further up the chain if needed + } +}; diff --git a/packages/hoppscotch-cli/src/utils/checks.ts b/packages/hoppscotch-cli/src/utils/checks.ts new file mode 100644 index 0000000..083aab7 --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/checks.ts @@ -0,0 +1,60 @@ +import { CommanderError } from "commander"; +import { HoppCLIError, HoppErrnoException } from "../types/errors"; + +/** + * Determines whether an object has a property with given name. + * @param target Object to be checked for given property. + * @param prop Property to be checked in target object. + * @returns True, if property exists in target object; False, otherwise. + */ +export const hasProperty =

( + target: object, + prop: P +): target is Record => prop in target; + +/** + * Checks if given error data is of type HoppCLIError, based on existence + * of code property. + * @param error Error data to check. + * @returns True, if unknown error validates to be HoppCLIError; + * False, otherwise. + */ +export const isHoppCLIError = (error: unknown): error is HoppCLIError => { + return ( + !!error && + typeof error === "object" && + hasProperty(error, "code") && + typeof error.code === "string" + ); +}; + +/** + * Checks if given error data is of type HoppErrnoException, based on existence + * of name property. + * @param error Error data to check. + * @returns True, if unknown error validates to be HoppErrnoException; + * False, otherwise. + */ +export const isHoppErrnoException = ( + error: unknown +): error is HoppErrnoException => { + return ( + !!error && + typeof error === "object" && + hasProperty(error, "name") && + typeof error.name === "string" + ); +}; + +/** + * Check whether given unknown error is instance of commander-error and + * has zero exit code (which we consider as safe error). + * @param error Error data to check. + * @returns True, if error data validates to be safe-commander-error; + * False, otherwise. + */ +export const isSafeCommanderError = ( + error: unknown +): error is CommanderError => { + return error instanceof CommanderError && error.exitCode === 0; +}; diff --git a/packages/hoppscotch-cli/src/utils/collections.ts b/packages/hoppscotch-cli/src/utils/collections.ts new file mode 100644 index 0000000..276d154 --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/collections.ts @@ -0,0 +1,366 @@ +import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"; +import chalk from "chalk"; +import { log } from "console"; +import * as A from "fp-ts/Array"; +import { pipe } from "fp-ts/function"; +import { round } from "lodash-es"; + +import { CollectionRunnerParam } from "../types/collections"; +import { + CollectionQueue, + HoppEnvs, + ProcessRequestParams, + RequestReport, +} from "../types/request"; +import { + PreRequestMetrics, + RequestMetrics, + TestMetrics, +} from "../types/response"; +import { DEFAULT_DURATION_PRECISION } from "./constants"; +import { + printErrorsReport, + printFailedTestsReport, + printPreRequestMetrics, + printRequestsMetrics, + printTestsMetrics, +} from "./display"; +import { exceptionColors } from "./getters"; +import { getPreRequestMetrics } from "./pre-request"; +import { buildJUnitReport, generateJUnitReportExport } from "./reporters/junit"; +import { + getRequestMetrics, + preProcessRequest, + processRequest, +} from "./request"; +import { getTestMetrics } from "./test"; +import { filterValidScripts } from "@hoppscotch/js-sandbox/scripting"; + +const { WARN, FAIL, INFO } = exceptionColors; + +/** + * Processes each requests within collections to prints details of subsequent requests, + * tests and to display complete errors-report, failed-tests-report and test-metrics. + * @param param Data of hopp-collection with hopp-requests, envs to be processed. + * @returns List of report for each processed request. + */ + +export const collectionsRunner = async ( + param: CollectionRunnerParam +): Promise => { + const { + collections, + envs, + delay, + iterationCount, + iterationData, + legacySandbox, + } = param; + + const resolvedDelay = delay ?? 0; + + const requestsReport: RequestReport[] = []; + const collectionQueue = getCollectionQueue(collections); + + // If iteration count is not supplied, it should be based on the size of iteration data if in scope + const resolvedIterationCount = iterationCount ?? iterationData?.length ?? 1; + + const originalSelectedEnvs = [...envs.selected]; + + for (let count = 0; count < resolvedIterationCount; count++) { + if (resolvedIterationCount > 1) { + log(INFO(`\nIteration: ${count + 1}/${resolvedIterationCount}`)); + } + + // Reset `envs` to the original value at the start of each iteration + envs.selected = [...originalSelectedEnvs]; + + if (iterationData) { + // Ensure last item is picked if the iteration count exceeds size of the iteration data + const iterationDataItem = + iterationData[Math.min(count, iterationData.length - 1)]; + + // Ensure iteration data takes priority over supplied environment variables + envs.selected = envs.selected + .filter( + (envPair) => + !iterationDataItem.some((dataPair) => dataPair.key === envPair.key) + ) + .concat(iterationDataItem); + } + + for (const { collection, path } of collectionQueue) { + await processCollection( + collection, + path, + envs, + resolvedDelay, + requestsReport, + legacySandbox + ); + } + } + + return requestsReport; +}; + +const processCollection = async ( + collection: HoppCollection, + path: string, + envs: HoppEnvs, + delay: number, + requestsReport: RequestReport[], + legacySandbox?: boolean, + ancestorPreRequestScripts: string[] = [], + ancestorTestScripts: string[] = [] +) => { + // Accumulate scripts from root -> current collection for inheritance + // filterValidScripts strips empty, whitespace-only, and module-prefix-only scripts + const inheritedPreRequestScripts = filterValidScripts([ + ...ancestorPreRequestScripts, + collection.preRequestScript, + ]); + const inheritedTestScripts = filterValidScripts([ + ...ancestorTestScripts, + collection.testScript, + ]); + + // Process each request in the collection + for (const request of collection.requests) { + const _request = preProcessRequest(request as HoppRESTRequest, collection); + const requestPath = `${path}/${_request.name}`; + + const collectionVariables = collection.variables.filter( + (variable) => variable.key && variable.key.trim() !== "" + ); + + const processRequestParams: ProcessRequestParams = { + path: requestPath, + request: _request, + envs, + delay, + legacySandbox, + collectionVariables, + inheritedPreRequestScripts, + inheritedTestScripts, + }; + + // Request processing initiated message. + log(WARN(`\nRunning: ${chalk.bold(requestPath)}`)); + + // Processing current request. + const result = await processRequest(processRequestParams)(); + + // Updating global & selected envs with new envs from processed-request output. + const { global, selected } = result.envs; + envs.global = global; + envs.selected = selected; + + // Storing current request's report. + const requestReport = result.report; + requestsReport.push(requestReport); + } + + // Process each folder in the collection + for (const folder of collection.folders) { + const updatedFolder: HoppCollection = { ...folder }; + + if (updatedFolder.auth.authType === "inherit") { + updatedFolder.auth = collection.auth; + } + + if (collection.headers.length) { + // Filter out header entries present in the parent collection under the same name + // This ensures the folder headers take precedence over the collection headers + const filteredHeaders = collection.headers.filter( + (collectionHeaderEntries) => { + return !updatedFolder.headers.some( + (folderHeaderEntries) => + folderHeaderEntries.key === collectionHeaderEntries.key + ); + } + ); + updatedFolder.headers.push(...filteredHeaders); + } + + // Inherit collection variables into folder, with folder variables taking precedence + if (collection.variables.length) { + // Filter out collection variables with same key as folder variables + const filteredVariables = collection.variables.filter( + (collectionVariableEntries) => { + return !updatedFolder.variables.some( + (folderVariableEntries) => + folderVariableEntries.key === collectionVariableEntries.key + ); + } + ); + + updatedFolder.variables.push(...filteredVariables); + } + + await processCollection( + updatedFolder, + `${path}/${updatedFolder.name}`, + envs, + delay, + requestsReport, + legacySandbox, + inheritedPreRequestScripts, + inheritedTestScripts + ); + } +}; +/** + * Transforms collections to generate collection-stack which describes each collection's + * path within collection & the collection itself. + * @param collections Hopp-collection objects to be mapped to collection-stack type. + * @returns Mapped collections to collection-stack. + */ +const getCollectionQueue = (collections: HoppCollection[]): CollectionQueue[] => + pipe( + collections, + A.map( + (collection) => { collection, path: collection.name } + ) + ); + +/** + * Prints collection-runner-report using test-metrics, request-metrics and + * pre-request-metrics data in pretty-format. + * @param requestsReport Provides data for each request-report which includes + * path of each request within collection-json file, failed-tests-report, errors, + * total execution duration for requests, pre-request-scripts, test-scripts. + * @returns True, if collection runner executed without any errors or failed test-cases. + * False, if errors occurred or test-cases failed. + */ +export const collectionsRunnerResult = ( + requestsReport: RequestReport[], + reporterJUnitExportPath?: string +): boolean => { + const overallTestMetrics = { + tests: { failed: 0, passed: 0 }, + testSuites: { failed: 0, passed: 0 }, + duration: 0, + scripts: { failed: 0, passed: 0 }, + }; + const overallRequestMetrics = { + requests: { failed: 0, passed: 0 }, + duration: 0, + }; + const overallPreRequestMetrics = { + scripts: { failed: 0, passed: 0 }, + duration: 0, + }; + let finalResult = true; + + let totalErroredTestCases = 0; + let totalFailedTestCases = 0; + + // Printing requests-report details of failed-tests and errors + for (const requestReport of requestsReport) { + const { path, tests, errors, result, duration } = requestReport; + const requestDuration = duration.request; + const testsDuration = duration.test; + const preRequestDuration = duration.preRequest; + + finalResult = finalResult && result; + + printFailedTestsReport(path, tests); + + printErrorsReport(path, errors); + + if (reporterJUnitExportPath) { + const { failedRequestTestCases, erroredRequestTestCases } = + buildJUnitReport({ + path, + tests, + errors, + duration: duration.test, + }); + + totalFailedTestCases += failedRequestTestCases; + totalErroredTestCases += erroredRequestTestCases; + } + + /** + * Extracting current request report's test-metrics and updating + * overall test-metrics. + */ + const testMetrics = getTestMetrics(tests, testsDuration, errors); + overallTestMetrics.duration += testMetrics.duration; + overallTestMetrics.testSuites.failed += testMetrics.testSuites.failed; + overallTestMetrics.testSuites.passed += testMetrics.testSuites.passed; + overallTestMetrics.tests.failed += testMetrics.tests.failed; + overallTestMetrics.tests.passed += testMetrics.tests.passed; + overallTestMetrics.scripts.failed += testMetrics.scripts.failed; + overallTestMetrics.scripts.passed += testMetrics.scripts.passed; + + /** + * Extracting current request report's request-metrics and updating + * overall request-metrics. + */ + const requestMetrics = getRequestMetrics(errors, requestDuration); + overallRequestMetrics.duration += requestMetrics.duration; + overallRequestMetrics.requests.failed += requestMetrics.requests.failed; + overallRequestMetrics.requests.passed += requestMetrics.requests.passed; + + /** + * Extracting current request report's pre-request-metrics and updating + * overall pre-request-metrics. + */ + const preRequestMetrics = getPreRequestMetrics(errors, preRequestDuration); + overallPreRequestMetrics.duration += preRequestMetrics.duration; + overallPreRequestMetrics.scripts.failed += preRequestMetrics.scripts.failed; + overallPreRequestMetrics.scripts.passed += preRequestMetrics.scripts.passed; + } + + const testMetricsDuration = overallTestMetrics.duration; + const requestMetricsDuration = overallRequestMetrics.duration; + + // Rounding-off overall test-metrics duration upto DEFAULT_DURATION_PRECISION. + overallTestMetrics.duration = round( + testMetricsDuration, + DEFAULT_DURATION_PRECISION + ); + + // Rounding-off overall request-metrics duration upto DEFAULT_DURATION_PRECISION. + overallRequestMetrics.duration = round( + requestMetricsDuration, + DEFAULT_DURATION_PRECISION + ); + + printTestsMetrics(overallTestMetrics); + printRequestsMetrics(overallRequestMetrics); + printPreRequestMetrics(overallPreRequestMetrics); + + if (reporterJUnitExportPath) { + const totalTestCases = + overallTestMetrics.tests.failed + overallTestMetrics.tests.passed; + + generateJUnitReportExport({ + totalTestCases, + totalFailedTestCases, + totalErroredTestCases, + testDuration: overallTestMetrics.duration, + reporterJUnitExportPath, + }); + } + + return finalResult; +}; + +/** + * Exiting hopp cli process with appropriate exit code depending on + * collections-runner result. + * If result is true, we exit the cli process with code 0. + * Else, exit with code 1. + * @param result Boolean defining the collections-runner result. + */ +export const collectionsRunnerExit = (result: boolean): never => { + if (!result) { + const EXIT_MSG = FAIL(`\nExited with code 1`); + process.stderr.write(EXIT_MSG); + process.exit(1); + } + process.exit(0); +}; diff --git a/packages/hoppscotch-cli/src/utils/constants.ts b/packages/hoppscotch-cli/src/utils/constants.ts new file mode 100644 index 0000000..16ca169 --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/constants.ts @@ -0,0 +1,12 @@ +import { ResponseErrorPair } from "../interfaces/response"; + +export const responseErrors: ResponseErrorPair = { + 501: "REQUEST NOT SUPPORTED", + 408: "NETWORK TIMEOUT", + 400: "BAD REQUEST", +} as const; + +/** + * Default decimal precision to round-off calculated HRTime time in seconds. + */ +export const DEFAULT_DURATION_PRECISION: number = 3; diff --git a/packages/hoppscotch-cli/src/utils/display.ts b/packages/hoppscotch-cli/src/utils/display.ts new file mode 100644 index 0000000..564512f --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/display.ts @@ -0,0 +1,231 @@ +import chalk from "chalk"; +import { groupEnd, group, log } from "console"; +import { handleError } from "../handlers/error"; +import { RequestConfig } from "../interfaces/request"; +import { RequestRunnerResponse, TestReport } from "../interfaces/response"; +import { HoppCLIError } from "../types/errors"; +import { + PreRequestMetrics, + RequestMetrics, + TestMetrics, +} from "../types/response"; +import { exceptionColors, getColorStatusCode } from "./getters"; +import { getFailedExpectedResults, getFailedTestsReport } from "./test"; + +const { FAIL, SUCCESS, BG_INFO, INFO_BRIGHT } = exceptionColors; + +/** + * Prints total failed and passed stats of executed pre-request-scripts. + * @param preRequestMetrics Provides data for total failed and passed + * stats of all executed pre-request-scripts. + */ +export const printPreRequestMetrics = ( + preRequestMetrics: PreRequestMetrics +) => { + const { + scripts: { failed, passed }, + } = preRequestMetrics; + + const failedPreRequestsOut = FAIL(`${failed} failed`); + const passedPreRequestsOut = SUCCESS(`${passed} passed`); + const preRequestsOut = `Pre-Request Scripts: ${failedPreRequestsOut} ${passedPreRequestsOut}\n`; + + const message = `\n${preRequestsOut}`; + process.stdout.write(message); +}; + +/** + * Prints total failed and passed stats, duration of executed request. + * @param requestsMetrics Provides data for total duration and total failed and + * passed stats of all executed requests. + */ +export const printRequestsMetrics = (requestsMetrics: RequestMetrics) => { + const { + requests: { failed, passed }, + duration, + } = requestsMetrics; + + const failedRequestsOut = FAIL(`${failed} failed`); + const passedRequestsOut = SUCCESS(`${passed} passed`); + const requestsOut = `Requests: ${failedRequestsOut} ${passedRequestsOut}\n`; + const requestsDurationOut = + duration > 0 ? `Requests Duration: ${INFO_BRIGHT(`${duration} s`)}\n` : ""; + + const message = `\n${requestsOut}${requestsDurationOut}`; + process.stdout.write(message); +}; + +/** + * Prints test-suites in pretty-way describing each test-suites failed/passed + * status and duration to execute the test-script. + * @param testsReport Providing details of each test-suites with tests-report. + * @param duration Time taken (in seconds) to execute the test-script. + */ +export const printTestSuitesReport = ( + testsReport: TestReport[], + duration: number +) => { + const durationMsg = + duration > 0 ? INFO_BRIGHT(`Ran tests in ${duration} s`) : ""; + + group(); + for (const testReport of testsReport) { + const { failed, descriptor } = testReport; + + if (failed > 0) { + log(`${FAIL("✖")} ${descriptor}`); + } else { + log(`${SUCCESS("✔")} ${descriptor}`); + } + } + log(durationMsg); + groupEnd(); +}; + +/** + * Prints total failed and passed stats for test-suites, test-cases, test-scripts, + * and total duration of executed test-scripts. + * @param testsMetrics Provides testSuites, testCases metrics, test-script + * execution duration and test-script passed/failed stats. + */ +export const printTestsMetrics = (testsMetrics: TestMetrics) => { + const { testSuites, tests, duration, scripts } = testsMetrics; + + const failedTestCasesOut = FAIL(`${tests.failed} failed`); + const passedTestCasesOut = SUCCESS(`${tests.passed} passed`); + const testCasesOut = `Test Cases: ${failedTestCasesOut} ${passedTestCasesOut}\n`; + + const failedTestSuitesOut = FAIL(`${testSuites.failed} failed`); + const passedTestSuitesOut = SUCCESS(`${testSuites.passed} passed`); + const testSuitesOut = `Test Suites: ${failedTestSuitesOut} ${passedTestSuitesOut}\n`; + + const failedTestScriptsOut = FAIL(`${scripts.failed} failed`); + const passedTestScriptsOut = SUCCESS(`${scripts.passed} passed`); + const testScriptsOut = `Test Scripts: ${failedTestScriptsOut} ${passedTestScriptsOut}\n`; + + const testsDurationOut = + duration > 0 ? `Tests Duration: ${INFO_BRIGHT(`${duration} s`)}\n` : ""; + + const message = `\n${testCasesOut}${testSuitesOut}${testScriptsOut}${testsDurationOut}`; + process.stdout.write(message); +}; + +/** + * Prints details of each reported error for a request with error code. + * @param path Request's path in collection for which errors occurred. + * @param errorsReport List of errors reported. + */ +export const printErrorsReport = ( + path: string, + errorsReport: HoppCLIError[] +) => { + if (errorsReport.length > 0) { + const REPORTED_ERRORS_TITLE = FAIL( + `\n${chalk.bold(path)} reported errors:` + ); + + group(REPORTED_ERRORS_TITLE); + for (const errorReport of errorsReport) { + handleError(errorReport); + } + groupEnd(); + } +}; + +/** + * Prints details of each failed tests for given request's path. + * @param path Request's path in collection for which tests-failed. + * @param testsReport Overall tests-report including failed-tests-report. + */ +export const printFailedTestsReport = ( + path: string, + testsReport: TestReport[] +) => { + const failedTestsReport = getFailedTestsReport(testsReport); + + // Only printing test-reports with failed test-cases. + if (failedTestsReport.length > 0) { + const FAILED_TESTS_PATH = FAIL(`\n${chalk.bold(path)} failed tests:`); + group(FAILED_TESTS_PATH); + + for (const failedTestReport of failedTestsReport) { + const { descriptor, expectResults } = failedTestReport; + const failedExpectResults = getFailedExpectedResults(expectResults); + + // Only printing failed expected-results. + if (failedExpectResults.length > 0) { + group("⦁", descriptor); + + for (const failedExpectResult of failedExpectResults) { + log(FAIL("-"), failedExpectResult.message); + } + + groupEnd(); + } + } + + groupEnd(); + } +}; + +/** + * Provides methods for printing request-runner's state messages. + */ +export const printRequestRunner = { + /** + * Request-runner starting message. + * @param requestConfig Provides request's method and url. + */ + start: (requestConfig: RequestConfig) => { + const METHOD = BG_INFO(` ${requestConfig.method} `); + const ENDPOINT = requestConfig.displayUrl || requestConfig.url; + + process.stdout.write(`${METHOD} ${ENDPOINT}`); + }, + + /** + * Prints response's status, when request-runner executes successfully. + * @param requestResponse Provides request's status and execution duration. + */ + success: (requestResponse: RequestRunnerResponse) => { + const { status, statusText, duration } = requestResponse; + const statusMsg = getColorStatusCode(status, statusText); + const durationMsg = duration > 0 ? INFO_BRIGHT(`(${duration} s)`) : ""; + + process.stdout.write(` ${statusMsg} ${durationMsg}\n`); + }, + + /** + * Prints error message, when request-runner fails to execute. + */ + fail: () => log(FAIL(" ERROR\n⚠ Error running request.")), +}; + +/** + * Provides methods for printing test-runner's state messages. + */ +export const printTestRunner = { + /** + * Prints test-runner failed message. + */ + fail: () => log(FAIL("⚠ Error running test-script.")), + + /** + * Prints test-runner success message including tests-report. + * @param testsReport List of expected result(s) and metrics for the executed + * test-script. + * @param duration Time taken to execute a test-script. + */ + success: (testsReport: TestReport[], duration: number) => + printTestSuitesReport(testsReport, duration), +}; + +/** + * Provides methods for printing pre-request-runner's state messages. + */ +export const printPreRequestRunner = { + /** + * Prints pre-request-runner failed message. + */ + fail: () => log(FAIL("⚠ Error running pre-request-script.")), +}; diff --git a/packages/hoppscotch-cli/src/utils/functions/array.ts b/packages/hoppscotch-cli/src/utils/functions/array.ts new file mode 100644 index 0000000..f616d59 --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/functions/array.ts @@ -0,0 +1,37 @@ +import { clone } from "lodash-es"; + +/** + * Sorts the array based on the sort func. + * NOTE: Creates a new array, if you don't need ref + * to original array, use `arrayUnsafeSort` for better perf + * @param sortFunc Sort function to sort against + */ +export const arraySort = + (sortFunc: (a: T, b: T) => number) => + (arr: T[]) => { + const newArr = clone(arr); + + newArr.sort(sortFunc); + + return newArr; + }; + +/** + * Equivalent to `Array.prototype.flatMap`. + * @param mapFunc The map function. + * @returns Array formed by applying given mapFunc. + */ +export const arrayFlatMap = + (mapFunc: (value: T, index: number, arr: T[]) => U[]) => + (arr: T[]) => + arr.flatMap(mapFunc); + +export const tupleToRecord = < + KeyType extends string | number | symbol, + ValueType, +>( + tuples: [KeyType, ValueType][] +): Record => + tuples.length > 0 + ? (Object.assign as any)(...tuples.map(([key, val]) => ({ [key]: val }))) + : {}; diff --git a/packages/hoppscotch-cli/src/utils/getters.ts b/packages/hoppscotch-cli/src/utils/getters.ts new file mode 100644 index 0000000..f87431d --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/getters.ts @@ -0,0 +1,335 @@ +import { + EnvironmentVariable, + HoppCollectionVariable, + HoppRESTHeader, + HoppRESTParam, + HoppRESTRequestVariables, + parseTemplateStringE, +} from "@hoppscotch/data"; +import axios, { AxiosError } from "axios"; +import chalk from "chalk"; +import * as A from "fp-ts/Array"; +import * as E from "fp-ts/Either"; +import * as O from "fp-ts/Option"; +import { pipe } from "fp-ts/function"; +import * as S from "fp-ts/string"; +import fs from "fs/promises"; +import { round } from "lodash-es"; + +import { error } from "../types/errors"; +import { DEFAULT_DURATION_PRECISION } from "./constants"; +import { readJsonFile } from "./mutators"; +import { + WorkspaceCollection, + WorkspaceEnvironment, + transformWorkspaceCollections, + transformWorkspaceEnvironment, +} from "./workspace-access"; + +type GetResourceContentsParams = { + pathOrId: string; + accessToken?: string; + serverUrl?: string; + resourceType: "collection" | "environment"; +}; + +/** + * Generates template string (status + statusText) with specific color unicodes + * based on type of status. + * @param status Status code of a HTTP response. + * @param statusText Status text of a HTTP response. + * @returns Template string with related color unicodes. + */ +export const getColorStatusCode = ( + status: number | string, + statusText: string +): string => { + const statusCode = `${status == 0 ? "Error" : status} : ${statusText}`; + + if (status.toString().startsWith("2")) { + return chalk.greenBright(statusCode); + } else if (status.toString().startsWith("3")) { + return chalk.yellowBright(statusCode); + } + + return chalk.redBright(statusCode); +}; + +/** + * Replaces all template-string with their effective ENV values to generate effective + * request headers/parameters meta-data. + * @param metaData Headers/parameters on which ENVs will be applied. + * @param resolvedVariables Provides ENV variables for parsing template-string. + * @returns Active, non-empty-key, parsed headers/parameters pairs. + */ +export const getEffectiveFinalMetaData = ( + metaData: HoppRESTHeader[] | HoppRESTParam[], + resolvedVariables: EnvironmentVariable[] +) => + pipe( + metaData, + + /** + * Selecting only non-empty and active pairs. + */ + A.filter(({ key, active }) => !S.isEmpty(key) && active), + A.map(({ key, value, description }) => { + return { + active: true, + key: parseTemplateStringE(key, resolvedVariables), + value: parseTemplateStringE(value, resolvedVariables), + description, + }; + }), + E.fromPredicate( + /** + * Check if every key-value is right either. Else return HoppCLIError with + * appropriate reason. + */ + A.every(({ key, value }) => E.isRight(key) && E.isRight(value)), + (reason) => error({ code: "PARSING_ERROR", data: reason }) + ), + E.map( + /** + * Filtering and mapping only right-eithers for each key-value as [string, string]. + */ + A.filterMap(({ key, value, description }) => + E.isRight(key) && E.isRight(value) + ? O.some({ + active: true, + key: key.right, + value: value.right, + description, + }) + : O.none + ) + ) + ); + +/** + * Reduces array of HoppRESTParam or HoppRESTHeader to unique key-value + * pair. + * @param metaData Array of meta-data to reduce. + * @returns Object with unique key-value pair. + */ +export const getMetaDataPairs = ( + metaData: HoppRESTParam[] | HoppRESTHeader[] +) => + pipe( + metaData, + + // Excluding non-active & empty key request meta-data. + A.filter(({ active, key }) => active && !S.isEmpty(key)), + + // Reducing array of request-meta-data to key-value pair object. + A.reduce(>{}, (target, { key, value }) => + Object.assign(target, { [`${key}`]: value }) + ) + ); + +/** + * Object providing aliases for chalk color properties based on exceptions. + */ +export const exceptionColors = { + WARN: chalk.yellow, + INFO: chalk.blue, + FAIL: chalk.red, + SUCCESS: chalk.green, + INFO_BRIGHT: chalk.blueBright, + BG_WARN: chalk.bgYellow, + BG_FAIL: chalk.bgRed, + BG_INFO: chalk.bgBlue, + BG_SUCCESS: chalk.bgGreen, +}; + +/** + * Calculates duration in seconds for given end-HRTime of format [seconds, nanoseconds], + * which is rounded-off upto given decimal value. + * @param end Providing end-HRTime of format [seconds, nanoseconds]. + * @param precision Decimal precision to round-off float duration value (DEFAULT = 3). + * @returns Rounded duration in seconds for given decimal precision. + */ +export const getDurationInSeconds = ( + end: [number, number], + precision: number = DEFAULT_DURATION_PRECISION +) => { + const durationInSeconds = (end[0] * 1e9 + end[1]) / 1e9; + return round(durationInSeconds, precision); +}; + +export const roundDuration = ( + duration: number, + precision: number = DEFAULT_DURATION_PRECISION +) => round(duration, precision); + +/** + * Retrieves the contents of a resource (collection or environment) from a local file (export) or a remote server (workspaces). + * + * @param {GetResourceContentsParams} params - The parameters for retrieving resource contents. + * @param {string} params.pathOrId - The path to the local file or the ID for remote retrieval. + * @param {string} [params.accessToken] - The access token for authorizing remote retrieval. + * @param {string} [params.serverUrl] - The SH instance server URL for remote retrieval. Defaults to the cloud instance. + * @param {"collection" | "environment"} params.resourceType - The type of the resource to retrieve. + * @returns {Promise} A promise that resolves to the contents of the resource. + * @throws Will throw an error if the content type of the fetched resource is not `application/json`, + * if there is an issue with the access token, if the server connection is refused, + * or if the server URL is invalid. + */ +export const getResourceContents = async ( + params: GetResourceContentsParams +): Promise => { + const { pathOrId, accessToken, serverUrl, resourceType } = params; + + let contents: unknown | null = null; + let fileExistsInPath = false; + + try { + await fs.access(pathOrId); + fileExistsInPath = true; + } catch (e) { + fileExistsInPath = false; + } + + if (accessToken && !fileExistsInPath) { + const resolvedServerUrl = serverUrl || "https://api.hoppscotch.io"; + + try { + const separator = resolvedServerUrl.endsWith("/") ? "" : "/"; + const resourcePath = + resourceType === "collection" ? "collection" : "environment"; + + const url = `${resolvedServerUrl}${separator}v1/access-tokens/${resourcePath}/${pathOrId}`; + + const { data, headers } = await axios.get(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!headers["content-type"].includes("application/json")) { + throw new AxiosError("INVALID_CONTENT_TYPE"); + } + + contents = + resourceType === "collection" + ? transformWorkspaceCollections([data] as WorkspaceCollection[])[0] + : transformWorkspaceEnvironment(data as WorkspaceEnvironment); + } catch (err) { + if (err instanceof AxiosError) { + const axiosErr: AxiosError<{ + reason?: "TOKEN_EXPIRED" | "TOKEN_INVALID" | "INVALID_ID"; + message: string; + statusCode: number; + }> = err; + + const errReason = axiosErr.response?.data?.reason; + + if (errReason) { + throw error({ + code: errReason, + data: ["TOKEN_EXPIRED", "TOKEN_INVALID"].includes(errReason) + ? accessToken + : pathOrId, + }); + } + + if (axiosErr.code === "ECONNREFUSED") { + throw error({ + code: "SERVER_CONNECTION_REFUSED", + data: resolvedServerUrl, + }); + } + + if ( + axiosErr.message === "INVALID_CONTENT_TYPE" || + axiosErr.code === "ERR_INVALID_URL" || + axiosErr.code === "ENOTFOUND" || + axiosErr.code === "ERR_BAD_REQUEST" || + axiosErr.response?.status === 404 + ) { + throw error({ code: "INVALID_SERVER_URL", data: resolvedServerUrl }); + } + } else { + throw error({ code: "UNKNOWN_ERROR", data: err }); + } + } + } + + // Fallback to reading from file if contents are not available + if (contents === null) { + contents = await readJsonFile(pathOrId, fileExistsInPath); + } + + return contents; +}; + +/** + * Processes incoming request variables and environment variables and returns a list + * where active request variables are picked and prioritised over the supplied environment variables. + * Falls back to environment variables for an empty request variable. + * + * @param {HoppRESTRequestVariables} requestVariables - Incoming request variables. + * @param {EnvironmentVariable[]} environmentVariables - Incoming environment variables. + * @param {HoppCollectionVariable[]} collectionVariables - Optional collection variables to be included. + * @returns {EnvironmentVariable[]} The resolved list of variables that conforms to the shape of environment variables. + */ +export const getResolvedVariables = ( + requestVariables: HoppRESTRequestVariables, + environmentVariables: EnvironmentVariable[], + collectionVariables: HoppCollectionVariable[] = [] +): EnvironmentVariable[] => { + // Transforming request variables to the shape of environment variables + const activeRequestVariables = requestVariables + .filter(({ active, value }) => active && value) + .map(({ key, value }) => ({ + key, + initialValue: value, + currentValue: value, + secret: false, + })); + + const requestVariableKeys = activeRequestVariables.map(({ key }) => key); + + // Request variables have higher priority, hence filtering out collection variables with the same keys + const filteredCollectionVariables = collectionVariables.filter( + ({ key }) => !requestVariableKeys.includes(key) + ); + + const collectionVariableKeys = filteredCollectionVariables.map( + ({ key }) => key + ); + + // Filtering out environment variables that have keys present in request or collection variables + const filteredEnvironmentVariables = environmentVariables.filter( + ({ key }) => + ![...requestVariableKeys, ...collectionVariableKeys].includes(key) + ); + + // Setting currentValue to initialValue for environment variables + // because the exported file might not have the currentValue field + const processedEnvironmentVariables = filteredEnvironmentVariables.map( + ({ key, initialValue, currentValue, secret }) => ({ + key, + initialValue, + currentValue: + currentValue && currentValue !== "" ? currentValue : initialValue, + secret, + }) + ); + + const processedCollectionVariables = filteredCollectionVariables.map( + ({ key, initialValue, currentValue, secret }) => ({ + key, + initialValue, + currentValue: + currentValue && currentValue !== "" ? currentValue : initialValue, + secret, + }) + ); + + return [ + ...activeRequestVariables, + ...processedCollectionVariables, + ...processedEnvironmentVariables, + ]; +}; diff --git a/packages/hoppscotch-cli/src/utils/hopp-fetch.ts b/packages/hoppscotch-cli/src/utils/hopp-fetch.ts new file mode 100644 index 0000000..652b6e1 --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/hopp-fetch.ts @@ -0,0 +1,274 @@ +import axios, { Method } from "axios"; +import type { HoppFetchHook } from "@hoppscotch/js-sandbox"; +import { wrapper as axiosCookieJarSupport } from "axios-cookiejar-support"; +import { CookieJar } from "tough-cookie"; + +/** + * Creates a hopp.fetch() hook implementation for CLI. + * Uses axios directly for network requests since CLI has no interceptor concept. + * + * @returns HoppFetchHook implementation + */ +export const createHoppFetchHook = (): HoppFetchHook => { + // Cookie jar maintains cookies across redirects (matches Postman behavior) + const jar = new CookieJar(); + const axiosWithCookies = axiosCookieJarSupport(axios.create()); + + return async (input, init) => { + // Extract URL from different input types + const urlStr = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + + // Extract method from Request object if available (init takes precedence) + const requestMethod = input instanceof Request ? input.method : undefined; + const method = (init?.method || requestMethod || "GET") as Method; + + // Merge headers from Request object and init (init takes precedence) + const headers: Record = {}; + + // First, add headers from Request object if input is a Request + if (input instanceof Request) { + input.headers.forEach((value, key) => { + headers[key] = value; + }); + } + + // Then overlay with init.headers (takes precedence) + if (init?.headers) { + Object.assign(headers, headersToObject(init.headers)); + } + + // Extract body from Request object if available (init takes precedence) + // Note: Request.body is a ReadableStream which axios cannot handle, + // so we need to read it first + let body: BodyInit | null | undefined; + if (init?.body !== undefined) { + body = init.body; + } else if (input instanceof Request && input.body !== null) { + // Read the ReadableStream into an ArrayBuffer that axios can send + const clonedRequest = input.clone(); + body = await clonedRequest.arrayBuffer(); + } else { + body = undefined; + } + + // Convert Fetch API options to axios config + // Note: Using 'any' for config because axios-cookiejar-support extends AxiosRequestConfig + // with 'jar' property that isn't in standard types + const config: any = { + url: urlStr, + method, + headers: Object.keys(headers).length > 0 ? headers : {}, + data: body, + responseType: "arraybuffer", // Prevents binary corruption from string encoding + validateStatus: () => true, // Don't throw on any status code + jar, + withCredentials: true, // Required for cookie jar + }; + + // Handle AbortController signal if provided + if (init?.signal) { + config.signal = init.signal; + } + + try { + const axiosResponse = await axiosWithCookies(config); + + // Convert axios response to serializable response (with _bodyBytes) + // Native Response objects can't cross VM boundaries + return createSerializableResponse( + axiosResponse.status, + axiosResponse.statusText, + axiosResponse.headers, + axiosResponse.data + ); + } catch (error) { + // Handle axios errors + if (axios.isAxiosError(error) && error.response) { + // Return error response as serializable Response object + return createSerializableResponse( + error.response.status, + error.response.statusText, + error.response.headers, + error.response.data + ); + } + + // Network error or other failure + throw new Error( + `Fetch failed: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + }; +}; + +/** + * Creates a serializable Response-like object with _bodyBytes. + * + * Native Response objects can't cross the QuickJS boundary due to internal state. + * Returns a plain object with all data loaded upfront. + */ +function createSerializableResponse( + status: number, + statusText: string, + headers: any, + body: any +): Response { + const ok = status >= 200 && status < 300; + + // Convert headers to plain object (serializable) + // Set-Cookie headers kept separate - commas can appear in cookie values + const headersObj: Record = {}; + const setCookieHeaders: string[] = []; + + Object.entries(headers).forEach(([key, value]) => { + if (value !== undefined) { + if (key.toLowerCase() === "set-cookie") { + // Preserve Set-Cookie headers as array for getSetCookie() compatibility + if (Array.isArray(value)) { + setCookieHeaders.push(...value); + } else { + setCookieHeaders.push(String(value)); + } + // Also store first Set-Cookie in headersObj for backward compatibility + headersObj[key] = Array.isArray(value) ? value[0] : String(value); + } else { + // Other headers can be safely concatenated with commas + headersObj[key] = Array.isArray(value) + ? value.join(", ") + : String(value); + } + } + }); + + // Store body as plain number array for VM serialization + let bodyBytes: number[] = []; + + if (body) { + if (Array.isArray(body)) { + // Already an array + bodyBytes = body; + } else if (body instanceof ArrayBuffer) { + // ArrayBuffer (from axios) - convert to plain array + bodyBytes = Array.from(new Uint8Array(body)); + } else if (body instanceof Uint8Array) { + // Uint8Array - convert to plain array + bodyBytes = Array.from(body); + } else if (ArrayBuffer.isView(body)) { + // Other typed array + bodyBytes = Array.from(new Uint8Array(body.buffer)); + } else if (typeof body === "string") { + // String body + bodyBytes = Array.from(new TextEncoder().encode(body)); + } else if (typeof body === "object") { + // Check if it's a Buffer-like object with 'type' and 'data' properties + if ("type" in body && "data" in body && Array.isArray(body.data)) { + bodyBytes = body.data; + } else { + // Plain object with numeric keys (like {0: 72, 1: 101, ...}) + const keys = Object.keys(body) + .map(Number) + .filter((n) => !isNaN(n)) + .sort((a, b) => a - b); + bodyBytes = keys.map((k) => body[k]); + } + } + } + + // Create Response-like object with all methods implemented using stored data + const serializableResponse = { + status, + statusText, + ok, + // Store raw headers data for fetch module to use + _headersData: headersObj, + headers: { + get(name: string): string | null { + // Case-insensitive header lookup + const lowerName = name.toLowerCase(); + for (const [key, value] of Object.entries(headersObj)) { + if (key.toLowerCase() === lowerName) { + return value; + } + } + return null; + }, + has(name: string): boolean { + return this.get(name) !== null; + }, + entries(): IterableIterator<[string, string]> { + return Object.entries(headersObj)[Symbol.iterator](); + }, + keys(): IterableIterator { + return Object.keys(headersObj)[Symbol.iterator](); + }, + values(): IterableIterator { + return Object.values(headersObj)[Symbol.iterator](); + }, + forEach(callback: (value: string, key: string) => void) { + Object.entries(headersObj).forEach(([key, value]) => + callback(value, key) + ); + }, + // Returns all Set-Cookie headers as array + getSetCookie(): string[] { + return setCookieHeaders; + }, + }, + _bodyBytes: bodyBytes, + + // Body methods - will be overridden by custom fetch module with VM-native versions + async text(): Promise { + return new TextDecoder().decode(new Uint8Array(bodyBytes)); + }, + + async json(): Promise { + const text = await this.text(); + return JSON.parse(text); + }, + + async arrayBuffer(): Promise { + return new Uint8Array(bodyBytes).buffer; + }, + + async blob(): Promise { + return new Blob([new Uint8Array(bodyBytes)]); + }, + + // Required Response properties + type: "basic" as ResponseType, + url: "", + redirected: false, + bodyUsed: false, + }; + + // Cast to Response for type compatibility + return serializableResponse as unknown as Response; +} + +/** + * Converts Fetch API headers to plain object for axios + */ +function headersToObject(headers: HeadersInit): Record { + const result: Record = {}; + + if (headers instanceof Headers) { + headers.forEach((value, key) => { + result[key] = value; + }); + } else if (Array.isArray(headers)) { + headers.forEach(([key, value]) => { + result[key] = value; + }); + } else { + Object.entries(headers).forEach(([key, value]) => { + result[key] = value; + }); + } + + return result; +} diff --git a/packages/hoppscotch-cli/src/utils/jsonc.ts b/packages/hoppscotch-cli/src/utils/jsonc.ts new file mode 100644 index 0000000..0052203 --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/jsonc.ts @@ -0,0 +1,89 @@ +import { Node, parseTree, stripComments as stripComments_ } from "jsonc-parser"; + +/** + * An internal error that is thrown when an invalid JSONC node configuration + * is encountered + */ +class InvalidJSONCNodeError extends Error { + constructor() { + super(); + this.message = "Invalid JSONC node"; + } +} + +// NOTE: If we choose to export this function, do refactor it to return a result discriminated union instead of throwing +/** + * @throws {InvalidJSONCNodeError} if the node is in an invalid configuration + * @returns The JSON string without comments and trailing commas + */ +function convertNodeToJSON(node: Node): string { + switch (node.type) { + case "string": + return JSON.stringify(node.value); + case "null": + return "null"; + case "array": + if (!node.children) { + throw new InvalidJSONCNodeError(); + } + + return `[${node.children + .map((child) => convertNodeToJSON(child)) + .join(",")}]`; + case "number": + return JSON.stringify(node.value); + case "boolean": + return JSON.stringify(node.value); + case "object": + if (!node.children) { + throw new InvalidJSONCNodeError(); + } + + return `{${node.children + .map((child) => convertNodeToJSON(child)) + .join(",")}}`; + case "property": + if (!node.children || node.children.length !== 2) { + throw new InvalidJSONCNodeError(); + } + + const [keyNode, valueNode] = node.children; + + // Use keyNode.value instead of keyNode to avoid circular references. + // Attempting to JSON.stringify(keyNode) directly would throw + // "Converting circular structure to JSON" error. + // If the valueNode configuration is wrong, this will return an error, which will propagate up + return `${JSON.stringify(keyNode.value)}:${convertNodeToJSON(valueNode)}`; + } +} + +function stripCommentsAndCommas(text: string): string { + const tree = parseTree(text, undefined, { + allowEmptyContent: true, + allowTrailingComma: true, + }); + + // If we couldn't parse the tree, return the original text + if (!tree) { + return text; + } + + // convertNodeToJSON can throw an error if the tree is invalid + try { + return convertNodeToJSON(tree); + } catch (_) { + return text; + } +} + +/** + * Removes comments and trailing commas from a JSONC string. + * This is needed because APIs like AWS Cognito expect valid JSON without comments, + * but Hoppscotch allows users to add comments to their request bodies. + * + * @param jsoncString The JSONC string with comments and/or trailing commas. + * @returns The clean JSON string without comments or trailing commas. + */ +export function stripComments(jsoncString: string): string { + return stripCommentsAndCommas(stripComments_(jsoncString) ?? jsoncString); +} diff --git a/packages/hoppscotch-cli/src/utils/mutators.ts b/packages/hoppscotch-cli/src/utils/mutators.ts new file mode 100644 index 0000000..153e66b --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/mutators.ts @@ -0,0 +1,160 @@ +import { Environment, HoppCollection, HoppRESTRequest } from "@hoppscotch/data"; +import fs from "fs/promises"; +import { entityReference } from "verzod"; +import { z } from "zod"; + +import { TestCmdCollectionOptions } from "../types/commands"; +import { error } from "../types/errors"; +import { FormDataEntry } from "../types/request"; +import { isHoppErrnoException } from "./checks"; +import { getResourceContents } from "./getters"; + +const getValidRequests = ( + collections: HoppCollection[], + collectionFilePath: string +) => { + return collections.map((collection) => { + // Validate requests using zod schema + const requestSchemaParsedResult = z + .array(entityReference(HoppRESTRequest)) + .safeParse(collection.requests); + + // Handle validation errors + if (!requestSchemaParsedResult.success) { + throw error({ + code: "MALFORMED_COLLECTION", + path: collectionFilePath, + data: "Please check the collection data.", + }); + } + + // Recursively validate requests in nested folders + if (collection.folders.length > 0) { + collection.folders = getValidRequests( + collection.folders, + collectionFilePath + ); + } + + // Return validated collection + return { + ...collection, + requests: requestSchemaParsedResult.data, + }; + }); +}; + +/** + * Parses array of FormDataEntry to FormData. + * @param values Array of FormDataEntry. + * @returns FormData with key-value pair from FormDataEntry. + */ +export const toFormData = (values: FormDataEntry[]) => { + const formData = new FormData(); + + values.forEach(({ key, value, contentType }) => { + if (contentType) { + formData.append( + key, + new Blob([value], { + type: contentType, + }), + key + ); + + return; + } + + formData.append(key, value); + }); + + return formData; +}; + +/** + * Parses provided error message to maintain hopp-error messages. + * @param e Custom error data. + * @returns Parsed error message without extra spaces. + */ +export const parseErrorMessage = (e: unknown) => { + let msg: string; + if (isHoppErrnoException(e)) { + msg = e.message.replace(e.code! + ":", "").replace("error:", ""); + } else if (typeof e === "string") { + msg = e; + } else { + msg = JSON.stringify(e); + } + return msg.replace(/\n+$|\s{2,}/g, "").trim(); +}; + +/** + * Reads a JSON file from the specified path and returns the parsed content. + * + * @param {string} path - The path to the JSON file. + * @param {boolean} fileExistsInPath - Indicates whether the file exists in the specified path. + * @returns {Promise} A Promise that resolves to the parsed JSON contents. + * @throws {Error} If the file path does not end with `.json`. + * @throws {Error} If the file does not exist in the specified path. + * @throws {Error} If an unknown error occurs while reading or parsing the file. + */ +export async function readJsonFile( + path: string, + fileExistsInPath: boolean +): Promise { + if (!path.endsWith(".json")) { + throw error({ code: "INVALID_FILE_TYPE", data: path }); + } + + if (!fileExistsInPath) { + throw error({ code: "FILE_NOT_FOUND", path }); + } + + try { + return JSON.parse((await fs.readFile(path)).toString()); + } catch (e) { + throw error({ code: "UNKNOWN_ERROR", data: e }); + } +} + +/** + * Parses collection data from a given path or ID and returns the data conforming to the latest version of the `HoppCollection` schema. + * + * @param pathOrId Collection JSON file path/ID from a workspace. + * @param {TestCmdCollectionOptions} options Supplied values for CLI flags. + * @param {string} [options.token] Personal access token to fetch workspace environments. + * @param {string} [options.server] server URL for SH instance. + * @returns {Promise} A promise that resolves to an array of HoppCollection objects. + * @throws Throws an error if the collection data is malformed. + */ +export async function parseCollectionData( + pathOrId: string, + options: TestCmdCollectionOptions +): Promise { + const { token: accessToken, server: serverUrl } = options; + + const contents = await getResourceContents({ + pathOrId, + accessToken, + serverUrl, + resourceType: "collection", + }); + + const maybeArrayOfCollections: unknown[] = Array.isArray(contents) + ? contents + : [contents]; + + const collectionSchemaParsedResult = z + .array(entityReference(HoppCollection)) + .safeParse(maybeArrayOfCollections); + + if (!collectionSchemaParsedResult.success) { + throw error({ + code: "MALFORMED_COLLECTION", + path: pathOrId, + data: "Please check the collection data.", + }); + } + + return getValidRequests(collectionSchemaParsedResult.data, pathOrId); +} diff --git a/packages/hoppscotch-cli/src/utils/pre-request.ts b/packages/hoppscotch-cli/src/utils/pre-request.ts new file mode 100644 index 0000000..8397986 --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/pre-request.ts @@ -0,0 +1,685 @@ +import { + Environment, + EnvironmentVariable, + HoppCollectionVariable, + HoppRESTRequest, + calculateHawkHeader, + generateJWTToken, + parseBodyEnvVariablesE, + parseRawKeyValueEntriesE, + parseTemplateString, + parseTemplateStringE, +} from "@hoppscotch/data"; +import { runPreRequestScript } from "@hoppscotch/js-sandbox/node"; +import { AwsV4Signer } from "aws4fetch"; +import * as A from "fp-ts/Array"; +import * as E from "fp-ts/Either"; +import * as O from "fp-ts/Option"; +import * as RA from "fp-ts/ReadonlyArray"; +import * as TE from "fp-ts/TaskEither"; +import { flow, pipe } from "fp-ts/function"; +import * as S from "fp-ts/string"; +import qs from "qs"; +import { createHoppFetchHook } from "./hopp-fetch"; + +import { EffectiveHoppRESTRequest } from "../interfaces/request"; +import { HoppCLIError, error } from "../types/errors"; +import { HoppEnvs } from "../types/request"; +import { PreRequestMetrics } from "../types/response"; +import { + DigestAuthParams, + fetchInitialDigestAuthInfo, + generateDigestAuthHeader, +} from "./auth/digest"; +import { isHoppCLIError } from "./checks"; +import { arrayFlatMap, arraySort, tupleToRecord } from "./functions/array"; +import { getEffectiveFinalMetaData, getResolvedVariables } from "./getters"; +import { stripComments } from "./jsonc"; +import { toFormData } from "./mutators"; +import { combineScriptsWithIIFE, filterValidScripts } from "@hoppscotch/js-sandbox/scripting"; + +/** + * Runs pre-request-script runner over given request which extracts set ENVs and + * applies them on current request to generate updated request. + * @param request HoppRESTRequest to be converted to EffectiveHoppRESTRequest. + * @param envs Environment variables related to request. + * @param legacySandbox Whether to use the legacy sandbox. + * @param collectionVariables Collection variables to use. + * @param inheritedPreRequestScripts Pre-request scripts inherited from parent collections. + * @returns EffectiveHoppRESTRequest that includes parsed ENV variables with in + * request OR HoppCLIError with error code and related information. + */ +export const preRequestScriptRunner = ( + request: HoppRESTRequest, + envs: HoppEnvs, + legacySandbox: boolean, + collectionVariables?: HoppCollectionVariable[], + inheritedPreRequestScripts: string[] = [] +): TE.TaskEither< + HoppCLIError, + { effectiveRequest: EffectiveHoppRESTRequest } & { updatedEnvs: HoppEnvs } +> => { + const experimentalScriptingSandbox = !legacySandbox; + const hoppFetchHook = createHoppFetchHook(); + + // Pre-request order: root → request. + const combinedScript = combineScriptsWithIIFE( + filterValidScripts([ + ...inheritedPreRequestScripts, + request.preRequestScript, + ]), + experimentalScriptingSandbox ? "experimental" : "legacy" + ); + + return pipe( + TE.of(request), + TE.chain(() => + runPreRequestScript(combinedScript, { + envs, + experimentalScriptingSandbox, + request, + cookies: null, + hoppFetchHook, + }) + ), + TE.map(({ updatedEnvs, updatedRequest }) => { + const { selected, global } = updatedEnvs; + + return { + // Keep the original updatedEnvs with separate global and selected arrays + preRequestUpdatedEnvs: updatedEnvs, + // Create Environment format for getEffectiveRESTRequest + envForEffectiveRequest: { + name: "Env", + variables: [...(selected ?? []), ...(global ?? [])], + }, + updatedRequest: updatedRequest ?? {}, + }; + }), + TE.chainW( + ({ preRequestUpdatedEnvs, envForEffectiveRequest, updatedRequest }) => { + const finalRequest = { ...request, ...updatedRequest }; + + return TE.tryCatch( + async () => { + const result = await getEffectiveRESTRequest( + finalRequest, + envForEffectiveRequest, + collectionVariables + ); + // Replace the updatedEnvs from getEffectiveRESTRequest with the one from pre-request script + // This preserves the global/selected separation + if (E.isRight(result)) { + return E.right({ + ...result.right, + updatedEnvs: preRequestUpdatedEnvs, + }); + } + return result; + }, + (reason) => error({ code: "PRE_REQUEST_SCRIPT_ERROR", data: reason }) + ); + } + ), + TE.chainEitherKW((effectiveRequest) => effectiveRequest), + TE.mapLeft((reason) => + isHoppCLIError(reason) + ? reason + : error({ + code: "PRE_REQUEST_SCRIPT_ERROR", + data: reason, + }) + ) + ); +}; + +/** + * Outputs an executable request format with environment variables applied + * + * @param request The request to source from + * @param environment The environment to apply + * + * @returns An object with extra fields defining a complete request + */ +export async function getEffectiveRESTRequest( + request: HoppRESTRequest, + environment: Environment, + collectionVariables?: HoppCollectionVariable[] +): Promise< + E.Either< + HoppCLIError, + { effectiveRequest: EffectiveHoppRESTRequest } & { updatedEnvs: HoppEnvs } + > +> { + const envVariables = environment.variables; + + const resolvedVariables = getResolvedVariables( + request.requestVariables, + envVariables, + collectionVariables + ); + + // Parsing final headers with applied ENVs. + const _effectiveFinalHeaders = getEffectiveFinalMetaData( + request.headers, + resolvedVariables + ); + if (E.isLeft(_effectiveFinalHeaders)) { + return _effectiveFinalHeaders; + } + const effectiveFinalHeaders = _effectiveFinalHeaders.right; + + // Parsing final parameters with applied ENVs. + const _effectiveFinalParams = getEffectiveFinalMetaData( + request.params, + resolvedVariables + ); + if (E.isLeft(_effectiveFinalParams)) { + return _effectiveFinalParams; + } + const effectiveFinalParams = _effectiveFinalParams.right; + + // Parsing final-body with applied ENVs. + const _effectiveFinalBody = getFinalBodyFromRequest( + request, + resolvedVariables + ); + if (E.isLeft(_effectiveFinalBody)) { + return _effectiveFinalBody; + } + + // Authentication + if (request.auth.authActive) { + // TODO: Support a better b64 implementation than btoa ? + if (request.auth.authType === "basic") { + const username = parseTemplateString( + request.auth.username, + resolvedVariables + ); + const password = parseTemplateString( + request.auth.password, + resolvedVariables + ); + + effectiveFinalHeaders.push({ + active: true, + key: "Authorization", + value: `Basic ${btoa(`${username}:${password}`)}`, + description: "", + }); + } else if (request.auth.authType === "bearer") { + effectiveFinalHeaders.push({ + active: true, + key: "Authorization", + value: `Bearer ${parseTemplateString(request.auth.token, resolvedVariables)}`, + description: "", + }); + } else if (request.auth.authType === "oauth-2") { + const { addTo } = request.auth; + + if (addTo === "HEADERS") { + effectiveFinalHeaders.push({ + active: true, + key: "Authorization", + value: `Bearer ${parseTemplateString(request.auth.grantTypeInfo.token, resolvedVariables)}`, + description: "", + }); + } else if (addTo === "QUERY_PARAMS") { + effectiveFinalParams.push({ + active: true, + key: "access_token", + value: parseTemplateString( + request.auth.grantTypeInfo.token, + resolvedVariables + ), + description: "", + }); + } + } else if (request.auth.authType === "api-key") { + const { key, value, addTo } = request.auth; + if (addTo === "HEADERS") { + effectiveFinalHeaders.push({ + active: true, + key: parseTemplateString(key, resolvedVariables), + value: parseTemplateString(value, resolvedVariables), + description: "", + }); + } else if (addTo === "QUERY_PARAMS") { + effectiveFinalParams.push({ + active: true, + key: parseTemplateString(key, resolvedVariables), + value: parseTemplateString(value, resolvedVariables), + description: "", + }); + } + } else if (request.auth.authType === "aws-signature") { + const { addTo } = request.auth; + + const currentDate = new Date(); + const amzDate = currentDate.toISOString().replace(/[:-]|\.\d{3}/g, ""); + const { method, endpoint } = request; + + const body = getFinalBodyFromRequest(request, resolvedVariables); + + const signer = new AwsV4Signer({ + method, + body: E.isRight(body) ? body.right?.toString() : undefined, + datetime: amzDate, + signQuery: addTo === "QUERY_PARAMS", + accessKeyId: parseTemplateString( + request.auth.accessKey, + resolvedVariables + ), + secretAccessKey: parseTemplateString( + request.auth.secretKey, + resolvedVariables + ), + region: + parseTemplateString(request.auth.region, resolvedVariables) ?? + "us-east-1", + service: parseTemplateString( + request.auth.serviceName, + resolvedVariables + ), + url: parseTemplateString(endpoint, resolvedVariables), + sessionToken: + request.auth.serviceToken && + parseTemplateString(request.auth.serviceToken, resolvedVariables), + }); + + const sign = await signer.sign(); + + if (addTo === "HEADERS") { + sign.headers.forEach((value, key) => { + effectiveFinalHeaders.push({ + active: true, + key, + value, + description: "", + }); + }); + } else if (addTo === "QUERY_PARAMS") { + sign.url.searchParams.forEach((value, key) => { + effectiveFinalParams.push({ + active: true, + key, + value, + description: "", + }); + }); + } + } else if (request.auth.authType === "digest") { + const { method, endpoint } = request as HoppRESTRequest; + + // Step 1: Fetch the initial auth info (nonce, realm, etc.) + const authInfo = await fetchInitialDigestAuthInfo( + parseTemplateString(endpoint, resolvedVariables), + method, + request.auth.disableRetry + ); + + // Step 2: Set up the parameters for the digest authentication header + const digestAuthParams: DigestAuthParams = { + username: parseTemplateString(request.auth.username, resolvedVariables), + password: parseTemplateString(request.auth.password, resolvedVariables), + realm: request.auth.realm + ? parseTemplateString(request.auth.realm, resolvedVariables) + : authInfo.realm, + nonce: request.auth.nonce + ? parseTemplateString(authInfo.nonce, resolvedVariables) + : authInfo.nonce, + endpoint: parseTemplateString(endpoint, resolvedVariables), + method, + algorithm: request.auth.algorithm ?? authInfo.algorithm, + qop: request.auth.qop + ? parseTemplateString(request.auth.qop, resolvedVariables) + : authInfo.qop, + opaque: request.auth.opaque + ? parseTemplateString(request.auth.opaque, resolvedVariables) + : authInfo.opaque, + reqBody: typeof request.body.body === "string" ? request.body.body : "", + }; + + // Step 3: Generate the Authorization header + const authHeaderValue = await generateDigestAuthHeader(digestAuthParams); + + effectiveFinalHeaders.push({ + active: true, + key: "Authorization", + value: authHeaderValue, + description: "", + }); + } else if (request.auth.authType === "hawk") { + const { method, endpoint } = request; + + const hawkHeader = await calculateHawkHeader({ + url: parseTemplateString(endpoint, resolvedVariables), // URL + method: method, // HTTP method + id: parseTemplateString(request.auth.authId, resolvedVariables), + key: parseTemplateString(request.auth.authKey, resolvedVariables), + algorithm: request.auth.algorithm, + + // advanced parameters (optional) + includePayloadHash: request.auth.includePayloadHash, + nonce: request.auth.nonce + ? parseTemplateString(request.auth.nonce, resolvedVariables) + : undefined, + ext: request.auth.ext + ? parseTemplateString(request.auth.ext, resolvedVariables) + : undefined, + app: request.auth.app + ? parseTemplateString(request.auth.app, resolvedVariables) + : undefined, + dlg: request.auth.dlg + ? parseTemplateString(request.auth.dlg, resolvedVariables) + : undefined, + timestamp: request.auth.timestamp + ? parseInt( + parseTemplateString(request.auth.timestamp, resolvedVariables), + 10 + ) + : undefined, + }); + + effectiveFinalHeaders.push({ + active: true, + key: "Authorization", + value: hawkHeader, + description: "", + }); + } else if (request.auth.authType === "jwt") { + const { addTo } = request.auth; + + // Generate JWT token + const token = await generateJWTToken({ + algorithm: request.auth.algorithm || "HS256", + secret: parseTemplateString(request.auth.secret, resolvedVariables), + privateKey: parseTemplateString( + request.auth.privateKey, + resolvedVariables + ), + payload: parseTemplateString(request.auth.payload, resolvedVariables), + jwtHeaders: parseTemplateString( + request.auth.jwtHeaders, + resolvedVariables + ), + isSecretBase64Encoded: request.auth.isSecretBase64Encoded, + }); + + if (token) { + if (addTo === "HEADERS") { + const headerPrefix = + parseTemplateString(request.auth.headerPrefix, resolvedVariables) || + "Bearer "; + + effectiveFinalHeaders.push({ + active: true, + key: "Authorization", + value: `${headerPrefix}${token}`, + description: "", + }); + } else if (addTo === "QUERY_PARAMS") { + const paramName = + parseTemplateString(request.auth.paramName, resolvedVariables) || + "token"; + + effectiveFinalParams.push({ + active: true, + key: paramName, + value: token, + description: "", + }); + } + } + } + } + + const effectiveFinalBody = _effectiveFinalBody.right; + + if ( + request.body.contentType && + !effectiveFinalHeaders.some( + ({ key }) => key.toLowerCase() === "content-type" + ) + ) { + effectiveFinalHeaders.push({ + active: true, + key: "Content-Type", + value: request.body.contentType, + description: "", + }); + } + + // Parsing final-endpoint with applied ENVs (environment + request variables). + const _effectiveFinalURL = parseTemplateStringE( + request.endpoint, + resolvedVariables + ); + if (E.isLeft(_effectiveFinalURL)) { + return E.left( + error({ + code: "PARSING_ERROR", + data: `${request.endpoint} (${_effectiveFinalURL.left})`, + }) + ); + } + const effectiveFinalURL = _effectiveFinalURL.right; + + // Secret environment variables referenced in the request endpoint should be masked + let effectiveFinalDisplayURL; + if (envVariables.some(({ secret }) => secret)) { + const _effectiveFinalDisplayURL = parseTemplateStringE( + request.endpoint, + resolvedVariables, + true + ); + + if (E.isRight(_effectiveFinalDisplayURL)) { + effectiveFinalDisplayURL = _effectiveFinalDisplayURL.right; + } + } + + return E.right({ + effectiveRequest: { + ...request, + effectiveFinalURL, + effectiveFinalDisplayURL, + effectiveFinalHeaders, + effectiveFinalParams, + effectiveFinalBody, + }, + updatedEnvs: { global: [], selected: resolvedVariables }, + }); +} + +/** + * Replaces template variables in request's body from the given set of ENVs, + * to generate final request body without any template variables. + * @param request Provides request's body, on which ENVs has to be applied. + * @param resolvedVariables Provides set of key-value pairs (request + environment variables), + * used to parse-out template variables. + * @returns Final request body without any template variables as value. + * Or, HoppCLIError in case of error while parsing. + */ +function getFinalBodyFromRequest( + request: HoppRESTRequest, + resolvedVariables: EnvironmentVariable[] +): E.Either { + if (request.body.contentType === null) { + return E.right(null); + } + + if (request.body.contentType === "application/x-www-form-urlencoded") { + return pipe( + request.body.body, + parseRawKeyValueEntriesE, + E.map( + flow( + RA.toArray, + + /** + * Filtering out empty keys and non-active pairs. + */ + A.filter(({ active, key }) => active && !S.isEmpty(key)), + + /** + * Mapping each key-value to template-string-parser with either on array, + * which will be resolved in further steps. + */ + A.map(({ key, value }) => [ + parseTemplateStringE(key, resolvedVariables), + parseTemplateStringE(value, resolvedVariables), + ]), + + /** + * Filtering and mapping only right-eithers for each key-value as [string, string]. + */ + A.filterMap(([key, value]) => + E.isRight(key) && E.isRight(value) + ? O.some([key.right, value.right] as [string, string]) + : O.none + ), + tupleToRecord, + qs.stringify + ) + ), + E.mapLeft((e) => error({ code: "PARSING_ERROR", data: e.message })) + ); + } + + if (request.body.contentType === "multipart/form-data") { + return pipe( + request.body.body, + A.filter((x) => x.key !== "" && x.active), // Remove empty keys + + // Sort files down + arraySort((a, b) => { + if (a.isFile) return 1; + if (b.isFile) return -1; + return 0; + }), + + // FormData allows only a single blob in an entry, + // we split array blobs into separate entries (FormData will then join them together during exec) + arrayFlatMap((x) => + x.isFile + ? (x.value as (Blob | null)[]).map((v: Blob | null) => ({ + key: parseTemplateString(x.key, resolvedVariables), + value: v as string | Blob, + contentType: x.contentType, + })) + : [ + { + key: parseTemplateString(x.key, resolvedVariables), + value: parseTemplateString(x.value, resolvedVariables), + contentType: x.contentType, + }, + ] + ), + toFormData, + E.right + ); + } + + if (request.body.contentType === "application/octet-stream") { + const body = request.body.body; + + if (!body) { + return E.right(null); + } + + if (!(body instanceof File)) { + return E.right(null); + } + + return E.right(body); + } + + // For JSON content types, parse the string body into a JavaScript object + // so axios can properly serialize it. This includes standard application/json + // and vendor-specific JSON media types (for example those with a +json suffix + // or subtypes whose names end with "json" or "-json"). + if (request.body.contentType) { + const mimeType = request.body.contentType + .split(";")[0] + .trim() + .toLowerCase(); + + if ( + mimeType === "application/json" || + mimeType.endsWith("+json") || + mimeType.endsWith("/json") || + mimeType.endsWith("-json") + ) { + const envResult = parseBodyEnvVariablesE( + request.body.body, + resolvedVariables + ); + + if (E.isLeft(envResult)) { + return E.left( + error({ + code: "PARSING_ERROR", + data: `${request.body.body} (${envResult.left})`, + }) + ); + } + + const bodyString = envResult.right; + + // If the body string is empty or null, return null + if (!bodyString || S.isEmpty(bodyString.trim())) { + return E.right(null); + } + + // Strip comments and trailing commas from JSONC + // This ensures collections with comments work the same in CLI as in desktop app + const cleanedBody = stripComments(bodyString); + + // Try to parse the JSON body + try { + const parsedBody = JSON.parse(cleanedBody); + return E.right(JSON.stringify(parsedBody)); + } catch (err) { + // If parsing fails after stripping comments, return error to provide + // immediate feedback instead of sending invalid JSON to the API. + // Use original template string to avoid leaking secrets from env vars. + return E.left( + error({ + code: "PARSING_ERROR", + data: `${request.body.body} (Invalid JSON in request body: ${err instanceof Error ? err.message : String(err)})`, + }) + ); + } + } + } + return pipe( + parseBodyEnvVariablesE(request.body.body, resolvedVariables), + E.mapLeft((e) => + error({ + code: "PARSING_ERROR", + data: `${request.body.body} (${e})`, + }) + ) + ); +} + +/** + * Get pre-request-metrics (stats + duration) object based on existence of + * PRE_REQUEST_ERROR code in given hopp-error list. + * @param errors List of errors to check for PRE_REQUEST_ERROR code. + * @param duration Time taken (in seconds) to execute the pre-request-script. + * @returns Object containing details of pre-request-script's execution stats + * i.e., failed/passed data and duration. + */ +export const getPreRequestMetrics = ( + errors: HoppCLIError[], + duration: number +): PreRequestMetrics => + pipe( + errors, + A.some(({ code }) => code === "PRE_REQUEST_SCRIPT_ERROR"), + (hasPreReqErrors) => + hasPreReqErrors ? { failed: 1, passed: 0 } : { failed: 0, passed: 1 }, + (scripts) => { scripts, duration } + ); diff --git a/packages/hoppscotch-cli/src/utils/reporters/junit.ts b/packages/hoppscotch-cli/src/utils/reporters/junit.ts new file mode 100644 index 0000000..a47fdf5 --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/reporters/junit.ts @@ -0,0 +1,178 @@ +import { info, log } from "console"; +import fs from "fs"; +import path from "path"; + +import { create } from "xmlbuilder2"; +import { XMLBuilder } from "xmlbuilder2/lib/interfaces"; +import { TestReport } from "../../interfaces/response"; +import { error, HoppCLIError } from "../../types/errors"; +import { RequestReport } from "../../types/request"; +import { exceptionColors } from "../getters"; + +type BuildJUnitReportArgs = Omit & { + duration: RequestReport["duration"]["test"]; +}; + +type BuildJUnitReportResult = { + failedRequestTestCases: number; + erroredRequestTestCases: number; +}; + +type GenerateJUnitReportExportArgs = { + totalTestCases: number; + totalFailedTestCases: number; + totalErroredTestCases: number; + testDuration: number; + reporterJUnitExportPath: string; +}; + +const { INFO, SUCCESS } = exceptionColors; + +// Create the root XML element +const rootEl = create({ version: "1.0", encoding: "UTF-8" }).ele("testsuites"); + +/** + * Builds a JUnit report based on the provided request report. + * Creates a test suite at the request level populating the XML document structure. + * + * @param {BuildJUnitReportArgs} options - The options to build the JUnit report. + * @param {string} options.path - The path of the request. + * @param {TestReport[]} options.tests - The test suites for the request. + * @param {HoppCLIError[]} options.errors - The errors encountered during the request. + * @param {number} options.duration - Time taken to execute the test suite. + * @returns {BuildJUnitReportResult} An object containing the number of failed and errored test cases. + */ +export const buildJUnitReport = ({ + path, + tests: testSuites, + errors: requestTestSuiteErrors, + duration: testSuiteDuration, +}: BuildJUnitReportArgs): BuildJUnitReportResult => { + let requestTestSuiteError: XMLBuilder | null = null; + + // Create a test suite at the request level + const requestTestSuite = rootEl.ele("testsuite", { + name: path, + time: testSuiteDuration, + timestamp: new Date().toISOString(), + }); + + if (requestTestSuiteErrors.length > 0) { + requestTestSuiteError = requestTestSuite.ele("system-err"); + } + + let systemErrContent = ""; + + requestTestSuiteErrors.forEach((error) => { + let compiledError = error.code; + + if ("data" in error) { + compiledError += ` - ${error.data}`; + } + + // Append each error message with a newline for separation + systemErrContent += `\n${" ".repeat(6)}${compiledError}`; + }); + + // There'll be a single `CDATA` element compiling all the error messages + if (requestTestSuiteError) { + requestTestSuiteError.dat(systemErrContent); + } + + let requestTestCases = 0; + let erroredRequestTestCases = 0; + let failedRequestTestCases = 0; + + // Test suites correspond to `pw.test()` invocations + testSuites.forEach(({ descriptor, expectResults }) => { + requestTestCases += expectResults.length; + + expectResults.forEach(({ status, message }) => { + const testCase = requestTestSuite + .ele("testcase", { + name: `${descriptor} - ${message}`, + }) + .att("classname", path); + + if (status === "fail") { + failedRequestTestCases += 1; + + testCase + .ele("failure") + .att("type", "AssertionFailure") + .att("message", message); + } else if (status === "error") { + erroredRequestTestCases += 1; + + testCase.ele("error").att("message", message); + } + }); + }); + + requestTestSuite.att("tests", requestTestCases.toString()); + requestTestSuite.att("failures", failedRequestTestCases.toString()); + requestTestSuite.att("errors", erroredRequestTestCases.toString()); + + return { + failedRequestTestCases, + erroredRequestTestCases, + }; +}; + +/** + * Generates the built JUnit report export at the specified path. + * + * @param {GenerateJUnitReportExportArgs} options - The options to generate the JUnit report export. + * @param {number} options.totalTestCases - The total number of test cases. + * @param {number} options.totalFailedTestCases - The total number of failed test cases. + * @param {number} options.totalErroredTestCases - The total number of errored test cases. + * @param {number} options.testDuration - The total duration of test cases. + * @param {string} options.reporterJUnitExportPath - The path to export the JUnit report. + * @returns {void} + */ +export const generateJUnitReportExport = ({ + totalTestCases, + totalFailedTestCases, + totalErroredTestCases, + testDuration, + reporterJUnitExportPath, +}: GenerateJUnitReportExportArgs) => { + rootEl + .att("tests", totalTestCases.toString()) + .att("failures", totalFailedTestCases.toString()) + .att("errors", totalErroredTestCases.toString()) + .att("time", testDuration.toString()); + + // Convert the XML structure to a string + const xmlDocString = rootEl.end({ prettyPrint: true }); + + // Write the XML string to the specified path + try { + const resolvedExportPath = path.resolve(reporterJUnitExportPath); + + if (fs.existsSync(resolvedExportPath)) { + info( + INFO(`\nOverwriting the pre-existing path: ${reporterJUnitExportPath}.`) + ); + } + + fs.mkdirSync(path.dirname(resolvedExportPath), { + recursive: true, + }); + + fs.writeFileSync(resolvedExportPath, xmlDocString); + + log( + SUCCESS( + `\nSuccessfully exported the JUnit report to: ${reporterJUnitExportPath}.` + ) + ); + } catch (err) { + const data = err instanceof Error ? err.message : null; + throw error({ + code: "REPORT_EXPORT_FAILED", + data, + path: reporterJUnitExportPath, + }); + } +}; diff --git a/packages/hoppscotch-cli/src/utils/request.ts b/packages/hoppscotch-cli/src/utils/request.ts new file mode 100644 index 0000000..3c42dd3 --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/request.ts @@ -0,0 +1,490 @@ +import { + Environment, + HoppCollection, + HoppRESTRequest, + RESTReqSchemaVersion, +} from "@hoppscotch/data"; +import axios, { Method } from "axios"; +import * as A from "fp-ts/Array"; +import * as E from "fp-ts/Either"; +import * as T from "fp-ts/Task"; +import * as TE from "fp-ts/TaskEither"; +import { pipe } from "fp-ts/function"; +import * as S from "fp-ts/string"; +import { hrtime } from "process"; +import { URL } from "url"; +import { EffectiveHoppRESTRequest, RequestConfig } from "../interfaces/request"; +import { RequestRunnerResponse } from "../interfaces/response"; +import { HoppCLIError, error } from "../types/errors"; +import { + HoppEnvs, + ProcessRequestParams, + RequestReport, +} from "../types/request"; +import { RequestMetrics } from "../types/response"; +import { responseErrors } from "./constants"; +import { + printPreRequestRunner, + printRequestRunner, + printTestRunner, +} from "./display"; +import { getDurationInSeconds, getMetaDataPairs } from "./getters"; +import { preRequestScriptRunner } from "./pre-request"; +import { getTestScriptParams, hasAllTestsPassed, testRunner } from "./test"; + +/** + * Processes given variable, which includes checking for secret variables + * and getting value from system environment + * @param variable Variable to be processed + * @returns Updated variable with value from system environment + */ +const processVariables = (variable: Environment["variables"][number]) => { + if (variable.secret) { + return { + ...variable, + currentValue: + "currentValue" in variable && variable.currentValue !== "" + ? variable.currentValue + : process.env[variable.key] || variable.initialValue, + }; + } + return variable; +}; + +/** + * Processes given envs, which includes processing each variable in global + * and selected envs + * @param envs Global + selected envs used by requests with in collection + * @returns Processed envs with each variable processed + */ +const processEnvs = (envs: Partial) => { + // This can take the shape `{ global: undefined, selected: undefined }` when no environment is supplied + const processedEnvs = { + global: envs.global?.map(processVariables) ?? [], + selected: envs.selected?.map(processVariables) ?? [], + }; + + return processedEnvs; +}; + +/** + * Transforms given request data to request-config used by request-runner to + * perform HTTP request. + * @param req Effective request data with parsed ENVs. + * @returns Request config with data related to HTTP request. + */ +export const createRequest = (req: EffectiveHoppRESTRequest): RequestConfig => { + const config: RequestConfig = { + displayUrl: req.effectiveFinalDisplayURL, + }; + + const { finalBody, finalEndpoint, finalHeaders, finalParams } = getRequest; + + const reqParams = finalParams(req); + const reqHeaders = finalHeaders(req); + + config.url = finalEndpoint(req); + config.method = req.method as Method; + config.params = getMetaDataPairs(reqParams); + config.headers = getMetaDataPairs(reqHeaders); + + config.data = finalBody(req); + + return config; +}; + +/** + * Performs http request using axios with given requestConfig axios + * parameters. + * @param requestConfig The axios request config. + * @returns If successfully ran, we get runner-response including HTTP response data. + * Else, HoppCLIError with appropriate error code & data. + */ +export const requestRunner = + ( + requestConfig: RequestConfig + ): TE.TaskEither => + async () => { + const start = hrtime(); + + try { + // NOTE: Temporary parsing check for request endpoint. + requestConfig.url = new URL(requestConfig.url ?? "").toString(); + + const baseResponse = await axios(requestConfig); + const { config } = baseResponse; + + const end = hrtime(start); + const duration = getDurationInSeconds(end); + const responseTime = duration * 1000; // Convert seconds to milliseconds + + // Transform axios headers to required format + const transformedHeaders: { key: string; value: string }[] = []; + if (baseResponse.headers) { + for (const [key, value] of Object.entries(baseResponse.headers)) { + if (value !== undefined) { + transformedHeaders.push({ + key, + value: Array.isArray(value) ? value.join(", ") : String(value), + }); + } + } + } + + const runnerResponse: RequestRunnerResponse = { + endpoint: getRequest.endpoint(config.url), + method: getRequest.method(config.method), + body: baseResponse.data, + responseTime, + duration: duration, + status: baseResponse.status, + statusText: baseResponse.statusText, + headers: transformedHeaders, + }; + + return E.right(runnerResponse); + } catch (e) { + const runnerResponse: RequestRunnerResponse = { + endpoint: "", + method: "GET", + body: {}, + statusText: responseErrors[400], + status: 400, + headers: [], + duration: 0, + responseTime: 0, + }; + + if (axios.isAxiosError(e)) { + runnerResponse.endpoint = e.config?.url ?? ""; + + if (e.response) { + const { data, status, statusText, headers } = e.response; + runnerResponse.body = data; + runnerResponse.statusText = statusText; + runnerResponse.status = status; + + // Transform axios headers to required format + const transformedHeaders: { key: string; value: string }[] = []; + if (headers) { + for (const [key, value] of Object.entries(headers)) { + if (value !== undefined) { + transformedHeaders.push({ + key, + value: Array.isArray(value) + ? value.join(", ") + : String(value), + }); + } + } + } + runnerResponse.headers = transformedHeaders; + } else if (e.request) { + return E.left(error({ code: "REQUEST_ERROR", data: E.toError(e) })); + } + + const end = hrtime(start); + const duration = getDurationInSeconds(end); + runnerResponse.duration = duration; + + return E.right(runnerResponse); + } + + return E.left(error({ code: "REQUEST_ERROR", data: E.toError(e) })); + } + }; + +/** + * Getter object methods for request-runner. + */ +const getRequest = { + method: (value: string | undefined) => + value ? (value.toUpperCase() as Method) : "GET", + + endpoint: (value: string | undefined): string => (value ? value : ""), + + finalEndpoint: (req: EffectiveHoppRESTRequest): string => + S.isEmpty(req.effectiveFinalURL) ? req.endpoint : req.effectiveFinalURL, + + finalHeaders: (req: EffectiveHoppRESTRequest) => + A.isNonEmpty(req.effectiveFinalHeaders) + ? req.effectiveFinalHeaders + : req.headers, + + finalParams: (req: EffectiveHoppRESTRequest) => + A.isNonEmpty(req.effectiveFinalParams) + ? req.effectiveFinalParams + : req.params, + + finalBody: (req: EffectiveHoppRESTRequest) => + req.effectiveFinalBody ? req.effectiveFinalBody : req.body.body, +}; + +/** + * Processes given request, which includes executing pre-request-script, + * running request & executing test-script. + * @param request Request to be processed. + * @param envs Global + selected envs used by requests with in collection. + * @returns Updated envs and current request's report. + */ +export const processRequest = + ( + params: ProcessRequestParams + ): T.Task<{ envs: HoppEnvs; report: RequestReport }> => + async () => { + const { + envs, + path, + request, + delay, + legacySandbox, + collectionVariables, + inheritedPreRequestScripts = [], + inheritedTestScripts = [], + } = params; + + // Initialising updatedEnvs with given parameter envs, will eventually get updated. + const result = { + envs: envs, + report: {}, + }; + + // Initial value for current request's report with default values for properties. + const report: RequestReport = { + path: path, + tests: [], + errors: [], + result: true, + duration: { test: 0, request: 0, preRequest: 0 }, + }; + + // Initial value for effective-request with default values for properties. + let effectiveRequest = { + ...request, + effectiveFinalBody: null, + effectiveFinalHeaders: [], + effectiveFinalParams: [], + effectiveFinalURL: "", + }; + + // Fetch values for secret environment variables from system environment + const processedEnvs = processEnvs(envs); + + // Default envs to the pre-script state so downstream consumers + // (test-runner, effectiveRequest builder) receive a well-shaped + // HoppEnvs even if the pre-request script fails. + let updatedEnvs: HoppEnvs = processedEnvs; + + const preRequestRes = await preRequestScriptRunner( + request, + processedEnvs, + legacySandbox ?? false, + collectionVariables, + inheritedPreRequestScripts + )(); + if (E.isLeft(preRequestRes)) { + printPreRequestRunner.fail(); + + // Updating report for errors & current result + report.errors.push(preRequestRes.left); + + // Ensure, the CLI fails with a non-zero exit code if there are any errors + report.result = false; + } else { + // Updating effective-request and consuming updated envs after pre-request script execution + ({ effectiveRequest, updatedEnvs } = preRequestRes.right); + } + + // Creating request-config for request-runner. + const requestConfig = createRequest(effectiveRequest); + + printRequestRunner.start(requestConfig); + + // Default value for request-runner's response. + let _requestRunnerRes: RequestRunnerResponse = { + endpoint: "", + method: "GET", + headers: [], + status: 400, + statusText: "", + responseTime: 0, + body: Object(null), + duration: 0, + }; + // Executing request-runner. + const requestRunnerRes = await delayPromiseFunction< + E.Either + >(requestRunner(requestConfig), delay); + if (E.isLeft(requestRunnerRes)) { + // Updating report for errors & current result + report.errors.push(requestRunnerRes.left); + + // Ensure, the CLI fails with a non-zero exit code if there are any errors + report.result = false; + + printRequestRunner.fail(); + } else { + _requestRunnerRes = requestRunnerRes.right; + report.duration.request = _requestRunnerRes.duration; + printRequestRunner.success(_requestRunnerRes); + } + + const testScriptParams = getTestScriptParams( + _requestRunnerRes, + effectiveRequest, + updatedEnvs, + legacySandbox ?? false, + inheritedTestScripts + ); + + // Executing test-runner. + const testRunnerRes = await testRunner(testScriptParams)(); + if (E.isLeft(testRunnerRes)) { + printTestRunner.fail(); + + // Updating report with current errors & result. + report.errors.push(testRunnerRes.left); + + // Ensure, the CLI fails with a non-zero exit code if there are any errors + report.result = false; + } else { + const { envs, testsReport, duration } = testRunnerRes.right; + const _allTestsPassed = hasAllTestsPassed(testsReport); + + // Check if any tests have uncaught runtime errors (e.g., ReferenceError, TypeError) + // Don't include validation errors (they're reported as individual testcases) + const testScriptErrors = testsReport.flatMap((testReport) => + testReport.expectResults + .filter( + (result) => + result.status === "error" && + /^(ReferenceError|TypeError|SyntaxError|RangeError|URIError|EvalError|AggregateError|InternalError|Error):/.test( + result.message + ) + ) + .map((result) => result.message) + ); + + // If there are runtime errors, add them to report.errors + if (testScriptErrors.length > 0) { + const errorMessages = testScriptErrors.join("; "); + + report.errors.push( + error({ + code: "TEST_SCRIPT_ERROR", + data: errorMessages, + }) + ); + + report.result = false; + } + + // Updating report with current tests, result and duration. + report.tests = testsReport; + report.result = report.result && _allTestsPassed; + report.duration.test = duration; + + // Updating resulting envs from test-runner. + result.envs = envs; + + // Printing tests-report, when test-runner executes successfully. + printTestRunner.success(testsReport, duration); + } + + result.report = report; + + return result; + }; + +/** + * Generates new request without any missing/invalid data using + * current request object. + * @param request Hopp rest request to be processed. + * @returns Updated request object free of invalid/missing data. + */ +export const preProcessRequest = ( + request: HoppRESTRequest, + collection: HoppCollection +): HoppRESTRequest => { + const tempRequest = Object.assign({}, request); + const { headers: parentHeaders, auth: parentAuth } = collection; + + if (!tempRequest.v) { + tempRequest.v = RESTReqSchemaVersion; + } + if (!tempRequest.name) { + tempRequest.name = "Untitled Request"; + } + if (!tempRequest.method) { + tempRequest.method = "GET"; + } + if (!tempRequest.endpoint) { + tempRequest.endpoint = ""; + } + if (!tempRequest.params) { + tempRequest.params = []; + } + + if (parentHeaders?.length) { + // Filter out header entries present in the parent (folder/collection) under the same name + // This ensures the child headers take precedence over the parent headers + const filteredEntries = parentHeaders.filter((parentHeaderEntries) => { + return !tempRequest.headers.some( + (reqHeaderEntries) => reqHeaderEntries.key === parentHeaderEntries.key + ); + }); + tempRequest.headers.push(...filteredEntries); + } else if (!tempRequest.headers) { + tempRequest.headers = []; + } + + if (!tempRequest.preRequestScript) { + tempRequest.preRequestScript = ""; + } + if (!tempRequest.testScript) { + tempRequest.testScript = ""; + } + + if (tempRequest.auth?.authType === "inherit") { + tempRequest.auth = parentAuth; + } else if (!tempRequest.auth) { + tempRequest.auth = { authActive: false, authType: "none" }; + } + + if (!tempRequest.body) { + tempRequest.body = { contentType: null, body: null }; + } + return tempRequest; +}; + +/** + * Get request-metrics object (stats+duration) based on existence of REQUEST_ERROR code + * in hopp-errors list. + * @param errors List of errors to check for REQUEST_ERROR. + * @param duration Time taken (in seconds) to execute the request. + * @returns Object containing details of request's execution stats i.e., failed/passed + * data and duration. + */ +export const getRequestMetrics = ( + errors: HoppCLIError[], + duration: number +): RequestMetrics => + pipe( + errors, + A.some(({ code }) => code === "REQUEST_ERROR"), + (hasReqErrors) => + hasReqErrors ? { failed: 1, passed: 0 } : { failed: 0, passed: 1 }, + (requests) => { requests, duration } + ); + +/** + * A function to execute promises with specific delay in milliseconds. + * @param func Function with promise with return type T. + * @param delay TIme in milliseconds to delay function. + * @returns Promise of type same as func. + */ +export const delayPromiseFunction = ( + func: () => Promise, + delay: number +): Promise => + new Promise((resolve) => setTimeout(() => resolve(func()), delay)); diff --git a/packages/hoppscotch-cli/src/utils/test.ts b/packages/hoppscotch-cli/src/utils/test.ts new file mode 100644 index 0000000..33da6fd --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/test.ts @@ -0,0 +1,268 @@ +import { HoppRESTRequest } from "@hoppscotch/data"; +import { TestDescriptor } from "@hoppscotch/js-sandbox"; +import { runTestScript } from "@hoppscotch/js-sandbox/node"; +import * as A from "fp-ts/Array"; +import * as RA from "fp-ts/ReadonlyArray"; +import * as T from "fp-ts/Task"; +import * as TE from "fp-ts/TaskEither"; +import { flow, pipe } from "fp-ts/function"; +import { hrtime } from "process"; + +import { + RequestRunnerResponse, + TestReport, + TestScriptParams, +} from "../interfaces/response"; +import { HoppCLIError, error } from "../types/errors"; +import { HoppEnvs } from "../types/request"; +import { ExpectResult, TestMetrics, TestRunnerRes } from "../types/response"; +import { getDurationInSeconds } from "./getters"; +import { createHoppFetchHook } from "./hopp-fetch"; +import { combineScriptsWithIIFE, filterValidScripts } from "@hoppscotch/js-sandbox/scripting"; + +/** + * Executes test script and runs testDescriptorParser to generate test-report using + * expected-results, test-status & test-descriptor. + * @param testScriptData Parameters related to test-script function. + * @returns If executes successfully, we get TestRunnerRes(updated ENVs, test-reports, duration). + * Else, HoppCLIError with appropriate code & data. + */ +export const testRunner = ( + testScriptData: TestScriptParams +): TE.TaskEither => + pipe( + /** + * Executing test-script. + */ + TE.Do, + TE.bind("start", () => TE.of(hrtime())), + TE.bind("test_response", () => + pipe( + TE.of(testScriptData), + TE.chain( + ({ + request, + response, + envs, + legacySandbox, + inheritedTestScripts = [], + }) => { + const { status, statusText, headers, responseTime, body } = + response; + + const effectiveResponse = { + status, + statusText, + headers, + responseTime, + body, + }; + + const experimentalScriptingSandbox = !legacySandbox; + const hoppFetchHook = createHoppFetchHook(); + + // Test order: request → root (reverse of pre-request). + const combinedScript = combineScriptsWithIIFE( + filterValidScripts([ + request.testScript, + ...inheritedTestScripts.slice().reverse(), + ]), + experimentalScriptingSandbox ? "experimental" : "legacy" + ); + + return runTestScript(combinedScript, { + envs, + request, + response: effectiveResponse, + experimentalScriptingSandbox, + hoppFetchHook, + }); + } + ) + ) + ), + + /** + * Recursively parsing test-results using test-descriptor-parser + * to generate test-reports. + */ + TE.chainTaskK(({ test_response: { tests, envs }, start }) => + pipe( + tests, + A.map(testDescriptorParser), + T.sequenceArray, + T.map( + flow( + RA.flatten, + RA.toArray, + (testsReport) => + { + envs, + testsReport, + duration: pipe(start, hrtime, getDurationInSeconds), + } + ) + ) + ) + ), + TE.mapLeft((e) => + error({ + code: "TEST_SCRIPT_ERROR", + data: e, + }) + ) + ); +/** + * Recursive function to parse test-descriptor from nested-children and + * generate tests-report. + * @param testDescriptor Object with details of test-descriptor. + * @returns Flattened array of TestReport parsed from TestDescriptor. + */ +export const testDescriptorParser = ( + testDescriptor: TestDescriptor +): T.Task => + pipe( + /** + * Generate single TestReport from given testDescriptor. + * Skip "root" descriptor to avoid showing synthetic top-level test. + */ + testDescriptor, + ({ expectResults, descriptor }) => + A.isNonEmpty(expectResults) && descriptor !== "root" + ? pipe( + expectResults, + A.reduce({ failed: 0, passed: 0 }, (prev, { status }) => + /** + * Incrementing number of passed test-cases if status is "pass", + * else, incrementing number of failed test-cases. + */ + status === "pass" + ? { failed: prev.failed, passed: prev.passed + 1 } + : { failed: prev.failed + 1, passed: prev.passed } + ), + ({ failed, passed }) => + { + failed, + passed, + descriptor, + expectResults, + }, + Array.of + ) + : [], + T.of, + + /** + * Recursive call to testDescriptorParser on testDescriptor's children. + * The result is concated with previous testReport. + */ + T.chain((testReport) => + pipe( + testDescriptor.children, + A.map(testDescriptorParser), + T.sequenceArray, + T.map(flow(RA.flatten, RA.toArray, A.concat(testReport))) + ) + ) + ); + +/** + * Extracts parameter object from request-runner's response, request and envs + * for test-runner. + * @param reqRunnerRes Provides response data. + * @param request Provides test-script data. + * @param envs Current ENVs state with-in collections-runner. + * @returns Object to be passed as parameter for test-runner + */ +export const getTestScriptParams = ( + reqRunnerRes: RequestRunnerResponse, + request: HoppRESTRequest, + envs: HoppEnvs, + legacySandbox: boolean, + inheritedTestScripts: string[] = [] +) => { + const testScriptParams: TestScriptParams = { + request, + response: { + body: reqRunnerRes.body, + status: reqRunnerRes.status, + statusText: reqRunnerRes.statusText, + responseTime: reqRunnerRes.responseTime, + headers: reqRunnerRes.headers, + }, + envs, + legacySandbox, + inheritedTestScripts, + }; + return testScriptParams; +}; + +/** + * Combines quantitative details (test-cases passed/failed) of each test-report + * to generate TestMetrics object with total test-cases & total test-suites. + * @param testsReport Contains details of each test-report (failed/passed test-cases). + * @param testDuration Time taken (in seconds) to execute the test-script. + * @param errors List of HoppCLIErrors to check for TEST_SCRIPT_ERROR code. + * @returns Object containing details of total test-cases passed/failed and + * total test-suites passed/failed. + */ +export const getTestMetrics = ( + testsReport: TestReport[], + testDuration: number, + errors: HoppCLIError[] +): TestMetrics => + testsReport.reduce( + ({ testSuites, tests, duration, scripts }, testReport) => ({ + tests: { + failed: tests.failed + testReport.failed, + passed: tests.passed + testReport.passed, + }, + testSuites: { + failed: testSuites.failed + (testReport.failed > 0 ? 1 : 0), + passed: testSuites.passed + (testReport.failed === 0 ? 1 : 0), + }, + scripts: scripts, + duration: duration, + }), + { + tests: { failed: 0, passed: 0 }, + testSuites: { failed: 0, passed: 0 }, + duration: testDuration, + scripts: errors.some(({ code }) => code === "TEST_SCRIPT_ERROR") + ? { failed: 1, passed: 0 } + : { failed: 0, passed: 1 }, + } + ); + +/** + * Filters tests-report containing atleast one or more failed test-cases. + * @param testsReport Provides "failed" test-cases data. + * @returns Tests report with one or more test-cases failed. + */ +export const getFailedTestsReport = (testsReport: TestReport[]) => + pipe( + testsReport, + A.filter(({ failed }) => failed > 0) + ); + +/** + * Filters expected-results containing which has status as "fail" or "error". + * @param expectResults Provides "status" data for each expected result. + * @returns Expected results with "fail" or "error" status. + */ +export const getFailedExpectedResults = (expectResults: ExpectResult[]) => + pipe( + expectResults, + A.filter(({ status }) => status !== "pass") + ); + +/** + * Checks whether every test report has zero failed test cases. + * @param testsReport Provides "failed" test-cases data. + * @returns True, if all test-cases passed. False, otherwise. + */ +export const hasAllTestsPassed = (testsReport: TestReport[]) => + pipe( + testsReport, + A.every(({ failed }) => failed === 0) + ); diff --git a/packages/hoppscotch-cli/src/utils/workspace-access.ts b/packages/hoppscotch-cli/src/utils/workspace-access.ts new file mode 100644 index 0000000..2c9a947 --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/workspace-access.ts @@ -0,0 +1,222 @@ +import { + CollectionSchemaVersion, + Environment, + EnvironmentSchemaVersion, + HoppCollection, + HoppCollectionVariable, + HoppRESTAuth, + HoppRESTHeaders, + HoppRESTRequest, +} from "@hoppscotch/data"; + +import { HoppEnvPair } from "../types/request"; + +export interface WorkspaceEnvironment { + id: string; + teamID: string; + name: string; + variables: HoppEnvPair[]; +} + +export interface WorkspaceCollection { + id: string; + data: string | null; + title: string; + parentID: string | null; + folders: WorkspaceCollection[]; + requests: WorkspaceRequest[]; +} + +interface WorkspaceRequest { + id: string; + collectionID: string; + teamID: string; + title: string; + request: string; +} + +/** + * Transforms the incoming list of workspace requests by applying `JSON.parse` to the `request` field. + * It includes the `v` field indicating the schema version, but migration is handled already at the `parseCollectionData()` helper function. + * + * @param {WorkspaceRequest[]} requests - An array of workspace request objects to be transformed. + * @returns {HoppRESTRequest[]} The transformed array of requests conforming to the `HoppRESTRequest` type. + */ +const transformWorkspaceRequests = ( + requests: WorkspaceRequest[] +): HoppRESTRequest[] => requests.map(({ request }) => JSON.parse(request)); + +/** + * Apply relevant migrations for data conforming to older formats + * + * @param {HoppEnvPair} variable - The environment variable to normalize. + * @returns {HoppEnvPair} The normalized environment variable conforming to the `HoppEnvPair` type. + */ +const normalizeEnvironmentVariable = (variable: HoppEnvPair): HoppEnvPair => { + if ( + "secret" in variable && + "initialValue" in variable && + "currentValue" in variable + ) { + return variable; + } + + const envPair = variable as Partial & { + key: string; + value?: string; + }; + + const isSecret = !!envPair.secret; + const value = envPair.value ?? ""; + + return { + key: envPair.key, + secret: isSecret, + initialValue: isSecret ? "" : (envPair.initialValue ?? value), + currentValue: isSecret ? "" : (envPair.currentValue ?? value), + }; +}; + +/** + * Transforms the given `HoppRESTAuth` object to ensure it conforms to the latest + * OAuth 2.0 authentication structure. Depending on the `grantType` within the + * `grantTypeInfo` property, this function adds or initializes specific fields + * such as `clientAuthentication`, `authRequestParams`, `tokenRequestParams`, + * and `refreshRequestParams` to maintain compatibility with updated schema + * requirements. + * + * - For "CLIENT_CREDENTIALS" grant type, sets `clientAuthentication` to "IN_BODY" + * and initializes `tokenRequestParams` and `refreshRequestParams` as empty arrays. + * - For "AUTHORIZATION_CODE" grant type, initializes `authRequestParams`, + * `tokenRequestParams`, and `refreshRequestParams` as empty arrays. + * - For "PASSWORD" grant type, initializes `tokenRequestParams` and + * `refreshRequestParams` as empty arrays. + * - For "IMPLICIT" grant type, initializes `authRequestParams` and + * `refreshRequestParams` as empty arrays. + * + * If the `authType` is not "oauth-2", the original `auth` object is returned unchanged. + * + * @param {HoppRESTAuth} auth - The authentication object to transform. + * @returns {HoppRESTAuth} The transformed authentication object with updated grant type information. + */ +const transformAuth = (auth: HoppRESTAuth): HoppRESTAuth => { + if (auth.authType === "oauth-2") { + const oldGrantTypeInfo = auth.grantTypeInfo; + let newGrantTypeInfo = oldGrantTypeInfo; + + // Add clientAuthentication for CLIENT_CREDENTIALS + if (oldGrantTypeInfo.grantType === "CLIENT_CREDENTIALS") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + clientAuthentication: "IN_BODY", + tokenRequestParams: [], + refreshRequestParams: [], + }; + } else if (oldGrantTypeInfo.grantType === "AUTHORIZATION_CODE") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + authRequestParams: [], + tokenRequestParams: [], + refreshRequestParams: [], + }; + } else if (oldGrantTypeInfo.grantType === "PASSWORD") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + tokenRequestParams: [], + refreshRequestParams: [], + }; + } else if (oldGrantTypeInfo.grantType === "IMPLICIT") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + authRequestParams: [], + refreshRequestParams: [], + }; + } + + return { + ...auth, + grantTypeInfo: newGrantTypeInfo, + }; + } + + return auth; +}; + +/** + * Transforms workspace environment data to the `HoppEnvironment` format. + * + * @param {WorkspaceEnvironment} workspaceEnvironment - The workspace environment object to transform. + * @returns {Environment} The transformed environment object conforming to the `Environment` type. + */ +export const transformWorkspaceEnvironment = ( + workspaceEnvironment: WorkspaceEnvironment +): Environment => { + const { teamID, variables, ...rest } = workspaceEnvironment; + + // The response doesn't include a way to infer the schema version, so it's set to the latest version + // Any relevant migrations have to be accounted here + return { + v: EnvironmentSchemaVersion, + variables: variables.map(normalizeEnvironmentVariable), + ...rest, + }; +}; + +/** + * Transforms workspace collection data to the `HoppCollection` format. + * + * @param {WorkspaceCollection[]} collections - An array of workspace collection objects to be transformed. + * @returns {HoppCollection[]} The transformed array of collections conforming to the `HoppCollection` type. + */ +export const transformWorkspaceCollections = ( + collections: WorkspaceCollection[] +): HoppCollection[] => { + return collections.map((collection) => { + const { id, title, data, requests, folders } = collection; + + const parsedData: { + auth?: HoppRESTAuth; + headers?: HoppRESTHeaders; + variables: HoppCollectionVariable[]; + description: string | null; + preRequestScript?: string; + testScript?: string; + } = data ? JSON.parse(data) : {}; + + const { + auth = { authType: "inherit", authActive: true }, + headers = [], + variables = [], + description = null, + preRequestScript = "", + testScript = "", + } = parsedData; + + const transformedAuth = transformAuth(auth); + + const transformedHeaders = headers.map((header) => + header.description ? header : { ...header, description: "" } + ); + + const filteredCollectionVariables = variables.filter( + (variable) => variable.key.trim() !== "" + ); + + // The response doesn't include a way to infer the schema version, so it's set to the latest version + // Any relevant migrations have to be accounted here + // `ref_id` field isn't necessary being applicable only to personal workspace and asociates with syncing + return { + v: CollectionSchemaVersion, + id, + name: title, + folders: transformWorkspaceCollections(folders), + requests: transformWorkspaceRequests(requests), + auth: transformedAuth, + headers: transformedHeaders, + variables: filteredCollectionVariables, + description, + preRequestScript, + testScript, + }; + }); +}; diff --git a/packages/hoppscotch-cli/tsconfig.json b/packages/hoppscotch-cli/tsconfig.json new file mode 100644 index 0000000..ce1cf67 --- /dev/null +++ b/packages/hoppscotch-cli/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "outDir": ".", + "rootDir": ".", + "strict": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "composite": true, + "lib": ["ESNext", "DOM"] + }, + "files": ["package.json"] +} diff --git a/packages/hoppscotch-cli/tsup.config.ts b/packages/hoppscotch-cli/tsup.config.ts new file mode 100644 index 0000000..d6e19c1 --- /dev/null +++ b/packages/hoppscotch-cli/tsup.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["./src/index.ts"], + outDir: "./dist/", + format: ["esm"], + platform: "node", + sourcemap: true, + bundle: true, + target: "esnext", + skipNodeModulesBundle: false, + esbuildOptions(options) { + options.bundle = true; + }, + clean: true, +}); diff --git a/packages/hoppscotch-cli/vitest.config.ts b/packages/hoppscotch-cli/vitest.config.ts new file mode 100644 index 0000000..bac92a0 --- /dev/null +++ b/packages/hoppscotch-cli/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + setupFiles: ["./setupFiles.ts"], + include: ["**/src/__tests__/**/**/*.{test,spec}.ts"], + exclude: [ + "**/node_modules/**", + "**/dist/**", + "**/src/__tests__/functions/**/*.ts", + ], + }, +}); diff --git a/packages/hoppscotch-common/.gitignore b/packages/hoppscotch-common/.gitignore new file mode 100644 index 0000000..68b1243 --- /dev/null +++ b/packages/hoppscotch-common/.gitignore @@ -0,0 +1,35 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Environment Variables +.env + +# Backend Code generation +src/helpers/backend/graphql.ts +src/helpers/backend/backend-schema.json + +# vite-plugin-pages-sitemap generates its files in the public directory +public/robots.txt +public/sitemap.xml diff --git a/packages/hoppscotch-common/.prettierignore b/packages/hoppscotch-common/.prettierignore new file mode 100644 index 0000000..bafee6e --- /dev/null +++ b/packages/hoppscotch-common/.prettierignore @@ -0,0 +1,12 @@ +.dependabot +.github +.nuxt +.hoppscotch +.vscode +package-lock.json +node_modules +dist +static +components.d.ts +src/types +src/helpers/backend/graphql.ts diff --git a/packages/hoppscotch-common/.prettierrc.js b/packages/hoppscotch-common/.prettierrc.js new file mode 100644 index 0000000..27bf9f5 --- /dev/null +++ b/packages/hoppscotch-common/.prettierrc.js @@ -0,0 +1,8 @@ +module.exports = { + semi: false, + trailingComma: "es5", + singleQuote: false, + printWidth: 80, + useTabs: false, + tabWidth: 2, +} diff --git a/packages/hoppscotch-common/assets/icons/apple.svg b/packages/hoppscotch-common/assets/icons/apple.svg new file mode 100644 index 0000000..6e9303c --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/apple.svg @@ -0,0 +1 @@ +Apple \ No newline at end of file diff --git a/packages/hoppscotch-common/assets/icons/auth/email.svg b/packages/hoppscotch-common/assets/icons/auth/email.svg new file mode 100644 index 0000000..ac21b3d --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/auth/email.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/packages/hoppscotch-common/assets/icons/auth/github.svg b/packages/hoppscotch-common/assets/icons/auth/github.svg new file mode 100644 index 0000000..a4a47b0 --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/auth/github.svg @@ -0,0 +1,12 @@ + + + + diff --git a/packages/hoppscotch-common/assets/icons/auth/google.svg b/packages/hoppscotch-common/assets/icons/auth/google.svg new file mode 100644 index 0000000..5cdb447 --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/auth/google.svg @@ -0,0 +1,24 @@ + + + + + + + diff --git a/packages/hoppscotch-common/assets/icons/auth/microsoft.svg b/packages/hoppscotch-common/assets/icons/auth/microsoft.svg new file mode 100644 index 0000000..dcc6cd2 --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/auth/microsoft.svg @@ -0,0 +1,12 @@ + + + + + + + diff --git a/packages/hoppscotch-common/assets/icons/brands/chrome.svg b/packages/hoppscotch-common/assets/icons/brands/chrome.svg new file mode 100644 index 0000000..c36112c --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/brands/chrome.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/assets/icons/brands/discord.svg b/packages/hoppscotch-common/assets/icons/brands/discord.svg new file mode 100644 index 0000000..079b54b --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/brands/discord.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/hoppscotch-common/assets/icons/brands/facebook.svg b/packages/hoppscotch-common/assets/icons/brands/facebook.svg new file mode 100644 index 0000000..a365ddc --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/brands/facebook.svg @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/packages/hoppscotch-common/assets/icons/brands/firefox.svg b/packages/hoppscotch-common/assets/icons/brands/firefox.svg new file mode 100644 index 0000000..c52dd46 --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/brands/firefox.svg @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/assets/icons/brands/linkedin.svg b/packages/hoppscotch-common/assets/icons/brands/linkedin.svg new file mode 100644 index 0000000..798e8e2 --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/brands/linkedin.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/hoppscotch-common/assets/icons/brands/reddit.svg b/packages/hoppscotch-common/assets/icons/brands/reddit.svg new file mode 100644 index 0000000..3ea3984 --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/brands/reddit.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/hoppscotch-common/assets/icons/brands/twitter.svg b/packages/hoppscotch-common/assets/icons/brands/twitter.svg new file mode 100644 index 0000000..b67dc2f --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/brands/twitter.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/hoppscotch-common/assets/icons/graphql.svg b/packages/hoppscotch-common/assets/icons/graphql.svg new file mode 100644 index 0000000..f04d6a5 --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/graphql.svg @@ -0,0 +1 @@ + diff --git a/packages/hoppscotch-common/assets/icons/insomnia.svg b/packages/hoppscotch-common/assets/icons/insomnia.svg new file mode 100644 index 0000000..6358eec --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/insomnia.svg @@ -0,0 +1 @@ + diff --git a/packages/hoppscotch-common/assets/icons/linux.svg b/packages/hoppscotch-common/assets/icons/linux.svg new file mode 100644 index 0000000..393e95e --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/linux.svg @@ -0,0 +1 @@ +Linux \ No newline at end of file diff --git a/packages/hoppscotch-common/assets/icons/logo.svg b/packages/hoppscotch-common/assets/icons/logo.svg new file mode 100644 index 0000000..8044120 --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/logo.svg @@ -0,0 +1 @@ + diff --git a/packages/hoppscotch-common/assets/icons/mqtt.svg b/packages/hoppscotch-common/assets/icons/mqtt.svg new file mode 100644 index 0000000..40a33f9 --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/mqtt.svg @@ -0,0 +1 @@ + diff --git a/packages/hoppscotch-common/assets/icons/postman.svg b/packages/hoppscotch-common/assets/icons/postman.svg new file mode 100644 index 0000000..4d224f1 --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/postman.svg @@ -0,0 +1 @@ + diff --git a/packages/hoppscotch-common/assets/icons/socketio.svg b/packages/hoppscotch-common/assets/icons/socketio.svg new file mode 100644 index 0000000..b97daeb --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/socketio.svg @@ -0,0 +1 @@ + diff --git a/packages/hoppscotch-common/assets/icons/star-off.svg b/packages/hoppscotch-common/assets/icons/star-off.svg new file mode 100644 index 0000000..3d542a9 --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/star-off.svg @@ -0,0 +1 @@ + diff --git a/packages/hoppscotch-common/assets/icons/websocket.svg b/packages/hoppscotch-common/assets/icons/websocket.svg new file mode 100644 index 0000000..a55697f --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/websocket.svg @@ -0,0 +1 @@ + diff --git a/packages/hoppscotch-common/assets/icons/windows.svg b/packages/hoppscotch-common/assets/icons/windows.svg new file mode 100644 index 0000000..92b6d8c --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/windows.svg @@ -0,0 +1 @@ +Windows \ No newline at end of file diff --git a/packages/hoppscotch-common/assets/scss/styles.scss b/packages/hoppscotch-common/assets/scss/styles.scss new file mode 100644 index 0000000..5c0541a --- /dev/null +++ b/packages/hoppscotch-common/assets/scss/styles.scss @@ -0,0 +1,579 @@ +/* +* Write hoppscotch-common related custom styles in this file. +* If styles are sharable across all package then write into hoppscotch-ui/assets/scss/styles.scss file. +*/ + +* { + backface-visibility: hidden; + -moz-backface-visibility: hidden; + -webkit-backface-visibility: hidden; + + &::before { + backface-visibility: hidden; + -moz-backface-visibility: hidden; + -webkit-backface-visibility: hidden; + } + + &::after { + backface-visibility: hidden; + -moz-backface-visibility: hidden; + -webkit-backface-visibility: hidden; + } + + @apply selection:bg-accentDark; + @apply selection:text-accentContrast; +} + +:root { + accent-color: var(--accent-color); + font-variant-ligatures: common-ligatures; + @apply antialiased; + @apply overscroll-none; +} + +::-webkit-scrollbar-track { + @apply bg-transparent; + @apply border-b-0 border-l border-r-0 border-t-0 border-solid border-dividerLight; +} + +::-webkit-scrollbar-thumb { + @apply bg-divider bg-clip-content; + @apply rounded-full; + @apply border-4 border-solid border-transparent; + @apply hover:bg-dividerDark; + @apply hover:bg-clip-content; +} + +::-webkit-scrollbar { + @apply w-4; + @apply h-0; +} + +.no-scrollbar { + scrollbar-width: none; +} + +input::placeholder, +textarea::placeholder, +.cm-placeholder { + @apply text-secondary; + @apply opacity-50 #{!important}; +} + +input, +textarea { + @apply text-secondaryDark; + @apply font-medium; +} + +html { + scroll-behavior: smooth; +} + +body { + @apply bg-primary; + @apply text-body text-secondary; + @apply font-medium; + @apply select-none; + @apply overflow-x-hidden; + @apply leading-body #{!important}; + animation: fade 300ms forwards; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; +} + +@keyframes fade { + 0% { + @apply opacity-0; + } + + 100% { + @apply opacity-100; + } +} + +.fade-enter-active, +.fade-leave-active { + @apply transition-opacity; +} + +.fade-enter-from, +.fade-leave-to { + @apply opacity-0; +} + +.slide-enter-active, +.slide-leave-active { + @apply transition; + @apply duration-300; +} + +.slide-enter-from, +.slide-leave-to { + @apply transform; + @apply translate-x-full; +} + +.bounce-enter-active, +.bounce-leave-active { + @apply transition; +} + +.bounce-enter-from, +.bounce-leave-to { + @apply transform; + @apply scale-95; +} + +.svg-icons { + @apply flex-shrink-0; + @apply overflow-hidden; + height: var(--line-height-body); + width: var(--line-height-body); +} + +a { + @apply inline-flex; + @apply text-current; + @apply no-underline; + @apply transition; + @apply leading-body; + @apply focus:outline-none; + + &.link { + @apply items-center; + @apply px-1 py-0.5; + @apply -mx-1 -my-0.5; + @apply text-accent; + @apply rounded; + @apply hover:text-accentDark; + @apply focus-visible:ring; + @apply focus-visible:ring-accent; + @apply focus-visible:text-accentDark; + } +} + +hr { + @apply border-b border-dividerLight; + @apply my-2 #{!important}; +} + +.heading { + @apply font-bold; + @apply text-lg text-secondaryDark; + @apply tracking-tight; +} + +.input, +.select, +.textarea { + @apply flex; + @apply w-full; + @apply px-4 py-2; + @apply bg-transparent; + @apply rounded; + @apply text-secondaryDark; + @apply border border-divider; + @apply focus-visible:border-dividerDark; +} + +input, +select, +textarea, +button { + @apply truncate; + @apply transition; + @apply text-body; + @apply leading-body; + @apply focus:outline-none; + @apply disabled:cursor-not-allowed; +} + +.input[type="file"], +.input[type="radio"], +#installPWA { + @apply hidden; +} + +.floating-input ~ label { + @apply absolute; + @apply px-2 py-0.5; + @apply m-2; + @apply rounded; + @apply transition; + @apply origin-top-left; +} + +.floating-input:focus-within ~ label, +.floating-input:not(:placeholder-shown) ~ label { + @apply bg-primary; + @apply transform; + @apply origin-top-left; + @apply scale-75; + @apply -translate-y-4 translate-x-1; +} + +.floating-input:focus-within ~ label { + @apply text-secondaryDark; +} + +.floating-input ~ .end-actions { + @apply absolute; + @apply right-[.05rem]; + @apply inset-y-0; + @apply flex; + @apply items-center; +} + +.floating-input:has(~ .end-actions) { + @apply pr-12; +} + +pre.ace_editor { + @apply font-mono; + @apply resize-none; + @apply z-0; +} + +.select { + @apply appearance-none; + @apply cursor-pointer; + + &::-ms-expand { + @apply hidden; + } +} + +.info-response { + color: var(--status-info-color); + &.outlined { + border: 1px solid var(--status-info-color); + } +} + +.success-response { + color: var(--status-success-color); + &.outlined { + border: 1px solid var(--status-success-color); + } +} + +.redirect-response { + color: var(--status-redirect-color); + &.outlined { + border: 1px solid var(--status-redirect-color); + } +} + +.critical-error-response { + color: var(--status-critical-error-color); + &.outlined { + border: 1px solid var(--status-critical-error-color); + } +} + +.server-error-response { + color: var(--status-server-error-color); + &.outlined { + border: 1px solid var(--status-server-error-color); + } +} + +.missing-data-response { + color: var(--status-missing-data-color); + &.outlined { + border: 1px solid var(--status-missing-data-color); + } +} + +.toasted-container { + @apply max-w-md; + @apply z-[10000]; + + .toasted { + &.toasted-primary { + @apply px-4 py-2; + @apply bg-tooltip; + @apply border-secondaryDark; + @apply text-body text-primary; + @apply justify-between; + @apply shadow-lg; + @apply font-semibold; + @apply transition; + @apply leading-body; + @apply sm:rounded; + @apply sm:border; + + .action { + @apply relative; + @apply flex flex-shrink-0; + @apply text-body; + @apply px-4; + @apply my-1; + @apply ml-auto; + @apply normal-case; + @apply font-semibold; + @apply leading-body; + @apply tracking-normal; + @apply rounded; + @apply last:ml-4; + @apply sm:ml-8; + @apply before:absolute; + @apply before:bg-current; + @apply before:opacity-10; + @apply before:inset-0; + @apply before:transition; + @apply before:content-['']; + @apply hover:no-underline; + @apply hover:before:opacity-20; + } + } + + &.info { + @apply bg-accent; + @apply text-accentContrast; + @apply border-accentDark; + } + + &.error { + @apply bg-red-200; + @apply text-red-800; + @apply border-red-400; + } + + &.success { + @apply bg-green-200; + @apply text-green-800; + @apply border-green-400; + } + } +} + +.splitpanes__pane { + @apply will-change-auto; + transform: translateZ(0); +} + +.smart-splitter .splitpanes__splitter { + @apply relative; + @apply before:absolute; + @apply before:inset-0; + @apply before:bg-accentLight; + @apply before:opacity-0; + @apply before:z-20; + @apply before:transition; + @apply before:content-['']; + @apply hover:before:opacity-100; +} + +.no-splitter .splitpanes__splitter { + @apply relative; +} + +.smart-splitter.splitpanes--vertical > .splitpanes__splitter { + @apply w-0; + @apply before:-left-0.5; + @apply before:-right-0.5; + @apply before:h-full; + @apply bg-divider; +} + +.smart-splitter.splitpanes--horizontal > .splitpanes__splitter { + @apply h-0; + @apply before:-top-0.5; + @apply before:-bottom-0.5; + @apply before:w-full; + @apply bg-divider; +} + +.no-splitter.splitpanes--vertical > .splitpanes__splitter { + @apply w-0; + @apply pointer-events-none; + @apply bg-dividerLight; +} + +.no-splitter.splitpanes--horizontal > .splitpanes__splitter { + @apply h-0; + @apply pointer-events-none; + @apply bg-dividerLight; +} + +.splitpanes--horizontal .splitpanes__pane { + @apply transition-none; +} + +.splitpanes--vertical .splitpanes__pane { + @apply transition-none; +} + +.cm-focused { + @apply select-auto; + @apply outline-none #{!important}; + + .cm-activeLine { + @apply bg-primaryLight; + } + + .cm-activeLineGutter { + @apply bg-primaryDark; + } +} + +.cm-scroller { + @apply overscroll-y-auto; +} + +.cm-editor { + .cm-line::selection { + @apply bg-accentDark #{!important}; + @apply text-accentContrast #{!important}; + } + + .cm-line ::selection { + @apply bg-accentDark #{!important}; + @apply text-accentContrast #{!important}; + } +} + +.shortcut-key { + @apply inline-flex; + @apply font-sans; + @apply text-tiny; + @apply bg-dividerLight; + @apply rounded; + @apply ml-2; + @apply px-0.5; + @apply min-w-[1rem]; + @apply min-h-[1rem]; + @apply leading-none; + @apply items-center; + @apply justify-center; + @apply border border-dividerDark; + @apply shadow-sm; + @apply span { + @apply block #{!important}; + } + } + + .tippy-svg-arrow { + svg:last-child { + @apply fill-popover; + } + } + } + + [data-v-tippy] { + @apply flex flex-1; + @apply truncate; + } + + [interactive] > div { + @apply flex flex-1; + @apply h-full; + } +} + +// Theme-specific styles +@mixin light-tippy-theme { + @include base-tippy-styles; + + .cm-tooltip { + .cm-tooltip-arrow:after, + .cm-tooltip-arrow:before { + border-bottom-color: theme("colors.primaryDark") !important; + } + } + + .tippy-box[data-theme~="tooltip"] { + @apply bg-popover; + @apply border-solid border-dividerDark; + @apply shadow; + + .tippy-content { + @apply text-secondary; + + kbd { + background-color: rgba(0, 0, 0, 0.1); + @apply text-secondaryDark; + } + } + + .tippy-svg-arrow { + svg:first-child { + @apply fill-dividerDark; + } + + svg:last-child { + @apply fill-popover; + } + } + } + + .tippy-box[data-theme~="popover"] { + @apply border-dividerDark; + @apply shadow-lg; + + .tippy-svg-arrow { + svg:first-child { + @apply fill-dividerDark; + } + } + } +} + +@mixin dark-tippy-theme { + @include base-tippy-styles; + + .cm-tooltip { + .cm-tooltip-arrow::after, + .cm-tooltip-arrow::before { + border-bottom-color: theme("colors.primaryDark") !important; + } + } + + .tippy-box[data-theme~="tooltip"] { + @apply bg-primaryDark; + @apply border-solid border-divider; + @apply shadow-lg; + + .tippy-content { + @apply text-secondary; + + kbd { + background-color: rgba(255, 255, 255, 0.1); + @apply text-secondary; + } + } + + .tippy-svg-arrow { + svg:first-child { + @apply fill-divider; + } + + svg:last-child { + @apply fill-primaryDark; + } + } + } + + .tippy-box[data-theme~="popover"] { + @apply border-divider; + @apply shadow-xl; + + .tippy-svg-arrow { + svg:first-child { + @apply fill-divider; + } + } + } +} + +@mixin black-tippy-theme { + @include base-tippy-styles; + + .cm-tooltip { + .cm-tooltip-arrow:after, + .cm-tooltip-arrow:before { + border-bottom-color: theme("colors.primaryLight") !important; + } + } + + .tippy-box[data-theme~="tooltip"] { + @apply bg-primaryLight; + @apply border-solid border-divider; + @apply shadow-lg; + + .tippy-content { + @apply text-secondary; + + kbd { + background-color: rgba(255, 255, 255, 0.1); + @apply text-secondary; + } + } + + .tippy-svg-arrow { + svg:first-child { + @apply fill-divider; + } + + svg:last-child { + @apply fill-primaryLight; + } + } + } + + .tippy-box[data-theme~="popover"] { + @apply border-divider; + @apply shadow-xl; + + .tippy-svg-arrow { + svg:first-child { + @apply fill-divider; + } + } + } +} diff --git a/packages/hoppscotch-common/eslint.config.mjs b/packages/hoppscotch-common/eslint.config.mjs new file mode 100644 index 0000000..897f19b --- /dev/null +++ b/packages/hoppscotch-common/eslint.config.mjs @@ -0,0 +1,96 @@ +import pluginVue from "eslint-plugin-vue" +import { + defineConfigWithVueTs, + vueTsConfigs, +} from "@vue/eslint-config-typescript" +import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended" +import globals from "globals" + +export default defineConfigWithVueTs( + { + ignores: [ + "static/**", + "src/helpers/backend/graphql.ts", + "**/*.d.ts", + "types/**", + "dist/**", + "node_modules/**", + ], + }, + pluginVue.configs["flat/recommended"], + vueTsConfigs.recommended, + eslintPluginPrettierRecommended, + { + files: ["**/*.ts", "**/*.js", "**/*.vue"], + linterOptions: { + reportUnusedDisableDirectives: false, + }, + languageOptions: { + sourceType: "module", + ecmaVersion: "latest", + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + requireConfigFile: false, + ecmaFeatures: { + jsx: false, + }, + }, + }, + rules: { + semi: [2, "never"], + "import/named": "off", + "no-console": "off", + "no-debugger": process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn", + "prettier/prettier": [ + process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn", + { + semi: false, + trailingComma: "es5", + singleQuote: false, + printWidth: 80, + useTabs: false, + tabWidth: 2, + }, + ], + "vue/multi-word-component-names": "off", + "vue/no-side-effects-in-computed-properties": "off", + "import/no-named-as-default": "off", + "import/no-named-as-default-member": "off", + "@typescript-eslint/no-unused-vars": [ + process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/no-unused-expressions": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-function-type": "off", + "import/default": "off", + "no-undef": "off", + "no-restricted-globals": [ + "error", + { + name: "localStorage", + message: + "Do not use 'localStorage' directly. Please use the PersistenceService", + }, + ], + "no-restricted-syntax": [ + "error", + { + selector: "CallExpression[callee.object.property.name='localStorage']", + message: + "Do not use 'localStorage' directly. Please use the PersistenceService", + }, + ], + eqeqeq: 1, + "no-else-return": 1, + }, + } +) diff --git a/packages/hoppscotch-common/gql-codegen.yml b/packages/hoppscotch-common/gql-codegen.yml new file mode 100644 index 0000000..05927a7 --- /dev/null +++ b/packages/hoppscotch-common/gql-codegen.yml @@ -0,0 +1,18 @@ +overwrite: true +schema: "../../gql-gen/*.gql" +generates: + src/helpers/backend/graphql.ts: + documents: "src/**/*.graphql" + plugins: + - add: + content: > + /* eslint-disable */ + // Auto-generated file (DO NOT EDIT!!!), refer gql-codegen.yml + - typescript + - typescript-operations + - typed-document-node + - typescript-urql-graphcache + + src/helpers/backend/backend-schema.json: + plugins: + - urql-introspection diff --git a/packages/hoppscotch-common/languages.json b/packages/hoppscotch-common/languages.json new file mode 100644 index 0000000..f8a14e8 --- /dev/null +++ b/packages/hoppscotch-common/languages.json @@ -0,0 +1,207 @@ +[ + { + "code": "af", + "file": "af.json", + "iso": "af-AF", + "name": "Afrikaans" + }, + { + "code": "ar", + "dir": "rtl", + "file": "ar.json", + "iso": "ar-AR", + "name": "عربى" + }, + { + "code": "ca", + "file": "ca.json", + "iso": "ca-CA", + "name": "Català" + }, + { + "code": "cn", + "file": "cn.json", + "iso": "zh-CN", + "name": "简体中文" + }, + { + "code": "cs", + "file": "cs.json", + "iso": "cs-CS", + "name": "Čeština" + }, + { + "code": "da", + "file": "da.json", + "iso": "da-DA", + "name": "Dansk" + }, + { + "code": "de", + "file": "de.json", + "iso": "de-DE", + "name": "Deutsch" + }, + { + "code": "el", + "file": "el.json", + "iso": "el-EL", + "name": "Ελληνικά" + }, + { + "code": "en", + "file": "en.json", + "iso": "en-US", + "name": "English" + }, + { + "code": "es", + "file": "es.json", + "iso": "es-ES", + "name": "Español" + }, + { + "code": "fi", + "file": "fi.json", + "iso": "fi-FI", + "name": "Suomalainen" + }, + { + "code": "fr", + "file": "fr.json", + "iso": "fr-FR", + "name": "Français" + }, + { + "code": "he", + "file": "he.json", + "iso": "he-HE", + "name": "עִברִית" + }, + { + "code": "hu", + "file": "hu.json", + "iso": "hu-HU", + "name": "Magyar" + }, + { + "code": "hy", + "file": "hy.json", + "iso": "hy-AM", + "name": "Հայերեն" + }, + { + "code": "id", + "file": "id.json", + "iso": "id", + "name": "Indonesian" + }, + { + "code": "it", + "file": "it.json", + "iso": "it", + "name": "Italiano" + }, + { + "code": "ja", + "file": "ja.json", + "iso": "ja-JA", + "name": "日本語" + }, + { + "code": "ko", + "file": "ko.json", + "iso": "ko-KO", + "name": "한국어" + }, + { + "code": "mn", + "file": "mn.json", + "iso": "mn-MN", + "name": "Монгол" + }, + { + "code": "nl", + "file": "nl.json", + "iso": "nl-NL", + "name": "Nederlands" + }, + { + "code": "no", + "file": "no.json", + "iso": "no-NO", + "name": "Norsk" + }, + { + "code": "pl", + "file": "pl.json", + "iso": "pl-PL", + "name": "Polskie" + }, + { + "code": "pt-br", + "file": "pt-br.json", + "iso": "pt-BR", + "name": "Português Brasileiro" + }, + { + "code": "pt", + "file": "pt.json", + "iso": "pt-PT", + "name": "Português" + }, + { + "code": "ro", + "file": "ro.json", + "iso": "ro-RO", + "name": "Română" + }, + { + "code": "ru", + "file": "ru.json", + "iso": "ru-RU", + "name": "Pусский" + }, + { + "code": "sr", + "file": "sr.json", + "iso": "sr-SR", + "name": "Српски" + }, + { + "code": "sv", + "file": "sv.json", + "iso": "sv-SV", + "name": "Svenska" + }, + { + "code": "th", + "file": "th.json", + "iso": "th-TH", + "name": "ไทย" + }, + { + "code": "tr", + "file": "tr.json", + "iso": "tr-TR", + "name": "Türkçe" + }, + { + "code": "tw", + "file": "tw.json", + "iso": "zh-TW", + "name": "繁體中文" + }, + { + "code": "uk", + "file": "uk.json", + "iso": "uk-UK", + "name": "Українська" + }, + { + "code": "vi", + "file": "vi.json", + "iso": "vi-VI", + "name": "Tiếng Việt" + } +] diff --git a/packages/hoppscotch-common/locales/af.json b/packages/hoppscotch-common/locales/af.json new file mode 100644 index 0000000..7f1bae1 --- /dev/null +++ b/packages/hoppscotch-common/locales/af.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Add", + "autoscroll": "Autoscroll", + "cancel": "Kanselleer", + "choose_file": "Kies 'n lêer", + "clear": "Duidelik", + "clear_all": "Maak alles skoon", + "clear_history": "Clear all History", + "close": "Close", + "connect": "Koppel", + "connecting": "Connecting", + "copy": "Kopieer", + "create": "Create", + "delete": "Vee uit", + "disconnect": "Ontkoppel", + "dismiss": "Weier", + "dont_save": "Don't save", + "download_file": "Aflaai leêr", + "drag_to_reorder": "Drag to reorder", + "duplicate": "Duplicate", + "edit": "Redigeer", + "filter": "Filter", + "go_back": "Gaan terug", + "go_forward": "Go forward", + "group_by": "Group by", + "hide_secret": "Hide secret", + "label": "Etiket", + "learn_more": "Leer meer", + "download_here": "Download here", + "less": "Less", + "more": "Meer", + "new": "Nuut", + "no": "Geen", + "open_workspace": "Open workspace", + "paste": "Paste", + "prettify": "Prettify", + "properties": "Properties", + "remove": "Verwyder", + "rename": "Rename", + "restore": "Herstel", + "save": "Stoor", + "scroll_to_bottom": "Scroll to bottom", + "scroll_to_top": "Scroll to top", + "search": "Soek", + "send": "Stuur", + "share": "Share", + "show_secret": "Show secret", + "start": "Begin", + "starting": "Starting", + "stop": "Stop", + "to_close": "to close", + "to_navigate": "to navigate", + "to_select": "to select", + "turn_off": "Skakel af", + "turn_on": "Sit aan", + "undo": "Ontdoen", + "yes": "Ja" + }, + "add": { + "new": "Voeg nuwe", + "star": "Voeg ster by" + }, + "app": { + "chat_with_us": "Gesels met ons", + "contact_us": "Kontak Ons", + "cookies": "Cookies", + "copy": "Kopieer", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Copy User Auth Token", + "developer_option": "Developer options", + "developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.", + "discord": "Discord", + "documentation": "Dokumentasie", + "github": "GitHub", + "help": "Hulp, terugvoer en dokumentasie", + "home": "Tuis", + "invite": "Nooi", + "invite_description": "In Hoppscotch het ons 'n eenvoudige en intuïtiewe koppelvlak ontwerp om u API's te skep en te bestuur. Hoppscotch is 'n instrument waarmee u u API's kan bou, toets, dokumenteer en deel.", + "invite_your_friends": "Nooi jou vriende uit", + "join_discord_community": "Sluit aan by ons Discord -gemeenskap", + "keyboard_shortcuts": "Sleutelbord kortpaaie", + "name": "Hoppscotch", + "new_version_found": "Nuwe weergawe gevind. Herlaai om op te dateer.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Options", + "proxy_privacy_policy": "Volmag privaatheidsbeleid", + "reload": "Herlaai", + "search": "Soek", + "share": "Deel", + "shortcuts": "Kortpaaie", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "Kollig", + "status": "Status", + "status_description": "Check the status of the website", + "terms_and_privacy": "Bepalings en privaatheid", + "twitter": "Twitter", + "type_a_command_search": "Tik 'n opdrag of soek ...", + "we_use_cookies": "Ons gebruik koekies", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "Wat's nuut?", + "see_whats_new": "See what’s new", + "wiki": "Wiki" + }, + "auth": { + "account_exists": "Rekening bestaan met verskillende geloofsbriewe - Meld aan om beide rekeninge te koppel", + "all_sign_in_options": "Alle aanmeldopsies", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "Gaan voort met e -pos", + "continue_with_github": "Gaan voort met GitHub", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Gaan voort met Google", + "continue_with_microsoft": "Continue with Microsoft", + "email": "E -pos", + "logged_out": "Uitgeteken", + "login": "Teken aan", + "login_success": "Suksesvol aangemeld", + "login_to_hoppscotch": "Teken in op Hoppscotch", + "logout": "Teken uit", + "re_enter_email": "Voer die e-posadres weer in", + "send_magic_link": "Stuur 'n magiese skakel", + "sync": "Sinkroniseer", + "we_sent_magic_link": "Ons het 'n magiese skakel vir u gestuur!", + "we_sent_magic_link_description": "Gaan jou inkassie na - ons het 'n e -pos na {email} gestuur. Dit bevat 'n magiese skakel waarmee u sal aanmeld." + }, + "authorization": { + "generate_token": "Genereer teken", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "Sluit in by URL", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "Leer hoe", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "Pass by", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "Wagwoord", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "Teken", + "type": "Magtigingstipe", + "username": "Gebruikersnaam" + }, + "collection": { + "created": "Versameling geskep", + "different_parent": "Cannot reorder collection with different parent", + "edit": "Wysig versameling", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "Gee 'n geldige naam vir die versameling", + "invalid_root_move": "Collection already in the root", + "moved": "Moved Successfully", + "my_collections": "My versamelings", + "name": "My nuwe versameling", + "name_length_insufficient": "Collection name should be at least 3 characters long", + "new": "Nuwe versameling", + "order_changed": "Collection Order Updated", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "Versameling hernoem", + "request_in_use": "Request in use", + "save_as": "Stoor as", + "save_to_collection": "Save to Collection", + "select": "Kies 'n versameling", + "select_location": "Kies ligging", + "details": "Details", + "select_team": "Kies 'n span", + "team_collections": "Spanversamelings" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "exit_team": "Are you sure you want to leave this team?", + "logout": "Weet u seker dat u wil afmeld?", + "remove_collection": "Weet u seker dat u hierdie versameling permanent wil uitvee?", + "remove_environment": "Is u seker dat u hierdie omgewing permanent wil uitvee?", + "remove_folder": "Weet u seker dat u hierdie vouer permanent wil uitvee?", + "remove_history": "Is u seker dat u alle geskiedenis permanent wil uitvee?", + "remove_request": "Is u seker dat u hierdie versoek permanent wil uitvee?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "Weet u seker dat u hierdie span wil uitvee?", + "remove_telemetry": "Weet u seker dat u van Telemetry wil afskakel?", + "request_change": "Are you sure you want to discard current request, unsaved changes will be lost.", + "save_unsaved_tab": "Do you want to save changes made in this tab?", + "sync": "Is u seker dat u hierdie werkruimte wil sinkroniseer?", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "Koptekst {count}", + "message": "Boodskap {count}", + "parameter": "Parameter {count}", + "protocol": "Protokol {count}", + "value": "Waarde {count}", + "variable": "Veranderlike {count}" + }, + "documentation": { + "generate": "Genereer dokumentasie", + "generate_message": "Voer enige Hoppscotch-versameling in om API-dokumentasie onderweg te genereer." + }, + "empty": { + "authorization": "Hierdie versoek gebruik geen magtiging nie", + "body": "Hierdie versoek het nie 'n liggaam nie", + "collection": "Versameling is leeg", + "collections": "Versamelings is leeg", + "documentation": "Connect to a GraphQL endpoint to view documentation", + "endpoint": "Endpoint cannot be empty", + "environments": "Omgewings is leeg", + "folder": "Vouer is leeg", + "headers": "Hierdie versoek bevat geen opskrifte nie", + "history": "Die geskiedenis is leeg", + "invites": "Invite list is empty", + "members": "Span is leeg", + "parameters": "Hierdie versoek het geen parameters nie", + "pending_invites": "There are no pending invites for this team", + "profile": "Login to view your profile", + "protocols": "Protokolle is leeg", + "request_variables": "This request does not have any request variables", + "schema": "Koppel aan 'n GraphQL -eindpunt", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "Subscriptions are empty", + "team_name": "Spannaam leeg", + "teams": "Spanne is leeg", + "tests": "Daar is geen toetse vir hierdie versoek nie", + "access_tokens": "Access tokens are empty", + "shortcodes": "Shortcodes are empty" + }, + "environment": { + "add_to_global": "Add to Global", + "added": "Environment addition", + "create_new": "Skep nuwe omgewing", + "created": "Environment created", + "deleted": "Environment deletion", + "duplicated": "Environment duplicated", + "edit": "Bewerk omgewing", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create a environment", + "invalid_name": "Gee 'n geldige naam vir die omgewing", + "list": "Environment variables", + "my_environments": "My Environments", + "name": "Name", + "nested_overflow": "nested environment variables are limited to 10 levels", + "new": "Nuwe omgewing", + "no_active_environment": "No active environment", + "no_environment": "Geen omgewing nie", + "no_environment_description": "No environments were selected. Choose what to do with the following variables.", + "quick_peek": "Environment Quick Peek", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "Kies omgewing", + "set": "Set environment", + "set_as_environment": "Set as environment", + "team_environments": "Team Environments", + "title": "Omgewings", + "updated": "Environment updation", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Veranderlike lys", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "Dit lyk nie asof hierdie blaaier ondersteuning vir bedieners gestuurde geleenthede het nie.", + "check_console_details": "Kyk na die konsole -log vir meer inligting.", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "cURL is nie behoorlik geformateer nie", + "danger_zone": "Danger zone", + "delete_account": "Your account is currently an owner in these teams:", + "delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "Leë versoeknaam", + "f12_details": "(F12 vir meer inligting)", + "gql_prettify_invalid_query": "Kon nie 'n ongeldige navraag mooi maak nie, los sintaksisfoute op en probeer weer", + "incomplete_config_urls": "Incomplete configuration URLs", + "incorrect_email": "Incorrect email", + "invalid_link": "Invalid link", + "invalid_link_description": "The link you clicked is invalid or expired.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "Invalid JSON", + "json_prettify_invalid_body": "Kon nie 'n ongeldige liggaam mooi maak nie, los json -sintaksisfoute op en probeer weer", + "network_error": "There seems to be a network error. Please try again.", + "network_fail": "Kon nie versoek stuur nie", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "Geen duur nie", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "No matches found", + "page_not_found": "This page could not be found", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "Kon nie voorafversoekskrip uitvoer nie", + "something_went_wrong": "Iets het verkeerd geloop", + "post_request_script_fail": "Could not execute post-request script", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "Uitvoer as JSON", + "create_secret_gist": "Skep geheime Gist", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "Teken in met GitHub om 'n geheime idee te skep", + "title": "Export", + "success": "Successfully exported", + "gist_created": "Gis geskep" + }, + "filter": { + "all": "All", + "none": "None", + "starred": "Starred" + }, + "folder": { + "created": "Vouer geskep", + "edit": "Wysig gids", + "invalid_name": "Gee 'n naam vir die gids", + "name_length_insufficient": "Folder name should be at least 3 characters long", + "new": "Nuwe leêr", + "renamed": "Vouer hernoem" + }, + "graphql": { + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "mutations": "Mutasies", + "schema": "Skema", + "subscriptions": "Inskrywings", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "Time", + "url": "URL" + }, + "header": { + "install_pwa": "Installeer toep", + "login": "Teken aan", + "save_workspace": "Stoor my werkspasie" + }, + "helpers": { + "authorization": "Die magtigingskop sal outomaties gegenereer word wanneer u die versoek stuur.", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "Genereer eers dokumentasie", + "network_fail": "Kon nie die API -eindpunt bereik nie. Kontroleer u netwerkverbinding en probeer weer.", + "offline": "Dit lyk asof u vanlyn is. Data in hierdie werkruimte is moontlik nie op datum nie.", + "offline_short": "Dit lyk asof u vanlyn is.", + "post_request_tests": "Toetsskrifte word in JavaScript geskryf en word uitgevoer nadat die antwoord ontvang is.", + "pre_request_script": "Skripte voor die versoek word in JavaScript geskryf en word uitgevoer voordat die versoek gestuur word.", + "script_fail": "Dit blyk dat daar 'n fout in die voorversoekskrif is. Kontroleer die fout hieronder en maak die skrif dienooreenkomstig reg.", + "post_request_script_fail": "There seems to be an error with post-request script. Please fix the errors and run tests again", + "post_request_script": "Skryf 'n toetsskrif om ontfouting te outomatiseer." + }, + "hide": { + "collection": "Collapse Collection Panel", + "more": "Steek meer weg", + "preview": "Versteek voorskou", + "sidebar": "Versteek sybalk" + }, + "import": { + "collections": "Voer versamelings in", + "curl": "Voer cURL in", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "Invoer misluk", + "from_file": "Import from File", + "from_gist": "Invoer vanaf Gist", + "from_gist_description": "Import from Gist URL", + "from_insomnia": "Import from Insomnia", + "from_insomnia_description": "Import from Insomnia collection", + "from_json": "Import from Hoppscotch", + "from_json_description": "Import from Hoppscotch collection file", + "from_my_collections": "Invoer uit My versamelings", + "from_my_collections_description": "Import from My Collections file", + "from_openapi": "Import from OpenAPI", + "from_openapi_description": "Import from OpenAPI specification file (YML/JSON)", + "from_postman": "Import from Postman", + "from_postman_description": "Import from Postman collection", + "from_url": "Import from URL", + "gist_url": "Voer Gist URL in", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "Couldn't get data from the url", + "import_from_url_invalid_file_format": "Error while importing collections", + "import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Collections Imported", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Import collections from a Hoppscotch Collections JSON file", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "Invoer", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled." + } + }, + "layout": { + "collapse_collection": "Collapse or Expand Collections", + "collapse_sidebar": "Collapse or Expand the sidebar", + "column": "Vertical layout", + "name": "Layout", + "row": "Horizontal layout" + }, + "modal": { + "close_unsaved_tab": "You have unsaved changes", + "collections": "Versamelings", + "confirm": "Bevestig", + "customize_request": "Customize Request", + "edit_request": "Wysig versoek", + "import_export": "Invoer uitvoer", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "You are already subscribed to this topic.", + "clean_session": "Clean Session", + "clear_input": "Clear input", + "clear_input_on_send": "Clear input on send", + "client_id": "Client ID", + "color": "Pick a color", + "communication": "Kommunikasie", + "connection_config": "Connection Config", + "connection_not_authorized": "This MQTT connection does not use any authentication.", + "invalid_topic": "Please provide a topic for the subscription", + "keep_alive": "Keep Alive", + "log": "Meld", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "Boodskap", + "new": "New Subscription", + "not_connected": "Please start a MQTT connection first.", + "publish": "Publiseer", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Teken in", + "topic": "Onderwerp", + "topic_name": "Onderwerpnaam", + "topic_title": "Publiseer / teken in op onderwerp", + "unsubscribe": "Teken uit", + "url": "URL" + }, + "navigation": { + "doc": "Dokumente", + "graphql": "GraphQL", + "profile": "Profile", + "realtime": "Ware tyd", + "rest": "REST", + "settings": "Instellings" + }, + "preRequest": { + "javascript_code": "JavaScript -kode", + "learn": "Lees dokumentasie", + "script": "Vooraansoekskrif", + "snippets": "Brokkies" + }, + "profile": { + "app_settings": "App Settings", + "default_hopp_displayname": "Unnamed User", + "editor": "Editor", + "editor_description": "Editors can add, edit, and delete requests.", + "email_verification_mail": "A verification email has been sent to your email address. Please click on the link to verify your email address.", + "no_permission": "You do not have permission to perform this action.", + "owner": "Owner", + "owner_description": "Owners can add, edit, and delete requests, collections and team members.", + "roles": "Roles", + "roles_description": "Roles are used to control access to the shared collections.", + "updated": "Profile updated", + "viewer": "Viewer", + "viewer_description": "Viewers can only view and use requests." + }, + "remove": { + "star": "Verwyder ster" + }, + "request": { + "added": "Versoek bygevoeg", + "authorization": "Magtiging", + "body": "Versoek liggaam", + "choose_language": "Kies taal", + "content_type": "Inhoudstipe", + "content_type_titles": { + "others": "Others", + "structured": "Structured", + "text": "Text" + }, + "different_collection": "Cannot reorder requests from different collections", + "duplicated": "Request duplicated", + "duration": "Duur", + "enter_curl": "Voer cURL in", + "generate_code": "Genereer kode", + "generated_code": "Kode gegenereer", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "Koplys", + "invalid_name": "Gee 'n naam vir die versoek", + "method": "Metode", + "moved": "Request moved", + "name": "Versoek naam", + "new": "New Request", + "order_changed": "Request Order Updated", + "override": "Override", + "override_help": "Set Content-Type in Headers", + "overriden": "Overridden", + "parameter_list": "Navraagparameters", + "parameters": "Grense", + "path": "Pad", + "payload": "Nuttingslading", + "query": "Navraag", + "raw_body": "Rou versoeksliggaam", + "rename": "Rename Request", + "renamed": "Versoek hernoem", + "request_variables": "Request variables", + "run": "Hardloop", + "save": "Stoor", + "save_as": "Stoor as", + "saved": "Versoek gestoor", + "share": "Deel", + "share_description": "Share Hoppscotch with your friends", + "share_request": "Share Request", + "stop": "Stop", + "title": "Versoek", + "type": "Soort versoek", + "url": "URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "Veranderlikes", + "view_my_links": "View my links", + "copy_link": "Kopieer skakel" + }, + "response": { + "audio": "Audio", + "body": "Reaksie liggaam", + "filter_response_body": "Filter JSON response body (uses jq syntax)", + "headers": "Opskrifte", + "html": "HTML", + "image": "Beeld", + "json": "JSON", + "pdf": "PDF", + "preview_html": "Voorbeskou HTML", + "raw": "Rou", + "size": "Grootte", + "status": "Status", + "time": "Tyd", + "title": "Reaksie", + "video": "Video", + "waiting_for_connection": "wag vir verbinding", + "xml": "XML" + }, + "settings": { + "accent_color": "Aksent kleur", + "account": "Rekening", + "account_deleted": "Your account has been deleted", + "account_description": "Pas u rekeninginstellings aan.", + "account_email_description": "Jou primêre e -posadres.", + "account_name_description": "Dit is u vertoonnaam.", + "additional": "Additional Settings", + "background": "Agtergrond", + "black_mode": "Swart", + "choose_language": "Kies taal", + "dark_mode": "Donker", + "delete_account": "Delete account", + "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", + "expand_navigation": "Expand navigation", + "experiments": "Eksperimente", + "experiments_notice": "Dit is 'n versameling eksperimente waaraan ons werk, wat nuttig, pret of beide kan wees. Hulle is nie finaal nie en is moontlik nie stabiel nie, dus moenie paniekerig raak as iets te vreemd gebeur nie. Skakel net die ding uit. Grappies eenkant,", + "extension_ver_not_reported": "Nie gerapporteer nie", + "extension_version": "Uitbreiding weergawe", + "extensions": "Uitbreidings", + "extensions_use_toggle": "Gebruik die blaaieruitbreiding om versoeke te stuur (indien teenwoordig)", + "follow": "Follow Us", + "interceptor": "Onderskepper", + "interceptor_description": "Middelware tussen toepassing en API's.", + "language": "Taal", + "light_mode": "Lig", + "official_proxy_hosting": "Amptelike volmag word aangebied deur Hoppscotch.", + "profile": "Profile", + "profile_description": "Update your profile details", + "profile_email": "Email address", + "profile_name": "Profile name", + "proxy": "Volmag", + "proxy_url": "Volmag -URL", + "proxy_use_toggle": "Gebruik die proxy -middelware om versoeke te stuur", + "read_the": "Lees die", + "reset_default": "Herstel tot standaard", + "short_codes": "Short codes", + "short_codes_description": "Short codes which were created by you.", + "sidebar_on_left": "Sidebar on left", + "sync": "Sinchroniseer", + "sync_collections": "Versamelings", + "sync_description": "Hierdie instellings word met wolk gesinkroniseer.", + "sync_environments": "Omgewings", + "sync_history": "Geskiedenis", + "system_mode": "Stelsel", + "telemetry": "Telemetrie", + "telemetry_helps_us": "Telemetrie help ons om ons bedrywighede te personaliseer en die beste ervaring aan u te lewer.", + "theme": "Tema", + "theme_description": "Pas u toepassings tema aan.", + "use_experimental_url_bar": "Gebruik 'n eksperimentele URL -balk met omgewingsverligting", + "user": "Gebruiker", + "verified_email": "Verified email", + "verify_email": "Verify email" + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Maak huidige spyskaart toe", + "command_menu": "Soek- en bevelkieslys", + "help_menu": "Help -spyskaart", + "show_all": "Sleutelbord kortpaaie", + "title": "Algemeen" + }, + "miscellaneous": { + "invite": "Nooi mense na Hoppscotch", + "title": "Diverse" + }, + "navigation": { + "back": "Gaan terug na vorige bladsy", + "documentation": "Gaan na die dokumentasiebladsy", + "forward": "Gaan vorentoe na die volgende bladsy", + "graphql": "Gaan na die GraphQL -bladsy", + "profile": "Go to Profile page", + "realtime": "Gaan na Realtime -bladsy", + "rest": "Gaan na die REST -bladsy", + "settings": "Gaan na die instellingsbladsy", + "title": "Navigasie" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "Kies DELETE metode", + "get_method": "Kies GET -metode", + "head_method": "Kies HOOF metode", + "import_curl": "Import cURL", + "method": "Metode", + "next_method": "Kies Volgende metode", + "post_method": "Kies POST -metode", + "previous_method": "Kies Vorige metode", + "put_method": "Kies PUT -metode", + "rename": "Rename Request", + "reset_request": "Herstel versoek", + "save_request": "Save Request", + "save_to_collections": "Stoor in versamelings", + "send_request": "Stuur versoek", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "Versoek", + "copy_request_link": "Kopieer versoekskakel" + }, + "response": { + "copy": "Copy response to clipboard", + "download": "Download response as file", + "title": "Response" + }, + "theme": { + "black": "Switch theme to black mode", + "dark": "Switch theme to dark mode", + "light": "Switch theme to light mode", + "system": "Switch theme to system mode", + "title": "Theme" + } + }, + "show": { + "code": "Wys kode", + "collection": "Expand Collection Panel", + "more": "Wys meer", + "sidebar": "Wys sybalk" + }, + "socketio": { + "communication": "Kommunikasie", + "connection_not_authorized": "This SocketIO connection does not use any authentication.", + "event_name": "Gebeurtenisnaam", + "events": "Gebeurtenisse", + "log": "Meld", + "url": "URL" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current team", + "edit": "Edit current team", + "invite": "Invite people to team", + "new": "Create new team", + "switch_to_personal": "Switch to your personal workspace", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "Soort gebeurtenis", + "log": "Meld", + "url": "URL" + }, + "state": { + "bulk_mode": "Grootmaatbewerking", + "bulk_mode_placeholder": "Inskrywings word geskei deur nuwe lyn\nSleutels en waardes word geskei deur:\nStel # voor op enige ry wat u wil byvoeg, maar hou dit uitgeskakel", + "cleared": "Uitgevee", + "connected": "Koppel", + "connected_to": "Gekoppel aan {naam}", + "connecting_to": "Koppel tans aan {naam} ...", + "connection_error": "Failed to connect", + "connection_failed": "Connection failed", + "connection_lost": "Connection lost", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "Na knipbord gekopieer", + "deleted": "Uitgevee", + "deprecated": "GEDRAGTEER", + "disabled": "Gestremdes", + "disconnected": "Ontkoppel", + "disconnected_from": "Ontkoppel van {name}", + "docs_generated": "Dokumentasie gegenereer", + "download_failed": "Download failed", + "download_started": "Aflaai begin", + "enabled": "Geaktiveer", + "file_imported": "Lêer ingevoer", + "finished_in": "Klaar in {duration} ms", + "hide": "Hide", + "history_deleted": "Geskiedenis uitgevee", + "linewrap": "Draai lyne toe", + "loading": "Laai tans ...", + "message_received": "Message: {message} arrived on topic: {topic}", + "mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}", + "none": "Geen", + "nothing_found": "Niks gevind vir", + "published_error": "Something went wrong while publishing msg: {topic} to topic: {message}", + "published_message": "Published message: {message} to topic: {topic}", + "reconnection_error": "Failed to reconnect", + "show": "Show", + "subscribed_failed": "Failed to subscribe to topic: {topic}", + "subscribed_success": "Successfully subscribed to topic: {topic}", + "unsubscribed_failed": "Failed to unsubscribe from topic: {topic}", + "unsubscribed_success": "Successfully unsubscribed from topic: {topic}", + "waiting_send_request": "Wag om versoek te stuur" + }, + "support": { + "changelog": "Lees meer oor die nuutste uitgawes", + "chat": "Vrae? Gesels met ons!", + "community": "Stel vrae en help ander", + "documentation": "Lees meer oor Hoppscotch", + "forum": "Stel vrae en kry antwoorde", + "github": "Follow us on Github", + "shortcuts": "Blaai vinniger deur die app", + "title": "Ondersteuning", + "twitter": "volg ons op Twitter", + "team": "Kontak die span" + }, + "tab": { + "authorization": "Magtiging", + "body": "Liggaam", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "Versamelings", + "documentation": "Dokumentasie", + "duplicate": "Duplicate Tab", + "environments": "Environments", + "headers": "Opskrifte", + "history": "Geskiedenis", + "mqtt": "MQTT", + "parameters": "Grense", + "pre_request_script": "Vooraf versoekskrif", + "queries": "Navrae", + "query": "Navraag", + "schema": "Schema", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Toetse", + "types": "Tipes", + "variables": "Veranderlikes", + "websocket": "WebSocket" + }, + "team": { + "already_member": "You are already a member of this team. Contact your team owner.", + "create_new": "Skep nuwe span", + "deleted": "Span uitgevee", + "edit": "Wysig span", + "email": "E-pos", + "email_do_not_match": "Email doesn't match with your account details. Contact your team owner.", + "exit": "Verlaat span", + "exit_disabled": "Slegs eienaar kan nie die span verlaat nie", + "failed_invites": "Failed invites", + "invalid_coll_id": "Invalid collection ID", + "invalid_email_format": "Die e -posformaat is ongeldig", + "invalid_id": "Invalid team ID. Contact your team owner.", + "invalid_invite_link": "Invalid invite link", + "invalid_invite_link_description": "The link you followed is invalid. Contact your team owner.", + "invalid_member_permission": "Gee 'n geldige toestemming aan die spanlid", + "invite": "Invite", + "invite_more": "Invite more", + "invite_tooltip": "Invite people to this workspace", + "invited_to_team": "{owner} invited you to join {team}", + "join": "Invitation accepted", + "join_team": "Join {team}", + "joined_team": "You have joined {team}", + "joined_team_description": "You are now a member of this team", + "left": "Jy het die span verlaat", + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to join a team.", + "logout_and_try_again": "Logout and sign in with another account", + "member_has_invite": "This email ID already has an invite. Contact your team owner.", + "member_not_found": "Member not found. Contact your team owner.", + "member_removed": "Gebruiker verwyder", + "member_role_updated": "Gebruikersrolle opgedateer", + "members": "Lede", + "more_members": "+{count} more", + "name_length_insufficient": "Spannaam moet ten minste 6 karakters lank wees", + "name_updated": "Team name updated", + "new": "Nuwe span", + "new_created": "Nuwe span geskep", + "new_name": "My nuwe span", + "no_access": "U het nie redigeertoegang tot hierdie versamelings nie", + "no_invite_found": "Invitation not found. Contact your team owner.", + "no_request_found": "Request not found.", + "not_found": "Team not found. Contact your team owner.", + "not_valid_viewer": "You are not a valid viewer. Contact your team owner.", + "parent_coll_move": "Cannot move collection to a child collection", + "pending_invites": "Pending invites", + "permissions": "Toestemmings", + "same_target_destination": "Same target and destination", + "saved": "Span gered", + "select_a_team": "Select a team", + "success_invites": "Success invites", + "title": "Spanne", + "we_sent_invite_link": "We sent an invite link to all invitees!", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "Ask all invitees to check their inbox. Click on the link to join the team.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "Sluit aan by die beta -program om toegang tot spanne te kry." + }, + "team_environment": { + "deleted": "Environment Deleted", + "duplicate": "Environment Duplicated", + "not_found": "Environment not found." + }, + "test": { + "failed": "test failed", + "javascript_code": "JavaScript -kode", + "learn": "Lees dokumentasie", + "passed": "test passed", + "report": "Toetsverslag", + "results": "Toets resultate", + "script": "Skrif", + "snippets": "Brokkies" + }, + "websocket": { + "communication": "Kommunikasie", + "log": "Meld", + "message": "Boodskap", + "protocols": "Protokolle", + "url": "URL" + }, + "workspace": { + "change": "Change workspace", + "personal": "My Workspace", + "other_workspaces": "My Workspaces", + "team": "Team Workspace", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "Actions", + "created_on": "Created on", + "deleted": "Shortcode deleted", + "method": "Method", + "not_found": "Shortcode not found", + "short_code": "Short code", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/ar.json b/packages/hoppscotch-common/locales/ar.json new file mode 100644 index 0000000..3723ec9 --- /dev/null +++ b/packages/hoppscotch-common/locales/ar.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "إضافة", + "autoscroll": "التمرير التلقائي", + "cancel": "إلغاء", + "choose_file": "اختر ملف", + "clear": "محو", + "clear_all": "محو الكل", + "clear_history": "محو السجل", + "close": "إغلاق", + "connect": "اتصال", + "connecting": "جارٍ الاتصال", + "copy": "نسخ", + "create": "إنشاء", + "delete": "حذف", + "disconnect": "قطع الاتصال", + "dismiss": "تجاهل", + "dont_save": "لا تحفظ", + "download_file": "تحميل الملف", + "drag_to_reorder": "سحب لإعادة الترتيب", + "duplicate": "تكرار", + "edit": "تعديل", + "filter": "تصفية", + "go_back": "رجوع", + "go_forward": "تقدم", + "group_by": "تجميع حسب", + "hide_secret": "إخفاء السر", + "label": "ملصق", + "learn_more": "اقرأ المزيد", + "download_here": "تنزيل هنا", + "less": "أقل", + "more": "المزيد", + "new": "جديد", + "no": "لا", + "open_workspace": "افتح مساحة العمل", + "paste": "لصق", + "prettify": "تجميل", + "properties": "خصائص", + "remove": "إزالة", + "rename": "إعادة تسمية", + "restore": "استعادة", + "save": "حفظ", + "scroll_to_bottom": "التمرير إلى الأسفل", + "scroll_to_top": "التمرير إلى الأعلى", + "search": "بحث", + "send": "إرسال", + "share": "مشاركة", + "show_secret": "عرض السر", + "start": "ابدأ", + "starting": "جارٍ البدء", + "stop": "إيقاف", + "to_close": "للإغلاق", + "to_navigate": "للتنقل", + "to_select": "للاختيار", + "turn_off": "إيقاف التشغيل", + "turn_on": "تشغيل", + "undo": "تراجع", + "yes": "نعم" + }, + "add": { + "new": "اضف جديد", + "star": "أضف نجمة" + }, + "app": { + "chat_with_us": "دردش معنا", + "contact_us": "اتصل بنا", + "cookies": "ملفات تعريف الارتباط", + "copy": "نسخ", + "copy_interface_type": "نسخ نوع الواجهة", + "copy_user_id": "نسخ رمز تعريف المستخدم", + "developer_option": "خيارات المطور", + "developer_option_description": "أدوات المطور التي تساعد في تطوير وصيانة هوبسكوتش.", + "discord": "Discord", + "documentation": "توثيق", + "github": "GitHub", + "help": "المساعدة والتعليقات والتوثيق", + "home": "الصفحة الرئيسية", + "invite": "دعوة", + "invite_description": "هوبسكوتش هو نظام بيئي مفتوح المصدر لتطوير API. قمنا بتصميم واجهة بسيطة وبديهية لإنشاء وإدارة API الخاصة بك. هوبسكوتش هي أداة تساعدك على بناء واختبار وتوثيق ومشاركة API الخاصة بك.", + "invite_your_friends": "ادعو أصدقائك", + "join_discord_community": "انضم إلى مجتمع Discord الخاص بنا", + "keyboard_shortcuts": "اختصارات لوحة المفاتيح", + "name": "هوبسكوتش", + "new_version_found": "تم العثور على نسخة جديدة. قم بالتحديث الآن.", + "open_in_hoppscotch": "افتح في هوبسكوتش", + "options": "خيارات", + "proxy_privacy_policy": "سياسة خصوصية الوكيل", + "reload": "إعادة تحميل", + "search": "بحث", + "share": "مشاركة", + "shortcuts": "الاختصارات", + "social_description": "تابعنا على وسائل التواصل الاجتماعي للبقاء على اطلاع بآخر الأخبار والتحديثات والإصدارات.", + "social_links": "روابط اجتماعية", + "spotlight": "أضواء كاشفة", + "status": "حالة", + "status_description": "تحقق من حالة الموقع", + "terms_and_privacy": "الشروط والخصوصية", + "twitter": "Twitter", + "type_a_command_search": "اكتب أمرًا أو ابحث...", + "we_use_cookies": "نحن نستخدم ملفات تعريف الارتباط", + "updated_text": "تم تحديث هوبسكوتش إلى الإصدار {version} 🎉", + "whats_new": "ما الجديد؟", + "see_whats_new": "شاهد ما الجديد", + "wiki": "ويكي" + }, + "auth": { + "account_exists": "الحساب موجود ببيانات اعتماد مختلفة - تسجيل الدخول لربط كلا الحسابين", + "all_sign_in_options": "كل خيارات تسجيل الدخول", + "continue_with_auth_provider": "المواصلة من خلال {provider}", + "continue_with_email": "تواصل مع البريد الإلكتروني", + "continue_with_github": "المواصلة من خلال جيت هاب", + "continue_with_github_enterprise": "المواصلة من خلال جيت هاب Enterprise", + "continue_with_google": "المواصلة من خلال جوجل", + "continue_with_microsoft": "المواصلة من خلال مايكروسوفت", + "email": "بريد إلكتروني", + "logged_out": "تسجيل الخروج", + "login": "تسجيل الدخول", + "login_success": "تم تسجيل الدخول بنجاح", + "login_to_hoppscotch": "تسجيل الدخول إلى هوبسكوتش", + "logout": "تسجيل خروج", + "re_enter_email": "أعد إدخال البريد الإلكتروني", + "send_magic_link": "أرسل رابطًا سحريًا", + "sync": "مزامنة", + "we_sent_magic_link": "أرسلنا لك رابط سحري!", + "we_sent_magic_link_description": "تحقق من بريدك الوارد - لقد أرسلنا بريدًا إلكترونيًا إلى {email}. يحتوي على رابط سحري سيسجل دخولك." + }, + "authorization": { + "generate_token": "توليد رمز الوصول", + "graphql_headers": "تُرسل رؤوس التفويض كجزء من الحمولة إلى `connection_init`", + "include_in_url": "تضمين في URL", + "inherited_from": "موروثة من {auth} من المجموعة الأصلية {collection}", + "learn": "تعلم كيف", + "oauth": { + "redirect_auth_server_returned_error": "عاد خادم Authorization بحالة خطأ", + "redirect_auth_token_request_failed": "فشل الطلب للحصول على رمز Authorization", + "redirect_auth_token_request_invalid_response": "استجابة غير صالحة من الرمز عند طلب رمز Authorization endpoint", + "redirect_invalid_state": "قيمة حالة غير صالحة موجودة في إعادة التوجيه", + "redirect_no_auth_code": "لا يوجد رمز تفويض في إعادة التوجيه", + "redirect_no_client_id": "لم يتم تعريف رقم العميل", + "redirect_no_client_secret": "لم يتم تعريف سر العميل", + "redirect_no_code_verifier": "لم يتم تعريف محقق الشيفرة", + "redirect_no_token_endpoint": "لم يتم تعريف endpoint الرمز", + "something_went_wrong_on_oauth_redirect": "حدث خطأ أثناء إعادة توجيه OAuth", + "something_went_wrong_on_token_generation": "حدث خطأ أثناء توليد الرمز", + "token_generation_oidc_discovery_failed": "فشل توليد الرمز: فشل اكتشاف OpenID Connect", + "grant_type": "نوع المنح", + "grant_type_auth_code": "رمز Authorization", + "token_fetched_successfully": "تم الحصول على الرمز بنجاح", + "token_fetch_failed": "فشل الحصول على الرمز", + "validation_failed": "فشل التحقق، يرجى التحقق من حقول النموذج", + "label_authorization_endpoint": "Authorization endpoint", + "label_client_id": "معرف العميل", + "label_client_secret": "سر العميل", + "label_code_challenge": "تحدي الشيفرة", + "label_code_challenge_method": "طريقة تحدي الشيفرة", + "label_code_verifier": "محقق الشيفرة", + "label_scopes": "النطاقات", + "label_token_endpoint": "endpoint الرمز", + "label_use_pkce": "استخدم PKCE", + "label_implicit": "ضمني", + "label_password": "كلمة المرور", + "label_username": "اسم المستخدم", + "label_auth_code": "رمز Authorization", + "label_client_credentials": "بيانات اعتماد العميل" + }, + "pass_key_by": "تمرير بواسطة", + "pass_by_query_params_label": "معلمات الاستعلام", + "pass_by_headers_label": "الرؤوس", + "password": "كلمة المرور", + "save_to_inherit": "يرجى حفظ هذا الطلب في أي مجموعة لتوريث Authorization", + "token": "رمز الوصول", + "type": "نوع Authorization", + "username": "اسم المستخدم" + }, + "collection": { + "created": "تم إنشاء المجموعة", + "different_parent": "لا يمكن إعادة ترتيب المجموعة مع والد مختلف", + "edit": "تحرير المجموعة", + "import_or_create": "استيراد أو إنشاء مجموعة", + "import_collection": "استيراد مجموعة", + "invalid_name": "الرجاء تقديم اسم صالح للمجموعة", + "invalid_root_move": "المجموعة موجودة بالفعل في الجذر", + "moved": "تم النقل بنجاح", + "my_collections": "مجموعاتي", + "name": "مجموعتي الجديدة", + "name_length_insufficient": "يجب أن لا يقل اسم المجموعة عن 3 رموز", + "new": "مجموعة جديدة", + "order_changed": "تم تحديث ترتيب المجموعة", + "properties": "خصائص المجموعة", + "properties_updated": "تم تحديث خصائص المجموعة", + "renamed": "تمت إعادة تسمية المجموعة", + "request_in_use": "الطلب قيد الاستخدام", + "save_as": "حفظ باسم", + "save_to_collection": "حفظ إلى المجموعة", + "select": "حدد مجموعة", + "select_location": "اختر موقعا", + "details": "تفاصيل", + "select_team": "اختر فريقًا", + "team_collections": "مجموعات الفريق" + }, + "confirm": { + "close_unsaved_tab": "هل أنت متأكد أنك تريد إغلاق هذه التبويبة؟", + "close_unsaved_tabs": "هل أنت متأكد أنك تريد إغلاق جميع علامات التبويب سيتم فقدان {count} من التبويبات غير المحفوظة.", + "exit_team": "هل أنت متأكد أنك تريد مغادرة هذا الفريق؟", + "logout": "هل أنت متأكد أنك تريد تسجيل الخروج؟", + "remove_collection": "هل أنت متأكد أنك تريد حذف هذه المجموعة نهائيًا؟", + "remove_environment": "هل أنت متأكد أنك تريد حذف هذه البيئة بشكل دائم؟", + "remove_folder": "هل أنت متأكد أنك تريد حذف هذا المجلد نهائيًا؟", + "remove_history": "هل أنت متأكد أنك تريد حذف كل المحفوظات بشكل دائم؟", + "remove_request": "هل أنت متأكد أنك تريد حذف هذا الطلب نهائيًا؟", + "remove_shared_request": "هل أنت متأكد أنك تريد حذف هذا الطلب المشترك نهائيًا؟", + "remove_team": "هل أنت متأكد أنك تريد حذف هذا الفريق؟", + "remove_telemetry": "هل أنت متأكد أنك تريد الانسحاب من القياس عن بعد؟", + "request_change": "هل أنت متأكد أنك تريد تجاهل الطلب الحالي؟ سيتم فقدان التغييرات غير المحفوظة.", + "save_unsaved_tab": "هل تريد حفظ التغييرات التي أجريتها في هذه التبويبة؟", + "sync": "هل أنت متأكد أنك تريد مزامنة مساحة العمل هذه؟", + "delete_access_token": "هل أنت متأكد أنك تريد حذف رمز الوصول {tokenLabel}؟" + }, + "context_menu": { + "add_parameters": "إضافة إلى parameters", + "open_request_in_new_tab": "فتح الطلب في تبويبة جديدة", + "set_environment_variable": "تعيين كمتغير" + }, + "cookies": { + "modal": { + "cookie_expires": "تنتهي في", + "cookie_name": "الاسم", + "cookie_path": "المسار", + "cookie_string": "نص الكوكي", + "cookie_value": "القيمة", + "empty_domain": "النطاق فارغ", + "empty_domains": "قائمة النطاقات فارغة", + "enter_cookie_string": "أدخل نص الكوكي", + "interceptor_no_support": "الاعتراض المحدد حاليًا لا يدعم الكوكيز. اختر معترضًا مختلفًا وحاول مرة أخرى.", + "managed_tab": "مدار", + "new_domain_name": "اسم نطاق جديد", + "no_cookies_in_domain": "لا توجد كوكيز محددة لهذا النطاق", + "raw_tab": "خام", + "set": "تعيين كوكي" + } + }, + "count": { + "header": "رأس {count}", + "message": "رسالة {count}", + "parameter": "المعلمة {count}", + "protocol": "البروتوكول {count}", + "value": "قيمة {count}", + "variable": "متغير {count}" + }, + "documentation": { + "generate": "توليد docs", + "generate_message": "قم باستيراد أي مجموعة هوبسكوتش لإنشاء API docs أثناء التنقل." + }, + "empty": { + "authorization": "هذا الطلب لا يستخدم أي إذن", + "body": "هذا الطلب ليس له هيئة", + "collection": "المجموعة فارغة", + "collections": "المجموعات فارغة", + "documentation": "اتصل ب GraphQL endpoint لعرض الوثائق", + "endpoint": "لا يمكن أن تكون endpoint فارغة", + "environments": "البيئات فارغة", + "folder": "مجلد فارغ", + "headers": "لا يحتوي هذا الطلب على أية Headers", + "history": "التاريخ فارغ", + "invites": "قائمة الدعوات فارغة", + "members": "الفريق فارغ", + "parameters": "هذا الطلب لا يحتوي على أي معلمات", + "pending_invites": "لاتوجد اي دعوات معلقة لهذا الفريق", + "profile": "سجل الدخول لرؤية فريقك", + "protocols": "البروتوكولات فارغة", + "request_variables": "لا يحتوي هذا الطلب على أي متغيرات ", + "schema": " اتصل ب GraphQL endpoint", + "secret_environments": "الأسرار غير متزامنة مع هوبسكوتش", + "shared_requests": "الطلبات المشتركة فارغة", + "shared_requests_logout": "سجل الدخول لعرض طلباتك المشتركة أو لإنشاء طلب جديد", + "subscription": "الاشتراكات فارغة", + "team_name": "اسم الفريق فارغ", + "teams": "الفرق فارغة", + "tests": "لا توجد tests لهذا الطلب", + "access_tokens": "رموز الوصول فارغة", + "shortcodes": "الرموز القصيرة فارغة" + }, + "environment": { + "add_to_global": "أضف إلى العام", + "added": "تمت إضافة البيئة", + "create_new": "إنشاء بيئة جديدة", + "created": "تم إنشاء البيئة", + "deleted": "تم حذف البيئة", + "duplicated": "تم تكرار البيئة", + "edit": "تحرير البيئة", + "empty_variables": "لا توجد متغيرات", + "global": "عام", + "global_variables": "المتغيرات العامة", + "import_or_create": "استيراد أو إنشاء بيئة", + "invalid_name": "يرجى كنابة اسم صحيح للبيئة", + "list": "متغيرات البيئة", + "my_environments": "البيئات الخاصة بي", + "name": "الاسم", + "nested_overflow": "المتغيرات البيئية المتداخلة محدودة بـ 10 مستويات", + "new": "بيئة جديدة", + "no_active_environment": "لا توجد بيئة نشطة", + "no_environment": "لا توجد بيئة", + "no_environment_description": "لم يتم اختيار أي بيئات. اختر ما تريد القيام به مع المتغيرات التالية.", + "quick_peek": "نظرة سريعة على البيئة", + "replace_with_variable": "استبدال بمتغير", + "scope": "نطاق", + "secrets": "الأسرار", + "secret_value": "القيمة السرية", + "select": "اختر البيئة", + "set": "تعيين البيئة", + "set_as_environment": "تعيين كبيئة", + "team_environments": "بيئات العمل", + "title": "البيئات", + "updated": "تم تحديث البيئة", + "value": "القيمة", + "variable": "متغير", + "variables": "المتغيرات", + "variable_list": "قائمة المتغيرات", + "properties": "خصائص البيئة", + "details": "التفاصيل" + }, + "error": { + "authproviders_load_error": "غير قادر على تحميل مقدمي Authorization", + "browser_support_sse": "يبدو أن هذا المتصفح لا يدعم Server Sent Events.", + "check_console_details": "تحقق من سجل وحدة التحكم للحصول على التفاصيل.", + "check_how_to_add_origin": "تحقق من كيفية إضافة مصدر", + "curl_invalid_format": "صيغة cURL غير صحيحة", + "danger_zone": "منطقة الخطر", + "delete_account": "حسابك حاليًا هو مالك في مساحات العمل التالية:", + "delete_account_description": "يجب عليك إما إزالة نفسك أو نقل الملكية أو حذف هذه مساحات العمل قبل أن تتمكن من حذف حسابك.", + "empty_email_address": "لا يُمكن ترك حقل عنوان البريد الإلكتروني فارغًا.", + "empty_profile_name": "لا يمكن أن يكون اسم الملف الشخصي فارغًا", + "empty_req_name": "اسم الطلب فارغ", + "f12_details": "(F12 للتفاصيل)", + "gql_prettify_invalid_query": "لم يتمكن من تجميل استعلام غير صالح، حل أخطاء بناء الجملة وحاول مرة أخرى", + "incomplete_config_urls": "عناوين URL الخاصة بالتكوين غير مكتملة", + "incorrect_email": "البريد الإلكتروني غير صحيح", + "invalid_link": "الرابط غير صالح", + "invalid_link_description": "الرابط الذي نقرت عليه غير صالح أو منتهي الصلاحية.", + "invalid_embed_link": "التضمين غير موجود أو غير صالح.", + "json_parsing_failed": "JSON غير صالح", + "json_prettify_invalid_body": "لم يتمكن من تجميل نص غير صالح، حل أخطاء بناء الجملة في JSON وحاول مرة أخرى", + "network_error": "يبدو أن هناك خطأ في الشبكة. حاول مرة أخرى.", + "network_fail": "لم يتمكن من إرسال الطلب", + "no_collections_to_export": "لا توجد مجموعات للتصدير. يرجى إنشاء مجموعة للبدء.", + "no_duration": "لا يوجد مدة", + "no_environments_to_export": "لا توجد بيئات للتصدير. يرجى إنشاء بيئة للبدء.", + "no_results_found": "لم يتم العثور على نتائج", + "page_not_found": "لا يمكن العثور على هذه الصفحة", + "please_install_extension": "يرجى تثبيت الإضافة وإضافة المصدر إلى الإضافة.", + "proxy_error": "خطأ في الوكيل", + "same_email_address": "عنوان البريد الإلكتروني المُحدّث هو نفسه عنوان البريد الإلكتروني الحالي.", + "same_profile_name": "اسم الملف الشخصي المحدث هو نفسه اسم الملف الشخصي الحالي", + "script_fail": "لم يتمكن من تنفيذ نص الطلب المسبق", + "something_went_wrong": "حدث خطأ ما", + "post_request_script_fail": "لم يتمكن من تنفيذ نص ما بعد الطلب", + "reading_files": "حدث خطأ أثناء قراءة واحد أو أكثر من الملفات.", + "fetching_access_tokens_list": "حدث خطأ أثناء جلب قائمة الرموز", + "generate_access_token": "حدث خطأ أثناء توليد رمز الوصول", + "delete_access_token": "حدث خطأ أثناء حذف رمز الوصول" + }, + "export": { + "as_json": "تصدير كملف JSON", + "create_secret_gist": "إنشاء Gist سري", + "create_secret_gist_tooltip_text": "تصدير كـ Gist سري", + "failed": "حدث خطأ أثناء التصدير", + "secret_gist_success": "تم التصدير بنجاح كـ Gist سري", + "require_github": "تسجيل الدخول باستخدام GitHub لإنشاء Gist سري", + "title": "تصدير", + "success": "تم التصدير بنجاح", + "gist_created": "تم إنشاء Gist" + }, + "filter": { + "all": "الكل", + "none": "لا شيء", + "starred": "المفضلة" + }, + "folder": { + "created": "تم إنشاء المجلد", + "edit": "تحرير المجلد", + "invalid_name": "يرجى تقديم اسم للمجلد", + "name_length_insufficient": "يجب أن يتكون اسم المجلد من 3 أحرف على الأقل", + "new": "مجلد جديد", + "renamed": "تمت إعادة تسمية المجلد" + }, + "graphql": { + "connection_switch_confirm": "هل تريد الاتصال بنقطة النهاية الأخيرة لـ GraphQL؟", + "connection_switch_new_url": "التبديل إلى علامة تبويب أخرى سيفصلك عن الاتصال النشط بـ GraphQL. عنوان URL للاتصال الجديد هو", + "connection_switch_url": "أنت متصل بنقطة نهاية GraphQL وعنوان URL للاتصال هو", + "mutations": "التغييرات", + "schema": "المخطط", + "subscriptions": "الاشتراكات", + "switch_connection": "تبديل الاتصال", + "url_placeholder": "أدخل عنوان URL لنقطة نهاية GraphQL" + }, + "graphql_collections": { + "title": "مجموعات GraphQL" + }, + "group": { + "time": "الوقت", + "url": "الرابط" + }, + "header": { + "install_pwa": "تثبيت التطبيق", + "login": "تسجيل الدخول", + "save_workspace": "حفظ مساحة العمل الخاصة بي" + }, + "helpers": { + "authorization": "سيتم إنشاء رأس التفويض تلقائيًا عند إرسال الطلب.", + "collection_properties_authorization": "سيتم تعيين هذا التفويض لكل طلب في هذه المجموعة.", + "collection_properties_header": "سيتم تعيين هذا الرأس لكل طلب في هذه المجموعة.", + "generate_documentation_first": "قم بإنشاء الوثائق أولاً", + "network_fail": "تعذر الوصول إلى نقطة النهاية الخاصة بـ API. تحقق من اتصال الشبكة الخاص بك أو اختر Interceptor مختلفًا وحاول مرة أخرى.", + "offline": "أنت تستخدم Hoppscotch دون اتصال. سيتم مزامنة التحديثات عندما تكون متصلاً بالإنترنت، بناءً على إعدادات مساحة العمل.", + "offline_short": "أنت تستخدم Hoppscotch دون اتصال.", + "post_request_tests": "يتم كتابة نصوص الاختبار بلغة JavaScript، ويتم تشغيلها بعد استلام الاستجابة.", + "pre_request_script": "يتم كتابة نصوص ما قبل الطلب بلغة JavaScript، ويتم تشغيلها قبل إرسال الطلب.", + "script_fail": "يبدو أن هناك خللاً في نص ما قبل الطلب. تحقق من الخطأ أدناه وقم بإصلاح النص وفقًا لذلك.", + "post_request_script_fail": "يبدو أن هناك خطأ في نص الاختبار. يرجى إصلاح الأخطاء وإعادة تشغيل الاختبارات مرة أخرى", + "post_request_script": "اكتب نص اختبار لأتمتة التصحيح." + }, + "hide": { + "collection": "طي لوحة المجموعة", + "more": "إخفاء المزيد", + "preview": "إخفاء المعاينة", + "sidebar": "طي الشريط الجانبي" + }, + "import": { + "collections": "استيراد المجموعات", + "curl": "استيراد cURL", + "environments_from_gist": "استيراد من Gist", + "environments_from_gist_description": "استيراد بيئات Hoppscotch من Gist", + "failed": "حدث خطأ أثناء الاستيراد: التنسيق غير معترف به", + "from_file": "استيراد من ملف", + "from_gist": "استيراد من Gist", + "from_gist_description": "استيراد من رابط Gist", + "from_insomnia": "استيراد من Insomnia", + "from_insomnia_description": "استيراد من مجموعة Insomnia", + "from_json": "استيراد من Hoppscotch", + "from_json_description": "استيراد من ملف مجموعة Hoppscotch", + "from_my_collections": "استيراد من مجموعاتي الشخصية", + "from_my_collections_description": "استيراد من ملف مجموعاتي الشخصية", + "from_openapi": "استيراد من OpenAPI", + "from_openapi_description": "استيراد من ملف مواصفات OpenAPI (YML/JSON)", + "from_postman": "استيراد من Postman", + "from_postman_description": "استيراد من مجموعة Postman", + "from_url": "استيراد من رابط URL", + "gist_url": "أدخل رابط Gist", + "gql_collections_from_gist_description": "استيراد مجموعات GraphQL من Gist", + "hoppscotch_environment": "بيئة Hoppscotch", + "hoppscotch_environment_description": "استيراد ملف JSON لبيئة Hoppscotch", + "import_from_url_invalid_fetch": "تعذر الحصول على البيانات من الرابط", + "import_from_url_invalid_file_format": "حدث خطأ أثناء استيراد المجموعات", + "import_from_url_invalid_type": "نوع غير مدعوم. القيم المقبولة هي 'hoppscotch'، 'openapi'، 'postman'، 'insomnia'", + "import_from_url_success": "تم استيراد المجموعات بنجاح", + "insomnia_environment_description": "استيراد بيئة Insomnia من ملف JSON/YAML", + "json_description": "استيراد المجموعات من ملف JSON الخاص بـ Hoppscotch", + "postman_environment": "بيئة Postman", + "postman_environment_description": "استيراد بيئة Postman من ملف JSON", + "title": "استيراد", + "file_size_limit_exceeded_warning_multiple_files": "الملفات المختارة تتجاوز الحد الموصى به وهو 10MB. سيتم استيراد الملفات {files} الأولى المختارة فقط", + "file_size_limit_exceeded_warning_single_file": "الملف المختار حاليًا يتجاوز الحد الموصى به وهو 10MB. يرجى اختيار ملف آخر.", + "success": "تم الاستيراد بنجاح" + }, + "inspections": { + "description": "فحص الأخطاء المحتملة", + "environment": { + "add_environment": "إضافة إلى البيئة", + "add_environment_value": "إضافة قيمة", + "empty_value": "قيمة البيئة فارغة للمتغير '{variable}'", + "not_found": "لم يتم العثور على متغير البيئة “{environment}”." + }, + "header": { + "cookie": "لا يسمح المتصفح لـ هوبسكوتش بتعيين عنوان كوكي. بينما نعمل على تطبيق هوبسكوتش لسطح المكتب (قريبًا)، يرجى استخدام عنوان Authorization بدلاً من ذلك." + }, + "response": { + "401_error": "يرجى التحقق من بيانات اعتماد المصادقة الخاصة بك.", + "404_error": "يرجى التحقق من عنوان URL للطلب ونوع الطريقة.", + "cors_error": "يرجى التحقق من تكوين مشاركة الموارد عبر المواقع.", + "default_error": "يرجى التحقق من طلبك.", + "network_error": "يرجى التحقق من اتصال الشبكة الخاص بك." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "الاضافة غير مثبتة", + "extension_unknown_origin": "تأكد من أنك أضفت أصل نقطة نهاية API إلى قائمة ملحق متصفح هوبسكوتش.", + "extention_enable_action": "تفعيل الاضافة", + "extention_not_enabled": "الاضافة غير مفعلة" + } + }, + "layout": { + "collapse_collection": "طي أو توسيع المجموعات", + "collapse_sidebar": "طي أو توسيع الشريط الجانبي", + "column": "تصميم عمودي", + "name": "التصميم", + "row": "تصميم أفقي" + }, + "modal": { + "close_unsaved_tab": "لديك تغييرات غير محفوظة", + "collections": "المجموعات", + "confirm": "تأكيد", + "customize_request": "تخصيص الطلب", + "edit_request": "تحرير الطلب", + "import_export": "استيراد وتصدير", + "share_request": "مشاركة الطلب" + }, + "mqtt": { + "already_subscribed": "أنت مشترك بالفعل في هذا الموضوع.", + "clean_session": "جلسة نظيفة", + "clear_input": "مسح المدخلات", + "clear_input_on_send": "مسح المدخلات عند الإرسال", + "client_id": "رقم العميل", + "color": "اختر لونًا", + "communication": "تواصل", + "connection_config": "تكوين الاتصال", + "connection_not_authorized": "هذا الاتصال بـ MQTT لا يستخدم أي مصادقة.", + "invalid_topic": "يرجى تقديم موضوع للاشتراك", + "keep_alive": "الحفاظ على الاتصال", + "log": "سجل", + "lw_message": "رسالة آخر مشيئة", + "lw_qos": "آخر مشيئة QoS", + "lw_retain": "آخر مشيئة احتفاظ", + "lw_topic": "موضوع آخر مشيئة", + "message": "رسالة", + "new": "اشتراك جديد", + "not_connected": "يرجى بدء اتصال MQTT أولاً.", + "publish": "نشر", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "اشتراك", + "topic": "موضوع", + "topic_name": "اسم الموضوع", + "topic_title": "نشر / اشتراك الموضوع", + "unsubscribe": "إلغاء الاشتراك", + "url": "URL" + }, + "navigation": { + "doc": "المستندات", + "graphql": "GraphQL", + "profile": "الملف الشخصي", + "realtime": "في الوقت الحالى", + "rest": "REST", + "settings": "إعدادات" + }, + "preRequest": { + "javascript_code": "كود جافا سكريبت", + "learn": "اقرأ الوثائق", + "script": "البرنامج النصي للطلب المسبق", + "snippets": "المقتطفات" + }, + "profile": { + "app_settings": "إعدادات التطبيق", + "default_hopp_displayname": "مستخدم بلا اسم", + "editor": "محرر", + "editor_description": "المحررين يمكنهم اضافة و تعديل و حذف الطلبات.", + "email_verification_mail": "تم إرسال رابط التحقق إلى بريدك الإلكتروني. الرجاء الضغط على الرابط لتأكيد بريدك الإلكتروني.", + "no_permission": "ليس لديك الصلاحية للقيام بهذه العملية.", + "owner": "مالك", + "owner_description": "المالكين يمكنهم اضافة و تعديل و حذف الطلبات و المجموعات و اعضاء الفريق.", + "roles": "الأدوار", + "roles_description": "تُستخدم الأدوار للتحكم في الوصول إلى المجموعات المشتركة.", + "updated": "تم تحديث الملف الشخصي.", + "viewer": "مشاهد", + "viewer_description": "المشاهدون يمكنهم فقط رؤية و استعمال الطلبات." + }, + "remove": { + "star": "إزالة النجمة" + }, + "request": { + "added": "تمت إضافة الطلب", + "authorization": "Authorization", + "body": "Body", + "choose_language": "اختر اللغة", + "content_type": "Content-Type", + "content_type_titles": { + "others": "أخرى", + "structured": "Structured", + "text": "نص" + }, + "different_collection": "لا يمكن إعادة ترتيب الطلبات من مجموعات مختلفة", + "duplicated": "تم تكرار الطلب", + "duration": "مدة", + "enter_curl": "أدخل cURL", + "generate_code": "إنشاء الكود", + "generated_code": "رمز تم إنشاؤه", + "go_to_authorization_tab": "الانتقال إلى علامة التبويب Authorization", + "go_to_body_tab": "الانتقال إلى علامة التبويب Body", + "header_list": "قائمة Header", + "invalid_name": "يرجى تقديم اسم للطلب", + "method": "طريقة", + "moved": "تم نقل الطلب", + "name": "اسم الطلب", + "new": "طلب جديد", + "order_changed": "تم تحديث ترتيب الطلبات", + "override": "Override", + "override_help": "Set Content-Type in Headers", + "overriden": "Overridden", + "parameter_list": "قائمة Parameters", + "parameters": "Parameters", + "path": "طريق", + "payload": "Payload", + "query": "Query", + "raw_body": "نص طلب خام", + "rename": "إعادة تسمية الطلب", + "renamed": "تمت إعادة تسمية الطلب", + "request_variables": "متغيرات الطلب", + "run": "تشغيل", + "save": "حفظ", + "save_as": "حفظ باسم", + "saved": "تم حفظ الطلب", + "share": "مشاركة", + "share_description": "شارك هوبسكوتش مع أصدقائك", + "share_request": "مشاركة الطلب", + "stop": "إيقاف", + "title": "طلب", + "type": "نوع الطلب", + "url": "URL", + "url_placeholder": "أدخل URL أو الصق أمر cURL", + "variables": "المتغيرات", + "view_my_links": "عرض روابطى", + "copy_link": "نسخ الرابط" + }, + "response": { + "audio": "صوت", + "body": "Body", + "filter_response_body": "Filter JSON response body (uses jq syntax)", + "headers": "Headers", + "html": "HTML", + "image": "صورة", + "json": "JSON", + "pdf": "PDF", + "preview_html": "معاينة HTML", + "raw": "خام", + "size": "مقاس", + "status": "حالة", + "time": "وقت", + "title": "الاستجابة", + "video": "فيديو", + "waiting_for_connection": "في انتظار الاتصال", + "xml": "XML" + }, + "settings": { + "accent_color": "لون التمييز", + "account": "حساب", + "account_deleted": "تم حذف الحساب", + "account_description": "تخصيص إعدادات حسابك.", + "account_email_description": "عنوان بريدك الإلكتروني الأساسي.", + "account_name_description": "هذا هو اسم العرض الخاص بك.", + "additional": "المزيد من الإعدادات", + "background": "خلفية", + "black_mode": "أسود", + "choose_language": "اختر اللغة", + "dark_mode": "داكن", + "delete_account": "Delete account", + "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", + "expand_navigation": "توسيع التنقل", + "experiments": "التجارب", + "experiments_notice": "هذه مجموعة من التجارب التي نعمل عليها والتي قد تكون مفيدة ، أو ممتعة ، أو كليهما ، أو لا شيء. إنها ليست نهائية وقد لا تكون مستقرة ، لذلك إذا حدث شيء غريب للغاية ، فلا داعي للذعر. فقط قم بإيقاف تشغيل الشيء. النكات جانبا،", + "extension_ver_not_reported": "لم يبلغ عنها", + "extension_version": "نسخة التمديد", + "extensions": "الاضافات", + "extensions_use_toggle": "استخدم اضافة المتصفح لإرسال الطلبات (إن وجدت)", + "follow": "تابعنا", + "interceptor": "المعترض", + "interceptor_description": "البرامج الوسيطة بين التطبيق وواجهات برمجة التطبيقات.", + "language": "اللغة", + "light_mode": "الوضع الفاتح", + "official_proxy_hosting": "يستضيف هوبسكوتش الوكيل الرسمي.", + "profile": "الملف الشخصي", + "profile_description": "قم بتحديث بيانات ملفك الشخصي", + "profile_email": "البريد الإلكتروني", + "profile_name": "الاسم", + "proxy": "الوكيل", + "proxy_url": "وكيل URL", + "proxy_use_toggle": "استخدم البرنامج الوسيط الوكيل لإرسال الطلبات", + "read_the": "إقرأ ال", + "reset_default": "الرجوع إلى الإعدادات الافتراضية", + "short_codes": "الرموز القصيرة", + "short_codes_description": "الرموز القصيرة التي أنشأتها.", + "sidebar_on_left": "الشريط الجانبي على اليسار", + "sync": "تزامن", + "sync_collections": "المجموعات", + "sync_description": "تتم مزامنة هذه الإعدادات مع السحابة.", + "sync_environments": "البيئات", + "sync_history": "تاريخ", + "system_mode": "وضع النظام", + "telemetry": "القياس عن بعد", + "telemetry_helps_us": "يساعدنا القياس عن بعد على تخصيص عملياتنا وتقديم أفضل تجربة لك.", + "theme": "سمة", + "theme_description": "تخصيص موضوع التطبيق الخاص بك.", + "use_experimental_url_bar": "استخدم شريط URL التجريبي مع تمييز البيئة", + "user": "المستخدم", + "verified_email": "بريد إلكتروني موثق", + "verify_email": "تأكيد البريد الإلكتروني" + }, + "shared_requests": { + "button": "زر", + "button_info": "أنشئ زر 'تشغيل في هوبسكوتش' لموقعك الإلكتروني أو مدونتك أو ملف README.", + "copy_html": "نسخ HTML", + "copy_link": "نسخ الرابط", + "copy_markdown": "نسخ Markdown", + "creating_widget": "إنشاء الواجهة", + "customize": "تخصيص", + "deleted": "تم حذف الطلب المشترك", + "description": "اختر واجهة، يمكنك تغييرها وتخصيصها لاحقًا", + "embed": "تضمين", + "embed_info": "أضف 'ملعب API في هوبسكوتش' مصغر إلى موقعك الإلكتروني أو مدونتك أو مستنداتك.", + "link": "رابط", + "link_info": "أنشئ رابطًا قابلًا للمشاركة لتشاركه مع أي شخص على الإنترنت مع إمكانية العرض.", + "modified": "تم تعديل الطلب المشترك", + "not_found": "لم يتم العثور على الطلب المشترك", + "open_new_tab": "فتح في تبويبة جديدة", + "preview": "معاينة", + "run_in_hoppscotch": "تشغيل في هوبسكوتش", + "theme": { + "dark": "داكن", + "light": "فاتح", + "system": "الوضع الإفتراضي للنظام", + "title": "الموضوع" + } + }, + "shortcut": { + "general": { + "close_current_menu": "إغلاق القائمة الحالية", + "command_menu": "قائمة البحث والأوامر", + "help_menu": "قائمة المساعدة", + "show_all": "اختصارات لوحة المفاتيح", + "title": "عام" + }, + "miscellaneous": { + "invite": "دعوة الناس إلى هوبسكوتش", + "title": "متفرقات" + }, + "navigation": { + "back": "عد إلى الصفحة السابقة", + "documentation": "انتقل إلى صفحة التوثيق", + "forward": "انتقل إلى الصفحة التالية", + "graphql": "انتقل إلى صفحة GraphQL", + "profile": "انتقل إلى صفحة الملف الشخصي", + "realtime": "انتقل إلى صفحة الوقت الفعلي", + "rest": "انتقل إلى صفحة REST", + "settings": "انتقل إلى صفحة الإعدادات", + "title": "التنقل" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "أخرى" + }, + "request": { + "delete_method": "حدد طريقة DELETE", + "get_method": "حدد طريقة GET", + "head_method": "حدد طريقة HEAD", + "import_curl": "Import cURL", + "method": "طريقة", + "next_method": "حدد الطريقة التالية", + "post_method": "حدد طريقة POST", + "previous_method": "حدد الطريقة السابقة", + "put_method": "حدد طريقة PUT", + "rename": "إعادة تسمية الطلب", + "reset_request": "طلب إعادة التعيين", + "save_request": "حفظ الطلب", + "save_to_collections": "حفظ في المجموعات", + "send_request": "ارسل طلب", + "share_request": "مشاركة الطلب", + "show_code": "توليد الكود", + "title": "طلب", + "copy_request_link": "نسخ رابط الطلب" + }, + "response": { + "copy": "نسخ الاستجابة إلى الحافظة", + "download": "تنزيل الاستجابة كملف", + "title": "الاستجابة" + }, + "theme": { + "black": "انتقل الى الوضع الأسود", + "dark": "انتقل الى الوضع الليلي", + "light": "انتقل الى الوضح الفاتح", + "system": "انتقل الى الوضع الإفتراضي للنظام", + "title": "السمة" + } + }, + "show": { + "code": "عرض الكود", + "collection": "توسيع لوحة المجموعة", + "more": "عرض المزيد", + "sidebar": "عرض الشريط الجانبي" + }, + "socketio": { + "communication": "تواصل", + "connection_not_authorized": "اتصال SocketIO هذا لا يستخدم أي مصادقة.", + "event_name": "اسم الحدث", + "events": "الأحداث", + "log": "سجل", + "url": "URL" + }, + "spotlight": { + "change_language": "تغيير اللغة", + "environments": { + "delete": "حذف البيئة الحالية", + "duplicate": "تكرار البيئة الحالية", + "duplicate_global": "تكرار البيئة العامة", + "edit": "تحرير البيئة الحالية", + "edit_global": "تحرير البيئة العامة", + "new": "إنشاء بيئة جديدة", + "new_variable": "إنشاء متغير بيئة جديد", + "title": "البيئات" + }, + "general": { + "chat": "الدردشة مع الدعم", + "help_menu": "المساعدة والدعم", + "open_docs": "قراءة المستندات", + "open_github": "فتح مستودع جيت هاب", + "open_keybindings": "اختصارات لوحة المفاتيح", + "social": "وسائل التواصل الاجتماعي", + "title": "عام" + }, + "graphql": { + "connect": "الاتصال بالخادم", + "disconnect": "انهاء الاتصال بالخادم" + }, + "miscellaneous": { + "invite": "دعوة أصدقائك إلى هوبسكوتش", + "title": "متنوع" + }, + "request": { + "save_as_new": "حفظ كطلب جديد", + "select_method": "اختيار الطريقة", + "switch_to": "الانتقال إلى", + "tab_authorization": "تبويب Authorization", + "tab_body": "تبويب Body", + "tab_headers": "تبويب Headers", + "tab_parameters": "تبويب Parameters", + "tab_pre_request_script": "تبويب السكربت قبل الطلب", + "tab_query": "تبويب Query", + "tab_tests": "تبويب Tests", + "tab_variables": "تبويب Variables" + }, + "response": { + "copy": "نسخ الاستجابة", + "download": "تحميل الاستجابة كملف", + "title": "الاستجابة" + }, + "section": { + "interceptor": "القاطع", + "interface": "الواجهة", + "theme": "السمة", + "user": "المستخدم" + }, + "settings": { + "change_interceptor": "تغيير القاطع", + "change_language": "تغيير اللغة", + "theme": { + "black": "أسود", + "dark": "داكن", + "light": "فاتح", + "system": "الوضع الإفتراضي للنظام" + } + }, + "tab": { + "close_current": "إغلاق علامة التبويب الحالية", + "close_others": "إغلاق كل علامات التبويب الأخرى", + "duplicate": "تكرار علامة التبويب الحالية", + "new_tab": "فتح تبويب جديد", + "title": "علامات التبويب" + }, + "workspace": { + "delete": "حذف الفريق الحالي", + "edit": "تحرير الفريق الحالي", + "invite": "دعوة أشخاص إلى الفريق", + "new": "إنشاء فريق جديد", + "switch_to_personal": "الانتقال إلى مساحة العمل الشخصية", + "title": "الفرق" + }, + "phrases": { + "try": "جرب", + "import_collections": "استيراد المجموعات", + "create_environment": "إنشاء بيئة", + "create_workspace": "إنشاء مساحة عمل", + "share_request": "مشاركة الطلب" + } + }, + "sse": { + "event_type": "نوع الحدث", + "log": "السجل", + "url": "URL" + }, + "state": { + "bulk_mode": "التحرير بالجملة", + "bulk_mode_placeholder": "يتم فصل الإدخالات بواسطة سطر جديد\nالمفاتيح والقيم مفصولة بـ:\nقم بإلحاق # بأي صف تريد إضافته ولكن يظل معطلاً", + "cleared": "مسح", + "connected": "متصل", + "connected_to": "متصل بـ {name}", + "connecting_to": "جارٍ الاتصال بـ {name} ...", + "connection_error": "فشل الاتصال", + "connection_failed": "فشل الاتصال", + "connection_lost": "انقطع الاتصال", + "copied_interface_to_clipboard": "تم نسخ نوع واجهة {language} إلى الحافظة", + "copied_to_clipboard": "نسخ إلى الحافظة", + "deleted": "تم الحذف", + "deprecated": "مهمل", + "disabled": "معطل", + "disconnected": "انقطع الاتصال", + "disconnected_from": "انقطع الاتصال بـ {name}", + "docs_generated": "تم إنشاء الوثائق", + "download_failed": "فشل التنزيل", + "download_started": "بدأ التنزيل", + "enabled": "ممكّن", + "file_imported": "تم استيراد الملف", + "finished_in": "انتهى في {duration} مللي ثانية", + "hide": "إخفاء", + "history_deleted": "تم حذف السجل", + "linewrap": "التفاف السطور", + "loading": "تحميل...", + "message_received": "الرسالة: {message} وصلت على الموضوع: {topic}", + "mqtt_subscription_failed": "حدث خطأ ما أثناء الاشتراك في الموضوع: {topic}", + "none": "لا شيء", + "nothing_found": "لم يتم العثور على شيء", + "published_error": "حدث خطأ ما أثناء نشر الرسالة: {message} على الموضوع: {topic}", + "published_message": "تم نشر الرسالة: {message} على الموضوع: {topic}", + "reconnection_error": "فشل إعادة الاتصال", + "show": "إظهار", + "subscribed_failed": "فشل الاشتراك في الموضوع: {topic}", + "subscribed_success": "تم الاشتراك بنجاح في الموضوع: {topic}", + "unsubscribed_failed": "فشل إلغاء الاشتراك من الموضوع: {topic}", + "unsubscribed_success": "تم إلغاء الاشتراك بنجاح من الموضوع: {topic}", + "waiting_send_request": "في انتظار إرسال الطلب" + }, + "support": { + "changelog": "اقرأ المزيد عن أحدث الإصدارات", + "chat": "أسئلة؟ دردش معنا!", + "community": "اطرح الأسئلة وساعد الآخرين", + "documentation": "اقرأ المزيد عن هوبسكوتش", + "forum": "اسأل سؤالاً وتلقَّ جواباً", + "github": "تابعنا على جيت هاب", + "shortcuts": "تصفح التطبيق بشكل أسرع", + "title": "الدعم", + "twitter": "تابعنا على تويتر", + "team": "تواصل مع الفريق" + }, + "tab": { + "authorization": "Authorization", + "body": "Body", + "close": "إغلاق", + "close_others": "إغلاق كل التبويبات الأخرى", + "collections": "المجموعات", + "documentation": "المستندات", + "duplicate": "تكرار التبويب", + "environments": "البيئات", + "headers": "Headers", + "history": "السجل", + "mqtt": "MQTT", + "parameters": "Parameters", + "pre_request_script": "البرنامج النصي للطلب المسبق", + "queries": "Queries", + "query": "Query", + "schema": "Schema", + "shared_requests": "الطلبات المشتركة", + "codegen": "توليد الكود", + "code_snippet": "مقتطف الكود", + "share_tab_request": "مشاركة طلب التبويب", + "socketio": "مقبس", + "sse": "SSE", + "tests": "الاختبارات", + "types": "أنواع", + "variables": "المتغيرات", + "websocket": "WebSocket" + }, + "team": { + "already_member": "أنت بالفعل عضو في هذا الفريق! اتصل بمدير الفريق.", + "create_new": "أنشئ فريقًا جديدًا", + "deleted": "تم حذف الفريق", + "edit": "تحرير الفريق", + "email": "بريد إلكتروني", + "email_do_not_match": "البريد الإلكتروني لا يتطابق مع معلومات حسابك. اتصل بمدير الفريق.", + "exit": "الخروج من الفريق", + "exit_disabled": "فقط المالك يمكنه الخروج من الفريق", + "failed_invites": "الدعوات الفاشلة", + "invalid_coll_id": "رقم المجموعة غير صالح", + "invalid_email_format": "تنسيق البريد الإلكتروني غير صالح", + "invalid_id": "رقم الفريق غير صالح. اتصل بمدير الفريق.", + "invalid_invite_link": "رابط الدعوة غير صالح", + "invalid_invite_link_description": "الرابط غير صالح أو منتهي. اتصل بمدير الفريق.", + "invalid_member_permission": "يرجى تقديم إذن صالح لعضو الفريق", + "invite": "دعوة", + "invite_more": "دعوة المزيد", + "invite_tooltip": "دعوة الأشخاص للانضمام", + "invited_to_team": "{owner} دعاك للانضمام إلى فريق {team}", + "join": "تم قبول الدعوة", + "join_team": "انضم إلى فريق {team}", + "joined_team": "لقد انضممت إلى فريق {team}", + "joined_team_description": "أنت الآن عضو في الفريق", + "left": "لقد تركت الفريق", + "login_to_continue": "سجل الدخول للإكمال", + "login_to_continue_description": "يجب عليك تسجيل الدخول لكي تنضم إلى الفريق", + "logout_and_try_again": "سجل الخروج وادخل بحساب آخر", + "member_has_invite": "البريد الإلكتروني لديه دعوة بالفعل. اتصل بمدير الفريق.", + "member_not_found": "لم يتم العثور على العضو. اتصل بمدير الفريق.", + "member_removed": "تمت إزالة المستخدم", + "member_role_updated": "تم تحديث أدوار المستخدم", + "members": "الأعضاء", + "more_members": "+{count} أعضاء آخرين", + "name_length_insufficient": "يجب أن يتكون اسم الفريق من 6 أحرف على الأقل", + "name_updated": "تم تحديث اسم الفريق", + "new": "فريق جديد", + "new_created": "تم إنشاء فريق جديد", + "new_name": "فريقي الجديد", + "no_access": "ليس لديك حق التعديل في هذه المجموعات", + "no_invite_found": "لم يتم العثور على الدعوة. اتصل بمدير الفريق", + "no_request_found": "لم يتم العثور على الطلب.", + "not_found": "الفريق غير موجود. اتصل بمالك الفريق.", + "not_valid_viewer": "أنت لست مشاهدًا صالحًا. اتصل بمالك الفريق.", + "parent_coll_move": "لا يمكن نقل المجموعة إلى مجموعة فرعية", + "pending_invites": "دعوات معلقة", + "permissions": "الأذونات", + "same_target_destination": "نفس الهدف والوجهة", + "saved": "تم حفظ الفريق", + "select_a_team": "اختر فريقًا", + "success_invites": "الدعوات الناجحة", + "title": "الفرق", + "we_sent_invite_link": "لقد أرسلنا رابط الدعوة لجميع المدعوين!", + "invite_sent_smtp_disabled": "تم إنشاء روابط الدعوة", + "we_sent_invite_link_description": "اطلب من جميع المدعوين التحقق من صندوق الوارد الخاص بهم. انقر على الرابط للانضمام إلى الفريق.", + "invite_sent_smtp_disabled_description": "إرسال رسائل الدعوة معطل لهذه النسخة من هوبسكوتش. يرجى استخدام زر نسخ الرابط لنسخ الرابط ومشاركته يدويًا.", + "copy_invite_link": "نسخ رابط الدعوة", + "search_title": "طلبات الفريق", + "join_beta": "انضم إلى برنامج بيتا للوصول إلى الفرق." + }, + "team_environment": { + "deleted": "تم حذف البيئة", + "duplicate": "تم تكرار البيئة", + "not_found": "البيئة غير موجودة" + }, + "test": { + "failed": "فشل test", + "javascript_code": "كود جافا سكريبت", + "learn": "اقرأ الوثائق", + "passed": "نجاح test", + "report": "تقرير test", + "results": "نتائج test", + "script": "السكربت", + "snippets": "المقتطفات" + }, + "websocket": { + "communication": "تواصل", + "log": "سجل", + "message": "رسالة", + "protocols": "البروتوكولات", + "url": "URL" + }, + "workspace": { + "change": "تغيير مساحة العمل", + "personal": "مساحتي الشخصية", + "other_workspaces": "مساحاتي", + "team": "مساحة عمل الفريق", + "title": "مساحات العمل" + }, + "site_protection": { + "login_to_continue": "تسجيل الدخول للمتابعة", + "login_to_continue_description": "تحتاج إلى تسجيل الدخول للوصول إلى هذه النسخة من هوبسكوتش للمؤسسات.", + "error_fetching_site_protection_status": "حدث خطأ أثناء جلب حالة حماية الموقع" + }, + "access_tokens": { + "tab_title": "الرموز", + "section_title": "الرموز الشخصية", + "section_description": "تساعدك الرموز الشخصية على ربط CLI بحسابك في هوبسكوتش", + "last_used_on": "آخر استخدام في", + "expires_on": "تنتهي في", + "no_expiration": "بدون انتهاء", + "expired": "منتهية الصلاحية", + "copy_token_warning": "تأكد من نسخ الرمز الشخصي الآن. لن تتمكن من رؤيته مرة أخرى!", + "token_purpose": "ما الغرض من هذا الرمز؟", + "expiration_label": "تاريخ الانتهاء", + "scope_label": "نطاق", + "workspace_read_only_access": "وصول للقراءة فقط إلى بيانات مساحة العمل.", + "personal_workspace_access_limitation": "لا يمكن للرموز الشخصية الوصول إلى مساحة العمل الشخصية.", + "generate_token": "توليد رمز", + "invalid_label": "يرجى تقديم تسمية صالحة للرمز", + "no_expiration_verbose": "هذا الرمز لن ينتهي أبدًا!", + "token_expires_on": "سينتهي هذا الرمز في", + "generate_new_token": "توليد رمز جديد", + "generate_modal_title": "رمز وصول شخصي جديد", + "deletion_success": "تم حذف رمز الوصول {label}" + }, + "collection_runner": { + "collection_id": "رقم المجموعة", + "environment_id": "رقم البيئة", + "cli_collection_id_description": "سيتم استخدام رقم المجموعة هذا بواسطة مشغل المجموعة عبر CLI لـ هوبسكوتش.", + "cli_environment_id_description": "سيتم استخدام رقم البيئة هذا بواسطة مشغل المجموعة عبر CLI لـ هوبسكوتش.", + "include_active_environment": "تضمين البيئة النشطة:", + "cli": "CLI", + "ui": "المشغل (قريبًا)", + "cli_command_generation_description_cloud": "انسخ الأمر أدناه وقم بتشغيله من CLI. يرجى تحديد رمز الوصول الشخصي.", + "cli_command_generation_description_sh": "انسخ الأمر أدناه وقم بتشغيله من CLI. يرجى تحديد رمز الوصول الشخصي والتحقق من عنوان URL لخادم مثيل SH المُولّد.", + "cli_command_generation_description_sh_with_server_url_placeholder": "انسخ الأمر أدناه وقم بتشغيله من CLI. يرجى تحديد رمز الوصول الشخصي وعنوان URL لخادم مثيل SH.", + "run_collection": "تشغيل المجموعة" + }, + "shortcodes": { + "actions": "الإجراءات", + "created_on": "تم الإنشاء في", + "deleted": "تم حذف الرمز القصير", + "method": "الطريقة", + "not_found": "الرمز القصير غير موجود", + "short_code": "رمز قصير", + "url": "الرابط" + } +} diff --git a/packages/hoppscotch-common/locales/ca.json b/packages/hoppscotch-common/locales/ca.json new file mode 100644 index 0000000..35d1226 --- /dev/null +++ b/packages/hoppscotch-common/locales/ca.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Add", + "autoscroll": "Autoscroll", + "cancel": "Cancel·lar", + "choose_file": "Triar un fitxer", + "clear": "Netejar", + "clear_all": "Neteja-ho tot", + "clear_history": "Clear all History", + "close": "Close", + "connect": "Connectar", + "connecting": "Connecting", + "copy": "Copiar", + "create": "Create", + "delete": "Eliminar", + "disconnect": "Desconnectar", + "dismiss": "Tancar", + "dont_save": "No guardar", + "download_file": "Descarregar l'arxiu", + "drag_to_reorder": "Drag to reorder", + "duplicate": "Duplicar", + "edit": "Editar", + "filter": "Filtrar resposta", + "go_back": "Tornar", + "go_forward": "Go forward", + "group_by": "Group by", + "hide_secret": "Hide secret", + "label": "Etiquetar", + "learn_more": "Aprèn més", + "download_here": "Download here", + "less": "Menys", + "more": "Més", + "new": "Novetat", + "no": "No", + "open_workspace": "Obrir espai de treball", + "paste": "Enganxar", + "prettify": "Fes-ho bonic", + "properties": "Properties", + "remove": "Eliminar", + "rename": "Rename", + "restore": "Restaurar", + "save": "Guardar", + "scroll_to_bottom": "Desplaceu-vos cap avall", + "scroll_to_top": "Desplaceu-vos cap a dalt", + "search": "Cercar", + "send": "Enviar", + "share": "Share", + "show_secret": "Show secret", + "start": "Començar", + "starting": "Starting", + "stop": "Aturar", + "to_close": "Tancar", + "to_navigate": "Navegar", + "to_select": "Seleccionar", + "turn_off": "Apagar", + "turn_on": "Encendre", + "undo": "Desfés", + "yes": "Sí" + }, + "add": { + "new": "Afegir nou", + "star": "Afegir estrella" + }, + "app": { + "chat_with_us": "Xateja amb nosaltres", + "contact_us": "Contacta amb nosaltres", + "cookies": "Cookies", + "copy": "Copiar", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Copiar User Auth Token", + "developer_option": "Opcions de desenvolupador", + "developer_option_description": "Eines de desenvolupament que ajuden en el desenvolupament i manteniment de Hoppscotch.", + "discord": "Discord", + "documentation": "Documentació", + "github": "GitHub", + "help": "Ajuda & comentaris", + "home": "Inici", + "invite": "Convidar", + "invite_description": "Hoppscotch és un ecosistema de desenvolupament d'API de codi obert. Hem dissenyat una interfície senzilla i intuïtiva per crear i gestionar les vostres API. Hoppscotch és una eina que us ajuda a construir, provar, documentar i compartir les vostres API.", + "invite_your_friends": "Convida els teus amics", + "join_discord_community": "Uniu-vos a la nostra comunitat Discord", + "keyboard_shortcuts": "Dreceres de teclat", + "name": "Hoppscotch", + "new_version_found": "S'ha trobat una nova versió. Refresca per actualitzar.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Opcions", + "proxy_privacy_policy": "Política de privadesa del servidor intermediari (proxy)", + "reload": "Recarregar", + "search": "Cercar", + "share": "Compartir", + "shortcuts": "Dreceres", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "Destacar", + "status": "Estat", + "status_description": "Comproveu l'estat de la web", + "terms_and_privacy": "Condicions i privadesa", + "twitter": "Twitter", + "type_a_command_search": "Escriviu una comanda o cerqueu...", + "we_use_cookies": "Utilitzem cookies", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "Què hi ha de nou?", + "see_whats_new": "See what’s new", + "wiki": "Wiki" + }, + "auth": { + "account_exists": "El compte existeix amb credencials diferents - Inicieu sessió per enllaçar els dos comptes", + "all_sign_in_options": "Totes les opcions d'inici de sessió", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "Continuar amb el correu electrònic", + "continue_with_github": "Continuar amb GitHub", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Continuar amb Google", + "continue_with_microsoft": "Continuar amb Microsoft", + "email": "Correu electrònic", + "logged_out": "Sessió tancada", + "login": "Iniciar Sessió", + "login_success": "S'ha iniciat la sessió correctament", + "login_to_hoppscotch": "Iniciar la sessió a Hoppscotch", + "logout": "Tancar sessió", + "re_enter_email": "Re-escriu el teu correu", + "send_magic_link": "Envia un enllaç màgic", + "sync": "Sincronitzar", + "we_sent_magic_link": "T'hem enviat un enllaç màgic!", + "we_sent_magic_link_description": "Comprova la vostra safata d'entrada - T'hem enviat un correu electrònic a {email}. Conté un enllaç màgic que us permetrà iniciar la sessió." + }, + "authorization": { + "generate_token": "Generar Token", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "Inclou a l'URL", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "Aprèn com", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "Passar per", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "Contrasenya", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "Token", + "type": "Tipus d'autorització", + "username": "Nom d'usuari" + }, + "collection": { + "created": "Col·lecció creada", + "different_parent": "Cannot reorder collection with different parent", + "edit": "Editar la col·lecció", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "Proporcioneu un nom vàlid per a la col·lecció", + "invalid_root_move": "Collection already in the root", + "moved": "Moved Successfully", + "my_collections": "Les meves col·leccions", + "name": "La meva nova col·lecció", + "name_length_insufficient": "El nom de la col·lecció ha de tenir almenys 3 caràcters", + "new": "Nova col · lecció", + "order_changed": "Collection Order Updated", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "S'ha canviat el nom de la col·lecció", + "request_in_use": "Request in use", + "save_as": "Guardar com", + "save_to_collection": "Save to Collection", + "select": "Seleccionar una col·lecció", + "select_location": "Seleccionar la ubicació", + "details": "Details", + "select_team": "Seleccionar un equip", + "team_collections": "Col·leccions per equips" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "exit_team": "Està segur que vol deixar aquest equip?", + "logout": "Està segur que vol tancar la sessió?", + "remove_collection": "Està segur que vol suprimir permanentment aquesta col·lecció?", + "remove_environment": "Està segur que vol suprimir permanentment aquest entorn?", + "remove_folder": "Està segur que vol suprimir definitivament aquesta carpeta?", + "remove_history": "Està segur que vol suprimir definitivament tot l'historial?", + "remove_request": "Està segur que vol suprimir definitivament aquesta sol·licitud?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "Està segur que vol suprimir aquest equip?", + "remove_telemetry": "Està segur que vol desactivar Telemetry?", + "request_change": "Està segur que vol descartar la sol·licitud actual, els canvis no desats es perdran.", + "save_unsaved_tab": "Do you want to save changes made in this tab?", + "sync": "Està segur que vol sincronitzar aquest espai de treball?", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "Capçalera {count}", + "message": "Missatges {count}", + "parameter": "Paràmetre {count}", + "protocol": "Protocol {count}", + "value": "Valor {count}", + "variable": "Variable {count}" + }, + "documentation": { + "generate": "Generar documentació", + "generate_message": "Importeu qualsevol col·lecció Hoppscotch per generar documentació de l'API sobre la marxa." + }, + "empty": { + "authorization": "Aquesta sol·licitud no utilitza cap autorització", + "body": "Aquesta sol·licitud no té cap cos", + "collection": "La col·lecció està buida", + "collections": "Les col·leccions estan buides", + "documentation": "Connecta't a un punt final de GraphQL per veure la documentació", + "endpoint": "L'endpoint no pot estar buit", + "environments": "Els entorns estan buits", + "folder": "La carpeta està buida", + "headers": "Aquesta sol·licitud no té cap capçalera", + "history": "L'historial està buit", + "invites": "La llista d'invitació està buida", + "members": "L'equip està buit", + "parameters": "Aquesta sol·licitud no té cap paràmetre", + "pending_invites": "No hi ha invitacions pendents per a aquest equip", + "profile": "Inicia sessió per veure el vostre perfil", + "protocols": "Els protocols estan buits", + "request_variables": "This request does not have any request variables", + "schema": "Connecta't a un endpoint GraphQL", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "Subscriptions are empty", + "team_name": "El nom de l'equip és buit", + "teams": "Els equips estan buits", + "tests": "No hi ha proves per a aquesta sol·licitud", + "access_tokens": "Access tokens are empty", + "shortcodes": "Els shortcodes estan buits" + }, + "environment": { + "add_to_global": "Afegir-ho a Global", + "added": "Addició d'entorn", + "create_new": "Crea un entorn nou", + "created": "Etorn creat", + "deleted": "Entorn eliminat", + "duplicated": "Environment duplicated", + "edit": "Editar l'entorn", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create a environment", + "invalid_name": "Proporcioneu un nom vàlid per a l'entorn", + "list": "Environment variables", + "my_environments": "My Environments", + "name": "Name", + "nested_overflow": "Les variables d'entorn niuades estan limitades a 10 nivells", + "new": "Nou entorn", + "no_active_environment": "No active environment", + "no_environment": "Sense entorn", + "no_environment_description": "No s'ha seleccionat cap entorn. Trieu què voleu fer amb les variables següents.", + "quick_peek": "Environment Quick Peek", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "Seleccioneu un entorn", + "set": "Set environment", + "set_as_environment": "Set as environment", + "team_environments": "Team Environments", + "title": "Entorns", + "updated": "Entorn actualitzat", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Llista de variables", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "Sembla que aquest navegador no és compatible amb els Esdeveniments Enviats pel Servidor (Server Sent Events).", + "check_console_details": "Consulta el registre de la consola per obtenir més informació.", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "cURL no està formatat correctament", + "danger_zone": "Danger zone", + "delete_account": "Your account is currently an owner in these teams:", + "delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", + "empty_email_address": "L'adreça electrònica no pot estar buida", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "Nom de la sol·licitud buida", + "f12_details": "(F12 per obtenir més informació)", + "gql_prettify_invalid_query": "No s'ha pogut definir una consulta no vàlida, resoldre els errors de sintaxi de la consulta i tornar-ho a provar", + "incomplete_config_urls": "Configuració d'URLs incompleta", + "incorrect_email": "Correu electrònic incorrecte", + "invalid_link": "Enllaç invalid", + "invalid_link_description": "L'enllaç en que heu fet clic no és vàlid o ha caducat.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "JSON no vàlid", + "json_prettify_invalid_body": "No s'ha pogut personalitzar un cos no vàlid, resol els errors de sintaxi json i tornar-ho a provar", + "network_error": "Sembla que hi ha un error de xarxa. Si us plau torna-ho a provar.", + "network_fail": "No s'ha pogut enviar la sol·licitud", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "Sense durada", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "No s'ha trobat cap coincidència", + "page_not_found": "This page could not be found", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "L'adreça de correu electrònic actualitzada és la mateixa que l'adreça de correu electrònic actual", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "No s'ha pogut executar l'script de sol·licitud prèvia", + "something_went_wrong": "Alguna cosa ha anat malament", + "post_request_script_fail": "No s'ha pogut executar l'script posterior a la sol·licitud", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "Exporta com a JSON", + "create_secret_gist": "Crear un Gist secret", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "Inicieu la sessió amb GitHub per crear un Gisst secret", + "title": "Exportar", + "success": "Successfully exported", + "gist_created": "Gist creat" + }, + "filter": { + "all": "All", + "none": "None", + "starred": "Starred" + }, + "folder": { + "created": "S'ha creat la carpeta", + "edit": "Editar la carpeta", + "invalid_name": "Proporcioneu un nom per a la carpeta", + "name_length_insufficient": "El nom de la carpeta ha de tenir almenys 3 caràcters", + "new": "Carpeta nova", + "renamed": "S'ha canviat el nom de la carpeta" + }, + "graphql": { + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "mutations": "Mutacions", + "schema": "Esquema", + "subscriptions": "Subscripcions", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "Time", + "url": "URL" + }, + "header": { + "install_pwa": "Instal·la l'aplicació", + "login": "Iniciar Sessió", + "save_workspace": "Desa el meu espai de treball" + }, + "helpers": { + "authorization": "La capçalera de l'autorització es generarà automàticament quan envieu la sol·licitud.", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "Genereu documentació primer", + "network_fail": "No es pot arribar al punt final de l'API. Comproveu la connexió de xarxa i torneu-ho a provar.", + "offline": "Sembla que estàs fora de línia. És possible que les dades d'aquest espai de treball no estiguin actualitzades.", + "offline_short": "Sembla que estàs fora de línia.", + "post_request_tests": "Els scripts de prova s'escriuen en JavaScript i s'executen després de rebre la resposta.", + "pre_request_script": "Els scripts de sol·licitud prèvia s'escriuen en JavaScript i s'executen abans que s'enviï la sol·licitud.", + "script_fail": "Sembla que hi ha un error a l'script de sol·licitud prèvia. Comproveu l'error a continuació i solucioneu l'script en conseqüència.", + "post_request_script_fail": "Sembla que hi ha un error amb l'script de sol·licitud posterior. Corregiu els errors i torneu a fer proves.", + "post_request_script": "Escriviu un script de prova per automatitzar la depuració." + }, + "hide": { + "collection": "Redueix el tauler de col·lecció", + "more": "Amagar més", + "preview": "Amagar la previsualització", + "sidebar": "Amagar la barra lateral" + }, + "import": { + "collections": "Importar col·leccions", + "curl": "Importar cURL", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "La importació ha fallat", + "from_file": "Import from File", + "from_gist": "Importar des de Gist", + "from_gist_description": "Importar des de l'URL de Gist", + "from_insomnia": "Importar des d'Insomnia", + "from_insomnia_description": "Importar des de la col·lecció d'Insomnia", + "from_json": "Importar des de Hoppscotch", + "from_json_description": "Importar des del fitxer de col·lecció Hoppscotch", + "from_my_collections": "Importar des de Les meves Col·leccions", + "from_my_collections_description": "Importar des del fitxer de Les meves Col·leccions", + "from_openapi": "Importar des de OpenAPI", + "from_openapi_description": "Importa des del fitxer d'especificacions OpenAPI (YML/JSON)", + "from_postman": "Importar des de Postman", + "from_postman_description": "Importar des de la col·lecció de Postman", + "from_url": "Importar des de l'URL", + "gist_url": "Introduïu l'URL del Gist", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "No s'han pogut obtenir dades de l'URL", + "import_from_url_invalid_file_format": "S'ha produït un error en importar les col·leccions", + "import_from_url_invalid_type": "Tipus no compatible. Els valors acceptats són 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Col·leccions importades", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Importar col·leccions des d'un fitxer JSON de col·leccions Hoppscotch", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "Importació", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled." + } + }, + "layout": { + "collapse_collection": "Amagar o Ampliar Col·leccions", + "collapse_sidebar": "Amagar o Ampliar la barra lateral", + "column": "Distribució vertical", + "name": "Distribució", + "row": "Distribució horitzontal" + }, + "modal": { + "close_unsaved_tab": "You have unsaved changes", + "collections": "Col·leccions", + "confirm": "Confirmar", + "customize_request": "Customize Request", + "edit_request": "Sol·licitud d'edició", + "import_export": "Importar / Exportar", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "You are already subscribed to this topic.", + "clean_session": "Clean Session", + "clear_input": "Clear input", + "clear_input_on_send": "Clear input on send", + "client_id": "Client ID", + "color": "Pick a color", + "communication": "Comunicació", + "connection_config": "Connection Config", + "connection_not_authorized": "This MQTT connection does not use any authentication.", + "invalid_topic": "Please provide a topic for the subscription", + "keep_alive": "Keep Alive", + "log": "Registre", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "Missatge", + "new": "New Subscription", + "not_connected": "Please start a MQTT connection first.", + "publish": "Publicar", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "subscriure's", + "topic": "Tema", + "topic_name": "Nom del Tema", + "topic_title": "Publicar / Subscriure's al tema", + "unsubscribe": "Cancel·lar la subscripció", + "url": "URL" + }, + "navigation": { + "doc": "Documents", + "graphql": "GraphQL", + "profile": "Perfil", + "realtime": "En temps real", + "rest": "REST", + "settings": "Configuració" + }, + "preRequest": { + "javascript_code": "Codi JavaScript", + "learn": "Llegiu la documentació", + "script": "Script de sol·licitud prèvia", + "snippets": "Fragments (Snippets)" + }, + "profile": { + "app_settings": "Configuració de l'aplicació", + "default_hopp_displayname": "Unnamed User", + "editor": "Editor", + "editor_description": "Els editors poden afegir, editar i eliminar sol·licituds.", + "email_verification_mail": "S'ha enviat un correu electrònic de verificació a la vostra adreça electrònica. Feu clic a l'enllaç per verificar la vostra adreça de correu electrònic.", + "no_permission": "No teniu permís per dur a terme aquesta acció.", + "owner": "Propietari", + "owner_description": "Els propietaris poden afegir, editar i suprimir sol·licituds, col·leccions i membres de l'equip.", + "roles": "Rols", + "roles_description": "Els rols s'utilitzen per controlar l'accés a les col·leccions compartides.", + "updated": "Perfil actualitzat", + "viewer": "Espectador", + "viewer_description": "Els espectadors només poden veure i utilitzar sol·licituds." + }, + "remove": { + "star": "Eliminar estrella" + }, + "request": { + "added": "S'ha afegit la sol·licitud", + "authorization": "Autorització", + "body": "Cos de la sol·licitud", + "choose_language": "Tria l'idioma", + "content_type": "Tipus de contingut", + "content_type_titles": { + "others": "Altres", + "structured": "Estructurat", + "text": "Text" + }, + "different_collection": "Cannot reorder requests from different collections", + "duplicated": "Request duplicated", + "duration": "Durada", + "enter_curl": "Introduïu cURL", + "generate_code": "Generar codi", + "generated_code": "Codi generat", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "Llista de capçaleres", + "invalid_name": "Proporcioneu un nom per a la sol·licitud", + "method": "Mètode", + "moved": "Request moved", + "name": "Sol·licita el nom", + "new": "Nova sol·licitud", + "order_changed": "Request Order Updated", + "override": "Sobreescriure", + "override_help": "Estableix Content-Type a les capçaleres (Headers)", + "overriden": "Sobreescrit", + "parameter_list": "Paràmetres de consulta", + "parameters": "Paràmetres", + "path": "Ruta", + "payload": "Payload", + "query": "Consulta", + "raw_body": "Cos de sol·licitud sense processar", + "rename": "Rename Request", + "renamed": "S'ha canviat el nom de la sol·licitud", + "request_variables": "Request variables", + "run": "Executar", + "save": "Guardar", + "save_as": "Guardar com", + "saved": "S'ha desat la sol·licitud", + "share": "Compartir", + "share_description": "Comparteix Hoppscotch amb els teus amics", + "share_request": "Share Request", + "stop": "Stop", + "title": "Sol·licitud", + "type": "Tipus de sol·licitud", + "url": "URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "Variables", + "view_my_links": "Visualitzar els meus enllaços", + "copy_link": "Copia l'enllaç" + }, + "response": { + "audio": "Audio", + "body": "Cos de resposta", + "filter_response_body": "Filtrar el cos de la resposta JSON (utilitza la sintaxi jq)", + "headers": "Capçaleres", + "html": "HTML", + "image": "Imatge", + "json": "JSON", + "pdf": "PDF", + "preview_html": "Previsualitza HTML", + "raw": "Sense processar (Raw)", + "size": "Mida", + "status": "Estat", + "time": "Temps", + "title": "Resposta", + "video": "Video", + "waiting_for_connection": "esperant la connexió", + "xml": "XML" + }, + "settings": { + "accent_color": "Color d'accent", + "account": "Compte", + "account_deleted": "Your account has been deleted", + "account_description": "Personalitzeu la configuració del compte.", + "account_email_description": "La vostra adreça de correu electrònic principal.", + "account_name_description": "Aquest és el vostre nom d'exposició", + "additional": "Additional Settings", + "background": "Fons", + "black_mode": "Negre", + "choose_language": "Tria l'idioma", + "dark_mode": "Fosc", + "delete_account": "Delete account", + "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", + "expand_navigation": "Ampliar navegació", + "experiments": "Experiments", + "experiments_notice": "Es tracta d'una col·lecció d'experiments en què estem treballant que poden resultar útils, divertits, o ambdós. No són finals i potser no són estables, de manera que si passa alguna cosa massa estrany, no us espanteu. Només cal que el desactiveu. Bromes a part,", + "extension_ver_not_reported": "No informat", + "extension_version": "Versió d'extensió", + "extensions": "Extensions", + "extensions_use_toggle": "Utilitzeu l'extensió del navegador per enviar sol·licituds (si n'hi ha)", + "follow": "Segueix-nos", + "interceptor": "Interceptor", + "interceptor_description": "Middleware entre aplicació i APIs.", + "language": "Llenguatge", + "light_mode": "Lluminós", + "official_proxy_hosting": "El servidor intermediari (proxy) oficial està allotjat per Hoppscotch.", + "profile": "Perfil", + "profile_description": "Actualitza les dades del teu perfil", + "profile_email": "Correu electrònic", + "profile_name": "Nom de perfil", + "proxy": "Servidor intermediari (Proxy)", + "proxy_url": "URL del servidor intermediari", + "proxy_use_toggle": "Utilitzeu el middleware del servidor intermediari per enviar sol·licituds", + "read_the": "Llegir el", + "reset_default": "Restableix els valors predeterminats", + "short_codes": "Short codes", + "short_codes_description": "Short codes que ha creat.", + "sidebar_on_left": "Barra lateral a l'esquerra", + "sync": "Sincronitzar", + "sync_collections": "Col·leccions", + "sync_description": "Aquesta configuració es sincronitza amb el núvol.", + "sync_environments": "Entorns", + "sync_history": "Historial", + "system_mode": "Sistema", + "telemetry": "Telemetria", + "telemetry_helps_us": "La telemetria ens ajuda a personalitzar les nostres operacions i oferir-vos la millor experiència.", + "theme": "Tema", + "theme_description": "Personalitzeu el tema de l'aplicació.", + "use_experimental_url_bar": "Utilitzeu la barra d'URL experimental amb ressaltat de l'entorn", + "user": "Usuari", + "verified_email": "Verified email", + "verify_email": "Verificar correu electronic" + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Tanca el menú actual", + "command_menu": "Menú de cerca & ordres", + "help_menu": "Menú d'ajuda", + "show_all": "Dreceres de teclat", + "title": "General" + }, + "miscellaneous": { + "invite": "Convidar gent a Hoppscotch", + "title": "Miscel·lània" + }, + "navigation": { + "back": "Torneu a la pàgina anterior", + "documentation": "Anar a la pàgina Documentació", + "forward": "Anar a la pàgina següent", + "graphql": "Anar a la pàgina de GraphQL", + "profile": "Anar a la pàgina de Perfil", + "realtime": "Anar a la pàgina de Temps Real (Realtime)", + "rest": "Anar a la pàgina de REST", + "settings": "Anar a la pàgina de Configuració", + "title": "Navegació" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "Seleccionar el mètode DELETE", + "get_method": "Seleccionar el mètode GET", + "head_method": "Seleccionar el mètode HEAD", + "import_curl": "Import cURL", + "method": "Mètode", + "next_method": "Seleccionar mètode Següent", + "post_method": "Seleccionar mètode POST", + "previous_method": "Seleccionar mètode Anterior", + "put_method": "Seleccionar mètode PUT", + "rename": "Rename Request", + "reset_request": "Sol·licitud de restabliment", + "save_request": "Save Request", + "save_to_collections": "Guardar a les col·leccions", + "send_request": "Enviar sol.licitud", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "Sol·licitud", + "copy_request_link": "Copiar l'enllaç de la sol·licitud" + }, + "response": { + "copy": "Copy response to clipboard", + "download": "Download response as file", + "title": "Response" + }, + "theme": { + "black": "Canviar el tema al mode negre", + "dark": "Canviar el tema al mode fosc", + "light": "Canviar el tema al mode lluminós", + "system": "Canviar el tema al mode del sistema", + "title": "Tema" + } + }, + "show": { + "code": "Mostrar el codi", + "collection": "Ampliar el Panell de Col·lecció", + "more": "Mostrar més", + "sidebar": "Mostrar la barra lateral" + }, + "socketio": { + "communication": "Comunicació", + "connection_not_authorized": "Aquesta connexió SocketIO no utilitza cap autenticació.", + "event_name": "Nom de l'esdeveniment", + "events": "Esdeveniments", + "log": "Registre", + "url": "URL" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current team", + "edit": "Edit current team", + "invite": "Invite people to team", + "new": "Create new team", + "switch_to_personal": "Switch to your personal workspace", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "Tipus d'esdeveniment", + "log": "Registre", + "url": "URL" + }, + "state": { + "bulk_mode": "Edició massiva", + "bulk_mode_placeholder": "Les entrades estan separades per una nova línia\nLes claus i els valors estan separats per:\nAnteposa # a qualsevol fila que vulguis afegir, però que es mantingui desactivat", + "cleared": "Esborrat", + "connected": "Connectat", + "connected_to": "Connectat a {name}", + "connecting_to": "S'està connectant a {name}...", + "connection_error": "No s'ha pogut connectar", + "connection_failed": "Connexió fallida", + "connection_lost": "Connexió perduda", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "Copiat al porta-retalls", + "deleted": "Eliminat", + "deprecated": "Obsolet", + "disabled": "Desactivat", + "disconnected": "Desconnectat", + "disconnected_from": "Desconnectat de {name}", + "docs_generated": "Documentació generada", + "download_failed": "Download failed", + "download_started": "S'ha iniciat la baixada", + "enabled": "Activat", + "file_imported": "Fitxer importat", + "finished_in": "Acabat en {duration} ms", + "hide": "Hide", + "history_deleted": "S'ha suprimit l'historial", + "linewrap": "Embolcar línies", + "loading": "S'està carregant...", + "message_received": "Missatge: {message} ha arribat al tema: {topic}", + "mqtt_subscription_failed": "S'ha produït un error en subscriure's al tema: {topic}", + "none": "Cap", + "nothing_found": "No s'ha trobat res per", + "published_error": "S'ha produït un error en publicar el missatge: {topic} al tema: {message}", + "published_message": "Missatge publicat: {missatge} al tema: {tema}", + "reconnection_error": "No s'ha pogut tornar a connectar", + "show": "Show", + "subscribed_failed": "No s'ha pogut subscriure al tema: {topic}", + "subscribed_success": "S'ha subscrit correctament al tema: {topic}", + "unsubscribed_failed": "No s'ha pogut cancel·lar la subscripció al tema: {topic}", + "unsubscribed_success": "S'ha cancel·lat correctament la subscripció del tema: {topic}", + "waiting_send_request": "S'està esperant l'enviament de la sol·licitud" + }, + "support": { + "changelog": "Llegiu més sobre les darreres versions", + "chat": "Tens preguntes? Xateja amb nosaltres!", + "community": "Feu preguntes i ajudeu els altres", + "documentation": "Llegiu més sobre Hoppscotch", + "forum": "Feu preguntes i obteniu respostes", + "github": "Segueix-nos a Github", + "shortcuts": "Navega per l'aplicació més ràpidament", + "title": "Suport", + "twitter": "Segueix-nos a Twitter", + "team": "Poseu-vos en contacte amb l'equip" + }, + "tab": { + "authorization": "Autorització", + "body": "Cos", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "Col·leccions", + "documentation": "Documentació", + "duplicate": "Duplicate Tab", + "environments": "Environments", + "headers": "Capçaleres", + "history": "Historial", + "mqtt": "MQTT", + "parameters": "Paràmetres", + "pre_request_script": "Script de sol·licitud prèvia", + "queries": "Consultes", + "query": "Consulta", + "schema": "Schema", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Proves", + "types": "Tipus", + "variables": "Variables", + "websocket": "WebSocket" + }, + "team": { + "already_member": "Ja sou membre d'aquest equip. Contacta amb el propietari del teu equip.", + "create_new": "Crear un equip nou", + "deleted": "S'ha eliminat l'equip", + "edit": "Editar l'equip", + "email": "Correu electrònic", + "email_do_not_match": "El correu electrònic no coincideix amb les dades del vostre compte. Contacta amb el propietari del teu equip.", + "exit": "Sortir de l'equip", + "exit_disabled": "L'únic propietari no pot sortir de l'equip", + "failed_invites": "Failed invites", + "invalid_coll_id": "Invalid collection ID", + "invalid_email_format": "El format del correu electrònic no és vàlid", + "invalid_id": "Identificador d'equip no vàlid. Contacta amb el propietari del teu equip.", + "invalid_invite_link": "Enllaç d'invitació no vàlid", + "invalid_invite_link_description": "L'enllaç que has seguit no és vàlid. Contacta amb el propietari del teu equip.", + "invalid_member_permission": "Si us plau proporcioneu un permís vàlid al membre de l'equip", + "invite": "Invitar", + "invite_more": "Invitar més", + "invite_tooltip": "Invitar persones a aquest espai de treball", + "invited_to_team": "{owner} t'ha invitat a unir-te a {team}", + "join": "S'ha acceptat la invitació", + "join_team": "Uneix-te a {team}", + "joined_team": "T'has unit a {team}", + "joined_team_description": "Ara ets membre d'aquest equip", + "left": "Has deixat l'equip", + "login_to_continue": "Inicieu sessió per continuar", + "login_to_continue_description": "Has d'haver iniciat sessió per unir-te a un equip.", + "logout_and_try_again": "Tanqueu la sessió i inicieu la sessió amb un altre compte.", + "member_has_invite": "Aquest identificador de correu electrònic ja té una invitació. Contacta amb el propietari del teu equip.", + "member_not_found": "Membre no trobat. Contacta amb el propietari del teu equip.", + "member_removed": "S'ha eliminat l'usuari", + "member_role_updated": "Rols d'usuari actualitzats", + "members": "Membres", + "more_members": "+{count} more", + "name_length_insufficient": "El nom de l'equip ha de tenir com a mínim 6 caràcters", + "name_updated": "S'ha actualitzat el nom de l'equip", + "new": "Nou equip", + "new_created": "S'ha creat un nou equip", + "new_name": "El meu Nou Equip", + "no_access": "No teniu accés d'edició a aquestes col·leccions", + "no_invite_found": "No s'ha trobat la invitació. Contacta amb el propietari del teu equip.", + "no_request_found": "Request not found.", + "not_found": "No s'ha trobat l'equip. Contacta amb el propietari del teu equip.", + "not_valid_viewer": "No ets un espectador vàlid. Contacta amb el propietari del teu equip.", + "parent_coll_move": "Cannot move collection to a child collection", + "pending_invites": "Invitacions pendents", + "permissions": "Permisos", + "same_target_destination": "Same target and destination", + "saved": "S'ha guardat l'equip", + "select_a_team": "Select a team", + "success_invites": "Success invites", + "title": "Equips", + "we_sent_invite_link": "Hem enviat un enllaç d'invitació a tots els convidats!", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "Demaneu a tots els convidats que comprovin la seva safata d'entrada. Feu clic a l'enllaç per unir-vos a l'equip.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "Uneix-te al programa beta per accedir als equips." + }, + "team_environment": { + "deleted": "Environment Deleted", + "duplicate": "Environment Duplicated", + "not_found": "Environment not found." + }, + "test": { + "failed": "prova fallada", + "javascript_code": "Codi JavaScript", + "learn": "Llegir la documentació", + "passed": "prova superada", + "report": "Informe de la prova", + "results": "Resultats de la prova", + "script": "Script", + "snippets": "Fragments (Snippets)" + }, + "websocket": { + "communication": "Comunicació", + "log": "Registre", + "message": "Missatge", + "protocols": "Protocols", + "url": "URL" + }, + "workspace": { + "change": "Change workspace", + "personal": "My Workspace", + "other_workspaces": "My Workspaces", + "team": "Team Workspace", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "Accions", + "created_on": "Creat el", + "deleted": "S'ha suprimit el shortcode", + "method": "Mètode", + "not_found": "No s'ha trobat el shortcode", + "short_code": "Short code", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/cn.json b/packages/hoppscotch-common/locales/cn.json new file mode 100644 index 0000000..73311d9 --- /dev/null +++ b/packages/hoppscotch-common/locales/cn.json @@ -0,0 +1,2361 @@ +{ + "action": { + "add": "新增", + "autoscroll": "自动滚动", + "cancel": "取消", + "choose_file": "选择文件", + "choose_workspace": "选择工作区", + "choose_collection": "选择集合", + "select_workspace": "选择工作区", + "clear": "清除", + "clear_all": "全部清除", + "clear_cache": "清除缓存", + "clear_history": "清除全部历史记录", + "clear_unpinned": "清除未固定项", + "clear_response": "清除响应", + "close": "关闭", + "confirm": "确定", + "connect": "连接", + "connecting": "连接中", + "copy": "复制", + "create": "新增", + "delete": "删除", + "disconnect": "断开连接", + "dismiss": "忽略", + "done": "完成", + "dont_save": "不保存", + "download_file": "下载文件", + "download_test_report": "下载测试报告", + "drag_to_reorder": "拖曳以重新排序", + "duplicate": "复制", + "edit": "编辑", + "filter": "过滤", + "go_back": "返回", + "go_forward": "前进", + "group_by": "分组方式", + "hide_secret": "隐藏密钥", + "label": "标签", + "learn_more": "了解更多", + "download_here": "下载到此处", + "less": "更少", + "more": "更多", + "new": "新增", + "no": "否", + "open": "打开", + "open_workspace": "打开工作区", + "paste": "粘贴", + "prettify": "美化", + "properties": "属性", + "register": "注册", + "remove": "移除", + "remove_instance": "移除实例", + "rename": "重命名", + "restore": "恢复", + "retry": "重试", + "save": "保存", + "save_as_example": "保存示例", + "add_example": "添加示例", + "invalid_request": "无效的请求数据", + "scroll_to_bottom": "滚动至底部", + "scroll_to_top": "滚动至顶部", + "search": "搜索", + "send": "发送", + "share": "分享", + "show_secret": "显示密钥", + "sort": "排序", + "start": "开始", + "starting": "正在开始", + "stop": "停止", + "to_close": "关闭", + "to_navigate": "定位", + "to_select": "选择", + "turn_off": "关闭", + "turn_on": "开启", + "undo": "撤消", + "unpublish": "取消发布", + "yes": "是", + "verify": "验证", + "enable": "启用", + "disable": "禁用", + "assign": "分配" + }, + "activity_logs": { + "ACTIVITY_LOG_DELETE": "活动日志已删除", + "WORKSPACE_CREATE": "创建了新工作区 {name}", + "WORKSPACE_RENAME": "将工作区从 {old_name} 重命名为 {new_name}", + "WORKSPACE_USER_ADD": "{user} 被添加到工作区的 {role} 角色", + "WORKSPACE_USER_INVITE": "{inviteeEmail} 被 {user} 邀请为 {role} 角色", + "WORKSPACE_USER_INVITE_REVOKE": "已撤消对 {inviteeEmail} 成为 {inviteeRole} 角色的邀请", + "WORKSPACE_USER_INVITE_ACCEPT": "{inviteeEmail} 已接受成为 {inviteeRole} 角色的邀请", + "WORKSPACE_USER_REMOVE": "{user} 被移出工作区", + "WORKSPACE_USER_ROLE_UPDATE": "{user} 的角色已从 {old_role} 更新为 {new_role}", + "COLLECTION_CREATE": "创建了新的集合 {title}", + "COLLECTION_RENAME": "将集合从 {old_title} 重命名为 {new_title}", + "COLLECTION_IMPORT": "已导入 {count} 个集合", + "COLLECTION_DELETE": "删除了集合 {title}", + "COLLECTION_DUPLICATE": "复制了集合 {parentTitle}", + "REQUEST_CREATE": "创建了新的请求 {title}", + "REQUEST_RENAME": "将请求从 {old_title} 重命名为 {new_title}", + "REQUEST_DELETE": "删除了请求 {title}" + }, + "add": { + "new": "新增", + "star": "添加星标" + }, + "agent": { + "registration_instruction": "请使用您的网页客户端注册 Hoppscotch Agent 以继续。", + "enter_otp_instruction": "请输入由 Hoppscotch Agent 生成的验证码以完成注册。", + "otp_label": "验证码", + "processing": "正在处理您的请求...", + "not_running_title": "未检测到Agent", + "registration_title": "注册Agent", + "verify_ssl_certs": "验证 SSL 证书", + "ca_certs": "CA 证书", + "client_certs": "客户端证书", + "use_http_proxy": "使用 HTTP 代理", + "proxy_capabilities": "Hoppscotch Agent 支持 HTTP/HTTPS/SOCKS 代理,以及 NTLM 和 Basic 认证。请在代理 URL 中包含用于认证的用户名和密码。", + "add_cert_file": "添加证书文件", + "add_client_cert": "添加客户端证书", + "add_key_file": "添加私钥文件", + "domain": "域名", + "cert": "证书", + "key": "私钥", + "pfx_or_pkcs": "PFX/PKCS12", + "pfx_or_pkcs_file": "PFX/PKCS12 文件", + "add_pfx_or_pkcs_file": "添加 PFX/PKCS12 文件" + }, + "app": { + "additional_links": { + "macOS": "macOS", + "windows": "Windows", + "linux": "Linux", + "web_app": "Web App", + "cli": "CLI" + }, + "downloads": "下载", + "chat_with_us": "与我们交谈", + "contact_us": "联系我们", + "cookies": "Cookies", + "copy": "复制", + "copy_interface_type": "复制接口类型", + "copy_user_id": "复制认证 Token", + "developer_option": "开发者选项", + "developer_option_description": "开发者工具,有助于开发和维护 Hoppscotch。", + "discord": "Discord", + "documentation": "帮助文档", + "github": "GitHub", + "help": "帮助与反馈", + "home": "主页", + "invite": "邀请", + "invite_description": "Hoppscotch 是一个开源的 API 开发生态系统。我们设计了简单而直观的界面来创建和管理您的 API。Hoppscotch 是一个帮助您构建、测试、记录与分享您的 API 的工具。", + "invite_your_friends": "邀请您的伙伴", + "join_discord_community": "加入我们的 Discord 社区", + "keyboard_shortcuts": "键盘快捷键", + "name": "Hoppscotch", + "new_version_found": "已发现新版本。刷新页面以更新。", + "open_in_hoppscotch": "在 Hoppscotch 中打开", + "options": "选项", + "powered_by": "Powered by Hoppscotch", + "proxy_privacy_policy": "代理隐私政策", + "reload": "重新加载", + "search": "搜索", + "share": "分享", + "shortcuts": "快捷方式", + "social_description": "在社交媒体上关注我们,了解最新新闻、更新和发布。", + "social_links": "社交媒体链接", + "spotlight": "聚光灯", + "status": "状态", + "status_description": "检查网站状态", + "terms_and_privacy": "隐私条款", + "twitter": "Twitter", + "type_a_command_search": "输入命令或搜索内容……", + "we_use_cookies": "我们使用 cookies", + "updated_text": "Hoppscotch 已更新至 v{version} 🎉", + "whats_new": "新增内容", + "see_whats_new": "查看更新内容", + "wiki": "帮助", + "collapse_sidebar": "折叠侧边栏", + "continue_to_dashboard": "继续前往仪表板", + "expand_sidebar": "展开侧边栏", + "no_name": "没有名称", + "open_navigation": "打开导航", + "read_documentation": "阅读文档", + "default": "默认: {value}" + }, + "auth": { + "account_deactivated": "您的帐户已被停用。请联系管理员获取访问权限。", + "account_exists": "当前帐号已存在 - 登录以链接两个帐号", + "all_sign_in_options": "所有登录选项", + "continue_with_auth_provider": "使用 {provider} 登录", + "continue_with_email": "使用电子邮箱登录", + "continue_with_github": "使用 GitHub 登录", + "continue_with_github_enterprise": "使用 GitHub 企业版登录", + "continue_with_google": "使用 Google 登录", + "continue_with_microsoft": "使用 Microsoft 登录", + "email": "电子邮箱地址", + "logged_out": "已退出登录", + "login": "登录", + "login_success": "登录成功", + "login_to_hoppscotch": "登录 Hoppscotch", + "logout": "退出", + "re_enter_email": "重新输入电子邮箱", + "send_magic_link": "发送魔术链接", + "sync": "同步", + "we_sent_magic_link": "已发送魔术链接!", + "we_sent_magic_link_description": "请检查您的收件箱 - 我们向 {email} 发送了一封邮件,其中包含了能够让您登录的魔术链接。" + }, + "authorization": { + "generate_token": "生成令牌", + "refresh_token": "刷新令牌", + "graphql_headers": "将 Authorization 请求头作为负载的一部分发送到 connection_init", + "include_in_url": "包含在 URL 内", + "inherited_from": "从父级集合 {collection} 继承 {auth}", + "learn": "了解更多", + "oauth": { + "redirect_auth_server_returned_error": "鉴权服务器返回了一个错误状态", + "redirect_auth_token_request_failed": "获取鉴权令牌失败", + "redirect_auth_token_request_invalid_response": "请求鉴权令牌时,令牌端点返回了无效响应", + "redirect_invalid_state": "重定向中存在无效的状态值", + "redirect_no_auth_code": "重定向中不存在授权码", + "redirect_no_client_id": "未定义客户端ID", + "redirect_no_client_secret": "未定义客户端密钥", + "redirect_no_code_verifier": "未定义代码验证器", + "redirect_no_token_endpoint": "未定义令牌端点", + "something_went_wrong_on_oauth_redirect": "OAuth 重定向过程中出现问题", + "something_went_wrong_on_token_generation": "令牌生成过程中出现问题", + "token_generation_oidc_discovery_failed": "令牌生成失败: OpenID Connect 发现失败", + "grant_type": "授权类型", + "grant_type_auth_code": "授权码", + "token_fetched_successfully": "令牌获取成功", + "token_fetch_failed": "令牌获取失败", + "validation_failed": "验证失败,请检查表单字段", + "no_refresh_token_present": "不存在刷新令牌。请重新运行令牌生成流程", + "refresh_token_request_failed": "刷新令牌请求失败", + "token_refreshed_successfully": "令牌刷新成功", + "label_authorization_endpoint": "授权端点", + "label_client_id": "客户端 ID", + "label_client_secret": "客户端密钥", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge 方法", + "label_code_verifier": "Code Verifier", + "label_scopes": "范围", + "label_token_endpoint": "令牌端点", + "label_use_pkce": "使用 PKCE", + "label_implicit": "隐式", + "label_password": "密码", + "label_username": "用户名", + "label_auth_code": "授权码", + "label_client_credentials": "客户端凭证", + "label_send_as": "客户端身份验证", + "label_send_in_body": "在 Body 中发送凭证", + "label_send_as_basic_auth": "以 Basic Auth 发送凭证", + "enter_value": "输入值", + "auth_request": "授权请求", + "token_request": "令牌请求", + "refresh_request": "刷新请求", + "send_in": "发送" + }, + "pass_key_by": "传递方式", + "pass_by_query_params_label": "请求参数", + "pass_by_headers_label": "头部参数", + "password": "密码", + "save_to_inherit": "为了继承鉴权信息,请保存这个请求到任意集合中", + "token": "令牌", + "access_token": "访问令牌", + "client_token": "客户端令牌", + "client_secret": "客户端密钥", + "timestamp": "时间戳", + "host": "主机", + "type": "授权类型", + "username": "用户名", + "advance_config": "高级配置", + "advance_config_description": "如果没有提供明确的值,Hoppscotch 会自动为某些字段分配默认值", + "algorithm": "算法", + "payload": "负载", + "secret": "密钥", + "aws_signature": { + "access_key": "Access Key", + "secret_key": "Secret Key", + "service_name": "服务名称", + "aws_region": "AWS区域", + "service_token": "服务令牌" + }, + "digest": { + "realm": "领域(realm)", + "nonce": "随机数(nonce)", + "algorithm": "算法", + "qop": "保护级别(qop)", + "nonce_count": "随机数数量", + "client_nonce": "客户端随机数", + "opaque": "不透明(opaque)", + "disable_retry": "禁止重试请求" + }, + "akamai": { + "headers_to_sign": "待签名头部参数", + "max_body_size": "最大消息体大小" + }, + "hawk": { + "id": "HAWK 认证ID", + "key": "HAWK 认证密钥", + "ext": "扩展数据(ext)", + "app": "应用标识(app)", + "dlg": "委托方标识(dlg)", + "include": "包含负载哈希" + }, + "jwt": { + "params_name": "参数名", + "param_name": "参数名", + "header_prefix": "头部前缀", + "placeholder_request_header": "请求头前缀", + "placeholder_request_param": "请求参数名", + "secret_base64_encoded": "密钥Base64编码", + "headers": "JWT 标头", + "private_key": "私钥", + "placeholder_headers": "JWT 标头" + }, + "ntlm": { + "domain": "域名", + "workstation": "工作站", + "disable_retrying_request": "禁用重试请求" + }, + "asap": { + "issuer": "颁发者", + "audience": "接收方", + "expires_in": "过期时间", + "key_id": "Key ID", + "optional_config": "可选配置", + "subject": "主题", + "additional_claims": "附加声明" + } + }, + "collection": { + "title": "集合", + "run": "运行集合", + "created": "集合已创建", + "different_parent": "不能用不同的父类来重新排序集合", + "edit": "编辑集合", + "import_or_create": "导入或创建一个集合", + "import_collection": "导入集合", + "invalid_name": "请提供有效的集合名称", + "invalid_root_move": "该集合已经在根级了", + "moved": "移动完成", + "my_collections": "我的集合", + "name": "我的新集合", + "name_length_insufficient": "集合名字至少需要 3 个字符", + "new": "新建集合", + "order_changed": "集合顺序已更新", + "properties": "集合属性", + "properties_updated": "集合属性已更新", + "renamed": "集合已更名", + "request_in_use": "请求正在使用中", + "save_as": "另存为", + "save_to_collection": "保存至集合", + "select": "选择一个集合", + "select_location": "选择位置", + "sorted": "集合排序", + "details": "详情", + "duplicated": "集合已复制" + }, + "confirm": { + "close_unsaved_tab": "您确定要关闭此标签页吗?", + "close_unsaved_tabs": "您确定要关闭所有标签页吗? {count} 个未保存的标签页将被丢失。", + "delete_all_activity_logs": "您确定要删除所有活动日志吗?", + "exit_team": "您确定要离开此团队吗?", + "logout": "您确定要退出登录吗?", + "remove_collection": "您确定要永久删除该集合吗?", + "remove_environment": "您确定要永久删除该环境吗?", + "remove_folder": "您确定要永久删除该文件夹吗?", + "remove_history": "您确定要永久删除全部历史记录吗?", + "remove_request": "您确定要永久删除该请求吗?", + "remove_response": "您确定要永久删除该响应吗?", + "remove_shared_request": "您确定要永久删除该共享请求吗?", + "remove_team": "您确定要删除该团队吗?", + "remove_telemetry": "您确定要退出遥测服务吗?", + "request_change": "您确定您要放弃当前的请求,未保存的修改将被丢失。", + "save_unsaved_tab": "您想保存在此标签页中所作的修改吗?", + "sync": "您确定要同步该工作区吗?", + "delete_access_token": "您确定要删除这个授权令牌 {tokenLabel} 吗?", + "delete_mock_server": "您确定要删除这个 Mock 服务吗?" + }, + "context_menu": { + "add_parameters": "添加至参数", + "open_request_in_new_tab": "在新标签页中打开请求", + "set_environment_variable": "设置为变量", + "encode_uri_component": "编码 URL 组件", + "decode_uri_component": "解码 URL 组件" + }, + "cookies": { + "modal": { + "cookie_expires": "过期时间", + "cookie_name": "名称", + "cookie_path": "路径", + "cookie_string": "Cookie 字符串", + "cookie_value": "值", + "empty_domain": "域名为空", + "empty_domains": "域名列表为空", + "enter_cookie_string": "输入 Cookie 字符串", + "interceptor_no_support": "您当前选择的中间件不支持 Cookie,请选择一个其他的中间件并重试。", + "managed_tab": "管理", + "new_domain_name": "新域名", + "no_cookies_in_domain": "该域名没有 Cookie", + "raw_tab": "原始内容", + "set": "设置一个 Cookie" + } + }, + "count": { + "currentValue": "当前值 {count}", + "description": "描述 {count}", + "header": "请求头 {count}", + "initialValue": "初始值 {count}", + "key": "键 {count}", + "message": "消息 {count}", + "parameter": "参数 {count}", + "protocol": "协议 {count}", + "value": "值 {count}", + "variable": "变量 {count}" + }, + "documentation": { + "add_description": "在此处为此集合添加描述...", + "add_description_placeholder": "在此处添加描述...", + "add_request_description": "在此处为请求添加描述...", + "auth": { + "access_key": "访问密钥", + "access_token": "访问令牌", + "add_to": "添加到", + "akamai_edgegrid": "Akamai EdgeGrid", + "algorithm": "算法", + "api_key": "API 密钥", + "app_id": "应用 ID", + "auth_id": "认证 ID", + "auth_key": "认证密钥", + "auth_url": "认证地址", + "aws_signature": "AWS 签名", + "basic_auth": "Basic 认证", + "bearer_token": "Bearer 令牌", + "client_id": "客户端 ID", + "client_nonce": "客户端 Nonce", + "client_secret": "客户端密钥", + "client_token": "客户端令牌", + "delegation": "委托", + "digest_auth": "摘要认证", + "extra_data": "额外数据", + "grant_type": "授权类型", + "hawk_auth": "HAWK 认证", + "headers_to_sign": "待签名的头部", + "host": "主机", + "include_payload_hash": "包含负载哈希", + "jwt_auth": "JWT 认证", + "max_body_size": "最大主体大小", + "no_auth": "无需认证", + "nonce": "随机数(nonce)", + "oauth_2": "OAuth 2.0", + "opaque": "不透明(opaque)", + "password": "密码", + "payload": "负载", + "qop": "保护级别(qop)", + "realm": "领域(realm)", + "region": "区域(region)", + "scope": "范围(scope)", + "secret_key": "密钥", + "service_name": "服务名称", + "timestamp": "时间戳", + "title": "认证", + "token_url": "令牌地址", + "user": "用户", + "username": "用户名" + }, + "body": { + "content_type": "内容类型", + "no_body": "未定义主体", + "title": "主体" + }, + "copied_to_clipboard": "已复制到剪贴板!", + "curl": { + "click_to_load": "点击加载 cURL 命令", + "copied": "cURL 命令已复制到剪贴板!", + "copy_to_clipboard": "复制到剪贴板", + "generating": "正在生成 cURL 命令...", + "load": "加载 cURL", + "title": "cURL" + }, + "description": "描述", + "error_rendering_markdown": "渲染 Markdown 时出错:", + "fetching_documentation": "正在获取文档...", + "generate": "生成文档", + "generate_message": "导入 Hoppscotch 集合以随时随地生成 API 文档。", + "headers": { + "no_headers": "未定义头部", + "title": "头部" + }, + "hide_all_documentation": "隐藏所有文档", + "inherited_from": "继承自 {name}", + "inherited_with_type": "从 {name} 继承 {type}", + "key": "键", + "loading": "加载中...", + "loading_collection_data": "正在加载集合数据...", + "no": "否", + "no_collection_data": "无可用集合数据", + "no_documentation_found": "未找到文件夹或请求的文档", + "no_request_data": "无可用请求数据", + "no_requests_or_folders": "无请求或文件夹", + "not_set": "未设置", + "open_request_in_new_tab": "在新标签页中打开请求", + "parameters": { + "no_params": "未定义参数", + "title": "参数" + }, + "percent_complete": "完成百分比", + "processing_documentation": "正在处理文档", + "publish": { + "already_published": "此集合已发布", + "auto_sync": "与集合自动同步", + "auto_sync_description": "集合更改时自动更新已发布文档", + "button": "发布", + "copy_url": "复制 URL", + "delete": "删除文档", + "unpublish_doc": "您确定要取消发布此文档吗?", + "delete_success": "已成功删除已发布的文档", + "doc_title": "标题", + "doc_version": "版本", + "edit_published_doc": "编辑已发布文档", + "last_updated": "最后更新", + "metadata": "元数据 (JSON)", + "open_published_doc": "在新标签页中打开已发布文档", + "publish_error": "发布文档失败", + "publish_success": "文档发布成功!", + "published": "已发布", + "published_url": "已发布的 URL", + "title": "发布文档", + "update_button": "更新", + "update_error": "更新文档失败", + "update_published_docs": "更新已发布文档", + "update_success": "文档更新成功!", + "unpublish": "取消发布", + "update_title": "更新已发布文档", + "url_copied": "URL 已复制到剪贴板!", + "view_published": "查看已发布文档", + "view_title": "已发布文档快照", + "versions": "版本", + "create_new_version": "创建新版本", + "invalid_version": "版本只能包含字母数字字符、点和连字符", + "live": "实时版", + "snapshot": "快照", + "version_immutable": "已发布版本为只读快照", + "view_snapshot": "查看快照", + "current_version": "当前", + "not_found": "未找到已发布文档", + "no_doc_id": "未提供文档 ID", + "version_label": "v{version}", + "unpublish_version": "取消发布此版本", + "loading_snapshot": "正在加载快照...", + "retry_snapshot": "重试", + "sensitive_data_warning": "请确保已发布文档中未暴露敏感数据。", + "snapshot_preview": "快照预览", + "snapshot_load_error": "加载快照预览失败", + "snapshot_empty": "此快照中没有请求或文件夹", + "snapshot_item_count": "{count} 项", + "auto_sync_live_notice": "此版本会与实时集合自动同步", + "snapshot_promote_warning": "启用自动同步会将此冻结快照替换为实时集合树。此操作无法撤销。", + "live_freeze_notice": "自动同步将被关闭,并且此版本会冻结在当前集合状态。", + "untitled_project": "未命名项目", + "environment": "环境", + "no_environment": "无环境", + "environment_description": "附加一个环境以解析已发布文档中的变量" + }, + "request_opened_in_new_tab": "请求已在新标签页中打开!", + "response": { + "body": "响应主体", + "copy": "复制响应", + "example_copied": "响应示例已复制到剪贴板!", + "example_copy_failed": "复制响应示例失败", + "headers": "响应头部", + "no_examples": "无可用响应示例", + "title": "响应示例" + }, + "save_error": "保存文档时出错", + "save_success": "文档保存成功", + "saved_items_status": "已保存 {success} 项。未能保存 {failure} 项。", + "show_all_documentation": "显示所有文档", + "source": "来源", + "title": "文档", + "unsaved_changes": "您有 {count} 处未保存的更改。请在关闭前保存。", + "untitled_collection": "未命名集合", + "untitled_request": "未命名请求", + "value": "值", + "variables": { + "no_vars": "未定义变量", + "title": "变量" + }, + "yes": "是" + }, + "empty": { + "activity_logs": "未找到活动日志", + "authorization": "该请求没有使用任何授权", + "body": "该请求没有任何请求体", + "collection": "集合为空", + "collections": "集合为空", + "documentation": "连接至 GraphQL 端点以查看文档", + "empty_schema": "未找到结构", + "endpoint": "端点不能为空", + "environments": "环境为空", + "collection_variables": "集合变量为空", + "folder": "文件夹为空", + "headers": "该请求没有任何请求头", + "history": "历史记录为空", + "invites": "邀请列表为空", + "members": "团队为空", + "parameters": "该请求没有任何参数", + "pending_invites": "此团队无待办邀请", + "profile": "登录以查看您的个人资料", + "protocols": "协议为空", + "request_variables": "这个请求没有任何请求变量", + "schema": "连接至 GraphQL 端点", + "search_environment": "未找到匹配的环境", + "secret_environments": "密钥不会被 Hoppscotch 同步", + "shared_requests": "共享请求为空", + "shared_requests_logout": "登录并查看您的共享请求或创建一个新的共享请求", + "subscription": "订阅为空", + "team_name": "团队名称为空", + "teams": "团队为空", + "tests": "没有针对该请求的测试", + "access_tokens": "鉴权令牌为空", + "response": "未收到响应", + "mock_servers": "未找到 Mock 服务" + }, + "environment": { + "heading": "环境", + "add_to_global": "添加到全局环境", + "added": "环境变量已添加", + "create_new": "创建新环境", + "created": "环境已创建", + "current_value": "当前值", + "deleted": "环境变量已删除", + "duplicated": "环境已复制", + "edit": "编辑环境", + "empty_variables": "没有变量", + "global": "全局", + "global_variables": "全局变量", + "import_or_create": "导入或创建一个环境", + "initial_value": "初始值", + "invalid_name": "请提供有效的环境名称", + "list": "环境变量", + "my_environments": "我的环境", + "name": "名称", + "nested_overflow": "环境嵌套深度超过限制(10层)", + "new": "新建环境", + "no_active_environment": "没有激活的环境", + "no_environment": "无环境", + "no_environment_description": "没有选择环境。选择如何处理以下变量。", + "quick_peek": "快速浏览环境", + "replace_all_current_with_initial": "用初始值替换所有当前值", + "replace_all_initial_with_current": "用当前值替换所有初始值", + "replace_current_with_initial": "替换为初始值", + "replace_initial_with_current": "替换为当前值", + "replace_with_variable": "替换为变量", + "scope": "范围", + "secrets": "密钥", + "secret_value": "密钥值", + "select": "选择环境", + "set": "设置环境", + "set_as_environment": "设置为环境", + "short_name": "环境名称至少需要包含 1 个字符", + "team_environments": "团队环境", + "title": "环境", + "updated": "环境已更新", + "value": "值", + "variable": "变量", + "variables": "变量", + "variable_list": "变量列表", + "properties": "环境属性", + "details": "详情" + }, + "error": { + "network": { + "heading": "网络错误", + "description": "网络连接失败。{message}: {cause}" + }, + "timeout": { + "heading": "超时错误", + "description": "请求在 {phase} 阶段超时。{message}" + }, + "certificate": { + "heading": "证书错误", + "description": "证书无效。{message}: {cause}" + }, + "auth": { + "heading": "认证错误", + "description": "访问被拒绝。{message}: {cause}" + }, + "proxy": { + "heading": "代理错误", + "description": "代理连接失败。{message}: {cause}" + }, + "parse": { + "heading": "解析错误", + "description": "解析响应失败。{message}: {cause}" + }, + "version": { + "heading": "版本错误", + "description": "版本不兼容。{message}: {cause}" + }, + "abort": { + "heading": "请求已中止", + "description": "操作已取消。{message}: {cause}" + }, + "unknown": { + "heading": "未知错误", + "description": "发生未知错误。", + "cause": "未知原因" + }, + "extension": { + "heading": "扩展错误", + "description": "在扩展中执行请求失败" + }, + "authproviders_load_error": "无法加载鉴权提供者", + "browser_support_sse": "该浏览器似乎不支持 SSE。", + "check_console_details": "请检查控制台日志以获取详细信息", + "check_how_to_add_origin": "检查如何添加源", + "curl_invalid_format": "cURL 格式不正确", + "danger_zone": "危险区域", + "delete_account": "您的帐号目前为这些团队的拥有者:", + "delete_account_description": "您在删除帐号前必须先将您自己从团队中移除、转移拥有权,或是删除团队。", + "delete_activity_log": "删除活动日志失败", + "delete_all_activity_logs": "删除所有活动日志失败", + "email_already_exists": "此邮箱已绑定其他账户,请使用不同的邮箱地址。", + "empty_email_address": "邮箱地址不能为空", + "empty_profile_name": "配置文件名称不能为空", + "empty_req_name": "请求名称不能为空", + "fetch_activity_logs": "获取活动日志失败", + "f12_details": "(按 F12 查看详情)", + "gql_prettify_invalid_query": "无法美化无效的查询,处理查询语法错误并重试", + "incomplete_config_urls": "配置文件中的 URL 无效", + "incorrect_email": "邮箱地址错误", + "invalid_file_type": "`{filename}` 的文件类型无效。", + "invalid_link": "无效的链接", + "invalid_link_description": "您点击的链接无效或已过期。", + "invalid_embed_link": "嵌入内容不存在或已失效。", + "json_parsing_failed": "无效的 JSON", + "json_prettify_invalid_body": "无法美化无效的请求头,处理 JSON 语法错误并重试", + "network_error": "好像发生了网络错误,请重试。", + "network_fail": "无法发送请求", + "no_collections_to_export": "导出集合为空,请先创建一个集合。", + "no_duration": "无持续时间", + "no_environments_to_export": "导出集合环境,请先创建一个环境。", + "no_results_found": "未找到结果", + "page_not_found": "未找到此页面", + "please_install_extension": "请安装扩展并将源添加至扩展。", + "proxy_error": "代理错误", + "same_email_address": "更新后的邮箱地址与当前邮箱地址相同", + "same_profile_name": "更新后的配置文件名称与当前配置文件名称相同", + "script_fail": "无法执行预请求脚本", + "something_went_wrong": "发生了一些错误", + "subscription_error": "订阅主题失败:{error}", + "post_request_script_fail": "无法执行请求脚本", + "reading_files": "读取一个或多个文件时出错。", + "fetching_access_tokens_list": "获取令牌列表时出错", + "generate_access_token": "生成访问令牌时出错", + "delete_access_token": "删除访问令牌时出错", + "extension_not_found": "未找到扩展", + "invalid_request": "无效的请求数据" + }, + "export": { + "as_json": "导出为 JSON", + "create_secret_gist": "创建私密 Gist", + "create_secret_gist_tooltip_text": "导出为私密 Gist", + "failed": "导出时发生了错误", + "secret_gist_success": "成功导出为私密 Gist", + "require_github": "使用 GitHub 登录以创建私密 Gist", + "title": "导出", + "success": "成功导出" + }, + "file_upload": { + "choose_file": "选择文件", + "max_size_format": "最大 5MB。支持 JPEG、PNG、GIF、WebP 格式", + "profile_photo_updated": "头像更新成功", + "profile_photo_removed": "头像已成功移除", + "org_logo_updated": "组织头像更新成功", + "error_size_limit": "文件大小必须小于 5MB", + "error_invalid_format": "文件必须是图片(JPEG、PNG、GIF 或 WebP)", + "error_invalid_upload_type": "无效的上传类型", + "error_invalid_org_id": "无效的组织 ID 格式", + "error_upload_failed": "上传失败,请重试", + "error_network_failed": "网络错误,请检查您的连接", + "error_timeout": "上传在 30 秒后超时,请重试", + "error_missing_backend_url": "未配置后端 URL。请在环境设置中设置 VITE_BACKEND_API_URL 环境变量", + "error_invalid_backend_url": "无效的后端 URL 配置" + }, + "filename": { + "cookie_key_value_pairs": "Cookie", + "codegen": "{request_name} - code", + "graphql_response": "GraphQL-Response", + "lens": "{request_name} - response", + "realtime_response": "Realtime-Response", + "response_interface": "Response-Interface" + }, + "filter": { + "all": "全部", + "none": "无", + "starred": "已加星标" + }, + "folder": { + "created": "已创建文件夹", + "edit": "编辑文件夹", + "invalid_name": "请提供文件夹的名称", + "name_length_insufficient": "文件夹名称应至少为 3 个字符", + "new": "新文件夹", + "run": "运行文件夹", + "renamed": "文件夹已更名", + "sorted": "文件夹已排序" + }, + "graphql": { + "arguments": "参数", + "connection_switch_confirm": "您想连接最新的 GraphQL 端点吗?", + "connection_error_http": "由于网络错误,获取 GraphQL Schema 失败。", + "connection_switch_new_url": "切换到标签页将使您与活动的 GraphQL 连接断开。新的连接 URL 是", + "connection_switch_url": "您已连接到 GraphQL 端点,连接 URL 为", + "deprecated": "已弃用", + "fields": "字段", + "mutation": "变更", + "mutations": "变更", + "schema": "模式", + "show_depricated_values": "显示已弃用的值", + "subscription": "订阅", + "subscriptions": "订阅", + "switch_connection": "切换连接", + "url_placeholder": "输入一个 GraphQL 端点 URL", + "query": "查询" + }, + "graphql_collections": { + "title": "GraphQL 集合" + }, + "group": { + "time": "时间", + "url": "网址" + }, + "header": { + "install_pwa": "安装应用", + "login": "登录", + "save_workspace": "保存我的工作区" + }, + "helpers": { + "authorization": "授权头将会在您发送请求时自动生成。", + "collection_properties_authorization": "这个授权将被应用在当前集合下的所有请求。", + "collection_properties_header": "这个请求头将被应用在当前集合下的所有请求。", + "collection_properties_scripts": "这些脚本会针对该集合中的每个请求运行。预请求脚本会在请求发送前运行,请求后脚本会在收到响应后运行。", + "generate_documentation_first": "请先生成文档", + "network_fail": "无法到达 API 端点。请检查网络连接并重试。", + "offline": "您似乎处于离线状态,该工作区中的数据可能不是最新。", + "offline_short": "您似乎处于离线状态。", + "post_request_tests": "请求后脚本使用 JavaScript 编写,并在收到响应后执行。", + "pre_request_script": "预请求脚本使用 JavaScript 编写,并在请求发送前执行。", + "script_fail": "预请求脚本中似乎存在故障。 检查下面的错误并相应地修复脚本。", + "post_request_script_fail": "请求后脚本似乎有一个错误。请修复错误并再次运行测试", + "post_request_script": "编写请求后以自动调试。" + }, + "hide": { + "collection": "隐藏集合", + "more": "隐藏更多", + "preview": "隐藏预览", + "sidebar": "隐藏侧边栏", + "password": "隐藏密码" + }, + "import": { + "collections": "导入集合", + "curl": "导入 cURL", + "environments_from_gist": "从 Gist 导入", + "environments_from_gist_description": "从 Gist 导入 Hoppscotch 环境", + "failed": "导入失败", + "from_file": "从文件导入", + "from_gist": "从 Gist 导入", + "from_gist_description": "从 Gist URL 导入", + "from_gist_import_summary": "集合和请求将被导入。", + "from_hoppscotch_importer_summary": "所有 hoppscotch 功能已导入。", + "from_insomnia": "从 Insomnia 导入", + "from_insomnia_description": "从 Insomnia 集合中导入", + "from_insomnia_import_summary": "集合和请求将被导入。", + "from_json": "从 Hoppscotch 导入", + "from_json_description": "从 Hoppscotch 集合中导入", + "from_my_collections": "从我的集合导入", + "from_my_collections_description": "从我的集合文件导入", + "from_all_collections": "从其他工作区导入", + "from_all_collections_description": "将其他工作区中的任何集合导入到当前工作区", + "from_openapi": "从 OpenAPI 导入", + "from_openapi_description": "从 OpenAPI 文件导入(YML/JSON)", + "from_openapi_import_summary": "集合(通过标签创建)、请求和响应示例将被导入。", + "from_postman": "从 Postman 导入", + "from_postman_description": "从 Postman 集合中导入", + "from_postman_import_summary": "集合、请求和响应示例将被导入。", + "import_scripts": "导入脚本", + "import_scripts_description": "支持 Postman 集合v2.0/v2.1.", + "from_url": "从 URL 导入", + "gist_url": "输入 Gist URL", + "from_har": "从 HAR 导入", + "from_har_description": "从 HAR 文件导入", + "from_har_import_summary": "请求将被导入到默认集合中。", + "gql_collections_from_gist_description": "从 Gist 导入 GraphQL 集合", + "hoppscotch_environment": "Hoppscotch 环境", + "hoppscotch_environment_description": "导入 Hoppscotch 环境 JSON 文件", + "import_from_url_invalid_fetch": "无法从网址取得资料", + "import_from_url_invalid_file_format": "导入组合时发生错误", + "import_from_url_invalid_type": "不支持此类型。可接受的值为 'hoppscotch'、'openapi'、'postman'、'insomnia'", + "import_from_url_success": "已导入组合", + "insomnia_environment_description": "从一个 JSON/YAML 文件中导入 Insomnia 环境", + "json_description": "从 Hoppscotch 的集合文件导入(JSON)", + "postman_environment": "Postman 环境", + "postman_environment_description": "从一个 JSON 文件中导入 Postman 环境", + "title": "导入", + "file_size_limit_exceeded_warning_multiple_files": "当前选择的文件大小超过了推荐的 {sizeLimit}MB,只导入第一个被选择的 {files}。", + "file_size_limit_exceeded_warning_single_file": "当前选择的文件大小超过了推荐的 {sizeLimit}MB,请选择其他文件", + "success": "成功导入", + "import_summary_collections_title": "集合", + "import_summary_requests_title": "请求", + "import_summary_responses_title": "响应", + "import_summary_pre_request_scripts_title": "预请求脚本", + "import_summary_post_request_scripts_title": "请求后脚本", + "import_summary_not_supported_by_hoppscotch_import": "我们目前不支持从此来源导入 {featureLabel}。", + "import_summary_script_found": "发现脚本但未导入", + "import_summary_scripts_found": "发现脚本但未导入", + "import_summary_enable_experimental_sandbox": "要导入 Postman 脚本,请在设置中启用“实验性脚本沙盒”。注意:此功能为实验性质。", + "cors_error_modal": { + "title": "检测到 CORS 错误", + "description": "由于服务器施加的 CORS(跨域资源共享)限制,导入失败。", + "explanation": "这是一项安全机制,用于防止网页向不同域名发起请求。您可以尝试使用我们的代理服务来绕过此限制并重新尝试。", + "url_label": "尝试访问的 URL", + "retry_with_proxy": "使用代理重试" + } + }, + "instances": { + "switch": "切换 Hoppscotch 实例", + "enter_server_url": "连接到自托管实例", + "already_connected": "您已连接到该实例", + "recent_connections": "最近的连接", + "add_instance": "添加实例", + "add_new": "添加新实例", + "confirm_remove": "确认移除", + "remove_warning": "确定要移除此实例吗?", + "clear_cached_bundles": "清除缓存的资源包", + "opening_add_modal": "正在打开添加实例对话框", + "closed_add_modal": "添加实例对话框已关闭", + "cancelled_removal": "实例移除已取消", + "connection_cancelled": "连接因预连接验证而取消", + "post_connect_completed": "连接后设置已完成", + "connecting": "正在连接到实例...", + "confirm_removal": "确认移除实例", + "removal_cancelled": "实例移除因预移除验证而取消", + "post_remove_completed": "移除后清理已完成", + "removing": "正在移除实例...", + "clearing_cache": "正在清除缓存...", + "initialized": "实例切换器已初始化", + "connecting_state": "正在建立连接...", + "connected_state": "已成功连接到实例", + "disconnected_state": "已与实例断开连接", + "stream_error": "连接状态监控失败", + "recent_instances_error": "加载最近实例失败", + "instance_changed": "已切换到实例", + "current_instance_error": "追踪当前实例失败", + "not_available": "实例切换不可用", + "cleanup_completed": "实例切换器清理已完成", + "self_hosted": "自托管实例" + }, + "inspections": { + "description": "检查可能存在的错误", + "environment": { + "add_environment": "添加到环境", + "add_environment_value": "添加值", + "empty_value": "环境变量“{variable}”的值为空", + "not_found": "环境变量“{environment}”未找到。" + }, + "header": { + "cookie": "浏览器不允许 Hoppscotch 设置 Cookie 标头。当前我们正在开发 Hoppscotch 桌面应用程序(即将推出),与此同时请改用授权标头。" + }, + "response": { + "401_error": "请检查您的身份验证凭据。", + "404_error": "请检查您的请求 URL 和方法类型。", + "cors_error": "请检查您的跨源资源共享配置。", + "default_error": "请检查您的请求。", + "network_error": "请检查您的网络连接。" + }, + "title": "Inspector", + "url": { + "extension_not_installed": "未安装扩展。", + "extension_unknown_origin": "确保您已将 API 端点的源添加到 Hoppscotch 浏览器扩展列表中。", + "extention_enable_action": "启用浏览器扩展", + "extention_not_enabled": "扩展未启用。", + "localaccess_unsupported": "当前拦截器不支持本地访问,请考虑使用代理程序(Agent)、扩展拦截器或桌面应用程序" + }, + "auth": { + "digest": "使用 Digest 认证时,建议在Web应用中使用代理程序(Agent)拦截器,或在桌面应用中使用原生拦截器。", + "hawk": "使用 Hawk 认证时,建议在Web应用中使用代理程序(Agent)拦截器,或在桌面应用中使用原生拦截器。" + }, + "body": { + "binary": "当前拦截器尚不支持发送二进制数据。" + }, + "scripting_interceptor": { + "pre_request": "预请求脚本", + "post_request": "请求后脚本", + "both_scripts": "预请求和请求后脚本", + "unsupported_interceptor": "您的 {scriptType} 使用了 {apiUsed}。为确保脚本可靠执行,请切换到代理程序拦截器(网页应用)或原生拦截器(桌面应用)。{interceptor} 拦截器对脚本化请求的支持有限,可能无法按预期工作。", + "same_origin_csrf_warning": "安全警告:您的 {scriptType} 使用 {apiUsed} 进行了同源请求。由于本平台使用基于 Cookie 的身份验证,这些请求会自动包含您的会话 Cookie,可能导致恶意脚本执行未授权操作。请使用代理程序拦截器处理同源请求,或仅运行您信任的脚本。" + } + }, + "interceptor": { + "native": { + "name": "原生(Native)", + "settings_title": "原生" + }, + "agent": { + "name": "代理程序(Agent)", + "settings_title": "代理程序" + }, + "proxy": { + "name": "网络代理(Proxy)", + "settings_title": "网络代理" + }, + "browser": { + "name": "浏览器(Browser)", + "settings_title": "浏览器" + }, + "extension": { + "name": "扩展(Extension)", + "settings_title": "扩展" + } + }, + "layout": { + "collapse_collection": "折叠/展开集合", + "collapse_sidebar": "折叠/展开边栏", + "column": "垂直布局", + "name": "布局", + "row": "水平布局" + }, + "modal": { + "close_unsaved_tab": "有未保存的变更", + "collections": "集合", + "confirm": "确认", + "customize_request": "自定义请求", + "edit_request": "编辑请求", + "edit_response": "编辑响应", + "import_export": "导入/导出", + "response_name": "响应名称", + "share_request": "分享请求" + }, + "mqtt": { + "already_subscribed": "您已经订阅了此主题。", + "clean_session": "清除会话", + "clear_input": "清除输入", + "clear_input_on_send": "发送后清除输入", + "client_id": "客户端 ID", + "color": "选择颜色", + "communication": "通讯", + "connection_config": "连接配置", + "connection_not_authorized": "此MQTT连接未使用任何验证。", + "invalid_topic": "请提供该订阅的主题", + "keep_alive": "Keep Alive", + "log": "日志", + "lw_message": "遗嘱消息", + "lw_qos": "遗嘱消息 QoS", + "lw_retain": "遗嘱消息保留", + "lw_topic": "遗嘱消息主题", + "message": "消息", + "new": "新订阅", + "not_connected": "请先启动 MQTT 连接。", + "publish": "发布", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "订阅", + "topic": "主题", + "topic_name": "主题名称", + "topic_title": "发布/订阅主题", + "unsubscribe": "取消订阅", + "url": "网址" + }, + "navigation": { + "admin_dashboard": "仪表板", + "doc": "文档", + "graphql": "GraphQL", + "profile": "个人资料", + "realtime": "实时", + "rest": "REST", + "mock_servers": "Mock 服务", + "settings": "设置", + "goto_app": "转到应用", + "authentication": "认证" + }, + "mock_server": { + "confirm_delete_log": "确定要删除此日志吗?", + "create_mock_server": "配置 Mock 服务", + "mock_server_configuration": "Mock 服务配置", + "mock_server_name": "Mock 服务名称", + "mock_server_name_placeholder": "输入 Mock 服务名称", + "base_url": "基础 URL", + "start_server": "启动服务", + "stop_server": "停止服务", + "mock_server_created": "Mock 服务创建成功", + "mock_server_started": "Mock 服务启动成功", + "mock_server_stopped": "Mock 服务停止成功", + "active": "Mock 服务正在运行", + "inactive": "Mock 服务未运行", + "edit_mock_server": "编辑 Mock 服务", + "path_based_url": "基于路径的 URL", + "subdomain_based_url": "基于子域的 URL", + "mock_server_updated": "Mock 服务更新成功", + "no_collection": "无集合", + "collection_deleted": "关联的集合已删除。", + "private_access_hint": "对于私有 Mock 服务,请在请求头中包含 'x-api-key' 并使用您的个人访问令牌(可在您的个人资料中创建)。", + "private_access_instruction": "要访问此私有 Mock 服务,请在请求头中包含 'x-api-key' 并使用您的个人访问令牌。", + "create_token_here": "在此创建", + "status": "状态", + "server_running": "服务器正在运行", + "server_stopped": "服务器已停止", + "delay_ms": "响应延迟(毫秒)", + "delay_placeholder": "输入延迟时间(毫秒)", + "delay_description": "为模拟响应添加人工延迟", + "public_access": "公开访问", + "public": "公开", + "private": "私有", + "public_description": "任何拥有该 URL 的人均可访问此 Mock 服务", + "private_description": "仅认证用户可访问此 Mock 服务", + "select_collection": "选择一个集合", + "select_collection_error": "请选择一个集合", + "invalid_collection_error": "未能为该集合创建 Mock 服务。", + "url_copied": "URL 已复制到剪贴板", + "make_public": "设为公开", + "view_logs": "查看日志", + "logs_title": "Mock 服务日志", + "no_logs": "暂无日志", + "request_headers": "请求头部", + "request_body": "请求正文", + "response_status": "响应状态", + "response_headers": "响应头部", + "response_body": "响应正文", + "log_deleted": "日志删除成功", + "description": "Mock 服务允许您根据集合中的示例响应来模拟 API 响应。", + "set_in_environment": "设置到环境变量中", + "set_in_environment_hint": "Mock 服务的 URL 将自动以 'mockUrl' 变量的形式添加到集合的环境中", + "environment_variable_added": "模拟 URL 已添加到环境变量中", + "environment_variable_updated": "模拟 URL 已在环境变量中更新", + "environment_created_with_variable": "环境变量已创建并包含模拟 URL", + "add_example_request": "添加示例请求", + "add_example_request_hint": "创建的集合将包含一个演示如何使用 Mock 服务的示例请求", + "create_example_collection": "创建示例集合", + "create_example_collection_hint": "创建一个宠物商店示例集合,包含示例请求(GET、POST、PUT、DELETE)", + "creating_example_collection": "正在创建示例集合...", + "failed_to_create_collection": "创建示例集合失败", + "enable_example_collection_hint": "如需创建新集合,请启用“创建示例集合”开关", + "new_collection_name_hint": "新集合将以您的 Mock 服务名称命名", + "existing_collection": "现有集合", + "new_collection": "新建集合" + }, + "preRequest": { + "javascript_code": "JavaScript 代码", + "learn": "阅读文档", + "script": "预请求脚本", + "snippets": "代码片段" + }, + "profile": { + "app_settings": "应用设置", + "default_hopp_displayname": "未命名使用者", + "editor": "编辑者", + "editor_description": "编辑者可以添加、编辑和删除请求。", + "email_verification_mail": "确认邮件已发送至您的邮箱,请点击链接以验证您的电子邮箱。", + "no_permission": "您无权执行此操作。", + "owner": "所有者", + "owner_description": "所有者可以添加、编辑和删除请求、集合及团队成员。", + "roles": "角色", + "roles_description": "角色用以控制共享集合的访问权限。", + "updated": "已更新", + "viewer": "查看者", + "viewer_description": "查看者只可查看与使用请求。", + "verified_email_sent": "验证邮件已发送至您的邮箱地址。请在完成邮箱验证后刷新页面。如果该邮箱未关联任何其他账号,您将收到一封邮件。" + }, + "remove": { + "star": "移除星标" + }, + "request": { + "added": "已添加请求", + "add": "添加请求", + "authorization": "授权", + "body": "请求体", + "choose_language": "选择语言", + "content_type": "内容类型", + "content_type_titles": { + "others": "其他", + "structured": "对象结构", + "text": "文本", + "binary": "二进制" + }, + "show_content_type": "显示内容类型", + "different_collection": "不能对来自不同集合的请求进行重新排序", + "duplicated": "重复的请求", + "duration": "持续时间", + "enter_curl": "输入 cURL", + "generate_code": "生成代码", + "generated_code": "已生成代码", + "go_to_authorization_tab": "前往鉴权标签", + "go_to_body_tab": "前往请求体标签", + "header_list": "请求头列表", + "invalid_name": "请提供请求名称", + "method": "方法", + "moved": "请求移动完成", + "name": "请求名称", + "new": "新请求", + "order_changed": "请求顺序更新完成", + "override": "覆盖", + "override_help": "设置 Content-Type 头", + "overriden": "覆盖", + "parameter_list": "查询参数", + "parameters": "参数", + "path": "路径", + "payload": "负载", + "query": "查询", + "raw_body": "原始请求体", + "rename": "重命名请求", + "renamed": "请求重命名", + "request_variables": "请求变量", + "response_name_exists": "响应名称已存在", + "run": "运行", + "save": "保存", + "save_as": "另存为", + "saved": "请求已保存", + "share": "分享", + "share_description": "分享 Hoppscotch 给您的朋友", + "share_request": "分享请求", + "stop": "停止", + "title": "请求", + "type": "请求类型", + "url": "URL", + "url_placeholder": "输入一个 URL 或者粘贴一个 cURL 命令", + "variables": "变量", + "view_my_links": "查看我的链接", + "generate_name_error": "生成请求名称失败。" + }, + "response": { + "audio": "声音", + "body": "响应体", + "duplicated": "响应重名", + "duplicate_name_error": "有同名的响应已存在", + "filter_response_body": "筛选JSON响应本体(使用jq语法)", + "headers": "响应头", + "request_headers": "请求头", + "html": "HTML", + "image": "图像", + "json": "JSON", + "pdf": "PDF", + "please_save_request": "请先保存请求以创建示例", + "preview_html": "预览 HTML", + "raw": "原始内容", + "renamed": "响应已重命名", + "same_name_inspector_warning": "该请求的响应名称已存在,若保存将覆盖现有响应。", + "size": "大小", + "status": "状态", + "time": "时间", + "title": "响应", + "video": "视频", + "waiting_for_connection": "等待连接", + "xml": "XML", + "generate_data_schema": "生成数据结构", + "data_schema": "数据结构", + "saved": "响应已保存", + "invalid_name": "请为响应提供名称" + }, + "script": { + "inheriting": "正在从以下位置继承脚本", + "inheriting_from_count": "从 {count} 个集合继承 | 从 {count} 个集合继承", + "inherited_scripts": "继承的脚本", + "view_inherited": "查看继承的脚本" + }, + "settings": { + "accent_color": "强调色", + "account": "帐户", + "account_deleted": "已刪除您的账号", + "account_description": "自定义您的帐户设置。", + "account_email_description": "您的主要电子邮箱地址。", + "account_name_description": "这是您的显示名称。", + "additional": "其他设置", + "agent_not_running": "未检测到 Hoppscotch Agent。请检查 Agent 是否正在运行。", + "agent_not_running_short": "检查 Agent 状态。", + "agent_running": "Hoppscotch Agent 正在运行。", + "agent_running_short": "Hoppscotch Agent 正在运行。", + "agent_discard_registration": "取消 Agent 注册", + "agent_registered": "Agent 已注册", + "agent_registration_successful": "Agent 注册成功", + "agent_registration_fetch_failed": "无法获取 Agent 注册信息。请重新注册 Agent。", + "agent_registration_already_in_progress": "Agent 注册已在进程中。请先完成或取消当前注册,然后重试。", + "auto_encode_mode": "自动", + "auto_encode_mode_tooltip": "仅在存在特殊字符时才对请求中的参数进行编码", + "background": "背景", + "black_mode": "黑色", + "choose_language": "选择语言", + "dark_mode": "暗色", + "delete_account": "刪除账号", + "delete_account_description": "一旦您删除了您的帐号,您的所有数据将被永久删除。此操作无法复原。", + "desktop": "桌面版", + "desktop_description": "更新 Hoppscotch 桌面应用的行为和键盘处理方式。", + "desktop_keyboard": "键盘", + "desktop_keyboard_strategy_label": "按输入字符或物理按键位置匹配快捷键", + "desktop_keyboard_strategy_description": "在非 QWERTY 布局中,同一个字母可能来自不同的物理按键。默认设置适用于大多数布局;如果快捷键在您的布局上未按预期触发,请切换选项。", + "desktop_keyboard_strategy_hybrid": "智能(推荐)", + "desktop_keyboard_strategy_hybrid_description": "拉丁字符使用输入的字母;非拉丁布局(西里尔、CJK)则回退到物理按键位置。", + "desktop_keyboard_strategy_key": "输入的字母", + "desktop_keyboard_strategy_key_description": "始终使用输入的字母。如果快捷键在您的布局上未按预期工作,请选择此项。", + "desktop_keyboard_strategy_code": "物理按键位置", + "desktop_keyboard_strategy_code_description": "始终使用 US-QWERTY 物理位置。如果您在非拉丁布局上已有 QWERTY 肌肉记忆,请选择此项。", + "desktop_updates": "更新", + "disable_encode_mode_tooltip": "从不编码请求中的参数", + "disable_update_checks": "禁用自动更新检查", + "disable_update_checks_description": "应用启动时跳过更新检查。使用上方按钮可按需检查。", + "enable_encode_mode_tooltip": "始终编码请求中的参数", + "enter_otp": "输入 Agent 的验证码", + "expand_navigation": "展开导航栏", + "experiments": "实验功能", + "experiments_notice": "下面是我们正在开发中的一些实验功能,这些功能可能会很有用,可能很有趣,又或者二者都是或都不是。这些功能并非最终版本且可能不稳定,所以如果发生了一些过于奇怪的事情,不要惊慌,关掉它们就好了。玩笑归玩笑,", + "extension_ver_not_reported": "未报告", + "extension_version": "扩展版本", + "extensions": "扩展", + "extensions_use_toggle": "使用浏览器扩展发送请求(如果存在)", + "follow": "关注我们", + "general": "通用", + "general_description": "应用程序中使用的通用设置", + "interceptor": "拦截器", + "interceptor_description": "应用程序和 API 之间的中间件。", + "kernel_interceptor": "拦截器", + "kernel_interceptor_description": "应用程序与 API 之间的中间件。", + "language": "语言", + "light_mode": "亮色", + "official_proxy_hosting": "官方代理由 Hoppscotch 托管。", + "query_parameters_encoding": "查询参数编码", + "query_parameters_encoding_description": "配置请求中查询参数的编码方式", + "profile": "个人资料", + "profile_description": "更新您的资料", + "profile_email": "电子邮箱地址", + "profile_name": "名称", + "profile_photo": "头像", + "proxy": "网络代理", + "proxy_url": "代理网址", + "proxy_use_toggle": "使用代理中间件发送请求", + "read_the": "阅读", + "register_agent": "注册 Agent", + "reset_default": "重置为默认", + "short_codes": "快捷键", + "short_codes_description": "我们为您打造的快捷键。", + "sidebar_on_left": "侧边栏移至左侧", + "ai_experiments": "AI实验", + "ai_request_naming_style": "请求命名风格", + "ai_request_naming_style_descriptive_with_spaces": "描述性带空格", + "ai_request_naming_style_camel_case": "驼峰命名法(camelCase)", + "ai_request_naming_style_snake_case": "蛇形命名法(snake_case)", + "ai_request_naming_style_pascal_case": "帕斯卡命名法(PascalCase)", + "ai_request_naming_style_custom": "自定义", + "ai_request_naming_style_custom_placeholder": "输入您的自定义命名风格模板...", + "experimental_scripting_sandbox": "实验性脚本沙盒", + "enable_experimental_mock_servers": "启用 Mock 服务", + "enable_experimental_documentation": "启用文档", + "sync": "同步", + "sync_collections": "集合", + "sync_description": "这些设置会同步到云。", + "sync_environments": "环境", + "sync_history": "历史", + "history_disabled": "历史记录功能已禁用。请联系您的组织管理员以启用该功能", + "system_mode": "系统", + "telemetry": "遥测服务", + "telemetry_helps_us": "遥测服务帮助我们进行个性化操作,为您提供最佳体验。", + "theme": "主题", + "theme_description": "自定义您的应用程序主题。", + "update_check_description": "手动检查新版本并安装。无论下方开关状态如何都可使用。", + "update_check_now": "检查更新", + "update_checking": "正在检查…", + "update_download_version": "下载 v{version}", + "update_downloading": "正在下载…", + "update_downloading_percent": "正在下载 {percent}%", + "update_installing": "正在安装…", + "update_restart_now": "重启以应用更新", + "update_up_to_date": "已是最新版本", + "use_experimental_url_bar": "使用实验性的带有环境高亮的 URL 栏", + "user": "用户", + "verified_email": "已验证邮箱地址", + "verify_email": "验证电子邮箱", + "validate_certificates": "验证 SSL/TLS 证书", + "verify_host": "验证主机(Host)", + "verify_peer": "验证对等端(Peer)", + "follow_redirects": "跟随重定向", + "client_certificates": "客户端证书", + "certificate_settings": "证书设置", + "certificate": "证书", + "key": "私钥", + "pfx_or_p12": "PFX/PKCS#12", + "password": "密码", + "select_file": "选择文件", + "domain": "域名", + "add_certificate": "添加证书", + "add_cert_file": "添加证书文件", + "add_key_file": "添加密钥文件", + "add_pfx_file": "添加 PFX 文件", + "global_defaults": "全局默认设置", + "add_domain_override": "添加域名覆盖", + "domain_override": "域名覆盖", + "manage_domains_overrides": "管理域名覆盖", + "add_domain": "添加域名", + "remove_domain": "移除域名", + "ca_certificate": "CA 证书", + "ca_certificates": "CA 证书", + "ca_certificates_support": "Hoppscotch 支持包含一个或多个证书的 .crt、.cer 或 .pem 文件。", + "proxy_capabilities": "Hoppscotch Agent 和桌面应用支持 HTTP/HTTPS/SOCKS 代理,并支持 NTLM 和 Basic 身份验证。", + "proxy_auth": "您还可以在 URL 中包含用户名和密码。" + }, + "shared_requests": { + "button": "按钮", + "button_info": "为您的网站、博客或者 README 创建一个“Run in Hoppscotch”按钮。", + "copy_html": "复制 HTML", + "copy_link": "复制链接", + "copy_markdown": "复制 Markdown", + "creating_widget": "创建控件", + "customize": "自定义", + "deleted": "共享请求已删除", + "description": "选择一个控件,之后您可以更改或者自定义", + "embed": "内嵌", + "embed_info": "为您的网站、博客或者文档添加一个小的“Hoppscotch API Playground”。", + "link": "链接", + "link_info": "创建一个仅查看的分享链接给任何人", + "modified": "共享请求与修改", + "not_found": "未找到共享请求", + "open_new_tab": "在新标签中打开", + "preview": "预览", + "run_in_hoppscotch": "在 Hoppscotch 中运行", + "theme": { + "dark": "深色", + "light": "浅色", + "system": "系统", + "title": "主题" + }, + "action": "操作", + "clear_filter": "清除筛选", + "confirm_request_deletion": "确认删除选中的共享请求?", + "copy": "复制", + "created_on": "创建于", + "delete": "删除", + "email": "邮箱", + "filter": "筛选", + "filter_by_email": "按邮箱筛选", + "id": "ID", + "load_list_error": "无法加载共享请求列表", + "no_requests": "未找到共享请求", + "open_request": "打开请求", + "properties": "属性", + "request": "请求", + "show_more": "显示更多", + "title": "共享请求", + "url": "URL" + }, + "shortcut": { + "general": { + "close_current_menu": "关闭当前菜单", + "command_menu": "搜索与命令菜单", + "help_menu": "帮助菜单", + "show_all": "键盘快捷键", + "title": "通用", + "comment_uncomment": "注释/取消注释", + "close_tab": "关闭标签页", + "undo": "撤消", + "redo": "重做" + }, + "miscellaneous": { + "invite": "邀请使用 Hoppscotch", + "title": "杂项" + }, + "navigation": { + "back": "返回上一页面", + "documentation": "前往文档页面", + "forward": "前往下一页面", + "graphql": "前往 GraphQL 页面", + "profile": "前往个人资料页面", + "realtime": "前往实时页面", + "rest": "前往 REST 页面", + "settings": "前往设置页面", + "title": "导航" + }, + "others": { + "prettify": "美化内容", + "title": "其他" + }, + "request": { + "delete_method": "选择 DELETE 方法", + "get_method": "选择 GET 方法", + "head_method": "选择 HEAD 方法", + "import_curl": "导入cURL", + "method": "方法", + "next_method": "选择下一个方法", + "post_method": "选择 POST 方法", + "previous_method": "选择上一个方法", + "put_method": "选择 PUT 方法", + "rename": "重命名请求", + "reset_request": "重置请求", + "save_request": "保存请求", + "save_to_collections": "保存到集合", + "send_request": "发送请求", + "share_request": "共享请求", + "show_code": "生成代码片段", + "title": "请求", + "focus_url": "聚焦地址栏" + }, + "response": { + "copy": "复制响应至剪贴板", + "download": "下载响应", + "title": "响应" + }, + "tabs": { + "title": "标签页", + "new_tab": "新建标签页", + "close_tab": "关闭标签页", + "reopen_tab": "重新打开已关闭标签页", + "next_tab": "下一个标签页", + "previous_tab": "上一个标签页", + "first_tab": "切换到首个标签页", + "last_tab": "切换到最后一个标签页", + "mru_switch": "切换到最近的标签页 (MRU)", + "mru_switch_reverse": "切换到上一个最近的标签页 (MRU)" + }, + "theme": { + "black": "切换为黑色主题", + "dark": "切换为深色主题", + "light": "切换为浅色主题", + "system": "切换为系统主题", + "title": "主题" + } + }, + "show": { + "code": "显示代码", + "collection": "展开集合", + "more": "显示更多", + "sidebar": "显示侧边栏", + "password": "显示密码" + }, + "socketio": { + "communication": "通讯", + "connection_not_authorized": "此 SocketIO 连接未使用任何验证。", + "event_name": "事件名称", + "events": "事件", + "log": "日志", + "url": "URL" + }, + "spotlight": { + "change_language": "更改语言", + "environments": { + "delete": "删除当前环境", + "duplicate": "复制当前环境", + "duplicate_global": "复制全局环境", + "edit": "编辑当前环境", + "edit_global": "编辑全局环境", + "new": "创建新环境", + "new_variable": "创建新的环境变量", + "title": "环境" + }, + "general": { + "chat": "与支持人员聊天", + "help_menu": "帮助和支持", + "open_docs": "阅读文档", + "open_github": "打开 GitHub 存储库", + "open_keybindings": "键盘快捷键", + "social": "社交媒体", + "title": "一般" + }, + "graphql": { + "connect": "连接到服务器", + "disconnect": "与服务器断开连接" + }, + "miscellaneous": { + "invite": "邀请您的朋友来 Hoppscotch", + "title": "杂项" + }, + "request": { + "save_as_new": "另存为新请求", + "select_method": "选择方法", + "switch_to": "切换到", + "tab_authorization": "授权标签页", + "tab_body": "请求体标签页", + "tab_headers": "请求头标签页", + "tab_parameters": "参数标签页", + "tab_pre_request_script": "预请求脚本标签页", + "tab_query": "查询标签页", + "tab_tests": "测试标签页", + "tab_variables": "变量标签页" + }, + "response": { + "copy": "复制响应", + "download": "将响应下载为文件", + "title": "响应" + }, + "section": { + "interceptor": "拦截器", + "interface": "界面", + "theme": "主题", + "user": "用户" + }, + "settings": { + "change_interceptor": "更改拦截器", + "change_language": "更改语言", + "theme": { + "black": "黑色", + "dark": "暗色", + "light": "亮色", + "system": "系统" + } + }, + "tab": { + "close_current": "关闭当前标签页", + "close_others": "关闭所有其他标签页", + "duplicate": "复制当前标签页", + "new_tab": "打开新的标签页", + "next": "切换到下一个标签页", + "previous": "切换到上一个标签页", + "switch_to_first": "切换到第一个标签页", + "switch_to_last": "切换到最后一个标签页", + "mru_switch": "切换到最近的标签页", + "mru_switch_reverse": "切换到上一个最近的标签页", + "title": "标签页" + }, + "workspace": { + "delete": "删除当前团队", + "edit": "编辑当前团队", + "invite": "邀请人员加入团队", + "new": "创建新团队", + "switch_to_personal": "切换到您的个人工作区", + "title": "团队" + }, + "phrases": { + "try": "尝试", + "import_collections": "导入集合", + "create_environment": "创建环境", + "create_workspace": "创建工作区", + "share_request": "分享请求" + } + }, + "sse": { + "event_type": "事件类型", + "log": "日志", + "url": "URL" + }, + "state": { + "bulk_mode": "批量编辑", + "bulk_mode_placeholder": "条目之间使用换行符分隔\n键和值之间使用冒号分隔\n将 # 置于行首以添加该行条目并保持禁用", + "cleared": "已清除", + "connected": "已连接", + "connected_to": "已连接到 {name}", + "connecting_to": "正在连接到 {name}……", + "connection_error": "连接错误", + "connection_failed": "连接失败", + "connection_lost": "连接丢失", + "copied_interface_to_clipboard": "复制 {language} 接口类型到剪贴板", + "copied_to_clipboard": "已复制到剪贴板", + "deleted": "已删除", + "deprecated": "已弃用", + "disabled": "已禁用", + "disconnected": "断开连接", + "disconnected_from": "与 {name} 断开连接", + "docs_generated": "已生成文档", + "download_failed": "下载失败", + "download_started": "开始下载", + "enabled": "启用", + "experimental": "实验功能", + "file_imported": "文件已导入", + "finished_in": "在 {duration} 毫秒内完成", + "hide": "隐藏", + "history_deleted": "历史记录已删除", + "linewrap": "换行", + "loading": "正在加载……", + "message_received": "信息:{message}已到达主题:{topic}", + "mqtt_subscription_failed": "订阅此主题时发生错误:{topic}", + "no_content_found": "未找到内容", + "none": "无", + "nothing_found": "没有找到", + "published_error": "将信息:{topic}发布至主题:{message}时发生错误", + "published_message": "已将此信息:{message} 发布至主题:{topic}", + "reconnection_error": "重连失败", + "saved": "已保存", + "show": "显示", + "subscribed_failed": "无法订阅此主题:{topic}", + "subscribed_success": "成功订阅此主题:{topic}", + "unsubscribed_failed": "无法取消订阅此主题:{topic}", + "unsubscribed_success": "成功取消订阅此主题:{topic}", + "waiting_send_request": "等待发送请求", + "user_deactivated": "您的账号已停用。请联系管理员以重新激活。", + "add_user_failure": "添加用户到工作区失败!", + "add_user_success": "用户已成为工作区的成员!", + "admin_failure": "授予用户管理员权限失败!", + "admin_success": "用户现已获得管理员权限!", + "and": "和", + "clear_selection": "清除选择", + "configure_auth": "请从管理员设置中配置认证提供商,或查阅文档以配置认证提供商。", + "confirm_admin_to_user": "您确定要移除此用户的管理员权限吗?", + "confirm_admins_to_users": "您确定要移除选中用户的管理员权限吗?", + "confirm_delete_infra_token": "您确定要删除基础设施令牌 {tokenLabel} 吗?", + "confirm_delete_invite": "您确定要撤消选中的邀请吗?", + "confirm_delete_invites": "您确定要撤消选中的邀请吗?", + "confirm_user_deletion": "确认删除用户?", + "confirm_users_deletion": "您确定要删除选中的用户吗?", + "confirm_user_to_admin": "您确定要将此用户设为管理员吗?", + "confirm_users_to_admin": "您确定要将选中的用户设为管理员吗?", + "confirm_user_deactivation": "确认停用用户?", + "confirm_logout": "确认退出登录", + "created_on": "创建于", + "continue_email": "使用邮箱继续", + "continue_github": "使用 Github 继续", + "continue_google": "使用 Google 继续", + "continue_microsoft": "使用 Microsoft 继续", + "create_team_failure": "创建工作区失败!", + "create_team_success": "工作区创建成功!", + "data_sharing_failure": "更新数据共享设置失败", + "delete_infra_token_failure": "删除基础设施令牌时出现错误", + "delete_invite_failure": "删除邀请失败!", + "delete_invites_failure": "删除选中邀请失败!", + "delete_invite_success": "邀请删除成功!", + "delete_invites_success": "选中邀请删除成功!", + "delete_request_failure": "共享请求删除失败!", + "delete_request_success": "共享请求删除成功!", + "delete_team_failure": "工作区删除失败!", + "delete_team_success": "工作区删除成功!", + "delete_some_users_failure": "未删除的用户数量:{count}", + "delete_some_users_success": "已删除的用户数量:{count}", + "delete_user_failed_only_one_admin": "删除用户失败。工作区必须至少保留一位管理员!", + "delete_user_failure": "用户删除失败!", + "delete_users_failure": "删除选中用户失败!", + "delete_user_success": "用户删除成功!", + "delete_users_success": "选中用户删除成功!", + "email": "电子邮箱", + "email_failure": "发送邀请失败", + "email_signin_failure": "使用邮箱登录失败", + "email_success": "邮箱邀请已成功发送", + "emails_cannot_be_same": "您不能邀请自己,请选择其他邮箱地址!", + "enter_team_email": "请输入工作区所有者的邮箱!", + "error": "出现错误", + "error_auth_providers": "无法加载认证提供商", + "generate_infra_token_failure": "生成基础设施令牌时出现错误", + "github_signin_failure": "使用 Github 登录失败", + "google_signin_failure": "使用 Google 登录失败", + "infra_token_label_short": "基础设施令牌标签字符长度太短!", + "invalid_email": "请输入有效的邮箱地址", + "link_copied_to_clipboard": "链接已复制到剪贴板", + "logged_out": "已退出登录", + "login_as_admin": "并使用管理员账号登录。", + "login_using_email": "请通知用户检查他们的邮箱,或分享以下链接", + "login_using_link": "请通知用户使用以下链接登录", + "logout": "退出登录", + "magic_link_sign_in": "点击链接以登录。", + "magic_link_success": "我们已向以下邮箱发送了魔法链接:", + "microsoft_signin_failure": "使用 Microsoft 登录失败", + "newsletter_failure": "无法更新通讯订阅设置", + "non_admin_logged_in": "以非管理员用户身份登录。", + "non_admin_login": "您已登录,但不是管理员", + "owner_not_present": "团队中必须至少有一位所有者!", + "privacy_policy": "隐私政策", + "reenter_email": "重新输入邮箱", + "remove_admin_failure": "移除管理员权限失败!", + "remove_admin_failure_only_one_admin": "移除管理员权限失败。工作区必须至少保留一位管理员!", + "remove_admin_success": "管理员权限已移除!", + "remove_admin_from_users_failure": "移除选中用户的管理员权限失败!", + "remove_admin_from_users_success": "选中用户的管理员权限已移除!", + "remove_admin_to_delete_user": "请先移除该用户的管理员权限,再进行删除!", + "remove_owner_to_delete_user": "请先移除该用户的团队所有者身份,再进行删除!", + "remove_owner_failure_only_one_owner": "移除成员失败。团队中必须至少有一位所有者!", + "remove_admin_for_deletion": "请在删除前先移除管理员身份!", + "remove_owner_for_deletion": "一个或多个用户是团队所有者。请在删除前更新所有权!", + "remove_invitee_failure": "移除受邀者失败!", + "remove_invitee_success": "移除受邀者成功!", + "remove_member_failure": "无法移除成员!", + "remove_member_success": "成员移除成功!", + "rename_team_failure": "工作区重命名失败!", + "rename_team_success": "工作区重命名成功!", + "rename_user_failure": "用户重命名失败!", + "rename_user_success": "用户重命名成功!", + "require_auth_provider": "您需要配置至少一个认证提供商才能登录。", + "role_update_failed": "角色更新失败!", + "role_update_success": "角色更新成功!", + "selected": "已选中 {count} 项", + "self_host_docs": "自托管文档", + "send_magic_link": "发送魔法链接", + "setup_failure": "设置失败!", + "setup_success": "设置成功完成!", + "sign_in_agreement": "登录即表示您同意我们的", + "sign_in_options": "所有登录选项", + "sign_out": "退出登录", + "something_went_wrong": "出现错误", + "team_name_too_short": "工作区名称长度至少应为 6 个字符!", + "user_already_invited": "发送邀请失败。该用户已被邀请!", + "user_not_found": "在基础设施中未找到该用户!", + "users_to_admin_success": "选中用户已成功提升为管理员!", + "users_to_admin_failure": "将选中用户提升为管理员失败!", + "loading_workspaces": "正在加载工作区", + "loading_collections_in_workspace": "正在加载工作区中的集合" + }, + "support": { + "changelog": "阅读更多有关最新版本的内容", + "chat": "有问题?来和我们交流吧!", + "community": "提问与互助", + "documentation": "阅读更多有关 Hoppscotch 的内容", + "forum": "答疑解惑", + "github": "在 Github 关注我们", + "shortcuts": "更快浏览应用", + "title": "支持", + "twitter": "在 Twitter 关注我们" + }, + "tab": { + "authorization": "授权", + "body": "请求体", + "close": "关闭标签页", + "close_others": "关闭所有其他标签页", + "collections": "集合", + "documentation": "帮助文档", + "duplicate": "复制当前标签页", + "environments": "环境", + "headers": "请求头", + "history": "历史记录", + "mqtt": "MQTT", + "parameters": "参数", + "post_request_script": "请求后脚本", + "pre_request_script": "预请求脚本", + "scripts": "脚本", + "queries": "查询", + "query": "查询", + "schema": "模式", + "shared_requests": "共享请求", + "codegen": "生成代码", + "code_snippet": "代码片段", + "mock_servers": "Mock 服务", + "share_tab_request": "分享标签页请求", + "socketio": "Socket.IO", + "sse": "SSE", + "types": "类型", + "variables": "变量", + "websocket": "WebSocket", + "all_tests": "所有", + "passed": "通过", + "failed": "失败" + }, + "team": { + "activity_logs": "活动日志", + "already_member": "您已经是此团队的成员。请联系您的团队者。", + "create_new": "创建新团队", + "deleted": "团队已删除", + "delete_all_activity_logs": "删除所有活动日志", + "successfully_deleted_all_activity_logs": "已成功删除所有活动日志", + "delete_activity_log": "删除活动日志", + "deleted_activity_log": "已删除选中的活动日志", + "deleted_all_activity_logs": "已删除所有活动日志", + "edit": "编辑团队", + "email": "电子邮箱", + "email_do_not_match": "邮箱无法与您的帐户信息匹配。请联系您的团队者。", + "exit": "退出团队", + "exit_disabled": "团队所有者无法退出团队", + "failed_invites": "邀请失败", + "invalid_coll_id": "无效的集合 ID", + "invalid_email_format": "电子邮箱格式无效", + "invalid_id": "无效的团队 ID,请联系您的团队者。", + "invalid_invite_link": "无效的邀请链接", + "invalid_invite_link_description": "您点击的链接无效。请联系您的团队者。", + "invalid_member_permission": "请为团队成员提供有效的权限", + "invite": "邀请", + "invite_more": "邀请更多成员", + "invite_tooltip": "邀请成员加入此工作区", + "invited_to_team": "{owner} 邀请您加入 {workspace}", + "join": "邀请已被接受", + "join_team": "加入 {workspace}", + "joined_team": "您已加入 {workspace}", + "joined_team_description": "您现在是此团队的成员了", + "left": "您已离开团队", + "login_to_continue": "登录以继续", + "login_to_continue_description": "您需要登录以加入团队", + "logout_and_try_again": "退出登录并以其他帐户登录", + "member_has_invite": "此邮箱 ID 已有邀请。请联系您的团队者。", + "member_not_found": "未找到成员。请联系您的团队者。", + "member_removed": "用户已移除", + "member_role_updated": "用户角色已更新", + "members": "成员", + "more_members": "+{count} 更多", + "name_length_insufficient": "团队名称至少为 6 个字符", + "name_updated": "团队名称已更新", + "new": "新团队", + "new_created": "已创建新团队", + "new_name": "我的新团队", + "no_access": "您没有编辑集合的权限", + "no_invite_found": "未找到邀请。请联系您的团队者。", + "no_request_found": "请求不存在", + "not_found": "没有找到团队,请联系您的团队所有者。", + "not_valid_viewer": "您不是有效的查看者。请联系您的团队者。", + "parent_coll_move": "不能将集合移动到一个子集合", + "pending_invites": "待办邀请", + "permissions": "权限", + "same_target_destination": "目标相同", + "saved": "团队已保存", + "select_a_team": "选择团队", + "success_invites": "邀请成功", + "title": "团队", + "we_sent_invite_link": "我们向所有受邀者发送了邀请链接!", + "invite_sent_smtp_disabled": "邀请链接已生成", + "we_sent_invite_link_description": "请所有受邀者检查他们的收件箱,点击链接以加入团队。", + "invite_sent_smtp_disabled_description": "发送邀请邮件在此 Hoppscotch 实例中已禁用。请使用“复制链接”按钮手动复制并分享邀请链接。", + "copy_invite_link": "复制邀请链接", + "search_title": "团队请求", + "user_not_found": "在此实例中未找到该用户。", + "invite_members": "邀请成员" + }, + "team_environment": { + "deleted": "已刪除环境", + "duplicate": "已复制环境", + "not_found": "找不到环境。" + }, + "test": { + "requests": "请求", + "selection": "选择", + "failed": "测试失败", + "javascript_code": "JavaScript 代码", + "learn": "阅读文档", + "passed": "测试通过", + "report": "测试报告", + "results": "测试结果", + "script": "脚本", + "snippets": "代码片段", + "run": "运行", + "run_again": "再次运行", + "stop": "停止", + "new_run": "重新运行", + "iterations": "迭代次数", + "duration": "持续时间", + "avg_resp": "平均响应时间" + }, + "websocket": { + "communication": "通讯", + "log": "日志", + "message": "信息", + "protocols": "协议", + "url": "URL" + }, + "workspace": { + "change": "切换工作区", + "personal": "个人工作区", + "other_workspaces": "我的工作区", + "team": "团队工作区", + "title": "工作区" + }, + "site_protection": { + "login_to_continue": "登录以继续", + "login_to_continue_description": "您需要登录才能访问此 Hoppscotch 企业实例。", + "error_fetching_site_protection_status": "获取站点保护状态时出现问题" + }, + "access_tokens": { + "tab_title": "令牌", + "section_title": "个人访问令牌", + "section_description": "个人访问令牌目前帮助您将 CLI 连接到您的 Hoppscotch 账户", + "last_used_on": "最后使用于", + "expires_on": "过期于", + "no_expiration": "未过期", + "expired": "已过期", + "copy_token_warning": "请确保现在复制您的个人访问令牌。您将无法再次看到它!", + "token_purpose": "此令牌的用途是什么?", + "expiration_label": "到期", + "scope_label": "范围", + "workspace_read_only_access": "对工作区数据的只读访问权限。", + "personal_workspace_access_limitation": "个人访问令牌无法访问您的个人工作区。", + "generate_token": "生成令牌", + "invalid_label": "请为令牌提供一个标签", + "no_expiration_verbose": "此令牌永不过期!", + "token_expires_on": "此令牌将在", + "generate_new_token": "生成新令牌", + "generate_modal_title": "新个人访问令牌", + "deletion_success": "访问令牌 {label} 已被删除" + }, + "collection_runner": { + "collection_id": "集合ID", + "environment_id": "环境ID", + "cli_collection_id_description": "此集合 ID 将由 Hoppscotch 的 CLI 集合运行器使用。", + "cli_environment_id_description": "此环境 ID 将由 Hoppscotch 的 CLI 集合运行器使用。", + "include_active_environment": "包含活动环境:", + "cli": "CLI", + "cli_comming_soon_for_personal_collection": "CLI 中的个人集合运行器即将推出。", + "delay": "延迟", + "negative_delay": "延迟时间不能为负数", + "ui": "运行器(即将推出)", + "running_collection": "正在运行集合", + "run_config": "运行配置", + "advanced_settings": "高级设置", + "stop_on_error": "出错时停止运行", + "persist_responses": "持久化响应", + "keep_variable_values": "保留变量值", + "collection_not_found": "未找到集合,可能已被删除或移动。", + "empty_collection": "集合为空,请添加请求以运行。", + "no_response_persist": "当前集合运行器配置为不持久化响应。此设置将阻止显示响应数据。若要更改此行为,请启动新的运行配置。", + "select_request": "选择请求以查看响应和测试结果", + "response_body_lost_rerun": "响应正文已丢失。请重新运行集合以获取响应正文。", + "cli_command_generation_description_cloud": "复制以下命令并在 CLI 中运行。请指定个人访问令牌。", + "cli_command_generation_description_sh": "复制以下命令并在 CLI 中运行。请指定个人访问令牌并验证生成的SH 实例服务器 URL。", + "cli_command_generation_description_sh_with_server_url_placeholder": "复制以下命令并在 CLI 中运行。请指定个人访问令牌和 SH 实例服务器 URL。", + "run_collection": "运行集合", + "no_passed_tests": "没有测试通过", + "no_failed_tests": "没有测试失败" + }, + "ai_experiments": { + "generate_request_name": "使用 AI 生成请求名称", + "generate_or_modify_request_body": "生成或修改请求正文", + "modify_with_ai": "使用 AI 修改", + "generate": "生成", + "generate_or_modify_request_body_input_placeholder": "输入提示语以修改请求正文", + "accept_change": "接受更改", + "feedback_success": "反馈提交成功", + "feedback_failure": "提交反馈失败", + "feedback_thank_you": "感谢您的反馈!", + "feedback_cta_text_long": "为生成结果评分,帮助我们改进", + "feedback_cta_request_name": "您喜欢生成的名称吗?", + "modify_request_body_error": "修改请求正文失败", + "generate_or_modify_prerequest_input_placeholder": "输入提示语以生成或修改预请求脚本", + "generate_or_modify_post_request_script_input_placeholder": "输入提示语以生成或修改请求后脚本", + "modify_post_request_script_error": "修改请求后脚本失败", + "modify_prerequest_error": "修改预请求脚本失败" + }, + "configs": { + "auth_providers": { + "callback_url": "回调地址", + "client_id": "客户端 ID", + "client_secret": "客户端密钥", + "description": "为您的服务器配置认证提供商", + "provider_not_specified": "请至少启用一个认证提供商", + "scope": "授权范围", + "tenant": "租户", + "title": "认证提供商", + "update_failure": "更新认证提供商配置失败!" + }, + "confirm_changes": "Hoppscotch 服务器必须重启以使新更改生效。确认对服务器配置所做的更改吗?", + "input_empty": "请在更新配置前填写所有字段", + "data_sharing": { + "title": "数据共享", + "description": "通过共享匿名数据帮助改进 Hoppscotch", + "enable": "启用数据共享", + "secondary_title": "数据共享配置", + "see_shared": "查看共享内容", + "toggle_description": "共享匿名数据", + "update_failure": "更新数据共享配置失败!" + }, + "load_error": "无法加载服务器配置", + "mail_configs": { + "address_from": "发件人地址", + "custom_smtp_configs": "使用自定义 SMTP 配置", + "description": "配置 SMTP 设置", + "enable_email_auth": "启用基于邮箱的认证", + "enable_smtp": "启用 SMTP", + "host": "邮件服务器主机", + "password": "邮件服务器密码", + "port": "邮件服务器端口", + "secure": "邮件服务器安全连接", + "smtp_url": "邮件服务器 SMTP 地址", + "tls_reject_unauthorized": "TLS 拒绝未授权连接", + "title": "SMTP 配置", + "toggle_failure": "切换 SMTP 状态失败!", + "update_failure": "更新 SMTP 配置失败!", + "user": "邮件服务器用户名" + }, + "reset": { + "confirm_reset": "Hoppscotch 服务器必须重启以使更改生效。确认重置服务器配置吗?", + "description": "将加载环境文件中指定的默认配置", + "failure": "重置配置失败!", + "title": "重置配置", + "info": "重置服务器配置" + }, + "restart": { + "description": "此页面将在 {duration} 秒后自动重新加载", + "initiate": "正在启动服务器重启...", + "title": "服务器正在重启" + }, + "save_changes": "保存更改", + "title": "配置", + "update_failure": "更新服务器配置失败", + "restrict_access": "限制访问", + "site_protection": { + "control_access": "控制谁可以访问 Hoppscotch 应用", + "description": "使用站点保护设置自定义访问者访问您的 Hoppscotch 应用的方式。", + "enable": "启用站点保护", + "note": "访问 Hoppscotch 应用的用户将看到登录页面,必须登录才能使用应用。授权仍需管理员批准。", + "update_failure": "更新站点保护配置失败!" + }, + "domain_whitelisting": { + "add_domain": "添加新域名", + "description": "邮箱地址在已列入白名单的域中注册的用户无需管理员明确批准即可访问 Hoppscotch 应用", + "enable": "启用域名白名单", + "enter_domain": "输入域名", + "title": "白名单域名", + "toggle_failure": "切换域名白名单失败", + "update_failure": "更新域名白名单配置失败!" + }, + "oidc_configs": { + "auth_url": "认证地址", + "callback_url": "回调地址", + "client_id": "客户端 ID", + "client_secret": "客户端密钥", + "description": "配置 OIDC 设置", + "enable": "启用基于 OIDC 的认证", + "issuer": "颁发者", + "provider_name": "提供商名称", + "scope": "授权范围", + "title": "OIDC 配置", + "token_url": "令牌地址", + "update_failure": "更新 OIDC 配置失败!", + "user_info_url": "用户信息地址" + }, + "saml": { + "audience": "接收方", + "callback_url": "回调地址", + "certificate": "证书", + "description": "配置 SAML 设置", + "enable": "启用基于 SAML 的认证", + "entry_point": "入口点", + "issuer": "颁发者", + "title": "SAML 配置", + "update_failure": "更新 SAML SSO 配置失败!", + "want_assertions_signed": "签署断言", + "want_response_signed": "签署响应" + } + }, + "data_sharing": { + "description": "共享匿名使用数据以改进 Hoppscotch", + "enable": "启用数据共享", + "see_shared": "查看共享内容", + "toggle_description": "共享数据,让 Hoppscotch 变得更好", + "title": "让 Hoppscotch 变得更好", + "welcome": "欢迎来到" + }, + "infra_tokens": { + "copy_token_warning": "请务必现在复制您的基础设施令牌。之后您将无法再次查看它!", + "deletion_success": "基础设施令牌 {label} 已被删除", + "empty": "基础设施令牌为空", + "expired": "已过期", + "expiration_label": "过期时间", + "expires_on": "到期于", + "generate_modal_title": "新建基础设施令牌", + "generate_new_token": "生成新令牌", + "generate_token": "生成令牌", + "invalid_label": "请为令牌提供一个标签", + "last_used_on": "最后使用于", + "no_expiration": "永不过期", + "no_expiration_verbose": "此令牌将永不过期!", + "section_description": "通过基础设施令牌和 API 管理您的 Hoppscotch 用户", + "section_title": "基础设施令牌", + "tab_title": "基础设施令牌", + "token_expires_on": "此令牌将于以下时间过期", + "token_purpose": "输入标签以标识此令牌" + }, + "metrics": { + "dashboard": "仪表板", + "no_metrics": "未找到指标数据", + "total_collections": "集合总数", + "total_requests": "请求总数", + "total_teams": "工作区总数", + "total_users": "用户总数" + }, + "newsletter": { + "description": "获取我们最新消息的更新", + "subscribe": "订阅", + "title": "保持联系", + "toggle_description": "获取 Hoppscotch 最新动态的更新", + "unsubscribe": "取消订阅" + }, + "teams": { + "add_member": "添加成员", + "add_members": "添加成员", + "add_new": "新增", + "admin": "管理员", + "admin_Email": "管理员邮箱", + "admin_id": "管理员 ID", + "cancel": "取消", + "confirm_team_deletion": "确认删除此工作区?", + "copy": "复制", + "create_team": "创建工作区", + "date": "日期", + "delete_team": "删除工作区", + "details": "详情", + "edit": "编辑", + "editor": "编辑者", + "editor_description": "编辑者可以添加、编辑和删除请求及集合。", + "email": "工作区所有者邮箱", + "email_address": "邮箱地址", + "email_title": "邮箱", + "empty_name": "工作区名称不能为空!", + "error": "出现错误,请稍后重试。", + "id": "工作区 ID", + "invited_email": "受邀者邮箱", + "invited_on": "邀请时间", + "invites": "邀请", + "load_info_error": "无法加载工作区信息", + "load_list_error": "无法加载工作区列表", + "members": "成员数量", + "no_invite": "暂无邀请", + "no_invite_description": "邀请您的团队成员开始协作", + "owner": "所有者", + "owner_description": "所有者可以添加、编辑和删除请求、集合以及工作区成员。", + "permissions": "权限", + "name": "工作区名称", + "no_members": "此工作区暂无成员。添加成员以开始协作", + "no_pending_invites": "暂无待处理邀请", + "no_teams": "未找到工作区。", + "no_teams_description": "创建工作区以与您的团队协作", + "pending_invites": "待处理邀请", + "roles": "角色", + "roles_description": "角色用于控制对共享集合的访问权限。", + "remove": "移除", + "rename": "重命名", + "save": "保存", + "save_changes": "保存更改", + "send_invite": "发送邀请", + "show_more": "显示更多", + "team_details": "工作区详情", + "team_members": "成员", + "team_members_tab": "工作区成员", + "teams": "工作区", + "uid": "用户标识符", + "unnamed": "(未命名工作区)", + "viewer": "查看者", + "viewer_description": "查看者只能查看和使用请求", + "valid_name": "请输入有效的工作区名称", + "valid_owner_email": "请输入有效的所有者邮箱" + }, + "users": { + "add_user": "添加用户", + "admin": "管理员", + "admin_id": "管理员 ID", + "cancel": "取消", + "created_on": "创建于", + "copy_invite_link": "复制邀请链接", + "copy_link": "复制链接", + "date": "日期", + "delete": "删除", + "delete_user": "删除用户", + "delete_users": "删除用户", + "details": "详情", + "edit": "编辑", + "email": "邮箱", + "email_address": "邮箱地址", + "empty_name": "名称不能为空!", + "id": "用户 ID", + "invalid_user": "无效用户", + "invite_load_list_error": "无法加载受邀用户列表", + "invite_user": "邀请用户", + "invited_by": "邀请人", + "invited_on": "邀请时间", + "invited_users": "受邀用户", + "invitee_email": "受邀者邮箱", + "last_active_on": "最后活跃时间", + "load_info_error": "无法加载用户信息", + "load_list_error": "无法加载用户列表", + "make_admin": "设为管理员", + "name": "姓名", + "new_user_added": "已添加新用户", + "no_invite": "未找到待处理邀请", + "no_invite_description": "暂无待处理邀请!开始邀请您的团队成员使用 Hoppscotch", + "no_shared_requests": "该用户未创建任何共享请求", + "no_users": "未找到用户", + "not_available": "不可用", + "not_found": "用户未找到", + "pending_invites": "待处理邀请", + "remove_admin_privilege": "移除管理员权限", + "remove_admin_status": "移除管理员状态", + "rename": "重命名", + "revoke_invitation": "撤消邀请", + "searchbar_placeholder": "按姓名或邮箱搜索...", + "send_invite": "发送邀请", + "show_more": "显示更多", + "uid": "用户标识符", + "unnamed": "(未命名用户)", + "user_not_found": "在基础设施中未找到该用户!", + "users": "用户", + "valid_email": "请输入有效的邮箱地址", + "deactivate": "停用", + "deactivate_user": "停用用户" + }, + "organization": { + "login_to_continue_description": "您需要登录才能加入组织实例。", + "create_an_organization": "创建一个组织", + "deactivate_user_failure": "用户停用失败!", + "deactivate_user_success": "用户停用成功!", + "delete_account_description": "这将删除与您的 Hoppscotch 账户关联的所有数据,包括您当前所属以及参与的任何其他组织实例。", + "delete_account": "删除 Hoppscotch 账户", + "user_deletion_failed_sole_admin": "该用户是一个或多个组织实例的唯一管理员。请在删除前先取消其管理员权限。", + "user_deletion_failed_sole_team_owner": "该用户在一个或多个组织实例中是工作区的唯一所有者。请在删除前转移所有权或删除相关的工作区。", + "no_organizations": "您不属于任何组织", + "admin": "管理员" + }, + "organization_sidebar": { + "hoppscotch_cloud": "Hoppscotch 云", + "cloud_locked": "默认实例无法移除", + "admin": "管理员", + "error_loading": "加载组织失败", + "inactive_orgs": "未激活的组织", + "no_orgs_found": "未找到组织", + "no_active_orgs_found": "没有活跃组织", + "organizations_for": "{email} 的组织", + "multi_account_notice": "每个组织都使用独立的登录状态,基于最近访问的账号。", + "inactive_orgs_tooltip": "请联系支持以获取帮助。" + }, + "billing": { + "confirm": { + "update_seat_count": "您确定要将席位数量更新为 {newSeatCount} 吗?", + "update_billing_cycle": "您确定要将结算周期更新为 {newBillingCycle} 吗?" + }, + "cancel_subscription": "取消订阅" + }, + "app_console": { + "entries": "控制台条目", + "no_entries": "暂无条目" + }, + "mockServer": { + "create_modal": { + "title": "创建 Mock 服务", + "name_label": "Mock 服务名称", + "name_placeholder": "输入 Mock 服务名称", + "name_required": "Mock 服务名称不能为空", + "collection_source_label": "集合来源", + "existing_collection": "现有集合", + "new_collection": "新建集合", + "select_collection_label": "选择集合", + "select_collection_placeholder": "选择一个集合", + "collection_required": "请选择一个集合", + "collection_name_label": "集合名称", + "collection_name_placeholder": "输入集合名称", + "collection_name_required": "集合名称不能为空", + "request_config_label": "请求配置", + "add_request": "添加请求", + "create_button": "创建 Mock 服务", + "success": "Mock 服务创建成功", + "error": "创建 Mock 服务失败", + "no_collections": "暂无可用集合" + }, + "edit_modal": { + "title": "编辑 Mock 服务", + "name_label": "Mock 服务名称", + "name_placeholder": "输入 Mock 服务名称", + "name_required": "Mock 服务名称不能为空", + "active_label": "激活状态", + "url_label": "Mock 服务 URL", + "collection_label": "关联集合", + "update_button": "更新 Mock 服务", + "success": "Mock 服务更新成功", + "error": "更新 Mock 服务失败", + "url_copied": "URL 已复制到剪贴板" + }, + "dashboard": { + "title": "Mock 服务", + "subtitle": "创建和管理您的 API 模拟服务", + "create_button": "创建 Mock 服务", + "create_first": "创建您的第一个 Mock 服务", + "empty_title": "未找到 Mock 服务", + "empty_description": "基于您的 API 集合创建 Mock 服务,无需依赖后端即可支持前端和移动端开发。", + "collection": "集合", + "active": "运行中", + "inactive": "已停止", + "mock_url": "Mock URL", + "endpoints": "端点数量", + "created": "创建时间", + "view_collection": "查看集合", + "documentation": "使用说明", + "doc_description": "在您的应用中将此 URL 作为 API 基础 URL 使用:", + "url_copied": "Mock 服务 URL 已复制到剪贴板", + "delete_title": "删除 Mock 服务", + "delete_description": "确定要删除此 Mock 服务吗?", + "delete_success": "Mock 服务删除成功", + "delete_error": "删除 Mock 服务失败" + } + } +} diff --git a/packages/hoppscotch-common/locales/cs.json b/packages/hoppscotch-common/locales/cs.json new file mode 100644 index 0000000..9f7e33e --- /dev/null +++ b/packages/hoppscotch-common/locales/cs.json @@ -0,0 +1,2299 @@ +{ + "action": { + "add": "Přidat", + "autoscroll": "Autoscroll", + "cancel": "Zrušit", + "choose_file": "Vyberte soubor", + "choose_workspace": "Vyberte pracovní prostor", + "choose_collection": "Vyberte kolekci", + "select_workspace": "Vyberte pracovní prostor", + "clear": "Vymazat", + "clear_all": "Vymazat vše", + "clear_cache": "Vymazat mezipaměť", + "clear_history": "Vymazat celou historii", + "clear_unpinned": "Vymazat nepřipnuté", + "clear_response": "Vymazat odpověď", + "close": "Zavřít", + "confirm": "Potvrdit", + "connect": "Připojit", + "connecting": "Připojování", + "copy": "Kopírovat", + "create": "Vytvořit", + "delete": "Vymazat", + "disconnect": "Odpojit", + "dismiss": "Skrýt", + "done": "Hotovo", + "dont_save": "Neukládat", + "download_file": "Stáhnout soubor", + "download_test_report": "Stáhnout test report", + "drag_to_reorder": "Přetáhněte pro změnu pořadí", + "duplicate": "Duplikovat", + "edit": "Upravit", + "filter": "Filtrovat", + "go_back": "Přejít zpět", + "go_forward": "Přejít vpřed", + "group_by": "Seskupit podle", + "hide_secret": "Skrýt tajný údaj", + "label": "Označení", + "learn_more": "Další informace", + "download_here": "Stáhnout zde", + "less": "Méně", + "more": "Více", + "new": "Nový", + "no": "Ne", + "open": "Otevřít", + "open_workspace": "Otevřít pracovní prostor", + "paste": "Vložit", + "prettify": "Prettify", + "properties": "Vlastnosti", + "register": "Registrovat", + "remove": "Odstranit", + "remove_instance": "Odebrat instanci", + "rename": "Přejmenovat", + "restore": "Obnovit", + "retry": "Zkusit znovu", + "save": "Uložit", + "save_as_example": "Uložit jako příklad", + "add_example": "Přidat příklad", + "invalid_request": "Neplatná data požadavku", + "scroll_to_bottom": "Posunout dolů", + "scroll_to_top": "Posunout nahoru", + "search": "Vyhledávání", + "send": "Odeslat", + "share": "Sdílet", + "show_secret": "Zobrazit tajný údaj", + "sort": "Seřadit", + "start": "Spustit", + "starting": "Spouštění", + "stop": "Zastavit", + "to_close": "pro zavření", + "to_navigate": "pro navigaci", + "to_select": "pro výběr", + "turn_off": "Vypnout", + "turn_on": "Zapnout", + "undo": "Vrátit zpět", + "unpublish": "Zrušit publikování", + "yes": "Ano", + "verify": "Ověřit", + "enable": "Povolit", + "disable": "Zakázat", + "assign": "Přiřadit" + }, + "activity_logs": { + "ACTIVITY_LOG_DELETE": "Protokol aktivit byl smazán", + "WORKSPACE_CREATE": "Vytvořen nový pracovní prostor {name}", + "WORKSPACE_RENAME": "Přejmenován pracovní prostor z {old_name} na {new_name}", + "WORKSPACE_USER_ADD": "{user} byl přidán do pracovního prostoru jako {role}", + "WORKSPACE_USER_INVITE": "{user} byl pozván uživatelem {inviteeEmail} jako {role}", + "WORKSPACE_USER_INVITE_REVOKE": "Odvoláno pozvání {inviteeEmail} jako {inviteeRole}", + "WORKSPACE_USER_INVITE_ACCEPT": "{inviteeEmail} přijal pozvání jako {inviteeRole}", + "WORKSPACE_USER_REMOVE": "{user} byl odebrán z pracovního prostoru", + "WORKSPACE_USER_ROLE_UPDATE": "Role {user} byla aktualizována z {old_role} na {new_role}", + "COLLECTION_CREATE": "Vytvořena nová kolekce {title}", + "COLLECTION_RENAME": "Přejmenována kolekce z {old_title} na {new_title}", + "COLLECTION_IMPORT": "Importováno {count} kolekcí", + "COLLECTION_DELETE": "Smazána kolekce {title}", + "COLLECTION_DUPLICATE": "Duplikována kolekce {parentTitle}", + "REQUEST_CREATE": "Vytvořen nový požadavek {title}", + "REQUEST_RENAME": "Přejmenován požadavek z {old_title} na {new_title}", + "REQUEST_DELETE": "Smazán požadavek {title}" + }, + "add": { + "new": "Přidat nový", + "star": "Přidat hvězdičku" + }, + "agent": { + "registration_instruction": "Chcete-li pokračovat, zaregistrujte si Hoppscotch Agenta u svého webového klienta.", + "enter_otp_instruction": "Zadejte prosím ověřovací kód vygenerovaný agentem Hoppscotch a dokončete registraci", + "otp_label": "Ověřovací kód", + "processing": "Zpracovává se váš požadavek...", + "not_running_title": "Agent nebyl zjištěn", + "registration_title": "Registrace agenta", + "verify_ssl_certs": "Ověřit SSL certifikáty", + "ca_certs": "Certifikáty CA", + "client_certs": "Klientské certifikáty", + "use_http_proxy": "Použít HTTP proxy", + "proxy_capabilities": "Hoppscotch Agent podporuje HTTP/HTTPS/SOCKS proxy spolu s NTLM a Basic Auth v těchto proxy. V samotném URL uveďte uživatelské jméno a heslo pro ověření proxy.", + "add_cert_file": "Přidat soubor certifikátu", + "add_client_cert": "Přidat klientský certifikát", + "add_key_file": "Přidat soubor klíče", + "domain": "Doména", + "cert": "Certifikát", + "key": "Klíč", + "pfx_or_pkcs": "PFX/PKCS12", + "pfx_or_pkcs_file": "Soubor PFX/PKCS12", + "add_pfx_or_pkcs_file": "Přidat soubor PFX/PKCS12" + }, + "app": { + "additional_links": { + "macOS": "macOS", + "windows": "Windows", + "linux": "Linux", + "web_app": "Webová aplikace", + "cli": "CLI" + }, + "chat_with_us": "Napište nám", + "contact_us": "Kontaktujte nás", + "cookies": "Soubory cookie", + "copy": "Kopírovat", + "copy_interface_type": "Kopírovat typ rozhraní", + "copy_user_id": "Kopírovat auth token uživatele", + "developer_option": "Možnosti vývojáře", + "developer_option_description": "Nástroje pro vývojáře, které pomáhají s vývojem a údržbou Hoppscotch.", + "discord": "Discord", + "documentation": "Dokumentace", + "github": "GitHub", + "help": "Nápověda a zpětná vazba", + "home": "Domov", + "invite": "Pozvat", + "invite_description": "Hoppscotch je open-source ekosystém pro vývoj API. Navrhli jsme jednoduché a intuitivní rozhraní pro vytváření a správu API. Hoppscotch vám pomůže API vytvářet, testovat, dokumentovat a sdílet.", + "invite_your_friends": "Pozvěte své přátele", + "join_discord_community": "Připojte se k naší komunitě Discord", + "keyboard_shortcuts": "Klávesové zkratky", + "name": "Hoppscotch", + "new_version_found": "Nalezena nová verze. Obnovte stránku pro aktualizaci.", + "open_in_hoppscotch": "Otevřít v Hoppscotch", + "options": "Možnosti", + "powered_by": "Powered by Hoppscotch", + "proxy_privacy_policy": "Zásady ochrany osobních údajů proxy", + "reload": "Znovu načíst", + "search": "Vyhledávání a příkazy", + "share": "Sdílet", + "shortcuts": "Klávesové zkratky", + "social_description": "Sledujte nás na sociálních sítích, ať máte přehled o novinkách, aktualizacích a vydáních.", + "social_links": "Odkazy na sociální sítě", + "spotlight": "Spotlight", + "status": "Stav", + "status_description": "Zkontrolujte stav služby", + "terms_and_privacy": "Podmínky a soukromí", + "twitter": "Twitter", + "type_a_command_search": "Zadejte příkaz nebo hledejte…", + "we_use_cookies": "Používáme soubory cookie", + "updated_text": "Hoppscotch byl aktualizován na v{version} 🎉", + "whats_new": "Co je nového?", + "see_whats_new": "Podívejte se, co je nového", + "wiki": "Wiki", + "collapse_sidebar": "Sbalit postranní panel", + "continue_to_dashboard": "Pokračovat na dashboard", + "expand_sidebar": "Rozbalit postranní panel", + "no_name": "Bez názvu", + "open_navigation": "Otevřít navigaci", + "read_documentation": "Přečtěte si dokumentaci", + "default": "výchozí: {value}" + }, + "auth": { + "account_deactivated": "Váš účet byl deaktivován. Chcete-li získat přístup, kontaktujte správce.", + "account_exists": "Účet existuje s jinými přihlašovacími údaji. Přihlaste se a propojte oba účty.", + "all_sign_in_options": "Všechny možnosti přihlášení", + "continue_with_auth_provider": "Pokračovat s {provider}", + "continue_with_email": "Pokračovat e-mailem", + "continue_with_github": "Pokračovat s GitHubem", + "continue_with_github_enterprise": "Pokračovat s GitHub Enterprise", + "continue_with_google": "Pokračovat s Googlem", + "continue_with_microsoft": "Pokračovat s Microsoftem", + "email": "E-mail", + "logged_out": "Odhlášeno", + "login": "Přihlásit se", + "login_success": "Úspěšně přihlášeno", + "login_to_hoppscotch": "Přihlaste se do Hoppscotch", + "logout": "Odhlásit se", + "re_enter_email": "Znovu zadat e-mail", + "send_magic_link": "Odeslat magic link", + "sync": "Synchronizace", + "we_sent_magic_link": "Poslali jsme vám magic link!", + "we_sent_magic_link_description": "Zkontrolujte svou doručenou poštu - odeslali jsme e-mail na adresu {email}. Obsahuje magic link, který vás přihlásí." + }, + "authorization": { + "generate_token": "Vygenerovat token", + "refresh_token": "Obnovit token", + "graphql_headers": "Autorizační hlavičky se odesílají jako součást payloadu do connection_init", + "include_in_url": "Zahrnout do adresy URL", + "inherited_from": "Autorizace {auth} zděděna z nadřazené kolekce {collection}", + "learn": "Zjistěte více", + "oauth": { + "redirect_auth_server_returned_error": "Autorizační server vrátil chybový stav", + "redirect_auth_token_request_failed": "Požadavek na získání Access Tokenu selhal", + "redirect_auth_token_request_invalid_response": "Při požadavku na získání Access Tokenu přišla z Token Endpointu neplatná odpověď", + "redirect_invalid_state": "V přesměrování je neplatná hodnota state", + "redirect_no_auth_code": "V přesměrování chybí autorizační kód", + "redirect_no_client_id": "Není definováno Client ID", + "redirect_no_client_secret": "Není definován Client Secret", + "redirect_no_code_verifier": "Není definován Code Verifier", + "redirect_no_token_endpoint": "Není definován Token Endpoint", + "something_went_wrong_on_oauth_redirect": "Při OAuth přesměrování se něco pokazilo", + "something_went_wrong_on_token_generation": "Při generování tokenu se něco pokazilo", + "token_generation_oidc_discovery_failed": "Generování tokenu selhalo: nepodařilo se OIDC Discovery", + "grant_type": "Typ grantu", + "grant_type_auth_code": "Autorizační kód", + "token_fetched_successfully": "Token byl úspěšně získán", + "token_fetch_failed": "Token se nepodařilo získat", + "validation_failed": "Validace selhala, zkontrolujte prosím pole formuláře", + "no_refresh_token_present": "Chybí Refresh Token. Spusťte znovu proces generování tokenu", + "refresh_token_request_failed": "Požadavek na obnovení tokenu se nezdařil", + "token_refreshed_successfully": "Token byl úspěšně obnoven", + "label_authorization_endpoint": "Autorizační endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Metoda Code Challenge", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Použít PKCE", + "label_implicit": "Implicitní", + "label_password": "Heslo", + "label_username": "Uživatelské jméno", + "label_auth_code": "Autorizační kód", + "label_client_credentials": "Client Credentials", + "label_send_as": "Ověření klienta", + "label_send_in_body": "Odeslat přihlašovací údaje v těle požadavku", + "label_send_as_basic_auth": "Odeslat přihlašovací údaje jako Basic Auth", + "enter_value": "Zadejte hodnotu", + "auth_request": "Auth Request", + "token_request": "Token Request", + "refresh_request": "Refresh Request", + "send_in": "Odeslat v" + }, + "pass_key_by": "Předat přes", + "pass_by_query_params_label": "Query parametry", + "pass_by_headers_label": "Hlavičky", + "password": "Heslo", + "save_to_inherit": "Uložte prosím tento požadavek do některé kolekce, aby bylo možné dědit autorizaci", + "token": "Token", + "access_token": "Access Token", + "client_token": "Client Token", + "client_secret": "Client Secret", + "timestamp": "Časové razítko", + "host": "Host", + "type": "Typ autorizace", + "username": "Uživatelské jméno", + "advance_config": "Pokročilá konfigurace", + "advance_config_description": "Hoppscotch automaticky přiřadí výchozí hodnoty určitým polím, pokud není zadána žádná explicitní hodnota", + "algorithm": "Algoritmus", + "payload": "Payload", + "secret": "Secret", + "aws_signature": { + "access_key": "Access Key", + "secret_key": "Secret Key", + "service_name": "Název služby", + "aws_region": "AWS Region", + "service_token": "Service Token" + }, + "digest": { + "realm": "Realm", + "nonce": "Nonce", + "algorithm": "Algoritmus", + "qop": "qop", + "nonce_count": "Nonce Count", + "client_nonce": "Client Nonce", + "opaque": "Opaque", + "disable_retry": "Zakázat opakování požadavku" + }, + "akamai": { + "headers_to_sign": "Hlavičky k podepsání", + "max_body_size": "Maximální velikost těla" + }, + "hawk": { + "id": "HAWK Auth ID", + "key": "HAWK Auth Key", + "ext": "ext", + "app": "app", + "dlg": "dlg", + "include": "Zahrnout Payload Hash" + }, + "jwt": { + "params_name": "Název parametrů", + "param_name": "Název parametru", + "header_prefix": "Předpona hlavičky", + "placeholder_request_header": "Předpona hlavičky požadavku", + "placeholder_request_param": "Název parametru požadavku", + "secret_base64_encoded": "Secret Base64 Encoded", + "headers": "JWT hlavičky", + "private_key": "Soukromý klíč", + "placeholder_headers": "JWT hlavičky" + }, + "ntlm": { + "domain": "Doména", + "workstation": "Pracovní stanice", + "disable_retrying_request": "Zakázat opakování požadavku" + }, + "asap": { + "issuer": "Vydavatel", + "audience": "Audience", + "expires_in": "Platnost vyprší za", + "key_id": "ID klíče", + "optional_config": "Volitelná konfigurace", + "subject": "Subject", + "additional_claims": "Additional Claims" + } + }, + "collection": { + "title": "Kolekce", + "run": "Spustit kolekci", + "created": "Kolekce vytvořena", + "different_parent": "Nelze změnit pořadí kolekce s jiným nadřazeným prvkem", + "edit": "Upravit kolekci", + "import_or_create": "Importovat nebo vytvořit kolekci", + "import_collection": "Importovat kolekci", + "invalid_name": "Uveďte prosím název kolekce", + "invalid_root_move": "Kolekce už je v kořeni", + "moved": "Kolekce přesunuta", + "my_collections": "Moje kolekce", + "name": "Moje nová kolekce", + "name_length_insufficient": "Název kolekce musí mít alespoň 3 znaky", + "new": "Nová kolekce", + "order_changed": "Pořadí kolekce aktualizováno", + "properties": "Vlastnosti kolekce", + "properties_updated": "Vlastnosti kolekce aktualizovány", + "renamed": "Kolekce přejmenována", + "request_in_use": "Požadavek se používá", + "save_as": "Uložit jako", + "save_to_collection": "Uložit do kolekce", + "select": "Vyberte kolekci", + "select_location": "Vyberte umístění", + "sorted": "Kolekce seřazena", + "details": "Podrobnosti", + "duplicated": "Kolekce duplikována" + }, + "confirm": { + "close_unsaved_tab": "Opravdu chcete zavřít tuto kartu?", + "close_unsaved_tabs": "Opravdu chcete zavřít všechny karty? {count} neuložených karet bude ztraceno.", + "delete_all_activity_logs": "Opravdu chcete smazat všechny protokoly aktivit?", + "exit_team": "Opravdu chcete opustit tento pracovní prostor?", + "logout": "Opravdu se chcete odhlásit?", + "remove_collection": "Opravdu chcete tuto kolekci trvale smazat?", + "remove_environment": "Opravdu chcete toto prostředí trvale odstranit?", + "remove_folder": "Opravdu chcete tuto složku trvale smazat?", + "remove_history": "Opravdu chcete trvale smazat celou historii?", + "remove_request": "Opravdu chcete tento požadavek trvale smazat?", + "remove_response": "Opravdu chcete tuto odpověď trvale smazat?", + "remove_shared_request": "Opravdu chcete trvale smazat tento sdílený požadavek?", + "remove_team": "Opravdu chcete tento pracovní prostor smazat?", + "remove_telemetry": "Opravdu se chcete odhlásit z telemetrie?", + "request_change": "Opravdu chcete zrušit aktuální požadavek? Neuložené změny budou ztraceny.", + "save_unsaved_tab": "Chcete uložit změny provedené na této kartě?", + "sync": "Chcete obnovit pracovní prostor z cloudu? Tím se ztratí váš lokální postup.", + "delete_access_token": "Opravdu chcete smazat přístupový token {tokenLabel}?", + "delete_mock_server": "Opravdu chcete smazat tento mock server?" + }, + "context_menu": { + "add_parameters": "Přidat k parametrům", + "open_request_in_new_tab": "Otevřít požadavek na nové kartě", + "set_environment_variable": "Nastavit jako proměnnou" + }, + "cookies": { + "modal": { + "cookie_expires": "Platnost do", + "cookie_name": "Název", + "cookie_path": "Cesta", + "cookie_string": "Řetězec cookie", + "cookie_value": "Hodnota", + "empty_domain": "Doména je prázdná", + "empty_domains": "Seznam domén je prázdný", + "enter_cookie_string": "Zadejte řetězec cookie", + "interceptor_no_support": "Aktuálně zvolený interceptor nepodporuje cookies. Zvolte jiný interceptor a zkuste to znovu.", + "managed_tab": "Spravované", + "new_domain_name": "Nový název domény", + "no_cookies_in_domain": "Pro tuto doménu nejsou nastavené žádné cookies", + "raw_tab": "Surové", + "set": "Nastavit cookie" + } + }, + "count": { + "currentValue": "Aktuální hodnota {count}", + "description": "Popis {count}", + "header": "Hlavičky {count}", + "initialValue": "Počáteční hodnota {count}", + "key": "Klíč {count}", + "message": "Zpráva {count}", + "parameter": "Parametr {count}", + "protocol": "Protokol {count}", + "value": "Hodnota {count}", + "variable": "Proměnná {count}" + }, + "documentation": { + "add_description": "Sem přidejte popis této kolekce...", + "add_description_placeholder": "Zde přidejte popis...", + "add_request_description": "Sem přidejte popis tohoto požadavku...", + "auth": { + "access_key": "Access Key", + "access_token": "Access Token", + "add_to": "Přidat do", + "akamai_edgegrid": "Akamai EdgeGrid", + "algorithm": "Algoritmus", + "api_key": "Klíč API", + "app_id": "ID aplikace", + "auth_id": "ID ověření", + "auth_key": "Auth Key", + "auth_url": "Auth URL", + "aws_signature": "AWS Signature", + "basic_auth": "Basic Auth", + "bearer_token": "Bearer Token", + "client_id": "Client ID", + "client_nonce": "Client Nonce", + "client_secret": "Client Secret", + "client_token": "Client Token", + "delegation": "Delegace", + "digest_auth": "Digest Auth", + "extra_data": "Extra Data", + "grant_type": "Typ grantu", + "hawk_auth": "HAWK Auth", + "headers_to_sign": "Hlavičky k podepsání", + "host": "Hostitel", + "include_payload_hash": "Zahrnout Payload Hash", + "jwt_auth": "JWT Auth", + "max_body_size": "Maximální velikost těla", + "no_auth": "Žádná autorizace", + "nonce": "Nonce", + "oauth_2": "OAuth 2.0", + "opaque": "Neprůhledný", + "password": "Heslo", + "payload": "Payload", + "qop": "QOP", + "realm": "Realm", + "region": "Region", + "scope": "Rozsah", + "secret_key": "Secret Key", + "service_name": "Název služby", + "timestamp": "Časové razítko", + "title": "Autentizace", + "token_url": "Token URL", + "user": "Uživatel", + "username": "Uživatelské jméno" + }, + "body": { + "content_type": "Typ obsahu", + "no_body": "Není definováno žádné tělo", + "title": "Tělo" + }, + "copied_to_clipboard": "Zkopírováno do schránky!", + "curl": { + "click_to_load": "Kliknutím načtete příkaz cURL", + "copied": "Příkaz cURL zkopírován do schránky!", + "copy_to_clipboard": "Kopírovat do schránky", + "generating": "Generování příkazu cURL...", + "load": "Načíst cURL", + "title": "cURL" + }, + "description": "Popis", + "error_rendering_markdown": "Chyba při vykreslování značky:", + "fetching_documentation": "Načítání dokumentace...", + "generate": "Generovat dokumentaci", + "generate_message": "Importujte libovolnou kolekci Hoppscotch a vygenerujte dokumentaci API.", + "headers": { + "no_headers": "Nejsou definovány žádné hlavičky", + "title": "Hlavičky" + }, + "hide_all_documentation": "Skrýt veškerou dokumentaci", + "inherited_from": "Zděděno od {name}", + "inherited_with_type": "Zděděno {type} od {name}", + "key": "Klíč", + "loading": "Načítání...", + "loading_collection_data": "Načítání dat kolekce...", + "no": "Žádný", + "no_collection_data": "Nejsou k dispozici žádná data kolekce", + "no_documentation_found": "Nebyla nalezena žádná dokumentace pro složky nebo požadavky", + "no_request_data": "Nejsou k dispozici žádná data požadavku", + "no_requests_or_folders": "Žádné požadavky ani složky", + "not_set": "Nenastaveno", + "open_request_in_new_tab": "Otevřít požadavek na nové kartě", + "parameters": { + "no_params": "Nejsou definovány žádné parametry", + "title": "Parametry" + }, + "percent_complete": "% dokončeno", + "processing_documentation": "Zpracování dokumentace", + "publish": { + "already_published": "Tato kolekce je již publikována", + "auto_sync": "Automatická synchronizace s kolekcí", + "auto_sync_description": "Automaticky aktualizovat publikované dokumenty, když se změní kolekce", + "button": "Publikovat", + "copy_url": "Kopírovat URL", + "delete": "Smazat dokumentaci", + "unpublish_doc": "Opravdu chcete zrušit publikování dokumentace?", + "delete_success": "Publikovaná dokumentace byla úspěšně smazána", + "doc_title": "Název", + "doc_version": "Verze", + "edit_published_doc": "Upravit publikovanou dokumentaci", + "last_updated": "Poslední aktualizace", + "metadata": "Metadata (JSON)", + "open_published_doc": "Otevřít publikovanou dokumentaci na nové kartě", + "publish_error": "Dokumentaci se nepodařilo publikovat", + "publish_success": "Dokumentace byla úspěšně zveřejněna!", + "published": "Publikováno", + "published_url": "Publikované URL", + "title": "Publikovat dokumentaci", + "update_button": "Aktualizovat", + "update_error": "Aktualizace dokumentace se nezdařila", + "update_published_docs": "Aktualizovat publikované dokumenty", + "update_success": "Dokumentace byla úspěšně aktualizována!", + "unpublish": "Zrušit publikování", + "update_title": "Aktualizovat publikovanou dokumentaci", + "url_copied": "URL zkopírováno do schránky!", + "view_published": "Zobrazit publikované dokumenty", + "view_title": "Zobrazit publikovanou dokumentaci" + }, + "request_opened_in_new_tab": "Požadavek byl otevřen na nové kartě!", + "response": { + "body": "Tělo odpovědi", + "copy": "Kopírovat odpověď", + "example_copied": "Příklad odpovědi zkopírován do schránky!", + "example_copy_failed": "Příklad odpovědi se nepodařilo zkopírovat", + "headers": "Hlavičky odpovědi", + "no_examples": "Nejsou k dispozici žádné příklady odpovědí", + "title": "Příklady odpovědí" + }, + "save_error": "Při ukládání dokumentace došlo k chybě", + "save_success": "Dokumentace byla úspěšně uložena", + "saved_items_status": "Uloženo {success} položek. Nepodařilo se uložit {failure} položky.", + "show_all_documentation": "Zobrazit veškerou dokumentaci", + "source": "Zdroj", + "title": "Dokumentace", + "unsaved_changes": "Máte {count} neuložených změn. Před zavřením prosím uložte.", + "untitled_collection": "Kolekce bez názvu", + "untitled_request": "Požadavek bez názvu", + "value": "Hodnota", + "variables": { + "no_vars": "Nejsou definovány žádné proměnné", + "title": "Proměnné" + }, + "yes": "Ano" + }, + "empty": { + "activity_logs": "Nebyly nalezeny žádné protokoly aktivit", + "authorization": "Tento požadavek nepoužívá žádnou autorizaci", + "body": "Tento požadavek nemá tělo", + "collection": "Kolekce je prázdná", + "collections": "Kolekce jsou prázdné", + "documentation": "Připojte se ke GraphQL endpointu pro zobrazení dokumentace", + "empty_schema": "Nebylo nalezeno žádné schéma", + "endpoint": "Endpoint nesmí být prázdný", + "environments": "Prostředí jsou prázdná", + "collection_variables": "Proměnné kolekce jsou prázdné", + "folder": "Složka je prázdná", + "headers": "Tento požadavek nemá žádné hlavičky", + "history": "Historie je prázdná", + "invites": "Seznam pozvánek je prázdný", + "members": "Pracovní prostor je prázdný", + "parameters": "Tento požadavek nemá žádné parametry", + "pending_invites": "Pro tento pracovní prostor nejsou žádné čekající pozvánky", + "profile": "Pro zobrazení profilu se přihlaste", + "protocols": "Protokoly jsou prázdné", + "request_variables": "Tento požadavek nemá žádné proměnné", + "schema": "Připojte se ke GraphQL endpointu pro zobrazení schématu", + "search_environment": "Nebylo nalezeno žádné odpovídající prostředí pro", + "secret_environments": "Tajné údaje nejsou synchronizovány do Hoppscotch", + "shared_requests": "Sdílené požadavky jsou prázdné", + "shared_requests_logout": "Přihlaste se pro zobrazení sdílených požadavků nebo vytvořte nový", + "subscription": "Odběry jsou prázdné", + "team_name": "Název pracovního prostoru je prázdný", + "teams": "Nejste členem žádného pracovního prostoru", + "tests": "Pro tento požadavek neexistují žádné testy", + "access_tokens": "Přístupové tokeny jsou prázdné", + "response": "Nebyla přijata žádná odpověď", + "mock_servers": "Nebyly nalezeny žádné mock servery" + }, + "environment": { + "heading": "Prostředí", + "add_to_global": "Přidat do globálního", + "added": "Prostředí přidáno", + "create_new": "Vytvořit nové prostředí", + "created": "Prostředí vytvořeno", + "current_value": "Aktuální hodnota", + "deleted": "Prostředí odstraněno", + "duplicated": "Prostředí duplikováno", + "edit": "Upravit prostředí", + "empty_variables": "Žádné proměnné", + "global": "Globální", + "global_variables": "Globální proměnné", + "import_or_create": "Importovat nebo vytvořit prostředí", + "initial_value": "Počáteční hodnota", + "invalid_name": "Zadejte platný název prostředí", + "list": "Proměnné prostředí", + "my_environments": "Moje prostředí", + "name": "Název", + "nested_overflow": "Vnořené proměnné prostředí jsou omezeny na 10 úrovní", + "new": "Nové prostředí", + "no_active_environment": "Žádné aktivní prostředí", + "no_environment": "Žádné prostředí", + "no_environment_description": "Nebyla vybrána žádná prostředí. Zvolte, co udělat s následujícími proměnnými.", + "quick_peek": "Rychlý náhled na prostředí", + "replace_all_current_with_initial": "Vyměňte veškerý proud za počáteční", + "replace_all_initial_with_current": "Nahraďte všechny iniciály aktuálními", + "replace_current_with_initial": "Nahraďte počátečním", + "replace_initial_with_current": "Vyměňte za proud", + "replace_with_variable": "Nahradit proměnnou", + "scope": "Oblast platnosti", + "secrets": "Tajné údaje", + "secret_value": "Tajná hodnota", + "select": "Vyberte prostředí", + "set": "Nastavit prostředí", + "set_as_environment": "Nastavit jako prostředí", + "short_name": "Prostředí musí mít v názvu minimálně 1 znak", + "team_environments": "Prostředí pracovního prostoru", + "title": "Prostředí", + "updated": "Prostředí aktualizováno", + "value": "Hodnota", + "variable": "Proměnná", + "variables": "Proměnné", + "variable_list": "Seznam proměnných", + "properties": "Vlastnosti prostředí", + "details": "Podrobnosti" + }, + "error": { + "network": { + "heading": "Chyba sítě", + "description": "Síťové připojení se nezdařilo. {message}: {cause}" + }, + "timeout": { + "heading": "Chyba vypršení časového limitu", + "description": "Časový limit požadavku vypršel během {phase}. {message}" + }, + "certificate": { + "heading": "Chyba certifikátu", + "description": "Neplatný certifikát. {message}: {cause}" + }, + "auth": { + "heading": "Chyba ověření", + "description": "Přístup odepřen. {message}: {cause}" + }, + "proxy": { + "heading": "Chyba proxy", + "description": "Připojení proxy se nezdařilo. {message}: {cause}" + }, + "parse": { + "heading": "Chyba analýzy", + "description": "Analýza odpovědi se nezdařila. {message}: {cause}" + }, + "version": { + "heading": "Chyba verze", + "description": "Nekompatibilní verze. {message}: {cause}" + }, + "abort": { + "heading": "Požadavek zrušen", + "description": "Operace zrušena. {message}: {cause}" + }, + "unknown": { + "heading": "Neznámá chyba", + "description": "Došlo k neznámé chybě.", + "cause": "Neznámá příčina" + }, + "extension": { + "heading": "Chyba rozšíření", + "description": "Spuštění požadavku na rozšíření se nezdařilo" + }, + "authproviders_load_error": "Nelze načíst poskytovatele přihlášení", + "browser_support_sse": "Zdá se, že tento prohlížeč nemá podporu událostí odeslaných serverem.", + "check_console_details": "Podrobnosti najdete v konzoli.", + "check_how_to_add_origin": "Podívejte se, jak přidat origin", + "curl_invalid_format": "cURL nemá správný formát", + "danger_zone": "Nebezpečná zóna", + "delete_account": "Váš účet je aktuálně jediným vlastníkem těchto pracovních prostorů:", + "delete_account_description": "Před smazáním účtu se z těchto pracovních prostorů odeberte, převeďte vlastnictví, nebo je smažte.", + "delete_activity_log": "Protokol aktivit se nepodařilo smazat", + "delete_all_activity_logs": "Nepodařilo se odstranit všechny protokoly aktivit", + "email_already_exists": "Tento e-mail již existuje s jiným účtem. Nastavte prosím jinou e-mailovou adresu.", + "empty_email_address": "E-mailová adresa nemůže být prázdná", + "empty_profile_name": "Název profilu nemůže být prázdný", + "empty_req_name": "Název požadavku je prázdný", + "fetch_activity_logs": "Protokoly aktivit se nepodařilo načíst", + "f12_details": "(F12 pro podrobnosti)", + "gql_prettify_invalid_query": "Neplatný dotaz nelze formátovat pomocí Prettify. Opravte chyby syntaxe dotazu a zkuste to znovu.", + "incomplete_config_urls": "Neúplné konfigurační URL", + "incorrect_email": "Nesprávný e-mail", + "invalid_file_type": "Neplatný typ souboru pro `{filename}`.", + "invalid_link": "Neplatný odkaz", + "invalid_link_description": "Odkaz, na který jste klikli, je neplatný nebo expirovaný.", + "invalid_embed_link": "Embed odkaz neexistuje nebo je neplatný.", + "json_parsing_failed": "Neplatný JSON", + "json_prettify_invalid_body": "Neplatné tělo nelze Prettify. Opravte chyby syntaxe JSON a zkuste to znovu.", + "network_error": "Zdá se, že došlo k chybě sítě. Zkuste to prosím znovu.", + "network_fail": "Požadavek nelze odeslat", + "no_collections_to_export": "Žádné kolekce k exportu. Nejprve prosím vytvořte kolekci.", + "no_duration": "Žádné trvání", + "no_environments_to_export": "Žádná prostředí k exportu. Nejprve prosím vytvořte prostředí.", + "no_results_found": "Nebyly nalezeny žádné shody", + "page_not_found": "Tuto stránku se nepodařilo najít", + "please_install_extension": "Nainstalujte rozšíření a přidejte origin do rozšíření.", + "proxy_error": "Chyba proxy", + "same_email_address": "Aktualizovaná e-mailová adresa se shoduje s aktuální", + "same_profile_name": "Aktualizovaný název profilu je stejný jako aktuální", + "script_fail": "Pre-request skript nelze spustit", + "something_went_wrong": "Něco se pokazilo", + "subscription_error": "Nepodařilo se přihlásit k odběru tématu: {error}", + "post_request_script_fail": "Nepodařilo se spustit post-request skript", + "reading_files": "Chyba při čtení jednoho nebo více souborů.", + "fetching_access_tokens_list": "Při načítání seznamu tokenů se něco pokazilo", + "generate_access_token": "Při generování přístupového tokenu se něco pokazilo", + "delete_access_token": "Při mazání přístupového tokenu se něco pokazilo", + "extension_not_found": "Rozšíření nenalezeno", + "invalid_request": "Neplatná data požadavku" + }, + "export": { + "as_json": "Exportovat jako JSON", + "create_secret_gist": "Vytvořit tajný Gist", + "create_secret_gist_tooltip_text": "Exportovat jako tajný Gist", + "failed": "Během exportování se něco nepodařilo", + "secret_gist_success": "Tajný Gist úspěšně exportován", + "require_github": "Přihlaste se přes GitHub a vytvořte tajný Gist", + "title": "Exportovat", + "success": "Úspěšně exportováno" + }, + "file_upload": { + "choose_file": "Vyberte soubor", + "max_size_format": "Maximálně 5 MB. Podporuje JPEG, PNG, GIF, WebP", + "profile_photo_updated": "Profilová fotka byla úspěšně aktualizována", + "profile_photo_removed": "Profilová fotka byla úspěšně odstraněna", + "org_logo_updated": "Logo organizace bylo úspěšně aktualizováno", + "error_size_limit": "Velikost souboru musí být menší než 5 MB", + "error_invalid_format": "Soubor musí být obrázek (JPEG, PNG, GIF nebo WebP)", + "error_invalid_upload_type": "Neplatný typ nahrávání", + "error_invalid_org_id": "Neplatný formát ID organizace", + "error_upload_failed": "Nahrání se nezdařilo. Zkuste to prosím znovu", + "error_network_failed": "Chyba sítě. Zkontrolujte prosím své připojení", + "error_timeout": "Časový limit nahrávání vypršel po 30 sekundách. Zkuste to prosím znovu", + "error_missing_backend_url": "Backend URL není nakonfigurován. Nastavte prosím proměnnou prostředí VITE_BACKEND_API_URL v nastavení prostředí", + "error_invalid_backend_url": "Neplatná konfigurace backendu URL" + }, + "filename": { + "cookie_key_value_pairs": "Cookie", + "codegen": "{request_name} - kód", + "graphql_response": "GraphQL odpověď", + "lens": "{request_name} - odpověď", + "realtime_response": "Odpověď v reálném čase", + "response_interface": "Rozhraní odpovědi" + }, + "filter": { + "all": "Vše", + "none": "Žádné", + "starred": "S hvězdičkou" + }, + "folder": { + "created": "Složka vytvořena", + "edit": "Upravit složku", + "invalid_name": "Zadejte název složky", + "name_length_insufficient": "Název složky musí mít alespoň 3 znaky", + "new": "Nová složka", + "run": "Spustit složku", + "renamed": "Složka přejmenována", + "sorted": "Složka seřazena" + }, + "graphql": { + "arguments": "Argumenty", + "connection_switch_confirm": "Chcete se připojit k nejnovějšímu GraphQL endpointu?", + "connection_error_http": "Nepodařilo se načíst schéma GraphQL kvůli chybě sítě.", + "connection_switch_new_url": "Přepnutí na kartu vás odpojí od aktivního GraphQL připojení. Nová adresa připojení je", + "connection_switch_url": "Jste připojeni ke GraphQL endpointu, adresa připojení je", + "deprecated": "Zastaralé", + "fields": "Pole", + "mutation": "Mutace", + "mutations": "Mutace", + "schema": "Schéma", + "show_depricated_values": "Zobrazit zastaralé hodnoty", + "subscription": "Předplatné", + "subscriptions": "Odběry", + "switch_connection": "Přepnout připojení", + "url_placeholder": "Zadejte URL GraphQL endpointu", + "query": "Query" + }, + "graphql_collections": { + "title": "GraphQL kolekce" + }, + "group": { + "time": "Čas", + "url": "URL" + }, + "header": { + "install_pwa": "Nainstalovat aplikaci", + "login": "Přihlásit se", + "save_workspace": "Uložit můj pracovní prostor" + }, + "helpers": { + "authorization": "Autorizační hlavička se automaticky vygeneruje při odeslání požadavku.", + "collection_properties_authorization": "Tato autorizace se nastaví pro každý požadavek z této kolekce.", + "collection_properties_header": "Tato hlavička se nastaví pro každý požadavek z této kolekce.", + "generate_documentation_first": "Nejprve vytvořte dokumentaci", + "network_fail": "K API endpointu se nelze připojit. Zkontrolujte síťové připojení nebo zvolte jiný Interceptor a zkuste to znovu.", + "offline": "Používáte Hoppscotch offline. Aktualizace se po připojení synchronizují podle nastavení pracovního prostoru.", + "offline_short": "Používáte Hoppscotch offline.", + "post_request_tests": "Post-request skripty se píšou v JavaScriptu a spouštějí se po přijetí odpovědi.", + "pre_request_script": "Pre-request skripty jsou napsané v JavaScriptu a spouštějí se před odesláním požadavku.", + "script_fail": "Zdá se, že v pre-request skriptu je chyba. Zkontrolujte níže uvedenou chybu a skript opravte.", + "post_request_script_fail": "Zdá se, že v post-request skriptu je chyba. Opravte chyby a spusťte testy znovu.", + "post_request_script": "Napište post-request skript pro automatizaci ladění." + }, + "hide": { + "collection": "Sbalit panel kolekcí", + "more": "Skrýt více", + "preview": "Skrýt náhled", + "sidebar": "Skrýt postranní lištu", + "password": "Skrýt heslo" + }, + "import": { + "collections": "Import kolekcí", + "curl": "Importovat cURL", + "environments_from_gist": "Importovat z Gist", + "environments_from_gist_description": "Importovat prostředí Hoppscotch z Gist", + "failed": "Chyba při importu: formát nebyl rozpoznán", + "from_file": "Importovat ze souboru", + "from_gist": "Import z Gist", + "from_gist_description": "Importovat z URL Gist", + "from_gist_import_summary": "Budou importovány všechny funkce Hoppscotch.", + "from_hoppscotch_importer_summary": "Budou importovány všechny funkce Hoppscotch.", + "from_insomnia": "Importovat z Insomnia", + "from_insomnia_description": "Importovat kolekci Insomnia", + "from_insomnia_import_summary": "Kolekce a požadavky budou importovány.", + "from_json": "Importovat z kolekce Hoppscotch", + "from_json_description": "Importovat ze souboru kolekce Hoppscotch", + "from_my_collections": "Importovat z mých kolekcí", + "from_my_collections_description": "Importovat soubor z mých kolekcí", + "from_all_collections": "Import z jiného pracovního prostoru", + "from_all_collections_description": "Importovat libovolné kolekce z jiného pracovního prostoru do aktuálního pracovního prostoru.", + "from_openapi": "Importovat z OpenAPI", + "from_openapi_description": "Importovat soubor specifikace OpenAPI (YML/JSON)", + "from_openapi_import_summary": "Budou importovány kolekce (vytvořené ze štítků), požadavky i ukázkové odpovědi.", + "from_postman": "Importovat z Postman", + "from_postman_description": "Importovat kolekci Postman", + "from_postman_import_summary": "Budou importovány kolekce, požadavky a ukázky odpovědí.", + "import_scripts": "Importovat skripty", + "import_scripts_description": "Podporuje Postman kolekce v2.0/v2.1.", + "from_url": "Importovat z URL", + "gist_url": "Zadejte URL Gistu", + "from_har": "Import z HAR", + "from_har_description": "Import ze souboru HAR", + "from_har_import_summary": "Požadavky budou importovány do výchozí kolekce.", + "gql_collections_from_gist_description": "Importovat GraphQL kolekce z Gist", + "hoppscotch_environment": "Prostředí Hoppscotch", + "hoppscotch_environment_description": "Importovat JSON soubor prostředí Hoppscotch", + "import_from_url_invalid_fetch": "Z URL se nepodařilo načíst data", + "import_from_url_invalid_file_format": "Chyba při importu kolekcí", + "import_from_url_invalid_type": "Nepodporovaný typ. Přijímané hodnoty jsou 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Kolekce byly importovány", + "insomnia_environment_description": "Importovat prostředí Insomnia ze souboru JSON/YAML", + "json_description": "Importovat kolekce ze souboru Hoppscotch Collections JSON", + "postman_environment": "Prostředí Postman", + "postman_environment_description": "Importovat prostředí Postman ze souboru JSON", + "title": "Import", + "file_size_limit_exceeded_warning_multiple_files": "Vybrané soubory překračují doporučený limit {sizeLimit} MB. Importuje se pouze prvních {files} vybraných souborů.", + "file_size_limit_exceeded_warning_single_file": "Vybraný soubor překračuje doporučený limit {sizeLimit} MB. Vyberte prosím jiný soubor.", + "success": "Úspěšně importováno", + "import_summary_collections_title": "Kolekce", + "import_summary_requests_title": "Požadavky", + "import_summary_responses_title": "Odpovědi", + "import_summary_pre_request_scripts_title": "Pre-request skripty", + "import_summary_post_request_scripts_title": "Post-request skripty", + "import_summary_not_supported_by_hoppscotch_import": "V současné době nepodporujeme import {featureLabel} z tohoto zdroje.", + "import_summary_script_found": "skript nalezen, ale nebyl importován", + "import_summary_scripts_found": "skripty byly nalezeny, ale nebyly importovány", + "import_summary_enable_experimental_sandbox": "Chcete-li importovat Postman skripty, povolte v nastavení \"Experimentální skriptovací sandbox\". Poznámka: Tato funkce je experimentální.", + "cors_error_modal": { + "title": "Byla zjištěna chyba CORS", + "description": "Import se nezdařil kvůli omezením CORS (Cross-Origin Resource Sharing) uloženým serverem.", + "explanation": "Jedná se o bezpečnostní funkci, která zabraňuje webovým stránkám zadávat požadavky na různé domény. Toto omezení můžete obejít pomocí naší proxy služby.", + "url_label": "Cílové URL", + "retry_with_proxy": "Zkuste to znovu s proxy" + } + }, + "instances": { + "switch": "Přepnout instanci Hoppscotch", + "enter_server_url": "Připojit se k self-hostované instanci", + "already_connected": "K této instanci jste již připojeni", + "recent_connections": "Nedávná připojení", + "add_instance": "Přidat instanci", + "add_new": "Přidat novou instanci", + "confirm_remove": "Potvrdit odebrání", + "remove_warning": "Opravdu chcete odstranit tuto instanci?", + "clear_cached_bundles": "Vymazat balíčky uložené v mezipaměti", + "opening_add_modal": "Otevírá se dialog pro přidání instance", + "closed_add_modal": "Dialog Přidat instanci byl uzavřen", + "cancelled_removal": "Odebrání instance bylo zrušeno", + "connection_cancelled": "Připojení bylo zrušeno ověřením před připojením", + "post_connect_completed": "Nastavení po připojení bylo dokončeno", + "connecting": "Připojování k instanci...", + "confirm_removal": "Potvrdit odebrání instance", + "removal_cancelled": "Odstranění instance bylo zrušeno ověřením před odstraněním", + "post_remove_completed": "Čištění po odstranění bylo dokončeno", + "removing": "Odebírání instance...", + "clearing_cache": "Mazání mezipaměti...", + "initialized": "Přepínač instancí inicializován", + "connecting_state": "Navazování spojení...", + "connected_state": "Úspěšně připojeno k instanci", + "disconnected_state": "Odpojeno od instance", + "stream_error": "Sledování stavu připojení se nezdařilo", + "recent_instances_error": "Nepodařilo se načíst nedávné instance", + "instance_changed": "Přepnuto na instanci", + "current_instance_error": "Aktuální instanci se nepodařilo sledovat", + "not_available": "Přepínání instancí není k dispozici", + "cleanup_completed": "Vyčištění přepínače instancí dokončeno" + }, + "inspections": { + "description": "Zkontrolovat možné chyby", + "environment": { + "add_environment": "Přidat do prostředí", + "add_environment_value": "Přidat hodnotu", + "empty_value": "Hodnota prostředí je prázdná pro proměnnou '{variable}'", + "not_found": "Proměnná prostředí „{environment}“ nebyla nalezena." + }, + "header": { + "cookie": "Prohlížeč nedovoluje Hoppscotch nastavit hlavičku Cookie. Dokud pracujeme na desktopové aplikaci Hoppscotch (již brzy), použijte místo toho hlavičku Authorization." + }, + "response": { + "401_error": "Zkontrolujte prosím své přihlašovací údaje.", + "404_error": "Zkontrolujte prosím URL požadavku a metodu.", + "cors_error": "Zkontrolujte prosím konfiguraci CORS.", + "default_error": "Zkontrolujte prosím svůj požadavek.", + "network_error": "Zkontrolujte prosím síťové připojení." + }, + "title": "Inspektor", + "url": { + "extension_not_installed": "Rozšíření není nainstalováno.", + "extension_unknown_origin": "Ujistěte se, že jste přidali origin API endpointu do seznamu povolených originů rozšíření Hoppscotch.", + "extention_enable_action": "Povolit rozšíření prohlížeče", + "extention_not_enabled": "Rozšíření není povoleno.", + "localaccess_unsupported": "Aktuální zachycovač nepodporuje místní přístup, zvažte použití agenta, zachycovače rozšíření nebo desktopové aplikace" + }, + "auth": { + "digest": "Při použití autorizace Digest se doporučuje zachycovač agentů ve webové aplikaci nebo nativní zachycovač v aplikaci pro stolní počítače.", + "hawk": "Při použití autorizace Hawk se doporučuje zachycovač agentů ve webové aplikaci nebo nativní zachycovač v aplikaci pro stolní počítače." + }, + "body": { + "binary": "Odesílání binárních dat přes aktuální interceptor zatím není podporováno." + }, + "scripting_interceptor": { + "pre_request": "pre-request skript", + "post_request": "post-request skript", + "both_scripts": "pre-request a post-request skripty", + "unsupported_interceptor": "Vaše {scriptType} používá {apiUsed}. Pro spolehlivé provádění skriptů přepněte na Agent interceptor (webová aplikace) nebo Native interceptor (desktopová aplikace). Zachycovač {interceptor} má omezenou podporu pro požadavky skriptování a nemusí fungovat podle očekávání.", + "same_origin_csrf_warning": "Bezpečnostní varování: Vaše {scriptType} odesílá požadavky stejného původu pomocí {apiUsed}. Protože tato platforma používá ověřování založené na souborech cookie, tyto požadavky automaticky zahrnují soubory cookie vaší relace, což potenciálně umožňuje škodlivým skriptům provádět neoprávněné akce. Použijte Agent interceptor pro požadavky stejného původu nebo spouštějte pouze skripty, kterým důvěřujete." + } + }, + "interceptor": { + "native": { + "name": "Nativní", + "settings_title": "Nativní" + }, + "agent": { + "name": "Agent", + "settings_title": "Agent" + }, + "proxy": { + "name": "Proxy", + "settings_title": "Proxy" + }, + "browser": { + "name": "Prohlížeč", + "settings_title": "Prohlížeč" + }, + "extension": { + "name": "Rozšíření", + "settings_title": "Rozšíření" + } + }, + "layout": { + "collapse_collection": "Sbalit nebo rozbalit kolekce", + "collapse_sidebar": "Sbalit nebo rozbalit postranní lištu", + "column": "Svislé rozvržení", + "name": "Rozvržení", + "row": "Vodorovné rozvržení" + }, + "modal": { + "close_unsaved_tab": "Máte neuložené změny", + "collections": "Kolekce", + "confirm": "Potvrdit", + "customize_request": "Přizpůsobit požadavek", + "edit_request": "Upravit požadavek", + "edit_response": "Upravit odpověď", + "import_export": "Importovat/exportovat", + "response_name": "Název odpovědi", + "share_request": "Sdílet požadavek" + }, + "mqtt": { + "already_subscribed": "K tomuto tématu už jste přihlášeni.", + "clean_session": "Čistá relace", + "clear_input": "Vymazat vstup", + "clear_input_on_send": "Vymazat vstup při odeslání", + "client_id": "Client ID", + "color": "Vybrat barvu", + "communication": "Komunikace", + "connection_config": "Konfigurace připojení", + "connection_not_authorized": "Toto MQTT připojení nepoužívá žádné ověření.", + "invalid_topic": "Zadejte téma pro odběr", + "keep_alive": "Udržovat spojení", + "log": "Záznam", + "lw_message": "Last-Will zpráva", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will téma", + "message": "Zpráva", + "new": "Nové předplatné", + "not_connected": "Nejprve spusťte MQTT připojení.", + "publish": "Publikovat", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Přihlásit k odběru", + "topic": "Téma", + "topic_name": "Název tématu", + "topic_title": "Téma – publikovat / přihlásit k odběru", + "unsubscribe": "Odhlásit odběr", + "url": "URL" + }, + "navigation": { + "admin_dashboard": "Administrace", + "doc": "Dokumentace", + "graphql": "GraphQL", + "profile": "Profil", + "realtime": "Reálný čas", + "rest": "REST", + "mock_servers": "Mock servery", + "settings": "Nastavení", + "goto_app": "Přejít na aplikaci", + "authentication": "Autentizace" + }, + "mock_server": { + "confirm_delete_log": "Opravdu chcete tento protokol smazat?", + "create_mock_server": "Nakonfigurovat Mock Server", + "mock_server_configuration": "Konfigurace Mock Serveru", + "mock_server_name": "Název mock serveru", + "mock_server_name_placeholder": "Zadejte název mock serveru", + "base_url": "Base URL", + "start_server": "Spustit server", + "stop_server": "Zastavit server", + "mock_server_created": "Mock server byl úspěšně vytvořen", + "mock_server_started": "Mock server byl úspěšně spuštěn", + "mock_server_stopped": "Mock server byl úspěšně zastaven", + "active": "Mock server je aktivní", + "inactive": "Mock server je neaktivní", + "edit_mock_server": "Upravit Mock Server", + "path_based_url": "URL založená na cestě", + "subdomain_based_url": "URL založená na subdoméně", + "mock_server_updated": "Mock server byl úspěšně aktualizován", + "no_collection": "Žádná kolekce", + "collection_deleted": "Přidružená kolekce byla odstraněna.", + "private_access_hint": "U soukromých mock serverů přidejte hlavičku 'x-api-key' s vaším osobním Access Tokenem (vytvoříte ho v profilu).", + "private_access_instruction": "Pro přístup k tomuto soukromému mock serveru přidejte hlavičku 'x-api-key' s vaším osobním Access Tokenem.", + "create_token_here": "Vytvořit zde", + "status": "Stav", + "server_running": "Server běží", + "server_stopped": "Server je zastaven", + "delay_ms": "Zpoždění odezvy (ms)", + "delay_placeholder": "Zadejte zpoždění v milisekundách", + "delay_description": "Přidat umělé zpoždění do mock odpovědí", + "public_access": "Veřejný přístup", + "public": "Veřejný", + "private": "Soukromý", + "public_description": "Kdokoli s URL má přístup k tomuto mock serveru", + "private_description": "K tomuto mock serveru mají přístup pouze ověření uživatelé", + "select_collection": "Vyberte kolekci", + "select_collection_error": "Vyberte prosím kolekci", + "invalid_collection_error": "Nepodařilo se vytvořit mock server pro kolekci.", + "url_copied": "URL zkopírováno do schránky", + "make_public": "Zveřejnit", + "view_logs": "Zobrazit protokoly", + "logs_title": "Logy mock serveru", + "no_logs": "Nejsou k dispozici žádné protokoly", + "request_headers": "Hlavičky požadavku", + "request_body": "Tělo požadavku", + "response_status": "Stav odpovědi", + "response_headers": "Hlavičky odpovědi", + "response_body": "Tělo odpovědi", + "log_deleted": "Protokol byl úspěšně smazán", + "description": "Mock servery vám umožňují simulovat odpovědi API na základě vašich příkladů odpovědí kolekce.", + "set_in_environment": "Nastavit v prostředí", + "set_in_environment_hint": "URL mock serveru bude automaticky přidáno jako proměnná 'mockUrl' do prostředí kolekce", + "environment_variable_added": "Mock URL přidáno do prostředí", + "environment_variable_updated": "Mock URL aktualizováno v prostředí", + "environment_created_with_variable": "Prostředí vytvořeno s mock URL", + "add_example_request": "Přidat ukázkový požadavek", + "add_example_request_hint": "Bude vytvořena kolekce s ukázkovým požadavkem, který ukazuje použití mock serveru", + "create_example_collection": "Vytvořit ukázkovou kolekci", + "create_example_collection_hint": "Vytvořit ukázkovou kolekci Pet Store se vzorovými požadavky (GET, POST, PUT, DELETE)", + "creating_example_collection": "Vytváření příkladu kolekce...", + "failed_to_create_collection": "Nepodařilo se vytvořit příklad kolekce", + "enable_example_collection_hint": "Pro nový režim kolekce zapněte přepínač 'Vytvořit ukázkovou kolekci'", + "new_collection_name_hint": "Kolekce bude vytvořena se stejným názvem jako váš mock server", + "existing_collection": "Existující kolekce", + "new_collection": "Nová kolekce" + }, + "preRequest": { + "javascript_code": "JavaScriptový kód", + "learn": "Přečtěte si dokumentaci", + "script": "Pre-request skript", + "snippets": "Úryvky" + }, + "profile": { + "app_settings": "Nastavení aplikace", + "default_hopp_displayname": "Uživatel bez jména", + "editor": "Editor", + "editor_description": "Editoři mohou přidávat, upravovat a mazat požadavky.", + "email_verification_mail": "Na vaši e-mailovou adresu byl odeslán ověřovací e-mail. Klikněte na odkaz pro ověření e-mailu.", + "no_permission": "Nemáte oprávnění k této akci.", + "owner": "Vlastník", + "owner_description": "Vlastníci mohou přidávat, upravovat a mazat požadavky, kolekce a členy pracovního prostoru.", + "roles": "Role", + "roles_description": "Role se používají k řízení přístupu ke sdíleným kolekcím.", + "updated": "Profil aktualizován", + "viewer": "Čtenář", + "viewer_description": "Čtenáři mohou požadavky pouze zobrazovat a používat.", + "verified_email_sent": "Na vaši e-mailovou adresu byl odeslán ověřovací e-mail. Po ověření e-mailové adresy prosím obnovte stránku. Pokud tento e-mail není přidružen k žádnému jinému účtu, obdržíte e-mail." + }, + "remove": { + "star": "Odebrat hvězdičku" + }, + "request": { + "added": "Požadavek přidán", + "add": "Přidat požadavek", + "authorization": "Autorizace", + "body": "Tělo", + "choose_language": "Vyberte jazyk", + "content_type": "Typ obsahu", + "content_type_titles": { + "others": "Ostatní", + "structured": "Strukturované", + "text": "Text", + "binary": "Binární" + }, + "show_content_type": "Zobrazit typ obsahu", + "different_collection": "Nelze měnit pořadí požadavků z různých kolekcí", + "duplicated": "Požadavek duplikován", + "duration": "Doba trvání", + "enter_curl": "Zadejte cURL", + "generate_code": "Vygenerovat kód", + "generated_code": "Generovaný kód", + "go_to_authorization_tab": "Přejít na kartu Autorizace", + "go_to_body_tab": "Přejít na kartu Tělo", + "header_list": "Seznam hlaviček", + "invalid_name": "Uveďte prosím název požadavku", + "method": "Metoda", + "moved": "Požadavek přesunut", + "name": "Název požadavku", + "new": "Nový požadavek", + "order_changed": "Pořadí požadavků aktualizováno", + "override": "Přepsat", + "override_help": "Nastavit Content-Type v hlavičkách", + "overriden": "Přepsáno", + "parameter_list": "Query parametry", + "parameters": "Parametry", + "path": "Cesta", + "payload": "Payload", + "query": "Query", + "raw_body": "Raw body požadavku", + "rename": "Přejmenovat požadavek", + "renamed": "Požadavek přejmenován", + "request_variables": "Proměnné požadavku", + "response_name_exists": "Název odpovědi již existuje", + "run": "Spustit", + "save": "Uložit", + "save_as": "Uložit jako", + "saved": "Požadavek uložen", + "share": "Sdílet", + "share_description": "Sdílejte Hoppscotch se svými přáteli", + "share_request": "Sdílet požadavek", + "stop": "Zastavit", + "title": "Požadavek", + "type": "Typ požadavku", + "url": "URL", + "url_placeholder": "Zadejte URL nebo vložte příkaz cURL", + "variables": "Proměnné", + "view_my_links": "Zobrazit moje odkazy", + "generate_name_error": "Vygenerování názvu požadavku se nezdařilo." + }, + "response": { + "audio": "Audio", + "body": "Tělo odpovědi", + "duplicated": "Odpověď duplikována", + "duplicate_name_error": "Odpověď se stejným názvem již existuje", + "filter_response_body": "Filtrovat JSON tělo odpovědi (používá syntaxi jq)", + "headers": "Hlavičky", + "request_headers": "Hlavičky požadavku", + "html": "HTML", + "image": "Obrázek", + "json": "JSON", + "pdf": "PDF", + "please_save_request": "Uložte požadavek pro vytvoření příkladu", + "preview_html": "Náhled HTML", + "raw": "Surové", + "renamed": "Odpověď přejmenována", + "same_name_inspector_warning": "Název odpovědi již pro tento požadavek existuje, pokud bude uložen, přepíše stávající odpověď", + "size": "Velikost", + "status": "Stav", + "time": "Čas", + "title": "Odpověď", + "video": "Video", + "waiting_for_connection": "Čekání na připojení", + "xml": "XML", + "generate_data_schema": "Vygenerovat schéma dat", + "data_schema": "Schéma dat", + "saved": "Odpověď uložena", + "invalid_name": "Zadejte prosím název odpovědi" + }, + "settings": { + "accent_color": "Akcentní barva", + "account": "Účet", + "account_deleted": "Váš účet byl smazán", + "account_description": "Přizpůsobte si nastavení účtu.", + "account_email_description": "Vaše primární e-mailová adresa.", + "account_name_description": "Toto je vaše zobrazované jméno.", + "additional": "Další nastavení", + "agent_not_running": "Hoppscotch Agent nebyl zjištěn. Zkontrolujte, zda je Agent spuštěn.", + "agent_not_running_short": "Zkontrolujte stav agenta.", + "agent_running": "Hoppscotch Agent je aktivní.", + "agent_running_short": "Hoppscotch Agent je aktivní.", + "agent_discard_registration": "Zrušit registraci agenta", + "agent_registered": "Registrován agent", + "agent_registration_successful": "Agent byl úspěšně zaregistrován", + "agent_registration_fetch_failed": "Nepodařilo se načíst registrační informace agenta. Prosím znovu zaregistrujte Agenta.", + "agent_registration_already_in_progress": "Registrace agenta již probíhá. Dokončete nebo zrušte aktuální registraci a zkuste to znovu.", + "auto_encode_mode": "Auto", + "auto_encode_mode_tooltip": "Parametry v požadavku zakódujte pouze v případě, že jsou přítomny nějaké speciální znaky", + "background": "Pozadí", + "black_mode": "Černé", + "choose_language": "Vyberte jazyk", + "dark_mode": "Tmavé", + "delete_account": "Smazat účet", + "delete_account_description": "Po smazání účtu budou všechna vaše data trvale odstraněna. Tuto akci nelze vrátit.", + "disable_encode_mode_tooltip": "Nikdy nekódujte parametry v požadavku", + "enable_encode_mode_tooltip": "Parametry v požadavku vždy zakódujte", + "enter_otp": "Zadejte kód agenta", + "expand_navigation": "Rozšířit navigaci", + "experiments": "Experimenty", + "experiments_notice": "Soubor experimentů, na kterých pracujeme a které se mohou ukázat jako užitečné, zábavné, obojí nebo ani jedno z toho. Nejsou konečné a nemusí být stabilní, takže pokud se stane něco příliš divného, nepanikařte. Prostě tu nebezpečnou věc vypněte. Vtipy stranou,", + "extension_ver_not_reported": "Nehlášeno", + "extension_version": "Verze rozšíření", + "extensions": "Rozšíření prohlížeče", + "extensions_use_toggle": "K odeslání požadavků použijte rozšíření prohlížeče (je-li k dispozici)", + "follow": "Sledujte nás", + "general": "Obecné", + "general_description": "Obecná nastavení použitá v aplikaci", + "interceptor": "Interceptor", + "interceptor_description": "Middleware mezi aplikací a API.", + "kernel_interceptor": "Interceptor", + "kernel_interceptor_description": "Middleware mezi aplikací a API.", + "language": "Jazyk", + "light_mode": "Světlé", + "official_proxy_hosting": "Oficiální proxy hostuje Hoppscotch.", + "query_parameters_encoding": "Kódování query parametrů", + "query_parameters_encoding_description": "Nastavte kódování query parametrů v požadavcích", + "profile": "Profil", + "profile_description": "Aktualizovat údaje profilu", + "profile_email": "E-mailová adresa", + "profile_name": "Název profilu", + "profile_photo": "Profilový obrázek", + "proxy": "Proxy", + "proxy_url": "Proxy URL", + "proxy_use_toggle": "Použít proxy middleware pro odesílání požadavků", + "read_the": "Přečíst", + "register_agent": "Registrovat agenta", + "reset_default": "Použít výchozí proxy", + "short_codes": "Zkratkové kódy", + "short_codes_description": "Zkratkové kódy, které jste vytvořili.", + "sidebar_on_left": "Postranní lišta vlevo", + "ai_experiments": "AI experimenty", + "ai_request_naming_style": "Styl pojmenování požadavků", + "ai_request_naming_style_descriptive_with_spaces": "Popisný s mezerami", + "ai_request_naming_style_camel_case": "Camel Case ( camelCase )", + "ai_request_naming_style_snake_case": "Snake Case ( snake_case )", + "ai_request_naming_style_pascal_case": "Pascal Case ( PascalCase )", + "ai_request_naming_style_custom": "Vlastní", + "ai_request_naming_style_custom_placeholder": "Zadejte vlastní šablonu stylu pojmenování...", + "experimental_scripting_sandbox": "Experimentální skriptovací sandbox", + "enable_experimental_mock_servers": "Povolit mock servery", + "enable_experimental_documentation": "Povolit dokumentaci", + "sync": "Synchronizovat", + "sync_collections": "Kolekce", + "sync_description": "Tato nastavení jsou synchronizována s cloudem.", + "sync_environments": "Prostředí", + "sync_history": "Historie", + "history_disabled": "Historie je zakázána. Pro její povolení kontaktujte správce organizace.", + "system_mode": "Systémový", + "telemetry": "Telemetrie", + "telemetry_helps_us": "Telemetrie nám pomáhá zlepšovat naše služby a poskytovat vám co nejlepší prostředí.", + "theme": "Motiv vzhledu", + "theme_description": "Přizpůsobte si motiv vzhledu aplikace.", + "use_experimental_url_bar": "Použijte experimentální lištu URL se zvýrazněním prostředí", + "user": "Uživatel", + "verified_email": "Ověřený e-mail", + "verify_email": "Ověřit e-mail", + "validate_certificates": "Ověřit SSL/TLS certifikáty", + "verify_host": "Ověřit hostitele", + "verify_peer": "Ověřit peer", + "follow_redirects": "Sledovat přesměrování", + "client_certificates": "Klientské certifikáty", + "certificate_settings": "Nastavení certifikátu", + "certificate": "Certifikát", + "key": "Soukromý klíč", + "pfx_or_p12": "PFX/PKCS#12", + "password": "Heslo", + "select_file": "Vyberte soubor", + "domain": "Doména", + "add_certificate": "Přidat certifikát", + "add_cert_file": "Přidat soubor certifikátu", + "add_key_file": "Přidat soubor klíče", + "add_pfx_file": "Přidat soubor PFX", + "global_defaults": "Globální výchozí nastavení", + "add_domain_override": "Přidat přepsání domény", + "domain_override": "Přepsání domény", + "manage_domains_overrides": "Správa přepsání domén", + "add_domain": "Přidat doménu", + "remove_domain": "Odebrat doménu", + "ca_certificate": "Certifikát CA", + "ca_certificates": "Certifikáty CA", + "ca_certificates_support": "Hoppscotch podporuje soubory .crt, .cer nebo .pem obsahující jeden či více certifikátů.", + "proxy_capabilities": "Hoppscotch Agent a Desktop App podporují HTTP/HTTPS/SOCKS proxy s podporou NTLM a Basic Auth.", + "proxy_auth": "Do pole URL můžete také zahrnout uživatelské jméno a heslo." + }, + "shared_requests": { + "button": "Tlačítko", + "button_info": "Vytvořte tlačítko „Spustit v Hoppscotch“ pro svůj web, blog nebo README.", + "copy_html": "Kopírovat HTML", + "copy_link": "Kopírovat odkaz", + "copy_markdown": "Kopírovat Markdown", + "creating_widget": "Vytváření widgetu", + "customize": "Přizpůsobit", + "deleted": "Sdílený požadavek byl smazán", + "description": "Vyberte widget; později jej můžete změnit a přizpůsobit.", + "embed": "Vložit", + "embed_info": "Přidejte mini „Hoppscotch API Playground“ na web, blog nebo do dokumentace.", + "link": "Odkaz", + "link_info": "Vytvořte sdílitelný odkaz s přístupem pro zobrazení.", + "modified": "Sdílený požadavek byl upraven", + "not_found": "Sdílený požadavek nebyl nalezen", + "open_new_tab": "Otevřít na nové kartě", + "preview": "Náhled", + "run_in_hoppscotch": "Spustit v Hoppscotch", + "theme": { + "dark": "Tmavý", + "light": "Světlý", + "system": "Systém", + "title": "Motiv" + }, + "action": "Akce", + "clear_filter": "Vymazat filtr", + "confirm_request_deletion": "Potvrdit smazání vybraného sdíleného požadavku?", + "copy": "Kopie", + "created_on": "Vytvořeno dne", + "delete": "Vymazat", + "email": "E-mail", + "filter": "Filtr", + "filter_by_email": "Filtrujte podle e-mailu", + "id": "ID", + "load_list_error": "Nelze načíst seznam sdílených požadavků", + "no_requests": "Nebyly nalezeny žádné sdílené požadavky", + "open_request": "Otevřete požadavek", + "properties": "Vlastnosti", + "request": "Požadavek", + "show_more": "Zobrazit více", + "title": "Sdílené požadavky", + "url": "URL" + }, + "shortcut": { + "general": { + "close_current_menu": "Zavřít aktuální nabídku", + "command_menu": "Nabídka Vyhledávání a příkazy", + "help_menu": "Nabídka nápovědy", + "show_all": "Klávesové zkratky", + "title": "Všeobecné", + "comment_uncomment": "Komentář/Odkomentování", + "close_tab": "Zavřít kartu", + "undo": "Vrátit zpět", + "redo": "Předělat" + }, + "miscellaneous": { + "invite": "Pozvěte lidi na Hoppscotch", + "title": "Různé" + }, + "navigation": { + "back": "Přejít na předchozí stránku", + "documentation": "Přejděte na stránku dokumentace", + "forward": "Přejít na další stránku", + "graphql": "Přejděte na stránku GraphQL", + "profile": "Přejděte na stránku profilu", + "realtime": "Přejít na stránku v reálném čase", + "rest": "Přejděte na stránku REST", + "settings": "Přejděte na stránku Nastavení", + "title": "Navigace" + }, + "others": { + "prettify": "Prettify obsah editoru", + "title": "Ostatní" + }, + "request": { + "delete_method": "Vyberte metodu DELETE", + "get_method": "Vyberte metodu GET", + "head_method": "Vyberte metodu HEAD", + "import_curl": "Import cURL", + "method": "Metoda", + "next_method": "Vyberte další metodu", + "post_method": "Vyberte metodu POST", + "previous_method": "Vyberte předchozí metodu", + "put_method": "Vyberte metodu PUT", + "rename": "Přejmenovat požadavek", + "reset_request": "Resetovat požadavek", + "save_request": "Uložit požadavek", + "save_to_collections": "Uložit do kolekcí", + "send_request": "Odeslat požadavek", + "share_request": "Sdílet požadavek", + "show_code": "Vygenerovat úryvek kódu", + "title": "Požadavek", + "focus_url": "Zaostřete na pruh URL" + }, + "response": { + "copy": "Zkopírovat odpověď do schránky", + "download": "Stáhnout odpověď jako soubor", + "title": "Odpověď" + }, + "tabs": { + "title": "Karty", + "new_tab": "Nová karta", + "close_tab": "Zavřít kartu", + "reopen_tab": "Znovu otevřít zavřenou kartu", + "next_tab": "Další karta", + "previous_tab": "Předchozí karta", + "first_tab": "Přepněte na první kartu", + "last_tab": "Přepnout na poslední kartu", + "mru_switch": "Přepnout na poslední kartu (MRU)", + "mru_switch_reverse": "Přepnout na předchozí poslední kartu (MRU)" + }, + "theme": { + "black": "Přepnout motiv na černý režim", + "dark": "Přepnout motiv na tmavý režim", + "light": "Přepnout motiv na světlý režim", + "system": "Přepnout motiv na systémový režim", + "title": "Motiv" + } + }, + "show": { + "code": "Zobrazit kód", + "collection": "Rozbalit panel kolekcí", + "more": "Zobrazit více", + "sidebar": "Zobrazit postranní lištu", + "password": "Zobrazit heslo" + }, + "socketio": { + "communication": "Komunikace", + "connection_not_authorized": "Toto Socket.IO připojení nepoužívá žádné ověření.", + "event_name": "Název události", + "events": "Události", + "log": "Záznam", + "url": "URL" + }, + "spotlight": { + "change_language": "Změnit jazyk", + "environments": { + "delete": "Smazat aktuální prostředí", + "duplicate": "Duplikovat aktuální prostředí", + "duplicate_global": "Duplikovat globální prostředí", + "edit": "Upravit aktuální prostředí", + "edit_global": "Upravit globální prostředí", + "new": "Vytvořit nové prostředí", + "new_variable": "Vytvořit novou proměnnou prostředí", + "title": "Prostředí" + }, + "general": { + "chat": "Chatovat s podporou", + "help_menu": "Nápověda a podpora", + "open_docs": "Přečíst dokumentaci", + "open_github": "Otevřít repozitář GitHub", + "open_keybindings": "Klávesové zkratky", + "social": "Sociální sítě", + "title": "Obecné" + }, + "graphql": { + "connect": "Připojit k serveru", + "disconnect": "Odpojit od serveru" + }, + "miscellaneous": { + "invite": "Pozvěte přátele do Hoppscotch", + "title": "Různé" + }, + "request": { + "save_as_new": "Uložit jako nový požadavek", + "select_method": "Vybrat metodu", + "switch_to": "Přepnout na", + "tab_authorization": "Karta Autorizace", + "tab_body": "Karta Tělo", + "tab_headers": "Karta Hlavičky", + "tab_parameters": "Karta Parametry", + "tab_pre_request_script": "Karta Pre-request skriptu", + "tab_query": "Karta Query", + "tab_tests": "Karta Testy", + "tab_variables": "Karta Proměnné" + }, + "response": { + "copy": "Kopírovat odpověď", + "download": "Stáhnout odpověď jako soubor", + "title": "Odpověď" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Rozhraní", + "theme": "Motiv", + "user": "Uživatel" + }, + "settings": { + "change_interceptor": "Změnit interceptor", + "change_language": "Změnit jazyk", + "theme": { + "black": "Černý", + "dark": "Tmavý", + "light": "Světlý", + "system": "Systémové nastavení" + } + }, + "tab": { + "close_current": "Zavřít aktuální kartu", + "close_others": "Zavřít všechny ostatní karty", + "duplicate": "Duplikovat aktuální kartu", + "new_tab": "Otevřít novou kartu", + "next": "Přepnout na další kartu", + "previous": "Přepnout na předchozí kartu", + "switch_to_first": "Přepnout na první kartu", + "switch_to_last": "Přepnout na poslední kartu", + "mru_switch": "Přepnout na naposledy použitou kartu", + "mru_switch_reverse": "Přepnout na předchozí naposledy použitou kartu", + "title": "Karty" + }, + "workspace": { + "delete": "Smazat aktuální pracovní prostor", + "edit": "Upravit aktuální pracovní prostor", + "invite": "Pozvat lidi do pracovního prostoru", + "new": "Vytvořit nový pracovní prostor", + "switch_to_personal": "Přepnout na osobní pracovní prostor", + "title": "Pracovní prostory" + }, + "phrases": { + "try": "Zkuste", + "import_collections": "Importovat kolekce", + "create_environment": "Vytvořit prostředí", + "create_workspace": "Vytvořit pracovní prostor", + "share_request": "Sdílet požadavek" + } + }, + "sse": { + "event_type": "Typ události", + "log": "Záznam", + "url": "URL" + }, + "state": { + "bulk_mode": "Hromadná úprava", + "bulk_mode_placeholder": "Záznamy jsou odděleny novým řádkem\nKlíče a hodnoty jsou odděleny:\nPředřaďte # do libovolného řádku, který chcete přidat, ale ponechte jej deaktivovaný", + "cleared": "Vymazáno", + "connected": "Připojeno", + "connected_to": "Připojeno k {name}", + "connecting_to": "Připojování k {name}...", + "connection_error": "Nepodařilo se připojit", + "connection_failed": "Připojení selhalo", + "connection_lost": "Připojení ztraceno", + "copied_interface_to_clipboard": "Typ rozhraní {language} zkopírován do schránky", + "copied_to_clipboard": "Zkopírováno do schránky", + "deleted": "Smazáno", + "deprecated": "ZASTARALÉ", + "disabled": "Zakázáno", + "disconnected": "Odpojeno", + "disconnected_from": "Odpojeno od {name}", + "docs_generated": "Dokumentace vygenerována", + "download_failed": "Stahování selhalo", + "download_started": "Stahování zahájeno", + "enabled": "Povoleno", + "experimental": "Experimentální", + "file_imported": "Soubor importován", + "finished_in": "Hotovo za {duration} ms", + "hide": "Skrýt", + "history_deleted": "Historie odstraněna", + "linewrap": "Zalamovat řádky", + "loading": "Načítání...", + "message_received": "Zpráva: {message} dorazila na téma: {topic}", + "mqtt_subscription_failed": "Při odběru tématu {topic} se něco pokazilo", + "no_content_found": "Nebyl nalezen žádný obsah", + "none": "Žádný", + "nothing_found": "Nic nebylo nalezeno pro", + "published_error": "Při publikování zprávy {message} do tématu {topic} se něco pokazilo", + "published_message": "Zpráva {message} byla publikována do tématu {topic}", + "reconnection_error": "Nepodařilo se znovu připojit", + "saved": "Uloženo", + "show": "Zobrazit", + "subscribed_failed": "Nepodařilo se přihlásit k odběru tématu: {topic}", + "subscribed_success": "Úspěšně přihlášeno k odběru tématu: {topic}", + "unsubscribed_failed": "Nepodařilo se odhlásit odběr tématu: {topic}", + "unsubscribed_success": "Úspěšně odhlášeno z odběru tématu: {topic}", + "waiting_send_request": "Čekání na odeslání požadavku", + "user_deactivated": "Váš účet je deaktivovaný. Pro opětovnou aktivaci kontaktujte správce.", + "add_user_failure": "Nepodařilo se přidat uživatele do pracovního prostoru.", + "add_user_success": "Uživatel byl přidán do pracovního prostoru.", + "admin_failure": "Nepodařilo se nastavit uživatele jako správce.", + "admin_success": "Uživatel je nyní správce.", + "and": "a", + "clear_selection": "Vymazat výběr", + "configure_auth": "Nastavte poskytovatele autentizace v nastavení správce nebo si projděte dokumentaci.", + "confirm_admin_to_user": "Chcete tomuto uživateli odebrat status správce?", + "confirm_admins_to_users": "Chcete vybraným uživatelům odebrat status správce?", + "confirm_delete_infra_token": "Opravdu chcete smazat infra token {tokenLabel}?", + "confirm_delete_invite": "Chcete vybranou pozvánku zrušit?", + "confirm_delete_invites": "Chcete zrušit vybrané pozvánky?", + "confirm_user_deletion": "Potvrdit smazání uživatele?", + "confirm_users_deletion": "Chcete smazat vybrané uživatele?", + "confirm_user_to_admin": "Chcete z tohoto uživatele udělat správce?", + "confirm_users_to_admin": "Chcete z vybraných uživatelů udělat správce?", + "confirm_user_deactivation": "Potvrdit deaktivaci uživatele?", + "confirm_logout": "Potvrdit odhlášení", + "created_on": "Vytvořeno dne", + "continue_email": "Pokračovat e-mailem", + "continue_github": "Pokračovat s GitHubem", + "continue_google": "Pokračovat s Googlem", + "continue_microsoft": "Pokračovat s Microsoftem", + "create_team_failure": "Nepodařilo se vytvořit pracovní prostor.", + "create_team_success": "Pracovní prostor byl úspěšně vytvořen.", + "data_sharing_failure": "Aktualizace nastavení sdílení dat se nezdařila", + "delete_infra_token_failure": "Při mazání infra tokenu se něco pokazilo", + "delete_invite_failure": "Nepodařilo se smazat pozvánku.", + "delete_invites_failure": "Nepodařilo se smazat vybrané pozvánky.", + "delete_invite_success": "Pozvánka byla úspěšně smazána.", + "delete_invites_success": "Vybrané pozvánky byly úspěšně smazány.", + "delete_request_failure": "Nepodařilo se smazat sdílený požadavek.", + "delete_request_success": "Sdílený požadavek byl úspěšně smazán.", + "delete_team_failure": "Nepodařilo se smazat pracovní prostor.", + "delete_team_success": "Pracovní prostor byl úspěšně smazán.", + "delete_some_users_failure": "Počet nesmazaných uživatelů: {count}", + "delete_some_users_success": "Počet smazaných uživatelů: {count}", + "delete_user_failed_only_one_admin": "Nepodařilo se smazat uživatele. Musí zůstat alespoň jeden správce.", + "delete_user_failure": "Nepodařilo se smazat uživatele.", + "delete_users_failure": "Nepodařilo se smazat vybrané uživatele.", + "delete_user_success": "Uživatel byl úspěšně smazán.", + "delete_users_success": "Vybraní uživatelé byli úspěšně smazáni.", + "email": "E-mail", + "email_failure": "Odeslání pozvánky se nezdařilo", + "email_signin_failure": "Nepodařilo se přihlásit pomocí e-mailu", + "email_success": "E-mailová pozvánka byla úspěšně odeslána", + "emails_cannot_be_same": "Nemůžete pozvat sami sebe. Zvolte jinou e-mailovou adresu.", + "enter_team_email": "Zadejte e-mail vlastníka pracovního prostoru.", + "error": "Něco se pokazilo", + "error_auth_providers": "Nelze načíst poskytovatele ověření", + "generate_infra_token_failure": "Při generování infra tokenu se něco pokazilo", + "github_signin_failure": "Nepodařilo se přihlásit přes GitHub", + "google_signin_failure": "Nepodařilo se přihlásit pomocí Google", + "infra_token_label_short": "Popisek infra tokenu je příliš krátký.", + "invalid_email": "Zadejte prosím platnou e-mailovou adresu", + "link_copied_to_clipboard": "Odkaz zkopírován do schránky", + "logged_out": "Odhlášen", + "login_as_admin": "a přihlaste se pomocí účtu správce.", + "login_using_email": "Požádejte uživatele, aby zkontroloval e-mail, nebo mu sdílejte odkaz níže.", + "login_using_link": "Požádejte uživatele, aby se přihlásil pomocí odkazu níže.", + "logout": "Odhlášení", + "magic_link_sign_in": "Pro přihlášení klikněte na odkaz.", + "magic_link_success": "Poslali jsme magic link na", + "microsoft_signin_failure": "Přihlášení k Microsoftu se nezdařilo", + "newsletter_failure": "Nastavení newsletteru nelze aktualizovat", + "non_admin_logged_in": "Přihlášen jako uživatel bez oprávnění správce.", + "non_admin_login": "Jste přihlášeni, ale nemáte roli správce.", + "owner_not_present": "V týmu musí být alespoň jeden vlastník.", + "privacy_policy": "Zásady ochrany osobních údajů", + "reenter_email": "Zadejte e-mail znovu", + "remove_admin_failure": "Nepodařilo se odebrat roli správce.", + "remove_admin_failure_only_one_admin": "Nepodařilo se odebrat roli správce. Musí zůstat alespoň jeden správce.", + "remove_admin_success": "Role správce byla odebrána.", + "remove_admin_from_users_failure": "Nepodařilo se odebrat roli správce vybraným uživatelům.", + "remove_admin_from_users_success": "Role správce byla vybraným uživatelům odebrána.", + "remove_admin_to_delete_user": "Před smazáním uživatele odeberte roli správce.", + "remove_owner_to_delete_user": "Před smazáním uživatele odeberte roli vlastníka týmu.", + "remove_owner_failure_only_one_owner": "Nepodařilo se odebrat člena. V týmu musí zůstat alespoň jeden vlastník.", + "remove_admin_for_deletion": "Před smazáním nejdříve odeberte roli správce.", + "remove_owner_for_deletion": "Jeden nebo více uživatelů je vlastníkem týmu. Před smazáním upravte vlastnictví.", + "remove_invitee_failure": "Nepodařilo se odebrat pozvaného uživatele.", + "remove_invitee_success": "Pozvaný uživatel byl úspěšně odebrán.", + "remove_member_failure": "Nepodařilo se odebrat člena.", + "remove_member_success": "Člen byl úspěšně odebrán.", + "rename_team_failure": "Nepodařilo se přejmenovat pracovní prostor.", + "rename_team_success": "Pracovní prostor byl úspěšně přejmenován.", + "rename_user_failure": "Nepodařilo se přejmenovat uživatele.", + "rename_user_success": "Uživatel byl úspěšně přejmenován.", + "require_auth_provider": "Pro přihlášení musíte nastavit alespoň jednoho poskytovatele ověření.", + "role_update_failed": "Aktualizace rolí se nezdařila.", + "role_update_success": "Role byly úspěšně aktualizovány.", + "selected": "Vybráno {count}", + "self_host_docs": "Dokumentace k self-hostingu", + "send_magic_link": "Odeslat magic link", + "setup_failure": "Nastavení se nezdařilo.", + "setup_success": "Nastavení bylo úspěšně dokončeno.", + "sign_in_agreement": "Přihlášením souhlasíte s naším", + "sign_in_options": "Všechny možnosti přihlášení", + "sign_out": "Odhlásit se", + "something_went_wrong": "Něco se pokazilo", + "team_name_too_short": "Název pracovního prostoru musí mít alespoň 6 znaků.", + "user_already_invited": "Nepodařilo se odeslat pozvánku. Uživatel už je pozván.", + "user_not_found": "Uživatel nebyl nalezen v infrastruktuře.", + "users_to_admin_success": "Vybraní uživatelé byli povýšeni na správce.", + "users_to_admin_failure": "Nepodařilo se povýšit vybrané uživatele na správce.", + "loading_workspaces": "Načítání pracovních prostorů", + "loading_collections_in_workspace": "Načítání kolekcí v pracovním prostoru" + }, + "support": { + "changelog": "Přečtěte si více o nejnovějších verzích", + "chat": "Máte otázky? Napište nám!", + "community": "Ptejte se a pomáhejte ostatním", + "documentation": "Přečtěte si více o aplikaci Hoppscotch", + "forum": "Ptejte se a dostávejte odpovědi", + "github": "Sledujte nás na GitHubu", + "shortcuts": "Procházejte aplikaci rychleji", + "title": "Podpora", + "twitter": "Sledujte nás na Twitteru" + }, + "tab": { + "authorization": "Autorizace", + "body": "Tělo", + "close": "Zavřít kartu", + "close_others": "Zavřít ostatní karty", + "collections": "Kolekce", + "documentation": "Dokumentace", + "duplicate": "Duplikovat kartu", + "environments": "Prostředí", + "headers": "Hlavičky", + "history": "Historie", + "mqtt": "MQTT", + "parameters": "Parametry", + "post_request_script": "Post-request skript", + "pre_request_script": "Pre-request skript", + "queries": "Dotazy", + "query": "Query", + "schema": "Schéma", + "shared_requests": "Sdílené požadavky", + "codegen": "Generovat kód", + "code_snippet": "Úryvek kódu", + "mock_servers": "Mock servery", + "share_tab_request": "Sdílet kartu s požadavkem", + "socketio": "Socket.IO", + "sse": "SSE", + "types": "Typy", + "variables": "Proměnné", + "websocket": "WebSocket", + "all_tests": "Všechny testy", + "passed": "Úspěšné", + "failed": "Neúspěšné" + }, + "team": { + "activity_logs": "Protokoly aktivit", + "already_member": "Tento e-mail je už přiřazen k existujícímu uživateli.", + "create_new": "Vytvořit nový pracovní prostor", + "deleted": "Pracovní prostor smazán", + "delete_all_activity_logs": "Smazat všechny protokoly aktivit", + "successfully_deleted_all_activity_logs": "Všechny protokoly aktivit byly úspěšně smazány", + "delete_activity_log": "Smazat protokol aktivit", + "deleted_activity_log": "Vybraný protokol aktivit byl smazán", + "deleted_all_activity_logs": "Smazány všechny protokoly aktivit", + "edit": "Upravit pracovní prostor", + "email": "E-mail", + "email_do_not_match": "E-mail se neshoduje s údaji vašeho účtu. Kontaktujte vlastníka pracovního prostoru.", + "exit": "Opustit pracovní prostor", + "exit_disabled": "Pracovní prostor nemůže opustit jen vlastník", + "failed_invites": "Neúspěšné pozvánky", + "invalid_coll_id": "Neplatné ID kolekce", + "invalid_email_format": "Formát e-mailu je neplatný", + "invalid_id": "Neplatné ID pracovního prostoru. Kontaktujte vlastníka pracovního prostoru.", + "invalid_invite_link": "Neplatný odkaz na pozvánku", + "invalid_invite_link_description": "Odkaz, který jste použili, je neplatný. Kontaktujte vlastníka pracovního prostoru.", + "invalid_member_permission": "Zadejte prosím platné oprávnění pro člena pracovního prostoru", + "invite": "Pozvat", + "invite_more": "Pozvat další", + "invite_tooltip": "Pozvat lidi do tohoto pracovního prostoru", + "invited_to_team": "{owner} vás pozval do pracovního prostoru {workspace}", + "join": "Pozvánka přijata", + "join_team": "Připojit se k {workspace}", + "joined_team": "Připojili jste se k {workspace}", + "joined_team_description": "Nyní jste členem tohoto pracovního prostoru", + "left": "Opustili jste pracovní prostor", + "login_to_continue": "Přihlaste se pro pokračování", + "login_to_continue_description": "Pro připojení k pracovnímu prostoru musíte být přihlášeni.", + "logout_and_try_again": "Odhlaste se a přihlaste se jiným účtem", + "member_has_invite": "Uživatel už má pozvánku. Požádejte ho, ať zkontroluje doručenou poštu, nebo pozvánku odvolejte a odešlete znovu.", + "member_not_found": "Člen nebyl nalezen. Kontaktujte vlastníka pracovního prostoru.", + "member_removed": "Uživatel byl odebrán", + "member_role_updated": "Role uživatelů aktualizovány", + "members": "Členové", + "more_members": "+{count} dalších", + "name_length_insufficient": "Název pracovního prostoru nesmí být prázdný", + "name_updated": "Název pracovního prostoru aktualizován", + "new": "Nový pracovní prostor", + "new_created": "Vytvořen nový pracovní prostor", + "new_name": "Můj nový pracovní prostor", + "no_access": "K tomuto pracovnímu prostoru nemáte oprávnění k úpravám", + "no_invite_found": "Pozvánka nenalezena. Kontaktujte vlastníka pracovního prostoru.", + "no_request_found": "Požadavek nenalezen.", + "not_found": "Pracovní prostor nebyl nalezen. Kontaktujte vlastníka pracovního prostoru.", + "not_valid_viewer": "Nejste platný čtenář. Kontaktujte vlastníka pracovního prostoru.", + "parent_coll_move": "Nelze přesunout kolekci do podřízené kolekce", + "pending_invites": "Čekající pozvánky", + "permissions": "Oprávnění", + "same_target_destination": "Stejný zdroj i cíl", + "saved": "Pracovní prostor uložen", + "select_a_team": "Vyberte pracovní prostor", + "success_invites": "Úspěšné pozvánky", + "title": "Pracovní prostory", + "we_sent_invite_link": "Pozvánky jsou na cestě", + "invite_sent_smtp_disabled": "Odkazy na pozvánky byly vygenerovány", + "we_sent_invite_link_description": "Nově pozvaní uživatelé obdrží odkaz pro připojení k pracovnímu prostoru. Stávající členové a čekající pozvaní nový odkaz nedostanou.", + "invite_sent_smtp_disabled_description": "Odesílání e-mailů s pozvánkami je v této instanci Hoppscotch zakázáno. Použijte tlačítko Kopírovat odkaz a sdílejte pozvánku ručně.", + "copy_invite_link": "Kopírovat odkaz na pozvánku", + "search_title": "Požadavky týmu", + "user_not_found": "Uživatel nebyl v instanci nalezen.", + "invite_members": "Pozvat členy" + }, + "team_environment": { + "deleted": "Prostředí odstraněno", + "duplicate": "Prostředí duplikováno", + "not_found": "Prostředí nenalezeno." + }, + "test": { + "requests": "Požadavky", + "selection": "Výběr", + "failed": "Test selhal", + "javascript_code": "JavaScriptový kód", + "learn": "Přečtěte si dokumentaci", + "passed": "Test prošel", + "report": "Test report", + "results": "Výsledky testů", + "script": "Skript", + "snippets": "Úryvky", + "run": "Běh", + "run_again": "Spusťte znovu", + "stop": "Zastavit", + "new_run": "Nový běh", + "iterations": "Iterace", + "duration": "Trvání", + "avg_resp": "Prům. Doba odezvy" + }, + "websocket": { + "communication": "Komunikace", + "log": "Záznam", + "message": "Zpráva", + "protocols": "Protokoly", + "url": "URL" + }, + "workspace": { + "change": "Změnit pracovní prostor", + "personal": "Osobní pracovní prostor", + "other_workspaces": "Moje pracovní prostory", + "team": "Pracovní prostor", + "title": "Pracovní prostory" + }, + "site_protection": { + "login_to_continue": "Přihlaste se pro pokračování", + "login_to_continue_description": "Pro přístup k této Hoppscotch Enterprise instanci musíte být přihlášeni.", + "error_fetching_site_protection_status": "Při načítání stavu ochrany webu se něco pokazilo" + }, + "access_tokens": { + "tab_title": "Tokeny", + "section_title": "Osobní přístupové tokeny", + "section_description": "Osobní přístupové tokeny vám zatím umožňují připojit CLI k vašemu účtu Hoppscotch", + "last_used_on": "Naposledy použito", + "expires_on": "Platí do", + "no_expiration": "Bez expirace", + "expired": "Expirovaný", + "copy_token_warning": "Ujistěte se, že si osobní přístupový token nyní zkopírujete. Později ho už neuvidíte!", + "token_purpose": "K čemu je tento token?", + "expiration_label": "Expirace", + "scope_label": "Rozsah", + "workspace_read_only_access": "Přístup pouze pro čtení k datům pracovního prostoru.", + "personal_workspace_access_limitation": "Osobní přístupové tokeny nemohou přistupovat k vašemu osobnímu pracovnímu prostoru.", + "generate_token": "Vygenerovat token", + "invalid_label": "Zadejte prosím popisek tokenu", + "no_expiration_verbose": "Tento token nikdy nevyprší!", + "token_expires_on": "Tento token vyprší", + "generate_new_token": "Vygenerovat nový token", + "generate_modal_title": "Nový osobní přístupový token", + "deletion_success": "Přístupový token {label} byl smazán" + }, + "collection_runner": { + "collection_id": "ID kolekce", + "environment_id": "ID prostředí", + "cli_collection_id_description": "Toto ID kolekce se použije v CLI Collection Runneru Hoppscotch.", + "cli_environment_id_description": "Toto ID prostředí se použije v CLI Collection Runneru Hoppscotch.", + "include_active_environment": "Zahrnout aktivní prostředí:", + "cli": "CLI", + "cli_comming_soon_for_personal_collection": "Collection Runner pro osobní kolekce v CLI bude brzy k dispozici.", + "delay": "Zpoždění", + "negative_delay": "Zpoždění nemůže být záporné", + "ui": "Runner", + "running_collection": "Běží kolekce", + "run_config": "Konfigurace běhu", + "advanced_settings": "Pokročilá nastavení", + "stop_on_error": "Zastavit běh při chybě", + "persist_responses": "Uchovávat odpovědi", + "keep_variable_values": "Zachovat hodnoty proměnných", + "collection_not_found": "Kolekce nebyla nalezena. Mohla být smazána nebo přesunuta.", + "empty_collection": "Kolekce je prázdná. Přidejte požadavky ke spuštění.", + "no_response_persist": "Collection Runner je aktuálně nastavený tak, aby neuchovával odpovědi. Proto se nezobrazují data odpovědi. Pro změnu spusťte novou konfiguraci běhu.", + "select_request": "Vyberte požadavek pro zobrazení odpovědi a výsledků testů", + "response_body_lost_rerun": "Tělo odpovědi není k dispozici. Spusťte kolekci znovu.", + "cli_command_generation_description_cloud": "Zkopírujte níže uvedený příkaz a spusťte jej v CLI. Uveďte prosím osobní přístupový token.", + "cli_command_generation_description_sh": "Zkopírujte níže uvedený příkaz a spusťte jej v CLI. Uveďte prosím osobní přístupový token a ověřte vygenerovanou URL serveru instance SH.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Zkopírujte níže uvedený příkaz a spusťte jej v CLI. Uveďte prosím osobní přístupový token a URL serveru instance SH.", + "run_collection": "Spustit kolekci", + "no_passed_tests": "Žádné testy neprošly", + "no_failed_tests": "Žádné testy se nezdařily" + }, + "ai_experiments": { + "generate_request_name": "Generování názvu požadavku pomocí AI", + "generate_or_modify_request_body": "Vygenerovat nebo upravit tělo požadavku", + "modify_with_ai": "Upravte pomocí AI", + "generate": "Generovat", + "generate_or_modify_request_body_input_placeholder": "Zadejte výzvu k úpravě těla požadavku", + "accept_change": "Přijmout změnu", + "feedback_success": "Zpětná vazba byla úspěšně odeslána", + "feedback_failure": "Zpětnou vazbu se nepodařilo odeslat", + "feedback_thank_you": "Děkujeme za vaši zpětnou vazbu!", + "feedback_cta_text_long": "Hodnotit generaci, pomáhá nám se zlepšovat", + "feedback_cta_request_name": "Líbilo se vám vygenerované jméno?", + "modify_request_body_error": "Nepodařilo se upravit tělo požadavku", + "generate_or_modify_prerequest_input_placeholder": "Zadejte výzvu ke generování nebo úpravě skriptu pre-request", + "generate_or_modify_post_request_script_input_placeholder": "Zadejte výzvu ke generování nebo úpravě skriptu post-request", + "modify_post_request_script_error": "Nepodařilo se upravit skript post-request", + "modify_prerequest_error": "Nepodařilo se upravit skript pre-request" + }, + "configs": { + "auth_providers": { + "callback_url": "CALLBACK URL", + "client_id": "CLIENT ID", + "client_secret": "CLIENT SECRET", + "description": "Nakonfigurujte poskytovatele autentizace pro váš server", + "provider_not_specified": "Povolte alespoň jednoho poskytovatele autentizace", + "scope": "SCOPE", + "tenant": "TENANT", + "title": "Poskytovatelé autentizace", + "update_failure": "Aktualizace konfigurace poskytovatelů autentizace se nezdařila." + }, + "confirm_changes": "Hoppscotch server se musí restartovat, aby se projevily nové změny. Potvrdit změny provedené v konfiguraci serveru?", + "input_empty": "Před aktualizací konfigurací vyplňte všechna pole", + "data_sharing": { + "title": "Sdílení dat", + "description": "Pomozte vylepšit Hoppscotch sdílením anonymních dat", + "enable": "Povolit sdílení dat", + "secondary_title": "Konfigurace sdílení dat", + "see_shared": "Podívejte se, co je sdíleno", + "toggle_description": "Sdílejte anonymní data", + "update_failure": "Aktualizace konfigurace sdílení dat se nezdařila." + }, + "load_error": "Nelze načíst konfigurace serveru", + "mail_configs": { + "address_from": "MAILER FROM ADDRESS", + "custom_smtp_configs": "Použít vlastní SMTP konfiguraci", + "description": "Nakonfigurujte SMTP", + "enable_email_auth": "Povolit ověřování založené na e-mailu", + "enable_smtp": "Povolit SMTP", + "host": "MAILER HOST", + "password": "MAILER PASSWORD", + "port": "MAILER PORT", + "secure": "MAILER SECURE", + "smtp_url": "MAILER SMTP URL", + "tls_reject_unauthorized": "TLS REJECT UNAUTHORIZED", + "title": "Konfigurace SMTP", + "toggle_failure": "Nepodařilo se přepnout SMTP.", + "update_failure": "Aktualizace SMTP konfigurace se nezdařila.", + "user": "MAILER USER" + }, + "reset": { + "confirm_reset": "Hoppscotch server se musí restartovat, aby se projevily nové změny. Potvrdit resetování konfigurace serveru?", + "description": "Výchozí konfigurace budou načteny, jak je uvedeno v souboru prostředí", + "failure": "Reset konfigurace se nezdařil.", + "title": "Obnovit konfigurace", + "info": "Resetovat konfigurace serveru" + }, + "restart": { + "description": "Do automatického opětovného načtení této stránky zbývá {duration} sekund", + "initiate": "Probíhá restartování serveru...", + "title": "Server se restartuje" + }, + "save_changes": "Uložit změny", + "title": "Konfigurace", + "update_failure": "Aktualizace konfigurace serveru se nezdařila", + "restrict_access": "Omezit přístup", + "site_protection": { + "control_access": "Určete, kdo má přístup k aplikaci Hoppscotch", + "description": "Přizpůsobte si způsob přístupu návštěvníků k vaší aplikaci Hoppscotch pomocí nastavení ochrany webu.", + "enable": "Povolit ochranu webu", + "note": "Uživatelům, kteří navštíví aplikaci Hoppscotch, se zobrazí přihlašovací stránka, pro přístup k aplikaci je vyžadováno povinné přihlášení. Pro autorizaci je stále vyžadován souhlas administrátora", + "update_failure": "Aktualizace konfigurace ochrany webu se nezdařila." + }, + "domain_whitelisting": { + "add_domain": "Přidat novou doménu", + "description": "Uživatelé s e-mailovým ID registrovaným v doménách na seznamu povolených nevyžadují výslovný souhlas správce pro přístup k aplikaci Hoppscotch", + "enable": "Povolit přidávání domén na seznam povolených", + "enter_domain": "Zadejte doménu", + "title": "Domény na seznamu povolených", + "toggle_failure": "Nepodařilo se přepnout na seznam povolených domén", + "update_failure": "Aktualizace konfigurace whitelistu domén se nezdařila." + }, + "oidc_configs": { + "auth_url": "Auth URL", + "callback_url": "Zpětné volání URL", + "client_id": "Client ID", + "client_secret": "Client Secret", + "description": "Nakonfigurujte OIDC", + "enable": "Povolit ověřování založené na OIDC", + "issuer": "Vydavatel", + "provider_name": "Jméno poskytovatele", + "scope": "Rozsah", + "title": "Konfigurace OIDC", + "token_url": "Token URL", + "update_failure": "Aktualizace OIDC konfigurace se nezdařila.", + "user_info_url": "Informace o uživateli URL" + }, + "saml": { + "audience": "Audience", + "callback_url": "Zpětné volání URL", + "certificate": "Certifikát", + "description": "Nakonfigurujte SAML", + "enable": "Povolit ověřování založené na SAML", + "entry_point": "Vstupní bod", + "issuer": "Vydavatel", + "title": "Konfigurace SAML", + "update_failure": "Aktualizace SAML konfigurace se nezdařila.", + "want_assertions_signed": "Podepsat tvrzení", + "want_response_signed": "Podepsat odpověď" + } + }, + "data_sharing": { + "description": "Sdílejte anonymní využití dat za účelem zlepšení Hoppscotch", + "enable": "Povolit sdílení dat", + "see_shared": "Podívejte se, co je sdíleno", + "toggle_description": "Sdílejte data a vylepšete Hoppscotch", + "title": "Vylepšete Hoppscotch", + "welcome": "Vítejte na" + }, + "infra_tokens": { + "copy_token_warning": "Ujistěte se, že jste si zkopírovali infra token. Už to nebudete moci vidět!", + "deletion_success": "Infra token {label} byl smazán", + "empty": "Infra tokeny jsou prázdné", + "expired": "Platnost vypršela", + "expiration_label": "Vypršení platnosti", + "expires_on": "Vyprší dne", + "generate_modal_title": "Nový infra token", + "generate_new_token": "Vygenerovat nový token", + "generate_token": "Vygenerovat token", + "invalid_label": "Zadejte prosím popisek tokenu", + "last_used_on": "Naposledy použito", + "no_expiration": "Žádná expirace", + "no_expiration_verbose": "Platnost tohoto tokenu nikdy nevyprší!", + "section_description": "Spravujte své Hoppscotch uživatele prostřednictvím API s tokeny Infra", + "section_title": "Infra tokeny", + "tab_title": "Infra tokeny", + "token_expires_on": "Platnost tohoto tokenu vyprší dne", + "token_purpose": "Zadejte štítek k identifikaci tohoto tokenu" + }, + "metrics": { + "dashboard": "Dashboard", + "no_metrics": "Nebyly nalezeny žádné metriky", + "total_collections": "Celkový počet kolekcí", + "total_requests": "Celkový počet požadavků", + "total_teams": "Celkový počet pracovních prostorů", + "total_users": "Celkový počet uživatelů" + }, + "newsletter": { + "description": "Získejte aktualizace o našich nejnovějších zprávách", + "subscribe": "Přihlásit odběr", + "title": "Zůstaňte v kontaktu", + "toggle_description": "Získejte aktualizace o nejnovějších na Hoppscotch", + "unsubscribe": "Odhlásit odběr" + }, + "teams": { + "add_member": "Přidat člena", + "add_members": "Přidat členy", + "add_new": "Přidat nový", + "admin": "Admin", + "admin_Email": "E-mail správce", + "admin_id": "ID správce", + "cancel": "Zrušit", + "confirm_team_deletion": "Potvrdit smazání pracovního prostoru?", + "copy": "Kopírovat", + "create_team": "Vytvořit pracovní prostor", + "date": "Datum", + "delete_team": "Smazat pracovní prostor", + "details": "Podrobnosti", + "edit": "Upravit", + "editor": "Editor", + "editor_description": "Editoři mohou přidávat, upravovat a odstraňovat požadavky a kolekce.", + "email": "E-mail vlastníka pracovního prostoru", + "email_address": "E-mailová adresa", + "email_title": "E-mail", + "empty_name": "Název pracovního prostoru nemůže být prázdný.", + "error": "Něco se pokazilo. Zkuste to znovu později.", + "id": "ID pracovního prostoru", + "invited_email": "E-mail pozvaného", + "invited_on": "Pozván dne", + "invites": "Pozvánky", + "load_info_error": "Informace o pracovním prostoru nelze načíst", + "load_list_error": "Nelze načíst seznam pracovních prostorů", + "members": "Počet členů", + "no_invite": "Žádné pozvánky", + "no_invite_description": "Pozvěte svůj tým do pracovního prostoru a začněte spolupracovat", + "owner": "Vlastník", + "owner_description": "Vlastníci mohou přidávat, upravovat a odstraňovat požadavky, kolekce a členy pracovního prostoru.", + "permissions": "Oprávnění", + "name": "Název pracovního prostoru", + "no_members": "V tomto pracovním prostoru nejsou žádní členové. Přidejte členy do tohoto pracovního prostoru, abyste mohli spolupracovat", + "no_pending_invites": "Žádné nevyřízené pozvánky", + "no_teams": "Nebyly nalezeny žádné pracovní prostory.", + "no_teams_description": "Vytvořte pracovní prostor pro spolupráci se svým týmem", + "pending_invites": "Nevyřízené pozvánky", + "roles": "Role", + "roles_description": "Role se používají k řízení přístupu ke sdíleným kolekcím.", + "remove": "Odstranit", + "rename": "Přejmenovat", + "save": "Uložit", + "save_changes": "Uložit změny", + "send_invite": "Odeslat pozvánku", + "show_more": "Zobrazit více", + "team_details": "Podrobnosti o pracovním prostoru", + "team_members": "Členové", + "team_members_tab": "Členové pracovního prostoru", + "teams": "Pracovní prostory", + "uid": "UID", + "unnamed": "(Nepojmenovaný pracovní prostor)", + "viewer": "Čtenář", + "viewer_description": "Čtenáři mohou požadavky pouze zobrazovat a používat.", + "valid_name": "Zadejte prosím platný název pracovního prostoru", + "valid_owner_email": "Zadejte prosím platný e-mail vlastníka" + }, + "users": { + "add_user": "Přidat uživatele", + "admin": "Admin", + "admin_id": "ID správce", + "cancel": "Zrušit", + "created_on": "Vytvořeno dne", + "copy_invite_link": "Zkopírovat odkaz na pozvánku", + "copy_link": "Kopírovat odkaz", + "date": "Datum", + "delete": "Vymazat", + "delete_user": "Smazat uživatele", + "delete_users": "Smazat uživatele", + "details": "Podrobnosti", + "edit": "Upravit", + "email": "E-mail", + "email_address": "E-mailová adresa", + "empty_name": "Jméno nemůže být prázdné.", + "id": "ID uživatele", + "invalid_user": "Neplatný uživatel", + "invite_load_list_error": "Nelze načíst seznam pozvaných uživatelů", + "invite_user": "Pozvat uživatele", + "invited_by": "Pozván od", + "invited_on": "Pozván dne", + "invited_users": "Pozvaní uživatelé", + "invitee_email": "E-mail pozvaného", + "last_active_on": "Poslední aktivní", + "load_info_error": "Nelze načíst informace o uživateli", + "load_list_error": "Nelze načíst seznam uživatelů", + "make_admin": "Nastavit jako správce", + "name": "Jméno", + "new_user_added": "Přidán nový uživatel", + "no_invite": "Nebyly nalezeny žádné nevyřízené pozvánky", + "no_invite_description": "Žádné nevyřízené pozvánky. Začněte zvát kolegy do Hoppscotch.", + "no_shared_requests": "Žádné sdílené požadavky vytvořené uživatelem", + "no_users": "Nebyli nalezeni žádní uživatelé", + "not_available": "Není k dispozici", + "not_found": "Uživatel nenalezen", + "pending_invites": "Nevyřízené pozvánky", + "remove_admin_privilege": "Odebrat oprávnění správce", + "remove_admin_status": "Odebrat roli správce", + "rename": "Přejmenovat", + "revoke_invitation": "Zrušit pozvánku", + "searchbar_placeholder": "Hledat podle jména nebo e-mailu...", + "send_invite": "Odeslat pozvánku", + "show_more": "Zobrazit více", + "uid": "UID", + "unnamed": "(Nepojmenovaný uživatel)", + "user_not_found": "Uživatel nebyl nalezen v infrastruktuře.", + "users": "Uživatelé", + "valid_email": "Zadejte prosím platnou e-mailovou adresu", + "deactivate": "Deaktivovat", + "deactivate_user": "Deaktivovat uživatele" + }, + "organization": { + "login_to_continue_description": "Chcete-li se připojit k instanci organizace, musíte být přihlášeni.", + "create_an_organization": "Vytvořte organizaci", + "deactivate_user_failure": "Nepodařilo se deaktivovat uživatele.", + "deactivate_user_success": "Uživatel byl úspěšně deaktivován.", + "delete_account_description": "Tímto smažete všechna data spojená s vaším účtem Hoppscotch, včetně této a všech dalších organizací, kterých jste součástí.", + "delete_account": "Smazat účet Hoppscotch", + "user_deletion_failed_sole_admin": "Uživatel je jediný správce v jedné nebo více organizacích. Před smazáním ho nejdříve degradujte.", + "user_deletion_failed_sole_team_owner": "Uživatel je jediným vlastníkem pracovního prostoru v jedné nebo více instancích organizace. Před pokusem o smazání prosím převeďte vlastnictví nebo smažte příslušné pracovní prostory.", + "no_organizations": "Nejste členem žádné organizace", + "admin": "Admin" + }, + "organization_sidebar": { + "instances": "Instance", + "hoppscotch_cloud": "Hoppscotch Cloud", + "admin": "Admin", + "no_orgs_title": "Zatím žádné organizace", + "no_orgs_description": "Připojte se nebo vytvořte organizaci a spolupracujte se svým týmem", + "error_loading": "Organizace se nepodařilo načíst", + "inactive_orgs": "Neaktivní organizace", + "multi_account_notice": "Každá organizace si uchovává své vlastní přihlašovací údaje pomocí posledního účtu, ke kterému se přihlásila.", + "inactive_orgs_tooltip": "Požádejte o pomoc podporu." + }, + "billing": { + "confirm": { + "update_seat_count": "Opravdu chcete aktualizovat počet míst na {newSeatCount}?", + "update_billing_cycle": "Opravdu chcete aktualizovat fakturační cyklus na {newBillingCycle}?" + }, + "cancel_subscription": "Zrušit předplatné" + }, + "app_console": { + "entries": "Záznamy konzole", + "no_entries": "Žádné záznamy" + }, + "mockServer": { + "create_modal": { + "title": "Vytvořit Mock Server", + "name_label": "Název mock serveru", + "name_placeholder": "Zadejte název mock serveru", + "name_required": "Název mock serveru je povinný", + "collection_source_label": "Zdroj kolekce", + "existing_collection": "Existující kolekce", + "new_collection": "Nová kolekce", + "select_collection_label": "Vyberte kolekci", + "select_collection_placeholder": "Vyberte kolekci", + "collection_required": "Vyberte prosím kolekci", + "collection_name_label": "Název kolekce", + "collection_name_placeholder": "Zadejte název kolekce", + "collection_name_required": "Název kolekce je povinný", + "request_config_label": "Konfigurace požadavku", + "add_request": "Přidat požadavek", + "create_button": "Vytvořit Mock Server", + "success": "Mock server byl úspěšně vytvořen", + "error": "Nepodařilo se vytvořit mock server", + "no_collections": "Není k dispozici žádná kolekce" + }, + "edit_modal": { + "title": "Upravit Mock Server", + "name_label": "Název mock serveru", + "name_placeholder": "Zadejte název mock serveru", + "name_required": "Název mock serveru je povinný", + "active_label": "Aktivní", + "url_label": "URL mock serveru", + "collection_label": "Kolekce", + "update_button": "Aktualizovat Mock Server", + "success": "Mock server byl úspěšně aktualizován", + "error": "Aktualizace mock serveru se nezdařila", + "url_copied": "URL zkopírováno do schránky" + }, + "dashboard": { + "title": "Mock servery", + "subtitle": "Vytvářejte a spravujte své API mock servery", + "create_button": "Vytvořit Mock Server", + "create_first": "Vytvořte svůj první mock server", + "empty_title": "Nebyly nalezeny žádné mock servery", + "empty_description": "Vytvořte mock servery na základě API kolekcí, abyste mohli vyvíjet frontend a mobilní aplikace bez závislosti na backendu.", + "collection": "Kolekce", + "active": "Aktivní", + "inactive": "Neaktivní", + "mock_url": "Mock URL", + "endpoints": "Endpointy", + "created": "Vytvořeno", + "view_collection": "Zobrazit kolekci", + "documentation": "Dokumentace", + "doc_description": "Použijte toto URL jako base URL API ve svých aplikacích:", + "url_copied": "Mock server URL zkopírován do schránky", + "delete_title": "Smazat Mock Server", + "delete_description": "Opravdu chcete smazat tento mock server?", + "delete_success": "Mock server byl úspěšně smazán", + "delete_error": "Smazání mock serveru se nezdařilo" + } + } +} diff --git a/packages/hoppscotch-common/locales/da.json b/packages/hoppscotch-common/locales/da.json new file mode 100644 index 0000000..ad6132b --- /dev/null +++ b/packages/hoppscotch-common/locales/da.json @@ -0,0 +1,1096 @@ +{ + "action": { + "add": "Tilføj", + "autoscroll": "Automatisk rulning", + "cancel": "Annuller", + "choose_file": "Vælg en fil", + "clear": "Ryd", + "clear_all": "Ryd alt", + "clear_history": "Ryd al historik", + "close": "Luk", + "connect": "Forbind", + "connecting": "Forbinder", + "copy": "Kopiér", + "create": "Opret", + "delete": "Slet", + "disconnect": "Afbryd forbindelse", + "dismiss": "Afvis", + "dont_save": "Gem ikke", + "download_file": "Download fil", + "drag_to_reorder": "Træk for at omarrangere", + "duplicate": "Duplikér", + "edit": "Redigér", + "filter": "Filtrer", + "go_back": "Gå tilbage", + "go_forward": "Gå fremad", + "group_by": "Gruppér efter", + "hide_secret": "Skjul hemmelighed", + "label": "Etiket", + "learn_more": "Lær mere", + "download_here": "Download her", + "less": "Mindre", + "more": "Mere", + "new": "Ny", + "no": "Nej", + "open_workspace": "Åbn arbejdsområde", + "paste": "Indsæt", + "prettify": "Forskøn", + "properties": "Egenskaber", + "remove": "Fjern", + "rename": "Omdøb", + "restore": "Gendan", + "save": "Gem", + "scroll_to_bottom": "Rul til bunden", + "scroll_to_top": "Rul til toppen", + "search": "Søg", + "send": "Send", + "share": "Del", + "show_secret": "Vis hemmelighed", + "start": "Start", + "starting": "Starter", + "stop": "Stop", + "to_close": "for at lukke", + "to_navigate": "for at navigere", + "to_select": "for at vælge", + "turn_off": "Sluk", + "turn_on": "Tænd", + "undo": "Fortryd", + "yes": "Ja" + }, + "add": { + "new": "Tilføj ny", + "star": "Tilføj stjerne" + }, + "app": { + "chat_with_us": "Chat med os", + "contact_us": "Kontakt os", + "cookies": "Cookies", + "copy": "Kopiér", + "copy_interface_type": "Kopiér interfacetype", + "copy_user_id": "Kopiér brugerautentifikationstoken", + "developer_option": "Udviklermuligheder", + "developer_option_description": "Udviklingsværktøjer, der hjælper med udvikling og vedligeholdelse af Hoppscotch.", + "discord": "Discord", + "documentation": "Dokumentation", + "github": "GitHub", + "help": "Hjælp og feedback", + "home": "Hjem", + "invite": "Invitér", + "invite_description": "Hoppscotch er et open source API-udviklingsøkosystem. Vi har designet en simpel og intuitiv brugerflade til at oprette og administrere dine API'er. Hoppscotch er et værktøj, der hjælper dig med at bygge, teste, dokumentere og dele dine API'er.", + "invite_your_friends": "Invitér dine venner", + "join_discord_community": "Tilslut dig vores Discord-fællesskab", + "keyboard_shortcuts": "Tastaturgenveje", + "name": "Hoppscotch", + "new_version_found": "Ny version fundet. Opdatér for at opdatere.", + "open_in_hoppscotch": "Åbn i Hoppscotch", + "options": "Indstillinger", + "proxy_privacy_policy": "Proxy privatlivspolitik", + "reload": "Genindlæs", + "search": "Søg", + "share": "Del", + "shortcuts": "Genveje", + "social_description": "Følg os på sociale medier for at holde dig opdateret med de seneste nyheder, opdateringer og udgivelser.", + "social_links": "Sociale links", + "spotlight": "Spotlight", + "status": "Status", + "status_description": "Tjek hjemmesidens status", + "terms_and_privacy": "Vilkår og privatlivspolitik", + "twitter": "Twitter", + "type_a_command_search": "Skriv en kommando eller søg...", + "we_use_cookies": "Vi bruger cookies", + "updated_text": "Hoppscotch er blevet opdateret til v{version} 🎉", + "whats_new": "Hvad er nyt?", + "see_whats_new": "Se hvad der er nyt", + "wiki": "Wiki" + }, + "auth": { + "account_exists": "Konto eksisterer med andre legitimationsoplysninger - Log ind for at forbinde begge konti", + "all_sign_in_options": "Alle loginmuligheder", + "continue_with_auth_provider": "Fortsæt med {provider}", + "continue_with_email": "Fortsæt med e-mail", + "continue_with_github": "Fortsæt med GitHub", + "continue_with_github_enterprise": "Fortsæt med GitHub Enterprise", + "continue_with_google": "Fortsæt med Google", + "continue_with_microsoft": "Fortsæt med Microsoft", + "email": "E-mail", + "logged_out": "Logget ud", + "login": "Log ind", + "login_success": "Logget ind med succes", + "login_to_hoppscotch": "Log ind på Hoppscotch", + "logout": "Log ud", + "re_enter_email": "Indtast e-mail igen", + "send_magic_link": "Send et magisk link", + "sync": "Synkroniser", + "we_sent_magic_link": "Vi har sendt dig et magisk link!", + "we_sent_magic_link_description": "Tjek din indbakke - vi har sendt en e-mail til {email}. Den indeholder et magisk link, der vil logge dig ind." + }, + "authorization": { + "generate_token": "Generer token", + "graphql_headers": "Autorisationsheadere sendes som en del af nyttelasten til connection_init", + "include_in_url": "Inkluder i URL", + "inherited_from": "Nedarvet {auth} fra overordnet samling {collection}", + "learn": "Lær hvordan", + "oauth": { + "redirect_auth_server_returned_error": "Auth-server returnerede en fejltilstand", + "redirect_auth_token_request_failed": "Anmodning om at få auth-token mislykkedes", + "redirect_auth_token_request_invalid_response": "Ugyldigt svar fra Token-endepunktet ved anmodning om et auth-token", + "redirect_invalid_state": "Ugyldig tilstandsværdi til stede i omdirigeringen", + "redirect_no_auth_code": "Ingen autorisationskode til stede i omdirigeringen", + "redirect_no_client_id": "Ingen klient-id defineret", + "redirect_no_client_secret": "Ingen klienthemmelighed defineret", + "redirect_no_code_verifier": "Ingen kodeverifikator defineret", + "redirect_no_token_endpoint": "Intet token-endepunkt defineret", + "something_went_wrong_on_oauth_redirect": "Noget gik galt under OAuth-omdirigering", + "something_went_wrong_on_token_generation": "Noget gik galt ved tokengenerering", + "token_generation_oidc_discovery_failed": "Fejl ved tokengenerering: OpenID Connect-opdagelse mislykkedes", + "grant_type": "Tilladelsestype", + "grant_type_auth_code": "Autorisationskode", + "token_fetched_successfully": "Token hentet med succes", + "token_fetch_failed": "Kunne ikke hente token", + "validation_failed": "Validering mislykkedes, tjek venligst formularfelterne", + "label_authorization_endpoint": "Autorisationsendepunkt", + "label_client_id": "Klient-id", + "label_client_secret": "Klienthemmelighed", + "label_code_challenge": "Kodeudfordring", + "label_code_challenge_method": "Kodeudfordringsmetode", + "label_code_verifier": "Kodeverifikator", + "label_scopes": "Områder", + "label_token_endpoint": "Token-endepunkt", + "label_use_pkce": "Brug PKCE", + "label_implicit": "Implicit", + "label_password": "Adgangskode", + "label_username": "Brugernavn", + "label_auth_code": "Autorisationskode", + "label_client_credentials": "Klientlegitimationsoplysninger" + }, + "pass_key_by": "Send via", + "pass_by_query_params_label": "Forespørgselsparametre", + "pass_by_headers_label": "Headers", + "password": "Adgangskode", + "save_to_inherit": "Gem venligst denne anmodning i en samling for at arve autorisationen", + "token": "Token", + "type": "Autorisationstype", + "username": "Brugernavn" + }, + "collection": { + "created": "Samling oprettet", + "different_parent": "Kan ikke omarrangere samling med forskellig overordnet", + "edit": "Redigér samling", + "import_or_create": "Importér eller opret en samling", + "import_collection": "Importér samling", + "invalid_name": "Angiv venligst et navn til samlingen", + "invalid_root_move": "Samling allerede i roden", + "moved": "Flyttet med succes", + "my_collections": "Personlige samlinger", + "name": "Min nye samling", + "name_length_insufficient": "Samlingens navn skal være mindst 3 tegn langt", + "new": "Ny samling", + "order_changed": "Samlingsrækkefølge opdateret", + "properties": "Samlingsegenskaber", + "properties_updated": "Samlingsegenskaber opdateret", + "renamed": "Samling omdøbt", + "request_in_use": "Anmodning i brug", + "save_as": "Gem som", + "save_to_collection": "Gem i samling", + "select": "Vælg en samling", + "select_location": "Vælg placering", + "details": "Detaljer", + "duplicated": "Samling duplikeret" + }, + "confirm": { + "close_unsaved_tab": "Er du sikker på, at du vil lukke denne fane?", + "close_unsaved_tabs": "Er du sikker på, at du vil lukke alle faner? {count} ikke-gemte faner vil gå tabt.", + "exit_team": "Er du sikker på, at du vil forlade dette arbejdsområde?", + "logout": "Er du sikker på, at du vil logge ud?", + "remove_collection": "Er du sikker på, at du permanent vil slette denne samling?", + "remove_environment": "Er du sikker på, at du permanent vil slette dette miljø?", + "remove_folder": "Er du sikker på, at du permanent vil slette denne mappe?", + "remove_history": "Er du sikker på, at du permanent vil slette al historik?", + "remove_request": "Er du sikker på, at du permanent vil slette denne anmodning?", + "remove_shared_request": "Er du sikker på, at du permanent vil slette denne delte anmodning?", + "remove_team": "Er du sikker på, at du vil slette dette arbejdsområde?", + "remove_telemetry": "Er du sikker på, at du vil fravælge telemetri?", + "request_change": "Er du sikker på, at du vil kassere den nuværende anmodning, ikke-gemte ændringer vil gå tabt.", + "save_unsaved_tab": "Vil du gemme ændringer foretaget i denne fane?", + "sync": "Vil du gendanne dit arbejdsområde fra skyen? Dette vil kassere dit lokale fremskridt.", + "delete_access_token": "Er du sikker på, at du vil slette adgangstokenet {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Tilføj til parametre", + "open_request_in_new_tab": "Åbn anmodning i ny fane", + "set_environment_variable": "Indstil som variabel" + }, + "cookies": { + "modal": { + "cookie_expires": "Udløber", + "cookie_name": "Navn", + "cookie_path": "Sti", + "cookie_string": "Cookie-streng", + "cookie_value": "Værdi", + "empty_domain": "Domæne er tomt", + "empty_domains": "Domæneliste er tom", + "enter_cookie_string": "Indtast cookie-streng", + "interceptor_no_support": "Din aktuelt valgte interceptor understøtter ikke cookies. Vælg en anden interceptor og prøv igen.", + "managed_tab": "Administreret", + "new_domain_name": "Nyt domænenavn", + "no_cookies_in_domain": "Ingen cookies indstillet for dette domæne", + "raw_tab": "Rå", + "set": "Indstil en cookie" + } + }, + "count": { + "header": "Header {count}", + "message": "Besked {count}", + "parameter": "Parameter {count}", + "protocol": "Protokol {count}", + "value": "Værdi {count}", + "variable": "Variabel {count}" + }, + "documentation": { + "generate": "Generer dokumentation", + "generate_message": "Importér enhver Hoppscotch-samling for at generere API-dokumentation on-the-go." + }, + "empty": { + "authorization": "Denne anmodning bruger ingen autorisation", + "body": "Denne anmodning har ingen meddelelse", + "collection": "Samlingen er tom", + "collections": "Samlinger er tomme", + "documentation": "Forbind til et GraphQL-endepunkt for at se dokumentation", + "endpoint": "Endepunkt kan ikke være tomt", + "environments": "Miljøer er tomme", + "folder": "Mappe er tom", + "headers": "Denne anmodning har ingen headers", + "history": "Historik er tom", + "invites": "Invitationsliste er tom", + "members": "Arbejdsområde er tomt", + "parameters": "Denne anmodning har ingen parametre", + "pending_invites": "Der er ingen ventende invitationer til dette arbejdsområde", + "profile": "Log ind for at se din profil", + "protocols": "Protokoller er tomme", + "request_variables": "Denne anmodning har ingen anmodningsvariabler", + "schema": "Forbind til et GraphQL-endepunkt for at se skema", + "secret_environments": "Hemmeligheder synkroniseres ikke til Hoppscotch", + "shared_requests": "Delte anmodninger er tomme", + "shared_requests_logout": "Log ind for at se dine delte anmodninger eller opret en ny", + "subscription": "Abonnementer er tomme", + "team_name": "Arbejdsområdenavn tomt", + "teams": "Du tilhører ikke nogen arbejdsområder", + "tests": "Der er ingen tests for denne anmodning", + "access_tokens": "Adgangstokens er tomme" + }, + "environment": { + "add_to_global": "Tilføj til global", + "added": "Miljø tilføjet", + "create_new": "Opret nyt miljø", + "created": "Miljø oprettet", + "deleted": "Miljø slettet", + "duplicated": "Miljø duplikeret", + "edit": "Redigér miljø", + "empty_variables": "Ingen variabler", + "global": "Global", + "global_variables": "Globale variabler", + "import_or_create": "Importér eller opret et miljø", + "invalid_name": "Angiv venligst et navn til miljøet", + "list": "Miljøvariabler", + "my_environments": "Personlige miljøer", + "name": "Navn", + "nested_overflow": "Indlejrede miljøvariabler er begrænset til 10 niveauer", + "new": "Nyt miljø", + "no_active_environment": "Intet aktivt miljø", + "no_environment": "Intet miljø", + "no_environment_description": "Ingen miljøer blev valgt. Vælg hvad der skal gøres med følgende variabler.", + "quick_peek": "Miljø hurtig kig", + "replace_with_variable": "Erstat med variabel", + "scope": "Omfang", + "secrets": "Hemmeligheder", + "secret_value": "Hemmelig værdi", + "select": "Vælg miljø", + "set": "Indstil miljø", + "set_as_environment": "Indstil som miljø", + "team_environments": "Arbejdsområdemiljøer", + "title": "Miljøer", + "updated": "Miljø opdateret", + "value": "Værdi", + "variable": "Variabel", + "variables": "Variabler", + "variable_list": "Variabelliste", + "properties": "Miljøegenskaber", + "details": "Detaljer" + }, + "error": { + "authproviders_load_error": "Kunne ikke indlæse autorisationsudbydere", + "browser_support_sse": "Denne browser ser ikke ud til at have understøttelse af Server Sent Events.", + "check_console_details": "Tjek konsolloggen for detaljer.", + "check_how_to_add_origin": "Tjek hvordan du kan tilføje en oprindelse", + "curl_invalid_format": "cURL er ikke formateret korrekt", + "danger_zone": "Farligt område", + "delete_account": "Din konto er i øjeblikket ejer af disse arbejdsområder:", + "delete_account_description": "Du skal enten fjerne dig selv, overføre ejerskab eller slette disse arbejdsområder, før du kan slette din konto.", + "empty_email_address": "E-mailadresse må ikke være tom", + "empty_profile_name": "Profilnavn kan ikke være tomt", + "empty_req_name": "Tomt anmodningsnavn", + "f12_details": "(F12 for detaljer)", + "gql_prettify_invalid_query": "Kunne ikke forskønne en ugyldig forespørgsel, løs forespørgselssyntaksfejl og prøv igen", + "incomplete_config_urls": "Ufuldstændige konfigurationsadresser", + "incorrect_email": "Forkert e-mail", + "invalid_link": "Ugyldigt link", + "invalid_link_description": "Linket, du klikkede på, er ugyldigt eller udløbet.", + "invalid_embed_link": "Indlejringen eksisterer ikke eller er ugyldig.", + "json_parsing_failed": "Ugyldig JSON", + "json_prettify_invalid_body": "Kunne ikke forskønne en ugyldig meddelelse, løs json-syntaksfejl og prøv igen", + "network_error": "Der ser ud til at være en netværksfejl. Prøv venligst igen.", + "network_fail": "Kunne ikke sende anmodning", + "no_collections_to_export": "Ingen samlinger at eksportere. Opret venligst en samling for at komme i gang.", + "no_duration": "Ingen varighed", + "no_environments_to_export": "Ingen miljøer at eksportere. Opret venligst et miljø for at komme i gang.", + "no_results_found": "Ingen matches fundet", + "page_not_found": "Denne side kunne ikke findes", + "please_install_extension": "Installer venligst udvidelsen og tilføj oprindelse til udvidelsen.", + "proxy_error": "Proxy-fejl", + "same_email_address": "Opdateret e-mailadresse er den samme som den aktuelle e-mailadresse", + "same_profile_name": "Opdateret profilnavn er det samme som det nuværende profilnavn", + "script_fail": "Kunne ikke udføre pre-request script", + "something_went_wrong": "Noget gik galt", + "post_request_script_fail": "Kunne ikke udføre post-request script", + "reading_files": "Fejl under læsning af en eller flere filer.", + "fetching_access_tokens_list": "Noget gik galt under hentning af listen over tokens", + "generate_access_token": "Noget gik galt under generering af adgangstoken", + "delete_access_token": "Noget gik galt under sletning af adgangstoken" + }, + "export": { + "as_json": "Eksportér som JSON", + "create_secret_gist": "Opret hemmelig Gist", + "create_secret_gist_tooltip_text": "Eksportér som hemmelig Gist", + "failed": "Noget gik galt under eksport", + "secret_gist_success": "Eksporteret som hemmelig Gist med succes", + "require_github": "Log ind med GitHub for at oprette hemmelig gist", + "title": "Eksportér", + "success": "Eksporteret med succes" + }, + "filter": { + "all": "Alle", + "none": "Ingen", + "starred": "Stjernemarkeret" + }, + "folder": { + "created": "Mappe oprettet", + "edit": "Redigér mappe", + "invalid_name": "Angiv venligst et navn til mappen", + "name_length_insufficient": "Mappenavn skal være mindst 3 tegn langt", + "new": "Ny mappe", + "renamed": "Mappe omdøbt" + }, + "graphql": { + "connection_switch_confirm": "Vil du forbinde med det seneste GraphQL-endepunkt?", + "connection_switch_new_url": "Skift til en fane vil afbryde din forbindelse til den aktive GraphQL-forbindelse. Ny forbindelsesadresse er", + "connection_switch_url": "Du er forbundet til et GraphQL-endepunkt, forbindelsesadressen er", + "mutations": "Mutationer", + "schema": "Skema", + "subscriptions": "Abonnementer", + "switch_connection": "Skift forbindelse", + "url_placeholder": "Indtast en GraphQL-endepunktsadresse" + }, + "graphql_collections": { + "title": "GraphQL-samlinger" + }, + "group": { + "time": "Tid", + "url": "URL" + }, + "header": { + "install_pwa": "Installér app", + "login": "Log ind", + "save_workspace": "Gem mit arbejdsområde" + }, + "helpers": { + "authorization": "Autorisationsheaderen vil automatisk blive genereret, når du sender anmodningen.", + "collection_properties_authorization": "Denne autorisation vil blive indstillet for hver anmodning i denne samling.", + "collection_properties_header": "Denne header vil blive indstillet for hver anmodning i denne samling.", + "generate_documentation_first": "Generer dokumentation først", + "network_fail": "Kan ikke nå API-endepunktet. Tjek din netværksforbindelse eller vælg en anden Interceptor og prøv igen.", + "offline": "Du bruger Hoppscotch offline. Opdateringer vil synkronisere, når du er online, baseret på arbejdsområdeindstillinger.", + "offline_short": "Du bruger Hoppscotch offline.", + "post_request_tests": "Testscripts er skrevet i JavaScript og køres efter svaret er modtaget.", + "pre_request_script": "Pre-request scripts er skrevet i JavaScript og køres før anmodningen sendes.", + "script_fail": "Det ser ud til, at der er en fejl i pre-request scriptet. Tjek fejlen nedenfor og ret scriptet i overensstemmelse hermed.", + "post_request_script_fail": "Der ser ud til at være en fejl med post-request scriptet. Ret venligst fejlene og kør tests igen", + "post_request_script": "Skriv et testscript for at automatisere fejlfinding." + }, + "hide": { + "collection": "Skjul samlingspanel", + "more": "Skjul mere", + "preview": "Skjul forhåndsvisning", + "sidebar": "Skjul sidebjælke" + }, + "import": { + "collections": "Importér samlinger", + "curl": "Importér cURL", + "environments_from_gist": "Importér fra Gist", + "environments_from_gist_description": "Importér Hoppscotch-miljøer fra Gist", + "failed": "Fejl under import: format ikke genkendt", + "from_file": "Importér fra fil", + "from_gist": "Importér fra Gist", + "from_gist_description": "Importér fra Gist-URL", + "from_insomnia": "Importér fra Insomnia", + "from_insomnia_description": "Importér fra Insomnia-samling", + "from_json": "Importér fra Hoppscotch", + "from_json_description": "Importér fra Hoppscotch-samlingsfil", + "from_my_collections": "Importér fra personlige samlinger", + "from_my_collections_description": "Importér fra personlige samlingsfil", + "from_openapi": "Importér fra OpenAPI", + "from_openapi_description": "Importér fra OpenAPI-specifikationsfil (YML/JSON)", + "from_postman": "Importér fra Postman", + "from_postman_description": "Importér fra Postman-samling", + "from_url": "Importér fra URL", + "gist_url": "Indtast Gist-URL", + "gql_collections_from_gist_description": "Importér GraphQL-samlinger fra Gist", + "hoppscotch_environment": "Hoppscotch-miljø", + "hoppscotch_environment_description": "Importér Hoppscotch-miljø JSON-fil", + "import_from_url_invalid_fetch": "Kunne ikke hente data fra URL'en", + "import_from_url_invalid_file_format": "Fejl under import af samlinger", + "import_from_url_invalid_type": "Ikke-understøttet type. Accepterede værdier er 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Samlinger importeret", + "insomnia_environment_description": "Importér Insomnia-miljø fra en JSON/YAML-fil", + "json_description": "Importér samlinger fra en Hoppscotch-samlinger JSON-fil", + "postman_environment": "Postman-miljø", + "postman_environment_description": "Importér Postman-miljø fra en JSON-fil", + "title": "Importér", + "file_size_limit_exceeded_warning_multiple_files": "Valgte filer overstiger den anbefalede grænse på 10MB. Kun de første {files} valgte vil blive importeret", + "file_size_limit_exceeded_warning_single_file": "Den aktuelt valgte fil overstiger den anbefalede grænse på 10MB. Vælg venligst en anden fil.", + "success": "Importeret med succes" + }, + "inspections": { + "description": "Inspicér mulige fejl", + "environment": { + "add_environment": "Tilføj til miljø", + "add_environment_value": "Tilføj værdi", + "empty_value": "Miljøværdi er tom for variablen '{variable}'", + "not_found": "Miljøvariabel \"{environment}\" blev ikke fundet" + }, + "header": { + "cookie": "Browseren tillader ikke Hoppscotch at indstille Cookie-headere. Brug venligst autorisationsheadere i stedet. Vores Hoppscotch Desktop App er dog live nu og understøtter cookies." + }, + "response": { + "401_error": "Tjek venligst dine autentifikationsoplysninger.", + "404_error": "Tjek venligst din anmodnings-URL og metodetype.", + "cors_error": "Tjek venligst din Cross-Origin Resource Sharing-konfiguration.", + "default_error": "Tjek venligst din anmodning.", + "network_error": "Tjek venligst din netværksforbindelse." + }, + "title": "Inspektør", + "url": { + "extension_not_installed": "Udvidelse ikke installeret.", + "extension_unknown_origin": "Sørg for, at du har tilføjet API-endepunktets oprindelse til Hoppscotch Browser Extension-listen.", + "extention_enable_action": "Aktivér browserudvidelse", + "extention_not_enabled": "Udvidelse ikke aktiveret." + } + }, + "layout": { + "collapse_collection": "Skjul eller udvid samlinger", + "collapse_sidebar": "Skjul eller udvid sidebjælken", + "column": "Lodret layout", + "name": "Layout", + "row": "Vandret layout" + }, + "modal": { + "close_unsaved_tab": "Du har ikke-gemte ændringer", + "collections": "Samlinger", + "confirm": "Bekræft", + "customize_request": "Tilpas anmodning", + "edit_request": "Redigér anmodning", + "import_export": "Import / Eksport", + "share_request": "Del anmodning" + }, + "mqtt": { + "already_subscribed": "Du abonnerer allerede på dette emne.", + "clean_session": "Ryd session", + "clear_input": "Ryd input", + "clear_input_on_send": "Ryd input ved afsendelse", + "client_id": "Klient-ID", + "color": "Vælg en farve", + "communication": "Kommunikation", + "connection_config": "Forbindelseskonfiguration", + "connection_not_authorized": "Denne MQTT-forbindelse bruger ingen godkendelse.", + "invalid_topic": "Angiv venligst et emne for abonnementet", + "keep_alive": "Hold i live", + "log": "Log", + "lw_message": "Last-Will besked", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will behold", + "lw_topic": "Last-Will emne", + "message": "Besked", + "new": "Nyt abonnement", + "not_connected": "Start venligst en MQTT-forbindelse først.", + "publish": "Udgiv", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Abonnér", + "topic": "Emne", + "topic_name": "Emnenavn", + "topic_title": "Udgiv / Abonnér på emne", + "unsubscribe": "Afmeld abonnement", + "url": "URL" + }, + "navigation": { + "doc": "Docs", + "graphql": "GraphQL", + "profile": "Profil", + "realtime": "Realtid", + "rest": "REST", + "settings": "Indstillinger" + }, + "preRequest": { + "javascript_code": "JavaScript-kode", + "learn": "Læs dokumentation", + "script": "Pre-Request Script", + "snippets": "Snippets" + }, + "profile": { + "app_settings": "App-indstillinger", + "default_hopp_displayname": "Unavngivet bruger", + "editor": "Redaktør", + "editor_description": "Redaktører kan tilføje, redigere og slette anmodninger.", + "email_verification_mail": "En bekræftelses-e-mail er blevet sendt til din e-mailadresse. Klik venligst på linket for at bekræfte din e-mailadresse.", + "no_permission": "Du har ikke tilladelse til at udføre denne handling.", + "owner": "Ejer", + "owner_description": "Ejere kan tilføje, redigere og slette anmodninger, samlinger og arbejdsområdemedlemmer.", + "roles": "Roller", + "roles_description": "Roller bruges til at kontrollere adgang til de delte samlinger.", + "updated": "Profil opdateret", + "viewer": "Betragter", + "viewer_description": "Betragtere kan kun se og bruge anmodninger." + }, + "remove": { + "star": "Fjern stjerne" + }, + "request": { + "added": "Anmodning tilføjet", + "authorization": "Autorisation", + "body": "Anmodnings meddelelse", + "choose_language": "Vælg sprog", + "content_type": "Indholdstype", + "content_type_titles": { + "others": "Andre", + "structured": "Struktureret", + "text": "Tekst" + }, + "different_collection": "Kan ikke omarrangere anmodninger fra forskellige samlinger", + "duplicated": "Anmodning duplikeret", + "duration": "Varighed", + "enter_curl": "Indtast cURL-kommando", + "generate_code": "Generér kode", + "generated_code": "Genereret kode", + "go_to_authorization_tab": "Gå til autorisationsfanen", + "go_to_body_tab": "Gå til meddelelsefanen", + "header_list": "Header-liste", + "invalid_name": "Angiv venligst et navn til anmodningen", + "method": "Metode", + "moved": "Anmodning flyttet", + "name": "Anmodningsnavn", + "new": "Ny anmodning", + "order_changed": "Anmodningsrækkefølge opdateret", + "override": "Tilsidesæt", + "override_help": "Indstil Content-Type i Headers", + "overriden": "Tilsidesat", + "parameter_list": "Forespørgselsparametre", + "parameters": "Parametre", + "path": "Sti", + "payload": "Nyttelast", + "query": "Forespørgsel", + "raw_body": "Rå anmodnings meddelelse", + "rename": "Omdøb anmodning", + "renamed": "Anmodning omdøbt", + "request_variables": "Anmodningsvariabler", + "run": "Kør", + "save": "Gem", + "save_as": "Gem som", + "saved": "Anmodning gemt", + "share": "Del", + "share_description": "Del Hoppscotch med dine venner", + "share_request": "Del anmodning", + "stop": "Stop", + "title": "Anmodning", + "type": "Anmodningstype", + "url": "URL", + "url_placeholder": "Indtast en URL eller indsæt en cURL-kommando", + "variables": "Variabler", + "view_my_links": "Se mine links", + "generate_name_error": "Kunne ikke generere anmodningsnavn." + }, + "response": { + "audio": "Lyd", + "body": "Svarbesked", + "filter_response_body": "Filtrer JSON-svarbesked (bruger jq-syntaks)", + "headers": "Headers", + "html": "HTML", + "image": "Billede", + "json": "JSON", + "pdf": "PDF", + "preview_html": "Forhåndsvis HTML", + "raw": "Rå", + "size": "Størrelse", + "status": "Status", + "time": "Tid", + "title": "Svar", + "video": "Video", + "waiting_for_connection": "venter på forbindelse", + "xml": "XML" + }, + "settings": { + "accent_color": "Accentfarve", + "account": "Konto", + "account_deleted": "Din konto er blevet slettet", + "account_description": "Tilpas dine kontoindstillinger.", + "account_email_description": "Din primære e-mailadresse.", + "account_name_description": "Dette er dit visningsnavn.", + "additional": "Yderligere indstillinger", + "background": "Baggrund", + "black_mode": "Sort", + "choose_language": "Vælg sprog", + "dark_mode": "Mørk", + "delete_account": "Slet konto", + "delete_account_description": "Når du sletter din konto, vil alle dine data blive permanent slettet. Denne handling kan ikke fortrydes.", + "expand_navigation": "Udvid navigation", + "experiments": "Eksperimenter", + "experiments_notice": "Dette er en samling af eksperimenter, vi arbejder på, som måske viser sig at være nyttige, sjove, begge dele eller ingen af delene. De er ikke endelige og er måske ikke stabile, så hvis noget overdrevent mærkeligt sker, så gå ikke i panik. Bare sluk for det. Spøg til side, ", + "extension_ver_not_reported": "Ikke rapporteret", + "extension_version": "Udvidelses version", + "extensions": "Browserudvidelse", + "extensions_use_toggle": "Brug browserudvidelsen til at sende anmodninger (hvis til stede)", + "follow": "Følg os", + "interceptor": "Interceptor", + "interceptor_description": "Middleware mellem applikation og API'er.", + "language": "Sprog", + "light_mode": "Lys", + "official_proxy_hosting": "Officiel Proxy hostes af Hoppscotch.", + "profile": "Profil", + "profile_description": "Opdater dine profildetaljer", + "profile_email": "E-mailadresse", + "profile_name": "Profilnavn", + "proxy": "Proxy", + "proxy_url": "Proxy URL", + "proxy_use_toggle": "Brug proxy-middleware til at sende anmodninger", + "read_the": "Læs", + "reset_default": "Nulstil til standard", + "short_codes": "Korte koder", + "short_codes_description": "Korte koder, som blev oprettet af dig.", + "sidebar_on_left": "Sidebjælke til venstre", + "ai_experiments": "AI-eksperimenter", + "sync": "Synkroniser", + "sync_collections": "Samlinger", + "sync_description": "Disse indstillinger synkroniseres til skyen.", + "sync_environments": "Miljøer", + "sync_history": "Historik", + "system_mode": "System", + "telemetry": "Telemetri", + "telemetry_helps_us": "Telemetri hjælper os med at personliggøre vores operationer og levere den bedste oplevelse til dig.", + "theme": "Tema", + "theme_description": "Tilpas dit applikationstema.", + "use_experimental_url_bar": "Brug eksperimentel URL-bar med miljøfremhævning", + "user": "Bruger", + "verified_email": "Bekræftet e-mail", + "verify_email": "Bekræft e-mail" + }, + "shared_requests": { + "button": "Knap", + "button_info": "Opret en 'Kør i Hoppscotch' knap til dit websted, blog eller en README.", + "copy_html": "Kopiér HTML", + "copy_link": "Kopiér link", + "copy_markdown": "Kopiér Markdown", + "creating_widget": "Opretter widget", + "customize": "Tilpas", + "deleted": "Delt anmodning slettet", + "description": "Vælg en widget, du kan ændre og tilpasse dette senere", + "embed": "Indlejr", + "embed_info": "Tilføj en mini 'Hoppscotch API Playground' til dit websted, blog eller dokumentation.", + "link": "Link", + "link_info": "Opret et delbart link til at dele med hvem som helst på internettet med visningsadgang.", + "modified": "Delt anmodning ændret", + "not_found": "Delt anmodning ikke fundet", + "open_new_tab": "Åbn i ny fane", + "preview": "Forhåndsvis", + "run_in_hoppscotch": "Kør i Hoppscotch", + "theme": { + "dark": "Mørk", + "light": "Lys", + "system": "System", + "title": "Tema" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Luk nuværende menu", + "command_menu": "Søg & kommandomenu", + "help_menu": "Hjælpemenu", + "show_all": "Tastaturgenveje", + "title": "Generelt" + }, + "miscellaneous": { + "invite": "Invitér folk til Hoppscotch", + "title": "Diverse" + }, + "navigation": { + "back": "Gå tilbage til forrige side", + "documentation": "Gå til dokumentationssiden", + "forward": "Gå frem til næste side", + "graphql": "Gå til GraphQL-siden", + "profile": "Gå til profilsiden", + "realtime": "Gå til realtidssiden", + "rest": "Gå til REST-siden", + "settings": "Gå til indstillingssiden", + "title": "Navigation" + }, + "others": { + "prettify": "Forskøn editorens indhold", + "title": "Andre" + }, + "request": { + "delete_method": "Vælg DELETE-metode", + "get_method": "Vælg GET-metode", + "head_method": "Vælg HEAD-metode", + "import_curl": "Importér cURL", + "method": "Metode", + "next_method": "Vælg næste metode", + "post_method": "Vælg POST-metode", + "previous_method": "Vælg forrige metode", + "put_method": "Vælg PUT-metode", + "rename": "Omdøb anmodning", + "reset_request": "Nulstil anmodning", + "save_request": "Gem anmodning", + "save_to_collections": "Gem til samlinger", + "send_request": "Send anmodning", + "share_request": "Del anmodning", + "show_code": "Generér kodesnippet", + "title": "Anmodning" + }, + "response": { + "copy": "Kopiér svar til udklipsholder", + "download": "Download svar som fil", + "title": "Svar" + }, + "theme": { + "black": "Skift tema til sort tilstand", + "dark": "Skift tema til mørk tilstand", + "light": "Skift tema til lys tilstand", + "system": "Skift tema til systemtilstand", + "title": "Tema" + } + }, + "show": { + "code": "Vis kode", + "collection": "Udvid samlingspanel", + "more": "Vis mere", + "sidebar": "Udvid sidebjælke" + }, + "socketio": { + "communication": "Kommunikation", + "connection_not_authorized": "Denne SocketIO-forbindelse bruger ingen godkendelse.", + "event_name": "Begivenheds-/emnenavn", + "events": "Begivenheder", + "log": "Log", + "url": "URL" + }, + "spotlight": { + "change_language": "Skift sprog", + "environments": { + "delete": "Slet nuværende miljø", + "duplicate": "Duplikér nuværende miljø", + "duplicate_global": "Duplikér globalt miljø", + "edit": "Redigér nuværende miljø", + "edit_global": "Redigér globalt miljø", + "new": "Opret nyt miljø", + "new_variable": "Opret en ny miljøvariabel", + "title": "Miljøer" + }, + "general": { + "chat": "Chat med support", + "help_menu": "Hjælp og support", + "open_docs": "Læs dokumentation", + "open_github": "Åbn GitHub-repository", + "open_keybindings": "Tastaturgenveje", + "social": "Social", + "title": "Generelt" + }, + "graphql": { + "connect": "Forbind til server", + "disconnect": "Afbryd forbindelse fra server" + }, + "miscellaneous": { + "invite": "Invitér dine venner til Hoppscotch", + "title": "Diverse" + }, + "request": { + "save_as_new": "Gem som ny anmodning", + "select_method": "Vælg metode", + "switch_to": "Skift til", + "tab_authorization": "Autorisationsfane", + "tab_body": "Meddelelsefane", + "tab_headers": "Headers-fane", + "tab_parameters": "Parameterfane", + "tab_pre_request_script": "Pre-request script-fane", + "tab_query": "Forespørgselsfane", + "tab_tests": "Testfane", + "tab_variables": "Variabelfane" + }, + "response": { + "copy": "Kopiér svar", + "download": "Download svar som fil", + "title": "Svar" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Grænseflade", + "theme": "Tema", + "user": "Bruger" + }, + "settings": { + "change_interceptor": "Skift interceptor", + "change_language": "Skift sprog", + "theme": { + "black": "Sort", + "dark": "Mørk", + "light": "Lys", + "system": "Systemindstilling" + } + }, + "tab": { + "close_current": "Luk nuværende fane", + "close_others": "Luk alle andre faner", + "duplicate": "Duplikér nuværende fane", + "new_tab": "Åbn en ny fane", + "title": "Faner" + }, + "workspace": { + "delete": "Slet nuværende arbejdsområde", + "edit": "Redigér nuværende arbejdsområde", + "invite": "Invitér folk til arbejdsområde", + "new": "Opret nyt arbejdsområde", + "switch_to_personal": "Skift til dit personlige arbejdsområde", + "title": "Arbejdsområder" + }, + "phrases": { + "try": "Prøv", + "import_collections": "Importér samlinger", + "create_environment": "Opret miljø", + "create_workspace": "Opret arbejdsområde", + "share_request": "Del anmodning" + } + }, + "sse": { + "event_type": "Begivenhedstype", + "log": "Log", + "url": "URL" + }, + "state": { + "bulk_mode": "Masseredigering", + "bulk_mode_placeholder": "Poster er adskilt af linjeskift\nNøgler og værdier er adskilt af :\nIndled med # for enhver række, du ønsker at tilføje, men holde deaktiveret", + "cleared": "Ryddet", + "connected": "Forbundet", + "connected_to": "Forbundet til {name}", + "connecting_to": "Forbinder til {name}...", + "connection_error": "Kunne ikke oprette forbindelse", + "connection_failed": "Forbindelse mislykkedes", + "connection_lost": "Forbindelse tabt", + "copied_interface_to_clipboard": "Kopierede {language} interfacetype til udklipsholder", + "copied_to_clipboard": "Kopieret til udklipsholder", + "deleted": "Slettet", + "deprecated": "FORÆLDET", + "disabled": "Deaktiveret", + "disconnected": "Afbrudt", + "disconnected_from": "Afbrudt fra {name}", + "docs_generated": "Dokumentation genereret", + "download_failed": "Download mislykkedes", + "download_started": "Download startet", + "enabled": "Aktiveret", + "file_imported": "Fil importeret", + "finished_in": "Afsluttet på {duration} ms", + "hide": "Skjul", + "history_deleted": "Historik slettet", + "linewrap": "Ombryd linjer", + "loading": "Indlæser...", + "message_received": "Besked: {message} ankom på emne: {topic}", + "mqtt_subscription_failed": "Noget gik galt under abonnement på emne: {topic}", + "none": "Ingen", + "nothing_found": "Intet fundet for", + "published_error": "Noget gik galt under udgivelse af besked: {topic} til emne: {message}", + "published_message": "Udgav besked: {message} til emne: {topic}", + "reconnection_error": "Kunne ikke genetablere forbindelse", + "show": "Vis", + "subscribed_failed": "Kunne ikke abonnere på emne: {topic}", + "subscribed_success": "Abonneret på emne: {topic} med succes", + "unsubscribed_failed": "Kunne ikke afmelde abonnement fra emne: {topic}", + "unsubscribed_success": "Afmeldt abonnement fra emne: {topic} med succes", + "waiting_send_request": "Venter på at sende anmodning" + }, + "support": { + "changelog": "Læs mere om de seneste udgivelser", + "chat": "Spørgsmål? Chat med os!", + "community": "Stil spørgsmål og hjælp andre", + "documentation": "Læs mere om Hoppscotch", + "forum": "Stil spørgsmål og få svar", + "github": "Følg os på Github", + "shortcuts": "Naviger i appen hurtigere", + "title": "Support", + "twitter": "Følg os på Twitter" + }, + "tab": { + "authorization": "Autorisation", + "body": "Meddelelse", + "close": "Luk fane", + "close_others": "Luk andre faner", + "collections": "Samlinger", + "documentation": "Dokumentation", + "duplicate": "Duplikér fane", + "environments": "Miljøer", + "headers": "Headers", + "history": "Historik", + "mqtt": "MQTT", + "parameters": "Parametre", + "pre_request_script": "Pre-request script", + "queries": "Forespørgsler", + "query": "Forespørgsel", + "schema": "Skema", + "shared_requests": "Delte anmodninger", + "codegen": "Generér kode", + "code_snippet": "Kodesnippet", + "share_tab_request": "Del fanens anmodning", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Tests", + "types": "Typer", + "variables": "Variabler", + "websocket": "WebSocket" + }, + "team": { + "already_member": "Du er allerede medlem af dette arbejdsområde. Kontakt din arbejdsområdeejer.", + "create_new": "Opret nyt arbejdsområde", + "deleted": "Arbejdsområde slettet", + "edit": "Redigér arbejdsområde", + "email": "E-mail", + "email_do_not_match": "E-mail matcher ikke dine kontooplysninger. Kontakt din arbejdsområdeejer.", + "exit": "Forlad arbejdsområde", + "exit_disabled": "Kun ejer kan ikke forlade arbejdsområdet", + "failed_invites": "Mislykkede invitationer", + "invalid_coll_id": "Ugyldigt samlings-ID", + "invalid_email_format": "E-mailformat er ugyldigt", + "invalid_id": "Ugyldigt arbejdsområde-ID. Kontakt din arbejdsområdeejer.", + "invalid_invite_link": "Ugyldigt invitationslink", + "invalid_invite_link_description": "Linket, du fulgte, er ugyldigt. Kontakt din arbejdsområdeejer.", + "invalid_member_permission": "Angiv venligst en gyldig tilladelse til arbejdsområdemedlemmet", + "invite": "Invitér", + "invite_more": "Invitér flere", + "invite_tooltip": "Invitér folk til dette arbejdsområde", + "invited_to_team": "{owner} inviterede dig til at tilslutte dig {workspace}", + "join": "Invitation accepteret", + "join_team": "Tilslut {workspace}", + "joined_team": "Du har tilsluttet dig {workspace}", + "joined_team_description": "Du er nu medlem af dette arbejdsområde", + "left": "Du forlod arbejdsområdet", + "login_to_continue": "Log ind for at fortsætte", + "login_to_continue_description": "Du skal være logget ind for at tilslutte dig et arbejdsområde.", + "logout_and_try_again": "Log ud og log ind med en anden konto", + "member_has_invite": "Denne e-mail-ID har allerede en invitation. Kontakt din arbejdsområdeejer.", + "member_not_found": "Medlem ikke fundet. Kontakt din arbejdsområdeejer.", + "member_removed": "Bruger fjernet", + "member_role_updated": "Brugerroller opdateret", + "members": "Medlemmer", + "more_members": "+{count} flere", + "name_length_insufficient": "Arbejdsområdenavn skal være mindst 6 tegn langt", + "name_updated": "Arbejdsområdenavn opdateret", + "new": "Nyt arbejdsområde", + "new_created": "Nyt arbejdsområde oprettet", + "new_name": "Mit nye arbejdsområde", + "no_access": "Du har ikke redigeringsadgang til dette arbejdsområde", + "no_invite_found": "Invitation ikke fundet. Kontakt din arbejdsområdeejer.", + "no_request_found": "Anmodning ikke fundet.", + "not_found": "Arbejdsområde ikke fundet. Kontakt din arbejdsområdeejer.", + "not_valid_viewer": "Du er ikke en gyldig betragter. Kontakt din arbejdsområdeejer.", + "parent_coll_move": "Kan ikke flytte samling til en undersamling", + "pending_invites": "Ventende invitationer", + "permissions": "Tilladelser", + "same_target_destination": "Samme mål og destination", + "saved": "Arbejdsområde gemt", + "select_a_team": "Vælg et arbejdsområde", + "success_invites": "Vellykkede invitationer", + "title": "Arbejdsområder", + "we_sent_invite_link": "Vi sendte et invitationslink til alle inviterede!", + "invite_sent_smtp_disabled": "Invitationslinks genereret", + "we_sent_invite_link_description": "Bed alle inviterede om at tjekke deres indbakke. Klik på linket for at tilslutte sig arbejdsområdet.", + "invite_sent_smtp_disabled_description": "Afsendelse af invitations-e-mails er deaktiveret for denne instans af Hoppscotch. Brug venligst knappen Kopiér link til at kopiere og dele invitationslinket manuelt.", + "copy_invite_link": "Kopiér invitationslink", + "search_title": "Teamanmodninger" + }, + "team_environment": { + "deleted": "Miljø slettet", + "duplicate": "Miljø duplikeret", + "not_found": "Miljø ikke fundet." + }, + "test": { + "failed": "test mislykkedes", + "javascript_code": "JavaScript-kode", + "learn": "Læs dokumentation", + "passed": "test bestået", + "report": "Testrapport", + "results": "Testresultater", + "script": "Script", + "snippets": "Snippets" + }, + "websocket": { + "communication": "Kommunikation", + "log": "Log", + "message": "Besked", + "protocols": "Protokoller", + "url": "URL" + }, + "workspace": { + "change": "Skift arbejdsområde", + "personal": "Personligt arbejdsområde", + "other_workspaces": "Mine arbejdsområder", + "team": "Arbejdsområde", + "title": "Arbejdsområder" + }, + "site_protection": { + "login_to_continue": "Log ind for at fortsætte", + "login_to_continue_description": "Du skal være logget ind for at få adgang til denne Hoppscotch Enterprise-instans.", + "error_fetching_site_protection_status": "Noget gik galt under hentning af sitebeskyttelsesstatus" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personlige adgangstokens", + "section_description": "Personlige adgangstokens hjælper dig i øjeblikket med at forbinde CLI'en til din Hoppscotch-konto", + "last_used_on": "Sidst brugt den", + "expires_on": "Udløber den", + "no_expiration": "Ingen udløbsdato", + "expired": "Udløbet", + "copy_token_warning": "Sørg for at kopiere dit personlige adgangstoken nu. Du vil ikke kunne se det igen!", + "token_purpose": "Hvad er dette token til?", + "expiration_label": "Udløb", + "scope_label": "Omfang", + "workspace_read_only_access": "Skrivebeskyttet adgang til arbejdsområdedata.", + "personal_workspace_access_limitation": "Personlige adgangstokens kan ikke få adgang til dit personlige arbejdsområde.", + "generate_token": "Generér token", + "invalid_label": "Angiv venligst en etiket for tokenet", + "no_expiration_verbose": "Dette token vil aldrig udløbe!", + "token_expires_on": "Dette token udløber den", + "generate_new_token": "Generér nyt token", + "generate_modal_title": "Nyt personligt adgangstoken", + "deletion_success": "Adgangstokenet {label} er blevet slettet" + }, + "collection_runner": { + "collection_id": "Samlings-ID", + "environment_id": "Miljø-ID", + "cli_collection_id_description": "Dette samlings-ID vil blive brugt af CLI-samlingsløberen for Hoppscotch.", + "cli_environment_id_description": "Dette miljø-ID vil blive brugt af CLI-samlingsløberen for Hoppscotch.", + "include_active_environment": "Inkludér aktivt miljø:", + "cli": "CLI", + "ui": "Løber (kommer snart)", + "cli_command_generation_description_cloud": "Kopiér nedenstående kommando og kør den fra CLI'en. Angiv venligst et personligt adgangstoken.", + "cli_command_generation_description_sh": "Kopiér nedenstående kommando og kør den fra CLI'en. Angiv venligst et personligt adgangstoken og verificér den genererede SH-instans serveradresse.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Kopiér nedenstående kommando og kør den fra CLI'en. Angiv venligst et personligt adgangstoken og SH-instansens serveradresse.", + "run_collection": "Kør samling" + }, + "ai_experiments": { + "generate_request_name": "Generér anmodningsnavn ved hjælp af AI", + "generate_or_modify_request_body": "Generér eller modificér anmodning meddelelse" + } +} diff --git a/packages/hoppscotch-common/locales/de.json b/packages/hoppscotch-common/locales/de.json new file mode 100644 index 0000000..e1f5dcc --- /dev/null +++ b/packages/hoppscotch-common/locales/de.json @@ -0,0 +1,1221 @@ +{ + "action": { + "add": "Hinzufügen", + "autoscroll": "Automatisch Scrollen", + "cancel": "Abbrechen", + "choose_file": "Datei auswählen", + "choose_workspace": "Arbeitsbereich auswählen", + "choose_collection": "Sammlung auswählen", + "select_workspace": "Arbeitsbereich auswählen", + "clear": "Zurücksetzen", + "clear_all": "Alles zurücksetzen", + "clear_cache": "Cache leeren", + "clear_history": "Alle Verlaufsdaten löschen", + "close": "Schließen", + "confirm": "Bestätigen", + "connect": "Verbinden", + "connecting": "Verbinde", + "copy": "Kopieren", + "create": "Erstellen", + "delete": "Löschen", + "disconnect": "Trennen", + "dismiss": "Verwerfen", + "done": "Fertig", + "dont_save": "Nicht speichern", + "download_file": "Datei herunterladen", + "download_test_report": "Testbericht herunterladen", + "drag_to_reorder": "Ziehen zum Umordnen", + "duplicate": "Duplizieren", + "edit": "Bearbeiten", + "filter": "Filtern", + "go_back": "Zurück", + "go_forward": "Weiter", + "group_by": "Gruppiere nach", + "hide_secret": "Geheimnis verbergen", + "label": "Bezeichnung", + "learn_more": "Mehr erfahren", + "download_here": "Hier herunterladen", + "less": "Weniger", + "more": "Mehr", + "new": "Neu", + "no": "Nein", + "open_workspace": "Arbeitsbereich öffnen", + "paste": "Einfügen", + "prettify": "Verschönern", + "properties": "Eigenschaften", + "register": "Registrieren", + "remove": "Entfernen", + "remove_instance": "Instanz entfernen", + "rename": "Umbenennen", + "restore": "Wiederherstellen", + "retry": "Wiederholen", + "save": "Speichern", + "save_as_example": "Als Beispiel speichern", + "scroll_to_bottom": "Zum Ende scrollen", + "scroll_to_top": "Zum Anfang scrollen", + "search": "Suchen", + "send": "Senden", + "share": "Teilen", + "show_secret": "Geheimnis anzeigen", + "start": "Starten", + "starting": "Startet", + "stop": "Stopp", + "to_close": "zum Schließen", + "to_navigate": "zum Navigieren", + "to_select": "zum Auswählen", + "turn_off": "Ausschalten", + "turn_on": "Einschalten", + "undo": "Rückgängig machen", + "verify": "Überprüfen", + "yes": "Ja", + "enable": "Aktivieren", + "disable": "Deaktivieren", + "assign": "Zuweisen" + }, + "activity_logs": { + "ACTIVITY_LOG_DELETE": "Aktivitätsprotokoll gelöscht", + "WORKSPACE_CREATE": "Arbeitsbereich {title} erstellt", + "WORKSPACE_RENAME": "Arbeitsbereich von {old_name} zu {new_name} umbenannt", + "WORKSPACE_USER_ADD": "{user} wurde zum Arbeitsbereich als {role} hinzugefügt", + "WORKSPACE_USER_INVITE": "{user} wurde von {inviteeEmail} als {role} eingeladen", + "WORKSPACE_USER_INVITE_REVOKE": "Einladung von {inviteeEmail} als {inviteeRole} wurde zurückgezogen", + "WORKSPACE_USER_INVITE_ACCEPT": "{inviteeEmail} hat die Einladung als {inviteeRole} angenommen", + "WORKSPACE_USER_REMOVE": "{user} wurde vom Arbeitsbereich entfernt", + "WORKSPACE_USER_ROLE_UPDATE": "{user}s Rolle wurde von {old_role} zu {new_role} aktualisiert", + "COLLECTION_CREATE": "Sammlung {title} erstellt", + "COLLECTION_RENAME": "Sammlung von {old_title} zu {new_title} umbenannt", + "COLLECTION_IMPORT": "{count} Sammlung(en) importiert", + "COLLECTION_DELETE": "Sammlung {title} gelöscht", + "COLLECTION_DUPLICATE": "Sammlung {parentTitle} dupliziert", + "REQUEST_CREATE": "Anfrage {title} erstellt", + "REQUEST_RENAME": "Anfrage von {old_title} zu {new_title} umbenannt", + "REQUEST_DELETE": "Anfrage {title} gelöscht" + }, + "add": { + "new": "Neue hinzufügen", + "star": "Stern hinzufügen" + }, + "agent": { + "registration_instruction": "Bitte registrieren Sie Hoppscotch Agent mit Ihrem Webclient, um fortzufahren.", + "enter_otp_instruction": "Bitte geben Sie den Verifizierungscode ein, der vom Hoppscotch Agent generiert wurde, und schließen Sie die Registrierung ab.", + "otp_label": "Verifizierungscode", + "processing": "Verarbeite die Anfrage...", + "not_running": "Der Hoppscotch Agent läuft nicht. Bitte starten Sie den Agenten und klicken Sie auf 'Wiederholen'.", + "not_running_title": "Agent nicht erkannt", + "registration_title": "Agent registrierung", + "verify_ssl_certs": "SSL-Zertifikate überprüfen", + "client_certs": "Client-Zertifikate", + "use_http_proxy": "HTTP-Proxy verwenden", + "proxy_capabilities": "Hoppscotch Agent unterstützt HTTP/HTTPS/SOCKS-Proxys sowie NTLM und Basic Auth für Proxys. Geben Sie den Benutzernamen und das Passwort für die Proxy-Authentifizierung in der URL an.", + "add_cert_file": "Zertifikatdatei hinzufügen", + "add_client_cert": "Client-Zertifikat hinzufügen", + "add_key_file": "Schlüssel Datei hinzufügen", + "domain": "Domain", + "cert": "Zertifikat", + "key": "Schlüssel", + "pfx_or_pkcs": "PFX/PKCS12", + "pfx_or_pkcs_file": "PFX/PKCS12-Datei", + "add_pfx_or_pkcs_file": "PFX/PKCS12-Datei hinzufügen" + }, + "app": { + "additional_links": { + "macOS": "macOS", + "windows": "Windows", + "linux": "Linux", + "web_app": "Web App", + "cli": "CLI" + }, + "chat_with_us": "Chatte mit uns", + "contact_us": "Kontaktiere uns", + "cookies": "Cookies", + "copy": "Kopieren", + "copy_interface_type": "Schnittstellentyp kopieren", + "copy_user_id": "Kopiere Nutzer-Authentisierungstoken", + "developer_option": "Entwickleroptionen", + "developer_option_description": "Entwicklungswerkzeuge, die helfen Hoppscotch weiter zu entwickeln und zu warten.", + "discord": "Discord", + "documentation": "Dokumentation", + "github": "GitHub", + "help": "Hilfe, Feedback und Dokumentation", + "home": "Home", + "invite": "Einladen", + "invite_description": "In Hoppscotch haben wir eine einfache und intuitive Benutzeroberfläche zum Erstellen und Verwalten Deiner APIs entwickelt. Hoppscotch ist ein Tool, mit dem Du Deine APIs erstellen, testen, dokumentieren und teilen kannst.", + "invite_your_friends": "Lade Deine Freunde ein", + "join_discord_community": "Tritt unserer Discord-Community bei", + "keyboard_shortcuts": "Tastaturkürzel", + "name": "Hoppscotch", + "new_version_found": "Neue Version gefunden. Zum Aktualisieren Seite neu laden.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Options", + "proxy_privacy_policy": "Proxy-Datenschutzrichtlinie", + "reload": "Neu laden", + "search": "Suche", + "share": "Teilen", + "shortcuts": "Verknüpfungen", + "social_description": "Folge uns in den sozialen Medien für die neuesten Nachrichten und Updates", + "social_links": "Social links", + "spotlight": "Scheinwerfer", + "status": "Status", + "status_description": "Überprüfe den Status der Webseite", + "terms_and_privacy": "AGB und Datenschutzerklärung", + "twitter": "Twitter", + "type_a_command_search": "Gib einen Befehl ein oder suche…", + "we_use_cookies": "Wir verwenden Cookies", + "updated_text": "Hoppscotch wurde auf die Version v{version} aktualisiert 🎉", + "whats_new": "Was gibt's Neues?", + "see_whats_new": "Sieh dir an, was neu ist", + "wiki": "Wiki", + "collapse_sidebar": "Seitenleiste einklappen", + "continue_to_dashboard": "Weiter zum Dashboard", + "expand_sidebar": "Seitenleiste ausklappen", + "no_name": "Kein Name", + "open_navigation": "Navigation öffnen", + "read_documentation": "Dokumentation öffnen", + "default": "standard: {value}" + }, + "auth": { + "account_deactivated": "Dein Konto wurde deaktiviert. Bitte kontaktiere einen Admin, um es wieder zu aktivieren.", + "account_exists": "Konto existiert mit unterschiedlichen Zugangsdaten - Melde Dich an, um beide Konten zu verknüpfen", + "all_sign_in_options": "Alle Anmeldeoptionen", + "continue_with_auth_provider": "Fortfahren mit {provider}", + "continue_with_email": "Mit E-Mail anmelden", + "continue_with_github": "Mit GitHub anmelden", + "continue_with_github_enterprise": "Fortfahren mit GitHub Enterprise", + "continue_with_google": "Mit Google anmelden", + "continue_with_microsoft": "Mit Microsoft anmelden", + "email": "E-Mail-Adresse", + "logged_out": "Abgemeldet", + "login": "Anmelden", + "login_success": "Erfolgreich eingeloggt", + "login_to_hoppscotch": "Anmelden bei Hoppscotch", + "logout": "Ausloggen", + "re_enter_email": "E-Mail erneut eingeben", + "send_magic_link": "Magischen Link schicken", + "sync": "Synchronisieren", + "we_sent_magic_link": "Wir haben dir einen magischen Link geschickt!", + "we_sent_magic_link_description": "Überprüfe Deinen Posteingang - wir haben eine E-Mail an {email} gesendet. Es enthält einen magischen Link, der Dich einloggt." + }, + "authorization": { + "generate_token": "Token generieren", + "refresh_token": "Aktualisierungs Token", + "graphql_headers": "Autorisierungsheader werden als Teil des Payloads an connection_init gesendet", + "include_in_url": "In URL einbinden", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "Dokumentation", + "oauth": { + "redirect_auth_server_returned_error": "Der Auth-Server hat einen Fehlerstatus zurückgegeben.", + "redirect_auth_token_request_failed": "Anforderung des Auth-Token ist fehlgeschlagen.", + "redirect_auth_token_request_invalid_response": "Ungültige Antwort vom Token-Endpunkt bei der Anforderung eines Auth-Tokens.", + "redirect_invalid_state": "Ungültiger Statuswert im Redirect vorhanden.", + "redirect_no_auth_code": "Kein Autorisierungscode im Redirect vorhanden.", + "redirect_no_client_id": "Keine Client-ID definiert.", + "redirect_no_client_secret": "Kein Client-Geheimnis definiert.", + "redirect_no_code_verifier": "Kein Code-Verifier definiert.", + "redirect_no_token_endpoint": "Kein Token-Endpunkt definiert.", + "something_went_wrong_on_oauth_redirect": "Beim OAuth-Redirect ist etwas schiefgegangen.", + "something_went_wrong_on_token_generation": "Beim Generieren des Tokens ist etwas schiefgegangen.", + "token_generation_oidc_discovery_failed": "Fehler bei der Token-Generierung: OpenID Connect Discovery fehlgeschlagen.", + "grant_type": "Grant-Typ", + "grant_type_auth_code": "Autorisierungscode", + "token_fetched_successfully": "Token erfolgreich abgerufen.", + "token_fetch_failed": "Abrufen des Tokens fehlgeschlagen.", + "validation_failed": "Validierung fehlgeschlagen, bitte überprüfen Sie die Formularfelder.", + "no_refresh_token_present": "Kein Aktualisierungs-Token vorhanden. Bitte führen Sie den Token-Generierungsprozess erneut aus", + "refresh_token_request_failed": "Aktualisierungs-Token-Anforderung fehlgeschlagen", + "token_refreshed_successfully": "Token erfolgreich aktualisiert", + "label_authorization_endpoint": "Autorisierungsendpunkt", + "label_client_id": "Client-ID", + "label_client_secret": "Client-Geheimnis", + "label_code_challenge": "Code-Challenge", + "label_code_challenge_method": "Code-Challenge-Methode", + "label_code_verifier": "Code-Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token-Endpunkt", + "label_use_pkce": "PKCE verwenden", + "label_implicit": "Implizit", + "label_password": "Passwort", + "label_username": "Benutzername", + "label_auth_code": "Autorisierungscode", + "label_client_credentials": "Client-Anmeldeinformationen" + }, + "pass_key_by": "Übertragungsart", + "pass_by_query_params_label": "Abfrageparameter", + "pass_by_headers_label": "Headers", + "password": "Passwort", + "save_to_inherit": "Bitte speichere diese Anfrage in einer Sammlung, um die Autorisierung zu erben", + "token": "Token", + "type": "Autorisierungsverfahren", + "username": "Nutzername", + "aws_signature": { + "access_key": "Zugangs-Schlüssel", + "secret_key": "Geheimer Schlüssel", + "service_name": "Dienstname", + "aws_region": "AWS-Region", + "service_token": "Dienst-Token", + "advance_config": "Erweiterte Konfiguration", + "advance_config_description": "Hoppscotch weist automatisch Standardwerte für bestimmte Felder zu, wenn kein expliziter Wert angegeben wird." + } + }, + "collection": { + "created": "Sammlung erstellt", + "different_parent": "Sammlung kann nicht neu geordnet werden, weil sie ein anderes übergeordnetes Element hat", + "edit": "Sammlung bearbeiten", + "import_or_create": "Sammlung erstellen oder importieren", + "import_collection": "Sammlung importieren", + "invalid_name": "Bitte gib einen gültigen Namen für die Sammlung an", + "invalid_root_move": "Sammlung bereits im Stammverzeichnis", + "moved": "Erfolgreich verschoben", + "my_collections": "Meine Sammlungen", + "name": "Meine neue Sammlung", + "name_length_insufficient": "Sammlungsname muss mindestens 3 Zeichen lang sein", + "new": "Neue Sammlung", + "order_changed": "Reihenfolge der Sammlung aktualisiert", + "properties": "Eigenschaften der Sammlung", + "properties_updated": "Eigenschaften der Sammlung aktualisiert", + "renamed": "Sammlung umbenannt", + "request_in_use": "Anfrage wird ausgeführt", + "save_as": "Speichern als", + "save_to_collection": "In Sammlung speichern", + "select": "Wähle eine Sammlung", + "select_location": "Ort auswählen", + "details": "Details", + "duplicated": "Sammlung dupliziert" + }, + "confirm": { + "close_unsaved_tab": "Bist du sicher, dass du dieses Tab schließen möchtest?", + "close_unsaved_tabs": "Bist du sicher, dass du alle Tabs schließen möchtest? {count} nicht gespeicherte Tabs gehen verloren.", + "exit_team": "Möchtest Du dieses Team wirklich verlassen?", + "logout": "Möchtest Du Dich wirklich abmelden?", + "remove_collection": "Möchtest Du diese Sammlung wirklich endgültig löschen?", + "remove_environment": "Möchtest Du diese Umgebung wirklich dauerhaft löschen?", + "remove_folder": "Möchtest Du diesen Ordner wirklich dauerhaft löschen?", + "remove_history": "Möchtest Du wirklich den gesamten Verlauf dauerhaft löschen?", + "remove_request": "Möchtest Du diese Anfrage wirklich dauerhaft löschen?", + "remove_response": "Möchtest Du diese Antwort wirklich dauerhaft löschen?", + "remove_shared_request": "Möchtest Du diese geteilte Anfrage wirklich dauerhaft löschen?", + "remove_team": "Möchtest Du dieses Team wirklich löschen?", + "remove_telemetry": "Möchtest Du die Telemetrie wirklich deaktivieren?", + "request_change": "Möchtest Du diese Anfrage verwerfen? Ungespeicherte Änderungen gehen verloren.", + "save_unsaved_tab": "Möchtest du die Änderungen aus diesem Tab speichern?", + "sync": "Möchtest Du diesen Arbeitsbereich wirklich synchronisieren?", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Zu Parametern hinzufügen", + "open_request_in_new_tab": "Anfrage in neuem Tab öffnen", + "set_environment_variable": "Als Variable setzen" + }, + "cookies": { + "modal": { + "cookie_expires": "Läuft ab", + "cookie_name": "Name", + "cookie_path": "Pfad", + "cookie_string": "Cookie-String", + "cookie_value": "Wert", + "empty_domain": "Domain ist leer", + "empty_domains": "Domainliste ist leer", + "enter_cookie_string": "Cookie-String eingeben", + "interceptor_no_support": "Der derzeit ausgewählte Interceptor unterstützt keine Cookies. Wählen Sie einen anderen Interceptor und versuchen Sie es erneut.", + "managed_tab": "Verwaltet", + "new_domain_name": "Neuer Domainname", + "no_cookies_in_domain": "Es sind keine Cookies für diese Domain gesetzt", + "raw_tab": "Raw", + "set": "Ein Cookie setzen" + } + }, + "count": { + "header": "Header {count}", + "message": "Nachricht {count}", + "parameter": "Parameter {count}", + "key": "Schlüssel {count}", + "description": "Beschreibung {count}", + "protocol": "Protokoll {count}", + "value": "Wert {count}", + "variable": "Variable {count}" + }, + "documentation": { + "generate": "Dokumentation erstellen", + "generate_message": "Importiere eine beliebige Hoppscotch-Sammlung, um unterwegs API-Dokumentation zu generieren." + }, + "empty": { + "authorization": "Diese Anfrage verwendet keine Autorisierung", + "body": "Diese Anfrage hat keinen Körper", + "collection": "Sammlung ist leer", + "collections": "Sammlungen sind leer", + "documentation": "Verbinde Dich zu einem GraphQL-Endpunkt, um die API-Dokumentation zu sehen", + "endpoint": "Endpunkt kann nicht leer sein", + "environments": "Umgebungen sind leer", + "folder": "Der Ordner ist leer", + "headers": "Diese Anfrage hat keinen Header", + "history": "Verlauf ist leer", + "invites": "Einladungsliste ist leer", + "members": "Team ist leer", + "parameters": "Diese Anfrage hat keine Parameter", + "pending_invites": "Es gibt keine ausstehenden Einladungen für dieses Team", + "profile": "Einloggen um das Profil anzusehen", + "protocols": "Protokolle sind leer", + "request_variables": "Diese Anfrage hat keine Anfragevariablen", + "schema": "Verbinden mit einem GraphQL-Endpunkt", + "secret_environments": "Geheimnisse werden nicht mit dem Hoppscotch Server synchronisiert", + "shared_requests": "Keine geteilten Abfragen", + "shared_requests_logout": "Melde dich an, um geteilte Anfragen zu sehen oder eine neue zu erstellen", + "subscription": "Keine Abonnements", + "team_name": "Teamname leer", + "teams": "Keine Teams", + "tests": "Es gibt keine Tests für diese Anfrage", + "access_tokens": "Zugriffs-Token sind leer", + "response": "Keine Antwort erhalten" + }, + "environment": { + "add_to_global": "Zur Globalen Umgebung hinzufügen", + "added": "Umgebung hinzugefügt", + "create_new": "Neue Umgebung erstellen", + "created": "Umgebung erzeugt", + "deleted": "Umgebung löschen", + "duplicated": "Umgebung dupliziert", + "edit": "Umgebung bearbeiten", + "empty_variables": "Keine Variablen", + "global": "Global", + "global_variables": "Globale Variablen", + "import_or_create": "Importiere oder erstelle eine Umgebung", + "invalid_name": "Bitte gib einen gültigen Namen für die Umgebung an", + "list": "Umgebungsvariablen", + "my_environments": "Meine Umgebungen", + "name": "Name", + "nested_overflow": "Verschachtelte Umgebungsvariablen sind limitert auf 10 Unterebenen", + "new": "Neue Umgebung", + "no_active_environment": "Keine Umgebung ausgewählt", + "no_environment": "Keine Umgebung", + "no_environment_description": "Es wurden keine Umgebungen ausgewählt. Wähle aus, was mit den untenstehenden Variablen geschehen soll.", + "quick_peek": "Kurzinfo der Umgebung", + "replace_with_variable": "Mit Variable ersetzen", + "scope": "Scope", + "secrets": "Geheimnis", + "secret_value": "Geheimwert", + "select": "Umgebung auswählen", + "set": "Umgebung setzen", + "set_as_environment": "Als Umgebung setzen", + "short_name": "Umgebung muss mindestens 3 Zeichen haben", + "team_environments": "Teamumgebungen", + "title": "Umgebungen", + "updated": "Umgebung aktualisiert", + "value": "Wert", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Variablenliste", + "properties": "Umgebungsparameter", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Auth-Provider können nicht geladen werden", + "browser_support_sse": "Dieser Browser scheint keine Unterstützung für die vom Server gesendete Ereignisse zu haben.", + "check_console_details": "Einzelheiten findest Du in der Browser-Konsole.", + "check_how_to_add_origin": "Erfahre, wie du eine Quelle hinzufügen kannst", + "curl_invalid_format": "cURL ist nicht richtig formatiert", + "danger_zone": "Gefahrenbereich", + "delete_account": "Dein Konto ist derzeit Besitzer dieser Teams:", + "delete_account_description": "Du musst dich entweder selbst entfernen, den Besitz übertragen oder diese Teams löschen, bevor du dein Konto löschen kannst.", + "empty_email_address": "E-Mail-Adresse darf nicht leer sein.", + "empty_profile_name": "Profilname darf nicht leer sein", + "empty_req_name": "Leerer Anfragename", + "f12_details": "(F12 für Details)", + "gql_prettify_invalid_query": "Eine ungültige Abfrage konnte nicht verschönert werden. Fehler in der Abfragesyntax beheben und erneut versuchen", + "incomplete_config_urls": "Fehlende Konfigurations-URLs", + "incorrect_email": "Falsche E-Mail Adresse", + "invalid_link": "Falscher Link", + "invalid_link_description": "Der verwendete Link ist entweder ungültig oder abgelaufen.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "JSON ungültig", + "json_prettify_invalid_body": "Ein ungültiger Text konnte nicht verschönert werden, JSON-Syntaxfehler beheben und erneut versuchen", + "network_error": "Netzwerkfehler. Bitte versuche es erneut.", + "network_fail": "Anfrage konnte nicht gesendet werden", + "no_collections_to_export": "Keine Sammlungen zu exportieren. Bitte erstelle eine Sammlung, um loszulegen.", + "no_duration": "Keine Dauer", + "no_environments_to_export": "Keine Umgebungen zum Exportieren. Bitte erstelle eine Umgebung, um loszulegen.", + "no_results_found": "Keine Ergebnisse gefunden", + "page_not_found": "Diese Seite konnte nicht gefunden werden", + "please_install_extension": "Bitte installiere die Browser-Erweiterung, um Quellen hinzuzufügen.", + "proxy_error": "Proxy fehler", + "same_email_address": "Die aktualisierte E-Mail-Adresse muss mit der aktuellen E-Mail-Adresse übereinstimmen.", + "same_profile_name": "Der aktualisierte Profilname ist identisch mit dem aktuellen Profilnamen", + "script_fail": "Pre-Request-Skripte konnte nicht ausgeführt werden", + "something_went_wrong": "Etwas ist schief gelaufen", + "post_request_script_fail": "Testskripts konnten nicht ausgeführt werden", + "reading_files": "Fehler beim Lesen einer oder mehrerer Dateien.", + "fetching_access_tokens_list": "Beim Abrufen der Liste der Tokens ist etwas schiefgegangen.", + "generate_access_token": "Beim Generieren des Zugriffstokens ist etwas schiefgegangen.", + "delete_access_token": "Beim Löschen des Zugriffstokens ist etwas schiefgegangen." + }, + "export": { + "as_json": "Als JSON exportieren", + "create_secret_gist": "Geheimes Gist erstellen", + "create_secret_gist_tooltip_text": "Als geheimes Gist exportieren", + "failed": "Beim Exportieren ist etwas schiefgegangen", + "secret_gist_success": "Erfolgreich als geheimes Gist exportiert", + "require_github": "Melden Sie sich mit GitHub an, um ein geheimes Gist zu erstellen", + "title": "Exportieren", + "success": "Erfolgreich exportiert" + }, + "filename": { + "cookie_key_value_pairs": "Cookies", + "codegen": "{request_name} - Code", + "graphql_response": "GraphQL-Antwort", + "lens": "{request_name} - Antwort", + "realtime_response": "Echtzeit-Antwort", + "response_interface": "Antwort-Schnittstelle" + }, + "filter": { + "all": "Alle", + "none": "Keine", + "starred": "Favoriten" + }, + "folder": { + "created": "Ordner erstellt", + "edit": "Ordner bearbeiten", + "invalid_name": "Bitte gib einen Namen für den Ordner an", + "name_length_insufficient": "Ordnername sollte mindestens 3 Zeichen lang sein", + "new": "Neuer Ordner", + "renamed": "Ordner umbenannt" + }, + "graphql": { + "connection_switch_confirm": "Möchtest du dich mit dem letzten GraphQL-Endpunkt verbinden?", + "connection_switch_new_url": "Wenn du zu einem anderen Tab wechselst, wird die aktive GraphQL-Verbindung getrennt. Die neue Verbindungs-URL lautet", + "connection_switch_url": "Du bist mit einem GraphQL-Endpunkt verbunden, die Verbindungs-URL lautet", + "mutations": "Mutationen", + "schema": "Schema", + "subscriptions": "Abonnements", + "switch_connection": "Verbindung wechseln", + "url_placeholder": "Gib eine GraphQL-Endpunkt-URL ein" + }, + "graphql_collections": { + "title": "GraphQL Sammlungen" + }, + "group": { + "time": "Time", + "url": "URL" + }, + "header": { + "install_pwa": "App installieren", + "login": "Anmeldung", + "save_workspace": "Arbeitsbereich speichern" + }, + "helpers": { + "authorization": "Der Autorisierungsheader wird automatisch generiert, wenn Du die Anfrage sendest.", + "collection_properties_authorization": "Diese Autorisierung wird für jede Anfrage in dieser Sammlung gesetzt.", + "collection_properties_header": "Dieser Header wird für jede Anfrage in dieser Sammlung gesetzt.", + "generate_documentation_first": "Zuerst Dokumentation erstellen", + "network_fail": "Der API-Endpunkt kann nicht erreicht werden. Überprüfe Deine Netzwerkverbindung und versuche es erneut.", + "offline": "Du scheinst offline zu sein. Die Daten in diesem Arbeitsbereich sind möglicherweise nicht aktuell.", + "offline_short": "Du scheinst offline zu sein.", + "post_request_tests": "Testskripts werden in JavaScript geschrieben und nach Erhalt der Antwort ausgeführt.", + "pre_request_script": "Pre-Request-Skripte sind in JavaScript geschrieben und werden ausgeführt, bevor die Anfrage gesendet wird.", + "script_fail": "Es scheint ein Fehler im Pre-Request-Skript zu sein. Überprüfe den Fehler unten und korrigiere das Skript entsprechend.", + "post_request_script_fail": "Es scheint ein Fehler im Post-Request-Skript zu sein. Überprüfe den Fehler unten und korrigiere das Skript entsprechend.", + "post_request_script": "Schreibe ein Testskript, um das Debuggen zu automatisieren." + }, + "hide": { + "collection": "Sammlungsbereich einklappen", + "more": "Mehr ausblenden", + "preview": "Vorschau ausblenden", + "sidebar": "Seitenleiste ausblenden" + }, + "import": { + "collections": "Sammlungen importieren", + "curl": "cURL importieren", + "environments_from_gist": "Aus Gist importieren", + "environments_from_gist_description": "Hoppscotch-Umgebungen aus Gist importieren", + "failed": "Importieren fehlgeschlagen", + "from_file": "Aus Datei importieren", + "from_gist": "Aus GitHub Gist importieren", + "from_gist_description": "Aus GitHub Gist URL importieren", + "from_insomnia": "Aus Insomnia importieren", + "from_insomnia_description": "Insomnia-Sammlung importieren", + "from_json": "Aus Hoppscotch importieren", + "from_json_description": "Hoppscotch-Sammlung importieren", + "from_my_collections": "Aus 'Meine Sammlungen' importieren", + "from_my_collections_description": "Meine-Sammlungen-Datei importieren", + "from_all_collections": "Von einem anderen Arbeitsbereich importieren", + "from_all_collections_description": "Importiere jede Sammlung von einem anderen Arbeitsbereich in den aktuellen Arbeitsbereich", + "from_openapi": "Aus OpenAPI importieren", + "from_openapi_description": "OpenAPI-Spezifikation importieren (YAML/JSON)", + "from_postman": "Aus Postman importieren", + "from_postman_description": "Postman-Sammlung importieren", + "from_url": "Aus URL importieren", + "gist_url": "Gist-URL eingeben", + "from_har": "Von HAR importieren", + "from_har_description": "Von HAR-Datei importieren", + "gql_collections_from_gist_description": "GraphQL-Sammlungen aus Gist importieren", + "hoppscotch_environment": "Hoppscotch-Umgebung", + "hoppscotch_environment_description": "Hoppscotch-Umgebung aus JSON-Datei importieren", + "import_from_url_invalid_fetch": "Konnte keine Daten aus der URL abrufen", + "import_from_url_invalid_file_format": "Fehler beim Importieren von Sammlungen", + "import_from_url_invalid_type": "Typ wird nicht unterstützt. Akzeptierte Werte sind: 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Sammlungen importiert", + "insomnia_environment_description": "Insomnia-Umgebung aus YAML/JSON-Datei importieren", + "json_description": "Hoppscotch-Sammlung aus JSON-Datei importieren", + "postman_environment": "Postman Umgebung", + "postman_environment_description": "Postman-Umgebung aus einer JSON-Datei importieren", + "title": "Importieren", + "file_size_limit_exceeded_warning_multiple_files": "Die gewählten Dateien überschreiten das Limit von 10 MB. Nur die ersten {files} ausgewählten Dateien werden importiert.", + "file_size_limit_exceeded_warning_single_file": "Die aktuell gewählte Datei überschreitet das empfohlene Limit von 10 MB. Bitte wählen Sie eine andere Datei.", + "success": "Erfolgreich importiert" + }, + "inspections": { + "description": "Mögliche Fehler überprüfen", + "environment": { + "add_environment": "Zur Umgebung hinzufügen", + "add_environment_value": "Add value", + "empty_value": "Der Umgebungswert ist für die Variable '{variable}' leer", + "not_found": "Umgebungsvariable “{environment}” nicht gefunden." + }, + "header": { + "cookie": "Der Browser erlaubt Hoppscotch nicht, den Cookie-Header zu setzen. Während wir an der Hoppscotch Desktop App arbeiten (kommt bald), verwende bitte stattdessen den Authorization Header." + }, + "response": { + "401_error": "Bitte überprüfe die Authentifizierungsdaten.", + "404_error": "Bitte überprüfe die URL und die HTTP-Anfragemethode.", + "cors_error": "Bitte überprüfe die Konfiguration für Cross-Origin Resource Sharing (CORS)", + "default_error": "Bitte überprüfe deine Anfrage.", + "network_error": "Bitte überprüfe deine Netzwerkverbindung." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Erweiterung nicht installiert.", + "extension_unknown_origin": "Stelle sicher, dass du die Quelle für den API-Endpunkt zur Liste in der Hoppscotch Browsererweiterungen hinzugefügt hast.", + "extention_enable_action": "Browsererweiterung aktivieren", + "extention_not_enabled": "Browsererweiterungen nicht aktiviert." + } + }, + "layout": { + "collapse_collection": "Sammlungen ein- oder ausklappen", + "collapse_sidebar": "Seitenleiste ein- oder ausklappen", + "column": "Vertikales Layout", + "name": "Layout", + "row": "Horizontales Layout" + }, + "modal": { + "close_unsaved_tab": "Du hast ungespeicherte Änderungen", + "collections": "Sammlungen", + "confirm": "Aktion bestätigen", + "customize_request": "Anfrage anpassen", + "edit_request": "Anfrage bearbeiten", + "edit_response": "Antwort bearbeiten", + "import_export": "Importieren / Exportieren", + "response_name": "Antwort Name", + "share_request": "Anfrage teilen" + }, + "mqtt": { + "already_subscribed": "Du hast dieses Topic bereits abonniert.", + "clean_session": "Sitzung bereinigen", + "clear_input": "Eingaben löschen", + "clear_input_on_send": "Eingaben beim Senden löschen", + "client_id": "Client ID", + "color": "Wähle eine Farbe", + "communication": "Kommunikation", + "connection_config": "Verbindungseinstellungen", + "connection_not_authorized": "Diese MQTT-Verbindung verwendet keine Authentifizierung.", + "invalid_topic": "Bitte gib ein Topic für das Abonnement an", + "keep_alive": "Keep Alive", + "log": "Protokoll", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "Nachricht", + "new": "Neues Abonnement", + "not_connected": "Bitte stelle zuerst eine MQTT-Verbindung her.", + "publish": "Veröffentlichen", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Abonnieren", + "topic": "Topic", + "topic_name": "Topic-Name", + "topic_title": "Topic veröffentlichen / abonnieren", + "unsubscribe": "Deabonnieren", + "url": "URL" + }, + "navigation": { + "doc": "Dokumentation", + "graphql": "GraphQL", + "profile": "Profil", + "realtime": "Echtzeit", + "rest": "REST", + "settings": "Einstellungen" + }, + "preRequest": { + "javascript_code": "JavaScript-Code", + "learn": "Mehr erfahren", + "script": "Pre-Request-Skript", + "snippets": "Ausschnitte" + }, + "profile": { + "app_settings": "App-Einstellungen", + "default_hopp_displayname": "Unnamed User", + "editor": "Editor", + "editor_description": "Editoren können neue Anfragen hinzufügen, bearbeiten und löschen.", + "email_verification_mail": "Eine Bestätigungsmail wurde an Deine primäre E-Mail-Adresse versendet. Bitte klicke auf den darin stehenden Link um Deine E-Mail-Adresse zu bestätigen.", + "no_permission": "Du hast nicht die erforderlichen Berechtigungen für diese Aktion.", + "owner": "Eigentümer", + "owner_description": "Eigentümer können Abfragen, Sammlungen und Teammitglieder hinzufügen, bearbeiten und löschen.", + "roles": "Rollen", + "roles_description": "Rollen werden verwendet um Zugriff auf geteilte Sammlungen zu kontrollieren.", + "updated": "Profil aktualisiert", + "viewer": "Gast", + "viewer_description": "Gäste können Anfragen sehen und ausführen." + }, + "remove": { + "star": "Stern entfernen" + }, + "request": { + "added": "Anfrage hinzugefügt", + "add": "Anfrage hinzufügen", + "authorization": "Autorisierung", + "body": "Anfragekörper", + "choose_language": "Sprache wählen", + "content_type": "Inhaltstyp", + "content_type_titles": { + "others": "Others", + "structured": "Structured", + "text": "Text" + }, + "different_collection": "Anfragen aus verschiedenen Sammlungen können nicht neu geordnet werden", + "duplicated": "Anfrage dupliziert", + "duration": "Dauer", + "enter_curl": "cURL eingeben", + "generate_code": "Code generieren", + "generated_code": "Generierter Code", + "go_to_authorization_tab": "Zum Tab 'Autorisierung' wechseln", + "go_to_body_tab": "Zum Tab 'Anfragekörper' wechseln", + "header_list": "Header-Liste", + "invalid_name": "Bitte gib einen Namen für die Anfrage an", + "method": "Methode", + "moved": "Anfrage verschoben", + "name": "Anfragename", + "new": "Neue Anfrage", + "order_changed": "Reihenfolge der Anfragen aktualisiert", + "override": "Überschreiben", + "override_help": "Setze Content-Type in Headers", + "overriden": "Überschrieben", + "parameter_list": "Abfrageparameter", + "parameters": "Parameter", + "path": "Pfad", + "payload": "Nutzlast", + "query": "Anfrage", + "raw_body": "Roher Anfragetext", + "rename": "Anfrage umbenennen", + "renamed": "Anfrage umbenannt", + "request_variables": "Anfragevariablen", + "response_name_exists": "Antwortname existiert bereits", + "run": "Ausführen", + "save": "Speichern", + "save_as": "Speichern als", + "saved": "Anfrage gespeichert", + "share": "Teilen", + "share_description": "Teile Hoppscotch mit Deinen Freunden", + "share_request": "Anfrage teilen", + "stop": "Stop", + "title": "Anfrage", + "type": "Anfragetyp", + "url": "URL", + "url_placeholder": "Gib eine URL ein oder füge einen cURL-Befehl ein", + "variables": "Variablen", + "view_my_links": "Meine Links anzeigen", + "copy_link": "Link kopieren" + }, + "response": { + "audio": "Audio", + "body": "Antworttext", + "duplicated": "Antwort dupliziert", + "duplicate_name_error": "Antwort mit demselben Namen existiert bereits", + "filter_response_body": "JSON-Antwortkörper filtern (verwendet jq-Syntax)", + "headers": "Header", + "html": "HTML", + "image": "Bild", + "json": "JSON", + "pdf": "PDF", + "please_save_request": "Die Anfrage speichern, um ein Beispiel zu erstellen", + "preview_html": "HTML-Vorschau", + "raw": "Rohdaten", + "renamed": "Antwort umbenannt", + "size": "Größe", + "status": "Status", + "time": "Zeit", + "title": "Antwort", + "video": "Video", + "waiting_for_connection": "auf Verbindung warten", + "xml": "XML", + "generate_data_schema": "Daten-Schema generieren", + "data_schema": "Daten-Schema", + "saved": "Antwort gespeichert", + "invalid_name": "Bitte geben Sie einen Namen für die Antwort an" + }, + "settings": { + "accent_color": "Akzentfarbe", + "account": "Konto", + "account_deleted": "Dein Konto wurde gelöscht", + "account_description": "Passe Deine Kontoeinstellungen an.", + "account_email_description": "Deine primäre E-Mail-Adresse.", + "account_name_description": "Dies ist Dein Anzeigename.", + "additional": "Weitere Einstellungen", + "background": "Hintergrund", + "black_mode": "Schwarz", + "choose_language": "Sprache wählen", + "dark_mode": "Dunkel", + "delete_account": "Konto löschen", + "delete_account_description": "Wenn du dein Konto löschst, werden alle Daten dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", + "expand_navigation": "Menüpunkte vergrößern", + "experiments": "Experimente", + "experiments_notice": "Dies ist eine Sammlung von Experimenten, an denen wir aktuell arbeiten und die sich als nützlich erweisen könnten, Spaß machen, beides oder keines von beiden. Sie sind nicht endgültig und möglicherweise nicht stabil. Wenn also etwas übermäßig Seltsames passiert, gerate nicht in Panik. Schalte das verdammte Ding einfach aus. Scherz beiseite,", + "extension_ver_not_reported": "Nicht gemeldet", + "extension_version": "Erweiterungsversion", + "extensions": "Erweiterungen", + "extensions_use_toggle": "Verwende die Browsererweiterung, um Anfragen zu senden (falls vorhanden)", + "follow": "Folge uns", + "interceptor": "Interceptor", + "interceptor_description": "Middleware zwischen Anwendung und APIs.", + "language": "Sprache", + "light_mode": "Hell", + "official_proxy_hosting": "Offizieller Proxy, von Hoppscotch gehostet.", + "profile": "Profil", + "profile_description": "Aktualisiere Deine Profildaten.", + "profile_email": "E-Mail-Adresse", + "profile_name": "Profilname", + "proxy": "Proxy", + "proxy_url": "Proxy-URL", + "proxy_use_toggle": "Verwende die Proxy-Middleware, um Anfragen zu senden", + "read_the": "Lies die", + "reset_default": "Zurücksetzen", + "short_codes": "Kurzcodes", + "short_codes_description": "Kurzcodes, die von dir erstellt wurden", + "sidebar_on_left": "Seitenleiste links", + "ai_experiments": "KI-Experimente", + "sync": "Synchronisieren", + "sync_collections": "Sammlungen", + "sync_description": "Diese Einstellungen werden mit der Cloud synchronisiert.", + "sync_environments": "Umgebungen", + "sync_history": "Verlauf", + "system_mode": "System", + "telemetry": "Telemetrie", + "telemetry_helps_us": "Telemetrie hilft uns, unseren Betrieb zu personalisieren und Ihnen das beste Erlebnis zu bieten.", + "theme": "Thema", + "theme_description": "Passe Dein Anwendungsdesign an.", + "use_experimental_url_bar": "Experimentelle URL-Leiste mit Hervorhebung der Umgebung verwenden", + "user": "Nutzer", + "verified_email": "Bestätigte E-Mail-Adresse", + "verify_email": "E-Mail-Adresse bestätigen" + }, + "shared_requests": { + "button": "Schaltfläche", + "button_info": "Erstelle einen 'Run in Hoppscotch'-Schaltfläche für deine Website, deinen Blog oder eine README.", + "copy_html": "HTML kopieren", + "copy_link": "Link kopieren", + "copy_markdown": "Markdown kopieren", + "creating_widget": "Widget erstellen", + "customize": "Anpassen", + "deleted": "Geteilte Anfrage gelöscht", + "description": "Wähle ein Widget aus. Du kannst es später noch ändern und anpassen.", + "embed": "Embed", + "embed_info": "Füge deiner Website, deinem Blog oder deiner Dokumentation einen kleinen 'Hoppscotch API Playground' hinzu.", + "link": "Link", + "link_info": "Erstelle einen Link, den du mit jedem im Internet teilen kannst, der Berechtigungen zur Ansicht bekommt.", + "modified": "Geteilte Anfrage geändert", + "not_found": "Geteilte Anfrage nicht gefunden", + "open_new_tab": "In neuem Tab öffnen", + "preview": "Vorschau", + "run_in_hoppscotch": "In Hoppscotch ausführen", + "theme": { + "dark": "Dunkel", + "light": "Hell", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Aktuelles Menü schließen", + "command_menu": "Such- und Befehlsmenü", + "help_menu": "Hilfemenü", + "show_all": "Tastatürkürzel", + "title": "Allgemein" + }, + "miscellaneous": { + "invite": "Lade Leute zu Hoppscotch ein", + "title": "Sonstiges" + }, + "navigation": { + "back": "Zur vorherigen Seite gehen", + "documentation": "Dokumentation-Seite öffnen", + "forward": "Zur nächsten Seite gehen", + "graphql": "GraphQL-Seite öffnen", + "profile": "Profil öffnen", + "realtime": "Echtzeit-Seite öffnen", + "rest": "REST-Seite öffnen", + "settings": "Einstellungen öffnen", + "title": "Navigation" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "DELETE-Methode auswählen", + "get_method": "GET-Methode auswählen", + "head_method": "HEAD-Methode auswählen", + "import_curl": "Import cURL", + "method": "Methode", + "next_method": "Nächste Methode auswählen", + "post_method": "POST-Methode auswählen", + "previous_method": "Vorherige Methode auswählen", + "put_method": "PUT-Methode auswählen", + "rename": "Rename Request", + "reset_request": "Anfrage zurücksetzen", + "save_request": "Save Request", + "save_to_collections": "In Sammlungen speichern", + "send_request": "Anfrage senden", + "share_request": "Anfrage teilen", + "show_code": "Code-Schnipsel generieren", + "title": "Anfrage" + }, + "response": { + "copy": "Antwort in die Zwischenablage kopieren", + "download": "Antwort als Datei herunterladen", + "title": "Antwort" + }, + "theme": { + "black": "Auf Schwarzes Design wechseln", + "dark": "Auf dunkles Design wechseln", + "light": "Auf helles Design wechseln", + "system": "Auf Systemdesign wechseln", + "title": "Theme" + } + }, + "show": { + "code": "Code anzeigen", + "collection": "Sammlungspanel ausklappen", + "more": "Mehr anzeigen", + "sidebar": "Seitenleiste anzeigen" + }, + "socketio": { + "communication": "Kommunikation", + "connection_not_authorized": "Diese SocketIO-Verbindung verwendet keine Authentifizierung.", + "event_name": "Ereignissname", + "events": "Ereigniss", + "log": "Protokoll", + "url": "URL" + }, + "spotlight": { + "change_language": "Sprache ändern", + "environments": { + "delete": "Aktuelle Umgebung löschen", + "duplicate": "Aktuelle Umgebung duplizieren", + "duplicate_global": "Globale Umgebung duplizieren", + "edit": "Aktuelle Umgebung bearbeiten", + "edit_global": "Globale Umgebung bearbeiten", + "new": "Neue Umgebung erstellen", + "new_variable": "Neue Umgebungsvariable erstellen", + "title": "Umgebungen" + }, + "general": { + "chat": "Chat mit dem Support", + "help_menu": "Hilfe und Support", + "open_docs": "Dokumentation lesen", + "open_github": "GitHub-Repository öffnen", + "open_keybindings": "Tastenkürzel anzeigen", + "social": "Soziale Medien", + "title": "Allgemein" + }, + "graphql": { + "connect": "Verbindung zum Server herstellen", + "disconnect": "Verbindung zum Server trennen" + }, + "miscellaneous": { + "invite": "Lade deine Freunde zu Hoppscotch ein", + "title": "Verschiedenes" + }, + "request": { + "save_as_new": "Als neue Anfrage speichern", + "select_method": "Methode wählen", + "switch_to": "Wechseln zu", + "tab_authorization": "Tab 'Autorisierung'", + "tab_body": "Tab 'Anfragekörper'", + "tab_headers": "Tab 'Header'", + "tab_parameters": "Tab 'Parameter'", + "tab_pre_request_script": "Tab 'Pre-Request-Skripte'", + "tab_query": "Tab 'Anfrage'", + "tab_tests": "Tab 'Tests'", + "tab_variables": "Tab 'Variablen'" + }, + "response": { + "copy": "Antwort kopieren", + "download": "Antwort als Datei herunterladen", + "title": "Antwort" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Oberfläche", + "theme": "Theme", + "user": "Benutzer" + }, + "settings": { + "change_interceptor": "Interceptor ändern", + "change_language": "Sprache ändern", + "theme": { + "black": "Schwarz", + "dark": "Dunkel", + "light": "Hell", + "system": "System" + } + }, + "tab": { + "close_current": "Aktuelles Tab schließen", + "close_others": "Andere Tabs schließen", + "duplicate": "Tab duplizieren", + "new_tab": "Neues Tab öffnen", + "title": "Tabs" + }, + "workspace": { + "delete": "Aktuelles Team löschen", + "edit": "Aktuelles Team bearbeiten", + "invite": "Leute zum Team einladen", + "new": "Neues Team erstellen", + "switch_to_personal": "Zum eigenen Arbeitsbereich wechseln", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "Ereignistyp", + "log": "Protokoll", + "url": "URL" + }, + "state": { + "bulk_mode": "Bulk-Bearbeitung", + "bulk_mode_placeholder": "Einträge werden durch Zeilenumbruch getrennt\nSchlüssel und Werte werden getrennt durch:\nStelle # jeder Zeile voran, die Du hinzufügen möchtest, aber lasse sie deaktiviert", + "cleared": "Gelöscht", + "connected": "In Verbindung gebracht", + "connected_to": "Verbunden mit {name}", + "connecting_to": "Verbindung zu {name}...", + "connection_error": "Verbindung nicht möglich", + "connection_failed": "Verbindungsaufbau fehlgeschlagen", + "connection_lost": "Verbindung unterbrochen", + "copied_interface_to_clipboard": "Den {language} Schnittstellentyp in die Zwischenablage kopiert", + "copied_to_clipboard": "In die Zwischenablage kopiert", + "deleted": "Gelöscht", + "deprecated": "VERALTET", + "disabled": "Deaktiviert", + "disconnected": "Getrennt", + "disconnected_from": "Verbindung zu {name} getrennt", + "docs_generated": "Dokumentation erstellt", + "download_failed": "Download fehlgeschlagen", + "download_started": "Download gestartet", + "enabled": "Aktiviert", + "file_imported": "Datei importiert", + "finished_in": "Fertig in {duration} ms", + "hide": "Ausblenden", + "history_deleted": "Verlauf gelöscht", + "linewrap": "Zeilen umbrechen", + "loading": "Wird geladen...", + "message_received": "Nachricht: {message} eingegangen auf Topic: {topic}", + "mqtt_subscription_failed": "Beim Abonnieren des Topics '{topic}' ist etwas schiefgelaufen.", + "none": "Keiner", + "nothing_found": "Nichts gefunden für", + "published_error": "Beim Veröffentlichen der Nachricht '{message}' im Topic '{topic}' ist etwas schiefgelaufen.", + "published_message": "Nachricht '{message}' wurde im Topic '{topic}' veröffentlicht.", + "reconnection_error": "Verbindung fehlgeschlagen", + "show": "Anzeigen", + "subscribed_failed": "Topic '{topic}' konnte nicht abonniert werden.", + "subscribed_success": "Topic '{topic}' wurde erfolgreich abonniert.", + "unsubscribed_failed": "Topic '{topic}' konnte nicht deabonniert werden.", + "unsubscribed_success": "Topic '{topic}' wurde erfolgreich deabonniert.", + "waiting_send_request": "Warten auf Anfrage senden", + "loading_workspaces": "Arbeitsbereiche werden geladen", + "loading_collections_in_workspace": "Sammlungen im Arbeitsbereich werden geladen" + }, + "support": { + "changelog": "Lese mehr über die neuesten Versionen", + "chat": "Fragen? Chatte mit uns!", + "community": "Stelle Fragen und helfe anderen", + "documentation": "Lese mehr über Hoppscotch", + "forum": "Stelle Fragen und erhalte Antworten", + "github": "Folge uns auf GitHub", + "shortcuts": "Hoppscotch schneller bedienen", + "title": "Hilfe", + "twitter": "Folge uns auf Twitter" + }, + "tab": { + "authorization": "Autorisierung", + "body": "Anfragekörper", + "close": "Tab schließen", + "close_others": "Andere Tabs schließen", + "collections": "Sammlungen", + "documentation": "Dokumentation", + "duplicate": "Tab duplizieren", + "environments": "Umgebungen", + "headers": "Header", + "history": "Verlauf", + "mqtt": "MQTT", + "parameters": "Parameter", + "pre_request_script": "Pre-Request-Skripte", + "queries": "Anfragen", + "query": "Anfrage", + "schema": "Schema", + "shared_requests": "Geteilte Anfragen", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Tests", + "types": "Typen", + "variables": "Variablen", + "websocket": "WebSocket" + }, + "team": { + "already_member": "Du bist bereits ein Mitglied des Teams, bitte kontaktiere den Teameigentümer.", + "create_new": "Neues Team erstellen", + "deleted": "Team gelöscht", + "edit": "Team bearbeiten", + "email": "E-Mail-Adresse", + "email_do_not_match": "E-Mail-Adresse stimmt nicht mit Deinen Kontodaten überein, bitte kontaktiere den Teameigentümer.", + "exit": "Team verlassen", + "exit_disabled": "Eigentümer können das Team nicht verlassen", + "failed_invites": "Gescheiterte Einladungen", + "invalid_coll_id": "Ungültige Sammlungs-ID", + "invalid_email_format": "E-Mail-Format ist ungültig", + "invalid_id": "Ungültige Team-ID, bitte kontaktiere den Teameigentümer.", + "invalid_invite_link": "Ungültiger Einladungslink.", + "invalid_invite_link_description": "Der Einladungslink ist ungültig, bitte kontaktiere den Teameigentümer.", + "invalid_member_permission": "Bitte erteile dem Teammitglied eine gültige Erlaubnis", + "invite": "Einladen", + "invite_more": "Mehr einladen", + "invite_tooltip": "Personen zum Team einladen", + "invited_to_team": "{owner} hat dich zu {team} eingeladen", + "join": "Einladung angenommen", + "join_team": "{team} beitreten", + "joined_team": "Du bist {team} beigetreten", + "joined_team_description": "Du bist nun ein Mitglied des Teams", + "left": "Du hast das Team verlassen", + "login_to_continue": "Zum Fortfahren anmelden", + "login_to_continue_description": "Du musst angemeldet sein um ein Team beitreten zu können.", + "logout_and_try_again": "Bitte melde Dich ab und versuche es mit einem anderen Konto.", + "member_has_invite": "Diese E-Mail-Adresse wurde bereits eingeladen, bitte kontaktiere den Teameigentümer.", + "member_not_found": "Mitglied konnte nicht gefunden werden, bitte kontaktiere den Teameigentümer.", + "member_removed": "Benutzer entfernt", + "member_role_updated": "Benutzerrollen aktualisiert", + "members": "Mitglieder", + "more_members": "+{count} weitere", + "name_length_insufficient": "Der Teamname sollte mindestens 6 Zeichen lang sein", + "name_updated": "Teamname aktualisiert", + "new": "Neues Team", + "new_created": "Neues Team erstellt", + "new_name": "Mein neues Team", + "no_access": "Du hast keinen Bearbeitungszugriff auf diese Sammlungen", + "no_invite_found": "Einladung nicht gefunden, bitte kontaktiere den Teameigentümer.", + "no_request_found": "Anfrage nicht gefunden.", + "not_found": "Team wurde nicht gefunde, bitte kontaktiere den Teameigentümer.", + "not_valid_viewer": "Du hast nicht die richtige Berechtigung als Gast, bitte kontaktiere den Teameigentümer.", + "parent_coll_move": "Sammlung kann nicht in eine untergeordnete Sammlung verschoben werden", + "pending_invites": "Ausstehende Einladungen", + "permissions": "Berechtigungen", + "same_target_destination": "Ziel und Zielort sind identisch", + "saved": "Team gespeichert", + "select_a_team": "Team auswählen", + "success_invites": "Erfolgreiche Einladungen", + "title": "Team", + "we_sent_invite_link": "Einladungen wurden an alle E-Mails verschickt!", + "invite_sent_smtp_disabled": "Einladungslinks erstellt", + "we_sent_invite_link_description": "Bitte alle eingeladenen Personen, ihren Posteingang zu überprüfen und auf den Link zu klicken, um dem Team beizutreten.", + "invite_sent_smtp_disabled_description": "Das Versenden von Einladungsemails ist für diese Instanz von Hoppscotch deaktiviert. Bitte verwende die Schaltfläche 'Link kopieren', um den Einladungslink manuell zu kopieren und zu teilen.", + "copy_invite_link": "Einladungslink kopieren", + "search_title": "Team-Anfragen" + }, + "team_environment": { + "deleted": "Umgebung gelöscht", + "duplicate": "Umgebung dupliziert", + "not_found": "Umgebung nicht gefunden." + }, + "test": { + "failed": "Test fehlgeschlagen", + "javascript_code": "JavaScript-Code", + "learn": "Mehr erfahren", + "passed": "Test bestanden", + "report": "Testbericht", + "results": "Testergebnisse", + "script": "Skript", + "snippets": "Schnipsel" + }, + "websocket": { + "communication": "Kommunikation", + "log": "Protokoll", + "message": "Nachricht", + "protocols": "Protokolle", + "url": "URL" + }, + "workspace": { + "change": "Arbeitsbereich wechseln", + "personal": "Mein Arbeitsbereich", + "other_workspaces": "Meine Arbeitsbereiche", + "team": "Team Arbeitsbereich", + "title": "Arbeitsbereiche" + }, + "site_protection": { + "login_to_continue": "Einloggen, um fortzufahren", + "login_to_continue_description": "Du musst eingeloggt sein, um auf diese Hoppscotch Enterprise-Instanz zuzugreifen.", + "error_fetching_site_protection_status": "Beim Abrufen des Status der Seiten­schutz­maßnahmen ist ein Fehler aufgetreten." + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Persönliche Zugangstoken", + "section_description": "Persönliche Zugangstoken helfen dir derzeit, das CLI mit deinem Hoppscotch-Konto zu verbinden", + "last_used_on": "Zuletzt verwendet am", + "expires_on": "Läuft ab am", + "no_expiration": "Kein Ablaufdatum", + "expired": "Abgelaufen", + "copy_token_warning": "Stelle sicher, dass du dein persönliches Zugangs-Token jetzt kopierst. Du wirst es nicht mehr sehen können!", + "token_purpose": "Wofür ist dieses Token?", + "expiration_label": "Ablaufdatum", + "scope_label": "Geltungsbereich", + "workspace_read_only_access": "Lesezugriff auf Arbeitsbereichsdaten.", + "personal_workspace_access_limitation": "Persönliche Zugangstoken können nicht auf deinen persönlichen Arbeitsbereich zugreifen.", + "generate_token": "Token generieren", + "invalid_label": "Bitte gib eine Bezeichnung für das Token an", + "no_expiration_verbose": "Dieses Token wird nie ablaufen!", + "token_expires_on": "Dieses Token verfällt am", + "generate_new_token": "Neues Token generieren", + "generate_modal_title": "Neues persönliches Zugangstoken", + "deletion_success": "Das persönliche Zugriffstoken {label} wurde gelöscht" + }, + "collection_runner": { + "collection_id": "Sammlungs-ID", + "environment_id": "Umgebungs-ID", + "cli_collection_id_description": "Diese Sammlungs-ID wird vom CLI-Sammlungs-Runner für Hoppscotch verwendet.", + "cli_environment_id_description": "Diese Umgebungs-ID wird vom CLI-Sammlungs-Runner für Hoppscotch verwendet.", + "include_active_environment": "Aktive Umgebung einbeziehen:", + "cli": "CLI", + "ui": "Runner (kommt bald)", + "cli_command_generation_description_cloud": "Kopiere den folgenden Befehl und führe ihn im CLI aus. Bitte gib ein persönliches Zugriffstoken an.", + "cli_command_generation_description_sh": "Kopiere den folgenden Befehl und führe ihn im CLI aus. Bitte gib ein persönliches Zugriffstoken an und überprüfe die generierte SH-Instanz-Server-URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Kopiere den folgenden Befehl und führe ihn im CLI aus. Bitte gib ein persönliches Zugriffstoken und die SH-Instanz-Server-URL an.", + "run_collection": "Sammlung ausführen" + }, + "ai_experiments": { + "generate_request_name": "Anfragenname mit KI generieren", + "generate_or_modify_request_body": "Anfragebody generieren oder ändern", + "modify_with_ai": "Mit KI ändern", + "generate": "Generieren", + "generate_or_modify_request_body_input_placeholder": "Geben Sie Ihren Prompt ein, um den Anfragebody zu ändern", + "accept_change": "Änderung akzeptieren", + "feedback_success": "Feedback erfolgreich eingereicht", + "feedback_failure": "Feedback konnte nicht eingereicht werden", + "feedback_thank_you": "Vielen Dank für Ihr Feedback!", + "feedback_cta_text_long": "Bewerten Sie die Generierung, hilft uns, uns zu verbessern", + "feedback_cta_request_name": "Hat Ihnen der generierte Name gefallen?", + "modify_request_body_error": "Fehler beim Ändern des Anfragebodys" + } +} diff --git a/packages/hoppscotch-common/locales/el.json b/packages/hoppscotch-common/locales/el.json new file mode 100644 index 0000000..de85601 --- /dev/null +++ b/packages/hoppscotch-common/locales/el.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Add", + "autoscroll": "Autoscroll", + "cancel": "Ματαίωση", + "choose_file": "Επιλέξτε ένα αρχείο", + "clear": "Σαφή", + "clear_all": "Τα καθαρίζω όλα", + "clear_history": "Clear all History", + "close": "Close", + "connect": "Συνδέω-συωδεομαι", + "connecting": "Connecting", + "copy": "αντίγραφο", + "create": "Create", + "delete": "Διαγράφω", + "disconnect": "Αποσυνδέω", + "dismiss": "Απολύω", + "dont_save": "Don't save", + "download_file": "Λήψη αρχείου", + "drag_to_reorder": "Drag to reorder", + "duplicate": "Duplicate", + "edit": "Επεξεργασία", + "filter": "Filter", + "go_back": "Πήγαινε πίσω", + "go_forward": "Go forward", + "group_by": "Group by", + "hide_secret": "Hide secret", + "label": "Επιγραφή", + "learn_more": "Μάθε περισσότερα", + "download_here": "Download here", + "less": "Less", + "more": "Περισσότερο", + "new": "Νέος", + "no": "Οχι", + "open_workspace": "Open workspace", + "paste": "Paste", + "prettify": "Ωραιοποιώ", + "properties": "Properties", + "remove": "Αφαιρώ", + "rename": "Rename", + "restore": "Επαναφέρω", + "save": "Αποθηκεύσετε", + "scroll_to_bottom": "Scroll to bottom", + "scroll_to_top": "Scroll to top", + "search": "Αναζήτηση", + "send": "Στείλετε", + "share": "Share", + "show_secret": "Show secret", + "start": "Αρχή", + "starting": "Starting", + "stop": "Να σταματήσει", + "to_close": "to close", + "to_navigate": "to navigate", + "to_select": "to select", + "turn_off": "Σβήνω", + "turn_on": "Ανάβω", + "undo": "Ξεκάνω", + "yes": "Ναί" + }, + "add": { + "new": "Προσθεσε νεο", + "star": "Προσθήκη αστεριού" + }, + "app": { + "chat_with_us": "μίλα μαζί μας", + "contact_us": "Επικοινωνήστε μαζί μας", + "cookies": "Cookies", + "copy": "αντίγραφο", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Copy User Auth Token", + "developer_option": "Developer options", + "developer_option_description": "Εργαλεία για προγραμματιστές που βοηθάνε στην ανάπτυξη και συντήρηση του Hoppscotch.", + "discord": "Discord", + "documentation": "Τεκμηρίωση", + "github": "GitHub", + "help": "Βοήθεια, σχόλια και τεκμηρίωση", + "home": "Σπίτι", + "invite": "Καλώ", + "invite_description": "Στο Hoppscotch, σχεδιάσαμε μια απλή και διαισθητική διεπαφή για τη δημιουργία και τη διαχείριση των API σας. Το Hoppscotch είναι ένα εργαλείο που σας βοηθά να δημιουργήσετε, να δοκιμάσετε, να τεκμηριώσετε και να μοιραστείτε τα API σας.", + "invite_your_friends": "Κάλεσε τους φίλους σου", + "join_discord_community": "Γίνετε μέλος της κοινότητάς μας Discord", + "keyboard_shortcuts": "Συντομεύσεις πληκτρολογίου", + "name": "Hoppscotch", + "new_version_found": "Βρέθηκε νέα έκδοση. Ανανέωση για ενημέρωση.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Options", + "proxy_privacy_policy": "Πολιτική απορρήτου μεσολάβησης", + "reload": "Φορτώνω πάλι", + "search": "Αναζήτηση", + "share": "Μερίδιο", + "shortcuts": "Συντομεύσεις", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "Προβολέας θέατρου", + "status": "Κατάσταση", + "status_description": "Ελέγξτε το status της Ιστοσελίδας", + "terms_and_privacy": "Όροι και απόρρητο", + "twitter": "Twitter", + "type_a_command_search": "Πληκτρολογήστε μια εντολή ή αναζήτηση…", + "we_use_cookies": "Χρησιμοποιούμε cookies", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "Τι νέα?", + "see_whats_new": "See what’s new", + "wiki": "Wiki" + }, + "auth": { + "account_exists": "Ο λογαριασμός υπάρχει με διαφορετικά διαπιστευτήρια - Συνδεθείτε για να συνδέσετε και τους δύο λογαριασμούς", + "all_sign_in_options": "Όλες οι επιλογές σύνδεσης", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "Συνεχίστε με το Email", + "continue_with_github": "Συνεχίστε με το GitHub", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Συνεχίστε με την Google", + "continue_with_microsoft": "Συνεχίστε με την Microsoft", + "email": "ΗΛΕΚΤΡΟΝΙΚΗ ΔΙΕΥΘΥΝΣΗ", + "logged_out": "Αποσυνδέθηκα", + "login": "Σύνδεση", + "login_success": "Επιτυχής σύνδεση", + "login_to_hoppscotch": "Συνδεθείτε στο Hoppscotch", + "logout": "Αποσύνδεση", + "re_enter_email": "Εισάγετε ξανά τη διεύθυνση ηλεκτρονικού ταχυδρομείου", + "send_magic_link": "Στείλτε έναν μαγικό σύνδεσμο", + "sync": "Συγχρονισμός", + "we_sent_magic_link": "Σας στείλαμε έναν μαγικό σύνδεσμο!", + "we_sent_magic_link_description": "Ελέγξτε τα εισερχόμενά σας - στείλαμε ένα μήνυμα ηλεκτρονικού ταχυδρομείου στη διεύθυνση {email}. Περιέχει έναν μαγικό σύνδεσμο που θα σας συνδέσει." + }, + "authorization": { + "generate_token": "Δημιουργήστε το διακριτικό", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "Συμπερίληψη στη διεύθυνση URL", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "Μάθε πως", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "Pass by", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "Κωδικός πρόσβασης", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "Ενδειξη", + "type": "Τύπος εξουσιοδότησης", + "username": "Όνομα χρήστη" + }, + "collection": { + "created": "Η συλλογή δημιουργήθηκε", + "different_parent": "Cannot reorder collection with different parent", + "edit": "Επεξεργασία Συλλογής", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "Καταχωρίστε ένα έγκυρο όνομα για τη συλλογή", + "invalid_root_move": "Collection already in the root", + "moved": "Moved Successfully", + "my_collections": "Οι Συλλογές μου", + "name": "Η νέα μου συλλογή", + "name_length_insufficient": "Collection name should be at least 3 characters long", + "new": "Νέα συλλογή", + "order_changed": "Collection Order Updated", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "Η συλλογή μετονομάστηκε", + "request_in_use": "Request in use", + "save_as": "Αποθήκευση ως", + "save_to_collection": "Save to Collection", + "select": "Επιλέξτε μια Συλλογή", + "select_location": "Επιλέξτε τοποθεσία", + "details": "Details", + "select_team": "Επιλέξτε μια ομάδα", + "team_collections": "Συλλογές ομάδων" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "exit_team": "Are you sure you want to leave this team?", + "logout": "Είστε βέβαιοι ότι θέλετε να αποσυνδεθείτε?", + "remove_collection": "Είστε βέβαιοι ότι θέλετε να διαγράψετε οριστικά αυτήν τη συλλογή;", + "remove_environment": "Είστε βέβαιοι ότι θέλετε να διαγράψετε οριστικά αυτό το περιβάλλον;", + "remove_folder": "Είστε βέβαιοι ότι θέλετε να διαγράψετε οριστικά αυτόν τον φάκελο;", + "remove_history": "Είστε βέβαιοι ότι θέλετε να διαγράψετε οριστικά όλο το ιστορικό;", + "remove_request": "Είστε βέβαιοι ότι θέλετε να διαγράψετε οριστικά αυτό το αίτημα;", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτήν την ομάδα;", + "remove_telemetry": "Είστε βέβαιοι ότι θέλετε να εξαιρεθείτε από την τηλεμετρία;", + "request_change": "Are you sure you want to discard current request, unsaved changes will be lost.", + "save_unsaved_tab": "Do you want to save changes made in this tab?", + "sync": "Είστε βέβαιοι ότι θέλετε να συγχρονίσετε αυτόν τον χώρο εργασίας;", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "Κεφαλίδα {count}", + "message": "Μήνυμα {count}", + "parameter": "Παράμετρος {count}", + "protocol": "Πρωτόκολλο {count}", + "value": "Τιμή {count}", + "variable": "Μεταβλητή {count}" + }, + "documentation": { + "generate": "Δημιουργήστε τεκμηρίωση", + "generate_message": "Εισαγάγετε οποιαδήποτε συλλογή Hoppscotch για να δημιουργήσετε τεκμηρίωση API εν κινήσει." + }, + "empty": { + "authorization": "Αυτό το αίτημα δεν χρησιμοποιεί καμία εξουσιοδότηση", + "body": "Αυτό το αίτημα δεν έχει σώμα", + "collection": "Η συλλογή είναι άδεια", + "collections": "Οι συλλογές είναι άδειες", + "documentation": "Συνδεθείτε σε ένα GraphQL endpoint για προβολή της τεκμηρίωσης", + "endpoint": "Το Endpoint δεν μπορεί να είναι άδειο", + "environments": "Τα περιβάλλοντα είναι άδεια", + "folder": "Ο φάκελος είναι άδειος", + "headers": "Αυτό το αίτημα δεν έχει κεφαλίδες", + "history": "Το ιστορικό είναι άδειο", + "invites": "Η λίστα προσκλήσεων είναι άδεια", + "members": "Η ομάδα είναι άδεια", + "parameters": "Αυτό το αίτημα δεν έχει παραμέτρους", + "pending_invites": "Δεν υπάρχουν εκκρεμείς προσκλήσεις για αυτή την ομάδα", + "profile": "Συνδεθείτε για προβολή του προφίλ σας", + "protocols": "Τα πρωτόκολλα είναι κενά", + "request_variables": "This request does not have any request variables", + "schema": "Συνδεθείτε σε ένα τελικό σημείο GraphQL", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "Subscriptions are empty", + "team_name": "Το όνομα της ομάδας είναι κενό", + "teams": "Οι ομάδες είναι άδειες", + "tests": "Δεν υπάρχουν δοκιμές για αυτό το αίτημα", + "access_tokens": "Access tokens are empty", + "shortcodes": "Τα Shortcodes είναι κενά" + }, + "environment": { + "add_to_global": "Προσθήκη στο Global", + "added": "Προσθήκη Περιβάλλοντος", + "create_new": "Δημιουργήστε νέο περιβάλλον", + "created": "Το Περιβάλλον δημιουργήθηκε", + "deleted": "Διαγραφή Περιβάλλοντος", + "duplicated": "Environment duplicated", + "edit": "Επεξεργασία Περιβάλλοντος", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create a environment", + "invalid_name": "Καταχωρίστε ένα έγκυρο όνομα για το περιβάλλον", + "list": "Environment variables", + "my_environments": "Τα Περιβάλλοντα μου", + "name": "Name", + "nested_overflow": "Οι 'φωλιασμένες' μεταβλητές περιβάλλοντος είναι περιορισμένες σε 10 επίπεδα", + "new": "Νέο Περιβάλλον", + "no_active_environment": "No active environment", + "no_environment": "Χωρίς περιβάλλον", + "no_environment_description": "Δέν επιλέχθηκε κάποιο περιβάλλον. Διαλέξτε τι θέλετε να κάνετε με τις παρακάτω μεταβλητές.", + "quick_peek": "Environment Quick Peek", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "Επιλέξτε περιβάλλον", + "set": "Set environment", + "set_as_environment": "Set as environment", + "team_environments": "Περιβάλλοντα Ομάδας", + "title": "Περιβάλλοντα", + "updated": "Αναβάθμιση Περιβάλλοντος", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Λίστα μεταβλητών", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "Αυτό το πρόγραμμα περιήγησης δεν φαίνεται να υποστηρίζει διακομιστές που έχουν σταλεί συμβάντα.", + "check_console_details": "Ελέγξτε το αρχείο καταγραφής της κονσόλας για λεπτομέρειες.", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "Το cURL δεν έχει μορφοποιηθεί σωστά", + "danger_zone": "Danger zone", + "delete_account": "Your account is currently an owner in these teams:", + "delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "Όνομα κενού αιτήματος", + "f12_details": "(F12 για λεπτομέρειες)", + "gql_prettify_invalid_query": "Δεν ήταν δυνατή η προεπιλογή ενός μη έγκυρου ερωτήματος, η επίλυση σφαλμάτων σύνταξης ερωτήματος και η δοκιμή ξανά", + "incomplete_config_urls": "Μη-ολοκληρωμένα URLs διαμόρφωσης", + "incorrect_email": "Λάθος email", + "invalid_link": "Μή έγκυρο link", + "invalid_link_description": "Ο σύνδεσμος που επιλέξατε έχει λήξει ή δεν είναι έγκυρος.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "Μη έγκυρο JSON", + "json_prettify_invalid_body": "Δεν ήταν δυνατή η ομορφιά ενός μη έγκυρου σώματος, η επίλυση σφαλμάτων σύνταξης json και η προσπάθεια ξανά", + "network_error": "Από ότι φαίνεται υπάρχει ένα σφάλμα δικτύου. Παρακαλούμε προσπαθήστε ξανά.", + "network_fail": "Δεν ήταν δυνατή η αποστολή του αιτήματος", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "Χωρίς διάρκεια", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "Δεν βρέθηκαν αντιστοιχίες", + "page_not_found": "Αυτή η σελίδα δεν βρέθηκε", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "Δεν ήταν δυνατή η εκτέλεση του σεναρίου πριν από το αίτημα", + "something_went_wrong": "Κάτι πήγε στραβά", + "post_request_script_fail": "Δεν μπορεσε να εκτελεστεί το post-request script", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "Εξαγωγή ως JSON", + "create_secret_gist": "Δημιουργήστε μυστική ουσία", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "Συνδεθείτε με το GitHub για να δημιουργήσετε μυστική ουσία", + "title": "Export", + "success": "Successfully exported", + "gist_created": "Η ουσία δημιουργήθηκε" + }, + "filter": { + "all": "All", + "none": "None", + "starred": "Starred" + }, + "folder": { + "created": "Ο φάκελος δημιουργήθηκε", + "edit": "Επεξεργασία φακέλου", + "invalid_name": "Καταχωρίστε ένα όνομα για το φάκελο", + "name_length_insufficient": "Το όνομα του φακέλου πρέπει έχει μέγεθος τουλάχιστον 3 χαρακτήρες.", + "new": "Νέος φάκελος", + "renamed": "Ο φάκελος μετονομάστηκε" + }, + "graphql": { + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "mutations": "Μεταλλάξεις", + "schema": "Σχήμα", + "subscriptions": "Συνδρομές", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "Time", + "url": "URL" + }, + "header": { + "install_pwa": "Εγκατάσταση εφαρμογής", + "login": "Σύνδεση", + "save_workspace": "Αποθήκευση του χώρου εργασίας μου" + }, + "helpers": { + "authorization": "Η κεφαλίδα εξουσιοδότησης θα δημιουργηθεί αυτόματα κατά την αποστολή του αιτήματος.", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "Δημιουργήστε πρώτα έγγραφα", + "network_fail": "Δεν είναι δυνατή η πρόσβαση στο τελικό σημείο API. Ελέγξτε τη σύνδεση δικτύου και δοκιμάστε ξανά.", + "offline": "Φαίνεται ότι είστε εκτός σύνδεσης. Τα δεδομένα σε αυτόν τον χώρο εργασίας ενδέχεται να μην είναι ενημερωμένα.", + "offline_short": "Φαίνεται ότι είστε εκτός σύνδεσης.", + "post_request_tests": "Τα σενάρια δοκιμής γράφονται σε JavaScript και εκτελούνται μετά τη λήψη της απάντησης.", + "pre_request_script": "Τα σενάρια προ-αίτησης είναι γραμμένα σε JavaScript και εκτελούνται πριν από την αποστολή του αιτήματος.", + "script_fail": "Φαίνεται ότι υπάρχει ένα σφάλμα στο σενάριο πριν από το αίτημα. Ελέγξτε το παρακάτω σφάλμα και διορθώστε το σενάριο ανάλογα.", + "post_request_script_fail": "There seems to be an error with post-request script. Please fix the errors and run tests again", + "post_request_script": "Γράψτε ένα δοκιμαστικό σενάριο για να αυτοματοποιήσετε τον εντοπισμό σφαλμάτων." + }, + "hide": { + "collection": "Σύμπτυξη Panel Συλλογών", + "more": "Κρύψου περισσότερο", + "preview": "Απόκρυψη προεπισκόπησης", + "sidebar": "Απόκρυψη πλευρικής γραμμής" + }, + "import": { + "collections": "Εισαγωγή συλλογών", + "curl": "Εισαγωγή cURL", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "Η εισαγωγή απέτυχε", + "from_file": "Import from File", + "from_gist": "Εισαγωγή από το Gist", + "from_gist_description": "Εισαγωγή από Gist URL", + "from_insomnia": "Εισαγωγή από Insomnia", + "from_insomnia_description": "Εισαγωγή από Συλλογή Insomnia", + "from_json": "Εισαγωγή από Hoppscotch", + "from_json_description": "Εισαγωγή από αρχείο συλλογών Hoppscotch", + "from_my_collections": "Εισαγωγή από τις Συλλογές μου", + "from_my_collections_description": "Εισαγωγή από αρχείο οι Συλλογές μου", + "from_openapi": "Εισαγωγή από OpenAPI", + "from_openapi_description": "Εισαγωγή από αρχειο προδιαγραφών OpenAPI (YML/JSON)", + "from_postman": "Εισαγωγή από Postman", + "from_postman_description": "Εισαγωγή Συλλογής από Postman", + "from_url": "Εισαγωγή από URL", + "gist_url": "Εισαγάγετε Gist URL", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "Δεν μπορέσαμε να πάρουμε δεδομένα από το url", + "import_from_url_invalid_file_format": "Σφάλμα κατά την εισαγωγή των Συλλογών", + "import_from_url_invalid_type": "Μη υποστηριζόμενος τύπος. Αποδεκτές τιμές είναι 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Η εισαγωγή των Συλλογών ήταν επιτυχής", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Εισαγωγή συλλογών αρχείο JSON Hoppscotch Collections", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "Εισαγωγή", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled." + } + }, + "layout": { + "collapse_collection": "Σύμπτυξη ή επέκταση Συλλογών", + "collapse_sidebar": "Σύμπτυξη ή επέκταση του sidebar", + "column": "Κατακόρυφη Διάταξη", + "name": "Διάταξη", + "row": "Οριζόντια Διάταξη" + }, + "modal": { + "close_unsaved_tab": "You have unsaved changes", + "collections": "Συλλογές", + "confirm": "Επιβεβαιώνω", + "customize_request": "Customize Request", + "edit_request": "Αίτημα Επεξεργασίας", + "import_export": "Εισαγωγή εξαγωγή", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "You are already subscribed to this topic.", + "clean_session": "Clean Session", + "clear_input": "Clear input", + "clear_input_on_send": "Clear input on send", + "client_id": "Client ID", + "color": "Pick a color", + "communication": "Επικοινωνία", + "connection_config": "Connection Config", + "connection_not_authorized": "This MQTT connection does not use any authentication.", + "invalid_topic": "Please provide a topic for the subscription", + "keep_alive": "Keep Alive", + "log": "Κούτσουρο", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "Μήνυμα", + "new": "New Subscription", + "not_connected": "Please start a MQTT connection first.", + "publish": "Δημοσιεύω", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Εγγραφείτε", + "topic": "Θέμα", + "topic_name": "Όνομα θέματος", + "topic_title": "Δημοσίευση / Εγγραφή θέματος", + "unsubscribe": "Κατάργηση εγγραφής", + "url": "URL" + }, + "navigation": { + "doc": "Έγγραφα", + "graphql": "GraphQL", + "profile": "Profile", + "realtime": "Πραγματικός χρόνος", + "rest": "REST", + "settings": "Ρυθμίσεις" + }, + "preRequest": { + "javascript_code": "Κώδικας JavaScript", + "learn": "Διαβάστε την τεκμηρίωση", + "script": "Σενάριο προ-αιτήματος", + "snippets": "Αποσπάσματα" + }, + "profile": { + "app_settings": "Ρυθμίσεις Εφαρμογής", + "default_hopp_displayname": "Ανώνυμος Χρήστης", + "editor": "Editor", + "editor_description": "Οι Editors μπορούν να προσθέσουν, επεξεργαστούν και διαγράψουν αιτήματα.", + "email_verification_mail": "Έχει αποσταλεί ένα μήνυμα επιβεβαίωσης στην διεύθυνση email σας. Παρακαλούμε κάντε κλικ στο Link που περιέχει το email για να επιβεβαιώσετε την διεύθυνση email σας.", + "no_permission": "Δεν έχετε άδεια για την εκτέλεση αυτής της λειτουργίας.", + "owner": "Ιδιοκτήτης", + "owner_description": "Οι Ιδιοκτήτες μπορούν να προσθέσουν, επεξεργαστούν και διαγράψουν αιτήματα, συλλογές και μέλη ομάδων.", + "roles": "Ρόλοι", + "roles_description": "Οι ρόλοι χρησιμοποιούνται για να ελέγχεται η πρόσβαση και ο χειρισμός μιας κοινοποιημένης συλλογής.", + "updated": "Το προφίλ ενημερώθηκε", + "viewer": "Viewer", + "viewer_description": "Οι Viewers μπορούν μόνο να δούν και χρησιμοποιήσουν αιτήματα." + }, + "remove": { + "star": "Αφαίρεση αστεριού" + }, + "request": { + "added": "Το αίτημα προστέθηκε", + "authorization": "Εξουσιοδότηση", + "body": "Σώμα αιτήματος", + "choose_language": "Διάλεξε γλώσσα", + "content_type": "Τύπος περιεχομένου", + "content_type_titles": { + "others": "Others", + "structured": "Structured", + "text": "Text" + }, + "different_collection": "Cannot reorder requests from different collections", + "duplicated": "Request duplicated", + "duration": "Διάρκεια", + "enter_curl": "Εισαγάγετε cURL", + "generate_code": "Δημιουργία κώδικα", + "generated_code": "Παραγόμενος κώδικας", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "Λίστα κεφαλίδων", + "invalid_name": "Καταχωρίστε ένα όνομα για το αίτημα", + "method": "Μέθοδος", + "moved": "Request moved", + "name": "Αίτημα ονόματος", + "new": "Νέο Αίτημα", + "order_changed": "Request Order Updated", + "override": "Override", + "override_help": "Set Content-Type in Headers", + "overriden": "Overridden", + "parameter_list": "Παράμετροι ερωτήματος", + "parameters": "Παράμετροι", + "path": "Μονοπάτι", + "payload": "Φορτίο επί πληρωμή", + "query": "Ερώτηση", + "raw_body": "Σώμα Ακατέργαστου Αιτήματος", + "rename": "Rename Request", + "renamed": "Το αίτημα μετονομάστηκε", + "request_variables": "Request variables", + "run": "Τρέξιμο", + "save": "Σώσει", + "save_as": "Αποθήκευση ως", + "saved": "Το αίτημα αποθηκεύτηκε", + "share": "Μερίδιο", + "share_description": "Κοινοποίηση Hoppscotch στους φίλους σου", + "share_request": "Share Request", + "stop": "Stop", + "title": "Αίτηση", + "type": "Τύπος αιτήματος", + "url": "URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "Μεταβλητές", + "view_my_links": "Προβολή των links μου", + "copy_link": "Αντιγραφή συνδέσμου" + }, + "response": { + "audio": "Audio", + "body": "Σώμα απόκρισης", + "filter_response_body": "Filter JSON response body (uses jq syntax)", + "headers": "Κεφαλίδες", + "html": "HTML", + "image": "Εικόνα", + "json": "JSON", + "pdf": "PDF", + "preview_html": "Προεπισκόπηση HTML", + "raw": "Ακατέργαστος", + "size": "Μέγεθος", + "status": "Κατάσταση", + "time": "χρόνος", + "title": "Απάντηση", + "video": "Video", + "waiting_for_connection": "περιμένοντας τη σύνδεση", + "xml": "XML" + }, + "settings": { + "accent_color": "Χρώμα προφοράς", + "account": "λογαριασμός", + "account_deleted": "Your account has been deleted", + "account_description": "Προσαρμόστε τις ρυθμίσεις του λογαριασμού σας.", + "account_email_description": "Η κύρια διεύθυνση email σας.", + "account_name_description": "Αυτό είναι το εμφανιζόμενο όνομά σας.", + "additional": "Additional Settings", + "background": "Ιστορικό", + "black_mode": "Μαύρος", + "choose_language": "Διάλεξε γλώσσα", + "dark_mode": "Σκοτάδι", + "delete_account": "Delete account", + "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", + "expand_navigation": "Επέκταση navigation", + "experiments": "Πειράματα", + "experiments_notice": "Αυτή είναι μια συλλογή πειραμάτων που δουλεύουμε και που μπορεί να αποδειχθούν χρήσιμα, διασκεδαστικά, και τα δύο ή κανένα από τα δύο. Δεν είναι οριστικά και μπορεί να μην είναι σταθερά, οπότε αν συμβεί κάτι υπερβολικά περίεργο, μην πανικοβληθείτε. Απλώς απενεργοποιήστε το πράγμα. Τα αστεία στην άκρη,", + "extension_ver_not_reported": "Δεν αναφέρεται", + "extension_version": "Έκταση επέκτασης", + "extensions": "Επεκτάσεις", + "extensions_use_toggle": "Χρησιμοποιήστε την επέκταση του προγράμματος περιήγησης για να στείλετε αιτήματα (εάν υπάρχουν)", + "follow": "Ακολούθησε Μας", + "interceptor": "Αναχαιτιστής", + "interceptor_description": "Middleware μεταξύ εφαρμογής και API.", + "language": "Γλώσσα", + "light_mode": "Φως", + "official_proxy_hosting": "Το Official Proxy φιλοξενείται από το Hoppscotch.", + "profile": "Profile", + "profile_description": "Ανανεώστε τις λεπτομέρειες του προφίλ σας", + "profile_email": "Διευθυνση Email", + "profile_name": "Όνομα Προφίλ", + "proxy": "Πληρεξούσιο", + "proxy_url": "URL διακομιστή μεσολάβησης", + "proxy_use_toggle": "Χρησιμοποιήστε το ενδιάμεσο διακομιστή μεσολάβησης για να στείλετε αιτήματα", + "read_the": "Διαβάστε το", + "reset_default": "Επαναφορά στο προκαθορισμένο", + "short_codes": "Short codes", + "short_codes_description": "Short codes πυ δημιουργήσατε.", + "sidebar_on_left": "Sidebar στα αριστερά", + "sync": "Συγχρονίζω", + "sync_collections": "Συλλογές", + "sync_description": "Αυτές οι ρυθμίσεις συγχρονίζονται με το cloud.", + "sync_environments": "Περιβάλλοντα", + "sync_history": "Ιστορία", + "system_mode": "Σύστημα", + "telemetry": "Τηλεμετρία", + "telemetry_helps_us": "Η τηλεμετρία μας βοηθά να εξατομικεύουμε τις λειτουργίες μας και να σας προσφέρουμε την καλύτερη εμπειρία.", + "theme": "Θέμα", + "theme_description": "Προσαρμόστε το θέμα της εφαρμογής σας.", + "use_experimental_url_bar": "Χρήση πειραματικής γραμμής URL με ανάδειξη περιβάλλοντος", + "user": "Χρήστης", + "verified_email": "Επαληθευμένο email", + "verify_email": "Επαλήθευση email" + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Κλείσιμο τρέχοντος μενού", + "command_menu": "Μενού αναζήτησης & εντολών", + "help_menu": "Μενού βοήθειας", + "show_all": "Συντομεύσεις πληκτρολογίου", + "title": "Γενικός" + }, + "miscellaneous": { + "invite": "Προσκαλέστε άτομα στο Hoppscotch", + "title": "Διάφορα" + }, + "navigation": { + "back": "Επιστροφή στην προηγούμενη σελίδα", + "documentation": "Μεταβείτε στη σελίδα Τεκμηρίωση", + "forward": "Προχωρήστε στην επόμενη σελίδα", + "graphql": "Μεταβείτε στη σελίδα GraphQL", + "profile": "Πάνε στην σελίδα του Προφίλ", + "realtime": "Μεταβείτε στη σελίδα σε πραγματικό χρόνο", + "rest": "Μεταβείτε στη σελίδα REST", + "settings": "Μεταβείτε στη σελίδα Ρυθμίσεις", + "title": "Πλοήγηση" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "Επιλέξτε ΔΙΑΓΡΑΦΗ μεθόδου", + "get_method": "Επιλέξτε μέθοδο GET", + "head_method": "Επιλέξτε μέθοδο HEAD", + "import_curl": "Import cURL", + "method": "Μέθοδος", + "next_method": "Επιλέξτε Επόμενη μέθοδος", + "post_method": "Επιλέξτε μέθοδο POST", + "previous_method": "Επιλέξτε Προηγούμενη μέθοδος", + "put_method": "Επιλέξτε μέθοδο PUT", + "rename": "Rename Request", + "reset_request": "Επαναφορά αιτήματος", + "save_request": "Save Request", + "save_to_collections": "Αποθήκευση στις Συλλογές", + "send_request": "Στείλε αίτημα", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "Αίτηση", + "copy_request_link": "Αντιγραφή συνδέσμου αιτήματος" + }, + "response": { + "copy": "Αντιγραφή response στο πρόχειρο", + "download": "Κατέβασμα response ώς αρχείο", + "title": "Response" + }, + "theme": { + "black": "Αλλαγή θέματος στη Μαύρη Λειτουργία", + "dark": "Αλλαγή θέματος στη Σκούρη Λειτουργία", + "light": "Αλλαγή θέματος στη Ανοιχτή Λειτουργία", + "system": "Αλλαγή θέματος στη Λειτουργία Συστήματος", + "title": "Θέμα" + } + }, + "show": { + "code": "Εμφάνιση κωδικού", + "collection": "Επέκταση Collection Panel", + "more": "Δείτε περισσότερα", + "sidebar": "Εμφάνιση πλευρικής γραμμής" + }, + "socketio": { + "communication": "Επικοινωνία", + "connection_not_authorized": "Η συγκεκριμένη σύνδεση SocketIO δεν χρησιμοποιεί αυθεντικοποίηση.", + "event_name": "Όνομα συμβάντος", + "events": "Εκδηλώσεις", + "log": "Logs", + "url": "URL" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current team", + "edit": "Edit current team", + "invite": "Invite people to team", + "new": "Create new team", + "switch_to_personal": "Switch to your personal workspace", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "Τύπος συμβάντος", + "log": "Logs", + "url": "URL" + }, + "state": { + "bulk_mode": "Μαζική επεξεργασία", + "bulk_mode_placeholder": "Οι εγγραφές χωρίζονται με νέα γραμμή\nΤα κλειδιά και οι τιμές διαχωρίζονται με:\nΠροσθήκη # σε οποιαδήποτε γραμμή που θέλετε να προσθέσετε αλλά διατηρήστε απενεργοποιημένη", + "cleared": "Εκκαθαρίστηκε", + "connected": "Συνδεδεμένος", + "connected_to": "Συνδέθηκε με το {name}", + "connecting_to": "Σύνδεση με {name} ...", + "connection_error": "Η Σύνδεση απέτυχε", + "connection_failed": "Αποτυχής Σύνδεση", + "connection_lost": "Η Σύνδεση χάθηκε", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "Αντιγράφηκε στο πρόχειρο", + "deleted": "Διαγράφηκε", + "deprecated": "ΚΑΤΑΡΓΗΘΗΚΕ", + "disabled": "άτομα με ειδικές ανάγκες", + "disconnected": "Ασύνδετος", + "disconnected_from": "Αποσυνδέθηκε από το {name}", + "docs_generated": "Δημιουργήθηκε τεκμηρίωση", + "download_failed": "Download failed", + "download_started": "Η λήψη ξεκίνησε", + "enabled": "Ενεργοποιημένο", + "file_imported": "Το αρχείο εισήχθη", + "finished_in": "Ολοκληρώθηκε σε {duration} ms", + "hide": "Hide", + "history_deleted": "Το ιστορικό διαγράφηκε", + "linewrap": "Τυλίξτε γραμμές", + "loading": "Φόρτωση...", + "message_received": "Μήνυμα: {message} ήρθε με θέμα: {topic}", + "mqtt_subscription_failed": "Κάτι πήγε στραβά κατα την εγγραφή στο Θέμα: {topic}", + "none": "Κανένας", + "nothing_found": "Δεν βρέθηκε τίποτα για", + "published_error": "Κάτι πήγε στραβά κατα την αποστολή του μηνύματος: {topic} με θέμα: {message}", + "published_message": "Δημοσιευμένο Μηνυμα: {message} με θέμα: {topic}", + "reconnection_error": "Αποτυχία επανασύνδεσης", + "show": "Show", + "subscribed_failed": "Αποτυχία εγγραφής στο Θέμα: {topic}", + "subscribed_success": "Επιτυχία εγγραφής στο Θέμα: {topic}", + "unsubscribed_failed": "Αποτυχία απεγγραφής στο Θέμα: {topic}", + "unsubscribed_success": "Επιτυχία απεγγραφής στο Θέμα: {topic}", + "waiting_send_request": "Αναμονή για αποστολή αιτήματος" + }, + "support": { + "changelog": "Διαβάστε περισσότερα για τις τελευταίες κυκλοφορίες", + "chat": "Ερωτήσεις; Μίλα μαζί μας!", + "community": "Κάντε ερωτήσεις και βοηθήστε τους άλλους", + "documentation": "Διαβάστε περισσότερα για Hoppscotch", + "forum": "Κάντε ερωτήσεις και λάβετε απαντήσεις", + "github": "Follow us on Github", + "shortcuts": "Περιηγηθείτε πιο γρήγορα στην εφαρμογή", + "title": "Υποστήριξη", + "twitter": "Ακολουθήστε μας στο Twitter", + "team": "Επικοινωνήστε με την ομάδα" + }, + "tab": { + "authorization": "Εξουσιοδότηση", + "body": "Σώμα", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "Συλλογές", + "documentation": "Τεκμηρίωση", + "duplicate": "Duplicate Tab", + "environments": "Environments", + "headers": "Κεφαλίδες", + "history": "Ιστορία", + "mqtt": "MQTT", + "parameters": "Παράμετροι", + "pre_request_script": "Σενάριο προπαραγγελίας", + "queries": "Ερωτήματα", + "query": "Ερώτηση", + "schema": "Schema", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Υποδοχή.IO", + "sse": "SSE", + "tests": "Δοκιμές", + "types": "Τύποι", + "variables": "Μεταβλητές", + "websocket": "WebSocket" + }, + "team": { + "already_member": "Είστε ήδη μέλος σε αυτή την ομάδα. Επικοινωνήστε με τον Ιδιοκτήτη της Ομάδας.", + "create_new": "Δημιουργία νέας ομάδας", + "deleted": "Η ομάδα διαγράφηκε", + "edit": "Επεξεργασία ομάδας", + "email": "ΗΛΕΚΤΡΟΝΙΚΗ ΔΙΕΥΘΥΝΣΗ", + "email_do_not_match": "Το Email δεν ταιριάζει με τις λεπτομέριες του προφιλ σας. Επικοινωνήστε με τον Ιδιοκτήτη της Ομάδας.", + "exit": "Έξοδος από την ομάδα", + "exit_disabled": "Μόνο ο ιδιοκτήτης δεν μπορεί να αποχωρήσει από την ομάδα", + "failed_invites": "Failed invites", + "invalid_coll_id": "Invalid collection ID", + "invalid_email_format": "Η μορφή ηλεκτρονικού ταχυδρομείου δεν είναι έγκυρη", + "invalid_id": "Μή εγκυρο αναγνωριστικό ομάδας. Επικοινωνήστε με τον Ιδιοκτήτη της Ομάδας.", + "invalid_invite_link": "Μη έγκυρος σύνδεσμος πρόσκλησης", + "invalid_invite_link_description": "Ο σύνδεσμος πρόσκλησης δεν είναι έγκυρος. Επικοινωνήστε με τον Ιδιοκτήτη της Ομάδας.", + "invalid_member_permission": "Δώστε έγκυρη άδεια στο μέλος της ομάδας", + "invite": "Πρόσκληση", + "invite_more": "Πρόσκληση περισσοτέρων", + "invite_tooltip": "Πρόσκληση χρηστών σε αυτό το workspace", + "invited_to_team": "{owner} σου έκανε πρόσκληση για να μπεις στην {team}", + "join": "Πρόσκληση Αποδεκτή", + "join_team": "Γίνε Μέλος {team}", + "joined_team": "Μπήκες στην ομάδα: {team}", + "joined_team_description": "Είστε πλέον μέλος αυτής της ομάδας", + "left": "Έφυγες από την ομάδα", + "login_to_continue": "Συνδέσου για να συνεχίσεις", + "login_to_continue_description": "Πρέπει να είσαι συνδεδεμένος για να μπεις σε κάποια ομάδα.", + "logout_and_try_again": "Αποσύνδεση και Σύνδεση με άλλο λογαριασμό", + "member_has_invite": "Αυτή η διεύθυνση email έχει ήδη πρόσκληση. This email ID already has an invite. Επικοινωνήστε με τον Ιδιοκτήτη της Ομάδας.", + "member_not_found": "Το μέλος δεν βρέθηκε. Επικοινωνήστε με τον Ιδιοκτήτη της Ομάδας.", + "member_removed": "Ο χρήστης καταργήθηκε", + "member_role_updated": "Οι ρόλοι των χρηστών ενημερώθηκαν", + "members": "Μέλη", + "more_members": "+{count} more", + "name_length_insufficient": "Το όνομα της ομάδας πρέπει να έχει τουλάχιστον 6 χαρακτήρες", + "name_updated": "Το όνομα ομάδας ανανεώθηκε", + "new": "Νέα Ομάδα", + "new_created": "Δημιουργήθηκε νέα ομάδα", + "new_name": "Η νέα μου ομάδα", + "no_access": "Δεν έχετε πρόσβαση επεξεργασίας σε αυτές τις συλλογές", + "no_invite_found": "Δέν βρέθηκε πρόσκληση. Επικοινωνήστε με τον Ιδιοκτήτη της Ομάδας.", + "no_request_found": "Request not found.", + "not_found": "Η ομάδα δεν βρέθηκε. Επικοινωνήστε με τον Ιδιοκτήτη της Ομάδας.", + "not_valid_viewer": "Δεν είστε έγκυρος viewer. Επικοινωνήστε με τον Ιδιοκτήτη της Ομάδας.", + "parent_coll_move": "Cannot move collection to a child collection", + "pending_invites": "Εκκρεμης προσκλήσεις", + "permissions": "Άδειες", + "same_target_destination": "Same target and destination", + "saved": "Η ομάδα σώθηκε", + "select_a_team": "Επιλογή ομάδας", + "success_invites": "Success invites", + "title": "Της ομάδας", + "we_sent_invite_link": "Στείλαμε έναν σύνδεσμο πρόσκλησης σε όλους!", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "Ζητήστε από όλους όσους στείλατε πρόσκληση να ελέγξουν τα email τους. Click στον σύνδεσμο για εισαγωγή στην ομάδα.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "Εγγραφείτε στο πρόγραμμα beta για πρόσβαση σε ομάδες." + }, + "team_environment": { + "deleted": "Το περιβάλλον διαγράφηκε", + "duplicate": "Το περιβάλλον αντιγράφηκε", + "not_found": "Το περιβάλλον δεν βρέθηκε." + }, + "test": { + "failed": "Αποτυχία test", + "javascript_code": "Κώδικας JavaScript", + "learn": "Διαβάστε την τεκμηρίωση", + "passed": "Επιτυχία test", + "report": "Αναφορά δοκιμής", + "results": "Αποτελέσματα δοκιμών", + "script": "Γραφή", + "snippets": "Αποσπάσματα" + }, + "websocket": { + "communication": "Επικοινωνία", + "log": "Κούτσουρο", + "message": "Μήνυμα", + "protocols": "Πρωτόκολλα", + "url": "URL" + }, + "workspace": { + "change": "Change workspace", + "personal": "My Workspace", + "other_workspaces": "My Workspaces", + "team": "Team Workspace", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "Δράσεις", + "created_on": "Created on", + "deleted": "Το Shortcode διαγράφηκε", + "method": "Method", + "not_found": "Το Shortcode δεν βρέθηκε", + "short_code": "Short code", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json new file mode 100644 index 0000000..1f52c23 --- /dev/null +++ b/packages/hoppscotch-common/locales/en.json @@ -0,0 +1,2381 @@ +{ + "action": { + "add": "Add", + "autoscroll": "Autoscroll", + "cancel": "Cancel", + "choose_file": "Choose a file", + "choose_workspace": "Choose a workspace", + "choose_collection": "Choose a collection", + "select_workspace": "Select a workspace", + "clear": "Clear", + "clear_all": "Clear all", + "clear_cache": "Clear Cache", + "clear_history": "Clear all History", + "clear_unpinned": "Clear Unpinned", + "clear_response": "Clear Response", + "close": "Close", + "confirm": "Confirm", + "connect": "Connect", + "connecting": "Connecting", + "copy": "Copy", + "create": "Create", + "delete": "Delete", + "disconnect": "Disconnect", + "dismiss": "Dismiss", + "done": "Done", + "dont_save": "Don't save", + "download_file": "Download file", + "download_test_report": "Download test report", + "drag_to_reorder": "Drag to reorder", + "duplicate": "Duplicate", + "edit": "Edit", + "filter": "Filter", + "go_back": "Go back", + "go_forward": "Go forward", + "group_by": "Group by", + "hide_secret": "Hide secret", + "label": "Label", + "learn_more": "Learn more", + "download_here": "Download here", + "less": "Less", + "more": "More", + "new": "New", + "no": "No", + "open": "Open", + "open_workspace": "Open workspace", + "paste": "Paste", + "prettify": "Prettify", + "properties": "Properties", + "register": "Register", + "remove": "Remove", + "remove_instance": "Remove instance", + "rename": "Rename", + "restore": "Restore", + "retry": "Retry", + "save": "Save", + "save_as_example": "Save as example", + "add_example": "Add example", + "invalid_request": "Invalid request data", + "scroll_to_bottom": "Scroll to bottom", + "scroll_to_top": "Scroll to top", + "search": "Search", + "send": "Send", + "share": "Share", + "show_secret": "Show secret", + "sort": "Sort", + "start": "Start", + "starting": "Starting", + "stop": "Stop", + "to_close": "to close", + "to_navigate": "to navigate", + "to_select": "to select", + "turn_off": "Turn off", + "turn_on": "Turn on", + "undo": "Undo", + "unpublish": "Unpublish", + "yes": "Yes", + "verify": "Verify", + "enable": "Enable", + "disable": "Disable", + "assign": "Assign" + }, + "activity_logs": { + "ACTIVITY_LOG_DELETE": "Activity log has been deleted", + "WORKSPACE_CREATE": "Created new workspace {name}", + "WORKSPACE_RENAME": "Renamed workspace from {old_name} to {new_name}", + "WORKSPACE_USER_ADD": "{user} was added to the workspace as {role}", + "WORKSPACE_USER_INVITE": "{user} was invited by {inviteeEmail} as {role}", + "WORKSPACE_USER_INVITE_REVOKE": "Revoked invitation of {inviteeEmail} as {inviteeRole}", + "WORKSPACE_USER_INVITE_ACCEPT": "{inviteeEmail} accepted the invitation as {inviteeRole}", + "WORKSPACE_USER_REMOVE": "{user} was removed from the workspace", + "WORKSPACE_USER_ROLE_UPDATE": "{user}'s role was updated from {old_role} to {new_role}", + "COLLECTION_CREATE": "Created new collection {title}", + "COLLECTION_RENAME": "Renamed collection from {old_title} to {new_title}", + "COLLECTION_IMPORT": "Imported {count} collection(s)", + "COLLECTION_DELETE": "Deleted collection {title}", + "COLLECTION_DUPLICATE": "Duplicated collection {parentTitle}", + "REQUEST_CREATE": "Created new request {title}", + "REQUEST_RENAME": "Renamed request from {old_title} to {new_title}", + "REQUEST_DELETE": "Deleted request {title}" + }, + "add": { + "new": "Add new", + "star": "Add star" + }, + "agent": { + "registration_instruction": "Please register Hoppscotch Agent with your web client to continue.", + "enter_otp_instruction": "Please enter the verification code generated by Hoppscotch Agent and complete the registration", + "otp_label": "Verification Code", + "processing": "Processing your request...", + "not_running_title": "Agent not detected", + "registration_title": "Agent registration", + "verify_ssl_certs": "Verify SSL Certificates", + "ca_certs": "CA Certificates", + "client_certs": "Client Certificates", + "use_http_proxy": "Use HTTP Proxy", + "proxy_capabilities": "Hoppscotch Agent supports HTTP/HTTPS/SOCKS proxies along with NTLM and Basic Auth in those proxies. Include the username and password for the proxy authentication in the URL itself.", + "add_cert_file": "Add Certificate File", + "add_client_cert": "Add Client Certificate", + "add_key_file": "Add Key File", + "domain": "Domain", + "cert": "Certificate", + "key": "Key", + "pfx_or_pkcs": "PFX/PKCS12", + "pfx_or_pkcs_file": "PFX/PKCS12 File", + "add_pfx_or_pkcs_file": "Add PFX/PKCS12 File" + }, + "app": { + "additional_links": { + "macOS": "macOS", + "windows": "Windows", + "linux": "Linux", + "web_app": "Web App", + "cli": "CLI" + }, + "downloads": "Downloads", + "chat_with_us": "Chat with us", + "contact_us": "Contact us", + "cookies": "Cookies", + "copy": "Copy", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Copy User Auth Token", + "developer_option": "Developer options", + "developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.", + "discord": "Discord", + "documentation": "Documentation", + "github": "GitHub", + "help": "Help & feedback", + "home": "Home", + "invite": "Invite", + "invite_description": "Hoppscotch is an open source API development ecosystem. We designed a simple and intuitive interface for creating and managing your APIs. Hoppscotch is a tool that helps you build, test, document and share your APIs.", + "invite_your_friends": "Invite your friends", + "join_discord_community": "Join our Discord community", + "keyboard_shortcuts": "Keyboard shortcuts", + "name": "Hoppscotch", + "new_version_found": "New version found. Refresh to update.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Options", + "powered_by": "Powered by Hoppscotch", + "proxy_privacy_policy": "Proxy privacy policy", + "reload": "Reload", + "search": "Search and commands", + "share": "Share", + "shortcuts": "Shortcuts", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "Spotlight", + "status": "Status", + "status_description": "Check the status of the website", + "terms_and_privacy": "Terms and privacy", + "twitter": "Twitter", + "type_a_command_search": "Type a command or search…", + "we_use_cookies": "We use cookies", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "What's new?", + "see_whats_new": "See what's new", + "wiki": "Wiki", + "collapse_sidebar": "Collapse Sidebar", + "continue_to_dashboard": "Continue to Dashboard", + "expand_sidebar": "Expand Sidebar", + "no_name": "No name", + "open_navigation": "Open Navigation", + "read_documentation": "Read Documentation", + "default": "default: {value}" + }, + "auth": { + "account_deactivated": "Your account has been deactivated. Contact the admin to get access.", + "account_exists": "Account exists with different credential - Login to link both accounts", + "all_sign_in_options": "All sign in options", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "Continue with Email", + "continue_with_github": "Continue with GitHub", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Continue with Google", + "continue_with_microsoft": "Continue with Microsoft", + "email": "Email", + "logged_out": "Logged out", + "login": "Login", + "login_success": "Successfully logged in", + "login_to_hoppscotch": "Login to Hoppscotch", + "logout": "Logout", + "re_enter_email": "Re-enter email", + "send_magic_link": "Send a magic link", + "sync": "Sync", + "we_sent_magic_link": "We sent you a magic link!", + "we_sent_magic_link_description": "Check your inbox - we sent an email to {email}. It contains a magic link that will log you in." + }, + "authorization": { + "generate_token": "Generate Token", + "refresh_token": "Refresh Token", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "Include in URL", + "inherited_from": "Inherited {auth} from parent collection {collection} ", + "learn": "Learn how", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "no_refresh_token_present": "No Refresh Token present. Please run the token generation flow again", + "refresh_token_request_failed": "Refresh token request failed", + "token_refreshed_successfully": "Token refreshed successfully", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials", + "label_send_as": "Client Authentication", + "label_send_in_body": "Send Credentials in Body", + "label_send_as_basic_auth": "Send Credentials as Basic Auth", + "enter_value": "Enter value", + "auth_request": "Auth Request", + "token_request": "Token Request", + "refresh_request": "Refresh Request", + "send_in": "Send In" + }, + "pass_key_by": "Pass by", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "Password", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "Token", + "access_token": "Access Token", + "client_token": "Client Token", + "client_secret": "Client Secret", + "timestamp": "Timestamp", + "host": "Host", + "type": "Authorization Type", + "username": "Username", + "advance_config": "Advanced Configuration", + "advance_config_description": "Hoppscotch automatically assigns default values to certain fields if no explicit value is provided", + "algorithm": "Algorithm", + "payload": "Payload", + "secret": "Secret", + "aws_signature": { + "access_key": "Access Key", + "secret_key": "Secret Key", + "service_name": "Service Name", + "aws_region": "AWS Region", + "service_token": "Service Token" + }, + "digest": { + "realm": "Realm", + "nonce": "Nonce", + "algorithm": "Algorithm", + "qop": "qop", + "nonce_count": "Nonce Count", + "client_nonce": "Client Nonce", + "opaque": "Opaque", + "disable_retry": "Disable Retrying Request" + }, + "akamai": { + "headers_to_sign": "Headers to Sign", + "max_body_size": "Max Body Size" + }, + "hawk": { + "id": "HAWK Auth ID", + "key": "HAWK Auth Key", + "ext": "ext", + "app": "app", + "dlg": "dlg", + "include": "Include Payload Hash" + }, + "jwt": { + "params_name": "Params Name", + "param_name": "Parameter Name", + "header_prefix": "Header Prefix", + "placeholder_request_header": "Request header prefix", + "placeholder_request_param": "Request params name", + "secret_base64_encoded": "Secret Base64 Encoded", + "headers": "JWT Headers", + "private_key": "Private Key", + "placeholder_headers": "JWT Headers" + }, + "ntlm": { + "domain": "Domain", + "workstation": "Workstation", + "disable_retrying_request": "Disable Retrying Request" + }, + "asap": { + "issuer": "Issuer", + "audience": "Audience", + "expires_in": "Expires In", + "key_id": "Key ID", + "optional_config": "Optional Configuration", + "subject": "Subject", + "additional_claims": "Additional Claims" + } + }, + "collection": { + "title": "Collection", + "run": "Run Collection", + "created": "Collection created", + "different_parent": "Cannot reorder collection with different parent", + "edit": "Edit Collection", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "Please provide a name for the collection", + "invalid_root_move": "Collection already in the root", + "moved": "Moved Successfully", + "my_collections": "Personal Collections", + "name": "My New Collection", + "name_length_insufficient": "Collection name should be at least 3 characters long", + "new": "New Collection", + "order_changed": "Collection Order Updated", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "Collection renamed", + "request_in_use": "Request in use", + "save_as": "Save as", + "save_to_collection": "Save to Collection", + "select": "Select a Collection", + "select_location": "Select location", + "sorted": "Collection sorted", + "details": "Details", + "duplicated": "Collection duplicated" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "delete_all_activity_logs": "Are you sure you want to delete all activity logs?", + "exit_team": "Are you sure you want to leave this workspace?", + "logout": "Are you sure you want to logout?", + "remove_collection": "Are you sure you want to permanently delete this collection?", + "remove_environment": "Are you sure you want to permanently delete this environment?", + "remove_folder": "Are you sure you want to permanently delete this folder?", + "remove_history": "Are you sure you want to permanently delete all history?", + "remove_request": "Are you sure you want to permanently delete this request?", + "remove_response": "Are you sure you want to permanently delete this response?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "Are you sure you want to delete this workspace?", + "remove_telemetry": "Are you sure you want to opt-out of Telemetry?", + "request_change": "Are you sure you want to discard current request, unsaved changes will be lost.", + "save_unsaved_tab": "Do you want to save changes made in this tab?", + "sync": "Would you like to restore your workspace from cloud? This will discard your local progress.", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?", + "delete_mock_server": "Are you sure you want to delete this mock server?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable", + "encode_uri_component": "Encode URL component", + "decode_uri_component": "Decode URL component" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "invalid_domain": "Domain has invalid characters", + "enter_cookie_string": "Enter cookie string", + "http_only": "HttpOnly", + "http_only_info": "Not exposed to page scripts. Still sent on matching requests.", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "currentValue": "Current value {count}", + "description": "Description {count}", + "header": "Header {count}", + "initialValue": "Initial value {count}", + "key": "Key {count}", + "message": "Message {count}", + "parameter": "Parameter {count}", + "protocol": "Protocol {count}", + "value": "Value {count}", + "variable": "Variable {count}" + }, + "documentation": { + "add_description": "Add description for this collection here...", + "add_description_placeholder": "Add description here...", + "add_request_description": "Add description for request here...", + "auth": { + "access_key": "Access Key", + "access_token": "Access Token", + "add_to": "Add to", + "akamai_edgegrid": "Akamai EdgeGrid", + "algorithm": "Algorithm", + "api_key": "API Key", + "app_id": "App ID", + "auth_id": "Auth ID", + "auth_key": "Auth Key", + "auth_url": "Auth URL", + "aws_signature": "AWS Signature", + "basic_auth": "Basic Auth", + "bearer_token": "Bearer Token", + "client_id": "Client ID", + "client_nonce": "Client Nonce", + "client_secret": "Client Secret", + "client_token": "Client Token", + "delegation": "Delegation", + "digest_auth": "Digest Auth", + "extra_data": "Extra Data", + "grant_type": "Grant Type", + "hawk_auth": "HAWK Auth", + "headers_to_sign": "Headers to Sign", + "host": "Host", + "include_payload_hash": "Include Payload Hash", + "jwt_auth": "JWT Auth", + "max_body_size": "Max Body Size", + "no_auth": "No authentication", + "nonce": "Nonce", + "oauth_2": "OAuth 2.0", + "opaque": "Opaque", + "password": "Password", + "payload": "Payload", + "qop": "QOP", + "realm": "Realm", + "region": "Region", + "scope": "Scope", + "secret_key": "Secret Key", + "service_name": "Service Name", + "timestamp": "Timestamp", + "title": "Authentication", + "token_url": "Token URL", + "user": "User", + "username": "Username" + }, + "body": { + "content_type": "Content Type", + "no_body": "No body defined", + "title": "Body" + }, + "copied_to_clipboard": "Copied to clipboard!", + "curl": { + "click_to_load": "Click to load cURL command", + "copied": "cURL command copied to clipboard!", + "copy_to_clipboard": "Copy to clipboard", + "generating": "Generating cURL command...", + "load": "Load cURL", + "title": "cURL" + }, + "description": "Description", + "error_rendering_markdown": "Error rendering markdown:", + "fetching_documentation": "Fetching Documentation...", + "generate": "Generate documentation", + "generate_message": "Import any Hoppscotch collection to generate API documentation on-the-go.", + "headers": { + "no_headers": "No headers defined", + "title": "Headers" + }, + "hide_all_documentation": "Hide All Documentation", + "inherited_from": "Inherited from {name}", + "inherited_with_type": "Inherited {type} from {name}", + "key": "Key", + "loading": "Loading...", + "loading_collection_data": "Loading Collection Data...", + "no": "No", + "no_collection_data": "No collection data available", + "no_documentation_found": "No documentation found for folders or requests", + "no_request_data": "No request data available", + "no_requests_or_folders": "No requests or folders", + "not_set": "Not set", + "open_request_in_new_tab": "Open request in new tab", + "parameters": { + "no_params": "No parameters defined", + "title": "Parameters" + }, + "percent_complete": "% complete", + "processing_documentation": "Processing Documentation", + "publish": { + "already_published": "This collection is already published", + "auto_sync": "Auto-sync with collection", + "auto_sync_description": "Automatically update published docs when collection changes", + "button": "Publish", + "copy_url": "Copy URL", + "delete": "Delete Documentation", + "unpublish_doc": "Are you sure you want to unpublish the documentation?", + "delete_success": "Published documentation deleted successfully", + "doc_title": "Title", + "doc_version": "Version", + "edit_published_doc": "Edit Published Doc", + "last_updated": "Last Updated", + "metadata": "Metadata (JSON)", + "open_published_doc": "Open Published Documentation in new tab", + "publish_error": "Failed to publish documentation", + "publish_success": "Documentation published successfully!", + "published": "Published", + "published_url": "Published URL", + "title": "Publish Documentation", + "update_button": "Update", + "update_error": "Failed to update documentation", + "update_published_docs": "Update Published Docs", + "update_success": "Documentation updated successfully!", + "unpublish": "Unpublish", + "update_title": "Update Published Documentation", + "url_copied": "URL copied to clipboard!", + "view_published": "View Published Docs", + "view_title": "Published Documentation Snapshot", + "versions": "Versions", + "create_new_version": "Create New Version", + "invalid_version": "Version must only contain alphanumeric characters, dots, and hyphens", + "live": "Live", + "snapshot": "Snapshot", + "version_immutable": "Published versions are read-only snapshots", + "view_snapshot": "View Snapshot", + "current_version": "CURRENT", + "not_found": "Published documentation not found", + "no_doc_id": "No document ID provided", + "version_label": "v{version}", + "unpublish_version": "Unpublish this version", + "loading_snapshot": "Loading snapshot...", + "retry_snapshot": "Retry", + "sensitive_data_warning": "Please make sure no sensitive data is exposed in the published documentation.", + "snapshot_preview": "Snapshot Preview", + "snapshot_load_error": "Failed to load snapshot preview", + "snapshot_empty": "No requests or folders in this snapshot", + "snapshot_item_count": "{count} items", + "auto_sync_live_notice": "This version auto-syncs with the live collection", + "snapshot_promote_warning": "Enabling auto-sync will replace this frozen snapshot with the live collection tree. This cannot be undone.", + "live_freeze_notice": "Auto-sync will be turned off and this version will be frozen at the current collection state.", + "untitled_project": "Untitled Project", + "environment": "Environment", + "no_environment": "No environment", + "environment_description": "Attach an environment to resolve variables in the published documentation" + }, + "request_opened_in_new_tab": "Request opened in new tab!", + "response": { + "body": "Response Body", + "copy": "Copy response", + "example_copied": "Response example copied to clipboard!", + "example_copy_failed": "Failed to copy response example", + "headers": "Response Headers", + "no_examples": "No response examples available", + "title": "Response Examples" + }, + "save_error": "Error saving documentation", + "save_success": "Documentation saved successfully", + "saved_items_status": "Saved {success} items. Failed to save {failure} items.", + "show_all_documentation": "Show All Documentation", + "source": "Source", + "title": "Documentation", + "unsaved_changes": "You have {count} unsaved changes. Please save before closing.", + "untitled_collection": "Untitled Collection", + "untitled_request": "Untitled Request", + "value": "Value", + "variables": { + "no_vars": "No variables defined", + "title": "Variables" + }, + "yes": "Yes" + }, + "empty": { + "activity_logs": "No activity logs found", + "authorization": "This request does not use any authorization", + "body": "This request does not have a body", + "collection": "Collection is empty", + "collections": "Collections are empty", + "documentation": "Connect to a GraphQL endpoint to view documentation", + "empty_schema": "No schema found", + "endpoint": "Endpoint cannot be empty", + "environments": "Environments are empty", + "collection_variables": "Collection variables are empty", + "folder": "Folder is empty", + "headers": "This request does not have any headers", + "history": "History is empty", + "invites": "Invite list is empty", + "members": "Workspace is empty", + "parameters": "This request does not have any parameters", + "pending_invites": "There are no pending invites for this workspace", + "profile": "Login to view your profile", + "protocols": "Protocols are empty", + "request_variables": "This request does not have any request variables", + "schema": "Connect to a GraphQL endpoint to view schema", + "search_environment": "No matching environment found for", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "Subscriptions are empty", + "team_name": "Workspace name empty", + "teams": "You don't belong to any workspaces", + "tests": "There are no tests for this request", + "access_tokens": "Access tokens are empty", + "response": "No response received", + "mock_servers": "No mock servers found" + }, + "environment": { + "heading": "Environment", + "add_to_global": "Add to Global", + "added": "Environment variable added", + "create_new": "Create new environment", + "created": "Environment created", + "current_value": "Current value", + "deleted": "Environment variable deleted", + "duplicated": "Environment duplicated", + "edit": "Edit Environment", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create an environment", + "initial_value": "Initial value", + "invalid_name": "Please provide a name for the environment", + "list": "Environment variables", + "my_environments": "Personal Environments", + "name": "Name", + "nested_overflow": "Nested environment variables are limited to 10 levels", + "new": "New Environment", + "no_active_environment": "No active environment", + "no_environment": "No environment", + "no_environment_description": "No environments were selected. Choose what to do with the following variables.", + "quick_peek": "Environment Quick Peek", + "replace_all_current_with_initial": "Replace all current with initial", + "replace_all_initial_with_current": "Replace all initial with current", + "replace_current_with_initial": "Replace with initial", + "replace_initial_with_current": "Replace with current", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "Select environment", + "set": "Set environment", + "set_as_environment": "Set as environment", + "short_name": "Environment needs to have minimum 1 character in its name", + "team_environments": "Workspace Environments", + "title": "Environments", + "updated": "Environment updated", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Variable List", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "network": { + "heading": "Network Error", + "description": "Network connection failed. {message}: {cause}" + }, + "timeout": { + "heading": "Timeout Error", + "description": "Request timed out during {phase}. {message}" + }, + "certificate": { + "heading": "Certificate Error", + "description": "Invalid certificate. {message}: {cause}" + }, + "auth": { + "heading": "Authentication Error", + "description": "Access denied. {message}: {cause}" + }, + "proxy": { + "heading": "Proxy Error", + "description": "Proxy connection failed. {message}: {cause}" + }, + "parse": { + "heading": "Parse Error", + "description": "Failed to parse response. {message}: {cause}" + }, + "version": { + "heading": "Version Error", + "description": "Incompatible versions. {message}: {cause}" + }, + "abort": { + "heading": "Request Aborted", + "description": "Operation cancelled. {message}: {cause}" + }, + "unknown": { + "heading": "Unknown Error", + "description": "An unknown error occurred.", + "cause": "Unknown cause" + }, + "extension": { + "heading": "Extension error", + "description": "Failed running request on extension" + }, + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "This browser doesn't seems to have Server Sent Events support.", + "check_console_details": "Check console log for details.", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "cURL is not formatted properly", + "danger_zone": "Danger zone", + "delete_account": "Your account is currently the sole owner in these workspaces:", + "delete_account_description": "You must either remove yourself, transfer ownership, or delete these workspaces before you can delete your account.", + "delete_activity_log": "Failed to delete activity log", + "delete_all_activity_logs": "Failed to delete all activity logs", + "email_already_exists": "This email already exists with a different account. Please set a different email address.", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "Empty Request Name", + "fetch_activity_logs": "Failed to fetch activity logs", + "f12_details": "(F12 for details)", + "gql_prettify_invalid_query": "Couldn't prettify an invalid query, solve query syntax errors and try again", + "incomplete_config_urls": "Incomplete configuration URLs", + "incorrect_email": "Incorrect email", + "invalid_file_type": "Invalid file type for `{filename}`.", + "invalid_link": "Invalid link", + "invalid_link_description": "The link you clicked is invalid or expired.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "Invalid JSON", + "json_prettify_invalid_body": "Couldn't prettify an invalid body, solve json syntax errors and try again", + "network_error": "There seems to be a network error. Please try again.", + "network_fail": "Could not send request", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "No duration", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "No matches found", + "page_not_found": "This page could not be found", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "Could not execute pre-request script", + "something_went_wrong": "Something went wrong", + "subscription_error": "Failed to subscribe to the topic: {error}", + "post_request_script_fail": "Could not execute post-request script", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token", + "extension_not_found": "Extension not found", + "invalid_request": "Invalid request data" + }, + "export": { + "as_json": "Export as JSON", + "as_openapi": "Export as OpenAPI", + "as_yaml": "Export as YAML", + "choose_format": "Choose export format", + "collection": "Export collection", + "collections": "Export collections", + "format_hoppscotch": "Export as Hoppscotch JSON", + "format_openapi_json": "Export as OpenAPI 3.1 (JSON)", + "format_openapi_yaml": "Export as OpenAPI 3.1 (YAML)", + "openapi_lossy_summary": "OpenAPI export drops scripts, auth credentials, inactive items, duplicate paths, and duplicate response status codes.", + "create_secret_gist": "Create secret Gist", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "Login with GitHub to create secret gist", + "title": "Export", + "success": "Successfully exported" + }, + "file_upload": { + "choose_file": "Choose file", + "max_size_format": "Max 5MB. Supports JPEG, PNG, GIF, WebP", + "profile_photo_updated": "Profile photo updated successfully", + "profile_photo_removed": "Profile photo removed successfully", + "org_logo_updated": "Organization logo updated successfully", + "error_size_limit": "File size must be less than 5MB", + "error_invalid_format": "File must be an image (JPEG, PNG, GIF, or WebP)", + "error_invalid_upload_type": "Invalid upload type", + "error_invalid_org_id": "Invalid organization ID format", + "error_upload_failed": "Upload failed. Please try again", + "error_network_failed": "Network error. Please check your connection", + "error_timeout": "Upload timed out after 30 seconds. Please try again", + "error_missing_backend_url": "Backend URL is not configured. Please set the VITE_BACKEND_API_URL environment variable in your environment settings", + "error_invalid_backend_url": "Invalid backend URL configuration" + }, + "filename": { + "cookie_key_value_pairs": "Cookie", + "codegen": "{request_name} - code", + "graphql_response": "GraphQL-Response", + "lens": "{request_name} - response", + "realtime_response": "Realtime-Response", + "response_interface": "Response-Interface" + }, + "filter": { + "all": "All", + "none": "None", + "starred": "Starred" + }, + "folder": { + "created": "Folder created", + "edit": "Edit Folder", + "invalid_name": "Please provide a name for the folder", + "name_length_insufficient": "Folder name should be at least 3 characters long", + "new": "New Folder", + "run": "Run Folder", + "renamed": "Folder renamed", + "sorted": "Folder sorted" + }, + "graphql": { + "arguments": "Arguments", + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_error_http": "Failed to fetch GraphQL Schema due to network error.", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "deprecated": "Deprecated", + "fields": "Fields", + "mutation": "Mutation", + "mutations": "Mutations", + "schema": "Schema", + "show_depricated_values": "Show deprecated values", + "subscription": "Subscription", + "subscriptions": "Subscriptions", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL", + "query": "Query" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "Time", + "url": "URL" + }, + "header": { + "install_pwa": "Install app", + "login": "Login", + "save_workspace": "Save My Workspace" + }, + "helpers": { + "authorization": "The authorization header will be automatically generated when you send the request.", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "collection_properties_scripts": "These scripts will run for every request in this collection. Pre-request scripts run before the request, test scripts run after the response.", + "generate_documentation_first": "Generate documentation first", + "network_fail": "Unable to reach the API endpoint. Check your network connection or select a different Interceptor and try again.", + "offline": "You're using Hoppscotch offline. Updates will sync when you're online, based on workspace settings.", + "offline_short": "You're using Hoppscotch offline.", + "post_request_tests": "Post-request scripts are written in JavaScript, and are run after the response is received.", + "pre_request_script": "Pre-request scripts are written in JavaScript, and are run before the request is sent.", + "script_fail": "It seems there is a glitch in the pre-request script. Check the error below and fix the script accordingly.", + "post_request_script_fail": "There seems to be an error with post-request script. Please fix the errors and run tests again", + "post_request_script": "Write a post-request script to automate debugging." + }, + "hide": { + "collection": "Collapse Collection Panel", + "more": "Hide more", + "preview": "Hide Preview", + "sidebar": "Collapse sidebar", + "password": "Hide Password" + }, + "import": { + "collections": "Import collections", + "curl": "Import cURL", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "Error while importing: format not recognized", + "from_file": "Import from File", + "from_gist": "Import from Gist", + "from_gist_description": "Import from Gist URL", + "from_gist_import_summary": "All hoppscotch features are imported.", + "from_hoppscotch_importer_summary": "All hoppscotch features are imported.", + "from_insomnia": "Import from Insomnia", + "from_insomnia_description": "Import from Insomnia collection", + "from_insomnia_import_summary": "Collections and Requests will be imported.", + "from_json": "Import from Hoppscotch", + "from_json_description": "Import from Hoppscotch collection file", + "from_my_collections": "Import from Personal Collections", + "from_my_collections_description": "Import from Personal Collections file", + "from_all_collections": "Import from Another Workspace", + "from_all_collections_description": "Import any collection from Another Workspace to the current workspace", + "from_openapi": "Import from OpenAPI", + "from_openapi_description": "Import from OpenAPI specification file (YML/JSON)", + "from_openapi_import_summary": "Collections ( will be created from tags ), Requests and response examples will be imported.", + "from_postman": "Import from Postman", + "from_postman_description": "Import from Postman collection", + "from_postman_import_summary": "Collections, Requests and response examples will be imported.", + "import_scripts": "Import scripts", + "import_scripts_description": "Supports Postman Collection v2.0/v2.1.", + "from_url": "Import from URL", + "gist_url": "Enter Gist URL", + "from_har": "Import from HAR", + "from_har_description": "Import from HAR file", + "from_har_import_summary": "Requests will be imported to a default collection.", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "Couldn't get data from the url", + "import_from_url_invalid_file_format": "Error while importing collections", + "import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Collections Imported", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Import collections from a Hoppscotch Collections JSON file", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "Import", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of {sizeLimit}MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of {sizeLimit}MB. Please select another file.", + "success": "Successfully imported", + "import_summary_collections_title": "Collections", + "import_summary_requests_title": "Requests", + "import_summary_responses_title": "Responses", + "import_summary_pre_request_scripts_title": "Pre-request scripts", + "import_summary_post_request_scripts_title": "Post request scripts", + "import_summary_not_supported_by_hoppscotch_import": "We do not support importing {featureLabel} from this source right now.", + "import_summary_script_found": "script found but not imported", + "import_summary_scripts_found": "scripts found but not imported", + "import_summary_enable_experimental_sandbox": "To import Postman scripts, enable 'Experimental scripting sandbox' in settings. Note: This feature is experimental.", + "cors_error_modal": { + "title": "CORS Error Detected", + "description": "The import failed due to CORS (Cross-Origin Resource Sharing) restrictions imposed by the server.", + "explanation": "This is a security feature that prevents web pages from making requests to different domains. You can retry using our proxy service to bypass this restriction.", + "url_label": "Attempted URL", + "retry_with_proxy": "Retry with Proxy" + } + }, + "instances": { + "switch": "Switch Hoppscotch Instance", + "enter_server_url": "Connect to a self-hosted instance", + "already_connected": "You are already connected to this instance", + "recent_connections": "Recent Connections", + "add_instance": "Add an instance", + "add_new": "Add a new instance", + "confirm_remove": "Confirm Removal", + "remove_warning": "Are you sure you want to remove this instance?", + "clear_cached_bundles": "Clear cached bundles", + "opening_add_modal": "Opening add instance dialog", + "closed_add_modal": "Add instance dialog closed", + "cancelled_removal": "Instance removal cancelled", + "connection_cancelled": "Connection cancelled by pre-connect validation", + "post_connect_completed": "Post-connection setup completed", + "connecting": "Connecting to instance...", + "confirm_removal": "Confirm removal of instance", + "removal_cancelled": "Instance removal cancelled by pre-removal validation", + "post_remove_completed": "Post-removal cleanup completed", + "removing": "Removing instance...", + "clearing_cache": "Clearing cache...", + "initialized": "Instance switcher initialized", + "connecting_state": "Establishing connection...", + "connected_state": "Successfully connected to instance", + "disconnected_state": "Disconnected from instance", + "stream_error": "Connection state monitoring failed", + "recent_instances_error": "Failed to load recent instances", + "instance_changed": "Switched to instance", + "current_instance_error": "Failed to track current instance", + "not_available": "Instance switching is not available", + "cleanup_completed": "Instance switcher cleanup completed", + "self_hosted": "Self-hosted instances" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable '{environment}' not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set Cookie Headers. Please use Authorization Headers instead. However, our Hoppscotch Desktop App is live now and supports Cookies." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled.", + "localaccess_unsupported": "Current interceptor does not support local access, please consider using Agent, Extension interceptors or the Desktop App" + }, + "auth": { + "digest": "Agent interceptor on the web app or Native interceptor on the Desktop app are recommended when using Digest Authorization.", + "hawk": "Agent interceptor on the web app or Native interceptor on the Desktop app are recommended when using Hawk Authorization." + }, + "body": { + "binary": "Sending binary data via the current interceptor is not supported yet." + }, + "scripting_interceptor": { + "pre_request": "pre-request script", + "post_request": "post-request script", + "both_scripts": "pre-request and post-request scripts", + "unsupported_interceptor": "Your {scriptType} uses {apiUsed}. For reliable script execution, switch to Agent interceptor (web app) or Native interceptor (Desktop app). The {interceptor} interceptor has limited support for scripting requests and may not work as expected.", + "same_origin_csrf_warning": "Security Warning: Your {scriptType} makes same-origin requests using {apiUsed}. Since this platform uses cookie-based authentication, these requests automatically include your session cookies, potentially allowing malicious scripts to perform unauthorized actions. Use Agent interceptor for same-origin requests, or only run scripts you trust." + } + }, + "interceptor": { + "native": { + "name": "Native", + "settings_title": "Native" + }, + "agent": { + "name": "Agent", + "settings_title": "Agent" + }, + "proxy": { + "name": "Proxy", + "settings_title": "Proxy" + }, + "browser": { + "name": "Browser", + "settings_title": "Browser" + }, + "extension": { + "name": "Extension", + "settings_title": "Extension" + } + }, + "layout": { + "collapse_collection": "Collapse or Expand Collections", + "collapse_sidebar": "Collapse or Expand the sidebar", + "column": "Vertical layout", + "name": "Layout", + "row": "Horizontal layout" + }, + "modal": { + "close_unsaved_tab": "You have unsaved changes", + "collections": "Collections", + "confirm": "Confirm", + "customize_request": "Customize Request", + "edit_request": "Edit Request", + "edit_response": "Edit Response", + "import_export": "Import / Export", + "response_name": "Response Name", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "You are already subscribed to this topic.", + "clean_session": "Clean Session", + "clear_input": "Clear input", + "clear_input_on_send": "Clear input on send", + "client_id": "Client ID", + "color": "Pick a color", + "communication": "Communication", + "connection_config": "Connection Config", + "connection_not_authorized": "This MQTT connection does not use any authentication.", + "invalid_topic": "Please provide a topic for the subscription", + "keep_alive": "Keep Alive", + "log": "Log", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "Message", + "new": "New Subscription", + "not_connected": "Please start a MQTT connection first.", + "publish": "Publish", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Subscribe", + "topic": "Topic", + "topic_name": "Topic Name", + "topic_title": "Publish / Subscribe topic", + "unsubscribe": "Unsubscribe", + "url": "URL" + }, + "navigation": { + "admin_dashboard": "Dashboard", + "doc": "Docs", + "graphql": "GraphQL", + "profile": "Profile", + "realtime": "Realtime", + "rest": "REST", + "mock_servers": "Mock Servers", + "settings": "Settings", + "goto_app": "Goto App", + "authentication": "Authentication" + }, + "mock_server": { + "confirm_delete_log": "Are you sure you want to delete this log?", + "create_mock_server": "Configure Mock Server", + "mock_server_configuration": "Mock Server Configuration", + "mock_server_name": "Mock Server Name", + "mock_server_name_placeholder": "Enter mock server name", + "base_url": "Base URL", + "start_server": "Start Server", + "stop_server": "Stop Server", + "mock_server_created": "Mock server created successfully", + "mock_server_started": "Mock server started successfully", + "mock_server_stopped": "Mock server stopped successfully", + "active": "Mock server is active", + "inactive": "Mock server is inactive", + "edit_mock_server": "Edit Mock Server", + "path_based_url": "Path based URL", + "subdomain_based_url": "Subdomain based URL", + "mock_server_updated": "Mock server updated successfully", + "no_collection": "No collection", + "collection_deleted": "associated collection deleted.", + "private_access_hint": "For private mock servers, include the header 'x-api-key' with your Personal Access Token (create one from your profile).", + "private_access_instruction": "To access this private mock server, include the header 'x-api-key' with your Personal Access Token.", + "create_token_here": "Create here", + "status": "Status", + "server_running": "Server is running", + "server_stopped": "Server is stopped", + "delay_ms": "Response Delay (ms)", + "delay_placeholder": "Enter delay in milliseconds", + "delay_description": "Add artificial delay to mock responses", + "public_access": "Public Access", + "public": "Public", + "private": "Private", + "public_description": "Anyone with the URL can access this mock server", + "private_description": "Only authenticated users can access this mock server", + "select_collection": "Select a collection", + "select_collection_error": "Please select a collection", + "invalid_collection_error": "Failed to create a mock server for the collection.", + "url_copied": "URL copied to clipboard", + "make_public": "Make Public", + "view_logs": "View logs", + "logs_title": "Mock Server Logs", + "no_logs": "No logs available", + "request_headers": "Request Headers", + "request_body": "Request Body", + "response_status": "Response Status", + "response_headers": "Response Headers", + "response_body": "Response Body", + "log_deleted": "Log deleted successfully", + "description": "Mock servers allow you to simulate API responses based on your collection's example responses.", + "set_in_environment": "Set in environment", + "set_in_environment_hint": "The mock server URL will be automatically added as 'mockUrl' variable in the collection's environment", + "environment_variable_added": "Mock URL added to environment", + "environment_variable_updated": "Mock URL updated in environment", + "environment_created_with_variable": "Environment created with mock URL", + "add_example_request": "Add example request", + "add_example_request_hint": "The collection will be created with a sample request that demonstrates how to use the mock server", + "create_example_collection": "Create example collection", + "create_example_collection_hint": "Create a pet store example collection with sample requests (GET, POST, PUT, DELETE)", + "creating_example_collection": "Creating example collection...", + "failed_to_create_collection": "Failed to create example collection", + "enable_example_collection_hint": "Please enable 'Create example collection' toggle for new collection mode", + "new_collection_name_hint": "The collection will be created with the same name as your mock server", + "existing_collection": "Existing Collection", + "new_collection": "New Collection" + }, + "preRequest": { + "javascript_code": "JavaScript Code", + "learn": "Read documentation", + "script": "Pre-Request Script", + "snippets": "Snippets" + }, + "profile": { + "app_settings": "App Settings", + "default_hopp_displayname": "Unnamed User", + "editor": "Editor", + "editor_description": "Editors can add, edit, and delete requests.", + "email_verification_mail": "A verification email has been sent to your email address. Please click on the link to verify your email address.", + "no_permission": "You do not have permission to perform this action.", + "owner": "Owner", + "owner_description": "Owners can add, edit, and delete requests, collections and workspace members.", + "roles": "Roles", + "roles_description": "Roles are used to control access to the shared collections.", + "updated": "Profile updated", + "viewer": "Viewer", + "viewer_description": "Viewers can only view and use requests.", + "verified_email_sent": "A verification email has been sent to your email address. Please refresh the page after verifying your email address. You will receive an email if this email is not associated with any other account." + }, + "remove": { + "star": "Remove star" + }, + "request": { + "added": "Request added", + "add": "Add Request", + "authorization": "Authorization", + "body": "Request Body", + "choose_language": "Choose language", + "content_type": "Content Type", + "content_type_titles": { + "others": "Others", + "structured": "Structured", + "text": "Text", + "binary": "Binary" + }, + "show_content_type": "Show Content Type", + "different_collection": "Cannot reorder requests from different collections", + "duplicated": "Request duplicated", + "duration": "Duration", + "enter_curl": "Enter cURL command", + "generate_code": "Generate code", + "generated_code": "Generated code", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "Header List", + "invalid_name": "Please provide a name for the request", + "method": "Method", + "moved": "Request moved", + "name": "Request name", + "new": "New Request", + "order_changed": "Request Order Updated", + "override": "Override", + "override_help": "Set Content-Type in Headers", + "overriden": "Overridden", + "parameter_list": "Query Parameters", + "parameters": "Parameters", + "path": "Path", + "payload": "Payload", + "query": "Query", + "raw_body": "Raw Request Body", + "rename": "Rename Request", + "renamed": "Request renamed", + "request_variables": "Request variables", + "response_name_exists": "Response name already exists", + "run": "Run", + "save": "Save", + "save_as": "Save as", + "saved": "Request saved", + "share": "Share", + "share_description": "Share Hoppscotch with your friends", + "share_request": "Share Request", + "stop": "Stop", + "title": "Request", + "type": "Request type", + "url": "URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "Variables", + "view_my_links": "View my links", + "generate_name_error": "Failed to generate request name." + }, + "response": { + "audio": "Audio", + "body": "Response Body", + "duplicated": "Response duplicated", + "duplicate_name_error": "Same name response already exists", + "filter_response_body": "Filter JSON response body (uses jq syntax)", + "headers": "Headers", + "request_headers": "Request Headers", + "html": "HTML", + "image": "Image", + "json": "JSON", + "pdf": "PDF", + "please_save_request": "Save the request to create example", + "preview_html": "Preview HTML", + "raw": "Raw", + "renamed": "Response renamed", + "same_name_inspector_warning": "Response name already exists for this request, if saved it will overwrite the existing response", + "size": "Size", + "status": "Status", + "time": "Time", + "title": "Response", + "video": "Video", + "waiting_for_connection": "waiting for connection", + "xml": "XML", + "generate_data_schema": "Generate Data Schema", + "data_schema": "Data Schema", + "saved": "Response saved", + "invalid_name": "Please provide a name for the response" + }, + "script": { + "inheriting": "Inheriting scripts from", + "inheriting_from_count": "Inheriting from {count} collection | Inheriting from {count} collections", + "inherited_scripts": "Inherited Scripts", + "view_inherited": "View inherited scripts" + }, + "settings": { + "accent_color": "Accent color", + "account": "Account", + "account_deleted": "Your account has been deleted", + "account_description": "Customize your account settings.", + "account_email_description": "Your primary email address.", + "account_name_description": "This is your display name.", + "additional": "Additional Settings", + "agent_not_running": "Hoppscotch Agent not detected. Please check if the Agent is running.", + "agent_not_running_short": "Check Agent's status.", + "agent_running": "Hoppscotch Agent is live.", + "agent_running_short": "Hoppscotch Agent is live.", + "agent_discard_registration": "Discard Agent Registration", + "agent_registered": "Agent Registered", + "agent_registration_successful": "Agent Registered Successfully", + "agent_registration_fetch_failed": "Couldn't fetch Agent registration information. Please re-register the Agent.", + "agent_registration_already_in_progress": "Agent registration is already in progress. Please complete or cancel the current registration and try again.", + "auto_encode_mode": "Auto", + "auto_encode_mode_tooltip": "Encode the parameters in the request only if some special characters are present", + "background": "Background", + "black_mode": "Black", + "choose_language": "Choose language", + "dark_mode": "Dark", + "delete_account": "Delete account", + "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", + "desktop": "Desktop", + "desktop_description": "Update behavior, keyboard handling, and display preferences for the Hoppscotch desktop app.", + "desktop_display": "Display", + "desktop_keyboard": "Keyboard", + "desktop_keyboard_strategy_label": "Match shortcuts by typed letter or physical position", + "desktop_keyboard_strategy_description": "On non-QWERTY layouts, the same letter can come from different physical keys. The default works for most layouts; switch options if shortcuts don't fire as expected on yours.", + "desktop_keyboard_strategy_hybrid": "Smart (recommended)", + "desktop_keyboard_strategy_hybrid_description": "Use the typed letter for Latin characters; fall back to the physical key position for non-Latin layouts (Cyrillic, CJK).", + "desktop_keyboard_strategy_key": "Typed letter", + "desktop_keyboard_strategy_key_description": "Always use the typed letter. Pick this if shortcuts don't work as expected on your layout.", + "desktop_keyboard_strategy_code": "Physical key position", + "desktop_keyboard_strategy_code_description": "Always use the US-QWERTY physical position. Pick this if you have QWERTY muscle memory on a non-Latin layout.", + "desktop_updates": "Updates", + "disable_encode_mode_tooltip": "Never encode the parameters in the request", + "disable_update_checks": "Disable automatic update checks", + "disable_update_checks_description": "Skip the update check at app startup. Use the button above to check on demand.", + "zoom_level": "Zoom level", + "zoom_level_description": "Scales the entire interface. Higher values make text and controls larger on high-resolution screens.", + "zoom_level_100": "100%", + "zoom_level_110": "110%", + "zoom_level_125": "125%", + "zoom_level_150": "150%", + "enable_encode_mode_tooltip": "Always encode the parameters in the request", + "enter_otp": "Enter Agent's code", + "expand_navigation": "Expand navigation", + "experiments": "Experiments", + "experiments_notice": "This is a collection of experiments we're working on that might turn out to be useful, fun, both, or neither. They're not final and may not be stable, so if something overly weird happens, don't panic. Just turn the dang thing off. Jokes aside, ", + "extension_ver_not_reported": "Not Reported", + "extension_version": "Extension Version", + "extensions": "Browser extension", + "extensions_use_toggle": "Use the browser extension to send requests (if present)", + "follow": "Follow us", + "general": "General", + "general_description": " General settings used in the application", + "interceptor": "Interceptor", + "interceptor_description": "Middleware between application and APIs.", + "kernel_interceptor": "Interceptor", + "kernel_interceptor_description": "Middleware between application and APIs.", + "language": "Language", + "light_mode": "Light", + "official_proxy_hosting": "Official Proxy is hosted by Hoppscotch.", + "query_parameters_encoding": "Query Parameters Encoding", + "query_parameters_encoding_description": "Configure encoding for query parameters in requests", + "profile": "Profile", + "profile_description": "Update your profile details", + "profile_email": "Email address", + "profile_name": "Profile name", + "profile_photo": "Profile photo", + "proxy": "Proxy", + "proxy_url": "Proxy URL", + "proxy_url_invalid": "Proxy URL must start with http(s):// and contain no spaces", + "proxy_use_toggle": "Use the proxy middleware to send requests", + "read_the": "Read the", + "register_agent": "Register Agent", + "reset_default": "Use Default Proxy", + "short_codes": "Short codes", + "short_codes_description": "Short codes which were created by you.", + "sidebar_on_left": "Sidebar on left", + "ai_experiments": "AI Experiments", + "ai_request_naming_style": "Request Naming Style", + "ai_request_naming_style_descriptive_with_spaces": "Descriptive With Spaces", + "ai_request_naming_style_camel_case": "Camel Case ( camelCase )", + "ai_request_naming_style_snake_case": "Snake Case ( snake_case )", + "ai_request_naming_style_pascal_case": "Pascal Case ( PascalCase )", + "ai_request_naming_style_custom": "Custom", + "ai_request_naming_style_custom_placeholder": "Enter your custom naming style template...", + "experimental_scripting_sandbox": "Experimental scripting sandbox", + "enable_experimental_mock_servers": "Enable Mock Servers", + "enable_experimental_documentation": "Enable Documentation", + "sync": "Synchronise", + "sync_collections": "Collections", + "sync_description": "These settings are synced to cloud.", + "sync_environments": "Environments", + "sync_history": "History", + "history_disabled": "History is disabled. Contact your organization admin to enable history", + "system_mode": "System", + "telemetry": "Telemetry", + "telemetry_helps_us": "Telemetry helps us to personalize our operations and deliver the best experience to you.", + "theme": "Theme", + "theme_description": "Customize your application theme.", + "update_check_description": "Manually check for a new version and install it. Works regardless of the toggle below.", + "update_check_now": "Check for updates", + "update_checking": "Checking\u2026", + "update_download_version": "Download v{version}", + "update_downloading": "Downloading\u2026", + "update_downloading_percent": "Downloading {percent}%", + "update_installing": "Installing\u2026", + "update_restart_now": "Restart to apply update", + "update_up_to_date": "Up to date", + "use_experimental_url_bar": "Use experimental URL bar with environment highlighting", + "user": "User", + "verified_email": "Verified email", + "verify_email": "Verify email", + "validate_certificates": "Validate SSL/TLS Certificates", + "verify_host": "Verify Host", + "verify_peer": "Verify Peer", + "follow_redirects": "Follow Redirects", + "client_certificates": "Client Certificates", + "certificate_settings": "Certificate Settings", + "certificate": "Certificate", + "key": "Private Key", + "pfx_or_p12": "PFX/PKCS#12", + "password": "Password", + "select_file": "Select File", + "domain": "Domain", + "add_certificate": "Add Certificate", + "add_cert_file": "Add Certificate File", + "add_key_file": "Add Key File", + "add_pfx_file": "Add PFX File", + "global_defaults": "Global Defaults", + "add_domain_override": "Add Domain Override", + "domain_override": "Domain Override", + "manage_domains_overrides": "Manage Domains Overrides", + "add_domain": "Add Domain", + "remove_domain": "Remove Domain", + "ca_certificate": "CA Certificate", + "ca_certificates": "CA Certificates", + "ca_certificates_support": "Hoppscotch supports .crt, .cer or .pem files containing one or more certificates.", + "proxy_capabilities": "Hoppscotch Agent and Desktop App supports HTTP/HTTPS/SOCKS proxies with NTLM and Basic Auth support.", + "proxy_auth": "You can also include username and password in the URL." + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + }, + "action": "Action", + "clear_filter": "Clear Filter", + "confirm_request_deletion": "Confirm deletion of the selected shared request?", + "copy": "Copy", + "created_on": "Created On", + "delete": "Delete", + "email": "Email", + "filter": "Filter", + "filter_by_email": "Filter by email", + "id": "ID", + "load_list_error": "Unable to load shared requests list", + "no_requests": "No shared requests found", + "open_request": "Open Request", + "properties": "Properties", + "request": "Request", + "show_more": "Show more", + "title": "Shared Requests", + "url": "URL" + }, + "shortcut": { + "general": { + "close_current_menu": "Close current menu", + "command_menu": "Search & command menu", + "help_menu": "Help menu", + "show_all": "Keyboard shortcuts", + "title": "General", + "comment_uncomment": "Comment/Uncomment", + "close_tab": "Close Tab", + "undo": "Undo", + "redo": "Redo" + }, + "miscellaneous": { + "invite": "Invite people to Hoppscotch", + "title": "Miscellaneous" + }, + "navigation": { + "back": "Go back to previous page", + "documentation": "Go to Documentation page", + "forward": "Go forward to next page", + "graphql": "Go to GraphQL page", + "profile": "Go to Profile page", + "realtime": "Go to Realtime page", + "rest": "Go to REST page", + "settings": "Go to Settings page", + "title": "Navigation" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "Select DELETE method", + "get_method": "Select GET method", + "head_method": "Select HEAD method", + "import_curl": "Import cURL", + "method": "Method", + "next_method": "Select Next method", + "post_method": "Select POST method", + "previous_method": "Select Previous method", + "put_method": "Select PUT method", + "rename": "Rename Request", + "reset_request": "Reset Request", + "save_request": "Save Request", + "save_to_collections": "Save to Collections", + "send_request": "Send Request", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "Request", + "focus_url": "Focus URL bar" + }, + "response": { + "copy": "Copy response to clipboard", + "download": "Download response as file", + "title": "Response" + }, + "tabs": { + "title": "Tabs", + "new_tab": "New Tab", + "close_tab": "Close Tab", + "reopen_tab": "Reopen Closed Tab", + "next_tab": "Next Tab", + "previous_tab": "Previous Tab", + "first_tab": "Switch to First Tab", + "last_tab": "Switch to Last Tab", + "mru_switch": "Switch to recent tab (MRU)", + "mru_switch_reverse": "Switch to previous recent tab (MRU)" + }, + "theme": { + "black": "Switch theme to Black Mode", + "dark": "Switch theme to Dark Mode", + "light": "Switch theme to Light Mode", + "system": "Switch theme to System Mode", + "title": "Theme" + } + }, + "show": { + "code": "Show code", + "collection": "Expand Collection Panel", + "more": "Show more", + "sidebar": "Expand sidebar", + "password": "Show Password" + }, + "socketio": { + "communication": "Communication", + "connection_not_authorized": "This SocketIO connection does not use any authentication.", + "event_name": "Event/Topic Name", + "events": "Events", + "log": "Log", + "url": "URL" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "next": "Switch to next tab", + "previous": "Switch to previous tab", + "switch_to_first": "Switch to first tab", + "switch_to_last": "Switch to last tab", + "mru_switch": "Switch to recent tab", + "mru_switch_reverse": "Switch to previous recent tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current workspace", + "edit": "Edit current workspace", + "invite": "Invite people to workspace", + "new": "Create new workspace", + "switch_to_personal": "Switch to your personal workspace", + "title": "Workspaces" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "Event type", + "log": "Log", + "url": "URL" + }, + "state": { + "bulk_mode": "Bulk edit", + "bulk_mode_placeholder": "Entries are separated by newline\nKeys and values are separated by :\nPrepend # to any row you want to add but keep disabled", + "cleared": "Cleared", + "connected": "Connected", + "connected_to": "Connected to {name}", + "connecting_to": "Connecting to {name}...", + "connection_error": "Failed to connect", + "connection_failed": "Connection failed", + "connection_lost": "Connection lost", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "Copied to clipboard", + "deleted": "Deleted", + "deprecated": "DEPRECATED", + "disabled": "Disabled", + "disconnected": "Disconnected", + "disconnected_from": "Disconnected from {name}", + "docs_generated": "Documentation generated", + "download_failed": "Download failed", + "download_started": "Download started", + "enabled": "Enabled", + "experimental": "Experimental", + "file_imported": "File imported", + "finished_in": "Finished in {duration} ms", + "hide": "Hide", + "history_deleted": "History deleted", + "linewrap": "Wrap lines", + "loading": "Loading...", + "message_received": "Message: {message} arrived on topic: {topic}", + "mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}", + "no_content_found": "No content found", + "none": "None", + "nothing_found": "Nothing found for", + "published_error": "Something went wrong while publishing msg: {topic} to topic: {message}", + "published_message": "Published message: {message} to topic: {topic}", + "reconnection_error": "Failed to reconnect", + "saved": "Saved", + "show": "Show", + "subscribed_failed": "Failed to subscribe to topic: {topic}", + "subscribed_success": "Successfully subscribed to topic: {topic}", + "unsubscribed_failed": "Failed to unsubscribe from topic: {topic}", + "unsubscribed_success": "Successfully unsubscribed from topic: {topic}", + "waiting_send_request": "Waiting to send request", + "user_deactivated": "Your account is deactivated. Please contact admin to reactivate your account.", + "add_user_failure": "Failed to add user to the workspace!!", + "add_user_success": "User is now a member of the workspace!!", + "admin_failure": "Failed to make user an admin!!", + "admin_success": "User is now an admin!!", + "and": "and", + "clear_selection": "Clear Selection", + "configure_auth": "Please set up an auth provider from the admin settings or check out the documentation to configure auth providers.", + "confirm_admin_to_user": "Do you want to remove admin status from this user?", + "confirm_admins_to_users": "Do you want to remove admin status from selected users?", + "confirm_delete_infra_token": "Are you sure you want to delete the infra token {tokenLabel}?", + "confirm_delete_invite": "Do you want to revoke the selected invite?", + "confirm_delete_invites": "Do you want to revoke selected invites?", + "confirm_user_deletion": "Confirm user deletion?", + "confirm_users_deletion": "Do you want to delete selected users?", + "confirm_user_to_admin": "Do you want to make this user into an admin?", + "confirm_users_to_admin": "Do you want to make selected users into admins?", + "confirm_user_deactivation": "Confirm user deactivation?", + "confirm_logout": "Confirm Logout", + "created_on": "Created On", + "continue_email": "Continue with Email", + "continue_github": "Continue with Github", + "continue_google": "Continue with Google", + "continue_microsoft": "Continue with Microsoft", + "create_team_failure": "Failed to create workspace!!", + "create_team_success": "Workspace created successfully!!", + "data_sharing_failure": "Failed to update data sharing settings", + "delete_infra_token_failure": "Something went wrong while deleting the infra token", + "delete_invite_failure": "Failed to delete invite!!", + "delete_invites_failure": "Failed to delete selected invites!!", + "delete_invite_success": "Invite deleted successfully!!", + "delete_invites_success": "Selected invites deleted successfully!!", + "delete_request_failure": "Shared Request deletion failed!!", + "delete_request_success": "Shared Request deleted successfully!!", + "delete_team_failure": "Workspace deletion failed!!", + "delete_team_success": "Workspace deleted successfully!!", + "delete_some_users_failure": "Number of Users Not Deleted: {count}", + "delete_some_users_success": "Number of Users Deleted: {count}", + "delete_user_failed_only_one_admin": "Failed to delete user. There should be atleast one admin!!", + "delete_user_failure": "User deletion failed!!", + "delete_users_failure": "Failed to delete selected users!!", + "delete_user_success": "User deleted successfully!!", + "delete_users_success": "Selected users deleted successfully!!", + "email": "Email", + "email_failure": "Failed to send invitation", + "email_signin_failure": "Failed to login with Email", + "email_success": "Email invitation sent successfully", + "emails_cannot_be_same": "You cannot invite yourself, please choose a different email address!!", + "enter_team_email": "Please enter email of workspace owner!!", + "error": "Something went wrong", + "error_auth_providers": "Unable to load auth providers", + "generate_infra_token_failure": "Something went wrong while generating the infra token", + "github_signin_failure": "Failed to login with Github", + "google_signin_failure": "Failed to login with Google", + "infra_token_label_short": "Infra Token Label character length is too short!!", + "invalid_email": "Please enter a valid email address", + "link_copied_to_clipboard": "Link copied to clipboard", + "logged_out": "Logged out", + "login_as_admin": "and login with an admin account.", + "login_using_email": "Please ask the user to check their email or share the link below", + "login_using_link": "Please ask the user to login using the link below", + "logout": "Logout", + "magic_link_sign_in": "Click on the link to sign in.", + "magic_link_success": "We sent a magic link to", + "microsoft_signin_failure": "Failed to login with Microsoft", + "newsletter_failure": "Unable to update newsletter settings", + "non_admin_logged_in": "Logged in as non admin user.", + "non_admin_login": "You are logged in. But you're not an admin", + "owner_not_present": "Atleast one owner should be present in the team!!", + "privacy_policy": "Privacy Policy", + "reenter_email": "Re-enter email", + "remove_admin_failure": "Failed to remove admin status!!", + "remove_admin_failure_only_one_admin": "Failed to remove admin status. There should be at least one admin!!", + "remove_admin_success": "Admin status removed!!", + "remove_admin_from_users_failure": "Failed to remove admin status from selected users!!", + "remove_admin_from_users_success": "Admin status removed from selected users!!", + "remove_admin_to_delete_user": "Remove admin privilege to delete the user!!", + "remove_owner_to_delete_user": "Remove team ownership status to delete the user!!", + "remove_owner_failure_only_one_owner": "Failed to remove member. There should be atleast one owner in a team!!", + "remove_admin_for_deletion": "Remove admin status before attempting deletion!!", + "remove_owner_for_deletion": "One or more users are team owners. Update ownership before deletion!!", + "remove_invitee_failure": "Removal of invitee failed!!", + "remove_invitee_success": "Removal of invitee is successfull!!", + "remove_member_failure": "Member couldn't be removed!!", + "remove_member_success": "Member removed successfully!!", + "rename_team_failure": "Failed to rename workspace!!", + "rename_team_success": "Workspace renamed successfully!", + "rename_user_failure": "Failed to rename user!!", + "rename_user_success": "User renamed successfully!!", + "require_auth_provider": "You need to set atleast one authentication provider to log in.", + "role_update_failed": "Roles updation has failed!!", + "role_update_success": "Roles updated successfully!!", + "selected": "{count} selected", + "self_host_docs": "Self Host Documentation", + "send_magic_link": "Send magic link", + "setup_failure": "Setup has failed!!", + "setup_success": "Setup completed successfully!!", + "sign_in_agreement": "By signing in, you are agreeing to our", + "sign_in_options": "All sign in option", + "sign_out": "Sign out", + "something_went_wrong": "Something went wrong", + "team_name_too_short": "Workspace name should be atleast 6 characters long!!", + "user_already_invited": "Failed to send invite. User is already invited!!", + "user_not_found": "User not found in the infra!!", + "users_to_admin_success": "Selected users are elevated to admin status!!", + "users_to_admin_failure": "Failed to elevate selected users to admin status!!", + "loading_workspaces": "Loading workspaces", + "loading_collections_in_workspace": "Loading collections in workspace" + }, + "support": { + "changelog": "Read more about latest releases", + "chat": "Questions? Chat with us!", + "community": "Ask questions and help others", + "documentation": "Read more about Hoppscotch", + "forum": "Ask questions and get answers", + "github": "Follow us on Github", + "shortcuts": "Browse app faster", + "title": "Support", + "twitter": "Follow us on Twitter" + }, + "tab": { + "authorization": "Authorization", + "body": "Body", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "Collections", + "documentation": "Documentation", + "duplicate": "Duplicate Tab", + "environments": "Environments", + "headers": "Headers", + "history": "History", + "mqtt": "MQTT", + "parameters": "Parameters", + "post_request_script": "Post-request Script", + "pre_request_script": "Pre-request Script", + "scripts": "Scripts", + "queries": "Queries", + "query": "Query", + "schema": "Schema", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "mock_servers": "Mock Servers", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "types": "Types", + "variables": "Variables", + "websocket": "WebSocket", + "all_tests": "All Tests", + "passed": "Passed", + "failed": "Failed" + }, + "team": { + "activity_logs": "Activity Logs", + "already_member": "This email is associated with an existing user.", + "create_new": "Create new workspace", + "deleted": "Workspace deleted", + "delete_all_activity_logs": "Delete all activity logs", + "successfully_deleted_all_activity_logs": "Successfully deleted all activity logs", + "delete_activity_log": "Delete activity log", + "deleted_activity_log": "Deleted selected activity log", + "deleted_all_activity_logs": "Deleted all activity logs", + "edit": "Edit Workspace", + "email": "E-mail", + "email_do_not_match": "Email doesn't match with your account details. Contact your workspace owner.", + "exit": "Exit Workspace", + "exit_disabled": "Only owner cannot exit the workspace", + "failed_invites": "Failed invites", + "invalid_coll_id": "Invalid collection ID", + "invalid_email_format": "Email format is invalid", + "invalid_id": "Invalid workspace ID. Contact your workspace owner.", + "invalid_invite_link": "Invalid invite link", + "invalid_invite_link_description": "The link you followed is invalid. Contact your workspace owner.", + "invalid_member_permission": "Please provide a valid permission to the workspace member", + "invite": "Invite", + "invite_more": "Invite more", + "invite_tooltip": "Invite people to this workspace", + "invited_to_team": "{owner} invited you to join {workspace}", + "join": "Invitation accepted", + "join_team": "Join {workspace}", + "joined_team": "You have joined {workspace}", + "joined_team_description": "You are now a member of this workspace", + "left": "You left the workspace", + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to join a workspace.", + "logout_and_try_again": "Logout and sign in with another account", + "member_has_invite": "User already has an invite. Please ask them to check their inbox or revoke and resend the invite.", + "member_not_found": "Member not found. Contact your workspace owner.", + "member_removed": "User removed", + "member_role_updated": "User roles updated", + "members": "Members", + "more_members": "+{count} more", + "name_length_insufficient": "Workspace name should not be empty", + "name_updated": "Workspace name updated", + "new": "New Workspace", + "new_created": "New workspace created", + "new_name": "My New Workspace", + "no_access": "You do not have edit access to this workspace", + "no_invite_found": "Invitation not found. Contact your workspace owner.", + "no_request_found": "Request not found.", + "not_found": "Workspace not found. Contact your workspace owner.", + "not_valid_viewer": "You are not a valid viewer. Contact your workspace owner.", + "parent_coll_move": "Cannot move collection to a child collection", + "pending_invites": "Pending invites", + "permissions": "Permissions", + "same_target_destination": "Same target and destination", + "saved": "Workspace saved", + "select_a_team": "Select a workspace", + "success_invites": "Success invites", + "title": "Workspaces", + "we_sent_invite_link": "Invitations are on the way", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": " New invitees will receive a link to join the workspace, existing members and pending invitees won't receive a new link.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "user_not_found": "User not found in the instance.", + "invite_members": "Invite Members" + }, + "team_environment": { + "deleted": "Environment Deleted", + "duplicate": "Environment Duplicated", + "not_found": "Environment not found." + }, + "test": { + "requests": "Requests", + "selection": "Selection", + "failed": "test failed", + "javascript_code": "JavaScript Code", + "learn": "Read documentation", + "passed": "test passed", + "report": "Test Report", + "results": "Test Results", + "script": "Script", + "snippets": "Snippets", + "run": "Run", + "run_again": "Run again", + "stop": "Stop", + "new_run": "New Run", + "iterations": "Iterations", + "duration": "Duration", + "avg_resp": "Avg. Response Time" + }, + "websocket": { + "communication": "Communication", + "log": "Log", + "message": "Message", + "protocols": "Protocols", + "url": "URL" + }, + "workspace": { + "change": "Change workspace", + "personal": "Personal Workspace", + "other_workspaces": "My Workspaces", + "team": "Workspace", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "cli_comming_soon_for_personal_collection": "Collection Runner for personal collections in CLI is coming soon.", + "delay": "Delay", + "negative_delay": "Delay cannot be negative", + "ui": "Runner", + "running_collection": "Running collection", + "run_config": "Run Configuration", + "advanced_settings": "Advanced Settings", + "stop_on_error": "Stop run if an error occurs", + "persist_responses": "Persist responses", + "keep_variable_values": "Keep variable values", + "collection_not_found": "Collection not found. May be deleted or moved.", + "empty_collection": "Collection is empty. Add requests to run.", + "no_response_persist": "The collection runner is presently configured not to persist responses. This setting prevents showing the response data. To modify this behavior, initiate a new run configuration.", + "select_request": "Select a request to see response and test results", + "response_body_lost_rerun": "Response body is lost. Run the collection again to get the response body.", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection", + "no_passed_tests": "No tests passed", + "no_failed_tests": "No tests failed" + }, + "ai_experiments": { + "generate_request_name": "Generate Request Name Using AI", + "generate_or_modify_request_body": "Generate or Modify Request Body", + "modify_with_ai": "Modify with AI", + "generate": "Generate", + "generate_or_modify_request_body_input_placeholder": "Enter your prompt to modify request body", + "accept_change": "Accept Change", + "feedback_success": "Feedback submitted successfully", + "feedback_failure": "Failed to submit feedback", + "feedback_thank_you": "Thank you for your feedback!", + "feedback_cta_text_long": "Rate the generation, helps us to improve", + "feedback_cta_request_name": "Did you like the generated name?", + "modify_request_body_error": "Failed to modify request body", + "generate_or_modify_prerequest_input_placeholder": "Enter a prompt to generate or modify the pre-request script", + "generate_or_modify_post_request_script_input_placeholder": "Enter a prompt to generate or modify the post-request script", + "modify_post_request_script_error": "Failed to modify post-request script", + "modify_prerequest_error": "Failed to modify pre-request script" + }, + "configs": { + "auth_providers": { + "callback_url": "CALLBACK URL", + "client_id": "CLIENT ID", + "client_secret": "CLIENT SECRET", + "description": "Configure authentication providers for your server", + "provider_not_specified": "Please enable at least one authentication provider", + "scope": "SCOPE", + "tenant": "TENANT", + "title": "Authentication Providers", + "update_failure": "Failed to update authentication provider configurations!!" + }, + "confirm_changes": "Hoppscotch server must restart to reflect the new changes. Confirm changes made to the server configurations?", + "input_empty": "Please fill all the fields before updating the configurations", + "data_sharing": { + "title": "Data Sharing", + "description": "Help improve Hoppscotch by sharing anonymous data", + "enable": "Enable Data Sharing", + "secondary_title": "Data Sharing Configurations", + "see_shared": "See what is shared", + "toggle_description": "Share anonymous data", + "update_failure": "Failed to update data sharing configurations!!" + }, + "load_error": "Unable to load server configurations", + "mail_configs": { + "address_from": "MAILER FROM ADDRESS", + "custom_smtp_configs": "Use Custom SMTP Configurations", + "description": " Configure the smtp configurations", + "enable_email_auth": "Enable Email based authentication", + "enable_smtp": "Enable SMTP", + "host": "MAILER HOST", + "password": "MAILER PASSWORD", + "port": "MAILER PORT", + "secure": "MAILER SECURE", + "smtp_url": "MAILER SMTP URL", + "tls_reject_unauthorized": "TLS REJECT UNAUTHORIZED", + "title": "SMTP Configurations", + "toggle_failure": "Failed to toggle smtp!!", + "update_failure": "Failed to update smtp configurations!!", + "user": "MAILER USER" + }, + "reset": { + "confirm_reset": "Hoppscotch server must restart to reflect the new changes. Confirm the reset of server configurations?", + "description": "Default configurations will be loaded as specified in the environment file", + "failure": "Failed to reset configurations!!", + "title": "Reset Configurations", + "info": "Reset server configurations" + }, + "restart": { + "description": "{duration} seconds remaining before this page reloads automatically", + "initiate": "Initiating server restart...", + "title": "Server is restarting" + }, + "save_changes": "Save Changes", + "title": "Configurations", + "update_failure": "Failed to update server configurations", + "restrict_access": "Restrict Access", + "site_protection": { + "control_access": "Control who can access Hoppscotch app", + "description": "Customize how visitors access your Hoppscotch app using site protection settings.", + "enable": "Enable site protection", + "note": "Users visiting the Hoppscotch app will see the login page, mandatory login is required to access the app. Admin approval is still required for authorization", + "update_failure": "Failed to update site protection configurations!!" + }, + "domain_whitelisting": { + "add_domain": "Add New Domain", + "description": "Users with email ID registered in whitelisted domains does not require explicit approval from admin to access the Hoppscotch app", + "enable": "Enable domain whitelisting", + "enter_domain": "Enter domain", + "title": "Whitelisted Domains", + "toggle_failure": "Failed to toggle domain whitelisting", + "update_failure": "Failed to update domain whitelisting configurations!!" + }, + "oidc_configs": { + "auth_url": "Auth URL", + "callback_url": "Callback URL", + "client_id": "Client ID", + "client_secret": "Client Secret", + "description": "Configure OIDC configurations", + "enable": "Enable OIDC based authentication", + "issuer": "Issuer", + "provider_name": "Provider Name", + "scope": "Scope", + "title": "OIDC Configurations", + "token_url": "Token URL", + "update_failure": "Failed to update OIDC configurations!!", + "user_info_url": "User Info URL" + }, + "saml": { + "audience": "Audience", + "callback_url": "Callback URL", + "certificate": "Certificate", + "description": "Configure SAML configurations", + "enable": "Enable SAML based authentication", + "entry_point": "Entry Point", + "issuer": "Issuer", + "title": "SAML Configurations", + "update_failure": "Failed to update SAML SSO configurations!!", + "want_assertions_signed": "Sign Assertions", + "want_response_signed": "Sign Response" + } + }, + "data_sharing": { + "description": "Share anonymous data usage to improve Hoppscotch", + "enable": "Enable Data Sharing", + "see_shared": "See what is shared", + "toggle_description": "Share data and make Hoppscotch better", + "title": "Make Hoppscotch Better", + "welcome": "Welcome to" + }, + "infra_tokens": { + "copy_token_warning": "Make sure to copy your infra token now. You won't be able to see it again!", + "deletion_success": "The infra token {label} has been deleted", + "empty": "Infra tokens are empty", + "expired": "Expired", + "expiration_label": "Expiration", + "expires_on": "Expires on", + "generate_modal_title": "New Infra Token", + "generate_new_token": "Generate new token", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "last_used_on": "Last used on", + "no_expiration": "No expiration", + "no_expiration_verbose": "This token will never expire!", + "section_description": "Manage your Hoppscotch users through APIs with Infra tokens", + "section_title": "Infra Tokens", + "tab_title": "Infra Tokens", + "token_expires_on": "This token will expire on", + "token_purpose": "Enter a label to identify this token" + }, + "metrics": { + "dashboard": "Dashboard", + "no_metrics": "No metrics found", + "total_collections": "Total Collections", + "total_requests": "Total Requests", + "total_teams": "Total Workspaces", + "total_users": "Total Users" + }, + "newsletter": { + "description": "Get updates about our latest news", + "subscribe": "Subscribe", + "title": "Stay in Touch", + "toggle_description": "Get updates about the latest at Hoppscotch", + "unsubscribe": "Unsubscribe" + }, + "teams": { + "add_member": "Add Member", + "add_members": "Add Members", + "add_new": "Add New", + "admin": "Admin", + "admin_Email": "Admin Email", + "admin_id": "Admin ID", + "cancel": "Cancel", + "confirm_team_deletion": "Confirm deletion of the workspace?", + "copy": "Copy", + "create_team": "Create Workspace", + "date": "Date", + "delete_team": "Delete Workspace", + "details": "Details", + "edit": "Edit", + "editor": "EDITOR", + "editor_description": "Editors can add, edit, and delete requests and collections.", + "email": "Workspace owner email", + "email_address": "Email Address", + "email_title": "Email", + "empty_name": "Team name cannot be empty!!", + "error": "Something went wrong. Please try again later.", + "id": "Workspace ID", + "invited_email": "Invitee Email", + "invited_on": "Invited On", + "invites": "Invites", + "load_info_error": "Unable to load Workspace info", + "load_list_error": "Unable to Load Workspace List", + "members": "Number of members", + "no_invite": "No invites", + "no_invite_description": "Invite your team to start collaborating", + "owner": "OWNER", + "owner_description": " Owners can add, edit, and delete requests, collections and workspace members.", + "permissions": "Permissions", + "name": "Workspace Name", + "no_members": "No members in this workspace. Add members to this workspace to collaborate", + "no_pending_invites": "No pending invites", + "no_teams": "No workspaces found..", + "no_teams_description": "Create a workspace to collaborate with your team", + "pending_invites": "Pending invites", + "roles": "Roles", + "roles_description": "Roles are used to control access to the shared collections.", + "remove": "Remove", + "rename": "Rename", + "save": "Save", + "save_changes": "Save Changes", + "send_invite": "Send Invite", + "show_more": "Show more", + "team_details": "Workspace details", + "team_members": "Members", + "team_members_tab": "Workspace members", + "teams": "Workspace", + "uid": "UID", + "unnamed": "(Unnamed Workspace)", + "viewer": "VIEWER", + "viewer_description": "Viewers can only view and use requests", + "valid_name": "Please enter a valid workspace name", + "valid_owner_email": "Please enter a valid owner email" + }, + "users": { + "add_user": "Add User", + "admin": "Admin", + "admin_id": "Admin ID", + "cancel": "Cancel", + "created_on": "Created On", + "copy_invite_link": "Copy Invite Link", + "copy_link": "Copy Link", + "date": "Date", + "delete": "Delete", + "delete_user": "Delete User", + "delete_users": "Delete Users", + "details": "Details", + "edit": "Edit", + "email": "Email", + "email_address": "Email Address", + "empty_name": "Name cannot be empty!!", + "id": "User ID", + "invalid_user": "Invalid User", + "invite_load_list_error": "Unable to Load Invited Users List", + "invite_user": "Invite User", + "invited_by": "Invited By", + "invited_on": "Invited On", + "invited_users": "Invited Users", + "invitee_email": "Invitee Email", + "last_active_on": "Last Active", + "load_info_error": "Unable to load user info", + "load_list_error": "Unable to Load Users List", + "make_admin": "Make Admin", + "name": "Name", + "new_user_added": "New User Added", + "no_invite": "No pending invites found", + "no_invite_description": "No pending invites! Start inviting your teammates to Hoppscotch", + "no_shared_requests": "No shared requests created by the user", + "no_users": "No users found", + "not_available": "Not Available", + "not_found": "User not found", + "pending_invites": "Pending Invites", + "remove_admin_privilege": "Remove Admin Privilege", + "remove_admin_status": "Remove Admin Status", + "rename": "Rename", + "revoke_invitation": "Revoke Invitation", + "searchbar_placeholder": "Search by name or email..", + "send_invite": "Send Invite", + "show_more": "Show more", + "uid": "UID", + "unnamed": "(Unnamed User)", + "user_not_found": "User not found in the infra!!", + "users": "Users", + "valid_email": "Please enter a valid email address", + "deactivate": "Deactivate", + "deactivate_user": "Deactivate User" + }, + "organization": { + "login_to_continue_description": "You need to be logged in to join an organization instance.", + "create_an_organization": "Create an organization", + "deactivate_user_failure": "User deactivation failed!!", + "deactivate_user_success": "User deactivated successfully!!", + "delete_account_description": "This will delete all data associated with your Hoppscotch account, including this and any other organizations you are part of.", + "delete_account": "Delete Hoppscotch Account", + "user_deletion_failed_sole_admin": "The user is the sole admin of one or more organization instances. Please demote the user before attempting deletion.", + "user_deletion_failed_sole_team_owner": "The user is the sole team owner on one or more organization instances. Please transfer the ownership or delete the relevant workspaces before attempting deletion.", + "no_organizations": "You are not a member of any organizations", + "admin": "Admin" + }, + "organization_sidebar": { + "hoppscotch_cloud": "Hoppscotch Cloud", + "cloud_locked": "Default instance cannot be removed", + "admin": "Admin", + "error_loading": "Failed to load organizations", + "inactive_orgs": "Inactive Organizations", + "no_orgs_found": "No organizations found", + "no_active_orgs_found": "No active organizations", + "organizations_for": "Organizations for {email}", + "multi_account_notice": "Each organization keeps its own login, using the last account accessed.", + "inactive_orgs_tooltip": "Contact support for assistance." + }, + "billing": { + "confirm": { + "update_seat_count": "Are you sure you want to update the seat count to {newSeatCount}?", + "update_billing_cycle": "Are you sure you want to update the billing cycle to {newBillingCycle}?" + }, + "cancel_subscription": "Cancel subscription" + }, + "app_console": { + "entries": "Console entries", + "no_entries": "No entries" + }, + "mockServer": { + "create_modal": { + "title": "Create Mock Server", + "name_label": "Mock Server Name", + "name_placeholder": "Enter mock server name", + "name_required": "Mock server name is required", + "collection_source_label": "Collection Source", + "existing_collection": "Existing Collection", + "new_collection": "New Collection", + "select_collection_label": "Select Collection", + "select_collection_placeholder": "Choose a collection", + "collection_required": "Please select a collection", + "collection_name_label": "Collection Name", + "collection_name_placeholder": "Enter collection name", + "collection_name_required": "Collection name is required", + "request_config_label": "Request Configuration", + "add_request": "Add Request", + "create_button": "Create Mock Server", + "success": "Mock server created successfully", + "error": "Failed to create mock server", + "no_collections": "No collections available" + }, + "edit_modal": { + "title": "Edit Mock Server", + "name_label": "Mock Server Name", + "name_placeholder": "Enter mock server name", + "name_required": "Mock server name is required", + "active_label": "Active", + "url_label": "Mock Server URL", + "collection_label": "Collection", + "update_button": "Update Mock Server", + "success": "Mock server updated successfully", + "error": "Failed to update mock server", + "url_copied": "URL copied to clipboard" + }, + "dashboard": { + "title": "Mock Servers", + "subtitle": "Create and manage your API mock servers", + "create_button": "Create Mock Server", + "create_first": "Create your first mock server", + "empty_title": "No mock servers found", + "empty_description": "Create mock servers based on your API collections to enable frontend and mobile development without backend dependencies.", + "collection": "Collection", + "active": "Active", + "inactive": "Inactive", + "mock_url": "Mock URL", + "endpoints": "Endpoints", + "created": "Created", + "view_collection": "View Collection", + "documentation": "Documentation", + "doc_description": "Use this URL as your API base URL in your applications:", + "url_copied": "Mock server URL copied to clipboard", + "delete_title": "Delete Mock Server", + "delete_description": "Are you sure you want to delete this mock server?", + "delete_success": "Mock server deleted successfully", + "delete_error": "Failed to delete mock server" + } + } +} diff --git a/packages/hoppscotch-common/locales/es.json b/packages/hoppscotch-common/locales/es.json new file mode 100644 index 0000000..47e7134 --- /dev/null +++ b/packages/hoppscotch-common/locales/es.json @@ -0,0 +1,2330 @@ +{ + "action": { + "add": "Añadir", + "autoscroll": "Desplazamiento automático", + "cancel": "Cancelar", + "choose_file": "Seleccionar archivo", + "choose_workspace": "Elegir un espacio de trabajo", + "choose_collection": "Elegir una colección", + "select_workspace": "Seleccionar un espacio de trabajo", + "clear": "Limpiar", + "clear_all": "Limpiar todo", + "clear_cache": "Limpiar caché", + "clear_history": "Borrar todo el historial", + "clear_unpinned": "Limpiar no fijados", + "clear_response": "Limpiar respuesta", + "close": "Cerrar", + "confirm": "Confirmar", + "connect": "Conectar", + "connecting": "Conectando", + "copy": "Copiar", + "create": "Crear", + "delete": "Borrar", + "disconnect": "Desconectar", + "dismiss": "Descartar", + "done": "Listo", + "dont_save": "No guardar", + "download_file": "Descargar archivo", + "download_test_report": "Descargar reporte de pruebas", + "drag_to_reorder": "Arrastrar para reordenar", + "duplicate": "Duplicar", + "edit": "Editar", + "filter": "Filtrar", + "go_back": "Volver", + "go_forward": "Adelante", + "group_by": "Agrupar por", + "hide_secret": "Ocultar secreto", + "label": "Etiqueta", + "learn_more": "Aprender más", + "download_here": "Descargar aquí", + "less": "Menos", + "more": "Más", + "new": "Nuevo", + "no": "No", + "open": "Abrir", + "open_workspace": "Abrir espacio de trabajo", + "paste": "Pegar", + "prettify": "Formatear", + "properties": "Propiedades", + "register": "Registrar", + "remove": "Eliminar", + "remove_instance": "Eliminar instancia", + "rename": "Renombrar", + "restore": "Restaurar", + "retry": "Reintentar", + "save": "Guardar", + "save_as_example": "Guardar como ejemplo", + "add_example": "Agregar ejemplo", + "invalid_request": "Datos de solicitud no válidos", + "scroll_to_bottom": "Desplazar hacia abajo", + "scroll_to_top": "Desplazar hacia arriba", + "search": "Buscar", + "send": "Enviar", + "share": "Compartir", + "show_secret": "Mostrar secreto", + "sort": "Ordenar", + "start": "Comenzar", + "starting": "Iniciando", + "stop": "Detener", + "to_close": "para cerrar", + "to_navigate": "para navegar", + "to_select": "para seleccionar", + "turn_off": "Desactivar", + "turn_on": "Activar", + "undo": "Deshacer", + "unpublish": "Despublicar", + "yes": "Sí", + "verify": "Verificar", + "enable": "Habilitar", + "disable": "Deshabilitar", + "assign": "Asignar" + }, + "activity_logs": { + "ACTIVITY_LOG_DELETE": "El registro de actividad ha sido eliminado", + "WORKSPACE_CREATE": "Se creó el nuevo espacio de trabajo {name}", + "WORKSPACE_RENAME": "Se renombró el espacio de trabajo de {old_name} a {new_name}", + "WORKSPACE_USER_ADD": "{user} fue agregado al espacio de trabajo como {role}", + "WORKSPACE_USER_INVITE": "{user} fue invitado por {inviteeEmail} como {role}", + "WORKSPACE_USER_INVITE_REVOKE": "Se revocó la invitación de {inviteeEmail} como {inviteeRole}", + "WORKSPACE_USER_INVITE_ACCEPT": "{inviteeEmail} aceptó la invitación como {inviteeRole}", + "WORKSPACE_USER_REMOVE": "{user} fue eliminado del espacio de trabajo", + "WORKSPACE_USER_ROLE_UPDATE": "El rol de {user} fue actualizado de {old_role} a {new_role}", + "COLLECTION_CREATE": "Se creó la nueva colección {title}", + "COLLECTION_RENAME": "Se renombró la colección de {old_title} a {new_title}", + "COLLECTION_IMPORT": "Se importaron {count} colección(es)", + "COLLECTION_DELETE": "Se eliminó la colección {title}", + "COLLECTION_DUPLICATE": "Se duplicó la colección {parentTitle}", + "REQUEST_CREATE": "Se creó la nueva solicitud {title}", + "REQUEST_RENAME": "Se renombró la solicitud de {old_title} a {new_title}", + "REQUEST_DELETE": "Se eliminó la solicitud {title}" + }, + "add": { + "new": "Agregar nuevo", + "star": "Agregar estrella" + }, + "agent": { + "registration_instruction": "Por favor registra Hoppscotch Agent con tu cliente web para continuar.", + "enter_otp_instruction": "Por favor ingresa el código de verificación generado por Hoppscotch Agent y completa el registro", + "otp_label": "Código de verificación", + "processing": "Procesando tu solicitud...", + "not_running_title": "Agente no detectado", + "registration_title": "Registro del agente", + "verify_ssl_certs": "Verificar certificados SSL", + "ca_certs": "Certificados CA", + "client_certs": "Certificados de cliente", + "use_http_proxy": "Usar proxy HTTP", + "proxy_capabilities": "Hoppscotch Agent soporta proxies HTTP/HTTPS/SOCKS junto con autenticación NTLM y Basic Auth en esos proxies. Incluye el nombre de usuario y la contraseña para la autenticación del proxy en la URL misma.", + "add_cert_file": "Agregar archivo de certificado", + "add_client_cert": "Agregar certificado de cliente", + "add_key_file": "Agregar archivo de clave", + "domain": "Dominio", + "cert": "Certificado", + "key": "Clave", + "pfx_or_pkcs": "PFX/PKCS12", + "pfx_or_pkcs_file": "Archivo PFX/PKCS12", + "add_pfx_or_pkcs_file": "Agregar archivo PFX/PKCS12" + }, + "app": { + "additional_links": { + "macOS": "macOS", + "windows": "Windows", + "linux": "Linux", + "web_app": "App Web", + "cli": "CLI" + }, + "downloads": "Descargas", + "chat_with_us": "Habla con nosotros", + "contact_us": "Contáctanos", + "cookies": "Cookies", + "copy": "Copiar", + "copy_interface_type": "Copiar tipo de interfaz", + "copy_user_id": "Copiar token de autenticación de usuario", + "developer_option": "Opciones para desarrolladores", + "developer_option_description": "Herramientas para desarrolladores que ayudan en el desarrollo y mantenimiento de Hoppscotch.", + "discord": "Discord", + "documentation": "Documentación", + "github": "GitHub", + "help": "Ayuda y comentarios", + "home": "Inicio", + "invite": "Invitar", + "invite_description": "En Hoppscotch, diseñamos una interfaz simple e intuitiva para crear y administrar tus APIs. Hoppscotch es una herramienta que le ayuda a crear, probar, documentar y compartir tus APIs.", + "invite_your_friends": "Invita a tus amigos", + "join_discord_community": "Únete a nuestra comunidad Discord", + "keyboard_shortcuts": "Atajos de teclado", + "name": "Hoppscotch", + "new_version_found": "Se ha encontrado una nueva versión. Recarga la página para usarla.", + "open_in_hoppscotch": "Abrir en Hoppscotch", + "options": "Opciones", + "powered_by": "Desarrollado con Hoppscotch", + "proxy_privacy_policy": "Política de privacidad de proxy", + "reload": "Recargar", + "search": "Buscar", + "share": "Compartir", + "shortcuts": "Atajos", + "social_description": "Síguenos en redes sociales para estar al día de las últimas noticias, actualizaciones y lanzamientos.", + "social_links": "Redes sociales", + "spotlight": "Destacar", + "status": "Estado", + "status_description": "Comprobar el estado del sitio web", + "terms_and_privacy": "Términos y privacidad", + "twitter": "Twitter", + "type_a_command_search": "Escribe un comando o buscar algo…", + "we_use_cookies": "Usamos cookies", + "updated_text": "Hoppscotch se ha actualizado a v{version} 🎉", + "whats_new": "¿Qué hay de nuevo?", + "see_whats_new": "Novedades", + "wiki": "Wiki", + "collapse_sidebar": "Contraer barra lateral", + "continue_to_dashboard": "Continuar al panel", + "expand_sidebar": "Expandir barra lateral", + "no_name": "Sin nombre", + "open_navigation": "Abrir navegación", + "read_documentation": "Leer documentación", + "default": "predeterminado: {value}" + }, + "auth": { + "account_deactivated": "Tu cuenta ha sido desactivada. Contacta al administrador para obtener acceso.", + "account_exists": "La cuenta existe con una credencial diferente - Inicia sesión para vincular ambas cuentas", + "all_sign_in_options": "Todas las opciones de inicio de sesión", + "continue_with_auth_provider": "Continuar con {provider}", + "continue_with_email": "Continuar con correo electrónico", + "continue_with_github": "Continuar con GitHub", + "continue_with_github_enterprise": "Continuar con GitHub Enterprise", + "continue_with_google": "Continuar con Google", + "continue_with_microsoft": "Continuar con Microsoft", + "email": "Correo electrónico", + "logged_out": "Desconectado", + "login": "Acceder", + "login_success": "Inicio de sesión exitoso", + "login_to_hoppscotch": "Iniciar sesión en Hoppscotch", + "logout": "Cerrar sesión", + "re_enter_email": "Volver a introducir correo electrónico", + "send_magic_link": "Enviar un enlace mágico", + "sync": "Sincronizar", + "we_sent_magic_link": "¡Te enviamos un enlace mágico!", + "we_sent_magic_link_description": "Comprueba tu bandeja de entrada: hemos enviado un correo electrónico a {email}. Contiene un enlace mágico que te permitirá iniciar sesión." + }, + "authorization": { + "generate_token": "Generar token", + "refresh_token": "Refresh Token", + "graphql_headers": "Las cabeceras de autorización se envían como parte de la carga útil de connection_init", + "include_in_url": "Incluir en la URL", + "inherited_from": "Heredado {auth} de colección padre {collection} ", + "learn": "Aprender", + "oauth": { + "redirect_auth_server_returned_error": "El servidor de autenticación ha devuelto un estado de error", + "redirect_auth_token_request_failed": "Fallo en la solicitud de token de autentificación", + "redirect_auth_token_request_invalid_response": "Respuesta no válida del punto final de Token al solicitar un token de autentificación", + "redirect_invalid_state": "Valor de estado no válido presente en la redirección", + "redirect_no_auth_code": "No hay código de autorización en la redirección", + "redirect_no_client_id": "No se ha definido el ID de cliente", + "redirect_no_client_secret": "No se ha definido ningún ID secreto de cliente", + "redirect_no_code_verifier": "No se ha definido ningún verificador de códigos", + "redirect_no_token_endpoint": "No se ha definido ningún punto final de token", + "something_went_wrong_on_oauth_redirect": "Algo ha ido mal durante la redirección OAuth", + "something_went_wrong_on_token_generation": "Algo salió mal en la generación del token", + "token_generation_oidc_discovery_failed": "Fallo en la generación del token: Error en el descubrimiento de OpenID Connect", + "grant_type": "Tipo de autorización", + "grant_type_auth_code": "Código de autorización", + "token_fetched_successfully": "Token recuperado exitosamente", + "token_fetch_failed": "Fallo al recuperar el token", + "validation_failed": "Fallo de validación, comprueba los campos del formulario", + "no_refresh_token_present": "No hay Refresh Token presente. Por favor ejecuta el flujo de generación de token nuevamente", + "refresh_token_request_failed": "La solicitud de Refresh Token falló", + "token_refreshed_successfully": "Token actualizado correctamente", + "label_authorization_endpoint": "Punto final de autorización", + "label_client_id": "ID de cliente", + "label_client_secret": "Secreto de cliente", + "label_code_challenge": "Código de desafío", + "label_code_challenge_method": "Método código de desafío", + "label_code_verifier": "Verificador de código", + "label_scopes": "Ámbitos", + "label_token_endpoint": "Punto final de token", + "label_use_pkce": "Utilizar PKCE", + "label_implicit": "Implícito", + "label_password": "Contraseña", + "label_username": "Nombre de usuario", + "label_auth_code": "Código de autorización", + "label_client_credentials": "Credenciales del cliente", + "label_send_as": "Autenticación del cliente", + "label_send_in_body": "Enviar credenciales en el cuerpo", + "label_send_as_basic_auth": "Enviar credenciales como Basic Auth", + "enter_value": "Ingresa un valor", + "auth_request": "Solicitud de autenticación", + "token_request": "Solicitud de token", + "refresh_request": "Solicitud de actualización", + "send_in": "Enviar en" + }, + "pass_key_by": "Pasar por", + "pass_by_query_params_label": "Parámetros de consulta", + "pass_by_headers_label": "Cabeceras", + "password": "Contraseña", + "save_to_inherit": "Por favor, guarda esta solicitud en cualquier colección para heredar la autorización", + "token": "Token", + "access_token": "Access Token", + "client_token": "Client Token", + "client_secret": "Client Secret", + "timestamp": "Marca de tiempo", + "host": "Host", + "type": "Tipo de autorización", + "username": "Nombre de usuario", + "advance_config": "Configuración avanzada", + "advance_config_description": "Hoppscotch asigna automáticamente valores predeterminados a ciertos campos si no se proporciona un valor explícito", + "algorithm": "Algoritmo", + "payload": "Payload", + "secret": "Secreto", + "aws_signature": { + "access_key": "Access Key", + "secret_key": "Secret Key", + "service_name": "Nombre del servicio", + "aws_region": "Región de AWS", + "service_token": "Service Token" + }, + "digest": { + "realm": "Realm", + "nonce": "Nonce", + "algorithm": "Algoritmo", + "qop": "qop", + "nonce_count": "Nonce Count", + "client_nonce": "Client Nonce", + "opaque": "Opaque", + "disable_retry": "Deshabilitar reintento de solicitud" + }, + "akamai": { + "headers_to_sign": "Headers a firmar", + "max_body_size": "Tamaño máximo del cuerpo" + }, + "hawk": { + "id": "HAWK Auth ID", + "key": "HAWK Auth Key", + "ext": "ext", + "app": "app", + "dlg": "dlg", + "include": "Incluir hash del payload" + }, + "jwt": { + "params_name": "Nombre de parámetros", + "param_name": "Nombre del parámetro", + "header_prefix": "Prefijo del header", + "placeholder_request_header": "Prefijo del header de solicitud", + "placeholder_request_param": "Nombre de parámetros de solicitud", + "secret_base64_encoded": "Secreto codificado en Base64", + "headers": "Headers JWT", + "private_key": "Clave privada", + "placeholder_headers": "Headers JWT" + }, + "ntlm": { + "domain": "Dominio", + "workstation": "Estación de trabajo", + "disable_retrying_request": "Deshabilitar reintento de solicitud" + }, + "asap": { + "issuer": "Emisor", + "audience": "Audiencia", + "expires_in": "Expira en", + "key_id": "Key ID", + "optional_config": "Configuración opcional", + "subject": "Sujeto", + "additional_claims": "Claims adicionales" + } + }, + "collection": { + "title": "Colección", + "run": "Ejecutar colección", + "created": "Colección creada", + "different_parent": "No se puede reordenar la colección con un padre diferente", + "edit": "Editar colección", + "import_or_create": "Importar o crear una colección", + "import_collection": "Importar colección", + "invalid_name": "El nombre para la colección no es válido", + "invalid_root_move": "La colección ya está en la raíz", + "moved": "Movido con éxito", + "my_collections": "Mis colecciones", + "name": "Mi nueva colección", + "name_length_insufficient": "El nombre de la colección debe tener al menos 3 caracteres", + "new": "Nueva colección", + "order_changed": "Orden de colección actualizada", + "properties": "Propiedades de la colección", + "properties_updated": "Propiedades de la colección actualizadas", + "renamed": "Colección renombrada", + "request_in_use": "Solicitud en uso", + "save_as": "Guardar como", + "save_to_collection": "Guardar en la colección", + "select": "Seleccionar colección", + "select_location": "Seleccionar ubicación", + "sorted": "Colección ordenada", + "details": "Detalles", + "duplicated": "Colección duplicada" + }, + "confirm": { + "close_unsaved_tab": "¿Seguro que quieres cerrar esta pestaña?", + "close_unsaved_tabs": "¿Estás seguro de que quieres cerrar todas las pestañas? {count} pestañas no guardadas se perderán.", + "delete_all_activity_logs": "¿Estás seguro de que deseas eliminar todos los registros de actividad?", + "exit_team": "¿Estás seguro de que quieres dejar este equipo?", + "logout": "¿Estás seguro de que deseas cerrar la sesión?", + "remove_collection": "¿Estás seguro de que deseas eliminar esta colección de forma permanente?", + "remove_environment": "¿Estás seguro de que deseas eliminar este entorno de forma permanente?", + "remove_folder": "¿Estás seguro de que deseas eliminar esta carpeta de forma permanente?", + "remove_history": "¿Estás seguro de que deseas eliminar todo el historial de forma permanente?", + "remove_request": "¿Estás seguro de que deseas eliminar esta solicitud de forma permanente?", + "remove_response": "¿Estás seguro de que deseas eliminar permanentemente esta respuesta?", + "remove_shared_request": "¿Estás seguro de que quieres eliminar definitivamente esta solicitud compartida?", + "remove_team": "¿Estás seguro de que deseas eliminar este equipo?", + "remove_telemetry": "¿Estás seguro de que deseas darse de baja de la telemetría?", + "request_change": "¿Estás seguro de que deseas descartar la solicitud actual, los cambios no guardados se perderán.", + "save_unsaved_tab": "¿Deseas guardar los cambios realizados en esta pestaña?", + "sync": "¿Estás seguro de que deseas sincronizar este espacio de trabajo?", + "delete_access_token": "¿Estás seguro de que deseas eliminar el token de acceso {tokenLabel}?", + "delete_mock_server": "¿Estás seguro de que deseas eliminar este servidor mock?" + }, + "context_menu": { + "add_parameters": "Añadir a parámetros", + "open_request_in_new_tab": "Abrir solicitud en una nueva pestaña", + "set_environment_variable": "Establecer como variable", + "encode_uri_component": "Codificar componente de URL", + "decode_uri_component": "Decodificar componente de URL" + }, + "cookies": { + "modal": { + "cookie_expires": "Expira en", + "cookie_name": "Nombre", + "cookie_path": "Ruta", + "cookie_string": "Cookies", + "cookie_value": "Valor", + "empty_domain": "Dominio vacio", + "empty_domains": "No hay dominios", + "enter_cookie_string": "Introducir cookies", + "interceptor_no_support": "El interceptor seleccionado actualmente no admite cookies. Seleccione otro interceptor e inténtelo de nuevo.", + "managed_tab": "Gestionado", + "new_domain_name": "Nuevo nombre de dominio", + "no_cookies_in_domain": "No hay cookies para este dominio", + "raw_tab": "Sin procesar", + "set": "Establecer una cookie" + } + }, + "count": { + "currentValue": "Valor actual {count}", + "description": "Descripción {count}", + "header": "{count} encabezado(s)", + "initialValue": "Valor inicial {count}", + "key": "Clave {count}", + "message": "{count} mensaje(s)", + "parameter": "{count} parámetro(s)", + "protocol": "{count} protocolo(s)", + "value": "Valor {count}", + "variable": "{count} variable(es)" + }, + "documentation": { + "add_description": "Agregar descripción para esta colección aquí...", + "add_description_placeholder": "Agregar descripción aquí...", + "add_request_description": "Agregar descripción para la solicitud aquí...", + "auth": { + "access_key": "Clave de acceso", + "access_token": "Token de acceso", + "add_to": "Agregar a", + "akamai_edgegrid": "Akamai EdgeGrid", + "algorithm": "Algoritmo", + "api_key": "API Key", + "app_id": "App ID", + "auth_id": "Auth ID", + "auth_key": "Auth Key", + "auth_url": "Auth URL", + "aws_signature": "AWS Signature", + "basic_auth": "Basic Auth", + "bearer_token": "Bearer Token", + "client_id": "Client ID", + "client_nonce": "Client Nonce", + "client_secret": "Client Secret", + "client_token": "Client Token", + "delegation": "Delegación", + "digest_auth": "Digest Auth", + "extra_data": "Datos adicionales", + "grant_type": "Tipo de concesión", + "hawk_auth": "HAWK Auth", + "headers_to_sign": "Headers a firmar", + "host": "Host", + "include_payload_hash": "Incluir hash del payload", + "jwt_auth": "JWT Auth", + "max_body_size": "Tamaño máximo del cuerpo", + "no_auth": "Sin autenticación", + "nonce": "Nonce", + "oauth_2": "OAuth 2.0", + "opaque": "Opaque", + "password": "Contraseña", + "payload": "Payload", + "qop": "QOP", + "realm": "Realm", + "region": "Región", + "scope": "Scope", + "secret_key": "Clave secreta", + "service_name": "Nombre del servicio", + "timestamp": "Marca de tiempo", + "title": "Autenticación", + "token_url": "Token URL", + "user": "Usuario", + "username": "Nombre de usuario" + }, + "body": { + "content_type": "Tipo de contenido", + "no_body": "No se definió un cuerpo", + "title": "Cuerpo" + }, + "copied_to_clipboard": "¡Copiado al portapapeles!", + "curl": { + "click_to_load": "Haz clic para cargar el comando cURL", + "copied": "¡Comando cURL copiado al portapapeles!", + "copy_to_clipboard": "Copiar al portapapeles", + "generating": "Generando comando cURL...", + "load": "Cargar cURL", + "title": "cURL" + }, + "description": "Descripción", + "error_rendering_markdown": "Error al renderizar markdown:", + "fetching_documentation": "Obteniendo documentación...", + "generate": "Generar documentación", + "generate_message": "Importar cualquier colección de Hoppscotch para generar documentación de la API sobre la marcha.", + "headers": { + "no_headers": "No se definieron encabezados", + "title": "Encabezados" + }, + "hide_all_documentation": "Ocultar toda la documentación", + "inherited_from": "Heredado de {name}", + "inherited_with_type": "{type} heredado de {name}", + "key": "Clave", + "loading": "Cargando...", + "loading_collection_data": "Cargando datos de la colección...", + "no": "No", + "no_collection_data": "No hay datos de colección disponibles", + "no_documentation_found": "No se encontró documentación para carpetas o solicitudes", + "no_request_data": "No hay datos de solicitud disponibles", + "no_requests_or_folders": "No hay solicitudes ni carpetas", + "not_set": "No configurado", + "open_request_in_new_tab": "Abrir solicitud en una nueva pestaña", + "parameters": { + "no_params": "No se definieron parámetros", + "title": "Parámetros" + }, + "percent_complete": "% completado", + "processing_documentation": "Procesando documentación", + "publish": { + "already_published": "Esta colección ya está publicada", + "auto_sync": "Sincronización automática con la colección", + "auto_sync_description": "Actualizar automáticamente la documentación publicada cuando cambie la colección", + "button": "Publicar", + "copy_url": "Copiar URL", + "delete": "Eliminar documentación", + "unpublish_doc": "¿Estás seguro de que deseas despublicar la documentación?", + "delete_success": "Documentación publicada eliminada correctamente", + "doc_title": "Título", + "doc_version": "Versión", + "edit_published_doc": "Editar documento publicado", + "last_updated": "Última actualización", + "metadata": "Metadatos (JSON)", + "open_published_doc": "Abrir documentación publicada en una nueva pestaña", + "publish_error": "Error al publicar la documentación", + "publish_success": "¡Documentación publicada exitosamente!", + "published": "Publicada", + "published_url": "URL publicada", + "title": "Publicar documentación", + "update_button": "Actualizar", + "update_error": "Error al actualizar la documentación", + "update_published_docs": "Actualizar documentación publicada", + "update_success": "¡Documentación actualizada correctamente!", + "unpublish": "Despublicar", + "update_title": "Actualizar documentación publicada", + "url_copied": "¡URL copiada al portapapeles!", + "view_published": "Ver documentación publicada", + "view_title": "Instantánea de documentación publicada", + "versions": "Versiones", + "create_new_version": "Crear nueva versión", + "invalid_version": "La versión solo debe contener caracteres alfanuméricos, puntos y guiones", + "snapshot_description": "Esto creará una instantánea de la documentación actual como esta versión", + "live": "En vivo", + "snapshot": "Instantánea", + "version_immutable": "Las versiones publicadas son instantáneas de solo lectura", + "view_snapshot": "Ver instantánea", + "current_version": "ACTUAL", + "not_found": "No se encontró la documentación publicada", + "no_doc_id": "No se proporcionó un ID de documento", + "version_label": "v{version}", + "unpublish_version": "Despublicar esta versión", + "loading_snapshot": "Cargando instantánea...", + "retry_snapshot": "Reintentar", + "sensitive_data_warning": "Asegúrate de que no se expongan datos sensibles en la documentación publicada.", + "snapshot_preview": "Vista previa de la instantánea", + "snapshot_load_error": "Error al cargar la vista previa de la instantánea", + "snapshot_empty": "No hay solicitudes ni carpetas en esta instantánea", + "snapshot_item_count": "{count} elementos", + "auto_sync_live_notice": "Esta versión se sincroniza automáticamente con la colección en vivo", + "untitled_project": "Proyecto sin título", + "first_publish_hint": "Tu documentación se publicará como una versión en vivo que se sincroniza automáticamente con tu colección", + "environment": "Entorno", + "no_environment": "Sin entorno", + "environment_description": "Adjuntar un entorno para resolver variables en la documentación publicada" + }, + "request_opened_in_new_tab": "¡Solicitud abierta en una nueva pestaña!", + "response": { + "body": "Cuerpo de la respuesta", + "copy": "Copiar respuesta", + "example_copied": "¡Ejemplo de respuesta copiado al portapapeles!", + "example_copy_failed": "Error al copiar el ejemplo de respuesta", + "headers": "Encabezados de la respuesta", + "no_examples": "No hay ejemplos de respuesta disponibles", + "title": "Ejemplos de respuesta" + }, + "save_error": "Error al guardar la documentación", + "save_success": "Documentación guardada correctamente", + "saved_items_status": "Se guardaron {success} elementos. Error al guardar {failure} elementos.", + "show_all_documentation": "Mostrar toda la documentación", + "source": "Fuente", + "title": "Documentación", + "unsaved_changes": "Tienes {count} cambios sin guardar. Guarda antes de cerrar.", + "untitled_collection": "Colección sin título", + "untitled_request": "Solicitud sin título", + "value": "Valor", + "variables": { + "no_vars": "No se definieron variables", + "title": "Variables" + }, + "yes": "Sí" + }, + "empty": { + "activity_logs": "No se encontraron registros de actividad", + "authorization": "Esta solicitud no utiliza ninguna autorización", + "body": "Esta solicitud no tiene cuerpo", + "collection": "Colección vacía", + "collections": "No hay colecciones", + "documentation": "Es necesario conectarse a un punto final de GraphQL para ver la documentación", + "empty_schema": "No se encontró esquema", + "endpoint": "El punto final no puede estar vacío", + "environments": "No hay entornos", + "collection_variables": "Las variables de colección están vacías", + "folder": "Carpeta vacía", + "headers": "Esta solicitud no tiene encabezados", + "history": "No hay historial", + "invites": "Lista de invitados vacía", + "members": "No hay miembros en el equipo", + "parameters": "Esta solicitud no tiene ningún parámetro", + "pending_invites": "No hay invitaciones pendientes para este equipo", + "profile": "Iniciar sesión para ver tu perfil", + "protocols": "No hay protocolos", + "request_variables": "Esta solicitud no tiene variables de solicitud", + "schema": "Conectarse a un punto final de GraphQL", + "search_environment": "No se encontró un entorno que coincida con", + "secret_environments": "Los secretos no están sincronizados con Hoppscotch", + "shared_requests": "No hay solicitudes compartidas", + "shared_requests_logout": "Iniciar sesión para ver sus solicitudes compartidas o crear una nueva", + "subscription": "No hay suscripciones", + "team_name": "Nombre del equipo vacío", + "teams": "No hay equipos", + "tests": "No hay pruebas para esta solicitud", + "access_tokens": "No hay tokens de acceso disponibles", + "response": "No se recibió respuesta", + "mock_servers": "No se encontraron servidores mock" + }, + "environment": { + "heading": "Entorno", + "add_to_global": "Añadir a Global", + "added": "Adición al entorno", + "create_new": "Crear un nuevo entorno", + "created": "Entorno creado", + "current_value": "Valor actual", + "deleted": "Eliminar el entorno", + "duplicated": "Entorno duplicado", + "edit": "Editar entorno", + "empty_variables": "No hay variables", + "global": "Global", + "global_variables": "Variables globales", + "import_or_create": "Importar o crear un entorno", + "initial_value": "Valor inicial", + "invalid_name": "Proporciona un nombre válido para el entorno.", + "list": "Variables de entorno", + "my_environments": "Mis entornos", + "name": "Nombre", + "nested_overflow": "las variables de entorno anidadas están limitadas a 10 niveles", + "new": "Nuevo entorno", + "no_active_environment": "Ningún entorno activo", + "no_environment": "Sin entorno", + "no_environment_description": "No se ha seleccionado ningún entorno. Elije qué hacer con las siguientes variables.", + "quick_peek": "Vistazo rápido al entorno", + "replace_all_current_with_initial": "Reemplazar todos los actuales con los iniciales", + "replace_all_initial_with_current": "Reemplazar todos los iniciales con los actuales", + "replace_current_with_initial": "Reemplazar con el inicial", + "replace_initial_with_current": "Reemplazar con el actual", + "replace_with_variable": "Sustituir por variable", + "scope": "Ámbito", + "secrets": "Secretos", + "secret_value": "Valor de secreto", + "select": "Seleccionar entorno", + "set": "Establecer entorno", + "set_as_environment": "Establecer como entorno", + "short_name": "El entorno necesita tener al menos 1 carácter en su nombre", + "team_environments": "Entornos de trabajo en equipo", + "title": "Entornos", + "updated": "Entorno actualizado", + "value": "Valor", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Lista de variables", + "properties": "Propiedades del entorno", + "details": "Detalles" + }, + "error": { + "network": { + "heading": "Error de red", + "description": "La conexión de red falló. {message}: {cause}" + }, + "timeout": { + "heading": "Error de tiempo de espera", + "description": "La solicitud expiró durante {phase}. {message}" + }, + "certificate": { + "heading": "Error de certificado", + "description": "Certificado inválido. {message}: {cause}" + }, + "auth": { + "heading": "Error de autenticación", + "description": "Acceso denegado. {message}: {cause}" + }, + "proxy": { + "heading": "Error de proxy", + "description": "La conexión del proxy falló. {message}: {cause}" + }, + "parse": { + "heading": "Error de análisis", + "description": "Error al analizar la respuesta. {message}: {cause}" + }, + "version": { + "heading": "Error de versión", + "description": "Versiones incompatibles. {message}: {cause}" + }, + "abort": { + "heading": "Solicitud cancelada", + "description": "Operación cancelada. {message}: {cause}" + }, + "unknown": { + "heading": "Error desconocido", + "description": "Ocurrió un error desconocido.", + "cause": "Causa desconocida" + }, + "extension": { + "heading": "Error de extensión", + "description": "Error al ejecutar la solicitud en la extensión" + }, + "authproviders_load_error": "No se han podido cargar los proveedores de autenticación", + "browser_support_sse": "Este navegador no parece ser compatible con los eventos enviados por el servidor.", + "check_console_details": "Consulta el registro de la consola para obtener más detalles.", + "check_how_to_add_origin": "Comprueba cómo puede añadir un origen", + "curl_invalid_format": "cURL no está formateado correctamente", + "danger_zone": "Zona de peligro", + "delete_account": "Tu cuenta es actualmente propietaria en estos equipos:", + "delete_account_description": "Para poder eliminar tu cuenta, debes darte de baja, transferir la propiedad o eliminar estos equipos.", + "delete_activity_log": "Error al eliminar el registro de actividad", + "delete_all_activity_logs": "Error al eliminar todos los registros de actividad", + "email_already_exists": "Este correo electrónico ya existe con una cuenta diferente. Establece una dirección de correo electrónico diferente.", + "empty_email_address": "La dirección de correo electrónico no puede estar vacía.", + "empty_profile_name": "El nombre del perfil no puede estar vacío", + "empty_req_name": "Nombre de solicitud vacío", + "fetch_activity_logs": "Error al obtener los registros de actividad", + "f12_details": "(F12 para más detalles)", + "gql_prettify_invalid_query": "No se puede aplicar embellecedor a una consulta no válida, resuelve los errores de sintaxis de la consulta y vuelve a intentarlo", + "incomplete_config_urls": "URLs de configuración incompletas", + "incorrect_email": "Correo electrónico incorrecto", + "invalid_file_type": "Tipo de archivo inválido para `{filename}`.", + "invalid_link": "Enlace no válido", + "invalid_link_description": "El enlace que has pulsado no es válido o ha caducado.", + "invalid_embed_link": "La inserción no existe o no es válida.", + "json_parsing_failed": "JSON no válido", + "json_prettify_invalid_body": "No se puede aplicar embellecedor a un cuerpo inválido, resuelve errores de sintaxis json y vuelve a intentarlo", + "network_error": "Parece que hay un error de red. Por favor, inténtalo de nuevo.", + "network_fail": "No se pudo enviar la solicitud", + "no_collections_to_export": "No hay colecciones para exportar. Por favor, crea una colección para empezar.", + "no_duration": "Sin duración", + "no_environments_to_export": "No hay entornos para exportar. Por favor, crea un entorno para empezar.", + "no_results_found": "No se han encontrado coincidencias", + "page_not_found": "No se ha podido encontrar esta página", + "please_install_extension": "Por favor, instala la extensión y añade el origen a la extensión.", + "proxy_error": "Error de proxy", + "same_email_address": "La dirección de correo electrónico actualizada es la misma que la actual.", + "same_profile_name": "El nombre del perfil actualizado es el mismo que el nombre del perfil actual", + "script_fail": "No se pudo ejecutar el script de solicitud previa", + "something_went_wrong": "Algo salió mal", + "subscription_error": "Error al suscribirse al tema: {error}", + "post_request_script_fail": "No se ha podido ejecutar la secuencia de comandos posterior a la solicitud", + "reading_files": "Error al leer uno o más archivos.", + "fetching_access_tokens_list": "Algo ha ido mal al obtener la lista de tokens", + "generate_access_token": "Algo ha ido mal al generar el token de acceso", + "delete_access_token": "Algo ha ido mal al borrar el token de acceso", + "extension_not_found": "Extensión no encontrada", + "invalid_request": "Datos de solicitud no válidos" + }, + "export": { + "as_json": "Exportar como JSON", + "create_secret_gist": "Crear un Gist secreto", + "create_secret_gist_tooltip_text": "Exportar como Gist secreto", + "failed": "Algo ha ido mal al exportar", + "secret_gist_success": "Exportado con éxito como Gist secreto", + "require_github": "Iniciar sesión con GitHub para crear un Gist secreto", + "title": "Exportar", + "success": "Exportado con éxito" + }, + "file_upload": { + "choose_file": "Elegir archivo", + "max_size_format": "Máximo 5MB. Compatible con JPEG, PNG, GIF, WebP", + "profile_photo_updated": "Foto de perfil actualizada correctamente", + "profile_photo_removed": "Foto de perfil eliminada correctamente", + "org_logo_updated": "Logo de la organización actualizado correctamente", + "error_size_limit": "El tamaño del archivo debe ser menor a 5MB", + "error_invalid_format": "El archivo debe ser una imagen (JPEG, PNG, GIF o WebP)", + "error_invalid_upload_type": "Tipo de carga inválido", + "error_invalid_org_id": "Formato de ID de organización inválido", + "error_upload_failed": "Error en la carga. Inténtalo de nuevo", + "error_network_failed": "Error de red. Verifica tu conexión", + "error_timeout": "La carga expiró después de 30 segundos. Inténtalo de nuevo", + "error_missing_backend_url": "La URL del backend no está configurada. Establece la variable de entorno VITE_BACKEND_API_URL en la configuración de tu entorno", + "error_invalid_backend_url": "Configuración de URL del backend inválida" + }, + "filename": { + "cookie_key_value_pairs": "Cookie", + "codegen": "{request_name} - código", + "graphql_response": "GraphQL-Respuesta", + "lens": "{request_name} - respuesta", + "realtime_response": "Respuesta-en-tiempo-real", + "response_interface": "Interfaz-de-respuesta" + }, + "filter": { + "all": "Todos", + "none": "Ninguno", + "starred": "Destacado" + }, + "folder": { + "created": "Carpeta creada", + "edit": "Editar carpeta", + "invalid_name": "Proporciona un nombre para la carpeta.", + "name_length_insufficient": "El nombre de la carpeta debe tener al menos 3 caracteres", + "new": "Nueva carpeta", + "run": "Ejecutar carpeta", + "renamed": "Carpeta renombrada", + "sorted": "Carpeta ordenada" + }, + "graphql": { + "arguments": "Argumentos", + "connection_switch_confirm": "¿Deseas conectarte con el punto final de GraphQL más reciente?", + "connection_error_http": "Error al obtener el esquema GraphQL debido a un error de red.", + "connection_switch_new_url": "Al cambiar a una pestaña se desconectará de la conexión GraphQL activa. La nueva URL de conexión es", + "connection_switch_url": "Estás conectado a un punto final de GraphQL cuya URL de conexión es", + "deprecated": "Obsoleto", + "fields": "Campos", + "mutation": "Mutation", + "mutations": "Mutaciones", + "schema": "Esquema", + "show_depricated_values": "Mostrar valores obsoletos", + "subscription": "Subscription", + "subscriptions": "Suscripciones", + "switch_connection": "Cambiar conexión", + "url_placeholder": "Introduce una URL de punto final de GraphQL", + "query": "Query" + }, + "graphql_collections": { + "title": "Colecciones de GraphQL" + }, + "group": { + "time": "Tiempo", + "url": "URL" + }, + "header": { + "install_pwa": "Instalar aplicación", + "login": "Iniciar sesión", + "save_workspace": "Guardar mi espacio de trabajo" + }, + "helpers": { + "authorization": "El encabezado de autorización se generará automáticamente cuando se envía la solicitud.", + "collection_properties_authorization": " Esta autorización se establecerá para cada solicitud de esta colección.", + "collection_properties_header": "Este encabezado se establecerá para cada solicitud de esta colección.", + "generate_documentation_first": "Generar la documentación primero", + "network_fail": "No se puede acceder a la API. Comprueba tu conexión de red y vuelve a intentarlo.", + "offline": "Pareces estar desconectado. Es posible que los datos de este espacio de trabajo no estén actualizados.", + "offline_short": "Pareces estar desconectado.", + "post_request_tests": "Los scripts de prueba están escritos en JavaScript y se ejecutan después de recibir la respuesta.", + "pre_request_script": "Los scripts previos a la solicitud están escritos en JavaScript y se ejecutan antes de que se envíe la solicitud.", + "script_fail": "Parece que hay un problema técnico en el script de solicitud previa. Comprueba el error a continuación y corrige el script en consecuencia.", + "post_request_script_fail": "Parece que hay un error con el script posterior a la solicitud. Por favor, corrige los errores y vuelve a ejecutar el script.", + "post_request_script": "Escribir un script de prueba para automatizar la depuración." + }, + "hide": { + "collection": "Colapsar el panel de colecciones", + "more": "Ocultar más", + "preview": "Ocultar vista previa", + "sidebar": "Ocultar barra lateral", + "password": "Ocultar contraseña" + }, + "import": { + "collections": "Importar colecciones", + "curl": "Importar cURL", + "environments_from_gist": "Importar desde GitHub Gist", + "environments_from_gist_description": "Importar entornos Hoppscotch desde GitHub Gist", + "failed": "Importación fallida", + "from_file": "Importar desde archivo", + "from_gist": "Importar desde GitHub Gist", + "from_gist_description": "Importar desde URL de GitHub Gist", + "from_gist_import_summary": "Se importarán todas las funcionalidades de Hoppscotch.", + "from_hoppscotch_importer_summary": "Se importarán todas las funcionalidades de Hoppscotch.", + "from_insomnia": "Importar desde Insomnia", + "from_insomnia_description": "Importar desde una colección de Insomnia", + "from_insomnia_import_summary": "Se importarán colecciones y solicitudes.", + "from_json": "Importar de Hoppscotch", + "from_json_description": "Importar desde el archivo de colección de Hoppscotch", + "from_my_collections": "Importar desde Mis colecciones", + "from_my_collections_description": "Importar desde el archivo de Mis colecciones", + "from_all_collections": "Importar desde otro espacio de trabajo", + "from_all_collections_description": "Importar cualquier colección de otro espacio de trabajo al espacio de trabajo actual", + "from_openapi": "Importar desde OpenAPI", + "from_openapi_description": "Importar desde un archivo de especificación OpenAPI (YML/JSON)", + "from_openapi_import_summary": "Se importarán colecciones (se crearán a partir de tags), solicitudes y ejemplos de respuesta.", + "from_postman": "Importar desde Postman", + "from_postman_description": "Importar desde una colección de Postman", + "from_postman_import_summary": "Se importarán colecciones, solicitudes y ejemplos de respuesta.", + "import_scripts": "Importar scripts", + "import_scripts_description": "Compatible con Postman Collection v2.0/v2.1.", + "from_url": "Importar desde una URL", + "gist_url": "Introduce la URL del GitHub Gist", + "from_har": "Importar desde HAR", + "from_har_description": "Importar desde archivo HAR", + "from_har_import_summary": "Las solicitudes se importarán a una colección predeterminada.", + "gql_collections_from_gist_description": "Importar colecciones GraphQL desde GitHub Gist", + "hoppscotch_environment": "Entorno de Hoppscotch", + "hoppscotch_environment_description": "Importar archivo JSON del entorno de Hoppscotch", + "import_from_url_invalid_fetch": "No se han podido obtener datos de la url", + "import_from_url_invalid_file_format": "Error al importar colecciones", + "import_from_url_invalid_type": "Tipo no admitido. Los valores aceptados son \"hoppscotch\", \"openapi\", \"postman\", \"insomnia\".", + "import_from_url_success": "Colecciones Importadas", + "insomnia_environment_description": "Importar el entorno de Insomnia desde un archivo JSON/YAML", + "json_description": "Importar colecciones desde un archivo JSON de colecciones de Hoppscotch", + "postman_environment": "Entorno de Postman", + "postman_environment_description": "Importar entorno de Postman desde un archivo JSON", + "title": "Importar", + "file_size_limit_exceeded_warning_multiple_files": "Los archivos seleccionados exceden el límite recomendado de {sizeLimit}MB. Sólo se importarán los primeros {files} seleccionados.", + "file_size_limit_exceeded_warning_single_file": "El archivo seleccionado supera el límite recomendado de {sizeLimit}MB. Por favor, selecciona otro archivo.", + "success": "Importado con éxito", + "import_summary_collections_title": "Colecciones", + "import_summary_requests_title": "Solicitudes", + "import_summary_responses_title": "Respuestas", + "import_summary_pre_request_scripts_title": "Scripts de pre-solicitud", + "import_summary_post_request_scripts_title": "Scripts de post-solicitud", + "import_summary_not_supported_by_hoppscotch_import": "Actualmente no soportamos la importación de {featureLabel} desde esta fuente.", + "import_summary_script_found": "script encontrado pero no importado", + "import_summary_scripts_found": "scripts encontrados pero no importados", + "import_summary_enable_experimental_sandbox": "Para importar scripts de Postman, habilita 'Sandbox de scripting experimental' en la configuración. Nota: Esta función es experimental.", + "cors_error_modal": { + "title": "Error de CORS detectado", + "description": "La importación falló debido a restricciones de CORS (Cross-Origin Resource Sharing) impuestas por el servidor.", + "explanation": "Esta es una función de seguridad que impide que las páginas web realicen solicitudes a dominios diferentes. Puedes reintentar usando nuestro servicio de proxy para evitar esta restricción.", + "url_label": "URL intentada", + "retry_with_proxy": "Reintentar con proxy" + } + }, + "instances": { + "switch": "Cambiar instancia de Hoppscotch", + "enter_server_url": "Conectar a una instancia autoalojada", + "already_connected": "Ya estás conectado a esta instancia", + "recent_connections": "Conexiones recientes", + "add_instance": "Agregar una instancia", + "add_new": "Agregar una nueva instancia", + "confirm_remove": "Confirmar eliminación", + "remove_warning": "¿Estás seguro de que deseas eliminar esta instancia?", + "clear_cached_bundles": "Limpiar paquetes en caché", + "opening_add_modal": "Abriendo diálogo para agregar instancia", + "closed_add_modal": "Diálogo para agregar instancia cerrado", + "cancelled_removal": "Eliminación de instancia cancelada", + "connection_cancelled": "Conexión cancelada por la validación previa", + "post_connect_completed": "Configuración posterior a la conexión completada", + "connecting": "Conectando a la instancia...", + "confirm_removal": "Confirmar eliminación de la instancia", + "removal_cancelled": "Eliminación de instancia cancelada por la validación previa", + "post_remove_completed": "Limpieza posterior a la eliminación completada", + "removing": "Eliminando instancia...", + "clearing_cache": "Limpiando caché...", + "initialized": "Selector de instancias inicializado", + "connecting_state": "Estableciendo conexión...", + "connected_state": "Conectado exitosamente a la instancia", + "disconnected_state": "Desconectado de la instancia", + "stream_error": "Error al monitorear el estado de la conexión", + "recent_instances_error": "Error al cargar las instancias recientes", + "instance_changed": "Se cambió a la instancia", + "current_instance_error": "Error al rastrear la instancia actual", + "not_available": "El cambio de instancia no está disponible", + "cleanup_completed": "Limpieza del selector de instancias completada", + "self_hosted": "Instancias autoalojadas" + }, + "inspections": { + "description": "Inspeccionar posibles errores", + "environment": { + "add_environment": "Añadir al Entorno", + "add_environment_value": "Añadir valor", + "empty_value": "El valor de la variable de entorno '{variable}' está vacío ", + "not_found": "No se ha encontrado la variable de entorno \"{environment}\"." + }, + "header": { + "cookie": "El navegador no permite que Hoppscotch establezca el encabezado Cookie. Mientras trabajamos en la aplicación de escritorio de Hoppscotch (próximamente), por favor utilice el encabezado de autorización en su lugar." + }, + "response": { + "401_error": "Compruebe tus credenciales de autenticación.", + "404_error": "Compruebe la URL de su solicitud y el tipo de método.", + "cors_error": "Por favor, comprueba tu configuración de Compartición de Recursos \"Cross-Origin\".", + "default_error": "Por favor, comprueba tu solicitud.", + "network_error": "Comprueba tu conexión de red." + }, + "title": "Inspeccionador", + "url": { + "extension_not_installed": "Extensión no instalada.", + "extension_unknown_origin": "Asegúrate de haber agregado el origen del punto final de la API a la lista de Extensiones del Navegador Hoppscotch.", + "extention_enable_action": "Activar la extensión del navegador", + "extention_not_enabled": "Extensión no habilitada.", + "localaccess_unsupported": "El interceptor actual no soporta acceso local, considera usar los interceptores Agent, Extension o la aplicación de escritorio" + }, + "auth": { + "digest": "Se recomienda el interceptor Agent en la aplicación web o el interceptor Native en la aplicación de escritorio al usar autorización Digest.", + "hawk": "Se recomienda el interceptor Agent en la aplicación web o el interceptor Native en la aplicación de escritorio al usar autorización Hawk." + }, + "body": { + "binary": "El envío de datos binarios a través del interceptor actual aún no es compatible." + }, + "scripting_interceptor": { + "pre_request": "script de pre-solicitud", + "post_request": "script de post-solicitud", + "both_scripts": "scripts de pre-solicitud y post-solicitud", + "unsupported_interceptor": "Tu {scriptType} usa {apiUsed}. Para una ejecución confiable de scripts, cambia al interceptor Agent (aplicación web) o al interceptor Native (aplicación de escritorio). El interceptor {interceptor} tiene soporte limitado para solicitudes de scripting y puede no funcionar como se espera.", + "same_origin_csrf_warning": "Advertencia de seguridad: Tu {scriptType} realiza solicitudes del mismo origen usando {apiUsed}. Dado que esta plataforma usa autenticación basada en cookies, estas solicitudes incluyen automáticamente tus cookies de sesión, lo que podría permitir que scripts maliciosos realicen acciones no autorizadas. Usa el interceptor Agent para solicitudes del mismo origen, o solo ejecuta scripts en los que confíes." + } + }, + "interceptor": { + "native": { + "name": "Native", + "settings_title": "Native" + }, + "agent": { + "name": "Agent", + "settings_title": "Agent" + }, + "proxy": { + "name": "Proxy", + "settings_title": "Proxy" + }, + "browser": { + "name": "Navegador", + "settings_title": "Navegador" + }, + "extension": { + "name": "Extensión", + "settings_title": "Extensión" + } + }, + "layout": { + "collapse_collection": "Contraer o expandir colecciones", + "collapse_sidebar": "Contraer o expandir la barra lateral", + "column": "Disposición vertical", + "name": "Diseño", + "row": "Disposición horizontal" + }, + "modal": { + "close_unsaved_tab": "Tienes cambios sin guardar", + "collections": "Colecciones", + "confirm": "Confirmar", + "customize_request": "Personalizar solicitud", + "edit_request": "Editar solicitud", + "edit_response": "Editar respuesta", + "import_export": "Importación y exportación", + "response_name": "Nombre de la respuesta", + "share_request": "Compartir solicitud" + }, + "mqtt": { + "already_subscribed": "Ya estás suscrito a este tema.", + "clean_session": "Borrar sesión", + "clear_input": "Borrar entrada", + "clear_input_on_send": "Borrar entrada al enviar", + "client_id": "Identificación del cliente", + "color": "Elige un color", + "communication": "Comunicación", + "connection_config": "Configuración de conexión", + "connection_not_authorized": "Esta conexión MQTT no utiliza ninguna autenticación.", + "invalid_topic": "Indica un tema para la suscripción", + "keep_alive": "Mantenerse activo", + "log": "Registro", + "lw_message": "Mensaje de última voluntad", + "lw_qos": "QoS de última voluntad", + "lw_retain": "Última voluntad", + "lw_topic": "Tema de última voluntad", + "message": "Mensaje", + "new": "Nueva suscripción", + "not_connected": "Por favor, inicia primero una conexión MQTT.", + "publish": "Publicar", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Suscribir", + "topic": "Tema", + "topic_name": "Nombre del tema", + "topic_title": "Publicar / Suscribir tema", + "unsubscribe": "Desuscribirse", + "url": "URL" + }, + "navigation": { + "admin_dashboard": "Panel de control", + "doc": "Documentación", + "graphql": "GraphQL", + "profile": "Perfil", + "realtime": "Tiempo real", + "rest": "REST", + "mock_servers": "Servidores mock", + "settings": "Ajustes", + "goto_app": "Ir a la aplicación", + "authentication": "Autenticación" + }, + "mock_server": { + "confirm_delete_log": "¿Estás seguro de que deseas eliminar este registro?", + "create_mock_server": "Configurar servidor mock", + "mock_server_configuration": "Configuración del servidor mock", + "mock_server_name": "Nombre del servidor mock", + "mock_server_name_placeholder": "Ingresa el nombre del servidor mock", + "base_url": "URL base", + "start_server": "Iniciar servidor", + "stop_server": "Detener servidor", + "mock_server_created": "Servidor mock creado exitosamente", + "mock_server_started": "Servidor mock iniciado exitosamente", + "mock_server_stopped": "Servidor mock detenido exitosamente", + "active": "El servidor mock está activo", + "inactive": "El servidor mock está inactivo", + "edit_mock_server": "Editar servidor mock", + "path_based_url": "URL basada en ruta", + "subdomain_based_url": "URL basada en subdominio", + "mock_server_updated": "Servidor mock actualizado exitosamente", + "no_collection": "Sin colección", + "collection_deleted": "colección asociada eliminada.", + "private_access_hint": "Para servidores mock privados, incluye el encabezado 'x-api-key' con tu token de acceso personal (crea uno desde tu perfil).", + "private_access_instruction": "Para acceder a este servidor mock privado, incluye el encabezado 'x-api-key' con tu token de acceso personal.", + "create_token_here": "Crear aquí", + "status": "Estado", + "server_running": "El servidor está en ejecución", + "server_stopped": "El servidor está detenido", + "delay_ms": "Retraso de respuesta (ms)", + "delay_placeholder": "Ingresa el retraso en milisegundos", + "delay_description": "Agregar retraso artificial a las respuestas mock", + "public_access": "Acceso público", + "public": "Público", + "private": "Privado", + "public_description": "Cualquier persona con la URL puede acceder a este servidor mock", + "private_description": "Solo los usuarios autenticados pueden acceder a este servidor mock", + "select_collection": "Seleccionar una colección", + "select_collection_error": "Por favor selecciona una colección", + "invalid_collection_error": "Error al crear un servidor mock para la colección.", + "url_copied": "URL copiada al portapapeles", + "make_public": "Hacer público", + "view_logs": "Ver registros", + "logs_title": "Registros del servidor mock", + "no_logs": "No hay registros disponibles", + "request_headers": "Encabezados de la solicitud", + "request_body": "Cuerpo de la solicitud", + "response_status": "Estado de la respuesta", + "response_headers": "Encabezados de la respuesta", + "response_body": "Cuerpo de la respuesta", + "log_deleted": "Registro eliminado exitosamente", + "description": "Los servidores mock te permiten simular respuestas de API basadas en las respuestas de ejemplo de tu colección.", + "set_in_environment": "Establecer en el entorno", + "set_in_environment_hint": "La URL del servidor mock se agregará automáticamente como variable 'mockUrl' en el entorno de la colección", + "environment_variable_added": "URL mock agregada al entorno", + "environment_variable_updated": "URL mock actualizada en el entorno", + "environment_created_with_variable": "Entorno creado con URL mock", + "add_example_request": "Agregar solicitud de ejemplo", + "add_example_request_hint": "La colección se creará con una solicitud de ejemplo que demuestra cómo usar el servidor mock", + "create_example_collection": "Crear colección de ejemplo", + "create_example_collection_hint": "Crear una colección de ejemplo de tienda de mascotas con solicitudes de ejemplo (GET, POST, PUT, DELETE)", + "creating_example_collection": "Creando colección de ejemplo...", + "failed_to_create_collection": "Error al crear la colección de ejemplo", + "enable_example_collection_hint": "Por favor habilita la opción 'Crear colección de ejemplo' para el modo de nueva colección", + "new_collection_name_hint": "La colección se creará con el mismo nombre que tu servidor mock", + "existing_collection": "Colección existente", + "new_collection": "Nueva colección" + }, + "preRequest": { + "javascript_code": "Código JavaScript", + "learn": "Leer documentación", + "script": "Script previo a la solicitud", + "snippets": "Fragmentos" + }, + "profile": { + "app_settings": "Ajustes de la aplicación", + "default_hopp_displayname": "Usuario anónimo", + "editor": "Editor", + "editor_description": "Los editores pueden añadir, editar y eliminar solicitudes.", + "email_verification_mail": "Se ha enviado un correo electrónico de verificación a tu dirección de correo electrónico. Haz clic en el enlace para verificar tu dirección de correo electrónico.", + "no_permission": "No tienes permiso para realizar esta acción.", + "owner": "Propietario", + "owner_description": "Los propietarios pueden añadir, editar y eliminar solicitudes, colecciones y miembros del equipo.", + "roles": "Roles", + "roles_description": "Los roles se utilizan para controlar el acceso a las colecciones compartidas.", + "updated": "Perfil actualizado", + "viewer": "Espectador", + "viewer_description": "Los espectadores sólo pueden ver y utilizar las solicitudes.", + "verified_email_sent": "Se ha enviado un correo de verificación a tu dirección de correo electrónico. Por favor actualiza la página después de verificar tu correo electrónico. Recibirás un correo si esta dirección no está asociada con otra cuenta." + }, + "remove": { + "star": "Eliminar estrella" + }, + "request": { + "added": "Solicitud agregada", + "add": "Agregar solicitud", + "authorization": "Autorización", + "body": "Cuerpo de la solicitud", + "choose_language": "Seleccionar lenguaje", + "content_type": "Tipo de contenido", + "content_type_titles": { + "others": "Otros", + "structured": "Estructurado", + "text": "Texto", + "binary": "Binario" + }, + "show_content_type": "Mostrar tipo de contenido", + "different_collection": "No se pueden reordenar solicitudes de diferentes colecciones", + "duplicated": "Solicitud duplicada", + "duration": "Duración", + "enter_curl": "Ingrese cURL", + "generate_code": "Generar código", + "generated_code": "Código generado", + "go_to_authorization_tab": "Ir a la pestaña Autorización", + "go_to_body_tab": "Ir a la pestaña de cuerpo de solicitud", + "header_list": "Lista de encabezados", + "invalid_name": "Proporciona un nombre para la solicitud.", + "method": "Método", + "moved": "Solicitud movida", + "name": "Nombre de solicitud", + "new": "Nueva solicitud", + "order_changed": "Orden de solicitudes actualizadas", + "override": "Anular", + "override_help": "Establecer Content-Type en las cabeceras", + "overriden": "Anulado", + "parameter_list": "Parámetros de consulta", + "parameters": "Parámetros", + "path": "Ruta", + "payload": "Carga útil", + "query": "Consulta", + "raw_body": "cuerpo sin procesar", + "rename": "Renombrar solicitud", + "renamed": "Solicitud renombrada", + "request_variables": "Variables de solicitud", + "response_name_exists": "El nombre de la respuesta ya existe", + "run": "Ejecutar", + "save": "Guardar", + "save_as": "Guardar como", + "saved": "Solicitud guardada", + "share": "Compartir", + "share_description": "Comparte Hoppscotch con tus amigos", + "share_request": "Compartir solicitud", + "stop": "Detener", + "title": "Solicitud", + "type": "Tipo de solicitud", + "url": "URL", + "url_placeholder": "Introduce una URL o pega un comando cURL", + "variables": "Variables", + "view_my_links": "Ver mis enlaces", + "generate_name_error": "Error al generar el nombre de la solicitud." + }, + "response": { + "audio": "Audio", + "body": "Cuerpo de respuesta", + "duplicated": "Respuesta duplicada", + "duplicate_name_error": "Ya existe una respuesta con el mismo nombre", + "filter_response_body": "Filtrar el cuerpo de la respuesta JSON (utiliza la sintaxis jq)", + "headers": "Encabezados", + "request_headers": "Encabezados de la solicitud", + "html": "HTML", + "image": "Imagen", + "json": "JSON", + "pdf": "PDF", + "please_save_request": "Guarda la solicitud para crear un ejemplo", + "preview_html": "Vista previa de HTML", + "raw": "Sin procesar", + "renamed": "Respuesta renombrada", + "same_name_inspector_warning": "Ya existe una respuesta con este nombre para esta solicitud, si se guarda se sobrescribirá la respuesta existente", + "size": "Tamaño", + "status": "Estado", + "time": "Tiempo", + "title": "Respuesta", + "video": "Video", + "waiting_for_connection": "esperando la conexión", + "xml": "XML", + "generate_data_schema": "Generar esquema de datos", + "data_schema": "Esquema de datos", + "saved": "Respuesta guardada", + "invalid_name": "Por favor proporciona un nombre para la respuesta" + }, + "settings": { + "accent_color": "Color de acentuación", + "account": "Cuenta", + "account_deleted": "Tu cuenta ha sido eliminada", + "account_description": "Personaliza la configuración de tu cuenta.", + "account_email_description": "Tu dirección de correo electrónico principal.", + "account_name_description": "Este es tu nombre para mostrar.", + "additional": "Configuración adicional", + "agent_not_running": "No se detectó el Agente de Hoppscotch. Por favor, verifica si el Agente está en ejecución.", + "agent_not_running_short": "Verifica el estado del Agente.", + "agent_running": "El Agente de Hoppscotch está activo.", + "agent_running_short": "El Agente de Hoppscotch está activo.", + "agent_discard_registration": "Descartar registro del Agente", + "agent_registered": "Agente registrado", + "agent_registration_successful": "Agente registrado exitosamente", + "agent_registration_fetch_failed": "No se pudo obtener la información de registro del Agente. Por favor, vuelve a registrar el Agente.", + "agent_registration_already_in_progress": "El registro del Agente ya está en progreso. Por favor, completa o cancela el registro actual e intenta de nuevo.", + "auto_encode_mode": "Automático", + "auto_encode_mode_tooltip": "Codificar los parámetros en la solicitud solo si hay caracteres especiales presentes", + "background": "Fondo", + "black_mode": "Negro", + "choose_language": "Elegir idioma", + "dark_mode": "Oscuro", + "delete_account": "Eliminar cuenta", + "delete_account_description": "Una vez que elimines tu cuenta, todos tus datos se borrarán permanentemente. Esta acción no se puede deshacer.", + "disable_encode_mode_tooltip": "Nunca codificar los parámetros en la solicitud", + "enable_encode_mode_tooltip": "Siempre codificar los parámetros en la solicitud", + "enter_otp": "Ingresa el código del Agente", + "expand_navigation": "Expandir la navegación", + "experiments": "Experimentos", + "experiments_notice": "Esta es una colección de experimentos en los que estamos trabajando que podrían resultar útiles, divertidos, ambos o ninguno. No son definitivos y es posible que no sean estables, por lo que si sucede algo demasiado extraño, no se asuste. Solo apaga la maldita cosa. Fuera de bromas,", + "extension_ver_not_reported": "No reportado", + "extension_version": "Versión de extensión", + "extensions": "Extensiones", + "extensions_use_toggle": "Utilizar la extensión del navegador para enviar peticiones (si está presente)", + "follow": "Síguenos", + "general": "General", + "general_description": "Configuración general utilizada en la aplicación", + "interceptor": "Interceptador", + "interceptor_description": "Middleware entre la aplicación y las APIs.", + "kernel_interceptor": "Interceptador", + "kernel_interceptor_description": "Middleware entre la aplicación y las APIs.", + "language": "Idioma", + "light_mode": "Luz", + "official_proxy_hosting": "El proxy oficial está alojado en Hoppscotch.", + "query_parameters_encoding": "Codificación de parámetros de consulta", + "query_parameters_encoding_description": "Configura la codificación para los parámetros de consulta en las solicitudes", + "profile": "Perfil", + "profile_description": "Actualiza los datos de tu perfil", + "profile_email": "Correo electrónico", + "profile_name": "Nombre de perfil", + "profile_photo": "Foto de perfil", + "proxy": "Proxy", + "proxy_url": "URL de proxy", + "proxy_use_toggle": "Utilizar el middleware de proxy para enviar peticiones", + "read_the": "Leer el", + "register_agent": "Registrar Agente", + "reset_default": "Restablecer a los predeterminados", + "short_codes": "Shortcodes", + "short_codes_description": "Shortcodes creados por ti.", + "sidebar_on_left": "Barra lateral a la izquierda", + "ai_experiments": "Experimentos de AI", + "ai_request_naming_style": "Estilo de nomenclatura de solicitudes", + "ai_request_naming_style_descriptive_with_spaces": "Descriptivo con espacios", + "ai_request_naming_style_camel_case": "Camel Case ( camelCase )", + "ai_request_naming_style_snake_case": "Snake Case ( snake_case )", + "ai_request_naming_style_pascal_case": "Pascal Case ( PascalCase )", + "ai_request_naming_style_custom": "Personalizado", + "ai_request_naming_style_custom_placeholder": "Ingresa tu plantilla de estilo de nomenclatura personalizada...", + "experimental_scripting_sandbox": "Sandbox de scripting experimental", + "enable_experimental_mock_servers": "Habilitar servidores mock", + "enable_experimental_documentation": "Habilitar documentación", + "sync": "Sincronizar", + "sync_collections": "Colecciones", + "sync_description": "Esta configuración se sincroniza con la nube.", + "sync_environments": "Entornos", + "sync_history": "Historial", + "history_disabled": "El historial está deshabilitado. Contacta al administrador de tu organización para habilitarlo", + "system_mode": "Sistema", + "telemetry": "Telemetría", + "telemetry_helps_us": "La telemetría nos ayuda a personalizar nuestras operaciones y brindarte la mejor experiencia.", + "theme": "Tema", + "theme_description": "Personaliza el tema de tu aplicación.", + "use_experimental_url_bar": "Utilizar la barra de URL experimental con resaltado de entorno", + "user": "Usuario", + "verified_email": "Correo electrónico verificado", + "verify_email": "Verificar correo electrónico", + "validate_certificates": "Validar certificados SSL/TLS", + "verify_host": "Verificar host", + "verify_peer": "Verificar par", + "follow_redirects": "Seguir redirecciones", + "client_certificates": "Certificados de cliente", + "certificate_settings": "Configuración de certificados", + "certificate": "Certificado", + "key": "Clave privada", + "pfx_or_p12": "PFX/PKCS#12", + "password": "Contraseña", + "select_file": "Seleccionar archivo", + "domain": "Dominio", + "add_certificate": "Agregar certificado", + "add_cert_file": "Agregar archivo de certificado", + "add_key_file": "Agregar archivo de clave", + "add_pfx_file": "Agregar archivo PFX", + "global_defaults": "Valores predeterminados globales", + "add_domain_override": "Agregar anulación de dominio", + "domain_override": "Anulación de dominio", + "manage_domains_overrides": "Administrar anulaciones de dominio", + "add_domain": "Agregar dominio", + "remove_domain": "Eliminar dominio", + "ca_certificate": "Certificado CA", + "ca_certificates": "Certificados CA", + "ca_certificates_support": "Hoppscotch admite archivos .crt, .cer o .pem que contengan uno o más certificados.", + "proxy_capabilities": "El Agente de Hoppscotch y la aplicación de escritorio admiten proxies HTTP/HTTPS/SOCKS con soporte para NTLM y autenticación básica.", + "proxy_auth": "También puedes incluir el nombre de usuario y la contraseña en la URL." + }, + "shared_requests": { + "button": "Botón", + "button_info": "Crea un botón \"Ejecutar en Hoppscotch\" para tu página web, blog o un README.", + "copy_html": "Copiar HTML", + "copy_link": "Copiar enlace", + "copy_markdown": "Copiar Markdown", + "creating_widget": "Crear widget", + "customize": "Personalizar", + "deleted": "Solicitud compartida eliminada", + "description": "Selecciona un widget, puedes cambiarlo y personalizarlo más tarde", + "embed": "Incrustar", + "embed_info": "Añada un mini \"Hoppscotch API Playground\" a tu sitio web, blog o documentación.", + "link": "Enlace", + "link_info": "Crea un enlace compartible para compartirlo con cualquier persona en Internet con acceso de visualización.", + "modified": "Solicitud compartida modificada", + "not_found": "Solicitud compartida no encontrada", + "open_new_tab": "Abrir en una nueva pestaña", + "preview": "Vista previa", + "run_in_hoppscotch": "Ejecutar en Hoppscotch", + "theme": { + "dark": "Oscuro", + "light": "Claro", + "system": "Sistema", + "title": "Tema" + }, + "action": "Acción", + "clear_filter": "Limpiar filtro", + "confirm_request_deletion": "¿Confirmas la eliminación de la solicitud compartida seleccionada?", + "copy": "Copiar", + "created_on": "Fecha de creación", + "delete": "Eliminar", + "email": "Correo electrónico", + "filter": "Filtrar", + "filter_by_email": "Filtrar por correo electrónico", + "id": "ID", + "load_list_error": "No se pudo cargar la lista de solicitudes compartidas", + "no_requests": "No se encontraron solicitudes compartidas", + "open_request": "Abrir solicitud", + "properties": "Propiedades", + "request": "Solicitud", + "show_more": "Mostrar más", + "title": "Solicitudes compartidas", + "url": "URL" + }, + "shortcut": { + "general": { + "close_current_menu": "Cerrar el menú actual", + "command_menu": "Menú de búsqueda y comandos", + "help_menu": "Menú de ayuda", + "show_all": "Atajos de teclado", + "title": "General", + "comment_uncomment": "Comentar/Descomentar", + "close_tab": "Cerrar pestaña", + "undo": "Deshacer", + "redo": "Rehacer" + }, + "miscellaneous": { + "invite": "Invita a tus amigos a Hoppscotch", + "title": "Varios" + }, + "navigation": { + "back": "Volver a la página anterior", + "documentation": "Ir a la página de documentación", + "forward": "Avanzar a la siguiente página", + "graphql": "Ir a la página GraphQL", + "profile": "Ir a la página de perfil", + "realtime": "Ir a la página en tiempo real", + "rest": "Ir a la página REST", + "settings": "Ir a la página de configuración", + "title": "Navegación" + }, + "others": { + "prettify": "Formatear el contenido del editor", + "title": "Otros" + }, + "request": { + "delete_method": "Seleccionar método DELETE", + "get_method": "Seleccionar método GET", + "head_method": "Seleccionar método HEAD", + "import_curl": "Importar cURL", + "method": "Método", + "next_method": "Seleccionar método siguiente", + "post_method": "Seleccionar método POST", + "previous_method": "Seleccionar método anterior", + "put_method": "Seleccionar método PUT", + "rename": "Renombrar solicitud", + "reset_request": "Solicitud de reinicio", + "save_request": "Guardar solicitud", + "save_to_collections": "Guardar en colecciones", + "send_request": "Enviar solicitud", + "share_request": "Compartir solicitud", + "show_code": "Generar fragmento de código", + "title": "Solicitud", + "focus_url": "Enfocar barra de URL" + }, + "response": { + "copy": "Copiar la respuesta al portapapeles", + "download": "Descargar la respuesta como archivo", + "title": "Respuesta" + }, + "tabs": { + "title": "Pestañas", + "new_tab": "Nueva pestaña", + "close_tab": "Cerrar pestaña", + "reopen_tab": "Reabrir pestaña cerrada", + "next_tab": "Siguiente pestaña", + "previous_tab": "Pestaña anterior", + "first_tab": "Cambiar a la primera pestaña", + "last_tab": "Cambiar a la última pestaña", + "mru_switch": "Cambiar a pestaña reciente (MRU)", + "mru_switch_reverse": "Cambiar a pestaña reciente anterior (MRU)" + }, + "theme": { + "black": "Cambiar el tema al modo negro", + "dark": "Cambiar el tema al modo oscuro", + "light": "Cambiar el tema a modo de claro", + "system": "Cambiar el tema al modo de sistema", + "title": "Tema" + } + }, + "show": { + "code": "Mostrar código", + "collection": "Ampliar el panel de colecciones", + "more": "Mostrar más", + "sidebar": "Mostrar barra lateral", + "password": "Mostrar contraseña" + }, + "socketio": { + "communication": "Comunicación", + "connection_not_authorized": "Esta conexión SocketIO no utiliza ningún tipo de autenticación.", + "event_name": "Nombre del evento", + "events": "Eventos", + "log": "Registro", + "url": "URL" + }, + "spotlight": { + "change_language": "Cambiar idioma", + "environments": { + "delete": "Borrar el entorno actual", + "duplicate": "Duplicar el entorno actual", + "duplicate_global": "Entorno global duplicado", + "edit": "Editar el entorno actual", + "edit_global": "Editar el entorno global", + "new": "Crear un nuevo entorno", + "new_variable": "Crear una nueva variable de entorno", + "title": "Entornos" + }, + "general": { + "chat": "Chatear con el servicio de asistencia", + "help_menu": "Ayuda y asistencia", + "open_docs": "Leer la documentación", + "open_github": "Abrir repositorio de GitHub", + "open_keybindings": "Atajos de teclado", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Conectarse al servidor", + "disconnect": "Desconectarse del servidor" + }, + "miscellaneous": { + "invite": "Invita a tus amigos a Hoppscotch", + "title": "Varios" + }, + "request": { + "save_as_new": "Guardar como nueva solicitud", + "select_method": "Seleccionar método", + "switch_to": "Cambiar a", + "tab_authorization": "Pestaña de autorización", + "tab_body": "Pestaña de cuerpo", + "tab_headers": "Pestaña de encabezados", + "tab_parameters": "Pestaña de parámetros", + "tab_pre_request_script": "Pestaña del script de pre-solicitud", + "tab_query": "Pestaña de consulta", + "tab_tests": "Pestaña de pruebas", + "tab_variables": "Pestaña de variables" + }, + "response": { + "copy": "Copiar respuesta", + "download": "Descargar la respuesta como archivo", + "title": "Respuesta" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interfaz", + "theme": "Tema", + "user": "Usuario" + }, + "settings": { + "change_interceptor": "Cambiar Interceptor", + "change_language": "Cambiar idioma", + "theme": { + "black": "Negro", + "dark": "Oscuro", + "light": "Claro", + "system": "Preferencia del sistema" + } + }, + "tab": { + "close_current": "Cerrar la pestaña actual", + "close_others": "Cerrar todas las demás pestañas", + "duplicate": "Duplicar pestaña actual", + "new_tab": "Abrir una nueva pestaña", + "next": "Cambiar a la siguiente pestaña", + "previous": "Cambiar a la pestaña anterior", + "switch_to_first": "Cambiar a la primera pestaña", + "switch_to_last": "Cambiar a la última pestaña", + "mru_switch": "Cambiar a pestaña reciente", + "mru_switch_reverse": "Cambiar a pestaña reciente anterior", + "title": "Pestañas" + }, + "workspace": { + "delete": "Borrar el espacio de trabajo actual", + "edit": "Editar el espacio de trabajo actual", + "invite": "Invitar al espacio de trabajo", + "new": "Crear un nuevo espacio de trabajo", + "switch_to_personal": "Cambiar a tu espacio de trabajo personal", + "title": "Espacio de trabajo" + }, + "phrases": { + "try": "Probar", + "import_collections": "Importar colecciones", + "create_environment": "Crear entorno", + "create_workspace": "Crear espacio de trabajo", + "share_request": "Compartir solicitud" + } + }, + "sse": { + "event_type": "Tipo de evento", + "log": "Registro", + "url": "URL" + }, + "state": { + "bulk_mode": "Edición masiva", + "bulk_mode_placeholder": "Las entradas están separadas por una nueva línea\nLas claves y los valores están separados por:\nAnteponer # a cualquier fila que desee agregar pero mantener deshabilitada", + "cleared": "Limpio", + "connected": "Conectado", + "connected_to": "Conectado a {name}", + "connecting_to": "Conectando con {name}...", + "connection_error": "Error de conexión", + "connection_failed": "Conexión fallida", + "connection_lost": "Conexión perdida", + "copied_interface_to_clipboard": "Copiado tipo de interfaz {language} al portapapeles", + "copied_to_clipboard": "Copiado al portapapeles", + "deleted": "Eliminado", + "deprecated": "OBSOLETO", + "disabled": "Desactivado", + "disconnected": "Desconectado", + "disconnected_from": "Desconectado de {name}", + "docs_generated": "Documentación generada", + "download_failed": "Descarga fallida", + "download_started": "Descarga iniciada", + "enabled": "Activado", + "experimental": "Experimental", + "file_imported": "Archivo importado", + "finished_in": "Terminado en {duration}ms", + "hide": "Ocultar", + "history_deleted": "Historial eliminado", + "linewrap": "Envolver líneas", + "loading": "Cargando...", + "message_received": "Mensaje: llegó {message} al: {topic}", + "mqtt_subscription_failed": "Algo ha ido mal al suscribirse al tema: {topic}", + "no_content_found": "No se encontró contenido", + "none": "Ninguno", + "nothing_found": "Nada encontrado para", + "published_error": "Algo ha ido mal al publicar el mensaje: {message} al tema: {topic}", + "published_message": "Mensaje publicado: {message} al tema: {topic}", + "reconnection_error": "Fallo en la reconexión", + "saved": "Guardado", + "show": "Mostrar", + "subscribed_failed": "Error al suscribirse al tema: {topic}", + "subscribed_success": "Suscrito con éxito al tema: {topic}", + "unsubscribed_failed": "Error al darse de baja del tema: {topic}", + "unsubscribed_success": "Se ha cancelado la suscripción al tema: {topic}", + "waiting_send_request": "Esperando para enviar solicitud", + "user_deactivated": "Tu cuenta está desactivada. Por favor, contacta al administrador para reactivar tu cuenta.", + "add_user_failure": "No se pudo agregar al usuario al espacio de trabajo.", + "add_user_success": "El usuario ahora es miembro del espacio de trabajo.", + "admin_failure": "No se pudo asignar el rol de administrador al usuario.", + "admin_success": "El usuario ahora es administrador.", + "and": "y", + "clear_selection": "Limpiar selección", + "configure_auth": "Por favor, configura un proveedor de autenticación desde la configuración de administrador o consulta la documentación para configurar proveedores de autenticación.", + "confirm_admin_to_user": "¿Deseas remover el estado de administrador de este usuario?", + "confirm_admins_to_users": "¿Deseas remover el estado de administrador de los usuarios seleccionados?", + "confirm_delete_infra_token": "¿Estás seguro de que deseas eliminar el token de infraestructura {tokenLabel}?", + "confirm_delete_invite": "¿Deseas revocar la invitación seleccionada?", + "confirm_delete_invites": "¿Deseas revocar las invitaciones seleccionadas?", + "confirm_user_deletion": "¿Confirmas la eliminación del usuario?", + "confirm_users_deletion": "¿Deseas eliminar los usuarios seleccionados?", + "confirm_user_to_admin": "¿Deseas convertir a este usuario en administrador?", + "confirm_users_to_admin": "¿Deseas convertir a los usuarios seleccionados en administradores?", + "confirm_user_deactivation": "¿Confirmas la desactivación del usuario?", + "confirm_logout": "Confirmar cierre de sesión", + "created_on": "Fecha de creación", + "continue_email": "Continuar con correo electrónico", + "continue_github": "Continuar con GitHub", + "continue_google": "Continuar con Google", + "continue_microsoft": "Continuar con Microsoft", + "create_team_failure": "No se pudo crear el espacio de trabajo.", + "create_team_success": "Espacio de trabajo creado exitosamente.", + "data_sharing_failure": "No se pudo actualizar la configuración de uso compartido de datos", + "delete_infra_token_failure": "Algo salió mal al eliminar el token de infraestructura", + "delete_invite_failure": "No se pudo eliminar la invitación.", + "delete_invites_failure": "No se pudieron eliminar las invitaciones seleccionadas.", + "delete_invite_success": "Invitación eliminada exitosamente.", + "delete_invites_success": "Invitaciones seleccionadas eliminadas exitosamente.", + "delete_request_failure": "No se pudo eliminar la solicitud compartida.", + "delete_request_success": "Solicitud compartida eliminada exitosamente.", + "delete_team_failure": "No se pudo eliminar el espacio de trabajo.", + "delete_team_success": "Espacio de trabajo eliminado exitosamente.", + "delete_some_users_failure": "Cantidad de usuarios no eliminados: {count}", + "delete_some_users_success": "Cantidad de usuarios eliminados: {count}", + "delete_user_failed_only_one_admin": "No se pudo eliminar al usuario. Debe haber al menos un administrador.", + "delete_user_failure": "No se pudo eliminar al usuario.", + "delete_users_failure": "No se pudieron eliminar los usuarios seleccionados.", + "delete_user_success": "Usuario eliminado exitosamente.", + "delete_users_success": "Usuarios seleccionados eliminados exitosamente.", + "email": "Correo electrónico", + "email_failure": "No se pudo enviar la invitación", + "email_signin_failure": "No se pudo iniciar sesión con correo electrónico", + "email_success": "Invitación por correo electrónico enviada exitosamente", + "emails_cannot_be_same": "No puedes invitarte a ti mismo, por favor elige una dirección de correo electrónico diferente.", + "enter_team_email": "Por favor, ingresa el correo electrónico del propietario del espacio de trabajo.", + "error": "Algo salió mal", + "error_auth_providers": "No se pudieron cargar los proveedores de autenticación", + "generate_infra_token_failure": "Algo salió mal al generar el token de infraestructura", + "github_signin_failure": "No se pudo iniciar sesión con GitHub", + "google_signin_failure": "No se pudo iniciar sesión con Google", + "infra_token_label_short": "La etiqueta del token de infraestructura es demasiado corta.", + "invalid_email": "Por favor, ingresa una dirección de correo electrónico válida", + "link_copied_to_clipboard": "Enlace copiado al portapapeles", + "logged_out": "Sesión cerrada", + "login_as_admin": "e inicia sesión con una cuenta de administrador.", + "login_using_email": "Por favor, pide al usuario que revise su correo electrónico o comparte el enlace a continuación", + "login_using_link": "Por favor, pide al usuario que inicie sesión usando el enlace a continuación", + "logout": "Cerrar sesión", + "magic_link_sign_in": "Haz clic en el enlace para iniciar sesión.", + "magic_link_success": "Enviamos un enlace mágico a", + "microsoft_signin_failure": "No se pudo iniciar sesión con Microsoft", + "newsletter_failure": "No se pudo actualizar la configuración del boletín", + "non_admin_logged_in": "Sesión iniciada como usuario no administrador.", + "non_admin_login": "Has iniciado sesión, pero no eres administrador", + "owner_not_present": "Debe haber al menos un propietario en el equipo.", + "privacy_policy": "Política de privacidad", + "reenter_email": "Vuelve a ingresar el correo electrónico", + "remove_admin_failure": "No se pudo remover el estado de administrador.", + "remove_admin_failure_only_one_admin": "No se pudo remover el estado de administrador. Debe haber al menos un administrador.", + "remove_admin_success": "Estado de administrador removido.", + "remove_admin_from_users_failure": "No se pudo remover el estado de administrador de los usuarios seleccionados.", + "remove_admin_from_users_success": "Estado de administrador removido de los usuarios seleccionados.", + "remove_admin_to_delete_user": "Remueve el privilegio de administrador para eliminar al usuario.", + "remove_owner_to_delete_user": "Remueve el estado de propietario del equipo para eliminar al usuario.", + "remove_owner_failure_only_one_owner": "No se pudo remover al miembro. Debe haber al menos un propietario en el equipo.", + "remove_admin_for_deletion": "Remueve el estado de administrador antes de intentar la eliminación.", + "remove_owner_for_deletion": "Uno o más usuarios son propietarios de equipos. Actualiza la propiedad antes de la eliminación.", + "remove_invitee_failure": "No se pudo remover al invitado.", + "remove_invitee_success": "Invitado removido exitosamente.", + "remove_member_failure": "No se pudo remover al miembro.", + "remove_member_success": "Miembro removido exitosamente.", + "rename_team_failure": "No se pudo renombrar el espacio de trabajo.", + "rename_team_success": "Espacio de trabajo renombrado exitosamente.", + "rename_user_failure": "No se pudo renombrar al usuario.", + "rename_user_success": "Usuario renombrado exitosamente.", + "require_auth_provider": "Necesitas configurar al menos un proveedor de autenticación para iniciar sesión.", + "role_update_failed": "La actualización de roles ha fallado.", + "role_update_success": "Roles actualizados exitosamente.", + "selected": "{count} seleccionados", + "self_host_docs": "Documentación de autoalojamiento", + "send_magic_link": "Enviar enlace mágico", + "setup_failure": "La configuración ha fallado.", + "setup_success": "Configuración completada exitosamente.", + "sign_in_agreement": "Al iniciar sesión, aceptas nuestros", + "sign_in_options": "Todas las opciones de inicio de sesión", + "sign_out": "Cerrar sesión", + "something_went_wrong": "Algo salió mal", + "team_name_too_short": "El nombre del espacio de trabajo debe tener al menos 6 caracteres.", + "user_already_invited": "No se pudo enviar la invitación. El usuario ya fue invitado.", + "user_not_found": "Usuario no encontrado en la infraestructura.", + "users_to_admin_success": "Los usuarios seleccionados fueron elevados a estado de administrador.", + "users_to_admin_failure": "No se pudo elevar a los usuarios seleccionados a estado de administrador.", + "loading_workspaces": "Cargando espacios de trabajo", + "loading_collections_in_workspace": "Cargando colecciones en el espacio de trabajo" + }, + "support": { + "changelog": "Leer más sobre los últimos lanzamientos", + "chat": "¿Preguntas? ¡Habla con nosotros!", + "community": "Haz preguntas y ayuda a los demás", + "documentation": "Leer más sobre Hoppscotch", + "forum": "Haz preguntas y obtén respuestas", + "github": "Síguenos en GitHub", + "shortcuts": "Navega por la aplicación más rápido", + "title": "Ayuda", + "twitter": "Síguenos en Twitter" + }, + "tab": { + "authorization": "Autorización", + "body": "Cuerpo", + "close": "Cerrar pestaña", + "close_others": "Cerrar otras pestañas", + "collections": "Colecciones", + "documentation": "Documentación", + "duplicate": "Duplicar pestaña", + "environments": "Entornos", + "headers": "Encabezados", + "history": "Historial", + "mqtt": "MQTT", + "parameters": "Parámetros", + "post_request_script": "Script post-solicitud", + "pre_request_script": "Script previo a la solicitud", + "queries": "Consultas", + "query": "Consulta", + "schema": "Esquema", + "shared_requests": "Solicitudes compartidas", + "codegen": "Generar código", + "code_snippet": "Fragmento de código", + "mock_servers": "Servidores mock", + "share_tab_request": "Compartir solicitud de pestaña", + "socketio": "Socket.IO", + "sse": "SSE", + "types": "Tipos", + "variables": "Variables", + "websocket": "WebSocket", + "all_tests": "Todas las pruebas", + "passed": "Aprobadas", + "failed": "Fallidas" + }, + "team": { + "activity_logs": "Registros de actividad", + "already_member": "Ya eres miembro de este espacio de trabajo. Ponte en contacto con el propietario del espacio de trabajo.", + "create_new": "Crear nuevo espacio de trabajo", + "deleted": "Espacio de trabajo eliminado", + "delete_all_activity_logs": "Eliminar todos los registros de actividad", + "successfully_deleted_all_activity_logs": "Todos los registros de actividad se eliminaron exitosamente", + "delete_activity_log": "Eliminar registro de actividad", + "deleted_activity_log": "Se eliminó el registro de actividad seleccionado", + "deleted_all_activity_logs": "Se eliminaron todos los registros de actividad", + "edit": "Editar espacio de trabajo", + "email": "Correo electrónico", + "email_do_not_match": "El correo electrónico no coincide con los datos de tu cuenta. Ponte en contacto con el propietario del espacio de trabajo.", + "exit": "Salir del espacio de trabajo", + "exit_disabled": "Sólo el propietario no puede salir del espacio de trabajo", + "failed_invites": "Invitaciones fallidas", + "invalid_coll_id": "Identificador de colección no válido", + "invalid_email_format": "El formato de correo electrónico no es válido", + "invalid_id": "Identificador de espacio de trabajo inválido. Ponte en contacto con el propietario del espacio de trabajo.", + "invalid_invite_link": "Enlace de invitación inválido", + "invalid_invite_link_description": "El enlace que has seguido no es válido. Ponte en contacto con el propietario del espacio de trabajo.", + "invalid_member_permission": "Por favor, proporciona un permiso válido al miembro del espacio de trabajo", + "invite": "Invitar", + "invite_more": "Invitar a más", + "invite_tooltip": "Invitar a personas a este espacio de trabajo", + "invited_to_team": "{owner} te ha invitado al espacio de trabajo {team}", + "join": "Invitación aceptada", + "join_team": "Entrar al espacio de trabajo {team}", + "joined_team": "Has entrado al espacio de trabajo {team}", + "joined_team_description": "Ahora eres miembro de este espacio de trabajo", + "left": "Saliste del espacio de trabajo", + "login_to_continue": "Iniciar sesión para continuar", + "login_to_continue_description": "Tienes que estar conectado para unirte a un espacio de trabajo.", + "logout_and_try_again": "Cerrar la sesión e iniciar sesión con otra cuenta", + "member_has_invite": "Este identificador de correo electrónico ya tiene una invitación. Ponte en contacto con el propietario del espacio de trabajo.", + "member_not_found": "Miembro no encontrado. Ponte en contacto con el propietario del espacio de trabajo.", + "member_removed": "Miembro eliminado", + "member_role_updated": "Funciones de usuario actualizadas", + "members": "Miembros", + "more_members": "+{count} más", + "name_length_insufficient": "El nombre del espacio de trabajo debe tener al menos 6 caracteres", + "name_updated": "Nombre de espacio de trabajo actualizado", + "new": "Nuevo espacio de trabajo", + "new_created": "Nuevo espacio de trabajo creado", + "new_name": "Mi nuevo espacio de trabajo", + "no_access": "No tienes acceso de edición a estas colecciones.", + "no_invite_found": "No se ha encontrado la invitación. Ponte en contacto con el propietario del espacio de trabajo.", + "no_request_found": "Solicitud no encontrada.", + "not_found": "Espacio de trabajo no encontrado. Ponte en contacto con el propietario del espacio de trabajo.", + "not_valid_viewer": "No eres un espectador válido. Ponte en contacto con el propietario del espacio de trabajo.", + "parent_coll_move": "No se puede mover la colección a una colección hija", + "pending_invites": "Invitaciones pendientes", + "permissions": "Permisos", + "same_target_destination": "Mismo objetivo y destino", + "saved": "Espacio de trabajo guardado", + "select_a_team": "Seleccionar un espacio de trabajo", + "success_invites": "Invitaciones realizadas con éxito", + "title": "Espacios de trabajo", + "we_sent_invite_link": "¡Hemos enviado un enlace de invitación a todos los invitados!", + "invite_sent_smtp_disabled": "Enlaces de invitación generados", + "we_sent_invite_link_description": "Pide a todos los invitados que revisen su bandeja de entrada. Tienen que hacer clic en el enlace para unirse al espacio de trabajo.", + "invite_sent_smtp_disabled_description": "El envío de correos electrónicos de invitación está deshabilitado para esta instancia de Hoppscotch. Utiliza el botón Copiar enlace para copiar y compartir el enlace de invitación manualmente.", + "copy_invite_link": "Copiar enlace de invitación", + "search_title": "Solicitudes del espacio de trabajo", + "user_not_found": "Usuario no encontrado en la instancia.", + "invite_members": "Invitar miembros" + }, + "team_environment": { + "deleted": "Entorno eliminado", + "duplicate": "Entorno duplicado", + "not_found": "Entorno no encontrado." + }, + "test": { + "requests": "Solicitudes", + "selection": "Selección", + "failed": "prueba fallida", + "javascript_code": "Código JavaScript", + "learn": "Leer documentación", + "passed": "prueba superada", + "report": "Informe de pruebas", + "results": "Resultados de las pruebas", + "script": "Script", + "snippets": "Fragmentos", + "run": "Ejecutar", + "run_again": "Ejecutar de nuevo", + "stop": "Detener", + "new_run": "Nueva ejecución", + "iterations": "Iteraciones", + "duration": "Duración", + "avg_resp": "Tiempo de respuesta prom." + }, + "websocket": { + "communication": "Comunicación", + "log": "Registro", + "message": "Mensaje", + "protocols": "Protocolos", + "url": "URL" + }, + "workspace": { + "change": "Cambiar el espacio de trabajo", + "personal": "Mi espacio de trabajo", + "other_workspaces": "Mis espacios de trabajo", + "team": "Espacio de trabajo en equipo", + "title": "Espacios de trabajo" + }, + "site_protection": { + "login_to_continue": "Iniciar sesión para continuar", + "login_to_continue_description": "Debes iniciar sesión para acceder a esta instancia de Hoppscotch Enterprise.", + "error_fetching_site_protection_status": "Algo ha fallado al obtener el estado de protección del sitio web" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Tokens de acceso personal", + "section_description": "Los tokens de acceso personal actualmente te ayudan a conectar el CLI a tu cuenta Hoppscotch", + "last_used_on": "Utilizado por última vez en", + "expires_on": "Expira en", + "no_expiration": "Sin expiración", + "expired": "Expirado", + "copy_token_warning": "Asegúrate de copiar ahora tu token de acceso personal. No podrás volver a verlo.", + "token_purpose": "¿Para qué es este token?", + "expiration_label": "Expiración", + "scope_label": "Ámbito", + "workspace_read_only_access": "Acceso de sólo lectura a los datos del espacio de trabajo.", + "personal_workspace_access_limitation": "Los tokens de acceso personal no pueden acceder a tu espacio de trabajo personal.", + "generate_token": "Generar token", + "invalid_label": "Proporciona un nombre válido para el token", + "no_expiration_verbose": "Este token no expirará nunca.", + "token_expires_on": "Este token expirará el", + "generate_new_token": "Generar nuevo token", + "generate_modal_title": "Nuevo token de acceso personal", + "deletion_success": "El token de acceso {label} ha sido eliminado" + }, + "collection_runner": { + "collection_id": "ID de colección", + "environment_id": "ID de entorno", + "cli_collection_id_description": "Este ID de colección será utilizado por el CLI collection runner para Hoppscotch.", + "cli_environment_id_description": "Este ID de entorno será utilizado por el CLI collection runner para Hoppscotch.", + "include_active_environment": "Incluir un entorno activo:", + "cli": "CLI", + "cli_comming_soon_for_personal_collection": "El ejecutor de colecciones para colecciones personales en CLI estará disponible próximamente.", + "delay": "Retraso", + "negative_delay": "El retraso no puede ser negativo", + "ui": "Runner (próximamente)", + "running_collection": "Ejecutando colección", + "run_config": "Configuración de ejecución", + "advanced_settings": "Configuración avanzada", + "stop_on_error": "Detener la ejecución si ocurre un error", + "persist_responses": "Persistir respuestas", + "keep_variable_values": "Mantener valores de variables", + "collection_not_found": "Colección no encontrada. Puede haber sido eliminada o movida.", + "empty_collection": "La colección está vacía. Agrega solicitudes para ejecutar.", + "no_response_persist": "El ejecutor de colecciones está configurado actualmente para no persistir respuestas. Esta configuración impide mostrar los datos de respuesta. Para modificar este comportamiento, inicia una nueva configuración de ejecución.", + "select_request": "Selecciona una solicitud para ver la respuesta y los resultados de las pruebas", + "response_body_lost_rerun": "El cuerpo de la respuesta se perdió. Ejecuta la colección de nuevo para obtener el cuerpo de la respuesta.", + "cli_command_generation_description_cloud": "Copia el siguiente comando y ejecútalo desde la CLI. Por favor, especifica un token de acceso personal.", + "cli_command_generation_description_sh": "Copia el siguiente comando y ejecútalo desde la CLI. Por favor, especifica un token de acceso personal y verifica la URL generada del servidor de instancias SH.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copia el siguiente comando y ejecútalo desde la CLI. Por favor, especifica un token de acceso personal y la URL del servidor de instancias SH.", + "run_collection": "Ejecutar colección", + "no_passed_tests": "Ninguna prueba aprobada", + "no_failed_tests": "Ninguna prueba fallida" + }, + "ai_experiments": { + "generate_request_name": "Generar nombre de solicitud con AI", + "generate_or_modify_request_body": "Generar o modificar cuerpo de solicitud", + "modify_with_ai": "Modificar con AI", + "generate": "Generar", + "generate_or_modify_request_body_input_placeholder": "Ingresa tu prompt para modificar el cuerpo de la solicitud", + "accept_change": "Aceptar cambio", + "feedback_success": "Comentario enviado exitosamente", + "feedback_failure": "No se pudo enviar el comentario", + "feedback_thank_you": "¡Gracias por tu comentario!", + "feedback_cta_text_long": "Califica la generación, nos ayuda a mejorar", + "feedback_cta_request_name": "¿Te gustó el nombre generado?", + "modify_request_body_error": "No se pudo modificar el cuerpo de la solicitud", + "generate_or_modify_prerequest_input_placeholder": "Ingresa un prompt para generar o modificar el script de pre-solicitud", + "generate_or_modify_post_request_script_input_placeholder": "Ingresa un prompt para generar o modificar el script de post-solicitud", + "modify_post_request_script_error": "No se pudo modificar el script de post-solicitud", + "modify_prerequest_error": "No se pudo modificar el script de pre-solicitud" + }, + "configs": { + "auth_providers": { + "callback_url": "URL DE CALLBACK", + "client_id": "ID DE CLIENTE", + "client_secret": "SECRETO DE CLIENTE", + "description": "Configura los proveedores de autenticación para tu servidor", + "provider_not_specified": "Por favor, habilita al menos un proveedor de autenticación", + "scope": "ALCANCE", + "tenant": "TENANT", + "title": "Proveedores de autenticación", + "update_failure": "No se pudieron actualizar las configuraciones del proveedor de autenticación." + }, + "confirm_changes": "El servidor de Hoppscotch debe reiniciarse para reflejar los nuevos cambios. ¿Confirmas los cambios realizados en las configuraciones del servidor?", + "input_empty": "Por favor, completa todos los campos antes de actualizar las configuraciones", + "data_sharing": { + "title": "Uso compartido de datos", + "description": "Ayuda a mejorar Hoppscotch compartiendo datos anónimos", + "enable": "Habilitar uso compartido de datos", + "secondary_title": "Configuraciones de uso compartido de datos", + "see_shared": "Ver qué se comparte", + "toggle_description": "Compartir datos anónimos", + "update_failure": "No se pudieron actualizar las configuraciones de uso compartido de datos." + }, + "load_error": "No se pudieron cargar las configuraciones del servidor", + "mail_configs": { + "address_from": "DIRECCIÓN DE REMITENTE", + "custom_smtp_configs": "Usar configuraciones SMTP personalizadas", + "description": "Configura las configuraciones de SMTP", + "enable_email_auth": "Habilitar autenticación basada en correo electrónico", + "enable_smtp": "Habilitar SMTP", + "host": "HOST DEL SERVIDOR DE CORREO", + "password": "CONTRASEÑA DEL SERVIDOR DE CORREO", + "port": "PUERTO DEL SERVIDOR DE CORREO", + "secure": "SERVIDOR DE CORREO SEGURO", + "smtp_url": "URL SMTP DEL SERVIDOR DE CORREO", + "tls_reject_unauthorized": "TLS RECHAZAR NO AUTORIZADO", + "title": "Configuraciones SMTP", + "toggle_failure": "No se pudo activar/desactivar SMTP.", + "update_failure": "No se pudieron actualizar las configuraciones SMTP.", + "user": "USUARIO DEL SERVIDOR DE CORREO" + }, + "reset": { + "confirm_reset": "El servidor de Hoppscotch debe reiniciarse para reflejar los nuevos cambios. ¿Confirmas el restablecimiento de las configuraciones del servidor?", + "description": "Se cargarán las configuraciones predeterminadas según lo especificado en el archivo de entorno", + "failure": "No se pudieron restablecer las configuraciones.", + "title": "Restablecer configuraciones", + "info": "Restablecer configuraciones del servidor" + }, + "restart": { + "description": "Quedan {duration} segundos antes de que esta página se recargue automáticamente", + "initiate": "Iniciando reinicio del servidor...", + "title": "El servidor se está reiniciando" + }, + "save_changes": "Guardar cambios", + "title": "Configuraciones", + "update_failure": "No se pudieron actualizar las configuraciones del servidor", + "restrict_access": "Restringir acceso", + "site_protection": { + "control_access": "Controla quién puede acceder a la aplicación de Hoppscotch", + "description": "Personaliza cómo los visitantes acceden a tu aplicación de Hoppscotch usando la configuración de protección del sitio.", + "enable": "Habilitar protección del sitio", + "note": "Los usuarios que visiten la aplicación de Hoppscotch verán la página de inicio de sesión; es obligatorio iniciar sesión para acceder a la aplicación. La aprobación del administrador sigue siendo necesaria para la autorización", + "update_failure": "No se pudieron actualizar las configuraciones de protección del sitio." + }, + "domain_whitelisting": { + "add_domain": "Agregar nuevo dominio", + "description": "Los usuarios con correo electrónico registrado en dominios de la lista blanca no requieren aprobación explícita del administrador para acceder a la aplicación de Hoppscotch", + "enable": "Habilitar lista blanca de dominios", + "enter_domain": "Ingresa el dominio", + "title": "Dominios en lista blanca", + "toggle_failure": "No se pudo activar/desactivar la lista blanca de dominios", + "update_failure": "No se pudieron actualizar las configuraciones de lista blanca de dominios." + }, + "oidc_configs": { + "auth_url": "URL de autenticación", + "callback_url": "URL de callback", + "client_id": "ID de cliente", + "client_secret": "Secreto de cliente", + "description": "Configura las configuraciones de OIDC", + "enable": "Habilitar autenticación basada en OIDC", + "issuer": "Emisor", + "provider_name": "Nombre del proveedor", + "scope": "Alcance", + "title": "Configuraciones OIDC", + "token_url": "URL de token", + "update_failure": "No se pudieron actualizar las configuraciones de OIDC.", + "user_info_url": "URL de información del usuario" + }, + "saml": { + "audience": "Audiencia", + "callback_url": "URL de callback", + "certificate": "Certificado", + "description": "Configura las configuraciones de SAML", + "enable": "Habilitar autenticación basada en SAML", + "entry_point": "Punto de entrada", + "issuer": "Emisor", + "title": "Configuraciones SAML", + "update_failure": "No se pudieron actualizar las configuraciones de SAML SSO.", + "want_assertions_signed": "Firmar aserciones", + "want_response_signed": "Firmar respuesta" + } + }, + "data_sharing": { + "description": "Comparte datos de uso anónimos para mejorar Hoppscotch", + "enable": "Habilitar uso compartido de datos", + "see_shared": "Ver qué se comparte", + "toggle_description": "Comparte datos y ayuda a mejorar Hoppscotch", + "title": "Mejora Hoppscotch", + "welcome": "Bienvenido a" + }, + "infra_tokens": { + "copy_token_warning": "Asegúrate de copiar tu token de infraestructura ahora. No podrás verlo de nuevo.", + "deletion_success": "El token de infraestructura {label} ha sido eliminado", + "empty": "No hay tokens de infraestructura", + "expired": "Expirado", + "expiration_label": "Expiración", + "expires_on": "Expira el", + "generate_modal_title": "Nuevo token de infraestructura", + "generate_new_token": "Generar nuevo token", + "generate_token": "Generar token", + "invalid_label": "Por favor, proporciona una etiqueta para el token", + "last_used_on": "Último uso el", + "no_expiration": "Sin expiración", + "no_expiration_verbose": "Este token nunca expirará.", + "section_description": "Administra tus usuarios de Hoppscotch a través de API con tokens de infraestructura", + "section_title": "Tokens de infraestructura", + "tab_title": "Tokens de infraestructura", + "token_expires_on": "Este token expirará el", + "token_purpose": "Ingresa una etiqueta para identificar este token" + }, + "metrics": { + "dashboard": "Panel de control", + "no_metrics": "No se encontraron métricas", + "total_collections": "Total de colecciones", + "total_requests": "Total de solicitudes", + "total_teams": "Total de espacios de trabajo", + "total_users": "Total de usuarios" + }, + "newsletter": { + "description": "Recibe actualizaciones sobre nuestras últimas noticias", + "subscribe": "Suscribirse", + "title": "Mantente en contacto", + "toggle_description": "Recibe actualizaciones sobre las novedades de Hoppscotch", + "unsubscribe": "Cancelar suscripción" + }, + "teams": { + "add_member": "Agregar miembro", + "add_members": "Agregar miembros", + "add_new": "Agregar nuevo", + "admin": "Administrador", + "admin_Email": "Correo del administrador", + "admin_id": "ID del administrador", + "cancel": "Cancelar", + "confirm_team_deletion": "¿Confirmas la eliminación del espacio de trabajo?", + "copy": "Copiar", + "create_team": "Crear espacio de trabajo", + "date": "Fecha", + "delete_team": "Eliminar espacio de trabajo", + "details": "Detalles", + "edit": "Editar", + "editor": "EDITOR", + "editor_description": "Los editores pueden agregar, editar y eliminar solicitudes y colecciones.", + "email": "Correo del propietario del espacio de trabajo", + "email_address": "Dirección de correo electrónico", + "email_title": "Correo electrónico", + "empty_name": "El nombre del equipo no puede estar vacío.", + "error": "Algo salió mal. Por favor, intenta de nuevo más tarde.", + "id": "ID del espacio de trabajo", + "invited_email": "Correo del invitado", + "invited_on": "Fecha de invitación", + "invites": "Invitaciones", + "load_info_error": "No se pudo cargar la información del espacio de trabajo", + "load_list_error": "No se pudo cargar la lista de espacios de trabajo", + "members": "Cantidad de miembros", + "no_invite": "Sin invitaciones", + "no_invite_description": "Invita a tu equipo para comenzar a colaborar", + "owner": "PROPIETARIO", + "owner_description": "Los propietarios pueden agregar, editar y eliminar solicitudes, colecciones y miembros del espacio de trabajo.", + "permissions": "Permisos", + "name": "Nombre del espacio de trabajo", + "no_members": "No hay miembros en este espacio de trabajo. Agrega miembros a este espacio de trabajo para colaborar", + "no_pending_invites": "No hay invitaciones pendientes", + "no_teams": "No se encontraron espacios de trabajo.", + "no_teams_description": "Crea un espacio de trabajo para colaborar con tu equipo", + "pending_invites": "Invitaciones pendientes", + "roles": "Roles", + "roles_description": "Los roles se usan para controlar el acceso a las colecciones compartidas.", + "remove": "Eliminar", + "rename": "Renombrar", + "save": "Guardar", + "save_changes": "Guardar cambios", + "send_invite": "Enviar invitación", + "show_more": "Mostrar más", + "team_details": "Detalles del espacio de trabajo", + "team_members": "Miembros", + "team_members_tab": "Miembros del espacio de trabajo", + "teams": "Espacio de trabajo", + "uid": "UID", + "unnamed": "(Espacio de trabajo sin nombre)", + "viewer": "VISOR", + "viewer_description": "Los visores solo pueden ver y usar solicitudes", + "valid_name": "Por favor, ingresa un nombre de espacio de trabajo válido", + "valid_owner_email": "Por favor, ingresa un correo electrónico de propietario válido" + }, + "users": { + "add_user": "Agregar usuario", + "admin": "Administrador", + "admin_id": "ID del administrador", + "cancel": "Cancelar", + "created_on": "Fecha de creación", + "copy_invite_link": "Copiar enlace de invitación", + "copy_link": "Copiar enlace", + "date": "Fecha", + "delete": "Eliminar", + "delete_user": "Eliminar usuario", + "delete_users": "Eliminar usuarios", + "details": "Detalles", + "edit": "Editar", + "email": "Correo electrónico", + "email_address": "Dirección de correo electrónico", + "empty_name": "El nombre no puede estar vacío.", + "id": "ID de usuario", + "invalid_user": "Usuario no válido", + "invite_load_list_error": "No se pudo cargar la lista de usuarios invitados", + "invite_user": "Invitar usuario", + "invited_by": "Invitado por", + "invited_on": "Fecha de invitación", + "invited_users": "Usuarios invitados", + "invitee_email": "Correo del invitado", + "last_active_on": "Última actividad", + "load_info_error": "No se pudo cargar la información del usuario", + "load_list_error": "No se pudo cargar la lista de usuarios", + "make_admin": "Convertir en administrador", + "name": "Nombre", + "new_user_added": "Nuevo usuario agregado", + "no_invite": "No se encontraron invitaciones pendientes", + "no_invite_description": "No hay invitaciones pendientes. Comienza a invitar a tus compañeros de equipo a Hoppscotch", + "no_shared_requests": "No hay solicitudes compartidas creadas por el usuario", + "no_users": "No se encontraron usuarios", + "not_available": "No disponible", + "not_found": "Usuario no encontrado", + "pending_invites": "Invitaciones pendientes", + "remove_admin_privilege": "Remover privilegio de administrador", + "remove_admin_status": "Remover estado de administrador", + "rename": "Renombrar", + "revoke_invitation": "Revocar invitación", + "searchbar_placeholder": "Buscar por nombre o correo electrónico...", + "send_invite": "Enviar invitación", + "show_more": "Mostrar más", + "uid": "UID", + "unnamed": "(Usuario sin nombre)", + "user_not_found": "Usuario no encontrado en la infraestructura.", + "users": "Usuarios", + "valid_email": "Por favor, ingresa una dirección de correo electrónico válida", + "deactivate": "Desactivar", + "deactivate_user": "Desactivar usuario" + }, + "organization": { + "login_to_continue_description": "Necesitas iniciar sesión para unirte a una instancia de organización.", + "create_an_organization": "Crear una organización", + "deactivate_user_failure": "No se pudo desactivar al usuario.", + "deactivate_user_success": "Usuario desactivado exitosamente.", + "delete_account_description": "Esto eliminará todos los datos asociados con tu cuenta de Hoppscotch, incluyendo esta y cualquier otra organización de la que formes parte.", + "delete_account": "Eliminar cuenta de Hoppscotch", + "user_deletion_failed_sole_admin": "El usuario es el único administrador de una o más instancias de organización. Por favor, degrada al usuario antes de intentar la eliminación.", + "user_deletion_failed_sole_team_owner": "El usuario es el único propietario de equipo en una o más instancias de organización. Por favor, transfiere la propiedad o elimina los espacios de trabajo correspondientes antes de intentar la eliminación.", + "no_organizations": "No eres miembro de ninguna organización", + "admin": "Administrador" + }, + "organization_sidebar": { + "hoppscotch_cloud": "Hoppscotch Cloud", + "cloud_locked": "La instancia predeterminada no se puede eliminar", + "admin": "Administrador", + "error_loading": "No se pudieron cargar las organizaciones", + "inactive_orgs": "Organizaciones inactivas", + "no_orgs_found": "No se encontraron organizaciones", + "no_active_orgs_found": "No hay organizaciones activas", + "organizations_for": "Organizaciones para {email}", + "multi_account_notice": "Cada organización mantiene su propio inicio de sesión, utilizando la última cuenta a la que se accedió.", + "inactive_orgs_tooltip": "Contacta a soporte para obtener asistencia." + }, + "billing": { + "confirm": { + "update_seat_count": "¿Estás seguro de que deseas actualizar la cantidad de puestos a {newSeatCount}?", + "update_billing_cycle": "¿Estás seguro de que deseas actualizar el ciclo de facturación a {newBillingCycle}?" + }, + "cancel_subscription": "Cancelar suscripción" + }, + "app_console": { + "entries": "Entradas de consola", + "no_entries": "Sin entradas" + }, + "mockServer": { + "create_modal": { + "title": "Crear servidor mock", + "name_label": "Nombre del servidor mock", + "name_placeholder": "Ingresa el nombre del servidor mock", + "name_required": "El nombre del servidor mock es obligatorio", + "collection_source_label": "Origen de la colección", + "existing_collection": "Colección existente", + "new_collection": "Nueva colección", + "select_collection_label": "Seleccionar colección", + "select_collection_placeholder": "Elige una colección", + "collection_required": "Por favor, selecciona una colección", + "collection_name_label": "Nombre de la colección", + "collection_name_placeholder": "Ingresa el nombre de la colección", + "collection_name_required": "El nombre de la colección es obligatorio", + "request_config_label": "Configuración de solicitud", + "add_request": "Agregar solicitud", + "create_button": "Crear servidor mock", + "success": "Servidor mock creado exitosamente", + "error": "No se pudo crear el servidor mock", + "no_collections": "No hay colecciones disponibles" + }, + "edit_modal": { + "title": "Editar servidor mock", + "name_label": "Nombre del servidor mock", + "name_placeholder": "Ingresa el nombre del servidor mock", + "name_required": "El nombre del servidor mock es obligatorio", + "active_label": "Activo", + "url_label": "URL del servidor mock", + "collection_label": "Colección", + "update_button": "Actualizar servidor mock", + "success": "Servidor mock actualizado exitosamente", + "error": "No se pudo actualizar el servidor mock", + "url_copied": "URL copiada al portapapeles" + }, + "dashboard": { + "title": "Servidores mock", + "subtitle": "Crea y administra tus servidores mock de API", + "create_button": "Crear servidor mock", + "create_first": "Crea tu primer servidor mock", + "empty_title": "No se encontraron servidores mock", + "empty_description": "Crea servidores mock basados en tus colecciones de API para permitir el desarrollo frontend y móvil sin dependencias del backend.", + "collection": "Colección", + "active": "Activo", + "inactive": "Inactivo", + "mock_url": "URL mock", + "endpoints": "Endpoints", + "created": "Creado", + "view_collection": "Ver colección", + "documentation": "Documentación", + "doc_description": "Usa esta URL como la URL base de tu API en tus aplicaciones:", + "url_copied": "URL del servidor mock copiada al portapapeles", + "delete_title": "Eliminar servidor mock", + "delete_description": "¿Estás seguro de que deseas eliminar este servidor mock?", + "delete_success": "Servidor mock eliminado exitosamente", + "delete_error": "No se pudo eliminar el servidor mock" + } + } +} diff --git a/packages/hoppscotch-common/locales/fi.json b/packages/hoppscotch-common/locales/fi.json new file mode 100644 index 0000000..563c2e8 --- /dev/null +++ b/packages/hoppscotch-common/locales/fi.json @@ -0,0 +1,2378 @@ +{ + "action": { + "add": "Lisää", + "autoscroll": "Automaattinen vieritys", + "cancel": "Peruuta", + "choose_file": "Valitse tiedosto", + "choose_workspace": "Valitse työtila", + "choose_collection": "Valitse kokoelma", + "select_workspace": "Valitse työtila", + "clear": "Tyhjennä", + "clear_all": "Tyhjennä kaikki", + "clear_cache": "Tyhjennä välimuisti", + "clear_history": "Tyhjennä kaikki historia", + "clear_unpinned": "Tyhjennä kiinnittämättömät", + "clear_response": "Tyhjennä vastaus", + "close": "Sulje", + "confirm": "Vahvista", + "connect": "Yhdistä", + "connecting": "Yhdistetään", + "copy": "Kopioi", + "create": "Luo", + "delete": "Poista", + "disconnect": "Katkaise yhteys", + "dismiss": "Hylkää", + "done": "Valmis", + "dont_save": "Älä tallenna", + "download_file": "Lataa tiedosto", + "download_test_report": "Lataa testiraportti", + "drag_to_reorder": "Vedä järjestääksesi uudelleen", + "duplicate": "Monista", + "edit": "Muokkaa", + "filter": "Suodata", + "go_back": "Takaisin", + "go_forward": "Eteenpäin", + "group_by": "Ryhmittele", + "hide_secret": "Piilota salaisuus", + "label": "Tunniste", + "learn_more": "Lue lisää", + "download_here": "Lataa tästä", + "less": "Vähemmän", + "more": "Lisää", + "new": "Uusi", + "no": "Ei", + "open": "Avaa", + "open_workspace": "Avaa työtila", + "paste": "Liitä", + "prettify": "Siisti", + "properties": "Ominaisuudet", + "register": "Rekisteröidy", + "remove": "Poista", + "remove_instance": "Poista instanssi", + "rename": "Nimeä uudelleen", + "restore": "Palauta", + "retry": "Yritä uudelleen", + "save": "Tallenna", + "save_as_example": "Tallenna esimerkkinä", + "add_example": "Lisää esimerkki", + "invalid_request": "Virheelliset pyyntötiedot", + "scroll_to_bottom": "Vieritä alas", + "scroll_to_top": "Vieritä ylös", + "search": "Hae", + "send": "Lähetä", + "share": "Jaa", + "show_secret": "Näytä salaisuus", + "sort": "Järjestä", + "start": "Aloita", + "starting": "Käynnistetään", + "stop": "Pysäytä", + "to_close": "sulkeaksesi", + "to_navigate": "navigoidaksesi", + "to_select": "valitaksesi", + "turn_off": "Poista käytöstä", + "turn_on": "Ota käyttöön", + "undo": "Kumoa", + "unpublish": "Poista julkaisu", + "yes": "Kyllä", + "verify": "Vahvista", + "enable": "Ota käyttöön", + "disable": "Poista käytöstä", + "assign": "Määritä" + }, + "activity_logs": { + "ACTIVITY_LOG_DELETE": "Toimintoloki on poistettu", + "WORKSPACE_CREATE": "Luotiin uusi työtila {name}", + "WORKSPACE_RENAME": "Työtila nimettiin uudelleen: {old_name} → {new_name}", + "WORKSPACE_USER_ADD": "{user} lisättiin työtilaan roolilla {role}", + "WORKSPACE_USER_INVITE": "{user} kutsui käyttäjän {inviteeEmail} roolilla {role}", + "WORKSPACE_USER_INVITE_REVOKE": "Käyttäjän {inviteeEmail} kutsu roolilla {inviteeRole} peruutettiin", + "WORKSPACE_USER_INVITE_ACCEPT": "{inviteeEmail} hyväksyi kutsun roolilla {inviteeRole}", + "WORKSPACE_USER_REMOVE": "{user} poistettiin työtilasta", + "WORKSPACE_USER_ROLE_UPDATE": "Käyttäjän {user} rooli päivitettiin: {old_role} → {new_role}", + "COLLECTION_CREATE": "Luotiin uusi kokoelma {title}", + "COLLECTION_RENAME": "Kokoelma nimettiin uudelleen: {old_title} → {new_title}", + "COLLECTION_IMPORT": "Tuotiin {count} kokoelma(a)", + "COLLECTION_DELETE": "Poistettiin kokoelma {title}", + "COLLECTION_DUPLICATE": "Kopioitiin kokoelma {parentTitle}", + "REQUEST_CREATE": "Luotiin uusi pyyntö {title}", + "REQUEST_RENAME": "Pyyntö nimettiin uudelleen: {old_title} → {new_title}", + "REQUEST_DELETE": "Poistettiin pyyntö {title}" + }, + "add": { + "new": "Lisää uusi", + "star": "Lisää tähti" + }, + "agent": { + "registration_instruction": "Rekisteröi Hoppscotch Agent verkkosovellukseesi jatkaaksesi.", + "enter_otp_instruction": "Syötä Hoppscotch Agentin luoma vahvistuskoodi ja suorita rekisteröinti loppuun", + "otp_label": "Vahvistuskoodi", + "processing": "Käsitellään pyyntöäsi...", + "not_running_title": "Agenttia ei havaittu", + "registration_title": "Agentin rekisteröinti", + "verify_ssl_certs": "Vahvista SSL-sertifikaatit", + "ca_certs": "CA-sertifikaatit", + "client_certs": "Asiakassertifikaatit", + "use_http_proxy": "Käytä HTTP-välityspalvelinta", + "proxy_capabilities": "Hoppscotch Agent tukee HTTP/HTTPS/SOCKS-välityspalvelimia sekä NTLM- ja Basic Auth -todennusta. Sisällytä käyttäjänimi ja salasana välityspalvelimen todennusta varten suoraan URL-osoitteeseen.", + "add_cert_file": "Lisää sertifikaattitiedosto", + "add_client_cert": "Lisää asiakassertifikaatti", + "add_key_file": "Lisää avaintiedosto", + "domain": "Verkkotunnus", + "cert": "Sertifikaatti", + "key": "Avain", + "pfx_or_pkcs": "PFX/PKCS12", + "pfx_or_pkcs_file": "PFX/PKCS12-tiedosto", + "add_pfx_or_pkcs_file": "Lisää PFX/PKCS12-tiedosto" + }, + "app": { + "additional_links": { + "macOS": "macOS", + "windows": "Windows", + "linux": "Linux", + "web_app": "Verkkosovellus", + "cli": "CLI" + }, + "downloads": "Lataukset", + "chat_with_us": "Keskustele kanssamme", + "contact_us": "Ota yhteyttä", + "cookies": "Evästeet", + "copy": "Kopioi", + "copy_interface_type": "Kopioi rajapintatyyppi", + "copy_user_id": "Kopioi käyttäjän todennustoken", + "developer_option": "Kehittäjäasetukset", + "developer_option_description": "Kehittäjätyökalut Hoppscotchin kehitykseen ja ylläpitoon.", + "discord": "Discord", + "documentation": "Dokumentaatio", + "github": "GitHub", + "help": "Ohje ja palaute", + "home": "Etusivu", + "invite": "Kutsu", + "invite_description": "Hoppscotch on avoimen lähdekoodin API-kehitysympäristö. Suunnittelimme yksinkertaisen ja intuitiivisen käyttöliittymän API-rajapintojen luomiseen ja hallintaan. Hoppscotch auttaa sinua rakentamaan, testaamaan, dokumentoimaan ja jakamaan API-rajapintasi.", + "invite_your_friends": "Kutsu ystäväsi", + "join_discord_community": "Liity Discord-yhteisöömme", + "keyboard_shortcuts": "Pikanäppäimet", + "name": "Hoppscotch", + "new_version_found": "Uusi versio löytyi. Päivitä sivusto.", + "open_in_hoppscotch": "Avaa Hoppscotchissa", + "options": "Asetukset", + "powered_by": "Powered by Hoppscotch", + "proxy_privacy_policy": "Välityspalvelimen tietosuojakäytäntö", + "reload": "Lataa uudelleen", + "search": "Haku ja komennot", + "share": "Jaa", + "shortcuts": "Pikanäppäimet", + "social_description": "Seuraa meitä sosiaalisessa mediassa pysyäksesi ajan tasalla uusimmista uutisista, päivityksistä ja julkaisuista.", + "social_links": "Sosiaaliset linkit", + "spotlight": "Spotlight", + "status": "Tila", + "status_description": "Tarkista sivuston tila", + "terms_and_privacy": "Ehdot ja tietosuoja", + "twitter": "Twitter", + "type_a_command_search": "Kirjoita komento tai hae…", + "we_use_cookies": "Käytämme evästeitä", + "updated_text": "Hoppscotch on päivitetty versioon v{version} 🎉", + "whats_new": "Mitä uutta?", + "see_whats_new": "Katso mitä uutta", + "wiki": "Wiki", + "collapse_sidebar": "Pienennä sivupalkki", + "continue_to_dashboard": "Jatka hallintapaneeliin", + "expand_sidebar": "Laajenna sivupalkki", + "no_name": "Nimetön", + "open_navigation": "Avaa navigaatio", + "read_documentation": "Lue dokumentaatio", + "default": "oletus: {value}" + }, + "auth": { + "account_deactivated": "Tilisi on deaktivoitu. Ota yhteyttä ylläpitäjään saadaksesi pääsyn.", + "account_exists": "Tili on olemassa eri tunnistetiedoilla - Kirjaudu sisään yhdistääksesi molemmat tilit", + "all_sign_in_options": "Kaikki kirjautumisvaihtoehdot", + "continue_with_auth_provider": "Jatka palvelulla {provider}", + "continue_with_email": "Jatka sähköpostilla", + "continue_with_github": "Jatka GitHubilla", + "continue_with_github_enterprise": "Jatka GitHub Enterprisella", + "continue_with_google": "Jatka Googlella", + "continue_with_microsoft": "Jatka Microsoftilla", + "email": "Sähköposti", + "logged_out": "Kirjauduttu ulos", + "login": "Kirjaudu sisään", + "login_success": "Kirjautuminen onnistui", + "login_to_hoppscotch": "Kirjaudu Hoppscotchiin", + "logout": "Kirjaudu ulos", + "re_enter_email": "Syötä sähköposti uudelleen", + "send_magic_link": "Lähetä kirjautumislinkki", + "sync": "Synkronoi", + "we_sent_magic_link": "Lähetimme sinulle kirjautumislinkin!", + "we_sent_magic_link_description": "Tarkista saapuneet - lähetimme sähköpostin osoitteeseen {email}. Se sisältää linkin, jolla voit kirjautua sisään." + }, + "authorization": { + "generate_token": "Luo token", + "refresh_token": "Päivitä token", + "graphql_headers": "Valtuutusotsikot lähetetään osana connection_init-viestiä", + "include_in_url": "Sisällytä URL-osoitteeseen", + "inherited_from": "Peritty {auth} yläkokoelmasta {collection} ", + "learn": "Opi miten", + "oauth": { + "redirect_auth_server_returned_error": "Todennuspalvelin palautti virhetilan", + "redirect_auth_token_request_failed": "Todennustokenin pyyntö epäonnistui", + "redirect_auth_token_request_invalid_response": "Virheellinen vastaus token-päätepisteeltä todennustokenia pyydettäessä", + "redirect_invalid_state": "Virheellinen State-arvo uudelleenohjauksessa", + "redirect_no_auth_code": "Valtuutuskoodia ei löydy uudelleenohjauksesta", + "redirect_no_client_id": "Client ID:tä ei ole määritetty", + "redirect_no_client_secret": "Client Secretiä ei ole määritetty", + "redirect_no_code_verifier": "Code Verifieriä ei ole määritetty", + "redirect_no_token_endpoint": "Token-päätepistettä ei ole määritetty", + "something_went_wrong_on_oauth_redirect": "OAuth-uudelleenohjauksessa tapahtui virhe", + "something_went_wrong_on_token_generation": "Tokenin luonnissa tapahtui virhe", + "token_generation_oidc_discovery_failed": "Tokenin luonti epäonnistui: OpenID Connect Discovery epäonnistui", + "grant_type": "Myöntämistyyppi", + "grant_type_auth_code": "Valtuutuskoodi", + "token_fetched_successfully": "Token haettu onnistuneesti", + "token_fetch_failed": "Tokenin haku epäonnistui", + "validation_failed": "Vahvistus epäonnistui, tarkista lomakkeen kentät", + "no_refresh_token_present": "Refresh tokenia ei löydy. Suorita tokenin luontiprosessi uudelleen", + "refresh_token_request_failed": "Refresh token -pyyntö epäonnistui", + "token_refreshed_successfully": "Token päivitetty onnistuneesti", + "label_authorization_endpoint": "Valtuutuspäätepiste", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge -menetelmä", + "label_code_verifier": "Code Verifier", + "label_scopes": "Käyttöoikeusalueet", + "label_token_endpoint": "Token-päätepiste", + "label_use_pkce": "Käytä PKCE:tä", + "label_implicit": "Implicit", + "label_password": "Salasana", + "label_username": "Käyttäjänimi", + "label_auth_code": "Valtuutuskoodi", + "label_client_credentials": "Client Credentials", + "label_send_as": "Asiakastodennustapa", + "label_send_in_body": "Lähetä tunnistetiedot rungossa", + "label_send_as_basic_auth": "Lähetä tunnistetiedot Basic Auth -muodossa", + "enter_value": "Syötä arvo", + "auth_request": "Todennuspyyntö", + "token_request": "Token-pyyntö", + "refresh_request": "Päivityspyyntö", + "send_in": "Lähetä kohteessa" + }, + "pass_key_by": "Välitä", + "pass_by_query_params_label": "Kyselyparametrit", + "pass_by_headers_label": "Otsikot", + "password": "Salasana", + "save_to_inherit": "Tallenna tämä pyyntö johonkin kokoelmaan periäksesi valtuutuksen", + "token": "Token", + "access_token": "Access Token", + "client_token": "Client Token", + "client_secret": "Client Secret", + "timestamp": "Aikaleima", + "host": "Isäntä", + "type": "Valtuutustyyppi", + "username": "Käyttäjänimi", + "advance_config": "Lisäasetukset", + "advance_config_description": "Hoppscotch asettaa oletusarvot automaattisesti tietyille kentille, jos arvoa ei ole erikseen annettu", + "algorithm": "Algoritmi", + "payload": "Sisältö", + "secret": "Salaisuus", + "aws_signature": { + "access_key": "Pääsyavain", + "secret_key": "Salainen avain", + "service_name": "Palvelun nimi", + "aws_region": "AWS-alue", + "service_token": "Palvelutoken" + }, + "digest": { + "realm": "Realm", + "nonce": "Nonce", + "algorithm": "Algoritmi", + "qop": "qop", + "nonce_count": "Nonce-laskuri", + "client_nonce": "Client Nonce", + "opaque": "Opaque", + "disable_retry": "Älä yritä pyyntöä uudelleen" + }, + "akamai": { + "headers_to_sign": "Allekirjoitettavat otsikot", + "max_body_size": "Rungon enimmäiskoko" + }, + "hawk": { + "id": "HAWK Auth ID", + "key": "HAWK Auth Key", + "ext": "ext", + "app": "app", + "dlg": "dlg", + "include": "Sisällytä Payload Hash" + }, + "jwt": { + "params_name": "Parametrin nimi", + "param_name": "Parametrin nimi", + "header_prefix": "Otsikon etuliite", + "placeholder_request_header": "Pyyntöotsikon etuliite", + "placeholder_request_param": "Pyyntöparametrin nimi", + "secret_base64_encoded": "Salaisuus Base64-koodattu", + "headers": "JWT-otsikot", + "private_key": "Yksityinen avain", + "placeholder_headers": "JWT-otsikot" + }, + "ntlm": { + "domain": "Verkkotunnus", + "workstation": "Työasema", + "disable_retrying_request": "Älä yritä pyyntöä uudelleen" + }, + "asap": { + "issuer": "Myöntäjä", + "audience": "Yleisö", + "expires_in": "Vanhenee", + "key_id": "Avaimen tunniste", + "optional_config": "Valinnaiset asetukset", + "subject": "Aihe", + "additional_claims": "Lisävaatimukset" + } + }, + "collection": { + "title": "Kokoelma", + "run": "Suorita kokoelma", + "created": "Kokoelma luotu", + "different_parent": "Kokoelmaa ei voi järjestää uudelleen eri yläkansion kanssa", + "edit": "Muokkaa kokoelmaa", + "import_or_create": "Tuo tai luo kokoelma", + "import_collection": "Tuo kokoelma", + "invalid_name": "Anna kokoelmalle nimi", + "invalid_root_move": "Kokoelma on jo juuritasolla", + "moved": "Siirretty onnistuneesti", + "my_collections": "Omat kokoelmat", + "name": "Uusi kokoelmani", + "name_length_insufficient": "Kokoelman nimen on oltava vähintään 3 merkkiä pitkä", + "new": "Uusi kokoelma", + "order_changed": "Kokoelman järjestys päivitetty", + "properties": "Kokoelman ominaisuudet", + "properties_updated": "Kokoelman ominaisuudet päivitetty", + "renamed": "Kokoelma nimetty uudelleen", + "request_in_use": "Pyyntö on käytössä", + "save_as": "Tallenna nimellä", + "save_to_collection": "Tallenna kokoelmaan", + "select": "Valitse kokoelma", + "select_location": "Valitse sijainti", + "sorted": "Kokoelma järjestetty", + "details": "Tiedot", + "duplicated": "Kokoelma kopioitu" + }, + "confirm": { + "close_unsaved_tab": "Haluatko varmasti sulkea tämän välilehden?", + "close_unsaved_tabs": "Haluatko varmasti sulkea kaikki välilehdet? {count} tallentamatonta välilehteä menetetään.", + "delete_all_activity_logs": "Haluatko varmasti poistaa kaikki toimintolokit?", + "exit_team": "Haluatko varmasti poistua tästä työtilasta?", + "logout": "Haluatko varmasti kirjautua ulos?", + "remove_collection": "Haluatko varmasti poistaa tämän kokoelman pysyvästi?", + "remove_environment": "Haluatko varmasti poistaa tämän ympäristön pysyvästi?", + "remove_folder": "Haluatko varmasti poistaa tämän kansion pysyvästi?", + "remove_history": "Haluatko varmasti poistaa kaiken historian pysyvästi?", + "remove_request": "Haluatko varmasti poistaa tämän pyynnön pysyvästi?", + "remove_response": "Haluatko varmasti poistaa tämän vastauksen pysyvästi?", + "remove_shared_request": "Haluatko varmasti poistaa tämän jaetun pyynnön pysyvästi?", + "remove_team": "Haluatko varmasti poistaa tämän työtilan?", + "remove_telemetry": "Haluatko varmasti poistua telemetriasta?", + "request_change": "Haluatko varmasti hylätä nykyisen pyynnön? Tallentamattomat muutokset menetetään.", + "save_unsaved_tab": "Haluatko tallentaa tähän välilehteen tehdyt muutokset?", + "sync": "Haluatko palauttaa työtilasi pilvestä? Tämä hylkää paikallisen edistymisesi.", + "delete_access_token": "Haluatko varmasti poistaa käyttöoikeustokenin {tokenLabel}?", + "delete_mock_server": "Haluatko varmasti poistaa tämän mock-palvelimen?" + }, + "context_menu": { + "add_parameters": "Lisää parametreihin", + "open_request_in_new_tab": "Avaa pyyntö uudessa välilehdessä", + "set_environment_variable": "Aseta muuttujaksi", + "encode_uri_component": "Koodaa URL-komponentti", + "decode_uri_component": "Pura URL-komponentti" + }, + "cookies": { + "modal": { + "cookie_expires": "Vanhenee", + "cookie_name": "Nimi", + "cookie_path": "Polku", + "cookie_string": "Evästejono", + "cookie_value": "Arvo", + "empty_domain": "Verkkotunnus on tyhjä", + "empty_domains": "Verkkotunnuslista on tyhjä", + "enter_cookie_string": "Syötä evästejono", + "interceptor_no_support": "Valittu sieppaaja ei tue evästeitä. Valitse toinen sieppaaja ja yritä uudelleen.", + "managed_tab": "Hallitut", + "new_domain_name": "Uusi verkkotunnus", + "no_cookies_in_domain": "Tälle verkkotunnukselle ei ole asetettu evästeitä", + "raw_tab": "Raaka", + "set": "Aseta eväste" + } + }, + "count": { + "currentValue": "Nykyinen arvo {count}", + "description": "Kuvaus {count}", + "header": "Otsikko {count}", + "initialValue": "Alkuarvo {count}", + "key": "Avain {count}", + "message": "Viesti {count}", + "parameter": "Parametri {count}", + "protocol": "Protokolla {count}", + "value": "Arvo {count}", + "variable": "Muuttuja {count}" + }, + "documentation": { + "add_description": "Lisää kuvaus tälle kokoelmalle tähän...", + "add_description_placeholder": "Lisää kuvaus tähän...", + "add_request_description": "Lisää kuvaus pyynnölle tähän...", + "auth": { + "access_key": "Pääsyavain", + "access_token": "Access Token", + "add_to": "Lisää kohteeseen", + "akamai_edgegrid": "Akamai EdgeGrid", + "algorithm": "Algoritmi", + "api_key": "API-avain", + "app_id": "Sovellustunnus", + "auth_id": "Todennustunnus", + "auth_key": "Todennusavain", + "auth_url": "Todennusosoite", + "aws_signature": "AWS Signature", + "basic_auth": "Basic Auth", + "bearer_token": "Bearer Token", + "client_id": "Client ID", + "client_nonce": "Client Nonce", + "client_secret": "Client Secret", + "client_token": "Client Token", + "delegation": "Delegointi", + "digest_auth": "Digest Auth", + "extra_data": "Lisätiedot", + "grant_type": "Myöntämistyyppi", + "hawk_auth": "HAWK Auth", + "headers_to_sign": "Allekirjoitettavat otsikot", + "host": "Isäntä", + "include_payload_hash": "Sisällytä Payload Hash", + "jwt_auth": "JWT Auth", + "max_body_size": "Rungon enimmäiskoko", + "no_auth": "Ei todennusta", + "nonce": "Nonce", + "oauth_2": "OAuth 2.0", + "opaque": "Opaque", + "password": "Salasana", + "payload": "Sisältö", + "qop": "QOP", + "realm": "Realm", + "region": "Alue", + "scope": "Käyttöoikeusalue", + "secret_key": "Salainen avain", + "service_name": "Palvelun nimi", + "timestamp": "Aikaleima", + "title": "Todennus", + "token_url": "Token URL", + "user": "Käyttäjä", + "username": "Käyttäjänimi" + }, + "body": { + "content_type": "Sisältötyyppi", + "no_body": "Runkoa ei ole määritetty", + "title": "Runko" + }, + "copied_to_clipboard": "Kopioitu leikepöydälle!", + "curl": { + "click_to_load": "Napsauta ladataksesi cURL-komennon", + "copied": "cURL-komento kopioitu leikepöydälle!", + "copy_to_clipboard": "Kopioi leikepöydälle", + "generating": "Luodaan cURL-komentoa...", + "load": "Lataa cURL", + "title": "cURL" + }, + "description": "Kuvaus", + "error_rendering_markdown": "Virhe markdown-renderöinnissä:", + "fetching_documentation": "Haetaan dokumentaatiota...", + "generate": "Luo dokumentaatio", + "generate_message": "Tuo mikä tahansa Hoppscotch-kokoelma luodaksesi API-dokumentaation lennossa.", + "headers": { + "no_headers": "Otsikoita ei ole määritetty", + "title": "Otsikot" + }, + "hide_all_documentation": "Piilota kaikki dokumentaatio", + "inherited_from": "Peritty kohteesta {name}", + "inherited_with_type": "Peritty {type} kohteesta {name}", + "key": "Avain", + "loading": "Ladataan...", + "loading_collection_data": "Ladataan kokoelman tietoja...", + "no": "Ei", + "no_collection_data": "Kokoelman tietoja ei ole saatavilla", + "no_documentation_found": "Kansioille tai pyynnöille ei löytynyt dokumentaatiota", + "no_request_data": "Pyynnön tietoja ei ole saatavilla", + "no_requests_or_folders": "Ei pyyntöjä tai kansioita", + "not_set": "Ei asetettu", + "open_request_in_new_tab": "Avaa pyyntö uudessa välilehdessä", + "parameters": { + "no_params": "Parametreja ei ole määritetty", + "title": "Parametrit" + }, + "percent_complete": "% valmis", + "processing_documentation": "Käsitellään dokumentaatiota", + "publish": { + "already_published": "Tämä kokoelma on jo julkaistu", + "auto_sync": "Automaattinen synkronointi kokoelman kanssa", + "auto_sync_description": "Päivitä julkaistu dokumentaatio automaattisesti, kun kokoelma muuttuu", + "button": "Julkaise", + "copy_url": "Kopioi URL", + "delete": "Poista dokumentaatio", + "unpublish_doc": "Haluatko varmasti poistaa dokumentaation julkaisun?", + "delete_success": "Julkaistu dokumentaatio poistettu onnistuneesti", + "doc_title": "Otsikko", + "doc_version": "Versio", + "edit_published_doc": "Muokkaa julkaistua dokumentaatiota", + "last_updated": "Viimeksi päivitetty", + "metadata": "Metatiedot (JSON)", + "open_published_doc": "Avaa julkaistu dokumentaatio uudessa välilehdessä", + "publish_error": "Dokumentaation julkaisu epäonnistui", + "publish_success": "Dokumentaatio julkaistu onnistuneesti!", + "published": "Julkaistu", + "published_url": "Julkaistu URL", + "title": "Julkaise dokumentaatio", + "update_button": "Päivitä", + "update_error": "Dokumentaation päivitys epäonnistui", + "update_published_docs": "Päivitä julkaistu dokumentaatio", + "update_success": "Dokumentaatio päivitetty onnistuneesti!", + "unpublish": "Poista julkaisu", + "update_title": "Päivitä julkaistu dokumentaatio", + "url_copied": "URL kopioitu leikepöydälle!", + "view_published": "Näytä julkaistu dokumentaatio", + "view_title": "Julkaistun dokumentaation tilannekuva", + "versions": "Versiot", + "create_new_version": "Luo uusi versio", + "invalid_version": "Versio saa sisältää vain kirjaimia, numeroita, pisteitä ja väliviivoja", + "live": "Live", + "snapshot": "Tilannekuva", + "version_immutable": "Julkaistut versiot ovat vain luku -tilannekuvia", + "view_snapshot": "Näytä tilannekuva", + "current_version": "NYKYINEN", + "not_found": "Julkaistua dokumentaatiota ei löytynyt", + "no_doc_id": "Dokumenttitunnusta ei annettu", + "version_label": "v{version}", + "unpublish_version": "Poista tämän version julkaisu", + "loading_snapshot": "Ladataan tilannekuvaa...", + "retry_snapshot": "Yritä uudelleen", + "sensitive_data_warning": "Varmista, ettei julkaistussa dokumentaatiossa paljasteta arkaluonteisia tietoja.", + "snapshot_preview": "Tilannekuvan esikatselu", + "snapshot_load_error": "Tilannekuvan esikatselun lataus epäonnistui", + "snapshot_empty": "Tässä tilannekuvassa ei ole pyyntöjä tai kansioita", + "snapshot_item_count": "{count} kohdetta", + "auto_sync_live_notice": "Tämä versio synkronoidaan automaattisesti live-kokoelman kanssa", + "snapshot_promote_warning": "Automaattisen synkronoinnin käyttöönotto korvaa tämän jäädytetyn tilannekuvan live-kokoelmapuulla. Tätä ei voi kumota.", + "live_freeze_notice": "Automaattinen synkronointi poistetaan käytöstä ja tämä versio jäädytetään kokoelman nykyiseen tilaan.", + "untitled_project": "Nimetön projekti", + "environment": "Ympäristö", + "no_environment": "Ei ympäristöä", + "environment_description": "Liitä ympäristö muuttujien ratkaisemiseksi julkaistussa dokumentaatiossa" + }, + "request_opened_in_new_tab": "Pyyntö avattu uudessa välilehdessä!", + "response": { + "body": "Vastauksen runko", + "copy": "Kopioi vastaus", + "example_copied": "Vastausesimerkki kopioitu leikepöydälle!", + "example_copy_failed": "Vastausesimerkin kopiointi epäonnistui", + "headers": "Vastauksen otsikot", + "no_examples": "Ei vastausesimerkkejä saatavilla", + "title": "Vastausesimerkit" + }, + "save_error": "Virhe dokumentaation tallennuksessa", + "save_success": "Dokumentaatio tallennettu onnistuneesti", + "saved_items_status": "Tallennettu {success} kohdetta. {failure} kohteen tallennus epäonnistui.", + "show_all_documentation": "Näytä kaikki dokumentaatio", + "source": "Lähde", + "title": "Dokumentaatio", + "unsaved_changes": "Sinulla on {count} tallentamatonta muutosta. Tallenna ennen sulkemista.", + "untitled_collection": "Nimetön kokoelma", + "untitled_request": "Nimetön pyyntö", + "value": "Arvo", + "variables": { + "no_vars": "Muuttujia ei ole määritetty", + "title": "Muuttujat" + }, + "yes": "Kyllä" + }, + "empty": { + "activity_logs": "Toimintolokeja ei löytynyt", + "authorization": "Tämä pyyntö ei käytä valtuutusta", + "body": "Tällä pyynnöllä ei ole runkoa", + "collection": "Kokoelma on tyhjä", + "collections": "Kokoelmat ovat tyhjiä", + "documentation": "Yhdistä GraphQL-päätepisteeseen nähdäksesi dokumentaation", + "empty_schema": "Skeemaa ei löytynyt", + "endpoint": "Päätepiste ei voi olla tyhjä", + "environments": "Ympäristöt ovat tyhjiä", + "collection_variables": "Kokoelman muuttujat ovat tyhjiä", + "folder": "Kansio on tyhjä", + "headers": "Tällä pyynnöllä ei ole otsikoita", + "history": "Historia on tyhjä", + "invites": "Kutsulista on tyhjä", + "members": "Työtila on tyhjä", + "parameters": "Tällä pyynnöllä ei ole parametreja", + "pending_invites": "Tässä työtilassa ei ole odottavia kutsuja", + "profile": "Kirjaudu sisään nähdäksesi profiilisi", + "protocols": "Protokollat ovat tyhjiä", + "request_variables": "Tällä pyynnöllä ei ole pyyntömuuttujia", + "schema": "Yhdistä GraphQL-päätepisteeseen nähdäksesi skeeman", + "search_environment": "Vastaavaa ympäristöä ei löytynyt haulle", + "secret_environments": "Salaisuuksia ei synkronoida Hoppscotchiin", + "shared_requests": "Jaettuja pyyntöjä ei ole", + "shared_requests_logout": "Kirjaudu sisään nähdäksesi jaetut pyyntösi tai luodaksesi uuden", + "subscription": "Tilaukset ovat tyhjiä", + "team_name": "Työtilan nimi on tyhjä", + "teams": "Et kuulu mihinkään työtilaan", + "tests": "Tällä pyynnöllä ei ole testejä", + "access_tokens": "Käyttöoikeustokenit ovat tyhjiä", + "response": "Vastausta ei saatu", + "mock_servers": "Mock-palvelimia ei löytynyt" + }, + "environment": { + "heading": "Ympäristö", + "add_to_global": "Lisää globaaliin", + "added": "Ympäristömuuttuja lisätty", + "create_new": "Luo uusi ympäristö", + "created": "Ympäristö luotu", + "current_value": "Nykyinen arvo", + "deleted": "Ympäristömuuttuja poistettu", + "duplicated": "Ympäristö kopioitu", + "edit": "Muokkaa ympäristöä", + "empty_variables": "Ei muuttujia", + "global": "Globaali", + "global_variables": "Globaalit muuttujat", + "import_or_create": "Tuo tai luo ympäristö", + "initial_value": "Alkuarvo", + "invalid_name": "Anna ympäristölle nimi", + "list": "Ympäristömuuttujat", + "my_environments": "Omat ympäristöt", + "name": "Nimi", + "nested_overflow": "Sisäkkäiset ympäristömuuttujat on rajoitettu 10 tasoon", + "new": "Uusi ympäristö", + "no_active_environment": "Ei aktiivista ympäristöä", + "no_environment": "Ei ympäristöä", + "no_environment_description": "Ympäristöjä ei ole valittu. Valitse mitä tehdä seuraavien muuttujien kanssa.", + "quick_peek": "Ympäristön pikatarkastelu", + "replace_all_current_with_initial": "Korvaa kaikki nykyiset alkuarvoilla", + "replace_all_initial_with_current": "Korvaa kaikki alkuarvot nykyisillä", + "replace_current_with_initial": "Korvaa alkuarvolla", + "replace_initial_with_current": "Korvaa nykyisellä", + "replace_with_variable": "Korvaa muuttujalla", + "scope": "Käyttöalue", + "secrets": "Salaisuudet", + "secret_value": "Salainen arvo", + "select": "Valitse ympäristö", + "set": "Aseta ympäristö", + "set_as_environment": "Aseta ympäristöksi", + "short_name": "Ympäristön nimessä on oltava vähintään 1 merkki", + "team_environments": "Työtilan ympäristöt", + "title": "Ympäristöt", + "updated": "Ympäristö päivitetty", + "value": "Arvo", + "variable": "Muuttuja", + "variables": "Muuttujat", + "variable_list": "Muuttujalista", + "properties": "Ympäristön ominaisuudet", + "details": "Tiedot" + }, + "error": { + "network": { + "heading": "Verkkovirhe", + "description": "Verkkoyhteys epäonnistui. {message}: {cause}" + }, + "timeout": { + "heading": "Aikakatkaisuvirhe", + "description": "Pyyntö aikakatkaistiin vaiheessa {phase}. {message}" + }, + "certificate": { + "heading": "Sertifikaattivirhe", + "description": "Virheellinen sertifikaatti. {message}: {cause}" + }, + "auth": { + "heading": "Todennusvirhe", + "description": "Pääsy evätty. {message}: {cause}" + }, + "proxy": { + "heading": "Välityspalvelinvirhe", + "description": "Välityspalvelinyhteys epäonnistui. {message}: {cause}" + }, + "parse": { + "heading": "Jäsennysvirhe", + "description": "Vastauksen jäsentäminen epäonnistui. {message}: {cause}" + }, + "version": { + "heading": "Versiovirhe", + "description": "Yhteensopimattomat versiot. {message}: {cause}" + }, + "abort": { + "heading": "Pyyntö keskeytetty", + "description": "Toiminto peruutettu. {message}: {cause}" + }, + "unknown": { + "heading": "Tuntematon virhe", + "description": "Tapahtui tuntematon virhe.", + "cause": "Tuntematon syy" + }, + "extension": { + "heading": "Laajennusvirhe", + "description": "Pyynnön suorittaminen laajennuksella epäonnistui" + }, + "authproviders_load_error": "Todennuspalveluiden lataaminen epäonnistui", + "browser_support_sse": "Tämä selain ei näytä tukevan Server Sent Events -ominaisuutta.", + "check_console_details": "Tarkista konsolin loki lisätiedoista.", + "check_how_to_add_origin": "Katso miten voit lisätä lähteen", + "curl_invalid_format": "cURL-komento ei ole oikeassa muodossa", + "danger_zone": "Vaaravyöhyke", + "delete_account": "Tilisi on tällä hetkellä ainoa omistaja näissä työtiloissa:", + "delete_account_description": "Sinun on joko poistettava itsesi, siirrettävä omistajuus tai poistettava nämä työtilat ennen kuin voit poistaa tilisi.", + "delete_activity_log": "Toimintolokin poistaminen epäonnistui", + "delete_all_activity_logs": "Kaikkien toimintolokien poistaminen epäonnistui", + "email_already_exists": "Tämä sähköposti on jo käytössä toisella tilillä. Aseta toinen sähköpostiosoite.", + "empty_email_address": "Sähköpostiosoite ei voi olla tyhjä", + "empty_profile_name": "Profiilin nimi ei voi olla tyhjä", + "empty_req_name": "Tyhjä pyynnön nimi", + "fetch_activity_logs": "Toimintolokien hakeminen epäonnistui", + "f12_details": "(F12 lisätiedot)", + "gql_prettify_invalid_query": "Virheellistä kyselyä ei voitu siistiä, korjaa kyselysyntaksivirheet ja yritä uudelleen", + "incomplete_config_urls": "Puutteelliset asetusliittymien URL-osoitteet", + "incorrect_email": "Virheellinen sähköposti", + "invalid_file_type": "Virheellinen tiedostotyyppi tiedostolle `{filename}`.", + "invalid_link": "Virheellinen linkki", + "invalid_link_description": "Linkki, jota napsautit, on virheellinen tai vanhentunut.", + "invalid_embed_link": "Upotusta ei ole olemassa tai se on virheellinen.", + "json_parsing_failed": "Virheellinen JSON", + "json_prettify_invalid_body": "Virheellistä runkoa ei voitu siistiä, korjaa JSON-syntaksivirheet ja yritä uudelleen", + "network_error": "Verkkoyhteydessä näyttää olevan ongelma. Yritä uudelleen.", + "network_fail": "Pyyntöä ei voitu lähettää", + "no_collections_to_export": "Ei kokoelmia vietäväksi. Luo kokoelma aloittaaksesi.", + "no_duration": "Ei kestoa", + "no_environments_to_export": "Ei ympäristöjä vietäväksi. Luo ympäristö aloittaaksesi.", + "no_results_found": "Osumia ei löytynyt", + "page_not_found": "Tätä sivua ei löytynyt", + "please_install_extension": "Asenna laajennus ja lisää lähde laajennukseen.", + "proxy_error": "Välityspalvelinvirhe", + "same_email_address": "Päivitetty sähköpostiosoite on sama kuin nykyinen", + "same_profile_name": "Päivitetty profiilin nimi on sama kuin nykyinen", + "script_fail": "Esipyyntöskriptiä ei voitu suorittaa", + "something_went_wrong": "Jokin meni pieleen", + "subscription_error": "Aiheen tilaaminen epäonnistui: {error}", + "post_request_script_fail": "Jälkipyyntöskriptiä ei voitu suorittaa", + "reading_files": "Virhe luettaessa yhtä tai useampaa tiedostoa.", + "fetching_access_tokens_list": "Jokin meni pieleen haettaessa tokenilistausta", + "generate_access_token": "Jokin meni pieleen luotaessa käyttöoikeustokenia", + "delete_access_token": "Jokin meni pieleen poistettaessa käyttöoikeustokenia", + "extension_not_found": "Laajennusta ei löytynyt", + "invalid_request": "Virheelliset pyyntötiedot" + }, + "export": { + "as_json": "Vie JSON-muodossa", + "as_openapi": "Vie OpenAPI-muodossa", + "as_yaml": "Vie YAML-muodossa", + "choose_format": "Valitse vientimuoto", + "collection": "Vie kokoelma", + "collections": "Vie kokoelmat", + "format_hoppscotch": "Vie Hoppscotch JSON -muodossa", + "format_openapi_json": "Vie OpenAPI 3.1 (JSON) -muodossa", + "format_openapi_yaml": "Vie OpenAPI 3.1 (YAML) -muodossa", + "openapi_lossy_summary": "OpenAPI-vienti jättää pois skriptit, todennustiedot, inaktiiviset kohteet, tupla-polut ja tuplavastauskoodit.", + "create_secret_gist": "Luo salainen Gist", + "create_secret_gist_tooltip_text": "Vie salaisena Gistinä", + "failed": "Jokin meni pieleen viennissä", + "secret_gist_success": "Viety onnistuneesti salaisena Gistinä", + "require_github": "Kirjaudu GitHubilla luodaksesi salaisen gistin", + "title": "Vienti", + "success": "Viety onnistuneesti" + }, + "file_upload": { + "choose_file": "Valitse tiedosto", + "max_size_format": "Enintään 5 Mt. Tukee JPEG, PNG, GIF, WebP", + "profile_photo_updated": "Profiilikuva päivitetty onnistuneesti", + "profile_photo_removed": "Profiilikuva poistettu onnistuneesti", + "org_logo_updated": "Organisaation logo päivitetty onnistuneesti", + "error_size_limit": "Tiedostokoon on oltava alle 5 Mt", + "error_invalid_format": "Tiedoston on oltava kuva (JPEG, PNG, GIF tai WebP)", + "error_invalid_upload_type": "Virheellinen lataustyyppi", + "error_invalid_org_id": "Virheellinen organisaatiotunnuksen muoto", + "error_upload_failed": "Lataus epäonnistui. Yritä uudelleen", + "error_network_failed": "Verkkovirhe. Tarkista yhteytesi", + "error_timeout": "Lataus aikakatkaistiin 30 sekunnin jälkeen. Yritä uudelleen", + "error_missing_backend_url": "Taustapalvelun URL-osoitetta ei ole määritetty. Aseta VITE_BACKEND_API_URL ympäristömuuttuja ympäristöasetuksissasi", + "error_invalid_backend_url": "Virheellinen taustapalvelun URL-osoitteen määritys" + }, + "filename": { + "cookie_key_value_pairs": "Eväste", + "codegen": "{request_name} - koodi", + "graphql_response": "GraphQL-vastaus", + "lens": "{request_name} - vastaus", + "realtime_response": "Reaaliaikavastaus", + "response_interface": "Vastausrajapinta" + }, + "filter": { + "all": "Kaikki", + "none": "Ei mitään", + "starred": "Tähdellä merkityt" + }, + "folder": { + "created": "Kansio luotu", + "edit": "Muokkaa kansiota", + "invalid_name": "Anna kansiolle nimi", + "name_length_insufficient": "Kansion nimen on oltava vähintään 3 merkkiä pitkä", + "new": "Uusi kansio", + "run": "Suorita kansio", + "renamed": "Kansio nimetty uudelleen", + "sorted": "Kansio järjestetty" + }, + "graphql": { + "arguments": "Argumentit", + "connection_switch_confirm": "Haluatko yhdistää uusimpaan GraphQL-päätepisteeseen?", + "connection_error_http": "GraphQL-skeeman haku epäonnistui verkkovirheen vuoksi.", + "connection_switch_new_url": "Välilehden vaihto katkaisee aktiivisen GraphQL-yhteyden. Uusi yhteysosoite on", + "connection_switch_url": "Olet yhteydessä GraphQL-päätepisteeseen, yhteysosoite on", + "deprecated": "Vanhentunut", + "fields": "Kentät", + "mutation": "Mutaatio", + "mutations": "Mutaatiot", + "schema": "Skeema", + "show_depricated_values": "Näytä vanhentuneet arvot", + "subscription": "Tilaus", + "subscriptions": "Tilaukset", + "switch_connection": "Vaihda yhteyttä", + "url_placeholder": "Syötä GraphQL-päätepisteen URL", + "query": "Kysely" + }, + "graphql_collections": { + "title": "GraphQL-kokoelmat" + }, + "group": { + "time": "Aika", + "url": "URL" + }, + "header": { + "install_pwa": "Asenna sovellus", + "login": "Kirjaudu sisään", + "save_workspace": "Tallenna työtilani" + }, + "helpers": { + "authorization": "Valtuutusotsikko luodaan automaattisesti, kun lähetät pyynnön.", + "collection_properties_authorization": " Tämä valtuutus asetetaan jokaiselle pyynnölle tässä kokoelmassa.", + "collection_properties_header": "Tämä otsikko asetetaan jokaiselle pyynnölle tässä kokoelmassa.", + "collection_properties_scripts": "Nämä skriptit suoritetaan jokaiselle pyynnölle tässä kokoelmassa. Esipyyntöskriptit suoritetaan ennen pyyntöä, testiskriptit vastauksen jälkeen.", + "generate_documentation_first": "Luo dokumentaatio ensin", + "network_fail": "API-päätepisteeseen ei saada yhteyttä. Tarkista verkkoyhteytesi tai valitse toinen sieppaaja ja yritä uudelleen.", + "offline": "Käytät Hoppscotchia offline-tilassa. Päivitykset synkronoidaan, kun olet online, työtilan asetusten mukaan.", + "offline_short": "Käytät Hoppscotchia offline-tilassa.", + "post_request_tests": "Jälkipyyntöskriptit kirjoitetaan JavaScriptillä ja suoritetaan vastauksen saamisen jälkeen.", + "pre_request_script": "Esipyyntöskriptit kirjoitetaan JavaScriptillä ja suoritetaan ennen pyynnön lähettämistä.", + "script_fail": "Esipyyntöskriptissä näyttää olevan virhe. Tarkista alla oleva virhe ja korjaa skripti.", + "post_request_script_fail": "Jälkipyyntöskriptissä näyttää olevan virhe. Korjaa virheet ja suorita testit uudelleen", + "post_request_script": "Kirjoita jälkipyyntöskripti automatisoidaksesi virheen etsinnän." + }, + "hide": { + "collection": "Pienennä kokoelmapaneeli", + "more": "Piilota lisää", + "preview": "Piilota esikatselu", + "sidebar": "Pienennä sivupalkki", + "password": "Piilota salasana" + }, + "import": { + "collections": "Tuo kokoelmat", + "curl": "Tuo cURL", + "environments_from_gist": "Tuo Gististä", + "environments_from_gist_description": "Tuo Hoppscotch-ympäristöt Gististä", + "failed": "Virhe tuonnissa: muotoa ei tunnistettu", + "from_file": "Tuo tiedostosta", + "from_gist": "Tuo Gististä", + "from_gist_description": "Tuo Gist-URL-osoitteesta", + "from_gist_import_summary": "Kaikki Hoppscotch-ominaisuudet tuodaan.", + "from_hoppscotch_importer_summary": "Kaikki Hoppscotch-ominaisuudet tuodaan.", + "from_insomnia": "Tuo Insomniasta", + "from_insomnia_description": "Tuo Insomnia-kokoelmasta", + "from_insomnia_import_summary": "Kokoelmat ja pyynnöt tuodaan.", + "from_json": "Tuo Hoppscotchista", + "from_json_description": "Tuo Hoppscotch-kokoelmatiedostosta", + "from_my_collections": "Tuo omista kokoelmista", + "from_my_collections_description": "Tuo omien kokoelmien tiedostosta", + "from_all_collections": "Tuo toisesta työtilasta", + "from_all_collections_description": "Tuo mikä tahansa kokoelma toisesta työtilasta nykyiseen työtilaan", + "from_openapi": "Tuo OpenAPI:sta", + "from_openapi_description": "Tuo OpenAPI-määrittelytiedostosta (YML/JSON)", + "from_openapi_import_summary": "Kokoelmat (luodaan tunnisteista), pyynnöt ja vastausesimerkit tuodaan.", + "from_postman": "Tuo Postmanista", + "from_postman_description": "Tuo Postman-kokoelmasta", + "from_postman_import_summary": "Kokoelmat, pyynnöt ja vastausesimerkit tuodaan.", + "import_scripts": "Tuo skriptit", + "import_scripts_description": "Tukee Postman Collection v2.0/v2.1.", + "from_url": "Tuo URL-osoitteesta", + "gist_url": "Syötä Gist-URL", + "from_har": "Tuo HAR-tiedostosta", + "from_har_description": "Tuo HAR-tiedostosta", + "from_har_import_summary": "Pyynnöt tuodaan oletuskokoelmaan.", + "gql_collections_from_gist_description": "Tuo GraphQL-kokoelmat Gististä", + "hoppscotch_environment": "Hoppscotch-ympäristö", + "hoppscotch_environment_description": "Tuo Hoppscotch-ympäristön JSON-tiedosto", + "import_from_url_invalid_fetch": "Tietoja ei voitu hakea URL-osoitteesta", + "import_from_url_invalid_file_format": "Virhe kokoelmien tuonnissa", + "import_from_url_invalid_type": "Tyyppiä ei tueta. Hyväksytyt arvot ovat 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Kokoelmat tuotu", + "insomnia_environment_description": "Tuo Insomnia-ympäristö JSON/YAML-tiedostosta", + "json_description": "Tuo kokoelmat Hoppscotch Collections JSON -tiedostosta", + "postman_environment": "Postman-ympäristö", + "postman_environment_description": "Tuo Postman-ympäristö JSON-tiedostosta", + "title": "Tuonti", + "file_size_limit_exceeded_warning_multiple_files": "Valitut tiedostot ylittävät suositellun rajan {sizeLimit} Mt. Vain ensimmäiset {files} valittua tuodaan", + "file_size_limit_exceeded_warning_single_file": "Valittu tiedosto ylittää suositellun rajan {sizeLimit} Mt. Valitse toinen tiedosto.", + "success": "Tuotu onnistuneesti", + "import_summary_collections_title": "Kokoelmat", + "import_summary_requests_title": "Pyynnöt", + "import_summary_responses_title": "Vastaukset", + "import_summary_pre_request_scripts_title": "Esipyyntöskriptit", + "import_summary_post_request_scripts_title": "Jälkipyyntöskriptit", + "import_summary_not_supported_by_hoppscotch_import": "Emme tue tällä hetkellä {featureLabel} tuontia tästä lähteestä.", + "import_summary_script_found": "skripti löydetty mutta ei tuotu", + "import_summary_scripts_found": "skriptiä löydetty mutta ei tuotu", + "import_summary_enable_experimental_sandbox": "Tuodaksesi Postman-skriptejä, ota käyttöön 'Kokeellinen skriptihiekkalaatikko' asetuksista. Huom: Tämä ominaisuus on kokeellinen.", + "cors_error_modal": { + "title": "CORS-virhe havaittu", + "description": "Tuonti epäonnistui palvelimen CORS-rajoitusten (Cross-Origin Resource Sharing) vuoksi.", + "explanation": "Tämä on turvaominaisuus, joka estää verkkosivuja tekemästä pyyntöjä eri verkkotunnuksiin. Voit yrittää uudelleen käyttämällä välityspalveluamme tämän rajoituksen ohittamiseksi.", + "url_label": "Yritetty URL", + "retry_with_proxy": "Yritä uudelleen välityspalvelimella" + } + }, + "instances": { + "switch": "Vaihda Hoppscotch-instanssia", + "enter_server_url": "Yhdistä itse isännöityyn instanssiin", + "already_connected": "Olet jo yhteydessä tähän instanssiin", + "recent_connections": "Viimeaikaiset yhteydet", + "add_instance": "Lisää instanssi", + "add_new": "Lisää uusi instanssi", + "confirm_remove": "Vahvista poisto", + "remove_warning": "Haluatko varmasti poistaa tämän instanssin?", + "clear_cached_bundles": "Tyhjennä välimuistissa olevat paketit", + "opening_add_modal": "Avataan lisää instanssi -ikkuna", + "closed_add_modal": "Lisää instanssi -ikkuna suljettu", + "cancelled_removal": "Instanssin poisto peruutettu", + "connection_cancelled": "Yhteys peruutettu esivalidoinnissa", + "post_connect_completed": "Yhteyden jälkeinen asennus valmis", + "connecting": "Yhdistetään instanssiin...", + "confirm_removal": "Vahvista instanssin poisto", + "removal_cancelled": "Instanssin poisto peruutettu esivalidoinnissa", + "post_remove_completed": "Poiston jälkeinen siivous valmis", + "removing": "Poistetaan instanssia...", + "clearing_cache": "Tyhjennetään välimuistia...", + "initialized": "Instanssinvaihto alustettu", + "connecting_state": "Muodostetaan yhteyttä...", + "connected_state": "Yhdistetty instanssiin onnistuneesti", + "disconnected_state": "Yhteys instanssiin katkaistu", + "stream_error": "Yhteystilan seuranta epäonnistui", + "recent_instances_error": "Viimeaikaisten instanssien lataus epäonnistui", + "instance_changed": "Vaihdettu instanssiin", + "current_instance_error": "Nykyisen instanssin seuranta epäonnistui", + "not_available": "Instanssin vaihto ei ole käytettävissä", + "cleanup_completed": "Instanssinvaihdon siivous valmis", + "self_hosted": "Itse isännöidyt instanssit" + }, + "inspections": { + "description": "Tarkista mahdolliset virheet", + "environment": { + "add_environment": "Lisää ympäristöön", + "add_environment_value": "Lisää arvo", + "empty_value": "Ympäristömuuttujan '{variable}' arvo on tyhjä ", + "not_found": "Ympäristömuuttujaa '{environment}' ei löytynyt." + }, + "header": { + "cookie": "Selain ei salli Hoppscotchin asettaa Cookie-otsikoita. Käytä sen sijaan valtuutusotsikoita. Hoppscotch-työpöytäsovellus kuitenkin tukee evästeitä." + }, + "response": { + "401_error": "Tarkista todennustietosi.", + "404_error": "Tarkista pyynnön URL-osoite ja metodityyppi.", + "cors_error": "Tarkista Cross-Origin Resource Sharing -asetukset.", + "default_error": "Tarkista pyynnösi.", + "network_error": "Tarkista verkkoyhteytesi." + }, + "title": "Tarkastaja", + "url": { + "extension_not_installed": "Laajennusta ei ole asennettu.", + "extension_unknown_origin": "Varmista, että olet lisännyt API-päätepisteen lähteen Hoppscotch-selainlaajennuksen listaan.", + "extention_enable_action": "Ota selainlaajennus käyttöön", + "extention_not_enabled": "Laajennus ei ole käytössä.", + "localaccess_unsupported": "Nykyinen sieppaaja ei tue paikallista pääsyä, harkitse Agent- tai laajennussieppaajien tai työpöytäsovelluksen käyttöä" + }, + "auth": { + "digest": "Agent-sieppaajaa verkkosovelluksessa tai natiivia sieppaajaa työpöytäsovelluksessa suositellaan Digest-valtuutuksen kanssa.", + "hawk": "Agent-sieppaajaa verkkosovelluksessa tai natiivia sieppaajaa työpöytäsovelluksessa suositellaan Hawk-valtuutuksen kanssa." + }, + "body": { + "binary": "Binääridatan lähettämistä nykyisellä sieppaajalla ei tueta vielä." + }, + "scripting_interceptor": { + "pre_request": "esipyyntöskripti", + "post_request": "jälkipyyntöskripti", + "both_scripts": "esipyyntö- ja jälkipyyntöskriptit", + "unsupported_interceptor": "{scriptType} käyttää rajapintaa {apiUsed}. Luotettavan suorituksen varmistamiseksi vaihda Agent-sieppaajaan (verkkosovellus) tai natiiviin sieppaajaan (työpöytäsovellus). {interceptor}-sieppaajalla on rajoitettu tuki skriptipyynnöille eikä se välttämättä toimi odotetulla tavalla.", + "same_origin_csrf_warning": "Turvallisuusvaroitus: {scriptType} tekee saman alkuperän pyyntöjä käyttäen rajapintaa {apiUsed}. Koska tämä alusta käyttää evästepohjaista todennusta, nämä pyynnöt sisältävät automaattisesti istuntoevästeet, mikä voi mahdollistaa haitallisten skriptien luvattomien toimintojen suorittamisen. Käytä Agent-sieppaajaa saman alkuperän pyynnöille tai suorita vain luotettavia skriptejä." + } + }, + "interceptor": { + "native": { + "name": "Natiivi", + "settings_title": "Natiivi" + }, + "agent": { + "name": "Agent", + "settings_title": "Agent" + }, + "proxy": { + "name": "Välityspalvelin", + "settings_title": "Välityspalvelin" + }, + "browser": { + "name": "Selain", + "settings_title": "Selain" + }, + "extension": { + "name": "Laajennus", + "settings_title": "Laajennus" + } + }, + "layout": { + "collapse_collection": "Pienennä tai laajenna kokoelmat", + "collapse_sidebar": "Pienennä tai laajenna sivupalkki", + "column": "Pystyasettelu", + "name": "Asettelu", + "row": "Vaaka-asettelu" + }, + "modal": { + "close_unsaved_tab": "Sinulla on tallentamattomia muutoksia", + "collections": "Kokoelmat", + "confirm": "Vahvista", + "customize_request": "Mukauta pyyntöä", + "edit_request": "Muokkaa pyyntöä", + "edit_response": "Muokkaa vastausta", + "import_export": "Tuonti / Vienti", + "response_name": "Vastauksen nimi", + "share_request": "Jaa pyyntö" + }, + "mqtt": { + "already_subscribed": "Olet jo tilannut tämän aiheen.", + "clean_session": "Puhdas istunto", + "clear_input": "Tyhjennä syöte", + "clear_input_on_send": "Tyhjennä syöte lähetettäessä", + "client_id": "Asiakastunnus", + "color": "Valitse väri", + "communication": "Viestintä", + "connection_config": "Yhteysasetukset", + "connection_not_authorized": "Tämä MQTT-yhteys ei käytä todennusta.", + "invalid_topic": "Anna aihe tilaukselle", + "keep_alive": "Pidä yhteys", + "log": "Loki", + "lw_message": "Testamenttiviesti", + "lw_qos": "Testamentin QoS", + "lw_retain": "Testamentin säilytys", + "lw_topic": "Testamenttiaihe", + "message": "Viesti", + "new": "Uusi tilaus", + "not_connected": "Aloita ensin MQTT-yhteys.", + "publish": "Julkaise", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Tilaa", + "topic": "Aihe", + "topic_name": "Aiheen nimi", + "topic_title": "Julkaise / Tilaa aihe", + "unsubscribe": "Peruuta tilaus", + "url": "URL" + }, + "navigation": { + "admin_dashboard": "Hallintapaneeli", + "doc": "Dokumentit", + "graphql": "GraphQL", + "profile": "Profiili", + "realtime": "Reaaliaikaiset", + "rest": "REST", + "mock_servers": "Mock-palvelimet", + "settings": "Asetukset", + "goto_app": "Siirry sovellukseen", + "authentication": "Todennus" + }, + "mock_server": { + "confirm_delete_log": "Haluatko varmasti poistaa tämän lokin?", + "create_mock_server": "Määritä mock-palvelin", + "mock_server_configuration": "Mock-palvelimen asetukset", + "mock_server_name": "Mock-palvelimen nimi", + "mock_server_name_placeholder": "Syötä mock-palvelimen nimi", + "base_url": "Perus-URL", + "start_server": "Käynnistä palvelin", + "stop_server": "Pysäytä palvelin", + "mock_server_created": "Mock-palvelin luotu onnistuneesti", + "mock_server_started": "Mock-palvelin käynnistetty onnistuneesti", + "mock_server_stopped": "Mock-palvelin pysäytetty onnistuneesti", + "active": "Mock-palvelin on aktiivinen", + "inactive": "Mock-palvelin on epäaktiivinen", + "edit_mock_server": "Muokkaa mock-palvelinta", + "path_based_url": "Polkupohjainen URL", + "subdomain_based_url": "Aliverkkotunnuspohjainen URL", + "mock_server_updated": "Mock-palvelin päivitetty onnistuneesti", + "no_collection": "Ei kokoelmaa", + "collection_deleted": "liitetty kokoelma poistettu.", + "private_access_hint": "Yksityisiä mock-palvelimia varten sisällytä otsikko 'x-api-key' henkilökohtaisella käyttöoikeustokenillasi (luo sellainen profiilissasi).", + "private_access_instruction": "Tämän yksityisen mock-palvelimen käyttämiseksi sisällytä otsikko 'x-api-key' henkilökohtaisella käyttöoikeustokenillasi.", + "create_token_here": "Luo tästä", + "status": "Tila", + "server_running": "Palvelin on käynnissä", + "server_stopped": "Palvelin on pysäytetty", + "delay_ms": "Vastauksen viive (ms)", + "delay_placeholder": "Syötä viive millisekunteina", + "delay_description": "Lisää keinotekoinen viive mock-vastauksiin", + "public_access": "Julkinen pääsy", + "public": "Julkinen", + "private": "Yksityinen", + "public_description": "Kuka tahansa URL-osoitteen tietävä voi käyttää tätä mock-palvelinta", + "private_description": "Vain todennetut käyttäjät voivat käyttää tätä mock-palvelinta", + "select_collection": "Valitse kokoelma", + "select_collection_error": "Valitse kokoelma", + "invalid_collection_error": "Mock-palvelimen luonti kokoelmalle epäonnistui.", + "url_copied": "URL kopioitu leikepöydälle", + "make_public": "Tee julkiseksi", + "view_logs": "Näytä lokit", + "logs_title": "Mock-palvelimen lokit", + "no_logs": "Ei lokeja saatavilla", + "request_headers": "Pyynnön otsikot", + "request_body": "Pyynnön runko", + "response_status": "Vastauksen tila", + "response_headers": "Vastauksen otsikot", + "response_body": "Vastauksen runko", + "log_deleted": "Loki poistettu onnistuneesti", + "description": "Mock-palvelimet simuloivat API-vastauksia kokoelmasi vastausesimerkkien perusteella.", + "set_in_environment": "Aseta ympäristöön", + "set_in_environment_hint": "Mock-palvelimen URL lisätään automaattisesti 'mockUrl'-muuttujana kokoelman ympäristöön", + "environment_variable_added": "Mock URL lisätty ympäristöön", + "environment_variable_updated": "Mock URL päivitetty ympäristössä", + "environment_created_with_variable": "Ympäristö luotu mock URL:lla", + "add_example_request": "Lisää esimerkkipyyntö", + "add_example_request_hint": "Kokoelma luodaan esimerkkipyynnöllä, joka näyttää miten mock-palvelinta käytetään", + "create_example_collection": "Luo esimerkkikokoelma", + "create_example_collection_hint": "Luo lemmikkikauppaesimerkkikokoelma esimerkkipyynnöillä (GET, POST, PUT, DELETE)", + "creating_example_collection": "Luodaan esimerkkikokoelmaa...", + "failed_to_create_collection": "Esimerkkikokoelman luonti epäonnistui", + "enable_example_collection_hint": "Ota käyttöön 'Luo esimerkkikokoelma' -valinta uuden kokoelman tilassa", + "new_collection_name_hint": "Kokoelma luodaan samalla nimellä kuin mock-palvelimesi", + "existing_collection": "Olemassa oleva kokoelma", + "new_collection": "Uusi kokoelma" + }, + "preRequest": { + "javascript_code": "JavaScript-koodi", + "learn": "Lue dokumentaatio", + "script": "Esipyyntöskripti", + "snippets": "Koodinpätkät" + }, + "profile": { + "app_settings": "Sovelluksen asetukset", + "default_hopp_displayname": "Nimetön käyttäjä", + "editor": "Muokkaaja", + "editor_description": "Muokkaajat voivat lisätä, muokata ja poistaa pyyntöjä.", + "email_verification_mail": "Vahvistussähköposti on lähetetty sähköpostiosoitteeseesi. Napsauta linkkiä vahvistaaksesi sähköpostiosoitteesi.", + "no_permission": "Sinulla ei ole oikeutta suorittaa tätä toimintoa.", + "owner": "Omistaja", + "owner_description": "Omistajat voivat lisätä, muokata ja poistaa pyyntöjä, kokoelmia ja työtilan jäseniä.", + "roles": "Roolit", + "roles_description": "Rooleja käytetään jaettujen kokoelmien pääsynhallintaan.", + "updated": "Profiili päivitetty", + "viewer": "Katsoja", + "viewer_description": "Katsojat voivat vain tarkastella ja käyttää pyyntöjä.", + "verified_email_sent": "Vahvistussähköposti on lähetetty sähköpostiosoitteeseesi. Päivitä sivu vahvistettuasi sähköpostiosoitteesi. Saat sähköpostin, jos tämä osoite ei ole yhdistetty toiseen tiliin." + }, + "remove": { + "star": "Poista tähti" + }, + "request": { + "added": "Pyyntö lisätty", + "add": "Lisää pyyntö", + "authorization": "Valtuutus", + "body": "Pyynnön runko", + "choose_language": "Valitse kieli", + "content_type": "Sisältötyyppi", + "content_type_titles": { + "others": "Muut", + "structured": "Rakenteinen", + "text": "Teksti", + "binary": "Binääri" + }, + "show_content_type": "Näytä sisältötyyppi", + "different_collection": "Pyyntöjä ei voi järjestää uudelleen eri kokoelmista", + "duplicated": "Pyyntö kopioitu", + "duration": "Kesto", + "enter_curl": "Syötä cURL-komento", + "generate_code": "Luo koodi", + "generated_code": "Luotu koodi", + "go_to_authorization_tab": "Siirry Valtuutus-välilehteen", + "go_to_body_tab": "Siirry Runko-välilehteen", + "header_list": "Otsikkolista", + "invalid_name": "Anna pyynnölle nimi", + "method": "Metodi", + "moved": "Pyyntö siirretty", + "name": "Pyynnön nimi", + "new": "Uusi pyyntö", + "order_changed": "Pyynnön järjestys päivitetty", + "override": "Ohita", + "override_help": "Aseta Content-Type otsikoissa", + "overriden": "Ohitettu", + "parameter_list": "Kyselyparametrit", + "parameters": "Parametrit", + "path": "Polku", + "payload": "Sisältö", + "query": "Kysely", + "raw_body": "Raaka pyynnön runko", + "rename": "Nimeä pyyntö uudelleen", + "renamed": "Pyyntö nimetty uudelleen", + "request_variables": "Pyyntömuuttujat", + "response_name_exists": "Vastauksen nimi on jo olemassa", + "run": "Suorita", + "save": "Tallenna", + "save_as": "Tallenna nimellä", + "saved": "Pyyntö tallennettu", + "share": "Jaa", + "share_description": "Jaa Hoppscotch ystävillesi", + "share_request": "Jaa pyyntö", + "stop": "Pysäytä", + "title": "Pyyntö", + "type": "Pyynnön tyyppi", + "url": "URL", + "url_placeholder": "Syötä URL tai liitä cURL-komento", + "variables": "Muuttujat", + "view_my_links": "Näytä linkkini", + "generate_name_error": "Pyynnön nimen luonti epäonnistui." + }, + "response": { + "audio": "Ääni", + "body": "Vastauksen runko", + "duplicated": "Vastaus kopioitu", + "duplicate_name_error": "Samanniminen vastaus on jo olemassa", + "filter_response_body": "Suodata JSON-vastauksen runko (käyttää jq-syntaksia)", + "headers": "Otsikot", + "request_headers": "Pyynnön otsikot", + "html": "HTML", + "image": "Kuva", + "json": "JSON", + "pdf": "PDF", + "please_save_request": "Tallenna pyyntö luodaksesi esimerkin", + "preview_html": "Esikatsele HTML", + "raw": "Raaka", + "renamed": "Vastaus nimetty uudelleen", + "same_name_inspector_warning": "Samanniminen vastaus on jo olemassa tälle pyynnölle. Tallennettaessa se korvaa olemassa olevan vastauksen", + "size": "Koko", + "status": "Tila", + "time": "Aika", + "title": "Vastaus", + "video": "Video", + "waiting_for_connection": "odotetaan yhteyttä", + "xml": "XML", + "generate_data_schema": "Luo tietoskeema", + "data_schema": "Tietoskeema", + "saved": "Vastaus tallennettu", + "invalid_name": "Anna vastaukselle nimi" + }, + "script": { + "inheriting": "Perityt skriptit kohteesta", + "inheriting_from_count": "Peritään {count} kokoelmasta | Peritään {count} kokoelmista", + "inherited_scripts": "Perityt skriptit", + "view_inherited": "Näytä perityt skriptit" + }, + "settings": { + "accent_color": "Korostusväri", + "account": "Tili", + "account_deleted": "Tilisi on poistettu", + "account_description": "Mukauta tilisi asetuksia.", + "account_email_description": "Ensisijainen sähköpostiosoitteesi.", + "account_name_description": "Tämä on näyttönimesi.", + "additional": "Lisäasetukset", + "agent_not_running": "Hoppscotch Agentia ei havaittu. Tarkista, että Agent on käynnissä.", + "agent_not_running_short": "Tarkista Agentin tila.", + "agent_running": "Hoppscotch Agent on aktiivinen.", + "agent_running_short": "Hoppscotch Agent on aktiivinen.", + "agent_discard_registration": "Hylkää Agent-rekisteröinti", + "agent_registered": "Agent rekisteröity", + "agent_registration_successful": "Agent rekisteröity onnistuneesti", + "agent_registration_fetch_failed": "Agent-rekisteröintitietojen haku epäonnistui. Rekisteröi Agent uudelleen.", + "agent_registration_already_in_progress": "Agent-rekisteröinti on jo käynnissä. Suorita tai peruuta nykyinen rekisteröinti ja yritä uudelleen.", + "auto_encode_mode": "Automaattinen", + "auto_encode_mode_tooltip": "Koodaa parametrit pyynnössä vain jos erikoismerkkejä on läsnä", + "background": "Tausta", + "black_mode": "Musta", + "choose_language": "Valitse kieli", + "dark_mode": "Tumma", + "delete_account": "Poista tili", + "delete_account_description": "Kun poistat tilisi, kaikki tietosi poistetaan pysyvästi. Tätä toimintoa ei voi peruuttaa.", + "desktop": "Työpöytä", + "desktop_description": "Päivitä käyttäytymis-, näppäimistö- ja näyttöasetukset Hoppscotch-työpöytäsovellukselle.", + "desktop_display": "Näyttö", + "desktop_keyboard": "Näppäimistö", + "desktop_keyboard_strategy_label": "Vastaa pikanäppäimiä kirjoitetun kirjaimen tai fyysisen sijainnin perusteella", + "desktop_keyboard_strategy_description": "Ei-QWERTY-asetteluissa sama kirjain voi tulla eri fyysisistä näppäimistä. Oletusasetus toimii useimmissa asetteluissa; vaihda vaihtoehtoa jos pikanäppäimet eivät toimi odotetusti.", + "desktop_keyboard_strategy_hybrid": "Älykäs (suositeltu)", + "desktop_keyboard_strategy_hybrid_description": "Käytä kirjoitettua kirjainta latinalaisille merkeille; käytä fyysistä näppäinsijaintia ei-latinalaisille asetteluille (kyrilliset, CJK).", + "desktop_keyboard_strategy_key": "Kirjoitettu kirjain", + "desktop_keyboard_strategy_key_description": "Käytä aina kirjoitettua kirjainta. Valitse tämä jos pikanäppäimet eivät toimi odotetusti asettelussasi.", + "desktop_keyboard_strategy_code": "Fyysinen näppäinsijainti", + "desktop_keyboard_strategy_code_description": "Käytä aina US-QWERTY fyysistä sijaintia. Valitse tämä jos sinulla on QWERTY-lihasmuisti ei-latinalaisessa asettelussa.", + "desktop_updates": "Päivitykset", + "disable_encode_mode_tooltip": "Älä koskaan koodaa parametreja pyynnössä", + "disable_update_checks": "Poista automaattiset päivitystarkistukset käytöstä", + "disable_update_checks_description": "Ohita päivitystarkistus sovelluksen käynnistyksessä. Käytä yllä olevaa painiketta tarkistaaksesi manuaalisesti.", + "zoom_level": "Zoomaustaso", + "zoom_level_description": "Skaalaa koko käyttöliittymän. Suuremmat arvot tekevät tekstistä ja säätimistä suurempia korkearesooluutioisilla näytöillä.", + "zoom_level_100": "100%", + "zoom_level_110": "110%", + "zoom_level_125": "125%", + "zoom_level_150": "150%", + "enable_encode_mode_tooltip": "Koodaa aina parametrit pyynnössä", + "enter_otp": "Syötä Agentin koodi", + "expand_navigation": "Laajenna navigointi", + "experiments": "Kokeilut", + "experiments_notice": "Tämä on kokoelma kokeiluja, joiden parissa työskentelemme ja jotka saattavat osoittautua hyödyllisiksi, hauskoiksi, molemmiksi tai ei kummaksikaan. Ne eivät ole lopullisia eivätkä välttämättä vakaita, joten jos jotain outoa tapahtuu, älä hätäänny. Sammuta se vain. Vakavasti puhuen, ", + "extension_ver_not_reported": "Ei ilmoitettu", + "extension_version": "Laajennuksen versio", + "extensions": "Selainlaajennus", + "extensions_use_toggle": "Käytä selainlaajennusta pyyntöjen lähettämiseen (jos asennettu)", + "follow": "Seuraa meitä", + "general": "Yleiset", + "general_description": "Sovelluksen yleiset asetukset", + "interceptor": "Sieppaaja", + "interceptor_description": "Väliohjelmisto sovelluksen ja API:en välillä.", + "kernel_interceptor": "Sieppaaja", + "kernel_interceptor_description": "Väliohjelmisto sovelluksen ja API:en välillä.", + "language": "Kieli", + "light_mode": "Vaalea", + "official_proxy_hosting": "Virallista välityspalvelinta ylläpitää Hoppscotch.", + "query_parameters_encoding": "Kyselyparametrien koodaus", + "query_parameters_encoding_description": "Määritä kyselyparametrien koodaus pyyntöihin", + "profile": "Profiili", + "profile_description": "Päivitä profiilitietosi", + "profile_email": "Sähköpostiosoite", + "profile_name": "Profiilin nimi", + "profile_photo": "Profiilikuva", + "proxy": "Välityspalvelin", + "proxy_url": "Välityspalvelimen URL", + "proxy_url_invalid": "Välityspalvelimen URL:n on alettava http(s):// eikä se saa sisältää välilyöntejä", + "proxy_use_toggle": "Käytä välityspalvelinta pyyntöjen lähettämiseen", + "read_the": "Lue", + "register_agent": "Rekisteröi Agent", + "reset_default": "Käytä oletusvälityspalvelinta", + "short_codes": "Lyhytkoodit", + "short_codes_description": "Luomasi lyhytkoodit.", + "sidebar_on_left": "Sivupalkki vasemmalla", + "ai_experiments": "Tekoälykokeilut", + "ai_request_naming_style": "Pyynnön nimeämistyyli", + "ai_request_naming_style_descriptive_with_spaces": "Kuvaileva välilyönneillä", + "ai_request_naming_style_camel_case": "Camel Case ( camelCase )", + "ai_request_naming_style_snake_case": "Snake Case ( snake_case )", + "ai_request_naming_style_pascal_case": "Pascal Case ( PascalCase )", + "ai_request_naming_style_custom": "Mukautettu", + "ai_request_naming_style_custom_placeholder": "Syötä mukautettu nimeämistyylin malli...", + "experimental_scripting_sandbox": "Kokeellinen skriptihiekkalaatikko", + "enable_experimental_mock_servers": "Ota Mock-palvelimet käyttöön", + "enable_experimental_documentation": "Ota Dokumentaatio käyttöön", + "sync": "Synkronoi", + "sync_collections": "Kokoelmat", + "sync_description": "Nämä asetukset synkronoidaan pilveen.", + "sync_environments": "Ympäristöt", + "sync_history": "Historia", + "history_disabled": "Historia on poistettu käytöstä. Ota yhteyttä organisaatiosi ylläpitäjään historian ottamiseksi käyttöön", + "system_mode": "Järjestelmä", + "telemetry": "Telemetria", + "telemetry_helps_us": "Telemetria auttaa meitä mukauttamaan toimintojamme ja tarjoamaan sinulle parhaan käyttökokemuksen.", + "theme": "Teema", + "theme_description": "Mukauta sovelluksen teemaa.", + "update_check_description": "Tarkista manuaalisesti uusi versio ja asenna se. Toimii alla olevan kytkimen asetuksesta riippumatta.", + "update_check_now": "Tarkista päivitykset", + "update_checking": "Tarkistetaan…", + "update_download_version": "Lataa v{version}", + "update_downloading": "Ladataan…", + "update_downloading_percent": "Ladataan {percent}%", + "update_installing": "Asennetaan…", + "update_restart_now": "Käynnistä uudelleen päivityksen asentamiseksi", + "update_up_to_date": "Ajan tasalla", + "use_experimental_url_bar": "Käytä kokeellista URL-palkkia ympäristökorostuksella", + "user": "Käyttäjä", + "verified_email": "Vahvistettu sähköposti", + "verify_email": "Vahvista sähköposti", + "validate_certificates": "Validoi SSL/TLS-sertifikaatit", + "verify_host": "Vahvista isäntä", + "verify_peer": "Vahvista vastapuoli", + "follow_redirects": "Seuraa uudelleenohjauksia", + "client_certificates": "Asiakassertifikaatit", + "certificate_settings": "Sertifikaattiasetukset", + "certificate": "Sertifikaatti", + "key": "Yksityinen avain", + "pfx_or_p12": "PFX/PKCS#12", + "password": "Salasana", + "select_file": "Valitse tiedosto", + "domain": "Verkkotunnus", + "add_certificate": "Lisää sertifikaatti", + "add_cert_file": "Lisää sertifikaattitiedosto", + "add_key_file": "Lisää avaintiedosto", + "add_pfx_file": "Lisää PFX-tiedosto", + "global_defaults": "Yleiset oletukset", + "add_domain_override": "Lisää verkkotunnusohitus", + "domain_override": "Verkkotunnusohitus", + "manage_domains_overrides": "Hallitse verkkotunnusohituksia", + "add_domain": "Lisää verkkotunnus", + "remove_domain": "Poista verkkotunnus", + "ca_certificate": "CA-sertifikaatti", + "ca_certificates": "CA-sertifikaatit", + "ca_certificates_support": "Hoppscotch tukee .crt-, .cer- tai .pem-tiedostoja, jotka sisältävät yhden tai useamman sertifikaatin.", + "proxy_capabilities": "Hoppscotch Agent ja työpöytäsovellus tukevat HTTP/HTTPS/SOCKS-välityspalvelimia NTLM- ja Basic Auth -tuella.", + "proxy_auth": "Voit myös sisällyttää käyttäjätunnuksen ja salasanan URL-osoitteeseen." + }, + "shared_requests": { + "button": "Painike", + "button_info": "Luo 'Suorita Hoppscotchissa' -painike verkkosivullesi, blogillesi tai README-tiedostolle.", + "copy_html": "Kopioi HTML", + "copy_link": "Kopioi linkki", + "copy_markdown": "Kopioi Markdown", + "creating_widget": "Luodaan widgettiä", + "customize": "Mukauta", + "deleted": "Jaettu pyyntö poistettu", + "description": "Valitse widget, voit muuttaa ja mukauttaa tätä myöhemmin", + "embed": "Upota", + "embed_info": "Lisää mini 'Hoppscotch API Playground' verkkosivullesi, blogillesi tai dokumentaatioosi.", + "link": "Linkki", + "link_info": "Luo jaettava linkki, jonka voit jakaa kenelle tahansa internetissä katseluoikeudella.", + "modified": "Jaettu pyyntö muokattu", + "not_found": "Jaettua pyyntöä ei löytynyt", + "open_new_tab": "Avaa uudessa välilehdessä", + "preview": "Esikatselu", + "run_in_hoppscotch": "Suorita Hoppscotchissa", + "theme": { + "dark": "Tumma", + "light": "Vaalea", + "system": "Järjestelmä", + "title": "Teema" + }, + "action": "Toiminto", + "clear_filter": "Tyhjennä suodatin", + "confirm_request_deletion": "Vahvista valitun jaetun pyynnön poisto?", + "copy": "Kopioi", + "created_on": "Luotu", + "delete": "Poista", + "email": "Sähköposti", + "filter": "Suodata", + "filter_by_email": "Suodata sähköpostilla", + "id": "ID", + "load_list_error": "Jaettujen pyyntöjen listan lataus epäonnistui", + "no_requests": "Jaettuja pyyntöjä ei löytynyt", + "open_request": "Avaa pyyntö", + "properties": "Ominaisuudet", + "request": "Pyyntö", + "show_more": "Näytä lisää", + "title": "Jaetut pyynnöt", + "url": "URL" + }, + "shortcut": { + "general": { + "close_current_menu": "Sulje nykyinen valikko", + "command_menu": "Haku- ja komentovalikko", + "help_menu": "Ohjevalikko", + "show_all": "Pikanäppäimet", + "title": "Yleiset", + "comment_uncomment": "Kommentoi/Poista kommentti", + "close_tab": "Sulje välilehti", + "undo": "Kumoa", + "redo": "Tee uudelleen" + }, + "miscellaneous": { + "invite": "Kutsu ihmisiä Hoppscotchiin", + "title": "Sekalaiset" + }, + "navigation": { + "back": "Siirry edelliselle sivulle", + "documentation": "Siirry Dokumentaatio-sivulle", + "forward": "Siirry seuraavalle sivulle", + "graphql": "Siirry GraphQL-sivulle", + "profile": "Siirry Profiili-sivulle", + "realtime": "Siirry Reaaliaikaiset-sivulle", + "rest": "Siirry REST-sivulle", + "settings": "Siirry Asetukset-sivulle", + "title": "Navigointi" + }, + "others": { + "prettify": "Muotoile editorin sisältö", + "title": "Muut" + }, + "request": { + "delete_method": "Valitse DELETE-metodi", + "get_method": "Valitse GET-metodi", + "head_method": "Valitse HEAD-metodi", + "import_curl": "Tuo cURL", + "method": "Metodi", + "next_method": "Valitse seuraava metodi", + "post_method": "Valitse POST-metodi", + "previous_method": "Valitse edellinen metodi", + "put_method": "Valitse PUT-metodi", + "rename": "Nimeä pyyntö uudelleen", + "reset_request": "Nollaa pyyntö", + "save_request": "Tallenna pyyntö", + "save_to_collections": "Tallenna kokoelmiin", + "send_request": "Lähetä pyyntö", + "share_request": "Jaa pyyntö", + "show_code": "Luo koodinpätkä", + "title": "Pyyntö", + "focus_url": "Kohdista URL-palkkiin" + }, + "response": { + "copy": "Kopioi vastaus leikepöydälle", + "download": "Lataa vastaus tiedostona", + "title": "Vastaus" + }, + "tabs": { + "title": "Välilehdet", + "new_tab": "Uusi välilehti", + "close_tab": "Sulje välilehti", + "reopen_tab": "Avaa suljettu välilehti uudelleen", + "next_tab": "Seuraava välilehti", + "previous_tab": "Edellinen välilehti", + "first_tab": "Vaihda ensimmäiseen välilehteen", + "last_tab": "Vaihda viimeiseen välilehteen", + "mru_switch": "Vaihda viimeksi käytettyyn välilehteen (MRU)", + "mru_switch_reverse": "Vaihda edelliseen viimeksi käytettyyn välilehteen (MRU)" + }, + "theme": { + "black": "Vaihda teemaksi musta", + "dark": "Vaihda teemaksi tumma", + "light": "Vaihda teemaksi vaalea", + "system": "Vaihda teemaksi järjestelmä", + "title": "Teema" + } + }, + "show": { + "code": "Näytä koodi", + "collection": "Laajenna kokoelmapaneeli", + "more": "Näytä lisää", + "sidebar": "Laajenna sivupalkki", + "password": "Näytä salasana" + }, + "socketio": { + "communication": "Viestintä", + "connection_not_authorized": "Tämä SocketIO-yhteys ei käytä todennusta.", + "event_name": "Tapahtuman/Aiheen nimi", + "events": "Tapahtumat", + "log": "Loki", + "url": "URL" + }, + "spotlight": { + "change_language": "Vaihda kieli", + "environments": { + "delete": "Poista nykyinen ympäristö", + "duplicate": "Kopioi nykyinen ympäristö", + "duplicate_global": "Kopioi yleinen ympäristö", + "edit": "Muokkaa nykyistä ympäristöä", + "edit_global": "Muokkaa yleistä ympäristöä", + "new": "Luo uusi ympäristö", + "new_variable": "Luo uusi ympäristömuuttuja", + "title": "Ympäristöt" + }, + "general": { + "chat": "Keskustele tuen kanssa", + "help_menu": "Ohje ja tuki", + "open_docs": "Lue dokumentaatio", + "open_github": "Avaa GitHub-repositorio", + "open_keybindings": "Pikanäppäimet", + "social": "Sosiaalinen media", + "title": "Yleiset" + }, + "graphql": { + "connect": "Yhdistä palvelimeen", + "disconnect": "Katkaise yhteys palvelimeen" + }, + "miscellaneous": { + "invite": "Kutsu ystäväsi Hoppscotchiin", + "title": "Sekalaiset" + }, + "request": { + "save_as_new": "Tallenna uutena pyyntönä", + "select_method": "Valitse metodi", + "switch_to": "Vaihda kohteeseen", + "tab_authorization": "Valtuutus-välilehti", + "tab_body": "Runko-välilehti", + "tab_headers": "Otsikot-välilehti", + "tab_parameters": "Parametrit-välilehti", + "tab_pre_request_script": "Esipyyntöskripti-välilehti", + "tab_query": "Kysely-välilehti", + "tab_tests": "Testit-välilehti", + "tab_variables": "Muuttujat-välilehti" + }, + "response": { + "copy": "Kopioi vastaus", + "download": "Lataa vastaus tiedostona", + "title": "Vastaus" + }, + "section": { + "interceptor": "Sieppaaja", + "interface": "Käyttöliittymä", + "theme": "Teema", + "user": "Käyttäjä" + }, + "settings": { + "change_interceptor": "Vaihda sieppaaja", + "change_language": "Vaihda kieli", + "theme": { + "black": "Musta", + "dark": "Tumma", + "light": "Vaalea", + "system": "Järjestelmän asetus" + } + }, + "tab": { + "close_current": "Sulje nykyinen välilehti", + "close_others": "Sulje kaikki muut välilehdet", + "duplicate": "Kopioi nykyinen välilehti", + "new_tab": "Avaa uusi välilehti", + "next": "Vaihda seuraavaan välilehteen", + "previous": "Vaihda edelliseen välilehteen", + "switch_to_first": "Vaihda ensimmäiseen välilehteen", + "switch_to_last": "Vaihda viimeiseen välilehteen", + "mru_switch": "Vaihda viimeksi käytettyyn välilehteen", + "mru_switch_reverse": "Vaihda edelliseen viimeksi käytettyyn välilehteen", + "title": "Välilehdet" + }, + "workspace": { + "delete": "Poista nykyinen työtila", + "edit": "Muokkaa nykyistä työtilaa", + "invite": "Kutsu ihmisiä työtilaan", + "new": "Luo uusi työtila", + "switch_to_personal": "Vaihda henkilökohtaiseen työtilaan", + "title": "Työtilat" + }, + "phrases": { + "try": "Kokeile", + "import_collections": "Tuo kokoelmat", + "create_environment": "Luo ympäristö", + "create_workspace": "Luo työtila", + "share_request": "Jaa pyyntö" + } + }, + "sse": { + "event_type": "Tapahtumatyyppi", + "log": "Loki", + "url": "URL" + }, + "state": { + "bulk_mode": "Massamuokkaus", + "bulk_mode_placeholder": "Merkinnät erotetaan rivinvaihdolla\nAvaimet ja arvot erotetaan :\nLisää # rivin alkuun lisätäksesi mutta pitääksesi poissa käytöstä", + "cleared": "Tyhjennetty", + "connected": "Yhdistetty", + "connected_to": "Yhdistetty kohteeseen {name}", + "connecting_to": "Yhdistetään kohteeseen {name}...", + "connection_error": "Yhdistäminen epäonnistui", + "connection_failed": "Yhteys epäonnistui", + "connection_lost": "Yhteys katkesi", + "copied_interface_to_clipboard": "Kopioitu {language}-rajapintatyyppi leikepöydälle", + "copied_to_clipboard": "Kopioitu leikepöydälle", + "deleted": "Poistettu", + "deprecated": "VANHENTUNUT", + "disabled": "Poistettu käytöstä", + "disconnected": "Yhteys katkaistu", + "disconnected_from": "Yhteys katkaistu kohteesta {name}", + "docs_generated": "Dokumentaatio luotu", + "download_failed": "Lataus epäonnistui", + "download_started": "Lataus aloitettu", + "enabled": "Käytössä", + "experimental": "Kokeellinen", + "file_imported": "Tiedosto tuotu", + "finished_in": "Valmis {duration} ms:ssä", + "hide": "Piilota", + "history_deleted": "Historia poistettu", + "linewrap": "Rivitä", + "loading": "Ladataan...", + "message_received": "Viesti: {message} saapui aiheeseen: {topic}", + "mqtt_subscription_failed": "Jokin meni pieleen tilatessa aihetta: {topic}", + "no_content_found": "Sisältöä ei löytynyt", + "none": "Ei mitään", + "nothing_found": "Mitään ei löytynyt haulle", + "published_error": "Jokin meni pieleen julkaistaessa viestin: {topic} aiheeseen: {message}", + "published_message": "Julkaistu viesti: {message} aiheeseen: {topic}", + "reconnection_error": "Uudelleenyhdistäminen epäonnistui", + "saved": "Tallennettu", + "show": "Näytä", + "subscribed_failed": "Aiheen tilaus epäonnistui: {topic}", + "subscribed_success": "Aihe tilattu onnistuneesti: {topic}", + "unsubscribed_failed": "Aiheen tilauksen peruutus epäonnistui: {topic}", + "unsubscribed_success": "Aiheen tilaus peruutettu onnistuneesti: {topic}", + "waiting_send_request": "Odotetaan pyynnön lähettämistä", + "user_deactivated": "Tilisi on deaktivoitu. Ota yhteyttä ylläpitäjään tilin uudelleenaktivoimiseksi.", + "add_user_failure": "Käyttäjän lisääminen työtilaan epäonnistui!", + "add_user_success": "Käyttäjä on nyt työtilan jäsen!", + "admin_failure": "Käyttäjän ylläpitäjäksi tekeminen epäonnistui!", + "admin_success": "Käyttäjä on nyt ylläpitäjä!", + "and": "ja", + "clear_selection": "Tyhjennä valinta", + "configure_auth": "Määritä todennuspalveluntarjoaja ylläpitoasetuksista tai tutustu dokumentaatioon todennuspalveluntarjoajien määrittämiseksi.", + "confirm_admin_to_user": "Haluatko poistaa ylläpitäjäoikeudet tältä käyttäjältä?", + "confirm_admins_to_users": "Haluatko poistaa ylläpitäjäoikeudet valituilta käyttäjiltä?", + "confirm_delete_infra_token": "Haluatko varmasti poistaa infrastruktuuritokenin {tokenLabel}?", + "confirm_delete_invite": "Haluatko peruuttaa valitun kutsun?", + "confirm_delete_invites": "Haluatko peruuttaa valitut kutsut?", + "confirm_user_deletion": "Vahvista käyttäjän poisto?", + "confirm_users_deletion": "Haluatko poistaa valitut käyttäjät?", + "confirm_user_to_admin": "Haluatko tehdä tästä käyttäjästä ylläpitäjän?", + "confirm_users_to_admin": "Haluatko tehdä valituista käyttäjistä ylläpitäjiä?", + "confirm_user_deactivation": "Vahvista käyttäjän deaktivointi?", + "confirm_logout": "Vahvista uloskirjautuminen", + "created_on": "Luotu", + "continue_email": "Jatka sähköpostilla", + "continue_github": "Jatka Githubilla", + "continue_google": "Jatka Googlella", + "continue_microsoft": "Jatka Microsoftilla", + "create_team_failure": "Työtilan luonti epäonnistui!", + "create_team_success": "Työtila luotu onnistuneesti!", + "data_sharing_failure": "Tietojenjakamisen asetusten päivitys epäonnistui", + "delete_infra_token_failure": "Jokin meni pieleen infrastruktuuritokenin poistamisessa", + "delete_invite_failure": "Kutsun poistaminen epäonnistui!", + "delete_invites_failure": "Valittujen kutsujen poistaminen epäonnistui!", + "delete_invite_success": "Kutsu poistettu onnistuneesti!", + "delete_invites_success": "Valitut kutsut poistettu onnistuneesti!", + "delete_request_failure": "Jaetun pyynnön poistaminen epäonnistui!", + "delete_request_success": "Jaettu pyyntö poistettu onnistuneesti!", + "delete_team_failure": "Työtilan poistaminen epäonnistui!", + "delete_team_success": "Työtila poistettu onnistuneesti!", + "delete_some_users_failure": "Poistamattomien käyttäjien määrä: {count}", + "delete_some_users_success": "Poistettujen käyttäjien määrä: {count}", + "delete_user_failed_only_one_admin": "Käyttäjän poistaminen epäonnistui. Vähintään yksi ylläpitäjä on oltava!", + "delete_user_failure": "Käyttäjän poistaminen epäonnistui!", + "delete_users_failure": "Valittujen käyttäjien poistaminen epäonnistui!", + "delete_user_success": "Käyttäjä poistettu onnistuneesti!", + "delete_users_success": "Valitut käyttäjät poistettu onnistuneesti!", + "email": "Sähköposti", + "email_failure": "Kutsun lähettäminen epäonnistui", + "email_signin_failure": "Kirjautuminen sähköpostilla epäonnistui", + "email_success": "Sähköpostikutsu lähetetty onnistuneesti", + "emails_cannot_be_same": "Et voi kutsua itseäsi, valitse toinen sähköpostiosoite!", + "enter_team_email": "Syötä työtilan omistajan sähköposti!", + "error": "Jokin meni pieleen", + "error_auth_providers": "Todennuspalveluntarjoajien lataus epäonnistui", + "generate_infra_token_failure": "Jokin meni pieleen infrastruktuuritokenin luomisessa", + "github_signin_failure": "Kirjautuminen Githubilla epäonnistui", + "google_signin_failure": "Kirjautuminen Googlella epäonnistui", + "infra_token_label_short": "Infrastruktuuritokenin tunniste on liian lyhyt!", + "invalid_email": "Syötä kelvollinen sähköpostiosoite", + "link_copied_to_clipboard": "Linkki kopioitu leikepöydälle", + "logged_out": "Uloskirjautunut", + "login_as_admin": "ja kirjaudu ylläpitäjätilillä.", + "login_using_email": "Pyydä käyttäjää tarkistamaan sähköpostinsa tai jaa alla oleva linkki", + "login_using_link": "Pyydä käyttäjää kirjautumaan alla olevan linkin kautta", + "logout": "Kirjaudu ulos", + "magic_link_sign_in": "Napsauta linkkiä kirjautuaksesi sisään.", + "magic_link_success": "Lähetimme kirjautumislinkin osoitteeseen", + "microsoft_signin_failure": "Kirjautuminen Microsoftilla epäonnistui", + "newsletter_failure": "Uutiskirjeen asetusten päivitys epäonnistui", + "non_admin_logged_in": "Kirjautunut sisään ei-ylläpitäjäkäyttäjänä.", + "non_admin_login": "Olet kirjautunut sisään. Mutta et ole ylläpitäjä", + "owner_not_present": "Vähintään yhden omistajan on oltava tiimissä!", + "privacy_policy": "Tietosuojakäytäntö", + "reenter_email": "Syötä sähköposti uudelleen", + "remove_admin_failure": "Ylläpitäjäoikeuksien poistaminen epäonnistui!", + "remove_admin_failure_only_one_admin": "Ylläpitäjäoikeuksien poistaminen epäonnistui. Vähintään yksi ylläpitäjä on oltava!", + "remove_admin_success": "Ylläpitäjäoikeudet poistettu!", + "remove_admin_from_users_failure": "Ylläpitäjäoikeuksien poistaminen valituilta käyttäjiltä epäonnistui!", + "remove_admin_from_users_success": "Ylläpitäjäoikeudet poistettu valituilta käyttäjiltä!", + "remove_admin_to_delete_user": "Poista ylläpitäjäoikeudet poistaaksesi käyttäjän!", + "remove_owner_to_delete_user": "Poista tiimin omistajuus poistaaksesi käyttäjän!", + "remove_owner_failure_only_one_owner": "Jäsenen poistaminen epäonnistui. Tiimissä on oltava vähintään yksi omistaja!", + "remove_admin_for_deletion": "Poista ylläpitäjäoikeudet ennen poistoyritystä!", + "remove_owner_for_deletion": "Yksi tai useampi käyttäjä on tiimin omistaja. Päivitä omistajuus ennen poistoa!", + "remove_invitee_failure": "Kutsutun poistaminen epäonnistui!", + "remove_invitee_success": "Kutsuttu poistettu onnistuneesti!", + "remove_member_failure": "Jäsentä ei voitu poistaa!", + "remove_member_success": "Jäsen poistettu onnistuneesti!", + "rename_team_failure": "Työtilan uudelleennimeäminen epäonnistui!", + "rename_team_success": "Työtila nimetty uudelleen onnistuneesti!", + "rename_user_failure": "Käyttäjän uudelleennimeäminen epäonnistui!", + "rename_user_success": "Käyttäjä nimetty uudelleen onnistuneesti!", + "require_auth_provider": "Sinun on määritettävä vähintään yksi todennuspalveluntarjoaja kirjautumista varten.", + "role_update_failed": "Roolien päivitys epäonnistui!", + "role_update_success": "Roolit päivitetty onnistuneesti!", + "selected": "{count} valittu", + "self_host_docs": "Itse-isännöinnin dokumentaatio", + "send_magic_link": "Lähetä taikalinkki", + "setup_failure": "Määritys epäonnistui!", + "setup_success": "Määritys valmis onnistuneesti!", + "sign_in_agreement": "Kirjautumalla sisään hyväksyt", + "sign_in_options": "Kaikki kirjautumisvaihtoehdot", + "sign_out": "Kirjaudu ulos", + "something_went_wrong": "Jokin meni pieleen", + "team_name_too_short": "Työtilan nimen on oltava vähintään 6 merkkiä pitkä!", + "user_already_invited": "Kutsun lähettäminen epäonnistui. Käyttäjä on jo kutsuttu!", + "user_not_found": "Käyttäjää ei löytynyt infrastruktuurista!", + "users_to_admin_success": "Valitut käyttäjät korotettu ylläpitäjäksi!", + "users_to_admin_failure": "Valittujen käyttäjien korottaminen ylläpitäjäksi epäonnistui!", + "loading_workspaces": "Ladataan työtiloja", + "loading_collections_in_workspace": "Ladataan kokoelmia työtilassa" + }, + "support": { + "changelog": "Lue lisää viimeisimmistä julkaisuista", + "chat": "Kysyttävää? Keskustele kanssamme!", + "community": "Kysy kysymyksiä ja auta muita", + "documentation": "Lue lisää Hoppscotchista", + "forum": "Kysy kysymyksiä ja saa vastauksia", + "github": "Seuraa meitä Githubissa", + "shortcuts": "Selaa sovellusta nopeammin", + "title": "Tuki", + "twitter": "Seuraa meitä Twitterissä" + }, + "tab": { + "authorization": "Valtuutus", + "body": "Runko", + "close": "Sulje välilehti", + "close_others": "Sulje muut välilehdet", + "collections": "Kokoelmat", + "documentation": "Dokumentaatio", + "duplicate": "Kopioi välilehti", + "environments": "Ympäristöt", + "headers": "Otsikot", + "history": "Historia", + "mqtt": "MQTT", + "parameters": "Parametrit", + "post_request_script": "Jälkipyyntöskripti", + "pre_request_script": "Esipyyntöskripti", + "scripts": "Skriptit", + "queries": "Kyselyt", + "query": "Kysely", + "schema": "Skeema", + "shared_requests": "Jaetut pyynnöt", + "codegen": "Luo koodi", + "code_snippet": "Koodinpätkä", + "mock_servers": "Mock-palvelimet", + "share_tab_request": "Jaa välilehden pyyntö", + "socketio": "Socket.IO", + "sse": "SSE", + "types": "Tyypit", + "variables": "Muuttujat", + "websocket": "WebSocket", + "all_tests": "Kaikki testit", + "passed": "Läpäisty", + "failed": "Epäonnistunut" + }, + "team": { + "activity_logs": "Toimintalokit", + "already_member": "Tämä sähköposti on yhdistetty olemassa olevaan käyttäjään.", + "create_new": "Luo uusi työtila", + "deleted": "Työtila poistettu", + "delete_all_activity_logs": "Poista kaikki toimintalokit", + "successfully_deleted_all_activity_logs": "Kaikki toimintalokit poistettu onnistuneesti", + "delete_activity_log": "Poista toimintaloki", + "deleted_activity_log": "Valittu toimintaloki poistettu", + "deleted_all_activity_logs": "Kaikki toimintalokit poistettu", + "edit": "Muokkaa työtilaa", + "email": "Sähköposti", + "email_do_not_match": "Sähköposti ei vastaa tilitietojasi. Ota yhteyttä työtilan omistajaan.", + "exit": "Poistu työtilasta", + "exit_disabled": "Ainoa omistaja ei voi poistua työtilasta", + "failed_invites": "Epäonnistuneet kutsut", + "invalid_coll_id": "Virheellinen kokoelmatunnus", + "invalid_email_format": "Sähköpostimuoto on virheellinen", + "invalid_id": "Virheellinen työtilatunnus. Ota yhteyttä työtilan omistajaan.", + "invalid_invite_link": "Virheellinen kutsulinkki", + "invalid_invite_link_description": "Seuraamasi linkki on virheellinen. Ota yhteyttä työtilan omistajaan.", + "invalid_member_permission": "Anna kelvollinen oikeus työtilan jäsenelle", + "invite": "Kutsu", + "invite_more": "Kutsu lisää", + "invite_tooltip": "Kutsu ihmisiä tähän työtilaan", + "invited_to_team": "{owner} kutsui sinut liittymään työtilaan {workspace}", + "join": "Kutsu hyväksytty", + "join_team": "Liity työtilaan {workspace}", + "joined_team": "Olet liittynyt työtilaan {workspace}", + "joined_team_description": "Olet nyt tämän työtilan jäsen", + "left": "Poistuit työtilasta", + "login_to_continue": "Kirjaudu jatkaaksesi", + "login_to_continue_description": "Sinun on kirjauduttava sisään liittyäksesi työtilaan.", + "logout_and_try_again": "Kirjaudu ulos ja kirjaudu sisään toisella tilillä", + "member_has_invite": "Käyttäjällä on jo kutsu. Pyydä häntä tarkistamaan sähköpostinsa tai peruuta ja lähetä kutsu uudelleen.", + "member_not_found": "Jäsentä ei löytynyt. Ota yhteyttä työtilan omistajaan.", + "member_removed": "Käyttäjä poistettu", + "member_role_updated": "Käyttäjän roolit päivitetty", + "members": "Jäsenet", + "more_members": "+{count} lisää", + "name_length_insufficient": "Työtilan nimi ei saa olla tyhjä", + "name_updated": "Työtilan nimi päivitetty", + "new": "Uusi työtila", + "new_created": "Uusi työtila luotu", + "new_name": "Uusi työtilani", + "no_access": "Sinulla ei ole muokkausoikeutta tähän työtilaan", + "no_invite_found": "Kutsua ei löytynyt. Ota yhteyttä työtilan omistajaan.", + "no_request_found": "Pyyntöä ei löytynyt.", + "not_found": "Työtilaa ei löytynyt. Ota yhteyttä työtilan omistajaan.", + "not_valid_viewer": "Et ole kelvollinen katsoja. Ota yhteyttä työtilan omistajaan.", + "parent_coll_move": "Kokoelmaa ei voi siirtää alikokoelmaan", + "pending_invites": "Odottavat kutsut", + "permissions": "Oikeudet", + "same_target_destination": "Sama kohde ja määränpää", + "saved": "Työtila tallennettu", + "select_a_team": "Valitse työtila", + "success_invites": "Onnistuneet kutsut", + "title": "Työtilat", + "we_sent_invite_link": "Kutsut ovat matkalla", + "invite_sent_smtp_disabled": "Kutsulinkit luotu", + "we_sent_invite_link_description": "Uudet kutsutut saavat linkin työtilaan liittymiseksi, olemassa olevat jäsenet ja odottavat kutsutut eivät saa uutta linkkiä.", + "invite_sent_smtp_disabled_description": "Kutsusähköpostien lähettäminen on poistettu käytöstä tässä Hoppscotch-instanssissa. Käytä Kopioi linkki -painiketta kopioidaksesi ja jakaaksesi kutsulinkin manuaalisesti.", + "copy_invite_link": "Kopioi kutsulinkki", + "search_title": "Tiimipyynnöt", + "user_not_found": "Käyttäjää ei löytynyt instanssista.", + "invite_members": "Kutsu jäseniä" + }, + "team_environment": { + "deleted": "Ympäristö poistettu", + "duplicate": "Ympäristö kopioitu", + "not_found": "Ympäristöä ei löytynyt." + }, + "test": { + "requests": "Pyynnöt", + "selection": "Valinta", + "failed": "testi epäonnistui", + "javascript_code": "JavaScript-koodi", + "learn": "Lue dokumentaatio", + "passed": "testi läpäisty", + "report": "Testiraportti", + "results": "Testitulokset", + "script": "Skripti", + "snippets": "Koodinpätkät", + "run": "Suorita", + "run_again": "Suorita uudelleen", + "stop": "Pysäytä", + "new_run": "Uusi suoritus", + "iterations": "Iteraatiot", + "duration": "Kesto", + "avg_resp": "Keskim. vasteaika" + }, + "websocket": { + "communication": "Viestintä", + "log": "Loki", + "message": "Viesti", + "protocols": "Protokollat", + "url": "URL" + }, + "workspace": { + "change": "Vaihda työtilaa", + "personal": "Henkilökohtainen työtila", + "other_workspaces": "Omat työtilat", + "team": "Työtila", + "title": "Työtilat" + }, + "site_protection": { + "login_to_continue": "Kirjaudu jatkaaksesi", + "login_to_continue_description": "Sinun on kirjauduttava sisään käyttääksesi tätä Hoppscotch Enterprise -instanssia.", + "error_fetching_site_protection_status": "Jokin meni pieleen sivuston suojaustilan haussa" + }, + "access_tokens": { + "tab_title": "Tokenit", + "section_title": "Henkilökohtaiset käyttöoikeustokenit", + "section_description": "Henkilökohtaiset käyttöoikeustokenit auttavat yhdistämään CLI:n Hoppscotch-tiliisi", + "last_used_on": "Viimeksi käytetty", + "expires_on": "Vanhenee", + "no_expiration": "Ei vanhene", + "expired": "Vanhentunut", + "copy_token_warning": "Muista kopioida henkilökohtainen käyttöoikeustokenisi nyt. Et voi nähdä sitä enää myöhemmin!", + "token_purpose": "Mihin tämä token on?", + "expiration_label": "Voimassaolo", + "scope_label": "Laajuus", + "workspace_read_only_access": "Vain luku -oikeus työtilan tietoihin.", + "personal_workspace_access_limitation": "Henkilökohtaiset käyttöoikeustokenit eivät voi käyttää henkilökohtaista työtilaasi.", + "generate_token": "Luo token", + "invalid_label": "Anna tokenille tunniste", + "no_expiration_verbose": "Tämä token ei koskaan vanhene!", + "token_expires_on": "Tämä token vanhenee", + "generate_new_token": "Luo uusi token", + "generate_modal_title": "Uusi henkilökohtainen käyttöoikeustoken", + "deletion_success": "Käyttöoikeustoken {label} on poistettu" + }, + "collection_runner": { + "collection_id": "Kokoelmatunnus", + "environment_id": "Ympäristötunnus", + "cli_collection_id_description": "Tätä kokoelmatunnusta käytetään Hoppscotchin CLI-kokoelmien suorittajassa.", + "cli_environment_id_description": "Tätä ympäristötunnusta käytetään Hoppscotchin CLI-kokoelmien suorittajassa.", + "include_active_environment": "Sisällytä aktiivinen ympäristö:", + "cli": "CLI", + "cli_comming_soon_for_personal_collection": "Kokoelmien suorittaja henkilökohtaisille kokoelmille CLI:ssä tulossa pian.", + "delay": "Viive", + "negative_delay": "Viive ei voi olla negatiivinen", + "ui": "Suorittaja", + "running_collection": "Suoritetaan kokoelmaa", + "run_config": "Suorituksen asetukset", + "advanced_settings": "Lisäasetukset", + "stop_on_error": "Pysäytä suoritus virheen sattuessa", + "persist_responses": "Säilytä vastaukset", + "keep_variable_values": "Säilytä muuttujien arvot", + "collection_not_found": "Kokoelmaa ei löytynyt. Se on ehkä poistettu tai siirretty.", + "empty_collection": "Kokoelma on tyhjä. Lisää pyyntöjä suorittaaksesi.", + "no_response_persist": "Kokoelmien suorittaja on tällä hetkellä määritetty olemaan säilyttämättä vastauksia. Tämä asetus estää vastaustietojen näyttämisen. Muuttaaksesi tätä käyttäytymistä, aloita uusi suorituksen määritys.", + "select_request": "Valitse pyyntö nähdäksesi vastauksen ja testitulokset", + "response_body_lost_rerun": "Vastauksen runko on menetetty. Suorita kokoelma uudelleen saadaksesi vastauksen rungon.", + "cli_command_generation_description_cloud": "Kopioi alla oleva komento ja suorita se CLI:stä. Määritä henkilökohtainen käyttöoikeustoken.", + "cli_command_generation_description_sh": "Kopioi alla oleva komento ja suorita se CLI:stä. Määritä henkilökohtainen käyttöoikeustoken ja tarkista luotu SH-instanssin palvelin-URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Kopioi alla oleva komento ja suorita se CLI:stä. Määritä henkilökohtainen käyttöoikeustoken ja SH-instanssin palvelin-URL.", + "run_collection": "Suorita kokoelma", + "no_passed_tests": "Yksikään testi ei läpäissyt", + "no_failed_tests": "Yksikään testi ei epäonnistunut" + }, + "ai_experiments": { + "generate_request_name": "Luo pyynnön nimi tekoälyn avulla", + "generate_or_modify_request_body": "Luo tai muokkaa pyynnön runkoa", + "modify_with_ai": "Muokkaa tekoälyllä", + "generate": "Luo", + "generate_or_modify_request_body_input_placeholder": "Syötä kehote pyynnön rungon muokkaamiseksi", + "accept_change": "Hyväksy muutos", + "feedback_success": "Palaute lähetetty onnistuneesti", + "feedback_failure": "Palautteen lähettäminen epäonnistui", + "feedback_thank_you": "Kiitos palautteestasi!", + "feedback_cta_text_long": "Arvioi luonti, auttaa meitä parantamaan", + "feedback_cta_request_name": "Piditkö luodusta nimestä?", + "modify_request_body_error": "Pyynnön rungon muokkaus epäonnistui", + "generate_or_modify_prerequest_input_placeholder": "Syötä kehote esipyyntöskriptin luomiseksi tai muokkaamiseksi", + "generate_or_modify_post_request_script_input_placeholder": "Syötä kehote jälkipyyntöskriptin luomiseksi tai muokkaamiseksi", + "modify_post_request_script_error": "Jälkipyyntöskriptin muokkaus epäonnistui", + "modify_prerequest_error": "Esipyyntöskriptin muokkaus epäonnistui" + }, + "configs": { + "auth_providers": { + "callback_url": "TAKAISINKUTSU-URL", + "client_id": "ASIAKASTUNNUS", + "client_secret": "ASIAKASSALAISUUS", + "description": "Määritä todennuspalveluntarjoajat palvelimellesi", + "provider_not_specified": "Ota käyttöön vähintään yksi todennuspalveluntarjoaja", + "scope": "LAAJUUS", + "tenant": "TENANT-TUNNUS", + "title": "Todennuspalveluntarjoajat", + "update_failure": "Todennuspalveluntarjoajien määritysten päivitys epäonnistui!" + }, + "confirm_changes": "Hoppscotch-palvelin on käynnistettävä uudelleen uusien muutosten voimaantulemiseksi. Vahvista palvelinmäärityksiin tehdyt muutokset?", + "input_empty": "Täytä kaikki kentät ennen määritysten päivittämistä", + "data_sharing": { + "title": "Tietojen jakaminen", + "description": "Auta parantamaan Hoppscotchia jakamalla anonyymiä tietoja", + "enable": "Ota tietojen jakaminen käyttöön", + "secondary_title": "Tietojenjakamisen asetukset", + "see_shared": "Katso mitä jaetaan", + "toggle_description": "Jaa anonyymiä tietoja", + "update_failure": "Tietojenjakamisen asetusten päivitys epäonnistui!" + }, + "load_error": "Palvelinmääritysten lataus epäonnistui", + "mail_configs": { + "address_from": "LÄHETTÄJÄN OSOITE", + "custom_smtp_configs": "Käytä mukautettuja SMTP-asetuksia", + "description": "Määritä SMTP-asetukset", + "enable_email_auth": "Ota sähköpostipohjainen todennus käyttöön", + "enable_smtp": "Ota SMTP käyttöön", + "host": "SÄHKÖPOSTIPALVELIMEN OSOITE", + "password": "SÄHKÖPOSTIN SALASANA", + "port": "SÄHKÖPOSTIN PORTTI", + "secure": "SUOJATTU SÄHKÖPOSTI", + "smtp_url": "SMTP-URL", + "tls_reject_unauthorized": "TLS REJECT UNAUTHORIZED", + "title": "SMTP-asetukset", + "toggle_failure": "SMTP:n käyttöönoton vaihto epäonnistui!", + "update_failure": "SMTP-asetusten päivitys epäonnistui!", + "user": "SÄHKÖPOSTIN KÄYTTÄJÄ" + }, + "reset": { + "confirm_reset": "Hoppscotch-palvelin on käynnistettävä uudelleen uusien muutosten voimaantulemiseksi. Vahvista palvelinmääritysten nollaus?", + "description": "Oletusmääritykset ladataan ympäristötiedostossa määritetyllä tavalla", + "failure": "Määritysten nollaus epäonnistui!", + "title": "Nollaa määritykset", + "info": "Nollaa palvelinmääritykset" + }, + "restart": { + "description": "{duration} sekuntia jäljellä ennen kuin sivu latautuu automaattisesti", + "initiate": "Käynnistetään palvelinta uudelleen...", + "title": "Palvelin käynnistyy uudelleen" + }, + "save_changes": "Tallenna muutokset", + "title": "Määritykset", + "update_failure": "Palvelinmääritysten päivitys epäonnistui", + "restrict_access": "Rajoita pääsyä", + "site_protection": { + "control_access": "Hallitse kuka voi käyttää Hoppscotch-sovellusta", + "description": "Mukauta miten vierailijat pääsevät Hoppscotch-sovellukseesi sivuston suojausasetuksilla.", + "enable": "Ota sivuston suojaus käyttöön", + "note": "Hoppscotch-sovellukseen saapuvat käyttäjät näkevät kirjautumissivun, pakollinen kirjautuminen vaaditaan sovelluksen käyttämiseksi. Ylläpitäjän hyväksyntä vaaditaan edelleen valtuutukseen", + "update_failure": "Sivuston suojausasetusten päivitys epäonnistui!" + }, + "domain_whitelisting": { + "add_domain": "Lisää uusi verkkotunnus", + "description": "Käyttäjät, joiden sähköposti on rekisteröity sallituissa verkkotunnuksissa, eivät tarvitse erillistä ylläpitäjän hyväksyntää Hoppscotch-sovelluksen käyttämiseksi", + "enable": "Ota verkkotunnusten sallimislista käyttöön", + "enter_domain": "Syötä verkkotunnus", + "title": "Sallitut verkkotunnukset", + "toggle_failure": "Verkkotunnusten sallimislistan vaihto epäonnistui", + "update_failure": "Verkkotunnusten sallimislistan asetusten päivitys epäonnistui!" + }, + "oidc_configs": { + "auth_url": "Todennus-URL", + "callback_url": "Takaisinkutsu-URL", + "client_id": "Asiakastunnus", + "client_secret": "Asiakassalaisuus", + "description": "Määritä OIDC-asetukset", + "enable": "Ota OIDC-pohjainen todennus käyttöön", + "issuer": "Myöntäjä", + "provider_name": "Palveluntarjoajan nimi", + "scope": "Laajuus", + "title": "OIDC-asetukset", + "token_url": "Token-URL", + "update_failure": "OIDC-asetusten päivitys epäonnistui!", + "user_info_url": "Käyttäjätietojen URL" + }, + "saml": { + "audience": "Yleisö", + "callback_url": "Takaisinkutsu-URL", + "certificate": "Sertifikaatti", + "description": "Määritä SAML-asetukset", + "enable": "Ota SAML-pohjainen todennus käyttöön", + "entry_point": "Aloituspiste", + "issuer": "Myöntäjä", + "title": "SAML-asetukset", + "update_failure": "SAML SSO -asetusten päivitys epäonnistui!", + "want_assertions_signed": "Allekirjoita väitteet", + "want_response_signed": "Allekirjoita vastaus" + } + }, + "data_sharing": { + "description": "Jaa anonyynejä käyttötietoja Hoppscotchin parantamiseksi", + "enable": "Ota tietojen jakaminen käyttöön", + "see_shared": "Katso mitä jaetaan", + "toggle_description": "Jaa tietoja ja tee Hoppscotchista parempi", + "title": "Tee Hoppscotchista parempi", + "welcome": "Tervetuloa" + }, + "infra_tokens": { + "copy_token_warning": "Muista kopioida infrastruktuuritokenisi nyt. Et voi nähdä sitä enää myöhemmin!", + "deletion_success": "Infrastruktuuritoken {label} on poistettu", + "empty": "Infrastruktuuritokeneita ei ole", + "expired": "Vanhentunut", + "expiration_label": "Voimassaolo", + "expires_on": "Vanhenee", + "generate_modal_title": "Uusi infrastruktuuritoken", + "generate_new_token": "Luo uusi token", + "generate_token": "Luo token", + "invalid_label": "Anna tokenille tunniste", + "last_used_on": "Viimeksi käytetty", + "no_expiration": "Ei vanhene", + "no_expiration_verbose": "Tämä token ei koskaan vanhene!", + "section_description": "Hallitse Hoppscotch-käyttäjiä API:en kautta infrastruktuuritokeneilla", + "section_title": "Infrastruktuuritokenit", + "tab_title": "Infrastruktuuritokenit", + "token_expires_on": "Tämä token vanhenee", + "token_purpose": "Syötä tunniste tämän tokenin tunnistamiseksi" + }, + "metrics": { + "dashboard": "Hallintapaneeli", + "no_metrics": "Mittareita ei löytynyt", + "total_collections": "Kokoelmia yhteensä", + "total_requests": "Pyyntöjä yhteensä", + "total_teams": "Työtiloja yhteensä", + "total_users": "Käyttäjiä yhteensä" + }, + "newsletter": { + "description": "Saa päivityksiä viimeisimmistä uutisistamme", + "subscribe": "Tilaa", + "title": "Pysy ajan tasalla", + "toggle_description": "Saa päivityksiä Hoppscotchin viimeisimmistä uutisista", + "unsubscribe": "Peruuta tilaus" + }, + "teams": { + "add_member": "Lisää jäsen", + "add_members": "Lisää jäseniä", + "add_new": "Lisää uusi", + "admin": "Ylläpitäjä", + "admin_Email": "Ylläpitäjän sähköposti", + "admin_id": "Ylläpitäjän ID", + "cancel": "Peruuta", + "confirm_team_deletion": "Vahvista työtilan poisto?", + "copy": "Kopioi", + "create_team": "Luo työtila", + "date": "Päivämäärä", + "delete_team": "Poista työtila", + "details": "Tiedot", + "edit": "Muokkaa", + "editor": "MUOKKAAJA", + "editor_description": "Muokkaajat voivat lisätä, muokata ja poistaa pyyntöjä ja kokoelmia.", + "email": "Työtilan omistajan sähköposti", + "email_address": "Sähköpostiosoite", + "email_title": "Sähköposti", + "empty_name": "Tiimin nimi ei voi olla tyhjä!", + "error": "Jokin meni pieleen. Yritä myöhemmin uudelleen.", + "id": "Työtilan ID", + "invited_email": "Kutsutun sähköposti", + "invited_on": "Kutsuttu", + "invites": "Kutsut", + "load_info_error": "Työtilan tietojen lataus epäonnistui", + "load_list_error": "Työtilalistan lataus epäonnistui", + "members": "Jäsenmäärä", + "no_invite": "Ei kutsuja", + "no_invite_description": "Kutsu tiimisi aloittamaan yhteistyö", + "owner": "OMISTAJA", + "owner_description": "Omistajat voivat lisätä, muokata ja poistaa pyyntöjä, kokoelmia ja työtilan jäseniä.", + "permissions": "Oikeudet", + "name": "Työtilan nimi", + "no_members": "Tässä työtilassa ei ole jäseniä. Lisää jäseniä aloittaaksesi yhteistyön", + "no_pending_invites": "Ei odottavia kutsuja", + "no_teams": "Työtiloja ei löytynyt..", + "no_teams_description": "Luo työtila aloittaaksesi yhteistyön tiimisi kanssa", + "pending_invites": "Odottavat kutsut", + "roles": "Roolit", + "roles_description": "Rooleja käytetään jaettujen kokoelmien pääsynhallintaan.", + "remove": "Poista", + "rename": "Nimeä uudelleen", + "save": "Tallenna", + "save_changes": "Tallenna muutokset", + "send_invite": "Lähetä kutsu", + "show_more": "Näytä lisää", + "team_details": "Työtilan tiedot", + "team_members": "Jäsenet", + "team_members_tab": "Työtilan jäsenet", + "teams": "Työtila", + "uid": "UID", + "unnamed": "(Nimetön työtila)", + "viewer": "KATSOJA", + "viewer_description": "Katsojat voivat vain tarkastella ja käyttää pyyntöjä", + "valid_name": "Syötä kelvollinen työtilan nimi", + "valid_owner_email": "Syötä kelvollinen omistajan sähköpostiosoite" + }, + "users": { + "add_user": "Lisää käyttäjä", + "admin": "Ylläpitäjä", + "admin_id": "Ylläpitäjän ID", + "cancel": "Peruuta", + "created_on": "Luotu", + "copy_invite_link": "Kopioi kutsulinkki", + "copy_link": "Kopioi linkki", + "date": "Päivämäärä", + "delete": "Poista", + "delete_user": "Poista käyttäjä", + "delete_users": "Poista käyttäjät", + "details": "Tiedot", + "edit": "Muokkaa", + "email": "Sähköposti", + "email_address": "Sähköpostiosoite", + "empty_name": "Nimi ei voi olla tyhjä!", + "id": "Käyttäjätunnus", + "invalid_user": "Virheellinen käyttäjä", + "invite_load_list_error": "Kutsuttujen käyttäjien listan lataus epäonnistui", + "invite_user": "Kutsu käyttäjä", + "invited_by": "Kutsujan", + "invited_on": "Kutsuttu", + "invited_users": "Kutsutut käyttäjät", + "invitee_email": "Kutsutun sähköposti", + "last_active_on": "Viimeksi aktiivinen", + "load_info_error": "Käyttäjätietojen lataus epäonnistui", + "load_list_error": "Käyttäjälistan lataus epäonnistui", + "make_admin": "Tee ylläpitäjäksi", + "name": "Nimi", + "new_user_added": "Uusi käyttäjä lisätty", + "no_invite": "Ei odottavia kutsuja", + "no_invite_description": "Ei odottavia kutsuja! Aloita tiimiläistesi kutsuminen Hoppscotchiin", + "no_shared_requests": "Käyttäjä ei ole luonut jaettuja pyyntöjä", + "no_users": "Käyttäjiä ei löytynyt", + "not_available": "Ei saatavilla", + "not_found": "Käyttäjää ei löytynyt", + "pending_invites": "Odottavat kutsut", + "remove_admin_privilege": "Poista ylläpitäjäoikeus", + "remove_admin_status": "Poista ylläpitäjätila", + "rename": "Nimeä uudelleen", + "revoke_invitation": "Peruuta kutsu", + "searchbar_placeholder": "Hae nimellä tai sähköpostilla..", + "send_invite": "Lähetä kutsu", + "show_more": "Näytä lisää", + "uid": "UID", + "unnamed": "(Nimetön käyttäjä)", + "user_not_found": "Käyttäjää ei löytynyt infrastruktuurista!", + "users": "Käyttäjät", + "valid_email": "Syötä kelvollinen sähköpostiosoite", + "deactivate": "Deaktivoi", + "deactivate_user": "Deaktivoi käyttäjä" + }, + "organization": { + "login_to_continue_description": "Sinun on kirjauduttava sisään liittyäksesi organisaation instanssiin.", + "create_an_organization": "Luo organisaatio", + "deactivate_user_failure": "Käyttäjän deaktivointi epäonnistui!", + "deactivate_user_success": "Käyttäjä deaktivoitu onnistuneesti!", + "delete_account_description": "Tämä poistaa kaikki Hoppscotch-tiliisi liittyvät tiedot, mukaan lukien tämän ja kaikki muut organisaatiot joissa olet jäsenenä.", + "delete_account": "Poista Hoppscotch-tili", + "user_deletion_failed_sole_admin": "Käyttäjä on yhden tai useamman organisaation instanssin ainoa ylläpitäjä. Alenna käyttäjä ennen poistoyritystä.", + "user_deletion_failed_sole_team_owner": "Käyttäjä on yhden tai useamman organisaation instanssin ainoa tiimin omistaja. Siirrä omistajuus tai poista asiaankuuluvat työtilat ennen poistoyritystä.", + "no_organizations": "Et ole minkään organisaation jäsen", + "admin": "Ylläpitäjä" + }, + "organization_sidebar": { + "hoppscotch_cloud": "Hoppscotch Cloud", + "cloud_locked": "Oletusinstanssia ei voi poistaa", + "admin": "Ylläpitäjä", + "error_loading": "Organisaatioiden lataus epäonnistui", + "inactive_orgs": "Epäaktiiviset organisaatiot", + "no_orgs_found": "Organisaatioita ei löytynyt", + "no_active_orgs_found": "Aktiivisia organisaatioita ei löytynyt", + "organizations_for": "Organisaatiot käyttäjälle {email}", + "multi_account_notice": "Jokainen organisaatio pitää oman kirjautumisensa, käyttäen viimeksi käytettyä tiliä.", + "inactive_orgs_tooltip": "Ota yhteyttä tukeen avun saamiseksi." + }, + "billing": { + "confirm": { + "update_seat_count": "Haluatko varmasti päivittää paikkamäärän arvoon {newSeatCount}?", + "update_billing_cycle": "Haluatko varmasti päivittää laskutusjakson arvoon {newBillingCycle}?" + }, + "cancel_subscription": "Peruuta tilaus" + }, + "app_console": { + "entries": "Konsolimerkinnät", + "no_entries": "Ei merkintöjä" + }, + "mockServer": { + "create_modal": { + "title": "Luo mock-palvelin", + "name_label": "Mock-palvelimen nimi", + "name_placeholder": "Syötä mock-palvelimen nimi", + "name_required": "Mock-palvelimen nimi on pakollinen", + "collection_source_label": "Kokoelman lähde", + "existing_collection": "Olemassa oleva kokoelma", + "new_collection": "Uusi kokoelma", + "select_collection_label": "Valitse kokoelma", + "select_collection_placeholder": "Valitse kokoelma", + "collection_required": "Valitse kokoelma", + "collection_name_label": "Kokoelman nimi", + "collection_name_placeholder": "Syötä kokoelman nimi", + "collection_name_required": "Kokoelman nimi on pakollinen", + "request_config_label": "Pyynnön asetukset", + "add_request": "Lisää pyyntö", + "create_button": "Luo mock-palvelin", + "success": "Mock-palvelin luotu onnistuneesti", + "error": "Mock-palvelimen luonti epäonnistui", + "no_collections": "Kokoelmia ei saatavilla" + }, + "edit_modal": { + "title": "Muokkaa mock-palvelinta", + "name_label": "Mock-palvelimen nimi", + "name_placeholder": "Syötä mock-palvelimen nimi", + "name_required": "Mock-palvelimen nimi on pakollinen", + "active_label": "Aktiivinen", + "url_label": "Mock-palvelimen URL", + "collection_label": "Kokoelma", + "update_button": "Päivitä mock-palvelin", + "success": "Mock-palvelin päivitetty onnistuneesti", + "error": "Mock-palvelimen päivitys epäonnistui", + "url_copied": "URL kopioitu leikepöydälle" + }, + "dashboard": { + "title": "Mock-palvelimet", + "subtitle": "Luo ja hallitse API-mock-palvelimiasi", + "create_button": "Luo mock-palvelin", + "create_first": "Luo ensimmäinen mock-palvelimesi", + "empty_title": "Mock-palvelimia ei löytynyt", + "empty_description": "Luo mock-palvelimia API-kokoelmiesi perusteella mahdollistaaksesi frontend- ja mobiilikehityksen ilman backend-riippuvuuksia.", + "collection": "Kokoelma", + "active": "Aktiivinen", + "inactive": "Epäaktiivinen", + "mock_url": "Mock-URL", + "endpoints": "Päätepisteet", + "created": "Luotu", + "view_collection": "Näytä kokoelma", + "documentation": "Dokumentaatio", + "doc_description": "Käytä tätä URL-osoitetta API:n perus-URL:na sovelluksissasi:", + "url_copied": "Mock-palvelimen URL kopioitu leikepöydälle", + "delete_title": "Poista mock-palvelin", + "delete_description": "Haluatko varmasti poistaa tämän mock-palvelimen?", + "delete_success": "Mock-palvelin poistettu onnistuneesti", + "delete_error": "Mock-palvelimen poisto epäonnistui" + } + } +} diff --git a/packages/hoppscotch-common/locales/fr.json b/packages/hoppscotch-common/locales/fr.json new file mode 100644 index 0000000..db42a5f --- /dev/null +++ b/packages/hoppscotch-common/locales/fr.json @@ -0,0 +1,1940 @@ +{ + "action": { + "add": "Ajouter", + "autoscroll": "Défiler automatiquement", + "cancel": "Annuler", + "choose_file": "Choisir un fichier", + "choose_workspace": "Choisir un espace de travail", + "choose_collection": "Choisir une collection", + "select_workspace": "Sélectionner un espace de travail", + "clear": "Effacer", + "clear_all": "Effacer tout", + "clear_cache": "Effacer le cache", + "clear_history": "Effacer tout l'historique", + "clear_unpinned": "Effacer les éléments non épinglés", + "close": "Fermer", + "confirm": "Confirmer", + "connect": "Se connecter", + "connecting": "Connexion", + "copy": "Copier", + "create": "Créer", + "delete": "Supprimer", + "disconnect": "Se déconnecter", + "dismiss": "Masquer", + "done": "Valider", + "dont_save": "Ne pas enregistrer", + "download_file": "Télécharger le fichier", + "download_test_report": "Télécharger le rapport des tests", + "drag_to_reorder": "Faire glisser pour réordonner", + "duplicate": "Dupliquer", + "edit": "Modifier", + "filter": "Filtrer", + "go_back": "Retour", + "go_forward": "Avancer", + "group_by": "Grouper par", + "hide_secret": "Masquer la clé secrète", + "label": "Étiqueter", + "learn_more": "En savoir plus", + "download_here": "Télécharger ici", + "less": "Moins", + "more": "Suite", + "new": "Nouveau", + "no": "Non", + "open_workspace": "Ouvrir un espace de travail", + "paste": "Coller", + "prettify": "Formater", + "properties": "Propriétés", + "register": "Créer un compte", + "remove": "Supprimer", + "remove_instance": "Supprimer l'instance", + "rename": "Renommer", + "restore": "Restaurer", + "retry": "Réessayer", + "save": "Enregistrer", + "save_as_example": "Enregistrer comme exemple", + "scroll_to_bottom": "Aller en bas de page", + "scroll_to_top": "Retour en haut de page", + "search": "Rechercher", + "send": "Envoyer", + "share": "Partager", + "show_secret": "Afficher la clé secrète", + "start": "Démarrer", + "starting": "Démarrage", + "stop": "Arrêter", + "to_close": "Pour fermer", + "to_navigate": "pour naviguer", + "to_select": "pour sélectionner", + "turn_off": "Éteindre", + "turn_on": "Allumer", + "undo": "Annuler", + "yes": "Oui", + "verify": "Vérifier", + "enable": "Activer", + "disable": "Désactiver", + "assign": "Assigner" + }, + "activity_logs": { + "ACTIVITY_LOG_DELETE": "Le journal des activités a bien été supprimé", + "WORKSPACE_CREATE": "L'espace de travail {name} a été créé", + "WORKSPACE_RENAME": "L'espace de travail {old_name} a été renommé {new_name}", + "WORKSPACE_USER_ADD": "{user} a été ajouté à l'espace de travail en tant que {role}", + "WORKSPACE_USER_INVITE": "{user} a été invité par {inviteeEmail} en tant que {role}", + "WORKSPACE_USER_INVITE_REVOKE": "L'invitation pour {inviteeEmail} en tant que {inviteeRole} a été révoquée", + "WORKSPACE_USER_INVITE_ACCEPT": "{inviteeEmail} a accepté l'invitation en tant que {inviteeRole}", + "WORKSPACE_USER_REMOVE": "{user} a été retiré de l'espace de travail", + "WORKSPACE_USER_ROLE_UPDATE": "Le rôle de {user} a été modifié pour passer du rôle {old_role} au rôle {new_role}", + "COLLECTION_CREATE": "La collection {title} a été créée", + "COLLECTION_RENAME": "La collection {old_title} a été renommée {new_title}", + "COLLECTION_IMPORT": "{count} collection(s) importée(s)", + "COLLECTION_DELETE": "La collection {title} a été supprimée", + "COLLECTION_DUPLICATE": "La collection {parentTitle} a été dupliquée", + "REQUEST_CREATE": "La requête {title} a été créée", + "REQUEST_RENAME": "La requête {old_title} a été renommée {new_title}", + "REQUEST_DELETE": "La requête {title} a été supprimée" + }, + "add": { + "new": "Ajouter un nouveau", + "star": "Ajouter une étoile" + }, + "agent": { + "registration_instruction": "Veuillez enregistrer l'agent Hoppscotch avec votre client web pour continuer.", + "enter_otp_instruction": "Veuillez entrer le code de vérification généré par l'agent Hoppscotch pour compléter l'enregistrement", + "otp_label": "Code de vérification", + "processing": "Traitement de votre requête...", + "not_running_title": "Agent non détecté", + "registration_title": "Enregistrement d'agent", + "verify_ssl_certs": "Vérifier les certificats SSL", + "ca_certs": "Certificats de l'autorité de certification", + "client_certs": "Certificats client", + "use_http_proxy": "Utiliser le proxy HTTP", + "proxy_capabilities": "L'agent Hoppscotch prend en charge les proxy HTTP/HTTPS/SOCKS avec NLTM et l'authentification basique (Basic) pour ces proxy. Le nom d'utilisateur et le mot de passe doivent être passés en paramètre de l'URL elle-même pour l'authentification via proxy.", + "add_cert_file": "Ajouter un fichier de certificat", + "add_client_cert": "Ajouter un certificat client", + "add_key_file": "Ajouter une clé de certificat", + "domain": "Domaine", + "cert": "Certificat", + "key": "Clé", + "pfx_or_pkcs": "PFX/PKCS12", + "pfx_or_pkcs_file": "Fichier PFX/PKCS12", + "add_pfx_or_pkcs_file": "Ajouter le fichier PFX/PKCS12" + }, + "app": { + "additional_links": { + "macOS": "macOS", + "windows": "Windows", + "linux": "Linux", + "web_app": "Application Web", + "cli": "CLI" + }, + "chat_with_us": "Discutez avec nous", + "contact_us": "Contactez-nous", + "cookies": "Cookies", + "copy": "Copier", + "copy_interface_type": "Copier le type d'interface", + "copy_user_id": "Copier le jeton d'authentification utilisateur", + "developer_option": "Outils du développeur", + "developer_option_description": "Outils du développeur qui aident sur le développement et la maintenance d'Hoppscotch.", + "discord": "Discord", + "documentation": "Documentation", + "github": "GitHub", + "help": "Aide & commentaires", + "home": "Accueil", + "invite": "Inviter", + "invite_description": "Hoppscotch est un écosystème de développement d'API OpenSource. Nous avons conçu une interface simple et intuitive pour créer et gérer vos APIs. Hoppscotch est un outil qui vous aide à créer, tester, documenter et partager vos APIs.", + "invite_your_friends": "Inviter des amis", + "join_discord_community": "Rejoignez notre communauté Discord", + "keyboard_shortcuts": "Raccourcis clavier", + "name": "Hoppscotch", + "new_version_found": "Nouvelle version trouvée. Actualisez la page pour mettre à jour.", + "open_in_hoppscotch": "Ouvrir dans Hoppscotch", + "options": "Options", + "proxy_privacy_policy": "Politique de confidentialité du proxy", + "reload": "Rafraîchir", + "search": "Rechercher", + "share": "Partager", + "shortcuts": "Raccourcis", + "social_description": "Suivez-nous sur les réseaux sociaux pour rester informé des dernières actualités, mises à jour et sorties.", + "social_links": "Liens sociaux", + "spotlight": "Spotlight", + "status": "Statut", + "status_description": "Vérifier le statut du site web", + "terms_and_privacy": "Conditions d'utilisation et politique de confidentialité", + "twitter": "Twitter", + "type_a_command_search": "Entrez une commande ou votre recherche…", + "we_use_cookies": "Nous utilisons des cookies", + "updated_text": "Hoppscotch a bien été mis à jour vers la version v{version} 🎉", + "whats_new": "Nouveautés", + "see_whats_new": "Voir les nouveautés", + "wiki": "Wiki", + "collapse_sidebar": "Réduire la barre latérale", + "continue_to_dashboard": "Poursuivre vers le tableau de bord", + "expand_sidebar": "Étendre la barre latérale", + "no_name": "Sans nom", + "open_navigation": "Ouvrir la navigation", + "read_documentation": "Lire la documentation", + "default": "Par défaut: {value}" + }, + "auth": { + "account_deactivated": "Votre compte a été désactivé. Contactez votre administrateur pour récupérer votre accès.", + "account_exists": "Ce compte existe déjà avec des informations d'authentification différentes - Connectez-nous pour lier les deux comptes", + "all_sign_in_options": "Toutes les options de connexion", + "continue_with_auth_provider": "Continuer avec {provider}", + "continue_with_email": "Continuer avec l'e-mail", + "continue_with_github": "Continuer avec GitHub", + "continue_with_github_enterprise": "Continuer avec GitHub Enterprise", + "continue_with_google": "Continuer avec Google", + "continue_with_microsoft": "Continuer avec Microsoft", + "email": "E-mail", + "logged_out": "Déconnecté", + "login": "Connexion", + "login_success": "Connexion réussie", + "login_to_hoppscotch": "Se connecter à Hoppscotch", + "logout": "Se déconnecter", + "re_enter_email": "Entrez votre adresse e-mail à nouveau", + "send_magic_link": "Envoyer un lien de connexion", + "sync": "Synchroniser", + "we_sent_magic_link": "Un lien de connexion vous a été envoyé !", + "we_sent_magic_link_description": "Vérifiez votre boîte de réception - nous avons envoyé un e-mail à {email}. Le mail contient un lien de connexion qui va vous authentifier directement dans l'application." + }, + "authorization": { + "generate_token": "Générer un jeton d'authentification", + "refresh_token": "Générer un jeton d'actualisation", + "graphql_headers": "Les en-têtes Authorization sont envoyés en tant que charge utile de connection_init", + "include_in_url": "Inclure dans l'URL", + "inherited_from": "Hérité de {auth} depuis la collection parente {collection} ", + "learn": "Apprendre comment", + "oauth": { + "redirect_auth_server_returned_error": "Le serveur d'authentification a retourné une erreur", + "redirect_auth_token_request_failed": "La requête pour récupérer le jeton d'authentification a échoué", + "redirect_auth_token_request_invalid_response": "Réponse invalide reçue par le point de terminaison API des jetons d'authentification lors de la demande d'obtention d'un jeton d'authentification", + "redirect_invalid_state": "Valeur d'état invalide présent dans la redirection (State)", + "redirect_no_auth_code": "Pas de code d'authentification présent dans la redirection (Authorization Code)", + "redirect_no_client_id": "Pas d'identifiant client défini (Client ID)", + "redirect_no_client_secret": "Pas de clé secrète défini (Client Secret)", + "redirect_no_code_verifier": "Pas de vérificateur de code défini (Code Verifier)", + "redirect_no_token_endpoint": "Pas de point de terminaison API défini pour les jetons d'authentification", + "something_went_wrong_on_oauth_redirect": "Une erreur s'est produite lors de la redirection OAuth", + "something_went_wrong_on_token_generation": "Une erreur s'est produite lors de la génération du jeton d'authentification", + "token_generation_oidc_discovery_failed": "Erreur critique pendant la génération d'un jeton d'authentification: Impossible de trouver un OpenID Connect", + "grant_type": "Type d'autorisation", + "grant_type_auth_code": "Code d'authentification", + "token_fetched_successfully": "Jeton d'authentification récupéré avec succès", + "token_fetch_failed": "Erreur lors de la récupération du jeton d'authentification", + "validation_failed": "Échec de la validation, veuillez vérifier les champs du formulaire", + "no_refresh_token_present": "Aucun jeton d'actualisation trouvé. Veuillez lancer à nouveau le processus de génération de jeton d'authentification.", + "refresh_token_request_failed": "La requête pour récupérer un jeton d'actualisation a échoué", + "token_refreshed_successfully": "Le jeton d'authentification a bien été actualisé", + "label_authorization_endpoint": "Point de terminaison pour l'authentification", + "label_client_id": "Identifiant client (Client ID)", + "label_client_secret": "Clé secrète client (Client Secret)", + "label_code_challenge": "Vérification par code", + "label_code_challenge_method": "Méthode de vérification par code", + "label_code_verifier": "Vérificateur de code", + "label_scopes": "Permissions", + "label_token_endpoint": "Point de terminaison pour les jetons d'authentification", + "label_use_pkce": "Utiliser PKCE", + "label_implicit": "Implicite", + "label_password": "Mot de passe", + "label_username": "Nom d'utilisateur", + "label_auth_code": "Code d'authentification", + "label_client_credentials": "Identifiants client", + "label_send_as": "Authentification client", + "label_send_in_body": "Envoyer les identifiants dans le corps de requête", + "label_send_as_basic_auth": "Envoyer les identifiants via l'en-tête d'autorisation simple (Authorization: Basic Auth)", + "enter_value": "Entrez la valeur", + "auth_request": "Requête d'authentification", + "token_request": "Requête pour les jetons d'authentification", + "refresh_request": "Requête pour les jetons d'actualisation", + "send_in": "Envoyer via" + }, + "pass_key_by": "Envoyer la clé via", + "pass_by_query_params_label": "Les paramètres de requête (URL)", + "pass_by_headers_label": "Les en-têtes de requête (Headers)", + "password": "Mot de passe", + "save_to_inherit": "Veuillez enregistrer cette requête dans une collection pour hériter de l'autorisation", + "token": "Jeton", + "access_token": "Jeton d'authentification", + "client_token": "Jeton d'authentification client", + "client_secret": "Clé secrète client", + "timestamp": "Horodatage", + "host": "Hôte", + "type": "Type d'authentification", + "username": "Nom d'utilisateur", + "advance_config": "Configuration avancée", + "advance_config_description": "Hoppscotch assigne automatiquement des valeurs par défaut pour les champs qui ne sont pas explicitement renseignés", + "algorithm": "Algorithme", + "payload": "Charge utile (Payload)", + "secret": "Clé secrète", + "aws_signature": { + "access_key": "Clé d'accès", + "secret_key": "Clé secrète", + "service_name": "Nom du service", + "aws_region": "Région AWS", + "service_token": "Jeton de service" + }, + "digest": { + "realm": "Domaine de protection (realm)", + "nonce": "Nonce", + "algorithm": "Algorithme", + "qop": "Qualité de protection (qop)", + "nonce_count": "Nombre de nonce", + "client_nonce": "Nonce client", + "opaque": "Opaque", + "disable_retry": "Désactiver les nouvelles tentatives" + }, + "akamai": { + "headers_to_sign": "En-têtes à signer", + "max_body_size": "Taille maximale du corps de requête" + }, + "hawk": { + "id": "Identifiant d'authentification HAWK (Auth ID)", + "key": "Clé d'authentification HAWK (Auth Key)", + "ext": "ext", + "app": "app", + "dlg": "dlg", + "include": "Inclure la clé de hachage de la charge utile" + }, + "jwt": { + "params_name": "Liste des paramètres", + "param_name": "Nom du paramètre", + "header_prefix": "Préfixe de l'en-tête", + "placeholder_request_header": "Demander le préfixe de l'en-tête", + "placeholder_request_param": "Demander la liste des paramètres", + "secret_base64_encoded": "Encodage Base64 pour les clés secrètes", + "headers": "En-têtes JWT", + "private_key": "Clé privée", + "placeholder_headers": "En-têtes JWT" + }, + "ntlm": { + "domain": "Domaine", + "workstation": "Station de travail", + "disable_retrying_request": "Désactiver les nouvelles tentatives" + }, + "asap": { + "issuer": "Émetteur (Issuer)", + "audience": "Audience", + "expires_in": "Expire après", + "key_id": "Identifiant de la clé (Key ID)", + "optional_config": "Configuration optionnelle", + "subject": "Référence utilisateur (Subject)", + "additional_claims": "Demandes additionnelles" + } + }, + "collection": { + "title": "Collection", + "run": "Exécuter la collection", + "created": "Collection créée", + "different_parent": "Impossible de ré-ordonner des collections avec des collections parentes différentes", + "edit": "Éditer la collection", + "import_or_create": "Importer ou créer une collection", + "import_collection": "Importer une collection", + "invalid_name": "Veuillez renseigner un nom pour la collection", + "invalid_root_move": "Collection déjà présente à la racine", + "moved": "Déplacé avec succès", + "my_collections": "Collections personnelles", + "name": "Ma nouvelle collection", + "name_length_insufficient": "Le nom de la collection doit contenir au moins 3 caractères", + "new": "Nouvelle collection", + "order_changed": "Ordre de la collection mis à jour", + "properties": "Propriétés de la collection", + "properties_updated": "Propriétés de la collection mises à jour", + "renamed": "Collection renommée", + "request_in_use": "Requête utilisée", + "save_as": "Enregistrer sous", + "save_to_collection": "Enregistrer dans la collection", + "select": "Sélectionner une collection", + "select_location": "Sélectionner l'emplacement", + "details": "Détails", + "duplicated": "Collection dupliquée" + }, + "confirm": { + "close_unsaved_tab": "Voulez-vous vraiment fermer cet onglet ?", + "close_unsaved_tabs": "Voulez-vous vraiment fermer tous les onglets ? {count} onglets possèdent des données non sauvegardées qui seront perdues.", + "delete_all_activity_logs": "Voulez-vous vraiment supprimer toutes les entrées de journalisation d'activités ?", + "exit_team": "Voulez-vous vraiment quitter cet espace de travail ?", + "logout": "Voulez-vous vraiment vous déconnecter ?", + "remove_collection": "Voulez-vous vraiment supprimer définitivement cette collection ?", + "remove_environment": "Voulez-vous vraiment supprimer définitivement cet environnement ?", + "remove_folder": "Voulez-vous vraiment supprimer définitivement ce dossier ?", + "remove_history": "Voulez-vous vraiment supprimer définitivement tout l'historique ?", + "remove_request": "Voulez-vous vraiment supprimer définitivement cette requête ?", + "remove_response": "Voulez-vous vraiment supprimer définitivement cette réponse ?", + "remove_shared_request": "Voulez-vous vraiment supprimer définitivement cette requête partagée ?", + "remove_team": "Voulez-vous vraiment supprimer cet espace de travail ?", + "remove_telemetry": "Voulez-vous vraiment vous ne plus participer au programme de collecte de données et de télémétries ?", + "request_change": "Voulez-vous vraiment annuler les modifications effectuées pour la requête actuelle ? Les changements non sauvegardés seront perdus.", + "save_unsaved_tab": "Voulez-vous enregistrer les changements effectués dans cet onglet ?", + "sync": "Voulez-vous restaurer votre espace de travail depuis le cloud ? Cela va écraser les modifications de la version locale de votre espace de travail.", + "delete_access_token": "Voulez-vous vraiment supprimer le jeton d'authentification {tokenLabel} ?" + }, + "context_menu": { + "add_parameters": "Ajouter aux paramètres", + "open_request_in_new_tab": "Ouvrir la requête dans un nouvel onglet", + "set_environment_variable": "Ajouter en tant que variable d'environnement" + }, + "cookies": { + "modal": { + "cookie_expires": "Expire", + "cookie_name": "Nom", + "cookie_path": "Emplacement", + "cookie_string": "Cookie (chaîne de caractères)", + "cookie_value": "Valeur", + "empty_domain": "Le domaine est vide", + "empty_domains": "La liste de domaines est vide", + "enter_cookie_string": "Entrez le cookie en chaîne de caractères", + "interceptor_no_support": "L'intercepteur actuellement sélectionné ne gère pas les cookies. Veuillez sélectionner un autre intercepteur et essayez à nouveau.", + "managed_tab": "Géré", + "new_domain_name": "Nouveau nom de domaine", + "no_cookies_in_domain": "Aucun cookie pour ce domaine", + "raw_tab": "Brut", + "set": "Ajouter un cookie" + } + }, + "count": { + "currentValue": "Valeur actuelle {count}", + "description": "Description {count}", + "header": "En-tête {count}", + "initialValue": "Valeur initiale {count}", + "key": "Clé {count}", + "message": "Message {count}", + "parameter": "Paramètre {count}", + "protocol": "Protocole {count}", + "value": "Valeur {count}", + "variable": "Variable {count}" + }, + "documentation": { + "generate": "Générer la documentation", + "generate_message": "Importer une collection Hoppscotch pour générer directement la documentation d'API associée." + }, + "empty": { + "activity_logs": "Aucune entrée de journalisation d'activité trouvée", + "authorization": "Cette requête n'utilise aucune forme d'authentification", + "body": "Cette requête ne possède aucun corps", + "collection": "La collection est vide", + "collections": "Les collections sont vides", + "documentation": "Se connecter à un point de terminaison GraphQL pour visualiser la documentation", + "empty_schema": "Aucune structure trouvée", + "endpoint": "Le point de terminaison ne peut pas être vide", + "environments": "Les environnements sont vides", + "folder": "Le dossier est vide", + "headers": "Cette requête ne possède aucun en-tête", + "history": "L'historique est vide", + "invites": "La liste d'invitation est vide", + "members": "L'espace de travail est vide", + "parameters": "Cette requête ne possède aucun paramètre", + "pending_invites": "Aucune invitation en attente pour cet espace de travail", + "profile": "Connectez-vous pour voir votre profil", + "protocols": "Les protocoles sont vides", + "request_variables": "Cette requête ne possède aucune variable", + "schema": "Se connecter à un point de terminaison GraphQL pour visualiser la structure", + "search_environment": "Aucun environnement trouvé pour", + "secret_environments": "Les clés secrètes ne sont pas synchronisés dans Hoppscotch", + "shared_requests": "Les requêtes partagées sont vides", + "shared_requests_logout": "Connectez-vous pour voir vos requêtes partagées ou pour en créer une nouvelle", + "subscription": "Les abonnements sont vides", + "team_name": "Le nom de l'espace de travail est vide", + "teams": "Vous n'appartenez à aucun espace de travail", + "tests": "Aucun test pour cette requête", + "access_tokens": "Les jetons d'authentification sont vides", + "response": "Aucune réponse reçue" + }, + "environment": { + "heading": "Environnement", + "add_to_global": "Ajouter au global", + "added": "Ajouté à l'environnement", + "create_new": "Créer un nouvel environnement", + "created": "Environnement", + "current_value": "Valeur actuelle", + "deleted": "Supprimé de l'environnement", + "duplicated": "Environnement dupliqué", + "edit": "Éditer l'environnement", + "empty_variables": "Aucune variable", + "global": "Global", + "global_variables": "Variables globales", + "import_or_create": "Importer ou créer un environnement", + "initial_value": "Valeur initiale", + "invalid_name": "Veuillez renseigner un nom pour l'environnement", + "list": "Variables d'environnement", + "my_environments": "Environnements personnels", + "name": "Nom", + "nested_overflow": "variables d'environnement imbriquées sont limités à 10 niveaux", + "new": "Nouvel environnement", + "no_active_environment": "Aucun environnement actif", + "no_environment": "Aucun environnement", + "no_environment_description": "Aucun environnement sélectionné. Choisissez quoi faire avec les variables suivantes.", + "quick_peek": "Aperçu rapide de l'environnement", + "replace_all_current_with_initial": "Remplacer toutes les valeurs actuelles par les valeurs initiales", + "replace_all_initial_with_current": "Remplacer toutes les valeurs initiales par les valeurs actuelles", + "replace_current_with_initial": "Remplacer par la valeur initiale", + "replace_initial_with_current": "Remplacer par la valeur actuelle", + "replace_with_variable": "Remplacer par la variable", + "scope": "Permission", + "secrets": "Clés secrètes", + "secret_value": "Valeur secrète", + "select": "Sélectionner un environnement", + "set": "Choisir un environnement", + "set_as_environment": "Choisir comme environment", + "short_name": "Le nom d'environnement doit avoir au moins 3 caractères", + "team_environments": "Environnements de l'espace de travail", + "title": "Environnements", + "updated": "Environnement mis à jour", + "value": "Valeur", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Liste des variables", + "properties": "Propriétés de l'environnement", + "details": "Détails" + }, + "error": { + "network": { + "heading": "Erreur réseau", + "description": "La connexion au réseau a échoué. {message}: {cause}" + }, + "timeout": { + "heading": "Erreur de dépassement de délai", + "description": "Le délai de la requête a été dépassé pendant l'opération {phase}. {message}" + }, + "certificate": { + "heading": "Erreur de certificat", + "description": "Certificat invalide. {message}: {cause}" + }, + "auth": { + "heading": "Erreur d'authentification", + "description": "Accès refusé. {message}: {cause}" + }, + "proxy": { + "heading": "Erreur de proxy", + "description": "La connexion au proxy a échoué. {message}: {cause}" + }, + "parse": { + "heading": "Erreur d'interprétation", + "description": "Erreur lors de l'interprétation de la réponse. {message}: {cause}" + }, + "version": { + "heading": "Erreur de version", + "description": "Versions incompatibles. {message}: {cause}" + }, + "abort": { + "heading": "Requête annulée", + "description": "Opération annulée. {message}: {cause}" + }, + "unknown": { + "heading": "Erreur inconnue", + "description": "Une erreur inconnue s'est produite.", + "cause": "Cause inconnue" + }, + "extension": { + "heading": "Erreur d'extension", + "description": "Erreur lors de l'exécution de la requête via l'extension" + }, + "authproviders_load_error": "Impossible de charger les fournisseurs de services d'authentification", + "browser_support_sse": "Ce navigateur ne semble pas supporter la fonctionnalité SSE (Server Sent Events).", + "check_console_details": "Vérifiez la journalisation console pour plus de détails.", + "check_how_to_add_origin": "Vérifiez comment vous pouvez ajouter une origine", + "curl_invalid_format": "Le format du champ cURL est invalide", + "danger_zone": "Zone de danger", + "delete_account": "Votre compte utilisateur est actuellement le seul propriétaire des espaces de travail :", + "delete_account_description": "Vous devez soit vous supprimez vous-même, transférer votre droit de propriété, ou supprimer ces espaces de travail avant de pouvoir supprimer votre compte utilisateur.", + "delete_activity_log": "Erreur lors de la suppression des journaux d'activité", + "delete_all_activity_logs": "Erreur lors de la suppression de tous les journaux d'activité", + "email_already_exists": "Cette adresse e-mail existe déjà pour un compte utilisateur différent. Veuillez renseigner une autre adresse e-mail.", + "empty_email_address": "L'adresse e-mail ne peut pas être vide", + "empty_profile_name": "Le nom du profil ne peut pas être vide", + "empty_req_name": "Nom de requête vide", + "fetch_activity_logs": "Erreur lors de la récupération des journaux d'activité", + "f12_details": "(F12 pour les détails)", + "gql_prettify_invalid_query": "Impossible de mettre en forme une requête invalide, veuillez résoudre les erreurs de syntaxe de la requête et essayez à nouveau", + "incomplete_config_urls": "Configuration des URLs imcomplète", + "incorrect_email": "E-mail incorrect", + "invalid_file_type": "Format de fichier invalide pour `{filename}`.", + "invalid_link": "Lien invalide", + "invalid_link_description": "Le lien sur lequel vous avez cliqué est invalide ou expiré.", + "invalid_embed_link": "Le lien d'intégration n'existe pas ou est invalide.", + "json_parsing_failed": "JSON invalide", + "json_prettify_invalid_body": "Impossible de mettre en forme un corps de requête invalide, veuillez résoudre les erreurs de syntaxe du JSON et essayez à nouveau", + "network_error": "Il semble y avoir une erreur réseau. Veuillez réessayer.", + "network_fail": "Impossible d'envoyer la requête", + "no_collections_to_export": "Aucune collection à exporter. Veuillez créer une collection pour commencer.", + "no_duration": "Aucune durée", + "no_environments_to_export": "Aucun environnement à exporter. Veuillez créer un environnement pour commencer.", + "no_results_found": "Aucun résultat trouvé", + "page_not_found": "Cette page n'a pas été trouvée", + "please_install_extension": "Veuillez installer l'extension et ajouter l'origine à l'extension.", + "proxy_error": "Erreur de proxy", + "same_email_address": "L'adresse e-mail mise à jour est la même que l'adresse e-mail précédemment renseignée", + "same_profile_name": "Le nom de profil mise à jour est la même que le nom de profil précédemment renseignée", + "script_fail": "Impossible d'exécuter le script de pré-requête", + "something_went_wrong": "Une erreur est survenue", + "subscription_error": "Impossible de souscrire au canal: {error}", + "post_request_script_fail": "Impossible d'exécuter le script post-requête", + "reading_files": "Erreur lors de la lecture d'un ou plusieurs fichiers.", + "fetching_access_tokens_list": "Une erreur est survenue lors de la récupération de la liste des jetons", + "generate_access_token": "Une erreur est survenue lors de la génération du jeton d'authentification", + "delete_access_token": "Une erreur est survenue lors de la suppression du jeton d'authentification", + "extension_not_found": "Extension non trouvée" + }, + "export": { + "as_json": "Exporter en JSON", + "create_secret_gist": "Créer un Gist secret", + "create_secret_gist_tooltip_text": "Exporter en Gist secret", + "failed": "Une erreur est survenue pendant l'export", + "secret_gist_success": "Export en Gist secret réussi", + "require_github": "Connectez-vous avec GitHub pour créer un Gist secret", + "title": "Exporter", + "success": "Export réussi" + }, + "filename": { + "cookie_key_value_pairs": "Cookie", + "codegen": "{request_name} - code", + "graphql_response": "GraphQL - Réponse", + "lens": "{request_name} - Réponse", + "realtime_response": "Temps réel - Réponse", + "response_interface": "Réponse - Interface" + }, + "filter": { + "all": "Tout", + "none": "Aucun", + "starred": "Favoris" + }, + "folder": { + "created": "Dossier créé", + "edit": "Éditer le dossier", + "invalid_name": "Veuillez renseigner un nom pour le dossier", + "name_length_insufficient": "Le nom du dossier doit avoir au moins 3 caractères", + "new": "Nouveau dossier", + "run": "Exécuter le dossier", + "renamed": "Dossier renommé" + }, + "graphql": { + "arguments": "Arguments", + "connection_switch_confirm": "Voulez-vous vous connecter au dernier point de terminaison GraphQL ?", + "connection_error_http": "Impossible de joindre le schéma GraphQL en raison d'une erreur de connexion réseau.", + "connection_switch_new_url": "Basculer vers un onglet va déconnecter la connexion active à GraphQL. La nouvelle URL de connexion est", + "connection_switch_url": "Vous êtes connecté à un point de terminaison GraphQL avec l'URL de connexion", + "deprecated": "Déprécié", + "fields": "Champs", + "mutation": "Mutation", + "mutations": "Mutations", + "schema": "Schéma", + "show_depricated_values": "Afficher les valeurs dépréciées", + "subscription": "Abonnement", + "subscriptions": "Abonnements", + "switch_connection": "Changer de connexion", + "url_placeholder": "Entrer l'URL d'un point de terminaison GraphQL", + "query": "Requête" + }, + "graphql_collections": { + "title": "Collections GraphQL" + }, + "group": { + "time": "Temps", + "url": "URL" + }, + "header": { + "install_pwa": "Installer l'application", + "login": "Connexion", + "save_workspace": "Enregistrer mon espace de travail" + }, + "helpers": { + "authorization": "L'en-tête Authorization sera généré automatiquement lorsque vous exécuterez la requête.", + "collection_properties_authorization": "Cette en-tête Authorization sera définie par défaut pour chaque requête de cette collection.", + "collection_properties_header": "Cette en-tête sera définie par défaut pour chaque requête de cette collection.", + "generate_documentation_first": "Générez la documentation d'abord", + "network_fail": "Impossible de joindre le point de terminaison API. Veuillez vérifier votre connexion réseau ou sélectionnez un Intercepteur différent puis réessayez.", + "offline": "Vous utilisez Hoppscotch hors-ligne. Les modifications seront synchronisés quand vous serez à nouveau en ligne suivant les paramètres de votre espace de travail.", + "offline_short": "Vous utilisez Hoppscotch hors-ligne.", + "post_request_tests": "Les scripts post-requête sont écrits en JavaScript et sont exécutés après que la réponse ait été reçue.", + "pre_request_script": "Les scripts post-requête sont écrits en JavaScript et sont exécutés avant que la requête ne soit envoyée.", + "script_fail": "Il semblerait qu'il y ait un bug dans le script de pré-requête. Veuillez vérifier l'erreur ci-dessous et corrigez le script en conséquence.", + "post_request_script_fail": "Il semblerait qu'il y ait une erreur avec le script de post-requête. Veuillez corriger les erreurs et relancez les tests à nouveau", + "post_request_script": "Écrivez un script post-requête pour automatiser le débuggage." + }, + "hide": { + "collection": "Masquer le panneau des collections", + "more": "Masquer davantage", + "preview": "Masquer l'aperçu", + "sidebar": "Masquer la barre latérale", + "password": "Masquer le mot de passe" + }, + "import": { + "collections": "Importer les collections", + "curl": "Importer un cURL", + "environments_from_gist": "Importer depuis Gist", + "environments_from_gist_description": "Importer les environnements Hoppscotch depuis Gist", + "failed": "Erreur pendant l'import : le format n'est pas reconnu", + "from_file": "Importer depuis un fichier", + "from_gist": "Importer depuis Gist", + "from_gist_description": "Inporter depuis une URL Gist", + "from_gist_import_summary": "Toutes les fonctionnalités d'Hoppscotch depuis Gist ont été importées.", + "from_hoppscotch_importer_summary": "Toutes les fonctionnalités d'Hoppscotch ont été importées.", + "from_insomnia": "Importer depuis Insomnia", + "from_insomnia_description": "Importer depuis une collection Insomnia", + "from_insomnia_import_summary": "Les collections et les requêtes vont être importées.", + "from_json": "Importer depuis Hoppscotch", + "from_json_description": "Importer depuis un fichier de collections Hoppscotch", + "from_my_collections": "Importer depuis les collections personnelles", + "from_my_collections_description": "Importer depuis un fichier de collections personnelles", + "from_all_collections": "Importer depuis un autre espace de travail", + "from_all_collections_description": "Importer une collection depuis un autre espace de travail vers l'espace de travail actuel", + "from_openapi": "Importer depuis OpenAPI", + "from_openapi_description": "Importer depuis un fichier de spécifications OpenAPI (YML/JSON)", + "from_openapi_import_summary": "Les collections (seront créées depuis les tags), requêtes et exemples de réponse seront importés.", + "from_postman": "Importer depuis Postman", + "from_postman_description": "Importer depuis une collection Postman", + "from_postman_import_summary": "Les collections, requêtes et exemples de réponse seront importés.", + "from_url": "Importer depuis une URL", + "gist_url": "Entrez l'URL Gist", + "from_har": "Importer depuis HAR", + "from_har_description": "Importer depuis un fichier HAR", + "from_har_import_summary": "Les requêtes seront importées dans une collection par défaut.", + "gql_collections_from_gist_description": "Importer les collections GraphQL depuis Gist", + "hoppscotch_environment": "Environnement Hoppscotch", + "hoppscotch_environment_description": "Importer un fichier JSON d'environnement Hoppscotch", + "import_from_url_invalid_fetch": "Impossible de récupérer les données depuis l'URL", + "import_from_url_invalid_file_format": "Erreur lors de l'importation des collections", + "import_from_url_invalid_type": "Format non géré. Les valeurs de format gérées sont 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Collections importées", + "insomnia_environment_description": "Importer un environnement Insomnia depuis un fichier JSON/YAML", + "json_description": "Importer des collections depuis un fichier JSON de collections Hoppscotch", + "postman_environment": "Environnement Postman", + "postman_environment_description": "Importer un environnement Postman depuis un fichier JSON", + "title": "Importer", + "file_size_limit_exceeded_warning_multiple_files": "Les fichiers choisis dépassent la limite recommandée de {sizeLimit}Mo. Seuls les premiers {files} sélectionnés seront importés", + "file_size_limit_exceeded_warning_single_file": "Le fichier actuellement choisi dépasse la limite recommandé de {sizeLimit}Mo. Veuillez sélectionner un autre fichier.", + "success": "Import réussi", + "import_summary_collections_title": "Collections", + "import_summary_requests_title": "Requêtes", + "import_summary_responses_title": "Réponses", + "import_summary_pre_request_scripts_title": "Scripts de pré-requête", + "import_summary_post_request_scripts_title": "Scripts de post-requête", + "import_summary_not_supported_by_hoppscotch_import": "Nous ne supportons pas l'import de {featureLabel} depuis cette source pour le moment.", + "cors_error_modal": { + "title": "Erreur de CORS détectée", + "description": "L'import a échoué à cause des restrictions du CORS (Cross-Origin Resource Sharing) imposées par le serveur.", + "explanation": "Ceci est une fonctionnalité de sécurité qui empêche les pages web d'émettre des requêtes depuis des domaines différents. Vous pouvez réessayer en utilisant notre service de proxy pour contourner cette restriction.", + "url_label": "URL tentée", + "retry_with_proxy": "Réessayer avec le proxy" + } + }, + "instances": { + "switch": "Changer d'instance Hoppscotch", + "enter_server_url": "Se connecter à une instance auto-hébergée", + "already_connected": "Vous êtes actuellement connecté(e) à cette instance", + "recent_connections": "Connexions récentes", + "add_instance": "Ajouter une instance", + "add_new": "Ajouter une nouvelle instance", + "confirm_remove": "Confirmer la suppression", + "remove_warning": "Voulez-vous vraiment supprimer cette instance ?", + "clear_cached_bundles": "Vider les paquets dans le cache" + }, + "inspections": { + "description": "Inspecter les erreurs possibles", + "environment": { + "add_environment": "Ajouter à l'environnement", + "add_environment_value": "Ajouter une valeur", + "empty_value": "La valeur d'environnement est vide pour la variable '{variable}' ", + "not_found": "La variable d'environnement '{environment}' n'a pas été trouvée." + }, + "header": { + "cookie": "Le navigateur web n'autorise pas Hoppscotch à définir les en-têtes de Cookie. Veuillez utiliser les en-têtes Authorization à la place. Cependant, notre application de bureau Hoppscotch est disponible et sait gérer les cookies." + }, + "response": { + "401_error": "Veuillez vérifier vos identifiants d'authentification.", + "404_error": "Veuillez vérifier l'URL de requête et la méthode associée.", + "cors_error": "Veuillez vérifier la configuration CORS (Cross-Origin Resource Sharing).", + "default_error": "Veuillez vérifier la requête.", + "network_error": "Veuillez vérifier votre connexion au réseau." + }, + "title": "Inspecteur", + "url": { + "extension_not_installed": "Extension non installée.", + "extension_unknown_origin": "Assurez-vous d'avoir ajouté l'origine du point de terminaison API dans la liste de l'extension web Hoppscotch.", + "extention_enable_action": "Activer l'extension web", + "extention_not_enabled": "Extension désactivée.", + "localaccess_unsupported": "L'intercepteur actuel ne gère pas l'accès local, veuillez considérer l'utilisation d'Agent, des extensions des intercepteurs ou l'application de bureau Hoppscotch" + }, + "auth": { + "digest": "L'Agent intercepteur de l'application web ou l'intercepteur natif de l'application de bureau sont recommandés pour l'utilisation de l'authentification de type Digest.", + "hawk": "L'Agent intercepteur de l'application web ou l'intercepteur natif de l'application de bureau sont recommandés pour l'utilisation de l'authentification de type Hawk." + }, + "body": { + "binary": "L'envoi de données binaires via l'intercepteur actuel n'eset pas encore géré." + } + }, + "interceptor": { + "native": { + "name": "Natif", + "settings_title": "Natif" + }, + "agent": { + "name": "Agent", + "settings_title": "Agent" + }, + "proxy": { + "name": "Proxy", + "settings_title": "Proxy" + }, + "browser": { + "name": "Navigateur web", + "settings_title": "Navigateur web" + }, + "extension": { + "name": "Extension", + "settings_title": "Extension" + } + }, + "layout": { + "collapse_collection": "Afficher ou masquer les collections", + "collapse_sidebar": "Afficher ou masquer la barre latérale", + "column": "Disposition verticale", + "name": "Disposition", + "row": "Disposition horizontale" + }, + "modal": { + "close_unsaved_tab": "Vous avez des changements non sauvegardés", + "collections": "Collections", + "confirm": "Confirmer", + "customize_request": "Personnaliser la requête", + "edit_request": "Éditer la requête", + "edit_response": "Éditer la réponse", + "import_export": "Importer / Exporter", + "response_name": "Nom de la réponse", + "share_request": "Partager la requête" + }, + "mqtt": { + "already_subscribed": "Vous avez déjà souscrit à ce topic.", + "clean_session": "Nettoyer la session", + "clear_input": "Effacer les entrées", + "clear_input_on_send": "Effacer les entrées lors de l'envoi", + "client_id": "ID Client", + "color": "Choisir une couleur", + "communication": "Communication", + "connection_config": "Configuration de la connexion", + "connection_not_authorized": "Cette connexion MQTT n'utilise aucune forme d'authentification.", + "invalid_topic": "Veuillez renseigner un topic pour la souscription", + "keep_alive": "Maintenir la connexion (Keep Alive)", + "log": "Log", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "Message", + "new": "Nouvelle souscription", + "not_connected": "Veuillez d'abord lancer une connexion MQTT.", + "publish": "Publier", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Souscrire", + "topic": "Topic", + "topic_name": "Nom du topic", + "topic_title": "Topic de publication / souscription", + "unsubscribe": "Désinscrire", + "url": "URL" + }, + "navigation": { + "admin_dashboard": "Dashboard", + "doc": "Documentation", + "graphql": "GraphQL", + "profile": "Profil", + "realtime": "Temps-réel", + "rest": "REST", + "settings": "Paramètres", + "goto_app": "Aller à l'application", + "authentication": "Authentification" + }, + "preRequest": { + "javascript_code": "Code JavaScript", + "learn": "Lire la documentation", + "script": "Script de pré-requête", + "snippets": "Snippets" + }, + "profile": { + "app_settings": "Paramètres d'application", + "default_hopp_displayname": "Utilisateur sans nom", + "editor": "Éditeur", + "editor_description": "Les éditeurs peuvent ajouter, éditer et supprimer des requêtes.", + "email_verification_mail": "Un e-mail de vérification a été envoyé à votre adresse e-mail. Veuillez cliquer sur le lien qu'il contient pour vérifier votre adresse e-mail.", + "no_permission": "Vous n'avez pas la permission pour exécuter cette action.", + "owner": "Propriétaire", + "owner_description": "Les propriétaires peuvent ajouter, éditer et supprimer les requêtes, collections et membres de l'espace de travail.", + "roles": "Rôles", + "roles_description": "Les rôles sont utilisés pour contrôler l'accès aux collections partagées.", + "updated": "Profil mis à jour", + "viewer": "Consultant", + "viewer_description": "Les consultants peuvent uniquement voir et utiliser les requêtes.", + "verified_email_sent": "Un e-mail de vérification a été envoyé à votre adresse e-mail. Veuillez rafraîchir la page après avoir vérifié votre adresse e-mail. Vous recevrez un e-mail si l'adresse e-mail utilisée n'est associée à aucun autre compte utilisateur." + }, + "remove": { + "star": "Supprimer l'étoile" + }, + "request": { + "added": "Requête ajoutée", + "add": "Ajouter une requête", + "authorization": "Authentification", + "body": "Corps de requête", + "choose_language": "Choisir une langue", + "content_type": "Type du contenu", + "content_type_titles": { + "others": "Autres", + "structured": "Structuré", + "text": "Texte", + "binary": "Binaire" + }, + "show_content_type": "Afficher le type du contenu", + "different_collection": "Impossible de ré-ordonner les requêtes de plusieurs collections", + "duplicated": "Requête dupliquée", + "duration": "Durée", + "enter_curl": "Entrer la commande cURL", + "generate_code": "Générer du code", + "generated_code": "Code généré", + "go_to_authorization_tab": "Aller à l'onglet Authentification", + "go_to_body_tab": "Aller à l'onglet Corps de requête", + "header_list": "Liste des en-têtes", + "invalid_name": "Veuillez renseigner un nom pour la requête", + "method": "Méthode", + "moved": "Requête déplacée", + "name": "Nom de la requête", + "new": "Nouvelle requête", + "order_changed": "Ordre de requête mis à jour", + "override": "Outrepasser", + "override_help": "Définir l'en-tête Content-Type", + "overriden": "Outrepassé", + "parameter_list": "Paramètres de la requête", + "parameters": "Paramètres", + "path": "Chemin (Path)", + "payload": "Charge utile (Payload)", + "query": "Requête (Query)", + "raw_body": "Corps de requête brut", + "rename": "Renommer la requête", + "renamed": "Requête renommée", + "request_variables": "Variables de la requête", + "response_name_exists": "Le nom de la réponse existe déjà", + "run": "Exécuter", + "save": "Enregistrer", + "save_as": "Enregistrer sous", + "saved": "Requête enregistrée", + "share": "Partager", + "share_description": "Partager Hoppscotch avec vos amis", + "share_request": "Partager la requête", + "stop": "Stopper", + "title": "Requête", + "type": "Type de requête", + "url": "URL", + "url_placeholder": "Entrer une URL ou coller une commande cURL", + "variables": "Variables", + "view_my_links": "Voir mes liens", + "generate_name_error": "Impossible de générer un nom de requête." + }, + "response": { + "audio": "Audio", + "body": "Corps de la réponse", + "duplicated": "Réponse dupliquée", + "duplicate_name_error": "Une réponse avec le même nom existe déjà", + "filter_response_body": "Filtrer le corps de la réponse JSON (utilise la syntaxe jq)", + "headers": "En-têtes", + "request_headers": "En-têtes de requête", + "html": "HTML", + "image": "Image", + "json": "JSON", + "pdf": "PDF", + "please_save_request": "Enregistrer la requête pour créer un exemple", + "preview_html": "Prévisualiser en HTML", + "raw": "Brut", + "renamed": "Réponse renommée", + "same_name_inspector_warning": "Le nom de réponse existe déjà pour cette requête, si vous décidez de l'enregistrer quand même alors cela écrasera la réponse existante", + "size": "Taille", + "status": "Statut", + "time": "Durée", + "title": "Réponse", + "video": "Vidéo", + "waiting_for_connection": "en attente de connexion", + "xml": "XML", + "generate_data_schema": "Générer le schéma de données", + "data_schema": "Schéma de données", + "saved": "Réponse enregistrée", + "invalid_name": "Veuillez renseigner un nom pour la réponse" + }, + "settings": { + "accent_color": "Couleur d'accentuation", + "account": "Compte utilisateur", + "account_deleted": "Votre compte utilisateur a été supprimé", + "account_description": "Personnalisez les paramètres de votre compte utilisateur.", + "account_email_description": "Votre adresse e-mail principale.", + "account_name_description": "Ceci est votre nom d'affichage.", + "additional": "Paramètres additionnels", + "agent_not_running": "Agent Hoopscotch non détecté. Veuillez vérifier que l'agent est en cours d'exécution.", + "agent_not_running_short": "Vérifiez le statut de l'agent.", + "agent_running": "L'agent Hoppscotch est en cours d'exécution.", + "agent_running_short": "L'agent Hoppscotch est en ligne.", + "agent_discard_registration": "Annuler l'enregistrement de l'agent", + "agent_registered": "Agent enregistré", + "agent_registration_successful": "Agent enregistré avec succès", + "agent_registration_fetch_failed": "Impossible de récupérer les informations d'enregistrement de l'agent. Veuillez ré-enregistrer l'agent.", + "agent_registration_already_in_progress": "L'opération d'enregistrement de l'agent est déjà en cours. Veuillez attendre la fin de l'opération ou annulez l'enregistrement en cours puis réessayez.", + "auto_encode_mode": "Auto", + "auto_encode_mode_tooltip": "Encoder les paramètres dans la requête uniquement si des caractères spéciaux sont présents", + "background": "Fond", + "black_mode": "Noir", + "choose_language": "Choisir une langue", + "dark_mode": "Sombre", + "delete_account": "Supprimer le compte", + "delete_account_description": "Une fois votre compte utilisateur supprimé, toutes les données associées seront supprimées de manière permanente. Cette action est irréversible.", + "disable_encode_mode_tooltip": "Ne jamais encoder les paramètres dans la requête", + "enable_encode_mode_tooltip": "Toujours encoder les paramètres dans la requête", + "enter_otp": "Entrer le code fourni par l'agent", + "expand_navigation": "Étendre la navigation", + "experiments": "Expérimentations", + "experiments_notice": "Ceci est une collection d'options expérimentales sur lesquelles nous travaillons et qui pourraient se révéler utiles, fun, les deux ou aucun des deux. Elles ne sont pas finies et peuvent être instables, si quelque chose de vraiment étrange survient, ne paniquez pas. Désactivez juste ce truc. Blague à part, ", + "extension_ver_not_reported": "Non signalé", + "extension_version": "Version de l'extension", + "extensions": "Extension web", + "extensions_use_toggle": "Utiliser l'extension web pour envoyer des requêtes (si présent)", + "follow": "Suivez-nous", + "general": "Général", + "general_description": "Paramètres généraux utilisés dans l'application", + "interceptor": "Intercepteur", + "interceptor_description": "Middleware entre l'application et les APIs.", + "kernel_interceptor": "Intercepteur", + "kernel_interceptor_description": "Middleware entre l'application et les APIs.", + "language": "Langue", + "light_mode": "Clair", + "official_proxy_hosting": "Proxy officiel hébergé par Hoppscotch.", + "query_parameters_encoding": "Encodage des paramètres de requête", + "query_parameters_encoding_description": "Configurer l'encodage pour les paramètres de requête", + "profile": "Profil", + "profile_description": "Mettez à jour les détails de votre profil utilisateur", + "profile_email": "Adresse e-mail", + "profile_name": "Nom", + "proxy": "Proxy", + "proxy_url": "URL du Proxy", + "proxy_use_toggle": "Utiliser le middleware du proxy pour envoyer les requêtes", + "read_the": "Lire", + "register_agent": "Enregistrer l'agent", + "reset_default": "Utiliser le proxy par défaut", + "short_codes": "Codes courts", + "short_codes_description": "Codes courts créés par vos soins.", + "sidebar_on_left": "Barre latérale sur la gauche", + "ai_experiments": "Expérimentations IA", + "ai_request_naming_style": "Demander un style de nommage", + "ai_request_naming_style_descriptive_with_spaces": "Descriptif avec espaces", + "ai_request_naming_style_camel_case": "Camel Case ( camelCase )", + "ai_request_naming_style_snake_case": "Snake Case ( snake_case )", + "ai_request_naming_style_pascal_case": "Pascal Case ( PascalCase )", + "ai_request_naming_style_custom": "Personnalisé", + "ai_request_naming_style_custom_placeholder": "Entrer votre propre style de nommage...", + "experimental_scripting_sandbox": "Bac à sable de scripting expérimental", + "sync": "Synchroniser", + "sync_collections": "Collections", + "sync_description": "Ces paramètres sont synchronisés dans le cloud.", + "sync_environments": "Environnements", + "sync_history": "Historique", + "history_disabled": "L'historique est désactivé. Veuillez contacter l'administrateur de votre organisation pour activer la fonctionnalité", + "system_mode": "Système", + "telemetry": "Télémétrie", + "telemetry_helps_us": "La télémétrie nous aide à personnaliser nos opérations et à vous proposer la meilleure expérience utilisateur.", + "theme": "Thème", + "theme_description": "Personnalisez le thème de l'application.", + "use_experimental_url_bar": "Utiliser la barre d'URL expérimentale avec la mise en évidence de l'environnement", + "user": "Utilisateur", + "verified_email": "Adresse e-mail vérifiée", + "verify_email": "Vérifier l'adresse e-mail", + "validate_certificates": "Valider les certificats SSL/TLS", + "verify_host": "Vérifier l'hôte (Host)", + "verify_peer": "Vérifier le client (Peer)", + "client_certificates": "Certificats client", + "certificate_settings": "Paramètres des certificats", + "certificate": "Certificats", + "key": "Clé privée", + "pfx_or_p12": "PFX/PKCS#12", + "password": "Mot de passe", + "select_file": "Choisir un fichier", + "domain": "Domaine", + "add_certificate": "Ajouter un certificat", + "add_cert_file": "Ajouter un fichier de certificat", + "add_key_file": "Ajouter un fichier de clé", + "add_pfx_file": "Ajouter un fichier PFX", + "global_defaults": "Paramètres par défaut globaux", + "add_domain_override": "Ajouter un domaine", + "domain_override": "Outrepassage de domaine", + "manage_domains_overrides": "Gérer les outrepassages de domaine", + "add_domain": "Ajouter domaine", + "remove_domain": "Supprimer domaine", + "ca_certificate": "Certificat de l'autorité certifiante", + "ca_certificates": "Certificats de l'autorité certifiante", + "ca_certificates_support": "Hoppscotch prend en charge les fichiers .crt, .cer ou .pem contenant un ou plusieurs certificats.", + "proxy_capabilities": "L'agent Hoppscotch prend en charge les proxy HTTP/HTTPS/SOCKS avec NLTM et l'authentification basique (Basic) pour ces proxy. Le nom d'utilisateur et le mot de passe doivent être passés en paramètre de l'URL elle-même pour l'authentification via proxy.", + "proxy_auth": "Vous pouvez aussi inclure le nom d'utilisateur et le mot de passe dans l'URL." + }, + "shared_requests": { + "button": "Bouton", + "button_info": "Créer un bouton 'Exécuter dans Hoppscotch' pour votre site web, blog ou pour un README.", + "copy_html": "Copier le code HTML", + "copy_link": "Copier le lien", + "copy_markdown": "Copier le code Markdown", + "creating_widget": "Création du widget", + "customize": "Personnaliser", + "deleted": "Requête partagée supprimée", + "description": "Choisissez un widget, vous pourrez le changer et le personnaliser plus tard", + "embed": "Encadrer", + "embed_info": "Ajouter un bouton 'API bac à sable Hoppscotch' pour votre site web, blog ou documentation.", + "link": "Lien", + "link_info": "Créer un lien internet partageable à partager avec tous ceux qui ont un accès en consultation.", + "modified": "Requête partagée modifiée", + "not_found": "Requête partagée non trouvée", + "open_new_tab": "Ouvrir dans un nouvel onglet", + "preview": "Prévisualiser", + "run_in_hoppscotch": "Exécuter dans Hoppscotch", + "theme": { + "dark": "Sombre", + "light": "Clair", + "system": "Système", + "title": "Thème" + }, + "action": "Action", + "clear_filter": "Effacer les filtres", + "confirm_request_deletion": "Confirmer la suppression de la requête partagée sélectionnée ?", + "copy": "Copier", + "created_on": "Créé le", + "delete": "Supprimer", + "email": "Adresse e-mail", + "filter": "Filtrer", + "filter_by_email": "Filtrer par adresse e-mail", + "id": "ID", + "load_list_error": "Impossible de charger la liste des requêtes partagées", + "no_requests": "Aucune requête partagée trouvée", + "open_request": "Ouvrir la requête", + "properties": "Propriétés", + "request": "Requêter", + "show_more": "Afficher plus", + "title": "Requêtes partagées", + "url": "URL" + }, + "shortcut": { + "general": { + "close_current_menu": "Fermer le menu actuel", + "command_menu": "Menu de recherche et des commandes", + "help_menu": "Menu d'aide", + "show_all": "Raccourcis clavier", + "title": "Général" + }, + "miscellaneous": { + "invite": "Inviter des personnes sur Hoppscotch", + "title": "Divers" + }, + "navigation": { + "back": "Aller à la page précédente", + "documentation": "Aller à la page documentation", + "forward": "Aller à la page suivante", + "graphql": "Aller à la page GraphQL", + "profile": "Aller à la page du profil", + "realtime": "Aller à la page temps-réel", + "rest": "Aller à la page API REST", + "settings": "Aller à la page des paramètres", + "title": "Navigation" + }, + "others": { + "prettify": "Remettre en forme le contenu de l'éditeur", + "title": "Autres" + }, + "request": { + "delete_method": "Sélectionner la méthode DELETE", + "get_method": "Sélectionner la méthode GET", + "head_method": "Sélectionner la méthode HEAD", + "import_curl": "Importer cURL", + "method": "Méthode", + "next_method": "Sélectionner la méthode suivante", + "post_method": "Sélectionner la méthode POST", + "previous_method": "Sélectionner la méthode précédente", + "put_method": "Sélectionner la méthode PUT", + "rename": "Renommer la requête", + "reset_request": "Réinitialiser la requête", + "save_request": "Enregistrer la requête", + "save_to_collections": "Enregistrer dans les collections", + "send_request": "Envoyer la requête", + "share_request": "Partager la requête", + "show_code": "Générer l'extrait de code", + "title": "Requête" + }, + "response": { + "copy": "Copier la réponse dans le presse-papiers", + "download": "Télécharger la réponse en tant que fichier", + "title": "Réponse" + }, + "tabs": { + "title": "Onglets", + "new_tab": "Nouvel onglet", + "close_tab": "Fermer l'onglet", + "reopen_tab": "Réouvrir l'onglet fermé", + "next_tab": "Onglet suivant", + "previous_tab": "Onglet précédent", + "first_tab": "Basculer vers le premier onglet", + "last_tab": "Basculer vers le dernier onglet" + }, + "theme": { + "black": "Basculer le thème en mode noir", + "dark": "Basculer le thème en mode sombre", + "light": "Basculer le thème en mode clair", + "system": "Basculer le thème pour le thème système", + "title": "Thème" + } + }, + "show": { + "code": "Afficher le code", + "collection": "Étendre le panneau des collections", + "more": "Afficher plus", + "sidebar": "Étendre la barre latérale", + "password": "Afficher le mot de passe" + }, + "socketio": { + "communication": "Communication", + "connection_not_authorized": "Cette connexion SocketIO n'utilise aucune forme d'authentification.", + "event_name": "Nom de l'événement / topic", + "events": "Événements", + "log": "Journalisation", + "url": "URL" + }, + "spotlight": { + "change_language": "Changer de langue", + "environments": { + "delete": "Supprimer l'environnement actuel", + "duplicate": "Dupliquer l'environnement actuel", + "duplicate_global": "Dupliquer l'environnement global", + "edit": "Éditer l'environnement actuel", + "edit_global": "Éditer l'environnement global", + "new": "Créer un nouvel environnement", + "new_variable": "Créer une nouvelle variable d'environnement", + "title": "Environnements" + }, + "general": { + "chat": "Discuter avec le support", + "help_menu": "Centre d'aide et support", + "open_docs": "Lire la documentation", + "open_github": "Accéder au projet GitHub", + "open_keybindings": "Raccourcis clavier", + "social": "Social", + "title": "Général" + }, + "graphql": { + "connect": "Se connecter à un serveur", + "disconnect": "Se déconnecter d'un serveur" + }, + "miscellaneous": { + "invite": "Inviter des personnes sur Hoppscotch", + "title": "Divers" + }, + "request": { + "save_as_new": "Enregistrer en tant que nouvelle requête", + "select_method": "Sélectionner la méthode", + "switch_to": "Basculer vers", + "tab_authorization": "Onglet d'authentification", + "tab_body": "Onglet du corps de requête", + "tab_headers": "Onglet des en-têtes", + "tab_parameters": "Onglet des paramètres", + "tab_pre_request_script": "Onglet des scripts de pré-requête", + "tab_query": "Onglet de requête", + "tab_tests": "Onglet des tests", + "tab_variables": "Onglet des variables" + }, + "response": { + "copy": "Copier la réponse", + "download": "Télécharger la réponse en tant que fichier", + "title": "Réponse" + }, + "section": { + "interceptor": "Intercepteur", + "interface": "Interface", + "theme": "Thème", + "user": "Utilisateur" + }, + "settings": { + "change_interceptor": "Changer l'intercepteur", + "change_language": "Changer la langue", + "theme": { + "black": "Noir", + "dark": "Sombre", + "light": "Clair", + "system": "Préférence système" + } + }, + "tab": { + "close_current": "Fermer l'onglet actuel", + "close_others": "Fermer tous les autres onglets", + "duplicate": "Dupliquer l'onglet actuel", + "new_tab": "Ouvrir un nouvel onglet", + "next": "Basculer vers l'onglet suivant", + "previous": "Basculer vers l'onglet précédent", + "switch_to_first": "Basculer vers le premier onglet", + "switch_to_last": "Basculer vers le dernier onglet", + "title": "Onglets" + }, + "workspace": { + "delete": "Supprimer l'espace de travail actuel", + "edit": "Éditer l'espace de travail actuel", + "invite": "Inviter des personnes dans l'espace de travail actuel", + "new": "Créer un nouvel espace de travail", + "switch_to_personal": "Basculer vers votre espace de travail personnel", + "title": "Espaces de travail" + }, + "phrases": { + "try": "Essayer", + "import_collections": "Importer des collections", + "create_environment": "Créer un environnement", + "create_workspace": "Créer un espace de travail", + "share_request": "Partager une requête" + } + }, + "sse": { + "event_type": "Type d'événement", + "log": "Journalisation", + "url": "URL" + }, + "state": { + "bulk_mode": "Modification multiple", + "bulk_mode_placeholder": "Les entrées sont séparées par des retours à la ligne\nLes clés et valeurs sont séparées par :\nAjouter # en début de ligne pour la commenter (et ne pas l'exécuter)", + "cleared": "Effacé", + "connected": "Connecté", + "connected_to": "Connecté à {name}", + "connecting_to": "Connexion à {name}...", + "connection_error": "Impossible de se connecter", + "connection_failed": "Connexion échouée", + "connection_lost": "Connexion perdue", + "copied_interface_to_clipboard": "Type d'interface {language} copié dans le presse-papiers", + "copied_to_clipboard": "Copié dans le presse-papiers", + "deleted": "Supprimé", + "deprecated": "DÉPRÉCIÉ", + "disabled": "Désactivé", + "disconnected": "Déconnecté", + "disconnected_from": "Déconnecté de {name}", + "docs_generated": "Documentation générée", + "download_failed": "Le téléchargement a échoué", + "download_started": "Le téléchargement a démarré", + "enabled": "Activé", + "file_imported": "Fichier importé", + "finished_in": "Terminé en {duration} ms", + "hide": "Masquer", + "history_deleted": "Historique supprimé", + "linewrap": "Passage à la ligne automatique", + "loading": "Chargement...", + "message_received": "Message entrant: {message} sur le topic: {topic}", + "mqtt_subscription_failed": "Une erreur est survenue pendant la souscription au topic : {topic}", + "none": "Aucun", + "nothing_found": "Aucun résultat pour", + "published_error": "Une erreur est survenu pendant la publication du message : {message} dans le topic : {topic}", + "published_message": "Message publié : {message} dans le topic : {topic}", + "reconnection_error": "Impossible de se reconnecter", + "saved": "Enregistré", + "show": "Afficher", + "subscribed_failed": "Impossible de souscrire au topic : {topic}", + "subscribed_success": "Souscription réussie au topic : {topic}", + "unsubscribed_failed": "Impossible de se désinscrire du topic : {topic}", + "unsubscribed_success": "Désinscription réussie du topic : {topic}", + "waiting_send_request": "En attente de l'envoi de requête", + "user_deactivated": "Votre compte utilisateur est désactivé. Veuillez contacter votre administrateur pour réactiver votre compte utilisateur.", + "add_user_failure": "Impossible d'ajouter l'utilisateur dans l'espace de travail", + "add_user_success": "L'utilisateur est désormais un membre de l'espace de travail", + "admin_failure": "Impossible de promouvoir administateur l'utilisateur", + "admin_success": "L'utilisateur est désormais promu administrateur", + "and": "et", + "clear_selection": "Effacer la sélection", + "configure_auth": "Veuillez paramétrer un fournisseur de services d'authentification depuis lese paramètres administrateur ou vérifiez la documentation pour configurer les fournisseurs de services d'authentification.", + "confirm_admin_to_user": "Voulez-vous supprimer le statut administrateur de cet utilisateur ?", + "confirm_admins_to_users": "Voulez-vous supprimer le statut administrateur des utilisateurs sélectionnés ?", + "confirm_delete_infra_token": "Voulez-vous vraiment supprimer le jeton interne {tokenLabel} ?", + "confirm_delete_invite": "Voulez-vous révoquer l'invitation sélectionnée ?", + "confirm_delete_invites": "Voulez-vous révoquer les invitations sélectionnées ?", + "confirm_user_deletion": "Confirmer la suppression de l'utilisateur ?", + "confirm_users_deletion": "Voulez-vous supprimer les utilisateurs sélectionnés ?", + "confirm_user_to_admin": "Voulez-vous promouvoir administrateur cet utilisateur ?", + "confirm_users_to_admin": "Voulez-vous promouvoir administrateur ces utilisateurs ?", + "confirm_user_deactivation": "Confirmer la désactivation de l'utilisateur ?", + "confirm_logout": "Confirmer la déconnexion", + "created_on": "Créé le", + "continue_email": "Continuer avec l'adresse e-mail", + "continue_github": "Continuer avec GitHub", + "continue_google": "Continuer avec Google", + "continue_microsoft": "Continuer avec Microsoft", + "create_team_failure": "Impossible de créer l'espace de travail", + "create_team_success": "Espace de travail créé", + "data_sharing_failure": "Impossible de mettre à jour les paramètres de partage des données", + "delete_infra_token_failure": "Une erreur est survenue pendant la suppression du jeton interne", + "delete_invite_failure": "Impossible de supprimer l'invitation", + "delete_invites_failure": "Impossible de supprimer les invitations sélectionnées", + "delete_invite_success": "Invitation supprimée", + "delete_invites_success": "Invitations sélectionnées supprimées", + "delete_request_failure": "Impossible de supprimer la requête partagée", + "delete_request_success": "Requête partagée supprimée", + "delete_team_failure": "Impossible de supprimer l'espace de travail", + "delete_team_success": "Espace de travail supprimé", + "delete_some_users_failure": "Nombre d'utilisateurs non supprimés : {count}", + "delete_some_users_success": "Nombre d'utilisateurs supprimés : {count}", + "delete_user_failed_only_one_admin": "Impossible de supprimer l'utilisateur. Il devrait y avoir au moins un administrateur", + "delete_user_failure": "Impossible de supprimer l'utilisateur", + "delete_users_failure": "Impossible de supprimer les utilisateurs sélectionnés", + "delete_user_success": "Utilisateur supprimé", + "delete_users_success": "Utilisateurs sélectionnés supprimés", + "email": "Adresse e-mail", + "email_failure": "Impossible d'envoyer une invitation", + "email_signin_failure": "Impossible de se connecter avec l'adresse e-mail", + "email_success": "Invitation par adresse e-mail envoyée", + "emails_cannot_be_same": "Vous ne pouvez pas vous inviter vous-même, veuillez choisir une adresse e-mail utilisateur différente.", + "enter_team_email": "Veuillez entrer l'adresse e-mail du propriétaire de l'espace de travail.", + "error": "Une erreur est survenue", + "error_auth_providers": "Impossible de charger les fournisseurs de services d'authentification", + "generate_infra_token_failure": "Une erreur est survenue pendant la génération d'un jeton interne", + "github_signin_failure": "Impossible de se connecter avec GitHub", + "google_signin_failure": "Impossible de se connecter avec Google", + "infra_token_label_short": "Le nom du jeton interne est trop court.", + "invalid_email": "Veuillez entrer une adresse e-mail valide", + "link_copied_to_clipboard": "Lien copié dans le presse-papiers", + "logged_out": "Déconnecté", + "login_as_admin": "et connectez-vous avec un compte administrateur.", + "login_using_email": "Veuillez demander à l'utilisateur de vérifier ses e-mails ou partagez-lui le lien ci-dessous", + "login_using_link": "Veuillez demander à l'utilisateur de se connecter avec le lien ci-dessous", + "logout": "Déconnexion", + "magic_link_sign_in": "Cliquez sur le lien pour vous connecter.", + "magic_link_success": "Nous avons envoyé un lien de connexion à", + "microsoft_signin_failure": "Impossible de se connecter avec Microsoft", + "newsletter_failure": "Impossible de mettre à jour les paramètres de newsletter", + "non_admin_logged_in": "Connecté en tant qu'utilisateur non administrateur.", + "non_admin_login": "Vous êtes connecté. Vous n'êtes pas administrateur", + "owner_not_present": "Au moins un propriétaire doit être présent dans l'équipe.", + "privacy_policy": "Politique de confidentialité", + "reenter_email": "Veuillez entrer à nouveau l'adresse e-mail", + "remove_admin_failure": "Impossible de supprimer le statut administrateur.", + "remove_admin_failure_only_one_admin": "Impossible de supprimer le statut administrateur. Il doit y avoir au moins un compte administrateur.", + "remove_admin_success": "Statut administrateur supprimé.", + "remove_admin_from_users_failure": "Impossible de supprimer le statut administrateur pour les utilisateurs sélectionnés.", + "remove_admin_from_users_success": "Statut administrateur supprimé pour les utilisateurs sélectionnés.", + "remove_admin_to_delete_user": "Supprimez les privilèges administrateurs pour supprimer l'utilisateur.", + "remove_owner_to_delete_user": "Supprimez le statut de propriétaire de l'équipe pour supprimer l'utilisateur.", + "remove_owner_failure_only_one_owner": "Impossible de supprimer le membre. Il doit y avoir au moins un propriétaire dans une équipe.", + "remove_admin_for_deletion": "Supprimez le statut administrateur avant d'essayer de supprimer quelque chose.", + "remove_owner_for_deletion": "Un ou plusieurs utilisateurs sont propriétaires d'équipes. Veuillez mettre à jour les propriétaires d'équipes avant la suppression.", + "remove_invitee_failure": "Impossible de supprimer les invités.", + "remove_invitee_success": "Invité supprimé.", + "remove_member_failure": "Impossible de supprimer ce membre.", + "remove_member_success": "Membre supprimé.", + "rename_team_failure": "Impossible de renommer l'espace de travail.", + "rename_team_success": "Espace de travail renommé.", + "rename_user_failure": "Impossible de renommer l'utilisateur.", + "rename_user_success": "Utilisateur renommé.", + "require_auth_provider": "Vous avez besoin d'au moins un fournisseur de services d'authentification pour vous connecter.", + "role_update_failed": "Impossible de mettre à jour les rôles.", + "role_update_success": "Rôles mis à jour.", + "selected": "{count} sélectionnés", + "self_host_docs": "Documentation auto-hébergée", + "send_magic_link": "Envoyer un lien de connexion", + "setup_failure": "Impossible de paramétrer.", + "setup_success": "Paramétrage effectué.", + "sign_in_agreement": "En vous connectant, vous acceptez nos", + "sign_in_options": "Tous les options de connexion", + "sign_out": "Déconnexion", + "something_went_wrong": "Une erreur est survenue", + "team_name_too_short": "Le nom de l'espace de travail doit contenir au moins 6 caractères.", + "user_already_invited": "Impossible d'envoyer une invitation. L'utilisateur a déjà été invité.", + "user_not_found": "Utilisateur non trouvé dans le système.", + "users_to_admin_success": "Les utilisateurs sélectionnés ont été promus administrateur.", + "users_to_admin_failure": "Impossible de promouvoir administrateur les utilisateurs sélectionnés.", + "loading_workspaces": "Chargement des espaces de travail", + "loading_collections_in_workspace": "Chargement des collections de l'espace de travail" + }, + "support": { + "changelog": "En savoir plus sur les dernières mises à jour", + "chat": "Des questions ? Discutez avec nous !", + "community": "Posez vos questions et aidez les autres", + "documentation": "En savoir plus sur Hoppscotch", + "forum": "Posez vos questions et obtenez des réponses", + "github": "Suivez-nous sur GitHub", + "shortcuts": "Parcourir l'application plus rapidement", + "title": "Support", + "twitter": "Suivez-nous sur Twitter" + }, + "tab": { + "authorization": "Authentification", + "body": "Corps de requête", + "close": "Fermer l'onglet", + "close_others": "Fermer les autres onglets", + "collections": "Collections", + "documentation": "Documentation", + "duplicate": "Dupliquer l'onglet", + "environments": "Environnements", + "headers": "En-têtes", + "history": "Historique", + "mqtt": "MQTT", + "parameters": "Paramètres", + "post_request_script": "Scripts post-requête", + "pre_request_script": "Scripts de pré-requête", + "queries": "Requêtes", + "query": "Requête", + "schema": "Schéma", + "shared_requests": "Requêtes partagées", + "codegen": "Générer du code", + "code_snippet": "Extraits de code", + "share_tab_request": "Partage de requête", + "socketio": "Socket.IO", + "sse": "SSE", + "types": "Types", + "variables": "Variables", + "websocket": "WebSocket", + "all_tests": "Tests", + "passed": "Réussi", + "failed": "Échoué" + }, + "team": { + "activity_logs": "Journalisation d'activités", + "already_member": "Cette adresse e-mail est associée à un utilisateur existant.", + "create_new": "Créer un nouvel espace de travaail", + "deleted": "Espace de travail supprimé", + "delete_all_activity_logs": "Supprimer tous les journaux d'activité", + "successfully_deleted_all_activity_logs": "Journaux d'activités supprimés", + "delete_activity_log": "Supprimer le journal d'activités", + "deleted_activity_log": "Journaux d'activité sélectionnés supprimés", + "deleted_all_activity_logs": "Journaux d'activités supprimés", + "edit": "Éditer l'espace de travail", + "email": "Adresse e-mail", + "email_do_not_match": "L'adresse e-mail ne correspond pas avec les détails de votre compte utilisateur. Veuillez contacter le propriétaire de l'espace de travail.", + "exit": "Quitter l'espace de travail", + "exit_disabled": "Seul le propriétaire ne peut pas quitter l'espace de travail", + "failed_invites": "Invitations échouées", + "invalid_coll_id": "ID de collection invalide", + "invalid_email_format": "Format de l'adresse e-mail invalide", + "invalid_id": "ID de l'espace de travail invalide. Contactez le propriétaire de l'espace de travail.", + "invalid_invite_link": "Lien d'invitation invalide", + "invalid_invite_link_description": "Le lien que vous avez renseigné est invalide. Contactez le propriétaire de l'espace de travail.", + "invalid_member_permission": "Veuillez renseigner une permission valide au membre de l'espace de travail", + "invite": "Inviter", + "invite_more": "Inviter plus", + "invite_tooltip": "Inviter des personnes dans cet espace de travail", + "invited_to_team": "{owner} vous a invité à rejoindre {workspace}", + "join": "Invitation acceptée", + "join_team": "Rejoindre {workspace}", + "joined_team": "Vous avez rejoint {workspace}", + "joined_team_description": "Vous êtes désormais un membre de cet espace de travail", + "left": "Vous avez quitté l'espace de travail", + "login_to_continue": "Connectez-vous pour continuer", + "login_to_continue_description": "Vous devez être connecté pour rejoindre un espace de travail.", + "logout_and_try_again": "Se déconnecter et se connecter avec un autre compte utilisateur", + "member_has_invite": "L'utilisateur a déjà reçu une invitation. Veuillez lui demander de vérifier leurs e-mails ou révoquez puis renvoyez-lui une invitation.", + "member_not_found": "Membre non trouvé. Contactez le propriétaire de l'espace de travail.", + "member_removed": "Utilisateur supprimé", + "member_role_updated": "Rôles utilisateur mis à jour", + "members": "Membres", + "more_members": "+{count} de plus", + "name_length_insufficient": "Le nom de l'espace de travail doit contenir au moins 6 caractères", + "name_updated": "Nom de l'espace de travail mis à jour", + "new": "Nouvel espace de travail", + "new_created": "Nouvel espace de travail créé", + "new_name": "Mon nouvel espace de travail", + "no_access": "Vous n'avez pas éditer les accès à cet espace de travail", + "no_invite_found": "Invitation non trouvée. Contactez le propriétaire de l'espace de travail.", + "no_request_found": "Requête non trouvée.", + "not_found": "L'espace de travail non trouvé. Contactez le propriétaire de l'espace de travail.", + "not_valid_viewer": "Vous n'avez pas le rôle de consultation correct. Contactez le propriétaire de l'espace de travail.", + "parent_coll_move": "Impossible de déplacer la collection vers une collection enfant", + "pending_invites": "Invitations en attente", + "permissions": "Permissions", + "same_target_destination": "Même cible et destination", + "saved": "Espace de travail enregistré", + "select_a_team": "Choisir un espace de travail", + "success_invites": "Invitations validées", + "title": "Espaces de travail", + "we_sent_invite_link": "Les invitations sont en route", + "invite_sent_smtp_disabled": "Liens d'invitation générés", + "we_sent_invite_link_description": "Les nouveaux invités vont recevoir un lien pour rejoindre l'espace de travail, les membres actuels et les invitations en attente ne recevront pas de nouveaux liens.", + "invite_sent_smtp_disabled_description": "L'envoi d'invitations par e-mail est désactivé pour cette instance d'Hoppscotch. Veuillez utiliser le bouton dédié pour copier le lien d'invitation et le partager manuellement.", + "copy_invite_link": "Copier le lien d'invitation", + "search_title": "Requêtes d'équipe", + "user_not_found": "Utilisateur non trouvé dans l'instance.", + "invite_members": "Inviter des membres" + }, + "team_environment": { + "deleted": "Environnement supprimé", + "duplicate": "Environnement dupliqué", + "not_found": "Environnement non trouvé." + }, + "test": { + "requests": "Requêtes", + "selection": "Sélection", + "failed": "test échoué", + "javascript_code": "Code JavaScript", + "learn": "Lire la documentation", + "passed": "test réussi", + "report": "Rapport des tests", + "results": "Résultat des tests", + "script": "Script", + "snippets": "Extraits de code", + "run": "Exécuter", + "run_again": "Exécuter à nouveau", + "stop": "Stopper", + "new_run": "Nouvelle exécution", + "iterations": "Itérations", + "duration": "Durée", + "avg_resp": "Temps de réponse moyen" + }, + "websocket": { + "communication": "Communication", + "log": "Journalisation", + "message": "Message", + "protocols": "Protocoles", + "url": "URL" + }, + "workspace": { + "change": "Changer d'espace de travail", + "personal": "Espace de travail personnel", + "other_workspaces": "Mes espaces de travail", + "team": "Espace de travail", + "title": "Espaces de travail" + }, + "site_protection": { + "login_to_continue": "Connectez-vous pour continuer", + "login_to_continue_description": "Vous devez être connecté pour accéder à cette instance d'Hoppscotch Entreprise.", + "error_fetching_site_protection_status": "Une erreur est survenue pendant la récupération du statut de protection du site" + }, + "access_tokens": { + "tab_title": "Jetons", + "section_title": "Jetons d'authentification personnels", + "section_description": "Les jetons d'authentification personnels permettent une connexion depuis un terminal ou une console (CLI) vers votre compte utilisateur Hoppscotch", + "last_used_on": "Utilisé la dernière fois le", + "expires_on": "Expire le", + "no_expiration": "Aucune expiration", + "expired": "Expiré", + "copy_token_warning": "Faites en sorte de copier votre jeton d'authentification personnel dès maintenant. Vous ne serez plus en capacité de le revoir à nouveau.", + "token_purpose": "Quelle utilisation de ce jeton est faite ?", + "expiration_label": "Expiration", + "scope_label": "Permissions", + "workspace_read_only_access": "Accès en lecture seule aux données de l'espace de travail.", + "personal_workspace_access_limitation": "Les jetons d'authentification personnel ne peuvent pas accéder à votre espace de travail personnel.", + "generate_token": "Générer un jeton", + "invalid_label": "Veuillez renseigner un nom pour le jeton", + "no_expiration_verbose": "Ce jeton ne va jamais expirer !", + "token_expires_on": "Ce jeton va expirer le", + "generate_new_token": "Générer un nouveau jeton", + "generate_modal_title": "Nouveau jeton d'authentification personnel", + "deletion_success": "Le jeton d'authentification {label} a bien été supprimé" + }, + "collection_runner": { + "collection_id": "ID de collection", + "environment_id": "ID d'environnement", + "cli_collection_id_description": "Cet ID de collection sera utilisé pour l'exécution de collection via le terminal (CLI) d'Hoppscotch.", + "cli_environment_id_description": "Cet ID d'environnement sera utilisé pour l'exécution de collection via le terminal (CLI) d'Hoppscotch", + "include_active_environment": "Inclure l'environnement actif :", + "cli": "Console (CLI)", + "cli_comming_soon_for_personal_collection": "L'exécution des collections personnelles via le terminal (CLI) sera bientôt disponible.", + "delay": "Délai", + "negative_delay": "Le délai ne peut pas être négatif", + "ui": "Exécuter", + "running_collection": "Exécution de la collection", + "run_config": "Exécuter la configuration", + "advanced_settings": "Paramètres avancés", + "stop_on_error": "Stopper l'exécution lors d'une erreur", + "persist_responses": "Conserver les réponses", + "keep_variable_values": "Conserver les valeurs des variables", + "collection_not_found": "Collection non trouvée. Elle a sûrement été déplacée ou supprimée.", + "empty_collection": "La collection est vide. Ajoutez des requêtes pour l'exécuter.", + "no_response_persist": "L'exécution de collection est actuellement configuré pour ne pas garder les réponses. Ce paramètre empêche l'affichage des données de réponses. Pour changer ce comportement, initiez une nouvelle exécution de configuration.", + "select_request": "Sélectionnez une requête pour voir la réponse et les résultats des tests", + "response_body_lost_rerun": "Le corps de réponse est perdu. Exécutez à nouveau la collection pour récupérer le corps de réponse.", + "cli_command_generation_description_cloud": "Copiez la commande ci-dessous et lancez-la depuis un terminal (CLI). Veuillez spécifier le jeton d'authentification personnel.", + "cli_command_generation_description_sh": "Copiez la commande ci-dessous et lancez-la depuis un terminal (CLI). Veuillez spécifier le jeton d'authentification personnel et vérifiez l'URL de l'instance de serveur SH générée.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copiez la commande ci-dessous et lancez-la depuis un terminal (CLI). Veuillez spécifier le jeton d'authentification personnel et l'URL de l'instance de serveur SH.", + "run_collection": "Exécuter la collection", + "no_passed_tests": "Aucun test réussi", + "no_failed_tests": "Aucun test échoué" + }, + "ai_experiments": { + "generate_request_name": "Générer un nom de requête avec l'IA", + "generate_or_modify_request_body": "Générer ou modifier un corps de requête", + "modify_with_ai": "Modifier avec l'IA", + "generate": "Générer", + "generate_or_modify_request_body_input_placeholder": "Entrer le prompt pour modifier le corps de requête", + "accept_change": "Accepter les changements", + "feedback_success": "Les retours d'expérience ont été soumis", + "feedback_failure": "Erreur lors de l'envoi des retours d'expérience", + "feedback_thank_you": "Merci pour votre retour d'expérience !", + "feedback_cta_text_long": "Notez la génération, cela nous aide à améliorer votre expérience", + "feedback_cta_request_name": "Avez-vous aimé le nom généré ?", + "modify_request_body_error": "Erreur lors de la modification du corps de requête", + "generate_or_modify_prerequest_input_placeholder": "Entrer un prompt pour générer ou modifier le script de pré-requête", + "generate_or_modify_post_request_script_input_placeholder": "Entrer un prompt pour générer ou modifier le script post-requête", + "modify_post_request_script_error": "Erreur lors de la modification du script post-requête", + "modify_prerequest_error": "Erreur lors de la modification du script de pré-requête" + }, + "configs": { + "auth_providers": { + "callback_url": "URL de retour (Callback)", + "client_id": "ID Client", + "client_secret": "Clé secrète client", + "description": "Configurer les fournisseurs de services d'authentification pour votre serveur", + "provider_not_specified": "Veuillez activer au moins un fournisseur de services d'authentification", + "scope": "Permissions (Scope)", + "tenant": "Propriétaire (Tenant)", + "title": "Fournisseurs de services d'authentification", + "update_failure": "Impossible de mettre à jour les configurations des fournisseurs de services d'authentification." + }, + "confirm_changes": "Le serveur Hoppscotch doit redémarrer pour appliquer les nouveaux changements. Voulez-vous appliquer les modifications apportées à la configuration du serveur ?", + "input_empty": "Veuillez renseigner l'ensemble des champs avant de mettre à jour les configurations", + "data_sharing": { + "title": "Partage des données", + "description": "Aidez-nous à améliorer Hoppscotch en partageant des données anonymisées", + "enable": "Activer le partage des données", + "secondary_title": "Configuration du partage des données", + "see_shared": "Voir ce qui est collecté", + "toggle_description": "Partager des données anonymisées", + "update_failure": "Erreur lors de la mise à jour de la configuration sur le partage de données." + }, + "load_error": "Impossible de charger les configurations serveur", + "mail_configs": { + "address_from": "Adresse e-mail de l'expéditeur", + "custom_smtp_configs": "Utiliser des configurations SMTP personnalisées", + "description": "Configurer le SMTP", + "enable_email_auth": "Activer l'authentification par e-mail", + "enable_smtp": "Activer le SMTP", + "host": "Adresse de l'hôte (Mail)", + "password": "Mot de passe (Mail)", + "port": "Port (Mail)", + "secure": "Mode sécurisé (Mail)", + "smtp_url": "URL du service SMTP (Mail)", + "tls_reject_unauthorized": "Forcer l'authentification via TLS", + "title": "Configurations SMTP", + "toggle_failure": "Impossible de changer l'activation SMTP.", + "update_failure": "Impossible de mettre à jour les configurations SMTP.", + "user": "Nom d'utilisateur (Mail)" + }, + "reset": { + "confirm_reset": "Le serveur Hoppscotch doit redémarrer afin d'appliquer les nouveaux changements. Voulez-vous appliquer la réinitialisation des configurations serveur ?", + "description": "Les configurations par défaut seront chargées comme spécifiées dans le fichier d'environnement", + "failure": "Impossible de réinitialiser les configurations.", + "title": "Réinitialiser les configurations", + "info": "Réinitialiser les configurations serveur" + }, + "restart": { + "description": "{duration} secondes restantes avant que la page ne se recharge automatiquement", + "initiate": "Initialisation du redémarrage du serveur...", + "title": "Le serveur est en cours de redémarrage" + }, + "save_changes": "Enregistrer les modifications", + "title": "Configurations", + "update_failure": "Impossible de mettre à jour les configurations serveur", + "restrict_access": "Restreindre les accès", + "site_protection": { + "control_access": "Contrôlez qui a accès à l'application Hoppscotch", + "description": "Personnalisez les moyens par lesquels les visiteurs accèdent à l'application Hoppscotch en utilisant les paramètres de protection de site.", + "enable": "Activer la protection de site", + "note": "Les utilisateurs visitant l'application Hoppscotch pourront voir la page de connexion, une authentification sera requise pour accéder à l'application. Une approbation par un administrateur sera toujours requise pour valider l'authentification", + "update_failure": "Impossible de mettre à jour les configurations de protection de site." + }, + "domain_whitelisting": { + "add_domain": "Ajouter un nouveau domaine", + "description": "Les utilisateurs avec une adresse e-mail enregistrée dans les domaines en liste blanche ne requièrent aucune approbation explicite par un administrateur pour accéder à l'application Hoppscotch", + "enable": "Activer la liste blanche des domaines", + "enter_domain": "Entrer un domaine", + "title": "Domaines en liste blanche", + "toggle_failure": "Impossible d'activer/désactiver les domaines en liste blanche.", + "update_failure": "Impossible de mettre à jour les configurations de domaines en liste blanche." + }, + "oidc_configs": { + "auth_url": "URL d'authentification (Auth)", + "callback_url": "Url de retour (Callback)", + "client_id": "ID Client", + "client_secret": "Clé secrète client", + "description": "Configurer l'OIDC", + "enable": "Activer l'authentification via OIDC", + "issuer": "Émetteur", + "provider_name": "Nom du fournisseur", + "scope": "Permissions (Scope)", + "title": "Configurations OIDC", + "token_url": "URL de jeton (Token)", + "update_failure": "Impossible de mettre à jour les configurations OIDC.", + "user_info_url": "URL des informations utilisateur" + }, + "saml": { + "audience": "Audience", + "callback_url": "Url de retour (Callback)", + "certificate": "Certificats", + "description": "Configurer SAML", + "enable": "Activer l'authentification via SAML", + "entry_point": "Point d'entrée", + "issuer": "Émetteur", + "title": "Configurations SAML", + "update_failure": "Impossible de mettre à jour les configurations SAML SSO", + "want_assertions_signed": "Signer les assertions", + "want_response_signed": "Signer la réponse" + } + }, + "data_sharing": { + "description": "Partager des données d'utilisation anonymisées nous permet d'améliorer Hoppscotch", + "enable": "Activer le partage des données", + "see_shared": "Voir ce qui est collecté", + "toggle_description": "Partager les données pour améliorer Hoppscotch", + "title": "Améliorer Hoppscotch", + "welcome": "Bienvenue dans" + }, + "infra_tokens": { + "copy_token_warning": "Faites en sorte de copier votre jeton interne dès maintenant. Vous ne serez plus en capacité de le revoir à nouveau.", + "deletion_success": "Le jeton interne {label} a bien été supprimé", + "empty": "Aucun jeton interne", + "expired": "Expiré", + "expiration_label": "Expiration", + "expires_on": "Expire le", + "generate_modal_title": "Nouveau jeton interne", + "generate_new_token": "Générer un nouveau jeton", + "generate_token": "Générer le jeton", + "invalid_label": "Veuillez renseigner un nom pour le jeton", + "last_used_on": "Dernière utilisation le", + "no_expiration": "Aucune expiration", + "no_expiration_verbose": "Ce jeton ne va jamais expirer !", + "section_description": "Gérez vos utilisateurs Hoppscotch via les APIs grâce aux jetons internes", + "section_title": "Jetons internes", + "tab_title": "Jetons internes", + "token_expires_on": "Ce jeton va expirer le", + "token_purpose": "Entrez un nom pour identifier ce jeton" + }, + "metrics": { + "dashboard": "Dashboard", + "no_metrics": "Aucune métrique trouvée", + "total_collections": "Nombre total de collections", + "total_requests": "Nombre total de requêtes", + "total_teams": "Nombre total d'espaces de travail", + "total_users": "Nombre total d'utilisateurs" + }, + "newsletter": { + "description": "Restez informés sur nos dernières actualités", + "subscribe": "S'abonner", + "title": "Rester en contact", + "toggle_description": "Soyez informés des dernières mises à jour sur Hoppscotch", + "unsubscribe": "Se désabonner" + }, + "teams": { + "add_member": "Ajouter un membre", + "add_members": "Ajouter des membres", + "add_new": "Ajouter un nouveau", + "admin": "Admin", + "admin_Email": "Adresse e-mail admin", + "admin_id": "ID Admin", + "cancel": "Annuler", + "confirm_team_deletion": "Voulez-vous confirmer la suppression de cet espace de travail ?", + "copy": "Copier", + "create_team": "Créer un espace de travail", + "date": "Date", + "delete_team": "Supprimer l'espace de travail", + "details": "Détails", + "edit": "Éditer", + "editor": "ÉDITEUR", + "editor_description": "Les éditeurs peuvent ajouter, éditer et supprimer des requêtes et des collections.", + "email": "Adresse e-mail du propriétaire de l'espace de travail", + "email_address": "Adresse e-mail", + "email_title": "E-mail", + "empty_name": "Le nom de l'espace de travail ne peut pas être vide.", + "error": "Une erreur est survenue. Veuillez réessayer plus tard.", + "id": "ID de l'espace de travail", + "invited_email": "E-mails d'invitation", + "invited_on": "Invité le", + "invites": "Invitations", + "load_info_error": "Impossible de charger les informations de l'espace de travail", + "load_list_error": "Impossible de charger la liste des espaces de travail", + "members": "Nombre de membres", + "no_invite": "Aucune invitation", + "no_invite_description": "Invitez votre équipe à collaborer", + "owner": "PROPRIÉTAIRE", + "owner_description": "Les propriétaires peuvent ajouter, éditer et supprimer des requêtes, des collections et des membres de l'espace de travail.", + "permissions": "Permissions", + "name": "Nom de l'espace de travail", + "no_members": "Aucun membre dans cet espace de travail. Ajoutez des membres dans cet espace de travail pour collaborer", + "no_pending_invites": "Aucune invitation en attente", + "no_teams": "Aucun espace de travail trouvé...", + "no_teams_description": "Créez un espace de travail pour collaborer avec votre équipe", + "pending_invites": "Invitations en attente", + "roles": "Rôles", + "roles_description": "Les rôles sont utilisés pour contrôler l'accès aux collections partagées.", + "remove": "Supprimer", + "rename": "Renommer", + "save": "Enregistrer", + "save_changes": "Enregistrer les modifications", + "send_invite": "Envoyer une invitation", + "show_more": "Afficher plus", + "team_details": "Détails de l'espace de travail", + "team_members": "Membres", + "team_members_tab": "Membres de l'espace de travail", + "teams": "Espace de travail", + "uid": "UID", + "unnamed": "(Espace de travail sans nom)", + "viewer": "CONSULTANT", + "viewer_description": "Les consultants peuvent uniquement voir et utiliser les requêtes", + "valid_name": "Veuillez entrer un nom valide pour l'espace de travail", + "valid_owner_email": "Veuillez entrer une adresse e-mail valide pour l'adresse e-mail du propriétaire" + }, + "users": { + "add_user": "Ajouter un utilisateur", + "admin": "Admin", + "admin_id": "ID Admin", + "cancel": "Annuler", + "created_on": "Créé le", + "copy_invite_link": "Copier le lien d'invitation", + "copy_link": "Copier le lien", + "date": "Date", + "delete": "Supprimer", + "delete_user": "Supprimer l'utilisateur", + "delete_users": "Supprimer les utilisateurs", + "details": "Détails", + "edit": "Éditer", + "email": "E-mail", + "email_address": "Adresse e-mail", + "empty_name": "Le nom ne peut pas être vide.", + "id": "ID Utilisateur", + "invalid_user": "Utilisateur invalide", + "invite_load_list_error": "Impossible de charger la liste des utilisateurs invités", + "invite_user": "Inviter un utilisateur", + "invited_by": "Invité par", + "invited_on": "Invité le", + "invited_users": "Utilisateurs invités", + "invitee_email": "E-mail de l'utilisateur invité", + "last_active_on": "Dernière activité le", + "load_info_error": "Impossible de charger les informations de l'utilisateur", + "load_list_error": "Impossible de charger la liste des utilisateurs", + "make_admin": "Promouvoir administrateur", + "name": "Nom", + "new_user_added": "Nouvel utilisateur ajouté", + "no_invite": "Aucune invitation en attente trouvée", + "no_invite_description": "Aucune invitation en attente ! Invitez vos collaborateurs sur Hoppscotch", + "no_shared_requests": "Aucune requête partagée créée par l'utilisateur", + "no_users": "Aucun utilisateur trouvé", + "not_available": "Indisponible", + "not_found": "Utilisateur non trouvé", + "pending_invites": "Invitations en attente", + "remove_admin_privilege": "Supprimer le privilège administrateur", + "remove_admin_status": "Supprimer le statut administrateur", + "rename": "Renommer", + "revoke_invitation": "Révoquer l'invitation", + "searchbar_placeholder": "Rechercher par nom ou par e-mail...", + "send_invite": "Envoyer une invitation", + "show_more": "Afficher plus", + "uid": "UID", + "unnamed": "(Utilisateur sans nom)", + "user_not_found": "Utilisateur non trouvé dans le système.", + "users": "Utilisateurs", + "valid_email": "Veuillez renseigner une adresse e-mail valide", + "deactivate": "Désactiver", + "deactivate_user": "Désactiver l'utilisateur" + }, + "organization": { + "login_to_continue_description": "Vous devez être connecté pour rejoindre une organisation.", + "create_an_organization": "Créer une organisation", + "deactivate_user_failure": "Erreur lors de la désactivation de l'utilisateur.", + "deactivate_user_success": "Utilisateur désactivé avec succès.", + "delete_account_description": "Cette action va supprimer toutes les données associées à votre compte Hoppscotch, incluant vos données dans cette organisation et dans celles dont vous faites partie.", + "delete_account": "Supprimer le compte Hoppscotch", + "user_deletion_failed_sole_admin": "L'utilisateur est l'unique administrateur d'une ou plusieurs organisations. Veuillez rétrograder l'utilisateur avant de retenter sa suppression.", + "user_deletion_failed_sole_team_owner": "L'utilisateur est l'unique administrateur d'une ou plusieurs organisations. Veuillez transférer le droit de propriété ou supprimez les organisations associées à l'utilisateur avant de retenter sa suppression." + }, + "billing": { + "confirm": { + "update_seat_count": "Voulez-vous vraiment mettre à jour le nombre de licences à {newSeatCount}?", + "update_billing_cycle": "Voulez-vous vraiment mettre à jour la périodicité de facturation à {newBillingCycle}?" + }, + "cancel_subscription": "Annuler l'abonnement" + }, + "app_console": { + "entries": "Entrées console", + "no_entries": "Aucune entrée" + } +} diff --git a/packages/hoppscotch-common/locales/he.json b/packages/hoppscotch-common/locales/he.json new file mode 100644 index 0000000..7b71d80 --- /dev/null +++ b/packages/hoppscotch-common/locales/he.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Add", + "autoscroll": "Autoscroll", + "cancel": "לְבַטֵל", + "choose_file": "בחר קובץ", + "clear": "ברור", + "clear_all": "נקה הכל", + "clear_history": "Clear all History", + "close": "Close", + "connect": "לְחַבֵּר", + "connecting": "Connecting", + "copy": "עותק", + "create": "Create", + "delete": "לִמְחוֹק", + "disconnect": "לְנַתֵק", + "dismiss": "לשחרר", + "dont_save": "Don't save", + "download_file": "הורד קובץ", + "drag_to_reorder": "Drag to reorder", + "duplicate": "Duplicate", + "edit": "לַעֲרוֹך", + "filter": "Filter", + "go_back": "תחזור", + "go_forward": "Go forward", + "group_by": "Group by", + "hide_secret": "Hide secret", + "label": "תווית", + "learn_more": "למד עוד", + "download_here": "Download here", + "less": "Less", + "more": "יותר", + "new": "חָדָשׁ", + "no": "לא", + "open_workspace": "Open workspace", + "paste": "Paste", + "prettify": "לְיַפּוֹת", + "properties": "Properties", + "remove": "לְהַסִיר", + "rename": "Rename", + "restore": "לשחזר", + "save": "להציל", + "scroll_to_bottom": "Scroll to bottom", + "scroll_to_top": "Scroll to top", + "search": "לחפש", + "send": "לִשְׁלוֹחַ", + "share": "Share", + "show_secret": "Show secret", + "start": "הַתחָלָה", + "starting": "Starting", + "stop": "תפסיק", + "to_close": "to close", + "to_navigate": "to navigate", + "to_select": "to select", + "turn_off": "לכבות", + "turn_on": "להדליק", + "undo": "לבטל", + "yes": "כן" + }, + "add": { + "new": "הוסף חדש", + "star": "הוסף כוכב" + }, + "app": { + "chat_with_us": "שוחח עימנו", + "contact_us": "צור קשר", + "cookies": "Cookies", + "copy": "עותק", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Copy User Auth Token", + "developer_option": "Developer options", + "developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.", + "discord": "Discord", + "documentation": "תיעוד", + "github": "GitHub", + "help": "עזרה, משוב ו תיעוד", + "home": "בית", + "invite": "להזמין", + "invite_description": "ב- Hoppscotch עיצבנו ממשק פשוט ואינטואיטיבי ליצירה ולניהול ממשקי ה- API שלך. Hoppscotch הוא כלי שעוזר לך לבנות, לבדוק, לתעד ולשתף את ה- API שלך.", + "invite_your_friends": "הזמן את חבריך", + "join_discord_community": "הצטרף לקהילת הדיסקורד שלנו", + "keyboard_shortcuts": "קיצורי דרך במקלדת", + "name": "הופסקוץ '", + "new_version_found": "נמצאה גרסה חדשה. רענן לעדכון.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Options", + "proxy_privacy_policy": "מדיניות הפרטיות של פרוקסי", + "reload": "לִטעוֹן מִחָדָשׁ", + "search": "לחפש", + "share": "לַחֲלוֹק", + "shortcuts": "קיצורי דרך", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "זַרקוֹר", + "status": "סטָטוּס", + "status_description": "Check the status of the website", + "terms_and_privacy": "תנאים ופרטיות", + "twitter": "Twitter", + "type_a_command_search": "הקלד פקודה או חפש ...", + "we_use_cookies": "אנו משתמשים בעוגיות", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "מה חדש?", + "see_whats_new": "See what’s new", + "wiki": "ויקי" + }, + "auth": { + "account_exists": "החשבון קיים עם אישורים שונים - התחבר לקישור שני החשבונות", + "all_sign_in_options": "כל אפשרויות הכניסה", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "המשך עם מייל", + "continue_with_github": "המשך עם GitHub", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "המשך עם גוגל", + "continue_with_microsoft": "Continue with Microsoft", + "email": "אימייל", + "logged_out": "התנתק", + "login": "התחברות", + "login_success": "התחבר בהצלחה", + "login_to_hoppscotch": "התחבר ל- Hoppscotch", + "logout": "להתנתק", + "re_enter_email": "הזן מחדש את הדוא\"ל", + "send_magic_link": "שלח קישור קסם", + "sync": "סינכרון", + "we_sent_magic_link": "שלחנו לך קישור קסם!", + "we_sent_magic_link_description": "בדוק את תיבת הדואר הנכנס שלך - שלחנו הודעת דוא\"ל אל {email}. הוא מכיל קישור קסם שיכניס אותך למערכת." + }, + "authorization": { + "generate_token": "צור אסימון", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "כלול בכתובת האתר", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "ללמוד איך", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "Pass by", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "סיסמה", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "אֲסִימוֹן", + "type": "סוג הרשאה", + "username": "שם משתמש" + }, + "collection": { + "created": "אוסף נוצר", + "different_parent": "Cannot reorder collection with different parent", + "edit": "ערוך אוסף", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "אנא ספק שם תקף לאוסף", + "invalid_root_move": "Collection already in the root", + "moved": "Moved Successfully", + "my_collections": "האוספים שלי", + "name": "האוסף החדש שלי", + "name_length_insufficient": "Collection name should be at least 3 characters long", + "new": "קולקציה חדשה", + "order_changed": "Collection Order Updated", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "שם האוסף שונה", + "request_in_use": "Request in use", + "save_as": "שמור כ", + "save_to_collection": "Save to Collection", + "select": "בחר אוסף", + "select_location": "תבחר מיקום", + "details": "Details", + "select_team": "בחר צוות", + "team_collections": "אוספי צוות" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "exit_team": "Are you sure you want to leave this team?", + "logout": "האם אתה בטוח שאתה רוצה להתנתק?", + "remove_collection": "האם אתה בטוח שברצונך למחוק את האוסף הזה לצמיתות?", + "remove_environment": "האם אתה בטוח שברצונך למחוק סביבה זו לצמיתות?", + "remove_folder": "האם אתה בטוח שברצונך למחוק תיקיה זו לצמיתות?", + "remove_history": "האם אתה בטוח שברצונך למחוק לצמיתות את כל ההיסטוריה?", + "remove_request": "האם אתה בטוח שברצונך למחוק בקשה זו לצמיתות?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "האם אתה בטוח שברצונך למחוק את הצוות הזה?", + "remove_telemetry": "האם אתה בטוח שברצונך לבטל את הסכמתך לטלמטריה?", + "request_change": "Are you sure you want to discard current request, unsaved changes will be lost.", + "save_unsaved_tab": "Do you want to save changes made in this tab?", + "sync": "האם אתה בטוח שברצונך לסנכרן את סביבת העבודה הזו?", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "כותרת {count}", + "message": "הודעה {count}", + "parameter": "פרמטר {count}", + "protocol": "פרוטוקול {count}", + "value": "ערך {count}", + "variable": "משתנה {count}" + }, + "documentation": { + "generate": "צור תיעוד", + "generate_message": "ייבא כל אוסף Hoppscotch ליצירת תיעוד API תוך כדי תנועה." + }, + "empty": { + "authorization": "בקשה זו אינה משתמשת באישור כלשהו", + "body": "לבקשה זו אין גוף", + "collection": "האוסף ריק", + "collections": "האוספים ריקים", + "documentation": "Connect to a GraphQL endpoint to view documentation", + "endpoint": "Endpoint cannot be empty", + "environments": "הסביבה ריקה", + "folder": "התיקייה ריקה", + "headers": "לבקשה זו אין כותרות", + "history": "ההיסטוריה ריקה", + "invites": "Invite list is empty", + "members": "הקבוצה ריקה", + "parameters": "לבקשה זו אין פרמטרים", + "pending_invites": "There are no pending invites for this team", + "profile": "Login to view your profile", + "protocols": "הפרוטוקולים ריקים", + "request_variables": "This request does not have any request variables", + "schema": "התחבר לנקודת קצה של GraphQL", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "Subscriptions are empty", + "team_name": "שם הקבוצה ריק", + "teams": "הקבוצות ריקות", + "tests": "אין בדיקות לבקשה זו", + "access_tokens": "Access tokens are empty", + "shortcodes": "Shortcodes are empty" + }, + "environment": { + "add_to_global": "Add to Global", + "added": "Environment addition", + "create_new": "צור סביבה חדשה", + "created": "Environment created", + "deleted": "Environment deletion", + "duplicated": "Environment duplicated", + "edit": "ערוך את הסביבה", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create a environment", + "invalid_name": "אנא ספק שם חוקי לסביבה", + "list": "Environment variables", + "my_environments": "My Environments", + "name": "Name", + "nested_overflow": "nested environment variables are limited to 10 levels", + "new": "סביבה חדשה", + "no_active_environment": "No active environment", + "no_environment": "אין סביבה", + "no_environment_description": "No environments were selected. Choose what to do with the following variables.", + "quick_peek": "Environment Quick Peek", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "בחר סביבה", + "set": "Set environment", + "set_as_environment": "Set as environment", + "team_environments": "Team Environments", + "title": "סביבות", + "updated": "Environment updation", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "רשימת משתנים", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "נראה שלדפדפן זה אין תמיכה באירועי שרת נשלח.", + "check_console_details": "בדוק את יומן המסוף לפרטים.", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "cURL אינו בפורמט תקין", + "danger_zone": "Danger zone", + "delete_account": "Your account is currently an owner in these teams:", + "delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "שם הבקשה ריק", + "f12_details": "(F12 לפרטים)", + "gql_prettify_invalid_query": "לא ניתן היה לייפות שאילתה לא חוקית, לפתור שגיאות תחביר שאילתות ולנסות שוב", + "incomplete_config_urls": "Incomplete configuration URLs", + "incorrect_email": "Incorrect email", + "invalid_link": "Invalid link", + "invalid_link_description": "The link you clicked is invalid or expired.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "Invalid JSON", + "json_prettify_invalid_body": "לא ניתן היה לייפות גוף לא חוקי, לפתור שגיאות תחביר של json ולנסות שוב", + "network_error": "There seems to be a network error. Please try again.", + "network_fail": "לא ניתן היה לשלוח בקשה", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "אין משך זמן", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "No matches found", + "page_not_found": "This page could not be found", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "לא ניתן להפעיל סקריפט של בקשה מראש", + "something_went_wrong": "משהו השתבש", + "post_request_script_fail": "Could not execute post-request script", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "ייצא כ- JSON", + "create_secret_gist": "צור גיסט סודי", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "התחבר עם GitHub כדי ליצור תמצית סודית", + "title": "Export", + "success": "Successfully exported", + "gist_created": "גיסט נוצר" + }, + "filter": { + "all": "All", + "none": "None", + "starred": "Starred" + }, + "folder": { + "created": "תיקייה נוצרה", + "edit": "ערוך תיקייה", + "invalid_name": "אנא ספק שם לתיקייה", + "name_length_insufficient": "Folder name should be at least 3 characters long", + "new": "תיקייה חדשה", + "renamed": "שם התיקייה שונה" + }, + "graphql": { + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "mutations": "מוטציות", + "schema": "סכֵימָה", + "subscriptions": "מנויים", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "Time", + "url": "URL" + }, + "header": { + "install_pwa": "התקן אפליקציה", + "login": "התחברות", + "save_workspace": "שמור את סביבת העבודה שלי" + }, + "helpers": { + "authorization": "כותרת ההרשאה תיווצר אוטומטית בעת שליחת הבקשה.", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "צור קודם כל תיעוד", + "network_fail": "לא ניתן להגיע לנקודת הסיום של ה- API. בדוק את חיבור הרשת שלך ונסה שוב.", + "offline": "נראה שאתה מחובר לאינטרנט. יתכן שהנתונים בסביבת עבודה זו אינם מעודכנים.", + "offline_short": "נראה שאתה מחובר לאינטרנט.", + "post_request_tests": "סקריפטים לבדיקה נכתבים ב- JavaScript ומופעלים לאחר קבלת התגובה.", + "pre_request_script": "סקריפטים לבקשה מראש נכתבים ב- JavaScript ומופעלים לפני שליחת הבקשה.", + "script_fail": "נראה שיש תקלה בסקריפט שלפני הבקשה. בדוק את השגיאה למטה ותקן את הסקריפט בהתאם.", + "post_request_script_fail": "There seems to be an error with post-request script. Please fix the errors and run tests again", + "post_request_script": "כתוב סקריפט בדיקה כדי לבצע ניפוי באגים באופן אוטומטי." + }, + "hide": { + "collection": "Collapse Collection Panel", + "more": "הסתר עוד", + "preview": "הסתר תצוגה מקדימה", + "sidebar": "הסתר את סרגל הצד" + }, + "import": { + "collections": "ייבוא אוספים", + "curl": "ייבא cURL", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "הייבוא נכשל", + "from_file": "Import from File", + "from_gist": "ייבוא מ- Gist", + "from_gist_description": "Import from Gist URL", + "from_insomnia": "Import from Insomnia", + "from_insomnia_description": "Import from Insomnia collection", + "from_json": "Import from Hoppscotch", + "from_json_description": "Import from Hoppscotch collection file", + "from_my_collections": "ייבוא מהאוספים שלי", + "from_my_collections_description": "Import from My Collections file", + "from_openapi": "Import from OpenAPI", + "from_openapi_description": "Import from OpenAPI specification file (YML/JSON)", + "from_postman": "Import from Postman", + "from_postman_description": "Import from Postman collection", + "from_url": "Import from URL", + "gist_url": "הזן Gist URL", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "Couldn't get data from the url", + "import_from_url_invalid_file_format": "Error while importing collections", + "import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Collections Imported", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Import collections from a Hoppscotch Collections JSON file", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "יְבוּא", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled." + } + }, + "layout": { + "collapse_collection": "Collapse or Expand Collections", + "collapse_sidebar": "Collapse or Expand the sidebar", + "column": "Vertical layout", + "name": "Layout", + "row": "Horizontal layout" + }, + "modal": { + "close_unsaved_tab": "You have unsaved changes", + "collections": "אוספים", + "confirm": "לְאַשֵׁר", + "customize_request": "Customize Request", + "edit_request": "ערוך בקשה", + "import_export": "יבוא ויצוא", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "You are already subscribed to this topic.", + "clean_session": "Clean Session", + "clear_input": "Clear input", + "clear_input_on_send": "Clear input on send", + "client_id": "Client ID", + "color": "Pick a color", + "communication": "תִקשׁוֹרֶת", + "connection_config": "Connection Config", + "connection_not_authorized": "This MQTT connection does not use any authentication.", + "invalid_topic": "Please provide a topic for the subscription", + "keep_alive": "Keep Alive", + "log": "עֵץ", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "הוֹדָעָה", + "new": "New Subscription", + "not_connected": "Please start a MQTT connection first.", + "publish": "לְפַרְסֵם", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "הירשם", + "topic": "נוֹשֵׂא", + "topic_name": "שם הנושא", + "topic_title": "פרסם / הירשם נושא", + "unsubscribe": "בטל את המנוי", + "url": "כתובת URL" + }, + "navigation": { + "doc": "מסמכים", + "graphql": "GraphQL", + "profile": "Profile", + "realtime": "זמן אמת", + "rest": "REST", + "settings": "הגדרות" + }, + "preRequest": { + "javascript_code": "קוד JavaScript", + "learn": "קרא תיעוד", + "script": "סקריפט של בקשה מראש", + "snippets": "קטעים" + }, + "profile": { + "app_settings": "App Settings", + "default_hopp_displayname": "Unnamed User", + "editor": "Editor", + "editor_description": "Editors can add, edit, and delete requests.", + "email_verification_mail": "A verification email has been sent to your email address. Please click on the link to verify your email address.", + "no_permission": "You do not have permission to perform this action.", + "owner": "Owner", + "owner_description": "Owners can add, edit, and delete requests, collections and team members.", + "roles": "Roles", + "roles_description": "Roles are used to control access to the shared collections.", + "updated": "Profile updated", + "viewer": "Viewer", + "viewer_description": "Viewers can only view and use requests." + }, + "remove": { + "star": "הסר כוכב" + }, + "request": { + "added": "הבקשה נוספה", + "authorization": "הרשאה", + "body": "גוף הבקשה", + "choose_language": "בחר שפה", + "content_type": "סוג תוכן", + "content_type_titles": { + "others": "Others", + "structured": "Structured", + "text": "Text" + }, + "different_collection": "Cannot reorder requests from different collections", + "duplicated": "Request duplicated", + "duration": "מֶשֶׁך", + "enter_curl": "הזן cURL", + "generate_code": "צור קוד", + "generated_code": "קוד שנוצר", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "רשימת כותרות", + "invalid_name": "אנא ספק שם לבקשה", + "method": "שיטה", + "moved": "Request moved", + "name": "שם הבקשה", + "new": "New Request", + "order_changed": "Request Order Updated", + "override": "Override", + "override_help": "Set Content-Type in Headers", + "overriden": "Overridden", + "parameter_list": "פרמטרי שאילתה", + "parameters": "פרמטרים", + "path": "נָתִיב", + "payload": "מטען", + "query": "שאילתא", + "raw_body": "גוף בקשה גולמית", + "rename": "Rename Request", + "renamed": "שם הבקשה שונה", + "request_variables": "Request variables", + "run": "לָרוּץ", + "save": "להציל", + "save_as": "שמור כ", + "saved": "הבקשה נשמרה", + "share": "לַחֲלוֹק", + "share_description": "Share Hoppscotch with your friends", + "share_request": "Share Request", + "stop": "Stop", + "title": "בַּקָשָׁה", + "type": "סוג בקשה", + "url": "כתובת URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "משתנים", + "view_my_links": "View my links", + "copy_link": "העתק קישור" + }, + "response": { + "audio": "Audio", + "body": "גוף תגובה", + "filter_response_body": "Filter JSON response body (uses jq syntax)", + "headers": "כותרות", + "html": "HTML", + "image": "תמונה", + "json": "JSON", + "pdf": "PDF", + "preview_html": "תצוגה מקדימה של HTML", + "raw": "גלם", + "size": "גודל", + "status": "סטָטוּס", + "time": "זְמַן", + "title": "תְגוּבָה", + "video": "Video", + "waiting_for_connection": "מחכה לחיבור", + "xml": "XML" + }, + "settings": { + "accent_color": "צבע הדגשה", + "account": "חֶשְׁבּוֹן", + "account_deleted": "Your account has been deleted", + "account_description": "התאם אישית את הגדרות החשבון שלך.", + "account_email_description": "כתובת הדוא\"ל הראשית שלך.", + "account_name_description": "זהו שם התצוגה שלך.", + "additional": "Additional Settings", + "background": "רקע כללי", + "black_mode": "שָׁחוֹר", + "choose_language": "בחר שפה", + "dark_mode": "אפל", + "delete_account": "Delete account", + "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", + "expand_navigation": "Expand navigation", + "experiments": "ניסויים", + "experiments_notice": "זהו אוסף של ניסויים שעליהם אנו עובדים שעשויים להיות שימושיים, מהנים, שניהם או לא. הם לא סופיים ואולי לא יציבים, אז אם קורה משהו מוזר מדי, אל תיבהל. פשוט כבה את העניין המטומטם. בדיחות בצד,", + "extension_ver_not_reported": "לא דווח", + "extension_version": "גרסת הרחבה", + "extensions": "הרחבות", + "extensions_use_toggle": "השתמש בתוסף הדפדפן כדי לשלוח בקשות (אם קיימות)", + "follow": "Follow Us", + "interceptor": "מיירט", + "interceptor_description": "תוכנת ביניים בין יישום לממשקי API.", + "language": "שפה", + "light_mode": "אוֹר", + "official_proxy_hosting": "פרוקסי הרשמי מתארח אצל הופסקוטש.", + "profile": "Profile", + "profile_description": "Update your profile details", + "profile_email": "Email address", + "profile_name": "Profile name", + "proxy": "פרוקסי", + "proxy_url": "כתובת URL של פרוקסי", + "proxy_use_toggle": "השתמש בתוכנת ה- proxy באמצע כדי לשלוח בקשות", + "read_the": "קרא את ה", + "reset_default": "אפס לברירת המחדל", + "short_codes": "Short codes", + "short_codes_description": "Short codes which were created by you.", + "sidebar_on_left": "Sidebar on left", + "sync": "לְסַנכְרֵן", + "sync_collections": "אוספים", + "sync_description": "הגדרות אלה מסונכרנות עם ענן.", + "sync_environments": "סביבות", + "sync_history": "הִיסטוֹרִיָה", + "system_mode": "מערכת", + "telemetry": "טלמטריה", + "telemetry_helps_us": "טלמטריה עוזרת לנו להתאים אישית את הפעולות שלנו ולספק לך את החוויה הטובה ביותר.", + "theme": "נושא", + "theme_description": "התאם אישית את נושא היישום שלך.", + "use_experimental_url_bar": "השתמש בשורת כתובת URL ניסיונית עם הדגשת סביבה", + "user": "מִשׁתַמֵשׁ", + "verified_email": "Verified email", + "verify_email": "Verify email" + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "סגור את התפריט הנוכחי", + "command_menu": "תפריט חיפוש ופקודה", + "help_menu": "תפריט עזרה", + "show_all": "קיצורי דרך במקלדת", + "title": "כללי" + }, + "miscellaneous": { + "invite": "הזמן אנשים להופסקוץ '", + "title": "שונות" + }, + "navigation": { + "back": "חזור לדף הקודם", + "documentation": "עבור לדף תיעוד", + "forward": "המשך לדף הבא", + "graphql": "עבור לדף GraphQL", + "profile": "Go to Profile page", + "realtime": "עבור לדף בזמן אמת", + "rest": "עבור לדף REST", + "settings": "עבור אל דף ההגדרות", + "title": "ניווט" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "בחר שיטת DELETE", + "get_method": "בחר שיטת GET", + "head_method": "בחר שיטת HEAD", + "import_curl": "Import cURL", + "method": "שיטה", + "next_method": "בחר בשיטה הבאה", + "post_method": "בחר שיטת POST", + "previous_method": "בחר שיטה קודמת", + "put_method": "בחר שיטת PUT", + "rename": "Rename Request", + "reset_request": "איפוס הבקשה", + "save_request": "Save Request", + "save_to_collections": "שמור באוספים", + "send_request": "שלח בקשה", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "בַּקָשָׁה", + "copy_request_link": "העתק קישור לבקשה" + }, + "response": { + "copy": "Copy response to clipboard", + "download": "Download response as file", + "title": "Response" + }, + "theme": { + "black": "Switch theme to black mode", + "dark": "Switch theme to dark mode", + "light": "Switch theme to light mode", + "system": "Switch theme to system mode", + "title": "Theme" + } + }, + "show": { + "code": "הצג קוד", + "collection": "Expand Collection Panel", + "more": "להראות יותר", + "sidebar": "הצג סרגל צד" + }, + "socketio": { + "communication": "תִקשׁוֹרֶת", + "connection_not_authorized": "This SocketIO connection does not use any authentication.", + "event_name": "שם אירוע", + "events": "אירועים", + "log": "עֵץ", + "url": "כתובת URL" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current team", + "edit": "Edit current team", + "invite": "Invite people to team", + "new": "Create new team", + "switch_to_personal": "Switch to your personal workspace", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "סוג אירוע", + "log": "עֵץ", + "url": "כתובת URL" + }, + "state": { + "bulk_mode": "עריכה בכמות גדולה", + "bulk_mode_placeholder": "הערכים מופרדים באמצעות שורה חדשה\nמפתחות וערכים מופרדים על ידי:\nהזינו # לכל שורה שתרצו להוסיף אך תשמרו", + "cleared": "מְבוּעָר", + "connected": "מְחוּבָּר", + "connected_to": "מחובר אל {name}", + "connecting_to": "מתחבר אל {name} ...", + "connection_error": "Failed to connect", + "connection_failed": "Connection failed", + "connection_lost": "Connection lost", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "הועתק ללוח", + "deleted": "נמחק", + "deprecated": "מופחת", + "disabled": "נָכֶה", + "disconnected": "מְנוּתָק", + "disconnected_from": "נותק מ- {name}", + "docs_generated": "נוצר תיעוד", + "download_failed": "Download failed", + "download_started": "ההורדה החלה", + "enabled": "מופעל", + "file_imported": "הקובץ מיובא", + "finished_in": "הסתיים תוך {duration} אלפיות השנייה", + "hide": "Hide", + "history_deleted": "ההיסטוריה נמחקה", + "linewrap": "עוטפים קווים", + "loading": "טעינה...", + "message_received": "Message: {message} arrived on topic: {topic}", + "mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}", + "none": "אף אחד", + "nothing_found": "שום דבר לא נמצא עבור", + "published_error": "Something went wrong while publishing msg: {topic} to topic: {message}", + "published_message": "Published message: {message} to topic: {topic}", + "reconnection_error": "Failed to reconnect", + "show": "Show", + "subscribed_failed": "Failed to subscribe to topic: {topic}", + "subscribed_success": "Successfully subscribed to topic: {topic}", + "unsubscribed_failed": "Failed to unsubscribe from topic: {topic}", + "unsubscribed_success": "Successfully unsubscribed from topic: {topic}", + "waiting_send_request": "מחכה לשלוח בקשה" + }, + "support": { + "changelog": "קרא עוד על המהדורות האחרונות", + "chat": "שאלות? שוחח עימנו!", + "community": "שאל שאלות ועזור לאחרים", + "documentation": "קרא עוד על הופסקוטש", + "forum": "שאל שאלות וקבל תשובות", + "github": "Follow us on Github", + "shortcuts": "עיון באפליקציה מהר יותר", + "title": "תמיכה", + "twitter": "עקבו אחרינו בטוויטר", + "team": "צרו קשר עם הצוות" + }, + "tab": { + "authorization": "הרשאה", + "body": "גוּף", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "אוספים", + "documentation": "תיעוד", + "duplicate": "Duplicate Tab", + "environments": "Environments", + "headers": "כותרות", + "history": "הִיסטוֹרִיָה", + "mqtt": "MQTT", + "parameters": "פרמטרים", + "pre_request_script": "סקריפט לבקשה מראש", + "queries": "שאילתות", + "query": "שאילתא", + "schema": "Schema", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "בדיקות", + "types": "סוגים", + "variables": "משתנים", + "websocket": "WebSocket" + }, + "team": { + "already_member": "You are already a member of this team. Contact your team owner.", + "create_new": "צור צוות חדש", + "deleted": "הקבוצה נמחקה", + "edit": "ערוך צוות", + "email": "אימייל", + "email_do_not_match": "Email doesn't match with your account details. Contact your team owner.", + "exit": "יציאה מצוות", + "exit_disabled": "רק הבעלים אינו יכול לצאת מהצוות", + "failed_invites": "Failed invites", + "invalid_coll_id": "Invalid collection ID", + "invalid_email_format": "פורמט הדוא\"ל אינו חוקי", + "invalid_id": "Invalid team ID. Contact your team owner.", + "invalid_invite_link": "Invalid invite link", + "invalid_invite_link_description": "The link you followed is invalid. Contact your team owner.", + "invalid_member_permission": "אנא ספק אישור תקף לחבר הצוות", + "invite": "Invite", + "invite_more": "Invite more", + "invite_tooltip": "Invite people to this workspace", + "invited_to_team": "{owner} invited you to join {team}", + "join": "Invitation accepted", + "join_team": "Join {team}", + "joined_team": "You have joined {team}", + "joined_team_description": "You are now a member of this team", + "left": "עזבת את הקבוצה", + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to join a team.", + "logout_and_try_again": "Logout and sign in with another account", + "member_has_invite": "This email ID already has an invite. Contact your team owner.", + "member_not_found": "Member not found. Contact your team owner.", + "member_removed": "המשתמש הוסר", + "member_role_updated": "תפקידי משתמשים עודכנו", + "members": "חברים", + "more_members": "+{count} more", + "name_length_insufficient": "שם הקבוצה צריך לכלול לפחות 6 תווים", + "name_updated": "Team name updated", + "new": "קבוצה חדשה", + "new_created": "נוצר צוות חדש", + "new_name": "הצוות החדש שלי", + "no_access": "אין לך גישת עריכה לאוספים אלה", + "no_invite_found": "Invitation not found. Contact your team owner.", + "no_request_found": "Request not found.", + "not_found": "Team not found. Contact your team owner.", + "not_valid_viewer": "You are not a valid viewer. Contact your team owner.", + "parent_coll_move": "Cannot move collection to a child collection", + "pending_invites": "Pending invites", + "permissions": "הרשאות", + "same_target_destination": "Same target and destination", + "saved": "הקבוצה ניצלה", + "select_a_team": "Select a team", + "success_invites": "Success invites", + "title": "צוותים", + "we_sent_invite_link": "We sent an invite link to all invitees!", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "Ask all invitees to check their inbox. Click on the link to join the team.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "הצטרף לתוכנית הביטא לגישה לצוותים." + }, + "team_environment": { + "deleted": "Environment Deleted", + "duplicate": "Environment Duplicated", + "not_found": "Environment not found." + }, + "test": { + "failed": "test failed", + "javascript_code": "קוד JavaScript", + "learn": "קרא תיעוד", + "passed": "test passed", + "report": "דוח בדיקה", + "results": "תוצאות מבחן", + "script": "תַסרִיט", + "snippets": "קטעים" + }, + "websocket": { + "communication": "תִקשׁוֹרֶת", + "log": "עֵץ", + "message": "הוֹדָעָה", + "protocols": "פרוטוקולים", + "url": "כתובת URL" + }, + "workspace": { + "change": "Change workspace", + "personal": "My Workspace", + "other_workspaces": "My Workspaces", + "team": "Team Workspace", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "Actions", + "created_on": "Created on", + "deleted": "Shortcode deleted", + "method": "Method", + "not_found": "Shortcode not found", + "short_code": "Short code", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/hu.json b/packages/hoppscotch-common/locales/hu.json new file mode 100644 index 0000000..c814f4e --- /dev/null +++ b/packages/hoppscotch-common/locales/hu.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Hozzáadás", + "autoscroll": "Automatikus görgetés", + "cancel": "Mégse", + "choose_file": "Válasszon egy fájlt", + "clear": "Törlés", + "clear_all": "Összes törlése", + "clear_history": "Összes előzmény törlése", + "close": "Bezárás", + "connect": "Kapcsolódás", + "connecting": "Kapcsolódás", + "copy": "Másolás", + "create": "Létrehozás", + "delete": "Törlés", + "disconnect": "Leválasztás", + "dismiss": "Eltüntetés", + "dont_save": "Ne mentse", + "download_file": "Fájl letöltése", + "drag_to_reorder": "Húzza az átrendezéshez", + "duplicate": "Kettőzés", + "edit": "Szerkesztés", + "filter": "Szűrő", + "go_back": "Vissza", + "go_forward": "Előre", + "group_by": "Csoportosítás", + "hide_secret": "Hide secret", + "label": "Címke", + "learn_more": "Tudjon meg többet", + "download_here": "Download here", + "less": "Kevesebb", + "more": "Több", + "new": "Új", + "no": "Nem", + "open_workspace": "Munkaterület megnyitása", + "paste": "Beillesztés", + "prettify": "Csinosítás", + "properties": "Tulajdonságok", + "remove": "Eltávolítás", + "rename": "Átnevezés", + "restore": "Visszaállítás", + "save": "Mentés", + "scroll_to_bottom": "Görgetés az aljára", + "scroll_to_top": "Görgetés a tetejére", + "search": "Keresés", + "send": "Küldés", + "share": "Megosztás", + "show_secret": "Show secret", + "start": "Indítás", + "starting": "Indítás", + "stop": "Leállítás", + "to_close": "a bezáráshoz", + "to_navigate": "a navigáláshoz", + "to_select": "a kiválasztáshoz", + "turn_off": "Kikapcsolás", + "turn_on": "Bekapcsolás", + "undo": "Visszavonás", + "yes": "Igen" + }, + "add": { + "new": "Új hozzáadása", + "star": "Csillag hozzáadása" + }, + "app": { + "chat_with_us": "Csevegjen velünk", + "contact_us": "Lépjen kapcsolatba velünk", + "cookies": "Sütik", + "copy": "Másolás", + "copy_interface_type": "Interface típusának másolása", + "copy_user_id": "Felhasználó-hitelesítési token másolása", + "developer_option": "Fejlesztői beállítások", + "developer_option_description": "Fejlesztői eszközök, amelyek segítenek a Hoppscotch fejlesztésében és karbantartásában.", + "discord": "Discord", + "documentation": "Dokumentáció", + "github": "GitHub", + "help": "Súgó és visszajelzés", + "home": "Kezdőlap", + "invite": "Meghívás", + "invite_description": "A Hoppscotch egy nyílt forráskódú API-fejlesztési ökoszisztéma. Egyszerű és intuitív felületet terveztünk az API-k létrehozásához és kezeléséhez. A Hoppscotch egy olyan eszköz, amely segít az API-k összeállításában, tesztelésében, dokumentálásában és megosztásában.", + "invite_your_friends": "Hívja meg az ismerőseit", + "join_discord_community": "Csatlakozzon a Discord közösségünkhöz", + "keyboard_shortcuts": "Gyorsbillentyűk", + "name": "Hoppscotch", + "new_version_found": "Új verzió található. Töltse újra az oldalt a frissítéshez.", + "open_in_hoppscotch": "Megnyitás Hoppscotch-ban.", + "options": "Beállítások", + "proxy_privacy_policy": "Proxy adatvédelmi irányelvei", + "reload": "Újratöltés", + "search": "Keresés", + "share": "Megosztás", + "shortcuts": "Gyorsbillentyűk", + "social_description": "Kövess minket a közösségi médiában, hogy ne maradj le a hírekről, frissítésekről és új kiadásokról.", + "social_links": "Közösségi média linkek", + "spotlight": "Reflektorfény", + "status": "Állapot", + "status_description": "A weboldal állapotának ellenőrzése", + "terms_and_privacy": "Feltételek és adatvédelem", + "twitter": "Twitter", + "type_a_command_search": "Írjon be parancsot vagy keresést…", + "we_use_cookies": "Sütiket használunk", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "Mik az újdonságok?", + "see_whats_new": "See what’s new", + "wiki": "Wiki" + }, + "auth": { + "account_exists": "A fiók különböző hitelesítési adatokkal létezik – jelentkezzen be a két fiók összekapcsolásához", + "all_sign_in_options": "Összes bejelentkezési lehetőség", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "Folytatás e-mail-címmel", + "continue_with_github": "Folytatás GitHub használatával", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Folytatás Google használatával", + "continue_with_microsoft": "Folytatás Microsoft használatával", + "email": "E-mail", + "logged_out": "Kijelentkezett", + "login": "Bejelentkezés", + "login_success": "Sikeresen bejelentkezett", + "login_to_hoppscotch": "Bejelentkezés a Hoppscotch szolgáltatásba", + "logout": "Kijelentkezés", + "re_enter_email": "E-mail-cím ismételt megadása", + "send_magic_link": "Mágikus hivatkozás küldése", + "sync": "Szinkronizálás", + "we_sent_magic_link": "Elküldtünk Önnek egy mágikus hivatkozást.", + "we_sent_magic_link_description": "Nézze meg a beérkező leveleit – elküldtünk egy e-mailt a(z) {email} címre. A levél tartalmaz egy mágikus hivatkozást, amellyel be tud jelentkezni." + }, + "authorization": { + "generate_token": "Token előállítása", + "graphql_headers": "Azonosító fejléc connection_init tartalmaként elküldve", + "include_in_url": "Felvétel az URL-be", + "inherited_from": "Örökölt a(z) {auth}-tól, a(z) {collection} gyűjteményből ", + "learn": "Tudja meg, hogyan", + "oauth": { + "redirect_auth_server_returned_error": "Az Auth szerver hibás állapottal tért vissza", + "redirect_auth_token_request_failed": "Kérés az auth token lekéréséhez sikertelen", + "redirect_auth_token_request_invalid_response": "Érvénytelen válasz a Token Endpoint-tól, az auth token lekérésekpr", + "redirect_invalid_state": "Érvénytelen állapotérték az átirányításban", + "redirect_no_auth_code": "Nincs azonosítás az átirányításban", + "redirect_no_client_id": "Nincs felhasználó azonosító", + "redirect_no_client_secret": "Nincs felhasználó jelszó", + "redirect_no_code_verifier": "Nincs kódellenőrző", + "redirect_no_token_endpoint": "Nincs \"Token Endpoint\"", + "something_went_wrong_on_oauth_redirect": "Valami rosszul sikerült az OAuth átirányításakor", + "something_went_wrong_on_token_generation": "Valami rosszul sikerült a token generálásakor", + "token_generation_oidc_discovery_failed": "Hiba a token generálásakor: OpenID Connect Discovery hiba", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "Átadta", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "Jelszó", + "save_to_inherit": "Kérjük, mentse el ezt kérést bármelyik gyűjteménybe, hogy az azonosítás örökölhető lehessen", + "token": "Token", + "type": "Felhatalmazás típusa", + "username": "Felhasználónév" + }, + "collection": { + "created": "Gyűjtemény létrehozva", + "different_parent": "Nem lehet átrendezni a különböző szülővel rendelkező gyűjteményt", + "edit": "Gyűjtemény szerkesztése", + "import_or_create": "Gyűjtemény importálása vagy létrehozása", + "import_collection": "Import Collection", + "invalid_name": "Adjon nevet a gyűjteménynek", + "invalid_root_move": "A gyűjtemény már a gyökérben van", + "moved": "Sikeresen áthelyezve", + "my_collections": "Saját gyűjtemények", + "name": "Saját új gyűjtemény", + "name_length_insufficient": "A gyűjtemény nevének legalább 3 karakter hosszúságúnak kell lennie", + "new": "Új gyűjtemény", + "order_changed": "Gyűjtemény sorrendje frissítve", + "properties": "Gyűjtemény tulajdonságok", + "properties_updated": "Gyűjtemény tulajdonságai frissítve", + "renamed": "Gyűjtemény átnevezve", + "request_in_use": "A kérés használatban", + "save_as": "Mentés másként", + "save_to_collection": "Mentés egy gyűjteménybe", + "select": "Gyűjtemény kiválasztása", + "select_location": "Hely kiválasztása", + "details": "Details", + "select_team": "Csapat kiválasztása", + "team_collections": "Csapat gyűjteményei" + }, + "confirm": { + "close_unsaved_tab": "Biztos, hogy bezárja ezt a lapot?", + "close_unsaved_tabs": "Biztos, hogy bezárja az összes lapot? {count} elmentetlen lap el fog veszni.", + "exit_team": "Biztosan el szeretné hagyni ezt a csapatot?", + "logout": "Biztosan ki szeretne jelentkezni?", + "remove_collection": "Biztosan véglegesen törölni szeretné ezt a gyűjteményt?", + "remove_environment": "Biztosan véglegesen törölni szeretné ezt a környezetet?", + "remove_folder": "Biztosan véglegesen törölni szeretné ezt a mappát?", + "remove_history": "Biztosan véglegesen törölni szeretné az összes előzményt?", + "remove_request": "Biztosan véglegesen törölni szeretné ezt a kérést?", + "remove_shared_request": "Biztos, hogy véglegesen törölni szeretné ezt a megosztott kérést?", + "remove_team": "Biztosan törölni szeretné ezt a csapatot?", + "remove_telemetry": "Biztosan ki szeretné kapcsolni a telemetriát?", + "request_change": "Biztosan el szeretné vetni a jelenlegi kérést? Minden mentetlen változtatás el fog veszni.", + "save_unsaved_tab": "Szeretné menteni az ezen a lapon elvégzett változtatásokat?", + "sync": "Szeretné visszaállítani a munkaterületét a felhőből? Ez el fogja vetni a helyi folyamatát.", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Paraméterek hozzáadása", + "open_request_in_new_tab": "Kérés megnyitása új lapot", + "set_environment_variable": "Változóként való beállítás" + }, + "cookies": { + "modal": { + "cookie_expires": "Lejárat", + "cookie_name": "Név", + "cookie_path": "Útvonal", + "cookie_string": "Süti szöveg", + "cookie_value": "Érték", + "empty_domain": "Üres domain", + "empty_domains": "Domain lista üres", + "enter_cookie_string": "Süti szövegének megadása", + "interceptor_no_support": "A kiválasztott interceptor nem támogatja a sütiket. Válasszon ki egy másik interceptor-t és próbálja újra.", + "managed_tab": "Menedzselt", + "new_domain_name": "Új domain neve", + "no_cookies_in_domain": "Nincs süti beállítva ehhez a domainhez.", + "raw_tab": "Nyers", + "set": "Süti beállítása" + } + }, + "count": { + "header": "{count}. fejléc", + "message": "{count}. üzenet", + "parameter": "{count}. paraméter", + "protocol": "{count}. protokoll", + "value": "{count}. érték", + "variable": "{count}. változó" + }, + "documentation": { + "generate": "Dokumentáció előállítása", + "generate_message": "Importáljon bármilyen Hoppscotch-gyűjteményt, hogy API-dokumentációt készítsen a folyamat során." + }, + "empty": { + "authorization": "Ez a kérés nem használ azonosítást", + "body": "Ennek a kérésnek nincs törzse", + "collection": "A gyűjtemény üres", + "collections": "A gyűjtemények üresek", + "documentation": "Kapcsolódjon egy GraphQL-végponthoz a dokumentáció megtekintéséhez", + "endpoint": "A végpont nem lehet üres", + "environments": "A környezetek üresek", + "folder": "A mappa üres", + "headers": "Ennek a kérésnek nincsenek fejlécei", + "history": "Az előzmények üresek", + "invites": "A meghívási lista üres", + "members": "A csapat üres", + "parameters": "Ennek a kérésnek nincsenek paraméterei", + "pending_invites": "Nincsenek függőben lévő meghívások ennél a csapatnál", + "profile": "Jelentkezzen be a profilja megtekintéséhez", + "protocols": "A protokollok üresek", + "request_variables": "This request does not have any request variables", + "schema": "Kapcsolódjon egy GraphQL-végponthoz a séma megtekintéséhez", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "A feliratkozások üresek", + "team_name": "A csapat neve üres", + "teams": "Ön nem tartozik semmilyen csapathoz", + "tests": "Nincsenek tesztek ehhez a kéréshez", + "access_tokens": "Access tokens are empty", + "shortcodes": "A rövid kódok üresek" + }, + "environment": { + "add_to_global": "Hozzáadás a globálishoz", + "added": "Környezet hozzáadása", + "create_new": "Új környezet létrehozása", + "created": "Környezet létrehozva", + "deleted": "Környezet törlése", + "duplicated": "Környezet duplikálása", + "edit": "Környezet szerkesztése", + "empty_variables": "Nincs változó", + "global": "Globális", + "global_variables": "Globális változók", + "import_or_create": "Környezet importálása vagy létrehozása", + "invalid_name": "Adjon nevet a környezetnek", + "list": "Környezeti változók", + "my_environments": "Saját környezetek", + "name": "Név", + "nested_overflow": "az egymásba ágyazott környezeti változók 10 szintre vannak korlátozva", + "new": "Új környezet", + "no_active_environment": "Nincs aktív környezet", + "no_environment": "Nincs környezet", + "no_environment_description": "Nem lettek környezetek kiválasztva. Válassza ki, hogy mit kell tenni a következő változókkal.", + "quick_peek": "Környezet gyors megnézése", + "replace_with_variable": "Cserélje le egy változóra", + "scope": "Hatókör", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "Környezet kiválasztása", + "set": "Környezet beállítása", + "set_as_environment": "Környezetként való beállítás", + "team_environments": "Csapatkörnyezetek", + "title": "Környezetek", + "updated": "Környezet frissítve", + "value": "Érték", + "variable": "Változó", + "variables": "Variables", + "variable_list": "Változólista", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Nem sikerült betölteni az azonosító szolgáltatókat", + "browser_support_sse": "Úgy tűnik, hogy ez a böngésző nem támogatja a kiszolgáló által küldött eseményeket.", + "check_console_details": "Nézze meg a konzolnaplót a részletekért.", + "check_how_to_add_origin": "Ellenőrizze, hogy hogyan adhat hozzá forrást", + "curl_invalid_format": "A cURL nincs megfelelően formázva", + "danger_zone": "Veszélyes zóna", + "delete_account": "Az Ön fiókja jelenleg tulajdonos ezekben a csapatokban:", + "delete_account_description": "El kell távolítani magát, át kell adnia a tulajdonjogot vagy törölnie kell ezeket a csapatokat, mielőtt törölhetné a fiókját.", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "Üres kérésnév", + "f12_details": "(F12 a részletekért)", + "gql_prettify_invalid_query": "Nem sikerült csinosítani egy érvénytelen lekérdezést, oldja meg a lekérdezés szintaktikai hibáit, és próbálja újra", + "incomplete_config_urls": "Befejezetlen beállítási URL-ek", + "incorrect_email": "Hibás e-mail", + "invalid_link": "Érvénytelen hivatkozás", + "invalid_link_description": "A kattintott hivatkozás érvénytelen vagy lejárt.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "Érvénytelen JSON", + "json_prettify_invalid_body": "Nem sikerült csinosítani egy érvénytelen törzset, oldja meg a JSON szintaktikai hibáit, és próbálja újra", + "network_error": "Úgy tűnik, hogy hálózati hiba van. Próbálja újra.", + "network_fail": "Nem sikerült elküldeni a kérést", + "no_collections_to_export": "Nincs exportálható gyűjtemény. Kérjük, hozzon létre egyet, hogy elkezdhesse.", + "no_duration": "Nincs időtartam", + "no_environments_to_export": "Nincs exportálható környezet. Kérjük, hozzon létre egyet, hogy elkezdhesse.", + "no_results_found": "Nincs találat", + "page_not_found": "Ez az oldal nem található", + "please_install_extension": "Kérjük telepítse a bővítményt és adja hozzá a forráshoz.", + "proxy_error": "Proxy hiba", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "Nem sikerült végrehajtani a kérés előtti parancsfájlt", + "something_went_wrong": "Valami elromlott", + "post_request_script_fail": "Nem sikerült végrehajtani a kérés utáni parancsfájlt", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "Exportálás JSON formátumban", + "create_secret_gist": "Titkos Gist létrehozása", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Valami hiba történt az exportálás közben", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "Jelentkezzen be GitHub használatával a titkos Gist létrehozásához", + "title": "Exportálás", + "success": "Successfully exported", + "gist_created": "Gist létrehozva" + }, + "filter": { + "all": "Összes", + "none": "Nincs", + "starred": "Csillagozott" + }, + "folder": { + "created": "Mappa létrehozva", + "edit": "Mappa szerkesztése", + "invalid_name": "Adjon nevet a mappának", + "name_length_insufficient": "A mappa nevének legalább 3 karakter hosszúságúnak kell lennie", + "new": "Új mappa", + "renamed": "Mappa átnevezve" + }, + "graphql": { + "connection_switch_confirm": "Szeretne csatlakozni a legújabb GraphQL végponttal?", + "connection_switch_new_url": "A tab váltása lecsatlakoztatta az aktív GraphQL kapcsolatról. Az új kapcsolat", + "connection_switch_url": "Kapcsolódott a GraphQL végponthoz. A kapcsolat", + "mutations": "Mutációk", + "schema": "Séma", + "subscriptions": "Feliratkozások", + "switch_connection": "Kapcsolat váltása", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL gyűjtemény" + }, + "group": { + "time": "Idő", + "url": "URL" + }, + "header": { + "install_pwa": "Alkalmazás telepítése", + "login": "Bejelentkezés", + "save_workspace": "Saját munkaterület mentése" + }, + "helpers": { + "authorization": "Az Azonosítás fejléc automatikusan elő lesz állítva a kérés elküldésekor.", + "collection_properties_authorization": " Ez az azonosítás be lesz állítva minden kéréshez ebben a gyűjteményben.", + "collection_properties_header": "Ez a fejléc be lesz állítva mint minden kéréshez ebben a gyűjteményben.", + "generate_documentation_first": "Először állítsa elő a dokumentációt", + "network_fail": "Nem lehet elérni az API-végpontot. Ellenőrizze a hálózati kapcsolatot vagy válasszon egy másik elfogót, és próbálja újra.", + "offline": "Úgy tűnik, hogy kapcsolat nélküli módban van. Előfordulhat, hogy a munkaterületen lévő adatok nem naprakészek.", + "offline_short": "Úgy tűnik, hogy kapcsolat nélküli módban van.", + "post_request_tests": "A tesztparancsfájlokat JavaScriptben írták, és a válasz megérkezése után lesznek futtatva.", + "pre_request_script": "A kérés előtti parancsfájlokat JavaScriptben írták, és a kérés elküldése előtt lesznek futtatva.", + "script_fail": "Úgy tűnik, hogy működési hiba van a kérés előtti parancsfájlban. Nézze meg az alábbi hibát, és annak megfelelően javítsa a parancsfájlt.", + "post_request_script_fail": "Úgy tűnik, hogy hiba van az utólagos kérés parancsfájlokkal. Javítsa ki a hibákat, és futtassa újra a teszteket.", + "post_request_script": "Írjon tesztparancsfájlt a hibakeresés automatizálására." + }, + "hide": { + "collection": "Gyűjteménypanel összecsukása", + "more": "Több elrejtése", + "preview": "Előnézet elrejtése", + "sidebar": "Oldalsáv összecsukása" + }, + "import": { + "collections": "Gyűjtemények importálása", + "curl": "cURL importálása", + "environments_from_gist": "Importálás Gist-ből", + "environments_from_gist_description": "Hoppscotch környezetek importálása Gist-ből", + "failed": "Hiba az importálás során: a formátum nem azonosítható", + "from_file": "Importálás fájlból", + "from_gist": "Importálás Gist-ből", + "from_gist_description": "Importálás Gist URL-ből", + "from_insomnia": "Importálás Insomniából", + "from_insomnia_description": "Importálás Insomnia-gyűjteményből", + "from_json": "Importálás Hoppscotchból", + "from_json_description": "Importálás Hoppscotch-gyűjteményfájlból", + "from_my_collections": "Importálás saját gyűjteményekből", + "from_my_collections_description": "Importálás saját gyűjtemények fájlból", + "from_openapi": "Importálás OpenAPI-ból", + "from_openapi_description": "Importálás OpenAPI specifikációs fájlból (YML/JSON)", + "from_postman": "Importálás Postman-ből", + "from_postman_description": "Importálás Postman-gyűjteményből", + "from_url": "Importálás URL-ből", + "gist_url": "Gist URL megadása", + "gql_collections_from_gist_description": "GraphQL gyűjtemények importálása Gist-ből", + "hoppscotch_environment": "Hoppscotch környezet", + "hoppscotch_environment_description": "Hoppscotch környezet importálása JSON fájlból", + "import_from_url_invalid_fetch": "Nem sikerült lekérni az adatokat az URL-ről", + "import_from_url_invalid_file_format": "Hiba a gyűjtemények importálása során", + "import_from_url_invalid_type": "Nem támogatott típus. Az elfogadott értékek: „hoppscotch”, „openapi”, „postman” vagy „insomnia”.", + "import_from_url_success": "Gyűjtemények importálva", + "insomnia_environment_description": "Insomnia környezet importálása JSON/YAML fájlból", + "json_description": "Gyűjtemények importálása Hoppscotch-gyűjtemények JSON-fájlból", + "postman_environment": "Postman környezet", + "postman_environment_description": "Postman környezet importálása JSON fájlból", + "title": "Importálás", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Lehetséges hibák ellenőrzése", + "environment": { + "add_environment": "Hozzáadás a környezethez", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "\"{environment}\" környezet nem található." + }, + "header": { + "cookie": "A böngésző nem engedélyezi a Hoppscotch-nak, hogy süti fejlécet állítson be. Amíg dolgozunk a Hoppscotch asztali alkalmazáson (hamarosan), kérjük használjon Authorization Header fejlécet." + }, + "response": { + "401_error": "Kérjük ellenőrizze az autentikációs adatokat.", + "404_error": "Kérjük ellenőrizze a kérés URL-jét és típusát.", + "cors_error": "Kérjük ellenőrizze a Cross-Origin Resource Sharing beállítást.", + "default_error": "Kérjük ellenőrizze a kérését.", + "network_error": "Kérjük ellenőrizze a internet elérhetőségét." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Bővítmény nincs telepítve.", + "extension_unknown_origin": "Ellenőrizze, hogy hozzáadta az API végpont forrását Hoppscotch Browser bővítmény listájához.", + "extention_enable_action": "Bővítmény engedélyezése", + "extention_not_enabled": "Bővítmény nincs engedélyezve." + } + }, + "layout": { + "collapse_collection": "Gyűjtemények összecsukása vagy kinyitása", + "collapse_sidebar": "Az oldalsáv összecsukása vagy kinyitása", + "column": "Függőleges elrendezés", + "name": "Elrendezés", + "row": "Vízszintes elrendezés" + }, + "modal": { + "close_unsaved_tab": "Elmentetlen változtatásai vannak", + "collections": "Gyűjtemények", + "confirm": "Megerősítés", + "customize_request": "Kérés testreszabása", + "edit_request": "Kérés szerkesztése", + "import_export": "Importálás és exportálás", + "share_request": "Kérés megosztása" + }, + "mqtt": { + "already_subscribed": "Ön már feliratkozott erre a témára.", + "clean_session": "Munkamenet törlése", + "clear_input": "Bevitel törlése", + "clear_input_on_send": "Bevitel törlése küldéskor", + "client_id": "Ügyfél-azonosító", + "color": "Válasszon színt", + "communication": "Kommunikáció", + "connection_config": "Kapcsolat beállításai", + "connection_not_authorized": "Ez az MQTT-kapcsolat nem használ semmilyen hitelesítést.", + "invalid_topic": "Adjon témát a feliratkozáshoz", + "keep_alive": "Életben tartás", + "log": "Napló", + "lw_message": "Utolsó kívánság üzenet", + "lw_qos": "Utolsó kívánság QoS", + "lw_retain": "Utolsó kívánság megtartás", + "lw_topic": "Utolsó kívánság téma", + "message": "Üzenet", + "new": "Új feliratkozás", + "not_connected": "Először indítson egy MQTT-kapcsolatot.", + "publish": "Közzététel", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Feliratkozás", + "topic": "Téma", + "topic_name": "Téma neve", + "topic_title": "Téma közzététele vagy feliratkozás", + "unsubscribe": "Leiratkozás", + "url": "URL" + }, + "navigation": { + "doc": "Dokumentációk", + "graphql": "GraphQL", + "profile": "Profil", + "realtime": "Valós idő", + "rest": "REST", + "settings": "Beállítások" + }, + "preRequest": { + "javascript_code": "JavaScript kód", + "learn": "Dokumentáció elolvasása", + "script": "Kérés előtti parancsfájl", + "snippets": "Kódrészletek" + }, + "profile": { + "app_settings": "Alkalmazás beállításai", + "default_hopp_displayname": "Névtelen felhasználó", + "editor": "Szerkesztő", + "editor_description": "A szerkesztők hozzáadhatnak, szerkeszthetnek és törölhetnek kéréseket.", + "email_verification_mail": "Egy ellenőrző e-mail el lett küldve az e-mail-címére. Kattintson a hivatkozásra az e-mail-címe ellenőrzéséhez.", + "no_permission": "Nincs jogosultsága ennek a műveletnek a végrehajtásához.", + "owner": "Tulajdonos", + "owner_description": "A tulajdonosok hozzáadhatnak, szerkeszthetnek és törölhetnek kéréseket, gyűjteményeket és csapattagokat.", + "roles": "Szerepek", + "roles_description": "A szerepeket a megosztott gyűjteményekhez való hozzáférés szabályozására használják.", + "updated": "Profil frissítve", + "viewer": "Megtekintő", + "viewer_description": "A megtekintők csak megtekinthetik és használhatják a kéréseket." + }, + "remove": { + "star": "Csillag eltávolítása" + }, + "request": { + "added": "Kérés hozzáadva", + "authorization": "Azonosítás", + "body": "Kérés törzse", + "choose_language": "Nyelv kiválasztása", + "content_type": "Tartalom típusa", + "content_type_titles": { + "others": "Egyebek", + "structured": "Szerkesztett", + "text": "Szöveg" + }, + "different_collection": "Nem lehet átrendezni a különböző gyűjteményekből érkező kéréseket", + "duplicated": "Kérés megkettőzve", + "duration": "Időtartam", + "enter_curl": "cURL-parancs megadása", + "generate_code": "Kód előállítása", + "generated_code": "Előállított kód", + "go_to_authorization_tab": "Navigálás az Azonosítás lapra", + "go_to_body_tab": "Navigálás a Törzs lapra.", + "header_list": "Fejléclista", + "invalid_name": "Adjon nevet a kérésnek", + "method": "Módszer", + "moved": "Kérés áthelyezve", + "name": "Kérés neve", + "new": "Új kérés", + "order_changed": "Kérés sorrendje frissítve", + "override": "Felülbírálás", + "override_help": "Content-Type beállítása a fejlécekben", + "overriden": "Felülbírálva", + "parameter_list": "Lekérdezési paraméterek", + "parameters": "Paraméterek", + "path": "Útvonal", + "payload": "Hasznos teher", + "query": "Lekérdezés", + "raw_body": "Nyers kéréstörzs", + "rename": "Rename Request", + "renamed": "Kérés átnevezve", + "request_variables": "Request variables", + "run": "Futtatás", + "save": "Mentés", + "save_as": "Mentés másként", + "saved": "Kérés elmentve", + "share": "Megosztás", + "share_description": "A Hoppscotch megosztása az ismerőseivel", + "share_request": "Kérés megosztása", + "stop": "Leállítás", + "title": "Kérés", + "type": "Kérés típusa", + "url": "URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "Változók", + "view_my_links": "Saját hivatkozások megtekintése", + "copy_link": "Hivatkozás másolása" + }, + "response": { + "audio": "Hang", + "body": "Válasz törzse", + "filter_response_body": "JSON-válasz törzsének szűrése (jq szintaxist használ)", + "headers": "Fejlécek", + "html": "HTML", + "image": "Kép", + "json": "JSON", + "pdf": "PDF", + "preview_html": "HTML előnézete", + "raw": "Nyers", + "size": "Méret", + "status": "Állapot", + "time": "Idő", + "title": "Válasz", + "video": "Videó", + "waiting_for_connection": "várakozás kapcsolódásra", + "xml": "XML" + }, + "settings": { + "accent_color": "Kiemelőszín", + "account": "Fiók", + "account_deleted": "A fiókja törölve lett", + "account_description": "A fiókbeállítások személyre szabása.", + "account_email_description": "Az Ön elsődleges e-mail-címe.", + "account_name_description": "Ez a megjelenített neve.", + "additional": "További beállítások", + "background": "Háttér", + "black_mode": "Fekete", + "choose_language": "Nyelv kiválasztása", + "dark_mode": "Sötét", + "delete_account": "Fiók törlése", + "delete_account_description": "Ha törli a fiókját, akkor az összes adata véglegesen törlésre kerül. Ezt a műveletet nem lehet visszavonni.", + "expand_navigation": "Navigáció kinyitása", + "experiments": "Kísérletek", + "experiments_notice": "Ez olyan kísérletek gyűjteménye, amelyeken dolgozunk, és amelyek hasznosak, szórakoztatóak lehetnek, mindkettő, vagy egyik sem. Ezek nem véglegesek és nem stabilak, ezért ha valami túl furcsa dolog történik, ne essen pánikba. Egyszerűen kapcsolja ki a hibás dolgot. Viccet félretéve, ", + "extension_ver_not_reported": "Nincs bejelentve", + "extension_version": "Kiterjesztés verziója", + "extensions": "Böngészőkiterjesztés", + "extensions_use_toggle": "A böngészőkiterjesztés használata a kérések küldéséhez (ha jelen van)", + "follow": "Kövessen minket", + "interceptor": "Elfogó", + "interceptor_description": "Középprogram az alkalmazás és az API-k között.", + "language": "Nyelv", + "light_mode": "Világos", + "official_proxy_hosting": "A hivatalos proxyt a Hoppscotch üzemelteti.", + "profile": "Profil", + "profile_description": "Profilrészletek frissítése", + "profile_email": "E-mail-cím", + "profile_name": "Profil neve", + "proxy": "Proxy", + "proxy_url": "Proxy URL", + "proxy_use_toggle": "A proxy középprogram használata a kérések küldéséhez", + "read_the": "Olvassa el:", + "reset_default": "Visszaállítás az alapértelmezettre", + "short_codes": "Rövid kódok", + "short_codes_description": "Az Ön által létrehozott rövid kódok.", + "sidebar_on_left": "Oldalsáv a bal oldalon", + "sync": "Szinkronizálás", + "sync_collections": "Gyűjtemények", + "sync_description": "Ezek a beállítások szinkronizálva vannak a felhővel.", + "sync_environments": "Környezetek", + "sync_history": "Előzmények", + "system_mode": "Rendszer", + "telemetry": "Telemetria", + "telemetry_helps_us": "A telemetria segít nekünk személyre szabni a működésünket és a legjobb élményt nyújtani Önnek.", + "theme": "Téma", + "theme_description": "Az alkalmazás témájának személyre szabása.", + "use_experimental_url_bar": "Kísérleti URL-sáv használata a környezet kiemelésével", + "user": "Felhasználó", + "verified_email": "Ellenőrzött e-mail-cím", + "verify_email": "E-mail-cím ellenőrzése" + }, + "shared_requests": { + "button": "Gomb", + "button_info": "Hozza létre a 'Futtatás Hoppscotch-ban' gombot a weboldalán vagy blogján.", + "copy_html": "HTML másolása", + "copy_link": "Link másolása", + "copy_markdown": "Jelölő másolása", + "creating_widget": "Widget létrehozása", + "customize": "Testreszabás", + "deleted": "Megosztott kérés törölve", + "description": "Válasszon ki egy widgetet, ezt később módosíthatja és testreszabhatja", + "embed": "Beágyazás", + "embed_info": "Adja hozzá a \"Hoppscotch API Playground\"-ot weboldalához vagy blogjához.", + "link": "Link", + "link_info": "Link létrehozása olvasási joggal való megosztáshoz.", + "modified": "Megosztott kérés módosítva", + "not_found": "Megosztott kérés nem található.", + "open_new_tab": "Megnyitás új lapon.", + "preview": "Előnézet", + "run_in_hoppscotch": "Futtatás Hoppscotch-ban.", + "theme": { + "dark": "Sötét", + "light": "Világos", + "system": "Rendszer", + "title": "Téma" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Jelenlegi menü bezárása", + "command_menu": "Keresés és parancs menü", + "help_menu": "Súgó menü", + "show_all": "Gyorsbillentyűk", + "title": "Általános" + }, + "miscellaneous": { + "invite": "Emberek meghívása a Hoppscotch-ba", + "title": "Egyebek" + }, + "navigation": { + "back": "Vissza az előző oldalra", + "documentation": "Ugrás a dokumentációs oldalra", + "forward": "Ugrás a következő oldalra", + "graphql": "Ugrás a GraphQL oldalra", + "profile": "Ugrás a profil oldalra", + "realtime": "Ugrás a valós idő oldalra", + "rest": "Ugrás a REST oldalra", + "settings": "Ugrás a beállítások oldalra", + "title": "Navigáció" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "DELETE módszer kiválasztása", + "get_method": "GET módszer kiválasztása", + "head_method": "HEAD módszer kiválasztása", + "import_curl": "cURL importálása", + "method": "Módszer", + "next_method": "Következő módszer kiválasztása", + "post_method": "POST módszer kiválasztása", + "previous_method": "Előző módszer kiválasztása", + "put_method": "PUT módszer kiválasztása", + "rename": "Kérés átnevezése", + "reset_request": "Kérés visszaállítása", + "save_request": "Kérés mentése", + "save_to_collections": "Mentés a gyűjteményekbe", + "send_request": "Kérés elküldése", + "share_request": "Kérés megosztása", + "show_code": "Kódrészlet generálása", + "title": "Kérés", + "copy_request_link": "Kérés hivatkozásának másolása" + }, + "response": { + "copy": "Válasz másolása a vágólapra", + "download": "Válasz letöltés fájlként", + "title": "Válasz" + }, + "theme": { + "black": "Téma átváltása fekete módra", + "dark": "Téma átváltása sötét módra", + "light": "Téma átváltása világos módra", + "system": "Téma átváltása rendszer módra", + "title": "Téma" + } + }, + "show": { + "code": "Kód megjelenítése", + "collection": "Gyűjteménypanel kinyitása", + "more": "Több megjelenítése", + "sidebar": "Oldalsáv kinyitása" + }, + "socketio": { + "communication": "Kommunikáció", + "connection_not_authorized": "Ez a SocketIO-kapcsolat nem használ semmilyen hitelesítést.", + "event_name": "Esemény vagy téma neve", + "events": "Események", + "log": "Napló", + "url": "URL" + }, + "spotlight": { + "change_language": "Nyelv váltása", + "environments": { + "delete": "Jelenlegi környezet törlése", + "duplicate": "Jelenlegi környezet duplikálása", + "duplicate_global": "Globális környezet duplikálása", + "edit": "Jelenlegi környezet szerkesztése", + "edit_global": "Globális környezet szerkesztése", + "new": "Új környezet létrehozása", + "new_variable": "Új környezeti változó létrehozása", + "title": "Környezetek" + }, + "general": { + "chat": "Üzenet a supportnak", + "help_menu": "Segítség és support", + "open_docs": "Dokumentáció olvasása", + "open_github": "GitHub repository megnyitása", + "open_keybindings": "Billentyűkombinációk megnyitása", + "social": "Közösség", + "title": "Általános" + }, + "graphql": { + "connect": "Csatlakozás a szerverhez", + "disconnect": "Lecsatlakozás a szerverről" + }, + "miscellaneous": { + "invite": "Hívja meg barátait a Hoppscotch-ba", + "title": "Egyéb" + }, + "request": { + "save_as_new": "Mentés új kérésként", + "select_method": "Módszer kiválasztása", + "switch_to": "Váltás", + "tab_authorization": "Azonosítás lap", + "tab_body": "Törzs lap", + "tab_headers": "Fejlécek lap", + "tab_parameters": "Paraméterek lap", + "tab_pre_request_script": "Előzetes script lap", + "tab_query": "Kérés lap", + "tab_tests": "Tesztek lap", + "tab_variables": "Változók lap" + }, + "response": { + "copy": "Válasz másolása", + "download": "Válasz letöltése fájlként", + "title": "Válasz" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Téma", + "user": "Felhasználó" + }, + "settings": { + "change_interceptor": "Interceptor váltása", + "change_language": "Nyelv váltása", + "theme": { + "black": "Fekete", + "dark": "Sötét", + "light": "Világos", + "system": "Rendszer" + } + }, + "tab": { + "close_current": "Jelenlegi lap bezására", + "close_others": "Összes többi lap bezására", + "duplicate": "Jelenlegi lap diplikálása", + "new_tab": "Új lap megnyitása", + "title": "Lapok" + }, + "workspace": { + "delete": "Jelenlegi csapat törlése", + "edit": "Jelenlegi csapat szerkesztése", + "invite": "Emberek meghívása a jelenlegi csapatba", + "new": "Új csapat létrehozása", + "switch_to_personal": "Váltás a személyes munkaterületére", + "title": "Csapatok" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "Esemény típusa", + "log": "Napló", + "url": "URL" + }, + "state": { + "bulk_mode": "Tömeges szerkesztés", + "bulk_mode_placeholder": "A bejegyzéseket új sor választja el\nA kulcsokat és az értékeket : választja el\nTegyen # jelet bármely olyan sor elé, amelyet hozzá szeretne adni, de letiltva szeretne tartani", + "cleared": "Törölve", + "connected": "Kapcsolódva", + "connected_to": "Kapcsolódva ehhez: {name}", + "connecting_to": "Kapcsolódás ehhez: {name}…", + "connection_error": "Nem sikerült kapcsolódni", + "connection_failed": "A kapcsolódás sikertelen", + "connection_lost": "A kapcsolat elveszett", + "copied_interface_to_clipboard": "{language} interface típusa vágólapra másolva", + "copied_to_clipboard": "Vágólapra másolva", + "deleted": "Törölve", + "deprecated": "ELAVULT", + "disabled": "Letiltva", + "disconnected": "Lecsatlakoztatva", + "disconnected_from": "Lecsatlakoztatva innen: {name}", + "docs_generated": "Dokumentáció előállítva", + "download_failed": "Letöltés sikertelen", + "download_started": "A letöltés elkezdődött", + "enabled": "Engedélyezve", + "file_imported": "Fájl importálva", + "finished_in": "Befejeződött {duration} ms alatt", + "hide": "Elrejtés", + "history_deleted": "Előzmények törölve", + "linewrap": "Sorok tördelése", + "loading": "Betöltés…", + "message_received": "Üzenet: {message} érkezett ehhez a témához: {topic}", + "mqtt_subscription_failed": "Valami elromlott a következő témára való feliratkozás során: {topic}", + "none": "Nincs", + "nothing_found": "Semmi sem található ehhez:", + "published_error": "Valami elromlott a következő üzenet közzététele során: {topic}, ehhez a témához: {message}", + "published_message": "Közzétett üzenet: {message}, ehhez a témához: {topic}", + "reconnection_error": "Nem sikerült újrakapcsolódni", + "show": "Show", + "subscribed_failed": "Nem sikerült feliratkozni erre a témára: {topic}", + "subscribed_success": "Sikeresen feliratkozott erre a témára: {topic}", + "unsubscribed_failed": "Nem sikerült leiratkozni erről a témáról: {topic}", + "unsubscribed_success": "Sikeresen leiratkozott erről a témáról: {topic}", + "waiting_send_request": "Várakozás a kérés elküldésére" + }, + "support": { + "changelog": "Tudjon meg többet a legújabb kiadásokról", + "chat": "Kérdése van? Csevegjen velünk!", + "community": "Tegyen fel kérdéseket és segítsen másoknak", + "documentation": "Tudjon meg többet a Hoppscotchról", + "forum": "Tegyen fel kérdéseket és kapjon válaszokat", + "github": "Kövessen minket GitHubon", + "shortcuts": "Az alkalmazás gyorsabb böngészése", + "title": "Támogatás", + "twitter": "Kövessen minket Twitteren", + "team": "Vegye fel a kapcsolatot a csapattal" + }, + "tab": { + "authorization": "Azonosítás", + "body": "Törzs", + "close": "Lap bezárása", + "close_others": "Többi lap bezárása", + "collections": "Gyűjtemények", + "documentation": "Dokumentáció", + "duplicate": "Lap duplikálása", + "environments": "Környezetek", + "headers": "Fejlécek", + "history": "Előzmények", + "mqtt": "MQTT", + "parameters": "Paraméterek", + "pre_request_script": "Kérés előtti parancsfájl", + "queries": "Lekérdezések", + "query": "Lekérdezés", + "schema": "Séma", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Tesztek", + "types": "Típusok", + "variables": "Változók", + "websocket": "WebSocket" + }, + "team": { + "already_member": "Ön már tagja ennek a csapatnak. Vegye fel a kapcsolatot a csapat tulajdonosával.", + "create_new": "Új csapat létrehozása", + "deleted": "Csapat törölve", + "edit": "Csapat szerkesztése", + "email": "E-mail", + "email_do_not_match": "Az e-mail-cím nem egyezik a fiókja részleteivel. Vegye fel a kapcsolatot a csapat tulajdonosával.", + "exit": "Kilépés a csapatból", + "exit_disabled": "Csak a tulajdonos nem léphet ki a csapatból", + "failed_invites": "Hiba a meghívás közben", + "invalid_coll_id": "Érvénytelen gyűjteményazonosító", + "invalid_email_format": "Az e-mail formátuma érvénytelen", + "invalid_id": "Érvénytelen csapatazonosító. Vegye fel a kapcsolatot a csapat tulajdonosával.", + "invalid_invite_link": "Érvénytelen meghívási hivatkozás", + "invalid_invite_link_description": "Az Ön által követett hivatkozás érvénytelen. Vegye fel a kapcsolatot a csapat tulajdonosával.", + "invalid_member_permission": "Adjon érvényes jogosultságot a csapattagnak", + "invite": "Meghívás", + "invite_more": "Továbbiak meghívása", + "invite_tooltip": "Emberek meghívása erre a munkaterületre", + "invited_to_team": "{owner} meghívta Önt, hogy csatlakozzon ehhez a csapathoz: {team}", + "join": "Meghívás elfogadva", + "join_team": "Csatlakozás ehhez: {team}", + "joined_team": "Ön csatlakozott ehhez a csapathoz: {team}", + "joined_team_description": "Ön mostantól ennek a csapatnak a tagja", + "left": "Ön elhagyta a csapatot", + "login_to_continue": "Jelentkezzen be a folytatáshoz", + "login_to_continue_description": "Bejelentkezve kell lennie egy csapathoz való csatlakozáshoz.", + "logout_and_try_again": "Jelentkezzen ki és jelentkezzen be egy másik fiókkal", + "member_has_invite": "Ez az e-mail-azonosító már kapott meghívást. Vegye fel a kapcsolatot a csapat tulajdonosával.", + "member_not_found": "A tag nem található. Vegye fel a kapcsolatot a csapat tulajdonosával.", + "member_removed": "Felhasználó eltávolítva", + "member_role_updated": "Felhasználói szerepek frissítve", + "members": "Tagok", + "more_members": "+{count} további", + "name_length_insufficient": "A csapat nevének legalább 6 karakter hosszúságúnak kell lennie", + "name_updated": "Csapatnév frissítve", + "new": "Új csapat", + "new_created": "Új csapat létrehozva", + "new_name": "Saját új csapat", + "no_access": "Nincs szerkesztési jogosultsága ezekhez a gyűjteményekhez", + "no_invite_found": "A meghívás nem található. Vegye fel a kapcsolatot a csapat tulajdonosával.", + "no_request_found": "A kérés nem található.", + "not_found": "A csapat nem található. Vegye fel a kapcsolatot a csapat tulajdonosával.", + "not_valid_viewer": "Ön nem érvényes megtekintő. Vegye fel a kapcsolatot a csapat tulajdonosával.", + "parent_coll_move": "Nem lehet áthelyezni a gyűjteményt egy gyermekgyűjteménybe", + "pending_invites": "Függőben lévő meghívások", + "permissions": "Jogosultságok", + "same_target_destination": "Ugyanaz a cél és célhely", + "saved": "Csapat elmentve", + "select_a_team": "Csapat kiválasztása", + "success_invites": "Success invites", + "title": "Csapatok", + "we_sent_invite_link": "Elküldtünk egy meghívási hivatkozást az összes meghívottnak.", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "Kérje meg az összes meghívottat, hogy nézzék meg a beérkező leveleiket. Kattintsanak a hivatkozásra a csapathoz való csatlakozáshoz.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "Csatlakozzon a béta programhoz, hogy hozzáférjen a csapatokhoz." + }, + "team_environment": { + "deleted": "Környezet törölve", + "duplicate": "Környezet megkettőzve", + "not_found": "A környezet nem található." + }, + "test": { + "failed": "teszt sikertelen", + "javascript_code": "JavaScript kód", + "learn": "Dokumentáció elolvasása", + "passed": "teszt sikeres", + "report": "Tesztjelentés", + "results": "Teszteredmények", + "script": "Parancsfájl", + "snippets": "Kódrészletek" + }, + "websocket": { + "communication": "Kommunikáció", + "log": "Napló", + "message": "Üzenet", + "protocols": "Protokollok", + "url": "URL" + }, + "workspace": { + "change": "Munkaterület váltása", + "personal": "Saját munkaterület", + "other_workspaces": "My Workspaces", + "team": "Csapat-munkaterület", + "title": "Munkaterületek" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "Műveletek", + "created_on": "Létrehozva", + "deleted": "Rövid kód törölve", + "method": "Módszer", + "not_found": "A rövid kód nem található", + "short_code": "Rövid kód", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/hy.json b/packages/hoppscotch-common/locales/hy.json new file mode 100644 index 0000000..9515a40 --- /dev/null +++ b/packages/hoppscotch-common/locales/hy.json @@ -0,0 +1,2299 @@ +{ + "action": { + "add": "Ավելացնել", + "autoscroll": "Ավտոոլորում", + "cancel": "Չեղարկել", + "choose_file": "Ընտրել ֆայլ", + "choose_workspace": "Ընտրել աշխատանքային տիրույթ", + "choose_collection": "Ընտրել հավաքածու", + "select_workspace": "Ընտրել աշխատանքային տիրույթ", + "clear": "Մաքրել", + "clear_all": "Մաքրել բոլորը", + "clear_cache": "Մաքրել քեշը", + "clear_history": "Մաքրել ողջ պատմությունը", + "clear_unpinned": "Մաքրել չամրացվածները", + "clear_response": "Մաքրել պատասխանը", + "close": "Փակել", + "confirm": "Հաստատել", + "connect": "Միանալ", + "connecting": "Միանում է", + "copy": "Պատճենել", + "create": "Ստեղծել", + "delete": "Ջնջել", + "disconnect": "Անջատել", + "dismiss": "Մերժել", + "done": "Կատարված է", + "dont_save": "Չպահպանել", + "download_file": "Ներբեռնել ֆայլ", + "download_test_report": "Ներբեռնել թեստի հաշվետվությունը", + "drag_to_reorder": "Քաշեք՝ վերադասավորելու համար", + "duplicate": "Կրկնօրինակել", + "edit": "Խմբագրել", + "filter": "Զտել", + "go_back": "Գնալ հետ", + "go_forward": "Գնալ առաջ", + "group_by": "Խմբավորել ըստ", + "hide_secret": "Թաքցնել գաղտնիքը", + "label": "Պիտակ", + "learn_more": "Իմանալ ավելին", + "download_here": "Ներբեռնել այստեղ", + "less": "Ավելի քիչ", + "more": "Ավելի շատ", + "new": "Նոր", + "no": "Ոչ", + "open": "Բացել", + "open_workspace": "Բացել աշխատանքային տիրույթը", + "paste": "Տեղադրել", + "prettify": "Գեղեցկացնել", + "properties": "Հատկություններ", + "register": "Գրանցվել", + "remove": "Հեռացնել", + "remove_instance": "Հեռացնել նմուշը", + "rename": "Վերանվանել", + "restore": "Վերականգնել", + "retry": "Կրկնել", + "save": "Պահպանել", + "save_as_example": "Պահպանել որպես օրինակ", + "add_example": "Ավելացնել օրինակ", + "invalid_request": "Անվավեր հարցման տվյալներ", + "scroll_to_bottom": "Ոլորել ներքև", + "scroll_to_top": "Ոլորել վերև", + "search": "Որոնել", + "send": "Ուղարկել", + "share": "Կիսվել", + "show_secret": "Ցուցադրել գաղտնիքը", + "sort": "Տեսակավորել", + "start": "Սկսել", + "starting": "Սկսում է", + "stop": "Կանգնեցնել", + "to_close": "փակելու համար", + "to_navigate": "նավարկելու համար", + "to_select": "ընտրելու համար", + "turn_off": "Անջատել", + "turn_on": "Միացնել", + "undo": "Հետարկել", + "unpublish": "Ապահրապարակել", + "yes": "Այո", + "verify": "Ստուգել", + "enable": "Միացնել", + "disable": "Անջատել", + "assign": "Նշանակել" + }, + "activity_logs": { + "ACTIVITY_LOG_DELETE": "Գործունեության մատյանը ջնջվել է", + "WORKSPACE_CREATE": "Ստեղծվել է նոր աշխատանքային տիրույթ {name}", + "WORKSPACE_RENAME": "Աշխատանքային տիրույթը վերանվանվել է {old_name}-ից {new_name}", + "WORKSPACE_USER_ADD": "{user}-ը ավելացվել է աշխատանքային տիրույթ որպես {role}", + "WORKSPACE_USER_INVITE": "{user}-ը հրավիրվել է {inviteeEmail}-ի կողմից որպես {role}", + "WORKSPACE_USER_INVITE_REVOKE": "Չեղարկվել է {inviteeEmail}-ի հրավերը որպես {inviteeRole}", + "WORKSPACE_USER_INVITE_ACCEPT": "{inviteeEmail}-ը ընդունել է հրավերը որպես {inviteeRole}", + "WORKSPACE_USER_REMOVE": "{user}-ը հեռացվել է աշխատանքային տիրույթից", + "WORKSPACE_USER_ROLE_UPDATE": "{user}-ի դերը թարմացվել է {old_role}-ից {new_role}", + "COLLECTION_CREATE": "Ստեղծվել է նոր հավաքածու {title}", + "COLLECTION_RENAME": "Հավաքածուն վերանվանվել է {old_title}-ից {new_title}", + "COLLECTION_IMPORT": "Ներմուծվել է {count} հավաքածու", + "COLLECTION_DELETE": "Ջնջվել է հավաքածու {title}", + "COLLECTION_DUPLICATE": "Կրկնօրինակվել է հավաքածու {parentTitle}", + "REQUEST_CREATE": "Ստեղծվել է նոր հարցում {title}", + "REQUEST_RENAME": "Հարցումը վերանվանվել է {old_title}-ից {new_title}", + "REQUEST_DELETE": "Ջնջվել է հարցում {title}" + }, + "add": { + "new": "Ավելացնել նոր", + "star": "Ավելացնել աստղ" + }, + "agent": { + "registration_instruction": "Խնդրում ենք գրանցել Hoppscotch գործակալը ձեր վեբ հաճախորդի հետ՝ շարունակելու համար:", + "enter_otp_instruction": "Խնդրում ենք մուտքագրել Hoppscotch գործակալի կողմից ստեղծված ստուգման կոդը և ավարտել գրանցումը", + "otp_label": "Ստուգման կոդ", + "processing": "Ձեր հարցումը մշակվում է...", + "not_running_title": "Գործակալը չի հայտնաբերվել", + "registration_title": "Գործակալի գրանցում", + "verify_ssl_certs": "Ստուգել SSL վկայականները", + "ca_certs": "CA վկայականներ", + "client_certs": "Հաճախորդի վկայականներ", + "use_http_proxy": "Օգտագործել HTTP պրոքսի", + "proxy_capabilities": "Hoppscotch գործակալը աջակցում է HTTP/HTTPS/SOCKS պրոքսիներ, ինչպես նաև NTLM և հիմնական նույնականացում այդ պրոքսիներում: Ներառեք օգտանունը և գաղտնաբառը պրոքսիի նույնականացման համար URL-ի մեջ:", + "add_cert_file": "Ավելացնել վկայականի ֆայլ", + "add_client_cert": "Ավելացնել հաճախորդի վկայական", + "add_key_file": "Ավելացնել բանալու ֆայլ", + "domain": "Դոմեն", + "cert": "Վկայական", + "key": "Բանալի", + "pfx_or_pkcs": "PFX/PKCS12", + "pfx_or_pkcs_file": "PFX/PKCS12 ֆայլ", + "add_pfx_or_pkcs_file": "Ավելացնել PFX/PKCS12 ֆայլ" + }, + "app": { + "additional_links": { + "macOS": "macOS", + "windows": "Windows", + "linux": "Linux", + "web_app": "Վեբ հավելված", + "cli": "CLI" + }, + "chat_with_us": "Զրուցեք մեզ հետ", + "contact_us": "Կապվեք մեզ հետ", + "cookies": "Cookies", + "copy": "Պատճենել", + "copy_interface_type": "Պատճենել ինտերֆեյսի տեսակը", + "copy_user_id": "Պատճենել օգտատիրոջ նույնականացման տոկենը", + "developer_option": "Ծրագրավորողի ընտրանքներ", + "developer_option_description": "Ծրագրավորողի գործիքներ, որոնք օգնում են Hoppscotch-ի զարգացման և սպասարկման գործում:", + "discord": "Discord", + "documentation": "Փաստաթղթավորում", + "github": "GitHub", + "help": "Օգնություն և հետադարձ կապ", + "home": "Գլխավոր", + "invite": "Հրավիրել", + "invite_description": "Hoppscotch-ը բաց կոդով API-ի զարգացման էկոհամակարգ է: Մենք նախագծել ենք պարզ և ինտուիտիվ ինտերֆեյս ձեր API-ները ստեղծելու և կառավարելու համար: Hoppscotch-ը գործիք է, որն օգնում է ձեզ կառուցել, թեստավորել, փաստաթղթավորել և տարածել ձեր API-ները:", + "invite_your_friends": "Հրավիրեք ձեր ընկերներին", + "join_discord_community": "Միացեք մեր Discord համայնքին", + "keyboard_shortcuts": "Ստեղնաշարի դյուրանցումներ", + "name": "Hoppscotch", + "new_version_found": "Հայտնաբերվել է նոր տարբերակ: Թարմացրեք՝ նորացնելու համար:", + "open_in_hoppscotch": "Բացել Hoppscotch-ում", + "options": "Ընտրանքներ", + "powered_by": "Hoppscotch-ի կողմից", + "proxy_privacy_policy": "Պրոքսիի գաղտնիության քաղաքականություն", + "reload": "Վերբեռնել", + "search": "Որոնում և հրամաններ", + "share": "Կիսվել", + "shortcuts": "Դյուրանցումներ", + "social_description": "Հետևեք մեզ սոցիալական մեդիայում՝ վերջին նորություններին, թարմացումներին և թողարկումներին տեղյակ լինելու համար:", + "social_links": "Սոցիալական հղումներ", + "spotlight": "Spotlight", + "status": "Կարգավիճակ", + "status_description": "Ստուգեք կայքի կարգավիճակը", + "terms_and_privacy": "Պայմաններ և գաղտնիություն", + "twitter": "Twitter", + "type_a_command_search": "Մուտքագրեք հրաման կամ որոնեք...", + "we_use_cookies": "Մենք օգտագործում ենք cookie-ներ", + "updated_text": "Hoppscotch-ը թարմացվել է v{version}-ի 🎉", + "whats_new": "Ինչ նորություն կա", + "see_whats_new": "Տեսնել նորությունները", + "wiki": "Վիքի", + "collapse_sidebar": "Փոքրացնել կողագոտին", + "continue_to_dashboard": "Շարունակել դեպի կառավարման վահանակ", + "expand_sidebar": "Ընդլայնել կողագոտին", + "no_name": "Անանուն", + "open_navigation": "Բացել նավարկումը", + "read_documentation": "Կարդալ փաստաթղթավորումը", + "default": "լռելյայն՝ {value}" + }, + "auth": { + "account_deactivated": "Ձեր հաշիվը ապակտիվացված է: Մուտք գործելու համար կապվեք ադմինիստրատորի հետ:", + "account_exists": "Հաշիվը գոյություն ունի այլ հավատարմագրերով - Մուտք գործեք՝ երկու հաշիվները կապելու համար", + "all_sign_in_options": "Մուտքի բոլոր տարբերակները", + "continue_with_auth_provider": "Շարունակել {provider}-ի միջոցով", + "continue_with_email": "Շարունակել էլ. փոստով", + "continue_with_github": "Շարունակել GitHub-ով", + "continue_with_github_enterprise": "Շարունակել GitHub Enterprise-ով", + "continue_with_google": "Շարունակել Google-ով", + "continue_with_microsoft": "Շարունակել Microsoft-ով", + "email": "Էլ. փոստ", + "logged_out": "Դուրս եկած", + "login": "Մուտք", + "login_success": "Հաջողությամբ մուտք եք գործել", + "login_to_hoppscotch": "Մուտք գործել Hoppscotch", + "logout": "Դուրս գալ", + "re_enter_email": "Կրկին մուտքագրեք էլ. փոստը", + "send_magic_link": "Ուղարկել կախարդական հղում", + "sync": "Սինքրոնիզացնել", + "we_sent_magic_link": "Մենք ուղարկեցինք ձեզ կախարդական հղում:", + "we_sent_magic_link_description": "Ստուգեք ձեր մուտքային արկղը - մենք էլ. նամակ ենք ուղարկել {email}-ին: Այն պարունակում է կախարդական հղում, որը ձեզ մուտք կգործի:" + }, + "authorization": { + "generate_token": "Ստեղծել տոկեն", + "refresh_token": "Թարմացնել տոկենը", + "graphql_headers": "Authorization վերնագրերը ուղարկվում են որպես payload-ի մաս connection_init-ին", + "include_in_url": "Ներառել URL-ում", + "inherited_from": "Ժառանգված {auth} ծնող հավաքածուից {collection}", + "learn": "Իմանալ ինչպես", + "oauth": { + "redirect_auth_server_returned_error": "Auth սերվերը վերադարձրել է սխալի վիճակ", + "redirect_auth_token_request_failed": "Auth տոկեն ստանալու հարցումը ձախողվեց", + "redirect_auth_token_request_invalid_response": "Անվավեր պատասխան Token Endpoint-ից auth տոկեն խնդրելիս", + "redirect_invalid_state": "Վերահղման մեջ առկա է անվավեր State արժեք", + "redirect_no_auth_code": "Վերահղման մեջ առկա չէ Authorization կոդ", + "redirect_no_client_id": "Client ID սահմանված չէ", + "redirect_no_client_secret": "Client Secret սահմանված չէ", + "redirect_no_code_verifier": "Code Verifier սահմանված չէ", + "redirect_no_token_endpoint": "Token Endpoint սահմանված չէ", + "something_went_wrong_on_oauth_redirect": "Ինչ-որ բան սխալ գնաց OAuth վերահղման ժամանակ", + "something_went_wrong_on_token_generation": "Ինչ-որ բան սխալ գնաց տոկենի ստեղծման ժամանակ", + "token_generation_oidc_discovery_failed": "Տոկենի ստեղծման ձախողում՝ OpenID Connect Discovery-ն ձախողվեց", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Տոկենը հաջողությամբ ստացվեց", + "token_fetch_failed": "Տոկենի ստացումը ձախողվեց", + "validation_failed": "Վավերացումը ձախողվեց, խնդրում ենք ստուգել ձևի դաշտերը", + "no_refresh_token_present": "Refresh Token առկա չէ: Խնդրում ենք կրկին գործարկել տոկենի ստեղծման հոսքը", + "refresh_token_request_failed": "Refresh տոկենի հարցումը ձախողվեց", + "token_refreshed_successfully": "Տոկենը հաջողությամբ թարմացվեց", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Օգտագործել PKCE", + "label_implicit": "Implicit", + "label_password": "Գաղտնաբառ", + "label_username": "Օգտանուն", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials", + "label_send_as": "Client Authentication", + "label_send_in_body": "Ուղարկել հավատարմագրերը մարմնում", + "label_send_as_basic_auth": "Ուղարկել հավատարմագրերը որպես Basic Auth", + "enter_value": "Մուտքագրեք արժեք", + "auth_request": "Auth հարցում", + "token_request": "Տոկենի հարցում", + "refresh_request": "Թարմացման հարցում", + "send_in": "Ուղարկել" + }, + "pass_key_by": "փոխանցել", + "pass_by_query_params_label": "Հարցման պարամետրեր", + "pass_by_headers_label": "Վերնագրեր", + "password": "Գաղտնաբառ", + "save_to_inherit": "Խնդրում ենք պահպանել այս հարցումը որևէ հավաքածուում՝ թույլտվությունը ժառանգելու համար", + "token": "Տոկեն", + "access_token": "Մուտքի տոկեն", + "client_token": "Հաճախորդի տոկեն", + "client_secret": "Client Secret", + "timestamp": "Ժամանակի դրոշմ", + "host": "Հոսթ", + "type": "Թույլտվության տեսակը", + "username": "Օգտանուն", + "advance_config": "Ընդլայնված կազմաձևում", + "advance_config_description": "Hoppscotch-ը ավտոմատ կերպով լռելյայն արժեքներ է նշանակում որոշակի դաշտերի, եթե հստակ արժեք նշված չէ", + "algorithm": "Ալգորիթմ", + "payload": "Payload", + "secret": "Գաղտնիք", + "aws_signature": { + "access_key": "Access Key", + "secret_key": "Secret Key", + "service_name": "Ծառայության անուն", + "aws_region": "AWS տարածաշրջան", + "service_token": "Ծառայության տոկեն" + }, + "digest": { + "realm": "Realm", + "nonce": "Nonce", + "algorithm": "Ալգորիթմ", + "qop": "qop", + "nonce_count": "Nonce Count", + "client_nonce": "Client Nonce", + "opaque": "Opaque", + "disable_retry": "Անջատել հարցման կրկնությունը" + }, + "akamai": { + "headers_to_sign": "Ստորագրման վերնագրեր", + "max_body_size": "Մարմնի առավելագույն չափսը" + }, + "hawk": { + "id": "HAWK Auth ID", + "key": "HAWK Auth Key", + "ext": "ext", + "app": "app", + "dlg": "dlg", + "include": "Ներառել Payload հեշը" + }, + "jwt": { + "params_name": "Params Name", + "param_name": "Պարամետրի անուն", + "header_prefix": "Վերնագրի նախածանց", + "placeholder_request_header": "Հարցման վերնագրի նախածանց", + "placeholder_request_param": "Հարցման պարամետրերի անուն", + "secret_base64_encoded": "Գաղտնիքը Base64 կոդավորված է", + "headers": "JWT վերնագրեր", + "private_key": "Անձնական բանալի", + "placeholder_headers": "JWT վերնագրեր" + }, + "ntlm": { + "domain": "Դոմեն", + "workstation": "Աշխատանքային կայան", + "disable_retrying_request": "Անջատել հարցման կրկնությունը" + }, + "asap": { + "issuer": "Թողարկող", + "audience": "լսարան", + "expires_in": "Լրանում է", + "key_id": "Key ID", + "optional_config": "Լրացուցիչ կազմաձևում", + "subject": "Subject", + "additional_claims": "Լրացուցիչ պնդումներ" + } + }, + "collection": { + "title": "Հավաքածու", + "run": "Գործարկել հավաքածուն", + "created": "Հավաքածուն ստեղծված է", + "different_parent": "Հնարավոր չէ վերադասավորել հավաքածուն տարբեր ծնողի հետ", + "edit": "Խմբագրել հավաքածուն", + "import_or_create": "Ներմուծել կամ ստեղծել հավաքածու", + "import_collection": "Ներմուծել հավաքածու", + "invalid_name": "Խնդրում ենք տրամադրել անուն հավաքածուի համար", + "invalid_root_move": "Հավաքածուն արդեն արմատում է", + "moved": "Հաջողությամբ տեղափոխվեց", + "my_collections": "Անձնական հավաքածուներ", + "name": "Իմ նոր հավաքածուն", + "name_length_insufficient": "Հավաքածուի անունը պետք է լինի առնվազն 3 նիշ", + "new": "Նոր հավաքածու", + "order_changed": "Հավաքածուի հերթականությունը թարմացվել է", + "properties": "Հավաքածուի հատկություններ", + "properties_updated": "Հավաքածուի հատկությունները թարմացվել են", + "renamed": "Հավաքածուն վերանվանվել է", + "request_in_use": "Հարցումը օգտագործման մեջ է", + "save_as": "Պահպանել որպես", + "save_to_collection": "Պահպանել հավաքածուում", + "select": "Ընտրել հավաքածու", + "select_location": "Ընտրել վայրը", + "sorted": "Հավաքածուն տեսակավորված է", + "details": "Մանրամասներ", + "duplicated": "Հավաքածուն կրկնօրինակվել է" + }, + "confirm": { + "close_unsaved_tab": "Վստա՞հ եք, որ ցանկանում եք փակել այս ներդիրը:", + "close_unsaved_tabs": "Վստա՞հ եք, որ ցանկանում եք փակել բոլոր ներդիրները: {count} չպահպանված ներդիրներ կկորչեն:", + "delete_all_activity_logs": "Վստա՞հ եք, որ ցանկանում եք ջնջել գործունեության բոլոր մատյանները:", + "exit_team": "Վստա՞հ եք, որ ցանկանում եք լքել այս աշխատանքային տիրույթը:", + "logout": "Վստա՞հ եք, որ ցանկանում եք դուրս գալ:", + "remove_collection": "Վստա՞հ եք, որ ցանկանում եք ընդմիշտ ջնջել այս հավաքածուն:", + "remove_environment": "Վստա՞հ եք, որ ցանկանում եք ընդմիշտ ջնջել այս միջավայրը:", + "remove_folder": "Վստա՞հ եք, որ ցանկանում եք ընդմիշտ ջնջել այս թղթապանակը:", + "remove_history": "Վստա՞հ եք, որ ցանկանում եք ընդմիշտ ջնջել ողջ պատմությունը:", + "remove_request": "Վստա՞հ եք, որ ցանկանում եք ընդմիշտ ջնջել այս հարցումը:", + "remove_response": "Վստա՞հ եք, որ ցանկանում եք ընդմիշտ ջնջել այս պատասխանը:", + "remove_shared_request": "Վստա՞հ եք, որ ցանկանում եք ընդմիշտ ջնջել այս համօգտագործվող հարցումը:", + "remove_team": "Վստա՞հ եք, որ ցանկանում եք ջնջել այս աշխատանքային տիրույթը:", + "remove_telemetry": "Վստա՞հ եք, որ ցանկանում եք հրաժարվել Telemetry-ից:", + "request_change": "Վստա՞հ եք, որ ցանկանում եք չեղարկել ընթացիկ հարցումը, չպահպանված փոփոխությունները կկորչեն:", + "save_unsaved_tab": "Ցանկանու՞մ եք պահպանել այս ներդիրում կատարված փոփոխությունները:", + "sync": "Ցանկանու՞մ եք վերականգնել ձեր աշխատանքային տիրույթը ամպից: Սա կչեղարկի ձեր տեղական առաջընթացը:", + "delete_access_token": "Վստա՞հ եք, որ ցանկանում եք ջնջել մուտքի տոկենը {tokenLabel}:", + "delete_mock_server": "Վստա՞հ եք, որ ցանկանում եք ջնջել այս մոք սերվերը:" + }, + "context_menu": { + "add_parameters": "Ավելացնել պարամետրերին", + "open_request_in_new_tab": "Բացել հարցումը նոր ներդիրում", + "set_environment_variable": "Սահմանել որպես փոփոխական" + }, + "cookies": { + "modal": { + "cookie_expires": "Լրանում է", + "cookie_name": "Անուն", + "cookie_path": "Ուղի", + "cookie_string": "Cookie տող", + "cookie_value": "Արժեք", + "empty_domain": "Դոմենը դատարկ է", + "empty_domains": "Դոմենների ցանկը դատարկ է", + "enter_cookie_string": "Մուտքագրեք cookie տող", + "interceptor_no_support": "Ձեր ընտրած որսիչը (interceptor) չի աջակցում cookie-ներին: Ընտրեք այլ որսիչ և փորձեք կրկին:", + "managed_tab": "Կառավարվող", + "new_domain_name": "Նոր դոմենի անուն", + "no_cookies_in_domain": "Այս դոմենի համար cookie-ներ սահմանված չեն", + "raw_tab": "Raw", + "set": "Սահմանել cookie" + } + }, + "count": { + "currentValue": "Ընթացիկ արժեք {count}", + "description": "Նկարագրություն {count}", + "header": "Վերնագիր {count}", + "initialValue": "Սկզբնական արժեք {count}", + "key": "Բանալի {count}", + "message": "Հաղորդագրություն {count}", + "parameter": "Պարամետր {count}", + "protocol": "Արձանագրություն {count}", + "value": "Արժեք {count}", + "variable": "Փոփոխական {count}" + }, + "documentation": { + "add_description": "Ավելացնել նկարագրություն այս հավաքածուի համար այստեղ...", + "add_description_placeholder": "Ավելացնել նկարագրություն այստեղ...", + "add_request_description": "Ավելացնել նկարագրություն հարցման համար այստեղ...", + "auth": { + "access_key": "Access Key", + "access_token": "Access Token", + "add_to": "Ավելացնել", + "akamai_edgegrid": "Akamai EdgeGrid", + "algorithm": "Ալգորիթմ", + "api_key": "API Key", + "app_id": "App ID", + "auth_id": "Auth ID", + "auth_key": "Auth Key", + "auth_url": "Auth URL", + "aws_signature": "AWS Signature", + "basic_auth": "Basic Auth", + "bearer_token": "Bearer Token", + "client_id": "Client ID", + "client_nonce": "Client Nonce", + "client_secret": "Client Secret", + "client_token": "Client Token", + "delegation": "Պատվիրակում", + "digest_auth": "Digest Auth", + "extra_data": "Լրացուցիչ տվյալներ", + "grant_type": "Grant Type", + "hawk_auth": "HAWK Auth", + "headers_to_sign": "Ստորագրման վերնագրեր", + "host": "Հոսթ", + "include_payload_hash": "Ներառել Payload հեշը", + "jwt_auth": "JWT Auth", + "max_body_size": "Մարմնի առավելագույն չափսը", + "no_auth": "Առանց նույնականացման", + "nonce": "Nonce", + "oauth_2": "OAuth 2.0", + "opaque": "Opaque", + "password": "Գաղտնագր", + "payload": "Payload", + "qop": "QOP", + "realm": "Realm", + "region": "Տարածաշրջան", + "scope": "Scope", + "secret_key": "Secret Key", + "service_name": "Ծառայության անուն", + "timestamp": "Ժամանակի դրոշմ", + "title": "Նույնականացում", + "token_url": "Token URL", + "user": "Օգտատեր", + "username": "Օգտանուն" + }, + "body": { + "content_type": "Content Type", + "no_body": "Մարմին սահմանված չէ", + "title": "Մարմին" + }, + "copied_to_clipboard": "Պատճենվել է սեղմատախտակին:", + "curl": { + "click_to_load": "Սեղմեք cURL հրամանը բեռնելու համար", + "copied": "cURL հրամանը պատճենվեց սեղմատախտակին:", + "copy_to_clipboard": "Պատճենել սեղմատախտակին", + "generating": "cURL հրամանի ստեղծում...", + "load": "Բեռնել cURL", + "title": "cURL" + }, + "description": "Նկարագրություն", + "error_rendering_markdown": "Սխալ markdown-ի արտապատկերման ժամանակ:", + "fetching_documentation": "Փաստաթղթավորման ստացում...", + "generate": "Ստեղծել փաստաթղթավորում", + "generate_message": "Ներմուծեք ցանկացած Hoppscotch հավաքածու՝ API փաստաթղթավորում ստեղծելու համար:", + "headers": { + "no_headers": "Վերնագրեր սահմանված չեն", + "title": "Վերնագրեր" + }, + "hide_all_documentation": "Թաքցնել ամբողջ փաստաթղթավորումը", + "inherited_from": "Ժառանգված {name}-ից", + "inherited_with_type": "Ժառանգված {type}-ը {name}-ից", + "key": "Բանալի", + "loading": "Բեռնում...", + "loading_collection_data": "Հավաքածուի տվյալների բեռնում...", + "no": "Ոչ", + "no_collection_data": "Հավաքածուի տվյալներ առկա չեն", + "no_documentation_found": "Թղթապանակների կամ հարցումների համար փաստաթղթավորում չի գտնվել", + "no_request_data": "Հարցման տվյալներ առկա չեն", + "no_requests_or_folders": "Հարցումներ կամ թղթապանակներ չկան", + "not_set": "Սահմանված չէ", + "open_request_in_new_tab": "Բացել հարցումը նոր ներդիրում", + "parameters": { + "no_params": "Պարամետրեր սահմանված չեն", + "title": "Պարամետրեր" + }, + "percent_complete": "% ավարտված", + "processing_documentation": "Փաստաթղթավորման մշակում", + "publish": { + "already_published": "Այս հավաքածուն արդեն հրապարակված է", + "auto_sync": "Ավտոմատ սինքրոնիզացում հավաքածուի հետ", + "auto_sync_description": "Ավտոմատ թարմացնել հրապարակված փաստաթղթերը, երբ հավաքածուն փոխվի", + "button": "Հրապարակել", + "copy_url": "Պատճենել URL-ը", + "delete": "Ջնջել փաստաթղթավորումը", + "unpublish_doc": "Վստա՞հ եք, որ ցանկանում եք ապահրապարակել փաստաթղթավորումը:", + "delete_success": "Հրապարակված փաստաթղթավորումը հաջողությամբ ջնջվեց", + "doc_title": "Վերնագիր", + "doc_version": "Տարբերակ", + "edit_published_doc": "Խմբագրել հրապարակված փաստաթղթը", + "last_updated": "Վերջին թարմացումը", + "metadata": "Մետատվյալներ (JSON)", + "open_published_doc": "Բացել հրապարակված փաստաթղթավորումը նոր ներդիրում", + "publish_error": "Փաստաթղթավորման հրապարակումը ձախողվեց", + "publish_success": "Փաստաթղթավորումը հաջողությամբ հրապարակվեց:", + "published": "Հրապարակված է", + "published_url": "Հրապարակված URL", + "title": "Հրապարակել փաստաթղթավորումը", + "update_button": "Թարմացնել", + "update_error": "Փաստաթղթավորման թարմացումը ձախողվեց", + "update_published_docs": "Թարմացնել հրապարակված փաստաթղթերը", + "update_success": "Փաստաթղթավորումը հաջողությամբ թարմացվեց:", + "unpublish": "Ապահրապարակել", + "update_title": "Թարմացնել հրապարակված փաստաթղթավորումը", + "url_copied": "URL-ը պատճենվեց սեղմատախտակին:", + "view_published": "Դիտել հրապարակված փաստաթղթերը", + "view_title": "Դիտել հրապարակված փաստաթղթավորումը" + }, + "request_opened_in_new_tab": "Հարցումը բացվեց նոր ներդիրում:", + "response": { + "body": "Պատասխանի մարմին", + "copy": "Պատճենել պատասխանը", + "example_copied": "Պատասխանի օրինակը պատճենվեց սեղմատախտակին:", + "example_copy_failed": "Պատասխանի օրինակի պատճենումը ձախողվեց", + "headers": "Պատասխանի վերնագրեր", + "no_examples": "Պատասխանի օրինակներ առկա չեն", + "title": "Պատասխանի օրինակներ" + }, + "save_error": "Սխալ՝ փաստաթղթավորման պահպանման ժամանակ", + "save_success": "Փաստաթղթավորումը հաջողությամբ պահպանվեց", + "saved_items_status": "Պահպանվեց {success} տարր: Ձախողվեց {failure} տարրի պահպանումը:", + "show_all_documentation": "Ցուցադրել ամբողջ փաստաթղթավորումը", + "source": "Աղբյուր", + "title": "Փաստաթղթավորում", + "unsaved_changes": "Դուք ունեք {count} չպահպանված փոփոխություններ: Խնդրում ենք պահպանել փակելուց առաջ:", + "untitled_collection": "Անվերնագիր հավաքածու", + "untitled_request": "Անվերնագիր հարցում", + "value": "Արժեք", + "variables": { + "no_vars": "Փոփոխականներ սահմանված չեն", + "title": "Փոփոխականներ" + }, + "yes": "Այո" + }, + "empty": { + "activity_logs": "Գործունեության մատյաններ չեն գտնվել", + "authorization": "Այս հարցումը չի օգտագործում որևէ նույնականացում", + "body": "Այս հարցումը չունի մարմին", + "collection": "Հավաքածուն դատարկ է", + "collections": "Հավաքածուները դատարկ են", + "documentation": "Միացեք GraphQL վերջնակետին՝ փաստաթղթավորումը դիտելու համար", + "empty_schema": "Սխեմա չի գտնվել", + "endpoint": "Վերջնակետը չի կարող դատարկ լինել", + "environments": "Միջավայրերը դատարկ են", + "collection_variables": "Հավաքածուի փոփոխականները դատարկ են", + "folder": "Թղթապանակը դատարկ է", + "headers": "Այս հարցումը չունի վերնագրեր", + "history": "Պատմությունը դատարկ է", + "invites": "Հրավերների ցանկը դատարկ է", + "members": "Աշխատանքային տիրույթը դատարկ է", + "parameters": "Այս հարցումը չունի պարամետրեր", + "pending_invites": "Այս աշխատանքային տիրույթի համար սպասող հրավերներ չկան", + "profile": "Մուտք գործեք՝ ձեր պրոֆիլը դիտելու համար", + "protocols": "Արձանագրությունները դատարկ են", + "request_variables": "Այս հարցումը չունի հարցման փոփոխականներ", + "schema": "Միացեք GraphQL վերջնակետին՝ սխեման դիտելու համար", + "search_environment": "Համապատասխան միջավայր չի գտնվել", + "secret_environments": "Գաղտնիքները չեն սինքրոնացվում Hoppscotch-ի հետ", + "shared_requests": "Համօգտագործվող հարցումները դատարկ են", + "shared_requests_logout": "Մուտք գործեք՝ ձեր համօգտագործվող հարցումները դիտելու կամ նորը ստեղծելու համար", + "subscription": "Բաժանորդագրությունները դատարկ են", + "team_name": "Աշխատանքային տիրույթի անունը դատարկ է", + "teams": "Դուք ոչ մի աշխատանքային տիրույթի չեք պատկանում", + "tests": "Այս հարցման համար թեստեր չկան", + "access_tokens": "Մուտքի տոկենները դատարկ են", + "response": "Պատասխան չի ստացվել", + "mock_servers": "Մոք սերվերներ չեն գտնվել" + }, + "environment": { + "heading": "Միջավայր", + "add_to_global": "Ավելացնել գլոբալին", + "added": "Միջավայրի ավելացում", + "create_new": "Ստեղծել նոր միջավայր", + "created": "Միջավայրը ստեղծված է", + "current_value": "Ընթացիկ արժեք", + "deleted": "Միջավայրը ջնջվել է", + "duplicated": "Միջավայրը կրկնօրինակվել է", + "edit": "Խմբագրել միջավայրը", + "empty_variables": "Փոփոխականներ չկան", + "global": "Գլոբալ", + "global_variables": "Գլոբալ փոփոխականներ", + "import_or_create": "Ներմուծել կամ ստեղծել միջավայր", + "initial_value": "Սկզբնական արժեք", + "invalid_name": "Խնդրում ենք տրամադրել անուն միջավայրի համար", + "list": "Միջավայրի փոփոխականներ", + "my_environments": "Անձնական միջավայրեր", + "name": "Անուն", + "nested_overflow": "ներդրված միջավայրի փոփոխականները սահմանափակված են 10 մակարդակով", + "new": "Նոր միջավայր", + "no_active_environment": "Ակտիվ միջավայր չկա", + "no_environment": "Միջավայր չկա", + "no_environment_description": "Միջավայրեր ընտրված չեն: Ընտրեք՝ ինչ անել հետևյալ փոփոխականների հետ:", + "quick_peek": "Միջավայրի արագ դիտում", + "replace_all_current_with_initial": "Փոխարինել բոլոր ընթացիկները սկզբնականով", + "replace_all_initial_with_current": "Փոխարինել բոլոր սկզբնականները ընթացիկով", + "replace_current_with_initial": "Փոխարինել սկզբնականով", + "replace_initial_with_current": "Փոխարինել ընթացիկով", + "replace_with_variable": "Փոխարինել փոփոխականով", + "scope": "Scope", + "secrets": "Գաղտնիքներ", + "secret_value": "Գաղտնի արժեք", + "select": "Ընտրել միջավայր", + "set": "Սահմանել միջավայր", + "set_as_environment": "Սահմանել որպես միջավայր", + "short_name": "Միջավայրի անունը պետք է ունենա առնվազն 1 նիշ", + "team_environments": "Աշխատանքային տիրույթի միջավայրեր", + "title": "Միջավայրեր", + "updated": "Միջավայրը թարմացվել է", + "value": "Արժեք", + "variable": "Փոփոխական", + "variables": "Փոփոխականներ", + "variable_list": "Փոփոխականների ցանկ", + "properties": "Միջավայրի հատկություններ", + "details": "Մանրամասներ" + }, + "error": { + "network": { + "heading": "Ցանցային սխալ", + "description": "Ցանցային կապը ձախողվեց: {message}: {cause}" + }, + "timeout": { + "heading": "Ժամանակի սպառման սխալ", + "description": "Հարցման ժամանակը սպառվեց {phase}-ի ընթացքում: {message}" + }, + "certificate": { + "heading": "Վկայականի սխալ", + "description": "Անվավեր վկայական: {message}: {cause}" + }, + "auth": { + "heading": "Նույնականացման սխալ", + "description": "Մուտքն արգելված է: {message}: {cause}" + }, + "proxy": { + "heading": "Պրոքսիի սխալ", + "description": "Պրոքսիի կապը ձախողվեց: {message}: {cause}" + }, + "parse": { + "heading": "Վերլուծման սխալ", + "description": "Պատասխանի վերլուծությունը ձախողվեց: {message}: {cause}" + }, + "version": { + "heading": "Տարբերակի սխալ", + "description": "Անհամատեղելի տարբերակներ: {message}: {cause}" + }, + "abort": { + "heading": "Հարցումը ընդհատվեց", + "description": "Գործողությունը չեղարկվեց: {message}: {cause}" + }, + "unknown": { + "heading": "Անհայտ սխալ", + "description": "Տեղի ունեցավ անհայտ սխալ:", + "cause": "Անհայտ պատճառ" + }, + "extension": { + "heading": "Ընդլայնման սխալ", + "description": "Ընդլայնման վրա հարցման գործարկումը ձախողվեց" + }, + "authproviders_load_error": "Հնարավոր չէ բեռնել auth մատակարարներին", + "browser_support_sse": "Այս դիտարկիչը կարծես չունի Server Sent Events աջակցություն:", + "check_console_details": "Ստուգեք կոնսոլի մատյանը մանրամասների համար:", + "check_how_to_add_origin": "Ստուգեք, թե ինչպես կարող եք ավելացնել ծագման աղբյուր (origin)", + "curl_invalid_format": "cURL-ը ճիշտ ձևաչափված չէ", + "danger_zone": "Վտանգի գոտի", + "delete_account": "Ձեր հաշիվը ներկայումս միակ սեփականատերն է այս աշխատանքային տիրույթներում.", + "delete_account_description": "Դուք պետք է կամ հեռանաք, փոխանցեք սեփականությունը, կամ ջնջեք այս աշխատանքային տիրույթները, նախքան ձեր հաշիվը ջնջելը:", + "delete_activity_log": "Գործունեության մատյանի ջնջումը ձախողվեց", + "delete_all_activity_logs": "Գործունեության բոլոր մատյանների ջնջումը ձախողվեց", + "email_already_exists": "Այս էլ. փոստը արդեն գոյություն ունի այլ հաշվի հետ: Խնդրում ենք սահմանել այլ էլ. փոստի հասցե:", + "empty_email_address": "Էլ. փոստի հասցեն չի կարող դատարկ լինել", + "empty_profile_name": "Պրոֆիլի անունը չի կարող դատարկ լինել", + "empty_req_name": "Հարցման անունը դատարկ է", + "fetch_activity_logs": "Գործունեության մատյանների ստացումը ձախողվեց", + "f12_details": "(F12 մանրամասների համար)", + "gql_prettify_invalid_query": "Չհաջողվեց գեղեցկացնել անվավեր հարցումը, ուղղեք հարցման շարահյուսական սխալները և փորձեք կրկին", + "incomplete_config_urls": "Ոչ լիարժեք կազմաձևման URL-ներ", + "incorrect_email": "Սխալ էլ. փոստ", + "invalid_file_type": "Անվավեր ֆայլի տեսակ `{filename}`-ի համար:", + "invalid_link": "Անվավեր հղում", + "invalid_link_description": "Հղումը, որի վրա սեղմել եք, անվավեր է կամ ժամկետանց:", + "invalid_embed_link": "Ներդրումը (embed) գոյություն չունի կամ անվավեր է:", + "json_parsing_failed": "Անվավեր JSON", + "json_prettify_invalid_body": "Չհաջողվեց գեղեցկացնել անվավեր մարմինը, ուղղեք json շարահյուսական սխալները և փորձեք կրկին", + "network_error": "Կարծես ցանցային սխալ կա: Խնդրում ենք փորձել կրկին:", + "network_fail": "Հնարավոր չէ ուղարկել հարցումը", + "no_collections_to_export": "Արտահանման համար հավաքածուներ չկան: Սկսելու համար խնդրում ենք ստեղծել հավաքածու:", + "no_duration": "Տևողություն չկա", + "no_environments_to_export": "Արտահանման համար միջավայրեր չկան: Սկսելու համար խնդրում ենք ստեղծել միջավայր:", + "no_results_found": "Համընկնումներ չեն գտնվել", + "page_not_found": "Այս էջը չի գտնվել", + "please_install_extension": "Խնդրում ենք տեղադրել ընդլայնումը և ավելացնել ծագման աղբյուրը (origin) ընդլայնմանը:", + "proxy_error": "Պրոքսիի սխալ", + "same_email_address": "Թարմացված էլ. փոստի հասցեն նույնն է, ինչ ընթացիկ էլ. փոստի հասցեն", + "same_profile_name": "Թարմացված պրոֆիլի անունը նույնն է, ինչ ընթացիկ պրոֆիլի անունը", + "script_fail": "Հնարավոր չէ կատարել նախնական հարցման սկրիպտը", + "something_went_wrong": "Ինչ-որ բան սխալ գնաց", + "subscription_error": "Թեմային բաժանորդագրվելը ձախողվեց. {error}", + "post_request_script_fail": "Հնարավոր չէ կատարել հետ-հարցման սկրիպտը", + "reading_files": "Սխալ մեկ կամ ավելի ֆայլեր կարդալիս:", + "fetching_access_tokens_list": "Ինչ-որ բան սխալ գնաց տոկենների ցանկը ստանալիս", + "generate_access_token": "Ինչ-որ բան սխալ գնաց մուտքի տոկենը ստեղծելիս", + "delete_access_token": "Ինչ-որ բան սխալ գնաց մուտքի տոկենը ջնջելիս", + "extension_not_found": "Ընդլայնումը չի գտնվել", + "invalid_request": "Անվավեր հարցման տվյալներ" + }, + "export": { + "as_json": "Արտահանել որպես JSON", + "create_secret_gist": "Ստեղծել գաղտնի Gist", + "create_secret_gist_tooltip_text": "Արտահանել որպես գաղտնի Gist", + "failed": "Ինչ-որ բան սխալ գնաց արտահանման ժամանակ", + "secret_gist_success": "Հաջողությամբ արտահանվեց որպես գաղտնի Gist", + "require_github": "Մուտք գործեք GitHub-ով գաղտնի gist ստեղծելու համար", + "title": "Արտահանում", + "success": "Հաջողությամբ արտահանվեց" + }, + "file_upload": { + "choose_file": "Ընտրել ֆայլ", + "max_size_format": "Առավելագույնը 5ՄԲ: Աջակցվում են JPEG, PNG, GIF, WebP", + "profile_photo_updated": "Պրոֆիլի լուսանկարը հաջողությամբ թարմացվեց", + "profile_photo_removed": "Պրոֆիլի լուսանկարը հաջողությամբ հեռացվեց", + "org_logo_updated": "Կազմակերպության լոգոն հաջողությամբ թարմացվեց", + "error_size_limit": "Ֆայլի չափը պետք է լինի 5ՄԲ-ից պակաս", + "error_invalid_format": "Ֆայլը պետք է լինի պատկեր (JPEG, PNG, GIF կամ WebP)", + "error_invalid_upload_type": "Անվավեր վերբեռնման տեսակ", + "error_invalid_org_id": "Անվավեր կազմակերպության ID-ի ձևաչափ", + "error_upload_failed": "Վերբեռնումը ձախողվեց: Խնդրում ենք փորձել կրկին", + "error_network_failed": "Ցանցային սխալ: Խնդրում ենք ստուգել ձեր կապը", + "error_timeout": "Վերբեռնման ժամանակը սպառվեց 30 վայրկյանից: Խնդրում ենք փորձել կրկին", + "error_missing_backend_url": "Backend URL-ը կազմաձևված չէ: Խնդրում ենք սահմանել VITE_BACKEND_API_URL միջավայրի փոփոխականը ձեր միջավայրի կարգավորումներում", + "error_invalid_backend_url": "Անվավեր backend URL-ի կազմաձևում" + }, + "filename": { + "cookie_key_value_pairs": "Cookie", + "codegen": "{request_name} - կոդ", + "graphql_response": "GraphQL-Պատասխան", + "lens": "{request_name} - պատասխան", + "realtime_response": "Realtime-Պատասխան", + "response_interface": "Պատասխան-Ինտերֆեյս" + }, + "filter": { + "all": "Բոլորը", + "none": "Ոչ մեկը", + "starred": "Աստղանշված" + }, + "folder": { + "created": "Թղթապանակը ստեղծված է", + "edit": "Խմբագրել թղթապանակը", + "invalid_name": "Խնդրում ենք տրամադրել անուն թղթապանակի համար", + "name_length_insufficient": "Թղթապանակի անունը պետք է լինի առնվազն 3 նիշ", + "new": "Նոր թղթապանակ", + "run": "Գործարկել թղթապանակը", + "renamed": "Թղթապանակը վերանվանվել է", + "sorted": "Թղթապանակը տեսակավորված է" + }, + "graphql": { + "arguments": "Արգումենտներ", + "connection_switch_confirm": "Ցանկանու՞մ եք միանալ վերջին GraphQL վերջնակետին:", + "connection_error_http": "Չհաջողվեց ստանալ GraphQL սխեման ցանցային սխալի պատճառով:", + "connection_switch_new_url": "Ներդիրին անցնելը ձեզ կանջատի ակտիվ GraphQL կապից: Նոր կապի URL-ն է", + "connection_switch_url": "Դուք միացած եք GraphQL վերջնակետին, կապի URL-ն է", + "deprecated": "Հնացած", + "fields": "Դաշտեր", + "mutation": "Փոփոխություն (Mutation)", + "mutations": "Փոփոխություններ", + "schema": "Սխեմա", + "show_depricated_values": "Ցուցադրել հնացած արժեքները", + "subscription": "Բաժանորդագրություն", + "subscriptions": "Բաժանորդագրություններ", + "switch_connection": "Փոխել կապը", + "url_placeholder": "Մուտքագրեք GraphQL վերջնակետի URL", + "query": "Հարցում" + }, + "graphql_collections": { + "title": "GraphQL հավաքածուներ" + }, + "group": { + "time": "Ժամանակ", + "url": "URL" + }, + "header": { + "install_pwa": "Տեղադրել հավելվածը", + "login": "Մուտք", + "save_workspace": "Պահպանել իմ աշխատանքային տիրույթը" + }, + "helpers": { + "authorization": "Authorization վերնագիրը ավտոմատ կստեղծվի, երբ դուք ուղարկեք հարցումը:", + "collection_properties_authorization": " Այս թույլտվությունը կսահմանվի այս հավաքածուի յուրաքանչյուր հարցման համար:", + "collection_properties_header": "Այս վերնագիրը կսահմանվի այս հավաքածուի յուրաքանչյուր հարցման համար:", + "generate_documentation_first": "Նախ ստեղծեք փաստաթղթավորում", + "network_fail": "Հնարավոր չէ հասնել API վերջնակետին: Ստուգեք ձեր ցանցային կապը կամ ընտրեք այլ որսիչ և փորձեք կրկին:", + "offline": "Դուք օգտագործում եք Hoppscotch-ը օֆլայն ռեժիմում: Թարմացումները կսինքրոնացվեն, երբ դուք օնլայն լինեք, հիմնվելով աշխատանքային տիրույթի կարգավորումների վրա:", + "offline_short": "Դուք օգտագործում եք Hoppscotch-ը օֆլայն ռեժիմում:", + "post_request_tests": "Հետ-հարցման սկրիպտները գրված են JavaScript-ով և գործարկվում են պատասխանը ստանալուց հետո:", + "pre_request_script": "Նախնական հարցման սկրիպտները գրված են JavaScript-ով և գործարկվում են նախքան հարցումը ուղարկելը:", + "script_fail": "Կարծես թե խափանում կա նախնական հարցման սկրիպտում: Ստուգեք սխալը ներքևում և համապատասխանաբար ուղղեք սկրիպտը:", + "post_request_script_fail": "Կարծես թե սխալ կա հետ-հարցման սկրիպտի հետ: Խնդրում ենք ուղղել սխալները և կրկին գործարկել թեստերը:", + "post_request_script": "Գրեք հետ-հարցման սկրիպտ՝ վրիպազերծումը ավտոմատացնելու համար:" + }, + "hide": { + "collection": "Փոքրացնել հավաքածուի վահանակը", + "more": "Թաքցնել ավելին", + "preview": "Թաքցնել նախադիտումը", + "sidebar": "Փոքրացնել կողագոտին", + "password": "Թաքցնել գաղտնաբառը" + }, + "import": { + "collections": "Ներմուծել հավաքածուներ", + "curl": "Ներմուծել cURL", + "environments_from_gist": "Ներմուծել Gist-ից", + "environments_from_gist_description": "Ներմուծել Hoppscotch միջավայրեր Gist-ից", + "failed": "Սխալ ներմուծման ժամանակ՝ ձևաչափը չի ճանաչվել", + "from_file": "Ներմուծել ֆայլից", + "from_gist": "Ներմուծել Gist-ից", + "from_gist_description": "Ներմուծել Gist URL-ից", + "from_gist_import_summary": "Bolor Hoppscotch հնարավորությունները ներմուծված են:", + "from_hoppscotch_importer_summary": "Bolor Hoppscotch հնարավորությունները ներմուծված են:", + "from_insomnia": "Ներմուծել Insomnia-ից", + "from_insomnia_description": "Ներմուծել Insomnia հավաքածուից", + "from_insomnia_import_summary": "Հավաքածուները և հարցումները կներմուծվեն:", + "from_json": "Ներմուծել Hoppscotch-ից", + "from_json_description": "Ներմուծել Hoppscotch հավաքածուի ֆայլից", + "from_my_collections": "Ներմուծել անձնական հավաքածուներից", + "from_my_collections_description": "Ներմուծել անձնական հավաքածուների ֆայլից", + "from_all_collections": "Ներմուծել այլ աշխատանքային տիրույթից", + "from_all_collections_description": "Ներմուծել ցանկացած հավաքածու այլ աշխատանքային տիրույթից ընթացիկ աշխատանքային տիրույթ:", + "from_openapi": "Ներմուծել OpenAPI-ից", + "from_openapi_description": "Ներմուծել OpenAPI տեխնիկական պայմանների ֆայլից (YML/JSON)", + "from_openapi_import_summary": "Հավաքածուները (կստեղծվեն պիտակներից), Հարցումները և պատասխանի օրինակները կներմուծվեն:", + "from_postman": "Ներմուծել Postman-ից", + "from_postman_description": "Ներմուծել Postman հավաքածուից", + "from_postman_import_summary": "Հավաքածուները, Հարցումները և պատասխանի օրինակները կներմուծվեն:", + "import_scripts": "Ներմուծել սկրիպտներ", + "import_scripts_description": "Աջակցում է Postman Collection v2.0/v2.1:", + "from_url": "Ներմուծել URL-ից", + "gist_url": "Մուտքագրեք Gist URL", + "from_har": "Ներմուծել HAR-ից", + "from_har_description": "Ներմուծել HAR ֆայլից", + "from_har_import_summary": "Հարցումները կներմուծվեն լռելյայն հավաքածու:", + "gql_collections_from_gist_description": "Ներմուծել GraphQL հավաքածուներ Gist-ից", + "hoppscotch_environment": "Hoppscotch միջավայր", + "hoppscotch_environment_description": "Ներմուծել Hoppscotch միջավայրի JSON ֆայլ", + "import_from_url_invalid_fetch": "Չհաջողվեց ստանալ տվյալներ URL-ից", + "import_from_url_invalid_file_format": "Սխալ հավաքածուների ներմուծման ժամանակ", + "import_from_url_invalid_type": "Չաջակցվող տեսակ: Ընդունվող արժեքներն են 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Հավաքածուները ներմուծվել են", + "insomnia_environment_description": "Ներմուծել Insomnia միջավայր JSON/YAML ֆայլից", + "json_description": "Ներմուծել հավաքածուներ Hoppscotch հավաքածուների JSON ֆայլից", + "postman_environment": "Postman միջավայր", + "postman_environment_description": "Ներմուծել Postman միջավայր JSON ֆայլից", + "title": "Ներմուծել", + "file_size_limit_exceeded_warning_multiple_files": "Ընտրված ֆայլերը գերազանցում են {sizeLimit}ՄԲ առաջարկվող սահմանաչափը: Միայն առաջին {files} ընտրվածը կներմուծվեն", + "file_size_limit_exceeded_warning_single_file": "Ընթացիկ ընտրված ֆայլը գերազանցում է {sizeLimit}ՄԲ առաջարկվող սահմանաչափը: Խնդրում ենք ընտրել այլ ֆայլ:", + "success": "Հաջողությամբ ներմուծվեց", + "import_summary_collections_title": "Հավաքածուներ", + "import_summary_requests_title": "Հարցումներ", + "import_summary_responses_title": "Պատասխաններ", + "import_summary_pre_request_scripts_title": "Նախնական հարցման սկրիպտներ", + "import_summary_post_request_scripts_title": "Հետ-հարցման սկրիպտներ", + "import_summary_not_supported_by_hoppscotch_import": "Մենք հիմա չենք աջակցում {featureLabel}-ի ներմուծումը այս աղբյուրից:", + "import_summary_script_found": "սկրիպտ գտնվեց, բայց չներմուծվեց", + "import_summary_scripts_found": "սկրիպտներ գտնվեցին, բայց չներմուծվեցին", + "import_summary_enable_experimental_sandbox": "Postman սկրիպտները ներմուծելու համար միացրեք «Փորձարարական սկրիպտավորման ավազարկղը» կարգավորումներում: Նշում՝ Այս գործառույթը փորձարարական է:", + "cors_error_modal": { + "title": "CORS սխալ հայտնաբերվեց", + "description": "Ներմուծումը ձախողվեց սերվերի կողմից սահմանված CORS (Cross-Origin Resource Sharing) սահմանափակումների պատճառով:", + "explanation": "Սա անվտանգության միջոց է, որը կանխում է վեբ էջերին հարցումներ կատարել տարբեր դոմեններին: Դուք կարող եք կրկին փորձել օգտագործել մեր պրոքսի ծառայությունը՝ այս սահմանափակումը շրջանցելու համար:", + "url_label": "Փորձարկված URL", + "retry_with_proxy": "Կրկին փորձել պրոքսիով" + } + }, + "instances": { + "switch": "Փոխել Hoppscotch նմուշը", + "enter_server_url": "Միանալ ինքնասպասարկվող նմուշի", + "already_connected": "Դուք արդեն միացած եք այս նմուշին", + "recent_connections": "Վերջին կապերը", + "add_instance": "Ավելացնել նմուշ", + "add_new": "Ավելացնել նոր նմուշ", + "confirm_remove": "Հաստատել հեռացումը", + "remove_warning": "Վստա՞հ եք, որ ցանկանում եք հեռացնել այս նմուշը:", + "clear_cached_bundles": "Մաքրել քեշավորված փաթեթները", + "opening_add_modal": "Նմուշի ավելացման պատուհանի բացում", + "closed_add_modal": "Նմուշի ավելացման պատուհանը փակված է", + "cancelled_removal": "Նմուշի հեռացումը չեղարկվել է", + "connection_cancelled": "Կապը չեղարկվել է նախքան միացման վավերացումը", + "post_connect_completed": "Միացումից հետո կարգավորումը ավարտված է", + "connecting": "Միանում է նմուշին...", + "confirm_removal": "Հաստատեք նմուշի հեռացումը", + "removal_cancelled": "Նմուշի հեռացումը չեղարկվել է նախքան հեռացման վավերացումը", + "post_remove_completed": "Հեռացումից հետո մաքրումը ավարտված է", + "removing": "Նմուշի հեռացում...", + "clearing_cache": "Քեշի մաքրում...", + "initialized": "Նմուշի փոխարկիչը սկզբնավորված է", + "connecting_state": "Կապի հաստատում...", + "connected_state": "Հաջողությամբ միացված է նմուշին", + "disconnected_state": "Անջատված է նմուշից", + "stream_error": "Կապի վիճակի մոնիտորինգը ձախողվեց", + "recent_instances_error": "Չհաջողվեց բեռնել վերջին նմուշները", + "instance_changed": "Փոխարկվել է նմուշին", + "current_instance_error": "Չհաջողվեց հետևել ընթացիկ նմուշին", + "not_available": "Նմուշի փոխարկումը հասանելի չէ", + "cleanup_completed": "Նմուշի փոխարկիչի մաքրումը ավարտված է" + }, + "inspections": { + "description": "Ստուգել հնարավոր սխալները", + "environment": { + "add_environment": "Ավելացնել միջավայրին", + "add_environment_value": "Ավելացնել արժեք", + "empty_value": "Միջավայրի արժեքը դատարկ է '{variable}' փոփոխականի համար ", + "not_found": "Միջավայրի '{environment}' փոփոխականը չի գտնվել:" + }, + "header": { + "cookie": "Դիտարկիչը թույլ չի տալիս Hoppscotch-ին սահմանել Cookie վերնագրեր: Խնդրում ենք փոխարենը օգտագործել Authorization վերնագրեր: Այնուամենայնիվ, մեր Hoppscotch Desktop հավելվածը արդեն հասանելի է և աջակցում է Cookie-ներ:" + }, + "response": { + "401_error": "Խնդրում ենք ստուգել ձեր նույնականացման տվյալները:", + "404_error": "Խնդրում ենք ստուգել ձեր հարցման URL-ը և մեթոդի տեսակը:", + "cors_error": "Խնդրում ենք ստուգել ձեր Cross-Origin Resource Sharing կազմաձևումը:", + "default_error": "Խնդրում ենք ստուգել ձեր հարցումը:", + "network_error": "Խնդրում ենք ստուգել ձեր ցանցային կապը:" + }, + "title": "Տեսուչ", + "url": { + "extension_not_installed": "Ընդլայնումը տեղադրված չէ:", + "extension_unknown_origin": "Համոզվեք, որ ավելացրել եք API վերջնակետի ծագման աղբյուրը (origin) Hoppscotch դիտարկիչի ընդլայնման ցանկում:", + "extention_enable_action": "Միացնել դիտարկիչի ընդլայնումը", + "extention_not_enabled": "Ընդլայնումը միացված չէ:", + "localaccess_unsupported": "Ընթացիկ որսիչը չի աջակցում տեղական հասանելիություն, խնդրում ենք օգտագործել Agent, Extension որսիչներ կամ Desktop հավելվածը" + }, + "auth": { + "digest": "Digest նույնականացումն օգտագործելիս խորհուրդ է տրվում օգտագործել Agent որսիչը վեբ հավելվածում կամ Native որսիչը Desktop հավելվածում:", + "hawk": "Hawk նույնականացումն օգտագործելիս խորհուրդ է տրվում օգտագործել Agent որսիչը վեբ հավելվածում կամ Native որսիչը Desktop հավելվածում:" + }, + "body": { + "binary": "Ընթացիկ որսիչի միջոցով բինար տվյալների ուղարկումը դեռ չի աջակցվում:" + }, + "scripting_interceptor": { + "pre_request": "նախնական հարցման սկրիպտ", + "post_request": "հետ-հարցման սկրիպտ", + "both_scripts": "նախնական և հետ-հարցման սկրիպտներ", + "unsupported_interceptor": "Ձեր {scriptType}-ը օգտագործում է {apiUsed}: Սկրիպտի հուսալի կատարման համար անցեք Agent որսիչին (վեբ հավելված) կամ Native որսիչին (Desktop հավելված): {interceptor} որսիչը սահմանափակ աջակցություն ունի սկրիպտավորման հարցումների համար և կարող է չաշխատել այնպես, ինչպես սպասվում է:", + "same_origin_csrf_warning": "Անվտանգության նախազգուշացում. Ձեր {scriptType}-ը կատարում է same-origin հարցումներ՝ օգտագործելով {apiUsed}: Քանի որ այս հարթակը օգտագործում է cookie-ների վրա հիմնված նույնականացում, այս հարցումները ավտոմատ կերպով ներառում են ձեր նստաշրջանի cookie-ները, ինչը կարող է թույլ տալ վնասակար սկրիպտներին կատարել չարտոնված գործողություններ: Օգտագործեք Agent որսիչը same-origin հարցումների համար կամ գործարկեք միայն ձեր վստահելի սկրիպտները:" + } + }, + "interceptor": { + "native": { + "name": "Native", + "settings_title": "Native" + }, + "agent": { + "name": "Agent", + "settings_title": "Agent" + }, + "proxy": { + "name": "Proxy", + "settings_title": "Proxy" + }, + "browser": { + "name": "Browser", + "settings_title": "Browser" + }, + "extension": { + "name": "Extension", + "settings_title": "Extension" + } + }, + "layout": { + "collapse_collection": "Փոքրացնել կամ ընդլայնել հավաքածուները", + "collapse_sidebar": "Փոքրացնել կամ ընդլայնել կողագոտին", + "column": "Ուղղահայաց դասավորություն", + "name": "Դասավորություն", + "row": "Հորիզոնական դասավորություն" + }, + "modal": { + "close_unsaved_tab": "Դուք ունեք չպահպանված փոփոխություններ", + "collections": "Հավաքածուներ", + "confirm": "Հաստատել", + "customize_request": "Հարմարեցնել հարցումը", + "edit_request": "Խմբագրել հարցումը", + "edit_response": "Խմբագրել պատասխանը", + "import_export": "Ներմուծում / Արտահանում", + "response_name": "Պատասխանի անուն", + "share_request": "Կիսվել հարցումով" + }, + "mqtt": { + "already_subscribed": "Դուք արդեն բաժանորդագրված եք այս թեմային:", + "clean_session": "Մաքուր նստաշրջան", + "clear_input": "Մաքրել մուտքագրումը", + "clear_input_on_send": "Մաքրել մուտքագրումը ուղարկելիս", + "client_id": "Հաճախորդի ID", + "color": "Ընտրեք գույն", + "communication": "Հաղորդակցություն", + "connection_config": "Կապի կազմաձևում", + "connection_not_authorized": "Այս MQTT կապը չի օգտագործում որևէ նույնականացում:", + "invalid_topic": "Խնդրում ենք տրամադրել թեմա բաժանորդագրության համար", + "keep_alive": "Պահպանել ակտիվ (Keep Alive)", + "log": "Մատյան", + "lw_message": "Last-Will Հաղորդագրություն", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Թեմա", + "message": "Հաղորդագրություն", + "new": "Նոր բաժանորդագրություն", + "not_connected": "Խնդրում ենք նախ սկսել MQTT կապ:", + "publish": "Հրապարակել", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Բաժանորդագրվել", + "topic": "Թեմա", + "topic_name": "Թեմայի անուն", + "topic_title": "Հրապարակել / Բաժանորդագրվել թեմային", + "unsubscribe": "Ապաբաժանորդագրվել", + "url": "URL" + }, + "navigation": { + "admin_dashboard": "Կառավարման վահանակ", + "doc": "Փաստաթղթեր", + "graphql": "GraphQL", + "profile": "Պրոֆիլ", + "realtime": "Realtime", + "rest": "REST", + "mock_servers": "Մոք սերվերներ", + "settings": "Կարգավորումներ", + "goto_app": "Անցնել հավելվածին", + "authentication": "Նույնականացում" + }, + "mock_server": { + "confirm_delete_log": "Վստա՞հ եք, որ ցանկանում եք ջնջել այս մատյանը:", + "create_mock_server": "Կազմաձևել Մոք Սերվեր", + "mock_server_configuration": "Մոք Սերվերի կազմաձևում", + "mock_server_name": "Մոք Սերվերի անուն", + "mock_server_name_placeholder": "Մուտքագրեք մոք սերվերի անունը", + "base_url": "Հիմնական URL", + "start_server": "Սկսել սերվերը", + "stop_server": "Կանգնեցնել սերվերը", + "mock_server_created": "Մոք սերվերը հաջողությամբ ստեղծվեց", + "mock_server_started": "Մոք սերվերը հաջողությամբ գործարկվեց", + "mock_server_stopped": "Մոք սերվերը հաջողությամբ կանգնեցվեց", + "active": "Մոք սերվերը ակտիվ է", + "inactive": "Մոք սերվերը ակտիվ չէ", + "edit_mock_server": "Խմբագրել Մոք Սերվերը", + "path_based_url": "Ուղու վրա հիմնված URL", + "subdomain_based_url": "Ենթադոմենի վրա հիմնված URL", + "mock_server_updated": "Մոք սերվերը հաջողությամբ թարմացվեց", + "no_collection": "Հավաքածու չկա", + "collection_deleted": "կապակցված հավաքածուն ջնջված է:", + "private_access_hint": "Մասնավոր մոք սերվերների համար ներառեք 'x-api-key' վերնագիրը ձեր Personal Access Token-ի հետ (ստեղծեք այն ձեր պրոֆիլից):", + "private_access_instruction": "Այս մասնավոր մոք սերվերին մուտք գործելու համար ներառեք 'x-api-key' վերնագիրը ձեր Personal Access Token-ի հետ:", + "create_token_here": "Ստեղծել այստեղ", + "status": "Կարգավիճակ", + "server_running": "Սերվերը աշխատում է", + "server_stopped": "Սերվերը կանգնեցված է", + "delay_ms": "Պատասխանի ուշացում (մվ)", + "delay_placeholder": "Մուտքագրեք ուշացումը միլիվայրկյաններով", + "delay_description": "Ավելացնել արհեստական ուշացում է մոք պատասխաններին", + "public_access": "Հանրային հասանելիություն", + "public": "Հանրային", + "private": "Մասնավոր", + "public_description": "URL-ին տիրապետող յուրաքանչյուր ոք կարող է մուտք գործել այս մոք սերվեր", + "private_description": "Միայն նույնականացված օգտատերերը կարող են մուտք գործել այս մոք սերվեր", + "select_collection": "Ընտրել հավաքածու", + "select_collection_error": "Խնդրում ենք ընտրել հավաքածու", + "invalid_collection_error": "Չհաջողվեց ստեղծել մոք սերվեր հավաքածուի համար:", + "url_copied": "URL-ը պատճենվեց սեղմատախտակին", + "make_public": "Դարձնել հանրային", + "view_logs": "Դիտել մատյանները", + "logs_title": "Մոք Սերվերի մատյաններ", + "no_logs": "Մատյաններ առկա չեն", + "request_headers": "Հարցման վերնագրեր", + "request_body": "Հարցման մարմին", + "response_status": "Պատասխանի կարգավիճակ", + "response_headers": "Պատասխանի վերնագրեր", + "response_body": "Պատասխանի մարմին", + "log_deleted": "Մատյանը հաջողությամբ ջնջվեց", + "description": "Մոք սերվերները թույլ են տալիս սիմուլյացնել API պատասխանները՝ հիմնվելով ձեր հավաքածուի օրինակի պատասխանների վրա:", + "set_in_environment": "Սահմանել միջավայրում", + "set_in_environment_hint": "Մոք սերվերի URL-ը ավտոմատ կավելացվի որպես 'mockUrl' փոփոխական հավաքածուի միջավայրում", + "environment_variable_added": "Mock URL-ը ավելացվեց միջավայրին", + "environment_variable_updated": "Mock URL-ը թարմացվեց միջավայրում", + "environment_created_with_variable": "Միջավայրը ստեղծվեց mock URL-ով", + "add_example_request": "Ավելացնել օրինակելի հարցում", + "add_example_request_hint": "Հավաքածուն կստեղծվի նմուշային հարցումով, որը ցույց է տալիս, թե ինչպես օգտագործել մոք սերվերը", + "create_example_collection": "Ստեղծել օրինակելի հավաքածու", + "create_example_collection_hint": "Ստեղծել կենդանիների խանութի օրինակելի հավաքածու նմուշային հարցումներով (GET, POST, PUT, DELETE)", + "creating_example_collection": "Օրինակելի հավաքածուի ստեղծում...", + "failed_to_create_collection": "Չհաջողվեց ստեղծել օրինակելի հավաքածու", + "enable_example_collection_hint": "Խնդրում ենք միացնել «Ստեղծել օրինակելի հավաքածու» անջատիչը նոր հավաքածուի ռեժիմի համար", + "new_collection_name_hint": "Հավաքածուն կստեղծվի նույն անունով, ինչ ձեր մոք սերվերը", + "existing_collection": "Գոյություն ունեցող հավաքածու", + "new_collection": "Նոր հավաքածու" + }, + "preRequest": { + "javascript_code": "JavaScript կոդ", + "learn": "Կարդալ փաստաթղթավորումը", + "script": "Նախնական հարցման սկրիպտ", + "snippets": "Կոդի հատվածներ (Snippets)" + }, + "profile": { + "app_settings": "Հավելվածի կարգավորումներ", + "default_hopp_displayname": "Անանուն օգտատեր", + "editor": "Խմբագիր", + "editor_description": "Խմբագիրները կարող են ավելացնել, խմբագրել և ջնջել հարցումներ:", + "email_verification_mail": "Հաստատման էլ. նամակ է ուղարկվել ձեր էլ. փոստի հասցեին: Խնդրում ենք սեղմել հղմանը՝ ձեր էլ. փոստի հասցեն հաստատելու համար:", + "no_permission": "Դուք չունեք թույլտվություն այս գործողությունը կատարելու համար:", + "owner": "Սեփականատեր", + "owner_description": "Սեփականատերերը կարող են ավելացնել, խմբագրել և ջնջել հարցումներ, հավաքածուներ և աշխատանքային տիրույթի անդամներ:", + "roles": "Դերեր", + "roles_description": "Դերերը օգտագործվում են համօգտագործվող հավաքածուների հասանելիությունը վերահսկելու համար:", + "updated": "Պրոֆիլը թարմացվել է", + "viewer": "Դիտող", + "viewer_description": "Դիտողները կարող են միայն դիտել և օգտագործել հարցումները:", + "verified_email_sent": "Հաստատման էլ. նամակ է ուղարկվել ձեր էլ. փոստի հասցեին: Խնդրում ենք թարմացնել էջը ձեր էլ. փոստի հասցեն հաստատելուց հետո: Դուք կստանաք էլ. նամակ, եթե այս էլ. փոստը կապված չէ որևէ այլ հաշվի հետ:" + }, + "remove": { + "star": "Հեռացնել աստղը" + }, + "request": { + "added": "Հարցումը ավելացվել է", + "add": "Ավելացնել հարցում", + "authorization": "Նույնականացում", + "body": "Հարցման մարմին", + "choose_language": "Ընտրել լեզուն", + "content_type": "Content Type", + "content_type_titles": { + "others": "Այլ", + "structured": "Կառուցվածքային", + "text": "Տեքստ", + "binary": "Բինար" + }, + "show_content_type": "Ցուցադրել Content Type-ը", + "different_collection": "Հնարավոր չէ վերադասավորել տարբեր հավաքածուների հարցումները", + "duplicated": "Հարցումը կրկնօրինակվել է", + "duration": "Տևողություն", + "enter_curl": "Մուտքագրեք cURL հրաման", + "generate_code": "Ստեղծել կոդ", + "generated_code": "Ստեղծված կոդ", + "go_to_authorization_tab": "Անցնել Նույնականացման ներդիր", + "go_to_body_tab": "Անցնել Մարմնի ներդիր", + "header_list": "Վերնագրերի ցանկ", + "invalid_name": "Խնդրում ենք տրամադրել անուն հարցման համար", + "method": "Մեթոդ", + "moved": "Հարցումը տեղափոխվել է", + "name": "Հարցման անուն", + "new": "Նոր հարցում", + "order_changed": "Հարցման հերթականությունը թարմացվել է", + "override": "Գերազանցել (Override)", + "override_help": "Սահմանել Content-Type Վերնագրերում", + "overriden": "Գերազանցված է", + "parameter_list": "Հարցման պարամետրեր (Query Parameters)", + "parameters": "Պարամետրեր", + "path": "Ուղի", + "payload": "Payload", + "query": "Query", + "raw_body": "Raw Request Body", + "rename": "Վերանվանել հարցումը", + "renamed": "Հարցումը վերանվանվել է", + "request_variables": "Հարցման փոփոխականներ", + "response_name_exists": "Պատասխանի անունը արդեն գոյություն ունի", + "run": "Գործարկել", + "save": "Պահպանել", + "save_as": "Պահպանել որպես", + "saved": "Հարցումը պահպանվել է", + "share": "Կիսվել", + "share_description": "Կիսվեք Hoppscotch-ով ձեր ընկերների հետ", + "share_request": "Կիսվել հարցումով", + "stop": "Կանգնեցնել", + "title": "Հարցում", + "type": "Հարցման տեսակ", + "url": "URL", + "url_placeholder": "Մուտքագրեք URL կամ տեղադրեք cURL հրաման", + "variables": "Փոփոխականներ", + "view_my_links": "Դիտել իմ հղումները", + "generate_name_error": "Չհաջողվեց ստեղծել հարցման անունը:" + }, + "response": { + "audio": "Աուդիո", + "body": "Պատասխանի մարմին", + "duplicated": "Պատասխանը կրկնօրինակվել է", + "duplicate_name_error": "Նույն անունով պատասխան արդեն գոյություն ունի", + "filter_response_body": "Ֆիլտրել JSON պատասխանի մարմինը (օգտագործում է JSONPath շարահյուսություն)", + "headers": "Վերնագրեր", + "request_headers": "Հարցման վերնագրեր", + "html": "HTML", + "image": "Պատկեր", + "json": "JSON", + "pdf": "PDF", + "please_save_request": "Պահպանել հարցումը օրինակ ստեղծելու համար", + "preview_html": "Նախադիտել HTML", + "raw": "Raw", + "renamed": "Պատասխանը վերանվանվել է", + "same_name_inspector_warning": "Այս հարցման համար արդեն կա պատասխանի անուն, եթե պահպանվի, այն կվերագրի գոյություն ունեցող պատասխանը", + "size": "Չափ", + "status": "Կարգավիճակ", + "time": "Ժամանակ", + "title": "Պատասխան", + "video": "Տեսանյութ", + "waiting_for_connection": "սպասում է միացմանը", + "xml": "XML", + "generate_data_schema": "Ստեղծել տվյալների սխեմա", + "data_schema": "Տվյալների սխեմա", + "saved": "Պատասխանը պահպանվել է", + "invalid_name": "Խնդրում ենք տրամադրել անուն պատասխանի համար" + }, + "settings": { + "accent_color": "Շեշտադրման գույն", + "account": "Հաշիվ", + "account_deleted": "Ձեր հաշիվը ջնջվել է", + "account_description": "Հարմարեցրեք ձեր հաշվի կարգավորումները:", + "account_email_description": "Ձեր հիմնական էլ. փոստի հասցեն:", + "account_name_description": "Սա ձեր ցուցադրվող անունն է:", + "additional": "Լրացուցիչ կարգավորումներ", + "agent_not_running": "Hoppscotch Agent-ը չի հայտնաբերվել: Խնդրում ենք ստուգել, արդյոք Agent-ը աշխատում է:", + "agent_not_running_short": "Ստուգեք Agent-ի կարգավիճակը:", + "agent_running": "Hoppscotch Agent-ը ակտիվ է:", + "agent_running_short": "Hoppscotch Agent-ը ակտիվ է:", + "agent_discard_registration": "Չեղարկել Agent-ի գրանցումը", + "agent_registered": "Agent-ը գրանցված է", + "agent_registration_successful": "Agent-ը հաջողությամբ գրանցվել է", + "agent_registration_fetch_failed": "Չհաջողվեց ստանալ Agent-ի գրանցման տեղեկատվությունը: Խնդրում ենք վերագրանցել Agent-ը:", + "agent_registration_already_in_progress": "Agent-ի գրանցումը արդեն ընթացքի մեջ է: Խնդրում ենք ավարտել կամ չեղարկել ընթացիկ գրանցումը և փորձել կրկին:", + "auto_encode_mode": "Ավտոմատ", + "auto_encode_mode_tooltip": "Կոդավորել հարցման պարամետրերը միայն այն դեպքում, եթե առկա են որոշ հատուկ նիշեր", + "background": "Ֆոն", + "black_mode": "Սև", + "choose_language": "Ընտրել լեզուն", + "dark_mode": "Մուգ", + "delete_account": "Ջնջել հաշիվը", + "delete_account_description": "Երբ ջնջեք ձեր հաշիվը, ձեր բոլոր տվյալները ընդմիշտ կջնջվեն: Այս գործողությունը հնարավոր չէ հետ շրջել:", + "disable_encode_mode_tooltip": "Երբեք չկոդավորել հարցման պարամետրերը", + "enable_encode_mode_tooltip": "Միշտ կոդավորել հարցման պարամետրերը", + "enter_otp": "Մուտքագրեք Agent-ի կոդը", + "expand_navigation": "Ընդլայնել նավիգացիան", + "experiments": "Փորձարկումներ", + "experiments_notice": "Սա փորձարկումների հավաքածու է, որոնց վրա մենք աշխատում ենք, և որոնք կարող են լինել օգտակար, զվարճալի, երկուսն էլ կամ ոչ մեկը: Դրանք վերջնական չեն և կարող են կայուն չլինել, ուստի եթե ինչ-որ տարօրինակ բան պատահի, խուճապի մի մատնվեք: Ուղղակի անջատեք այդ անիծյալ բանը: Կատակները մի կողմ, ", + "extension_ver_not_reported": "Չի հաղորդվել", + "extension_version": "Ընդլայնման տարբերակ", + "extensions": "Դիտարկիչի ընդլայնում", + "extensions_use_toggle": "Օգտագործել դիտարկիչի ընդլայնումը հարցումներ ուղարկելու համար (եթե առկա է)", + "follow": "Հետևեք մեզ", + "general": "Ընդհանուր", + "general_description": " Հավելվածում օգտագործվող ընդհանուր կարգավորումներ", + "interceptor": "Որսիչ (Interceptor)", + "interceptor_description": "Միջնորդ ծրագիր՝ հավելվածի և API-ների միջև:", + "kernel_interceptor": "Որսիչ (Interceptor)", + "kernel_interceptor_description": "Միջնորդ ծրագիր՝ հավելվածի և API-ների միջև:", + "language": "Լեզու", + "light_mode": "Լուսավոր", + "official_proxy_hosting": "Պաշտոնական Proxy-ն հյուրընկալվում է Hoppscotch-ի կողմից:", + "query_parameters_encoding": "Հարցման պարամետրերի կոդավորում", + "query_parameters_encoding_description": "Կազմաձևել հարցման պարամետրերի կոդավորումը հարցումներում", + "profile": "Պրոֆիլ", + "profile_description": "Թարմացրեք ձեր պրոֆիլի մանրամասները", + "profile_email": "Էլ. փոստի հասցե", + "profile_name": "Պրոֆիլի անուն", + "profile_photo": "Պրոֆիլի լուսանկար", + "proxy": "Պրոքսի", + "proxy_url": "Պրոքսի URL", + "proxy_use_toggle": "Օգտագործել պրոքսի միջնորդ ծրագիրը հարցումներ ուղարկելու համար", + "read_the": "Կարդալ", + "register_agent": "Գրանցել Agent-ը", + "reset_default": "Օգտագործել լռելյայն Proxy", + "short_codes": "Կարճ կոդեր", + "short_codes_description": "Կարճ կոդեր, որոնք ստեղծվել են ձեր կողմից:", + "sidebar_on_left": "Կողագոտին ձախից", + "ai_experiments": "AI Փորձարկումներ", + "ai_request_naming_style": "Հարցման անվանման ոճ", + "ai_request_naming_style_descriptive_with_spaces": "Նկարագրական բացատներով", + "ai_request_naming_style_camel_case": "Camel Case ( camelCase )", + "ai_request_naming_style_snake_case": "Snake Case ( snake_case )", + "ai_request_naming_style_pascal_case": "Pascal Case ( PascalCase )", + "ai_request_naming_style_custom": "Հարմարեցված", + "ai_request_naming_style_custom_placeholder": "Մուտքագրեք ձեր հարմարեցված անվանման ոճի ձևանմուշը...", + "experimental_scripting_sandbox": "Փորձարարական սկրիպտավորման ավազարկղ", + "enable_experimental_mock_servers": "Միացնել Մոք Սերվերները", + "enable_experimental_documentation": "Միացնել Փաստաթղթավորումը", + "sync": "Սինքրոնացնել", + "sync_collections": "Հավաքածուներ", + "sync_description": "Այս կարգավորումները սինքրոնացվում են ամպի հետ:", + "sync_environments": "Միջավայրեր", + "sync_history": "Պատմություն", + "history_disabled": "Պատմությունը անջատված է: Կապվեք ձեր կազմակերպության ադմինիստրատորի հետ՝ պատմությունը միացնելու համար", + "system_mode": "Համակարգային", + "telemetry": "Հեռուստաչափություն", + "telemetry_helps_us": "Հեռուստաչափությունը օգնում է մեզ անհատականացնել մեր գործողությունները և մատուցել ձեզ լավագույն փորձը:", + "theme": "Թեմա", + "theme_description": "Հարմարեցրեք ձեր հավելվածի թեման:", + "use_experimental_url_bar": "Օգտագործել փորձարարական URL վահանակը միջավայրի ընդգծմամբ", + "user": "Օգտատեր", + "verified_email": "Հաստատված էլ. փոստ", + "verify_email": "Հաստատել էլ. փոստը", + "validate_certificates": "Վավերացնել SSL/TLS Վկայականները", + "verify_host": "Ստուգել Հոսթը", + "verify_peer": "Ստուգել Peer-ը", + "follow_redirects": "Հետևել ուղղորդումներին", + "client_certificates": "Կլիենտի վկայականներ", + "certificate_settings": "Վկայականի կարգավորումներ", + "certificate": "Վկայական", + "key": "Գաղտնի բանալի (Private Key)", + "pfx_or_p12": "PFX/PKCS#12", + "password": "Գաղտնաբառ", + "select_file": "Ընտրել ֆայլ", + "domain": "Դոմեն", + "add_certificate": "Ավելացնել վկայական", + "add_cert_file": "Ավելացնել վկայականի ֆայլ", + "add_key_file": "Ավելացնել բանալու ֆայլ", + "add_pfx_file": "Ավելացնել PFX ֆայլ", + "global_defaults": "Գլոբալ կանխադրվածներ", + "add_domain_override": "Ավելացնել դոմենի վերասահմանում", + "domain_override": "Դոմենի վերասահմանում", + "manage_domains_overrides": "Կառավարել դոմենների վերասահմանումները", + "add_domain": "Ավելացնել դոմեն", + "remove_domain": "Հեռացնել դոմենը", + "ca_certificate": "CA Վկայական", + "ca_certificates": "CA Վկայականներ", + "ca_certificates_support": "Hoppscotch-ը աջակցում է .crt, .cer կամ .pem ֆայլերին, որոնք պարունակում են մեկ կամ ավելի վկայականներ:", + "proxy_capabilities": "Hoppscotch Agent-ը և Desktop հավելվածը աջակցում են HTTP/HTTPS/SOCKS պրոքսիներին NTLM և Basic Auth աջակցությամբ:", + "proxy_auth": "Դուք կարող եք նաև ներառել օգտանուն և գաղտնաբառ URL-ում:" + }, + "shared_requests": { + "button": "Կոճակ", + "button_info": "Ստեղծեք 'Run in Hoppscotch' կոճակ ձեր կայքի, բլոգի կամ README-ի համար:", + "copy_html": "Պատճենել HTML", + "copy_link": "Պատճենել հղումը", + "copy_markdown": "Պատճենել Markdown", + "creating_widget": "Վիջեթի ստեղծում", + "customize": "Հարմարեցնել", + "deleted": "Համօգտագործվող հարցումը ջնջվել է", + "description": "Ընտրեք վիջեթ, դուք կարող եք փոխել և հարմարեցնել այն ավելի ուշ", + "embed": "Ներդնել (Embed)", + "embed_info": "Ավելացրեք մինի 'Hoppscotch API Playground' ձեր կայքում, բլոգում կամ փաստաթղթերում:", + "link": "Հղում", + "link_info": "Ստեղծեք համօգտագործելի հղում՝ դիտելու իրավունքով ինտերնետում ցանկացած մեկի հետ կիսվելու համար:", + "modified": "Համօգտագործվող հարցումը փոփոխվել է", + "not_found": "Համօգտագործվող հարցումը չի գտնվել", + "open_new_tab": "Բացել նոր ներդիրում", + "preview": "Նախադիտում", + "run_in_hoppscotch": "Գործարկել Hoppscotch-ում", + "theme": { + "dark": "Մուգ", + "light": "Լուսավոր", + "system": "Համակարգային", + "title": "Թեմա" + }, + "action": "Գործողություն", + "clear_filter": "Մաքրել ֆիլտրը", + "confirm_request_deletion": "Հաստատե՞լ ընտրված համօգտագործվող հարցման ջնջումը:", + "copy": "Պատճենել", + "created_on": "Ստեղծվել է", + "delete": "Ջնջել", + "email": "Էլ. փոստ", + "filter": "Ֆիլտր", + "filter_by_email": "Ֆիլտրել ըստ էլ. փոստի", + "id": "ID", + "load_list_error": "Չհաջողվեց բեռնել համօգտագործվող հարցումների ցանկը", + "no_requests": "Համօգտագործվող հարցումներ չեն գտնվել", + "open_request": "Բացել հարցումը", + "properties": "Հատկություններ", + "request": "Հարցում", + "show_more": "Ցուցադրել ավելին", + "title": "Համօգտագործվող հարցումներ", + "url": "URL" + }, + "shortcut": { + "general": { + "close_current_menu": "Փակել ընթացիկ ընտրացանկը", + "command_menu": "Որոնման և հրամանների ընտրացանկ", + "help_menu": "Օգնության ընտրացանկ", + "show_all": "Ստեղնաշարի դյուրանցումներ", + "title": "Ընդհանուր", + "comment_uncomment": "Մեկնաբանել/Հանել մեկնաբանությունը", + "close_tab": "Փակել ներդիրը", + "undo": "Հետարկել (Undo)", + "redo": "Վերարկել (Redo)" + }, + "miscellaneous": { + "invite": "Հրավիրել մարդկանց Hoppscotch", + "title": "Տարբեր" + }, + "navigation": { + "back": "Վերադառնալ նախորդ էջ", + "documentation": "Անցնել Փաստաթղթերի էջ", + "forward": "Անցնել հաջորդ էջ", + "graphql": "Անցնել GraphQL էջ", + "profile": "Անցնել Պրոֆիլի էջ", + "realtime": "Անցնել Realtime էջ", + "rest": "Անցնել REST էջ", + "settings": "Անցնել Կարգավորումների էջ", + "title": "Նավիգացիա" + }, + "others": { + "prettify": "Գեղեցկացնել խմբագրիչի պարունակությունը", + "title": "Այլ" + }, + "request": { + "delete_method": "Ընտրել DELETE մեթոդը", + "get_method": "Ընտրել GET մեթոդը", + "head_method": "Ընտրել HEAD մեթոդը", + "import_curl": "Ներմուծել cURL", + "method": "Մեթոդ", + "next_method": "Ընտրել հաջորդ մեթոդը", + "post_method": "Ընտրել POST մեթոդը", + "previous_method": "Ընտրել նախորդ մեթոդը", + "put_method": "Ընտրել PUT մեթոդը", + "rename": "Վերանվանել հարցումը", + "reset_request": "Վերականգնել հարցումը", + "save_request": "Պահպանել հարցումը", + "save_to_collections": "Պահպանել հավաքածուներում", + "send_request": "Ուղարկել հարցումը", + "share_request": "Կիսվել հարցումով", + "show_code": "Ստեղծել կոդի հատված", + "title": "Հարցում", + "focus_url": "Կենտրոնանալ URL վահանակի վրա" + }, + "response": { + "copy": "Պատճենել պատասխանը սեղմատախտակին", + "download": "Ներբեռնել պատասխանը որպես ֆայլ", + "title": "Պատասխան" + }, + "tabs": { + "title": "Ներդիրներ", + "new_tab": "Նոր ներդիր", + "close_tab": "Փակել ներդիրը", + "reopen_tab": "Վերաբացել փակված ներդիրը", + "next_tab": "Հաջորդ ներդիր", + "previous_tab": "Նախորդ ներդիր", + "first_tab": "Անցնել առաջին ներդիրին", + "last_tab": "Անցնել վերջին ներդիրին", + "mru_switch": "Անցնել վերջին օգտագործված ներդիրին (MRU)", + "mru_switch_reverse": "Անցնել նախորդ վերջին օգտագործված ներդիրին (MRU)" + }, + "theme": { + "black": "Փոխել թեման Սև ռեժիմի", + "dark": "Փոխել թեման Մուգ ռեժիմի", + "light": "Փոխել թեման Լուսավոր ռեժիմի", + "system": "Փոխել թեման Համակարգային ռեժիմի", + "title": "Թեմա" + } + }, + "show": { + "code": "Ցուցադրել կոդը", + "collection": "Ընդլայնել Հավաքածուի վահանակը", + "more": "Ցուցադրել ավելին", + "sidebar": "Ընդլայնել կողագոտին", + "password": "Ցուցադրել գաղտնաբառը" + }, + "socketio": { + "communication": "Հաղորդակցություն", + "connection_not_authorized": "Այս SocketIO կապը չի օգտագործում որևէ նույնականացում:", + "event_name": "Իրադարձության/Թեմայի անուն", + "events": "Իրադարձություններ", + "log": "Մատյան", + "url": "URL" + }, + "spotlight": { + "change_language": "Փոխել լեզուն", + "environments": { + "delete": "Ջնջել ընթացիկ միջավայրը", + "duplicate": "Կրկնօրինակել ընթացիկ միջավայրը", + "duplicate_global": "Կրկնօրինակել գլոբալ միջավայրը", + "edit": "Խմբագրել ընթացիկ միջավայրը", + "edit_global": "Խմբագրել գլոբալ միջավայրը", + "new": "Ստեղծել նոր միջավայր", + "new_variable": "Ստեղծել նոր միջավայրի փոփոխական", + "title": "Միջավայրեր" + }, + "general": { + "chat": "Զրուցել աջակցության հետ", + "help_menu": "Օգնություն և աջակցություն", + "open_docs": "Կարդալ Փաստաթղթավորումը", + "open_github": "Բացել GitHub ռեպոզիտորիան", + "open_keybindings": "Ստեղնաշարի դյուրանցումներ", + "social": "Սոցիալական", + "title": "Ընդհանուր" + }, + "graphql": { + "connect": "Միանալ սերվերին", + "disconnect": "Անջատվել սերվերից" + }, + "miscellaneous": { + "invite": "Հրավիրեք ձեր ընկերներին Hoppscotch", + "title": "Տարբեր" + }, + "request": { + "save_as_new": "Պահպանել որպես նոր հարցում", + "select_method": "Ընտրել մեթոդ", + "switch_to": "Փոխարկել", + "tab_authorization": "Նույնականացման ներդիր", + "tab_body": "Մարմնի ներդիր", + "tab_headers": "Վերնագրերի ներդիր", + "tab_parameters": "Պարամետրերի ներդիր", + "tab_pre_request_script": "Նախնական հարցման սկրիպտի ներդիր", + "tab_query": "Query ներդիր", + "tab_tests": "Թեստերի ներդիր", + "tab_variables": "Փոփոխականների ներդիր" + }, + "response": { + "copy": "Պատճենել պատասխանը", + "download": "Ներբեռնել պատասխանը որպես ֆայլ", + "title": "Պատասխան" + }, + "section": { + "interceptor": "Որսիչ (Interceptor)", + "interface": "Ինտերֆեյս", + "theme": "Թեմա", + "user": "Օգտատեր" + }, + "settings": { + "change_interceptor": "Փոխել Որսիչը (Interceptor)", + "change_language": "Փոխել Լեզուն", + "theme": { + "black": "Սև", + "dark": "Մուգ", + "light": "Լուսավոր", + "system": "Համակարգային նախապատվություն" + } + }, + "tab": { + "close_current": "Փակել ընթացիկ ներդիրը", + "close_others": "Փակել մյուս բոլոր ներդիրները", + "duplicate": "Կրկնօրինակել ընթացիկ ներդիրը", + "new_tab": "Բացել նոր ներդիր", + "next": "Անցնել հաջորդ ներդիրին", + "previous": "Անցնել նախորդ ներդիրին", + "switch_to_first": "Անցնել առաջին ներդիրին", + "switch_to_last": "Անցնել վերջին ներդիրին", + "mru_switch": "Անցնել վերջին օգտագործված ներդիրին", + "mru_switch_reverse": "Անցնել նախորդ վերջին օգտագործված ներդիրին", + "title": "Ներդիրներ" + }, + "workspace": { + "delete": "Ջնջել ընթացիկ աշխատանքային տիրույթը", + "edit": "Խմբագրել ընթացիկ աշխատանքային տիրույթը", + "invite": "Հրավիրել մարդկանց աշխատանքային տիրույթ", + "new": "Ստեղծել նոր աշխատանքային տիրույթ", + "switch_to_personal": "Անցնել ձեր անձնական աշխատանքային տիրույթին", + "title": "Աշխատանքային տիրույթներ" + }, + "phrases": { + "try": "Փորձել", + "import_collections": "Ներմուծել հավաքածուներ", + "create_environment": "Ստեղծել միջավայր", + "create_workspace": "Ստեղծել աշխատանքային տիրույթ", + "share_request": "Կիսվել հարցումով" + } + }, + "sse": { + "event_type": "Իրադարձության տեսակ", + "log": "Մատյան", + "url": "URL" + }, + "state": { + "bulk_mode": "Զանգվածային խմբագրում", + "bulk_mode_placeholder": "Գրառումները առանձնացված են նոր տողով\nԲանալիները և արժեքները առանձնացված են : -ով\nԱվելացրեք # ցանկացած տողի սկզբում, որը ցանկանում եք ավելացնել, բայց պահել անջատված", + "cleared": "Մաքրված", + "connected": "Միացված", + "connected_to": "Միացված է {name}-ին", + "connecting_to": "Միանում է {name}-ին...", + "connection_error": "Չհաջողվեց միանալ", + "connection_failed": "Կապը ձախողվեց", + "connection_lost": "Կապը կորել է", + "copied_interface_to_clipboard": "{language} ինտերֆեյսի տեսակը պատճենվեց սեղմատախտակին", + "copied_to_clipboard": "Պատճենվեց սեղմատախտակին", + "deleted": "Ջնջված է", + "deprecated": "Հնացած (DEPRECATED)", + "disabled": "Անջատված", + "disconnected": "Անջատված", + "disconnected_from": "Անջատված է {name}-ից", + "docs_generated": "Փաստաթղթավորումը ստեղծված է", + "download_failed": "Ներբեռնումը ձախողվեց", + "download_started": "Ներբեռնումը սկսվեց", + "enabled": "Միացված", + "experimental": "Փորձարարական", + "file_imported": "Ֆայլը ներմուծված է", + "finished_in": "Ավարտվեց {duration} մվ-ում", + "hide": "Թաքցնել", + "history_deleted": "Պատմությունը ջնջված է", + "linewrap": "Տողադարձ", + "loading": "Բեռնում...", + "message_received": "Հաղորդագրություն. {message}-ը ստացվել է {topic} թեմայով", + "mqtt_subscription_failed": "Ինչ-որ բան սխալ գնաց {topic} թեմային բաժանորդագրվելիս", + "no_content_found": "Բովանդակություն չի գտնվել", + "none": "Ոչինչ", + "nothing_found": "Ոչինչ չի գտնվել", + "published_error": "Ինչ-որ բան սխալ գնաց {topic} հաղորդագրությունը {message} թեմային հրապարակելիս", + "published_message": "Հրապարակված հաղորդագրություն. {message} {topic} թեմային", + "reconnection_error": "Չհաջողվեց վերամիանալ", + "saved": "Պահպանված է", + "show": "Ցուցադրել", + "subscribed_failed": "Չհաջողվեց բաժանորդագրվել {topic} թեմային", + "subscribed_success": "Հաջողությամբ բաժանորդագրվել է {topic} թեմային", + "unsubscribed_failed": "Չհաջողվեց ապաբաժանորդագրվել {topic} թեմայից", + "unsubscribed_success": "Հաջողությամբ ապաբաժանորդագրվել է {topic} թեմայից", + "waiting_send_request": "Սպասում է հարցման ուղարկմանը", + "user_deactivated": "Ձեր հաշիվը ապաակտիվացված է: Խնդրում ենք կապվել ադմինիստրատորի հետ՝ ձեր հաշիվը վերաակտիվացնելու համար:", + "add_user_failure": "Չհաջողվեց ավելացնել օգտատիրոջը աշխատանքային տիրույթում!!", + "add_user_success": "Օգտատերը այժմ աշխատանքային տիրույթի անդամ է!!", + "admin_failure": "Չհաջողվեց օգտատիրոջը դարձնել ադմին!!", + "admin_success": "Օգտատերը այժմ ադմին է!!", + "and": "և", + "clear_selection": "Մաքրել ընտրությունը", + "configure_auth": "Խնդրում ենք կարգավորել auth մատակարարին ադմինի կարգավորումներից կամ ստուգել փաստաթղթավորումը՝ auth մատակարարներին կազմաձևելու համար:", + "confirm_admin_to_user": "Ցանկանու՞մ եք հեռացնել ադմինի կարգավիճակը այս օգտատիրոջից:", + "confirm_admins_to_users": "Ցանկանու՞մ եք հեռացնել ադմինի կարգավիճակը ընտրված օգտատերերից:", + "confirm_delete_infra_token": "Վստա՞հ եք, որ ցանկանում եք ջնջել infra {tokenLabel} տոկենը:", + "confirm_delete_invite": "Ցանկանու՞մ եք չեղարկել ընտրված հրավերը:", + "confirm_delete_invites": "Ցանկանու՞մ եք չեղարկել ընտրված հրավերները:", + "confirm_user_deletion": "Հաստատե՞լ օգտատիրոջ ջնջումը:", + "confirm_users_deletion": "Ցանկանու՞մ եք ջնջել ընտրված օգտատերերին:", + "confirm_user_to_admin": "Ցանկանու՞մ եք այս օգտատիրոջը դարձնել ադմին:", + "confirm_users_to_admin": "Ցանկանու՞մ եք ընտրված օգտատերերին դարձնել ադմիններ:", + "confirm_user_deactivation": "Հաստատե՞լ օգտատիրոջ ապաակտիվացումը:", + "confirm_logout": "Հաստատել դուրս գալը", + "created_on": "Ստեղծվել է", + "continue_email": "Շարունակել Email-ով", + "continue_github": "Շարունակել Github-ով", + "continue_google": "Շարունակել Google-ով", + "continue_microsoft": "Շարունակել Microsoft-ով", + "create_team_failure": "Չհաջողվեց ստեղծել աշխատանքային տիրույթ!!", + "create_team_success": "Աշխատանքային տիրույթը հաջողությամբ ստեղծվեց!!", + "data_sharing_failure": "Չհաջողվեց թարմացնել տվյալների փոխանակման կարգավորումները", + "delete_infra_token_failure": "Ինչ-որ բան սխալ գնաց infra տոկենը ջնջելիս", + "delete_invite_failure": "Չհաջողվեց ջնջել հրավերը!!", + "delete_invites_failure": "Չհաջողվեց ջնջել ընտրված հրավերները!!", + "delete_invite_success": "Հրավերը հաջողությամբ ջնջվեց!!", + "delete_invites_success": "Ընտրված հրավերները հաջողությամբ ջնջվեցին!!", + "delete_request_failure": "Համօգտագործվող հարցման ջնջումը ձախողվեց!!", + "delete_request_success": "Համօգտագործվող հարցումը հաջողությամբ ջնջվեց!!", + "delete_team_failure": "Աշխատանքային տիրույթի ջնջումը ձախողվեց!!", + "delete_team_success": "Աշխատանքային տիրույթը հաջողությամբ ջնջվեց!!", + "delete_some_users_failure": "Չջնջված օգտատերերի քանակը՝ {count}", + "delete_some_users_success": "Ջնջված օգտատերերի քանակը՝ {count}", + "delete_user_failed_only_one_admin": "Չհաջողվեց ջնջել օգտատիրոջը: Պետք է լինի առնվազն մեկ ադմին!!", + "delete_user_failure": "Օգտատիրոջ ջնջումը ձախողվեց!!", + "delete_users_failure": "Չհաջողվեց ջնջել ընտրված օգտատերերին!!", + "delete_user_success": "Օգտատերը հաջողությամբ ջնջվեց!!", + "delete_users_success": "Ընտրված օգտատերերը հաջողությամբ ջնջվեցին!!", + "email": "Էլ. փոստ", + "email_failure": "Չհաջողվեց ուղարկել հրավերը", + "email_signin_failure": "Չհաջողվեց մուտք գործել էլ. փոստով", + "email_success": "Էլ. փոստի հրավերը հաջողությամբ ուղարկվեց", + "emails_cannot_be_same": "Դուք չեք կարող հրավիրել ինքներդ ձեզ, խնդրում ենք ընտրել այլ էլ. փոստի հասցե!!", + "enter_team_email": "Խնդրում ենք մուտքագրել աշխատանքային տիրույթի սեփականատիրոջ էլ. փոստը!!", + "error": "Ինչ-որ բան սխալ գնաց", + "error_auth_providers": "Չհաջողվեց բեռնել auth մատակարարներին", + "generate_infra_token_failure": "Ինչ-որ բան սխալ գնաց infra տոկենը ստեղծելիս", + "github_signin_failure": "Չհաջողվեց մուտք գործել Github-ով", + "google_signin_failure": "Չհաջողվեց մուտք գործել Google-ով", + "infra_token_label_short": "Infra տոկենի պիտակի նիշերի երկարությունը շատ կարճ է!!", + "invalid_email": "Խնդրում ենք մուտքագրել վավեր էլ. փոստի հասցե", + "link_copied_to_clipboard": "Հղումը պատճենվեց սեղմատախտակին", + "logged_out": "Դուրս եկած", + "login_as_admin": "և մուտք գործեք ադմինի հաշվով:", + "login_using_email": "Խնդրում ենք խնդրել օգտատիրոջը ստուգել իր էլ. փոստը կամ կիսվել ստորև նշված հղումով", + "login_using_link": "Խնդրում ենք խնդրել օգտատիրոջը մուտք գործել՝ օգտագործելով ստորև նշված հղումը", + "logout": "Դուրս գալ", + "magic_link_sign_in": "Սեղմեք հղմանը՝ մուտք գործելու համար:", + "magic_link_success": "Մենք ուղարկեցինք magic link", + "microsoft_signin_failure": "Չհաջողվեց մուտք գործել Microsoft-ով", + "newsletter_failure": "Չհաջողվեց թարմացնել նորությունների բաժանորդագրության կարգավորումները", + "non_admin_logged_in": "Մուտք եք գործել որպես ոչ ադմին օգտատեր:", + "non_admin_login": "Դուք մուտք եք գործել: Բայց դուք ադմին չեք", + "owner_not_present": "Թիմում պետք է լինի առնվազն մեկ սեփականատեր!!", + "privacy_policy": "Գաղտնիության քաղաքականություն", + "reenter_email": "Կրկին մուտքագրեք էլ. փոստը", + "remove_admin_failure": "Չհաջողվեց հեռացնել ադմինի կարգավիճակը!!", + "remove_admin_failure_only_one_admin": "Չհաջողվեց հեռացնել ադմինի կարգավիճակը: Պետք է լինի առնվազն մեկ ադմին!!", + "remove_admin_success": "Ադմինի կարգավիճակը հեռացված է!!", + "remove_admin_from_users_failure": "Չհաջողվեց հեռացնել ադմինի կարգավիճակը ընտրված օգտատերերից!!", + "remove_admin_from_users_success": "Ադմինի կարգավիճակը հեռացված է ընտրված օգտատերերից!!", + "remove_admin_to_delete_user": "Հեռացրեք ադմինի արտոնությունը՝ օգտատիրոջը ջնջելու համար!!", + "remove_owner_to_delete_user": "Հեռացրեք թիմի սեփականատիրոջ կարգավիճակը՝ օգտատիրոջը ջնջելու համար!!", + "remove_owner_failure_only_one_owner": "Չհաջողվեց հեռացնել անդամին: Թիմում պետք է լինի առնվազն մեկ սեփականատեր!!", + "remove_admin_for_deletion": "Հեռացրեք ադմինի կարգավիճակը նախքան ջնջման փորձ կատարելը!!", + "remove_owner_for_deletion": "Մեկ կամ ավելի օգտատերեր թիմի սեփականատերեր են: Թարմացրեք սեփականության իրավունքը նախքան ջնջելը!!", + "remove_invitee_failure": "Հրավիրվածի հեռացումը ձախողվեց!!", + "remove_invitee_success": "Հրավիրվածի հեռացումը հաջողվեց!!", + "remove_member_failure": "Անդամը չկարողացավ հեռացվել!!", + "remove_member_success": "Անդամը հաջողությամբ հեռացվեց!!", + "rename_team_failure": "Չհաջողվեց վերանվանել աշխատանքային տիրույթը!!", + "rename_team_success": "Աշխատանքային տիրույթը հաջողությամբ վերանվանվեց!", + "rename_user_failure": "Չհաջողվեց վերանվանել օգտատիրոջը!!", + "rename_user_success": "Օգտատերը հաջողությամբ վերանվանվեց!!", + "require_auth_provider": "Մուտք գործելու համար անհրաժեշտ է սահմանել առնվազն մեկ նույնականացման մատակարար:", + "role_update_failed": "Դերերի թարմացումը ձախողվեց!!", + "role_update_success": "Դերերը հաջողությամբ թարմացվեցին!!", + "selected": "{count} ընտրված", + "self_host_docs": "Սելֆ-Հոսթի Փաստաթղթավորում", + "send_magic_link": "Ուղարկել magic link", + "setup_failure": "Կարգավորումը ձախողվեց!!", + "setup_success": "Կարգավորումը հաջողությամբ ավարտվեց!!", + "sign_in_agreement": "Մուտք գործելով՝ դուք համաձայնում եք մեր", + "sign_in_options": "Մուտքի բոլոր տարբերակները", + "sign_out": "Դուրս գալ", + "something_went_wrong": "Ինչ-որ բան սխալ գնաց", + "team_name_too_short": "Աշխատանքային տիրույթի անունը պետք է լինի առնվազն 6 նիշ!!", + "user_already_invited": "Չհաջողվեց ուղարկել հրավերը: Օգտատերը արդեն հրավիրված է!!", + "user_not_found": "Օգտատերը չի գտնվել ենթակառուցվածքում!!", + "users_to_admin_success": "Ընտրված օգտատերերը ստացան ադմինի կարգավիճակ!!", + "users_to_admin_failure": "Չհաջողվեց ընտրված օգտատերերին տալ ադմինի կարգավիճակ!!", + "loading_workspaces": "Աշխատանքային տիրույթների բեռնում", + "loading_collections_in_workspace": "Հավաքածուների բեռնում աշխատանքային տիրույթում" + }, + "support": { + "changelog": "Կարդալ ավելին վերջին թողարկումների մասին", + "chat": "Հարցե՞ր: Զրուցեք մեզ հետ:", + "community": "Տվեք հարցեր և օգնեք ուրիշներին", + "documentation": "Կարդալ ավելին Hoppscotch-ի մասին", + "forum": "Տվեք հարցեր և ստացեք պատասխաններ", + "github": "Հետևեք մեզ Github-ում", + "shortcuts": "Ավելի արագ զննեք հավելվածը", + "title": "Աջակցություն", + "twitter": "Հետևեք մեզ Twitter-ում" + }, + "tab": { + "authorization": "Նույնականացում", + "body": "Մարմին", + "close": "Փակել ներդիրը", + "close_others": "Փակել մյուս ներդիրները", + "collections": "Հավաքածուներ", + "documentation": "Փաստաթղթավորում", + "duplicate": "Կրկնօրինակել ներդիրը", + "environments": "Միջավայրեր", + "headers": "Վերնագրեր", + "history": "Պատմություն", + "mqtt": "MQTT", + "parameters": "Պարամետրեր", + "post_request_script": "Հետ-հարցման սկրիպտ", + "pre_request_script": "Նախնական հարցման սկրիպտ", + "queries": "հարցումներ (Queries)", + "query": "Query", + "schema": "Սխեմա", + "shared_requests": "Համօգտագործվող հարցումներ", + "codegen": "Ստեղծել կոդ", + "code_snippet": "Կոդի հատված", + "mock_servers": "Մոք Սերվերներ", + "share_tab_request": "Կիսվել ներդիրի հարցումով", + "socketio": "Socket.IO", + "sse": "SSE", + "types": "Տեսակներ", + "variables": "Փոփոխականներ", + "websocket": "WebSocket", + "all_tests": "Բոլոր թեստերը", + "passed": "Անցած", + "failed": "Ձախողված" + }, + "team": { + "activity_logs": "Գործունեության մատյաններ", + "already_member": "Այս էլ. փոստը կապված է գոյություն ունեցող օգտատիրոջ հետ:", + "create_new": "Ստեղծել նոր աշխատանքային տիրույթ", + "deleted": "Աշխատանքային տիրույթը ջնջված է", + "delete_all_activity_logs": "Ջնջել բոլոր գործունեության մատյանները", + "successfully_deleted_all_activity_logs": "Բոլոր գործունեության մատյանները հաջողությամբ ջնջվեցին", + "delete_activity_log": "Ջնջել գործունեության մատյանը", + "deleted_activity_log": "Ընտրված գործունեության մատյանը ջնջված է", + "deleted_all_activity_logs": "Բոլոր գործունեության մատյանները ջնջված են", + "edit": "Խմբագրել Աշխատանքային տիրույթը", + "email": "Էլ. փոստ", + "email_do_not_match": "Էլ. փոստը չի համապատասխանում ձեր հաշվի տվյալներին: Կապվեք ձեր աշխատանքային տիրույթի սեփականատիրոջ հետ:", + "exit": "Լքել Աշխատանքային տիրույթը", + "exit_disabled": "Միայն սեփականատերը չի կարող լքել աշխատանքային տիրույթը", + "failed_invites": "Ձախողված հրավերներ", + "invalid_coll_id": "Անվավեր հավաքածուի ID", + "invalid_email_format": "Էլ. փոստի ձևաչափը անվավեր է", + "invalid_id": "Անվավեր աշխատանքային տիրույթի ID: Կապվեք ձեր աշխատանքային տիրույթի սեփականատիրոջ հետ:", + "invalid_invite_link": "Անվավեր հրավերի հղում", + "invalid_invite_link_description": "Հղումը, որով անցել եք, անվավեր է: Կապվեք ձեր աշխատանքային տիրույթի սեփականատիրոջ հետ:", + "invalid_member_permission": "Խնդրում ենք տրամադրել վավեր թույլտվություն աշխատանքային տիրույթի անդամին", + "invite": "Հրավիրել", + "invite_more": "Հրավիրել ավելին", + "invite_tooltip": "Հրավիրել մարդկանց այս աշխատանքային տիրույթ", + "invited_to_team": "{owner}-ը հրավիրել է ձեզ միանալ {workspace}-ին", + "join": "Հրավերն ընդունված է", + "join_team": "Միանալ {workspace}-ին", + "joined_team": "Դուք միացաք {workspace}-ին", + "joined_team_description": "Դուք այժմ այս աշխատանքային տիրույթի անդամ եք", + "left": "Դուք լքեցիք աշխատանքային տիրույթը", + "login_to_continue": "Մուտք գործեք շարունակելու համար", + "login_to_continue_description": "Աշխատանքային տիրույթին միանալու համար անհրաժեշտ է մուտք գործել:", + "logout_and_try_again": "Դուրս եկեք և մուտք գործեք այլ հաշվով", + "member_has_invite": "Օգտատերը արդեն ունի հրավեր: Խնդրեք նրանց ստուգել իրենց մուտքային արկղը կամ չեղարկել և կրկին ուղարկել հրավերը:", + "member_not_found": "Անդամը չի գտնվել: Կապվեք ձեր աշխատանքային տիրույթի սեփականատիրոջ հետ:", + "member_removed": "Օգտատերը հեռացված է", + "member_role_updated": "Օգտատիրոջ դերերը թարմացված են", + "members": "Անդամներ", + "more_members": "+{count} ավելի", + "name_length_insufficient": "Աշխատանքային տիրույթի անունը չպետք է դատարկ լինի", + "name_updated": "Աշխատանքային տիրույթի անունը թարմացված է", + "new": "Նոր Աշխատանքային տիրույթ", + "new_created": "Նոր աշխատանքային տիրույթը ստեղծված է", + "new_name": "Իմ Նոր Աշխատանքային տիրույթը", + "no_access": "Դուք չունեք խմբագրման թույլտվություն այս աշխատանքային տիրույթում", + "no_invite_found": "Հրավեր չի գտնվել: Կապվեք ձեր աշխատանքային տիրույթի սեփականատիրոջ հետ:", + "no_request_found": "Հարցումը չի գտնվել:", + "not_found": "Աշխատանքային տիրույթը չի գտնվել: Կապվեք ձեր աշխատանքային տիրույթի սեփականատիրոջ հետ:", + "not_valid_viewer": "Դուք վավեր դիտող չեք: Կապվեք ձեր աշխատանքային տիրույթի սեփականատիրոջ հետ:", + "parent_coll_move": "Չի կարելի տեղափոխել հավաքածուն դուստր հավաքածուի մեջ", + "pending_invites": "Սպասվող հրավերներ", + "permissions": "Թույլտվություններ", + "same_target_destination": "Նույն թիրախը և նպատակակետը", + "saved": "Աշխատանքային տիրույթը պահպանված է", + "select_a_team": "Ընտրեք աշխատանքային տիրույթ", + "success_invites": "Հաջողված հրավերներ", + "title": "Աշխատանքային տիրույթներ", + "we_sent_invite_link": "Հրավերները ճանապարհին են", + "invite_sent_smtp_disabled": "Հրավերի հղումները ստեղծված են", + "we_sent_invite_link_description": "Նոր հրավիրվածները կստանան հղում՝ միանալու աշխատանքային տիրույթին, գոյություն ունեցող անդամները և սպասվող հրավիրվածները չեն ստանա նոր հղում:", + "invite_sent_smtp_disabled_description": "Հրավերի էլ. նամակների ուղարկումը անջատված է Hoppscotch-ի այս օրինակի համար: Խնդրում ենք օգտագործել \"Պատճենել հղումը\" կոճակը՝ հրավերի հղումը ձեռքով պատճենելու և կիսվելու համար:", + "copy_invite_link": "Պատճենել հրավերի հղումը", + "search_title": "Թիմային հարցումներ", + "user_not_found": "Օգտատերը չի գտնվել օրինակում:", + "invite_members": "Հրավիրել անդամներ" + }, + "team_environment": { + "deleted": "Միջավայրը ջնջված է", + "duplicate": "Միջավայրը կրկնօրինակված է", + "not_found": "Միջավայրը չի գտնվել:" + }, + "test": { + "requests": "Հարցումներ", + "selection": "Ընտրություն", + "failed": "թեստը ձախողվեց", + "javascript_code": "JavaScript Կոդ", + "learn": "Կարդալ փաստաթղթավորումը", + "passed": "թեստը անցավ", + "report": "Թեստի հաշվետվություն", + "results": "Թեստի արդյունքներ", + "script": "Սկրիպտ", + "snippets": "Սնիփեթներ", + "run": "Գործարկել", + "run_again": "Կրկին գործարկել", + "stop": "Կանգնեցնել", + "new_run": "Նոր գործարկում", + "iterations": "Իտերացիաներ", + "duration": "Տևողություն", + "avg_resp": "Միջին պատասխանի ժամանակը" + }, + "websocket": { + "communication": "Հաղորդակցություն", + "log": "Մատյան", + "message": "Հաղորդագրություն", + "protocols": "Արձանագրություններ", + "url": "URL" + }, + "workspace": { + "change": "Փոխել աշխատանքային տիրույթը", + "personal": "Անձնական աշխատանքային տիրույթ", + "other_workspaces": "Իմ աշխատանքային տիրույթները", + "team": "Աշխատանքային տիրույթ", + "title": "Աշխատանքային տիրույթներ" + }, + "site_protection": { + "login_to_continue": "Մուտք գործեք շարունակելու համար", + "login_to_continue_description": "Անհրաժեշտ է մուտք գործել Hoppscotch Enterprise օրինակին հասանելիություն ստանալու համար:", + "error_fetching_site_protection_status": "Ինչ-որ բան սխալ գնաց Կայքի Պաշտպանության Կարգավիճակը ստանալիս" + }, + "access_tokens": { + "tab_title": "Տոկեններ", + "section_title": "Անձնական մուտքի տոկեններ", + "section_description": "Անձնական մուտքի տոկենները ներկայումս օգնում են ձեզ կապել CLI-ն ձեր Hoppscotch հաշվին", + "last_used_on": "Վերջին անգամ օգտագործվել է", + "expires_on": "Սպառվում է", + "no_expiration": "Սպառման ժամկետ չկա", + "expired": "Սպառված է", + "copy_token_warning": "Համոզվեք, որ պատճենել եք ձեր անձնական մուտքի տոկենը հիմա: Դուք այն այլևս չեք կարողանա տեսնել!", + "token_purpose": "Ինչի՞ համար է այս տոկենը:", + "expiration_label": "Սպառման ժամկետ", + "scope_label": "Շրջանակ", + "workspace_read_only_access": "Կարդալու իրավունքով մուտք դեպի աշխատանքային տիրույթի տվյալներ:", + "personal_workspace_access_limitation": "Անձնական մուտքի տոկենները չեն կարող մուտք գործել ձեր անձնական աշխատանքային տիրույթ:", + "generate_token": "Ստեղծել Տոկեն", + "invalid_label": "Խնդրում ենք տրամադրել պիտակ տոկենի համար", + "no_expiration_verbose": "Այս տոկենը երբեք չի սպառվի!", + "token_expires_on": "Այս տոկենը կսպառվի", + "generate_new_token": "Ստեղծել նոր տոկեն", + "generate_modal_title": "Նոր Անձնական Մուտքի Տոկեն", + "deletion_success": "{label} մուտքի տոկենը ջնջվել է" + }, + "collection_runner": { + "collection_id": "Հավաքածուի ID", + "environment_id": "Միջավայրի ID", + "cli_collection_id_description": "Այս հավաքածուի ID-ն կօգտագործվի Hoppscotch-ի CLI հավաքածուի գործարկիչի կողմից:", + "cli_environment_id_description": "Այս միջավայրի ID-ն կօգտագործվի Hoppscotch-ի CLI հավաքածուի գործարկիչի կողմից:", + "include_active_environment": "Ներառել ակտիվ միջավայրը.", + "cli": "CLI", + "cli_comming_soon_for_personal_collection": "Collection Runner-ը անձնական հավաքածուների համար CLI-ում շուտով հասանելի կլինի:", + "delay": "Հապաղում", + "negative_delay": "Հապաղումը չի կարող լինել բացասական", + "ui": "Գործարկիչ", + "running_collection": "Հավաքածուի գործարկում", + "run_config": "Գործարկման կարգավորումներ", + "advanced_settings": "Ընդլայնված կարգավորումներ", + "stop_on_error": "Կանգնեցնել գործարկումը սխալի դեպքում", + "persist_responses": "Պահպանել պատասխանները", + "keep_variable_values": "Պահպանել փոփոխականների արժեքները", + "collection_not_found": "Հավաքածուն չի գտնվել: Հնարավոր է այն ջնջվել կամ տեղափոխվել է:", + "empty_collection": "Հավաքածուն դատարկ է: Ավելացրեք հարցումներ գործարկելու համար:", + "no_response_persist": "Հավաքածուի գործարկիչը ներկայումս կարգավորված է չպահպանել պատասխանները: Այս կարգավորումը կանխում է պատասխանի տվյալների ցուցադրումը: Այս վարքագիծը փոխելու համար նախաձեռնեք նոր գործարկման կարգավորում:", + "select_request": "Ընտրեք հարցում՝ պատասխանը և թեստի արդյունքները տեսնելու համար", + "response_body_lost_rerun": "Պատասխանի մարմինը կորել է: Գործարկեք հավաքածուն կրկին՝ պատասխանի մարմինը ստանալու համար:", + "cli_command_generation_description_cloud": "Պատճենեք ստորև նշված հրամանը և գործարկեք այն CLI-ից: Խնդրում ենք նշել անձնական մուտքի տոկեն:", + "cli_command_generation_description_sh": "Պատճենեք ստորև նշված հրամանը և գործարկեք այն CLI-ից: Խնդրում ենք նշել անձնական մուտքի տոկեն և ստուգել ստեղծված SH օրինակի սերվերի URL-ը:", + "cli_command_generation_description_sh_with_server_url_placeholder": "Պատճենեք ստորև նշված հրամանը և գործարկեք այն CLI-ից: Խնդրում ենք նշել անձնական մուտքի տոկեն և SH օրինակի սերվերի URL-ը:", + "run_collection": "Գործարկել հավաքածուն", + "no_passed_tests": "Անցած թեստեր չկան", + "no_failed_tests": "Ձախողված թեստեր չկան" + }, + "ai_experiments": { + "generate_request_name": "Ստեղծել հարցման անուն AI-ի օգնությամբ", + "generate_or_modify_request_body": "Ստեղծել կամ փոփոխել հարցման մարմինը", + "modify_with_ai": "Փոփոխել AI-ի օգնությամբ", + "generate": "Ստեղծել", + "generate_or_modify_request_body_input_placeholder": "Մուտքագրեք ձեր հրահանգը՝ հարցման մարմինը փոփոխելու համար", + "accept_change": "Ընդունել փոփոխությունը", + "feedback_success": "Հետադարձ կապը հաջողությամբ ուղարկվեց", + "feedback_failure": "Չհաջողվեց ուղարկել հետադարձ կապ", + "feedback_thank_you": "Շնորհակալություն ձեր կարծիքի համար!", + "feedback_cta_text_long": "Գնահատեք ստեղծումը, դա օգնում է մեզ բարելավել", + "feedback_cta_request_name": "Հավանեցի՞ք ստեղծված անունը:", + "modify_request_body_error": "Չհաջողվեց փոփոխել հարցման մարմինը", + "generate_or_modify_prerequest_input_placeholder": "Մուտքագրեք հրահանգ՝ նախնական հարցման սկրիպտ ստեղծելու կամ փոփոխելու համար", + "generate_or_modify_post_request_script_input_placeholder": "Մուտքագրեք հրահանգ՝ հետ-հարցման սկրիպտ ստեղծելու կամ փոփոխելու համար", + "modify_post_request_script_error": "Չհաջողվեց փոփոխել հետ-հարցման սկրիպտը", + "modify_prerequest_error": "Չհաջողվեց փոփոխել նախնական հարցման սկրիպտը" + }, + "configs": { + "auth_providers": { + "callback_url": "CALLBACK URL", + "client_id": "CLIENT ID", + "client_secret": "CLIENT SECRET", + "description": "Կարգավորեք նույնականացման մատակարարներին ձեր սերվերի համար", + "provider_not_specified": "Խնդրում ենք միացնել առնվազն մեկ նույնականացման մատակարար", + "scope": "SCOPE", + "tenant": "TENANT", + "title": "Նույնականացման Մատակարարներ", + "update_failure": "Չհաջողվեց թարմացնել նույնականացման մատակարարի կարգավորումները!!" + }, + "confirm_changes": "Hoppscotch սերվերը պետք է վերագործարկվի՝ նոր փոփոխությունները արտացոլելու համար: Հաստատե՞լ սերվերի կարգավորումներում կատարված փոփոխությունները:", + "input_empty": "Խնդրում ենք լրացնել բոլոր դաշտերը նախքան կարգավորումները թարմացնելը", + "data_sharing": { + "title": "Տվյալների Փոխանակում", + "description": "Օգնեք բարելավել Hoppscotch-ը՝ կիսվելով անանուն տվյալներով", + "enable": "Միացնել Տվյալների Փոխանակումը", + "secondary_title": "Տվյալների Փոխանակման Կարգավորումներ", + "see_shared": "Տեսնել, թե ինչ է կիսվում", + "toggle_description": "Կիսվել անանուն տվյալներով", + "update_failure": "Չհաջողվեց թարմացնել տվյալների փոխանակման կարգավորումները!!" + }, + "load_error": "Չհաջողվեց բեռնել սերվերի կարգավորումները", + "mail_configs": { + "address_from": "MAILER FROM ADDRESS", + "custom_smtp_configs": "Օգտագործել Անհատական SMTP Կարգավորումներ", + "description": " Կարգավորել smtp կարգավորումները", + "enable_email_auth": "Միացնել Էլ. փոստի վրա հիմնված նույնականացումը", + "enable_smtp": "Միացնել SMTP", + "host": "MAILER HOST", + "password": "MAILER PASSWORD", + "port": "MAILER PORT", + "secure": "MAILER SECURE", + "smtp_url": "MAILER SMTP URL", + "tls_reject_unauthorized": "TLS REJECT UNAUTHORIZED", + "title": "SMTP Կարգավորումներ", + "toggle_failure": "Չհաջողվեց փոխարկել smtp-ն!!", + "update_failure": "Չհաջողվեց թարմացնել smtp կարգավորումները!!", + "user": "MAILER USER" + }, + "reset": { + "confirm_reset": "Hoppscotch սերվերը պետք է վերագործարկվի՝ նոր փոփոխությունները արտացոլելու համար: Հաստատե՞լ սերվերի կարգավորումների վերականգնումը:", + "description": "Կանխադրված կարգավորումները կբեռնվեն, ինչպես նշված է միջավայրի ֆայլում", + "failure": "Չհաջողվեց վերականգնել կարգավորումները!!", + "title": "Վերականգնել Կարգավորումները", + "info": "Վերականգնել սերվերի կարգավորումները" + }, + "restart": { + "description": "{duration} վայրկյան մնացել է մինչ այս էջի ավտոմատ վերբեռնումը", + "initiate": "Սերվերի վերագործարկման նախաձեռնում...", + "title": "Սերվերը վերագործարկվում է" + }, + "save_changes": "Պահպանել Փոփոխությունները", + "title": "Կարգավորումներ", + "update_failure": "Չհաջողվեց թարմացնել սերվերի կարգավորումները", + "restrict_access": "Սահմանափակել Մուտքը", + "site_protection": { + "control_access": "Վերահսկեք, թե ով կարող է մուտք գործել Hoppscotch հավելված", + "description": "Հարմարեցրեք, թե ինչպես են այցելուները մուտք գործում ձեր Hoppscotch հավելված՝ օգտագործելով կայքի պաշտպանության կարգավորումները:", + "enable": "Միացնել կայքի պաշտպանությունը", + "note": "Hoppscotch հավելված այցելող օգտատերերը կտեսնեն մուտքի էջը, հավելվածին մուտք գործելու համար պարտադիր մուտք է պահանջվում: Ադմինի հաստատումը դեռ պահանջվում է հեղինակավորման համար", + "update_failure": "Չհաջողվեց թարմացնել կայքի պաշտպանության կարգավորումները!!" + }, + "domain_whitelisting": { + "add_domain": "Ավելացնել Նոր Դոմեն", + "description": "Սպիտակ ցուցակում ներառված դոմեններում գրանցված էլ. փոստի ID ունեցող օգտատերերը չեն պահանջում ադմինի հստակ հաստատումը Hoppscotch հավելված մուտք գործելու համար", + "enable": "Միացնել դոմենների սպիտակ ցուցակը", + "enter_domain": "Մուտքագրեք դոմեն", + "title": "Սպիտակ ցուցակում ներառված Դոմեններ", + "toggle_failure": "Չհաջողվեց փոխարկել դոմենների սպիտակ ցուցակը", + "update_failure": "Չհաջողվեց թարմացնել դոմենների սպիտակ ցուցակի կարգավորումները!!" + }, + "oidc_configs": { + "auth_url": "Auth URL", + "callback_url": "Callback URL", + "client_id": "Client ID", + "client_secret": "Client Secret", + "description": "Կարգավորել OIDC կարգավորումները", + "enable": "Միացնել OIDC-ի վրա հիմնված նույնականացումը", + "issuer": "Issuer", + "provider_name": "Մատակարարի Անուն", + "scope": "Scope", + "title": "OIDC Կարգավորումներ", + "token_url": "Token URL", + "update_failure": "Չհաջողվեց թարմացնել OIDC կարգավորումները!!", + "user_info_url": "User Info URL" + }, + "saml": { + "audience": "Audience", + "callback_url": "Callback URL", + "certificate": "Certificate", + "description": "Կարգավորել SAML կարգավորումները", + "enable": "Միացնել SAML-ի վրա հիմնված նույնականացումը", + "entry_point": "Entry Point", + "issuer": "Issuer", + "title": "SAML Կարգավորումներ", + "update_failure": "Չհաջողվեց թարմացնել SAML SSO կարգավորումները!!", + "want_assertions_signed": "Ստորագրել Assertions-ը", + "want_response_signed": "Ստորագրել Պատասխանը" + } + }, + "data_sharing": { + "description": "Կիսվեք անանուն տվյալների օգտագործմամբ՝ Hoppscotch-ը բարելավելու համար", + "enable": "Միացնել Տվյալների Փոխանակումը", + "see_shared": "Տեսնել, թե ինչ է կիսվում", + "toggle_description": "Կիսվել տվյալներով և դարձնել Hoppscotch-ը ավելի լավը", + "title": "Դարձրեք Hoppscotch-ը Ավելի Լավ", + "welcome": "Բարի գալուստ" + }, + "infra_tokens": { + "copy_token_warning": "Համոզվեք, որ պատճենել եք ձեր infra տոկենը հիմա: Դուք այն այլևս չեք կարողանա տեսնել!", + "deletion_success": "{label} infra տոկենը ջնջվել է", + "empty": "Infra տոկենները դատարկ են", + "expired": "Սպառված է", + "expiration_label": "Սպառման ժամկետ", + "expires_on": "Սպառվում է", + "generate_modal_title": "Նոր Infra Տոկեն", + "generate_new_token": "Ստեղծել նոր տոկեն", + "generate_token": "Ստեղծել Տոկեն", + "invalid_label": "Խնդրում ենք տրամադրել պիտակ տոկենի համար", + "last_used_on": "Վերջին անգամ օգտագործվել է", + "no_expiration": "Սպառման ժամկետ չկա", + "no_expiration_verbose": "Այս տոկենը երբեք չի սպառվի!", + "section_description": "Կառավարեք ձեր Hoppscotch օգտատերերին API-ների միջոցով Infra տոկեններով", + "section_title": "Infra Տոկեններ", + "tab_title": "Infra Տոկեններ", + "token_expires_on": "Այս տոկենը կսպառվի", + "token_purpose": "Մուտքագրեք պիտակ այս տոկենը նույնականացնելու համար" + }, + "metrics": { + "dashboard": "Կառավարման վահանակ", + "no_metrics": "Չափումներ չեն գտնվել", + "total_collections": "Ընդհանուր Հավաքածուներ", + "total_requests": "Ընդհանուր Հարցումներ", + "total_teams": "Ընդհանուր Աշխատանքային տիրույթներ", + "total_users": "Ընդհանուր Օգտատերեր" + }, + "newsletter": { + "description": "Ստացեք թարմացումներ մեր վերջին նորությունների մասին", + "subscribe": "Բաժանորդագրվել", + "title": "Մնացեք կապի մեջ", + "toggle_description": "Ստացեք թարմացումներ Hoppscotch-ի վերջին նորությունների մասին", + "unsubscribe": "Ապաբաժանորդագրվել" + }, + "teams": { + "add_member": "Ավելացնել Անդամ", + "add_members": "Ավելացնել Անդամներ", + "add_new": "Ավելացնել Նոր", + "admin": "Ադմին", + "admin_Email": "Ադմինի Էլ. փոստ", + "admin_id": "Ադմինի ID", + "cancel": "Չեղարկել", + "confirm_team_deletion": "Հաստատե՞լ աշխատանքային տիրույթի ջնջումը:", + "copy": "Պատճենել", + "create_team": "Ստեղծել Աշխատանքային տիրույթ", + "date": "Ամսաթիվ", + "delete_team": "Ջնջել Աշխատանքային տիրույթը", + "details": "Մանրամասներ", + "edit": "Խմբագրել", + "editor": "ԽՄԲԱԳԻՐ (EDITOR)", + "editor_description": "Խմբագիրները կարող են ավելացնել, խմբագրել և ջնջել հարցումներ և հավաքածուներ:", + "email": "Աշխատանքային տիրույթի սեփականատիրոջ էլ. փոստ", + "email_address": "Էլ. փոստի հասցե", + "email_title": "Էլ. փոստ", + "empty_name": "Թիմի անունը չի կարող դատարկ լինել!!", + "error": "Ինչ-որ բան սխալ գնաց: Խնդրում ենք փորձել մի փոքր ուշ:", + "id": "Աշխատանքային տիրույթի ID", + "invited_email": "Հրավիրվածի Էլ. փոստ", + "invited_on": "Հրավիրվել է", + "invites": "Հրավերներ", + "load_info_error": "Չհաջողվեց բեռնել Աշխատանքային տիրույթի տվյալները", + "load_list_error": "Չհաջողվեց բեռնել Աշխատանքային տիրույթների ցանկը", + "members": "Անդամների քանակը", + "no_invite": "Հրավերներ չկան", + "no_invite_description": "Հրավիրեք ձեր թիմին համագործակցությունը սկսելու համար", + "owner": "ՍԵՓԱԿԱՆԱՏԵՐ (OWNER)", + "owner_description": "Սեփականատերերը կարող են ավելացնել, խմբագրել և ջնջել հարցումներ, հավաքածուներ և աշխատանքային տիրույթի անդամներ:", + "permissions": "Թույլտվություններ", + "name": "Աշխատանքային տիրույթի անուն", + "no_members": "Այս աշխատանքային տիրույթում անդամներ չկան: Ավելացրեք անդամներ այս աշխատանքային տիրույթում համագործակցելու համար", + "no_pending_invites": "Սպասվող հրավերներ չկան", + "no_teams": "Աշխատանքային տիրույթներ չեն գտնվել..", + "no_teams_description": "Ստեղծեք աշխատանքային տիրույթ՝ ձեր թիմի հետ համագործակցելու համար", + "pending_invites": "Սպասվող հրավերներ", + "roles": "Դերեր", + "roles_description": "Դերերը օգտագործվում են համօգտագործվող հավաքածուների հասանելիությունը վերահսկելու համար:", + "remove": "Հեռացնել", + "rename": "Վերանվանել", + "save": "Պահպանել", + "save_changes": "Պահպանել Փոփոխությունները", + "send_invite": "Ուղարկել Հրավեր", + "show_more": "Ցուցադրել ավելին", + "team_details": "Աշխատանքային տիրույթի մանրամասներ", + "team_members": "Անդամներ", + "team_members_tab": "Աշխատանքային տիրույթի անդամներ", + "teams": "Աշխատանքային տիրույթ", + "uid": "UID", + "unnamed": "(Անանուն Աշխատանքային տիրույթ)", + "viewer": "ԴԻՏՈՂ (VIEWER)", + "viewer_description": "Դիտողները կարող են միայն դիտել և օգտագործել հարցումներ", + "valid_name": "Խնդրում ենք մուտքագրել վավեր աշխատանքային տիրույթի անուն", + "valid_owner_email": "Խնդրում ենք մուտքագրել վավեր սեփականատիրոջ էլ. փոստ" + }, + "users": { + "add_user": "Ավելացնել Օգտատեր", + "admin": "Ադմին", + "admin_id": "Ադմինի ID", + "cancel": "Չեղարկել", + "created_on": "Ստեղծվել է", + "copy_invite_link": "Պատճենել Հրավերի Հղումը", + "copy_link": "Պատճենել Հղումը", + "date": "Ամսաթիվ", + "delete": "Ջնջել", + "delete_user": "Ջնջել Օգտատիրոջը", + "delete_users": "Ջնջել Օգտատերերին", + "details": "Մանրամասներ", + "edit": "Խմբագրել", + "email": "Էլ. փոստ", + "email_address": "Էլ. փոստի հասցե", + "empty_name": "Անունը չի կարող դատարկ լինել!!", + "id": "Օգտատիրոջ ID", + "invalid_user": "Անվավեր Օգտատեր", + "invite_load_list_error": "Չհաջողվեց բեռնել հրավիրված օգտատերերի ցանկը", + "invite_user": "Հրավիրել Օգտատեր", + "invited_by": "Հրավիրվել է կողմից", + "invited_on": "Հրավիրվել է", + "invited_users": "Հրավիրված Օգտատերեր", + "invitee_email": "Հրավիրվածի Էլ. փոստ", + "last_active_on": "Վերջին ակտիվությունը", + "load_info_error": "Չհաջողվեց բեռնել օգտատիրոջ տվյալները", + "load_list_error": "Չհաջողվեց բեռնել օգտատերերի ցանկը", + "make_admin": "Դարձնել Ադմին", + "name": "Անուն", + "new_user_added": "Նոր Օգտատեր Ավելացվեց", + "no_invite": "Սպասվող հրավերներ չկան", + "no_invite_description": "Սպասվող հրավերներ չկան! Սկսեք հրավիրել ձեր թիմակիցներին Hoppscotch", + "no_shared_requests": "Օգտատիրոջ կողմից ստեղծված համօգտագործվող հարցումներ չկան", + "no_users": "Օգտատերեր չեն գտնվել", + "not_available": "Հասանելի չէ", + "not_found": "Օգտատերը չի գտնվել", + "pending_invites": "Սպասվող Հրավերներ", + "remove_admin_privilege": "Հեռացնել Ադմինի Արտոնությունը", + "remove_admin_status": "Հեռացնել Ադմինի Կարգավիճակը", + "rename": "Վերանվանել", + "revoke_invitation": "Չեղարկել Հրավերը", + "searchbar_placeholder": "Որոնել անունով կամ էլ. փոստով..", + "send_invite": "Ուղարկել Հրավեր", + "show_more": "Ցուցադրել ավելին", + "uid": "UID", + "unnamed": "(Անանուն Օգտատեր)", + "user_not_found": "Օգտատերը չի գտնվել ենթակառուցվածքում!!", + "users": "Օգտատերեր", + "valid_email": "Խնդրում ենք մուտքագրել վավեր էլ. փոստի հասցե", + "deactivate": "Ապաակտիվացնել", + "deactivate_user": "Ապաակտիվացնել Օգտատիրոջը" + }, + "organization": { + "login_to_continue_description": "Կազմակերպության օրինակին միանալու համար անհրաժեշտ է մուտք գործել:", + "create_an_organization": "Ստեղծել կազմակերպություն", + "deactivate_user_failure": "Օգտատիրոջ ապաակտիվացումը ձախողվեց!!", + "deactivate_user_success": "Օգտատերը հաջողությամբ ապաակտիվացվեց!!", + "delete_account_description": "Սա կջնջի ձեր Hoppscotch հաշվի հետ կապված բոլոր տվյալները, ներառյալ այս և ցանկացած այլ կազմակերպությունները, որոնց անդամ եք:", + "delete_account": "Ջնջել Hoppscotch Հաշիվը", + "user_deletion_failed_sole_admin": "Օգտատերը մեկ կամ ավելի կազմակերպության օրինակների միակ ադմինն է: Խնդրում ենք իջեցնել օգտատիրոջ կարգավիճակը նախքան ջնջելու փորձ կատարելը:", + "user_deletion_failed_sole_team_owner": "Օգտատերը մեկ կամ ավելի կազմակերպության օրինակների միակ թիմի սեփականատերն է: Խնդրում ենք փոխանցել սեփականության իրավունքը կամ ջնջել համապատասխան աշխատանքային տիրույթները նախքան ջնջելու փորձ կատարելը:", + "no_organizations": "Դուք որևէ կազմակերպության անդամ չեք", + "admin": "Ադմին" + }, + "organization_sidebar": { + "instances": "Օրինակներ", + "hoppscotch_cloud": "Hoppscotch Cloud", + "admin": "Ադմին", + "no_orgs_title": "Դեռ կազմակերպություններ չկան", + "no_orgs_description": "Միացեք կամ ստեղծեք կազմակերպություն՝ ձեր թիմի հետ համագործակցելու համար", + "error_loading": "Չհաջողվեց բեռնել կազմակերպությունները", + "inactive_orgs": "Ոչ Ակտիվ Կազմակերպություններ", + "multi_account_notice": "Յուրաքանչյուր կազմակերպություն պահպանում է իր մուտքը՝ օգտագործելով վերջին օգտագործված հաշիվը:", + "inactive_orgs_tooltip": "Կապվեք աջակցության հետ օգնության համար:" + }, + "billing": { + "confirm": { + "update_seat_count": "Վստա՞հ եք, որ ցանկանում եք թարմացնել նստատեղերի քանակը՝ դարձնելով {newSeatCount}:", + "update_billing_cycle": "Վստա՞հ եք, որ ցանկանում եք թարմացնել վճարման ցիկլը՝ դարձնելով {newBillingCycle}:" + }, + "cancel_subscription": "Չեղարկել բաժանորդագրությունը" + }, + "app_console": { + "entries": "Կոնսոլի գրառումներ", + "no_entries": "Գրառումներ չկան" + }, + "mockServer": { + "create_modal": { + "title": "Ստեղծել Mock Server", + "name_label": "Mock Server-ի Անուն", + "name_placeholder": "Մուտքագրեք mock server-ի անունը", + "name_required": "Mock server-ի անունը պարտադիր է", + "collection_source_label": "Հավաքածուի Աղբյուր", + "existing_collection": "Գոյություն ունեցող Հավաքածու", + "new_collection": "Նոր Հավաքածու", + "select_collection_label": "Ընտրել Հավաքածու", + "select_collection_placeholder": "Ընտրեք հավաքածու", + "collection_required": "Խնդրում ենք ընտրել հավաքածու", + "collection_name_label": "Հավաքածուի Անուն", + "collection_name_placeholder": "Մուտքագրեք հավաքածուի անունը", + "collection_name_required": "Հավաքածուի անունը պարտադիր է", + "request_config_label": "Հարցման Կարգավորում", + "add_request": "Ավելացնել Հարցում", + "create_button": "Ստեղծել Mock Server", + "success": "Mock server-ը հաջողությամբ ստեղծվեց", + "error": "Չհաջողվեց ստեղծել mock server", + "no_collections": "Հասանելի հավաքածուներ չկան" + }, + "edit_modal": { + "title": "Խմբագրել Mock Server-ը", + "name_label": "Mock Server-ի Անուն", + "name_placeholder": "Մուտքագրեք mock server-ի անունը", + "name_required": "Mock server-ի անունը պարտադիր է", + "active_label": "Ակտիվ", + "url_label": "Mock Server URL", + "collection_label": "Հավաքածու", + "update_button": "Թարմացնել Mock Server-ը", + "success": "Mock server-ը հաջողությամբ թարմացվեց", + "error": "Չհաջողվեց թարմացնել mock server-ը", + "url_copied": "URL-ը պատճենվեց սեղմատախտակին" + }, + "dashboard": { + "title": "Mock Servers", + "subtitle": "Ստեղծեք և կառավարեք ձեր API mock server-ները", + "create_button": "Ստեղծել Mock Server", + "create_first": "Ստեղծեք ձեր առաջին mock server-ը", + "empty_title": "Mock server-ներ չեն գտնվել", + "empty_description": "Ստեղծեք mock server-ներ ձեր API հավաքածուների հիման վրա՝ frontend և mobile զարգացումը առանց backend կախվածությունների հնարավոր դարձնելու համար:", + "collection": "Հավաքածու", + "active": "Ակտիվ", + "inactive": "Ոչ ակտիվ", + "mock_url": "Mock URL", + "endpoints": "Վերջնակետեր (Endpoints)", + "created": "Ստեղծվել է", + "view_collection": "Դիտել Հավաքածուն", + "documentation": "Փաստաթղթավորում", + "doc_description": "Օգտագործեք այս URL-ը որպես ձեր API-ի հիմնական URL ձեր հավելվածներում.", + "url_copied": "Mock server-ի URL-ը պատճենվեց սեղմատախտակին", + "delete_title": "Ջնջել Mock Server-ը", + "delete_description": "Վստա՞հ եք, որ ցանկանում եք ջնջել այս mock server-ը:", + "delete_success": "Mock server-ը հաջողությամբ ջնջվեց", + "delete_error": "Չհաջողվեց ջնջել mock server-ը" + } + } +} diff --git a/packages/hoppscotch-common/locales/id.json b/packages/hoppscotch-common/locales/id.json new file mode 100644 index 0000000..0684c86 --- /dev/null +++ b/packages/hoppscotch-common/locales/id.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Add", + "autoscroll": "Gulir Otomatis", + "cancel": "Batalkan", + "choose_file": "Pilih file", + "clear": "Bersihkan", + "clear_all": "Bersihkan semua", + "clear_history": "Clear all History", + "close": "Tutup", + "connect": "Sambungkan", + "connecting": "Menyambungkan", + "copy": "Salin", + "create": "Create", + "delete": "Menghapus", + "disconnect": "Memutuskan", + "dismiss": "Tolak", + "dont_save": "Jangan simpan", + "download_file": "Unduh berkas", + "drag_to_reorder": "Seret untuk menyusun ulang", + "duplicate": "Duplikat", + "edit": "Edit", + "filter": "Tanggapan filter", + "go_back": "Kembali", + "go_forward": "Go forward", + "group_by": "Group by", + "hide_secret": "Hide secret", + "label": "Label", + "learn_more": "Pelajari lebih lanjut", + "download_here": "Download here", + "less": "Lebih sedikit", + "more": "Lebih banyak", + "new": "Baru", + "no": "Tidak", + "open_workspace": "Ruang kerja terbuka", + "paste": "Tempel", + "prettify": "Prettify", + "properties": "Properties", + "remove": "Hapus", + "rename": "Rename", + "restore": "Pulihkan", + "save": "Simpan", + "scroll_to_bottom": "Gulir ke bawah", + "scroll_to_top": "Gulir ke atas", + "search": "Cari", + "send": "Kirim", + "share": "Share", + "show_secret": "Show secret", + "start": "Mulai", + "starting": "Memulai", + "stop": "Berhenti", + "to_close": "Untuk menutup", + "to_navigate": "Untuk menavigasi", + "to_select": "Memilih", + "turn_off": "Matikan", + "turn_on": "Nyalakan", + "undo": "Mengembalikan", + "yes": "Ya" + }, + "add": { + "new": "Tambah baru", + "star": "Tambahkan bintang" + }, + "app": { + "chat_with_us": "Berbincanglah dengan kami", + "contact_us": "Hubungi kami", + "cookies": "Cookies", + "copy": "Salin", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Salin Token Otentikasi Pengguna", + "developer_option": "Opsi pengembang", + "developer_option_description": "Alat pengembang yang membantu dalam pengembangan dan pemeliharaan Hoppscotch.", + "discord": "Discord", + "documentation": "Dokumentasi", + "github": "GitHub", + "help": "Bantuan & umpan balik", + "home": "Beranda", + "invite": "Undang", + "invite_description": "Hoppscotch adalah ekosistem pengembangan API open source. Kami merancang antarmuka yang sederhana dan intuitif untuk membuat dan mengelola API Anda. Hoppscotch adalah alat yang membantu Anda membuat, menguji, mendokumentasikan, dan membagikan API Anda.", + "invite_your_friends": "Undang temanmu", + "join_discord_community": "Bergabunglah dengan komunitas Discord kami", + "keyboard_shortcuts": "Pintasan keyboard", + "name": "Hoppscotch", + "new_version_found": "Versi baru ditemukan. Segarkan untuk memperbarui..", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Pilihan", + "proxy_privacy_policy": "Kebijakan privasi proxy", + "reload": "Muat ulang", + "search": "Mencari", + "share": "Membagikan", + "shortcuts": "Jalan pintas", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "Menyoroti", + "status": "Status", + "status_description": "Periksa status situs web", + "terms_and_privacy": "Syarat dan privasi", + "twitter": "Twitter", + "type_a_command_search": "Ketik perintah atau cari…", + "we_use_cookies": "Kami menggunakan cookie", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "Apa yang baru?", + "see_whats_new": "See what’s new", + "wiki": "Wiki" + }, + "auth": { + "account_exists": "Akun ada dengan kredensial berbeda - Masuk untuk menautkan kedua akun", + "all_sign_in_options": "Semua opsi masuk", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "Lanjutkan dengan Surel", + "continue_with_github": "Lanjutkan dengan GitHub", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Lanjutkan dengan Google", + "continue_with_microsoft": "Lanjutkan dengan Microsoft", + "email": "Surel", + "logged_out": "Keluar", + "login": "Masuk", + "login_success": "Berhasil masuk", + "login_to_hoppscotch": "Masuk ke Hoppscotch", + "logout": "Keluar", + "re_enter_email": "Masukkan kembali surel", + "send_magic_link": "Kirim tautan ajaib", + "sync": "Sinkronkan", + "we_sent_magic_link": "Kami mengirimi Anda tautan ajaib!", + "we_sent_magic_link_description": "Periksa kotak masuk Anda - kami mengirim surel ke {email}. Ini berisi tautan ajaib yang akan membuat Anda masuk.." + }, + "authorization": { + "generate_token": "Buat Token", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "Sertakan dalam URL", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "Belajar bagaimana", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "Lewat", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "Kata Sandi", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "Token", + "type": "Jenis Otorisasi", + "username": "Nama Pengguna" + }, + "collection": { + "created": "Koleksi dibuat", + "different_parent": "Tidak dapat mengubah urutan koleksi dengan induk yang berbeda", + "edit": "Mengubah Koleksi", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "Berikan nama untuk Koleksi", + "invalid_root_move": "Koleksi sudah berada di akar direktori", + "moved": "Berhasil Dipindahkan", + "my_collections": "Koleksi Saya", + "name": "Koleksi Baru Saya", + "name_length_insufficient": "Nama koleksi harus minimal 3 karakter", + "new": "Koleksi baru", + "order_changed": "Pembaruan Urutan Koleksi", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "Koleksi berganti nama", + "request_in_use": "Permintaan sedang digunakan", + "save_as": "Simpan Sebagai", + "save_to_collection": "Save to Collection", + "select": "Pilih Koleksi", + "select_location": "Pilih lokasi", + "details": "Details", + "select_team": "Pilih tim", + "team_collections": "Koleksi Tim" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "exit_team": "Apakah Anda yakin ingin keluar dari tim ini?", + "logout": "Apakah Anda yakin ingin keluar?", + "remove_collection": "Apakah Anda yakin ingin menghapus koleksi ini secara permanen?", + "remove_environment": "Apakah Anda yakin ingin menghapus lingkungan ini secara permanen?", + "remove_folder": "Apakah Anda yakin ingin menghapus folder ini secara permanen?", + "remove_history": "Apakah Anda yakin ingin menghapus semua riwayat secara permanen?", + "remove_request": "Apakah Anda yakin ingin menghapus permintaan ini secara permanen?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "Apakah Anda yakin ingin menghapus tim ini?", + "remove_telemetry": "Apakah Anda yakin ingin menyisih dari Telemetri?", + "request_change": "Apakah Anda yakin ingin membuang permintaan saat ini, perubahan yang belum disimpan akan hilang.", + "save_unsaved_tab": "Apakah Anda ingin menyimpan perubahan yang dibuat di tab ini?", + "sync": "Apakah Anda ingin memulihkan ruang kerja Anda dari cloud? Ini akan membuang kemajuan lokal Anda.", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "Header {count}", + "message": "Pesan {count}", + "parameter": "Parameter {count}", + "protocol": "Protocol {count}", + "value": "Nilai {count}", + "variable": "Variabel {count}" + }, + "documentation": { + "generate": "Buat dokumentasi", + "generate_message": "Impor koleksi Hoppscotch apa pun untuk menghasilkan dokumentasi API saat on-the-go." + }, + "empty": { + "authorization": "Permintaan ini tidak menggunakan otorisasi apa pun", + "body": "Permintaan ini tidak memiliki body", + "collection": "Koleksi kosong", + "collections": "Koleksi kosong", + "documentation": "Hubungkan ke endpoint GraphQL untuk melihat dokumentasi", + "endpoint": "Endpoint tidak boleh kosong", + "environments": "Environments kosong", + "folder": "Folder kosong", + "headers": "Permintaan ini tidak memiliki headers", + "history": "Riwayat kosong", + "invites": "Daftar undangan kosong", + "members": "Team kosong", + "parameters": "Permintaan ini tidak memiliki parameter apa pun", + "pending_invites": "Tidak ada undangan yang tertunda untuk tim ini", + "profile": "Masuk untuk melihat profil Anda", + "protocols": "Protokol kosong", + "request_variables": "This request does not have any request variables", + "schema": "Hubungkan ke endpoint GraphQL untuk melihat skema", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "Langganan kosong", + "team_name": "Nama team kosong", + "teams": "Kamu bukan di team manapun", + "tests": "Tidak ada tes untuk permintaan ini", + "access_tokens": "Access tokens are empty", + "shortcodes": "Shortcodes kosong" + }, + "environment": { + "add_to_global": "Tambahkan ke Global", + "added": "Tambahan Environment", + "create_new": "Membuat environment baru", + "created": "Environment dibuat", + "deleted": "Environment dihapus", + "duplicated": "Environment duplicated", + "edit": "Sunting Environment", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create a environment", + "invalid_name": "Tolong beri nama untuk environment", + "list": "Environment variables", + "my_environments": "Environment Saya", + "name": "Name", + "nested_overflow": "Variabel environment bersarang dibatasi hingga 10 level", + "new": "Environment Baru", + "no_active_environment": "No active environment", + "no_environment": "No environment", + "no_environment_description": "Tidak ada environment yang dipilih. Pilih apa yang harus dilakukan dengan variabel berikut.", + "quick_peek": "Environment Quick Peek", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "Pilih environment", + "set": "Set environment", + "set_as_environment": "Set as environment", + "team_environments": "Environment Tim", + "title": "Environment", + "updated": "Environment diperbarui", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Daftar Variable", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "Browser ini sepertinya tidak memiliki dukungan Server Sent Events.", + "check_console_details": "Periksa console log untuk detailnya.", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "cURL tidak diformat dengan benar", + "danger_zone": "Danger zone", + "delete_account": "Akun Anda saat ini merupakan pemilik dalam tim-tim ini:", + "delete_account_description": "Anda harus menghapus diri Anda dari tim-tim ini, mentransfer kepemilikan, atau menghapus tim-tim ini sebelum Anda dapat menghapus akun Anda.", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "Nama Permintaan Kosong", + "f12_details": "(F12 untuk detailnya)", + "gql_prettify_invalid_query": "Tidak dapat prettify kueri yang tidak valid, menyelesaikan kesalahan sintaksis kueri, dan coba lagi", + "incomplete_config_urls": "URL konfigurasi tidak lengkap", + "incorrect_email": "Surel Salah", + "invalid_link": "Tautan tidak valid", + "invalid_link_description": "Tautan yang Anda klik tidak valid atau kedaluwarsa.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "JSON tidak valid", + "json_prettify_invalid_body": "Tidak dapat prettify body yang tidak valid, selesaikan kesalahan sintaks json dan coba lagi", + "network_error": "Sepertinya ada kesalahan jaringan. Silakan coba lagi.", + "network_fail": "Tidak dapat mengirim permintaan", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "Tidak ada durasi", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "Tidak ada kecocokan yang ditemukan", + "page_not_found": "Halaman ini tidak dapat ditemukan", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "Tidak dapat menjalankan pre-request script", + "something_went_wrong": "Ada yang salah", + "post_request_script_fail": "Tidak dapat mengeksekusi post-request script", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "Export sebagai JSON", + "create_secret_gist": "Buat secret Gist", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "Masuk dengan GitHub untuk membuat secret gist", + "title": "Export", + "success": "Successfully exported", + "gist_created": "Gist dibuat" + }, + "filter": { + "all": "All", + "none": "None", + "starred": "Starred" + }, + "folder": { + "created": "Folder dibuat", + "edit": "Edit Folder", + "invalid_name": "Harap berikan nama untuk foldernya", + "name_length_insufficient": "Nama folder harus minimal 3 karakter", + "new": "Folder baru", + "renamed": "Folder berganti nama" + }, + "graphql": { + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "mutations": "Mutations", + "schema": "Schema", + "subscriptions": "Subscriptions", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "Time", + "url": "URL" + }, + "header": { + "install_pwa": "Pasang app", + "login": "Login", + "save_workspace": "Simpan Ruang Kerja Saya" + }, + "helpers": { + "authorization": "Header otorisasi akan dibuat secara otomatis saat Anda mengirim permintaan.", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "Generate dokumentasi dulu", + "network_fail": "Tidak dapat mencapai API endpoint. Periksa koneksi jaringan Anda atau pilih Interceptor lain dan coba lagi.", + "offline": "Anda tampaknya sedang offline. Data di ruang kerja ini mungkin tidak terbarui.", + "offline_short": "Anda sepertinya sedang offline.", + "post_request_tests": "Pengujian scripts ditulis dalam JavaScript, dan dijalankan setelah respons diterima.", + "pre_request_script": "Pre-request scripts ditulis dalam JavaScript, dan dijalankan sebelum request dikirim.", + "script_fail": "Sepertinya ada kesalahan dalam pre-request script. Periksa kesalahan di bawah ini dan perbaiki script yang sesuai.", + "post_request_script_fail": "Tampaknya ada kesalahan dengan post-request script. Harap perbaiki kesalahan dan jalankan tes lagi", + "post_request_script": "Tulis test script untuk mengotomatiskan debugging." + }, + "hide": { + "collection": "Ciutkan Panel Koleksi", + "more": "Sembunyikan lainnya", + "preview": "Sembunyikan preview", + "sidebar": "Ciutkan sidebar" + }, + "import": { + "collections": "Impor koleksi", + "curl": "Impor cURL", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "Kesalahan saat mengimpor: format tidak dikenali", + "from_file": "Import from File", + "from_gist": "Impor dari Gist", + "from_gist_description": "Impor dari Gist URL", + "from_insomnia": "Impor dari Insomnia", + "from_insomnia_description": "Impor dari Koleksi Insomnia", + "from_json": "Impor dari Hoppscotch", + "from_json_description": "Impor dari Hoppscotch berkas koleksi", + "from_my_collections": "Impor dari Koleksi Saya", + "from_my_collections_description": "Impor dari Berkas Koleksi Saya", + "from_openapi": "Impor dari OpenAPI", + "from_openapi_description": "Impor dari OpenAPI syarat berkas (YML/JSON)", + "from_postman": "Impor dari Postman", + "from_postman_description": "Impor dari Koleksi Postman", + "from_url": "Impor dari URL", + "gist_url": "Masukkan Gist URL", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "Tidak bisa mendapatkan data dari url", + "import_from_url_invalid_file_format": "Kesalahan saat mengimpor koleksi", + "import_from_url_invalid_type": "Jenis tidak didukung. nilai yang diterima adalah 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Impor koleksi berhasil", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Impor Koleksi dari berkas JSON Koleksi Hoppscotch", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "Impor", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled." + } + }, + "layout": { + "collapse_collection": "Ciutkan atau Perluas Collections", + "collapse_sidebar": "Ciutkan atau Perluas sidebar", + "column": "Vertikal layout", + "name": "Layout", + "row": "Horisontal layout" + }, + "modal": { + "close_unsaved_tab": "Anda memiliki perubahan yang belum disimpan", + "collections": "Koleksi", + "confirm": "Mengonfirmasi", + "customize_request": "Customize Request", + "edit_request": "Edit Request", + "import_export": "Impor / Ekspor", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "Anda sudah berlangganan topik ini.", + "clean_session": "Sesi Bersih", + "clear_input": "Hapus input", + "clear_input_on_send": "Hapus input saat mengirim", + "client_id": "Client ID", + "color": "Pilih warna", + "communication": "Komunikasi", + "connection_config": "Konfigurasi Koneksi", + "connection_not_authorized": "Koneksi MQTT ini tidak menggunakan otentikasi", + "invalid_topic": "Harap berikan topik untuk langganan", + "keep_alive": "Keep Alive", + "log": "Log", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "Pesan", + "new": "Langganan Baru", + "not_connected": "Mulai koneksi MQTT terlebih dahulu", + "publish": "Menerbitkan", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Langganan", + "topic": "Topik", + "topic_name": "Nama Topik", + "topic_title": "Menerbitkan / Langganan topik", + "unsubscribe": "Berhenti berlangganan", + "url": "URL" + }, + "navigation": { + "doc": "Docs", + "graphql": "GraphQL", + "profile": "Profil", + "realtime": "Realtime", + "rest": "REST", + "settings": "Pengaturan" + }, + "preRequest": { + "javascript_code": "JavaScript Code", + "learn": "Baca dokumentasi", + "script": "Pre-Request Script", + "snippets": "Snippets" + }, + "profile": { + "app_settings": "Pengaturan aplikasi", + "default_hopp_displayname": "Unnamed User", + "editor": "Editor", + "editor_description": "Editor dapat menambah, mengedit, dan menghapus request.", + "email_verification_mail": "Surel verifikasi telah dikirim ke alamat surel Anda. Silakan klik tautan untuk memverifikasi alamat surel Anda.", + "no_permission": "Anda tidak memiliki izin untuk melakukan tindakan ini.", + "owner": "Owner", + "owner_description": "Pemilik dapat menambah, mengedit, dan menghapus permintaan, koleksi, dan anggota tim.", + "roles": "Roles", + "roles_description": "Roles digunakan untuk mengontrol akses ke koleksi bersama.", + "updated": "Profil diperbarui", + "viewer": "Penonton", + "viewer_description": "Penonton hanya dapat melihat dan menggunakan requests.." + }, + "remove": { + "star": "Hapus bintang" + }, + "request": { + "added": "Request ditambahkan", + "authorization": "Authorization", + "body": "Request Body", + "choose_language": "Pilih bahasa", + "content_type": "Content Type", + "content_type_titles": { + "others": "Others", + "structured": "Structured", + "text": "Text" + }, + "different_collection": "Tidak dapat mengubah urutan permintaan dari koleksi yang berbeda", + "duplicated": "Request duplicated", + "duration": "Durasi", + "enter_curl": "Masukkan cURL", + "generate_code": "Hasilkan kode", + "generated_code": "Hasilkan kode", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "Daftar Header", + "invalid_name": "Harap berikan nama untuk request", + "method": "Method", + "moved": "Request moved", + "name": "Request nama", + "new": "Request baru", + "order_changed": "Urutan Request Diperbarui", + "override": "Membatalkan", + "override_help": "Set Content-Type in Headers", + "overriden": "Diganti", + "parameter_list": "Query Parameters", + "parameters": "Parameters", + "path": "Path", + "payload": "Payload", + "query": "Query", + "raw_body": "Raw Request Body", + "rename": "Rename Request", + "renamed": "Request berganti nama", + "request_variables": "Request variables", + "run": "Jalankan", + "save": "Menyimpan", + "save_as": "Simpan sebagai", + "saved": "Request disimpan", + "share": "Membagikan", + "share_description": "Bagikan Hoppscotch dengan teman-teman Anda", + "share_request": "Share Request", + "stop": "Stop", + "title": "Request", + "type": "Tipe Request", + "url": "URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "Variables", + "view_my_links": "Lihat tautan saya", + "copy_link": "Salin tautan" + }, + "response": { + "audio": "Audio", + "body": "Response Body", + "filter_response_body": "Filter body respons JSON (menggunakan sintaks jq)", + "headers": "Headers", + "html": "HTML", + "image": "Gambar", + "json": "JSON", + "pdf": "PDF", + "preview_html": "Pratinjau HTML", + "raw": "Raw", + "size": "Size", + "status": "Status", + "time": "Waktu", + "title": "Response", + "video": "Video", + "waiting_for_connection": "Menunggu koneksi", + "xml": "XML" + }, + "settings": { + "accent_color": "Accent color", + "account": "Akun", + "account_deleted": "Akun Anda telah dihapus", + "account_description": "Sesuaikan pengaturan akun Anda.", + "account_email_description": "Alamat surel utama Anda.", + "account_name_description": "Ini adalah nama tampilan Anda.", + "additional": "Additional Settings", + "background": "Latar belakang", + "black_mode": "Hitam", + "choose_language": "Pilih bahasa", + "dark_mode": "Gelap", + "delete_account": "Delete account", + "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", + "expand_navigation": "Perluas navigasi", + "experiments": "Eksperimen", + "experiments_notice": "Ini adalah kumpulan eksperimen yang sedang kami kerjakan yang mungkin berguna, menyenangkan, keduanya, atau tidak keduanya. Mereka tidak final dan mungkin tidak stabil, jadi jika sesuatu yang terlalu aneh terjadi, jangan panik. Matikan saja. Kesampingkan lelucon, ", + "extension_ver_not_reported": "Tidak Dilaporkan", + "extension_version": "Versi Ekstensi", + "extensions": "Ekstensi Peramban", + "extensions_use_toggle": "Gunakan ekstensi peramban untuk mengirim permintaan (jika ada)", + "follow": "Ikuti kami", + "interceptor": "Pencegat", + "interceptor_description": "Middleware antara aplikasi dan API.", + "language": "Bahasa", + "light_mode": "Terang", + "official_proxy_hosting": "Proxy resmi dihosting oleh Hoppscotch.", + "profile": "Profil", + "profile_description": "Perbarui detail profil Anda", + "profile_email": "Alamat surel", + "profile_name": "Nama profil", + "proxy": "Proxy", + "proxy_url": "Proxy URL", + "proxy_use_toggle": "Gunakan middleware proxy untuk mengirim requests", + "read_the": "Baca", + "reset_default": "Setel ulang ke default", + "short_codes": "Short codes", + "short_codes_description": "Short codes yang Anda buat.", + "sidebar_on_left": "Sidebar berada di kiri", + "sync": "Synchronise", + "sync_collections": "Koleksi", + "sync_description": "Pengaturan ini disinkronkan ke cloud.", + "sync_environments": "Environments", + "sync_history": "Riwayat", + "system_mode": "Sistem", + "telemetry": "Telemetri", + "telemetry_helps_us": "Telemetri membantu kami mempersonalisasi operasi kami dan memberikan pengalaman terbaik kepada Anda.", + "theme": "Tema", + "theme_description": "Sesuaikan tema aplikasi Anda.", + "use_experimental_url_bar": "Gunakan experimental URL bar dengan environment highlighting", + "user": "Pengguna", + "verified_email": "Surel terverifikasi", + "verify_email": "Verifikasi surel" + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Tutup menu saat ini", + "command_menu": "Cari & menu perintah", + "help_menu": "Menu bantuan", + "show_all": "Pintasan keyboard", + "title": "Umum" + }, + "miscellaneous": { + "invite": "Undang orang ke Hoppscotch", + "title": "Miscellaneous" + }, + "navigation": { + "back": "Kembali ke halaman sebelumnya", + "documentation": "Pergi ke halaman Dokumentasi", + "forward": "Pergi ke halaman berikutnya", + "graphql": "Pergi ke halaman GraphQL", + "profile": "Pergi ke halaman Profile", + "realtime": "Pergi ke halaman Realtime", + "rest": "Pergi ke halaman REST", + "settings": "Pergi ke halaman Pengaturan", + "title": "Navigasi" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "Pilih metode DELETE", + "get_method": "Pilih metode GET", + "head_method": "Pilih metode HEAD", + "import_curl": "Import cURL", + "method": "metode", + "next_method": "Pilih metode Next", + "post_method": "Pilih metode POST", + "previous_method": "Pilih metode Previous", + "put_method": "Pilih metode PUT", + "rename": "Rename Request", + "reset_request": "Mengatur ulang Request", + "save_request": "Save Request", + "save_to_collections": "Menyimpan ke Collections", + "send_request": "Kirim Request", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "Request", + "copy_request_link": "Salin Tautan Permintaan" + }, + "response": { + "copy": "Salin response ke papan klip", + "download": "Unduh response sebagai file", + "title": "Response" + }, + "theme": { + "black": "Alihkan tema ke mode hitam", + "dark": "Alihkan tema ke mode gelap", + "light": "Alihkan tema ke mode terang", + "system": "Alihkan tema ke mode sistem", + "title": "Tema" + } + }, + "show": { + "code": "Tampilkan code", + "collection": "Luaskan Panel Collection", + "more": "Lebih banyak", + "sidebar": "Memperluas sidebar" + }, + "socketio": { + "communication": "Komunikasi", + "connection_not_authorized": "Koneksi SocketIO ini tidak menggunakan otentikasi apa pun.", + "event_name": "Nama Event", + "events": "Events", + "log": "Log", + "url": "URL" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current team", + "edit": "Edit current team", + "invite": "Invite people to team", + "new": "Create new team", + "switch_to_personal": "Switch to your personal workspace", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "Tipe Event", + "log": "Log", + "url": "URL" + }, + "state": { + "bulk_mode": "Bulk edit", + "bulk_mode_placeholder": "Entri dipisahkan oleh baris baru\nKeys dan nilai dipisahkan oleh :\nPrepend # ke baris mana pun yang ingin Anda tambahkan tetapi tetap dinonaktifkan", + "cleared": "Dihapus", + "connected": "Terhubung", + "connected_to": "Terhubung dengan {name}", + "connecting_to": "Menghubungkan ke {name}...", + "connection_error": "Gagal terhubung", + "connection_failed": "Koneksi gagal", + "connection_lost": "Koneksi terputus", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "Disalin ke papan klip", + "deleted": "Dihapus", + "deprecated": "Tidak digunakan lagi", + "disabled": "Nonaktifkan", + "disconnected": "Terputus", + "disconnected_from": "Terputus dari {name}", + "docs_generated": "Dokumentasi dihasilkan", + "download_failed": "Download failed", + "download_started": "Unduhan dimulai", + "enabled": "Diaktifkan", + "file_imported": "File diimpor", + "finished_in": "Selesai dalam {duration} ms", + "hide": "Hide", + "history_deleted": "Riwayat dihapus", + "linewrap": "Bungkus baris", + "loading": "Memuat...", + "message_received": "Pesan: {message} tiba di topik: {topic}", + "mqtt_subscription_failed": "Terjadi masalah saat berlangganan topik: {topic}", + "none": "Tidak ada", + "nothing_found": "Tidak ada yang ditemukan untuk", + "published_error": "Terjadi masalah saat memublikasikan msg: {topic} ke topik: {message}", + "published_message": "Pesan yang dipublikasikan: {message} ke topik: {topic}", + "reconnection_error": "Gagal menyambung kembali", + "show": "Show", + "subscribed_failed": "Gagal berlangganan topik: {topic}", + "subscribed_success": "Berhasil berlangganan topik: {topic}", + "unsubscribed_failed": "Gagal berhenti berlangganan dari topik: {topic}", + "unsubscribed_success": "Berhasil berhenti berlangganan dari topik: {topic}", + "waiting_send_request": "Menunggu untuk mengirim request" + }, + "support": { + "changelog": "Baca lebih lanjut tentang rilis terbaru", + "chat": "Pertanyaan? berbincanglah dengan kami!", + "community": "Ajukan pertanyaan dan bantu orang lain", + "documentation": "Baca lebih lanjut tentang Hoppscotch", + "forum": "Ajukan pertanyaan dan dapatkan jawaban", + "github": "Ikuti kami di GitHub", + "shortcuts": "Jelajahi aplikasi lebih cepat", + "title": "Mendukung", + "twitter": "Ikuti kami di Twitter", + "team": "Hubungi tim" + }, + "tab": { + "authorization": "Authorization", + "body": "Body", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "Collections", + "documentation": "Dokumentasi", + "duplicate": "Duplicate Tab", + "environments": "Environments", + "headers": "Headers", + "history": "Riwayat", + "mqtt": "MQTT", + "parameters": "Parameters", + "pre_request_script": "Pre-request Script", + "queries": "Queries", + "query": "Query", + "schema": "Schema", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Tests", + "types": "Types", + "variables": "Variables", + "websocket": "WebSocket" + }, + "team": { + "already_member": "Anda sudah menjadi anggota tim ini. Hubungi pemilik tim Anda.", + "create_new": "Buat tim baru", + "deleted": "Tim dihapus", + "edit": "Edit Tim", + "email": "E-mail", + "email_do_not_match": "Surel tidak cocok dengan detail akun Anda. Hubungi pemilik tim Anda.", + "exit": "Keluar dari Tim", + "exit_disabled": "Hanya pemilik yang tidak dapat keluar dari tim", + "failed_invites": "Failed invites", + "invalid_coll_id": "ID koleksi tidak valid", + "invalid_email_format": "Format surel tidak valid", + "invalid_id": "ID tim tidak valid. Hubungi pemilik tim Anda.", + "invalid_invite_link": "Tautan undangan tidak valid", + "invalid_invite_link_description": "Tautan yang Anda ikuti tidak valid. Hubungi pemilik tim Anda.", + "invalid_member_permission": "Harap berikan izin yang valid kepada anggota tim", + "invite": "Mengundang", + "invite_more": "Undang lebih banyak", + "invite_tooltip": "Undang orang ke ruang kerja ini", + "invited_to_team": "{owner} mengundang Anda untuk bergabung {team}", + "join": "Undangan diterima", + "join_team": "Bergabung {team}", + "joined_team": "Anda telah bergabung {team}", + "joined_team_description": "Anda sekarang adalah anggota tim ini", + "left": "Anda meninggalkan tim", + "login_to_continue": "Masuk untuk melanjutkan", + "login_to_continue_description": "Anda harus login untuk bergabung dengan tim.", + "logout_and_try_again": "Keluar dan login dengan akun lain", + "member_has_invite": "ID email ini sudah memiliki undangan. Hubungi pemilik tim Anda.", + "member_not_found": "Anggota tidak ditemukan. Hubungi pemilik tim Anda.", + "member_removed": "Pengguna dihapus", + "member_role_updated": "Peran pengguna diperbarui", + "members": "Anggota", + "more_members": "+{count} lebih", + "name_length_insufficient": "Nama tim harus setidaknya 6 karakter", + "name_updated": "Nama tim diperbarui", + "new": "Tim Baru", + "new_created": "Tim baru dibuat", + "new_name": "Tim baru saya", + "no_access": "Anda tidak memiliki akses edit ke collections ini", + "no_invite_found": "Undangan tidak ditemukan. Hubungi pemilik tim Anda.", + "no_request_found": "Request tidak ditemukan.", + "not_found": "Tim tidak ditemukan. Hubungi pemilik tim Anda.", + "not_valid_viewer": "Anda bukan penonton yang valid. Hubungi pemilik tim Anda.", + "parent_coll_move": "Tidak dapat memindahkan koleksi ke dalam koleksi anak", + "pending_invites": "Undangan tertunda", + "permissions": "Izin", + "same_target_destination": "Sama tujuan dan destinasi", + "saved": "Tim disimpan", + "select_a_team": "Pilih tim", + "success_invites": "Success invites", + "title": "tim", + "we_sent_invite_link": "Kami mengirim tautan undangan ke semua undangan!", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "Minta semua undangan untuk memeriksa kotak masuk mereka. Klik tautan untuk bergabung dengan tim.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "Bergabunglah dengan program beta untuk mengakses tim." + }, + "team_environment": { + "deleted": "Environment dihapus", + "duplicate": "Environment diduplikasi", + "not_found": "Environment tidak ditemukan." + }, + "test": { + "failed": "Tes gagal", + "javascript_code": "JavaScript Code", + "learn": "Baca dokumentasi", + "passed": "Tes lulus", + "report": "Laporan pengujian", + "results": "Hasil tes", + "script": "Script", + "snippets": "Snippets" + }, + "websocket": { + "communication": "Komunikasi", + "log": "Log", + "message": "Pesan", + "protocols": "Protokol", + "url": "URL" + }, + "workspace": { + "change": "Beralih workspace", + "personal": "Workspace Saya", + "other_workspaces": "My Workspaces", + "team": "Workspace Tim", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "Tindakan", + "created_on": "Dibuat pada", + "deleted": "Shortcode dihapus", + "method": "Method", + "not_found": "Shortcode tidak ditemukan", + "short_code": "Short code", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/it.json b/packages/hoppscotch-common/locales/it.json new file mode 100644 index 0000000..d36b32d --- /dev/null +++ b/packages/hoppscotch-common/locales/it.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Add", + "autoscroll": "Autoscroll", + "cancel": "Annulla", + "choose_file": "Scegli un file", + "clear": "Cancella", + "clear_all": "Cancella tutto", + "clear_history": "Clear all History", + "close": "Close", + "connect": "Connetti", + "connecting": "Connecting", + "copy": "Copia", + "create": "Create", + "delete": "Elimina", + "disconnect": "Disconnetti", + "dismiss": "Abbandona", + "dont_save": "Don't save", + "download_file": "Scarica file", + "drag_to_reorder": "Drag to reorder", + "duplicate": "Duplica", + "edit": "Modifica", + "filter": "Filter", + "go_back": "Torna indietro", + "go_forward": "Go forward", + "group_by": "Group by", + "hide_secret": "Hide secret", + "label": "Etichetta", + "learn_more": "Per saperne di più", + "download_here": "Download here", + "less": "Less", + "more": "Di più", + "new": "Nuovo", + "no": "No", + "open_workspace": "Open workspace", + "paste": "Paste", + "prettify": "Abbellisci", + "properties": "Properties", + "remove": "Rimuovi", + "rename": "Rename", + "restore": "Ripristina", + "save": "Salva", + "scroll_to_bottom": "Scroll to bottom", + "scroll_to_top": "Scroll to top", + "search": "Cerca", + "send": "Invia", + "share": "Share", + "show_secret": "Show secret", + "start": "Avvia", + "starting": "Starting", + "stop": "Interrompi", + "to_close": "to close", + "to_navigate": "to navigate", + "to_select": "to select", + "turn_off": "Spegni", + "turn_on": "Accendi", + "undo": "Annulla", + "yes": "Sì" + }, + "add": { + "new": "Aggiungi nuova", + "star": "Aggiungi stella" + }, + "app": { + "chat_with_us": "Chatta con noi", + "contact_us": "Contattaci", + "cookies": "Cookies", + "copy": "Copia", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Copy User Auth Token", + "developer_option": "Developer options", + "developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.", + "discord": "Discord", + "documentation": "Documentazione", + "github": "GitHub", + "help": "Aiuto, feedback e documentazione", + "home": "Home", + "invite": "Invita", + "invite_description": "In Hoppscotch, abbiamo progettato un'interfaccia semplice e intuitiva per creare e gestire le tue API. Hoppscotch è uno strumento che ti aiuta a creare, testare, documentare e condividere le tue API.", + "invite_your_friends": "Invita i tuoi amici", + "join_discord_community": "Unisciti alla nostra comunità Discord", + "keyboard_shortcuts": "Tasti rapidi", + "name": "Hoppscotch", + "new_version_found": "Nuova versione trovata. Aggiorna la pagina per aggiornare.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Options", + "proxy_privacy_policy": "Informativa sulla privacy del proxy", + "reload": "Ricarica", + "search": "Cerca", + "share": "Condividi", + "shortcuts": "Scorciatoie", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "Riflettore", + "status": "Stato", + "status_description": "Check the status of the website", + "terms_and_privacy": "Termini e privacy", + "twitter": "Twitter", + "type_a_command_search": "Digita un comando o cerca...", + "we_use_cookies": "Utilizziamo i cookie", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "Cosa c'è di nuovo?", + "see_whats_new": "See what’s new", + "wiki": "Wiki" + }, + "auth": { + "account_exists": "L'account esiste con credenziali diverse - Accedi per collegare entrambi gli account", + "all_sign_in_options": "Tutte le opzioni di accesso", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "Continua con e-mail", + "continue_with_github": "Continua con GitHub", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Continua con Google", + "continue_with_microsoft": "Continue with Microsoft", + "email": "E-mail", + "logged_out": "Disconnesso", + "login": "Accedi", + "login_success": "Accesso effettuato con successo", + "login_to_hoppscotch": "Accedi a Hoppscotch", + "logout": "Disconnettersi", + "re_enter_email": "Reinserisci l'email", + "send_magic_link": "Invia un link magico", + "sync": "Sincronizza", + "we_sent_magic_link": "Ti abbiamo inviato un link magico!", + "we_sent_magic_link_description": "Controlla la tua casella di posta: abbiamo inviato un'email a {email}. Contiene un collegamento magico che ti consentirà di accedere." + }, + "authorization": { + "generate_token": "Genera token", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "Includi nell'URL", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "Impara come", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "Pass by", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "Password", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "Token", + "type": "Tipo di autorizzazione", + "username": "Nome utente" + }, + "collection": { + "created": "Raccolta creata", + "different_parent": "Cannot reorder collection with different parent", + "edit": "Modifica raccolta", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "Si prega di fornire un nome valido per la raccolta", + "invalid_root_move": "Collection already in the root", + "moved": "Moved Successfully", + "my_collections": "Le Mie Raccolte", + "name": "La mia nuova raccolta", + "name_length_insufficient": "Collection name should be at least 3 characters long", + "new": "Nuova raccolta", + "order_changed": "Collection Order Updated", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "Raccolta rinominata", + "request_in_use": "Request in use", + "save_as": "Salva come", + "save_to_collection": "Save to Collection", + "select": "Seleziona una raccolta", + "select_location": "Seleziona la posizione", + "details": "Details", + "select_team": "Seleziona un team", + "team_collections": "Raccolte di team" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "exit_team": "Are you sure you want to leave this team?", + "logout": "Sei sicuro di voler uscire?", + "remove_collection": "Sei sicuro di voler eliminare definitivamente questa raccolta?", + "remove_environment": "Sei sicuro di voler eliminare definitivamente questo ambiente?", + "remove_folder": "Sei sicuro di voler eliminare definitivamente questa cartella?", + "remove_history": "Sei sicuro di voler eliminare definitivamente tutta la cronologia?", + "remove_request": "Sei sicuro di voler eliminare definitivamente questa richiesta?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "Sei sicuro di voler eliminare questo team?", + "remove_telemetry": "Sei sicuro di voler disattivare la telemetria?", + "request_change": "Are you sure you want to discard current request, unsaved changes will be lost.", + "save_unsaved_tab": "Do you want to save changes made in this tab?", + "sync": "Vuoi ripristinare il tuo spazio di lavoro con quello del cloud? Questo annullerà le tue modifiche fatte in locale.", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "Intestazione {count}", + "message": "Messaggio {count}", + "parameter": "Parametro {count}", + "protocol": "Protocollo {count}", + "value": "Valore {count}", + "variable": "Variabile {count}" + }, + "documentation": { + "generate": "Genera documentazione", + "generate_message": "Importa qualsiasi raccolta Hoppscotch per generare documentazione API al volo." + }, + "empty": { + "authorization": "Questa richiesta non utilizza alcuna autorizzazione", + "body": "Questa richiesta non ha un corpo", + "collection": "La raccolta è vuota", + "collections": "Le raccolte sono vuote", + "documentation": "Connetti ad un endpoint GraphQL per mostrare la documentazione", + "endpoint": "Endpoint cannot be empty", + "environments": "Gli ambienti sono vuoti", + "folder": "La cartella è vuota", + "headers": "Questa richiesta non ha intestazioni", + "history": "La cronologia è vuota", + "invites": "La lista degli inviti è vuota", + "members": "Il team non ha membri", + "parameters": "Questa richiesta non ha parametri", + "pending_invites": "Non ci sono inviti pendenti per questo team", + "profile": "Accedi per mostrare il tuo profilo", + "protocols": "I protocolli sono vuoti", + "request_variables": "This request does not have any request variables", + "schema": "Connettiti a un endpoint GraphQL per mostrare lo schema", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "Subscriptions are empty", + "team_name": "Il nome del team è vuoto", + "teams": "I team sono vuoti", + "tests": "Non ci sono test per questa richiesta", + "access_tokens": "Access tokens are empty", + "shortcodes": "Shortcodes are empty" + }, + "environment": { + "add_to_global": "Add to Global", + "added": "Environment addition", + "create_new": "Crea un nuovo ambiente", + "created": "Environment created", + "deleted": "Environment deletion", + "duplicated": "Environment duplicated", + "edit": "Modifica ambiente", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create a environment", + "invalid_name": "Si prega di fornire un nome valido per l'ambiente", + "list": "Environment variables", + "my_environments": "My Environments", + "name": "Name", + "nested_overflow": "nested environment variables are limited to 10 levels", + "new": "Nuovo ambiente", + "no_active_environment": "No active environment", + "no_environment": "Nessun ambiente", + "no_environment_description": "No environments were selected. Choose what to do with the following variables.", + "quick_peek": "Environment Quick Peek", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "Seleziona ambiente", + "set": "Set environment", + "set_as_environment": "Set as environment", + "team_environments": "Team Environments", + "title": "Ambienti", + "updated": "Environment updation", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Elenco variabili", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "Questo browser non sembra supportare gli eventi inviati dal server (Server Sent Events).", + "check_console_details": "Controlla il log della console per i dettagli.", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "cURL non è formattato correttamente", + "danger_zone": "Danger zone", + "delete_account": "Your account is currently an owner in these teams:", + "delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "Nome richiesta vuoto", + "f12_details": "(F12 per i dettagli)", + "gql_prettify_invalid_query": "Impossibile abbellire una query non valida, risolvere gli errori di sintassi della query e riprovare", + "incomplete_config_urls": "Incomplete configuration URLs", + "incorrect_email": "Indirizzo email errato", + "invalid_link": "Invalid link", + "invalid_link_description": "The link you clicked is invalid or expired.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "Invalid JSON", + "json_prettify_invalid_body": "Impossibile abbellire un corpo non valido, risolvere gli errori di sintassi JSON e riprovare", + "network_error": "Sembra ci sia un problema di rete. Per favore prova di nuovo.", + "network_fail": "Impossibile inviare la richiesta", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "Nessuna durata", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "No matches found", + "page_not_found": "This page could not be found", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "Impossibile eseguire lo script di pre-richiesta", + "something_went_wrong": "Qualcosa è andato storto", + "post_request_script_fail": "Could not execute post-request script", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "Esporta come JSON", + "create_secret_gist": "Crea un Gist segreto", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "Accedi con GitHub per creare un Gist segreto", + "title": "Export", + "success": "Successfully exported", + "gist_created": "Gist creato" + }, + "filter": { + "all": "All", + "none": "None", + "starred": "Starred" + }, + "folder": { + "created": "Cartella creata", + "edit": "Modifica cartella", + "invalid_name": "Si prega di fornire un nome per la cartella", + "name_length_insufficient": "Folder name should be at least 3 characters long", + "new": "Nuova cartella", + "renamed": "Cartella rinominata" + }, + "graphql": { + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "mutations": "Mutazioni", + "schema": "Schema", + "subscriptions": "Sottoscrizioni", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "Time", + "url": "URL" + }, + "header": { + "install_pwa": "Installa l'applicazione", + "login": "Accedi", + "save_workspace": "Salva il mio spazio di lavoro" + }, + "helpers": { + "authorization": "L'intestazione di autorizzazione verrà generata automaticamente quando invii la richiesta.", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "Generare prima la documentazione", + "network_fail": "Impossibile raggiungere l'endpoint API. Controlla la tua connessione di rete o accendi il Proxy Interceptor e riprova.", + "offline": "Sembra che tu sia offline. I dati in questo spazio di lavoro potrebbero non essere aggiornati.", + "offline_short": "Sembra che tu sia offline.", + "post_request_tests": "Gli script di test sono scritti in JavaScript e vengono eseguiti dopo aver ricevuto la risposta.", + "pre_request_script": "Gli script di pre-richiesta sono scritti in JavaScript e vengono eseguiti prima dell'invio della richiesta.", + "script_fail": "Sembra che ci sia un errore nello script di pre-richiesta. Controllare l'errore di seguito e correggere lo script di conseguenza.", + "post_request_script_fail": "There seems to be an error with post-request script. Please fix the errors and run tests again", + "post_request_script": "Scrivi uno script di test per automatizzare il debug." + }, + "hide": { + "collection": "Collapse Collection Panel", + "more": "Nascondi di più", + "preview": "Nascondi anteprima", + "sidebar": "Nascondi barra laterale" + }, + "import": { + "collections": "Importa raccolte", + "curl": "Importa cURL", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "Importazione non riuscita", + "from_file": "Import from File", + "from_gist": "Importa da Gist", + "from_gist_description": "Import from Gist URL", + "from_insomnia": "Import from Insomnia", + "from_insomnia_description": "Import from Insomnia collection", + "from_json": "Import from Hoppscotch", + "from_json_description": "Import from Hoppscotch collection file", + "from_my_collections": "Importa da Le Mie Raccolte", + "from_my_collections_description": "Import from My Collections file", + "from_openapi": "Import from OpenAPI", + "from_openapi_description": "Import from OpenAPI specification file (YML/JSON)", + "from_postman": "Import from Postman", + "from_postman_description": "Import from Postman collection", + "from_url": "Import from URL", + "gist_url": "Inserisci l'URL del Gist", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "Couldn't get data from the url", + "import_from_url_invalid_file_format": "Error while importing collections", + "import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Collections Imported", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Import collections from a Hoppscotch Collections JSON file", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "Importa", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled." + } + }, + "layout": { + "collapse_collection": "Collapse or Expand Collections", + "collapse_sidebar": "Collapse or Expand the sidebar", + "column": "Disposizione verticale", + "name": "Layout", + "row": "Disposizione orizzontale" + }, + "modal": { + "close_unsaved_tab": "You have unsaved changes", + "collections": "Raccolte", + "confirm": "Conferma", + "customize_request": "Customize Request", + "edit_request": "Modifica richiesta", + "import_export": "Importa/Esporta", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "You are already subscribed to this topic.", + "clean_session": "Clean Session", + "clear_input": "Clear input", + "clear_input_on_send": "Clear input on send", + "client_id": "Client ID", + "color": "Pick a color", + "communication": "Comunicazione", + "connection_config": "Connection Config", + "connection_not_authorized": "This MQTT connection does not use any authentication.", + "invalid_topic": "Please provide a topic for the subscription", + "keep_alive": "Keep Alive", + "log": "Log", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "Messaggio", + "new": "New Subscription", + "not_connected": "Please start a MQTT connection first.", + "publish": "Pubblica", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Sottoscrivi", + "topic": "Argomento", + "topic_name": "Nome argomento", + "topic_title": "Pubblica / Sottoscrivi argomento", + "unsubscribe": "Annulla la sottoscrizione", + "url": "URL" + }, + "navigation": { + "doc": "Documenti", + "graphql": "GraphQL", + "profile": "Profilo", + "realtime": "Tempo reale", + "rest": "REST", + "settings": "Impostazioni" + }, + "preRequest": { + "javascript_code": "Codice JavaScript", + "learn": "Leggi la documentazione", + "script": "Script di pre-richiesta", + "snippets": "Frammenti" + }, + "profile": { + "app_settings": "Impostazioni della app", + "default_hopp_displayname": "Unnamed User", + "editor": "Autore", + "editor_description": "Gli Autori possono aggiungere, modificare e cancellare richieste.", + "email_verification_mail": "A verification email has been sent to your email address. Please click on the link to verify your email address.", + "no_permission": "Non hai i permessi per compiere questa azione.", + "owner": "Proprietario", + "owner_description": "I Proprietari possono aggiungere, modificare e cancellare richieste, raccolte e membri del team.", + "roles": "Ruoli", + "roles_description": "I ruoli sono usati per controllare l'accesso alle raccolte condivise.", + "updated": "Profile updated", + "viewer": "Visualizzatore", + "viewer_description": "I Visualizzatori possono soltanto vedere e invocare richieste." + }, + "remove": { + "star": "Rimuovi stella" + }, + "request": { + "added": "Richiesta aggiunta", + "authorization": "Autorizzazione", + "body": "Corpo della richiesta", + "choose_language": "Scegli la lingua", + "content_type": "Tipo di contenuto (Content Type)", + "content_type_titles": { + "others": "Others", + "structured": "Structured", + "text": "Text" + }, + "different_collection": "Cannot reorder requests from different collections", + "duplicated": "Request duplicated", + "duration": "Durata", + "enter_curl": "Inserisci cURL", + "generate_code": "Genera codice", + "generated_code": "Codice generato", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "Elenco intestazioni", + "invalid_name": "Si prega di fornire un nome per la richiesta", + "method": "Metodo", + "moved": "Request moved", + "name": "Nome richiesta", + "new": "New Request", + "order_changed": "Request Order Updated", + "override": "Override", + "override_help": "Set Content-Type in Headers", + "overriden": "Overridden", + "parameter_list": "Parametri della query", + "parameters": "Parametri", + "path": "Percorso", + "payload": "Contenuto (Payload)", + "query": "Query", + "raw_body": "Corpo della richiesta non formattato", + "rename": "Rename Request", + "renamed": "Richiesta rinominata", + "request_variables": "Request variables", + "run": "Esegui", + "save": "Salva", + "save_as": "Salva come", + "saved": "Richiesta salvata", + "share": "Condividi", + "share_description": "Share Hoppscotch with your friends", + "share_request": "Share Request", + "stop": "Stop", + "title": "Richiesta", + "type": "Tipo di richiesta", + "url": "URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "Variabili", + "view_my_links": "View my links", + "copy_link": "Copia collegamento" + }, + "response": { + "audio": "Audio", + "body": "Corpo della risposta", + "filter_response_body": "Filter JSON response body (uses jq syntax)", + "headers": "Intestazioni", + "html": "HTML", + "image": "Immagine", + "json": "JSON", + "pdf": "PDF", + "preview_html": "Anteprima HTML", + "raw": "Non formattato", + "size": "Dimensione", + "status": "Stato", + "time": "Tempo impiegato", + "title": "Risposta", + "video": "Video", + "waiting_for_connection": "In attesa di connessione", + "xml": "XML" + }, + "settings": { + "accent_color": "Colore in risalto", + "account": "Account", + "account_deleted": "Your account has been deleted", + "account_description": "Personalizza le impostazioni del tuo account.", + "account_email_description": "Il tuo indirizzo email principale.", + "account_name_description": "Questo è il tuo nome mostrato.", + "additional": "Additional Settings", + "background": "Sfondo", + "black_mode": "Nero", + "choose_language": "Scegli la lingua", + "dark_mode": "Scuro", + "delete_account": "Delete account", + "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", + "expand_navigation": "Espandi navigazione", + "experiments": "Sperimentale", + "experiments_notice": "Questa è una raccolta di esperimenti su cui stiamo lavorando che potrebbero rivelarsi utili, divertenti, entrambi o nessuno dei due. Non sono definitivi e potrebbero non essere stabili, quindi se succede qualcosa di eccessivamente strano, niente panico. Basta solo disabilitare quella dannata cosa. Scherzi a parte, ", + "extension_ver_not_reported": "Non riportato", + "extension_version": "Versione dell'estensione", + "extensions": "Estensioni", + "extensions_use_toggle": "Utilizza l'estensione del browser per inviare richieste (se presente)", + "follow": "Follow Us", + "interceptor": "Interceptor", + "interceptor_description": "Middleware tra applicazione e API.", + "language": "Lingua", + "light_mode": "Chiaro", + "official_proxy_hosting": "Il proxy ufficiale è ospitato da Hoppscotch.", + "profile": "Profilo", + "profile_description": "Aggiorna i dettagli del tuo profilo", + "profile_email": "Email address", + "profile_name": "Nome del profilo", + "proxy": "Proxy", + "proxy_url": "URL del proxy", + "proxy_use_toggle": "Usa il middleware proxy per inviare richieste", + "read_the": "Leggi il", + "reset_default": "Riporta alle impostazioni originali", + "short_codes": "Short codes", + "short_codes_description": "Short codes which were created by you.", + "sidebar_on_left": "Barra laterale a sinistra", + "sync": "Sincronizza", + "sync_collections": "Raccolte", + "sync_description": "Queste impostazioni sono sincronizzate con il cloud.", + "sync_environments": "Ambienti", + "sync_history": "Cronologia", + "system_mode": "Sistema", + "telemetry": "Telemetria", + "telemetry_helps_us": "La telemetria ci aiuta a personalizzare le nostre operazioni e a offrirti la migliore esperienza.", + "theme": "Tema", + "theme_description": "Personalizza il tema della tua applicazione.", + "use_experimental_url_bar": "Usa la barra degli URL sperimentale con l'evidenziazione dell'ambiente", + "user": "Utente", + "verified_email": "Verified email", + "verify_email": "Verify email" + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Chiudi il menu attuale", + "command_menu": "Menu di ricerca e comando", + "help_menu": "Menu di aiuto", + "show_all": "Tasti rapidi", + "title": "Generale" + }, + "miscellaneous": { + "invite": "Invita le persone a Hoppscotch", + "title": "Varie" + }, + "navigation": { + "back": "Torna alla pagina precedente", + "documentation": "Vai alla pagina della documentazione", + "forward": "Vai alla pagina successiva", + "graphql": "Vai alla pagina GraphQL", + "profile": "Go to Profile page", + "realtime": "Vai alla pagina Real Time", + "rest": "Vai alla pagina REST", + "settings": "Vai alla pagina Impostazioni", + "title": "Navigazione" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "Seleziona il metodo DELETE", + "get_method": "Seleziona il metodo GET", + "head_method": "Seleziona il metodo HEAD", + "import_curl": "Import cURL", + "method": "Metodo", + "next_method": "Seleziona il metodo successivo", + "post_method": "Seleziona il metodo POST", + "previous_method": "Seleziona il metodo precedente", + "put_method": "Seleziona il metodo PUT", + "rename": "Rename Request", + "reset_request": "Resetta la richiesta", + "save_request": "Save Request", + "save_to_collections": "Salva nelle raccolte", + "send_request": "Invia richiesta", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "Richiesta", + "copy_request_link": "Copia il link alla richiesta" + }, + "response": { + "copy": "Copy response to clipboard", + "download": "Download response as file", + "title": "Response" + }, + "theme": { + "black": "Switch theme to black mode", + "dark": "Switch theme to dark mode", + "light": "Switch theme to light mode", + "system": "Switch theme to system mode", + "title": "Theme" + } + }, + "show": { + "code": "Mostra il codice", + "collection": "Expand Collection Panel", + "more": "Mostra di più", + "sidebar": "Mostra la barra laterale" + }, + "socketio": { + "communication": "Comunicazione", + "connection_not_authorized": "This SocketIO connection does not use any authentication.", + "event_name": "Nome dell'evento", + "events": "Eventi", + "log": "Log", + "url": "URL" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current team", + "edit": "Edit current team", + "invite": "Invite people to team", + "new": "Create new team", + "switch_to_personal": "Switch to your personal workspace", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "Tipo di evento", + "log": "Log", + "url": "URL" + }, + "state": { + "bulk_mode": "Modifica in blocco", + "bulk_mode_placeholder": "Le voci sono separate tramite Invio\nChiavi e valori sono separati da:\nAnteponi # a qualsiasi riga che desideri aggiungere ma lasciare disabilitata", + "cleared": "Cancellato", + "connected": "Connesso", + "connected_to": "Connesso a {nome}", + "connecting_to": "Connessione a {nome}...", + "connection_error": "Failed to connect", + "connection_failed": "Connection failed", + "connection_lost": "Connection lost", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "Copiato negli appunti", + "deleted": "Eliminato", + "deprecated": "DEPRECATO", + "disabled": "Disabilitato", + "disconnected": "Disconnesso", + "disconnected_from": "Disconnesso da {name}", + "docs_generated": "Documentazione generata", + "download_failed": "Download failed", + "download_started": "Download avviato", + "enabled": "Abilitato", + "file_imported": "File importato", + "finished_in": "Finito in {duration} ms", + "hide": "Hide", + "history_deleted": "Cronologia cancellata", + "linewrap": "Testo a capo", + "loading": "Caricamento in corso...", + "message_received": "Message: {message} arrived on topic: {topic}", + "mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}", + "none": "Nessuno", + "nothing_found": "Non è stato trovato nulla per", + "published_error": "Something went wrong while publishing msg: {topic} to topic: {message}", + "published_message": "Published message: {message} to topic: {topic}", + "reconnection_error": "Failed to reconnect", + "show": "Show", + "subscribed_failed": "Failed to subscribe to topic: {topic}", + "subscribed_success": "Successfully subscribed to topic: {topic}", + "unsubscribed_failed": "Failed to unsubscribe from topic: {topic}", + "unsubscribed_success": "Successfully unsubscribed from topic: {topic}", + "waiting_send_request": "In attesa di inviare la richiesta" + }, + "support": { + "changelog": "Maggiori informazioni sulle ultime versioni", + "chat": "Domande? Chatta con noi!", + "community": "Fai domande e aiuta gli altri", + "documentation": "Maggiori informazioni su Hoppscotch", + "forum": "Fai domande e ottieni risposte", + "github": "Follow us on Github", + "shortcuts": "Naviga nella app più velocemente", + "title": "Supporto", + "twitter": "Seguici su Twitter", + "team": "Mettiti in contatto con il team" + }, + "tab": { + "authorization": "Autorizzazione", + "body": "Corpo", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "Raccolte", + "documentation": "Documentazione", + "duplicate": "Duplicate Tab", + "environments": "Environments", + "headers": "Intestazioni", + "history": "Cronologia", + "mqtt": "MQTT", + "parameters": "Parametri", + "pre_request_script": "Script di pre-richiesta", + "queries": "Query", + "query": "Query", + "schema": "Schema", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Test", + "types": "Tipi", + "variables": "Variabili", + "websocket": "WebSocket" + }, + "team": { + "already_member": "Sei già membro di questo team. Contatta il proprietario del team.", + "create_new": "Crea una nuovo team", + "deleted": "Team eliminato", + "edit": "Modifica team", + "email": "E-mail", + "email_do_not_match": "L'indirizzo email non corrisponde a quella del tuo account. Contatta il proprietario del team.", + "exit": "Lascia il team", + "exit_disabled": "Solo il proprietario non può lasciare il team", + "failed_invites": "Failed invites", + "invalid_coll_id": "Invalid collection ID", + "invalid_email_format": "Il formato dell'email non è valido", + "invalid_id": "L'ID del team non è valido. Contatta il proprietario del team.", + "invalid_invite_link": "Il link di invito non è valido", + "invalid_invite_link_description": "Il link che hai seguito non è valido. Contatta il proprietario del team.", + "invalid_member_permission": "Si prega di fornire un permesso valido al membro del team", + "invite": "Invita", + "invite_more": "Invita altri", + "invite_tooltip": "Invita persone in questo spazio di lavoro", + "invited_to_team": "{owner} ti ha invitato ad unirti al team {team}", + "join": "Invito accettato", + "join_team": "Unisciti al team {team}", + "joined_team": "Sei parte del team {team}", + "joined_team_description": "Ora sei un membro di questo team", + "left": "Hai lasciato il team", + "login_to_continue": "Accedi per continuare", + "login_to_continue_description": "Devi aver effettuato l'accesso per unirti a un team.", + "logout_and_try_again": "Disconnettiti ed accedi con un altro account", + "member_has_invite": "Questo ID email ha già un invito. Contatta il proprietario del team.", + "member_not_found": "Membro non trovato. Contatta il proprietario del team.", + "member_removed": "Utente rimosso", + "member_role_updated": "Ruoli dell'utente aggiornati", + "members": "Membri", + "more_members": "+{count} more", + "name_length_insufficient": "Il nome del team deve essere lungo almeno 6 caratteri", + "name_updated": "Nome del team aggiornato", + "new": "Nuovo team", + "new_created": "Nuovo team creato", + "new_name": "Il mio nuovo team", + "no_access": "Non sei autorizzato a modificare queste raccolte", + "no_invite_found": "Invito non trovato. Contatta il proprietario del team.", + "no_request_found": "Request not found.", + "not_found": "Team not found. Contact your team owner.", + "not_valid_viewer": "Non sei autorizzato a visualizzare. Contatta il proprietario del team.", + "parent_coll_move": "Cannot move collection to a child collection", + "pending_invites": "Inviti pendenti", + "permissions": "Permessi", + "same_target_destination": "Same target and destination", + "saved": "Team salvato", + "select_a_team": "Seleziona un team", + "success_invites": "Success invites", + "title": "Team", + "we_sent_invite_link": "Abbiamo inviato un link di invito a tutti gli invitati!", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "Chiedi a tutti gli invitati di controllare la loro casella email. Cliccando sul link possono entrare nel team.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "Partecipa al programma beta per accedere ai team." + }, + "team_environment": { + "deleted": "Environment Deleted", + "duplicate": "Environment Duplicated", + "not_found": "Environment not found." + }, + "test": { + "failed": "Test fallito", + "javascript_code": "Codice JavaScript", + "learn": "Leggi la documentazione", + "passed": "Test superato", + "report": "Report del test", + "results": "Risultati del test", + "script": "Script", + "snippets": "Frammenti" + }, + "websocket": { + "communication": "Comunicazione", + "log": "Log", + "message": "Messaggio", + "protocols": "Protocolli", + "url": "URL" + }, + "workspace": { + "change": "Change workspace", + "personal": "My Workspace", + "other_workspaces": "My Workspaces", + "team": "Team Workspace", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "Actions", + "created_on": "Created on", + "deleted": "Shortcode deleted", + "method": "Method", + "not_found": "Shortcode not found", + "short_code": "Short code", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/ja.json b/packages/hoppscotch-common/locales/ja.json new file mode 100644 index 0000000..3739238 --- /dev/null +++ b/packages/hoppscotch-common/locales/ja.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Add", + "autoscroll": "自動スクロール", + "cancel": "キャンセル", + "choose_file": "ファイルを選択してください", + "clear": "クリア", + "clear_all": "すべてクリア", + "clear_history": "Clear all History", + "close": "閉じる", + "connect": "接続", + "connecting": "接続中", + "copy": "コピー", + "create": "Create", + "delete": "消去", + "disconnect": "切断", + "dismiss": "閉じる", + "dont_save": "保存しない", + "download_file": "ファイルをダウンロード", + "drag_to_reorder": "ドラッグして並べ替え", + "duplicate": "複製", + "edit": "編集", + "filter": "フィルター", + "go_back": "戻る", + "go_forward": "Go forward", + "group_by": "グループ化", + "hide_secret": "Hide secret", + "label": "ラベル", + "learn_more": "もっと詳しく", + "download_here": "Download here", + "less": "表示を減らす", + "more": "もっと見る", + "new": "新規", + "no": "いいえ", + "open_workspace": "ワークスペースを開く", + "paste": "貼り付け", + "prettify": "自動整形", + "properties": "Properties", + "remove": "削除", + "rename": "Rename", + "restore": "戻す", + "save": "保存", + "scroll_to_bottom": "下にスクロール", + "scroll_to_top": "上にスクロール", + "search": "検索", + "send": "送信", + "share": "Share", + "show_secret": "Show secret", + "start": "はじめる", + "starting": "開始中", + "stop": "止める", + "to_close": "閉じる", + "to_navigate": "上下に移動する", + "to_select": "選択する", + "turn_off": "オフ", + "turn_on": "オン", + "undo": "元に戻す", + "yes": "はい" + }, + "add": { + "new": "新しく追加", + "star": "スターを追加" + }, + "app": { + "chat_with_us": "チャットで問い合わせる", + "contact_us": "お問い合わせ", + "cookies": "Cookies", + "copy": "コピー", + "copy_interface_type": "Copy interface type", + "copy_user_id": "User Auth Tokenをコピー", + "developer_option": "開発者向けオプション", + "developer_option_description": "Hoppscotchの開発に役立つ開発者向けツール", + "discord": "Discord", + "documentation": "ドキュメント", + "github": "GitHub", + "help": "ヘルプ & フィードバック", + "home": "ホーム", + "invite": "招待", + "invite_description": "HoppscotchはオープンソースのAPI開発エコシステムです。APIを作成・管理するためのシンプルで直感的なインターフェイスを設計しました。HoppscotchはAPIの構築・テスト・文書化・共有を支援します。", + "invite_your_friends": "友達を招待する", + "join_discord_community": "Discordコミュニティに参加する", + "keyboard_shortcuts": "キーボードショートカット", + "name": "Hoppscotch", + "new_version_found": "新しいバージョンが見つかりました。再読み込みすると更新されます。", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "オプション", + "proxy_privacy_policy": "プロキシのプライバシーポリシー", + "reload": "更新", + "search": "検索", + "share": "共有", + "shortcuts": "ショートカット", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "スポットライト", + "status": "状態", + "status_description": "ウェブサイトの状態を確認", + "terms_and_privacy": "利用規約とプライバシーポリシー", + "twitter": "Twitter", + "type_a_command_search": "コマンドもしくは検索ワードを入力…", + "we_use_cookies": "Cookieを使用しています", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "新着情報", + "see_whats_new": "See what’s new", + "wiki": "Wiki" + }, + "auth": { + "account_exists": "アカウントがそれぞれの認証情報で存在しています - ログインして両方のアカウントを連携する", + "all_sign_in_options": "すべてのサインインオプション", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "メールアドレスで続行", + "continue_with_github": "GitHubアカウントで続行", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Googleアカウントで続行", + "continue_with_microsoft": "Microsoftアカウントで続行", + "email": "メールアドレス", + "logged_out": "ログアウトしました", + "login": "ログイン", + "login_success": "正常にログインしました", + "login_to_hoppscotch": "Hoppscotchにログインする", + "logout": "ログアウト", + "re_enter_email": "メールアドレスを再入力してください", + "send_magic_link": "マジックリンクを送る", + "sync": "同期", + "we_sent_magic_link": "マジックリンクを送信しました!", + "we_sent_magic_link_description": "受信トレイを確認してください - {email}にメールを送信しました。ログインするためのマジックリンクが含まれています。" + }, + "authorization": { + "generate_token": "トークンを生成", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "URLに含める", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "詳しく学ぶ", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "認証元: ", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "パスワード", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "トークン", + "type": "認証タイプ", + "username": "ユーザー名" + }, + "collection": { + "created": "コレクションが作成されました", + "different_parent": "Cannot reorder collection with different parent", + "edit": "コレクションの編集", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "コレクション名を入力してください", + "invalid_root_move": "Collection already in the root", + "moved": "Moved Successfully", + "my_collections": "マイコレクション", + "name": "新しいマイコレクション", + "name_length_insufficient": "コレクション名は3文字以上である必要があります", + "new": "新しいコレクション", + "order_changed": "Collection Order Updated", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "コレクション名が変更されました", + "request_in_use": "使用中のリクエスト", + "save_as": "名前を付けて保存", + "save_to_collection": "Save to Collection", + "select": "コレクションを選択", + "select_location": "場所を選択", + "details": "Details", + "select_team": "チームを選択", + "team_collections": "チームコレクション" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "exit_team": "本当にこのチームから退出しますか?", + "logout": "ログアウトしてもよろしいですか?", + "remove_collection": "このコレクションを完全に削除してもよろしいですか?", + "remove_environment": "この環境を完全に削除してもよろしいですか?", + "remove_folder": "このフォルダを完全に削除してもよろしいですか?", + "remove_history": "すべての履歴を完全に削除してもよろしいですか?", + "remove_request": "このリクエストを完全に削除してもよろしいですか?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "このチームを削除してもよろしいですか?", + "remove_telemetry": "テレメトリをオプトアウトしてもよろしいですか?", + "request_change": "現在のリクエストを削除してもよろしいですか?保存されていない変更は削除されます。", + "save_unsaved_tab": "Do you want to save changes made in this tab?", + "sync": "クラウドからワークスペースを復元しますか?この場合、ローカルの進行状況は破棄されます。", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "ヘッダー {count}", + "message": "メッセージ {count}", + "parameter": "パラメータ {count}", + "protocol": "プロトコル {count}", + "value": "値 {count}", + "variable": "変数 {count}" + }, + "documentation": { + "generate": "ドキュメントを生成", + "generate_message": "Hoppscotchのコレクションをインポートして、APIドキュメントをすぐに生成することができます。" + }, + "empty": { + "authorization": "このリクエストでは認証を使用しません", + "body": "リクエストボディがありません", + "collection": "コレクションは空です", + "collections": "コレクションがありません", + "documentation": "GraphQLエンドポイントに接続してドキュメントを表示する", + "endpoint": "エンドポイントを空にすることはできません", + "environments": "環境変数がありません", + "folder": "フォルダは空です", + "headers": "このリクエストにはヘッダーがありません", + "history": "履歴がありません", + "invites": "招待リストは空です", + "members": "チームメンバーはいません", + "parameters": "このリクエストにはパラメータがありません", + "pending_invites": "このチームに保留中の招待はありません", + "profile": "ログインしてプロフィールを見る", + "protocols": "プロトコルがありません", + "request_variables": "This request does not have any request variables", + "schema": "GraphQLエンドポイントに接続する", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "サブスクリプションはありません", + "team_name": "チーム名がありません", + "teams": "チームに参加していません", + "tests": "このリクエストのテストはありません", + "access_tokens": "Access tokens are empty", + "shortcodes": "ショートコードはありません" + }, + "environment": { + "add_to_global": "環境変数をGlobalに追加", + "added": "環境変数を追加しました", + "create_new": "新しい環境変数を作成", + "created": "環境変数を作成しました", + "deleted": "環境変数を削除しました", + "duplicated": "Environment duplicated", + "edit": "環境変数の編集", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create a environment", + "invalid_name": "環境変数名を入力してください", + "list": "Environment variables", + "my_environments": "個人の環境変数", + "name": "Name", + "nested_overflow": "環境変数の入れ子は10段階までです", + "new": "新しい環境変数", + "no_active_environment": "No active environment", + "no_environment": "環境変数が存在しません", + "no_environment_description": "環境変数が選択されていません。次の環境変数から選択してください。", + "quick_peek": "Environment Quick Peek", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "環境変数を選択", + "set": "Set environment", + "set_as_environment": "Set as environment", + "team_environments": "チームの環境変数", + "title": "環境変数", + "updated": "環境変数を更新しました", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "環境変数リスト", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "このブラウザはServer-Sent Eventsをサポートしていないようです。", + "check_console_details": "詳細については、コンソールログを確認してください。", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "cURLが正しくフォーマットされていません", + "danger_zone": "危険", + "delete_account": "あなたのアカウントは以下のチームのオーナーとなっています:", + "delete_account_description": "アカウントを削除する前にチームを離脱するか、オーナーを委任するか、チームを削除してください。", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "リクエスト名がありません", + "f12_details": "(詳細はF12キーを押してください)", + "gql_prettify_invalid_query": "クエリを整形できませんでした。クエリの構文エラーを解決して再試行してください。", + "incomplete_config_urls": "設定URLが不完全です", + "incorrect_email": "メールアドレスが間違っています", + "invalid_link": "リンクが無効です", + "invalid_link_description": "クリックしたリンクは無効か期限切れです。", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "JSONが無効です", + "json_prettify_invalid_body": "ボディを整形できませんでした。JSONの構文エラーを解決して再試行してください。", + "network_error": "ネットワークエラーが発生したようです。もう一度お試しください。", + "network_fail": "リクエストを送信できませんでした", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "期間なし", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "該当するものがありませんでした", + "page_not_found": "このページは見つかりませんでした", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "リクエスト前のスクリプトを実行できませんでした", + "something_went_wrong": "不明なエラーです", + "post_request_script_fail": "リクエスト後のスクリプトを実行できませんでした", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "JSONとしてエクスポート", + "create_secret_gist": "Secret Gistを作成", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "GitHubにログインしてSecret Gistを作成", + "title": "エクスポート", + "success": "Successfully exported", + "gist_created": "Gistが作成されました" + }, + "filter": { + "all": "全て", + "none": "無し", + "starred": "スター付き" + }, + "folder": { + "created": "作成されたフォルダ", + "edit": "フォルダの編集", + "invalid_name": "フォルダ名を入力してください", + "name_length_insufficient": "フォルダ名は3文字以上である必要があります", + "new": "新しいフォルダ", + "renamed": "フォルダ名が変更されました" + }, + "graphql": { + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "mutations": "ミューテーション", + "schema": "スキーマ", + "subscriptions": "サブスクリプション", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "時間", + "url": "URL" + }, + "header": { + "install_pwa": "アプリをインストール", + "login": "ログイン", + "save_workspace": "ワークスペースを保存" + }, + "helpers": { + "authorization": "リクエストを送信すると、認証ヘッダーが自動的に生成されます。", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "最初にドキュメントを生成する", + "network_fail": "APIエンドポイントに到達できません。ネットワーク接続を確認して再試行してください。", + "offline": "オフラインになっているようです。このワークスペースのデータは最新でない可能性があります。", + "offline_short": "オフラインになっているようです。", + "post_request_tests": "テストスクリプトはJavaScriptで記述されており、レスポンスを受信した後に実行されます。", + "pre_request_script": "プリリクエストスクリプトはJavaScriptで記述されており、リクエストが送信される前に実行されます。", + "script_fail": "プリリクエストスクリプトに問題があるようです。以下のエラーを確認し、スクリプトを修正してください。", + "post_request_script_fail": "テストスクリプトにエラーがあるようです。エラーを修正し、再度テストを実行してください。", + "post_request_script": "デバッグを自動化するテストスクリプトを作成します。" + }, + "hide": { + "collection": "コレクションパネルを非表示", + "more": "隠す", + "preview": "プレビューを非表示", + "sidebar": "サイドバーを非表示" + }, + "import": { + "collections": "コレクションをインポート", + "curl": "cURLをインポート", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "インポートに失敗しました", + "from_file": "Import from File", + "from_gist": "Gistからインポート", + "from_gist_description": "GistのURLからインポート", + "from_insomnia": "Insomniaからインポート", + "from_insomnia_description": "Insomniaのコレクションからインポート", + "from_json": "Hoppscotchからインポート", + "from_json_description": "Hoppscotchのコレクションファイルからインポート", + "from_my_collections": "マイコレクションからインポート", + "from_my_collections_description": "マイコレクションファイルからインポート", + "from_openapi": "OpenAPIからインポート", + "from_openapi_description": "OpenAPI仕様のファイル (YML/JSON) からインポート", + "from_postman": "Postmanからインポート", + "from_postman_description": "Postmanのコレクションからインポート", + "from_url": "URLからインポート", + "gist_url": "GistのURLを入力してください", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "指定されたURLからデータを取得できませんでした", + "import_from_url_invalid_file_format": "コレクションのインポート中にエラーが発生しました", + "import_from_url_invalid_type": "サポートされていないタイプです。サポートされているのは 'hoppscotch', 'openapi', 'postman', 'insomnia' です。", + "import_from_url_success": "コレクションがインポートされました", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Hoppscotchのコレクション (JSONファイル) からインポート", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "インポート", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled." + } + }, + "layout": { + "collapse_collection": "コレクションを表示・非表示", + "collapse_sidebar": "サイドバーを表示・非表示", + "column": "縦型レイアウト", + "name": "レイアウト", + "row": "横型レイアウト" + }, + "modal": { + "close_unsaved_tab": "You have unsaved changes", + "collections": "コレクション", + "confirm": "確認", + "customize_request": "Customize Request", + "edit_request": "リクエストの編集", + "import_export": "インポート・エクスポート", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "既にこのトピックにサブスクライブしています", + "clean_session": "セッションをクリア", + "clear_input": "入力をクリア", + "clear_input_on_send": "送信後に入力をクリア", + "client_id": "クライアントID", + "color": "色を選択", + "communication": "コミュニケーション", + "connection_config": "接続設定", + "connection_not_authorized": "このMQTTコネクションではどの認証も使われていません。", + "invalid_topic": "このサブスクリプションのためのトピックを入力してください", + "keep_alive": "Keep Alive", + "log": "ログ", + "lw_message": "Last-Willメッセージ", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Willトピック", + "message": "メッセージ", + "new": "新しいサブスクリプション", + "not_connected": "まずMQTTコネクションを開始してください。", + "publish": "パブリッシュ", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "サブスクライブ", + "topic": "トピック", + "topic_name": "トピック名", + "topic_title": "トピックのパブリッシュ・サブスクライブ", + "unsubscribe": "サブスクライブを解除", + "url": "URL" + }, + "navigation": { + "doc": "ドキュメント", + "graphql": "GraphQL", + "profile": "Profile", + "realtime": "リアルタイム", + "rest": "REST", + "settings": "設定" + }, + "preRequest": { + "javascript_code": "JavaScriptコード", + "learn": "ドキュメントを読む", + "script": "プリリクエストスクリプト", + "snippets": "スニペット" + }, + "profile": { + "app_settings": "アプリ設定", + "default_hopp_displayname": "ユーザ名未設定", + "editor": "編集者", + "editor_description": "編集者はリクエストの追加・編集・削除を行うことができます。", + "email_verification_mail": "登録されているメールアドレスに認証メールが送信されました。リンクをクリックしてメールアドレスを認証してください。", + "no_permission": "この操作を行う権限がありません。", + "owner": "管理者", + "owner_description": "管理者はリクエスト・コレクション・チームメンバーの追加・編集・削除が可能です。", + "roles": "ロール", + "roles_description": "ロールは共有コレクションへのアクセスを制御するために使用されます。", + "updated": "プロフィールを更新しました", + "viewer": "閲覧者", + "viewer_description": "閲覧者はリクエストの閲覧・利用が可能です。" + }, + "remove": { + "star": "スターを外す" + }, + "request": { + "added": "リクエストが追加されました", + "authorization": "認証", + "body": "リクエストボディ", + "choose_language": "言語を選択", + "content_type": "コンテンツタイプ", + "content_type_titles": { + "others": "その他", + "structured": "構造化されたデータ", + "text": "テキスト" + }, + "different_collection": "Cannot reorder requests from different collections", + "duplicated": "Request duplicated", + "duration": "間隔", + "enter_curl": "cURLコマンドを入力してください", + "generate_code": "コードを生成", + "generated_code": "生成されたコード", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "ヘッダーリスト", + "invalid_name": "リクエスト名を入力してください", + "method": "メソッド", + "moved": "Request moved", + "name": "リクエスト名", + "new": "新しいリクエスト", + "order_changed": "Request Order Updated", + "override": "上書き", + "override_help": "リクエストの Content-Type ヘッダを上書き", + "overriden": "上書きされました", + "parameter_list": "クエリパラメータ", + "parameters": "パラメータ", + "path": "パス", + "payload": "ペイロード", + "query": "クエリ", + "raw_body": "生のリクエストボディ", + "rename": "Rename Request", + "renamed": "リクエストの名前を変更", + "request_variables": "Request variables", + "run": "実行", + "save": "保存", + "save_as": "名前を付けて保存", + "saved": "保存されたリクエスト", + "share": "共有", + "share_description": "Hoppscotchを友人に共有", + "share_request": "Share Request", + "stop": "Stop", + "title": "リクエスト", + "type": "リクエストの種類", + "url": "URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "変数", + "view_my_links": "自分のリンクを見る", + "copy_link": "リンクをコピー" + }, + "response": { + "audio": "Audio", + "body": "レスポンスボディ", + "filter_response_body": "JSONレスポンスボディをフィルタ (jqシンタックスを使用)", + "headers": "ヘッダー", + "html": "HTML", + "image": "画像", + "json": "JSON", + "pdf": "PDF", + "preview_html": "HTMLのプレビュー", + "raw": "Raw", + "size": "サイズ", + "status": "ステータス", + "time": "時間", + "title": "レスポンス", + "video": "Video", + "waiting_for_connection": "接続を待っています", + "xml": "XML" + }, + "settings": { + "accent_color": "アクセントの色", + "account": "アカウント", + "account_deleted": "あなたのアカウントは削除されました", + "account_description": "アカウント設定をカスタマイズ", + "account_email_description": "プライマリメールアドレス", + "account_name_description": "あなたの表示名", + "additional": "Additional Settings", + "background": "背景", + "black_mode": "ブラック", + "choose_language": "言語を選択", + "dark_mode": "ダーク", + "delete_account": "アカウントの削除", + "delete_account_description": "アカウントを削除すると、あなたのアカウントに紐づくデータは全て永久に削除されます。これは取り消すことができません。", + "expand_navigation": "ナビゲーションの詳細表示", + "experiments": "試験的な機能", + "experiments_notice": "これらは試験的に実装している機能で、役に立つかもしれないし、楽しいかもしれないし、両方かもしれないし、はたまたどちらでもないかもしれません。これらは未完成で、安定したものではありません。何か問題がありましたら、こちらより報告をお願いします。→", + "extension_ver_not_reported": "未報告", + "extension_version": "ブラウザ拡張機能のバージョン", + "extensions": "拡張機能", + "extensions_use_toggle": "ブラウザ拡張機能を使用してリクエストを送信する(利用可能な場合)", + "follow": "フォローする", + "interceptor": "インターセプタ", + "interceptor_description": "アプリケーションとAPIをつなぐミドルウェア", + "language": "言語", + "light_mode": "ライト", + "official_proxy_hosting": "公式プロキシはHoppscotchによってホストされています。", + "profile": "プロフィール", + "profile_description": "プロフィールの詳細を更新", + "profile_email": "メールアドレス", + "profile_name": "プロフィールに表示する名前", + "proxy": "プロキシ", + "proxy_url": "プロキシURL", + "proxy_use_toggle": "リクエストの送信にプロキシミドルウェアを使用する", + "read_the": "詳しくは", + "reset_default": "デフォルトにリセット", + "short_codes": "ショートコード", + "short_codes_description": "あなたが作成したショートコードです。", + "sidebar_on_left": "サイドバーを左側に表示", + "sync": "同期する", + "sync_collections": "コレクション", + "sync_description": "これらの設定はクラウドに同期されます。", + "sync_environments": "環境変数", + "sync_history": "履歴", + "system_mode": "システム", + "telemetry": "テレメトリ", + "telemetry_helps_us": "テレメトリは私たちの業務を改善し、ユーザの皆様に最高の体験を提供するために役立てられます。", + "theme": "テーマ", + "theme_description": "アプリケーションのテーマをカスタマイズ", + "use_experimental_url_bar": "ハイライトした実験的なURLバーを使用", + "user": "ユーザー", + "verified_email": "メールアドレスの認証済", + "verify_email": "メールアドレスの認証" + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "現在のメニューを閉じる", + "command_menu": "検索&コマンドメニュー", + "help_menu": "ヘルプメニュー", + "show_all": "キーボードショートカット", + "title": "全般" + }, + "miscellaneous": { + "invite": "Hoppscotchに招待", + "title": "その他" + }, + "navigation": { + "back": "前のページに戻る", + "documentation": "ドキュメントページに移動", + "forward": "次のページに進む", + "graphql": "GraphQLページに移動", + "profile": "プロフィールページに移動", + "realtime": "リアルタイムページに移動", + "rest": "RESTページに移動", + "settings": "設定ページに移動", + "title": "ナビゲーション" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "DELETEメソッドを選択", + "get_method": "GETメソッドを選択", + "head_method": "HEADメソッドを選択", + "import_curl": "Import cURL", + "method": "メソッド", + "next_method": "次のメソッドを選択", + "post_method": "POSTメソッドを選択", + "previous_method": "前のメソッドを選択", + "put_method": "PUTメソッドを選択", + "rename": "Rename Request", + "reset_request": "リセットリクエスト", + "save_request": "Save Request", + "save_to_collections": "コレクションに保存", + "send_request": "リクエストを送信", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "リクエスト", + "copy_request_link": "コピーリクエストリンク" + }, + "response": { + "copy": "レスポンスをクリップボードにコピー", + "download": "レスポンスをファイルとして保存", + "title": "レスポンス" + }, + "theme": { + "black": "テーマをブラックモードに変更", + "dark": "テーマをダークモードに変更", + "light": "テーマをライトモードに変更", + "system": "テーマをシステム標準に変更", + "title": "テーマ" + } + }, + "show": { + "code": "コードを表示", + "collection": "コレクションパネルを表示", + "more": "さらに表示", + "sidebar": "サイドバーを表示" + }, + "socketio": { + "communication": "コミュニケーション", + "connection_not_authorized": "このSocketIOコネクションではどの認証も使われていません。", + "event_name": "イベント・トピック名", + "events": "イベント", + "log": "ログ", + "url": "URL" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current team", + "edit": "Edit current team", + "invite": "Invite people to team", + "new": "Create new team", + "switch_to_personal": "Switch to your personal workspace", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "イベントの種類", + "log": "ログ", + "url": "URL" + }, + "state": { + "bulk_mode": "一括編集", + "bulk_mode_placeholder": "エントリは改行で区切られます。\nキーと値は「:」で区切られます。\n追加したいが無効にしたい行の先頭には「#」を追加してください。", + "cleared": "クリア", + "connected": "接続済み", + "connected_to": "{名前}に接続しました", + "connecting_to": "{名前}に接続しています...", + "connection_error": "Failed to connect", + "connection_failed": "Connection failed", + "connection_lost": "Connection lost", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "クリップボードにコピーしました", + "deleted": "削除されました", + "deprecated": "非推奨", + "disabled": "無効", + "disconnected": "切断されました", + "disconnected_from": "{名前}から切断されました", + "docs_generated": "生成されたドキュメント", + "download_failed": "Download failed", + "download_started": "ダウンロード開始", + "enabled": "有効", + "file_imported": "インポートされたファイル", + "finished_in": "{duration} msで終了しました", + "hide": "Hide", + "history_deleted": "履歴が削除されました", + "linewrap": "行の折り返し", + "loading": "読み込んでいます...", + "message_received": "トピック: {topic} でメッセージ: {message} を受信しました", + "mqtt_subscription_failed": "トピック: {topic} のサブスクライブで問題が発生しました", + "none": "なし", + "nothing_found": "何も見つかりません", + "published_error": "ピック: {topic} にメッセージ: {message} を送信する際に問題が発生しました", + "published_message": "トピック: {topic} にメッセージ: {message} を送信しました", + "reconnection_error": "再接続に失敗", + "show": "Show", + "subscribed_failed": "トピック: {topic} のサブスクライブに失敗", + "subscribed_success": "トピック: {topic} のサブスクライブに成功", + "unsubscribed_failed": "トピック: {topic} のサブスクライブ解除に失敗", + "unsubscribed_success": "トピック: {topic} のサブスクライブ解除に成功", + "waiting_send_request": "リクエストの送信を待機中" + }, + "support": { + "changelog": "最新リリースについてもっと読む", + "chat": "ご質問はこちら。チャットでお問い合わせください!", + "community": "質問の投稿・回答はこちらから", + "documentation": "Hoppscotchをもっと知る", + "forum": "質問をして答えを得る", + "github": "GitHubでフォローする", + "shortcuts": "アプリをより効率よく使いこなす", + "title": "サポート", + "twitter": "私たちのTwitterをフォローする", + "team": "チームと連絡を取る" + }, + "tab": { + "authorization": "認証", + "body": "ボディ", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "コレクション", + "documentation": "ドキュメント", + "duplicate": "Duplicate Tab", + "environments": "Environments", + "headers": "ヘッダー", + "history": "履歴", + "mqtt": "MQTT", + "parameters": "パラメータ", + "pre_request_script": "プリリクエストスクリプト", + "queries": "クエリ", + "query": "クエリ", + "schema": "スキーマ", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "テスト", + "types": "種類", + "variables": "変数", + "websocket": "WebSocket" + }, + "team": { + "already_member": "あなたは既にこのチームのメンバーです。チームの管理者に連絡してください。", + "create_new": "新しいチームを作成", + "deleted": "チームが削除されました", + "edit": "チームの編集", + "email": "メールアドレス", + "email_do_not_match": "メールアドレスがアカウント情報と一致しません。チームの管理者に連絡してください。", + "exit": "チームから退出", + "exit_disabled": "管理者はチームから退出できません", + "failed_invites": "Failed invites", + "invalid_coll_id": "Invalid collection ID", + "invalid_email_format": "メールアドレスの形式が無効です", + "invalid_id": "チームIDが無効です。チームの管理者に連絡してください。", + "invalid_invite_link": "招待リンクが無効です", + "invalid_invite_link_description": "このリンクは無効です。チームの管理者に連絡してください。", + "invalid_member_permission": "チームメンバーに有効な許可を与えてください", + "invite": "招待", + "invite_more": "さらに招待", + "invite_tooltip": "このワークスペースに招待", + "invited_to_team": "{owner}が{team}にあなたを招待しました", + "join": "招待を了承しました", + "join_team": "{team}に参加", + "joined_team": "あなたは{team}に参加しました", + "joined_team_description": "あなたはこのチームのメンバーです", + "left": "あなたはチームから退出しました", + "login_to_continue": "ログインして続行", + "login_to_continue_description": "チームに参加するには、ログインが必要です。", + "logout_and_try_again": "ログアウトして別のアカウントでサインインする", + "member_has_invite": "このメールアドレスはすでに招待されています。チームの管理者に連絡してください。", + "member_not_found": "メンバーが見つかりませんでした。チームの管理者に連絡してください。", + "member_removed": "ユーザーが削除されました", + "member_role_updated": "ユーザーロールが更新されました", + "members": "メンバー", + "more_members": "+{count} more", + "name_length_insufficient": "チーム名は6文字以上である必要があります", + "name_updated": "チーム名が更新されました", + "new": "新しいチーム", + "new_created": "新しいチームが作成されました", + "new_name": "私の新しいチーム", + "no_access": "これらのコレクションを編集することはできません", + "no_invite_found": "招待が見つかりません。チームの管理者に連絡してください。", + "no_request_found": "Request not found.", + "not_found": "チームが見つかりません。チームの管理者に連絡してください。", + "not_valid_viewer": "あなたは有効な閲覧者ではありません。チームの管理者に連絡してください。", + "parent_coll_move": "Cannot move collection to a child collection", + "pending_invites": "招待の保留", + "permissions": "権限", + "same_target_destination": "Same target and destination", + "saved": "チームが保存されました", + "select_a_team": "チームを選択", + "success_invites": "Success invites", + "title": "チーム", + "we_sent_invite_link": "招待者の皆様に、招待リンクを送信しました!", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "招待者全員に受信トレイを確認するよう依頼します。リンクをクリックすると、チームに参加できます。", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "ベータプログラムに参加して、チームにアクセスする。" + }, + "team_environment": { + "deleted": "環境変数を削除しました", + "duplicate": "環境変数が重複しています", + "not_found": "環境変数が見つかりません" + }, + "test": { + "failed": "テスト失敗", + "javascript_code": "JavaScriptコード", + "learn": "ドキュメントを読む", + "passed": "テスト成功", + "report": "テストレポート", + "results": "テスト結果", + "script": "スクリプト", + "snippets": "スニペット" + }, + "websocket": { + "communication": "コミュニケーション", + "log": "ログ", + "message": "メッセージ", + "protocols": "プロトコル", + "url": "URL" + }, + "workspace": { + "change": "Change workspace", + "personal": "My Workspace", + "other_workspaces": "My Workspaces", + "team": "Team Workspace", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "アクション", + "created_on": "作成日", + "deleted": "ショートコードを削除しました", + "method": "メソッド", + "not_found": "ショートコードが見つかりません", + "short_code": "ショートコード", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/ko.json b/packages/hoppscotch-common/locales/ko.json new file mode 100644 index 0000000..8274dcf --- /dev/null +++ b/packages/hoppscotch-common/locales/ko.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Add", + "autoscroll": "자동 스크롤", + "cancel": "취소", + "choose_file": "파일 선택", + "clear": "지우기", + "clear_all": "모두 지우기", + "clear_history": "Clear all History", + "close": "닫기", + "connect": "연결", + "connecting": "Connecting", + "copy": "복사", + "create": "Create", + "delete": "삭제", + "disconnect": "연결 해제", + "dismiss": "닫기", + "dont_save": "저장 안함", + "download_file": "파일 다운로드", + "drag_to_reorder": "Drag to reorder", + "duplicate": "복제", + "edit": "편집", + "filter": "Filter", + "go_back": "돌아가기", + "go_forward": "Go forward", + "group_by": "Group by", + "hide_secret": "Hide secret", + "label": "이름", + "learn_more": "더 알아보기", + "download_here": "Download here", + "less": "접기", + "more": "더보기", + "new": "추가", + "no": "아니요", + "open_workspace": "Open workspace", + "paste": "붙여넣기", + "prettify": "구문 강조", + "properties": "Properties", + "remove": "제거", + "rename": "Rename", + "restore": "복원", + "save": "저장", + "scroll_to_bottom": "Scroll to bottom", + "scroll_to_top": "Scroll to top", + "search": "검색", + "send": "보내기", + "share": "Share", + "show_secret": "Show secret", + "start": "시작", + "starting": "Starting", + "stop": "정지", + "to_close": "로 닫기", + "to_navigate": "로 이동", + "to_select": "로 선택", + "turn_off": "끄기", + "turn_on": "켜기", + "undo": "실행 취소", + "yes": "예" + }, + "add": { + "new": "추가", + "star": "즐겨찾기 추가" + }, + "app": { + "chat_with_us": "개발팀과 채팅하기", + "contact_us": "문의하기", + "cookies": "Cookies", + "copy": "복사", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Copy User Auth Token", + "developer_option": "Developer options", + "developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.", + "discord": "Discord", + "documentation": "문서", + "github": "GitHub", + "help": "도움말, 피드백 및 문서", + "home": "홈", + "invite": "초대", + "invite_description": "Hoppscotch에서는 API를 생성하고 관리하기 위한 간단하고 직관적인 인터페이스를 설계했습니다. Hoppscotch는 API를 빌드, 테스트, 문서화, 공유하는 데 도움을 주는 도구입니다.", + "invite_your_friends": "친구 초대", + "join_discord_community": "Discord 커뮤니티에 가입하세요.", + "keyboard_shortcuts": "키보드 단축키", + "name": "Hoppscotch", + "new_version_found": "새 버전을 찾았습니다. 업데이트하려면 새로고침하세요.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Options", + "proxy_privacy_policy": "프록시 개인 정보 보호 정책", + "reload": "새로고침", + "search": "찾기", + "share": "공유하기", + "shortcuts": "바로가기", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "스포트라이트", + "status": "상태", + "status_description": "Check the status of the website", + "terms_and_privacy": "약관 및 개인정보 보호", + "twitter": "Twitter", + "type_a_command_search": "명령을 입력하거나 검색...", + "we_use_cookies": "우리는 쿠키를 사용중입니다.", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "새로 바뀐 점", + "see_whats_new": "See what’s new", + "wiki": "위키" + }, + "auth": { + "account_exists": "계정이 다른 자격 증명으로 존재합니다. 두 계정을 연결하려면 로그인하세요.", + "all_sign_in_options": "모든 로그인 옵션", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "이메일로 계속하기", + "continue_with_github": "GitHub로 계속하기", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Google로 계속하기", + "continue_with_microsoft": "Continue with Microsoft", + "email": "이메일", + "logged_out": "로그아웃", + "login": "로그인", + "login_success": "로그인 성공", + "login_to_hoppscotch": "Hoppscotch에 로그인", + "logout": "로그아웃", + "re_enter_email": "이메일을 다시 입력하세요", + "send_magic_link": "매직 링크 보내기", + "sync": "동기화", + "we_sent_magic_link": "매직 링크를 보냈습니다!", + "we_sent_magic_link_description": "받은 편지함을 확인하세요. {email}(으)로 이메일을 보냈습니다. 여기에는 로그인할 수 있는 매직 링크가 포함되어 있습니다." + }, + "authorization": { + "generate_token": "토큰 생성", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "URL에 포함", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "사용법 알아보기", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "전달 방식", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "비밀번호", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "토큰", + "type": "인증 유형", + "username": "사용자 이름" + }, + "collection": { + "created": "모음집 생성됨", + "different_parent": "Cannot reorder collection with different parent", + "edit": "모음집 편집", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "모음집 이름을 바르게 입력하세요.", + "invalid_root_move": "Collection already in the root", + "moved": "Moved Successfully", + "my_collections": "내 모음집", + "name": "내 새 모음집", + "name_length_insufficient": "모음집 이름은 최소 세 글자 이상이어야 합니다.", + "new": "새 모음집", + "order_changed": "Collection Order Updated", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "모음집 이름이 변경됨", + "request_in_use": "사용 중인 요청", + "save_as": "다른 이름으로 저장", + "save_to_collection": "Save to Collection", + "select": "모음집 선택", + "select_location": "위치 선택", + "details": "Details", + "select_team": "팀 선택", + "team_collections": "팀 모음집" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "exit_team": "이 팀을 떠나겠습니까?", + "logout": "로그아웃하겠습니까?", + "remove_collection": "이 모음집을 영구적으로 삭제하겠습니까?", + "remove_environment": "이 환경을 영구적으로 삭제하겠습니까?", + "remove_folder": "이 폴더를 영구적으로 삭제하겠습니까?", + "remove_history": "모든 이력을 영구적으로 삭제하겠습니까?", + "remove_request": "이 요청을 영구적으로 삭제하겠습니까?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "이 팀을 삭제하겠습니까?", + "remove_telemetry": "진단 데이터를 보내지 않겠습니까?", + "request_change": "현재 요청을 취소하시겠습니까? 저장되지 않은 변경사항은 삭제됩니다.", + "save_unsaved_tab": "Do you want to save changes made in this tab?", + "sync": "이 작업 공간을 동기화하겠습니까?", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "헤더 {count}", + "message": "메시지 {count}", + "parameter": "파라미터 {count}", + "protocol": "프로토콜 {count}", + "value": "값 {count}", + "variable": "변수 {count}" + }, + "documentation": { + "generate": "문서 생성", + "generate_message": "Hoppscotch 모음집을 임포트하여 API 문서를 생성합니다." + }, + "empty": { + "authorization": "이 요청은 인증을 사용하지 않습니다.", + "body": "이 요청에는 본문이 없습니다.", + "collection": "모음집이 비어 있습니다.", + "collections": "모음집이 비어 있습니다.", + "documentation": "문서를 보려면 GraphQL 엔드포인트에 연결하세요.", + "endpoint": "엔드포인트는 빈 값일 수 없습니다.", + "environments": "환경이 비어 있습니다.", + "folder": "폴더가 비어 있습니다.", + "headers": "이 요청에는 헤더가 없습니다.", + "history": "이력이 비어 있습니다.", + "invites": "초대 목록이 비어 있습니다.", + "members": "팀이 비어 있습니다.", + "parameters": "이 요청에는 파라미터가 없습니다.", + "pending_invites": "이 팀에는 대기 중인 초대가 없습니다.", + "profile": "로그인하여 프로필을 확인합니다.", + "protocols": "프로토콜이 비어 있습니다.", + "request_variables": "This request does not have any request variables", + "schema": "스키마를 보려면 GraphQL 엔드포인트에 연결하세요.", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "Subscriptions are empty", + "team_name": "팀 이름이 비어 있습니다.", + "teams": "아무 팀에도 속하지 않았습니다.", + "tests": "이 요청에 대한 테스트가 없습니다.", + "access_tokens": "Access tokens are empty", + "shortcodes": "Shortcodes are empty" + }, + "environment": { + "add_to_global": "전역 변수에 추가", + "added": "환경 추가됨", + "create_new": "새 환경 만들기", + "created": "Environment created", + "deleted": "Environment deletion", + "duplicated": "Environment duplicated", + "edit": "환경 편집", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create a environment", + "invalid_name": "환경 이름을 바르게 입력하세요.", + "list": "Environment variables", + "my_environments": "My Environments", + "name": "Name", + "nested_overflow": "환경 변수는 열 단계까지만 중첩될 수 있습니다.", + "new": "새 환경", + "no_active_environment": "No active environment", + "no_environment": "환경 없음", + "no_environment_description": "선택한 환경이 없습니다. 선택해주세요.", + "quick_peek": "Environment Quick Peek", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "환경 선택", + "set": "Set environment", + "set_as_environment": "Set as environment", + "team_environments": "Team Environments", + "title": "환경", + "updated": "환경 수정됨", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "변수 목록", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "이 브라우저는 서버 전송 이벤트를 지원하지 않는 것 같습니다.", + "check_console_details": "자세한 내용은 콘솔 로그를 확인하세요.", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "cURL 형식이 올바르지 않습니다.", + "danger_zone": "Danger zone", + "delete_account": "Your account is currently an owner in these teams:", + "delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "빈 요청 이름", + "f12_details": "(자세한 내용은 F12)", + "gql_prettify_invalid_query": "잘못된 쿼리를 구문 강조할 수 없습니다. 쿼리 구문 오류를 해결하고 다시 시도하세요.", + "incomplete_config_urls": "설정 URLs이 잘못됐습니다.", + "incorrect_email": "잘못된 이메일 형식", + "invalid_link": "잘못된 링크", + "invalid_link_description": "잘못된 링크이거나 만료된 링크입니다.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "Invalid JSON", + "json_prettify_invalid_body": "잘못된 본문을 구문 강조할 수 없습니다. json 구문 오류를 해결하고 다시 시도하세요.", + "network_error": "네트워크 에러인 것 같습니다. 다시 시도하세요.", + "network_fail": "요청을 보낼 수 없습니다.", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "소요 시간 없음", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "No matches found", + "page_not_found": "해당 페이지를 찾을 수 없습니다.", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "사전 요청 스크립트를 실행할 수 없습니다.", + "something_went_wrong": "문제가 발생했습니다.", + "post_request_script_fail": "테스트 스크립트를 실행할 수 없습니다.", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "JSON으로 내보내기", + "create_secret_gist": "Secret Gist 만들기", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "GitHub에 로그인하여 secret gist 만들기", + "title": "내보내기", + "success": "Successfully exported", + "gist_created": "Gist가 생성됨" + }, + "filter": { + "all": "All", + "none": "None", + "starred": "Starred" + }, + "folder": { + "created": "폴더 생성됨", + "edit": "폴더 수정", + "invalid_name": "폴더 이름을 바르게 입력하세요.", + "name_length_insufficient": "폴더 이름은 최소한 세 글자 이상이어야 합니다.", + "new": "새 폴더", + "renamed": "폴더 이름이 변경됨" + }, + "graphql": { + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "mutations": "뮤테이션", + "schema": "스키마", + "subscriptions": "섭스크립션", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "Time", + "url": "URL" + }, + "header": { + "install_pwa": "앱을 설치", + "login": "로그인", + "save_workspace": "내 작업 공간 저장" + }, + "helpers": { + "authorization": "요청을 보낼 때 인증 헤더가 자동으로 생성됩니다.", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "문서를 생성하세요.", + "network_fail": "API 엔드포인트에 연결할 수 없습니다. 네트워크 연결을 확인하고 다시 시도하세요.", + "offline": "오프라인 상태인 것 같습니다. 이 작업 공간의 데이터는 최신이 아닐 수 있습니다.", + "offline_short": "오프라인 상태인 것 같습니다.", + "post_request_tests": "테스트 스크립트는 JavaScript로 작성되며 응답을 받은 후 실행됩니다.", + "pre_request_script": "사전 요청 스크립트는 JavaScript로 작성되며 요청이 전송되기 전에 실행됩니다.", + "script_fail": "사전 요청 스크립트에 결함이 있는 것 같습니다. 아래 오류를 확인하고 스크립트를 수정하세요.", + "post_request_script_fail": "테스트 스크립트에 결함이 있는 것 같습니다. 오류를 수정하고 테스트를 다시 실행하세요.", + "post_request_script": "디버깅을 자동화하는 테스트 스크립트를 작성하세요." + }, + "hide": { + "collection": "Collapse Collection Panel", + "more": "더 숨기기", + "preview": "미리보기 숨기기", + "sidebar": "사이드바 숨기기" + }, + "import": { + "collections": "모음집 가져오기", + "curl": "cURL 가져오기", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "가져오기 실패", + "from_file": "Import from File", + "from_gist": "Gist에서 가져오기", + "from_gist_description": "Gist URL에서 가져오기", + "from_insomnia": "Insomnia 형식 가져오기", + "from_insomnia_description": "Insomnia 모음집을 가져옵니다.", + "from_json": "Import from Hoppscotch", + "from_json_description": "Hoppscotch 모음집 파일을 가져옵니다.", + "from_my_collections": "내 모음집에서 가져오기", + "from_my_collections_description": "내 모음집 파일을 가져옵니다.", + "from_openapi": "OpenAPI에서 가져오기", + "from_openapi_description": "OpenAPI 명세 파일(YML/JSON)을 가져옵니다.", + "from_postman": "Postman에서 가져오기", + "from_postman_description": "Postman 모음집을 가져옵니다.", + "from_url": "URL에서 가져오기", + "gist_url": "Gist URL 입력", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "URL에서 데이터를 가져올 수 없습니다.", + "import_from_url_invalid_file_format": "모음집을 가져오는데 실패하였습니다.", + "import_from_url_invalid_type": "지원되지 않는 타입입니다. 지원되는 타입은 'hoppscotch', 'openapi', 'postman', 'insomnia' 입니다.", + "import_from_url_success": "모음집을 성공적으로 가져왔습니다.", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Hoppscotch 모음집 JSON 파일을 가져옵니다.", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "가져오기", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled." + } + }, + "layout": { + "collapse_collection": "Collapse or Expand Collections", + "collapse_sidebar": "Collapse or Expand the sidebar", + "column": "가로형 레이아웃", + "name": "Layout", + "row": "세로형 레이아웃" + }, + "modal": { + "close_unsaved_tab": "You have unsaved changes", + "collections": "모음집", + "confirm": "확인", + "customize_request": "Customize Request", + "edit_request": "요청 수정", + "import_export": "가져오기 / 내보내기", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "You are already subscribed to this topic.", + "clean_session": "Clean Session", + "clear_input": "Clear input", + "clear_input_on_send": "Clear input on send", + "client_id": "Client ID", + "color": "Pick a color", + "communication": "통신", + "connection_config": "Connection Config", + "connection_not_authorized": "This MQTT connection does not use any authentication.", + "invalid_topic": "Please provide a topic for the subscription", + "keep_alive": "Keep Alive", + "log": "로그", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "메시지", + "new": "New Subscription", + "not_connected": "Please start a MQTT connection first.", + "publish": "게시", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "구독", + "topic": "토픽", + "topic_name": "토픽 이름", + "topic_title": "발행/구독 토픽", + "unsubscribe": "구독 취소", + "url": "URL" + }, + "navigation": { + "doc": "문서", + "graphql": "GraphQL", + "profile": "프로필", + "realtime": "실시간", + "rest": "REST", + "settings": "설정" + }, + "preRequest": { + "javascript_code": "자바스크립트 코드", + "learn": "문서 읽기", + "script": "사전 요청 스크립트", + "snippets": "코드 조각" + }, + "profile": { + "app_settings": "앱 설정", + "default_hopp_displayname": "Unnamed User", + "editor": "편집자", + "editor_description": "편집자는 요청을 추가, 수정, 삭제할 수 있습니다.", + "email_verification_mail": "인증 이메일을 보냈습니다. 이메일에 있는 인증용 링크를 눌러주세요.", + "no_permission": "이 행동에 필요한 권한이 없습니다.", + "owner": "소유자", + "owner_description": "소유자는 요청과 모음집, 팀원을 추가, 수정, 삭제할 수 있습니다.", + "roles": "역할", + "roles_description": "역할은 공유 모음집의 접근을 제어하는 데 사용됩니다.", + "updated": "프로필이 업데이트되었습니다.", + "viewer": "뷰어", + "viewer_description": "뷰어는 요청을 열고 사용할 수 있습니다." + }, + "remove": { + "star": "즐겨찾기 삭제" + }, + "request": { + "added": "요청이 추가됨", + "authorization": "권한 부여", + "body": "요청 본문", + "choose_language": "언어 선택", + "content_type": "컨텐츠 종류", + "content_type_titles": { + "others": "Others", + "structured": "Structured", + "text": "Text" + }, + "different_collection": "Cannot reorder requests from different collections", + "duplicated": "Request duplicated", + "duration": "소요 시간", + "enter_curl": "cURL 입력", + "generate_code": "코드 생성", + "generated_code": "코드가 생성됨", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "헤더 목록", + "invalid_name": "요청 이름을 바르게 입력하세요.", + "method": "메서드", + "moved": "Request moved", + "name": "요청 이름", + "new": "새로운 요청", + "order_changed": "Request Order Updated", + "override": "덮어쓰기", + "override_help": "헤더에 Content-Type를 설정해주세요.", + "overriden": "Overridden", + "parameter_list": "쿼리 파라미터 목록", + "parameters": "파라미터", + "path": "경로", + "payload": "페이로드", + "query": "쿼리", + "raw_body": "원시 요청 본문", + "rename": "Rename Request", + "renamed": "요청 이름이 변경됨", + "request_variables": "Request variables", + "run": "실행", + "save": "저장", + "save_as": "다른 이름으로 저장", + "saved": "요청이 저장됨", + "share": "공유하기", + "share_description": "Share Hoppscotch with your friends", + "share_request": "Share Request", + "stop": "Stop", + "title": "제목", + "type": "요청 유형", + "url": "URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "변수", + "view_my_links": "내 링크 보기", + "copy_link": "링크 복사" + }, + "response": { + "audio": "Audio", + "body": "응답 본문", + "filter_response_body": "Filter JSON response body (uses jq syntax)", + "headers": "헤더", + "html": "HTML", + "image": "이미지", + "json": "JSON", + "pdf": "PDF", + "preview_html": "HTML 미리보기", + "raw": "Raw", + "size": "크기", + "status": "상태", + "time": "시간", + "title": "제목", + "video": "Video", + "waiting_for_connection": "연결 대기 중", + "xml": "XML" + }, + "settings": { + "accent_color": "강조색", + "account": "계정", + "account_deleted": "Your account has been deleted", + "account_description": "계정 설정을 수정합니다.", + "account_email_description": "기본 이메일 주소입니다.", + "account_name_description": "디스플레이 이름입니다.", + "additional": "Additional Settings", + "background": "배경", + "black_mode": "검은 테마", + "choose_language": "언어 선택", + "dark_mode": "어두운 테마", + "delete_account": "Delete account", + "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", + "expand_navigation": "내비게이션 확장", + "experiments": "실험실", + "experiments_notice": "이것은 유용하거나 재미있거나 둘 다이거나 어느 쪽도 아닌, 아직 실험 중인 기능입니다. 완성되지 않았고 안정적이지 않을 수 있으므로 이상한 일이 발생하더라도 당황하지 마세요. 진지하게 말해서, 끄세요.", + "extension_ver_not_reported": "보고되지 않음", + "extension_version": "익스텐션 버전", + "extensions": "익스텐션", + "extensions_use_toggle": "브라우저 익스텐션을 사용하여 요청 보내기(있는 경우)", + "follow": "Follow Us", + "interceptor": "인터셉터", + "interceptor_description": "애플리케이션과 API 간의 미들웨어.", + "language": "언어", + "light_mode": "밝은 테마", + "official_proxy_hosting": "공식 프록시는 Hoppscotch에서 호스팅합니다.", + "profile": "프로필", + "profile_description": "프로필을 업데이트합니다", + "profile_email": "이메일 주소", + "profile_name": "프로필 이름", + "proxy": "프록시", + "proxy_url": "프록시 URL", + "proxy_use_toggle": "프록시 미들웨어를 사용하여 요청 보내기", + "read_the": "읽기", + "reset_default": "기본값으로 재설정", + "short_codes": "Short codes", + "short_codes_description": "Short codes which were created by you.", + "sidebar_on_left": "사이드바를 왼쪽에 위치", + "sync": "동기화", + "sync_collections": "모음집", + "sync_description": "설정을 클라우드에 동기화합니다.", + "sync_environments": "환경", + "sync_history": "이력", + "system_mode": "시스템에 따르기", + "telemetry": "진단 데이터 사용", + "telemetry_helps_us": "진단 데이터를 보내면 개인화된 대응과 최고의 경험을 제공하는 데 도움을 줍니다.", + "theme": "테마", + "theme_description": "응용 프로그램 테마를 사용자 지정합니다.", + "use_experimental_url_bar": "환경을 강조해주는 실험용 URL바 사용", + "user": "사용자", + "verified_email": "Verified email", + "verify_email": "이메일 확인" + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "현재 메뉴 닫기", + "command_menu": "검색과 명령 메뉴", + "help_menu": "도움말 메뉴", + "show_all": "키보드 단축키", + "title": "단축키" + }, + "miscellaneous": { + "invite": "사람들을 Hoppscotch에 초대하기", + "title": "기타" + }, + "navigation": { + "back": "이전 페이지로 돌아가기", + "documentation": "문서 페이지로 이동", + "forward": "다음 페이지로 이동", + "graphql": "GraphQL 페이지로 이동", + "profile": "프로필 페이지로 이동", + "realtime": "실시간 페이지로 이동", + "rest": "REST 페이지로 이동", + "settings": "설정 페이지로 이동", + "title": "내비게이션" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "DELETE 메서드 선택", + "get_method": "GET 메서드 선택", + "head_method": "HEAD 메서드 선택", + "import_curl": "Import cURL", + "method": "메서드", + "next_method": "다음 메서드 선택", + "post_method": "POST 메서드 선택", + "previous_method": "이전 메서드 선택", + "put_method": "PUT 메서드 선택", + "rename": "Rename Request", + "reset_request": "요청 초기화", + "save_request": "Save Request", + "save_to_collections": "모음집에 저장", + "send_request": "요청 보내기", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "요청", + "copy_request_link": "요청 링크 복사" + }, + "response": { + "copy": "Copy response to clipboard", + "download": "Download response as file", + "title": "Response" + }, + "theme": { + "black": "검은 테마로 전환", + "dark": "어두운 테마로 전환", + "light": "밝은 테마로 전환", + "system": "시스템 따르기 테마로 전환", + "title": "테마" + } + }, + "show": { + "code": "코드 표시", + "collection": "모음집 보기", + "more": "자세히 보기", + "sidebar": "사이드바 표시" + }, + "socketio": { + "communication": "통신", + "connection_not_authorized": "This SocketIO connection does not use any authentication.", + "event_name": "이벤트 이름", + "events": "이벤트", + "log": "로그", + "url": "URL" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current team", + "edit": "Edit current team", + "invite": "Invite people to team", + "new": "Create new team", + "switch_to_personal": "Switch to your personal workspace", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "이벤트 유형", + "log": "로그", + "url": "URL" + }, + "state": { + "bulk_mode": "일괄 수정", + "bulk_mode_placeholder": "항목은 줄 바꿈으로 구분됩니다.\n키와 값은 : 기호로 구분됩니다.\n#으로 시작하는 행은 비활성 상태로 유지됩니다.", + "cleared": "모두 지워짐", + "connected": "연결됨", + "connected_to": "{name}에 연결됨", + "connecting_to": "{name}에 연결 중...", + "connection_error": "연결 실패", + "connection_failed": "연결 실패", + "connection_lost": "연결 끊김", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "클립보드에 복사됨", + "deleted": "삭제됨", + "deprecated": "더 이상 사용되지 않음", + "disabled": "비활성화됨", + "disconnected": "연결 끊김", + "disconnected_from": "{name}에서 연결 끊김", + "docs_generated": "문서 생성됨", + "download_failed": "Download failed", + "download_started": "다운로드 시작됨", + "enabled": "활성화됨", + "file_imported": "파일을 임포트함", + "finished_in": "{duration}ms 후에 완료 예정", + "hide": "Hide", + "history_deleted": "이력 삭제됨", + "linewrap": "랩 라인", + "loading": "로드 중...", + "message_received": "Message: {message} arrived on topic: {topic}", + "mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}", + "none": "없음", + "nothing_found": "에 대한 검색 결과가 없습니다.", + "published_error": "Something went wrong while publishing msg: {topic} to topic: {message}", + "published_message": "Published message: {message} to topic: {topic}", + "reconnection_error": "Failed to reconnect", + "show": "Show", + "subscribed_failed": "Failed to subscribe to topic: {topic}", + "subscribed_success": "Successfully subscribed to topic: {topic}", + "unsubscribed_failed": "Failed to unsubscribe from topic: {topic}", + "unsubscribed_success": "Successfully unsubscribed from topic: {topic}", + "waiting_send_request": "요청 전송 대기 중" + }, + "support": { + "changelog": "최신 릴리스에 대해 자세히 알아보기", + "chat": "질문이 있다면 우리와 채팅하세요!", + "community": "질문하고 다른 사람을 돕습니다. ", + "documentation": "Hoppscotch에 대해 자세히 알아보기", + "forum": "질문하고 답변 받기", + "github": "Follow us on Github", + "shortcuts": "더 빠르게 앱 탐색", + "title": "지원", + "twitter": "트위터에서 팔로우", + "team": "팀에 연락하기" + }, + "tab": { + "authorization": "인증", + "body": "본문", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "모음집", + "documentation": "문서", + "duplicate": "Duplicate Tab", + "environments": "Environments", + "headers": "헤더", + "history": "이력", + "mqtt": "MQTT", + "parameters": "파라미터", + "pre_request_script": "사전 요청 스크립트", + "queries": "쿼리", + "query": "쿼리", + "schema": "스키마", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "테스트", + "types": "유형", + "variables": "변수", + "websocket": "웹소켓" + }, + "team": { + "already_member": "이미 이 팀의 구성원입니다. 팀 소유자에게 문의하세요.", + "create_new": "새 팀 만들기", + "deleted": "팀 삭제됨", + "edit": "팀 편집", + "email": "이메일", + "email_do_not_match": "이 계정의 이메일이 일치하지 않습니다. 팀 소유자에게 문의하세요.", + "exit": "팀 나가기", + "exit_disabled": "소유자는 팀을 나갈 수 없습니다.", + "failed_invites": "Failed invites", + "invalid_coll_id": "Invalid collection ID", + "invalid_email_format": "이메일을 바르게 입력하세요.", + "invalid_id": "팀 아이디가 올바르지 않습니다. 팀 소유자에게 문의하세요.", + "invalid_invite_link": "초대 링크가 올바르지 않습니다.", + "invalid_invite_link_description": "초대 링크가 올바르지 않습니다. 팀 소유자에게 문의하세요.", + "invalid_member_permission": "팀원에게 올바른 권한을 부여하세요.", + "invite": "초대", + "invite_more": "더 초대하기", + "invite_tooltip": "사람들을 이 작업 공간에 초대하기", + "invited_to_team": "{owner}가 당신을 {team} 팀에 초대했습니다.", + "join": "Invitation accepted", + "join_team": "{team}에 합류", + "joined_team": "{team}에 합류했습니다.", + "joined_team_description": "이 팀의 구성원이 되었습니다.", + "left": "팀을 탈퇴했습니다", + "login_to_continue": "계속하려면 로그인하세요.", + "login_to_continue_description": "팀에 합류하려면 로그인이 필요합니다. ", + "logout_and_try_again": "로그아웃 후 다른 계정으로 로그인하세요.", + "member_has_invite": "해당 이메일을 이미 초대했습니다. 팀 소유자에게 문의하세요.", + "member_not_found": "구성원을 찾을 수 없습니다. 팀 소유자에게 문의하세요.", + "member_removed": "사용자가 삭제됨", + "member_role_updated": "사용자 역할 업데이트됨", + "members": "구성원", + "more_members": "+{count} more", + "name_length_insufficient": "팀 이름은 여섯 글자 이상이어야 합니다.", + "name_updated": "팀 이름 업데이트됨", + "new": "새 팀", + "new_created": "새 팀이 생성됨", + "new_name": "새 팀", + "no_access": "이 모음집을 수정할 권한이 없습니다.", + "no_invite_found": "초대 내역을 찾을 수 없습니다. 팀 소유자에게 문의하세요.", + "no_request_found": "Request not found.", + "not_found": "팀을 찾을 수 없습니다. 팀 소유자에게 문의하세요.", + "not_valid_viewer": "뷰어 권한이 없습니다. 팀 소유자에게 문의하세요.", + "parent_coll_move": "Cannot move collection to a child collection", + "pending_invites": "응답을 기다리는 초대", + "permissions": "권한", + "same_target_destination": "Same target and destination", + "saved": "팀이 저장됨", + "select_a_team": "팀을 선택하세요.", + "success_invites": "Success invites", + "title": "팀", + "we_sent_invite_link": "모든 초대 대상에게 초대 링크를 보냈습니다.", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "초대 대상들이 이메일을 확인하도록 권하세요. 이메일 속 링크를 클릭하면 팀에 합류할 수 있습니다.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "베타 프로그램에 참여하여 팀에 액세스하세요." + }, + "team_environment": { + "deleted": "Environment Deleted", + "duplicate": "Environment Duplicated", + "not_found": "Environment not found." + }, + "test": { + "failed": "테스트 실패", + "javascript_code": "자바스크립트 코드", + "learn": "문서 읽기", + "passed": "테스트 성공", + "report": "테스트 보고서", + "results": "테스트 결과", + "script": "스크립트", + "snippets": "코드 조각" + }, + "websocket": { + "communication": "통신", + "log": "로그", + "message": "메시지", + "protocols": "프로토콜", + "url": "URL" + }, + "workspace": { + "change": "Change workspace", + "personal": "My Workspace", + "other_workspaces": "My Workspaces", + "team": "Team Workspace", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "Actions", + "created_on": "Created on", + "deleted": "Shortcode deleted", + "method": "Method", + "not_found": "Shortcode not found", + "short_code": "Short code", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/mn.json b/packages/hoppscotch-common/locales/mn.json new file mode 100644 index 0000000..0a15e0c --- /dev/null +++ b/packages/hoppscotch-common/locales/mn.json @@ -0,0 +1,2361 @@ +{ + "action": { + "add": "Нэмэх", + "autoscroll": "Автоматаар гүйлгэх", + "cancel": "Цуцлах", + "choose_file": "Файл сонгоно уу", + "choose_workspace": "Ажлын талбарыг сонгоно уу", + "choose_collection": "Цуглуулга сонгох", + "select_workspace": "Ажлын талбарыг сонгоно уу", + "clear": "Тодорхой", + "clear_all": "Бүгдийг арилгах", + "clear_cache": "Кэшийг цэвэрлэх", + "clear_history": "Бүх түүхийг арилгах", + "clear_unpinned": "Тодруулаагүйг арилгах", + "clear_response": "Тодорхой хариулт", + "close": "Хаах", + "confirm": "Баталгаажуулах", + "connect": "Холбох", + "connecting": "Холбож байна", + "copy": "Хуулах", + "create": "Үүсгэх", + "delete": "Устгах", + "disconnect": "Салгах", + "dismiss": "Хаах", + "done": "Дууслаа", + "dont_save": "Хадгалах хэрэггүй", + "download_file": "Файл татаж авах", + "download_test_report": "Туршилтын тайланг татаж авах", + "drag_to_reorder": "Дахин эрэмбэлэхийн тулд чирнэ үү", + "duplicate": "Давхардсан", + "edit": "Засварлах", + "filter": "Шүүлтүүр", + "go_back": "Буцах", + "go_forward": "Урагшаа яв", + "group_by": "Группээр", + "hide_secret": "Нууцыг нуу", + "label": "Шошго", + "learn_more": "Илүү ихийг мэдэж аваарай", + "download_here": "Эндээс татаж авна уу", + "less": "Бага", + "more": "Илүү", + "new": "Шинэ", + "no": "Үгүй", + "open": "Нээлттэй", + "open_workspace": "Нээлттэй ажлын талбар", + "paste": "Буулгах", + "prettify": "Сайхан болгох", + "properties": "Үл хөдлөх хөрөнгө", + "register": "Бүртгүүлэх", + "remove": "Устгах", + "remove_instance": "Жишээ устгах", + "rename": "Нэрээ өөрчлөх", + "restore": "Сэргээх", + "retry": "Дахин оролдоно уу", + "save": "Хадгалах", + "save_as_example": "Жишээ болгон хадгал", + "add_example": "Жишээ нэмнэ үү", + "invalid_request": "Хүсэлтийн өгөгдөл буруу байна", + "scroll_to_bottom": "Доош гүйлгэх", + "scroll_to_top": "Дээд талд гүйлгэх", + "search": "Хайх", + "send": "Илгээх", + "share": "Хуваалцах", + "show_secret": "Нууцыг харуулах", + "sort": "Эрэмбэлэх", + "start": "Эхлэх", + "starting": "Эхэлж байна", + "stop": "Зогс", + "to_close": "хаах", + "to_navigate": "чиглүүлэх", + "to_select": "сонгох", + "turn_off": "Унтраах", + "turn_on": "Асаах", + "undo": "Буцаах", + "unpublish": "Нийтлэлийг цуцлах", + "yes": "Тиймээ", + "verify": "Баталгаажуулах", + "enable": "Идэвхжүүлэх", + "disable": "Идэвхгүй болгох", + "assign": "Даалгах" + }, + "activity_logs": { + "ACTIVITY_LOG_DELETE": "Үйл ажиллагааны бүртгэлийг устгасан", + "WORKSPACE_CREATE": "Шинэ ажлын талбар үүсгэсэн {name}", + "WORKSPACE_RENAME": "Ажлын талбарын нэрийг {old_name}-с {new_name} болгон өөрчилсөн", + "WORKSPACE_USER_ADD": "{user}-г ажлын талбарт {role} гэж нэмсэн", + "WORKSPACE_USER_INVITE": "{user}-г {inviteeEmail} {role} гэж урьсан.", + "WORKSPACE_USER_INVITE_REVOKE": "{inviteeEmail} урилгыг {inviteeRole} гэж цуцалсан", + "WORKSPACE_USER_INVITE_ACCEPT": "{inviteeEmail} урилгыг {inviteeRole} гэж хүлээн авлаа", + "WORKSPACE_USER_REMOVE": "{user}-г ажлын талбараас хассан", + "WORKSPACE_USER_ROLE_UPDATE": "{user}-ийн үүрэг {old_role}-с {new_role} болж шинэчлэгдсэн", + "COLLECTION_CREATE": "Шинэ цуглуулга үүсгэсэн {title}", + "COLLECTION_RENAME": "Цуглуулгын нэрийг {old_title}-с {new_title} болгон өөрчилсөн", + "COLLECTION_IMPORT": "Импортолсон {count} цуглуулга", + "COLLECTION_DELETE": "Устгасан цуглуулга {title}", + "COLLECTION_DUPLICATE": "Давхардсан цуглуулга {parentTitle}", + "REQUEST_CREATE": "Шинэ хүсэлт үүсгэсэн {title}", + "REQUEST_RENAME": "{old_title}-с {new_title} болгон нэрийг нь өөрчилсөн хүсэлт", + "REQUEST_DELETE": "Устгасан хүсэлт {title}" + }, + "add": { + "new": "Шинэ нэмэх", + "star": "Од нэмэх" + }, + "agent": { + "registration_instruction": "Үргэлжлүүлэхийн тулд Hoppscotch Agent-г вэб клиентдээ бүртгүүлнэ үү.", + "enter_otp_instruction": "Hoppscotch Agent-аас үүсгэсэн баталгаажуулах кодыг оруулаад бүртгэлээ дуусгана уу", + "otp_label": "Баталгаажуулах код", + "processing": "Таны хүсэлтийг боловсруулж байна...", + "not_running_title": "Агент илрээгүй", + "registration_title": "Агент бүртгэл", + "verify_ssl_certs": "SSL гэрчилгээг баталгаажуулна уу", + "ca_certs": "CA гэрчилгээ", + "client_certs": "Үйлчлүүлэгчийн гэрчилгээ", + "use_http_proxy": "HTTP прокси ашиглах", + "proxy_capabilities": "Hoppscotch Agent нь HTTP/HTTPS/SOCKS прокси-г NTLM болон Basic Auth-ийн хамт дэмждэг. URL-д прокси баталгаажуулалтын хэрэглэгчийн нэр, нууц үгийг оруулна уу.", + "add_cert_file": "Сертификат файл нэмэх", + "add_client_cert": "Үйлчлүүлэгчийн гэрчилгээ нэмнэ үү", + "add_key_file": "Түлхүүр файл нэмэх", + "domain": "Домэйн", + "cert": "Сертификат", + "key": "Түлхүүр", + "pfx_or_pkcs": "PFX/PKCS12", + "pfx_or_pkcs_file": "PFX/PKCS12 файл", + "add_pfx_or_pkcs_file": "PFX/PKCS12 файл нэмнэ үү" + }, + "app": { + "additional_links": { + "macOS": "macOS", + "windows": "Windows", + "linux": "Линукс", + "web_app": "Вэб програм", + "cli": "CLI" + }, + "downloads": "Татаж авсан зүйлс", + "chat_with_us": "Бидэнтэй чатлаарай", + "contact_us": "Бидэнтэй холбоо барина уу", + "cookies": "Күүки", + "copy": "Хуулах", + "copy_interface_type": "Интерфейсийн төрлийг хуулах", + "copy_user_id": "Хэрэглэгчийн баталгаажуулалтын тэмдгийг хуулах", + "developer_option": "Хөгжүүлэгчийн сонголтууд", + "developer_option_description": "Hoppscotch-ийг хөгжүүлэх, засварлахад тусалдаг хөгжүүлэгчийн хэрэгслүүд.", + "discord": "Discord", + "documentation": "Баримт бичиг", + "github": "GitHub", + "help": "Тусламж, санал хүсэлт", + "home": "Гэр", + "invite": "Урих", + "invite_description": "Hoppscotch бол нээлттэй эхийн API хөгжүүлэлтийн экосистем юм. Бид таны API-г үүсгэх, удирдахад хялбар бөгөөд ойлгомжтой интерфэйсийг зохион бүтээсэн. Hoppscotch нь танд API үүсгэх, турших, баримтжуулах, хуваалцахад туслах хэрэгсэл юм.", + "invite_your_friends": "Найзуудаа урь", + "join_discord_community": "Манай Discord нийгэмлэгт нэгдээрэй", + "keyboard_shortcuts": "Гарын товчлолууд", + "name": "Хоппскоч", + "new_version_found": "Шинэ хувилбар олдсон. Шинэчлэхийн тулд дахин сэргээнэ үү.", + "open_in_hoppscotch": "Hoppscotch-д нээнэ", + "options": "Сонголтууд", + "powered_by": "Powered by Hoppscotch", + "proxy_privacy_policy": "Прокси нууцлалын бодлого", + "reload": "Дахин ачаалах", + "search": "Хайлт ба тушаалууд", + "share": "Хуваалцах", + "shortcuts": "Товчлолууд", + "social_description": "Хамгийн сүүлийн үеийн мэдээ, шинэчлэлт, хувилбаруудыг цаг тухайд нь авахын тулд бидэнтэй нийгмийн сүлжээг дага.", + "social_links": "Нийгмийн холбоосууд", + "spotlight": "Анхаарал татахуйц", + "status": "Статус", + "status_description": "Сайтын статусыг шалгана уу", + "terms_and_privacy": "Нөхцөл ба нууцлал", + "twitter": "Twitter", + "type_a_command_search": "Тушаал бичих эсвэл хайх...", + "we_use_cookies": "Бид күүки ашигладаг", + "updated_text": "Hoppscotch v{version}🎉 болж шинэчлэгдсэн", + "whats_new": "Шинэ зүйл юу вэ?", + "see_whats_new": "Шинэ зүйл юу байгааг хараарай", + "wiki": "Вики", + "collapse_sidebar": "Хажуугийн самбарыг буулгах", + "continue_to_dashboard": "Хяналтын самбар руу үргэлжлүүлнэ үү", + "expand_sidebar": "Хажуугийн самбарыг өргөжүүлэх", + "no_name": "Нэргүй", + "open_navigation": "Навигацийг нээх", + "read_documentation": "Баримт бичгийг уншина уу", + "default": "анхдагч: {value}" + }, + "auth": { + "account_deactivated": "Таны бүртгэл идэвхгүй болсон байна. Хандалт авахын тулд админтай холбогдоно уу.", + "account_exists": "Бүртгэл өөр өөр үнэмлэхтэй байна - Хоёр бүртгэлийг холбохын тулд нэвтэрнэ үү", + "all_sign_in_options": "Бүх нэвтрэх сонголтууд", + "continue_with_auth_provider": "{provider}-р үргэлжлүүлэх", + "continue_with_email": "Цахим шуудангаар үргэлжлүүлнэ үү", + "continue_with_github": "GitHub-г үргэлжлүүлнэ үү", + "continue_with_github_enterprise": "GitHub Enterprise-тэй үргэлжлүүлнэ үү", + "continue_with_google": "Google-тэй үргэлжлүүлнэ үү", + "continue_with_microsoft": "Майкрософттой үргэлжлүүлнэ үү", + "email": "Имэйл", + "logged_out": "Гарсан", + "login": "Нэвтрэх", + "login_success": "Амжилттай нэвтэрлээ", + "login_to_hoppscotch": "Hoppscotch руу нэвтэрнэ үү", + "logout": "Гарах", + "re_enter_email": "Имэйлээ дахин оруулна уу", + "send_magic_link": "Шидэт холбоос илгээнэ үү", + "sync": "Синк хийх", + "we_sent_magic_link": "Бид танд ид шидийн холбоос илгээсэн!", + "we_sent_magic_link_description": "Ирсэн имэйлээ шалгана уу - бид {email} руу имэйл илгээсэн. Энэ нь таныг нэвтрэх шидэт холбоосыг агуулдаг." + }, + "authorization": { + "generate_token": "Токен үүсгэх", + "refresh_token": "Токеныг шинэчлэх", + "graphql_headers": "Зөвшөөрлийн толгойг холболтын_init рүү ачааллын нэг хэсэг болгон илгээдэг", + "include_in_url": "URL-д оруулна уу", + "inherited_from": "Эцэг эхийн цуглуулгаас {collection} өвлөн авсан {auth}", + "learn": "Хэрхэн гэдгийг мэдэж аваарай", + "oauth": { + "redirect_auth_server_returned_error": "Auth сервер алдааны төлөвийг буцаасан", + "redirect_auth_token_request_failed": "Баталгаажуулах токен авах хүсэлт амжилтгүй боллоо", + "redirect_auth_token_request_invalid_response": "Баталгаажуулах токен хүсэх үед Токен төгсгөлийн цэгийн хариу буруу байна", + "redirect_invalid_state": "Дахин чиглүүлэхэд буруу төлөвийн утга байна", + "redirect_no_auth_code": "Дахин чиглүүлэхэд Зөвшөөрлийн код байхгүй байна", + "redirect_no_client_id": "Үйлчлүүлэгчийн ID-г тодорхойлоогүй байна", + "redirect_no_client_secret": "Үйлчлүүлэгчийн нууцыг тодорхойлоогүй", + "redirect_no_code_verifier": "Код баталгаажуулагчийг тодорхойлоогүй байна", + "redirect_no_token_endpoint": "Токен төгсгөлийн цэг тодорхойлогдоогүй", + "something_went_wrong_on_oauth_redirect": "OAuth дахин чиглүүлэх явцад алдаа гарлаа", + "something_went_wrong_on_token_generation": "Токен үүсгэхэд алдаа гарлаа", + "token_generation_oidc_discovery_failed": "Токен үүсгэхэд алдаа гарсан: OpenID Connect нээлт амжилтгүй боллоо", + "grant_type": "Тэтгэлгийн төрөл", + "grant_type_auth_code": "Зөвшөөрлийн код", + "token_fetched_successfully": "Токеныг амжилттай татаж авлаа", + "token_fetch_failed": "Токеныг дуудаж чадсангүй", + "validation_failed": "Баталгаажуулалт амжилтгүй болсон тул маягтын талбаруудыг шалгана уу", + "no_refresh_token_present": "Сэргээх токен байхгүй байна. Токен үүсгэх урсгалыг дахин ажиллуулна уу", + "refresh_token_request_failed": "Токен сэргээх хүсэлт амжилтгүй боллоо", + "token_refreshed_successfully": "Токеныг амжилттай шинэчилсэн", + "label_authorization_endpoint": "Зөвшөөрлийн төгсгөлийн цэг", + "label_client_id": "Үйлчлүүлэгчийн ID", + "label_client_secret": "Үйлчлүүлэгчийн нууц", + "label_code_challenge": "Код сорилт", + "label_code_challenge_method": "Код сорилтын арга", + "label_code_verifier": "Код баталгаажуулагч", + "label_scopes": "Хамрах хүрээ", + "label_token_endpoint": "Токен төгсгөлийн цэг", + "label_use_pkce": "PKCE ашиглах", + "label_implicit": "Далд", + "label_password": "Нууц үг", + "label_username": "Хэрэглэгчийн нэр", + "label_auth_code": "Зөвшөөрлийн код", + "label_client_credentials": "Үйлчлүүлэгчийн итгэмжлэл", + "label_send_as": "Үйлчлүүлэгчийн баталгаажуулалт", + "label_send_in_body": "Итгэмжлэх жуух бичгээ биедээ илгээнэ үү", + "label_send_as_basic_auth": "Итгэмжлэх жуух бичгийг үндсэн баталгаажуулалтаар илгээнэ үү", + "enter_value": "Утга оруулна уу", + "auth_request": "Баталгаажуулах хүсэлт", + "token_request": "Токен хүсэлт", + "refresh_request": "Хүсэлтийг сэргээх", + "send_in": "Илгээх" + }, + "pass_key_by": "Хажуугаар нь өнгөр", + "pass_by_query_params_label": "Асуулгын параметрүүд", + "pass_by_headers_label": "Гарчиг", + "password": "Нууц үг", + "save_to_inherit": "Зөвшөөрлийг өвлүүлэхийн тулд энэ хүсэлтийг дурын цуглуулгад хадгална уу", + "token": "Токен", + "access_token": "Токен руу нэвтрэх", + "client_token": "Үйлчлүүлэгчийн токен", + "client_secret": "Үйлчлүүлэгчийн нууц", + "timestamp": "Цагийн тэмдэг", + "host": "Хөтлөгч", + "type": "Зөвшөөрлийн төрөл", + "username": "Хэрэглэгчийн нэр", + "advance_config": "Нарийвчилсан тохиргоо", + "advance_config_description": "Хэрэв тодорхой утга байхгүй бол Hoppscotch автоматаар тодорхой талбарт өгөгдмөл утгыг оноодог", + "algorithm": "Алгоритм", + "payload": "Ачаалал", + "secret": "Нууц", + "aws_signature": { + "access_key": "Хандалтын түлхүүр", + "secret_key": "Нууц түлхүүр", + "service_name": "Үйлчилгээний нэр", + "aws_region": "AWS бүс", + "service_token": "Үйлчилгээний токен" + }, + "digest": { + "realm": "Хүрээ", + "nonce": "Үгүй ээ", + "algorithm": "Алгоритм", + "qop": "qop", + "nonce_count": "Nonce Count", + "client_nonce": "Үйлчлүүлэгч Нонс", + "opaque": "Тунгалаг", + "disable_retry": "Дахин оролдох хүсэлтийг идэвхгүй болгох" + }, + "akamai": { + "headers_to_sign": "Гарын үсэг зурах", + "max_body_size": "Хамгийн их биеийн хэмжээ" + }, + "hawk": { + "id": "HAWK баталгаажуулалтын ID", + "key": "HAWK баталгаажуулах түлхүүр", + "ext": "ext", + "app": "апп", + "dlg": "dlg", + "include": "Ачааны хэшийг оруулна уу" + }, + "jwt": { + "params_name": "Парамсын нэр", + "param_name": "Параметрийн нэр", + "header_prefix": "Толгой хэсгийн угтвар", + "placeholder_request_header": "Толгой хэсгийн угтварыг хүсэх", + "placeholder_request_param": "Парамсын нэрийг хүсэх", + "secret_base64_encoded": "Нууц суурь64 кодлогдсон", + "headers": "JWT толгой", + "private_key": "Хувийн түлхүүр", + "placeholder_headers": "JWT толгой" + }, + "ntlm": { + "domain": "Домэйн", + "workstation": "Ажлын станц", + "disable_retrying_request": "Дахин оролдох хүсэлтийг идэвхгүй болгох" + }, + "asap": { + "issuer": "Үнэт цаас гаргагч", + "audience": "Үзэгчид", + "expires_in": "Дуусах хугацаа", + "key_id": "Түлхүүр ID", + "optional_config": "Нэмэлт тохиргоо", + "subject": "Сэдэв", + "additional_claims": "Нэмэлт нэхэмжлэл" + } + }, + "collection": { + "title": "Цуглуулга", + "run": "Цуглуулга ажиллуулах", + "created": "Цуглуулга үүсгэсэн", + "different_parent": "Өөр эцэг эхтэй цуглуулгыг дахин захиалах боломжгүй", + "edit": "Цуглуулга засварлах", + "import_or_create": "Импортлох эсвэл цуглуулга үүсгэх", + "import_collection": "Импортын цуглуулга", + "invalid_name": "Цуглуулгын нэрийг оруулна уу", + "invalid_root_move": "Цуглуулга аль хэдийн үндэст байна", + "moved": "Амжилттай хөдөлсөн", + "my_collections": "Хувийн цуглуулгууд", + "name": "Миний шинэ цуглуулга", + "name_length_insufficient": "Цуглуулгын нэр дор хаяж 3 тэмдэгт байх ёстой", + "new": "Шинэ цуглуулга", + "order_changed": "Цуглуулгын захиалга шинэчлэгдсэн", + "properties": "Цуглуулгын шинж чанарууд", + "properties_updated": "Цуглуулгын шинж чанарууд шинэчлэгдсэн", + "renamed": "Цуглуулгын нэрийг өөрчилсөн", + "request_in_use": "Ашиглаж байгаа хүсэлт", + "save_as": "Хадгалах", + "save_to_collection": "Цуглуулгад хадгалах", + "select": "Цуглуулга сонгоно уу", + "select_location": "Байршлыг сонгоно уу", + "sorted": "Цуглуулгыг эрэмбэлсэн", + "details": "Дэлгэрэнгүй мэдээлэл", + "duplicated": "Цуглуулга давхардсан" + }, + "confirm": { + "close_unsaved_tab": "Та энэ табыг хаахдаа итгэлтэй байна уу?", + "close_unsaved_tabs": "Та бүх табыг хаахдаа итгэлтэй байна уу? {count} хадгалагдаагүй табууд устах болно.", + "delete_all_activity_logs": "Та бүх үйл ажиллагааны бүртгэлийг устгахдаа итгэлтэй байна уу?", + "exit_team": "Та энэ ажлын талбараас гарахдаа итгэлтэй байна уу?", + "logout": "Та гарахдаа итгэлтэй байна уу?", + "remove_collection": "Та энэ цуглуулгыг бүрмөсөн устгахдаа итгэлтэй байна уу?", + "remove_environment": "Та энэ орчныг бүрмөсөн устгахдаа итгэлтэй байна уу?", + "remove_folder": "Та энэ фолдерыг бүрмөсөн устгахдаа итгэлтэй байна уу?", + "remove_history": "Та бүх түүхийг бүрмөсөн устгахдаа итгэлтэй байна уу?", + "remove_request": "Та энэ хүсэлтийг бүрмөсөн устгахдаа итгэлтэй байна уу?", + "remove_response": "Та энэ хариултыг бүрмөсөн устгахдаа итгэлтэй байна уу?", + "remove_shared_request": "Та энэ хуваалцсан хүсэлтийг бүрмөсөн устгахдаа итгэлтэй байна уу?", + "remove_team": "Та энэ ажлын талбарыг устгахдаа итгэлтэй байна уу?", + "remove_telemetry": "Та Telemetry-ээс татгалзахдаа итгэлтэй байна уу?", + "request_change": "Та одоогийн хүсэлтийг цуцлахдаа итгэлтэй байна уу, хадгалаагүй өөрчлөлтүүд устах болно.", + "save_unsaved_tab": "Та энэ таб дээр хийсэн өөрчлөлтийг хадгалахыг хүсэж байна уу?", + "sync": "Та ажлын талбараа үүлнээс сэргээхийг хүсч байна уу? Энэ нь таны орон нутгийн ахиц дэвшлийг устгах болно.", + "delete_access_token": "Та {tokenLabel} хандалтын токеныг устгахдаа итгэлтэй байна уу?", + "delete_mock_server": "Та энэ хуурамч серверийг устгахдаа итгэлтэй байна уу?" + }, + "context_menu": { + "add_parameters": "Параметрт нэмэх", + "open_request_in_new_tab": "Хүсэлтийг шинэ таб дээр нээнэ үү", + "set_environment_variable": "Хувьсагчаар тохируулна уу", + "encode_uri_component": "URL бүрэлдэхүүн хэсгийг кодлох", + "decode_uri_component": "URL бүрэлдэхүүн хэсгийг тайлах" + }, + "cookies": { + "modal": { + "cookie_expires": "Хугацаа нь дуусна", + "cookie_name": "Нэр", + "cookie_path": "Зам", + "cookie_string": "Күүки мөр", + "cookie_value": "Үнэ цэнэ", + "empty_domain": "Домэйн хоосон байна", + "empty_domains": "Домэйн жагсаалт хоосон байна", + "enter_cookie_string": "Күүкийн мөрийг оруулна уу", + "interceptor_no_support": "Таны одоо сонгосон таслан зогсоох төхөөрөмж күүкиг дэмждэггүй. Өөр Interceptor сонгоод дахин оролдоно уу.", + "managed_tab": "Удирдсан", + "new_domain_name": "Шинэ домэйн нэр", + "no_cookies_in_domain": "Энэ домэйнд күүки тохируулаагүй байна", + "raw_tab": "Түүхий", + "set": "Күүки тавь" + } + }, + "count": { + "currentValue": "Одоогийн утга {count}", + "description": "Тодорхойлолт {count}", + "header": "Гарчиг {count}", + "initialValue": "Анхны утга {count}", + "key": "Түлхүүр {count}", + "message": "Зурвас {count}", + "parameter": "Параметр {count}", + "protocol": "Протокол {count}", + "value": "Утга {count}", + "variable": "Хувьсагч {count}" + }, + "documentation": { + "add_description": "Энэ цуглуулгын тайлбарыг энд нэмнэ үү...", + "add_description_placeholder": "Энд тайлбар нэмнэ үү...", + "add_request_description": "Хүсэлтийн тайлбарыг энд нэмнэ үү...", + "auth": { + "access_key": "Хандалтын түлхүүр", + "access_token": "Токен руу нэвтрэх", + "add_to": "-д нэмэх", + "akamai_edgegrid": "Akamai EdgeGrid", + "algorithm": "Алгоритм", + "api_key": "API түлхүүр", + "app_id": "Апп ID", + "auth_id": "Баталгаажуулах ID", + "auth_key": "Баталгаажуулах түлхүүр", + "auth_url": "Auth URL", + "aws_signature": "AWS гарын үсэг", + "basic_auth": "Үндсэн зохиогч", + "bearer_token": "Баригч токен", + "client_id": "Үйлчлүүлэгчийн ID", + "client_nonce": "Үйлчлүүлэгч Нонс", + "client_secret": "Үйлчлүүлэгчийн нууц", + "client_token": "Үйлчлүүлэгчийн токен", + "delegation": "Төлөөлөгч", + "digest_auth": "Digest Auth", + "extra_data": "Нэмэлт өгөгдөл", + "grant_type": "Тэтгэлгийн төрөл", + "hawk_auth": "HAWK Auth", + "headers_to_sign": "Гарын үсэг зурах", + "host": "Хөтлөгч", + "include_payload_hash": "Ачааны хэшийг оруулна уу", + "jwt_auth": "JWT Auth", + "max_body_size": "Хамгийн их биеийн хэмжээ", + "no_auth": "Баталгаажуулалт байхгүй", + "nonce": "Үгүй ээ", + "oauth_2": "OAuth 2.0", + "opaque": "Тунгалаг", + "password": "Нууц үг", + "payload": "Ачаалал", + "qop": "QOP", + "realm": "Хүрээ", + "region": "Бүс нутаг", + "scope": "Хамрах хүрээ", + "secret_key": "Нууц түлхүүр", + "service_name": "Үйлчилгээний нэр", + "timestamp": "Цагийн тэмдэг", + "title": "Баталгаажуулалт", + "token_url": "Токен URL", + "user": "Хэрэглэгч", + "username": "Хэрэглэгчийн нэр" + }, + "body": { + "content_type": "Агуулгын төрөл", + "no_body": "Тодорхойлсон бие байхгүй", + "title": "Бие" + }, + "copied_to_clipboard": "Түр санах ой руу хуулсан!", + "curl": { + "click_to_load": "cURL командыг ачаалах бол товшино уу", + "copied": "cURL командыг санах ой руу хуулсан!", + "copy_to_clipboard": "Түр санах ойд хуулах", + "generating": "cURL командыг үүсгэж байна...", + "load": "cURL-г ачаална уу", + "title": "cURL" + }, + "description": "Тодорхойлолт", + "error_rendering_markdown": "Тэмдэглэгээг буулгахад алдаа гарлаа:", + "fetching_documentation": "Баримт бичгийг авчирч байна...", + "generate": "Баримт бичиг үүсгэх", + "generate_message": "Явж байхдаа API баримт бичгийг үүсгэхийн тулд дурын Hoppscotch цуглуулгыг импортлох.", + "headers": { + "no_headers": "Тодорхойлолтгүй гарчиг", + "title": "Гарчиг" + }, + "hide_all_documentation": "Бүх баримт бичгийг нуух", + "inherited_from": "{name}-аас өвлөн авсан", + "inherited_with_type": "{type}-г {name}-аас өвлөн авсан", + "key": "Түлхүүр", + "loading": "Ачааж байна...", + "loading_collection_data": "Цуглуулгын өгөгдлийг ачаалж байна...", + "no": "Үгүй", + "no_collection_data": "Цуглуулгын мэдээлэл байхгүй байна", + "no_documentation_found": "Фолдер эсвэл хүсэлтийн баримт бичиг олдсонгүй", + "no_request_data": "Хүсэлтийн өгөгдөл байхгүй байна", + "no_requests_or_folders": "Хүсэлт, хавтас байхгүй", + "not_set": "Тохируулаагүй", + "open_request_in_new_tab": "Хүсэлтийг шинэ таб дээр нээнэ үү", + "parameters": { + "no_params": "Тодорхойлогдсон параметр байхгүй", + "title": "Параметрүүд" + }, + "percent_complete": "% дууссан", + "processing_documentation": "Баримт бичгийг боловсруулах", + "publish": { + "already_published": "Энэ цуглуулга аль хэдийн хэвлэгдсэн байна", + "auto_sync": "Цуглуулгатай автоматаар синк хийх", + "auto_sync_description": "Цуглуулга өөрчлөгдөх үед нийтлэгдсэн баримтуудыг автоматаар шинэчлэх", + "button": "Нийтлэх", + "copy_url": "URL хуулах", + "delete": "Баримт бичгийг устгах", + "unpublish_doc": "Та баримт бичгийг нийтлэхээ болиулахдаа итгэлтэй байна уу?", + "delete_success": "Нийтэлсэн баримт бичгийг амжилттай устгалаа", + "doc_title": "Гарчиг", + "doc_version": "Хувилбар", + "edit_published_doc": "Нийтэлсэн баримт бичгийг засварлах", + "last_updated": "Сүүлд шинэчлэгдсэн", + "metadata": "Мета өгөгдөл (JSON)", + "open_published_doc": "Нийтэлсэн баримт бичгийг шинэ таб дээр нээнэ үү", + "publish_error": "Баримт бичгийг нийтэлж чадсангүй", + "publish_success": "Баримт бичгийг амжилттай нийтлэв!", + "published": "Нийтэлсэн", + "published_url": "Нийтэлсэн URL", + "title": "Баримт бичгийг нийтлэх", + "update_button": "Шинэчлэх", + "update_error": "Баримт бичгийг шинэчилж чадсангүй", + "update_published_docs": "Нийтэлсэн баримт бичгүүдийг шинэчлэх", + "update_success": "Баримт бичгийг амжилттай шинэчилсэн!", + "unpublish": "Нийтлэлийг цуцлах", + "update_title": "Нийтэлсэн баримт бичгийг шинэчлэх", + "url_copied": "URL-г санах ой руу хуулсан!", + "view_published": "Нийтэлсэн баримтуудыг үзэх", + "view_title": "Нийтэлсэн баримт бичгийн агшин зураг", + "versions": "Хувилбарууд", + "create_new_version": "Шинэ хувилбар үүсгэх", + "invalid_version": "Хувилбар нь зөвхөн үсэг, тоон тэмдэгт, цэг, зураас агуулсан байх ёстой", + "live": "Амьд", + "snapshot": "Хормын хувилбар", + "version_immutable": "Нийтлэгдсэн хувилбарууд нь зөвхөн унших боломжтой хормын хувилбарууд юм", + "view_snapshot": "Snapshot үзэх", + "current_version": "ОДОО", + "not_found": "Нийтэлсэн баримт бичиг олдсонгүй", + "no_doc_id": "Баримт бичгийн ID өгөөгүй", + "version_label": "v{version}", + "unpublish_version": "Энэ хувилбарыг нийтлээгүй", + "loading_snapshot": "Хормын хувилбарыг ачаалж байна...", + "retry_snapshot": "Дахин оролдоно уу", + "sensitive_data_warning": "Нийтлэгдсэн баримт бичигт ямар ч нууц мэдээлэл ил гарсан эсэхийг шалгана уу.", + "snapshot_preview": "Хормын хувилбарыг урьдчилан харах", + "snapshot_load_error": "Хормын хувилбарыг урьдчилан үзэхийг ачаалж чадсангүй", + "snapshot_empty": "Энэ хормын хувилбарт хүсэлт эсвэл хавтас алга", + "snapshot_item_count": "{count} зүйл", + "auto_sync_live_notice": "Энэ хувилбар нь шууд цуглуулгатай автоматаар синк хийдэг", + "snapshot_promote_warning": "Автомат синкийг идэвхжүүлснээр энэ царцаасан агшин агшныг шууд цуглуулгын модоор солих болно. Үүнийг буцаах боломжгүй.", + "live_freeze_notice": "Автомат синкийг унтрааж, энэ хувилбарыг одоогийн цуглуулгын төлөвт царцаах болно.", + "untitled_project": "Гарчиггүй төсөл", + "environment": "Байгаль орчин", + "no_environment": "Орчин байхгүй", + "environment_description": "Нийтлэгдсэн баримт бичигт хувьсагчдыг шийдвэрлэх орчныг хавсаргана уу" + }, + "request_opened_in_new_tab": "Хүсэлтийг шинэ таб дээр нээсэн!", + "response": { + "body": "Хариу өгөх байгууллага", + "copy": "Хариултыг хуулах", + "example_copied": "Хариултын жишээг санах ой руу хуулсан!", + "example_copy_failed": "Хариултын жишээг хуулж чадсангүй", + "headers": "Хариултын толгой", + "no_examples": "Хариултын жишээ байхгүй", + "title": "Хариултын жишээ" + }, + "save_error": "Баримт бичгийг хадгалахад алдаа гарлаа", + "save_success": "Баримт бичгийг амжилттай хадгаллаа", + "saved_items_status": "{success} зүйлийг хадгалсан.{failure} зүйлийг хадгалж чадсангүй.", + "show_all_documentation": "Бүх баримт бичгийг харуулах", + "source": "Эх сурвалж", + "title": "Баримт бичиг", + "unsaved_changes": "Танд хадгалагдаагүй {count} өөрчлөлт байна. Хаахаасаа өмнө хадгална уу.", + "untitled_collection": "Гарчиггүй цуглуулга", + "untitled_request": "Гарчиггүй хүсэлт", + "value": "Үнэ цэнэ", + "variables": { + "no_vars": "Тодорхойлсон хувьсагч байхгүй", + "title": "Хувьсагч" + }, + "yes": "Тиймээ" + }, + "empty": { + "activity_logs": "Үйл ажиллагааны бүртгэл олдсонгүй", + "authorization": "Энэ хүсэлт нь ямар ч зөвшөөрөл ашиглахгүй", + "body": "Энэ хүсэлт нь биетэй байдаггүй", + "collection": "Цуглуулга хоосон байна", + "collections": "Цуглуулга хоосон байна", + "documentation": "Баримт бичгийг үзэхийн тулд GraphQL төгсгөлийн цэгт холбогдоно уу", + "empty_schema": "Схем олдсонгүй", + "endpoint": "Төгсгөлийн цэг хоосон байж болохгүй", + "environments": "Байгаль орчин хоосон байна", + "collection_variables": "Цуглуулгын хувьсагчид хоосон байна", + "folder": "Хавтас хоосон байна", + "headers": "Энэ хүсэлтэд гарчиг байхгүй", + "history": "Түүх хоосон байна", + "invites": "Урилгын жагсаалт хоосон байна", + "members": "Ажлын талбар хоосон байна", + "parameters": "Энэ хүсэлтэд ямар ч параметр байхгүй", + "pending_invites": "Энэ ажлын талбарт хүлээгдэж буй урилга алга", + "profile": "Өөрийн профайлыг үзэхийн тулд нэвтэрнэ үү", + "protocols": "Протоколууд хоосон байна", + "request_variables": "Энэ хүсэлтэд хүсэлтийн хувьсагч байхгүй байна", + "schema": "Схемийг үзэхийн тулд GraphQL төгсгөлийн цэгт холбогдоно уу", + "search_environment": "Тохирох орчин олдсонгүй", + "secret_environments": "Нууцуудыг Hoppscotch-тэй синк хийдэггүй", + "shared_requests": "Хуваалцсан хүсэлтүүд хоосон байна", + "shared_requests_logout": "Хуваалцсан хүсэлтээ харах эсвэл шинээр үүсгэхийн тулд нэвтэрнэ үү", + "subscription": "Захиалга хоосон байна", + "team_name": "Ажлын талбарын нэр хоосон байна", + "teams": "Та ямар ч ажлын талбарт харьяалагдахгүй", + "tests": "Энэ хүсэлтийн хувьд туршилт байхгүй байна", + "access_tokens": "Хандалтын токенууд хоосон байна", + "response": "Хариу ирээгүй", + "mock_servers": "Хуурамч сервер олдсонгүй" + }, + "environment": { + "heading": "Байгаль орчин", + "add_to_global": "Глобал руу нэмнэ үү", + "added": "Орчны хувьсагч нэмэгдсэн", + "create_new": "Шинэ орчин бүрдүүлэх", + "created": "Бүтээсэн орчин", + "current_value": "Одоогийн үнэ цэнэ", + "deleted": "Орчны хувьсагчийг устгасан", + "duplicated": "Хүрээлэн буй орчин давхардсан", + "edit": "Байгаль орчныг засах", + "empty_variables": "Хувьсагч байхгүй", + "global": "Глобал", + "global_variables": "Глобал хувьсагч", + "import_or_create": "Импортлох эсвэл орчин бүрдүүлэх", + "initial_value": "Анхны үнэ цэнэ", + "invalid_name": "Орчныхоо нэрийг оруулна уу", + "list": "Хүрээлэн буй орчны хувьсагчид", + "my_environments": "Хувийн орчин", + "name": "Нэр", + "nested_overflow": "Оруулсан орчны хувьсагчдыг 10 түвшинд хязгаарласан", + "new": "Шинэ орчин", + "no_active_environment": "Идэвхтэй орчин байхгүй", + "no_environment": "Орчин байхгүй", + "no_environment_description": "Ямар ч орчин сонгогдоогүй. Дараах хувьсагчид юу хийхээ сонгоно уу.", + "quick_peek": "Байгаль орчныг хурдан харах", + "replace_all_current_with_initial": "Бүх гүйдлийг эхнийхээр солино", + "replace_all_initial_with_current": "Бүх эхлэлийг гүйдэлээр солино", + "replace_current_with_initial": "Эхлэлээр солино", + "replace_initial_with_current": "Гүйдлээр солих", + "replace_with_variable": "Хувьсагчаар солино", + "scope": "Хамрах хүрээ", + "secrets": "Нууцууд", + "secret_value": "Нууц үнэ цэнэ", + "select": "Орчноо сонгох", + "set": "Орчноо тохируулах", + "set_as_environment": "Орчин болгон тохируулна уу", + "short_name": "Орчны нэр дор хаяж 1 тэмдэгттэй байх шаардлагатай", + "team_environments": "Ажлын байрны орчин", + "title": "Байгаль орчин", + "updated": "Байгаль орчин шинэчлэгдсэн", + "value": "Үнэ цэнэ", + "variable": "Хувьсагч", + "variables": "Хувьсагч", + "variable_list": "Хувьсагчийн жагсаалт", + "properties": "Байгаль орчны шинж чанарууд", + "details": "Дэлгэрэнгүй мэдээлэл" + }, + "error": { + "network": { + "heading": "Сүлжээний алдаа", + "description": "Сүлжээний холболт амжилтгүй боллоо. {message}: {cause}" + }, + "timeout": { + "heading": "Хугацаа хэтэрсэн алдаа", + "description": "{phase} үед хүсэлтийн хугацаа дууссан. {message}" + }, + "certificate": { + "heading": "Сертификатын алдаа", + "description": "Хүчингүй гэрчилгээ. {message}: {cause}" + }, + "auth": { + "heading": "Баталгаажуулалтын алдаа", + "description": "Хандалтыг хориглосон. {message}: {cause}" + }, + "proxy": { + "heading": "Прокси алдаа", + "description": "Прокси холболт амжилтгүй боллоо. {message}: {cause}" + }, + "parse": { + "heading": "Шинжилгээний алдаа", + "description": "Хариултыг задлан шинжилж чадсангүй. {message}: {cause}" + }, + "version": { + "heading": "Хувилбарын алдаа", + "description": "Тохиромжгүй хувилбарууд. {message}: {cause}" + }, + "abort": { + "heading": "Хүсэлтийг цуцалсан", + "description": "Үйл ажиллагааг цуцалсан. {message}: {cause}" + }, + "unknown": { + "heading": "Үл мэдэгдэх алдаа", + "description": "Үл мэдэгдэх алдаа гарлаа.", + "cause": "Үл мэдэгдэх шалтгаан" + }, + "extension": { + "heading": "Өргөтгөлийн алдаа", + "description": "Өргөтгөл дээр ажиллах хүсэлт амжилтгүй боллоо" + }, + "authproviders_load_error": "Баталгаажуулах үйлчилгээ үзүүлэгчдийг ачаалах боломжгүй", + "browser_support_sse": "Энэ хөтөч нь Server Sent Events-ийг дэмждэггүй бололтой.", + "check_console_details": "Дэлгэрэнгүй мэдээллийг консолын бүртгэлээс шалгана уу.", + "check_how_to_add_origin": "Гарал үүслийг хэрхэн нэмэх боломжтойг шалгана уу", + "curl_invalid_format": "cURL зөв форматлагдаагүй байна", + "danger_zone": "Аюултай бүс", + "delete_account": "Таны бүртгэл одоогоор эдгээр ажлын талбарт цорын ганц эзэмшигч байна:", + "delete_account_description": "Та бүртгэлээ устгахаасаа өмнө өөрийгөө устгах, өмчлөх эрхийг шилжүүлэх эсвэл эдгээр ажлын талбарыг устгах ёстой.", + "delete_activity_log": "Үйл ажиллагааны бүртгэлийг устгаж чадсангүй", + "delete_all_activity_logs": "Бүх үйл ажиллагааны бүртгэлийг устгаж чадсангүй", + "email_already_exists": "Энэ имэйл өөр бүртгэлтэй байна. Өөр имэйл хаяг тохируулна уу.", + "empty_email_address": "Имэйл хаяг хоосон байж болохгүй", + "empty_profile_name": "Профайлын нэр хоосон байж болохгүй", + "empty_req_name": "Хоосон хүсэлтийн нэр", + "fetch_activity_logs": "Үйл ажиллагааны бүртгэлийг дуудаж чадсангүй", + "f12_details": "(Дэлгэрэнгүйг F12)", + "gql_prettify_invalid_query": "Хүчингүй асуулгыг сайжруулж чадсангүй, асуулгын синтаксийн алдааг шийдэж, дахин оролдоно уу", + "incomplete_config_urls": "Бүрэн бус тохиргооны URL", + "incorrect_email": "Буруу имэйл", + "invalid_file_type": "`{filename}` файлын төрөл буруу.", + "invalid_link": "Буруу холбоос", + "invalid_link_description": "Таны товшсон холбоос хүчингүй эсвэл хугацаа нь дууссан байна.", + "invalid_embed_link": "Оруулсан зүйл байхгүй эсвэл буруу байна.", + "json_parsing_failed": "Буруу JSON", + "json_prettify_invalid_body": "Буруу биеийг гоёж чадсангүй, json синтакс алдааг шийдэж, дахин оролдоно уу", + "network_error": "Сүлжээнд алдаа гарсан бололтой. Дахин оролдоно уу.", + "network_fail": "Хүсэлт илгээж чадсангүй", + "no_collections_to_export": "Экспортлох цуглуулга байхгүй. Эхлэхийн тулд цуглуулга үүсгэнэ үү.", + "no_duration": "Үргэлжлэх хугацаа байхгүй", + "no_environments_to_export": "Экспортлох орчин байхгүй. Эхлэхийн тулд орчинг бүрдүүлнэ үү.", + "no_results_found": "Тохирох зүйл олдсонгүй", + "page_not_found": "Энэ хуудсыг олж чадсангүй", + "please_install_extension": "Өргөтгөлийг суулгаж, өргөтгөлийн эх сурвалжийг нэмнэ үү.", + "proxy_error": "Прокси алдаа", + "same_email_address": "Шинэчлэгдсэн имэйл хаяг нь одоогийн имэйл хаягтай ижил байна", + "same_profile_name": "Шинэчлэгдсэн профайлын нэр нь одоогийн профайлын нэртэй ижил байна", + "script_fail": "Урьдчилсан хүсэлтийн скриптийг ажиллуулж чадсангүй", + "something_went_wrong": "Ямар нэг алдаа гарлаа", + "subscription_error": "Энэ сэдвийг захиалж чадсангүй: {error}", + "post_request_script_fail": "Хүсэлтийн дараах скриптийг ажиллуулж чадсангүй", + "reading_files": "Нэг буюу хэд хэдэн файлыг унших явцад алдаа гарлаа.", + "fetching_access_tokens_list": "Токенуудын жагсаалтыг татах явцад алдаа гарлаа", + "generate_access_token": "Хандалтын тэмдгийг үүсгэх явцад алдаа гарлаа", + "delete_access_token": "Хандалтын тэмдгийг устгах явцад алдаа гарлаа", + "extension_not_found": "Өргөтгөл олдсонгүй", + "invalid_request": "Хүсэлтийн өгөгдөл буруу байна" + }, + "export": { + "as_json": "JSON хэлбэрээр экспортлох", + "create_secret_gist": "Нууц гол санааг үүсгэ", + "create_secret_gist_tooltip_text": "Нууц гол болгон экспортлох", + "failed": "Экспортлох явцад алдаа гарлаа", + "secret_gist_success": "Нууц Gist болгон амжилттай экспортоллоо", + "require_github": "GitHub-ээр нэвтэрч нууц гол санааг үүсгэнэ үү", + "title": "Экспорт", + "success": "Амжилттай экспортоллоо" + }, + "file_upload": { + "choose_file": "Файл сонгоно уу", + "max_size_format": "Хамгийн ихдээ 5MB. JPEG, PNG, GIF, WebP дэмждэг", + "profile_photo_updated": "Профайл зургийг амжилттай шинэчилсэн", + "profile_photo_removed": "Профайлын зургийг амжилттай устгалаа", + "org_logo_updated": "Байгууллагын лого амжилттай шинэчлэгдсэн", + "error_size_limit": "Файлын хэмжээ 5 МБ-аас бага байх ёстой", + "error_invalid_format": "Файл нь зураг байх ёстой (JPEG, PNG, GIF, эсвэл WebP)", + "error_invalid_upload_type": "Буруу байршуулах төрөл", + "error_invalid_org_id": "Байгууллагын ID формат буруу байна", + "error_upload_failed": "Байршуулж чадсангүй. Дахин оролдоно уу", + "error_network_failed": "Сүлжээний алдаа. Холболтоо шалгана уу", + "error_timeout": "30 секундын дараа байршуулах хугацаа дууссан. Дахин оролдоно уу", + "error_missing_backend_url": "Backend URL-г тохируулаагүй байна. Орчны тохиргоондоо VITE_BACKEND_API_URL орчны хувьсагчийг тохируулна уу", + "error_invalid_backend_url": "Буруу URL тохиргоо" + }, + "filename": { + "cookie_key_value_pairs": "Күүки", + "codegen": "{request_name}- код", + "graphql_response": "GraphQL-Хариу", + "lens": "{request_name}- хариулт", + "realtime_response": "Бодит цагийн хариу", + "response_interface": "Хариулт-Интерфэйс" + }, + "filter": { + "all": "Бүгд", + "none": "Байхгүй", + "starred": "Одтой" + }, + "folder": { + "created": "Хавтас үүсгэсэн", + "edit": "Хавтас засах", + "invalid_name": "Фолдерт нэр өгнө үү", + "name_length_insufficient": "Хавтасны нэр дор хаяж 3 тэмдэгт байх ёстой", + "new": "Шинэ хавтас", + "run": "Фолдер ажиллуулах", + "renamed": "Хавтасны нэрийг өөрчилсөн", + "sorted": "Фолдерыг эрэмбэлсэн" + }, + "graphql": { + "arguments": "Аргументууд", + "connection_switch_confirm": "Та хамгийн сүүлийн үеийн GraphQL төгсгөлтэй холбогдохыг хүсэж байна уу?", + "connection_error_http": "Сүлжээний алдааны улмаас GraphQL схемийг татаж авч чадсангүй.", + "connection_switch_new_url": "Таб руу шилжих нь таныг идэвхтэй GraphQL холболтоос салгах болно. Шинэ холболтын URL байна", + "connection_switch_url": "Та GraphQL-ийн төгсгөлийн цэгт холбогдсон байна, холболтын URL нь байна", + "deprecated": "Хаагдсан", + "fields": "Талбайнууд", + "mutation": "Мутаци", + "mutations": "Мутаци", + "schema": "Схем", + "show_depricated_values": "Хуучин утгыг харуулах", + "subscription": "Захиалга", + "subscriptions": "Захиалга", + "switch_connection": "Холболтыг солих", + "url_placeholder": "GraphQL төгсгөлийн URL-г оруулна уу", + "query": "Асуулга" + }, + "graphql_collections": { + "title": "GraphQL цуглуулгууд" + }, + "group": { + "time": "Цаг хугацаа", + "url": "URL" + }, + "header": { + "install_pwa": "Програмыг суулгана уу", + "login": "Нэвтрэх", + "save_workspace": "Миний ажлын талбарыг хадгалах" + }, + "helpers": { + "authorization": "Таныг хүсэлт илгээх үед зөвшөөрлийн гарчиг автоматаар үүсгэгдэнэ.", + "collection_properties_authorization": "Энэхүү зөвшөөрлийг энэ цуглуулгын хүсэлт бүрт тохируулна.", + "collection_properties_header": "Энэ цуглуулгын хүсэлт бүрт энэ толгойг тохируулах болно.", + "collection_properties_scripts": "Эдгээр скриптүүд нь энэ цуглуулгын хүсэлт бүрт ажиллах болно. Урьдчилсан хүсэлтийн скриптүүд хүсэлтийн өмнө, тестийн скриптүүд хариултын дараа ажиллана.", + "generate_documentation_first": "Эхлээд баримт бичгийг бүрдүүлэх", + "network_fail": "API төгсгөлийн цэгт хүрч чадсангүй. Сүлжээний холболтоо шалгах эсвэл өөр Interceptor сонгоод дахин оролдоно уу.", + "offline": "Та офлайнаар Hoppscotch ашиглаж байна. Ажлын байрны тохиргоонд тулгуурлан таныг онлайн байх үед шинэчлэлтүүд синк хийнэ.", + "offline_short": "Та офлайнаар Hoppscotch ашиглаж байна.", + "post_request_tests": "Хүсэлтийн дараах скриптүүд нь JavaScript дээр бичигдсэн бөгөөд хариуг хүлээн авсны дараа ажиллуулдаг.", + "pre_request_script": "Урьдчилсан хүсэлтийн скриптүүд нь JavaScript дээр бичигдсэн бөгөөд хүсэлтийг илгээхээс өмнө ажиллуулдаг.", + "script_fail": "Урьдчилсан хүсэлтийн скриптэд алдаа гарсан бололтой. Доорх алдааг шалгаад скриптийг тохируулна уу.", + "post_request_script_fail": "Хүсэлтийн дараах скриптэд алдаа гарсан бололтой. Алдааг засаад дахин туршилт явуулна уу", + "post_request_script": "Дибаг хийхийг автоматжуулахын тулд хүсэлтийн дараах скрипт бичнэ үү." + }, + "hide": { + "collection": "Цуглуулгын самбарыг буулгах", + "more": "Илүү ихийг нуу", + "preview": "Урьдчилан харахыг нуух", + "sidebar": "Хажуугийн самбарыг буулгах", + "password": "Нууц үгээ нуух" + }, + "import": { + "collections": "Цуглуулга импортлох", + "curl": "cURL импортлох", + "environments_from_gist": "Gist-ээс импортлох", + "environments_from_gist_description": "Gist-ээс Hoppscotch орчныг импортлох", + "failed": "Импортлох явцад гарсан алдаа: форматыг танихгүй байна", + "from_file": "Файлаас импортлох", + "from_gist": "Gist-ээс импортлох", + "from_gist_description": "Gist URL-аас импортлох", + "from_gist_import_summary": "Хоппскочны бүх функцийг импортоор оруулсан болно.", + "from_hoppscotch_importer_summary": "Хоппскочны бүх функцийг импортоор оруулсан болно.", + "from_insomnia": "Нойргүйдэлээс импортлох", + "from_insomnia_description": "Insomnia цуглуулгаас импортлох", + "from_insomnia_import_summary": "Цуглуулга болон хүсэлтийг импортлох болно.", + "from_json": "Хоппскочоос импортлох", + "from_json_description": "Hoppscotch цуглуулгын файлаас импортлох", + "from_my_collections": "Хувийн цуглуулгаас импортлох", + "from_my_collections_description": "Хувийн цуглуулгын файлаас импортлох", + "from_all_collections": "Өөр ажлын талбараас импорт хийх", + "from_all_collections_description": "Өөр ажлын талбараас аливаа цуглуулгыг одоогийн ажлын талбарт импортлох", + "from_openapi": "OpenAPI-аас импортлох", + "from_openapi_description": "OpenAPI тодорхойлолтын файлаас импорт хийх (YML/JSON)", + "from_openapi_import_summary": "Цуглуулга (шошгуудаас бий болно), Хүсэлт болон хариултын жишээг импортлох болно.", + "from_postman": "Шууданчаас импортлох", + "from_postman_description": "Шуудангийн цуглуулгаас импортлох", + "from_postman_import_summary": "Цуглуулга, хүсэлт, хариултын жишээг импортлох болно.", + "import_scripts": "Скриптүүдийг импортлох", + "import_scripts_description": "Postman Collection v2.0/v2.1-ийг дэмждэг.", + "from_url": "URL-аас импортлох", + "gist_url": "Gist URL-г оруулна уу", + "from_har": "HAR-аас импортлох", + "from_har_description": "HAR файлаас импортлох", + "from_har_import_summary": "Хүсэлтийг өгөгдмөл цуглуулгад импортлох болно.", + "gql_collections_from_gist_description": "Gist-ээс GraphQL цуглуулгуудыг импортлох", + "hoppscotch_environment": "Хоппскотийн орчин", + "hoppscotch_environment_description": "Hoppscotch Environment JSON файлыг импортлох", + "import_from_url_invalid_fetch": "URL-аас өгөгдөл авч чадсангүй", + "import_from_url_invalid_file_format": "Цуглуулга импортлох үед алдаа гарлаа", + "import_from_url_invalid_type": "Дэмжигдээгүй төрөл. хүлээн зөвшөөрөгдсөн үнэ цэнэ нь \"хоппскоч\", \"openapi\", \"шуудан зөөгч\", \"нойргүйдэл\"", + "import_from_url_success": "Импортолсон цуглуулгууд", + "insomnia_environment_description": "JSON/YAML файлаас нойргүйдлийн орчныг импортлох", + "json_description": "Hoppscotch Collections JSON файлаас цуглуулгуудыг импортлох", + "postman_environment": "Шуудангийн орчин", + "postman_environment_description": "JSON файлаас шуудангийн орчныг импортлох", + "title": "Импорт", + "file_size_limit_exceeded_warning_multiple_files": "Сонгосон файлууд нь санал болгож буй {sizeLimit}MB хязгаараас хэтэрсэн байна. Зөвхөн эхний сонгосон {files}-г импортлох болно", + "file_size_limit_exceeded_warning_single_file": "Одоогоор сонгосон файл нь санал болгож буй {sizeLimit}MB хязгаараас хэтэрсэн байна. Өөр файл сонгоно уу.", + "success": "Амжилттай оруулж ирлээ", + "import_summary_collections_title": "Цуглуулга", + "import_summary_requests_title": "Хүсэлтүүд", + "import_summary_responses_title": "Хариултууд", + "import_summary_pre_request_scripts_title": "Скриптүүдийг урьдчилан хүсэх", + "import_summary_post_request_scripts_title": "Хүсэлтийн скриптүүдийг нийтлэх", + "import_summary_not_supported_by_hoppscotch_import": "Бид яг одоо энэ эх сурвалжаас {featureLabel} импортлохыг дэмжихгүй байна.", + "import_summary_script_found": "скрипт олдсон боловч импортлоогүй", + "import_summary_scripts_found": "скриптүүд олдсон боловч импортлогдоогүй", + "import_summary_enable_experimental_sandbox": "Postman скриптүүдийг импортлохын тулд тохиргооноос \"Туршилтын скриптийн хамгаалагдсан хязгаарлагдмал орчин\"-г идэвхжүүлнэ үү. Тайлбар: Энэ функц нь туршилтын шинж чанартай.", + "cors_error_modal": { + "title": "CORS алдаа илэрсэн", + "description": "Серверээс тавьсан CORS (Гарал үүслийн эх сурвалжийг хуваалцах) хязгаарлалтын улмаас импорт амжилтгүй болсон.", + "explanation": "Энэ нь вэб хуудсууд өөр өөр домэйнд хүсэлт гаргахаас сэргийлдэг хамгаалалтын функц юм. Та энэ хязгаарлалтыг давахын тулд манай прокси үйлчилгээг ашиглан дахин оролдох боломжтой.", + "url_label": "Оролдсон URL", + "retry_with_proxy": "Прокси ашиглан дахин оролдоно уу" + } + }, + "instances": { + "switch": "Hoppscotch Instance солих", + "enter_server_url": "Өөрөө байршуулсан инстанттай холбогдоно уу", + "already_connected": "Та энэ жишээнд аль хэдийн холбогдсон байна", + "recent_connections": "Сүүлийн үеийн холболтууд", + "add_instance": "Жишээ нэмнэ үү", + "add_new": "Шинэ жишээ нэмнэ үү", + "confirm_remove": "Устгасныг баталгаажуулна уу", + "remove_warning": "Та энэ жишээг устгахдаа итгэлтэй байна уу?", + "clear_cached_bundles": "Кэш хийсэн багцуудыг арилгах", + "opening_add_modal": "Жишээ нэмэх харилцах цонхыг нээж байна", + "closed_add_modal": "Жишээ нэмэх харилцах цонх хаагдсан", + "cancelled_removal": "Нэгэн жишээг устгахыг цуцалсан", + "connection_cancelled": "Урьдчилан холболтын баталгаажуулалтаар холболтыг цуцалсан", + "post_connect_completed": "Холболтын дараах тохируулга дууссан", + "connecting": "Жишээ рүү холбогдож байна...", + "confirm_removal": "Жишээ устгасныг баталгаажуулна уу", + "removal_cancelled": "Устгах өмнөх баталгаажуулалтаар жишээ устгалыг цуцалсан", + "post_remove_completed": "Устгасны дараах цэвэрлэгээ дууссан", + "removing": "Жишээ устгаж байна...", + "clearing_cache": "Кэшийг цэвэрлэж байна...", + "initialized": "Инстанц шилжүүлэгчийг эхлүүлсэн", + "connecting_state": "Холболт үүсгэж байна...", + "connected_state": "Жишээнд амжилттай холбогдсон", + "disconnected_state": "Жишээнээс салсан", + "stream_error": "Холболтын төлөвийн хяналт амжилтгүй боллоо", + "recent_instances_error": "Сүүлийн тохиолдлуудыг ачаалж чадсангүй", + "instance_changed": "Жишээ рүү шилжсэн", + "current_instance_error": "Одоогийн жишээг хянаж чадсангүй", + "not_available": "Жишээ солих боломжгүй", + "cleanup_completed": "Инстанс шилжүүлэгчийн цэвэрлэгээ дууссан", + "self_hosted": "Өөрөө байршуулсан тохиолдлууд" + }, + "inspections": { + "description": "Боломжит алдааг шалгана уу", + "environment": { + "add_environment": "Байгаль орчинд нэмэх", + "add_environment_value": "Үнэ цэнэ нэмэх", + "empty_value": "'{variable}' хувьсагчийн орчны утга хоосон байна", + "not_found": "'{environment}' орчны хувьсагч олдсонгүй." + }, + "header": { + "cookie": "Хөтөч нь Hoppscotch-д күүки толгойг тохируулахыг зөвшөөрдөггүй. Оронд нь Зөвшөөрлийн толгой хэсгийг ашиглана уу. Гэсэн хэдий ч манай Hoppscotch Desktop App одоо ажиллаж байгаа бөгөөд күүки дэмждэг." + }, + "response": { + "401_error": "Баталгаажуулах үнэмлэхээ шалгана уу.", + "404_error": "Хүсэлтийн URL болон аргын төрлийг шалгана уу.", + "cors_error": "Эх сурвалж хоорондын нөөц хуваалцах тохиргоогоо шалгана уу.", + "default_error": "Хүсэлтээ шалгана уу.", + "network_error": "Сүлжээний холболтоо шалгана уу." + }, + "title": "Байцаагч", + "url": { + "extension_not_installed": "Өргөтгөлийг суулгаагүй байна.", + "extension_unknown_origin": "Та API төгсгөлийн цэгийн гарал үүслийг Hoppscotch Browser Extension жагсаалтад нэмсэн эсэхээ шалгаарай.", + "extention_enable_action": "Хөтөчийн өргөтгөлийг идэвхжүүлнэ үү", + "extention_not_enabled": "Өргөтгөлийг идэвхжүүлээгүй.", + "localaccess_unsupported": "Одоогийн саад тотгор нь орон нутгийн хандалтыг дэмждэггүй тул Agent, Extension interceptors эсвэл Desktop App ашиглана уу." + }, + "auth": { + "digest": "Digest Authorization-ийг ашиглах үед вэб апп дээрх агент таслан зогсоох хэрэгсэл эсвэл ширээний программ дээрх Native interceptor ашиглахыг зөвлөж байна.", + "hawk": "Hawk Authorization-г ашиглах үед вэб програм дээрх агент таслан зогсоох хэрэгсэл эсвэл Ширээний програм дээрх Native interceptor ашиглахыг зөвлөж байна." + }, + "body": { + "binary": "Одоогийн таслагчаар хоёртын өгөгдөл илгээхийг хараахан дэмжээгүй байна." + }, + "scripting_interceptor": { + "pre_request": "урьдчилсан хүсэлтийн скрипт", + "post_request": "хүсэлтийн дараах скрипт", + "both_scripts": "хүсэлтийн өмнөх болон дараах скриптүүд", + "unsupported_interceptor": "Таны {scriptType} нь {apiUsed} ашигладаг. Скриптийг найдвартай гүйцэтгэхийн тулд Agent interceptor (вэб програм) эсвэл Native interceptor (Desktop app) руу шилжинэ үү.{interceptor} саатуулагч нь скрипт бичих хүсэлтийг хязгаарлагдмал дэмждэг бөгөөд санаснаар ажиллахгүй байж магадгүй.", + "same_origin_csrf_warning": "Аюулгүй байдлын сэрэмжлүүлэг: Таны {scriptType} нь {apiUsed} ашиглан ижил төрлийн хүсэлт гаргадаг. Энэхүү платформ нь күүки дээр суурилсан баталгаажуулалтыг ашигладаг тул эдгээр хүсэлтүүд нь таны сесс күүкиг автоматаар агуулж, хортой скриптүүдэд зөвшөөрөлгүй үйлдэл хийхийг зөвшөөрдөг. Ижил гарал үүслийн хүсэлтүүдэд Agent interceptor-г ашиглах эсвэл зөвхөн итгэдэг скриптүүдээ ажиллуул." + } + }, + "interceptor": { + "native": { + "name": "Төрөлх", + "settings_title": "Төрөлх" + }, + "agent": { + "name": "Агент", + "settings_title": "Агент" + }, + "proxy": { + "name": "Прокси", + "settings_title": "Прокси" + }, + "browser": { + "name": "Хөтөч", + "settings_title": "Хөтөч" + }, + "extension": { + "name": "Өргөтгөл", + "settings_title": "Өргөтгөл" + } + }, + "layout": { + "collapse_collection": "Цуглуулгыг задлах эсвэл өргөжүүлэх", + "collapse_sidebar": "Хажуугийн самбарыг буулгах эсвэл өргөжүүлэх", + "column": "Босоо байрлал", + "name": "Зохион байгуулалт", + "row": "Хэвтээ байрлал" + }, + "modal": { + "close_unsaved_tab": "Танд хадгалагдаагүй өөрчлөлтүүд байна", + "collections": "Цуглуулга", + "confirm": "Баталгаажуулах", + "customize_request": "Хүсэлтийг тохируулах", + "edit_request": "Хүсэлтийг засах", + "edit_response": "Хариултыг засах", + "import_export": "Импорт / Экспорт", + "response_name": "Хариултын нэр", + "share_request": "Хүсэлт хуваалцах" + }, + "mqtt": { + "already_subscribed": "Та энэ сэдэвт аль хэдийн бүртгүүлсэн байна.", + "clean_session": "Цэвэр сесс", + "clear_input": "Оролтоо арилгах", + "clear_input_on_send": "Илгээх үед оруулгыг арилгах", + "client_id": "Үйлчлүүлэгчийн ID", + "color": "Өнгө сонгоно уу", + "communication": "Харилцаа холбоо", + "connection_config": "Холболтын тохиргоо", + "connection_not_authorized": "Энэ MQTT холболт нь ямар ч баталгаажуулалт ашигладаггүй.", + "invalid_topic": "Захиалгын сэдвийг оруулна уу", + "keep_alive": "Амьд байлга", + "log": "Бүртгэл", + "lw_message": "Эцсийн хүслийн зурвас", + "lw_qos": "Сүүлчийн хүсэл эрмэлзэлийн үйлчилгээний чанар", + "lw_retain": "Сүүлд хадгалагдах болно", + "lw_topic": "Эцсийн хүсэл сэдэв", + "message": "Мессеж", + "new": "Шинэ захиалга", + "not_connected": "Эхлээд MQTT холболтыг эхлүүлнэ үү.", + "publish": "Нийтлэх", + "qos": "QS", + "ssl": "SSL", + "subscribe": "Бүртгүүлэх", + "topic": "Сэдэв", + "topic_name": "Сэдвийн нэр", + "topic_title": "Сэдвийг нийтлэх / захиалах", + "unsubscribe": "Бүртгэлээ цуцлах", + "url": "URL" + }, + "navigation": { + "admin_dashboard": "Хяналтын самбар", + "doc": "Докс", + "graphql": "GraphQL", + "profile": "Профайл", + "realtime": "Бодит цаг", + "rest": "АМРАХ", + "mock_servers": "Хуурамч серверүүд", + "settings": "Тохиргоо", + "goto_app": "Програм руу очно уу", + "authentication": "Баталгаажуулалт" + }, + "mock_server": { + "confirm_delete_log": "Та энэ бүртгэлийг устгахдаа итгэлтэй байна уу?", + "create_mock_server": "Хуурамч серверийг тохируулах", + "mock_server_configuration": "Хуурамч серверийн тохиргоо", + "mock_server_name": "Хуурамч серверийн нэр", + "mock_server_name_placeholder": "Хуурамч серверийн нэрийг оруулна уу", + "base_url": "Үндсэн URL", + "start_server": "Серверийг эхлүүлэх", + "stop_server": "Серверийг зогсоох", + "mock_server_created": "Хуурамч серверийг амжилттай үүсгэсэн", + "mock_server_started": "Хуурамч сервер амжилттай эхэлсэн", + "mock_server_stopped": "Хуурамч сервер амжилттай зогслоо", + "active": "Хуурамч сервер идэвхтэй байна", + "inactive": "Хуурамч сервер идэвхгүй байна", + "edit_mock_server": "Хуурамч серверийг засах", + "path_based_url": "Зам дээр суурилсан URL", + "subdomain_based_url": "Дэд домайн дээр суурилсан URL", + "mock_server_updated": "Хуурамч серверийг амжилттай шинэчилсэн", + "no_collection": "Цуглуулга байхгүй", + "collection_deleted": "холбоотой цуглуулгыг устгасан.", + "private_access_hint": "Хувийн хуурамч серверүүдийн хувьд 'x-api-key' толгойг Хувийн хандалтын тэмдэгтээ оруулна уу (профайлаасаа нэгийг үүсгэнэ үү).", + "private_access_instruction": "Энэхүү хувийн хуурамч серверт хандахын тулд \"x-api-key\" толгой хэсгийг хувийн хандалтын токендоо оруулна уу.", + "create_token_here": "Энд үүсгэнэ үү", + "status": "Статус", + "server_running": "Сервер ажиллаж байна", + "server_stopped": "Сервер зогссон байна", + "delay_ms": "Хариу өгөх саатал (мс)", + "delay_placeholder": "Сааталыг миллисекундээр оруулна уу", + "delay_description": "Хуурамч хариулт өгөхийн тулд зохиомол саатал нэмнэ үү", + "public_access": "Нийтийн хүртээмж", + "public": "Олон нийтийн", + "private": "Хувийн", + "public_description": "URL-тай хэн ч энэ хуурамч серверт хандах боломжтой", + "private_description": "Зөвхөн баталгаажсан хэрэглэгчид энэ хуурамч серверт хандах боломжтой", + "select_collection": "Цуглуулга сонгоно уу", + "select_collection_error": "Цуглуулга сонгоно уу", + "invalid_collection_error": "Цуглуулгын хуурамч сервер үүсгэж чадсангүй.", + "url_copied": "URL-г санах ой руу хуулсан", + "make_public": "Олон нийтэд нээлттэй болгох", + "view_logs": "Бүртгэлүүдийг үзэх", + "logs_title": "Хуурамч серверийн бүртгэл", + "no_logs": "Лог байхгүй байна", + "request_headers": "Хүсэлтийн толгой", + "request_body": "Хүсэлтийн байгууллага", + "response_status": "Хариу үйлдлийн төлөв", + "response_headers": "Хариултын толгой", + "response_body": "Хариу өгөх байгууллага", + "log_deleted": "Бүртгэлийг амжилттай устгалаа", + "description": "Хуурамч серверүүд нь цуглуулгын жишээ хариултууд дээр үндэслэн API хариултуудыг дуурайлган хийх боломжийг олгодог.", + "set_in_environment": "Хүрээлэн буй орчинд тохируулна", + "set_in_environment_hint": "Хуурамч серверийн URL нь цуглуулгын орчинд автоматаар \"mockUrl\" хувьсагчаар нэмэгдэх болно", + "environment_variable_added": "Хүрээлэн буй орчинд хуурамч URL нэмсэн", + "environment_variable_updated": "Хуурамч URL орчинд шинэчлэгдсэн", + "environment_created_with_variable": "Хуурамч URL-аар үүсгэсэн орчин", + "add_example_request": "Жишээ хүсэлт нэмнэ үү", + "add_example_request_hint": "Цуглуулга нь хуурамч серверийг хэрхэн ашиглахыг харуулсан жишээ хүсэлтээр үүсгэгдэнэ", + "create_example_collection": "Жишээ цуглуулга үүсгэх", + "create_example_collection_hint": "Амьтны дэлгүүрийн жишээ цуглуулгыг жишээ хүсэлтээр үүсгэх (GET, POST, PUT, DELETE)", + "creating_example_collection": "Жишээ цуглуулгыг үүсгэж байна...", + "failed_to_create_collection": "Жишээ цуглуулгыг үүсгэж чадсангүй", + "enable_example_collection_hint": "Шинэ цуглуулгын горимд \"Жишээ цуглуулга үүсгэх\"-г идэвхжүүлнэ үү", + "new_collection_name_hint": "Цуглуулга нь таны хуурамч сервертэй ижил нэрээр үүсгэгдэх болно", + "existing_collection": "Одоо байгаа цуглуулга", + "new_collection": "Шинэ цуглуулга" + }, + "preRequest": { + "javascript_code": "JavaScript код", + "learn": "Баримт бичгийг уншина уу", + "script": "Урьдчилсан хүсэлтийн скрипт", + "snippets": "Хэсэг хэсгүүд" + }, + "profile": { + "app_settings": "Програмын тохиргоо", + "default_hopp_displayname": "Нэргүй хэрэглэгч", + "editor": "Редактор", + "editor_description": "Засварлагчид хүсэлт нэмэх, засах, устгах боломжтой.", + "email_verification_mail": "Баталгаажуулах имэйлийг таны имэйл хаяг руу илгээсэн. Холбоос дээр дарж имэйл хаягаа баталгаажуулна уу.", + "no_permission": "Танд энэ үйлдлийг хийх зөвшөөрөл байхгүй байна.", + "owner": "Эзэмшигч", + "owner_description": "Эзэмшигчид хүсэлт, цуглуулга, ажлын талбарын гишүүдийг нэмэх, засах, устгах боломжтой.", + "roles": "Дүрүүд", + "roles_description": "Хуваалцсан цуглуулгад хандах хандалтыг хянахын тулд дүрүүдийг ашигладаг.", + "updated": "Профайл шинэчлэгдсэн", + "viewer": "Үзэгч", + "viewer_description": "Үзэгчид зөвхөн хүсэлтийг үзэж, ашиглах боломжтой.", + "verified_email_sent": "Баталгаажуулах имэйлийг таны имэйл хаяг руу илгээсэн. Имэйл хаягаа баталгаажуулсны дараа хуудсыг дахин сэргээнэ үү. Хэрэв энэ имэйл өөр бүртгэлтэй холбоогүй бол та имэйл хүлээн авах болно." + }, + "remove": { + "star": "Од арилгах" + }, + "request": { + "added": "Хүсэлт нэмсэн", + "add": "Хүсэлт нэмэх", + "authorization": "Зөвшөөрөл", + "body": "Хүсэлтийн байгууллага", + "choose_language": "Хэл сонгоно уу", + "content_type": "Агуулгын төрөл", + "content_type_titles": { + "others": "Бусад", + "structured": "Бүтэцтэй", + "text": "Текст", + "binary": "Хоёртын" + }, + "show_content_type": "Агуулгын төрлийг харуулах", + "different_collection": "Өөр цуглуулгуудын хүсэлтийг дахин эрэмбэлэх боломжгүй", + "duplicated": "Хүсэлт давхардсан", + "duration": "Үргэлжлэх хугацаа", + "enter_curl": "cURL командыг оруулна уу", + "generate_code": "Код үүсгэх", + "generated_code": "Үүсгэсэн код", + "go_to_authorization_tab": "Зөвшөөрлийн таб руу очно уу", + "go_to_body_tab": "Биеийн таб руу очно уу", + "header_list": "Толгойн жагсаалт", + "invalid_name": "Хүсэлтийн нэрийг оруулна уу", + "method": "Арга", + "moved": "Хүсэлтийг шилжүүлсэн", + "name": "Нэрийг хүсэх", + "new": "Шинэ хүсэлт", + "order_changed": "Хүсэлтийн захиалгыг шинэчилсэн", + "override": "Дарж бичих", + "override_help": "Толгой хэсэгтагуулгын төрлийгтохируулах", + "overriden": "Дарсан", + "parameter_list": "Асуулгын параметрүүд", + "parameters": "Параметрүүд", + "path": "Зам", + "payload": "Ачаалал", + "query": "Асуулга", + "raw_body": "Түүхий хүсэлтийн байгууллага", + "rename": "Хүсэлтийн нэрийг өөрчлөх", + "renamed": "Хүсэлтийн нэрийг өөрчилсөн", + "request_variables": "Хувьсагчийг хүсэх", + "response_name_exists": "Хариултын нэр аль хэдийн байна", + "run": "Гүй", + "save": "Хадгалах", + "save_as": "Хадгалах", + "saved": "Хүсэлтийг хадгалсан", + "share": "Хуваалцах", + "share_description": "Hoppscotch-ийг найзуудтайгаа хуваалцаарай", + "share_request": "Хүсэлт хуваалцах", + "stop": "Зогс", + "title": "Хүсэлт", + "type": "Хүсэлтийн төрөл", + "url": "URL", + "url_placeholder": "URL оруулна уу эсвэл cURL командыг буулгана уу", + "variables": "Хувьсагч", + "view_my_links": "Миний холбоосуудыг үзэх", + "generate_name_error": "Хүсэлтийн нэрийг үүсгэж чадсангүй." + }, + "response": { + "audio": "Аудио", + "body": "Хариу өгөх байгууллага", + "duplicated": "Хариултыг давхардсан", + "duplicate_name_error": "Ижил нэртэй хариулт аль хэдийн байна", + "filter_response_body": "JSON хариултын хэсгийг шүүх (jq синтакс ашигладаг)", + "headers": "Гарчиг", + "request_headers": "Хүсэлтийн толгой", + "html": "HTML", + "image": "Зураг", + "json": "JSON", + "pdf": "PDF", + "please_save_request": "Жишээ үүсгэх хүсэлтийг хадгална уу", + "preview_html": "HTML-г урьдчилан үзэх", + "raw": "Түүхий", + "renamed": "Хариултын нэрийг өөрчилсөн", + "same_name_inspector_warning": "Энэ хүсэлтийн хариуны нэр аль хэдийн байгаа бөгөөд хадгалсан бол одоо байгаа хариултыг дарж бичих болно", + "size": "Хэмжээ", + "status": "Статус", + "time": "Цаг хугацаа", + "title": "Хариулт", + "video": "Видео", + "waiting_for_connection": "холболт хүлээж байна", + "xml": "XML", + "generate_data_schema": "Өгөгдлийн схем үүсгэх", + "data_schema": "Өгөгдлийн схем", + "saved": "Хариултыг хадгалсан", + "invalid_name": "Хариуд нь нэр өгнө үү" + }, + "script": { + "inheriting": "-аас скриптүүдийг өвлөн авч байна", + "inheriting_from_count": "{count} цуглуулгаас өвлөн авч байна |{count} цуглуулгаас өвлөн авч байна", + "inherited_scripts": "Өвлөгдсөн скриптүүд", + "view_inherited": "Өвлөгдсөн скриптүүдийг үзэх" + }, + "settings": { + "accent_color": "Өргөлтийн өнгө", + "account": "Данс", + "account_deleted": "Таны бүртгэл устсан", + "account_description": "Бүртгэлийнхээ тохиргоог өөрчил.", + "account_email_description": "Таны үндсэн имэйл хаяг.", + "account_name_description": "Энэ бол таны дэлгэцийн нэр юм.", + "additional": "Нэмэлт тохиргоо", + "agent_not_running": "Hoppscotch Agent илрээгүй. Агент ажиллаж байгаа эсэхийг шалгана уу.", + "agent_not_running_short": "Агентын статусыг шалгана уу.", + "agent_running": "Hoppscotch Agent шууд нэвтрүүлж байна.", + "agent_running_short": "Hoppscotch Agent шууд нэвтрүүлж байна.", + "agent_discard_registration": "Агентын бүртгэлээс татгалзах", + "agent_registered": "Агент бүртгэгдсэн", + "agent_registration_successful": "Агентыг амжилттай бүртгүүлэв", + "agent_registration_fetch_failed": "Агентын бүртгэлийн мэдээллийг авч чадсангүй. Агентыг дахин бүртгүүлнэ үү.", + "agent_registration_already_in_progress": "Агентын бүртгэл аль хэдийн явагдаж байна. Одоогийн бүртгэлийг дуусгах эсвэл цуцлаад дахин оролдоно уу.", + "auto_encode_mode": "Автомат", + "auto_encode_mode_tooltip": "Зөвхөн зарим тусгай тэмдэгтүүд байгаа тохиолдолд хүсэлтийн параметрүүдийг кодчил", + "background": "Суурь", + "black_mode": "Хар", + "choose_language": "Хэл сонгоно уу", + "dark_mode": "Харанхуй", + "delete_account": "Бүртгэл устгах", + "delete_account_description": "Бүртгэлээ устгасны дараа таны бүх өгөгдөл бүрмөсөн устах болно. Энэ үйлдлийг буцаах боломжгүй.", + "desktop": "Ширээний компьютер", + "desktop_description": "Hoppscotch ширээний програмын зан байдал, гарны ашиглалтыг шинэчилнэ үү.", + "desktop_keyboard": "Гар", + "desktop_keyboard_strategy_label": "Товчлолыг бичсэн үсэг эсвэл физик байрлалаар нь тааруулна уу", + "desktop_keyboard_strategy_description": "QWERTY бус загвар дээр ижил үсэг өөр өөр физик түлхүүрээс ирж болно. Өгөгдмөл нь ихэнх байршилд ажилладаг; Хэрэв товчлол таны санаснаар ажиллахгүй бол сонголтуудыг сэлгэнэ үү.", + "desktop_keyboard_strategy_hybrid": "Ухаалаг (санал болгосон)", + "desktop_keyboard_strategy_hybrid_description": "Латин үсгээр бичсэн үсгийг ашиглах; Латин бус байршлын (Кирилл, CJK) физик түлхүүрийн байрлал руу буцах.", + "desktop_keyboard_strategy_key": "Бичсэн үсэг", + "desktop_keyboard_strategy_key_description": "Бичсэн үсгийг үргэлж ашигла. Хэрэв товчлолууд таны зохион байгуулалтад санаснаар ажиллахгүй байвал үүнийг сонгоно уу.", + "desktop_keyboard_strategy_code": "Физик гол байрлал", + "desktop_keyboard_strategy_code_description": "АНУ-QWERTY биеийн байрлалыг үргэлж ашиглаарай. Хэрэв та латин бус загвар дээр QWERTY булчингийн санах ойтой бол үүнийг сонгоорой.", + "desktop_updates": "Шинэчлэлтүүд", + "disable_encode_mode_tooltip": "Хүсэлт дэх параметрүүдийг хэзээ ч кодлох хэрэггүй", + "disable_update_checks": "Автомат шинэчлэлтийн шалгалтыг идэвхгүй болгох", + "disable_update_checks_description": "Програмыг эхлүүлэх үед шинэчлэлтийн шалгалтыг алгасах. Хүсэлтийг шалгахын тулд дээрх товчлуурыг ашиглана уу.", + "enable_encode_mode_tooltip": "Хүсэлт дэх параметрүүдийг үргэлж кодчил", + "enter_otp": "Агентын кодыг оруулна уу", + "expand_navigation": "Навигацыг өргөжүүлэх", + "experiments": "Туршилтууд", + "experiments_notice": "Энэ бол бидний ажиллаж байгаа туршилтуудын цуглуулга бөгөөд ашигтай, хөгжилтэй, хоёулаа эсвэл аль нь ч биш байж магадгүй юм. Эдгээр нь эцсийнх биш бөгөөд тогтвортой биш байж магадгүй тул хэт хачирхалтай зүйл тохиолдвол сандрах хэрэггүй. Аюултай зүйлийг унтраа. Хошигнолын хажуугаар,", + "extension_ver_not_reported": "Мэдээгүй", + "extension_version": "Өргөтгөлийн хувилбар", + "extensions": "Хөтөчийн өргөтгөл", + "extensions_use_toggle": "Хүсэлт илгээхийн тулд хөтчийн өргөтгөлийг ашиглана уу (хэрэв байгаа бол)", + "follow": "Биднийг дага", + "general": "Генерал", + "general_description": "Програмд ашигласан ерөнхий тохиргоо", + "interceptor": "Таслагч", + "interceptor_description": "Аппликешн болон API хоорондын дунд програм.", + "kernel_interceptor": "Таслагч", + "kernel_interceptor_description": "Аппликешн болон API хоорондын дунд програм.", + "language": "Хэл", + "light_mode": "Гэрэл", + "official_proxy_hosting": "Албан ёсны проксиг Hoppscotch зохион байгуулдаг.", + "query_parameters_encoding": "Асуулгын параметрийн кодчилол", + "query_parameters_encoding_description": "Хүсэлт дэх асуулгын параметрүүдийн кодчилолыг тохируулах", + "profile": "Профайл", + "profile_description": "Профайлынхаа дэлгэрэнгүй мэдээллийг шинэчилнэ үү", + "profile_email": "Имэйл хаяг", + "profile_name": "Профайлын нэр", + "profile_photo": "Профайл зураг", + "proxy": "Прокси", + "proxy_url": "Прокси URL", + "proxy_use_toggle": "Хүсэлт илгээхийн тулд прокси дунд програмыг ашиглана уу", + "read_the": "-г уншина уу", + "register_agent": "Агент бүртгүүлэх", + "reset_default": "Өгөгдмөл прокси ашиглах", + "short_codes": "Богино кодууд", + "short_codes_description": "Таны үүсгэсэн богино кодууд.", + "sidebar_on_left": "Зүүн талд байгаа хажуугийн самбар", + "ai_experiments": "AI туршилтууд", + "ai_request_naming_style": "Хүсэлтийн нэрийн хэв маяг", + "ai_request_naming_style_descriptive_with_spaces": "Зайгаар дүрсэлсэн", + "ai_request_naming_style_camel_case": "Тэмээний хайрцаг (тэмээний хайрцаг)", + "ai_request_naming_style_snake_case": "Могойн хайрцаг ( могойн хэрэг )", + "ai_request_naming_style_pascal_case": "Паскаль Кейс (Pascal Case)", + "ai_request_naming_style_custom": "Захиалгат", + "ai_request_naming_style_custom_placeholder": "Өөрийн нэрийн загварын загвараа оруулна уу...", + "experimental_scripting_sandbox": "Туршилтын скриптийн хамгаалагдсан хязгаарлагдмал орчин", + "enable_experimental_mock_servers": "Хуурамч серверүүдийг идэвхжүүл", + "enable_experimental_documentation": "Баримт бичгийг идэвхжүүлэх", + "sync": "Синхрончлох", + "sync_collections": "Цуглуулга", + "sync_description": "Эдгээр тохиргоог үүл рүү синк хийсэн.", + "sync_environments": "Байгаль орчин", + "sync_history": "Түүх", + "history_disabled": "Түүхийг идэвхгүй болгосон. Түүхийг идэвхжүүлэхийн тулд байгууллагын админтай холбогдоно уу", + "system_mode": "Систем", + "telemetry": "Телеметр", + "telemetry_helps_us": "Телеметр нь үйл ажиллагаагаа хувийн болгож, танд хамгийн сайн туршлагыг хүргэхэд бидэнд тусалдаг.", + "theme": "Сэдэв", + "theme_description": "Өөрийн хэрэглээний сэдвийг тохируулна уу.", + "update_check_description": "Гараар шинэ хувилбар байгаа эсэхийг шалгаад суулгана уу. Доорх шилжүүлэгчээс үл хамааран ажиллана.", + "update_check_now": "Шинэчлэлт байгаа эсэхийг шалгана уу", + "update_checking": "Шалгаж байна...", + "update_download_version": "v{version} татаж авах", + "update_downloading": "Татаж авч байна…", + "update_downloading_percent": "Татаж авч байна {percent}%", + "update_installing": "Суулгаж байна...", + "update_restart_now": "Шинэчлэлтийг хэрэгжүүлэхийн тулд дахин эхлүүлнэ үү", + "update_up_to_date": "Шинэчлэгдсэн", + "use_experimental_url_bar": "Орчноо тодруулсан туршилтын URL мөрийг ашиглана уу", + "user": "Хэрэглэгч", + "verified_email": "Баталгаажсан имэйл", + "verify_email": "Имэйлийг баталгаажуулна уу", + "validate_certificates": "SSL/TLS гэрчилгээг баталгаажуулах", + "verify_host": "Хостыг баталгаажуулах", + "verify_peer": "Peer-г баталгаажуулах", + "follow_redirects": "Дахин чиглүүлэлтүүдийг дагах", + "client_certificates": "Үйлчлүүлэгчийн гэрчилгээ", + "certificate_settings": "Сертификат тохиргоо", + "certificate": "Сертификат", + "key": "Хувийн түлхүүр", + "pfx_or_p12": "PFX/PKCS#12", + "password": "Нууц үг", + "select_file": "Файлыг сонгоно уу", + "domain": "Домэйн", + "add_certificate": "Сертификат нэмэх", + "add_cert_file": "Сертификат файл нэмэх", + "add_key_file": "Түлхүүр файл нэмэх", + "add_pfx_file": "PFX файл нэмнэ үү", + "global_defaults": "Глобал өгөгдмөл", + "add_domain_override": "Домэйн хүчингүй болгохыг нэмнэ үү", + "domain_override": "Домэйн хүчингүй болгох", + "manage_domains_overrides": "Домэйн даралтыг удирдах", + "add_domain": "Домэйн нэмэх", + "remove_domain": "Домэйн устгах", + "ca_certificate": "CA гэрчилгээ", + "ca_certificates": "CA гэрчилгээ", + "ca_certificates_support": "Hoppscotch нь нэг буюу хэд хэдэн гэрчилгээ агуулсан .crt, .cer эсвэл .pem файлуудыг дэмждэг.", + "proxy_capabilities": "Hoppscotch Agent болон Desktop App нь NTLM болон Basic Auth дэмжлэг бүхий HTTP/HTTPS/SOCKS прокси дэмждэг.", + "proxy_auth": "Та мөн URL-д хэрэглэгчийн нэр, нууц үг оруулах боломжтой." + }, + "shared_requests": { + "button": "Товчлуур", + "button_info": "Вэбсайт, блог эсвэл README-д зориулж \"Run in Hoppscotch\" товчийг үүсгээрэй.", + "copy_html": "HTML хуулах", + "copy_link": "Холбоосыг хуулах", + "copy_markdown": "Markdown хуулах", + "creating_widget": "Виджет үүсгэж байна", + "customize": "Тохируулах", + "deleted": "Хуваалцсан хүсэлтийг устгасан", + "description": "Виджет сонго, та үүнийг дараа нь өөрчилж, өөрчлөх боломжтой", + "embed": "Оруулсан", + "embed_info": "Өөрийн вэбсайт, блог эсвэл баримт бичигт мини \"Hoppscotch API Playground\" нэмнэ үү.", + "link": "Холбоос", + "link_info": "Хуваалцах боломжтой холбоос үүсгэн үзэх боломжтой интернетэд байгаа хэнтэй ч хуваалцах боломжтой.", + "modified": "Хуваалцсан хүсэлтийг өөрчилсөн", + "not_found": "Хуваалцсан хүсэлт олдсонгүй", + "open_new_tab": "Шинэ таб дээр нээх", + "preview": "Урьдчилан үзэх", + "run_in_hoppscotch": "Хоппскочоор гүйх", + "theme": { + "dark": "Харанхуй", + "light": "Гэрэл", + "system": "Систем", + "title": "Сэдэв" + }, + "action": "Үйлдэл", + "clear_filter": "Шүүлтүүрийг арилгах", + "confirm_request_deletion": "Сонгосон хуваалцсан хүсэлтийг устгахыг баталгаажуулах уу?", + "copy": "Хуулах", + "created_on": "Үүсгэсэн", + "delete": "Устгах", + "email": "Имэйл", + "filter": "Шүүлтүүр", + "filter_by_email": "Имэйлээр шүүнэ үү", + "id": "ID", + "load_list_error": "Хуваалцсан хүсэлтийн жагсаалтыг ачаалах боломжгүй", + "no_requests": "Хуваалцсан хүсэлт олдсонгүй", + "open_request": "Хүсэлтийг нээх", + "properties": "Үл хөдлөх хөрөнгө", + "request": "Хүсэлт", + "show_more": "Илүү ихийг харуулах", + "title": "Хуваалцсан хүсэлтүүд", + "url": "URL" + }, + "shortcut": { + "general": { + "close_current_menu": "Одоогийн цэсийг хаах", + "command_menu": "Хайлт ба тушаалын цэс", + "help_menu": "Тусламжийн цэс", + "show_all": "Гарын товчлолууд", + "title": "Генерал", + "comment_uncomment": "Сэтгэгдэл/ Тайлбарыг цуцлах", + "close_tab": "Табыг хаах", + "undo": "Буцаах", + "redo": "Дахин хий" + }, + "miscellaneous": { + "invite": "Хүмүүсийг Hoppscotch-д урь", + "title": "Төрөл бүрийн" + }, + "navigation": { + "back": "Өмнөх хуудас руу буцах", + "documentation": "Баримт бичгийн хуудас руу очно уу", + "forward": "Дараагийн хуудас руу шилжинэ үү", + "graphql": "GraphQL хуудас руу очно уу", + "profile": "Профайл хуудас руу очно уу", + "realtime": "Бодит цагийн хуудас руу очно уу", + "rest": "REST хуудас руу очно уу", + "settings": "Тохиргоо хуудас руу очно уу", + "title": "Навигац" + }, + "others": { + "prettify": "Редакторын агуулгыг сайхан болгох", + "title": "Бусад" + }, + "request": { + "delete_method": "УСТГАХ аргыг сонгоно уу", + "get_method": "GET аргыг сонгоно уу", + "head_method": "HEAD аргыг сонгоно уу", + "import_curl": "cURL импортлох", + "method": "Арга", + "next_method": "Дараагийн аргыг сонгоно уу", + "post_method": "POST аргыг сонгоно уу", + "previous_method": "Өмнөх аргыг сонгоно уу", + "put_method": "PUT аргыг сонгоно уу", + "rename": "Хүсэлтийн нэрийг өөрчлөх", + "reset_request": "Хүсэлтийг дахин тохируулах", + "save_request": "Хүсэлтийг хадгалах", + "save_to_collections": "Цуглуулгад хадгалах", + "send_request": "Хүсэлт илгээх", + "share_request": "Хүсэлт хуваалцах", + "show_code": "Кодын хэсэгчилэн үүсгэх", + "title": "Хүсэлт", + "focus_url": "URL мөрөнд анхаарлаа төвлөрүүл" + }, + "response": { + "copy": "Хариуг санах ойд хуулах", + "download": "Хариултыг файл болгон татаж авна уу", + "title": "Хариулт" + }, + "tabs": { + "title": "Таб", + "new_tab": "Шинэ таб", + "close_tab": "Табыг хаах", + "reopen_tab": "Хаалттай табыг дахин нээнэ үү", + "next_tab": "Дараагийн таб", + "previous_tab": "Өмнөх таб", + "first_tab": "Эхний таб руу шилжих", + "last_tab": "Сүүлийн таб руу шилжих", + "mru_switch": "Сүүлийн таб руу шилжих (MRU)", + "mru_switch_reverse": "Өмнөх сүүлийн таб руу шилжих (MRU)" + }, + "theme": { + "black": "Загварыг Хар горим руу шилжүүлнэ үү", + "dark": "Загварыг Харанхуй горим руу сэлгэх", + "light": "Загварыг Гэрэл горим руу сэлгэх", + "system": "Загварыг системийн горимд шилжүүлнэ үү", + "title": "Сэдэв" + } + }, + "show": { + "code": "Кодыг харуулах", + "collection": "Цуглуулгын самбарыг өргөжүүлэх", + "more": "Илүү ихийг харуулах", + "sidebar": "Хажуугийн самбарыг өргөжүүлэх", + "password": "Нууц үгээ харуулах" + }, + "socketio": { + "communication": "Харилцаа холбоо", + "connection_not_authorized": "Энэ SocketIO холболт нь ямар ч баталгаажуулалт ашигладаггүй.", + "event_name": "Үйл явдал/сэдвийн нэр", + "events": "Үйл явдал", + "log": "Бүртгэл", + "url": "URL" + }, + "spotlight": { + "change_language": "Хэлийг өөрчлөх", + "environments": { + "delete": "Одоогийн орчныг устгах", + "duplicate": "Одоогийн орчныг хуулбарлах", + "duplicate_global": "Давхардсан дэлхийн орчин", + "edit": "Одоогийн орчинг засах", + "edit_global": "Глобал орчныг засах", + "new": "Шинэ орчин бүрдүүлэх", + "new_variable": "Шинэ орчны хувьсагч үүсгэх", + "title": "Байгаль орчин" + }, + "general": { + "chat": "Дэмжлэгтэй чатлах", + "help_menu": "Тусламж, дэмжлэг", + "open_docs": "Баримт бичгийг уншина уу", + "open_github": "GitHub репозиторыг нээнэ үү", + "open_keybindings": "Гарын товчлолууд", + "social": "Нийгмийн", + "title": "Генерал" + }, + "graphql": { + "connect": "Сервертэй холбогдоно уу", + "disconnect": "Серверээс салгах" + }, + "miscellaneous": { + "invite": "Найзуудаа Hoppscotch-д урь", + "title": "Төрөл бүрийн" + }, + "request": { + "save_as_new": "Шинэ хүсэлт болгон хадгалах", + "select_method": "Арга сонгох", + "switch_to": "руу шилжих", + "tab_authorization": "Зөвшөөрлийн таб", + "tab_body": "Үндсэн таб", + "tab_headers": "Толгойн таб", + "tab_parameters": "Параметр таб", + "tab_pre_request_script": "Урьдчилан хүсэлт гаргах скрипт таб", + "tab_query": "Асуулга таб", + "tab_tests": "Туршилтын таб", + "tab_variables": "Хувьсагчийн таб" + }, + "response": { + "copy": "Хариултыг хуулах", + "download": "Хариултыг файл болгон татаж авна уу", + "title": "Хариулт" + }, + "section": { + "interceptor": "Таслагч", + "interface": "Интерфэйс", + "theme": "Сэдэв", + "user": "Хэрэглэгч" + }, + "settings": { + "change_interceptor": "Interceptor-ыг солих", + "change_language": "Хэлийг өөрчлөх", + "theme": { + "black": "Хар", + "dark": "Харанхуй", + "light": "Гэрэл", + "system": "Системийн сонголт" + } + }, + "tab": { + "close_current": "Одоогийн табыг хаах", + "close_others": "Бусад бүх табуудыг хаа", + "duplicate": "Одоогийн табыг хуулбарлах", + "new_tab": "Шинэ таб нээ", + "next": "Дараагийн таб руу шилжих", + "previous": "Өмнөх таб руу шилжих", + "switch_to_first": "Эхний таб руу шилжих", + "switch_to_last": "Сүүлийн таб руу шилжих", + "mru_switch": "Сүүлийн таб руу шилжих", + "mru_switch_reverse": "Өмнөх сүүлийн таб руу шилжих", + "title": "Таб" + }, + "workspace": { + "delete": "Одоогийн ажлын талбарыг устгах", + "edit": "Одоогийн ажлын талбарыг засах", + "invite": "Хүмүүсийг ажлын талбарт урих", + "new": "Шинэ ажлын талбар үүсгэх", + "switch_to_personal": "Хувийн ажлын талбар руугаа шилжинэ", + "title": "Ажлын талбарууд" + }, + "phrases": { + "try": "Оролдоод үзээрэй", + "import_collections": "Цуглуулга импортлох", + "create_environment": "Орчин бүрдүүлэх", + "create_workspace": "Ажлын талбар үүсгэх", + "share_request": "Хүсэлт хуваалцах" + } + }, + "sse": { + "event_type": "Үйл явдлын төрөл", + "log": "Бүртгэл", + "url": "URL" + }, + "state": { + "bulk_mode": "Бөөнөөр засварлах", + "bulk_mode_placeholder": "Бичлэгүүдийг шинэ мөрөөр тусгаарлана\nТүлхүүрүүд болон утгуудыг дараах байдлаар тусгаарлана.\nНэмэх мөрийн өмнө # гэж бичнэ үү, гэхдээ идэвхгүй байлгана уу", + "cleared": "Цэвэрлэсэн", + "connected": "Холбогдсон", + "connected_to": "{name}-д холбогдсон", + "connecting_to": "{name}-д холбогдож байна ...", + "connection_error": "Холбож чадсангүй", + "connection_failed": "Холболт амжилтгүй боллоо", + "connection_lost": "Холболт тасарсан", + "copied_interface_to_clipboard": "{language} интерфэйсийн төрлийг санах ой руу хуулсан", + "copied_to_clipboard": "Түр санах ой руу хуулсан", + "deleted": "Устгасан", + "deprecated": "ХОЦРОГДСОН", + "disabled": "Идэвхгүй", + "disconnected": "Салгасан", + "disconnected_from": "{name}-аас салсан", + "docs_generated": "Баримт бичгийг үүсгэсэн", + "download_failed": "Татаж чадсангүй", + "download_started": "Татаж эхэлсэн", + "enabled": "Идэвхжүүлсэн", + "experimental": "Туршилтын", + "file_imported": "Импортолсон файл", + "finished_in": "{duration} мс-д дууссан", + "hide": "Нуух", + "history_deleted": "Түүхийг устгасан", + "linewrap": "Мөрүүдийг боох", + "loading": "Ачааж байна...", + "message_received": "Зурвас: {message} сэдэв дээр ирсэн: {topic}", + "mqtt_subscription_failed": "Сэдвийг захиалах явцад алдаа гарлаа: {topic}", + "no_content_found": "Агуулга олдсонгүй", + "none": "Байхгүй", + "nothing_found": "Юу ч олдсонгүй", + "published_error": "{topic} сэдэв рүү мессеж нийтлэх явцад алдаа гарлаа: {message}", + "published_message": "Нийтэлсэн зурвас: {message} сэдэв: {topic}", + "reconnection_error": "Дахин холбогдож чадсангүй", + "saved": "Хадгалсан", + "show": "Үзүүлэх", + "subscribed_failed": "Сэдвийг захиалж чадсангүй: {topic}", + "subscribed_success": "Сэдвийг амжилттай бүртгүүлсэн: {topic}", + "unsubscribed_failed": "Сэдвээс бүртгэлээ хасаж чадсангүй: {topic}", + "unsubscribed_success": "Сэдвээс бүртгэлээ амжилттай цуцаллаа: {topic}", + "waiting_send_request": "Хүсэлт илгээхийг хүлээж байна", + "user_deactivated": "Таны бүртгэл идэвхгүй болсон байна. Бүртгэлээ дахин идэвхжүүлэхийн тулд админтай холбогдоно уу.", + "add_user_failure": "Хэрэглэгчийг ажлын талбарт нэмж чадсангүй!!", + "add_user_success": "Хэрэглэгч одоо ажлын талбарын гишүүн боллоо!!", + "admin_failure": "Хэрэглэгчийг админ болгож чадсангүй!!", + "admin_success": "Хэрэглэгч одоо админ боллоо!!", + "and": "болон", + "clear_selection": "Сонголтыг арилгах", + "configure_auth": "Баталгаажуулах үйлчилгээ үзүүлэгчийг админ тохиргооноос тохируулна уу эсвэл баримт бичгийг шалгана уу.", + "confirm_admin_to_user": "Та энэ хэрэглэгчээс админ статусыг устгахыг хүсэж байна уу?", + "confirm_admins_to_users": "Та сонгосон хэрэглэгчдээс админ статусыг устгахыг хүсэж байна уу?", + "confirm_delete_infra_token": "Та {tokenLabel} infra жетоныг устгахдаа итгэлтэй байна уу?", + "confirm_delete_invite": "Та сонгосон урилгыг хүчингүй болгохыг хүсэж байна уу?", + "confirm_delete_invites": "Та сонгосон урилгыг цуцлахыг хүсэж байна уу?", + "confirm_user_deletion": "Хэрэглэгчийн устгалыг баталгаажуулах уу?", + "confirm_users_deletion": "Та сонгосон хэрэглэгчдийг устгахыг хүсэж байна уу?", + "confirm_user_to_admin": "Та энэ хэрэглэгчийг админ болгохыг хүсэж байна уу?", + "confirm_users_to_admin": "Та сонгосон хэрэглэгчдийг админ болгохыг хүсэж байна уу?", + "confirm_user_deactivation": "Хэрэглэгчийг идэвхгүй болгохыг баталгаажуулах уу?", + "confirm_logout": "Гарахыг баталгаажуулна уу", + "created_on": "Үүсгэсэн", + "continue_email": "Цахим шуудангаар үргэлжлүүлнэ үү", + "continue_github": "Github-ийг үргэлжлүүлнэ үү", + "continue_google": "Google-тэй үргэлжлүүлнэ үү", + "continue_microsoft": "Майкрософттой үргэлжлүүлнэ үү", + "create_team_failure": "Ажлын талбар үүсгэж чадсангүй!!", + "create_team_success": "Ажлын талбарыг амжилттай үүсгэсэн!!", + "data_sharing_failure": "Өгөгдөл хуваалцах тохиргоог шинэчилж чадсангүй", + "delete_infra_token_failure": "Инфра токеныг устгах явцад алдаа гарлаа", + "delete_invite_failure": "Урилгыг устгаж чадсангүй!!", + "delete_invites_failure": "Сонгосон урилгыг устгаж чадсангүй!!", + "delete_invite_success": "Урилгыг амжилттай устгалаа!!", + "delete_invites_success": "Сонгосон урилгыг амжилттай устгалаа!!", + "delete_request_failure": "Хуваалцсан хүсэлтийг устгаж чадсангүй!!", + "delete_request_success": "Хуваалцсан хүсэлтийг амжилттай устгалаа!!", + "delete_team_failure": "Ажлын талбарыг устгаж чадсангүй!!", + "delete_team_success": "Ажлын талбарыг амжилттай устгалаа!!", + "delete_some_users_failure": "Устаагүй хэрэглэгчдийн тоо: {count}", + "delete_some_users_success": "Устгасан хэрэглэгчдийн тоо: {count}", + "delete_user_failed_only_one_admin": "Хэрэглэгчийг устгаж чадсангүй. Дор хаяж нэг админ байх ёстой!!", + "delete_user_failure": "Хэрэглэгчийг устгаж чадсангүй!!", + "delete_users_failure": "Сонгосон хэрэглэгчдийг устгаж чадсангүй!!", + "delete_user_success": "Хэрэглэгчийг амжилттай устгалаа!!", + "delete_users_success": "Сонгосон хэрэглэгчдийг амжилттай устгалаа!!", + "email": "Имэйл", + "email_failure": "Урилга илгээж чадсангүй", + "email_signin_failure": "Имэйлээр нэвтэрч чадсангүй", + "email_success": "Имэйл урилгыг амжилттай илгээв", + "emails_cannot_be_same": "Та өөрийгөө урих боломжгүй, өөр имэйл хаяг сонгоно уу!!", + "enter_team_email": "Ажлын талбар эзэмшигчийн имэйл хаягийг оруулна уу!!", + "error": "Ямар нэг алдаа гарлаа", + "error_auth_providers": "Баталгаажуулах үйлчилгээ үзүүлэгчдийг ачаалах боломжгүй", + "generate_infra_token_failure": "Инфра жетон үүсгэх явцад алдаа гарлаа", + "github_signin_failure": "Github-ээр нэвтэрч чадсангүй", + "google_signin_failure": "Google-ээр нэвтэрч чадсангүй", + "infra_token_label_short": "Infra Token Label тэмдэгтийн урт хэт богино байна!!", + "invalid_email": "Хүчинтэй имэйл хаяг оруулна уу", + "link_copied_to_clipboard": "Холбоосыг санах ой руу хуулсан", + "logged_out": "Гарсан", + "login_as_admin": "болон админ бүртгэлээр нэвтэрнэ үү.", + "login_using_email": "Хэрэглэгчээс имэйлээ шалгах эсвэл доорх холбоосыг хуваалцахыг хүснэ үү", + "login_using_link": "Доорх линкээр нэвтэрч орохыг хэрэглэгчээс хүснэ үү", + "logout": "Гарах", + "magic_link_sign_in": "Холбоос дээр дарж нэвтэрнэ үү.", + "magic_link_success": "Бид шидэт холбоос илгээсэн", + "microsoft_signin_failure": "Microsoft-оор нэвтэрч чадсангүй", + "newsletter_failure": "Мэдээллийн товхимолын тохиргоог шинэчлэх боломжгүй байна", + "non_admin_logged_in": "Админ бус хэрэглэгчээр нэвтэрсэн.", + "non_admin_login": "Та нэвтэрсэн байна. Гэхдээ та админ биш", + "owner_not_present": "Ядаж нэг эзэн багт байх ёстой!!", + "privacy_policy": "Нууцлалын бодлого", + "reenter_email": "Имэйлээ дахин оруулна уу", + "remove_admin_failure": "Админ статусыг устгаж чадсангүй!!", + "remove_admin_failure_only_one_admin": "Админ статусыг устгаж чадсангүй. Дор хаяж нэг админ байх ёстой!!", + "remove_admin_success": "Админ статус хасагдсан!!", + "remove_admin_from_users_failure": "Сонгогдсон хэрэглэгчдээс админ статусыг устгаж чадсангүй!!", + "remove_admin_from_users_success": "Сонгогдсон хэрэглэгчдээс админ статусыг хассан!!", + "remove_admin_to_delete_user": "Хэрэглэгчийг устгахын тулд админы эрхийг хасна уу!!", + "remove_owner_to_delete_user": "Хэрэглэгчийг устгахын тулд багийн эзэмшлийн статусыг устгана уу!!", + "remove_owner_failure_only_one_owner": "Гишүүнийг хасаж чадсангүй. Нэг багт дор хаяж нэг эзэн байх ёстой!!", + "remove_admin_for_deletion": "Устгахыг оролдохоос өмнө админ статусыг устгана уу!!", + "remove_owner_for_deletion": "Нэг буюу хэд хэдэн хэрэглэгчид багийн эзэд байна. Устгахаас өмнө өмчлөлийг шинэчилнэ үү!!", + "remove_invitee_failure": "Урьсан хүнийг хасаж чадсангүй!!", + "remove_invitee_success": "Урьсан хүнийг устгалаа.", + "remove_member_failure": "Гишүүнийг хасаж чадсангүй!!", + "remove_member_success": "Гишүүнийг амжилттай хаслаа!!", + "rename_team_failure": "Ажлын талбарын нэрийг өөрчилж чадсангүй!!", + "rename_team_success": "Ажлын талбайн нэрийг амжилттай өөрчилсөн!", + "rename_user_failure": "Хэрэглэгчийн нэрийг өөрчилж чадсангүй!!", + "rename_user_success": "Хэрэглэгчийн нэрийг амжилттай өөрчилсөн!!", + "require_auth_provider": "Нэвтрэхийн тулд та дор хаяж нэг баталгаажуулалтын үйлчилгээ үзүүлэгч тохируулах шаардлагатай.", + "role_update_failed": "Дүрүүдийн шинэчлэл амжилтгүй боллоо!!", + "role_update_success": "Дүрүүдийг амжилттай шинэчилсэн!!", + "selected": "{count} сонгосон", + "self_host_docs": "Өөрөө хостын баримт бичиг", + "send_magic_link": "Шидэт холбоос илгээх", + "setup_failure": "Тохиргоо амжилтгүй боллоо!!", + "setup_success": "Тохиргоо амжилттай дууссан!!", + "sign_in_agreement": "Нэвтрэн орсноор та манайхыг зөвшөөрч байна", + "sign_in_options": "Бүгд нэвтрэх сонголт", + "sign_out": "Гарах", + "something_went_wrong": "Ямар нэг алдаа гарлаа", + "team_name_too_short": "Ажлын талбарын нэр дор хаяж 6 тэмдэгтээс бүрдэх ёстой!!", + "user_already_invited": "Урилгыг илгээж чадсангүй. Хэрэглэгчийг аль хэдийн урьсан байна!!", + "user_not_found": "Хэрэглэгчийг инфра-д олоогүй байна!!", + "users_to_admin_success": "Сонгогдсон хэрэглэгчдийг админ статустай болгож байна!!", + "users_to_admin_failure": "Сонгогдсон хэрэглэгчдийг админы статустай болгож чадсангүй!!", + "loading_workspaces": "Ажлын талбаруудыг ачаалж байна", + "loading_collections_in_workspace": "Ажлын талбарт цуглуулгуудыг ачаалж байна" + }, + "support": { + "changelog": "Хамгийн сүүлийн үеийн хувилбаруудын талаар дэлгэрэнгүй уншина уу", + "chat": "Асуулт? Бидэнтэй чатлаарай!", + "community": "Асуулт асууж, бусдад туслаарай", + "documentation": "Hoppscotch-ийн талаар дэлгэрэнгүй уншина уу", + "forum": "Асуулт асууж, хариулт аваарай", + "github": "Github дээр биднийг дагаарай", + "shortcuts": "Аппликешныг илүү хурдан үзэх", + "title": "Дэмжлэг", + "twitter": "Twitter дээр биднийг дагаарай" + }, + "tab": { + "authorization": "Зөвшөөрөл", + "body": "Бие", + "close": "Табыг хаах", + "close_others": "Бусад цонхыг хаах", + "collections": "Цуглуулга", + "documentation": "Баримт бичиг", + "duplicate": "Давхардсан таб", + "environments": "Байгаль орчин", + "headers": "Гарчиг", + "history": "Түүх", + "mqtt": "MQTT", + "parameters": "Параметрүүд", + "post_request_script": "Хүсэлтийн дараах скрипт", + "pre_request_script": "Скриптийг урьдчилан хүсэх", + "scripts": "Скриптүүд", + "queries": "Асуултууд", + "query": "Асуулга", + "schema": "Схем", + "shared_requests": "Хуваалцсан хүсэлтүүд", + "codegen": "Код үүсгэх", + "code_snippet": "Кодын хэсэг", + "mock_servers": "Хуурамч серверүүд", + "share_tab_request": "Табын хүсэлтийг хуваалцах", + "socketio": "Socket.IO", + "sse": "SSE", + "types": "Төрөл", + "variables": "Хувьсагч", + "websocket": "WebSocket", + "all_tests": "Бүх тестүүд", + "passed": "Давсан", + "failed": "Амжилтгүй" + }, + "team": { + "activity_logs": "Үйл ажиллагааны бүртгэл", + "already_member": "Энэ имэйл одоо байгаа хэрэглэгчтэй холбоотой байна.", + "create_new": "Шинэ ажлын талбар үүсгэх", + "deleted": "Ажлын талбарыг устгасан", + "delete_all_activity_logs": "Бүх үйл ажиллагааны бүртгэлийг устгана уу", + "successfully_deleted_all_activity_logs": "Бүх үйл ажиллагааны бүртгэлийг амжилттай устгасан", + "delete_activity_log": "Үйл ажиллагааны бүртгэлийг устгах", + "deleted_activity_log": "Сонгосон үйл ажиллагааны бүртгэлийг устгасан", + "deleted_all_activity_logs": "Бүх үйл ажиллагааны бүртгэлийг устгасан", + "edit": "Ажлын талбарыг засах", + "email": "И-мэйл", + "email_do_not_match": "Имэйл таны бүртгэлийн мэдээлэлтэй таарахгүй байна. Ажлын талбайн эзэнтэй холбогдоно уу.", + "exit": "Ажлын талбараас гарах", + "exit_disabled": "Зөвхөн эзэмшигч нь ажлын талбараас гарах боломжгүй", + "failed_invites": "Амжилтгүй урилга", + "invalid_coll_id": "Цуглуулгын ID буруу", + "invalid_email_format": "Имэйлийн формат буруу байна", + "invalid_id": "Ажлын талбарын ID буруу. Ажлын талбайн эзэнтэй холбогдоно уу.", + "invalid_invite_link": "Урилгын холбоос буруу байна", + "invalid_invite_link_description": "Таны дагасан холбоос буруу байна. Ажлын талбайн эзэнтэй холбогдоно уу.", + "invalid_member_permission": "Ажлын талбарын гишүүнд хүчинтэй зөвшөөрөл өгнө үү", + "invite": "Урих", + "invite_more": "Илүү ихийг урих", + "invite_tooltip": "Хүмүүсийг энэ ажлын талбарт урь", + "invited_to_team": "{owner} таныг {workspace}-д нэгдэхийг урьсан.", + "join": "Урилгыг хүлээн авлаа", + "join_team": "{workspace}-д нэгдэх", + "joined_team": "Та {workspace}-д нэгдсэн", + "joined_team_description": "Та одоо энэ ажлын талбарын гишүүн боллоо", + "left": "Та ажлын талбараас гарлаа", + "login_to_continue": "Үргэлжлүүлэхийн тулд нэвтэрнэ үү", + "login_to_continue_description": "Та ажлын талбарт нэгдэхийн тулд нэвтэрсэн байх шаардлагатай.", + "logout_and_try_again": "Гараад өөр бүртгэлээр нэвтэрнэ үү", + "member_has_invite": "Хэрэглэгч аль хэдийн урилгатай байна. Тэднээс ирсэн имэйлээ шалгах эсвэл урилгыг хүчингүй болгож дахин илгээхийг хүснэ үү.", + "member_not_found": "Гишүүн олдсонгүй. Ажлын талбайн эзэнтэй холбогдоно уу.", + "member_removed": "Хэрэглэгчийг устгасан", + "member_role_updated": "Хэрэглэгчийн үүрэг шинэчлэгдсэн", + "members": "Гишүүд", + "more_members": "+{count} өөр", + "name_length_insufficient": "Ажлын талбарын нэр хоосон байж болохгүй", + "name_updated": "Ажлын талбарын нэрийг шинэчилсэн", + "new": "Шинэ ажлын талбар", + "new_created": "Шинэ ажлын талбар бий болсон", + "new_name": "Миний шинэ ажлын талбар", + "no_access": "Танд энэ ажлын талбарт засварлах эрх байхгүй", + "no_invite_found": "Урилга олдсонгүй. Ажлын талбайн эзэнтэй холбогдоно уу.", + "no_request_found": "Хүсэлт олдсонгүй.", + "not_found": "Ажлын талбар олдсонгүй. Ажлын талбайн эзэнтэй холбогдоно уу.", + "not_valid_viewer": "Та хүчинтэй үзэгч биш байна. Ажлын талбайн эзэнтэй холбогдоно уу.", + "parent_coll_move": "Цуглуулгыг хүүхдийн цуглуулга руу зөөх боломжгүй", + "pending_invites": "Хүлээгдэж буй урилгууд", + "permissions": "Зөвшөөрөл", + "same_target_destination": "Нэг зорилго, хүрэх газар", + "saved": "Ажлын талбарыг хадгалсан", + "select_a_team": "Ажлын талбарыг сонгоно уу", + "success_invites": "Амжилтыг урьж байна", + "title": "Ажлын талбарууд", + "we_sent_invite_link": "Урилгууд замдаа байна", + "invite_sent_smtp_disabled": "Урилгын холбоос үүсгэсэн", + "we_sent_invite_link_description": "Шинэ уригдсан хүмүүс ажлын талбарт нэгдэх холбоосыг хүлээн авах бөгөөд одоо байгаа гишүүд болон хүлээгдэж буй уригчид шинэ холбоос хүлээн авахгүй.", + "invite_sent_smtp_disabled_description": "Энэ Hoppscotch жишээний хувьд урилга имэйл илгээхийг идэвхгүй болгосон. Урилгын холбоосыг гараар хуулж, хуваалцахын тулд \"Холбоос хуулах\" товчийг ашиглана уу.", + "copy_invite_link": "Урилгын холбоосыг хуулах", + "search_title": "Багийн хүсэлт", + "user_not_found": "Энэ тохиолдолд хэрэглэгч олдсонгүй.", + "invite_members": "Гишүүдийг урих" + }, + "team_environment": { + "deleted": "Хүрээлэн буй орчныг устгасан", + "duplicate": "Хүрээлэн буй орчин давхардсан", + "not_found": "Байгаль орчин олдсонгүй." + }, + "test": { + "requests": "Хүсэлтүүд", + "selection": "Сонголт", + "failed": "туршилт амжилтгүй болсон", + "javascript_code": "JavaScript код", + "learn": "Баримт бичгийг уншина уу", + "passed": "шалгалтад тэнцсэн", + "report": "Туршилтын тайлан", + "results": "Туршилтын үр дүн", + "script": "Скрипт", + "snippets": "Хэсэг хэсгүүд", + "run": "Гүй", + "run_again": "Дахин гүй", + "stop": "Зогс", + "new_run": "Шинэ гүйлт", + "iterations": "Давталт", + "duration": "Үргэлжлэх хугацаа", + "avg_resp": "Дундаж. Хариу өгөх хугацаа" + }, + "websocket": { + "communication": "Харилцаа холбоо", + "log": "Бүртгэл", + "message": "Мессеж", + "protocols": "Протоколууд", + "url": "URL" + }, + "workspace": { + "change": "Ажлын талбарыг өөрчлөх", + "personal": "Хувийн ажлын талбар", + "other_workspaces": "Миний ажлын талбарууд", + "team": "Ажлын талбар", + "title": "Ажлын талбарууд" + }, + "site_protection": { + "login_to_continue": "Үргэлжлүүлэхийн тулд нэвтэрнэ үү", + "login_to_continue_description": "Энэ Hoppscotch Enterprise Instance-д хандахын тулд та нэвтэрсэн байх шаардлагатай.", + "error_fetching_site_protection_status": "Сайтын хамгаалалтын статусыг дуудаж байхад ямар нэг алдаа гарлаа" + }, + "access_tokens": { + "tab_title": "Токенууд", + "section_title": "Хувийн хандалтын токенууд", + "section_description": "Хувийн хандалтын токенууд нь танд CLI-г Hoppscotch дансандаа холбоход тусална", + "last_used_on": "Хамгийн сүүлд ашигласан", + "expires_on": "Дуусах өдөр", + "no_expiration": "Хугацаа дуусахгүй", + "expired": "Хугацаа дууссан", + "copy_token_warning": "Хувийн хандалтын токеноо яг одоо хуулахаа мартуузай. Та үүнийг дахиж харах боломжгүй болно!", + "token_purpose": "Энэ токен юунд зориулагдсан бэ?", + "expiration_label": "Хугацаа дуусах", + "scope_label": "Хамрах хүрээ", + "workspace_read_only_access": "Ажлын талбарын өгөгдөлд зөвхөн унших боломжтой.", + "personal_workspace_access_limitation": "Хувийн хандалтын токенууд таны хувийн ажлын талбарт хандах боломжгүй.", + "generate_token": "Токен үүсгэх", + "invalid_label": "Токены шошгыг өгнө үү", + "no_expiration_verbose": "Энэ токен хэзээ ч дуусахгүй!", + "token_expires_on": "Энэ токены хугацаа дуусах болно", + "generate_new_token": "Шинэ токен үүсгэх", + "generate_modal_title": "Шинэ хувийн хандалтын токен", + "deletion_success": "Хандалтын токен {label} устгагдсан" + }, + "collection_runner": { + "collection_id": "Цуглуулгын ID", + "environment_id": "Байгаль орчны ID", + "cli_collection_id_description": "Энэ цуглуулгын ID-г CLI цуглуулагч Hoppscotch-д ашиглах болно.", + "cli_environment_id_description": "Энэ орчны ID-г Hoppscotch-д зориулсан CLI цуглуулга гүйгч ашиглах болно.", + "include_active_environment": "Идэвхтэй орчинг оруулах:", + "cli": "CLI", + "cli_comming_soon_for_personal_collection": "CLI дахь хувийн цуглуулгуудад зориулсан Collection Runner удахгүй гарах болно.", + "delay": "Саатал", + "negative_delay": "Саатал сөрөг байж болохгүй", + "ui": "Гүйгч", + "running_collection": "Ажиллаж байгаа цуглуулга", + "run_config": "Тохиргоог ажиллуул", + "advanced_settings": "Нарийвчилсан тохиргоо", + "stop_on_error": "Алдаа гарвал ажиллуулахаа зогсоо", + "persist_responses": "Тогтвортой хариултууд", + "keep_variable_values": "Хувьсах утгуудыг хадгалах", + "collection_not_found": "Цуглуулга олдсонгүй. Устгаж эсвэл зөөж болно.", + "empty_collection": "Цуглуулга хоосон байна. Ажиллуулах хүсэлтийг нэмнэ үү.", + "no_response_persist": "Цуглуулгын гүйгчийг одоогоор хариу өгөхгүй байхаар тохируулсан байна. Энэ тохиргоо нь хариултын өгөгдлийг харуулахаас сэргийлдэг. Энэ зан үйлийг өөрчлөхийн тулд шинэ ажиллуулах тохиргоог эхлүүлнэ үү.", + "select_request": "Хариулт болон туршилтын үр дүнг харахын тулд хүсэлтийг сонгоно уу", + "response_body_lost_rerun": "Хариуцлагын бие алга болсон. Хариултыг авахын тулд цуглуулгыг дахин ажиллуулна уу.", + "cli_command_generation_description_cloud": "Доорх командыг хуулж CLI-аас ажиллуулна уу. Хувийн хандалтын токеныг зааж өгнө үү.", + "cli_command_generation_description_sh": "Доорх командыг хуулж CLI-аас ажиллуулна уу. Хувийн хандалтын токеныг зааж, үүсгэсэн SH жишээ серверийн URL-г баталгаажуулна уу.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Доорх командыг хуулж CLI-аас ажиллуулна уу. Хувийн хандалтын токен болон SH жишээ серверийн URL-г зааж өгнө үү.", + "run_collection": "Цуглуулга ажиллуулах", + "no_passed_tests": "Ямар ч шалгалт өгөөгүй", + "no_failed_tests": "Ямар ч туршилт амжилтгүй болсон" + }, + "ai_experiments": { + "generate_request_name": "AI ашиглан хүсэлтийн нэр үүсгэх", + "generate_or_modify_request_body": "Хүсэлтийн хэсгийг үүсгэх эсвэл өөрчлөх", + "modify_with_ai": "AI ашиглан өөрчлөх", + "generate": "Үүсгэх", + "generate_or_modify_request_body_input_placeholder": "Хүсэлтийн үндсэн хэсгийг өөрчлөх хүсэлтээ оруулна уу", + "accept_change": "Өөрчлөлтийг хүлээн зөвшөөрөх", + "feedback_success": "Санал хүсэлтийг амжилттай илгээсэн", + "feedback_failure": "Санал хүсэлт илгээж чадсангүй", + "feedback_thank_you": "Санал хүсэлтээ өгсөнд баярлалаа!", + "feedback_cta_text_long": "Үе үеийг үнэл, биднийг сайжруулахад тусалдаг", + "feedback_cta_request_name": "Үүсгэсэн нэр танд таалагдсан уу?", + "modify_request_body_error": "Хүсэлтийн үндсэн хэсгийг өөрчилж чадсангүй", + "generate_or_modify_prerequest_input_placeholder": "Урьдчилсан хүсэлтийн скриптийг үүсгэх эсвэл өөрчлөх хүсэлтийг оруулна уу", + "generate_or_modify_post_request_script_input_placeholder": "Хүсэлтийн дараах скриптийг үүсгэх эсвэл өөрчлөх хүсэлтийг оруулна уу", + "modify_post_request_script_error": "Хүсэлтийн дараах скриптийг өөрчилж чадсангүй", + "modify_prerequest_error": "Урьдчилсан хүсэлтийн скриптийг өөрчилж чадсангүй" + }, + "configs": { + "auth_providers": { + "callback_url": "БУЦАХ URL", + "client_id": "CLIENT ID", + "client_secret": "ҮЙЛЧЛҮҮЛЭГЧИЙН НУУЦ", + "description": "Сервертээ баталгаажуулах үйлчилгээ үзүүлэгчдийг тохируулна уу", + "provider_not_specified": "Дор хаяж нэг баталгаажуулалтын үйлчилгээ үзүүлэгчийг идэвхжүүлнэ үү", + "scope": "ХАМРАХ ХҮРЭЭ", + "tenant": "ТҮРЭЭЛЭГЧ", + "title": "Баталгаажуулах үйлчилгээ үзүүлэгчид", + "update_failure": "Баталгаажуулалтын үйлчилгээ үзүүлэгчийн тохиргоог шинэчилж чадсангүй!!" + }, + "confirm_changes": "Шинэ өөрчлөлтүүдийг тусгахын тулд Hoppscotch серверийг дахин эхлүүлэх шаардлагатай. Серверийн тохиргоонд хийсэн өөрчлөлтийг баталгаажуулах уу?", + "input_empty": "Тохиргоог шинэчлэхийн өмнө бүх талбарыг бөглөнө үү", + "data_sharing": { + "title": "Өгөгдөл хуваалцах", + "description": "Нэргүй өгөгдөл хуваалцаж Hoppscotch-г сайжруулахад тусална уу", + "enable": "Мэдээлэл хуваалцахыг идэвхжүүлнэ үү", + "secondary_title": "Өгөгдөл хуваалцах тохиргоо", + "see_shared": "Юу хуваалцаж байгааг хараарай", + "toggle_description": "Нэргүй өгөгдлийг хуваалцах", + "update_failure": "Өгөгдөл хуваалцах тохиргоог шинэчилж чадсангүй!!" + }, + "load_error": "Серверийн тохиргоог ачаалах боломжгүй байна", + "mail_configs": { + "address_from": "ХАЯГСАС ШУУД", + "custom_smtp_configs": "Тусгай SMTP тохиргоог ашиглах", + "description": "smtp тохиргоог тохируулна уу", + "enable_email_auth": "Имэйлд суурилсан баталгаажуулалтыг идэвхжүүлнэ үү", + "enable_smtp": "SMTP-г идэвхжүүлнэ үү", + "host": "Шуудангийн эзэн", + "password": "Шуудангийн нууц үг", + "port": "Шуудангийн порт", + "secure": "МАЙЛЕРИЙН АЮУЛГҮЙ", + "smtp_url": "MAILER SMTP URL", + "tls_reject_unauthorized": "TLS ЗӨВШӨӨРӨГӨӨГҮЙ ТАТГАЛЖ БАЙНА", + "title": "SMTP тохиргоо", + "toggle_failure": "smtp-г асааж чадсангүй!!", + "update_failure": "smtp тохиргоог шинэчилж чадсангүй!!", + "user": "МАЙЛАН ХЭРЭГЛЭГЧ" + }, + "reset": { + "confirm_reset": "Шинэ өөрчлөлтүүдийг тусгахын тулд Hoppscotch серверийг дахин эхлүүлэх шаардлагатай. Серверийн тохиргоог дахин тохируулахыг баталгаажуулах уу?", + "description": "Өгөгдмөл тохиргоонууд нь орчны файлд заасны дагуу ачаалагдах болно", + "failure": "Тохиргоог дахин тохируулж чадсангүй!!", + "title": "Тохиргоог дахин тохируулах", + "info": "Серверийн тохиргоог дахин тохируулах" + }, + "restart": { + "description": "Энэ хуудсыг автоматаар дахин ачаалахад {duration} секунд үлдлээ", + "initiate": "Серверийг дахин эхлүүлэхийг эхлүүлж байна...", + "title": "Сервер дахин эхэлж байна" + }, + "save_changes": "Өөрчлөлтүүдийг хадгалах", + "title": "Тохиргоо", + "update_failure": "Серверийн тохиргоог шинэчилж чадсангүй", + "restrict_access": "Хандалтыг хязгаарлах", + "site_protection": { + "control_access": "Hoppscotch програмд хэн хандах боломжтойг хянах", + "description": "Сайтын хамгаалалтын тохиргоог ашиглан зочдод таны Hoppscotch програмд хэрхэн хандахыг тохируулаарай.", + "enable": "Сайтын хамгаалалтыг идэвхжүүлэх", + "note": "Hoppscotch аппликейшнд зочилсон хэрэглэгчид нэвтрэх хуудсыг харах бөгөөд програмд нэвтрэхийн тулд заавал нэвтрэх шаардлагатай. Зөвшөөрөл авахын тулд админы зөвшөөрөл шаардлагатай хэвээр байна", + "update_failure": "Сайтын хамгаалалтын тохиргоог шинэчилж чадсангүй!!" + }, + "domain_whitelisting": { + "add_domain": "Шинэ домэйн нэмэх", + "description": "Зөвшөөрөгдсөн домэйнд бүртгэлтэй цахим шуудангийн ID-тай хэрэглэгчид Hoppscotch програм руу нэвтрэхийн тулд админаас тодорхой зөвшөөрөл шаарддаггүй.", + "enable": "Домэйн цагаан жагсаалтыг идэвхжүүлэх", + "enter_domain": "Домэйн оруулна уу", + "title": "Зөвшөөрөгдсөн жагсаалтад орсон домэйнууд", + "toggle_failure": "Домэйн зөвшөөрөгдсөн жагсаалтыг сэлгэж чадсангүй", + "update_failure": "Домэйн зөвшөөрөгдсөн жагсаалтын тохиргоог шинэчилж чадсангүй!!" + }, + "oidc_configs": { + "auth_url": "Auth URL", + "callback_url": "Буцах URL", + "client_id": "Үйлчлүүлэгчийн ID", + "client_secret": "Үйлчлүүлэгчийн нууц", + "description": "OIDC тохиргоог тохируулах", + "enable": "OIDC дээр суурилсан баталгаажуулалтыг идэвхжүүлнэ", + "issuer": "Үнэт цаас гаргагч", + "provider_name": "Үйлчилгээ үзүүлэгчийн нэр", + "scope": "Хамрах хүрээ", + "title": "OIDC тохиргоо", + "token_url": "Токен URL", + "update_failure": "OIDC тохиргоог шинэчилж чадсангүй!!", + "user_info_url": "Хэрэглэгчийн мэдээллийн URL" + }, + "saml": { + "audience": "Үзэгчид", + "callback_url": "Буцах URL", + "certificate": "Сертификат", + "description": "SAML тохиргоог тохируулах", + "enable": "SAML-д суурилсан баталгаажуулалтыг идэвхжүүлнэ үү", + "entry_point": "Нэвтрэх цэг", + "issuer": "Үнэт цаас гаргагч", + "title": "SAML тохиргоо", + "update_failure": "SAML SSO тохиргоог шинэчилж чадсангүй!!", + "want_assertions_signed": "Баталгаажуулалтад гарын үсэг зурах", + "want_response_signed": "Гарын үсэг зурах хариу" + } + }, + "data_sharing": { + "description": "Hoppscotch-ийг сайжруулахын тулд нэргүй дата ашиглалтыг хуваалцаарай", + "enable": "Мэдээлэл хуваалцахыг идэвхжүүлнэ үү", + "see_shared": "Юу хуваалцаж байгааг хараарай", + "toggle_description": "Өгөгдлийг хуваалцаж, Hoppscotch-ийг илүү сайн болго", + "title": "Хоппскочийг илүү сайн болго", + "welcome": "тавтай морил" + }, + "infra_tokens": { + "copy_token_warning": "Инфра токеноо яг одоо хуулж байгаарай. Та үүнийг дахиж харах боломжгүй болно!", + "deletion_success": "Инфра токен {label} устгагдсан", + "empty": "Инфра жетон хоосон байна", + "expired": "Хугацаа дууссан", + "expiration_label": "Хугацаа дуусах", + "expires_on": "Дуусах өдөр", + "generate_modal_title": "Шинэ инфра токен", + "generate_new_token": "Шинэ токен үүсгэх", + "generate_token": "Токен үүсгэх", + "invalid_label": "Токены шошгыг өгнө үү", + "last_used_on": "Хамгийн сүүлд ашигласан", + "no_expiration": "Хугацаа дуусахгүй", + "no_expiration_verbose": "Энэ токен хэзээ ч дуусахгүй!", + "section_description": "Infra жетон бүхий API-уудаар дамжуулан Hoppscotch хэрэглэгчидээ удирдаарай", + "section_title": "Инфра жетон", + "tab_title": "Инфра жетон", + "token_expires_on": "Энэ токены хугацаа дуусах болно", + "token_purpose": "Энэ тэмдгийг тодорхойлохын тулд шошго оруулна уу" + }, + "metrics": { + "dashboard": "Хяналтын самбар", + "no_metrics": "Ямар ч үзүүлэлт олдсонгүй", + "total_collections": "Нийт цуглуулга", + "total_requests": "Нийт хүсэлт", + "total_teams": "Нийт ажлын талбай", + "total_users": "Нийт хэрэглэгчид" + }, + "newsletter": { + "description": "Манай хамгийн сүүлийн үеийн мэдээний талаар мэдээлэл аваарай", + "subscribe": "Бүртгүүлэх", + "title": "Холбоотой байгаарай", + "toggle_description": "Hoppscotch-ээс хамгийн сүүлийн үеийн мэдээг аваарай", + "unsubscribe": "Бүртгэлээ цуцлах" + }, + "teams": { + "add_member": "Гишүүн нэмэх", + "add_members": "Гишүүн нэмэх", + "add_new": "Шинэ нэмэх", + "admin": "Админ", + "admin_Email": "Админ Имэйл", + "admin_id": "Админ ID", + "cancel": "Цуцлах", + "confirm_team_deletion": "Ажлын талбарыг устгахыг баталгаажуулах уу?", + "copy": "Хуулах", + "create_team": "Ажлын талбар үүсгэх", + "date": "Огноо", + "delete_team": "Ажлын талбарыг устгах", + "details": "Дэлгэрэнгүй мэдээлэл", + "edit": "Засварлах", + "editor": "РЕДАКТОР", + "editor_description": "Редакторууд хүсэлт, цуглуулга нэмэх, засах, устгах боломжтой.", + "email": "Ажлын талбар эзэмшигчийн имэйл", + "email_address": "Имэйл хаяг", + "email_title": "Имэйл", + "empty_name": "Багийн нэр хоосон байж болохгүй!!", + "error": "Ямар нэг алдаа гарлаа. Дараа дахин оролдоно уу.", + "id": "Ажлын талбарын ID", + "invited_email": "Урьсан имэйл", + "invited_on": "Уригдсан", + "invites": "урьж байна", + "load_info_error": "Workspace-н мэдээллийг ачаалах боломжгүй", + "load_list_error": "Ажлын талбарын жагсаалтыг ачаалах боломжгүй", + "members": "Гишүүдийн тоо", + "no_invite": "Урилга байхгүй", + "no_invite_description": "Хамтран ажиллаж эхлэхэд багаа урь", + "owner": "ЭЗЭН", + "owner_description": "Эзэмшигчид хүсэлт, цуглуулга, ажлын талбарын гишүүдийг нэмэх, засах, устгах боломжтой.", + "permissions": "Зөвшөөрөл", + "name": "Ажлын талбарын нэр", + "no_members": "Энэ ажлын талбарт гишүүн алга. Хамтран ажиллахын тулд энэ ажлын талбарт гишүүд нэмнэ үү", + "no_pending_invites": "Хүлээгдэж буй урилга байхгүй", + "no_teams": "Ажлын талбар олдсонгүй.", + "no_teams_description": "Багтайгаа хамтран ажиллах ажлын талбарыг бий болго", + "pending_invites": "Хүлээгдэж буй урилгууд", + "roles": "Дүрүүд", + "roles_description": "Хуваалцсан цуглуулгад хандах хандалтыг хянахын тулд дүрүүдийг ашигладаг.", + "remove": "Устгах", + "rename": "Нэрээ өөрчлөх", + "save": "Хадгалах", + "save_changes": "Өөрчлөлтүүдийг хадгалах", + "send_invite": "Урилга илгээх", + "show_more": "Илүү ихийг харуулах", + "team_details": "Ажлын байрны дэлгэрэнгүй мэдээлэл", + "team_members": "Гишүүд", + "team_members_tab": "Ажлын талбарын гишүүд", + "teams": "Ажлын талбар", + "uid": "UID", + "unnamed": "(Нэргүй ажлын талбар)", + "viewer": "ҮЗЭГЧ", + "viewer_description": "Үзэгчид зөвхөн хүсэлтийг үзэж, ашиглах боломжтой", + "valid_name": "Хүчинтэй ажлын талбайн нэрийг оруулна уу", + "valid_owner_email": "Зөв эзэмшигчийн имэйл хаягаа оруулна уу" + }, + "users": { + "add_user": "Хэрэглэгч нэмэх", + "admin": "Админ", + "admin_id": "Админ ID", + "cancel": "Цуцлах", + "created_on": "Үүсгэсэн", + "copy_invite_link": "Урилгын холбоосыг хуулах", + "copy_link": "Холбоосыг хуулах", + "date": "Огноо", + "delete": "Устгах", + "delete_user": "Хэрэглэгчийг устгах", + "delete_users": "Хэрэглэгчдийг устгах", + "details": "Дэлгэрэнгүй мэдээлэл", + "edit": "Засварлах", + "email": "Имэйл", + "email_address": "Имэйл хаяг", + "empty_name": "Нэр хоосон байж болохгүй!!", + "id": "Хэрэглэгчийн ID", + "invalid_user": "Буруу хэрэглэгч", + "invite_load_list_error": "Уригдсан хэрэглэгчдийн жагсаалтыг ачаалах боломжгүй байна", + "invite_user": "Хэрэглэгчийг урих", + "invited_by": "Урьсан", + "invited_on": "Уригдсан", + "invited_users": "Уригдсан хэрэглэгчид", + "invitee_email": "Урьсан имэйл", + "last_active_on": "Сүүлийн идэвхтэй", + "load_info_error": "Хэрэглэгчийн мэдээллийг ачаалах боломжгүй байна", + "load_list_error": "Хэрэглэгчдийн жагсаалтыг ачаалах боломжгүй", + "make_admin": "Админ болго", + "name": "Нэр", + "new_user_added": "Шинэ хэрэглэгч нэмэгдсэн", + "no_invite": "Хүлээгдэж буй урилга олдсонгүй", + "no_invite_description": "Хүлээгдэж буй урилга байхгүй! Багийн найзуудаа Hoppscotch-д урьж эхлээрэй", + "no_shared_requests": "Хэрэглэгчийн үүсгэсэн хуваалцсан хүсэлт байхгүй байна", + "no_users": "Хэрэглэгч олдсонгүй", + "not_available": "Боломжгүй", + "not_found": "Хэрэглэгч олдсонгүй", + "pending_invites": "Хүлээгдэж буй урилга", + "remove_admin_privilege": "Админы эрхийг хасах", + "remove_admin_status": "Админ статусыг устгах", + "rename": "Нэрээ өөрчлөх", + "revoke_invitation": "Урилгыг хүчингүй болгох", + "searchbar_placeholder": "Нэр эсвэл имэйлээр хайх.", + "send_invite": "Урилга илгээх", + "show_more": "Илүү ихийг харуулах", + "uid": "UID", + "unnamed": "(Нэргүй хэрэглэгч)", + "user_not_found": "Хэрэглэгчийг инфра-д олоогүй байна!!", + "users": "Хэрэглэгчид", + "valid_email": "Хүчинтэй имэйл хаяг оруулна уу", + "deactivate": "Идэвхгүй болгох", + "deactivate_user": "Хэрэглэгчийг идэвхгүй болгох" + }, + "organization": { + "login_to_continue_description": "Байгууллагын жишээнд нэгдэхийн тулд та нэвтэрсэн байх шаардлагатай.", + "create_an_organization": "Байгууллага бий болгох", + "deactivate_user_failure": "Хэрэглэгчийг идэвхгүй болгож чадсангүй!!", + "deactivate_user_success": "Хэрэглэгчийг амжилттай идэвхгүй болголоо!!", + "delete_account_description": "Энэ нь таны Hoppscotch бүртгэлтэй холбоотой бүх өгөгдлийг устгах болно, үүнд энэ болон таны нэг хэсэг болох бусад байгууллага орно.", + "delete_account": "Hoppscotch дансыг устгах", + "user_deletion_failed_sole_admin": "Хэрэглэгч нь нэг буюу хэд хэдэн байгууллагын жишээнүүдийн цорын ганц админ юм. Устгах оролдлого хийхээсээ өмнө хэрэглэгчийн зэрэглэлийг бууруулна уу.", + "user_deletion_failed_sole_team_owner": "Хэрэглэгч нь нэг буюу хэд хэдэн байгууллагын тохиолдлын цорын ганц багийн эзэмшигч юм. Устгах оролдлого хийхээсээ өмнө өмчлөлийг шилжүүлэх эсвэл холбогдох ажлын талбарыг устгана уу.", + "no_organizations": "Та ямар ч байгууллагын гишүүн биш", + "admin": "Админ" + }, + "organization_sidebar": { + "hoppscotch_cloud": "Хоппскоч үүл", + "cloud_locked": "Өгөгдмөл жишээг устгах боломжгүй", + "admin": "Админ", + "error_loading": "Байгууллагуудыг ачаалж чадсангүй", + "inactive_orgs": "Идэвхгүй байгууллагууд", + "no_orgs_found": "Байгууллага олдсонгүй", + "no_active_orgs_found": "Идэвхтэй байгууллага байхгүй", + "organizations_for": "{email}-д зориулсан байгууллагууд", + "multi_account_notice": "Байгууллага бүр хамгийн сүүлд нэвтэрсэн акаунтыг ашиглан өөрийн нэвтрэх эрхээ хадгалдаг.", + "inactive_orgs_tooltip": "Тусламж авахын тулд дэмжлэгтэй холбоо барина уу." + }, + "billing": { + "confirm": { + "update_seat_count": "Та суудлын тоог {newSeatCount} болгож шинэчлэхдээ итгэлтэй байна уу?", + "update_billing_cycle": "Та төлбөрийн мөчлөгийг {newBillingCycle} болгон шинэчлэхдээ итгэлтэй байна уу?" + }, + "cancel_subscription": "Захиалга цуцлах" + }, + "app_console": { + "entries": "Консолын оруулгууд", + "no_entries": "Оруулга алга" + }, + "mockServer": { + "create_modal": { + "title": "Хуурамч сервер үүсгэх", + "name_label": "Хуурамч серверийн нэр", + "name_placeholder": "Хуурамч серверийн нэрийг оруулна уу", + "name_required": "Хуурамч серверийн нэр шаардлагатай", + "collection_source_label": "Цуглуулгын эх сурвалж", + "existing_collection": "Одоо байгаа цуглуулга", + "new_collection": "Шинэ цуглуулга", + "select_collection_label": "Цуглуулга сонгоно уу", + "select_collection_placeholder": "Цуглуулга сонгох", + "collection_required": "Цуглуулга сонгоно уу", + "collection_name_label": "Цуглуулгын нэр", + "collection_name_placeholder": "Цуглуулгын нэрийг оруулна уу", + "collection_name_required": "Цуглуулгын нэр шаардлагатай", + "request_config_label": "Хүсэлтийн тохиргоо", + "add_request": "Хүсэлт нэмэх", + "create_button": "Хуурамч сервер үүсгэх", + "success": "Хуурамч серверийг амжилттай үүсгэсэн", + "error": "Хуурамч сервер үүсгэж чадсангүй", + "no_collections": "Цуглуулга байхгүй байна" + }, + "edit_modal": { + "title": "Хуурамч серверийг засах", + "name_label": "Хуурамч серверийн нэр", + "name_placeholder": "Хуурамч серверийн нэрийг оруулна уу", + "name_required": "Хуурамч серверийн нэр шаардлагатай", + "active_label": "Идэвхтэй", + "url_label": "Хуурамч серверийн URL", + "collection_label": "Цуглуулга", + "update_button": "Хуурамч серверийг шинэчлэх", + "success": "Хуурамч серверийг амжилттай шинэчилсэн", + "error": "Хуурамч серверийг шинэчилж чадсангүй", + "url_copied": "URL-г санах ой руу хуулсан" + }, + "dashboard": { + "title": "Хуурамч серверүүд", + "subtitle": "Өөрийн API хуурамч серверүүдийг үүсгэж удирдах", + "create_button": "Хуурамч сервер үүсгэх", + "create_first": "Өөрийн анхны хуурамч серверийг үүсгэ", + "empty_title": "Хуурамч сервер олдсонгүй", + "empty_description": "Өөрийн API цуглуулгууд дээр тулгуурлан хуурамч серверүүдийг үүсгэн, урд талын хамааралгүйгээр фронт болон гар утасны хөгжүүлэлтийг идэвхжүүлнэ үү.", + "collection": "Цуглуулга", + "active": "Идэвхтэй", + "inactive": "Идэвхгүй", + "mock_url": "Хуурамч URL", + "endpoints": "Төгсгөлийн цэгүүд", + "created": "Үүсгэсэн", + "view_collection": "Цуглуулга үзэх", + "documentation": "Баримт бичиг", + "doc_description": "Энэ URL-г өөрийн аппликешнүүдэд API үндсэн URL болгон ашиглаарай:", + "url_copied": "Хуурамч серверийн URL-г санах ой руу хуулсан", + "delete_title": "Хуурамч серверийг устгах", + "delete_description": "Та энэ хуурамч серверийг устгахдаа итгэлтэй байна уу?", + "delete_success": "Хуурамч серверийг амжилттай устгалаа", + "delete_error": "Хуурамч серверийг устгаж чадсангүй" + } + } +} \ No newline at end of file diff --git a/packages/hoppscotch-common/locales/nl.json b/packages/hoppscotch-common/locales/nl.json new file mode 100644 index 0000000..8c92b50 --- /dev/null +++ b/packages/hoppscotch-common/locales/nl.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Toevoegen", + "autoscroll": "Autoscroll", + "cancel": "Annuleren", + "choose_file": "Kies een bestand", + "clear": "Wis", + "clear_all": "Wis alles", + "clear_history": "Verwijder geschiedenis", + "close": "Sluiten", + "connect": "Verbinden", + "connecting": "Verbinding maken", + "copy": "Kopiëren", + "create": "Aanmaken", + "delete": "Verwijderen", + "disconnect": "Verbinding verbreken", + "dismiss": "Afwijzen", + "dont_save": "Niet opslaan", + "download_file": "Download bestand", + "drag_to_reorder": "Sleep om te herschikken", + "duplicate": "Dupliceren", + "edit": "Bewerking", + "filter": "Filter", + "go_back": "Ga terug", + "go_forward": "Ga verder", + "group_by": "Groeperen op", + "hide_secret": "Hide secret", + "label": "Label", + "learn_more": "Leer meer", + "download_here": "Download here", + "less": "Less", + "more": "Meer", + "new": "Nieuw", + "no": "Nee", + "open_workspace": "Open workspace", + "paste": "Plakken", + "prettify": "Netter opmaken", + "properties": "Eigenschappen", + "remove": "Verwijderen", + "rename": "Naam veranderen", + "restore": "Herstellen", + "save": "Opslaan", + "scroll_to_bottom": "Scroll naar beneden", + "scroll_to_top": "Scroll naar boven", + "search": "Zoeken", + "send": "Versturen", + "share": "Delen", + "show_secret": "Toon geheim", + "start": "Begin", + "starting": "Wordt gestart", + "stop": "Stop", + "to_close": "sluiten", + "to_navigate": "om te navigeren", + "to_select": "om te selecteren", + "turn_off": "Uitschakelen", + "turn_on": "Inschakelen", + "undo": "Ongedaan maken", + "yes": "Ja" + }, + "add": { + "new": "Nieuwe toevoegen", + "star": "Ster toevoegen" + }, + "app": { + "chat_with_us": "Chat met ons", + "contact_us": "Neem contact op", + "cookies": "Cookies", + "copy": "Kopiëren", + "copy_interface_type": "Kopieer interface type", + "copy_user_id": "Kopieer gebruikers authentificatietoken", + "developer_option": "Ontwikkelaarsopties", + "developer_option_description": "Ontwikkelaarstools die helpen bij de ontwikkeling en onderhoud van Hoppscotch.", + "discord": "Discord", + "documentation": "Documentatie", + "github": "GitHub", + "help": "Hulp, feedback en documentatie", + "home": "Home", + "invite": "Uitnodiging", + "invite_description": "In Hoppscotch hebben we een eenvoudige en intuïtieve interface ontworpen voor het maken en beheren van uw API's. Hoppscotch is een tool die je helpt bij het bouwen, testen, documenteren en delen van je API's.", + "invite_your_friends": "Nodig je vrienden uit", + "join_discord_community": "Word lid van onze Discord-community", + "keyboard_shortcuts": "Toetsenbord sneltoetsen", + "name": "Hoppscotch", + "new_version_found": "Nieuwe versie gevonden. Vernieuwen om te updaten.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Options", + "proxy_privacy_policy": "Proxy-privacybeleid", + "reload": "Herladen", + "search": "Zoeken", + "share": "Deel", + "shortcuts": "Sneltoetsen", + "social_description": "Volg ons op sociale media om op de hoogte te blijven van het laatste nieuws, updates en releases.", + "social_links": "Social links", + "spotlight": "Spotlight", + "status": "Toestand", + "status_description": "Controleer de status van de website", + "terms_and_privacy": "Voorwaarden en privacy", + "twitter": "Twitter", + "type_a_command_search": "Typ een opdracht of zoek...", + "we_use_cookies": "Wij gebruiken cookies", + "updated_text": "Hoppscotch is geüpdatet naar v{version} 🎉", + "whats_new": "Wat is er nieuw?", + "see_whats_new": "See what’s new", + "wiki": "Wiki" + }, + "auth": { + "account_exists": "Account bestaat met verschillende inloggegevens. Log in om beide accounts te koppelen", + "all_sign_in_options": "Alle aanmeldmogelijkheden", + "continue_with_auth_provider": "Ga verder met {provider}", + "continue_with_email": "Ga verder met e-mail", + "continue_with_github": "Ga verder met GitHub", + "continue_with_github_enterprise": "Ga verder met GitHub Enterprise", + "continue_with_google": "Ga verder met Google", + "continue_with_microsoft": "Ga verder met Microsoft", + "email": "E-mail", + "logged_out": "Uitgelogd", + "login": "Log in", + "login_success": "Succesvol ingelogd", + "login_to_hoppscotch": "Inloggen op Hoppscotch", + "logout": "Uitloggen", + "re_enter_email": "E-mailadres opnieuw invoeren", + "send_magic_link": "Stuur een magische link", + "sync": "Synchroniseren", + "we_sent_magic_link": "We hebben je een magische link gestuurd!", + "we_sent_magic_link_description": "Controleer je mailbox. We hebben een e-mail gestuurd naar {email}. Het bevat een magische link waarmee je kunt inloggen." + }, + "authorization": { + "generate_token": "Token genereren", + "graphql_headers": "Autorisatieheaders worden als onderdeel van de payload naar connection_init verzonden.", + "include_in_url": "Opnemen in URL", + "inherited_from": "Geërfd van {auth} uit de bovenliggende verzameling {collection} ", + "learn": "Leren hoe", + "oauth": { + "redirect_auth_server_returned_error": "Authenticatieserver heeft een foutstatus geretourneerd", + "redirect_auth_token_request_failed": "Verzoek om het authenticatietoken te verkrijgen is mislukt", + "redirect_auth_token_request_invalid_response": "Ongeldig antwoord van het token-eindpunt bij het aanvragen van een authenticatietoken", + "redirect_invalid_state": "Ongeldige statuswaarde aanwezig in de redirect", + "redirect_no_auth_code": "Geen autorisatiecode aanwezig in de redirect", + "redirect_no_client_id": "Geen client-ID gedefinieerd", + "redirect_no_client_secret": "Geen clientgeheim gedefinieerd", + "redirect_no_code_verifier": "Geen codeverificatie gedefinieerd", + "redirect_no_token_endpoint": "Geen token-eindpunt gedefinieerd", + "something_went_wrong_on_oauth_redirect": "Er is iets misgegaan tijdens de OAuth-omleiding", + "something_went_wrong_on_token_generation": "Er is iets misgegaan tijdens het genereren van het token", + "token_generation_oidc_discovery_failed": "Fout bij het genereren van het token: OpenID Connect Discovery mislukt", + "grant_type": "Granttype", + "grant_type_auth_code": "Autorisatiecode", + "token_fetched_successfully": "Token succesvol opgehaald", + "token_fetch_failed": "Het ophalen van het token is mislukt", + "validation_failed": "Validatie mislukt, controleer de formuliervelden", + "label_authorization_endpoint": "Autorisatie-eindpunt", + "label_client_id": "Client-ID", + "label_client_secret": "Clientgeheim", + "label_code_challenge": "Code-uitdaging", + "label_code_challenge_method": "Methode voor code-uitdaging", + "label_code_verifier": "Codeverificatie", + "label_scopes": "Bereik", + "label_token_endpoint": "Token-eindpunt", + "label_use_pkce": "PKCE gebruiken", + "label_implicit": "Impliciet", + "label_password": "Wachtwoord", + "label_username": "Gebruikersnaam", + "label_auth_code": "Autorisatiecode", + "label_client_credentials": "Clientgegevens" + }, + "pass_key_by": "Pass by", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "Wachtwoord", + "save_to_inherit": "Sla dit verzoek op in een verzameling om de autorisatie over te nemen.", + "token": "token", + "type": "Autorisatietype:", + "username": "Gebruikersnaam" + }, + "collection": { + "created": "Collectie gemaakt", + "different_parent": "Kan de collectie niet opnieuw rangschikken met een andere bovenliggende collectie", + "edit": "Verzameling bewerken", + "import_or_create": "Importeer of maak een collectie", + "import_collection": "Verzameling importeren", + "invalid_name": "Geef een geldige naam op voor de collectie", + "invalid_root_move": "Verzameling al in de root", + "moved": "Succesvol verplaatst", + "my_collections": "Mijn collecties", + "name": "Mijn nieuwe collectie", + "name_length_insufficient": "De naam van de collectie moet minimaal 3 tekens lang zijn", + "new": "Nieuwe collectie", + "order_changed": "Collectievolgorde bijgewerkt", + "properties": "Verzamelingseigenschappen", + "properties_updated": "Verzamelingseigenschappen bijgewerkt", + "renamed": "Collectie hernoemd", + "request_in_use": "Verzoek in gebruik", + "save_as": "Opslaan als", + "save_to_collection": "Opslaan in collectie", + "select": "Selecteer een collectie", + "select_location": "Selecteer een locatie", + "details": "Details", + "select_team": "Selecteer een team", + "team_collections": "Teamcollecties" + }, + "confirm": { + "close_unsaved_tab": "Weet u zeker dat u dit tabblad wilt sluiten?", + "close_unsaved_tabs": "Weet u zeker dat u alle tabbladen wilt sluiten? {count} niet-opgeslagen tabbladen gaan verloren.", + "exit_team": "Weet je zeker dat je dit team wilt verlaten?", + "logout": "Weet u zeker dat u wilt uitloggen?", + "remove_collection": "Weet je zeker dat je deze collectie definitief wilt verwijderen?", + "remove_environment": "Weet u zeker dat u deze omgeving permanent wilt verwijderen?", + "remove_folder": "Weet u zeker dat u deze map definitief wilt verwijderen?", + "remove_history": "Weet je zeker dat je hele geschiedenis permanent wilt verwijderen?", + "remove_request": "Weet u zeker dat u dit verzoek definitief wilt verwijderen?", + "remove_shared_request": "Weet u zeker dat u dit gedeelde verzoek definitief wilt verwijderen?", + "remove_team": "Weet je zeker dat je dit team wilt verwijderen?", + "remove_telemetry": "Weet u zeker dat u zich wilt afmelden voor telemetrie?", + "request_change": "Weet u zeker dat u het huidige verzoek wilt verwijderen; niet-opgeslagen wijzigingen gaan verloren.", + "save_unsaved_tab": "Wilt u de wijzigingen die op dit tabblad zijn aangebracht opslaan?", + "sync": "Weet u zeker dat u deze werkruimte wilt synchroniseren?", + "delete_access_token": "Weet u zeker dat u het toegangstoken {tokenLabel} wilt verwijderen?" + }, + "context_menu": { + "add_parameters": "Toevoegen aan parameters", + "open_request_in_new_tab": "Verzoek openen in een nieuw tabblad", + "set_environment_variable": "Instellen als variabele" + }, + "cookies": { + "modal": { + "cookie_expires": "Verloopt", + "cookie_name": "Naam", + "cookie_path": "Pad", + "cookie_string": "Cookie-string", + "cookie_value": "Waarde", + "empty_domain": "Domein is leeg", + "empty_domains": "Domeinlijst is leeg", + "enter_cookie_string": "Voer cookie-string in", + "interceptor_no_support": "Uw momenteel geselecteerde interceptor ondersteunt geen cookies. Selecteer een andere interceptor en probeer het opnieuw.", + "managed_tab": "Beheerd", + "new_domain_name": "Nieuwe domeinnaam", + "no_cookies_in_domain": "Geen cookies ingesteld voor dit domein", + "raw_tab": "Rauw", + "set": "Een cookie instellen" + } + }, + "count": { + "header": "Kop {count}", + "message": "Bericht {count}", + "parameter": "Parameter {count}", + "protocol": "Protocol {count}", + "value": "Waarde {count}", + "variable": "Variabele {count}" + }, + "documentation": { + "generate": "Documentatie genereren", + "generate_message": "Importeer elke Hoppscotch-verzameling om onderweg API-documentatie te genereren." + }, + "empty": { + "authorization": "Dit verzoek gebruikt geen autorisatie", + "body": "Dit verzoek heeft geen hoofdtekst", + "collection": "Collectie is leeg", + "collections": "Collecties zijn leeg", + "documentation": "Verbinding maken met een GraphQL-eindpunt om documentatie te bekijken", + "endpoint": "Endpoint cannot be empty", + "environments": "Omgevingen zijn leeg", + "folder": "Map is leeg", + "headers": "Dit verzoek heeft geen headers", + "history": "Geschiedenis is leeg", + "invites": "Invite list is empty", + "members": "Team is leeg", + "parameters": "Dit verzoek heeft geen parameters", + "pending_invites": "Er zijn geen uitnodigingen in behandeling voor dit team.", + "profile": "Log in om uw profiel te bekijken", + "protocols": "Protocollen zijn leeg", + "request_variables": "Dit verzoek heeft geen verzoekvariabelen", + "schema": "Verbinding maken met een GraphQL-eindpunt", + "secret_environments": "Geheimen worden niet gesynchroniseerd met Hoppscotch", + "shared_requests": "Gedeelde verzoeken zijn leeg", + "shared_requests_logout": "Log in om uw gedeelde verzoeken te bekijken of een nieuwe aan te maken", + "subscription": "Abonnementen zijn leeg", + "team_name": "Teamnaam leeg", + "teams": "Teams zijn leeg", + "tests": "Er zijn geen tests voor dit verzoek", + "access_tokens": "Toegangstokens zijn leeg", + "shortcodes": "Shortcodes zijn leeg" + }, + "environment": { + "add_to_global": "Toevoegen aan Globale", + "added": "Toevoeging aan omgeving", + "create_new": "Nieuwe omgeving maken", + "created": "Omgeving gemaakt", + "deleted": "Omgeving verwijderen", + "duplicated": "Omgeving gedupliceerd", + "edit": "Omgeving bewerken", + "empty_variables": "Geen variabelen", + "global": "Globaal", + "global_variables": "Globale variabelen", + "import_or_create": "Importeer of creëer een omgeving", + "invalid_name": "Geef een geldige naam voor de omgeving", + "list": "Omgevingsvariabelen", + "my_environments": "Mijn omgevingen", + "name": "Naam", + "nested_overflow": "genest Omgevingsvariabelen zijn beperkt tot 10 niveaus", + "new": "Nieuwe omgeving", + "no_active_environment": "Geen actieve omgeving", + "no_environment": "Geen omgeving", + "no_environment_description": "Er zijn geen omgevingen geselecteerd. Kies wat u met de volgende variabelen wilt doen.", + "quick_peek": "Snelle preview van de omgeving", + "replace_with_variable": "Vervang door variabele", + "scope": "Bereik", + "secrets": "Geheimen", + "secret_value": "Geheime waarde", + "select": "Selecteer omgeving", + "set": "Stel omgeving in", + "set_as_environment": "Instellen als omgeving", + "team_environments": "Teamomgevingen", + "title": "Omgevingen", + "updated": "Omgevingsupdate", + "value": "Waarde", + "variable": "Variabele", + "variables": "Variabelen", + "variable_list": "Variabele lijst", + "properties": "Omgevingseigenschappen", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Kan authenticatieproviders niet laden", + "browser_support_sse": "Deze browser lijkt geen ondersteuning te hebben voor door de server verzonden gebeurtenissen.", + "check_console_details": "Controleer het consolelogboek voor details.", + "check_how_to_add_origin": "Controleer hoe u een origin kunt toevoegen", + "curl_invalid_format": "cURL is niet correct geformatteerd", + "danger_zone": "Gevarenzone", + "delete_account": "Uw account is momenteel eigenaar van deze teams:", + "delete_account_description": "U moet uzelf verwijderen,het eigendom overdragen of deze teams verwijderen voordat u uw account kunt verwijderen.", + "empty_email_address": "E-mailadres mag niet leeg zijn", + "empty_profile_name": "Profielnaam mag niet leeg zijn", + "empty_req_name": "Lege verzoeksnaam", + "f12_details": "(F12 voor details)", + "gql_prettify_invalid_query": "Kon een ongeldige zoekopdracht niet mooier maken, syntaxisfouten in de query oplossen en opnieuw proberen", + "incomplete_config_urls": "Onvolledige configuratie-URL's", + "incorrect_email": "Onjuist e-mailadres", + "invalid_link": "Ongeldige link", + "invalid_link_description": "De link waarop u hebt geklikt is ongeldig of verlopen.", + "invalid_embed_link": "De insluiting bestaat niet of is ongeldig.", + "json_parsing_failed": "Ongeldige JSON", + "json_prettify_invalid_body": "Kon een ongeldige hoofdtekst niet mooier maken, json-syntaxisfouten oplossen en opnieuw proberen", + "network_error": "Er lijkt een netwerkfout te zijn opgetreden. Probeer het alstublieft opnieuw.", + "network_fail": "Kan verzoek niet versturen", + "no_collections_to_export": "Geen collecties om te exporteren. Maak een collectie aan om te beginnen.", + "no_duration": "Geen duur", + "no_environments_to_export": "Geen omgevingen om te exporteren. Maak een omgeving aan om te beginnen.", + "no_results_found": "Geen overeenkomsten gevonden", + "page_not_found": "Deze pagina kon niet worden gevonden", + "please_install_extension": "Installeer de extensie en voeg origin toe aan de extensie.", + "proxy_error": "Proxyfout", + "same_email_address": "Het bijgewerkte e-mailadres is hetzelfde als het huidige e-mailadres", + "same_profile_name": "De bijgewerkte profielnaam is hetzelfde als de huidige profielnaam", + "script_fail": "Kon pre-verzoekscript niet uitvoeren", + "something_went_wrong": "Er is iets fout gegaan", + "post_request_script_fail": "Kon het post-request script niet uitvoeren", + "reading_files": "Fout tijdens het lezen van een of meer bestanden.", + "fetching_access_tokens_list": "Er is iets misgegaan tijdens het ophalen van de lijst met tokens", + "generate_access_token": "Er is iets misgegaan tijdens het genereren van het toegangstoken", + "delete_access_token": "Er is iets misgegaan tijdens het verwijderen van het toegangstoken" + }, + "export": { + "as_json": "Exporteren als JSON", + "create_secret_gist": "Maak secret Gist", + "create_secret_gist_tooltip_text": "Exporteren als secret Gist", + "failed": "Er is iets misgegaan tijdens het exporteren", + "secret_gist_success": "Succesvol geëxporteerd als secret Gist", + "require_github": "Log in met GitHub om een secret Gist te maken", + "title": "Export", + "success": "Succesvol geëxporteerd", + "gist_created": "Gist gemaakt" + }, + "filter": { + "all": "Alle", + "none": "Geen", + "starred": "Favorieten" + }, + "folder": { + "created": "Map aangemaakt", + "edit": "Map bewerken", + "invalid_name": "Geef een naam op voor de map", + "name_length_insufficient": "Mapnaam moet minimaal 3 tekens lang zijn", + "new": "Nieuwe map", + "renamed": "Map hernoemd" + }, + "graphql": { + "connection_switch_confirm": "Wilt u verbinden met het laatste GraphQL-eindpunt?", + "connection_switch_new_url": "Als u overschakelt naar een tabblad,wordt u losgekoppeld van de actieve GraphQL-verbinding. De nieuwe verbinding URL is", + "connection_switch_url": "U bent verbonden met een GraphQL-eindpunt,de verbinding URL is", + "mutations": "Mutaties", + "schema": "Schema", + "subscriptions": "Abonnementen", + "switch_connection": "Verander verbinding", + "url_placeholder": "Voer een GraphQL-eindpunt URL in" + }, + "graphql_collections": { + "title": "GraphQL Collecties" + }, + "group": { + "time": "Tijd", + "url": "URL" + }, + "header": { + "install_pwa": "Installeer app", + "login": "Log in", + "save_workspace": "Bewaar mijn werkruimte" + }, + "helpers": { + "authorization": "De autorisatieheader wordt automatisch gegenereerd wanneer u het verzoek verzendt.", + "collection_properties_authorization": " Deze autorisatie wordt ingesteld voor elk verzoek in deze collectie.", + "collection_properties_header": "Deze header wordt ingesteld voor elk verzoek in deze collectie.", + "generate_documentation_first": "Genereer eerst documentatie", + "network_fail": "Kan het API-eindpunt niet bereiken. Controleer uw netwerkverbinding en probeer het opnieuw.", + "offline": "Je lijkt offline te zijn. Gegevens in deze werkruimte zijn mogelijk niet up-to-date.", + "offline_short": "Je lijkt offline te zijn.", + "post_request_tests": "Testscripts zijn geschreven in JavaScript en worden uitgevoerd nadat het antwoord is ontvangen.", + "pre_request_script": "Pre-request scripts zijn geschreven in JavaScript en worden uitgevoerd voordat het verzoek wordt verzonden.", + "script_fail": "Het lijkt erop dat er een storing is in het pre-request script. Controleer de onderstaande fout en corrigeer het script dienovereenkomstig.", + "post_request_script_fail": "Er lijkt een fout te zijn in het test script. Corrigeer de fouten en voer de tests opnieuw uit.", + "post_request_script": "Schrijf een testscript om foutopsporing te automatiseren." + }, + "hide": { + "collection": "Collectie Paneel Inklappen", + "more": "Meer verbergen", + "preview": "Voorbeeld verbergen", + "sidebar": "Verberg de zijbalk" + }, + "import": { + "collections": "Collecties importeren", + "curl": "cURL-commando importeren", + "environments_from_gist": "Importeer Vanuit Gist", + "environments_from_gist_description": "Importeer Hoppscotch Omgevingen Van Gist", + "failed": "Importeren mislukt", + "from_file": "Importeer van bestand", + "from_gist": "Importeren uit Gist", + "from_gist_description": "Importeer van Gist URL", + "from_insomnia": "Importeer van Insomnia", + "from_insomnia_description": "Importeer van Insomnia collectie", + "from_json": "Importeer van Hoppscotch", + "from_json_description": "Importeer van Hoppscotch collectiebestand", + "from_my_collections": "Importeren uit Mijn collecties", + "from_my_collections_description": "Importeer van Mijn Collecties bestand", + "from_openapi": "Importeer van OpenAPI", + "from_openapi_description": "Importeer van OpenAPI specificatiebestand (YML/JSON)", + "from_postman": "Importeer van Postman", + "from_postman_description": "Importeer van Postman collectie", + "from_url": "Importeer van URL", + "gist_url": "Vul de hoofd-URL in", + "gql_collections_from_gist_description": "Importeer GraphQL Collecties Van Gist", + "hoppscotch_environment": "Hoppscotch Omgeving", + "hoppscotch_environment_description": "Importeer Hoppscotch Omgeving JSON bestand", + "import_from_url_invalid_fetch": "Kon geen data ophalen van de url", + "import_from_url_invalid_file_format": "Fout tijdens importeren van collecties", + "import_from_url_invalid_type": "Niet-ondersteund type. geaccepteerde waarden zijn 'hoppscotch','openapi','postman','insomnia'", + "import_from_url_success": "Collecties Geïmporteerd", + "insomnia_environment_description": "Importeer Insomnia Omgeving van een JSON/YAML bestand", + "json_description": "Importeer collecties van een Hoppscotch Collecties JSON bestand", + "postman_environment": "Postman Omgeving", + "postman_environment_description": "Importeer Postman Omgeving van een JSON bestand", + "title": "Importeren", + "file_size_limit_exceeded_warning_multiple_files": "De geselecteerde bestanden overschrijden de aanbevolen limiet van 10 MB. Alleen de eerste {files} die zijn geselecteerd, worden geïmporteerd.", + "file_size_limit_exceeded_warning_single_file": "Het momenteel geselecteerde bestand overschrijdt de aanbevolen limiet van 10 MB. Selecteer een ander bestand.", + "success": "Geïmporteerd met succes" + }, + "inspections": { + "description": "Controleer mogelijke fouten", + "environment": { + "add_environment": "Toevoegen aan Omgeving", + "add_environment_value": "Voeg waarde toe", + "empty_value": "Omgevingswaarde is leeg voor de variabele '{variable}'", + "not_found": "Omgevingsvariabele “{environment}” niet gevonden." + }, + "header": { + "cookie": "De browser staat Hoppscotch niet toe om de Cookie Header in te stellen. Terwijl we werken aan de Hoppscotch Desktop App (binnenkort beschikbaar),gebruik in plaats daarvan de Authorization Header." + }, + "response": { + "401_error": "Controleer uw authenticatiegegevens.", + "404_error": "Controleer uw verzoek URL en methode type.", + "cors_error": "Controleer uw Cross-Origin Resource Sharing configuratie.", + "default_error": "Controleer uw verzoek.", + "network_error": "Controleer uw netwerkverbinding." + }, + "title": "Inspecteur", + "url": { + "extension_not_installed": "Extensie niet geïnstalleerd.", + "extension_unknown_origin": "Zorg ervoor dat je de oorsprong van het API-eindpunt hebt toegevoegd aan de lijst met Hoppscotch-browserextensies.", + "extention_enable_action": "Browser-extensie inschakelen", + "extention_not_enabled": "Extensie niet ingeschakeld." + } + }, + "layout": { + "collapse_collection": "Collecties inklappen", + "collapse_sidebar": "Zijbalk inklappen", + "column": "Verticale lay-out", + "name": "Lay-out", + "row": "Horizontale lay-out" + }, + "modal": { + "close_unsaved_tab": "U heeft niet-opgeslagen wijzigingen", + "collections": "Collecties", + "confirm": "Bevestigen", + "customize_request": "Verzoek aanpassen", + "edit_request": "Verzoek bewerken", + "import_export": "Importeren / exporteren", + "share_request": "Verzoek delen" + }, + "mqtt": { + "already_subscribed": "U bent al geabonneerd op dit onderwerp.", + "clean_session": "Sessie opschonen", + "clear_input": "Invoer wissen", + "clear_input_on_send": "Invoer wissen bij verzenden", + "client_id": "Client-ID", + "color": "Kies een kleur", + "communication": "Communicatie", + "connection_config": "Verbindingsconfiguratie", + "connection_not_authorized": "Deze MQTT-verbinding maakt geen gebruik van authenticatie.", + "invalid_topic": "Geef een onderwerp op voor het abonnement", + "keep_alive": "Bestand behouden", + "log": "Logboek", + "lw_message": "Laatste-Wil-bericht", + "lw_qos": "Laatste-Wil-QoS", + "lw_retain": "Laatste zal behouden", + "lw_topic": "Laatste Wilsonderwerp", + "message": "Bericht", + "new": "Nieuw abonnement", + "not_connected": "Start eerst een MQTT-verbinding.", + "publish": "Publiceren", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Abonneren", + "topic": "Onderwerp", + "topic_name": "Onderwerpnaam", + "topic_title": "Onderwerp uitgever / abonneren", + "unsubscribe": "Afmelden", + "url": "URL" + }, + "navigation": { + "doc": "Documenten", + "graphql": "GraphQL", + "profile": "Profiel", + "realtime": "Real-time", + "rest": "REST", + "settings": "Instellingen" + }, + "preRequest": { + "javascript_code": "JavaScript-code", + "learn": "Documentatie lezen", + "script": "Script vooraf aanvragen", + "snippets": "Fragmenten" + }, + "profile": { + "app_settings": "App-instellingen", + "default_hopp_displayname": "Naamloze gebruiker", + "editor": "Editor", + "editor_description": "Editors kunnen verzoeken toevoegen,bewerken en verwijderen.", + "email_verification_mail": "Er is een verificatiemail naar uw e-mailadres verzonden. Klik op de link om uw e-mailadres te verifiëren.", + "no_permission": "U hebt geen toestemming om deze actie uit te voeren.", + "owner": "Eigenaar", + "owner_description": "Eigenaren kunnen verzoeken,collecties en teamleden toevoegen,bewerken en verwijderen.", + "roles": "Rollen", + "roles_description": "Rollen worden gebruikt om de toegang tot de gedeelde collecties te beheren.", + "updated": "Profiel bijgewerkt", + "viewer": "Kijker", + "viewer_description": "Kijkers kunnen alleen verzoeken bekijken en gebruiken." + }, + "remove": { + "star": "Ster verwijderen" + }, + "request": { + "added": "Verzoek toegevoegd", + "authorization": "autorisatie", + "body": "Body", + "choose_language": "Kies een taal", + "content_type": "Type inhoud", + "content_type_titles": { + "others": "Andere", + "structured": "Gestructureerd", + "text": "Tekst" + }, + "different_collection": "Kan verzoek uit verschillende collecties niet opnieuw rangschikken", + "duplicated": "Verzoek gedupliceerd", + "duration": "Duur", + "enter_curl": "Voer cURL . in", + "generate_code": "Genereer code", + "generated_code": "Gegenereerde code", + "go_to_authorization_tab": "Ga naar het tabblad Autorisatie", + "go_to_body_tab": "Ga naar het Body tabblad", + "header_list": "Koplijst", + "invalid_name": "Geef een naam op voor het verzoek", + "method": "Methode", + "moved": "Verzoek verplaatst", + "name": "Naam verzoek", + "new": "Nieuw verzoek", + "order_changed": "Verzoek volgorde bijgewerkt", + "override": "Overschrijven", + "override_help": "Stel Content-Type in Headers in", + "overridden": "Overschreven", + "parameter_list": "Queryparameters", + "parameters": "Parameters:", + "path": "Pad", + "payload": "Payload", + "query": "Vraag", + "raw_body": "Raw Body", + "rename": "Verzoek hernoemen", + "renamed": "Verzoek hernoemd", + "request_variables": "Verzoek variabelen", + "run": "Uitvoeren", + "save": "Opslaan", + "save_as": "Opslaan als", + "saved": "Verzoek opgeslagen", + "share": "Deel", + "share_description": "Deel Hoppscotch met je vrienden", + "share_request": "Deel Verzoek", + "stop": "Stoppen", + "title": "Verzoek", + "type": "Verzoektype", + "url": "URL", + "url_placeholder": "Voer een URL in of plak een cURL-opdracht", + "variables": "Variabelen", + "view_my_links": "Bekijk mijn links", + "copy_link": "Kopieerlink" + }, + "response": { + "audio": "Audio", + "body": "Reactie inhoud", + "filter_response_body": "Filter JSON response body (uses jq syntax)", + "headers": "Headers", + "html": "HTML", + "image": "Afbeelding", + "json": "JSON", + "pdf": "PDF", + "preview_html": "Voorbeeld van HTML", + "raw": "Ruw", + "size": "Grootte", + "status": "Status", + "time": "Tijd", + "title": "Antwoord", + "video": "Video", + "waiting_for_connection": "wachten op verbinding", + "xml": "XML" + }, + "settings": { + "accent_color": "Accentkleur", + "account": "Account", + "account_deleted": "Uw account is verwijderd", + "account_description": "Beheer uw accountinstellingen.", + "account_email_description": "Uw primaire e-mailadres.", + "account_name_description": "Dit is uw weergavenaam.", + "additional": "Aanvullende instellingen", + "background": "Achtergrond", + "black_mode": "Zwart", + "choose_language": "Kies een taal", + "dark_mode": "Donker", + "delete_account": "Account verwijderen", + "delete_account_description": "Als u uw account verwijdert,worden al uw gegevens permanent verwijderd. Deze actie kan niet ongedaan worden gemaakt.", + "expand_navigation": "Navigatie uitvouwen", + "experiments": "Experimentele functies", + "experiments_notice": "Dit is een verzameling experimenten waaraan we werken en die nuttig,leuk,beide of geen van beide kunnen zijn. Ze zijn niet definitief en mogelijk niet stabiel. Als er iets raars gebeurt,raak niet in paniek. Zet de functie uit en ga verder.", + "extension_ver_not_reported": "Niet gerapporteerd", + "extension_version": "Extensie versie", + "extensions": "Extensies", + "extensions_use_toggle": "Gebruik de browserextensie om verzoeken te versturen (indien aanwezig)", + "follow": "Volg ons", + "interceptor": "Interceptor", + "interceptor_description": "Middleware tussen applicatie en API's.", + "language": "Taal", + "light_mode": "Licht", + "official_proxy_hosting": "Officiële proxy wordt gehost door Hoppscotch.", + "profile": "Profiel", + "profile_description": "Werk uw profielgegevens bij", + "profile_email": "E-mailadres", + "profile_name": "Profielnaam", + "proxy": "Proxy", + "proxy_url": "Proxy-URL", + "proxy_use_toggle": "Gebruik de proxy-middleware om verzoeken te versturen", + "read_the": "Lees de", + "reset_default": "Reset naar standaard", + "short_codes": "Korte codes", + "short_codes_description": "Korte codes die door u zijn aangemaakt.", + "sidebar_on_left": "Zijbalk aan de linkerkant", + "sync": "Synchroniseren", + "sync_collections": "Collecties", + "sync_description": "Deze instellingen worden gesynchroniseerd met de cloud.", + "sync_environments": "Omgevingen", + "sync_history": "Geschiedenis", + "system_mode": "Systeem", + "telemetry": "Telemetrie", + "telemetry_helps_us": "Telemetrie helpt ons om onze diensten te personaliseren en u de beste ervaring te bieden.", + "theme": "Thema", + "theme_description": "Pas uw applicatiethema aan.", + "use_experimental_url_bar": "Experimentele URL-balk gebruiken met omgevingsmarkering", + "user": "Gebruiker", + "verified_email": "E-mail geverifieerd", + "verify_email": "Verifieer e-mail" + }, + "shared_requests": { + "button": "Knop", + "button_info": "Maak een 'Run in Hoppscotch'-knop voor uw website,blog of README.", + "copy_html": "Kopieer HTML", + "copy_link": "Kopieer link", + "copy_markdown": "Kopieer Markdown", + "creating_widget": "Widget aanmaken", + "customize": "Aanpassen", + "deleted": "Gedeeld verzoek verwijderd", + "description": "Selecteer een widget,u kunt deze later wijzigen en aanpassen", + "embed": "Insluiten", + "embed_info": "Voeg een mini 'Hoppscotch API Playground' toe aan uw website,blog of documentatie.", + "link": "Link", + "link_info": "Maak een deelbare link om met iedereen op internet te delen met alleen-lezen toegang.", + "modified": "Gedeeld verzoek aangepast", + "not_found": "Gedeeld verzoek niet gevonden", + "open_new_tab": "Open in nieuw tabblad", + "preview": "Voorbeeld", + "run_in_hoppscotch": "Uitvoeren in Hoppscotch", + "theme": { + "dark": "Donker", + "light": "Licht", + "system": "Systeem", + "title": "Thema" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Huidig menu sluiten", + "command_menu": "Zoek- en commandomenu", + "help_menu": "Help-menu", + "show_all": "Toetsenbord sneltoetsen", + "title": "Algemeen" + }, + "miscellaneous": { + "invite": "Nodig mensen uit voor Hoppscotch", + "title": "Diversen" + }, + "navigation": { + "back": "Ga terug naar de vorige pagina", + "documentation": "Ga naar de documentatiepagina", + "forward": "Ga verder naar de volgende pagina", + "graphql": "Ga naar de GraphQL-pagina", + "profile": "Ga naar de profielpagina", + "realtime": "Ga naar de Realtime-pagina", + "rest": "Ga naar de REST-pagina", + "settings": "Ga naar de instellingenpagina", + "title": "Navigatie" + }, + "others": { + "prettify": "Maak de inhoud van de editor netjes", + "title": "Overige" + }, + "request": { + "delete_method": "Selecteer DELETE-methode", + "get_method": "Selecteer GET-methode", + "head_method": "Selecteer HEAD-methode:", + "import_curl": "Importeer cURL", + "method": "Methode", + "next_method": "Selecteer volgende methode", + "post_method": "Selecteer POST-methode", + "previous_method": "Selecteer vorige methode", + "put_method": "Selecteer PUT-methode", + "rename": "Hernoem verzoek", + "reset_request": "Verzoek resetten", + "save_request": "Verzoek opslaan", + "save_to_collections": "Opslaan in collecties", + "send_request": "Verstuur verzoek", + "share_request": "Verzoek delen", + "show_code": "Genereer codefragment", + "title": "Verzoek", + "copy_request_link": "Kopieer verzoeklink" + }, + "response": { + "copy": "Kopieer antwoord naar klembord", + "download": "Download antwoord als bestand", + "title": "Antwoord" + }, + "theme": { + "black": "Schakel thema naar zwart", + "dark": "Schakel thema naar donker", + "light": "Schakel thema naar licht", + "system": "Schakel thema naar systeemmodus", + "title": "Thema" + } + }, + "show": { + "code": "Toon code", + "collection": "Collectiepaneel uitklappen", + "more": "Laat meer zien", + "sidebar": "Toon zijbalk" + }, + "socketio": { + "communication": "Communicatie", + "connection_not_authorized": "Deze SocketIO-verbinding gebruikt geen authenticatie.", + "event_name": "Evenementnaam", + "events": "Evenementen", + "log": "Logboek", + "url": "URL" + }, + "spotlight": { + "change_language": "Wijzig taal", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Opslaan als nieuw verzoek", + "select_method": "Selecteer methode", + "switch_to": "Overschakelen naar", + "tab_authorization": "Tabblad Autorisatie", + "tab_body": "Tabblad Body", + "tab_headers": "Tabblad Headers", + "tab_parameters": "Tabblad Parameters", + "tab_pre_request_script": "Tabblad Pre-request script", + "tab_query": "Tabblad Query", + "tab_tests": "Tabblad Tests", + "tab_variables": "Tabblad Variabelen" + }, + "response": { + "copy": "Kopieer antwoord", + "download": "Download antwoord als bestand", + "title": "Antwoord" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Thema", + "user": "Gebruiker" + }, + "settings": { + "change_interceptor": "Wijzig Interceptor", + "change_language": "Wijzig taal", + "theme": { + "black": "Zwart", + "dark": "Donker", + "light": "Licht", + "system": "Systeemvoorkeur" + } + }, + "tab": { + "close_current": "Sluit huidig tabblad", + "close_others": "Sluit alle andere tabbladen", + "duplicate": "Dupliceer huidig tabblad", + "new_tab": "Open een nieuw tabblad", + "title": "Tabbladen" + }, + "workspace": { + "delete": "Verwijder huidig team", + "edit": "Bewerk huidig team", + "invite": "Nodig mensen uit voor team", + "new": "Nieuw team aanmaken", + "switch_to_personal": "Schakel naar uw persoonlijke werkruimte", + "title": "Teams" + }, + "phrases": { + "try": "Probeer", + "import_collections": "Importeer collecties", + "create_environment": "Maak omgeving aan", + "create_workspace": "Maak werkruimte aan", + "share_request": "Deel verzoek" + } + }, + "sse": { + "event_type": "Evenementtype", + "log": "Logboek", + "url": "URL" + }, + "state": { + "bulk_mode": "Bulkbewerking", + "bulk_mode_placeholder": "Invoer wordt gescheiden door een nieuwe regel\nSleutels en waarden worden gescheiden door:\nVoeg # toe aan elke rij die u wilt toevoegen, maar blijf uitgeschakeld", + "cleared": "gewist", + "connected": "Verbonden", + "connected_to": "Verbonden met {naam}", + "connecting_to": "Verbinding maken met {naam}...", + "connection_error": "Failed to connect", + "connection_failed": "Connection failed", + "connection_lost": "Connection lost", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "Gekopieerd naar het klembord", + "deleted": "verwijderd", + "deprecated": "VEROUDERD", + "disabled": "uitgeschakeld", + "disconnected": "Verbinding verbroken", + "disconnected_from": "Verbinding verbroken met {name}", + "docs_generated": "Documentatie gegenereerd", + "download_failed": "Download failed", + "download_started": "Download gestart", + "enabled": "ingeschakeld", + "file_imported": "Bestand geïmporteerd", + "finished_in": "Klaar in {duration} ms", + "hide": "Hide", + "history_deleted": "Geschiedenis verwijderd", + "linewrap": "Regels afbreken", + "loading": "Bezig met laden...", + "message_received": "Message: {message} arrived on topic: {topic}", + "mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}", + "none": "Geen", + "nothing_found": "Niets gevonden voor", + "published_error": "Something went wrong while publishing msg: {topic} to topic: {message}", + "published_message": "Published message: {message} to topic: {topic}", + "reconnection_error": "Failed to reconnect", + "show": "Show", + "subscribed_failed": "Failed to subscribe to topic: {topic}", + "subscribed_success": "Successfully subscribed to topic: {topic}", + "unsubscribed_failed": "Failed to unsubscribe from topic: {topic}", + "unsubscribed_success": "Successfully unsubscribed from topic: {topic}", + "waiting_send_request": "Wachten om verzoek te versturen" + }, + "support": { + "changelog": "Lees meer over de nieuwste releases", + "chat": "Vragen? Chat met ons!", + "community": "Stel vragen en help anderen", + "documentation": "Lees meer over Hoppscotch", + "forum": "Stel vragen en krijg antwoorden", + "github": "Volg ons op Github", + "shortcuts": "Sneller door de app bladeren", + "title": "Ondersteuning", + "twitter": "Volg ons op Twitter", + "team": "Neem contact op met het team" + }, + "tab": { + "authorization": "Autorisatie", + "body": "Inhoud", + "close": "Tabblad sluiten", + "close_others": "Andere tabbladen sluiten", + "collections": "Collecties", + "documentation": "Documentatie", + "duplicate": "Tabblad dupliceren", + "environments": "Omgevingen", + "headers": "Headers", + "history": "Geschiedenis", + "mqtt": "MQTT", + "parameters": "Parameters", + "pre_request_script": "Pre-request scripts", + "queries": "Queries", + "query": "Query", + "schema": "Schema", + "shared_requests": "Gedeelde verzoeken", + "codegen": "Genereer code", + "code_snippet": "Codefragment", + "share_tab_request": "Deel tabbladverzoek", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Testen", + "types": "Types", + "variables": "Variabelen", + "websocket": "WebSocket" + }, + "team": { + "already_member": "U bent al lid van dit team. Neem contact op met de teambeheerder.", + "create_new": "Nieuw team maken", + "deleted": "Team verwijderd", + "edit": "Team bewerken", + "email": "E-mail", + "email_do_not_match": "E-mailadres komt niet overeen met uw accountgegevens. Neem contact op met de teambeheerder.", + "exit": "Team verlaten", + "exit_disabled": "Alleen de eigenaar kan het team niet verlaten", + "failed_invites": "Mislukte uitnodigingen", + "invalid_coll_id": "Ongeldig collectie-ID", + "invalid_email_format": "E-mailindeling is ongeldig", + "invalid_id": "Ongeldig team-ID. Neem contact op met de teambeheerder.", + "invalid_invite_link": "Ongeldige uitnodigingslink", + "invalid_invite_link_description": "De link die u heeft gevolgd is ongeldig. Neem contact op met de teambeheerder.", + "invalid_member_permission": "Geef een geldige toestemming aan het teamlid", + "invite": "Uitnodigen", + "invite_more": "Meer uitnodigen", + "invite_tooltip": "Nodig mensen uit voor deze werkruimte", + "invited_to_team": "{owner} heeft u uitgenodigd om lid te worden van {team}", + "join": "Uitnodiging geaccepteerd", + "join_team": "Word lid van {team}", + "joined_team": "U bent lid geworden van {team}", + "joined_team_description": "U bent nu lid van dit team", + "left": "Je hebt het team verlaten", + "login_to_continue": "Log in om door te gaan", + "login_to_continue_description": "U moet ingelogd zijn om lid te worden van een team.", + "logout_and_try_again": "Log uit en meld u aan met een ander account", + "member_has_invite": "Dit e-mailadres heeft al een uitnodiging. Neem contact op met de teambeheerder.", + "member_not_found": "Lid niet gevonden. Neem contact op met de teambeheerder.", + "member_removed": "Gebruiker verwijderd", + "member_role_updated": "Gebruikersrollen bijgewerkt", + "members": "Leden", + "more_members": "+{count} meer", + "name_length_insufficient": "Teamnaam moet minimaal 6 tekens lang zijn", + "name_updated": "Teamnaam bijgewerkt", + "new": "Nieuw team", + "new_created": "Nieuw team gemaakt", + "new_name": "Mijn nieuwe team", + "no_access": "U heeft geen bewerkingsrechten voor deze collecties", + "no_invite_found": "Uitnodiging niet gevonden. Neem contact op met de teambeheerder.", + "no_request_found": "Verzoek niet gevonden.", + "not_found": "Team niet gevonden. Neem contact op met de teambeheerder.", + "not_valid_viewer": "U bent geen geldige kijker. Neem contact op met de teambeheerder.", + "parent_coll_move": "Kan collectie niet naar een subcollectie verplaatsen", + "pending_invites": "Uitnodigingen in behandeling", + "permissions": "Rechten", + "same_target_destination": "Zelfde doel en bestemming", + "saved": "Team opgeslagen", + "select_a_team": "Selecteer een team", + "success_invites": "Succesvolle uitnodigingen", + "title": "teams", + "we_sent_invite_link": "We hebben een uitnodigingslink naar alle genodigden gestuurd!", + "invite_sent_smtp_disabled": "Uitnodigingslinks gegenereerd", + "we_sent_invite_link_description": "Vraag alle genodigden hun inbox te controleren. Klik op de link om lid te worden van het team.", + "invite_sent_smtp_disabled_description": "Het verzenden van uitnodigingsmails is uitgeschakeld voor deze Hoppscotch-instantie. Gebruik de knop Link kopiëren om de uitnodigingslink handmatig te delen.", + "copy_invite_link": "Kopieer uitnodigingslink", + "search_title": "Teamverzoeken", + "join_beta": "Doe mee aan het bètaprogramma om toegang te krijgen tot teams." + }, + "team_environment": { + "deleted": "Omgeving verwijderd", + "duplicate": "Omgeving gedupliceerd", + "not_found": "Omgeving niet gevonden." + }, + "test": { + "failed": "test mislukt", + "javascript_code": "JavaScript-code", + "learn": "Documentatie lezen", + "passed": "test geslaagd", + "report": "Testrapport", + "results": "Testresultaten", + "script": "Script", + "snippets": "Fragmenten" + }, + "websocket": { + "communication": "Communicatie", + "log": "Logboek", + "message": "Bericht", + "protocols": "Protocollen", + "url": "URL" + }, + "workspace": { + "change": "Wijzig werkruimte", + "personal": "Mijn werkruimte", + "other_workspaces": "Mijn werkruimtes", + "team": "Teamwerkruimte", + "title": "Werkruimtes" + }, + "site_protection": { + "login_to_continue": "Log in om door te gaan", + "login_to_continue_description": "U moet ingelogd zijn om toegang te krijgen tot deze Hoppscotch Enterprise-instantie.", + "error_fetching_site_protection_status": "Er is iets misgegaan bij het ophalen van de sitebeveiligingsstatus" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Persoonlijke toegangstokens", + "section_description": "Persoonlijke toegangstokens helpen u momenteel de CLI met uw Hoppscotch-account te verbinden", + "last_used_on": "Laatst gebruikt op", + "expires_on": "Verloopt op", + "no_expiration": "Geen vervaldatum", + "expired": "Verlopen", + "copy_token_warning": "Kopieer nu uw persoonlijke toegangstoken. U kunt het later niet meer bekijken!", + "token_purpose": "Waar is dit token voor?", + "expiration_label": "Vervaldatum", + "scope_label": "Scope", + "workspace_read_only_access": "Alleen-lezen toegang tot werkruimtedata.", + "personal_workspace_access_limitation": "Persoonlijke toegangstokens hebben geen toegang tot uw persoonlijke werkruimte.", + "generate_token": "Genereer token", + "invalid_label": "Geef een label op voor het token", + "no_expiration_verbose": "Dit token verloopt nooit!", + "token_expires_on": "Dit token verloopt op", + "generate_new_token": "Genereer nieuw token", + "generate_modal_title": "Nieuw persoonlijk toegangstoken", + "deletion_success": "Het toegangstoken {label} is verwijderd" + }, + "collection_runner": { + "collection_id": "Collectie-ID", + "environment_id": "Omgevings-ID", + "cli_collection_id_description": "Dit collectie-ID wordt gebruikt door de CLI-collectierunner voor Hoppscotch.", + "cli_environment_id_description": "Dit omgevings-ID wordt gebruikt door de CLI-collectierunner voor Hoppscotch.", + "include_active_environment": "Inclusief actieve omgeving:", + "cli": "CLI", + "ui": "Runner (binnenkort beschikbaar)", + "cli_command_generation_description_cloud": "Kopieer het onderstaande commando en voer het uit in de CLI. Geef een persoonlijk toegangstoken op.", + "cli_command_generation_description_sh": "Kopieer het onderstaande commando en voer het uit in de CLI. Geef een persoonlijk toegangstoken op en controleer de gegenereerde SH-instantie server-URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Kopieer het onderstaande commando en voer het uit in de CLI. Geef een persoonlijk toegangstoken en de SH-instantie server-URL op.", + "run_collection": "Collectie uitvoeren" + }, + "shortcodes": { + "actions": "Acties", + "created_on": "Aangemaakt op", + "deleted": "Shortcode verwijderd", + "method": "Methode", + "not_found": "Shortcode niet gevonden", + "short_code": "Shortcode", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/no.json b/packages/hoppscotch-common/locales/no.json new file mode 100644 index 0000000..fdf6782 --- /dev/null +++ b/packages/hoppscotch-common/locales/no.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Add", + "autoscroll": "Autoscroll", + "cancel": "Avbryt", + "choose_file": "Velg en fil", + "clear": "Tøm", + "clear_all": "Tøm alt", + "clear_history": "Clear all History", + "close": "Close", + "connect": "Koble", + "connecting": "Connecting", + "copy": "Kopiere", + "create": "Create", + "delete": "Slett", + "disconnect": "Koble fra", + "dismiss": "Avbryt", + "dont_save": "Don't save", + "download_file": "Last ned fil", + "drag_to_reorder": "Drag to reorder", + "duplicate": "Duplicate", + "edit": "Rediger", + "filter": "Filter", + "go_back": "Gå tilbake", + "go_forward": "Go forward", + "group_by": "Group by", + "hide_secret": "Hide secret", + "label": "Merkelapp", + "learn_more": "Lær mer", + "download_here": "Download here", + "less": "Less", + "more": "Mer", + "new": "Ny", + "no": "Nei", + "open_workspace": "Open workspace", + "paste": "Paste", + "prettify": "Forskjønn", + "properties": "Properties", + "remove": "Ta bort", + "rename": "Rename", + "restore": "Gjenopprett", + "save": "Lagre", + "scroll_to_bottom": "Scroll to bottom", + "scroll_to_top": "Scroll to top", + "search": "Søk", + "send": "Sende", + "share": "Share", + "show_secret": "Show secret", + "start": "Start", + "starting": "Starting", + "stop": "Stoppe", + "to_close": "to close", + "to_navigate": "to navigate", + "to_select": "to select", + "turn_off": "Skru av", + "turn_on": "Slå på", + "undo": "Angre", + "yes": "Ja" + }, + "add": { + "new": "Legg til ny", + "star": "Legg til stjerne" + }, + "app": { + "chat_with_us": "Snakk med oss", + "contact_us": "Kontakt oss", + "cookies": "Cookies", + "copy": "Kopiere", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Copy User Auth Token", + "developer_option": "Developer options", + "developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.", + "discord": "Discord", + "documentation": "Dokumentasjon", + "github": "GitHub", + "help": "Hjelp, tilbakemelding og dokumentasjon", + "home": "Hjem", + "invite": "Invitere", + "invite_description": "I Hoppscotch designet vi et enkelt og intuitivt grensesnitt for å lage og administrere dine API-er. Hoppscotch er et verktøy som hjelper deg med å bygge, teste, dokumentere og dele API-ene dine.", + "invite_your_friends": "Inviter vennene dine", + "join_discord_community": "Bli med i Discord-fellesskapet vårt", + "keyboard_shortcuts": "Tastatursnarveier", + "name": "Hoppscotch", + "new_version_found": "Ny versjon funnet. Last ned for å oppdatere.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Options", + "proxy_privacy_policy": "Personvernerklæring for proxy", + "reload": "Last på nytt", + "search": "Søk", + "share": "Dele", + "shortcuts": "Snarveier", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "Spotlight", + "status": "Status", + "status_description": "Check the status of the website", + "terms_and_privacy": "Vilkår og personvern", + "twitter": "Twitter", + "type_a_command_search": "Skriv inn en kommando eller søk ...", + "we_use_cookies": "Vi bruker informasjonskapsler", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "Hva er nytt?", + "see_whats_new": "See what’s new", + "wiki": "Wiki" + }, + "auth": { + "account_exists": "Kontoen eksisterer med annen legitimasjon - Logg på for å koble begge kontoene", + "all_sign_in_options": "Alle påloggingsalternativer", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "Fortsett med e-post", + "continue_with_github": "Fortsett med GitHub", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Fortsett med Google", + "continue_with_microsoft": "Continue with Microsoft", + "email": "E-post", + "logged_out": "Logget ut", + "login": "Logg inn", + "login_success": "Logget på", + "login_to_hoppscotch": "Logg inn i Hoppscotch", + "logout": "Logg ut", + "re_enter_email": "Skriv inn e-post på nytt", + "send_magic_link": "Send en magisk lenke", + "sync": "Synk", + "we_sent_magic_link": "Vi har sendt deg en magisk lenke!", + "we_sent_magic_link_description": "Sjekk innboksen din - vi sendte en e-post til {email}. Den inneholder en magisk lenke som logger deg på." + }, + "authorization": { + "generate_token": "Generer nøkkel", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "Inkluder i URL", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "Lær hvordan", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "Pass by", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "Passord", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "Nøkkel", + "type": "Godkjenningstype", + "username": "Brukernavn" + }, + "collection": { + "created": "Samlingen er opprettet", + "different_parent": "Cannot reorder collection with different parent", + "edit": "Rediger samling", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "Oppgi et gyldig navn på samlingen", + "invalid_root_move": "Collection already in the root", + "moved": "Moved Successfully", + "my_collections": "Mine samlinger", + "name": "Min nye samling", + "name_length_insufficient": "Collection name should be at least 3 characters long", + "new": "Ny kolleksjon", + "order_changed": "Collection Order Updated", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "Samlingen ble omdøpt", + "request_in_use": "Request in use", + "save_as": "Lagre som", + "save_to_collection": "Save to Collection", + "select": "Velg en samling", + "select_location": "Velg plassering", + "details": "Details", + "select_team": "Velg et lag", + "team_collections": "Lagsamlinger" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "exit_team": "Are you sure you want to leave this team?", + "logout": "Er du sikker på at du vil logge deg av?", + "remove_collection": "Er du sikker på at du vil slette denne samlingen permanent?", + "remove_environment": "Er du sikker på at du vil slette dette miljøet for godt?", + "remove_folder": "Er du sikker på at du vil slette denne mappen for godt?", + "remove_history": "Er du sikker på at du vil slette all historikk permanent?", + "remove_request": "Er du sikker på at du vil slette denne forespørselen for godt?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "Er du sikker på at du vil slette dette laget?", + "remove_telemetry": "Er du sikker på at du vil velge bort telemetri?", + "request_change": "Are you sure you want to discard current request, unsaved changes will be lost.", + "save_unsaved_tab": "Do you want to save changes made in this tab?", + "sync": "Er du sikker på at du vil synkronisere dette arbeidsområdet?", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "Overskrift {count}", + "message": "Melding {count}", + "parameter": "Parameter {count}", + "protocol": "Protokoll {count}", + "value": "Verdi {count}", + "variable": "Variabel {count}" + }, + "documentation": { + "generate": "Lag dokumentasjon", + "generate_message": "Importer en hvilken som helst Hoppscotch-samling for å generere API-dokumentasjon når du er på farten." + }, + "empty": { + "authorization": "Denne forespørselen bruker ingen autorisasjon", + "body": "Denne forespørselen har ikke en kropp", + "collection": "Samlingen er tom", + "collections": "Samlingene er tomme", + "documentation": "Connect to a GraphQL endpoint to view documentation", + "endpoint": "Endpoint cannot be empty", + "environments": "Miljøer er tomme", + "folder": "Mappen er tom", + "headers": "Denne forespørselen har ingen overskrifter", + "history": "Historien er tom", + "invites": "Invite list is empty", + "members": "Teamet er tomt", + "parameters": "Denne forespørselen har ingen parametere", + "pending_invites": "There are no pending invites for this team", + "profile": "Login to view your profile", + "protocols": "Protokoller er tomme", + "request_variables": "This request does not have any request variables", + "schema": "Koble til et GraphQL-endepunkt", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "Subscriptions are empty", + "team_name": "Lagnavnet er tomt", + "teams": "Lagene er tomme", + "tests": "Det er ingen tester for denne forespørselen", + "access_tokens": "Access tokens are empty", + "shortcodes": "Shortcodes are empty" + }, + "environment": { + "add_to_global": "Add to Global", + "added": "Environment addition", + "create_new": "Skap nytt miljø", + "created": "Environment created", + "deleted": "Environment deletion", + "duplicated": "Environment duplicated", + "edit": "Rediger miljø", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create a environment", + "invalid_name": "Oppgi et gyldig navn på miljøet", + "list": "Environment variables", + "my_environments": "My Environments", + "name": "Name", + "nested_overflow": "nested environment variables are limited to 10 levels", + "new": "Nytt miljø", + "no_active_environment": "No active environment", + "no_environment": "Ingen miljø", + "no_environment_description": "No environments were selected. Choose what to do with the following variables.", + "quick_peek": "Environment Quick Peek", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "Velg miljø", + "set": "Set environment", + "set_as_environment": "Set as environment", + "team_environments": "Team Environments", + "title": "Miljøer", + "updated": "Environment updation", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Variabel liste", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "Denne nettleseren ser ikke ut til å ha Server Sent Events -støtte.", + "check_console_details": "Sjekk konsollloggen for detaljer.", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "cURL er ikke riktig formatert", + "danger_zone": "Danger zone", + "delete_account": "Your account is currently an owner in these teams:", + "delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "Tom forespørselsnavn", + "f12_details": "(F12 for detaljer)", + "gql_prettify_invalid_query": "Kunne ikke forskjønne en ugyldig spørring, løse spørringssyntaksfeil og prøve igjen", + "incomplete_config_urls": "Incomplete configuration URLs", + "incorrect_email": "Incorrect email", + "invalid_link": "Invalid link", + "invalid_link_description": "The link you clicked is invalid or expired.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "Invalid JSON", + "json_prettify_invalid_body": "Kunne ikke forskjønne et ugyldig brødtekst, løse json -syntaksfeil og prøve igjen", + "network_error": "There seems to be a network error. Please try again.", + "network_fail": "Kunne ikke sende forespørsel", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "Ingen varighet", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "No matches found", + "page_not_found": "This page could not be found", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "Kunne ikke kjøre forhåndsforespørselsskript", + "something_went_wrong": "Noe gikk galt", + "post_request_script_fail": "Could not execute post-request script", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "Eksporter som JSON", + "create_secret_gist": "Lag hemmelig Gist", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "Logg på med GitHub for å lage en hemmelig oppgave", + "title": "Export", + "success": "Successfully exported", + "gist_created": "Gist opprettet" + }, + "filter": { + "all": "All", + "none": "None", + "starred": "Starred" + }, + "folder": { + "created": "Mappen er opprettet", + "edit": "Rediger mappe", + "invalid_name": "Oppgi et navn på mappen", + "name_length_insufficient": "Folder name should be at least 3 characters long", + "new": "Ny mappe", + "renamed": "Mappen ble gitt nytt navn" + }, + "graphql": { + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "mutations": "Mutasjoner", + "schema": "Skjema", + "subscriptions": "Abonnementer", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "Time", + "url": "URL" + }, + "header": { + "install_pwa": "Installer app", + "login": "Logg Inn", + "save_workspace": "Lagre arbeidsområdet mitt" + }, + "helpers": { + "authorization": "Autorisasjonsoverskriften genereres automatisk når du sender forespørselen.", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "Lag dokumentasjon først", + "network_fail": "Kan ikke nå API-endepunktet. Kontroller nettverkstilkoblingen og prøv igjen.", + "offline": "Du ser ut til å være frakoblet. Data i dette arbeidsområdet er kanskje ikke oppdatert.", + "offline_short": "Du ser ut til å være frakoblet.", + "post_request_tests": "Testskript er skrevet i JavaScript og kjøres etter at svaret er mottatt.", + "pre_request_script": "Skript for forespørsel er skrevet i JavaScript og kjøres før forespørselen sendes.", + "script_fail": "Det ser ut til at det er en feil i forhåndsforespørselsskriptet. Sjekk feilen nedenfor og fiks skriptet deretter.", + "post_request_script_fail": "Det ser ut til å være en feil med skriptet etter forespørselen. Vennligst rett opp feilene og kjør skriptene på nytt.", + "post_request_script": "Skriv et post-request skript for å automatisere feilsøking." + }, + "hide": { + "collection": "Collapse Collection Panel", + "more": "Skjul mer", + "preview": "Skjul forhåndsvisning", + "sidebar": "Skjul sidefeltet" + }, + "import": { + "collections": "Importer samlinger", + "curl": "Importer cURL", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "Import mislyktes", + "from_file": "Import from File", + "from_gist": "Import fra Gist", + "from_gist_description": "Import from Gist URL", + "from_insomnia": "Import from Insomnia", + "from_insomnia_description": "Import from Insomnia collection", + "from_json": "Import from Hoppscotch", + "from_json_description": "Import from Hoppscotch collection file", + "from_my_collections": "Importer fra Mine samlinger", + "from_my_collections_description": "Import from My Collections file", + "from_openapi": "Import from OpenAPI", + "from_openapi_description": "Import from OpenAPI specification file (YML/JSON)", + "from_postman": "Import from Postman", + "from_postman_description": "Import from Postman collection", + "from_url": "Import from URL", + "gist_url": "Skriv inn Gist URL", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "Couldn't get data from the url", + "import_from_url_invalid_file_format": "Error while importing collections", + "import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Collections Imported", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Import collections from a Hoppscotch Collections JSON file", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "Import", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled." + } + }, + "layout": { + "collapse_collection": "Collapse or Expand Collections", + "collapse_sidebar": "Collapse or Expand the sidebar", + "column": "Vertical layout", + "name": "Layout", + "row": "Horizontal layout" + }, + "modal": { + "close_unsaved_tab": "You have unsaved changes", + "collections": "Samlinger", + "confirm": "Bekrefte", + "customize_request": "Customize Request", + "edit_request": "Rediger forespørsel", + "import_export": "Import / Eksport", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "You are already subscribed to this topic.", + "clean_session": "Clean Session", + "clear_input": "Clear input", + "clear_input_on_send": "Clear input on send", + "client_id": "Client ID", + "color": "Pick a color", + "communication": "Kommunikasjon", + "connection_config": "Connection Config", + "connection_not_authorized": "This MQTT connection does not use any authentication.", + "invalid_topic": "Please provide a topic for the subscription", + "keep_alive": "Keep Alive", + "log": "Logg", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "Beskjed", + "new": "New Subscription", + "not_connected": "Please start a MQTT connection first.", + "publish": "Publisere", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Abonnere", + "topic": "Emne", + "topic_name": "Emne navn", + "topic_title": "Publiser / abonner emne", + "unsubscribe": "Avslutte abonnement", + "url": "URL" + }, + "navigation": { + "doc": "Dokumenter", + "graphql": "GraphQL", + "profile": "Profile", + "realtime": "Sanntid", + "rest": "REST", + "settings": "Innstillinger" + }, + "preRequest": { + "javascript_code": "JavaScript-kode", + "learn": "Les dokumentasjon", + "script": "Skript for forespørsel", + "snippets": "Utdrag" + }, + "profile": { + "app_settings": "App Settings", + "default_hopp_displayname": "Unnamed User", + "editor": "Editor", + "editor_description": "Editors can add, edit, and delete requests.", + "email_verification_mail": "A verification email has been sent to your email address. Please click on the link to verify your email address.", + "no_permission": "You do not have permission to perform this action.", + "owner": "Owner", + "owner_description": "Owners can add, edit, and delete requests, collections and team members.", + "roles": "Roles", + "roles_description": "Roles are used to control access to the shared collections.", + "updated": "Profile updated", + "viewer": "Viewer", + "viewer_description": "Viewers can only view and use requests." + }, + "remove": { + "star": "Fjern stjernen" + }, + "request": { + "added": "Forespørsel lagt til", + "authorization": "Autorisasjon", + "body": "Forespørselsorgan", + "choose_language": "Velg språk", + "content_type": "Innholdstype", + "content_type_titles": { + "others": "Others", + "structured": "Structured", + "text": "Text" + }, + "different_collection": "Cannot reorder requests from different collections", + "duplicated": "Request duplicated", + "duration": "Varighet", + "enter_curl": "Skriv inn cURL", + "generate_code": "Generer kode", + "generated_code": "Generert kode", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "Toppliste", + "invalid_name": "Oppgi et navn på forespørselen", + "method": "Metode", + "moved": "Request moved", + "name": "Forespørselsnavn", + "new": "New Request", + "order_changed": "Request Order Updated", + "override": "Override", + "override_help": "Set Content-Type in Headers", + "overriden": "Overridden", + "parameter_list": "Spørringsparametere", + "parameters": "Parametere", + "path": "Sti", + "payload": "Nyttelast", + "query": "Spørsmål", + "raw_body": "Raw Request Body", + "rename": "Rename Request", + "renamed": "Forespørsel omdøpt", + "request_variables": "Request variables", + "run": "Løpe", + "save": "Lagre", + "save_as": "Lagre som", + "saved": "Forespørselen er lagret", + "share": "Dele", + "share_description": "Share Hoppscotch with your friends", + "share_request": "Share Request", + "stop": "Stop", + "title": "Be om", + "type": "Type forespørsel", + "url": "URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "Variabler", + "view_my_links": "View my links", + "copy_link": "Kopier link" + }, + "response": { + "audio": "Audio", + "body": "Svarkropp", + "filter_response_body": "Filter JSON response body (uses jq syntax)", + "headers": "Overskrifter", + "html": "HTML", + "image": "Bilde", + "json": "JSON", + "pdf": "PDF", + "preview_html": "Forhåndsvis HTML", + "raw": "Rå", + "size": "Størrelse", + "status": "Status", + "time": "Tid", + "title": "Respons", + "video": "Video", + "waiting_for_connection": "venter på tilkobling", + "xml": "XML" + }, + "settings": { + "accent_color": "Aksentfarge", + "account": "Konto", + "account_deleted": "Your account has been deleted", + "account_description": "Tilpass kontoinnstillingene dine.", + "account_email_description": "Din primære e-postadresse.", + "account_name_description": "Dette er visningsnavnet ditt.", + "additional": "Additional Settings", + "background": "Bakgrunn", + "black_mode": "Svart", + "choose_language": "Velg språk", + "dark_mode": "Mørk", + "delete_account": "Delete account", + "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", + "expand_navigation": "Expand navigation", + "experiments": "Eksperimenter", + "experiments_notice": "Dette er en samling eksperimenter vi jobber med som kan vise seg å være nyttige, morsomme, begge deler eller ingen av dem. De er ikke endelige og er kanskje ikke stabile, så hvis det skjer noe altfor rart, ikke få panikk. Bare slå av det. Vitser til side,", + "extension_ver_not_reported": "Ikke rapportert", + "extension_version": "Utvidelsesversjon", + "extensions": "Utvidelser", + "extensions_use_toggle": "Bruk nettleserutvidelsen til å sende forespørsler (hvis de er tilstede)", + "follow": "Follow Us", + "interceptor": "Interceptor", + "interceptor_description": "Mellomvare mellom applikasjon og API-er.", + "language": "Språk", + "light_mode": "Lys", + "official_proxy_hosting": "Official Proxy er vert for Hoppscotch.", + "profile": "Profile", + "profile_description": "Update your profile details", + "profile_email": "Email address", + "profile_name": "Profile name", + "proxy": "Fullmakt", + "proxy_url": "Proxy-URL", + "proxy_use_toggle": "Bruk proxy-mellomvaren til å sende forespørsler", + "read_the": "Les", + "reset_default": "Tilbakestill til standard", + "short_codes": "Short codes", + "short_codes_description": "Short codes which were created by you.", + "sidebar_on_left": "Sidebar on left", + "sync": "Synkroniser", + "sync_collections": "Samlinger", + "sync_description": "Disse innstillingene er synkronisert med skyen.", + "sync_environments": "Miljøer", + "sync_history": "Historie", + "system_mode": "System", + "telemetry": "Telemetri", + "telemetry_helps_us": "Telemetri hjelper oss med å tilpasse driften og levere den beste opplevelsen til deg.", + "theme": "Tema", + "theme_description": "Tilpass søknadstemaet ditt.", + "use_experimental_url_bar": "Bruk en eksperimentell URL-linje med utheving av miljøet", + "user": "Bruker", + "verified_email": "Verified email", + "verify_email": "Verify email" + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Lukk gjeldende meny", + "command_menu": "Søk- og kommandomeny", + "help_menu": "Hjelpemeny", + "show_all": "Tastatursnarveier", + "title": "Generell" + }, + "miscellaneous": { + "invite": "Inviter folk til Hoppscotch", + "title": "Diverse" + }, + "navigation": { + "back": "Gå tilbake til forrige side", + "documentation": "Gå til Dokumentasjonsside", + "forward": "Gå videre til neste side", + "graphql": "Gå til GraphQL-siden", + "profile": "Go to Profile page", + "realtime": "Gå til sanntid-siden", + "rest": "Gå til REST-siden", + "settings": "Gå til Innstillinger-siden", + "title": "Navigasjon" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "Velg SLETT metoden", + "get_method": "Velg GET-metode", + "head_method": "Velg HEAD-metode", + "import_curl": "Import cURL", + "method": "Metode", + "next_method": "Velg Neste metode", + "post_method": "Velg POST-metode", + "previous_method": "Velg Forrige metode", + "put_method": "Velg PUT-metode", + "rename": "Rename Request", + "reset_request": "Tilbakestill forespørsel", + "save_request": "Save Request", + "save_to_collections": "Lagre i samlinger", + "send_request": "Send forespørsel", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "Be om", + "copy_request_link": "Kopier forespørselskobling" + }, + "response": { + "copy": "Copy response to clipboard", + "download": "Download response as file", + "title": "Response" + }, + "theme": { + "black": "Switch theme to black mode", + "dark": "Switch theme to dark mode", + "light": "Switch theme to light mode", + "system": "Switch theme to system mode", + "title": "Theme" + } + }, + "show": { + "code": "Vis kode", + "collection": "Expand Collection Panel", + "more": "Vis mer", + "sidebar": "Vis sidefeltet" + }, + "socketio": { + "communication": "Kommunikasjon", + "connection_not_authorized": "This SocketIO connection does not use any authentication.", + "event_name": "Arrangementsnavn", + "events": "arrangementer", + "log": "Logg", + "url": "URL" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current team", + "edit": "Edit current team", + "invite": "Invite people to team", + "new": "Create new team", + "switch_to_personal": "Switch to your personal workspace", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "Hendelsestype", + "log": "Logg", + "url": "URL" + }, + "state": { + "bulk_mode": "Masse redigering", + "bulk_mode_placeholder": "Oppføringer skilles med ny linje\nNøkler og verdier er atskilt med:\nForbered # til hvilken som helst rad du vil legge til, men behold den deaktivert", + "cleared": "Ryddet", + "connected": "Tilkoblet", + "connected_to": "Koblet til {name}", + "connecting_to": "Kobler til {name} ...", + "connection_error": "Failed to connect", + "connection_failed": "Connection failed", + "connection_lost": "Connection lost", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "Kopiert til utklippstavlen", + "deleted": "Slettet", + "deprecated": "FORELDET", + "disabled": "Deaktivert", + "disconnected": "Frakoblet", + "disconnected_from": "Koblet fra {name}", + "docs_generated": "Dokumentasjon generert", + "download_failed": "Download failed", + "download_started": "Nedlastingen startet", + "enabled": "Aktivert", + "file_imported": "Fil importert", + "finished_in": "Ferdig om {duration} ms", + "hide": "Hide", + "history_deleted": "Historikk slettet", + "linewrap": "Brekk linjer", + "loading": "Laster inn ...", + "message_received": "Message: {message} arrived on topic: {topic}", + "mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}", + "none": "Ingen", + "nothing_found": "Ingenting funnet for", + "published_error": "Something went wrong while publishing msg: {topic} to topic: {message}", + "published_message": "Published message: {message} to topic: {topic}", + "reconnection_error": "Failed to reconnect", + "show": "Show", + "subscribed_failed": "Failed to subscribe to topic: {topic}", + "subscribed_success": "Successfully subscribed to topic: {topic}", + "unsubscribed_failed": "Failed to unsubscribe from topic: {topic}", + "unsubscribed_success": "Successfully unsubscribed from topic: {topic}", + "waiting_send_request": "Venter på å sende forespørsel" + }, + "support": { + "changelog": "Les mer om de siste utgivelsene", + "chat": "Spørsmål? Snakk med oss!", + "community": "Still spørsmål og hjelp andre", + "documentation": "Les mer om Hoppscotch", + "forum": "Still spørsmål og få svar", + "github": "Follow us on Github", + "shortcuts": "Bla gjennom appen raskere", + "title": "Brukerstøtte", + "twitter": "Følg oss på Twitter", + "team": "Ta kontakt med teamet" + }, + "tab": { + "authorization": "Autorisasjon", + "body": "Kropp", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "Samlinger", + "documentation": "Dokumentasjon", + "duplicate": "Duplicate Tab", + "environments": "Environments", + "headers": "Overskrifter", + "history": "Historie", + "mqtt": "MQTT", + "parameters": "Parametere", + "pre_request_script": "Skript på forhånd", + "queries": "Forespørsler", + "query": "Spørsmål", + "schema": "Schema", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Tester", + "types": "Typer", + "variables": "Variabler", + "websocket": "WebSocket" + }, + "team": { + "already_member": "You are already a member of this team. Contact your team owner.", + "create_new": "Opprett nytt team", + "deleted": "Team slettet", + "edit": "Rediger team", + "email": "E-post", + "email_do_not_match": "Email doesn't match with your account details. Contact your team owner.", + "exit": "Avslutt Team", + "exit_disabled": "Bare eieren kan ikke gå ut av teamet", + "failed_invites": "Failed invites", + "invalid_coll_id": "Invalid collection ID", + "invalid_email_format": "E -postformatet er ugyldig", + "invalid_id": "Invalid team ID. Contact your team owner.", + "invalid_invite_link": "Invalid invite link", + "invalid_invite_link_description": "The link you followed is invalid. Contact your team owner.", + "invalid_member_permission": "Oppgi en gyldig tillatelse til teammedlemmet", + "invite": "Invite", + "invite_more": "Invite more", + "invite_tooltip": "Invite people to this workspace", + "invited_to_team": "{owner} invited you to join {team}", + "join": "Invitation accepted", + "join_team": "Join {team}", + "joined_team": "You have joined {team}", + "joined_team_description": "You are now a member of this team", + "left": "Du forlot laget", + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to join a team.", + "logout_and_try_again": "Logout and sign in with another account", + "member_has_invite": "This email ID already has an invite. Contact your team owner.", + "member_not_found": "Member not found. Contact your team owner.", + "member_removed": "Bruker fjernet", + "member_role_updated": "Brukerroller er oppdatert", + "members": "Medlemmer", + "more_members": "+{count} more", + "name_length_insufficient": "Lagnavnet bør være minst 6 tegn langt", + "name_updated": "Team name updated", + "new": "Nytt lag", + "new_created": "Nytt lag opprettet", + "new_name": "Mitt nye lag", + "no_access": "Du har ikke redigeringstilgang til disse samlingene", + "no_invite_found": "Invitation not found. Contact your team owner.", + "no_request_found": "Request not found.", + "not_found": "Team not found. Contact your team owner.", + "not_valid_viewer": "You are not a valid viewer. Contact your team owner.", + "parent_coll_move": "Cannot move collection to a child collection", + "pending_invites": "Pending invites", + "permissions": "Tillatelser", + "same_target_destination": "Same target and destination", + "saved": "Lag reddet", + "select_a_team": "Select a team", + "success_invites": "Success invites", + "title": "Lag", + "we_sent_invite_link": "We sent an invite link to all invitees!", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "Ask all invitees to check their inbox. Click on the link to join the team.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "Bli med i betaprogrammet for å få tilgang til lag." + }, + "team_environment": { + "deleted": "Environment Deleted", + "duplicate": "Environment Duplicated", + "not_found": "Environment not found." + }, + "test": { + "failed": "test failed", + "javascript_code": "JavaScript-kode", + "learn": "Les dokumentasjon", + "passed": "test passed", + "report": "Testrapport", + "results": "Testresultater", + "script": "Manus", + "snippets": "Utdrag" + }, + "websocket": { + "communication": "Kommunikasjon", + "log": "Logg", + "message": "Beskjed", + "protocols": "Protokoller", + "url": "URL" + }, + "workspace": { + "change": "Change workspace", + "personal": "My Workspace", + "other_workspaces": "My Workspaces", + "team": "Team Workspace", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "Actions", + "created_on": "Created on", + "deleted": "Shortcode deleted", + "method": "Method", + "not_found": "Shortcode not found", + "short_code": "Short code", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/pl.json b/packages/hoppscotch-common/locales/pl.json new file mode 100644 index 0000000..363f38a --- /dev/null +++ b/packages/hoppscotch-common/locales/pl.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Add", + "autoscroll": "Autoscroll", + "cancel": "anuluj", + "choose_file": "Wybierz plik", + "clear": "Wyczyść", + "clear_all": "Wyczyść wszystko", + "clear_history": "Clear all History", + "close": "Close", + "connect": "Połącz", + "connecting": "Connecting", + "copy": "Kopiuj", + "create": "Create", + "delete": "Usuń", + "disconnect": "Rozłącz", + "dismiss": "Odrzuć", + "dont_save": "Don't save", + "download_file": "Pobieranie pliku", + "drag_to_reorder": "Drag to reorder", + "duplicate": "Duplicate", + "edit": "Edytuj", + "filter": "Filter", + "go_back": "Wróć", + "go_forward": "Go forward", + "group_by": "Group by", + "hide_secret": "Hide secret", + "label": "Etykieta", + "learn_more": "Dowiedz się więcej", + "download_here": "Download here", + "less": "Less", + "more": "Więcej", + "new": "Nowa", + "no": "Nie", + "open_workspace": "Open workspace", + "paste": "Paste", + "prettify": "Popraw czytelność", + "properties": "Properties", + "remove": "Usuń", + "rename": "Rename", + "restore": "Przywróć", + "save": "Zapisz", + "scroll_to_bottom": "Scroll to bottom", + "scroll_to_top": "Scroll to top", + "search": "Szukaj", + "send": "Wyślij", + "share": "Share", + "show_secret": "Show secret", + "start": "Rozpocznij", + "starting": "Starting", + "stop": "Zatrzymaj", + "to_close": "to close", + "to_navigate": "to navigate", + "to_select": "to select", + "turn_off": "Wyłącz", + "turn_on": "Włącz", + "undo": "Cofnij", + "yes": "Tak" + }, + "add": { + "new": "Dodaj nowe", + "star": "Dodaj gwiazdkę" + }, + "app": { + "chat_with_us": "Porozmawiaj z nami", + "contact_us": "Skontaktuj się z nami", + "cookies": "Cookies", + "copy": "Kopiuj", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Copy User Auth Token", + "developer_option": "Developer options", + "developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.", + "discord": "Discord", + "documentation": "Dokumentacja", + "github": "GitHub", + "help": "Pomoc, opinie i dokumentacja", + "home": "Start", + "invite": "Zaproś", + "invite_description": "W Hoppscotch zaprojektowaliśmy prosty i intuicyjny interfejs do tworzenia i zarządzania Twoimi API. Hoppscotch to narzędzie, które pomaga budować, testować, dokumentować i udostępniać interfejsy API.", + "invite_your_friends": "Zaproś swoich znajomych", + "join_discord_community": "Dołącz do naszej społeczności Discord", + "keyboard_shortcuts": "Skróty klawiszowe", + "name": "Hoppscotch", + "new_version_found": "Znaleziono nową wersję. Odśwież, aby zaktualizować.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Options", + "proxy_privacy_policy": "Polityka prywatności proxy", + "reload": "Przeładuj", + "search": "Szukaj", + "share": "Udostępnij", + "shortcuts": "Skróty", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "Reflektor", + "status": "Status", + "status_description": "Check the status of the website", + "terms_and_privacy": "Warunki i prywatność", + "twitter": "Twitter", + "type_a_command_search": "Wpisz polecenie lub wyszukaj…", + "we_use_cookies": "Używamy plików cookie", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "Co nowego?", + "see_whats_new": "See what’s new", + "wiki": "Wiki" + }, + "auth": { + "account_exists": "Konto istnieje z różnymi danymi uwierzytelniającymi - Zaloguj się, aby połączyć oba konta", + "all_sign_in_options": "Wszystkie opcje logowania", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "Kontynuuj z e-mailem", + "continue_with_github": "Kontynuuj z GitHub", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Kontynuuj z Google", + "continue_with_microsoft": "Continue with Microsoft", + "email": "E-mail", + "logged_out": "Wylogowano", + "login": "Zaloguj sie", + "login_success": "Zalogowano się pomyślnie", + "login_to_hoppscotch": "Zaloguj się do Hoppscotch", + "logout": "Wyloguj", + "re_enter_email": "Wprowadź email ponownie", + "send_magic_link": "Wyślij magiczny link", + "sync": "Synchronizuj", + "we_sent_magic_link": "Wysłaliśmy Ci magiczny link!", + "we_sent_magic_link_description": "Sprawdź swoją skrzynkę odbiorczą – wysłaliśmy e-mail na adres {email}. Zawiera magiczny link, który Cię zaloguje." + }, + "authorization": { + "generate_token": "Wygeneruj token", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "Uwzględnij w adresie URL", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "Naucz się jak", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "Pass by", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "Hasło", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "Token", + "type": "Typ autoryzacji", + "username": "Nazwa użytkownika" + }, + "collection": { + "created": "Utworzono kolekcję", + "different_parent": "Cannot reorder collection with different parent", + "edit": "Edytuj kolekcję", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "Podaj prawidłową nazwę kolekcji", + "invalid_root_move": "Collection already in the root", + "moved": "Moved Successfully", + "my_collections": "Moje kolekcje", + "name": "Moja nowa kolekcja", + "name_length_insufficient": "Collection name should be at least 3 characters long", + "new": "Nowa kolekcja", + "order_changed": "Collection Order Updated", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "Zmieniono nazwę kolekcji", + "request_in_use": "Request in use", + "save_as": "Zapisz jako", + "save_to_collection": "Save to Collection", + "select": "Wybierz kolekcję", + "select_location": "Wybierz lokalizację", + "details": "Details", + "select_team": "Wybierz zespół", + "team_collections": "Kolekcje zespołowe" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "exit_team": "Are you sure you want to leave this team?", + "logout": "Czy na pewno chcesz się wylogować?", + "remove_collection": "Czy na pewno chcesz trwale usunąć tę kolekcję?", + "remove_environment": "Czy na pewno chcesz trwale usunąć to środowisko?", + "remove_folder": "Czy na pewno chcesz trwale usunąć ten folder?", + "remove_history": "Czy na pewno chcesz trwale usunąć całą historię?", + "remove_request": "Czy na pewno chcesz trwale usunąć te żądanie?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "Czy na pewno chcesz usunąć ten zespół?", + "remove_telemetry": "Czy na pewno chcesz zrezygnować z telemetrii?", + "request_change": "Are you sure you want to discard current request, unsaved changes will be lost.", + "save_unsaved_tab": "Do you want to save changes made in this tab?", + "sync": "Czy na pewno chcesz zsynchronizować ten obszar roboczy?", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "Nagłówek {count}", + "message": "Wiadomość {count}", + "parameter": "Parametr {count}", + "protocol": "Protokół {count}", + "value": "Wartość {count}", + "variable": "Zmienna {count}" + }, + "documentation": { + "generate": "Wygeneruj dokumentację", + "generate_message": "Importuj dowolną kolekcję Hoppscotch, aby generować dokumentację API na żądanie." + }, + "empty": { + "authorization": "To żądanie nie używa żadnej autoryzacji", + "body": "To żądanie nie ma treści", + "collection": "Kolekcja jest pusta", + "collections": "Kolekcje są puste", + "documentation": "Connect to a GraphQL endpoint to view documentation", + "endpoint": "Endpoint cannot be empty", + "environments": "Środowiska są puste", + "folder": "Folder jest pusty", + "headers": "To żądanie nie ma żadnych nagłówków", + "history": "Historia jest pusta", + "invites": "Invite list is empty", + "members": "Zespół jest pusty", + "parameters": "To żądanie nie ma żadnych parametrów", + "pending_invites": "There are no pending invites for this team", + "profile": "Login to view your profile", + "protocols": "Protokoły są puste", + "request_variables": "This request does not have any request variables", + "schema": "Połącz się z punktem końcowym GraphQL", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "Subscriptions are empty", + "team_name": "Nazwa zespołu jest pusta", + "teams": "Zespoły są puste", + "tests": "Nie ma testów dla tego żądania", + "access_tokens": "Access tokens are empty", + "shortcodes": "Shortcodes are empty" + }, + "environment": { + "add_to_global": "Add to Global", + "added": "Environment addition", + "create_new": "Stwórz nowe środowisko", + "created": "Environment created", + "deleted": "Environment deletion", + "duplicated": "Environment duplicated", + "edit": "Edytuj środowisko", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create a environment", + "invalid_name": "Podaj prawidłową nazwę środowiska", + "list": "Environment variables", + "my_environments": "My Environments", + "name": "Name", + "nested_overflow": "nested environment variables are limited to 10 levels", + "new": "Nowe środowisko", + "no_active_environment": "No active environment", + "no_environment": "Brak środowiska", + "no_environment_description": "No environments were selected. Choose what to do with the following variables.", + "quick_peek": "Environment Quick Peek", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "Wybierz środowisko", + "set": "Set environment", + "set_as_environment": "Set as environment", + "team_environments": "Team Environments", + "title": "Środowiska", + "updated": "Environment updation", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Lista zmiennych", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "Wygląda na to, że ta przeglądarka nie obsługuje zdarzeń wysłanych przez serwer.", + "check_console_details": "Sprawdź dziennik konsoli, aby uzyskać szczegółowe informacje.", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "cURL nie jest poprawnie sformatowany", + "danger_zone": "Danger zone", + "delete_account": "Your account is currently an owner in these teams:", + "delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "Pusta nazwa żądania", + "f12_details": "(F12 po szczegóły)", + "gql_prettify_invalid_query": "Nie można poprawić czytelności nieprawidłowego zapytania, napraw błędy składni zapytania i spróbuj ponownie", + "incomplete_config_urls": "Incomplete configuration URLs", + "incorrect_email": "Incorrect email", + "invalid_link": "Invalid link", + "invalid_link_description": "The link you clicked is invalid or expired.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "Invalid JSON", + "json_prettify_invalid_body": "Nie można poprawić czytelności nieprawidłowej treści, napraw błędy składni json i spróbuj ponownie", + "network_error": "There seems to be a network error. Please try again.", + "network_fail": "Nie udało się wysłać zapytania", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "Brak czasu trwania", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "No matches found", + "page_not_found": "This page could not be found", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "Nie można wykonać skryptu żądania wstępnego", + "something_went_wrong": "Coś poszło nie tak", + "post_request_script_fail": "Could not execute post-request script", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "Eksportuj jako JSON", + "create_secret_gist": "Utwórz tajny Gist", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "Zaloguj się za pomocą GitHub, aby utworzyć tajny Gist", + "title": "Export", + "success": "Successfully exported", + "gist_created": "Utworzono Gist" + }, + "filter": { + "all": "All", + "none": "None", + "starred": "Starred" + }, + "folder": { + "created": "Utworzono folder", + "edit": "Edytuj folder", + "invalid_name": "Podaj nazwę folderu", + "name_length_insufficient": "Folder name should be at least 3 characters long", + "new": "Nowy folder", + "renamed": "Zmieniono nazwę folderu" + }, + "graphql": { + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "mutations": "Mutacje", + "schema": "Schemat", + "subscriptions": "Subskrypcje", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "Time", + "url": "URL" + }, + "header": { + "install_pwa": "Zainstaluj aplikację", + "login": "Zaloguj sie", + "save_workspace": "Zapisz mój obszar roboczy" + }, + "helpers": { + "authorization": "Nagłówek autoryzacji zostanie wygenerowany automatycznie po wysłaniu żądania.", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "Najpierw wygeneruj dokumentację", + "network_fail": "Nie można połączyć się z punktem końcowym interfejsu API. Sprawdź połączenie sieciowe i spróbuj ponownie.", + "offline": "Wygląda na to, że jesteś offline. Dane w tym obszarze roboczym mogą być nieaktualne.", + "offline_short": "Wygląda na to, że jesteś offline.", + "post_request_tests": "Skrypty testowe są pisane w języku JavaScript i są uruchamiane po otrzymaniu odpowiedzi.", + "pre_request_script": "Skrypty żądań wstępnych są napisane w języku JavaScript i są uruchamiane przed wysłaniem żądania.", + "script_fail": "Wygląda na to, że w skrypcie żądania wstępnego jest usterka. Sprawdź poniższy błąd i odpowiednio napraw skrypt.", + "post_request_script_fail": "There seems to be an error with post-request script. Please fix the errors and run tests again", + "post_request_script": "Napisz skrypt testowy, aby zautomatyzować debugowanie." + }, + "hide": { + "collection": "Collapse Collection Panel", + "more": "Ukryj więcej", + "preview": "Ukryj podgląd", + "sidebar": "Ukryj pasek boczny" + }, + "import": { + "collections": "Importuj kolekcje", + "curl": "Importuj CURL", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "Import nie powiódł się", + "from_file": "Import from File", + "from_gist": "Importuj z Gist", + "from_gist_description": "Import from Gist URL", + "from_insomnia": "Import from Insomnia", + "from_insomnia_description": "Import from Insomnia collection", + "from_json": "Import from Hoppscotch", + "from_json_description": "Import from Hoppscotch collection file", + "from_my_collections": "Importuj z Moich kolekcji", + "from_my_collections_description": "Import from My Collections file", + "from_openapi": "Import from OpenAPI", + "from_openapi_description": "Import from OpenAPI specification file (YML/JSON)", + "from_postman": "Import from Postman", + "from_postman_description": "Import from Postman collection", + "from_url": "Import from URL", + "gist_url": "Wpisz adres URL Gist", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "Couldn't get data from the url", + "import_from_url_invalid_file_format": "Error while importing collections", + "import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Collections Imported", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Import collections from a Hoppscotch Collections JSON file", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "Import", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled." + } + }, + "layout": { + "collapse_collection": "Collapse or Expand Collections", + "collapse_sidebar": "Collapse or Expand the sidebar", + "column": "Pionowy układ", + "name": "Layout", + "row": "Poziomy układ" + }, + "modal": { + "close_unsaved_tab": "You have unsaved changes", + "collections": "Kolekcje", + "confirm": "Potwierdź", + "customize_request": "Customize Request", + "edit_request": "Edytuj żądanie", + "import_export": "Import Eksport", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "You are already subscribed to this topic.", + "clean_session": "Clean Session", + "clear_input": "Clear input", + "clear_input_on_send": "Clear input on send", + "client_id": "Client ID", + "color": "Pick a color", + "communication": "Komunikacja", + "connection_config": "Connection Config", + "connection_not_authorized": "This MQTT connection does not use any authentication.", + "invalid_topic": "Please provide a topic for the subscription", + "keep_alive": "Keep Alive", + "log": "Logi", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "Wiadomość", + "new": "New Subscription", + "not_connected": "Please start a MQTT connection first.", + "publish": "Publikuj", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Subskrybuj", + "topic": "Temat", + "topic_name": "Nazwa tematu", + "topic_title": "Opublikuj/zasubskrybuj temat", + "unsubscribe": "Anuluj subskrypcję", + "url": "URL" + }, + "navigation": { + "doc": "Dokumenty", + "graphql": "GraphQL", + "profile": "Profile", + "realtime": "Czas rzeczywisty", + "rest": "REST", + "settings": "Ustawienia" + }, + "preRequest": { + "javascript_code": "Kod JavaScript", + "learn": "Przeczytaj dokumentację", + "script": "Skrypt żądania wstępnego", + "snippets": "Snippety" + }, + "profile": { + "app_settings": "App Settings", + "default_hopp_displayname": "Unnamed User", + "editor": "Editor", + "editor_description": "Editors can add, edit, and delete requests.", + "email_verification_mail": "A verification email has been sent to your email address. Please click on the link to verify your email address.", + "no_permission": "You do not have permission to perform this action.", + "owner": "Owner", + "owner_description": "Owners can add, edit, and delete requests, collections and team members.", + "roles": "Roles", + "roles_description": "Roles are used to control access to the shared collections.", + "updated": "Profile updated", + "viewer": "Viewer", + "viewer_description": "Viewers can only view and use requests." + }, + "remove": { + "star": "Usuń gwiazdkę" + }, + "request": { + "added": "Dodano żądanie", + "authorization": "Autoryzacja", + "body": "Treść zapytania", + "choose_language": "Wybierz język", + "content_type": "Typ zawartości", + "content_type_titles": { + "others": "Others", + "structured": "Structured", + "text": "Text" + }, + "different_collection": "Cannot reorder requests from different collections", + "duplicated": "Request duplicated", + "duration": "Czas trwania", + "enter_curl": "Wpisz cURL", + "generate_code": "Wygeneruj kod", + "generated_code": "Wygenerowany kod", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "Lista nagłówków", + "invalid_name": "Podaj nazwę żądania", + "method": "metoda", + "moved": "Request moved", + "name": "Nazwa", + "new": "New Request", + "order_changed": "Request Order Updated", + "override": "Override", + "override_help": "Set Content-Type in Headers", + "overriden": "Overridden", + "parameter_list": "Parametry zapytania", + "parameters": "Parametry", + "path": "Ścieżka", + "payload": "Dane żądania", + "query": "Zapytanie", + "raw_body": "Surowa treść żądania", + "rename": "Rename Request", + "renamed": "Zmieniono nazwę żądania", + "request_variables": "Request variables", + "run": "Uruchom", + "save": "Zapisz", + "save_as": "Zapisz jako", + "saved": "Żądanie zostało zapisane", + "share": "Udostępnij", + "share_description": "Share Hoppscotch with your friends", + "share_request": "Share Request", + "stop": "Stop", + "title": "Żądanie", + "type": "Typ żądania", + "url": "URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "Zmienne", + "view_my_links": "View my links", + "copy_link": "Skopiuj link" + }, + "response": { + "audio": "Audio", + "body": "Ciało odpowiedzi", + "filter_response_body": "Filter JSON response body (uses jq syntax)", + "headers": "Nagłówki", + "html": "HTML", + "image": "Obraz", + "json": "JSON", + "pdf": "PDF", + "preview_html": "Podgląd HTML", + "raw": "Surowe", + "size": "Rozmiar", + "status": "Status", + "time": "Czas", + "title": "Odpowiedź", + "video": "Video", + "waiting_for_connection": "oczekiwanie na połączenie", + "xml": "XML" + }, + "settings": { + "accent_color": "Kolor akcentu", + "account": "Konto", + "account_deleted": "Your account has been deleted", + "account_description": "Dostosuj ustawienia swojego konta.", + "account_email_description": "Twój podstawowy adres e-mail.", + "account_name_description": "To jest Twoja nazwa wyświetlana.", + "additional": "Additional Settings", + "background": "Tło", + "black_mode": "Czarny", + "choose_language": "Wybierz język", + "dark_mode": "Ciemny", + "delete_account": "Delete account", + "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", + "expand_navigation": "Expand navigation", + "experiments": "Eksperymenty", + "experiments_notice": "To jest zbiór eksperymentów, nad którymi pracujemy, które mogą okazać się przydatne, zabawne, obie te rzeczy albo żadne. Nie są ostateczne i mogą nie być stabilne, więc jeśli wydarzy się coś zbyt dziwnego, nie panikuj. Po prostu wyłącz to cholerstwo. Żarty na bok,", + "extension_ver_not_reported": "Nie zgłoszony", + "extension_version": "Wersja rozszerzenia", + "extensions": "Rozszerzenia", + "extensions_use_toggle": "Użyj rozszerzenia przeglądarki do wysyłania żądań (jeśli istnieje)", + "follow": "Follow Us", + "interceptor": "Interceptor", + "interceptor_description": "Oprogramowanie pośredniczące między aplikacją a interfejsami API.", + "language": "Język", + "light_mode": "Jasny", + "official_proxy_hosting": "Oficjalne proxy jest obsługiwane przez Hoppscotch.", + "profile": "Profile", + "profile_description": "Update your profile details", + "profile_email": "Email address", + "profile_name": "Profile name", + "proxy": "Proxy", + "proxy_url": "Adres URL proxy", + "proxy_use_toggle": "Użyj oprogramowania pośredniczącego proxy do wysyłania żądań", + "read_the": "Sprawdź", + "reset_default": "Przywróć ustawienia domyślne", + "short_codes": "Short codes", + "short_codes_description": "Short codes which were created by you.", + "sidebar_on_left": "Sidebar on left", + "sync": "Synchronizuj", + "sync_collections": "Kolekcje", + "sync_description": "Te ustawienia są synchronizowane z chmurą.", + "sync_environments": "Środowiska", + "sync_history": "Historia", + "system_mode": "System", + "telemetry": "Telemetria", + "telemetry_helps_us": "Telemetria pomaga nam spersonalizować nasze działania i zapewnić Ci jak najlepsze wrażenia.", + "theme": "Motyw", + "theme_description": "Dostosuj motyw aplikacji.", + "use_experimental_url_bar": "Użyj eksperymentalnego paska adresu URL z podświetlaniem środowiska", + "user": "Użytkownik", + "verified_email": "Verified email", + "verify_email": "Verify email" + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Zamknij bieżące menu", + "command_menu": "Menu wyszukiwania i poleceń", + "help_menu": "Menu pomocy", + "show_all": "Skróty klawiszowe", + "title": "Ogólny" + }, + "miscellaneous": { + "invite": "Zaproś ludzi do Hoppscotch", + "title": "Różne" + }, + "navigation": { + "back": "Wróć do poprzedniej strony", + "documentation": "Przejdź do strony dokumentacji", + "forward": "Przejdź do następnej strony", + "graphql": "Przejdź do strony GraphQL", + "profile": "Go to Profile page", + "realtime": "Przejdź do strony Realtime", + "rest": "Przejdź do strony REST", + "settings": "Przejdź do strony Ustawienia", + "title": "Nawigacja" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "Wybierz metodę DELETE", + "get_method": "Wybierz metodę GET", + "head_method": "Wybierz metodę HEAD", + "import_curl": "Import cURL", + "method": "metoda", + "next_method": "Wybierz następną metodę", + "post_method": "Wybierz metodę POST", + "previous_method": "Wybierz poprzednią metodę", + "put_method": "Wybierz metodę PUT", + "rename": "Rename Request", + "reset_request": "Zresetuj żądanie", + "save_request": "Save Request", + "save_to_collections": "Zapisz w kolekcjach", + "send_request": "Wyślij żądanie", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "Żądania", + "copy_request_link": "Kopiuj łącze żądania" + }, + "response": { + "copy": "Copy response to clipboard", + "download": "Download response as file", + "title": "Response" + }, + "theme": { + "black": "Switch theme to black mode", + "dark": "Switch theme to dark mode", + "light": "Switch theme to light mode", + "system": "Switch theme to system mode", + "title": "Theme" + } + }, + "show": { + "code": "Pokaż kod", + "collection": "Expand Collection Panel", + "more": "Pokaż więcej", + "sidebar": "Pokaż pasek boczny" + }, + "socketio": { + "communication": "Komunikacja", + "connection_not_authorized": "This SocketIO connection does not use any authentication.", + "event_name": "Nazwa wydarzenia", + "events": "Wydarzenia", + "log": "Logi", + "url": "URL" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current team", + "edit": "Edit current team", + "invite": "Invite people to team", + "new": "Create new team", + "switch_to_personal": "Switch to your personal workspace", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "Typ wydarzenia", + "log": "Logi", + "url": "URL" + }, + "state": { + "bulk_mode": "Edycja zbiorcza", + "bulk_mode_placeholder": "Wpisy są oddzielone znakiem nowej linii\nKlucze i wartości są oddzielone:\nDołącz # do dowolnego wiersza, który chcesz dodać, ale pozostaw wyłączony", + "cleared": "Wyczyszczono", + "connected": "Połączony", + "connected_to": "Połączony z {name}", + "connecting_to": "Łączę z {name}...", + "connection_error": "Failed to connect", + "connection_failed": "Connection failed", + "connection_lost": "Connection lost", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "Skopiowane do schowka", + "deleted": "Usunięto", + "deprecated": "PRZESTARZAŁE", + "disabled": "Wyłączony", + "disconnected": "Rozłączono", + "disconnected_from": "Rozłączono z {name}", + "docs_generated": "Wygenerowana dokumentacja", + "download_failed": "Download failed", + "download_started": "Pobieranie rozpoczęte", + "enabled": "Włączony", + "file_imported": "Zaimportowany plik", + "finished_in": "Zakończono za {duration} ms", + "hide": "Hide", + "history_deleted": "Historia została usunięta", + "linewrap": "Zawijaj linie", + "loading": "Ładowanie...", + "message_received": "Message: {message} arrived on topic: {topic}", + "mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}", + "none": "Brak", + "nothing_found": "Nic nie znaleziono dla", + "published_error": "Something went wrong while publishing msg: {topic} to topic: {message}", + "published_message": "Published message: {message} to topic: {topic}", + "reconnection_error": "Failed to reconnect", + "show": "Show", + "subscribed_failed": "Failed to subscribe to topic: {topic}", + "subscribed_success": "Successfully subscribed to topic: {topic}", + "unsubscribed_failed": "Failed to unsubscribe from topic: {topic}", + "unsubscribed_success": "Successfully unsubscribed from topic: {topic}", + "waiting_send_request": "Czekam na wysłanie żądania" + }, + "support": { + "changelog": "Przeczytaj więcej o najnowszych wydaniach", + "chat": "Pytania? Porozmawiaj z nami!", + "community": "Zadawaj pytania i pomagaj innym", + "documentation": "Przeczytaj więcej o Hoppscotch", + "forum": "Zadawaj pytania i otrzymuj odpowiedzi", + "github": "Follow us on Github", + "shortcuts": "Przeglądaj aplikację szybciej", + "title": "Wsparcie", + "twitter": "Śledź nas na Twitterze", + "team": "Skontaktuj się z zespołem" + }, + "tab": { + "authorization": "Autoryzacja", + "body": "Ciało", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "Kolekcje", + "documentation": "Dokumentacja", + "duplicate": "Duplicate Tab", + "environments": "Environments", + "headers": "Nagłówki", + "history": "Historia", + "mqtt": "MQTT", + "parameters": "Parametry", + "pre_request_script": "Skrypt żądania wstępnego", + "queries": "Zapytania", + "query": "Zapytanie", + "schema": "Schema", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Testy", + "types": "Rodzaje", + "variables": "Zmienne", + "websocket": "WebSocket" + }, + "team": { + "already_member": "You are already a member of this team. Contact your team owner.", + "create_new": "Utwórz nowy zespół", + "deleted": "Zespół usunięty", + "edit": "Edytuj zespół", + "email": "E-mail", + "email_do_not_match": "Email doesn't match with your account details. Contact your team owner.", + "exit": "Opóść zespół", + "exit_disabled": "Tylko właściciel nie może opuścić zespołu", + "failed_invites": "Failed invites", + "invalid_coll_id": "Invalid collection ID", + "invalid_email_format": "Format e-maila jest nieprawidłowy", + "invalid_id": "Invalid team ID. Contact your team owner.", + "invalid_invite_link": "Invalid invite link", + "invalid_invite_link_description": "The link you followed is invalid. Contact your team owner.", + "invalid_member_permission": "Proszę podać ważne pozwolenie członkowi zespołu", + "invite": "Invite", + "invite_more": "Invite more", + "invite_tooltip": "Invite people to this workspace", + "invited_to_team": "{owner} invited you to join {team}", + "join": "Invitation accepted", + "join_team": "Join {team}", + "joined_team": "You have joined {team}", + "joined_team_description": "You are now a member of this team", + "left": "Opuściłeś zespół", + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to join a team.", + "logout_and_try_again": "Logout and sign in with another account", + "member_has_invite": "This email ID already has an invite. Contact your team owner.", + "member_not_found": "Member not found. Contact your team owner.", + "member_removed": "Użytkownik usunięty", + "member_role_updated": "Zaktualizowano role użytkowników", + "members": "Członkowie", + "more_members": "+{count} more", + "name_length_insufficient": "Nazwa zespołu powinna mieć co najmniej 6 znaków", + "name_updated": "Team name updated", + "new": "Nowy zespół", + "new_created": "Utworzono nowy zespół", + "new_name": "Mój nowy zespół", + "no_access": "Nie masz uprawnień do edycji tych kolekcji", + "no_invite_found": "Invitation not found. Contact your team owner.", + "no_request_found": "Request not found.", + "not_found": "Team not found. Contact your team owner.", + "not_valid_viewer": "You are not a valid viewer. Contact your team owner.", + "parent_coll_move": "Cannot move collection to a child collection", + "pending_invites": "Pending invites", + "permissions": "Uprawnienia", + "same_target_destination": "Same target and destination", + "saved": "Zespół zapisany", + "select_a_team": "Select a team", + "success_invites": "Success invites", + "title": "Zespoły", + "we_sent_invite_link": "We sent an invite link to all invitees!", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "Ask all invitees to check their inbox. Click on the link to join the team.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "Dołącz do programu beta, aby uzyskać dostęp do zespołów." + }, + "team_environment": { + "deleted": "Environment Deleted", + "duplicate": "Environment Duplicated", + "not_found": "Environment not found." + }, + "test": { + "failed": "test failed", + "javascript_code": "Kod JavaScript", + "learn": "Przeczytaj dokumentację", + "passed": "test passed", + "report": "Sprawozdanie z badań", + "results": "Wyniki testu", + "script": "Scenariusz", + "snippets": "Snippety" + }, + "websocket": { + "communication": "Komunikacja", + "log": "Logi", + "message": "Wiadomość", + "protocols": "Protokoły", + "url": "URL" + }, + "workspace": { + "change": "Change workspace", + "personal": "My Workspace", + "other_workspaces": "My Workspaces", + "team": "Team Workspace", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "Actions", + "created_on": "Created on", + "deleted": "Shortcode deleted", + "method": "Method", + "not_found": "Shortcode not found", + "short_code": "Short code", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/pt-br.json b/packages/hoppscotch-common/locales/pt-br.json new file mode 100644 index 0000000..309d62b --- /dev/null +++ b/packages/hoppscotch-common/locales/pt-br.json @@ -0,0 +1,1273 @@ +{ + "action": { + "add": "Adicionar", + "autoscroll": "Rolagem automática", + "cancel": "Cancelar", + "choose_file": "Escolha um arquivo", + "choose_workspace": "Escolha um workspace", + "choose_collection": "Escolha uma coleção", + "select_workspace": "Selecionar um workspace", + "clear": "Limpar", + "clear_all": "Limpar tudo", + "clear_history": "Limpar o Histórico", + "close": "Fechar", + "connect": "Conectar", + "connecting": "Conectando", + "copy": "Copiar", + "create": "Criar", + "delete": "Excluir", + "disconnect": "Desconectar", + "dismiss": "Dispensar", + "dont_save": "Não salvar", + "download_file": "⇬ Fazer download do arquivo", + "download_test_report": "Baixar relatório de teste", + "drag_to_reorder": "Arrastar para reordenar", + "duplicate": "Duplicar", + "edit": "Editar", + "filter": "Filtrar", + "go_back": "Voltar", + "go_forward": "Avançar", + "group_by": "Agrupar por", + "hide_secret": "Esconder", + "label": "Nome", + "learn_more": "Saiba mais", + "download_here": "Baixe aqui", + "less": "Menos", + "more": "Mais", + "new": "Novo", + "no": "Não", + "open_workspace": "Abrir workspace", + "paste": "Colar", + "prettify": "Embelezar", + "properties": "Propriedades", + "register": "Registrar-se", + "remove": "Remover", + "rename": "Renomear", + "restore": "Restaurar", + "retry": "Tentar novamente", + "save": "Salvar", + "save_as_example": "Salvar como exemplo", + "scroll_to_bottom": "Ir para o final", + "scroll_to_top": "Ir para o topo", + "search": "Procurar", + "send": "Enviar", + "share": "Compartilhar", + "show_secret": "Exibir", + "start": "Iniciar", + "starting": "Iniciando", + "stop": "Parar", + "to_close": "Fechar", + "to_navigate": "Navegar", + "to_select": "Selecionar", + "turn_off": "Desligar", + "turn_on": "Ligar", + "undo": "Desfazer", + "verify": "Verificar", + "yes": "Sim", + "enable": "Habilitar", + "disable": "Desabilitar" + }, + "add": { + "new": "Adicionar novo", + "star": "Adicionar estrela" + }, + "agent": { + "registration_instruction": "Para continuar, cadastre o Agente Hoppscotch com seu cliente web.", + "enter_otp_instruction": "Insira o código de verificação gerado pelo Agente Hoppscotch e complete o cadastro", + "otp_label": "Cõdigo de verificação", + "processing": "Processando sua requisição...", + "not_running": "O Agente Hoppscotch não está executando. Inicie o agente e clique em 'Tentar novamente'.", + "not_running_title": "Agente não detectado", + "registration_title": "Cadastro do agente", + "verify_ssl_certs": "Verificar Certificados SSL", + "ca_certs": "Certificados CA", + "client_certs": "Certificados de Cliente", + "use_http_proxy": "Usar HTTP Proxy", + "proxy_capabilities": "O Agente Hoppscotch suporta proxies HTTP/HTTPS/SOCKS com NTLM e Basic Auth. Inclua o nome de usuário e a senha para a autenticação do proxy na URL.", + "add_cert_file": "Adicionar Arquivo de Certificado", + "add_client_cert": "Adicionar Certificado de Cliente", + "add_key_file": "Adicionar Arquivo Chave", + "domain": "Domínio", + "cert": "Certificado", + "key": "Chave", + "pfx_or_pkcs": "PFX/PKCS12", + "pfx_or_pkcs_file": "Arquivo PFX/PKCS12", + "add_pfx_or_pkcs_file": "Adicionar Arquivo PFX/PKCS12" + }, + "app": { + "chat_with_us": "Converse conosco", + "contact_us": "Contate-nos", + "cookies": "Cookies", + "copy": "Copiar", + "copy_interface_type": "Copiar tipo de interface", + "copy_user_id": "Copiar token de autenticação do usuário", + "developer_option": "Opções de desenvolvedor", + "developer_option_description": "Opções de desenvolvedor que ajudam no desenvolvimento e manutenção do Hoppscotch.", + "discord": "Discord", + "documentation": "Documentação", + "github": "GitHub", + "help": "Ajuda, feedback e documentação", + "home": "Página Inicial", + "invite": "Convidar", + "invite_description": "No Hoppscotch, projetamos uma interface simples e intuitiva para criar e gerenciar suas APIs. Hoppscotch é uma ferramenta que o ajuda a construir, testar, documentar e compartilhar suas APIs.", + "invite_your_friends": "Convide seus amigos", + "join_discord_community": "Junte-se à nossa comunidade Discord", + "keyboard_shortcuts": "Atalhos do teclado", + "name": "Hoppscotch", + "new_version_found": "Nova versão encontrada. Recarregue para atualizar.", + "open_in_hoppscotch": "Abrir no Hoppscotch", + "options": "Opções", + "proxy_privacy_policy": "Política de privacidade do proxy", + "reload": "Recarregar", + "search": "Procurar", + "share": "Compartilhar", + "shortcuts": "Atalhos", + "social_description": "Siga nossas redes sociais para acompanhar as últimas notícias, atualizações e lançamentos.", + "social_links": "Redes sociais", + "spotlight": "Destaque", + "status": "Status", + "status_description": "Verifique o estado do website.", + "terms_and_privacy": "Termos e privacidade", + "twitter": "Twitter", + "type_a_command_search": "Digite um comando ou pesquise...", + "we_use_cookies": "Usamos cookies", + "updated_text": "O Hoppscotch foi atualizado para v{version} 🎉", + "whats_new": "O que há de novo?", + "see_whats_new": "Ver novidades", + "wiki": "Wiki", + "default": "default: {value}" + }, + "auth": { + "account_exists": "A conta existe com credenciais diferentes - Faça login para vincular as duas contas", + "all_sign_in_options": "Todas as opções de login", + "continue_with_auth_provider": "Continuar com {provider}", + "continue_with_email": "Continuar com Email", + "continue_with_github": "Continuar com GitHub", + "continue_with_github_enterprise": "Continuar com GitHub Enterprise", + "continue_with_google": "Continuar com o Google", + "continue_with_microsoft": "Continuar com Microsoft", + "email": "E-mail", + "logged_out": "Desconectado", + "login": "Conecte-se", + "login_success": "Conectado com sucesso", + "login_to_hoppscotch": "Faça login no Hoppscotch", + "logout": "Sair", + "re_enter_email": "Digite o e-mail novamente", + "send_magic_link": "Envie um link mágico", + "sync": "Sincronizar", + "we_sent_magic_link": "Enviamos a você um link mágico!", + "we_sent_magic_link_description": "Verifique sua caixa de entrada - enviamos um e-mail para {email}. Ele contém um link mágico que fará o seu login." + }, + "authorization": { + "generate_token": "Gerar token", + "refresh_token": "Refresh Token", + "graphql_headers": "Cabeçalhos de Autorização são enviados como parte do payload de connection_init", + "include_in_url": "Incluir na URL", + "inherited_from": "Herdado {auth} da Coleção Pai {collection}", + "learn": "Aprenda como", + "oauth": { + "redirect_auth_server_returned_error": "O servidor de autenticação retornou um estado de erro.", + "redirect_auth_token_request_failed": "Falha na requisição do token de autenticação", + "redirect_auth_token_request_invalid_response": "Resposta inválida do endpoint de token ao solicitar um token de autenticação", + "redirect_invalid_state": "Valor de estado inválido presente no redirecionamento", + "redirect_no_auth_code": "Nenhum código de Autorização presente no redirecionamento", + "redirect_no_client_id": "Nenhum ID de Cliente definido", + "redirect_no_client_secret": "Nenhum secret de Cliente definido", + "redirect_no_code_verifier": "Nenhum verificador de código definido", + "redirect_no_token_endpoint": "Nenhum endpoint de token definido", + "something_went_wrong_on_oauth_redirect": "Algo deu errado no redirecionamento OAuth", + "something_went_wrong_on_token_generation": "Algo deu errado na geração do token", + "token_generation_oidc_discovery_failed": "Falha na geração do token: OpenID Connect Discovery Failed", + "grant_type": "Tipo de Autorização", + "grant_type_auth_code": "Código de Autorização", + "token_fetched_successfully": "Token buscado com sucesso", + "token_fetch_failed": "Falha ao buscar token", + "validation_failed": "Falha na validação, verifique os campos do formulário", + "no_refresh_token_present": "Nenhum Refresh Token encontrado. Execute o fluxo de geração de token novamente", + "refresh_token_request_failed": "Falha na requisição do refresh token", + "token_refreshed_successfully": "Token atualizado com sucesso", + "label_authorization_endpoint": "Endpoint de Autorização", + "label_client_id": "ID Cliente", + "label_client_secret": "Secret de Cliente", + "label_code_challenge": "Desafio de Código", + "label_code_challenge_method": "Método de Desafio de Código", + "label_code_verifier": "Verificador de Código", + "label_scopes": "Escopos", + "label_token_endpoint": "Endpoint de Token", + "label_use_pkce": "Usar PKCE", + "label_implicit": "Implícito", + "label_password": "Senha", + "label_username": "Nome de usuário", + "label_auth_code": "Código de Autorização", + "label_client_credentials": "Credenciais de Cliente", + "label_send_as": "Autenticação de Cliente", + "label_send_in_body": "Enviar Credenciais no Corpo", + "label_send_as_basic_auth": "Enviar Credenciais como Basic Auth" + }, + "pass_key_by": "Passar por", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Cabeçalhos", + "password": "Senha", + "save_to_inherit": "Salve essa requisição em alguma coleção para herdar a autorização", + "token": "Token", + "type": "Tipo de Autorização", + "username": "Nome de usuário", + "advance_config": "Configurações avançadas", + "advance_config_description": "O Hoppscotch atribui valores padrão para alguns campos se os valores não forem informados", + "aws_signature": { + "access_key": "Access Key", + "secret_key": "Secret Key", + "service_name": "Service Name", + "aws_region": "Região AWS", + "service_token": "Service Token" + }, + "digest": { + "realm": "Realm", + "nonce": "Nonce", + "algorithm": "Algoritmo", + "qop": "qop", + "nonce_count": "Nonce Count", + "client_nonce": "Client Nonce", + "opaque": "Opaque", + "disable_retry": "Desabilitar Retentativa de Requisição", + "inspector_warning": "É recomendado utilizar o interceptor Agente ao utilizar Autorização Digest." + } + }, + "collection": { + "title": "Coleção", + "run": "Executar Coleção", + "created": "Coleção criada", + "different_parent": "Não é possível reordenar coleções com Coleções Pai diferentes", + "edit": "Editar coleção", + "import_or_create": "Importar ou criar uma coleção", + "import_collection": "Importar coleção", + "invalid_name": "Forneça um nome válido para a coleção", + "invalid_root_move": "A coleção já está na raiz", + "moved": "Movido com sucesso", + "my_collections": "Minhas coleções", + "name": "Minha nova coleção", + "name_length_insufficient": "O nome da coleção deve ter pelo menos 3 caracteres", + "new": "Nova coleção", + "order_changed": "Ordem das coleções atualizada", + "properties": "Propriedades da coleção", + "properties_updated": "Propriedades da coleção atualizadas", + "renamed": "Coleção renomeada", + "request_in_use": "Requisição em uso", + "save_as": "Salvar como", + "save_to_collection": "Salvar na Coleção", + "select": "Selecione uma coleção", + "select_location": "Selecione a localização", + "details": "Detalhes", + "duplicated": "Coleção duplicada" + }, + "confirm": { + "close_unsaved_tab": "Tem certeza de que deseja fechar esta aba?", + "close_unsaved_tabs": "Tem certeza de que deseja fechar todas as abas? {count} abas não salvas serão perdidas.", + "exit_team": "Tem certeza que deseja sair deste workspace?", + "logout": "Tem certeza que deseja sair?", + "remove_collection": "Tem certeza de que deseja excluir esta coleção permanentemente?", + "remove_environment": "Tem certeza de que deseja excluir este ambiente permanentemente?", + "remove_folder": "Tem certeza de que deseja excluir esta pasta permanentemente?", + "remove_history": "Tem certeza de que deseja excluir permanentemente todo o histórico?", + "remove_request": "Tem certeza de que deseja excluir permanentemente esta requisição?", + "remove_response": "Tem certeza de que deseja excluir permanentemente esta resposta?", + "remove_shared_request": "Tem certeza de que deseja excluir permanentemente esta requisição compartilhada?", + "remove_team": "Tem certeza que deseja excluir este workspace?", + "remove_telemetry": "Tem certeza de que deseja cancelar a telemetria?", + "request_change": "Tem certeza que deseja descartar a requisição atual? Alterações não salvas serão perdidas.", + "save_unsaved_tab": "Deseja salvar as atualizações feitas nesta aba?", + "sync": "Tem certeza de que deseja sincronizar este workspace? Suas alterações locais serão perdidas.", + "delete_access_token": "Tem certeza de que deseja excluir o token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Adicionar aos parâmetros", + "open_request_in_new_tab": "Abrir requisição em nova aba", + "set_environment_variable": "Configurar como variável" + }, + "cookies": { + "modal": { + "cookie_expires": "Expira em", + "cookie_name": "Nome", + "cookie_path": "Caminho", + "cookie_string": "Cookie string", + "cookie_value": "Valor", + "empty_domain": "Domínio vazio", + "empty_domains": "Lista de domínios vazia", + "enter_cookie_string": "Digite a string do cookie", + "interceptor_no_support": "Seu interceptor atualmente selecionado não suporta cookies. Selecione um Interceptor diferente e tente novamente.", + "managed_tab": "Gerenciado", + "new_domain_name": "Novo nome de domínio", + "no_cookies_in_domain": "Nenhum cookie definido para este domínio", + "raw_tab": "Bruto", + "set": "Definir um cookie" + } + }, + "count": { + "header": "Cabeçalho {count}", + "message": "Mensagem {count}", + "parameter": "Parâmetro {count}", + "key": "Chave {count}", + "description": "Descrição {count}", + "protocol": "Protocolo {count}", + "value": "Valor {count}", + "variable": "Variável {count}" + }, + "documentation": { + "generate": "Gerar documentação", + "generate_message": "Importe qualquer coleção de Hoppscotch para gerar documentação de API em movimento." + }, + "empty": { + "authorization": "Esta requisição não usa nenhuma autorização", + "body": "Esta requisição não tem corpo", + "collection": "A coleção está vazia", + "collections": "As coleções estão vazias", + "documentation": "Conecte-se a um endpoint GraphQL para ver a documentação", + "endpoint": "O endpoint não pode estar vazio", + "environments": "Nenhum ambiente criado", + "folder": "A pasta está vazia", + "headers": "Esta requisição não possui cabeçalhos", + "history": "O histórico está vazio", + "invites": "A lista de convites está vazia", + "members": "O workspace está vazio", + "parameters": "Esta requisição não possui parâmetros", + "pending_invites": "Não há convites pendentes para este workspace", + "profile": "Entre para visualizar seu perfil", + "protocols": "Nenhum protocolo adicionado", + "request_variables": "Esta requisição nâo contém nenhuma variável", + "schema": "Conecte-se a um endpoint GraphQL para visualizar o schema", + "secret_environments": "Secrets não sincronizados com o Hoppscotch", + "shared_requests": "Lista de requisições compartilhadas vazia", + "shared_requests_logout": "Faça Login para visualizar suas requisições compartilhadas ou crie uma nova", + "subscription": "Lista de inscrições vazia", + "team_name": "Nome do workspace vazio", + "teams": "Você não está em nenhum workspace", + "tests": "Não há testes para esta requisição", + "access_tokens": "Tokens de acesso vazios", + "response": "Nenhuma resposta recebida" + }, + "environment": { + "heading": "Ambiente", + "add_to_global": "Adicionar ao Global", + "added": "Adição de ambiente", + "create_new": "Criar um novo ambiente", + "created": "Ambiente criado", + "deleted": "Exclusão de ambiente", + "duplicated": "Ambiente duplicado", + "edit": "Editar Ambiente", + "empty_variables": "Nenhuma variável", + "global": "Global", + "global_variables": "Variáveis globais", + "import_or_create": "Importar ou criar um ambiente", + "invalid_name": "Forneça um nome válido para o ambiente", + "list": "Variáveis de ambiente", + "my_environments": "Meus Ambientes", + "name": "Nome", + "nested_overflow": "Variáveis de ambiente aninhadas são limitadas a 10 níveis", + "new": "Novo ambiente", + "no_active_environment": "Nenhum ambiente ativo", + "no_environment": "Sem ambiente", + "no_environment_description": "Nenhum ambiente foi selecionado. Escolha o que fazer com as seguintes variáveis.", + "quick_peek": "Visualização rápida do ambiente", + "replace_with_variable": "Substituir por variável", + "scope": "Escopo", + "secrets": "Secrets", + "secret_value": "Valor de Secret", + "select": "Selecione o ambiente", + "set": "Definir ambiente", + "set_as_environment": "Definir como ambiente", + "short_name": "O ambiente precisa ter pelo menos 3 caracteres", + "team_environments": "Ambientes do Workspace", + "title": "Ambientes", + "updated": "Atualizacao de ambientes", + "value": "Valor", + "variable": "Variável", + "variables": "Variáveis", + "variable_list": "Lista de Variáveis", + "properties": "Propriedades do Ambiente", + "details": "Detalhes" + }, + "error": { + "authproviders_load_error": "Não foi possível carregar os provedores de autenticação", + "browser_support_sse": "Este navegador não parece ter suporte para eventos enviados pelo servidor.", + "check_console_details": "Verifique o log do console para obter detalhes.", + "check_how_to_add_origin": "Veja como adicionar uma origem", + "curl_invalid_format": "cURL não está formatado corretamente", + "danger_zone": "Zona de perigo", + "delete_account": "Atualmente sua conta é proprietária nesses workspaces:", + "delete_account_description": "Você precisa sair, transferir para outro proprietário ou remover esses workspaces antes de excluir sua conta.", + "empty_email_address": "Endereço de e-mail não pode estar vazio", + "empty_profile_name": "O nome do perfil não pode estar vazio", + "empty_req_name": "Nome de requisição vazio", + "f12_details": "(F12 para detalhes)", + "gql_prettify_invalid_query": "Não foi possível justificar uma requisição inválida, resolva os erros de sintaxe da requisição e tente novamente", + "incomplete_config_urls": "URLs de configuração incompletas", + "incorrect_email": "Email incorreto", + "invalid_file_type": "Tipo de arquivo inválido para `{filename}`.", + "invalid_link": "Link inválido", + "invalid_link_description": "O link que você clicou é inválido ou já expirou.", + "invalid_embed_link": "O embed não existe ou é inálido.", + "json_parsing_failed": "JSON inválido", + "json_prettify_invalid_body": "Não foi possível embelezar um corpo inválido, resolver erros de sintaxe json e tentar novamente", + "network_error": "Parece que houve um problema de rede. Por favor, tente novamente.", + "network_fail": "Não foi possível enviar requisição", + "no_collections_to_export": "Nenhuma coleção para exportar. Por favor, crie uma coleção para iniciar.", + "no_duration": "Sem duração", + "no_environments_to_export": "Nenhum ambiente para exportar. Por favor, crie um ambiente para iniciar.", + "no_results_found": "Nenhuma correspondência encontrada", + "page_not_found": "Esta página não foi encontrada", + "please_install_extension": "Instale a extensão e adicione a origem a ela.", + "proxy_error": "Erro de Proxy", + "same_email_address": "O endereço de e-mail atualizado é o mesmo que o endereço de e-mail atual", + "same_profile_name": "O nome do perfil atualizado é igual ao atual", + "script_fail": "Não foi possível executar o script pré-requisição", + "something_went_wrong": "Algo deu errado", + "post_request_script_fail": "Não foi possível executar o script pós-requisição", + "reading_files": "Erro na leitura de um ou mais arquivos.", + "fetching_access_tokens_list": "Algo deu errado ao buscar a lista de tokens", + "generate_access_token": "Algo deu errado ao gerar o token de acesso", + "delete_access_token": "Algo deu errado ao excluir o token de acesso" + }, + "export": { + "as_json": "Exportar como JSON", + "create_secret_gist": "Criar um secret Gist", + "create_secret_gist_tooltip_text": "Exportar como secret Gist", + "failed": "Algo deu errado ao exportar", + "secret_gist_success": "Exportado com sucesso como secret Gist", + "require_github": "Faça login com GitHub para criar um secret Gist", + "title": "Exportar", + "success": "Exportado coom sucesso" + }, + "filename": { + "cookie_key_value_pairs": "Cookie", + "codegen": "{request_name} - código", + "graphql_response": "GraphQL-Resposta", + "lens": "{request_name} - resposta", + "realtime_response": "Resposta-Tempo-real", + "response_interface": "Resposta-Interface" + }, + "filter": { + "all": "Todos", + "none": "Nenhum", + "starred": "Favoritos" + }, + "folder": { + "created": "Pasta criada", + "edit": "Editar pasta", + "invalid_name": "Forneça um nome para a pasta", + "name_length_insufficient": "O nome da pasta deve ter pelo menos 3 caracteres", + "new": "Nova pasta", + "run": "Executar Pasta", + "renamed": "Pasta renomeada" + }, + "graphql": { + "connection_switch_confirm": "Deseja conectar-se com o endpoint GraphQL mais recente?", + "connection_error_http": "Falha ao buscar o schema GraphQL devido a erro de conexão.", + "connection_switch_new_url": "Alternar para uma aba irá desconectá-lo da conexão GraphQL ativa. A nova URL de conexão é", + "connection_switch_url": "Você está conectado a um endpoint GraphQL. A URL de conexão é", + "mutations": "Mutations", + "schema": "Schema", + "subscriptions": "Assinaturas", + "switch_connection": "Alternar conexão", + "url_placeholder": "Insira a URL de um endpoint GraphQL" + }, + "graphql_collections": { + "title": "Coleções GraphQL" + }, + "group": { + "time": "Tempo", + "url": "URL" + }, + "header": { + "install_pwa": "Instalar aplicativo", + "login": "Conecte-se", + "save_workspace": "Salvar meu espaço de trabalho" + }, + "helpers": { + "authorization": "O cabeçalho da autorização será gerado automaticamente quando você enviar a requisição.", + "collection_properties_authorization": "Esta autorização será aplicada a todas as requisições nesta coleção.", + "collection_properties_header": "Este cabeçalho será aplicado a todas as requisições desta coleção.", + "generate_documentation_first": "Gere a documentação primeiro", + "network_fail": "Não foi possível alcançar o endpoint da API. Verifique sua conexão de rede e tente novamente.", + "offline": "Você parece estar offline. Os dados neste espaço de trabalho podem não estar atualizados.", + "offline_short": "Você parece estar offline.", + "post_request_tests": "Os scripts de teste são gravados em JavaScript e executados após o recebimento da resposta.", + "pre_request_script": "Os scripts de pré-requisição são gravados em JavaScript e executados antes do envio da requisição.", + "script_fail": "Parece que há uma falha no script de pré-requisição. Verifique o erro abaixo e corrija o script de acordo.", + "post_request_script_fail": "Parece haver um erro com o script de teste. Corrija os erros e execute os testes novamente", + "post_request_script": "Escreva um script de teste para automatizar a depuração." + }, + "hide": { + "collection": "Encolher Painel de Coleções", + "more": "Esconda mais", + "preview": "Esconder a Antevisão", + "sidebar": "Ocultar barra lateral" + }, + "import": { + "collections": "Importar coleções", + "curl": "Importar cURL", + "environments_from_gist": "Importar do Gist", + "environments_from_gist_description": "Importar Ambientes Hoppscotch do Gist", + "failed": "A importação falhou", + "from_file": "Importar de Arquivo", + "from_gist": "Importar do Gist", + "from_gist_description": "Importar de URL Gist", + "from_gist_import_summary": "Todas as funcinalidades do Hoppscotch foram importadas.", + "from_hoppscotch_importer_summary": "Todas as funcinalidades do Hoppscotch foram importadas.", + "from_insomnia": "Importar do Insomnia", + "from_insomnia_description": "Importa de coleção Insomnia", + "from_insomnia_import_summary": "Coleções e Requisições serão importadas.", + "from_json": "Importar de Hoppscotch", + "from_json_description": "Importar de arquivo de coleção Hoppscotch", + "from_my_collections": "Importar de minhas coleções", + "from_my_collections_description": "Importar de arquivo Minhas Coleções", + "from_all_collections": "Importar de outro Workspace", + "from_all_collections_description": "Importar uma coleção de outro Workspace para o atual", + "from_openapi": "Importar de OpenAPI", + "from_openapi_description": "Importa de arquivo de especificação OpenAPI (YML/JSON)", + "from_openapi_import_summary": "Exemplos de Coleções ( serão criadas a partir das tags ), Requisições e Resposta serão importados.", + "from_postman": "Importar do Postman", + "from_postman_description": "Importar de coleção Postman", + "from_postman_import_summary": "Exemplos de Coleções, Requisições e Resposta serão importados.", + "from_url": "Importar de URL", + "gist_url": "Insira o URL do Gist", + "from_har": "Importar de HAR", + "from_har_description": "Importar de arquivo HAR", + "from_har_import_summary": "As requisição serão importadas para uma coleção default.", + "gql_collections_from_gist_description": "Importar Coleções GraphQL do Gist", + "hoppscotch_environment": "Ambiente Hoppscotch", + "hoppscotch_environment_description": "Importar Ambiente Hoppscotch de arquivo JSON", + "import_from_url_invalid_fetch": "Não foi possível buscar os dados pela URL", + "import_from_url_invalid_file_format": "Erro ao importar as coleções", + "import_from_url_invalid_type": "Tipo não suportado. Valores aceitos: 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Coleções importadas", + "insomnia_environment_description": "Importar Ambiente Insomnia de arquivo JSON/YAML", + "json_description": "Importa coleções de um arquivo JSON de Coleções Hoppscotch", + "postman_environment": "Ambiente Postman", + "postman_environment_description": "Importar Ambiente Postman de arquivo JSON/YAML", + "title": "Importar", + "file_size_limit_exceeded_warning_multiple_files": "O arquivo escolhido ultrapassa o limite recomendado de 10MB. Apenas os primeiros {files} selecionados serão importados", + "file_size_limit_exceeded_warning_single_file": "O arquivo escolhido ultrapassa o limite recomendado de 10MB. Selecione outro arquivo.", + "success": "Importado com sucesso", + "import_summary_collections_title": "Coleções", + "import_summary_requests_title": "Requisições", + "import_summary_responses_title": "Respostas", + "import_summary_pre_request_scripts_title": "Scripts de Pré-Requisição", + "import_summary_post_request_scripts_title": "Scripts pós-requisição", + "import_summary_not_supported_by_hoppscotch_import": "No momento, não suportamos a importação de {featureLabel} deste tipo de fonte." + }, + "inspections": { + "description": "Inspecionar possíveis erros", + "environment": { + "add_environment": "Adicionar ao Ambiente", + "add_environment_value": "Inserir valor", + "empty_value": "A variável '{variable}' está vazia no ambiente", + "not_found": "Variável de ambiente “{environment}” não encontrada." + }, + "header": { + "cookie": "O navegador não permite que o Hoppscotch aplique o Cabeçalho Cookie. Enquanto trabalhamos no Hoppscotch Desktop (em breve), por favor use o Cabeçalho de Autorização." + }, + "response": { + "401_error": "Verifique suas credenciais de autenticação.", + "404_error": "Verifique a URL da requisição e o tipo de método.", + "cors_error": "Verifique sua configuração de CORS (Cross-Origin Resource Sharing)", + "default_error": "Por favor, verifique sua requisição.", + "network_error": "Por favor, verifique sua conexão de rede." + }, + "title": "Inspetor", + "url": { + "extension_not_installed": "Extensão não instalada", + "extension_unknown_origin": "Verifique se você adicionou a origem do endpoint da API à lista da extensão de navegador do Hoppscotch.", + "extention_enable_action": "Habilitar Extensão do Navegador", + "extention_not_enabled": "Extensão não habilitada." + }, + "requestBody": { + "active_interceptor_doesnt_support_binary_body": "O interceptor selecionado ainda não possui suporte para envio de dados binários." + } + }, + "layout": { + "collapse_collection": "Encolher ou expandir coleções", + "collapse_sidebar": "Encolher ou Expandir a barra lateral", + "column": "Layout vertical", + "name": "Layout", + "row": "Layout horizontal" + }, + "modal": { + "close_unsaved_tab": "Você possui alterações não salvas", + "collections": "Coleções", + "confirm": "Confirmar", + "customize_request": "Personalizar Requisição", + "edit_request": "Editar requisição", + "edit_response": "Editar resposta", + "import_export": "Importar / Exportar", + "response_name": "Nome da Resposta", + "share_request": "Compartilhar Requisição" + }, + "mqtt": { + "already_subscribed": "Você já está inscrito neste tópico.", + "clean_session": "Clean Session", + "clear_input": "Limpar entrada", + "clear_input_on_send": "Limpar entrada ao enviar", + "client_id": "Client ID", + "color": "Selecione uma cor", + "communication": "Comunicação", + "connection_config": "Configuração de Conexão", + "connection_not_authorized": "Esta conexão MQTT não usa nenhuma autenticação.", + "invalid_topic": "Por favor, informe um tópico para a inscrição", + "keep_alive": "Keep Alive", + "log": "Log", + "lw_message": "Mensagem Last-Will", + "lw_qos": "Last-Will QoS", + "lw_retain": "Retenção Last-Will", + "lw_topic": "Tópico Last-Will", + "message": "Mensagem", + "new": "Nova Inscrição", + "not_connected": "Por favor, inicie uma conexão MQTT primeiro.", + "publish": "Publicar", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Se inscrever", + "topic": "Tema", + "topic_name": "Nome do tópico", + "topic_title": "Publicar / Assinar tópico", + "unsubscribe": "Cancelar inscrição", + "url": "URL" + }, + "navigation": { + "doc": "Docs", + "graphql": "GraphQL", + "profile": "Perfil", + "realtime": "Tempo real", + "rest": "REST", + "settings": "Configurações" + }, + "preRequest": { + "javascript_code": "Código JavaScript", + "learn": "Leia a documentação", + "script": "Script de pré-requisição", + "snippets": "Snippets" + }, + "profile": { + "app_settings": "Configurações do App", + "default_hopp_displayname": "Usuário Desconhecido", + "editor": "Editor", + "editor_description": "Editores podem adicionar, editar e deletar requisições.", + "email_verification_mail": "Um e-mail de verificação foi enviado ao seu endereço de e-mail. Por favor, clique no link para verificar seu endereço e-mail.", + "no_permission": "Você não tem permissão para realizar esta ação.", + "owner": "Proprietário", + "owner_description": "Proprietários podem adicionar, editar e deletar requisições, coleções e membros de workspace.", + "roles": "Funções", + "roles_description": "Funções são utilizadas para gerenciar acesso às coleções compartilhadas.", + "updated": "Perfil atualizado", + "viewer": "Visualizador", + "viewer_description": "Visualizadores só podem ver e usar requisições." + }, + "remove": { + "star": "Remover estrela" + }, + "request": { + "added": "Requisição adicionada", + "add": "Adicionar Requisição", + "authorization": "Autorização", + "body": "Corpo da Requisição", + "choose_language": "Escolha o seu idioma", + "content_type": "Tipo de conteúdo", + "content_type_titles": { + "others": "Outros", + "structured": "Estruturado", + "text": "Texto", + "binary": "Binário" + }, + "show_content_type": "Exibir Content Type", + "different_collection": "Não é possível reordenar requisições de coleções diferentes", + "duplicated": "Requisição duplicada", + "duration": "Duração", + "enter_curl": "Digite o cURL", + "generate_code": "Gerar código", + "generated_code": "Código gerado", + "go_to_authorization_tab": "Ir para a aba de Autorização", + "go_to_body_tab": "Ir para a aba de Corpo", + "header_list": "Lista de Cabeçalhos", + "invalid_name": "Insira um nome para a requisição", + "method": "Método", + "moved": "Requisição movida", + "name": "Nome da requisição", + "new": "Nova requisição", + "order_changed": "Ordem de Requisições Atualizada", + "override": "Substituir", + "override_help": "Substituir Content-Type em Headers", + "overriden": "Substituído", + "parameter_list": "Parâmetros da requisição", + "parameters": "Parâmetros", + "path": "Caminho", + "payload": "Payload", + "query": "Enviar", + "raw_body": "Corpo de Requisição Bruta", + "rename": "Renomear Requisição", + "renamed": "Requisição renomeada", + "request_variables": "Variáveis da Requisição", + "response_name_exists": "Este nome de Resposta já existe", + "run": "Executar", + "save": "Salvar", + "save_as": "Salvar como", + "saved": "Requisição salva", + "share": "Compartilhar", + "share_description": "Compartilhe o Hoppscotch com seus amigos", + "share_request": "Compartilhar Requisição", + "stop": "Parar", + "title": "Solicitar", + "type": "Tipo de requisição", + "url": "URL", + "url_placeholder": "Insira uma URL ou cole um comando cURL", + "variables": "Variáveis", + "view_my_links": "Ver meus links", + "generate_name_error": "Falha ao gerar o nome da requisição." + }, + "response": { + "audio": "Áudio", + "body": "Corpo de Resposta", + "duplicated": "Resposta duplicada", + "duplicate_name_error": "Já existe uma resposta com o mesmo nome", + "filter_response_body": "Filtar corpo de resposta JSON (utiliza sintaxe jq)", + "headers": "Cabeçalhos", + "request_headers": "Cabeçalhos de Requisição", + "html": "HTML", + "image": "Imagem", + "json": "JSON", + "pdf": "PDF", + "please_save_request": "Salve a requisição para criar um exemplo", + "preview_html": "Pré-visualizar HTML", + "raw": "Cru", + "renamed": "Resposta renomeada", + "size": "Tamanho", + "status": "Status", + "time": "Tempo", + "title": "Resposta", + "video": "Vídeo", + "waiting_for_connection": "aguardando conexão", + "xml": "XML", + "generate_data_schema": "Gerar Data Schema", + "data_schema": "Data Schema", + "saved": "Resposta salva", + "invalid_name": "Insira um nome para a resposta" + }, + "settings": { + "accent_color": "Cor de destaque", + "account": "Conta", + "account_deleted": "Sua conta foi excluída", + "account_description": "Personalize as configurações da sua conta.", + "account_email_description": "Seu endereço de e-mail principal.", + "account_name_description": "Este é o seu nome de exibição.", + "additional": "Configurações adicionais", + "auto_encode_mode": "Auto", + "auto_encode_mode_tooltip": "Codifique os parâmetros na requisição apenas se houver caracteres especiais", + "background": "Fundo", + "black_mode": "Preto", + "choose_language": "Escolha o seu idioma", + "dark_mode": "Escuro", + "delete_account": "Excluir conta", + "delete_account_description": "Ao deletar sua conta, todos os seus dados serão permanentemente excluídos. Esta ação não pode ser desfeita.", + "desktop": "Desktop", + "desktop_description": "Preferências específicas do aplicativo desktop Hoppscotch.", + "desktop_updates": "Atualizações", + "disable_encode_mode_tooltip": "Nunca codificar os parâmetros na requisição", + "disable_update_checks": "Desativar verificação automática de atualizações", + "enable_encode_mode_tooltip": "Sempre codificar os parâmetros na requisição", + "expand_navigation": "Expandir navegação", + "experiments": "Experimentos", + "experiments_notice": "Esta é uma coleção de experimentos em que estamos trabalhando que podem ser úteis, divertidos, ambos ou nenhum dos dois. Eles não são finais e podem não ser estáveis, portanto, se algo muito estranho acontecer, não entre em pânico. Simplesmente desligue esse troço. Brincadeiras à parte,", + "extension_ver_not_reported": "Não reportado", + "extension_version": "Versão da extensão", + "extensions": "Extensões", + "extensions_use_toggle": "Use a extensão do navegador para enviar solicitações (se houver)", + "follow": "Siga-nos", + "general": "Geral", + "general_description": " Configurações gerais utilizadas na aplicação", + "interceptor": "Interceptor", + "interceptor_description": "Middleware entre aplicativo e APIs.", + "language": "Idioma", + "light_mode": "Claro", + "official_proxy_hosting": "O Proxy oficial é hospedado pelo Hoppscotch.", + "query_parameters_encoding": "Codificação de Parâmetros de Consulta", + "query_parameters_encoding_description": "Configure a codificação para os parâmetros de consulta nas requisições", + "profile": "Perfil", + "profile_description": "Atualize os detalhes de seu perfil", + "profile_email": "Endereço de email", + "profile_name": "Nome do perfil", + "proxy": "Proxy", + "proxy_url": "URL do proxy", + "proxy_use_toggle": "Use o middleware de proxy para enviar requisições", + "read_the": "Leia o", + "reset_default": "Restaurar ao padrão", + "short_codes": "Short codes", + "short_codes_description": "Short codes que foram criados por você.", + "sidebar_on_left": "Barra lateral à esquerda", + "ai_experiments": "Experimentos com IA", + "ai_request_naming_style": "Estilo de nomeação das requisição", + "ai_request_naming_style_descriptive_with_spaces": "Descritivo com espaços", + "ai_request_naming_style_camel_case": "Camel Case ( camelCase )", + "ai_request_naming_style_snake_case": "Snake Case ( snake_case )", + "ai_request_naming_style_pascal_case": "Pascal Case ( PascalCase )", + "sync": "Sincronizar", + "sync_collections": "Coleções", + "sync_description": "Estas configurações são sincronizadas com a nuvem.", + "sync_environments": "Ambientes", + "sync_history": "Histórico", + "history_disabled": "O Histórico está desabilitado. Contate o administrador da sua organização para habilitá-lo.", + "system_mode": "Sistema", + "telemetry": "Telemetria", + "telemetry_helps_us": "A telemetria nos ajuda a personalizar nossas operações e oferecer a melhor experiência para você.", + "theme": "Tema", + "theme_description": "Personalize o tema do seu aplicativo.", + "use_experimental_url_bar": "Use a barra de URL experimental com destaque do ambiente", + "user": "Usuário", + "verified_email": "Email verificado", + "verify_email": "Verificar email" + }, + "shared_requests": { + "button": "Botão", + "button_info": "Criar um botão 'Executar no Hoppscotch' para seu website, blog ou README.", + "copy_html": "Copiar HTML", + "copy_link": "Copiar Link", + "copy_markdown": "Copiar Markdown", + "creating_widget": "Criando widget", + "customize": "Personalizar", + "deleted": "Requisição compartilhada excluída", + "description": "Selecione um widget, você poderá mudar e personalizar isso depois", + "embed": "Incorporar", + "embed_info": "Adicionar um mini 'Hoppscotch API Playground' a seu website, blog ou documentação.", + "link": "Link", + "link_info": "Crie um link para compartilhar com qualquer pessoa na internet com acesso de visualização.", + "modified": "Requisição compartilhada modificada", + "not_found": "Requisição compartilhada não encontrada", + "open_new_tab": "Abrir em nova aba", + "preview": "Pré-visualizar", + "run_in_hoppscotch": "Executar no Hoppscotch", + "theme": { + "dark": "Escuro", + "light": "Claro", + "system": "Sistema", + "title": "Tema" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Fechar menu atual", + "command_menu": "Menu de pesquisa e comando", + "help_menu": "Menu de ajuda", + "show_all": "Atalhos do teclado", + "title": "Geral" + }, + "miscellaneous": { + "invite": "Convide pessoas para Hoppscotch", + "title": "Diversos" + }, + "navigation": { + "back": "Voltar para a página anterior", + "documentation": "Ir para a página de documentação", + "forward": "Avançar para a próxima página", + "graphql": "Ir para a página GraphQL", + "profile": "Ir para a página do perfil", + "realtime": "Ir para a página em tempo real", + "rest": "Ir para a página REST", + "settings": "Ir para a página de configurações", + "title": "Navegação" + }, + "others": { + "prettify": "Embelezar o Conteúdo do Editor", + "title": "Outros" + }, + "request": { + "delete_method": "Selecionar o método DELETE", + "get_method": "Selecionar o método GET", + "head_method": "Selecionar o método HEAD", + "import_curl": "Importar cURL", + "method": "Método", + "next_method": "Selecionar o próximo método", + "post_method": "Selecionar o método POST", + "previous_method": "Selecionar o método anterior", + "put_method": "Selecionar o método PUT", + "rename": "Renomear Requisição", + "reset_request": "Restaturar Requisição", + "save_request": "Salvar Requisição", + "save_to_collections": "Salvar em Coleções", + "send_request": "Enviar requisição", + "share_request": "Compartilhar Requisição", + "show_code": "Gerar snippet de código", + "title": "Solicitar" + }, + "response": { + "copy": "Copiar resposta para a área de transferência", + "download": "Baixar resposta como arquivo", + "title": "Resposta" + }, + "theme": { + "black": "Alterar para o tema preto", + "dark": "Alterar para o tema escuro", + "light": "Alterar para o tema claro", + "system": "Alterar para o tema do sistema", + "title": "Tema" + } + }, + "show": { + "code": "Exibir código", + "collection": "Expandir Painel de Coleções", + "more": "Mostrar mais", + "sidebar": "Mostrar barra lateral" + }, + "socketio": { + "communication": "Comunicação", + "connection_not_authorized": "Esta conexão SocketIO não usa nenhuma autenticação.", + "event_name": "Nome do Evento/Tópico", + "events": "Eventos", + "log": "Log", + "url": "URL" + }, + "spotlight": { + "change_language": "Mudar idioma", + "environments": { + "delete": "Excluir ambiente atual", + "duplicate": "Duplicar ambiente atual", + "duplicate_global": "Duplicar ambiente global", + "edit": "Editar ambiente atual", + "edit_global": "Editar ambiente global", + "new": "Criar novo ambiente", + "new_variable": "Cirar nova variável de ambiente", + "title": "Ambientes" + }, + "general": { + "chat": "Conversar com o suporte", + "help_menu": "Ajuda e suporte", + "open_docs": "Ler a Documentação", + "open_github": "Abrir repositório GitHub", + "open_keybindings": "Atalhos de teclado", + "social": "Social", + "title": "Geral" + }, + "graphql": { + "connect": "Conectar ao servidor", + "disconnect": "Desconectar do servidor" + }, + "miscellaneous": { + "invite": "Convide amigos para o Hoppscotch", + "title": "Diversos" + }, + "request": { + "save_as_new": "Salvar como nova requisição", + "select_method": "Selecionar método", + "switch_to": "Alternar para", + "tab_authorization": "Aba de Autorização", + "tab_body": "Aba de Corpo", + "tab_headers": "Aba de Cabeçalhos", + "tab_parameters": "Aba de Parâmetros", + "tab_pre_request_script": "Aba de script pré-requisição", + "tab_query": "Aba de Query", + "tab_tests": "Aba de Testes", + "tab_variables": "Aba de Variáveis" + }, + "response": { + "copy": "Copiar resposta", + "download": "Baixar resposta como arquivo", + "title": "Resposta" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Tema", + "user": "Usuário" + }, + "settings": { + "change_interceptor": "Altrar Interceptor", + "change_language": "Alterar Idioma", + "theme": { + "black": "Preto", + "dark": "Escuro", + "light": "Claro", + "system": "Configuração do sistema" + } + }, + "tab": { + "close_current": "Fechar a aba atual", + "close_others": "Fechar as outras abas", + "duplicate": "Duplicar a aba atual", + "new_tab": "Abrir uma nova aba", + "title": "Abas" + }, + "workspace": { + "delete": "Excluir workspace atual", + "edit": "Editar workspace atual", + "invite": "Convidar para o workspace", + "new": "Criar novo workspace", + "switch_to_personal": "Alternar para seu workspace pessoal", + "title": "Workspaces" + }, + "phrases": { + "try": "Experimente", + "import_collections": "Importar coleções", + "create_environment": "Criar ambiente", + "create_workspace": "Criar workspace", + "share_request": "Compartilhar requisição" + } + }, + "sse": { + "event_type": "Tipo de evento", + "log": "Log", + "url": "URL" + }, + "state": { + "bulk_mode": "Edição em lote", + "bulk_mode_placeholder": "As entradas são separadas por nova linha\nChaves e valores são separados por :\nAdicione # ao início de qualquer linha que deseje manter desativada", + "cleared": "Liberado", + "connected": "Conectado", + "connected_to": "Conectado a {name}", + "connecting_to": "Conectando-se a {name} ...", + "connection_error": "Falha ao conectar", + "connection_failed": "Falha na conexão", + "connection_lost": "Conexão perdida", + "copied_interface_to_clipboard": "Interface {language} copiada para a área de transferência", + "copied_to_clipboard": "Copiado para a área de transferência", + "deleted": "Excluído", + "deprecated": "DESCONTINUADA", + "disabled": "Desabilitado", + "disconnected": "Desconectado", + "disconnected_from": "Desconectado de {name}", + "docs_generated": "Documentação gerada", + "download_failed": "Download falhou", + "download_started": "Download iniciado", + "enabled": "Habilitado", + "file_imported": "Arquivo importado", + "finished_in": "Terminado em {duration} ms", + "hide": "Ocultar", + "history_deleted": "Histórico excluído", + "linewrap": "Quebrar linhas", + "loading": "Carregando...", + "message_received": "Mensagem: {message} recebida no tópico: {topic}", + "mqtt_subscription_failed": "Algo deu errado ao inscrever-se no tópico: {topic}", + "none": "Nenhum", + "nothing_found": "Nada encontrado para", + "published_error": "Algo deu errado ao publicar a mensagem: {message} no tópico: {topic}", + "published_message": "Publicada mensagem: {message} no tópico: {topic}", + "reconnection_error": "Falha ao reconectar", + "show": "Exibir", + "subscribed_failed": "Faha ao inscrever-se no tópico: {topic}", + "subscribed_success": "Inscrito com sucesso no tópico: {topic}", + "unsubscribed_failed": "Falha ao cancelar inscrição no tópico: {topic}", + "unsubscribed_success": "Inscrição cancelada no tópico: {topic}", + "waiting_send_request": "Aguardando para enviar requisição", + "loading_workspaces": "Carregando workspaces", + "loading_collections_in_workspace": "Carregando coleções no workspace" + }, + "support": { + "changelog": "Leia mais sobre os últimos lançamentos", + "chat": "Perguntas? Converse conosco!", + "community": "Faça perguntas e ajude os outros", + "documentation": "Leia mais sobre Hoppscotch", + "forum": "Faça perguntas e obtenha respostas", + "github": "Siga-nos no Github", + "shortcuts": "Navegue pelo aplicativo mais rápido", + "title": "Suporte", + "twitter": "Siga-nos no Twitter" + }, + "tab": { + "authorization": "Autorização", + "body": "Corpo", + "close": "Fechar aba", + "close_others": "Fechar outras abas", + "collections": "Coleções", + "documentation": "Documentação", + "duplicate": "Duplicar aba", + "environments": "Ambientes", + "headers": "Cabeçalhos", + "history": "Histórico", + "mqtt": "MQTT", + "parameters": "Parâmetros", + "pre_request_script": "Script de pré-requisição", + "queries": "Consultas", + "query": "Consulta", + "schema": "Schema", + "shared_requests": "Requisições compartilhadas", + "codegen": "Gerar código", + "code_snippet": "Snippet de código", + "share_tab_request": "Compartilhar requisição da aba", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Testes", + "types": "Tipos", + "variables": "Variáveis", + "websocket": "WebSocket", + "all_tests": "Todos os testes", + "passed": "Sucesso", + "failed": "Falha" + }, + "team": { + "already_member": "Este email já está sendo utilizado por outro usuário.", + "create_new": "Criar novo workspace", + "deleted": "Workspace excluído", + "edit": "Editar Workspace", + "email": "E-mail", + "email_do_not_match": "O e-mail não corresponde aos detalhes da sua conta. Contate o proprietário do seu Workspace.", + "exit": "Sair do Workspace", + "exit_disabled": "O proprietário não pode sair do Workspace", + "failed_invites": "Convites com falha", + "invalid_coll_id": "ID de Coleção inválido", + "invalid_email_format": "O formato do email é inválido", + "invalid_id": "ID de workspace inválido. Contate o proprietário do Workspace.", + "invalid_invite_link": "Link de convite inválido", + "invalid_invite_link_description": "O link que você seguiu é inválido. Contate o proprietário do Workspace.", + "invalid_member_permission": "Forneça uma permissão válida para o membro do workspace", + "invite": "Convidar", + "invite_more": "Convidar mais", + "invite_tooltip": "Convidar pessoas para este workspace", + "invited_to_team": "{owner} te convidou para se juntar ao {workspace}", + "join": "Convite aceito", + "join_team": "Junte-se ao {workspace}", + "joined_team": "Você se juntou ao {workspace}", + "joined_team_description": "Você agora é um membro deste workspace", + "left": "Você saiu do workspace", + "login_to_continue": "Entre para continuar", + "login_to_continue_description": "Você precisa estar logado para juntar-se a um workspace.", + "logout_and_try_again": "Sair e entrar com outra conta", + "member_has_invite": "Esse ID de email já foi convidado. Contate o proprietário do workspace.", + "member_not_found": "Membro não encontrado. Contate o proprietário do workspace.", + "member_removed": "Usuário removido", + "member_role_updated": "Funções de usuário atualizadas", + "members": "Membros", + "more_members": "+{count} mais", + "name_length_insufficient": "O nome do workspace deve ter pelo menos 6 caracteres", + "name_updated": "Nome do workspace atualizado", + "new": "Novo workspace", + "new_created": "Novo workspace criado", + "new_name": "Meu Novo Workspace", + "no_access": "Você não tem acesso de edição neste workspace", + "no_invite_found": "Convite não encontrado. Contate o proprietário do workspace.", + "no_request_found": "Requisição não encontrada.", + "not_found": "Workspace não encontrado. Contate o proprietário do workspace", + "not_valid_viewer": "Você não é um visualizador válido. Contate o proprietário do workspace.", + "parent_coll_move": "Não é possível mover a coleção para uma coleção filha.", + "pending_invites": "Convites pendentes", + "permissions": "Permissões", + "same_target_destination": "A origem e o destino são iguais", + "saved": "Workspace salvo", + "select_a_team": "Selecione um workspace", + "success_invites": "Convites com sucesso", + "title": "Workspaces", + "we_sent_invite_link": "Links de convite enviados!", + "invite_sent_smtp_disabled": "Links de convite gerados", + "we_sent_invite_link_description": "Novos convidados receberão um link para se juntarem ao workspace. Membros existentes e pendentes não receberão um novo link.", + "invite_sent_smtp_disabled_description": "O envio de emails está desabilitado para esta instância do Hoppscotch. Utilize o botão Copiar link para copiar e compartilhar o link de forma manual.", + "copy_invite_link": "Copiar link de convite", + "search_title": "Requisições do Workspace", + "user_not_found": "Usuário não encontrado." + }, + "team_environment": { + "deleted": "Ambiente excluído", + "duplicate": "Ambiente duplicado", + "not_found": "Ambiente não encontrado." + }, + "test": { + "requests": "Requisições", + "selection": "Seleção", + "failed": "teste falhou", + "javascript_code": "Código JavaScript", + "learn": "Leia a documentação", + "passed": "teste passou", + "report": "Relatório de testes", + "results": "Resultados dos testes", + "script": "Script", + "snippets": "Snippets", + "run": "Executar", + "run_again": "Executar novamente", + "stop": "Parar", + "new_run": "Nova Execução", + "iterations": "Iterações", + "duration": "Duração", + "avg_resp": "Tempo Médio de Resposta" + }, + "websocket": { + "communication": "Comunicação", + "log": "Log", + "message": "Mensagem", + "protocols": "Protocolos", + "url": "URL" + }, + "workspace": { + "change": "Alterar Workspace", + "personal": "Meu Workspace", + "other_workspaces": "Meus Workspaces", + "team": "Workspace", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Entre para continuar", + "login_to_continue_description": "Você precisa estar logado para acessar esta instância do Hoppscotch Enterprise.", + "error_fetching_site_protection_status": "Algo deu errado ao buscar o status de proteção do site" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Os Personal access tokens ajudam você a conectar a CLI à sua conta do Hoppscotch", + "last_used_on": "Usado por último em", + "expires_on": "Expira em", + "no_expiration": "Sem expiração", + "expired": "Expirado", + "copy_token_warning": "Certifique-se de copiar seu Personal access token agora. Você não poderá vê-lo novamente!", + "token_purpose": "Para que serve este token?", + "expiration_label": "Expiração", + "scope_label": "Escopo", + "workspace_read_only_access": "Acesso de somente-leitura aos dados do workspace.", + "personal_workspace_access_limitation": "Personal Access Tokens não pode acessar seu workspace pessoal.", + "generate_token": "Gerar Token", + "invalid_label": "Insira um rótulo para o token", + "no_expiration_verbose": "Este token nuca irá expirar!", + "token_expires_on": "Este token irá expirar em", + "generate_new_token": "Gerar novo token", + "generate_modal_title": "Novo Personal Access Token", + "deletion_success": "O token {label} foi excluído" + }, + "collection_runner": { + "collection_id": "ID da Coleção", + "environment_id": "ID do Ambiente", + "cli_collection_id_description": "Este ID de coleção será utilizado pelo runner de Coleções CLI do Hoppscotch.", + "cli_environment_id_description": "Este ID de ambiente será utilizado pelo runner de Coleções CLI do Hoppscotch.", + "include_active_environment": "Incluir ambiente ativo:", + "cli": "CLI", + "cli_comming_soon_for_personal_collection": "O Runner de Coleções para coleções pessoais em CLI estará disponível em breve.", + "delay": "Delay", + "negative_delay": "O delay não pode ser negativo", + "ui": "Runner", + "running_collection": "Executando coleção", + "run_config": "Executar configuração", + "advanced_settings": "Configurações Avançadas", + "stop_on_error": "Parar se ocorrer um erro", + "persist_responses": "Persistir respostas", + "keep_variable_values": "Manter valor das variáveis", + "collection_not_found": "Coleção não encontrada. Pode ter sido excluída ou movida.", + "empty_collection": "A Coleção está vazia. Adicione requisições para executar.", + "no_response_persist": "O Runner de Coleções está configurado para não persistir respostas. Esta configuração impede a exibição dos dados de resposta. Para modificar este comportamento, inicie uma nova configuração.", + "select_request": "Selecione uma requisição para ver a resposta e os resultados dos testes", + "response_body_lost_rerun": "O corpo de resposta se perdeu. Execute a coleção novamente para obtê-lo.", + "cli_command_generation_description_cloud": "Copie o comando abaixo e o execute pela CLI. Informe um personal access token.", + "cli_command_generation_description_sh": "Copie o comando abaixo e o execute pela CLI. Informe um personal access token e verifique a URL do servidor da instância SH gerada.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copie o comando abaixo e o execute pela CLI. Informe um personal access token e a URL do servidor da instância SH.", + "run_collection": "Executar coleção", + "no_passed_tests": "Nenhum dos testes passou", + "no_failed_tests": "Nenhum dos testes falhou" + }, + "ai_experiments": { + "generate_request_name": "Gerar Nome da Requisição Utilizando IA", + "generate_or_modify_request_body": "Gerar ou Modificar o Corpo da Requisição", + "modify_with_ai": "Modificar com IA", + "generate": "Gerar", + "generate_or_modify_request_body_input_placeholder": "Insira seu prompt para modificar o corpo da requisição", + "accept_change": "Aceitar Alteração", + "feedback_success": "Feedback enviado com sucesso", + "feedback_failure": "Falha ao enviar feedback", + "feedback_thank_you": "Agradecemos pelo seu feedback!", + "feedback_cta_text_long": "Avalie a geração, nos ajude a melhorar", + "feedback_cta_request_name": "Você gostou do nome gerado?", + "modify_request_body_error": "Falha ao modificar o corpo da requisição", + "generate_or_modify_prerequest_input_placeholder": "Insira um prompt para gerar ou modificar o script de pré-requisição", + "generate_or_modify_post_request_script_input_placeholder": "Insira um prompt para gerar ou modificar o script de teste", + "modify_post_request_script_error": "Falha ao modificar o script de teste", + "modify_prerequest_error": "Falha ao modificar o script de pré-requisição" + } +} diff --git a/packages/hoppscotch-common/locales/pt.json b/packages/hoppscotch-common/locales/pt.json new file mode 100644 index 0000000..0847c96 --- /dev/null +++ b/packages/hoppscotch-common/locales/pt.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Add", + "autoscroll": "Autoscroll", + "cancel": "Cancelar", + "choose_file": "Escolha um arquivo", + "clear": "Claro", + "clear_all": "Limpar tudo", + "clear_history": "Clear all History", + "close": "Close", + "connect": "Conectar", + "connecting": "Connecting", + "copy": "cópia de", + "create": "Create", + "delete": "Excluir", + "disconnect": "desconectar", + "dismiss": "Dispensar", + "dont_save": "Don't save", + "download_file": "⇬ Fazer download do arquivo", + "drag_to_reorder": "Drag to reorder", + "duplicate": "Duplicate", + "edit": "Editar", + "filter": "Filter", + "go_back": "Volte", + "go_forward": "Go forward", + "group_by": "Group by", + "hide_secret": "Hide secret", + "label": "Etiqueta", + "learn_more": "Saber mais", + "download_here": "Download here", + "less": "Less", + "more": "Mais", + "new": "Novo", + "no": "Não", + "open_workspace": "Open workspace", + "paste": "Paste", + "prettify": "Embelezar", + "properties": "Properties", + "remove": "Remover", + "rename": "Rename", + "restore": "Restaurar", + "save": "Salvar", + "scroll_to_bottom": "Scroll to bottom", + "scroll_to_top": "Scroll to top", + "search": "Procurar", + "send": "Mandar", + "share": "Share", + "show_secret": "Show secret", + "start": "Começar", + "starting": "Starting", + "stop": "Pare", + "to_close": "to close", + "to_navigate": "to navigate", + "to_select": "to select", + "turn_off": "Desligar", + "turn_on": "Ligar", + "undo": "Desfazer", + "yes": "sim" + }, + "add": { + "new": "Adicionar novo", + "star": "Adicionar estrela" + }, + "app": { + "chat_with_us": "Converse conosco", + "contact_us": "Contate-Nos", + "cookies": "Cookies", + "copy": "cópia de", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Copy User Auth Token", + "developer_option": "Developer options", + "developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.", + "discord": "Discord", + "documentation": "Documentação", + "github": "GitHub", + "help": "Ajuda, feedback e documentação", + "home": "Lar", + "invite": "Convidar", + "invite_description": "No Hoppscotch, projetamos uma interface simples e intuitiva para criar e gerenciar suas APIs. Hoppscotch é uma ferramenta que o ajuda a construir, testar, documentar e compartilhar suas APIs.", + "invite_your_friends": "Convide seus amigos", + "join_discord_community": "Junte-se à nossa comunidade Discord", + "keyboard_shortcuts": "Atalhos do teclado", + "name": "Hoppscotch", + "new_version_found": "Nova versão encontrada. Atualize para atualizar.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Options", + "proxy_privacy_policy": "Política de privacidade do proxy", + "reload": "recarregar", + "search": "Procurar", + "share": "Compartilhado", + "shortcuts": "Atalhos", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "Holofote", + "status": "Status", + "status_description": "Check the status of the website", + "terms_and_privacy": "Termos e privacidade", + "twitter": "Twitter", + "type_a_command_search": "Digite um comando ou pesquise ...", + "we_use_cookies": "Usamos cookies", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "O que há de novo?", + "see_whats_new": "See what’s new", + "wiki": "Wiki" + }, + "auth": { + "account_exists": "A conta existe com credenciais diferentes - Faça login para vincular as duas contas", + "all_sign_in_options": "Todas as opções de login", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "Continue com Email", + "continue_with_github": "Continue com GitHub", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Continue com o Google", + "continue_with_microsoft": "Continue with Microsoft", + "email": "E-mail", + "logged_out": "Desconectado", + "login": "Conecte-se", + "login_success": "Conectado com sucesso", + "login_to_hoppscotch": "Faça login no Hoppscotch", + "logout": "Sair", + "re_enter_email": "Digite o e-mail novamente", + "send_magic_link": "Envie um link mágico", + "sync": "Sincronizar", + "we_sent_magic_link": "Enviamos a você um link mágico!", + "we_sent_magic_link_description": "Verifique sua caixa de entrada - enviamos um e-mail para {email}. Ele contém um link mágico que fará o seu login." + }, + "authorization": { + "generate_token": "Gerar token", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "Incluir no URL", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "Aprenda como", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "Pass by", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "Senha", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "Símbolo", + "type": "Tipo de Autorização", + "username": "Nome do usuário" + }, + "collection": { + "created": "Coleção criada", + "different_parent": "Cannot reorder collection with different parent", + "edit": "Editar coleção", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "Forneça um nome válido para a coleção", + "invalid_root_move": "Collection already in the root", + "moved": "Moved Successfully", + "my_collections": "Minhas coleções", + "name": "Minha nova coleção", + "name_length_insufficient": "Collection name should be at least 3 characters long", + "new": "Nova coleção", + "order_changed": "Collection Order Updated", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "Coleção renomeada", + "request_in_use": "Request in use", + "save_as": "Salvar como", + "save_to_collection": "Save to Collection", + "select": "Selecione uma coleção", + "select_location": "Selecione a localização", + "details": "Details", + "select_team": "Selecione uma equipe", + "team_collections": "Coleções da equipe" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "exit_team": "Are you sure you want to leave this team?", + "logout": "Tem certeza que deseja sair?", + "remove_collection": "Tem certeza de que deseja excluir esta coleção permanentemente?", + "remove_environment": "Tem certeza de que deseja excluir este ambiente permanentemente?", + "remove_folder": "Tem certeza de que deseja excluir esta pasta permanentemente?", + "remove_history": "Tem certeza de que deseja excluir permanentemente todo o histórico?", + "remove_request": "Tem certeza de que deseja excluir permanentemente esta solicitação?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "Tem certeza que deseja excluir esta equipe?", + "remove_telemetry": "Tem certeza de que deseja cancelar a telemetria?", + "request_change": "Are you sure you want to discard current request, unsaved changes will be lost.", + "save_unsaved_tab": "Do you want to save changes made in this tab?", + "sync": "Tem certeza de que deseja sincronizar este espaço de trabalho?", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "Cabeçalho {count}", + "message": "Mensagem {count}", + "parameter": "Parâmetro {count}", + "protocol": "Protocolo {count}", + "value": "Valor {count}", + "variable": "Variável {count}" + }, + "documentation": { + "generate": "Gerar documentação", + "generate_message": "Importe qualquer coleção de Hoppscotch para gerar documentação de API em movimento." + }, + "empty": { + "authorization": "Esta solicitação não usa nenhuma autorização", + "body": "Este pedido não tem corpo", + "collection": "Coleção está vazia", + "collections": "Coleções estão vazias", + "documentation": "Connect to a GraphQL endpoint to view documentation", + "endpoint": "Endpoint cannot be empty", + "environments": "Ambientes estão vazios", + "folder": "Pasta está vazia", + "headers": "Esta solicitação não possui cabeçalhos", + "history": "A história está vazia", + "invites": "Invite list is empty", + "members": "Time está vazio", + "parameters": "Esta solicitação não possui parâmetros", + "pending_invites": "There are no pending invites for this team", + "profile": "Login to view your profile", + "protocols": "Os protocolos estão vazios", + "request_variables": "This request does not have any request variables", + "schema": "Conecte-se a um endpoint GraphQL", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "Subscriptions are empty", + "team_name": "Nome do time vazio", + "teams": "Times estão vazios", + "tests": "Não há testes para esta solicitação", + "access_tokens": "Access tokens are empty", + "shortcodes": "Shortcodes are empty" + }, + "environment": { + "add_to_global": "Add to Global", + "added": "Environment addition", + "create_new": "Crie um novo ambiente", + "created": "Environment created", + "deleted": "Environment deletion", + "duplicated": "Environment duplicated", + "edit": "Editar Ambiente", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create a environment", + "invalid_name": "Forneça um nome válido para o ambiente", + "list": "Environment variables", + "my_environments": "My Environments", + "name": "Name", + "nested_overflow": "nested environment variables are limited to 10 levels", + "new": "Novo ambiente", + "no_active_environment": "No active environment", + "no_environment": "Sem ambiente", + "no_environment_description": "No environments were selected. Choose what to do with the following variables.", + "quick_peek": "Environment Quick Peek", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "Selecione o ambiente", + "set": "Set environment", + "set_as_environment": "Set as environment", + "team_environments": "Team Environments", + "title": "Ambientes", + "updated": "Environment updation", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Lista de Variáveis", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "Este navegador não parece ter suporte para eventos enviados pelo servidor.", + "check_console_details": "Verifique o log do console para obter detalhes.", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "cURL não está formatado corretamente", + "danger_zone": "Danger zone", + "delete_account": "Your account is currently an owner in these teams:", + "delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "Nome do pedido vazio", + "f12_details": "(F12 para detalhes)", + "gql_prettify_invalid_query": "Não foi possível justificar uma consulta inválida, resolva os erros de sintaxe da consulta e tente novamente", + "incomplete_config_urls": "Incomplete configuration URLs", + "incorrect_email": "Incorrect email", + "invalid_link": "Invalid link", + "invalid_link_description": "The link you clicked is invalid or expired.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "Invalid JSON", + "json_prettify_invalid_body": "Não foi possível embelezar um corpo inválido, resolver erros de sintaxe json e tentar novamente", + "network_error": "There seems to be a network error. Please try again.", + "network_fail": "Não foi possível enviar pedido", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "Sem duração", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "No matches found", + "page_not_found": "This page could not be found", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "Não foi possível executar o script de pré-solicitação", + "something_went_wrong": "Algo deu errado", + "post_request_script_fail": "Could not execute post-request script", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "Exportar como JSON", + "create_secret_gist": "Crie uma essência secreta", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "Faça login com GitHub para criar uma essência secreta", + "title": "Export", + "success": "Successfully exported", + "gist_created": "Gist criado" + }, + "filter": { + "all": "All", + "none": "None", + "starred": "Starred" + }, + "folder": { + "created": "Pasta criada", + "edit": "Editar pasta", + "invalid_name": "Forneça um nome para a pasta", + "name_length_insufficient": "Folder name should be at least 3 characters long", + "new": "Nova pasta", + "renamed": "Pasta renomeada" + }, + "graphql": { + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "mutations": "Mutações", + "schema": "Esquema", + "subscriptions": "Assinaturas", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "Time", + "url": "URL" + }, + "header": { + "install_pwa": "Instalar aplicativo", + "login": "Conecte-se", + "save_workspace": "Salvar meu espaço de trabalho" + }, + "helpers": { + "authorization": "O cabeçalho da autorização será gerado automaticamente quando você enviar a solicitação.", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "Gere a documentação primeiro", + "network_fail": "Incapaz de alcançar o endpoint da API. Verifique sua conexão de rede e tente novamente.", + "offline": "Você parece estar offline. Os dados neste espaço de trabalho podem não estar atualizados.", + "offline_short": "Você parece estar offline.", + "post_request_tests": "Os scripts de teste são gravados em JavaScript e executados após o recebimento da resposta.", + "pre_request_script": "Os scripts de pré-solicitação são gravados em JavaScript e executados antes do envio da solicitação.", + "script_fail": "Parece que há uma falha no script de pré-solicitação. Verifique o erro abaixo e corrija o script de acordo.", + "post_request_script_fail": "There seems to be an error with test script. Please fix the errors and run tests again", + "post_request_script": "Escreva um script de teste para automatizar a depuração." + }, + "hide": { + "collection": "Collapse Collection Panel", + "more": "Esconda mais", + "preview": "Esconder a Antevisão", + "sidebar": "Ocultar barra lateral" + }, + "import": { + "collections": "Importar coleções", + "curl": "Importar cURL", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "A importação falhou", + "from_file": "Import from File", + "from_gist": "Importar do Gist", + "from_gist_description": "Import from Gist URL", + "from_insomnia": "Import from Insomnia", + "from_insomnia_description": "Import from Insomnia collection", + "from_json": "Import from Hoppscotch", + "from_json_description": "Import from Hoppscotch collection file", + "from_my_collections": "Importar de minhas coleções", + "from_my_collections_description": "Import from My Collections file", + "from_openapi": "Import from OpenAPI", + "from_openapi_description": "Import from OpenAPI specification file (YML/JSON)", + "from_postman": "Import from Postman", + "from_postman_description": "Import from Postman collection", + "from_url": "Import from URL", + "gist_url": "Insira o URL da essência", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "Couldn't get data from the url", + "import_from_url_invalid_file_format": "Error while importing collections", + "import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Collections Imported", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Import collections from a Hoppscotch Collections JSON file", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "Importar", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled." + } + }, + "layout": { + "collapse_collection": "Collapse or Expand Collections", + "collapse_sidebar": "Collapse or Expand the sidebar", + "column": "Vertical layout", + "name": "Layout", + "row": "Horizontal layout" + }, + "modal": { + "close_unsaved_tab": "You have unsaved changes", + "collections": "Coleções", + "confirm": "confirme", + "customize_request": "Customize Request", + "edit_request": "Editar pedido", + "import_export": "Importar / Exportar", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "You are already subscribed to this topic.", + "clean_session": "Clean Session", + "clear_input": "Clear input", + "clear_input_on_send": "Clear input on send", + "client_id": "Client ID", + "color": "Pick a color", + "communication": "Comunicação", + "connection_config": "Connection Config", + "connection_not_authorized": "This MQTT connection does not use any authentication.", + "invalid_topic": "Please provide a topic for the subscription", + "keep_alive": "Keep Alive", + "log": "Registro", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "Mensagem", + "new": "New Subscription", + "not_connected": "Please start a MQTT connection first.", + "publish": "Publicar", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Se inscrever", + "topic": "Tema", + "topic_name": "Nome do tópico", + "topic_title": "Publicar / Assinar tópico", + "unsubscribe": "Cancelar subscrição", + "url": "URL" + }, + "navigation": { + "doc": "Docs", + "graphql": "GraphQL", + "profile": "Profile", + "realtime": "Tempo real", + "rest": "REST", + "settings": "Configurações" + }, + "preRequest": { + "javascript_code": "Código JavaScript", + "learn": "Leia a documentação", + "script": "Script de pré-solicitação", + "snippets": "Trechos" + }, + "profile": { + "app_settings": "App Settings", + "default_hopp_displayname": "Unnamed User", + "editor": "Editor", + "editor_description": "Editors can add, edit, and delete requests.", + "email_verification_mail": "A verification email has been sent to your email address. Please click on the link to verify your email address.", + "no_permission": "You do not have permission to perform this action.", + "owner": "Owner", + "owner_description": "Owners can add, edit, and delete requests, collections and team members.", + "roles": "Roles", + "roles_description": "Roles are used to control access to the shared collections.", + "updated": "Profile updated", + "viewer": "Viewer", + "viewer_description": "Viewers can only view and use requests." + }, + "remove": { + "star": "Remover estrela" + }, + "request": { + "added": "Pedido adicionado", + "authorization": "Autorização", + "body": "Corpo de Solicitação", + "choose_language": "Escolha o seu idioma", + "content_type": "Tipo de conteúdo", + "content_type_titles": { + "others": "Others", + "structured": "Structured", + "text": "Text" + }, + "different_collection": "Cannot reorder requests from different collections", + "duplicated": "Request duplicated", + "duration": "Duração", + "enter_curl": "Digite cURL", + "generate_code": "Gerar código", + "generated_code": "Código gerado", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "Lista de Cabeçalhos", + "invalid_name": "Forneça um nome para o pedido", + "method": "Método", + "moved": "Request moved", + "name": "Nome do pedido", + "new": "New Request", + "order_changed": "Request Order Updated", + "override": "Override", + "override_help": "Set Content-Type in Headers", + "overriden": "Overridden", + "parameter_list": "Parâmetros de consulta", + "parameters": "Parâmetros", + "path": "Caminho", + "payload": "Carga útil", + "query": "Consulta", + "raw_body": "Corpo de Solicitação Bruta", + "rename": "Rename Request", + "renamed": "Pedido renomeado", + "request_variables": "Request variables", + "run": "Corre", + "save": "Salvar", + "save_as": "Salvar como", + "saved": "Pedido salvo", + "share": "Compartilhado", + "share_description": "Share Hoppscotch with your friends", + "share_request": "Share Request", + "stop": "Stop", + "title": "Solicitar", + "type": "Tipo de solicitação", + "url": "URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "Variáveis", + "view_my_links": "View my links", + "copy_link": "Link de cópia" + }, + "response": { + "audio": "Audio", + "body": "Corpo de Resposta", + "filter_response_body": "Filter JSON response body (uses jq syntax)", + "headers": "Cabeçalhos", + "html": "HTML", + "image": "Imagem", + "json": "JSON", + "pdf": "PDF", + "preview_html": "Pré-visualizar HTML", + "raw": "Cru", + "size": "Tamanho", + "status": "Status", + "time": "Tempo", + "title": "Resposta", + "video": "Video", + "waiting_for_connection": "aguardando conexão", + "xml": "XML" + }, + "settings": { + "accent_color": "Cor de destaque", + "account": "Conta", + "account_deleted": "Your account has been deleted", + "account_description": "Personalize as configurações da sua conta.", + "account_email_description": "Seu endereço de e-mail principal.", + "account_name_description": "Este é o seu nome de exibição.", + "additional": "Additional Settings", + "background": "Fundo", + "black_mode": "Preto", + "choose_language": "Escolha o seu idioma", + "dark_mode": "Escuro", + "delete_account": "Delete account", + "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", + "expand_navigation": "Expand navigation", + "experiments": "Experimentos", + "experiments_notice": "Esta é uma coleção de experimentos em que estamos trabalhando que podem ser úteis, divertidos, ambos ou nenhum dos dois. Eles não são finais e podem não ser estáveis, portanto, se algo muito estranho acontecer, não entre em pânico. Apenas desligue essa maldita coisa. Piadas à parte,", + "extension_ver_not_reported": "Não reportado", + "extension_version": "Versão da extensão", + "extensions": "Extensões", + "extensions_use_toggle": "Use a extensão do navegador para enviar solicitações (se houver)", + "follow": "Follow Us", + "interceptor": "Interceptor", + "interceptor_description": "Middleware entre aplicativo e APIs.", + "language": "Língua", + "light_mode": "Luz", + "official_proxy_hosting": "Official Proxy é hospedado por Hoppscotch.", + "profile": "Profile", + "profile_description": "Update your profile details", + "profile_email": "Email address", + "profile_name": "Profile name", + "proxy": "Proxy", + "proxy_url": "URL proxy", + "proxy_use_toggle": "Use o middleware de proxy para enviar solicitações", + "read_the": "Leia o", + "reset_default": "Restaurar ao padrão", + "short_codes": "Short codes", + "short_codes_description": "Short codes which were created by you.", + "sidebar_on_left": "Sidebar on left", + "sync": "Sincronizar", + "sync_collections": "Coleções", + "sync_description": "Essas configurações são sincronizadas com a nuvem.", + "sync_environments": "Ambientes", + "sync_history": "História", + "system_mode": "Sistema", + "telemetry": "Telemetria", + "telemetry_helps_us": "A telemetria nos ajuda a personalizar nossas operações e oferecer a melhor experiência para você.", + "theme": "Tema", + "theme_description": "Personalize o tema do seu aplicativo.", + "use_experimental_url_bar": "Use a barra de URL experimental com destaque do ambiente", + "user": "Do utilizador", + "verified_email": "Verified email", + "verify_email": "Verify email" + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Fechar menu atual", + "command_menu": "Menu de pesquisa e comando", + "help_menu": "Menu de ajuda", + "show_all": "Atalhos do teclado", + "title": "Em geral" + }, + "miscellaneous": { + "invite": "Convide pessoas para Hoppscotch", + "title": "Diversos" + }, + "navigation": { + "back": "Voltar para a página anterior", + "documentation": "Vá para a página de documentação", + "forward": "Avance para a próxima página", + "graphql": "Vá para a página GraphQL", + "profile": "Go to Profile page", + "realtime": "Vá para a página em tempo real", + "rest": "Vá para a página REST", + "settings": "Vá para a página de configurações", + "title": "Navegação" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "Selecione o método DELETE", + "get_method": "Selecione o método GET", + "head_method": "Selecione o método HEAD", + "import_curl": "Import cURL", + "method": "Método", + "next_method": "Selecione o próximo método", + "post_method": "Selecione o método POST", + "previous_method": "Selecione o método anterior", + "put_method": "Selecione o método PUT", + "rename": "Rename Request", + "reset_request": "Pedido de reinicialização", + "save_request": "Save Request", + "save_to_collections": "Salvar em coleções", + "send_request": "Enviar pedido", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "Solicitar", + "copy_request_link": "Copiar link de solicitação" + }, + "response": { + "copy": "Copy response to clipboard", + "download": "Download response as file", + "title": "Response" + }, + "theme": { + "black": "Switch theme to black mode", + "dark": "Switch theme to dark mode", + "light": "Switch theme to light mode", + "system": "Switch theme to system mode", + "title": "Theme" + } + }, + "show": { + "code": "Exibir código", + "collection": "Expand Collection Panel", + "more": "Mostre mais", + "sidebar": "Mostrar barra lateral" + }, + "socketio": { + "communication": "Comunicação", + "connection_not_authorized": "This SocketIO connection does not use any authentication.", + "event_name": "Nome do evento", + "events": "Eventos", + "log": "Registro", + "url": "URL" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current team", + "edit": "Edit current team", + "invite": "Invite people to team", + "new": "Create new team", + "switch_to_personal": "Switch to your personal workspace", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "Tipo de evento", + "log": "Registro", + "url": "URL" + }, + "state": { + "bulk_mode": "Edição em massa", + "bulk_mode_placeholder": "As entradas são separadas por nova linha\nChaves e valores são separados por:\nAnexar # a qualquer linha que você deseja adicionar, mas manter desativado", + "cleared": "Liberado", + "connected": "Conectado", + "connected_to": "Conectado a {name}", + "connecting_to": "Conectando-se a {name} ...", + "connection_error": "Failed to connect", + "connection_failed": "Connection failed", + "connection_lost": "Connection lost", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "Copiado para a área de transferência", + "deleted": "Excluído", + "deprecated": "DESCONTINUADA", + "disabled": "Desabilitado", + "disconnected": "Desconectado", + "disconnected_from": "Desconectado de {name}", + "docs_generated": "Documentação gerada", + "download_failed": "Download failed", + "download_started": "Download iniciado", + "enabled": "Habilitado", + "file_imported": "Arquivo importado", + "finished_in": "Terminado em {duration} ms", + "hide": "Hide", + "history_deleted": "Histórico excluído", + "linewrap": "Quebrar linhas", + "loading": "Carregando...", + "message_received": "Message: {message} arrived on topic: {topic}", + "mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}", + "none": "Nenhum", + "nothing_found": "Nada encontrado para", + "published_error": "Something went wrong while publishing msg: {topic} to topic: {message}", + "published_message": "Published message: {message} to topic: {topic}", + "reconnection_error": "Failed to reconnect", + "show": "Show", + "subscribed_failed": "Failed to subscribe to topic: {topic}", + "subscribed_success": "Successfully subscribed to topic: {topic}", + "unsubscribed_failed": "Failed to unsubscribe from topic: {topic}", + "unsubscribed_success": "Successfully unsubscribed from topic: {topic}", + "waiting_send_request": "Esperando para enviar pedido" + }, + "support": { + "changelog": "Leia mais sobre os últimos lançamentos", + "chat": "Questões? Converse conosco!", + "community": "Faça perguntas e ajude os outros", + "documentation": "Leia mais sobre Hoppscotch", + "forum": "Faça perguntas e obtenha respostas", + "github": "Follow us on Github", + "shortcuts": "Navegue pelo aplicativo mais rápido", + "title": "Apoio, suporte", + "twitter": "Siga-nos no Twitter", + "team": "Entre em contato com a equipe" + }, + "tab": { + "authorization": "Autorização", + "body": "Corpo", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "Coleções", + "documentation": "Documentação", + "duplicate": "Duplicate Tab", + "environments": "Environments", + "headers": "Cabeçalhos", + "history": "História", + "mqtt": "MQTT", + "parameters": "Parâmetros", + "pre_request_script": "Script de pré-solicitação", + "queries": "Consultas", + "query": "Consulta", + "schema": "Schema", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Testes", + "types": "Tipos", + "variables": "Variáveis", + "websocket": "WebSocket" + }, + "team": { + "already_member": "You are already a member of this team. Contact your team owner.", + "create_new": "Criar nova equipe", + "deleted": "Equipe excluída", + "edit": "Editar equipe", + "email": "E-mail", + "email_do_not_match": "Email doesn't match with your account details. Contact your team owner.", + "exit": "Sair da equipe", + "exit_disabled": "Apenas o dono não pode sair da equipe", + "failed_invites": "Failed invites", + "invalid_coll_id": "Invalid collection ID", + "invalid_email_format": "O formato do email é inválido", + "invalid_id": "Invalid team ID. Contact your team owner.", + "invalid_invite_link": "Invalid invite link", + "invalid_invite_link_description": "The link you followed is invalid. Contact your team owner.", + "invalid_member_permission": "Forneça uma permissão válida para o membro da equipe", + "invite": "Invite", + "invite_more": "Invite more", + "invite_tooltip": "Invite people to this workspace", + "invited_to_team": "{owner} invited you to join {team}", + "join": "Invitation accepted", + "join_team": "Join {team}", + "joined_team": "You have joined {team}", + "joined_team_description": "You are now a member of this team", + "left": "Você saiu do time", + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to join a team.", + "logout_and_try_again": "Logout and sign in with another account", + "member_has_invite": "This email ID already has an invite. Contact your team owner.", + "member_not_found": "Member not found. Contact your team owner.", + "member_removed": "Usuário removido", + "member_role_updated": "Funções de usuário atualizadas", + "members": "Membros", + "more_members": "+{count} more", + "name_length_insufficient": "O nome da equipe deve ter pelo menos 6 caracteres", + "name_updated": "Team name updated", + "new": "Novo time", + "new_created": "Nova equipe criada", + "new_name": "Minha Nova Equipe", + "no_access": "Você não tem acesso de edição a essas coleções", + "no_invite_found": "Invitation not found. Contact your team owner.", + "no_request_found": "Request not found.", + "not_found": "Team not found. Contact your team owner.", + "not_valid_viewer": "You are not a valid viewer. Contact your team owner.", + "parent_coll_move": "Cannot move collection to a child collection", + "pending_invites": "Pending invites", + "permissions": "Permissões", + "same_target_destination": "Same target and destination", + "saved": "Equipe salva", + "select_a_team": "Select a team", + "success_invites": "Success invites", + "title": "Times", + "we_sent_invite_link": "We sent an invite link to all invitees!", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "Ask all invitees to check their inbox. Click on the link to join the team.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "Junte-se ao programa beta para acessar as equipes." + }, + "team_environment": { + "deleted": "Environment Deleted", + "duplicate": "Environment Duplicated", + "not_found": "Environment not found." + }, + "test": { + "failed": "test failed", + "javascript_code": "Código JavaScript", + "learn": "Leia a documentação", + "passed": "test passed", + "report": "Relatório de teste", + "results": "Resultado dos testes", + "script": "Roteiro", + "snippets": "Trechos" + }, + "websocket": { + "communication": "Comunicação", + "log": "Registro", + "message": "Mensagem", + "protocols": "Protocolos", + "url": "URL" + }, + "workspace": { + "change": "Change workspace", + "personal": "My Workspace", + "other_workspaces": "My Workspaces", + "team": "Team Workspace", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "Actions", + "created_on": "Created on", + "deleted": "Shortcode deleted", + "method": "Method", + "not_found": "Shortcode not found", + "short_code": "Short code", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/ro.json b/packages/hoppscotch-common/locales/ro.json new file mode 100644 index 0000000..639dd32 --- /dev/null +++ b/packages/hoppscotch-common/locales/ro.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Add", + "autoscroll": "Derulare automată", + "cancel": "Anulare", + "choose_file": "Alegeți un fișier", + "clear": "Curăță", + "clear_all": "Curăță tot", + "clear_history": "Clear all History", + "close": "Închide", + "connect": "Conectare", + "connecting": "Connecting", + "copy": "Copiază", + "create": "Create", + "delete": "Șterge", + "disconnect": "Deconectare", + "dismiss": "Renunță", + "dont_save": "Nu salva", + "download_file": "Descărcare fișier", + "drag_to_reorder": "Trage pentru a rearanja", + "duplicate": "Duplicare", + "edit": "Editare", + "filter": "Filtrare răspuns", + "go_back": "Înapoi", + "go_forward": "Go forward", + "group_by": "Group by", + "hide_secret": "Hide secret", + "label": "Etichetă", + "learn_more": "Află mai multe", + "download_here": "Download here", + "less": "Mai puțin", + "more": "Mai mult", + "new": "Nou", + "no": "Nu", + "open_workspace": "Deschide spațiul de lucru", + "paste": "Lipește", + "prettify": "Formatează", + "properties": "Properties", + "remove": "Elimină", + "rename": "Rename", + "restore": "Restabilește", + "save": "Salvează", + "scroll_to_bottom": "Derulare la sfârșit", + "scroll_to_top": "Derulare la început", + "search": "Căutare", + "send": "Trimite", + "share": "Share", + "show_secret": "Show secret", + "start": "Start", + "starting": "Starting", + "stop": "Stop", + "to_close": "Închide", + "to_navigate": "Navighează", + "to_select": "Selectează", + "turn_off": "Oprește", + "turn_on": "Pornește", + "undo": "Anulare", + "yes": "da" + }, + "add": { + "new": "Adăuga nou", + "star": "Adăugați stea" + }, + "app": { + "chat_with_us": "Vorbeste cu noi", + "contact_us": "Contactează-ne", + "cookies": "Cookies", + "copy": "Copiază", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Copiază token-ul de autentificare", + "developer_option": "Setări pentru dezvoltatori", + "developer_option_description": "Unelte pentru dezvoltatori care ajută în dezvoltarea și menținerea Hoopscotch.", + "discord": "Discord", + "documentation": "Documentație", + "github": "GitHub", + "help": "Ajutor, feedback și documentație", + "home": "Acasă", + "invite": "Invită", + "invite_description": "Am proiectat în Hoppscotch o interfață simplă și intuitivă pentru crearea și gestionarea API-urilor dvs. Hoppscotch este un instrument care vă ajută să construiți, să testați, să documentați și să partajați API-urile dvs.", + "invite_your_friends": "Invită-ți prietenii", + "join_discord_community": "Alătură-te comunității noastre Discord", + "keyboard_shortcuts": "Comenzi rapide de la tastatură", + "name": "Hoppscotch", + "new_version_found": "Nouă versiune găsită. Reîmprospătați pentru a actualiza.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Opțiuni", + "proxy_privacy_policy": "Politica de confidențialitate proxy", + "reload": "Reîncarcă", + "search": "Căutare", + "share": "Distribuie", + "shortcuts": "Comenzi rapide", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "În centrul atenției", + "status": "Stare", + "status_description": "Verifică statusul paginii web", + "terms_and_privacy": "Termeni și confidențialitate", + "twitter": "Twitter", + "type_a_command_search": "Tastați o comandă sau căutați ...", + "we_use_cookies": "Folosim cookie-uri", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "Ce mai e nou?", + "see_whats_new": "See what’s new", + "wiki": "Wiki" + }, + "auth": { + "account_exists": "Contul există cu credențiale diferite - Conectați-vă pentru a conecta ambele conturi", + "all_sign_in_options": "Toate opțiunile de conectare", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "Continuați cu e-mailul", + "continue_with_github": "Continuați cu GitHub", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Continuați cu Google", + "continue_with_microsoft": "Continuați cu Microsoft", + "email": "E-mail", + "logged_out": "Delogat", + "login": "Autentificare", + "login_success": "Conectat cu succes", + "login_to_hoppscotch": "Conectați-vă la Hoppscotch", + "logout": "Deconectați-vă", + "re_enter_email": "Reintroduceți emailul", + "send_magic_link": "Trimiteți un link magic", + "sync": "Sincronizare", + "we_sent_magic_link": "V-am trimis un link magic!", + "we_sent_magic_link_description": "Verificați căsuța de e-mail - am trimis un e-mail la {email}. Conține un link magic care vă va conecta." + }, + "authorization": { + "generate_token": "Generați token", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "Includeți în URL", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "Află cum", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "Transimteți cheia", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "Parolă", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "Token", + "type": "Tipul de autorizare", + "username": "Nume de utilizator" + }, + "collection": { + "created": "Colecție creată", + "different_parent": "Cannot reorder collection with different parent", + "edit": "Editați colecția", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "Vă rugăm să furnizați un nume valid pentru colecție", + "invalid_root_move": "Collection already in the root", + "moved": "Moved Successfully", + "my_collections": "Colecțiile mele", + "name": "Noua mea colecție", + "name_length_insufficient": "Numele colecției trebuie să aibă minim 3 caractere lungime", + "new": "Colecție nouă", + "order_changed": "Collection Order Updated", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "Colecția redenumită", + "request_in_use": "Cerere în executare", + "save_as": "Salvează ca", + "save_to_collection": "Save to Collection", + "select": "Selectați o colecție", + "select_location": "Selectați locația", + "details": "Details", + "select_team": "Selectați o echipă", + "team_collections": "Colecții de echipă" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "exit_team": "Sigur doriți sa părăsiți echipa curentă?", + "logout": "Sigur doriți să vă deconectați?", + "remove_collection": "Sigur doriți să ștergeți definitiv această colecție?", + "remove_environment": "Sigur doriți să ștergeți definitiv acest mediu?", + "remove_folder": "Sigur doriți să ștergeți definitiv acest folder?", + "remove_history": "Sigur doriți să ștergeți definitiv tot istoricul?", + "remove_request": "Sigur doriți să ștergeți definitiv această solicitare?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "Sigur doriți să ștergeți această echipă?", + "remove_telemetry": "Sigur doriți să renunțați la telemetrie?", + "request_change": "Sigur doriți să renunțați la cererea curentă? Modificările nesalvate se vor pierde.", + "save_unsaved_tab": "Do you want to save changes made in this tab?", + "sync": "Sigur doriți să sincronizați acest spațiu de lucru?", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "Antet {count}", + "message": "Număr de mesaje", + "parameter": "Parametrul {count}", + "protocol": "Protocolul {count}", + "value": "Valoarea {count}", + "variable": "Variabila {count}" + }, + "documentation": { + "generate": "Generați documentație", + "generate_message": "Importați orice colecție Hoppscotch pentru a genera documentație API în mișcare." + }, + "empty": { + "authorization": "Această cerere nu folosește nicio autorizație", + "body": "Această cerere nu are un corp", + "collection": "Colecția este goală", + "collections": "Colecțiile sunt goale", + "documentation": "Conectați-vă la un Endpoint GraphQL pentru a vedea documentația", + "endpoint": "Endpoint-ul nu poate fi gol", + "environments": "Mediile sunt goale", + "folder": "Dosarul este gol", + "headers": "Această solicitare nu are anteturi", + "history": "Istoria este goală", + "invites": "Lista de invitații este goală", + "members": "Echipa este goală", + "parameters": "Această solicitare nu are niciun parametru", + "pending_invites": "Nu sunt invitații în așteptare pentru această echipă", + "profile": "Autentificați-vă pentru a putea vizualiza profilul", + "protocols": "Protocoalele sunt goale", + "request_variables": "This request does not have any request variables", + "schema": "Conectați-vă la un Endpoint GraphQL", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "Subscriptions are empty", + "team_name": "Numele echipei este gol", + "teams": "Echipele sunt goale", + "tests": "Nu există teste pentru această solicitare", + "access_tokens": "Access tokens are empty", + "shortcodes": "Codurile scurte sunt goale" + }, + "environment": { + "add_to_global": "Adaugă global", + "added": "Mediu adăugat", + "create_new": "Creați un mediu nou", + "created": "Mediu creat", + "deleted": "Mediu șters", + "duplicated": "Environment duplicated", + "edit": "Editați mediul", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create a environment", + "invalid_name": "Vă rugăm să furnizați un nume valid pentru mediu", + "list": "Environment variables", + "my_environments": "My Environments", + "name": "Name", + "nested_overflow": "Variabilele de mediu impricate sunt limitate la 10 nivele", + "new": "Mediu nou", + "no_active_environment": "No active environment", + "no_environment": "Fără mediu", + "no_environment_description": "Niciun mediu nu a fost selectat. Alegeți acțiuni pentru următoarele variabile.", + "quick_peek": "Environment Quick Peek", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "Selectați mediul", + "set": "Set environment", + "set_as_environment": "Set as environment", + "team_environments": "Team Environments", + "title": "Medii", + "updated": "Mediu actualizat", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Lista variabilelor", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "Acest browser pare să nu aibă suport pentru Server Sent Events.", + "check_console_details": "Verificați jurnalul consolei pentru detalii.", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "cURL nu este formatat corect", + "danger_zone": "Danger zone", + "delete_account": "Your account is currently an owner in these teams:", + "delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "Nume cerere goală", + "f12_details": "(F12 pentru detalii)", + "gql_prettify_invalid_query": "Nu am putut formata o interogare nevalidă, rezolvați erorile de sintaxă ale interogării și încercați din nou", + "incomplete_config_urls": "Configurație URL incompletă", + "incorrect_email": "Email incorect", + "invalid_link": "Link invalid", + "invalid_link_description": "Link-ul este invalid sau a expirat.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "JSON invalid", + "json_prettify_invalid_body": "Nu s-a putut formata un corp invalid, rezolvați erorile de sintaxă JSON și încercați din nou", + "network_error": "Probleme cu conexiunea. Încercați din nou.", + "network_fail": "Nu s-a putut trimite solicitarea", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "Fără durată", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "Nu au fost găsite potriviri", + "page_not_found": "Pagina nu a putut fi găsită", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "Nu s-a putut executa scriptul", + "something_went_wrong": "Ceva nu a mers bine", + "post_request_script_fail": "Nu s-a putut executa scriptul", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "Exportați ca JSON", + "create_secret_gist": "Creați Gist secret", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "Conectați-vă cu GitHub pentru a crea un Gist secret", + "title": "Export", + "success": "Successfully exported", + "gist_created": "Gist creat" + }, + "filter": { + "all": "All", + "none": "None", + "starred": "Starred" + }, + "folder": { + "created": "Dosar creat", + "edit": "Editați dosarul", + "invalid_name": "Vă rugăm să furnizați un nume pentru dosar", + "name_length_insufficient": "Numele dosarului trebuie să aibă cel puțin 3 caractere lungime", + "new": "Dosar nou", + "renamed": "Dosar redenumit" + }, + "graphql": { + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "mutations": "Mutații", + "schema": "Schemă", + "subscriptions": "Abonamente", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "Time", + "url": "URL" + }, + "header": { + "install_pwa": "Instalează aplicația", + "login": "Autentificare", + "save_workspace": "Salvați spațiul de lucru" + }, + "helpers": { + "authorization": "Antetul autorizației va fi generat automat la trimiterea cererii.", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "Generați mai întâi documentația", + "network_fail": "Imposibil de atins endpoint-ul API. Verificați conexiunea la rețea și încercați din nou.", + "offline": "Pari să fii offline. Este posibil ca datele din acest spațiu de lucru să nu fie actualizate.", + "offline_short": "Pari să fii offline.", + "post_request_tests": "Scripturile de testare sunt scrise în JavaScript și se execută după primirea răspunsului.", + "pre_request_script": "Scripturile de cerere prealabilă sunt scrise în JavaScript și sunt rulate înainte de trimiterea cererii.", + "script_fail": "Se pare că există o eroare în script. Verificați eroarea de mai jos și remediați scriptul în consecință.", + "post_request_script_fail": "Se pare că există o eroare în script-ul de test. Verificați eroarea de mai jos și remediați scriptul în consecință.", + "post_request_script": "Scrieți un script de test pentru automatizarea depanării." + }, + "hide": { + "collection": "Restrângeți panoul de colecții", + "more": "Ascunde mai mult", + "preview": "Ascundeți previzualizarea", + "sidebar": "Ascunde bara laterală" + }, + "import": { + "collections": "Importați colecții", + "curl": "Importați cURL", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "Importul nu a reușit", + "from_file": "Import from File", + "from_gist": "Importați din Gist", + "from_gist_description": "Importați din Gist URL", + "from_insomnia": "Importați din Insomnia", + "from_insomnia_description": "Importați din colecție Insomnia", + "from_json": "Importați din Hoppscotch", + "from_json_description": "Importați din colecție Hoppscotch", + "from_my_collections": "Import din colecțiile mele", + "from_my_collections_description": "Importați din fișierul Colecțiile Mele", + "from_openapi": "Importați din OpenAPI", + "from_openapi_description": "Importați din fișier de specificații OpenAPI (YML/JSON)", + "from_postman": "Importați din Postman", + "from_postman_description": "Importați dintr-o colecție Postman", + "from_url": "Importați prin URL", + "gist_url": "Introduceți adresa URL Gist", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "Nu s-au putut obține date de la URL", + "import_from_url_invalid_file_format": "Eroare la importarea colecțiilor", + "import_from_url_invalid_type": "Tip nesuportat. Valorile acceptate sunt 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Colecții importate", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Importați colecții dintr-un fisier de collectii JSON Hoppscotch", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "Import", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled." + } + }, + "layout": { + "collapse_collection": "Ascunde sau expandează colecțiile", + "collapse_sidebar": "Ascunde sau expandează bara laterală", + "column": "Aspect vertical (coloană)", + "name": "Aspect", + "row": "Aspect orizontal (rând)" + }, + "modal": { + "close_unsaved_tab": "You have unsaved changes", + "collections": "Colecții", + "confirm": "Confirmă", + "customize_request": "Customize Request", + "edit_request": "Solicită editare", + "import_export": "Import Export", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "You are already subscribed to this topic.", + "clean_session": "Clean Session", + "clear_input": "Clear input", + "clear_input_on_send": "Clear input on send", + "client_id": "Client ID", + "color": "Pick a color", + "communication": "Comunicare", + "connection_config": "Connection Config", + "connection_not_authorized": "This MQTT connection does not use any authentication.", + "invalid_topic": "Please provide a topic for the subscription", + "keep_alive": "Keep Alive", + "log": "Log", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "Mesaj", + "new": "New Subscription", + "not_connected": "Please start a MQTT connection first.", + "publish": "Publică", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Abonați-vă", + "topic": "Subiect", + "topic_name": "Nume subiect", + "topic_title": "Publică / Abonează subiect", + "unsubscribe": "Dezabonează-te", + "url": "URL" + }, + "navigation": { + "doc": "Documente", + "graphql": "GraphQL", + "profile": "Profil", + "realtime": "Timp real", + "rest": "REST", + "settings": "Setări" + }, + "preRequest": { + "javascript_code": "Cod JavaScript", + "learn": "Citiți documentația", + "script": "Script de cerere prealabilă", + "snippets": "Fragmente" + }, + "profile": { + "app_settings": "Setări aplicație", + "default_hopp_displayname": "Unnamed User", + "editor": "Editor", + "editor_description": "Editorii pot adăuga, edita și șterge cereri.", + "email_verification_mail": "Un email de verificare a fost trimis la adresa dumneavoastră de email. Apăsați link-ul pentru verificarea adresei.", + "no_permission": "Nu aveți permisiuni pentru a efectua această acțiune.", + "owner": "Proprietar", + "owner_description": "Proprietarii pot adăuga, edita și șterge cereri, colecții si membrii ai echipelor.", + "roles": "Roluri", + "roles_description": "Rolurile sunt folosite pentru a controla accesul la colecția partajată.", + "updated": "Profil actualizată", + "viewer": "Vizualizator", + "viewer_description": "Vizualizatorii pot vizualiza și utiliza cereri." + }, + "remove": { + "star": "Eliminați steaua" + }, + "request": { + "added": "Cerere adăugată", + "authorization": "Autorizare", + "body": "Corpul cererii", + "choose_language": "Alege limba", + "content_type": "Tipul de conținut", + "content_type_titles": { + "others": "Altele", + "structured": "Structurat", + "text": "Text" + }, + "different_collection": "Cannot reorder requests from different collections", + "duplicated": "Request duplicated", + "duration": "Durată", + "enter_curl": "Introduceți cURL", + "generate_code": "Generați cod", + "generated_code": "Cod generat", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "Lista anteturilor", + "invalid_name": "Vă rugăm să furnizați un nume pentru cerere", + "method": "Metodă", + "moved": "Request moved", + "name": "Solicitați numele", + "new": "Cerere nouă", + "order_changed": "Request Order Updated", + "override": "Suprascriere", + "override_help": "Setează Content-Type în antet", + "overriden": "Suprascris", + "parameter_list": "Parametrii interogării", + "parameters": "Parametrii", + "path": "Cale", + "payload": "Încărcătură", + "query": "Interogare", + "raw_body": "Corpul cererii", + "rename": "Rename Request", + "renamed": "Cerere redenumită", + "request_variables": "Request variables", + "run": "Execută", + "save": "Salvează", + "save_as": "Salvează ca", + "saved": "Cererea a fost salvată", + "share": "Distribuie", + "share_description": "Distribuie Hoppscotch către prietenii tăi", + "share_request": "Share Request", + "stop": "Stop", + "title": "Cerere", + "type": "Tip de cerere", + "url": "URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "Variabile", + "view_my_links": "Vizualizare link-uri", + "copy_link": "Copiază legătură" + }, + "response": { + "audio": "Audio", + "body": "Corpul de răspuns", + "filter_response_body": "Filtrează corpul răspunsului JSON (folosește sintaxa jq)", + "headers": "Anteturi", + "html": "HTML", + "image": "Imagine", + "json": "JSON", + "pdf": "PDF", + "preview_html": "Previzualizați HTML", + "raw": "Brut", + "size": "Dimensiune", + "status": "Stare", + "time": "Timp", + "title": "Raspuns", + "video": "Video", + "waiting_for_connection": "Așteptând conexiunea", + "xml": "XML" + }, + "settings": { + "accent_color": "Culoare de accent", + "account": "Cont", + "account_deleted": "Your account has been deleted", + "account_description": "Personalizați setările contului.", + "account_email_description": "Adresa dvs. de e-mail principală.", + "account_name_description": "Acesta este numele dvs. afișat.", + "additional": "Additional Settings", + "background": "Fundal", + "black_mode": "Negru", + "choose_language": "Alege limba", + "dark_mode": "Întunecat", + "delete_account": "Delete account", + "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", + "expand_navigation": "Expandează navigarea", + "experiments": "Experimente", + "experiments_notice": "Aceasta este o colecție de experimente la care lucrăm, care s-ar putea dovedi a fi utile, distractive, ambele sau nici una. Nu sunt definitive și s-ar putea să nu fie stabile, așa că, dacă se întâmplă ceva prea ciudat, nu vă panicați. Doar oprește-l. Lăsând glumele deoparte,", + "extension_ver_not_reported": "Nu a fost raportat", + "extension_version": "Versiune extensie", + "extensions": "Extensii", + "extensions_use_toggle": "Utilizați extensia browserului pentru a trimite cereri (dacă există)", + "follow": "Urmărește-ne", + "interceptor": "Interceptor", + "interceptor_description": "Middleware între aplicație și API-uri.", + "language": "Limbă", + "light_mode": "Ușoară", + "official_proxy_hosting": "Proxy-ul oficial este găzduit de Hoppscotch.", + "profile": "Profil", + "profile_description": "Actualizați-va detaliile profilului", + "profile_email": "Adresă email", + "profile_name": "Nume profil", + "proxy": "Proxy", + "proxy_url": "URL proxy", + "proxy_use_toggle": "Utilizați middleware-ul proxy pentru a trimite cereri", + "read_the": "Citește", + "reset_default": "Resetare la valorile implicite", + "short_codes": "Coduri scurte", + "short_codes_description": "Coduri scurte create de dumneavoastră.", + "sidebar_on_left": "Bara de navigație în partea stângă", + "sync": "Sincronizare", + "sync_collections": "Colecții", + "sync_description": "Aceste setări sunt sincronizate cu cloud.", + "sync_environments": "Medii", + "sync_history": "Istoric", + "system_mode": "Sistem", + "telemetry": "Telemetrie", + "telemetry_helps_us": "Telemetria ne ajută să ne personalizăm operațiunile și să vă oferim cea mai bună experiență.", + "theme": "Temă", + "theme_description": "Personalizați tema aplicației.", + "use_experimental_url_bar": "Utilizați bara URL experimentală cu evidențierea mediului", + "user": "Utilizator", + "verified_email": "Adresă de email verificată", + "verify_email": "Verificați adresa de email" + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Închideți meniul curent", + "command_menu": "Căutare și meniu de comandă", + "help_menu": "Meniul Ajutor", + "show_all": "Comenzi rapide de la tastatură", + "title": "General" + }, + "miscellaneous": { + "invite": "Invitați oamenii la Hoppscotch", + "title": "Diverse" + }, + "navigation": { + "back": "Reveniți la pagina anterioară", + "documentation": "Accesați pagina Documentație", + "forward": "Mergeți la pagina următoare", + "graphql": "Accesați pagina GraphQL", + "profile": "Mergeți la pagina de profil", + "realtime": "Accesați pagina în timp real", + "rest": "Accesați pagina REST", + "settings": "Accesați pagina Setări", + "title": "Navigare" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "Selectați metoda DELETE", + "get_method": "Selectați metoda GET", + "head_method": "Selectați metoda HEAD", + "import_curl": "Import cURL", + "method": "Metodă", + "next_method": "Selectați metoda următoare", + "post_method": "Selectați metoda POST", + "previous_method": "Selectați metoda anterioară", + "put_method": "Selectați metoda PUT", + "rename": "Rename Request", + "reset_request": "Cerere de resetare", + "save_request": "Save Request", + "save_to_collections": "Salvați în colecții", + "send_request": "Trimite cerere", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "Cerere", + "copy_request_link": "Copiați legătura de solicitare" + }, + "response": { + "copy": "Copiați răspunsul în clipboard", + "download": "Descărcați răspunsul ca și fișier", + "title": "Răspuns" + }, + "theme": { + "black": "Schimbă tema la modul negru", + "dark": "Schimbă tema la modul întunecat", + "light": "Schimbă tema la modul deschis", + "system": "Schimbă tema la modul sistemului", + "title": "Temă" + } + }, + "show": { + "code": "Afișați codul", + "collection": "Expandați panul colecții", + "more": "Afișați mai multe", + "sidebar": "Afișați bara laterală" + }, + "socketio": { + "communication": "Comunicare", + "connection_not_authorized": "Această conexiune SocketIO nu folosește autentificare.", + "event_name": "Numele evenimentului", + "events": "Evenimente", + "log": "Log", + "url": "URL" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current team", + "edit": "Edit current team", + "invite": "Invite people to team", + "new": "Create new team", + "switch_to_personal": "Switch to your personal workspace", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "Tip de eveniment", + "log": "Log", + "url": "URL" + }, + "state": { + "bulk_mode": "Editare în bloc", + "bulk_mode_placeholder": "Intrările sunt separate prin linie nouă\nCheile și valorile sunt separate prin:\nScrierea la început a # la orice rând pe care doriți să îl adăugați, dar păstrați-l dezactivat", + "cleared": "Eliminat", + "connected": "Conectat", + "connected_to": "Conectat la {name}", + "connecting_to": "Se conectează la {name} ...", + "connection_error": "Eroare la conectare", + "connection_failed": "Concetare eșuată", + "connection_lost": "Conexiune pierdută", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "Copiat în clipboard", + "deleted": "Șters", + "deprecated": "NEMENȚINUT", + "disabled": "Dezactivat", + "disconnected": "Deconectat", + "disconnected_from": "Deconectat de la {name}", + "docs_generated": "Documentație generată", + "download_failed": "Download failed", + "download_started": "Descărcarea a început", + "enabled": "Activat", + "file_imported": "Fișier importat", + "finished_in": "Finalizat în {duration} ms", + "hide": "Hide", + "history_deleted": "Istoricul a fost șters", + "linewrap": "Înfășurați liniile", + "loading": "Se încarcă...", + "message_received": "Mesajul: {message} a sosit pe topicul: {topic}", + "mqtt_subscription_failed": "Ceva a mers greșit la abonarea la topicul: {topic}", + "none": "Nici unul", + "nothing_found": "Nimic găsit pentru", + "published_error": "Ceva a mers greșit la publicarea mesajului: {message} la topicul: {topic}", + "published_message": "Mesaj publicat: {message} la topicul: {topic}", + "reconnection_error": "Reconectare eșuată", + "show": "Show", + "subscribed_failed": "Abonare eșuată la topicul: {topic}", + "subscribed_success": "Abonare reușită la topicul: {topic}", + "unsubscribed_failed": "Dezabonare eșuată la topicul: {topic}", + "unsubscribed_success": "Dezabonare reușita la topicul: {topic}", + "waiting_send_request": "Se așteaptă trimiterea cererii" + }, + "support": { + "changelog": "Citiți mai multe despre ultimele versiuni", + "chat": "Întrebări? Vorbeste cu noi!", + "community": "Puneți întrebări și ajutați-i pe ceilalți", + "documentation": "Citiți mai multe despre Hoppscotch", + "forum": "Puneți întrebări și primiți răspunsuri", + "github": "Urmarește-ne pe Github", + "shortcuts": "Utilizați aplicația mai rapid", + "title": "Suport", + "twitter": "Urmăriți-ne pe Twitter", + "team": "Luați legătura cu echipa" + }, + "tab": { + "authorization": "Autorizare", + "body": "Corp", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "Colecții", + "documentation": "Documentație", + "duplicate": "Duplicate Tab", + "environments": "Environments", + "headers": "Anteturi", + "history": "Istorie", + "mqtt": "MQTT", + "parameters": "Parametrii", + "pre_request_script": "Script de cerere prealabilă", + "queries": "Întrebări", + "query": "Interogare", + "schema": "Schema", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Teste", + "types": "Tipuri", + "variables": "Variabile", + "websocket": "WebSocket" + }, + "team": { + "already_member": "Sunteți deja membru în această echipă. Contactați proprietarul echipei.", + "create_new": "Creați o echipă nouă", + "deleted": "Echipa a fost ștearsă", + "edit": "Editați echipa", + "email": "E-mail", + "email_do_not_match": "Email-ul nu se potrivește cu detaliile contului dumneavoastră. Contactați proprietarul echipei.", + "exit": "Ieșiți din echipă", + "exit_disabled": "Numai proprietarul nu poate ieși din echipă", + "failed_invites": "Failed invites", + "invalid_coll_id": "Invalid collection ID", + "invalid_email_format": "Formatul de e-mail nu este valid", + "invalid_id": "ID-ul echipei nu este valid. Contactați proprietarul echipei.", + "invalid_invite_link": "Link de invitație invalid", + "invalid_invite_link_description": "Link-ul pe care ați incercat să îl accesați nu este valid. Contactați proprietarul echipei.", + "invalid_member_permission": "Vă rugăm să oferiți o permisiune validă membrilor echipei", + "invite": "Invită", + "invite_more": "Invită mai mulți", + "invite_tooltip": "Invită oameni la acest workspace", + "invited_to_team": "{owner} te-a invitat să te alături echipei {team}", + "join": "Invitație acceptată", + "join_team": "Alăturați-vă {team}", + "joined_team": "V-ați alăturat echipei {team}", + "joined_team_description": "Sunteți acum membru al acestei echipe", + "left": "Ați părăsit echipa", + "login_to_continue": "Autentificați-vă pentru a continua", + "login_to_continue_description": "Trebuie să fiți autentificați pentru a continua", + "logout_and_try_again": "Încercați să vă autentificați cu un alt cont", + "member_has_invite": "Utilizatorul cu această adresă de email are deja o invitație. Contactați proprietarul echipei.", + "member_not_found": "Utilizatorul nu a fost găsit. Contactați proprietarul echipei.", + "member_removed": "Utilizatorul a fost eliminat", + "member_role_updated": "Rolurile utilizatorului au fost actualizate", + "members": "Membri", + "more_members": "+{count} more", + "name_length_insufficient": "Numele echipei trebuie să aibă cel puțin 6 caractere", + "name_updated": "Numele echipei a fost actualizat", + "new": "Echipă nouă", + "new_created": "Echipă nouă creată", + "new_name": "Noua mea echipă", + "no_access": "Nu aveți acces de editare la aceste colecții", + "no_invite_found": "Invitația nu a fost găsită. Contactați proprietarul echipei.", + "no_request_found": "Request not found.", + "not_found": "Echipa nu a fost găsită. Contactați proprietarul echipei.", + "not_valid_viewer": "Nu sunteți un vizualizator valid. Contactați proprietarul echipei.", + "parent_coll_move": "Cannot move collection to a child collection", + "pending_invites": "Invitații în așteptare", + "permissions": "Permisiuni", + "same_target_destination": "Same target and destination", + "saved": "Echipă salvată", + "select_a_team": "Selectați o echipă", + "success_invites": "Success invites", + "title": "Echipe", + "we_sent_invite_link": "Am trimis un link de invitație către toți invitații!", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "Rugați toate persoanele invitate să iși verifice căsuța. Click pe link pentru a se alătura echipei.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "Alăturați-vă programului beta pentru a accesa echipe." + }, + "team_environment": { + "deleted": "Environment Deleted", + "duplicate": "Environment Duplicated", + "not_found": "Environment not found." + }, + "test": { + "failed": "test eșuat", + "javascript_code": "Cod JavaScript", + "learn": "Citiți documentația", + "passed": "test trecut", + "report": "Raport de testare", + "results": "Rezultatele testului", + "script": "Script", + "snippets": "Fragmente" + }, + "websocket": { + "communication": "Comunicare", + "log": "Log", + "message": "Mesaj", + "protocols": "Protocoale", + "url": "URL" + }, + "workspace": { + "change": "Change workspace", + "personal": "My Workspace", + "other_workspaces": "My Workspaces", + "team": "Team Workspace", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "Acțiuni", + "created_on": "Creat la", + "deleted": "Cod scurt șters", + "method": "Metodă", + "not_found": "Codul scurt nu a fost găsit", + "short_code": "Cod scurt", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/ru.json b/packages/hoppscotch-common/locales/ru.json new file mode 100644 index 0000000..df173dc --- /dev/null +++ b/packages/hoppscotch-common/locales/ru.json @@ -0,0 +1,1089 @@ +{ + "action": { + "add": "Добавить", + "autoscroll": "Автоскрол", + "cancel": "Отменить", + "choose_file": "Выберите файл", + "clear": "Очистить", + "clear_all": "Очистить все", + "clear_history": "Очистить всю историю", + "close": "Закрыть", + "connect": "Подключиться", + "connecting": "Соединение...", + "copy": "Скопировать", + "create": "Создать", + "delete": "Удалить", + "disconnect": "Отключиться", + "dismiss": "Скрыть", + "dont_save": "Не сохранять", + "download_file": "Скачать файл", + "drag_to_reorder": "Перетягивайте для сортировки", + "duplicate": "Дублировать", + "edit": "Редактировать", + "filter": "Фильтр", + "go_back": "Вернуться", + "go_forward": "Вперёд", + "group_by": "Сгруппировать по", + "hide_secret": "Спрятать секрет", + "label": "Название", + "learn_more": "Узнать больше", + "download_here": "Download here", + "less": "Меньше", + "more": "Больше", + "new": "Создать", + "no": "Нет", + "open_workspace": "Открыть пространство", + "paste": "Вставить", + "prettify": "Форматировать", + "properties": "Параметры", + "remove": "Удалить", + "rename": "Переименовать", + "restore": "Восстановить", + "save": "Сохранить", + "scroll_to_bottom": "Вниз", + "scroll_to_top": "Вверх", + "search": "Поиск", + "send": "Отправить", + "share": "Поделиться", + "show_secret": "Показать секрет", + "start": "Начать", + "starting": "Запускаю", + "stop": "Стоп", + "to_close": "закрыть", + "to_navigate": "для навигации", + "to_select": "выбрать", + "turn_off": "Выключить", + "turn_on": "Включить", + "undo": "Отменить", + "yes": "Да" + }, + "add": { + "new": "Добавить", + "star": "Добавить в избранное" + }, + "app": { + "chat_with_us": "Связаться с нами", + "contact_us": "Свяжитесь с нами", + "cookies": "Cookies", + "copy": "Копировать", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Копировать токен пользователя", + "developer_option": "Настройки разработчика", + "developer_option_description": "Инструмент разработчика помогает обслуживать и развивать Hoppscotch", + "discord": "Discord", + "documentation": "Документация", + "github": "GitHub", + "help": "Справка, отзывы и документация", + "home": "На главную", + "invite": "Пригласить", + "invite_description": "В Hoppscotch мы разработали простой и интуитивно понятный интерфейс для создания и управления вашими API. Hoppscotch - это инструмент, который помогает создавать, тестировать, документировать и делиться своими API.", + "invite_your_friends": "Пригласить своих друзей", + "join_discord_community": "Присоединяйтесь к нашему сообществу Discord", + "keyboard_shortcuts": "Горячие клавиши", + "name": "Hoppscotch", + "new_version_found": "Найдена новая версия. Перезагрузите для обновления. Все данные уже сохранены, ничего не потеряется", + "open_in_hoppscotch": "Открыть в Hoppscotch", + "options": "Настройки", + "proxy_privacy_policy": "Политика конфиденциальности прокси", + "reload": "Перезагрузить", + "search": "Поиск", + "share": "Поделиться", + "shortcuts": "Горячие клавиши", + "social_description": "Подписывайся на наши соц. сети и оставайся всегда в курсе последних новостей, обновлений и релизов.", + "social_links": "Социальные сети", + "spotlight": "Прожектор", + "status": "Статус", + "status_description": "Проверить состояние сайта", + "terms_and_privacy": "Условия и конфиденциальность", + "twitter": "Twitter", + "type_a_command_search": "Введите команду или выполните поиск…", + "we_use_cookies": "Мы используем куки", + "updated_text": "Hoppscotch был обновлен до v{version} 🎉", + "whats_new": "Что нового?", + "see_whats_new": "Узнать что нового", + "wiki": "Узнать больше" + }, + "auth": { + "account_exists": "Учетная запись существует с разными учетными данными - войдите, чтобы связать обе учетные записи", + "all_sign_in_options": "Все варианты входа", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "Продолжить с электронной почтой", + "continue_with_github": "Продолжить с GitHub", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Продолжить с Google", + "continue_with_microsoft": "Продолжить с Microsoft", + "email": "Электронная почта", + "logged_out": "Успешно вышли. Будем скучать!", + "login": "Авторизоваться", + "login_success": "Успешный вход в систему", + "login_to_hoppscotch": "Войти в Hoppscotch", + "logout": "Выйти", + "re_enter_email": "Введите адрес электронной почты еще раз", + "send_magic_link": "Отправить волшебную ссылку", + "sync": "Синхронизировать", + "we_sent_magic_link": "Мы отправили вам волшебную ссылку!", + "we_sent_magic_link_description": "Проверьте свой почтовый ящик - мы отправили письмо на адрес {email}. Он содержит волшебную ссылку, по которой вы авторизуетесь." + }, + "authorization": { + "generate_token": "Сгенерировать токен", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "Добавить в URL", + "inherited_from": "Унаследован тип аутентификации {auth} из родительской коллекции \"{collection}\"", + "learn": "Узнать больше", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Что-то пошло не так в процессе генерации токена доступа", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_by_headers_label": "Headers", + "pass_by_query_params_label": "Query Parameters", + "pass_key_by": "Pass by", + "password": "Пароль", + "save_to_inherit": "Cохраните этот запрос в любой коллекции, чтобы унаследовать авторизацию", + "token": "Токен", + "type": "Метод авторизации", + "username": "Имя пользователя" + }, + "collection": { + "created": "Коллекция создана", + "different_parent": "Нельзя сортировать коллекцию с разной родительской коллекцией", + "edit": "Редактировать коллекцию", + "import_or_create": "Вы можете импортировать существующую или создать новую коллекцию", + "import_collection": "Импортировать коллекцию", + "invalid_name": "Укажите допустимое название коллекции", + "invalid_root_move": "Коллекция уже в корне", + "moved": "Перемещено успешно", + "my_collections": "Мои коллекции", + "name": "Новая коллекция", + "name_length_insufficient": "Имя коллекции должно составлять не менее 3 символов", + "new": "Создать коллекцию", + "order_changed": "Порядок коллекции обновлён", + "properties": "Параметры коллекции", + "properties_updated": "Параметры коллекции обновлены", + "renamed": "Коллекция переименована", + "request_in_use": "Запрос обрабатывается", + "save_as": "Сохранить как", + "save_to_collection": "Сохранить в коллекцию", + "select": "Выбрать коллекцию", + "select_location": "Выберите местоположение", + "details": "Подробности" + }, + "confirm": { + "close_unsaved_tab": "Вы уверены, что хотите закрыть эту вкладку?", + "close_unsaved_tabs": "Вы уверены, что хотите закрыть все эти вкладки? Несохранённые данные {count} вкладок будут утеряны.", + "exit_team": "Вы точно хотите покинуть эту команду?", + "logout": "Вы действительно хотите выйти?", + "remove_collection": "Вы уверены, что хотите навсегда удалить эту коллекцию?", + "remove_environment": "Вы действительно хотите удалить это окружение без возможности восстановления?", + "remove_folder": "Вы уверены, что хотите навсегда удалить эту папку?", + "remove_history": "Вы уверены, что хотите навсегда удалить всю историю?", + "remove_request": "Вы уверены, что хотите навсегда удалить этот запрос?", + "remove_shared_request": "Вы уверены, что хотите навсегда удалить этот запрос?", + "remove_team": "Вы уверены, что хотите удалить эту команду?", + "remove_telemetry": "Вы действительно хотите отказаться от телеметрии?", + "request_change": "Вы уверены, что хотите сбросить текущий запрос, все несохранённые данные будт утеряны?", + "save_unsaved_tab": "Вы хотите сохранить изменения в этой вкладке?", + "sync": "Вы уверены, что хотите синхронизировать это рабочее пространство?", + "delete_access_token": "Вы уверены, что хотите удалить токен доступа {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Добавить в список параметров", + "open_request_in_new_tab": "Открыть запрос в новом окне", + "set_environment_variable": "Добавить значение в переменную" + }, + "cookies": { + "modal": { + "cookie_expires": "Истекает", + "cookie_name": "Имя", + "cookie_path": "Путь", + "cookie_string": "Cookie параметры", + "cookie_value": "Значение", + "empty_domain": "Нужно заполнить домен", + "empty_domains": "Список доменов пуст", + "enter_cookie_string": "Введите cookie параметры сюда", + "interceptor_no_support": "Ваш текущий Перехватчик не поддерживает работу с cookie. Выберите другой Перехватчик и попробуйте еще раз.", + "managed_tab": "Managed", + "new_domain_name": "Добавить новый домен", + "no_cookies_in_domain": "Никаких cookie не установлено для этого домена", + "raw_tab": "Raw", + "set": "Установить cookie" + } + }, + "count": { + "header": "Заголовок {count}", + "message": "Тело {count}", + "parameter": "Параметр {count}", + "protocol": "Протокол {count}", + "value": "Значение {count}", + "variable": "Переменная {count}" + }, + "documentation": { + "generate": "Создать документацию", + "generate_message": "Импортируйте любую коллекцию Hoppscotch для создания документации API на ходу." + }, + "empty": { + "authorization": "Этот запрос не использует авторизацию", + "body": "У этого запроса нет тела", + "collection": "Коллекция пуста", + "collections": "Коллекции пусты", + "documentation": "Подключите GraphQL endpoint, чтобы увидеть документацию", + "endpoint": "Endpoint не может быть пустым", + "environments": "Переменных окружения нет", + "folder": "Папка пуста", + "headers": "У этого запроса нет заголовков", + "history": "История пуста", + "invites": "Вы еще никого не приглашали", + "members": "В этой команде еще нет участников", + "parameters": "Этот запрос не содержит параметров", + "pending_invites": "Пока нет заявок, ожидающих вступления в команду", + "profile": "Войдите, чтобы просмотреть свой профиль", + "protocols": "Протоколы пусты", + "request_variables": "Этот запрос не содержит никаких переменных", + "schema": "Подключиться к конечной точке GraphQL", + "secret_environments": "Секреты хранятся только на этом устройстве и не синхронизируются с сервером", + "shared_requests": "Вы еще не делились запросами с другими", + "shared_requests_logout": "Нужно войти, чтобы делиться запросами и управлять ими", + "subscription": "Нет подписок", + "team_name": "Название команды пустое", + "teams": "Команды пустые", + "tests": "Для этого запроса нет тестов", + "access_tokens": "Токенов еще нет" + }, + "environment": { + "add_to_global": "Добавить в глобальное окружение", + "added": "Окружение добавлено", + "create_new": "Создать новое окружение", + "created": "Окружение создано", + "deleted": "Окружение удалено", + "duplicated": "Окружение продублировано", + "edit": "Редактировать окружение", + "empty_variables": "Переменные еще не добавлены", + "global": "Global", + "global_variables": "Глобальные переменные", + "import_or_create": "Импортировать или создать новое окружение", + "invalid_name": "Укажите допустимое имя для окружения", + "list": "Переменные окружения", + "my_environments": "Мои окружения", + "name": "Имя", + "nested_overflow": "максимальный уровень вложения переменных окружения - 10", + "new": "Новая среда", + "no_active_environment": "Нет активных окружений", + "no_environment": "Нет окружения", + "no_environment_description": "Не выбрано окружение, выберите что делать с переменными.", + "quick_peek": "Быстрый просмотр переменных", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Секретные переменные", + "secret_value": "Секретное значение", + "select": "Выберите среду", + "set": "Выбрать окружение", + "set_as_environment": "Поместить значение в переменную", + "team_environments": "Окружения команды", + "title": "Окружения", + "updated": "Окружение обновлено", + "value": "Значение", + "variable": "Переменная", + "variables": "Переменные", + "variable_list": "Список переменных", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Не получается загрузить список возможных вариантов входа. Проверьте сеть, возможно отключен VPN", + "browser_support_sse": "Похоже, в этом браузере нет поддержки событий, отправленных сервером", + "check_console_details": "Подробности смотрите в журнале консоли.", + "check_how_to_add_origin": "Инструкция как это сделать", + "curl_invalid_format": "cURL неправильно отформатирован", + "danger_zone": "Опасная зона", + "delete_account": "Вы являетесь владельцем этой команды:", + "delete_account_description": "Прежде чем удалить аккаунт вам необходимо либо назначить владельцом другого пользователя, либо удалить команды в которых вы являетесь владельцем.", + "empty_email_address": "Адрес электронной почты не может быть пустым", + "empty_profile_name": "Имя пользователя не может быть пустым", + "empty_req_name": "Пустое имя запроса", + "f12_details": "(F12 для подробностей)", + "gql_prettify_invalid_query": "Не удалось отформатировать, т.к. в запросе есть синтаксические ошибки. Устраните их и повторите попытку.", + "incomplete_config_urls": "Не заполнены URL конфигурации", + "incorrect_email": "Некорректный Email", + "invalid_link": "Некорректная ссылка", + "invalid_link_description": "Ссылка, по которой вы перешли, недействительна, либо срок её действия истёк", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "Некорректный JSON", + "json_prettify_invalid_body": "Не удалось определить формат строки, устраните синтаксические ошибки и повторите попытку.", + "network_error": "Похоже, возникла проблема с соединением. Попробуйте еще раз.", + "network_fail": "Не удалось отправить запрос", + "no_collections_to_export": "Нечего экспортировать. Для начала нужно создать коллекцию.", + "no_duration": "Без продолжительности", + "no_environments_to_export": "Нечего экспортировать. Для начала нужно создать переменные окружения.", + "no_results_found": "Совпадения не найдены", + "page_not_found": "Эта страница не найдена", + "please_install_extension": "Ничего страшного. Просто нужно установить специальное расширение в браузере.", + "proxy_error": "Ошибка в прокси", + "same_email_address": "Обновленный адрес электронной почты совпадает с текущим адресом электронной почты", + "same_profile_name": "Задано имя пользователя такое же как и было", + "script_fail": "Не удалось выполнить сценарий предварительного запроса", + "something_went_wrong": "Что-то пошло не так", + "post_request_script_fail": "Не удалось выполнить тестирование запроса", + "reading_files": "Произошла ошибка при чтении файла или нескольких файлов", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Что-то пошло не так в процессе генерации токена доступа", + "delete_access_token": "Что-то пошло не так в процессе удаления токена доступа" + }, + "export": { + "as_json": "Экспорт как JSON", + "create_secret_gist": "Создать секретный Gist", + "create_secret_gist_tooltip_text": "Экспортировать как секретный Gist", + "failed": "Произошла ошибка во время экспорта", + "secret_gist_success": "Успешно экспортировано как секретный Gist", + "require_github": "Войдите через GitHub, чтобы создать секретную суть", + "title": "Экспорт", + "success": "Успешно экспортировано" + }, + "filter": { + "all": "Все", + "none": "Не указано", + "starred": "Отмечено" + }, + "folder": { + "created": "Папка создана", + "edit": "Редактировать папку", + "invalid_name": "Укажите имя для папки", + "name_length_insufficient": "Имя папки должно содержать 3 или более символов", + "new": "Новая папка", + "renamed": "Папка переименована" + }, + "graphql": { + "connection_switch_confirm": "Вы желаете соединиться с последним GraphQL сервером?", + "connection_switch_new_url": "Смена вкладки разорвёт текущее GraphQL соединение. Новый URL соединения будет", + "connection_switch_url": "Вы присоединились к GraphQL, URL соединения", + "mutations": "Мутации", + "schema": "Схема", + "subscriptions": "Подписки", + "switch_connection": "Изменить соединение", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "Коллекции GraphQL" + }, + "group": { + "time": "Время", + "url": "URL" + }, + "header": { + "install_pwa": "Установить приложение", + "login": "Авторизоваться", + "save_workspace": "Сохранить мою рабочую область" + }, + "helpers": { + "authorization": "Заголовок авторизации будет автоматически сгенерирован при отправке запроса", + "collection_properties_authorization": "Этот заголовок авторизации будет подставляться при каждом запросе в этой коллекции", + "collection_properties_header": "Этот заголовок будет подставляться при каждом запросе в этой коллекции", + "generate_documentation_first": "Сначала создайте документацию", + "network_fail": "Невозможно достичь конечной точки API. Проверьте подключение к сети и попробуйте еще раз", + "offline": "Кажется, вы не в сети. Данные в этой рабочей области могут быть устаревшими", + "offline_short": "Кажется, вы не в сети", + "post_request_tests": "Сценарии тестирования написаны на JavaScript и запускаются после получения ответа", + "pre_request_script": "Скрипты предварительного запроса написаны на JavaScript и запускаются перед отправкой запроса", + "script_fail": "Похоже, в скрипте предварительного запроса есть сбой. Проверьте ошибку ниже и исправьте скрипт соответствующим образом", + "post_request_script_fail": "Похоже, что скрипт тестирования содержит ошибку. Пожалуйста исправьте её и попробуйте снова", + "post_request_script": "Напишите тестовый сценарий для автоматизации отладки." + }, + "hide": { + "collection": "Свернуть панель соединения", + "more": "Скрыть больше", + "preview": "Скрыть предварительный просмотр", + "sidebar": "Скрыть боковую панель" + }, + "import": { + "collections": "Импортировать коллекции", + "curl": "Импортировать из cURL", + "environments_from_gist": "Импортировать из Gist", + "environments_from_gist_description": "Импортировать переменные окружения Hoppscotch из Gist", + "failed": "Ошибка импорта", + "from_file": "Импортировать из одного или нескольких файлов", + "from_gist": "Импорт из Gist", + "from_gist_description": "Импортировать через Gist URL", + "from_insomnia": "Импортировать из Insomnia", + "from_insomnia_description": "Импортировать из коллекции Insomnia", + "from_json": "Импортировать из Hoppscotch", + "from_json_description": "Импортировать из файла коллекции Hoppscotch", + "from_my_collections": "Импортировать из моих коллекций", + "from_my_collections_description": "Импортировать коллекции из моего файла", + "from_openapi": "Импортировать из OpenAPI", + "from_openapi_description": "Импортировать из OpenAPI файла описания API (YML/JSON)", + "from_postman": "Импортировать из Postman", + "from_postman_description": "Импортировать из коллекции Postman", + "from_url": "Импортировать из URL", + "gist_url": "Введите URL-адрес Gist", + "gql_collections_from_gist_description": "Импортировать GraphQL коллекцию из Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Импортировать окружение Hoppscotch из JSON файла", + "import_from_url_invalid_fetch": "Не удалить получить данные по этому URL", + "import_from_url_invalid_file_format": "Ошибка при импорте коллекций", + "import_from_url_invalid_type": "Неподдерживаемый тип. Поддерживаемые типы: 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Коллекция импортирована", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Импортировать из коллекции Hoppscotch", + "postman_environment": "Postman Environment", + "postman_environment_description": "Импортировать переменные окружения Postman из JSON файла", + "title": "Импортировать", + "file_size_limit_exceeded_warning_multiple_files": "Выбранные файлы превышают рекомендованный лимит в 10MB. Были импортированы только первые {files}", + "file_size_limit_exceeded_warning_single_file": "Размер выбранного в данный момент файла превышает рекомендуемый лимит в 10 МБ. Пожалуйста, выберите другой файл", + "success": "Успешно импортировано" + }, + "inspections": { + "description": "Показать возможные ошибки", + "environment": { + "add_environment": "Добавить переменную", + "add_environment_value": "Заполнить значение", + "empty_value": "Значение переменной окружения '{variable}' пустое", + "not_found": "Переменная окружения “{environment}” не задана" + }, + "header": { + "cookie": "Из-за ограничений безопасности в веб версии нельзя задать Cookie параметры. Пожалуйста, используйте приложение Hoppscotch Desktop или используйте заголовок Authorization для веб-версии" + }, + "response": { + "401_error": "Проверьте данные для аутентификации", + "404_error": "Проверьте параметры запроса и метод HTTP", + "cors_error": "Проверьте настройки CORS (Cross-Origin Resource Sharing)", + "default_error": "Проверьте параметры запроса", + "network_error": "Проверьте сетевое подключение" + }, + "title": "Помощник", + "url": { + "extension_not_installed": "Расширение не установлено", + "extension_unknown_origin": "Убедитесь, что текущий домен добавлен в список доверенных ресурсов в расширении браузера", + "extention_enable_action": "Подключить расширение", + "extention_not_enabled": "Расширение в браузере не подключено" + } + }, + "layout": { + "collapse_collection": "Свернуть или развернуть коллекции", + "collapse_sidebar": "Свернуть или развернуть боковую панель", + "column": "Вертикальная развёртка", + "name": "Развёртка", + "row": "Горизонтальная развертка" + }, + "modal": { + "close_unsaved_tab": "У вас есть несохранённые изменения", + "collections": "Коллекции", + "confirm": "Подтвердите действие", + "customize_request": "Настроить вид запроса", + "edit_request": "Изменить запрос", + "import_export": "Импорт Экспорт", + "share_request": "Поделиться запросом" + }, + "mqtt": { + "already_subscribed": "Вы уже подписаны на этот топик", + "clean_session": "Очистить сессию", + "clear_input": "Очистить ввод", + "clear_input_on_send": "Очистить ввод перед отправкой", + "client_id": "Client ID", + "color": "Выбрать цвет", + "communication": "Коммуникация", + "connection_config": "Конфигурация соединения", + "connection_not_authorized": "Это соединение MQTT не использует какую-либо авторизацию", + "invalid_topic": "Пожалуйста выберите topic для подписки", + "keep_alive": "Поддерживать соединение", + "log": "Лог", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "Сообщение", + "new": "Новая подписка", + "not_connected": "Пожалуйста, сначала запустите MQTT соединение", + "publish": "Публиковать", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Подписаться", + "topic": "Тема", + "topic_name": "Название темы", + "topic_title": "Опубликовать / подписаться на тему", + "unsubscribe": "Отписаться от рассылки", + "url": "URL" + }, + "navigation": { + "doc": "Документы", + "graphql": "GraphQL", + "profile": "Профиль", + "realtime": "Realtime", + "rest": "REST", + "settings": "Настройки" + }, + "preRequest": { + "javascript_code": "Код JavaScript", + "learn": "Читать документацию", + "script": "Предварительный скрипт запроса", + "snippets": "Готовый код" + }, + "profile": { + "app_settings": "Настройки приложения", + "default_hopp_displayname": "Безымянный", + "editor": "Редактор", + "editor_description": "Редакторы могут добавлять, редактировать, а так же удалять запросы", + "email_verification_mail": "На вашу электронную почту отправлено письмо для подтверждения. Перейдите по ссылке из письма, чтобы подтвердить свой электронный адрес", + "no_permission": "У Вас недостаточно прав, чтобы выполнить это действие", + "owner": "Владелец", + "owner_description": "Владелец может добавлять, редактировать и удалять запросы, коллекции, а так же участников", + "roles": "Роли", + "roles_description": "Роли позволяют настраивать доступ конкретным людям к публичным коллекциям", + "updated": "Профиль обновлен", + "viewer": "Читатель", + "viewer_description": "Могут только просматривать и использовать запросы" + }, + "remove": { + "star": "Удалить звезду" + }, + "request": { + "added": "Запрос добавлен", + "authorization": "Авторизация", + "body": "Тело запроса", + "choose_language": "Выберите язык", + "content_type": "Тип содержимого", + "content_type_titles": { + "others": "Другие", + "structured": "Структурированный", + "text": "Текст" + }, + "different_collection": "Нельзя изменять порядок запросов из разных коллекций", + "duplicated": "Запрос скопирован", + "duration": "Продолжительность", + "enter_curl": "Введите сюда команду cURL", + "generate_code": "Сгенерировать код", + "generated_code": "Сгенерированный код", + "go_to_authorization_tab": "Перейти на вкладку авторизации", + "go_to_body_tab": "Перейти на вкладку тела запроса", + "header_list": "Список заголовков", + "invalid_name": "Укажите имя для запроса", + "method": "Метод", + "moved": "Запрос перемещён", + "name": "Имя запроса", + "new": "Новый запрос", + "order_changed": "Порядок запроса изменён", + "override": "Переопределить", + "override_help": "Установить Content-Type в Заголовках", + "overriden": "Переопределено", + "parameter_list": "Параметры запроса", + "parameters": "Параметры", + "path": "Путь", + "payload": "Полезная нагрузка", + "query": "Запрос", + "raw_body": "Необработанное тело запроса", + "rename": "Переименовать запрос", + "renamed": "Запрос переименован", + "request_variables": "Переменные запроса", + "run": "Запустить", + "save": "Сохранить", + "save_as": "Сохранить как", + "saved": "Запрос сохранен", + "share": "Поделиться", + "share_description": "Поделиться Hoppscotch с друзьями", + "share_request": "Поделиться запросом", + "stop": "Стоп", + "title": "Запрос", + "type": "Тип запроса", + "url": "URL", + "url_placeholder": "Введите URL или вставьте команду из cURL", + "variables": "Переменные", + "view_my_links": "Посмотреть мои ссылки" + }, + "response": { + "audio": "Аудио", + "body": "Тело ответа", + "filter_response_body": "Отфильтровать ответ в формате JSON (используется синтаксис jq)", + "headers": "Заголовки", + "html": "HTML", + "image": "Изображение", + "json": "JSON", + "pdf": "PDF", + "preview_html": "Предварительный просмотр HTML", + "raw": "RAW", + "size": "Размер", + "status": "Статус", + "time": "Время", + "title": "Ответ", + "video": "Видео", + "waiting_for_connection": "Ожидание соединения", + "xml": "XML" + }, + "settings": { + "accent_color": "Основной цвет", + "account": "Аккаунт", + "account_deleted": "Ваш аккаунт был удалён", + "account_description": "Настройте параметры своей учетной записи.", + "account_email_description": "Ваш основной адрес электронной почты.", + "account_name_description": "Это ваше отображаемое имя.", + "additional": "Additional Settings", + "background": "Задний фон", + "black_mode": "Чёрная", + "choose_language": "Выберите язык", + "dark_mode": "Тёмная", + "delete_account": "Удалить аккаунт", + "delete_account_description": "Удаление аккаунта нельзя отменить", + "expand_navigation": "Раскрыть боковую панель", + "experiments": "Эксперименты", + "experiments_notice": "Это набор экспериментов, над которыми мы работаем, которые могут оказаться полезными, интересными тебе и мне, а может и никому. Они не завершены и могут быть нестабильными, поэтому, если произойдет что-то слишком странное, не паникуйте. Просто выключи эту чертову штуку. Шутки в сторону,", + "extension_ver_not_reported": "Не сообщается", + "extension_version": "Версия расширения", + "extensions": "Расширения", + "extensions_use_toggle": "Используйте расширение браузера для отправки запросов (если есть)", + "follow": "Follow Us", + "interceptor": "Перехватчик", + "interceptor_description": "Промежуточное ПО между приложением и API", + "language": "Язык", + "light_mode": "Светлая", + "official_proxy_hosting": "Официальный прокси-сервер размещен на Hoppscotch", + "profile": "Профиль", + "profile_description": "Обновить настройки профиля", + "profile_email": "Адрес электронной почты", + "profile_name": "Имя учетной записи", + "proxy": "Прокси", + "proxy_url": "URL прокси", + "proxy_use_toggle": "Используйте промежуточное ПО прокси для отправки запросов", + "read_the": "Прочтите", + "reset_default": "Восстановление значений по умолчанию", + "short_codes": "Короткие ссылки", + "short_codes_description": "Короткие ссылки, созданные вами", + "sidebar_on_left": "Панель слева", + "sync": "Синхронизировать", + "sync_collections": "Коллекции", + "sync_description": "Эти настройки синхронизируются с облаком", + "sync_environments": "Среды", + "sync_history": "История", + "system_mode": "Система", + "telemetry": "Телеметрия", + "telemetry_helps_us": "Телеметрия помогает нам персонализировать наши операции и предоставлять вам лучший опыт", + "theme": "Тема", + "theme_description": "Настройте тему своего приложения.", + "use_experimental_url_bar": "Использовать экспериментальную строку URL с выделением среды", + "user": "Пользователь", + "verified_email": "Проверенный Email", + "verify_email": "Подтвердить Email" + }, + "shared_requests": { + "button": "Кнопка", + "button_info": "Добавить кнопку 'Run in Hoppscotch' на свой сайт, блог или README", + "copy_html": "Копировать HTML код", + "copy_link": "Копировать ссылку", + "copy_markdown": "Копировать Markdown", + "creating_widget": "Создание виджета", + "customize": "Настроить", + "deleted": "Запрос удален", + "description": "Выберите, каким образом вы хотите поделиться запросом. Вы сможете добавить дополнительные настройки кастомизации позже", + "embed": "Встраиваемое окно", + "embed_info": "Добавьте небольшую площадку 'Hoppscotch API Playground' на свой веб-сайт, блог или документацию", + "link": "Ссылка", + "link_info": "Создайте общедоступную ссылку, которой можно поделиться с любым пользователем", + "modified": "Запрос изменен", + "not_found": "Такой ссылки не нашлось", + "open_new_tab": "Открыть в новом окне", + "preview": "Как это будет выглядеть:", + "run_in_hoppscotch": "Запустить в Hoppscotch", + "theme": { + "dark": "Темная", + "light": "Светлая", + "system": "Системная", + "title": "Тема" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Закрыть текущее меню", + "command_menu": "Меню поиска и команд", + "help_menu": "Меню помощи", + "show_all": "Список горячих клавиш", + "title": "Общий" + }, + "miscellaneous": { + "invite": "Пригласите людей в Hoppscotch", + "title": "Разное" + }, + "navigation": { + "back": "Вернуться на предыдущую страницу", + "documentation": "Перейти на страницу документации", + "forward": "Перейти на следующую страницу", + "graphql": "Перейти на страницу GraphQL", + "profile": "Перейти к профилю", + "realtime": "Перейти на страницу в реальном времени", + "rest": "Перейти на страницу REST", + "settings": "Перейти на страницу настроек", + "title": "Навигация" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "Выберите метод DELETE", + "get_method": "Выберите метод GET", + "head_method": "Выберите метод HEAD", + "import_curl": "Импортировать из cURL", + "method": "Метод", + "next_method": "Выберите следующий метод", + "post_method": "Выберите метод POST", + "previous_method": "Выбрать предыдущий метод", + "put_method": "Выберите метод PUT", + "rename": "Переименовать запрос", + "reset_request": "Сбросить запрос", + "save_request": "Сохранить запрос", + "save_to_collections": "Сохранить в коллекции", + "send_request": "Отправить запрос", + "share_request": "Поделиться запросом", + "show_code": "Сгенерировать фрагмент кода из запроса", + "title": "Запрос" + }, + "response": { + "copy": "Копировать запрос в буфер обмена", + "download": "Скачать запрос как файл", + "title": "Запрос" + }, + "theme": { + "black": "Переключить на черный режим", + "dark": "Переключить на тёмный режим", + "light": "Переключить на светлый режим", + "system": "Переключить на тему, исходя из настроек системы", + "title": "Внешний вид" + } + }, + "show": { + "code": "Показать код", + "collection": "Развернуть панель коллекций", + "more": "Показать больше", + "sidebar": "Показать боковую панель" + }, + "socketio": { + "communication": "Коммуникация", + "connection_not_authorized": "Это SocketIO соединение не использует какую-либо авторизацию.", + "event_name": "Название события", + "events": "События", + "log": "Лог", + "url": "URL" + }, + "spotlight": { + "change_language": "Изменить язык", + "environments": { + "delete": "Удалить текущее окружение", + "duplicate": "Дублировать текущее окружение", + "duplicate_global": "Дублировать глобальное окружение", + "edit": "Редактировать текущее окружение", + "edit_global": "Редактировать глобальное окружение", + "new": "Создать новое окружение", + "new_variable": "Создать новую переменную окружения", + "title": "Окружение" + }, + "general": { + "chat": "Чат с поддержкой", + "help_menu": "Помощь", + "open_docs": "Почитать документацию", + "open_github": "Открыть GitHub репозиторий", + "open_keybindings": "Горячие клавиши", + "social": "Соц. сети", + "title": "Общее" + }, + "graphql": { + "connect": "Подключиться к серверу", + "disconnect": "Отключиться от сервера" + }, + "miscellaneous": { + "invite": "Пригласить друзей в Hoppscotch", + "title": "Другое" + }, + "request": { + "save_as_new": "Сохранить как новый запрос", + "select_method": "Выбрать метод", + "switch_to": "Переключиться", + "tab_authorization": "На вкладку авторизации", + "tab_body": "На вкладку тела запроса", + "tab_headers": "На вкладку заголовков", + "tab_parameters": "На вкладку параметров", + "tab_pre_request_script": "На вкладку пред-скрипта запроса", + "tab_query": "На вкладку запроса", + "tab_tests": "На вкладку тестов", + "tab_variables": "На вкладку переменных запроса" + }, + "response": { + "copy": "Копировать содержимое ответа", + "download": "Сказать содержимое ответа как файл", + "title": "Ответ запроса" + }, + "section": { + "interceptor": "Перехватчик", + "interface": "Интерфейс", + "theme": "Внешний вид", + "user": "Пользователь" + }, + "settings": { + "change_interceptor": "Изменить перехватчик", + "change_language": "Изменить язык", + "theme": { + "black": "Черная", + "dark": "Темная", + "light": "Светлая", + "system": "Как задано в системе" + } + }, + "tab": { + "close_current": "Закрыть текущую вкладку", + "close_others": "Закрыть все вкладки", + "duplicate": "Продублировать текущую вкладку", + "new_tab": "Открыть в новой вкладке", + "title": "Вкладки" + }, + "workspace": { + "delete": "Удалить текущую команду", + "edit": "Редактировать текущую команду", + "invite": "Пригласить людей в команду", + "new": "Создать новую команду", + "switch_to_personal": "Переключить на персональное пространство", + "title": "Команды" + }, + "phrases": { + "try": "Попробуй", + "import_collections": "Импортировать коллекцию", + "create_environment": "Создать окружение", + "create_workspace": "Создать пространство", + "share_request": "Поделиться запросом" + } + }, + "sse": { + "event_type": "Тип события", + "log": "Лог", + "url": "URL" + }, + "state": { + "bulk_mode": "Множественное редактирование", + "bulk_mode_placeholder": "Каждый параметр должен начинаться с новой строки\nКлючи и значения разделяются двоеточием\nИспользуйте # для комментария", + "cleared": "Очищено", + "connected": "Подключено", + "connected_to": "Подключено к {name}", + "connecting_to": "Подключение к {name} ...", + "connection_error": "Ошибка подключения", + "connection_failed": "Не удалось установить соединение", + "connection_lost": "Соединение утеряно", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "Скопировано в буфер обмена", + "deleted": "Удалено", + "deprecated": "УСТАРЕЛО", + "disabled": "Отключено", + "disconnected": "Нет подключения", + "disconnected_from": "Отключено от {name}", + "docs_generated": "Документация создана", + "download_failed": "Download failed", + "download_started": "Скачивание началось", + "enabled": "Включено", + "file_imported": "Файл успешно импортирован", + "finished_in": "Завершено через {duration} мс", + "hide": "Скрыть", + "history_deleted": "История удалена", + "linewrap": "Переносить строки", + "loading": "Загрузка...", + "message_received": "Сообщение: {message} получено по топику: {topic}", + "mqtt_subscription_failed": "Что-то пошло не так, при попытке подписаться на топик: {topic}", + "none": "Не задан", + "nothing_found": "Ничего не найдено для", + "published_error": "Что-то пошло не так при попытке опубликовать сообщение в топик {topic}: {message}", + "published_message": "Опубликовано сообщение: {message} в топик: {topic}", + "reconnection_error": "Не удалось переподключиться", + "show": "Показать", + "subscribed_failed": "Не удалось подписаться на топик: {topic}", + "subscribed_success": "Успешно подписался на топик: {topic}", + "unsubscribed_failed": "Не удалось отписаться от топика: {topic}", + "unsubscribed_success": "Успешно отписался от топика: {topic}", + "waiting_send_request": "Ожидание отправки запроса" + }, + "support": { + "changelog": "Узнать больше о последних выпусках", + "chat": "Есть вопрос? Давай поболтаем!", + "community": "Задавайте вопросы и помогайте другим", + "documentation": "Узнать больше о Hoppscotch", + "forum": "Задавайте вопросы и получайте ответы", + "github": "Подпишитесь на нас на Github", + "shortcuts": "Работайте с приложением ещё быстрее", + "title": "Служба поддержки", + "twitter": "Следуйте за нами на Twitter" + }, + "tab": { + "authorization": "Авторизация", + "body": "Тело", + "close": "Закрыть вкладку", + "close_others": "Закрыть остальные вкладки", + "collections": "Коллекции", + "documentation": "Документация", + "duplicate": "Дублировать вкладку", + "environments": "Окружения", + "headers": "Заголовки", + "history": "История", + "mqtt": "MQTT", + "parameters": "Параметры", + "pre_request_script": "Пред-скрипт", + "queries": "Запросы", + "query": "Запрос", + "schema": "Схема", + "shared_requests": "Запросы в общем доступе", + "codegen": "Сгенерировать код", + "code_snippet": "Фрагмент кода", + "share_tab_request": "Поделиться запросом", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Тесты", + "types": "Типы", + "variables": "Переменные", + "websocket": "WebSocket" + }, + "team": { + "already_member": "Вы уже являетесь участником этой команды.", + "create_new": "Создать новую команду", + "deleted": "Команда удалена", + "edit": "Редактировать команду", + "email": "Электронная почта", + "email_do_not_match": "Электронная почта, которой Вы воспользовались не соответсвует указанной в данных Вашей учетной записи.", + "exit": "Выйти из команды", + "exit_disabled": "Только владелец не может выйти из команды", + "failed_invites": "Непринятые приглашения", + "invalid_coll_id": "Не верный идентификатор коллекции", + "invalid_email_format": "Формат электронной почты недействителен", + "invalid_id": "Некорректный ID команды. Свяжитесь с руководителем команды.", + "invalid_invite_link": "Ссылка недействительна", + "invalid_invite_link_description": "Вы воспользовались недействительной ссылкой. Свяжитесь с руководителем команды.", + "invalid_member_permission": "Пожалуйста, предоставьте действительное разрешение участнику команды", + "invite": "Пригласить", + "invite_more": "Пригласить больше", + "invite_tooltip": "Пригласить людей в Ваше рабочее пространство", + "invited_to_team": "{owner} приглашает Вас присоединиться к пространству {workspace}", + "join": "Приглашение принято", + "join_team": "Присоединиться к {workspace}", + "joined_team": "Вы присоединились к команде {workspace}", + "joined_team_description": "Теперь Вы участник этой команды", + "left": "Вы покинули команду", + "login_to_continue": "Войдите для продолжения", + "login_to_continue_description": "Вам нужно авторизоваться, чтобы присоединиться к этой команде.", + "logout_and_try_again": "Выйти, чтобы войти из под другого аккаунта", + "member_has_invite": "Пользователя с этим электронным адресом уже пригласили. Свяжитесь с руководителем команды, если требуется.", + "member_not_found": "Участник не найден. Свяжитесь с руководителем этой команды", + "member_removed": "Пользователь удален", + "member_role_updated": "Роли пользователей обновлены", + "members": "Участники", + "more_members": "+{count}", + "name_length_insufficient": "Название команды должно составлять не менее 6 символов", + "name_updated": "Название команды обновлено", + "new": "Новая команда", + "new_created": "Создана новая команда", + "new_name": "Моя новая команда", + "no_access": "У вас нет прав на редактирование этих коллекций", + "no_invite_found": "Такое приглашение мы не смогли найти. Свяжитесь с руководителем команды.", + "no_request_found": "Запрос не найден", + "not_found": "Команда не найдена, свяжитесь с владельцем команды", + "not_valid_viewer": "У Вас нет прав просматривать это. Свяжитесь с руководителем команды.", + "parent_coll_move": "Не удалось переместить коллекцию в дочернюю", + "pending_invites": "Ждут добавления", + "permissions": "Разрешения", + "same_target_destination": "Таже цель и конечная точка", + "saved": "Команда сохранена", + "select_a_team": "Выбрать команду", + "success_invites": "Принятые приглашения", + "title": "Команды", + "we_sent_invite_link": "Мы отправили все приглашения!", + "invite_sent_smtp_disabled": "Ссылка-приглашение сгенерирована", + "we_sent_invite_link_description": "Попросите тех, кого Вы пригласили, проверить их почтовые ящики. Им нужно перейди по ссылке, чтобы подтвердить вступление в эту команду.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Скопировать ссылку-приглашение", + "search_title": "Team Requests" + }, + "team_environment": { + "deleted": "Окружение удалено", + "duplicate": "Окружение скопировано", + "not_found": "Окружение не найдено" + }, + "test": { + "failed": "Тест не пройден", + "javascript_code": "Код JavaScript", + "learn": "Читать документацию", + "passed": "Тест пройден", + "report": "Отчет", + "results": "Результаты", + "script": "Скрипт", + "snippets": "Фрагменты" + }, + "websocket": { + "communication": "Коммуникация", + "log": "Лог", + "message": "Сообщение", + "protocols": "Протоколы", + "url": "URL" + }, + "workspace": { + "change": "Изменить пространство", + "personal": "Моё пространство", + "other_workspaces": "Пространства", + "team": "Пространство команды", + "title": "Рабочие пространства" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Токены", + "section_title": "Персональные токены доступа", + "section_description": "С помощью персональных токенов доступа можно подключиться к вашей учётной записи Hoppscotch через CLI", + "last_used_on": "Последнее использование", + "expires_on": "Истекает", + "no_expiration": "Бессрочный", + "expired": "Истёк", + "copy_token_warning": "Обязательно скопируйте и сохраните свой персональный токен доступа. Вы больше с ним не увидитесь!", + "token_purpose": "Для чего будет использоваться токен?", + "expiration_label": "Время жизни", + "scope_label": "Ограничения", + "workspace_read_only_access": "Доступ к данным рабочей области только для чтения", + "personal_workspace_access_limitation": "Личные токены доступа не позволяют получить доступ к вашему личному рабочему пространству", + "generate_token": "Сгенерировать токен", + "invalid_label": "Придумайте название для токена", + "no_expiration_verbose": "Токен будет жить вечно!", + "token_expires_on": "Токен истечёт", + "generate_new_token": "Сгенерировать токен", + "generate_modal_title": "Новый персональный токен доступа", + "deletion_success": "Токен доступа {label} удалён" + }, + "collection_runner": { + "collection_id": "ID коллекции", + "environment_id": "ID окружения", + "cli_collection_id_description": "Этот ID будет использован для запуска коллекций через CLI в Hoppscoth", + "cli_environment_id_description": "Этот ID окружения будет использован для запуска коллекций через CLI в Hoppscoth", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (в разработке)", + "cli_command_generation_description_cloud": "Скопируйте команду и запустите её в CLI. Заполните параметр своим токеном", + "cli_command_generation_description_sh": "Скопируйте команду и запустите её в CLI. Заполните параметр своим токеном и проверьте, что сгенерирован верный URL сервера", + "cli_command_generation_description_sh_with_server_url_placeholder": "Скопируйте команду и запустите её в CLI. Заполните параметр своим токеном и определите URL сервера", + "run_collection": "Запустить коллекцию" + } +} diff --git a/packages/hoppscotch-common/locales/sr.json b/packages/hoppscotch-common/locales/sr.json new file mode 100644 index 0000000..3b29fcf --- /dev/null +++ b/packages/hoppscotch-common/locales/sr.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Add", + "autoscroll": "Autoscroll", + "cancel": "Поништити, отказати", + "choose_file": "Одаберите датотеку", + "clear": "Јасно", + "clear_all": "Избриши све", + "clear_history": "Clear all History", + "close": "Close", + "connect": "Цоннецт", + "connecting": "Connecting", + "copy": "Цопи", + "create": "Create", + "delete": "Избриши", + "disconnect": "Прекините везу", + "dismiss": "Одбаци", + "dont_save": "Don't save", + "download_file": "Скини докуменат", + "drag_to_reorder": "Drag to reorder", + "duplicate": "Duplicate", + "edit": "Уредити", + "filter": "Filter", + "go_back": "Вратити се", + "go_forward": "Go forward", + "group_by": "Group by", + "hide_secret": "Hide secret", + "label": "Ознака", + "learn_more": "Сазнајте више", + "download_here": "Download here", + "less": "Less", + "more": "Више", + "new": "Нова", + "no": "Не", + "open_workspace": "Open workspace", + "paste": "Paste", + "prettify": "Преттифи", + "properties": "Properties", + "remove": "Уклони", + "rename": "Rename", + "restore": "Ресторе", + "save": "сачувати", + "scroll_to_bottom": "Scroll to bottom", + "scroll_to_top": "Scroll to top", + "search": "Претрага", + "send": "Пошаљи", + "share": "Share", + "show_secret": "Show secret", + "start": "Почетак", + "starting": "Starting", + "stop": "Зауставити", + "to_close": "to close", + "to_navigate": "to navigate", + "to_select": "to select", + "turn_off": "Искључити", + "turn_on": "Укључити", + "undo": "Поништи", + "yes": "да" + }, + "add": { + "new": "Додај нови", + "star": "Додајте звездицу" + }, + "app": { + "chat_with_us": "Разговарајте са нама", + "contact_us": "Контактирајте нас", + "cookies": "Cookies", + "copy": "Цопи", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Copy User Auth Token", + "developer_option": "Developer options", + "developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.", + "discord": "Discord", + "documentation": "Документација", + "github": "GitHub", + "help": "Помоћ, повратне информације и документација", + "home": "Кућа", + "invite": "Позови", + "invite_description": "У Хоппсцотцх -у смо дизајнирали једноставан и интуитиван интерфејс за креирање и управљање вашим АПИ -јима. Хоппсцотцх је алатка која вам помаже у изградњи, тестирању, документовању и дељењу ваших АПИ -ја.", + "invite_your_friends": "Позвати своје пријатеље", + "join_discord_community": "Придружите се нашој заједници Дисцорд", + "keyboard_shortcuts": "Пречице на тастатури", + "name": "Хоппсцотцх", + "new_version_found": "Нова верзија је пронађена. Освежите да бисте ажурирали.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Options", + "proxy_privacy_policy": "Политика приватности посредника", + "reload": "Освежи", + "search": "Претрага", + "share": "Објави", + "shortcuts": "Пречице", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "Под лупом", + "status": "Статус", + "status_description": "Check the status of the website", + "terms_and_privacy": "Услови и приватност", + "twitter": "Twitter", + "type_a_command_search": "Унесите команду или претражите…", + "we_use_cookies": "Користимо колачиће", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "Шта је ново?", + "see_whats_new": "See what’s new", + "wiki": "Вики" + }, + "auth": { + "account_exists": "Налог постоји са различитим акредитивима - Пријавите се да бисте повезали оба налога", + "all_sign_in_options": "Све опције пријављивања", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "Наставите са е -поштом", + "continue_with_github": "Наставите са ГитХуб -ом", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Наставите са Гоогле -ом", + "continue_with_microsoft": "Continue with Microsoft", + "email": "Емаил", + "logged_out": "Одјављени", + "login": "Пријавите се", + "login_success": "Пријављени сте", + "login_to_hoppscotch": "Пријавите се на Хоппсцотцх", + "logout": "Одјавити се", + "re_enter_email": "Поново укуцати имејл", + "send_magic_link": "Пошаљите чаробну везу", + "sync": "Синхронизовати", + "we_sent_magic_link": "Послали смо вам чаробну везу!", + "we_sent_magic_link_description": "Проверите пријемно сандуче - послали смо поруку е -поште на адресу {емаил}. Садржи чаробну везу помоћу које ћете се пријавити." + }, + "authorization": { + "generate_token": "Генериши токен", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "Укључи у УРЛ", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "Научите како", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "Pass by", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "Лозинка", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "Токен", + "type": "Врста овлашћења", + "username": "Корисничко име" + }, + "collection": { + "created": "Колекција је направљена", + "different_parent": "Cannot reorder collection with different parent", + "edit": "Измени збирку", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "Наведите важећи назив збирке", + "invalid_root_move": "Collection already in the root", + "moved": "Moved Successfully", + "my_collections": "Моје колекције", + "name": "Моја нова колекција", + "name_length_insufficient": "Collection name should be at least 3 characters long", + "new": "Нова колекција", + "order_changed": "Collection Order Updated", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "Збирка је преименована", + "request_in_use": "Request in use", + "save_as": "Сачувај као", + "save_to_collection": "Save to Collection", + "select": "Изаберите колекцију", + "select_location": "Изаберите локацију", + "details": "Details", + "select_team": "Изаберите тим", + "team_collections": "Збирке тима" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "exit_team": "Are you sure you want to leave this team?", + "logout": "Јесте ли сигурни да се желите одјавити?", + "remove_collection": "Јесте ли сигурни да желите трајно да избришете ову колекцију?", + "remove_environment": "Јесте ли сигурни да желите трајно да избришете ово окружење?", + "remove_folder": "Јесте ли сигурни да желите трајно да избришете ову фасциклу?", + "remove_history": "Јесте ли сигурни да желите трајно да избришете сву историју?", + "remove_request": "Јесте ли сигурни да желите трајно да избришете овај захтев?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "Јесте ли сигурни да желите да избришете овај тим?", + "remove_telemetry": "Јесте ли сигурни да желите да искључите Телеметрију?", + "request_change": "Are you sure you want to discard current request, unsaved changes will be lost.", + "save_unsaved_tab": "Do you want to save changes made in this tab?", + "sync": "Јесте ли сигурни да желите да синхронизујете овај радни простор?", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "Заглавље {count}", + "message": "Порука {count}", + "parameter": "Параметар {count}", + "protocol": "Протокол {count}", + "value": "Вредност {count}", + "variable": "Променљива {count}" + }, + "documentation": { + "generate": "Направите документацију", + "generate_message": "Увезите било коју Хоппсцотцх колекцију за генерисање АПИ документације у покрету." + }, + "empty": { + "authorization": "Овај захтев не користи никаква овлашћења", + "body": "Овај захтев нема тело", + "collection": "Збирка је празна", + "collections": "Збирке су празне", + "documentation": "Connect to a GraphQL endpoint to view documentation", + "endpoint": "Endpoint cannot be empty", + "environments": "Окружења су празна", + "folder": "Мапа је празна", + "headers": "Овај захтев нема заглавља", + "history": "Историја је празна", + "invites": "Invite list is empty", + "members": "Тим је празан", + "parameters": "Овај захтев нема параметре", + "pending_invites": "There are no pending invites for this team", + "profile": "Login to view your profile", + "protocols": "Протоколи су празни", + "request_variables": "This request does not have any request variables", + "schema": "Повежите се са ГрапхКЛ крајњом тачком", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "Subscriptions are empty", + "team_name": "Назив тима празан", + "teams": "Тимови су празни", + "tests": "Нема тестова за овај захтев", + "access_tokens": "Access tokens are empty", + "shortcodes": "Shortcodes are empty" + }, + "environment": { + "add_to_global": "Add to Global", + "added": "Environment addition", + "create_new": "Креирајте ново окружење", + "created": "Environment created", + "deleted": "Environment deletion", + "duplicated": "Environment duplicated", + "edit": "Едит Енвиронмент", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create a environment", + "invalid_name": "Наведите важећи назив за окружење", + "list": "Environment variables", + "my_environments": "My Environments", + "name": "Name", + "nested_overflow": "nested environment variables are limited to 10 levels", + "new": "Ново окружење", + "no_active_environment": "No active environment", + "no_environment": "Нема окружења", + "no_environment_description": "No environments were selected. Choose what to do with the following variables.", + "quick_peek": "Environment Quick Peek", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "Изаберите окружење", + "set": "Set environment", + "set_as_environment": "Set as environment", + "team_environments": "Team Environments", + "title": "Енвиронментс", + "updated": "Environment updation", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Листа променљивих", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "Изгледа да овај прегледач нема подршку за Послане догађаје са сервера.", + "check_console_details": "Детаље потражите у дневнику конзоле.", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "цУРЛ није правилно форматиран", + "danger_zone": "Danger zone", + "delete_account": "Your account is currently an owner in these teams:", + "delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "Празан назив захтева", + "f12_details": "(Ф12 за детаље)", + "gql_prettify_invalid_query": "Није могуће унапредити неважећи упит, решити грешке у синтакси упита и покушати поново", + "incomplete_config_urls": "Incomplete configuration URLs", + "incorrect_email": "Incorrect email", + "invalid_link": "Invalid link", + "invalid_link_description": "The link you clicked is invalid or expired.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "Invalid JSON", + "json_prettify_invalid_body": "Није могуће унапредити неважеће тело, решити грешке у синтакси јсон -а и покушати поново", + "network_error": "There seems to be a network error. Please try again.", + "network_fail": "Слање захтева није успело", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "Нема трајања", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "No matches found", + "page_not_found": "This page could not be found", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "Није могуће извршити скрипту пре захтева", + "something_went_wrong": "Нешто није у реду", + "post_request_script_fail": "Could not execute post-request script", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "Извези као ЈСОН", + "create_secret_gist": "Направите тајну суштину", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "Пријавите се са ГитХуб -ом да бисте креирали тајну суштину", + "title": "Export", + "success": "Successfully exported", + "gist_created": "Суштина створена" + }, + "filter": { + "all": "All", + "none": "None", + "starred": "Starred" + }, + "folder": { + "created": "Фолдер је креиран", + "edit": "Едит Фолдер", + "invalid_name": "Наведите назив фасцикле", + "name_length_insufficient": "Folder name should be at least 3 characters long", + "new": "Нова фасцикла", + "renamed": "Фасцикла је преименована" + }, + "graphql": { + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "mutations": "Мутације", + "schema": "Схема", + "subscriptions": "Претплате", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "Time", + "url": "URL" + }, + "header": { + "install_pwa": "Инсталирати апликацију", + "login": "Пријавите се", + "save_workspace": "Сачувај мој радни простор" + }, + "helpers": { + "authorization": "Заглавље ауторизације ће се аутоматски генерисати када пошаљете захтев.", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "Прво направите документацију", + "network_fail": "Није могуће доћи до крајње тачке АПИ -ја. Проверите мрежну везу и покушајте поново.", + "offline": "Изгледа да сте ван мреже. Подаци у овом радном простору можда нису ажурирани.", + "offline_short": "Изгледа да сте ван мреже.", + "post_request_tests": "Тест скрипте су написане у ЈаваСцрипт -у и покрећу се након пријема одговора.", + "pre_request_script": "Скрипте пред-захтева су написане у ЈаваСцрипт-у и покрећу се пре слања захтева.", + "script_fail": "Чини се да постоји грешка у скрипти пре захтева. Проверите грешку у наставку и поправите скрипту у складу са тим.", + "post_request_script_fail": "There seems to be an error with test script. Please fix the errors and run tests again", + "post_request_script": "Напишите тест скрипту за аутоматизацију отклањања грешака." + }, + "hide": { + "collection": "Collapse Collection Panel", + "more": "Сакриј више", + "preview": "Сакриј преглед", + "sidebar": "Сакриј бочну траку" + }, + "import": { + "collections": "Увоз збирки", + "curl": "Увези цУРЛ", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "Увоз није успео", + "from_file": "Import from File", + "from_gist": "Увоз из Гиста", + "from_gist_description": "Import from Gist URL", + "from_insomnia": "Import from Insomnia", + "from_insomnia_description": "Import from Insomnia collection", + "from_json": "Import from Hoppscotch", + "from_json_description": "Import from Hoppscotch collection file", + "from_my_collections": "Увези из Мојих колекција", + "from_my_collections_description": "Import from My Collections file", + "from_openapi": "Import from OpenAPI", + "from_openapi_description": "Import from OpenAPI specification file (YML/JSON)", + "from_postman": "Import from Postman", + "from_postman_description": "Import from Postman collection", + "from_url": "Import from URL", + "gist_url": "Унесите Гист УРЛ", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "Couldn't get data from the url", + "import_from_url_invalid_file_format": "Error while importing collections", + "import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Collections Imported", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Import collections from a Hoppscotch Collections JSON file", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "Увоз", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled." + } + }, + "layout": { + "collapse_collection": "Collapse or Expand Collections", + "collapse_sidebar": "Collapse or Expand the sidebar", + "column": "Vertical layout", + "name": "Layout", + "row": "Horizontal layout" + }, + "modal": { + "close_unsaved_tab": "You have unsaved changes", + "collections": "Збирке", + "confirm": "Потврди", + "customize_request": "Customize Request", + "edit_request": "Измените захтев", + "import_export": "Увоз извоз", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "You are already subscribed to this topic.", + "clean_session": "Clean Session", + "clear_input": "Clear input", + "clear_input_on_send": "Clear input on send", + "client_id": "Client ID", + "color": "Pick a color", + "communication": "Комуникација", + "connection_config": "Connection Config", + "connection_not_authorized": "This MQTT connection does not use any authentication.", + "invalid_topic": "Please provide a topic for the subscription", + "keep_alive": "Keep Alive", + "log": "Пријава", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "Порука", + "new": "New Subscription", + "not_connected": "Please start a MQTT connection first.", + "publish": "Публисх", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "претплатити се", + "topic": "Тема", + "topic_name": "Назив теме", + "topic_title": "Објави / Претплати се на тему", + "unsubscribe": "Откажи претплату", + "url": "УРЛ" + }, + "navigation": { + "doc": "Документи", + "graphql": "GraphQL", + "profile": "Profile", + "realtime": "Реалном времену", + "rest": "REST", + "settings": "Подешавања" + }, + "preRequest": { + "javascript_code": "ЈаваСцрипт код", + "learn": "Прочитајте документацију", + "script": "Скрипта пред-захтева", + "snippets": "Сниппетс" + }, + "profile": { + "app_settings": "App Settings", + "default_hopp_displayname": "Unnamed User", + "editor": "Editor", + "editor_description": "Editors can add, edit, and delete requests.", + "email_verification_mail": "A verification email has been sent to your email address. Please click on the link to verify your email address.", + "no_permission": "You do not have permission to perform this action.", + "owner": "Owner", + "owner_description": "Owners can add, edit, and delete requests, collections and team members.", + "roles": "Roles", + "roles_description": "Roles are used to control access to the shared collections.", + "updated": "Profile updated", + "viewer": "Viewer", + "viewer_description": "Viewers can only view and use requests." + }, + "remove": { + "star": "Уклони звездицу" + }, + "request": { + "added": "Захтев је додат", + "authorization": "Овлашћење", + "body": "Рекуест Боди", + "choose_language": "Изабери језик", + "content_type": "Тип садржаја", + "content_type_titles": { + "others": "Others", + "structured": "Structured", + "text": "Text" + }, + "different_collection": "Cannot reorder requests from different collections", + "duplicated": "Request duplicated", + "duration": "Трајање", + "enter_curl": "Унесите цУРЛ", + "generate_code": "Генериши код", + "generated_code": "Генерисани код", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "Листа заглавља", + "invalid_name": "Наведите назив захтева", + "method": "Метод", + "moved": "Request moved", + "name": "Назив захтева", + "new": "New Request", + "order_changed": "Request Order Updated", + "override": "Override", + "override_help": "Set Content-Type in Headers", + "overriden": "Overridden", + "parameter_list": "Параметри упита", + "parameters": "Параметри", + "path": "Патх", + "payload": "Корисни терет", + "query": "Упит", + "raw_body": "Сирово тело захтева", + "rename": "Rename Request", + "renamed": "Захтев је преименован", + "request_variables": "Request variables", + "run": "Трцати", + "save": "сачувати", + "save_as": "Сачувај као", + "saved": "Захтев је сачуван", + "share": "Објави", + "share_description": "Share Hoppscotch with your friends", + "share_request": "Share Request", + "stop": "Stop", + "title": "Захтев", + "type": "Врста Захтева", + "url": "УРЛ", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "Променљиве", + "view_my_links": "View my links", + "copy_link": "Копирај везу" + }, + "response": { + "audio": "Audio", + "body": "Тело за одговор", + "filter_response_body": "Filter JSON response body (uses jq syntax)", + "headers": "Заглавља", + "html": "ХТМЛ", + "image": "Слика", + "json": "ЈСОН", + "pdf": "PDF", + "preview_html": "Преглед ХТМЛ -а", + "raw": "Сирово", + "size": "Величина", + "status": "Статус", + "time": "време", + "title": "Одговор", + "video": "Video", + "waiting_for_connection": "чека везу", + "xml": "КСМЛ" + }, + "settings": { + "accent_color": "Боја акцента", + "account": "Рачун", + "account_deleted": "Your account has been deleted", + "account_description": "Прилагодите поставке налога.", + "account_email_description": "Ваша примарна адреса е -поште.", + "account_name_description": "Ово је ваше име за приказ.", + "additional": "Additional Settings", + "background": "Позадина", + "black_mode": "Црн", + "choose_language": "Изабери језик", + "dark_mode": "Дарк", + "delete_account": "Delete account", + "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", + "expand_navigation": "Expand navigation", + "experiments": "Експерименти", + "experiments_notice": "Ово је збирка експеримената на којима радимо и који би се могли показати корисним, забавним, обоје или ниједно. Нису коначни и можда нису стабилни, па ако се догоди нешто превише чудно, немојте паничарити. Само искључите опасну ствар. Шалу на страну,", + "extension_ver_not_reported": "Није пријављено", + "extension_version": "Ектенсион Версион", + "extensions": "Ектенсионс", + "extensions_use_toggle": "Користите проширење прегледача за слање захтева (ако постоје)", + "follow": "Follow Us", + "interceptor": "Пресретач", + "interceptor_description": "Средњи софтвер између апликација и АПИ -ја.", + "language": "Језик", + "light_mode": "Лигхт", + "official_proxy_hosting": "Хоппсцотцх угошћује званични проки.", + "profile": "Profile", + "profile_description": "Update your profile details", + "profile_email": "Email address", + "profile_name": "Profile name", + "proxy": "Заступник", + "proxy_url": "Проки УРЛ", + "proxy_use_toggle": "За слање захтева користите проки посреднички софтвер", + "read_the": "Прочитајте", + "reset_default": "Врати на подразумеване вредности", + "short_codes": "Short codes", + "short_codes_description": "Short codes which were created by you.", + "sidebar_on_left": "Sidebar on left", + "sync": "Синхронизујте", + "sync_collections": "Збирке", + "sync_description": "Ова подешавања се синхронизују са облаком.", + "sync_environments": "Енвиронментс", + "sync_history": "Историја", + "system_mode": "Систем", + "telemetry": "Телеметрија", + "telemetry_helps_us": "Телеметрија нам помаже да персонализујемо наше операције и пружимо вам најбоље искуство.", + "theme": "Тхеме", + "theme_description": "Прилагодите тему апликације.", + "use_experimental_url_bar": "Користите експерименталну УРЛ траку са истицањем окружења", + "user": "Корисник", + "verified_email": "Verified email", + "verify_email": "Verify email" + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Затворите тренутни мени", + "command_menu": "Мени за претрагу и команду", + "help_menu": "Мени за помоћ", + "show_all": "Пречице на тастатури", + "title": "Генерал" + }, + "miscellaneous": { + "invite": "Позовите људе у Хоппсцотцх", + "title": "Остало" + }, + "navigation": { + "back": "Вратите се на претходну страницу", + "documentation": "Идите на страницу Документација", + "forward": "Идите на следећу страницу", + "graphql": "Идите на страницу ГрапхКЛ", + "profile": "Go to Profile page", + "realtime": "Идите на страницу у реалном времену", + "rest": "Идите на страницу REST", + "settings": "Идите на страницу Подешавања", + "title": "Навигација" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "Изаберите ДЕЛЕТЕ метход", + "get_method": "Изаберите метод ГЕТ", + "head_method": "Изаберите метод ХЕАД", + "import_curl": "Import cURL", + "method": "Метод", + "next_method": "Изаберите Следећи метод", + "post_method": "Изаберите ПОСТ метод", + "previous_method": "Изаберите Претходни метод", + "put_method": "Изаберите ПУТ метод", + "rename": "Rename Request", + "reset_request": "Ресет Рекуест", + "save_request": "Save Request", + "save_to_collections": "Сачувај у збирке", + "send_request": "Пошаљите упит", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "Захтев", + "copy_request_link": "Копирајте везу захтева" + }, + "response": { + "copy": "Copy response to clipboard", + "download": "Download response as file", + "title": "Response" + }, + "theme": { + "black": "Switch theme to black mode", + "dark": "Switch theme to dark mode", + "light": "Switch theme to light mode", + "system": "Switch theme to system mode", + "title": "Theme" + } + }, + "show": { + "code": "Прикажи код", + "collection": "Expand Collection Panel", + "more": "Прикажи више", + "sidebar": "Прикажи бочну траку" + }, + "socketio": { + "communication": "Комуникација", + "connection_not_authorized": "This SocketIO connection does not use any authentication.", + "event_name": "Назив догађаја", + "events": "Догађаји", + "log": "Пријава", + "url": "УРЛ" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current team", + "edit": "Edit current team", + "invite": "Invite people to team", + "new": "Create new team", + "switch_to_personal": "Switch to your personal workspace", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "Тип догађаја", + "log": "Пријава", + "url": "УРЛ" + }, + "state": { + "bulk_mode": "Скупно уређивање", + "bulk_mode_placeholder": "Уноси су одвојени новим редом\nКључеви и вредности су одвојени:\nДодати # било ком реду који желите да додате, али га онемогућите", + "cleared": "Очишћено", + "connected": "Повезан", + "connected_to": "Повезано са {наме}", + "connecting_to": "Повезивање са {наме} ...", + "connection_error": "Failed to connect", + "connection_failed": "Connection failed", + "connection_lost": "Connection lost", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "Копирано у међуспремник", + "deleted": "Избрисан", + "deprecated": "ЗАСТАРЕЛА", + "disabled": "Онемогућено", + "disconnected": "Прекинуто", + "disconnected_from": "Прекинута је веза са именом {наме}", + "docs_generated": "Генерисана документација", + "download_failed": "Download failed", + "download_started": "Преузимање је започело", + "enabled": "Омогућено", + "file_imported": "Датотека је увезена", + "finished_in": "Завршено за {duration} мс", + "hide": "Hide", + "history_deleted": "Историја је избрисана", + "linewrap": "Омотајте линије", + "loading": "Учитавање ...", + "message_received": "Message: {message} arrived on topic: {topic}", + "mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}", + "none": "Ниједан", + "nothing_found": "Ништа није пронађено за", + "published_error": "Something went wrong while publishing msg: {topic} to topic: {message}", + "published_message": "Published message: {message} to topic: {topic}", + "reconnection_error": "Failed to reconnect", + "show": "Show", + "subscribed_failed": "Failed to subscribe to topic: {topic}", + "subscribed_success": "Successfully subscribed to topic: {topic}", + "unsubscribed_failed": "Failed to unsubscribe from topic: {topic}", + "unsubscribed_success": "Successfully unsubscribed from topic: {topic}", + "waiting_send_request": "Чека се слање захтева" + }, + "support": { + "changelog": "Прочитајте више о најновијим издањима", + "chat": "Имате питања? Ћаскајте са нама!", + "community": "Постављајте питања и помажете другима", + "documentation": "Прочитајте више о Хоппсцотцх -у", + "forum": "Постављајте питања и добијте одговоре", + "github": "Follow us on Github", + "shortcuts": "Брже прегледајте апликацију", + "title": "Подршка", + "twitter": "Пратите нас на Твиттер -у", + "team": "Ступите у контакт са тимом" + }, + "tab": { + "authorization": "Овлашћење", + "body": "Боди", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "Збирке", + "documentation": "Документација", + "duplicate": "Duplicate Tab", + "environments": "Environments", + "headers": "Заглавља", + "history": "Историја", + "mqtt": "МКТТ", + "parameters": "Параметри", + "pre_request_script": "Скрипта пред-захтева", + "queries": "Упити", + "query": "Упит", + "schema": "Schema", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Соцкет.ИО", + "sse": "ССЕ", + "tests": "Тестови", + "types": "Врсте", + "variables": "Променљиве", + "websocket": "ВебСоцкет" + }, + "team": { + "already_member": "You are already a member of this team. Contact your team owner.", + "create_new": "Направите нови тим", + "deleted": "Тим је избрисан", + "edit": "Едит Теам", + "email": "Е-маил", + "email_do_not_match": "Email doesn't match with your account details. Contact your team owner.", + "exit": "Екит Теам", + "exit_disabled": "Само власник не може изаћи из тима", + "failed_invites": "Failed invites", + "invalid_coll_id": "Invalid collection ID", + "invalid_email_format": "Формат е -поште је неважећи", + "invalid_id": "Invalid team ID. Contact your team owner.", + "invalid_invite_link": "Invalid invite link", + "invalid_invite_link_description": "The link you followed is invalid. Contact your team owner.", + "invalid_member_permission": "Молимо да члану тима дате ваљану дозволу", + "invite": "Invite", + "invite_more": "Invite more", + "invite_tooltip": "Invite people to this workspace", + "invited_to_team": "{owner} invited you to join {team}", + "join": "Invitation accepted", + "join_team": "Join {team}", + "joined_team": "You have joined {team}", + "joined_team_description": "You are now a member of this team", + "left": "Напустио си тим", + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to join a team.", + "logout_and_try_again": "Logout and sign in with another account", + "member_has_invite": "This email ID already has an invite. Contact your team owner.", + "member_not_found": "Member not found. Contact your team owner.", + "member_removed": "Корисник је уклоњен", + "member_role_updated": "Ажуриране су улоге корисника", + "members": "Чланови", + "more_members": "+{count} more", + "name_length_insufficient": "Име тима треба да има најмање 6 знакова", + "name_updated": "Team name updated", + "new": "Нев Теам", + "new_created": "Нови тим је креиран", + "new_name": "Мој нови тим", + "no_access": "Немате приступ за уређивање ових колекција", + "no_invite_found": "Invitation not found. Contact your team owner.", + "no_request_found": "Request not found.", + "not_found": "Team not found. Contact your team owner.", + "not_valid_viewer": "You are not a valid viewer. Contact your team owner.", + "parent_coll_move": "Cannot move collection to a child collection", + "pending_invites": "Pending invites", + "permissions": "Дозволе", + "same_target_destination": "Same target and destination", + "saved": "Тим је сачуван", + "select_a_team": "Select a team", + "success_invites": "Success invites", + "title": "Тимови", + "we_sent_invite_link": "We sent an invite link to all invitees!", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "Ask all invitees to check their inbox. Click on the link to join the team.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "Придружите се бета програму да бисте приступили тимовима." + }, + "team_environment": { + "deleted": "Environment Deleted", + "duplicate": "Environment Duplicated", + "not_found": "Environment not found." + }, + "test": { + "failed": "test failed", + "javascript_code": "ЈаваСцрипт код", + "learn": "Прочитајте документацију", + "passed": "test passed", + "report": "Тест извештај", + "results": "Резултати теста", + "script": "Скрипта", + "snippets": "Сниппетс" + }, + "websocket": { + "communication": "Комуникација", + "log": "Пријава", + "message": "Порука", + "protocols": "Протоколи", + "url": "УРЛ" + }, + "workspace": { + "change": "Change workspace", + "personal": "My Workspace", + "other_workspaces": "My Workspaces", + "team": "Team Workspace", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "Actions", + "created_on": "Created on", + "deleted": "Shortcode deleted", + "method": "Method", + "not_found": "Shortcode not found", + "short_code": "Short code", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/sv.json b/packages/hoppscotch-common/locales/sv.json new file mode 100644 index 0000000..143e06a --- /dev/null +++ b/packages/hoppscotch-common/locales/sv.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Add", + "autoscroll": "Autoscroll", + "cancel": "Annullera", + "choose_file": "Välj en fil", + "clear": "Klar", + "clear_all": "Rensa alla", + "clear_history": "Clear all History", + "close": "Close", + "connect": "Ansluta", + "connecting": "Connecting", + "copy": "Kopiera", + "create": "Create", + "delete": "Radera", + "disconnect": "Koppla ifrån", + "dismiss": "Avfärda", + "dont_save": "Don't save", + "download_file": "Nedladdning fil", + "drag_to_reorder": "Drag to reorder", + "duplicate": "Duplicate", + "edit": "Redigera", + "filter": "Filter", + "go_back": "Gå tillbaka", + "go_forward": "Go forward", + "group_by": "Group by", + "hide_secret": "Hide secret", + "label": "Märka", + "learn_more": "Läs mer", + "download_here": "Download here", + "less": "Less", + "more": "Mer", + "new": "Ny", + "no": "Nej", + "open_workspace": "Open workspace", + "paste": "Paste", + "prettify": "Försköna", + "properties": "Properties", + "remove": "Avlägsna", + "rename": "Rename", + "restore": "Återställ", + "save": "Spara", + "scroll_to_bottom": "Scroll to bottom", + "scroll_to_top": "Scroll to top", + "search": "Sök", + "send": "Skicka", + "share": "Share", + "show_secret": "Show secret", + "start": "Start", + "starting": "Starting", + "stop": "Sluta", + "to_close": "to close", + "to_navigate": "to navigate", + "to_select": "to select", + "turn_off": "Stäng av", + "turn_on": "Sätta på", + "undo": "Ångra", + "yes": "Ja" + }, + "add": { + "new": "Lägg till ny", + "star": "Lägg till stjärna" + }, + "app": { + "chat_with_us": "chatta med oss", + "contact_us": "Kontakta oss", + "cookies": "Cookies", + "copy": "Kopiera", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Copy User Auth Token", + "developer_option": "Developer options", + "developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.", + "discord": "Discord", + "documentation": "Dokumentation", + "github": "GitHub", + "help": "Hjälp, feedback och dokumentation", + "home": "Hem", + "invite": "Inbjudan", + "invite_description": "I Hoppscotch designade vi ett enkelt och intuitivt gränssnitt för att skapa och hantera dina API: er. Hoppscotch är ett verktyg som hjälper dig att bygga, testa, dokumentera och dela dina API: er.", + "invite_your_friends": "Bjud in dina vänner", + "join_discord_community": "Gå med i vår Discord -community", + "keyboard_shortcuts": "Tangentbordsgenvägar", + "name": "Hoppscotch", + "new_version_found": "Ny version hittades. Uppdatera för att uppdatera.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Options", + "proxy_privacy_policy": "Sekretesspolicy för proxy", + "reload": "Ladda om", + "search": "Sök", + "share": "Dela med sig", + "shortcuts": "Genvägar", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "Strålkastare", + "status": "Status", + "status_description": "Check the status of the website", + "terms_and_privacy": "Villkor och sekretess", + "twitter": "Twitter", + "type_a_command_search": "Skriv ett kommando eller sök ...", + "we_use_cookies": "Vi använder cookies", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "Vad är nytt?", + "see_whats_new": "See what’s new", + "wiki": "Wiki" + }, + "auth": { + "account_exists": "Kontot finns med olika uppgifter - Logga in för att länka båda kontona", + "all_sign_in_options": "Alla inloggningsalternativ", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "Fortsätt med e -post", + "continue_with_github": "Fortsätt med GitHub", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Fortsätt med Google", + "continue_with_microsoft": "Continue with Microsoft", + "email": "E-post", + "logged_out": "Utloggad", + "login": "Logga in", + "login_success": "Loggade in", + "login_to_hoppscotch": "Logga in på Hoppscotch", + "logout": "Logga ut", + "re_enter_email": "Ange e-post igen", + "send_magic_link": "Skicka en magisk länk", + "sync": "Synkronisera", + "we_sent_magic_link": "Vi skickade en magisk länk till dig!", + "we_sent_magic_link_description": "Kontrollera din inkorg - vi skickade ett e -postmeddelande till {email}. Den innehåller en magisk länk som loggar in dig." + }, + "authorization": { + "generate_token": "Generera Token", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "Inkludera i URL", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "Lära sig hur", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "Pass by", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "Lösenord", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "Tecken", + "type": "Godkännande typ", + "username": "Användarnamn" + }, + "collection": { + "created": "Samlingen skapad", + "different_parent": "Cannot reorder collection with different parent", + "edit": "Redigera samling", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "Ange ett giltigt namn för samlingen", + "invalid_root_move": "Collection already in the root", + "moved": "Moved Successfully", + "my_collections": "Mina samlingar", + "name": "Min nya samling", + "name_length_insufficient": "Collection name should be at least 3 characters long", + "new": "Ny kollektion", + "order_changed": "Collection Order Updated", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "Samling bytt namn", + "request_in_use": "Request in use", + "save_as": "Spara som", + "save_to_collection": "Save to Collection", + "select": "Välj en samling", + "select_location": "Välj plats", + "details": "Details", + "select_team": "Välj ett lag", + "team_collections": "Lagsamlingar" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "exit_team": "Are you sure you want to leave this team?", + "logout": "Är du säker på att du vill logga ut?", + "remove_collection": "Är du säker på att du vill radera denna samling permanent?", + "remove_environment": "Är du säker på att du vill ta bort den här miljön permanent?", + "remove_folder": "Är du säker på att du vill ta bort den här mappen permanent?", + "remove_history": "Är du säker på att du vill radera all historik permanent?", + "remove_request": "Är du säker på att du vill radera denna begäran permanent?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "Är du säker på att du vill ta bort det här laget?", + "remove_telemetry": "Är du säker på att du vill välja bort telemetri?", + "request_change": "Are you sure you want to discard current request, unsaved changes will be lost.", + "save_unsaved_tab": "Do you want to save changes made in this tab?", + "sync": "Är du säker på att du vill synkronisera den här arbetsytan?", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "Rubrik {count}", + "message": "Meddelande {count}", + "parameter": "Parameter {count}", + "protocol": "Protokoll {count}", + "value": "Värde {count}", + "variable": "Variabel {count}" + }, + "documentation": { + "generate": "Skapa dokumentation", + "generate_message": "Importera en Hoppscotch-samling för att generera API-dokumentation när du är på språng." + }, + "empty": { + "authorization": "Denna begäran använder inget tillstånd", + "body": "Denna begäran har ingen kropp", + "collection": "Samlingen är tom", + "collections": "Samlingar är tomma", + "documentation": "Connect to a GraphQL endpoint to view documentation", + "endpoint": "Endpoint cannot be empty", + "environments": "Miljöer är tomma", + "folder": "Mappen är tom", + "headers": "Denna begäran har inga rubriker", + "history": "Historien är tom", + "invites": "Invite list is empty", + "members": "Teamet är tomt", + "parameters": "Denna begäran har inga parametrar", + "pending_invites": "There are no pending invites for this team", + "profile": "Login to view your profile", + "protocols": "Protokoll är tomma", + "request_variables": "This request does not have any request variables", + "schema": "Anslut till en GraphQL -slutpunkt", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "Subscriptions are empty", + "team_name": "Lagets namn är tomt", + "teams": "Lag är tomma", + "tests": "Det finns inga tester för denna begäran", + "access_tokens": "Access tokens are empty", + "shortcodes": "Shortcodes are empty" + }, + "environment": { + "add_to_global": "Add to Global", + "added": "Environment addition", + "create_new": "Skapa ny miljö", + "created": "Environment created", + "deleted": "Environment deletion", + "duplicated": "Environment duplicated", + "edit": "Redigera miljö", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create a environment", + "invalid_name": "Ange ett giltigt namn på miljön", + "list": "Environment variables", + "my_environments": "My Environments", + "name": "Name", + "nested_overflow": "nested environment variables are limited to 10 levels", + "new": "Ny miljö", + "no_active_environment": "No active environment", + "no_environment": "Ingen miljö", + "no_environment_description": "No environments were selected. Choose what to do with the following variables.", + "quick_peek": "Environment Quick Peek", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "Välj miljö", + "set": "Set environment", + "set_as_environment": "Set as environment", + "team_environments": "Team Environments", + "title": "Miljöer", + "updated": "Environment updation", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Variabel lista", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "Den här webbläsaren verkar inte ha stöd för Server Sent Events.", + "check_console_details": "Kontrollera konsolloggen för mer information.", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "cURL är inte korrekt formaterad", + "danger_zone": "Danger zone", + "delete_account": "Your account is currently an owner in these teams:", + "delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "Tom förfrågningsnamn", + "f12_details": "(F12 för detaljer)", + "gql_prettify_invalid_query": "Det gick inte att pryda en ogiltig fråga, lösa frågesyntaxfel och försök igen", + "incomplete_config_urls": "Incomplete configuration URLs", + "incorrect_email": "Incorrect email", + "invalid_link": "Invalid link", + "invalid_link_description": "The link you clicked is invalid or expired.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "Invalid JSON", + "json_prettify_invalid_body": "Det gick inte att pryda en ogiltig kropp, lösa json -syntaxfel och försök igen", + "network_error": "There seems to be a network error. Please try again.", + "network_fail": "Det gick inte att skicka förfrågan", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "Ingen varaktighet", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "No matches found", + "page_not_found": "This page could not be found", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "Det gick inte att köra skriptet för förhandsbegäran", + "something_went_wrong": "Något gick fel", + "post_request_script_fail": "Could not execute post-request script", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "Exportera som JSON", + "create_secret_gist": "Skapa hemlig Gist", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "Logga in med GitHub för att skapa hemlig information", + "title": "Export", + "success": "Successfully exported", + "gist_created": "Gist skapad" + }, + "filter": { + "all": "All", + "none": "None", + "starred": "Starred" + }, + "folder": { + "created": "Mapp skapad", + "edit": "Redigera mapp", + "invalid_name": "Ange ett namn på mappen", + "name_length_insufficient": "Folder name should be at least 3 characters long", + "new": "Ny mapp", + "renamed": "Mappen har bytt namn" + }, + "graphql": { + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "mutations": "Mutationer", + "schema": "Schema", + "subscriptions": "Prenumerationer", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "Time", + "url": "URL" + }, + "header": { + "install_pwa": "Installera app", + "login": "Logga in", + "save_workspace": "Spara min arbetsyta" + }, + "helpers": { + "authorization": "Auktoriseringsrubriken genereras automatiskt när du skickar begäran.", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "Skapa dokumentation först", + "network_fail": "Det gick inte att nå API -slutpunkten. Kontrollera din nätverksanslutning och försök igen.", + "offline": "Du verkar vara offline. Data i denna arbetsyta är kanske inte uppdaterad.", + "offline_short": "Du verkar vara offline.", + "post_request_tests": "Testskript skrivs i JavaScript och körs efter att svaret har mottagits.", + "pre_request_script": "Skript för förfrågan skrivs i JavaScript och körs innan begäran skickas.", + "script_fail": "Det verkar finnas ett fel i skriptet för förhandsbegäran. Kontrollera felet nedan och fixa skriptet därefter.", + "post_request_script_fail": "There seems to be an error with test script. Please fix the errors and run tests again", + "post_request_script": "Skriv ett testskript för att automatisera felsökning." + }, + "hide": { + "collection": "Collapse Collection Panel", + "more": "Dölj mer", + "preview": "Dölj förhandsgranskning", + "sidebar": "Dölj sidofältet" + }, + "import": { + "collections": "Importera samlingar", + "curl": "Importera cURL", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "Importen misslyckades", + "from_file": "Import from File", + "from_gist": "Importera från Gist", + "from_gist_description": "Import from Gist URL", + "from_insomnia": "Import from Insomnia", + "from_insomnia_description": "Import from Insomnia collection", + "from_json": "Import from Hoppscotch", + "from_json_description": "Import from Hoppscotch collection file", + "from_my_collections": "Importera från Mina samlingar", + "from_my_collections_description": "Import from My Collections file", + "from_openapi": "Import from OpenAPI", + "from_openapi_description": "Import from OpenAPI specification file (YML/JSON)", + "from_postman": "Import from Postman", + "from_postman_description": "Import from Postman collection", + "from_url": "Import from URL", + "gist_url": "Ange Gist URL", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "Couldn't get data from the url", + "import_from_url_invalid_file_format": "Error while importing collections", + "import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Collections Imported", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Import collections from a Hoppscotch Collections JSON file", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "Importera", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled." + } + }, + "layout": { + "collapse_collection": "Collapse or Expand Collections", + "collapse_sidebar": "Collapse or Expand the sidebar", + "column": "Vertical layout", + "name": "Layout", + "row": "Horizontal layout" + }, + "modal": { + "close_unsaved_tab": "You have unsaved changes", + "collections": "Samlingar", + "confirm": "Bekräfta", + "customize_request": "Customize Request", + "edit_request": "Redigera begäran", + "import_export": "Import Export", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "You are already subscribed to this topic.", + "clean_session": "Clean Session", + "clear_input": "Clear input", + "clear_input_on_send": "Clear input on send", + "client_id": "Client ID", + "color": "Pick a color", + "communication": "Kommunikation", + "connection_config": "Connection Config", + "connection_not_authorized": "This MQTT connection does not use any authentication.", + "invalid_topic": "Please provide a topic for the subscription", + "keep_alive": "Keep Alive", + "log": "Logga", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "Meddelande", + "new": "New Subscription", + "not_connected": "Please start a MQTT connection first.", + "publish": "Publicera", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Prenumerera", + "topic": "Ämne", + "topic_name": "Ämnesnamn", + "topic_title": "Publicera / prenumerera ämne", + "unsubscribe": "Säga upp", + "url": "URL" + }, + "navigation": { + "doc": "Dokument", + "graphql": "GraphQL", + "profile": "Profile", + "realtime": "Realtid", + "rest": "REST", + "settings": "inställningar" + }, + "preRequest": { + "javascript_code": "JavaScript -kod", + "learn": "Läs dokumentation", + "script": "Skript för förfrågan", + "snippets": "Utdrag" + }, + "profile": { + "app_settings": "App Settings", + "default_hopp_displayname": "Unnamed User", + "editor": "Editor", + "editor_description": "Editors can add, edit, and delete requests.", + "email_verification_mail": "A verification email has been sent to your email address. Please click on the link to verify your email address.", + "no_permission": "You do not have permission to perform this action.", + "owner": "Owner", + "owner_description": "Owners can add, edit, and delete requests, collections and team members.", + "roles": "Roles", + "roles_description": "Roles are used to control access to the shared collections.", + "updated": "Profile updated", + "viewer": "Viewer", + "viewer_description": "Viewers can only view and use requests." + }, + "remove": { + "star": "Ta bort stjärnan" + }, + "request": { + "added": "Begäran har lagts till", + "authorization": "Tillstånd", + "body": "Begär organ", + "choose_language": "Välj språk", + "content_type": "Innehållstyp", + "content_type_titles": { + "others": "Others", + "structured": "Structured", + "text": "Text" + }, + "different_collection": "Cannot reorder requests from different collections", + "duplicated": "Request duplicated", + "duration": "Varaktighet", + "enter_curl": "Ange cURL", + "generate_code": "Generera kod", + "generated_code": "Genererad kod", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "Rubriklista", + "invalid_name": "Ange ett namn på begäran", + "method": "Metod", + "moved": "Request moved", + "name": "Begär namn", + "new": "New Request", + "order_changed": "Request Order Updated", + "override": "Override", + "override_help": "Set Content-Type in Headers", + "overriden": "Overridden", + "parameter_list": "Frågeparametrar", + "parameters": "Parametrar", + "path": "Väg", + "payload": "Nyttolast", + "query": "Fråga", + "raw_body": "Raw Request Body", + "rename": "Rename Request", + "renamed": "Begäran bytt namn", + "request_variables": "Request variables", + "run": "Springa", + "save": "Spara", + "save_as": "Spara som", + "saved": "Begäran sparad", + "share": "Dela med sig", + "share_description": "Share Hoppscotch with your friends", + "share_request": "Share Request", + "stop": "Stop", + "title": "Begäran", + "type": "Typ av förfrågan", + "url": "URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "Variabler", + "view_my_links": "View my links", + "copy_link": "Kopiera länk" + }, + "response": { + "audio": "Audio", + "body": "Svarskommitté", + "filter_response_body": "Filter JSON response body (uses jq syntax)", + "headers": "Rubriker", + "html": "HTML", + "image": "Bild", + "json": "JSON", + "pdf": "PDF", + "preview_html": "Förhandsgranska HTML", + "raw": "Rå", + "size": "Storlek", + "status": "Status", + "time": "Tid", + "title": "Svar", + "video": "Video", + "waiting_for_connection": "väntar på anslutning", + "xml": "XML" + }, + "settings": { + "accent_color": "Accentfärg", + "account": "konto", + "account_deleted": "Your account has been deleted", + "account_description": "Anpassa dina kontoinställningar.", + "account_email_description": "Din primära e -postadress.", + "account_name_description": "Detta är ditt visningsnamn.", + "additional": "Additional Settings", + "background": "Bakgrund", + "black_mode": "Svart", + "choose_language": "Välj språk", + "dark_mode": "Mörk", + "delete_account": "Delete account", + "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", + "expand_navigation": "Expand navigation", + "experiments": "Experiment", + "experiments_notice": "Det här är en samling experiment vi arbetar med som kan visa sig vara användbara, roliga, båda eller ingen av dem. De är inte slutgiltiga och kanske inte är stabila, så om inget alltför konstigt händer, var inte rädd. Stäng bara av det jävla. Skämt åt sidan,", + "extension_ver_not_reported": "Inte raporterad", + "extension_version": "Tilläggsversion", + "extensions": "Tillägg", + "extensions_use_toggle": "Använd webbläsartillägget för att skicka förfrågningar (om sådana finns)", + "follow": "Follow Us", + "interceptor": "Jaktplan", + "interceptor_description": "Mellanprogram mellan applikation och API: er.", + "language": "Språk", + "light_mode": "Ljus", + "official_proxy_hosting": "Officiell proxy är värd av Hoppscotch.", + "profile": "Profile", + "profile_description": "Update your profile details", + "profile_email": "Email address", + "profile_name": "Profile name", + "proxy": "Ombud", + "proxy_url": "Proxy -URL", + "proxy_use_toggle": "Använd proxy -mellanprogrammet för att skicka förfrågningar", + "read_the": "Läs", + "reset_default": "Återställ till standard", + "short_codes": "Short codes", + "short_codes_description": "Short codes which were created by you.", + "sidebar_on_left": "Sidebar on left", + "sync": "Synkronisera", + "sync_collections": "Samlingar", + "sync_description": "Dessa inställningar synkroniseras till moln.", + "sync_environments": "Miljöer", + "sync_history": "Historia", + "system_mode": "Systemet", + "telemetry": "Telemetri", + "telemetry_helps_us": "Telemetri hjälper oss att anpassa vår verksamhet och leverera den bästa upplevelsen till dig.", + "theme": "Tema", + "theme_description": "Anpassa ditt applikationstema.", + "use_experimental_url_bar": "Använd experimentell URL -fält med miljömarkering", + "user": "Användare", + "verified_email": "Verified email", + "verify_email": "Verify email" + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Stäng aktuell meny", + "command_menu": "Sök- och kommandomeny", + "help_menu": "Hjälp -meny", + "show_all": "Tangentbordsgenvägar", + "title": "Allmän" + }, + "miscellaneous": { + "invite": "Bjud in människor till Hoppscotch", + "title": "Diverse" + }, + "navigation": { + "back": "Gå tillbaka till föregående sida", + "documentation": "Gå till dokumentationssidan", + "forward": "Gå vidare till nästa sida", + "graphql": "Gå till GraphQL -sidan", + "profile": "Go to Profile page", + "realtime": "Gå till realtidssidan", + "rest": "Gå till REST -sidan", + "settings": "Gå till sidan Inställningar", + "title": "Navigering" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "Välj DELETE method", + "get_method": "Välj GET -metod", + "head_method": "Välj HEAD -metod", + "import_curl": "Import cURL", + "method": "Metod", + "next_method": "Välj Nästa metod", + "post_method": "Välj POST -metod", + "previous_method": "Välj föregående metod", + "put_method": "Välj PUT -metod", + "rename": "Rename Request", + "reset_request": "Återställ begäran", + "save_request": "Save Request", + "save_to_collections": "Spara i samlingar", + "send_request": "Skicka förfrågan", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "Begäran", + "copy_request_link": "Kopiera begäran länk" + }, + "response": { + "copy": "Copy response to clipboard", + "download": "Download response as file", + "title": "Response" + }, + "theme": { + "black": "Switch theme to black mode", + "dark": "Switch theme to dark mode", + "light": "Switch theme to light mode", + "system": "Switch theme to system mode", + "title": "Theme" + } + }, + "show": { + "code": "Visa kod", + "collection": "Expand Collection Panel", + "more": "Visa mer", + "sidebar": "Visa sidofältet" + }, + "socketio": { + "communication": "Kommunikation", + "connection_not_authorized": "This SocketIO connection does not use any authentication.", + "event_name": "Event namn", + "events": "evenemang", + "log": "Logga", + "url": "URL" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current team", + "edit": "Edit current team", + "invite": "Invite people to team", + "new": "Create new team", + "switch_to_personal": "Switch to your personal workspace", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "Event typ", + "log": "Logga", + "url": "URL" + }, + "state": { + "bulk_mode": "Massredigering", + "bulk_mode_placeholder": "Posterna separeras med ny rad\nNycklar och värden separeras med:\nFörbered # till vilken rad du vill lägga till men behåll inaktiverad", + "cleared": "Rensat", + "connected": "Ansluten", + "connected_to": "Ansluten till {name}", + "connecting_to": "Ansluter till {name} ...", + "connection_error": "Failed to connect", + "connection_failed": "Connection failed", + "connection_lost": "Connection lost", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "Kopierat till urklipp", + "deleted": "raderade", + "deprecated": "DEPRECATED", + "disabled": "Inaktiverad", + "disconnected": "Osammanhängande", + "disconnected_from": "Kopplad från {name}", + "docs_generated": "Dokumentation genererad", + "download_failed": "Download failed", + "download_started": "Nedladdningen startade", + "enabled": "Aktiverad", + "file_imported": "Filen har importerats", + "finished_in": "Avslutad på {duration} ms", + "hide": "Hide", + "history_deleted": "Historik raderad", + "linewrap": "Slå in linjer", + "loading": "Läser in...", + "message_received": "Message: {message} arrived on topic: {topic}", + "mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}", + "none": "Ingen", + "nothing_found": "Inget hittat för", + "published_error": "Something went wrong while publishing msg: {topic} to topic: {message}", + "published_message": "Published message: {message} to topic: {topic}", + "reconnection_error": "Failed to reconnect", + "show": "Show", + "subscribed_failed": "Failed to subscribe to topic: {topic}", + "subscribed_success": "Successfully subscribed to topic: {topic}", + "unsubscribed_failed": "Failed to unsubscribe from topic: {topic}", + "unsubscribed_success": "Successfully unsubscribed from topic: {topic}", + "waiting_send_request": "Väntar på att skicka förfrågan" + }, + "support": { + "changelog": "Läs mer om de senaste utgåvorna", + "chat": "Frågor? Chatta med oss!", + "community": "Ställ frågor och hjälp andra", + "documentation": "Läs mer om Hoppscotch", + "forum": "Ställ frågor och få svar", + "github": "Follow us on Github", + "shortcuts": "Bläddra snabbare i appen", + "title": "Stöd", + "twitter": "Följ oss på Twitter", + "team": "Ta kontakt med laget" + }, + "tab": { + "authorization": "Tillstånd", + "body": "Kropp", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "Samlingar", + "documentation": "Dokumentation", + "duplicate": "Duplicate Tab", + "environments": "Environments", + "headers": "Rubriker", + "history": "Historia", + "mqtt": "MQTT", + "parameters": "Parametrar", + "pre_request_script": "Skript i förväg", + "queries": "Frågor", + "query": "Fråga", + "schema": "Schema", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Tester", + "types": "Typer", + "variables": "Variabler", + "websocket": "WebSocket" + }, + "team": { + "already_member": "You are already a member of this team. Contact your team owner.", + "create_new": "Skapa nytt team", + "deleted": "Teamet raderat", + "edit": "Redigera team", + "email": "E-post", + "email_do_not_match": "Email doesn't match with your account details. Contact your team owner.", + "exit": "Avsluta Team", + "exit_disabled": "Endast ägaren kan inte lämna laget", + "failed_invites": "Failed invites", + "invalid_coll_id": "Invalid collection ID", + "invalid_email_format": "E -postformatet är ogiltigt", + "invalid_id": "Invalid team ID. Contact your team owner.", + "invalid_invite_link": "Invalid invite link", + "invalid_invite_link_description": "The link you followed is invalid. Contact your team owner.", + "invalid_member_permission": "Ange ett giltigt tillstånd till teammedlemmen", + "invite": "Invite", + "invite_more": "Invite more", + "invite_tooltip": "Invite people to this workspace", + "invited_to_team": "{owner} invited you to join {team}", + "join": "Invitation accepted", + "join_team": "Join {team}", + "joined_team": "You have joined {team}", + "joined_team_description": "You are now a member of this team", + "left": "Du lämnade laget", + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to join a team.", + "logout_and_try_again": "Logout and sign in with another account", + "member_has_invite": "This email ID already has an invite. Contact your team owner.", + "member_not_found": "Member not found. Contact your team owner.", + "member_removed": "Användaren har tagits bort", + "member_role_updated": "Användarroller uppdaterade", + "members": "Medlemmar", + "more_members": "+{count} more", + "name_length_insufficient": "Lagets namn bör vara minst 6 tecken långt", + "name_updated": "Team name updated", + "new": "Nytt lag", + "new_created": "Nytt team skapat", + "new_name": "Mitt nya team", + "no_access": "Du har inte redigeringsåtkomst till dessa samlingar", + "no_invite_found": "Invitation not found. Contact your team owner.", + "no_request_found": "Request not found.", + "not_found": "Team not found. Contact your team owner.", + "not_valid_viewer": "You are not a valid viewer. Contact your team owner.", + "parent_coll_move": "Cannot move collection to a child collection", + "pending_invites": "Pending invites", + "permissions": "Behörigheter", + "same_target_destination": "Same target and destination", + "saved": "Lag räddade", + "select_a_team": "Select a team", + "success_invites": "Success invites", + "title": "Lag", + "we_sent_invite_link": "We sent an invite link to all invitees!", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "Ask all invitees to check their inbox. Click on the link to join the team.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "Gå med i betaprogrammet för att komma åt team." + }, + "team_environment": { + "deleted": "Environment Deleted", + "duplicate": "Environment Duplicated", + "not_found": "Environment not found." + }, + "test": { + "failed": "test failed", + "javascript_code": "JavaScript -kod", + "learn": "Läs dokumentation", + "passed": "test passed", + "report": "Testrapport", + "results": "Testresultat", + "script": "Manus", + "snippets": "Utdrag" + }, + "websocket": { + "communication": "Kommunikation", + "log": "Logga", + "message": "Meddelande", + "protocols": "Protokoll", + "url": "URL" + }, + "workspace": { + "change": "Change workspace", + "personal": "My Workspace", + "other_workspaces": "My Workspaces", + "team": "Team Workspace", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "Actions", + "created_on": "Created on", + "deleted": "Shortcode deleted", + "method": "Method", + "not_found": "Shortcode not found", + "short_code": "Short code", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/th.json b/packages/hoppscotch-common/locales/th.json new file mode 100644 index 0000000..e014577 --- /dev/null +++ b/packages/hoppscotch-common/locales/th.json @@ -0,0 +1,2378 @@ +{ + "action": { + "add": "เพิ่ม", + "autoscroll": "เลื่อนอัตโนมัติ", + "cancel": "ยกเลิก", + "choose_file": "เลือกไฟล์", + "choose_workspace": "เลือกพื้นที่ทำงาน", + "choose_collection": "เลือกคอลเลกชัน", + "select_workspace": "เลือกพื้นที่ทำงาน", + "clear": "ล้าง", + "clear_all": "ล้างทั้งหมด", + "clear_cache": "ล้างแคช", + "clear_history": "ล้างประวัติทั้งหมด", + "clear_unpinned": "ล้างรายการที่ไม่ได้ปักหมุด", + "clear_response": "ล้างการตอบกลับ", + "close": "ปิด", + "confirm": "ยืนยัน", + "connect": "เชื่อมต่อ", + "connecting": "กำลังเชื่อมต่อ", + "copy": "คัดลอก", + "create": "สร้าง", + "delete": "ลบ", + "disconnect": "ตัดการเชื่อมต่อ", + "dismiss": "ปิด", + "done": "เสร็จสิ้น", + "dont_save": "ไม่บันทึก", + "download_file": "ดาวน์โหลดไฟล์", + "download_test_report": "ดาวน์โหลดรายงานการทดสอบ", + "drag_to_reorder": "ลากเพื่อจัดเรียงใหม่", + "duplicate": "ทำสำเนา", + "edit": "แก้ไข", + "filter": "กรอง", + "go_back": "ย้อนกลับ", + "go_forward": "ไปข้างหน้า", + "group_by": "จัดกลุ่มตาม", + "hide_secret": "ซ่อนข้อมูลลับ", + "label": "ป้ายกำกับ", + "learn_more": "เรียนรู้เพิ่มเติม", + "download_here": "ดาวน์โหลดที่นี่", + "less": "น้อยลง", + "more": "เพิ่มเติม", + "new": "ใหม่", + "no": "ไม่", + "open": "เปิด", + "open_workspace": "เปิดพื้นที่ทำงาน", + "paste": "วาง", + "prettify": "จัดรูปแบบให้สวยงาม", + "properties": "คุณสมบัติ", + "register": "ลงทะเบียน", + "remove": "นำออก", + "remove_instance": "นำอินสแตนซ์ออก", + "rename": "เปลี่ยนชื่อ", + "restore": "กู้คืน", + "retry": "ลองใหม่", + "save": "บันทึก", + "save_as_example": "บันทึกเป็นตัวอย่าง", + "add_example": "เพิ่มตัวอย่าง", + "invalid_request": "ข้อมูลคำขอไม่ถูกต้อง", + "scroll_to_bottom": "เลื่อนไปด้านล่างสุด", + "scroll_to_top": "เลื่อนไปด้านบนสุด", + "search": "ค้นหา", + "send": "ส่ง", + "share": "แชร์", + "show_secret": "แสดงข้อมูลลับ", + "sort": "จัดเรียง", + "start": "เริ่ม", + "starting": "กำลังเริ่ม", + "stop": "หยุด", + "to_close": "เพื่อปิด", + "to_navigate": "เพื่อนำทาง", + "to_select": "เพื่อเลือก", + "turn_off": "ปิด", + "turn_on": "เปิด", + "undo": "เลิกทำ", + "unpublish": "ยกเลิกการเผยแพร่", + "yes": "ใช่", + "verify": "ยืนยัน", + "enable": "เปิดใช้งาน", + "disable": "ปิดใช้งาน", + "assign": "มอบหมาย" + }, + "activity_logs": { + "ACTIVITY_LOG_DELETE": "ลบบันทึกกิจกรรมแล้ว", + "WORKSPACE_CREATE": "สร้างพื้นที่ทำงานใหม่ {name}", + "WORKSPACE_RENAME": "เปลี่ยนชื่อพื้นที่ทำงานจาก {old_name} เป็น {new_name}", + "WORKSPACE_USER_ADD": "{user} ถูกเพิ่มเข้าพื้นที่ทำงานในฐานะ {role}", + "WORKSPACE_USER_INVITE": "{user} ได้รับเชิญโดย {inviteeEmail} ในฐานะ {role}", + "WORKSPACE_USER_INVITE_REVOKE": "เพิกถอนคำเชิญของ {inviteeEmail} ในฐานะ {inviteeRole}", + "WORKSPACE_USER_INVITE_ACCEPT": "{inviteeEmail} ยอมรับคำเชิญในฐานะ {inviteeRole}", + "WORKSPACE_USER_REMOVE": "{user} ถูกนำออกจากพื้นที่ทำงาน", + "WORKSPACE_USER_ROLE_UPDATE": "บทบาทของ {user} ถูกอัปเดตจาก {old_role} เป็น {new_role}", + "COLLECTION_CREATE": "สร้างคอลเลกชันใหม่ {title}", + "COLLECTION_RENAME": "เปลี่ยนชื่อคอลเลกชันจาก {old_title} เป็น {new_title}", + "COLLECTION_IMPORT": "นำเข้า {count} คอลเลกชัน", + "COLLECTION_DELETE": "ลบคอลเลกชัน {title}", + "COLLECTION_DUPLICATE": "ทำสำเนาคอลเลกชัน {parentTitle}", + "REQUEST_CREATE": "สร้างคำขอใหม่ {title}", + "REQUEST_RENAME": "เปลี่ยนชื่อคำขอจาก {old_title} เป็น {new_title}", + "REQUEST_DELETE": "ลบคำขอ {title}" + }, + "add": { + "new": "เพิ่มใหม่", + "star": "เพิ่มดาว" + }, + "agent": { + "registration_instruction": "โปรดลงทะเบียน Hoppscotch Agent กับเว็บไคลเอนต์ของคุณเพื่อดำเนินการต่อ", + "enter_otp_instruction": "โปรดกรอกรหัสยืนยันที่สร้างโดย Hoppscotch Agent และดำเนินการลงทะเบียนให้เสร็จสมบูรณ์", + "otp_label": "รหัสยืนยัน", + "processing": "กำลังประมวลผลคำขอของคุณ...", + "not_running_title": "ไม่พบ Agent", + "registration_title": "การลงทะเบียน Agent", + "verify_ssl_certs": "ยืนยันใบรับรอง SSL", + "ca_certs": "ใบรับรอง CA", + "client_certs": "ใบรับรองไคลเอนต์", + "use_http_proxy": "ใช้ HTTP Proxy", + "proxy_capabilities": "Hoppscotch Agent รองรับพร็อกซี HTTP/HTTPS/SOCKS พร้อมด้วย NTLM และ Basic Auth ในพร็อกซีเหล่านั้น ใส่ชื่อผู้ใช้และรหัสผ่านสำหรับการยืนยันตัวตนของพร็อกซีไว้ใน URL โดยตรง", + "add_cert_file": "เพิ่มไฟล์ใบรับรอง", + "add_client_cert": "เพิ่มใบรับรองไคลเอนต์", + "add_key_file": "เพิ่มไฟล์คีย์", + "domain": "โดเมน", + "cert": "ใบรับรอง", + "key": "คีย์", + "pfx_or_pkcs": "PFX/PKCS12", + "pfx_or_pkcs_file": "ไฟล์ PFX/PKCS12", + "add_pfx_or_pkcs_file": "เพิ่มไฟล์ PFX/PKCS12" + }, + "app": { + "additional_links": { + "macOS": "macOS", + "windows": "Windows", + "linux": "Linux", + "web_app": "เว็บแอป", + "cli": "CLI" + }, + "downloads": "ดาวน์โหลด", + "chat_with_us": "แชทกับเรา", + "contact_us": "ติดต่อเรา", + "cookies": "คุกกี้", + "copy": "คัดลอก", + "copy_interface_type": "คัดลอกประเภทอินเทอร์เฟซ", + "copy_user_id": "คัดลอกโทเค็นการให้สิทธิ์ของผู้ใช้", + "developer_option": "ตัวเลือกสำหรับนักพัฒนา", + "developer_option_description": "เครื่องมือสำหรับนักพัฒนาที่ช่วยในการพัฒนาและบำรุงรักษา Hoppscotch", + "discord": "Discord", + "documentation": "เอกสารประกอบ", + "github": "GitHub", + "help": "ความช่วยเหลือและข้อเสนอแนะ", + "home": "หน้าแรก", + "invite": "เชิญ", + "invite_description": "Hoppscotch เป็นระบบนิเวศการพัฒนา API แบบโอเพนซอร์ส เราออกแบบอินเทอร์เฟซที่เรียบง่ายและใช้งานง่ายสำหรับการสร้างและจัดการ API ของคุณ Hoppscotch เป็นเครื่องมือที่ช่วยให้คุณสร้าง ทดสอบ จัดทำเอกสาร และแชร์ API ของคุณ", + "invite_your_friends": "เชิญเพื่อนของคุณ", + "join_discord_community": "เข้าร่วมชุมชน Discord ของเรา", + "keyboard_shortcuts": "ทางลัดแป้นพิมพ์", + "name": "Hoppscotch", + "new_version_found": "พบเวอร์ชันใหม่ รีเฟรชเพื่ออัปเดต", + "open_in_hoppscotch": "เปิดใน Hoppscotch", + "options": "ตัวเลือก", + "powered_by": "ขับเคลื่อนโดย Hoppscotch", + "proxy_privacy_policy": "นโยบายความเป็นส่วนตัวของพร็อกซี", + "reload": "โหลดใหม่", + "search": "ค้นหาและคำสั่ง", + "share": "แชร์", + "shortcuts": "ทางลัด", + "social_description": "ติดตามเราบนโซเชียลมีเดียเพื่อรับข่าวสาร อัปเดต และการเปิดตัวล่าสุด", + "social_links": "ลิงก์โซเชียล", + "spotlight": "Spotlight", + "status": "สถานะ", + "status_description": "ตรวจสอบสถานะของเว็บไซต์", + "terms_and_privacy": "ข้อกำหนดและความเป็นส่วนตัว", + "twitter": "Twitter", + "type_a_command_search": "พิมพ์คำสั่งหรือค้นหา…", + "we_use_cookies": "เราใช้คุกกี้", + "updated_text": "Hoppscotch ได้รับการอัปเดตเป็น v{version} 🎉", + "whats_new": "มีอะไรใหม่?", + "see_whats_new": "ดูสิ่งที่มีใหม่", + "wiki": "Wiki", + "collapse_sidebar": "ยุบแถบด้านข้าง", + "continue_to_dashboard": "ไปยังแดชบอร์ด", + "expand_sidebar": "ขยายแถบด้านข้าง", + "no_name": "ไม่มีชื่อ", + "open_navigation": "เปิดการนำทาง", + "read_documentation": "อ่านเอกสารประกอบ", + "default": "ค่าเริ่มต้น: {value}" + }, + "auth": { + "account_deactivated": "บัญชีของคุณถูกปิดใช้งาน ติดต่อผู้ดูแลระบบเพื่อขอสิทธิ์การเข้าถึง", + "account_exists": "มีบัญชีอยู่แล้วด้วยข้อมูลรับรองอื่น - เข้าสู่ระบบเพื่อเชื่อมโยงทั้งสองบัญชี", + "all_sign_in_options": "ตัวเลือกการลงชื่อเข้าใช้ทั้งหมด", + "continue_with_auth_provider": "ดำเนินการต่อด้วย {provider}", + "continue_with_email": "ดำเนินการต่อด้วยอีเมล", + "continue_with_github": "ดำเนินการต่อด้วย GitHub", + "continue_with_github_enterprise": "ดำเนินการต่อด้วย GitHub Enterprise", + "continue_with_google": "ดำเนินการต่อด้วย Google", + "continue_with_microsoft": "ดำเนินการต่อด้วย Microsoft", + "email": "อีเมล", + "logged_out": "ออกจากระบบแล้ว", + "login": "เข้าสู่ระบบ", + "login_success": "เข้าสู่ระบบสำเร็จ", + "login_to_hoppscotch": "เข้าสู่ระบบ Hoppscotch", + "logout": "ออกจากระบบ", + "re_enter_email": "ป้อนอีเมลอีกครั้ง", + "send_magic_link": "ส่ง magic link", + "sync": "ซิงค์", + "we_sent_magic_link": "เราได้ส่ง magic link ให้คุณแล้ว!", + "we_sent_magic_link_description": "ตรวจสอบกล่องจดหมายของคุณ - เราได้ส่งอีเมลไปยัง {email} ซึ่งมี magic link ที่จะเข้าสู่ระบบให้คุณ" + }, + "authorization": { + "generate_token": "สร้างโทเค็น", + "refresh_token": "รีเฟรชโทเค็น", + "graphql_headers": "ส่วนหัวการให้สิทธิ์จะถูกส่งเป็นส่วนหนึ่งของ payload ไปยัง connection_init", + "include_in_url": "รวมไว้ใน URL", + "inherited_from": "สืบทอด {auth} จากคอลเลกชันหลัก {collection} ", + "learn": "เรียนรู้วิธีการ", + "oauth": { + "redirect_auth_server_returned_error": "เซิร์ฟเวอร์การให้สิทธิ์ส่งคืนสถานะข้อผิดพลาด", + "redirect_auth_token_request_failed": "คำขอเพื่อรับโทเค็นการให้สิทธิ์ล้มเหลว", + "redirect_auth_token_request_invalid_response": "การตอบกลับจาก Token Endpoint ไม่ถูกต้องเมื่อขอโทเค็นการให้สิทธิ์", + "redirect_invalid_state": "ค่า State ในการเปลี่ยนเส้นทางไม่ถูกต้อง", + "redirect_no_auth_code": "ไม่มี Authorization Code ในการเปลี่ยนเส้นทาง", + "redirect_no_client_id": "ไม่ได้กำหนด Client ID", + "redirect_no_client_secret": "ไม่ได้กำหนด Client Secret", + "redirect_no_code_verifier": "ไม่ได้กำหนด Code Verifier", + "redirect_no_token_endpoint": "ไม่ได้กำหนด Token Endpoint", + "something_went_wrong_on_oauth_redirect": "เกิดข้อผิดพลาดบางอย่างระหว่างการเปลี่ยนเส้นทาง OAuth", + "something_went_wrong_on_token_generation": "เกิดข้อผิดพลาดบางอย่างในการสร้างโทเค็น", + "token_generation_oidc_discovery_failed": "การสร้างโทเค็นล้มเหลว: OpenID Connect Discovery ล้มเหลว", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "ดึงโทเค็นสำเร็จ", + "token_fetch_failed": "ดึงโทเค็นไม่สำเร็จ", + "validation_failed": "การตรวจสอบล้มเหลว โปรดตรวจสอบฟิลด์ในแบบฟอร์ม", + "no_refresh_token_present": "ไม่มี Refresh Token โปรดเรียกใช้ขั้นตอนการสร้างโทเค็นอีกครั้ง", + "refresh_token_request_failed": "คำขอรีเฟรชโทเค็นล้มเหลว", + "token_refreshed_successfully": "รีเฟรชโทเค็นสำเร็จ", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "ใช้ PKCE", + "label_implicit": "Implicit", + "label_password": "รหัสผ่าน", + "label_username": "ชื่อผู้ใช้", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials", + "label_send_as": "การยืนยันตัวตนของไคลเอนต์", + "label_send_in_body": "ส่งข้อมูลรับรองในเนื้อหา", + "label_send_as_basic_auth": "ส่งข้อมูลรับรองเป็น Basic Auth", + "enter_value": "ป้อนค่า", + "auth_request": "คำขอการให้สิทธิ์", + "token_request": "คำขอโทเค็น", + "refresh_request": "คำขอรีเฟรช", + "send_in": "ส่งใน" + }, + "pass_key_by": "ส่งผ่านโดย", + "pass_by_query_params_label": "พารามิเตอร์เคียวรี", + "pass_by_headers_label": "ส่วนหัว", + "password": "รหัสผ่าน", + "save_to_inherit": "โปรดบันทึกคำขอนี้ในคอลเลกชันใดก็ได้เพื่อสืบทอดการให้สิทธิ์", + "token": "โทเค็น", + "access_token": "Access Token", + "client_token": "Client Token", + "client_secret": "Client Secret", + "timestamp": "Timestamp", + "host": "Host", + "type": "ประเภทการให้สิทธิ์", + "username": "ชื่อผู้ใช้", + "advance_config": "การกำหนดค่าขั้นสูง", + "advance_config_description": "Hoppscotch จะกำหนดค่าเริ่มต้นให้กับบางฟิลด์โดยอัตโนมัติหากไม่ได้ระบุค่าไว้อย่างชัดเจน", + "algorithm": "อัลกอริทึม", + "payload": "Payload", + "secret": "Secret", + "aws_signature": { + "access_key": "Access Key", + "secret_key": "Secret Key", + "service_name": "ชื่อบริการ", + "aws_region": "AWS Region", + "service_token": "Service Token" + }, + "digest": { + "realm": "Realm", + "nonce": "Nonce", + "algorithm": "อัลกอริทึม", + "qop": "qop", + "nonce_count": "Nonce Count", + "client_nonce": "Client Nonce", + "opaque": "Opaque", + "disable_retry": "ปิดการลองส่งคำขอใหม่" + }, + "akamai": { + "headers_to_sign": "ส่วนหัวที่จะเซ็น", + "max_body_size": "ขนาดเนื้อหาสูงสุด" + }, + "hawk": { + "id": "HAWK Auth ID", + "key": "HAWK Auth Key", + "ext": "ext", + "app": "app", + "dlg": "dlg", + "include": "รวม Payload Hash" + }, + "jwt": { + "params_name": "ชื่อพารามิเตอร์", + "param_name": "ชื่อพารามิเตอร์", + "header_prefix": "คำนำหน้าส่วนหัว", + "placeholder_request_header": "คำนำหน้าส่วนหัวของคำขอ", + "placeholder_request_param": "ชื่อพารามิเตอร์ของคำขอ", + "secret_base64_encoded": "Secret ที่เข้ารหัสแบบ Base64", + "headers": "JWT Headers", + "private_key": "Private Key", + "placeholder_headers": "JWT Headers" + }, + "ntlm": { + "domain": "โดเมน", + "workstation": "เวิร์กสเตชัน", + "disable_retrying_request": "ปิดการลองส่งคำขอใหม่" + }, + "asap": { + "issuer": "ผู้ออก", + "audience": "ผู้รับ", + "expires_in": "หมดอายุใน", + "key_id": "Key ID", + "optional_config": "การกำหนดค่าเสริม", + "subject": "หัวเรื่อง", + "additional_claims": "Claims เพิ่มเติม" + } + }, + "collection": { + "title": "คอลเลกชัน", + "run": "รันคอลเลกชัน", + "created": "สร้างคอลเลกชันแล้ว", + "different_parent": "ไม่สามารถจัดเรียงคอลเลกชันที่มีพาเรนต์ต่างกันได้", + "edit": "แก้ไขคอลเลกชัน", + "import_or_create": "นำเข้าหรือสร้างคอลเลกชัน", + "import_collection": "นำเข้าคอลเลกชัน", + "invalid_name": "โปรดระบุชื่อสำหรับคอลเลกชัน", + "invalid_root_move": "คอลเลกชันอยู่ในรากอยู่แล้ว", + "moved": "ย้ายสำเร็จ", + "my_collections": "คอลเลกชันส่วนตัว", + "name": "คอลเลกชันใหม่ของฉัน", + "name_length_insufficient": "ชื่อคอลเลกชันควรมีความยาวอย่างน้อย 3 อักขระ", + "new": "คอลเลกชันใหม่", + "order_changed": "อัปเดตลำดับคอลเลกชันแล้ว", + "properties": "คุณสมบัติคอลเลกชัน", + "properties_updated": "อัปเดตคุณสมบัติคอลเลกชันแล้ว", + "renamed": "เปลี่ยนชื่อคอลเลกชันแล้ว", + "request_in_use": "คำขอกำลังถูกใช้งาน", + "save_as": "บันทึกเป็น", + "save_to_collection": "บันทึกไปยังคอลเลกชัน", + "select": "เลือกคอลเลกชัน", + "select_location": "เลือกตำแหน่ง", + "sorted": "จัดเรียงคอลเลกชันแล้ว", + "details": "รายละเอียด", + "duplicated": "ทำสำเนาคอลเลกชันแล้ว" + }, + "confirm": { + "close_unsaved_tab": "คุณแน่ใจหรือไม่ว่าต้องการปิดแท็บนี้?", + "close_unsaved_tabs": "คุณแน่ใจหรือไม่ว่าต้องการปิดแท็บทั้งหมด? แท็บที่ยังไม่ได้บันทึก {count} แท็บจะสูญหาย", + "delete_all_activity_logs": "คุณแน่ใจหรือไม่ว่าต้องการลบบันทึกกิจกรรมทั้งหมด?", + "exit_team": "คุณแน่ใจหรือไม่ว่าต้องการออกจากพื้นที่ทำงานนี้?", + "logout": "คุณแน่ใจหรือไม่ว่าต้องการออกจากระบบ?", + "remove_collection": "คุณแน่ใจหรือไม่ว่าต้องการลบคอลเลกชันนี้อย่างถาวร?", + "remove_environment": "คุณแน่ใจหรือไม่ว่าต้องการลบสภาพแวดล้อมนี้อย่างถาวร?", + "remove_folder": "คุณแน่ใจหรือไม่ว่าต้องการลบโฟลเดอร์นี้อย่างถาวร?", + "remove_history": "คุณแน่ใจหรือไม่ว่าต้องการลบประวัติทั้งหมดอย่างถาวร?", + "remove_request": "คุณแน่ใจหรือไม่ว่าต้องการลบคำขอนี้อย่างถาวร?", + "remove_response": "คุณแน่ใจหรือไม่ว่าต้องการลบการตอบกลับนี้อย่างถาวร?", + "remove_shared_request": "คุณแน่ใจหรือไม่ว่าต้องการลบคำขอที่แชร์นี้อย่างถาวร?", + "remove_team": "คุณแน่ใจหรือไม่ว่าต้องการลบพื้นที่ทำงานนี้?", + "remove_telemetry": "คุณแน่ใจหรือไม่ว่าต้องการเลือกไม่ใช้ Telemetry?", + "request_change": "คุณแน่ใจหรือไม่ว่าต้องการละทิ้งคำขอปัจจุบัน การเปลี่ยนแปลงที่ยังไม่ได้บันทึกจะสูญหาย", + "save_unsaved_tab": "คุณต้องการบันทึกการเปลี่ยนแปลงที่ทำในแท็บนี้หรือไม่?", + "sync": "คุณต้องการกู้คืนพื้นที่ทำงานของคุณจากคลาวด์หรือไม่? การดำเนินการนี้จะละทิ้งความคืบหน้าในเครื่องของคุณ", + "delete_access_token": "คุณแน่ใจหรือไม่ว่าต้องการลบโทเค็นการเข้าถึง {tokenLabel}?", + "delete_mock_server": "คุณแน่ใจหรือไม่ว่าต้องการลบ mock server นี้?" + }, + "context_menu": { + "add_parameters": "เพิ่มไปยังพารามิเตอร์", + "open_request_in_new_tab": "เปิดคำขอในแท็บใหม่", + "set_environment_variable": "ตั้งเป็นตัวแปร", + "encode_uri_component": "เข้ารหัสคอมโพเนนต์ URL", + "decode_uri_component": "ถอดรหัสคอมโพเนนต์ URL" + }, + "cookies": { + "modal": { + "cookie_expires": "หมดอายุ", + "cookie_name": "ชื่อ", + "cookie_path": "เส้นทาง", + "cookie_string": "สตริงคุกกี้", + "cookie_value": "ค่า", + "empty_domain": "โดเมนว่างเปล่า", + "empty_domains": "รายการโดเมนว่างเปล่า", + "enter_cookie_string": "ป้อนสตริงคุกกี้", + "interceptor_no_support": "ตัวสกัดกั้นที่คุณเลือกอยู่ในปัจจุบันไม่รองรับคุกกี้ เลือกตัวสกัดกั้นอื่นแล้วลองอีกครั้ง", + "managed_tab": "จัดการแล้ว", + "new_domain_name": "ชื่อโดเมนใหม่", + "no_cookies_in_domain": "ไม่มีการตั้งค่าคุกกี้สำหรับโดเมนนี้", + "raw_tab": "ดิบ", + "set": "ตั้งค่าคุกกี้" + } + }, + "count": { + "currentValue": "ค่าปัจจุบัน {count}", + "description": "คำอธิบาย {count}", + "header": "ส่วนหัว {count}", + "initialValue": "ค่าเริ่มต้น {count}", + "key": "คีย์ {count}", + "message": "ข้อความ {count}", + "parameter": "พารามิเตอร์ {count}", + "protocol": "โปรโตคอล {count}", + "value": "ค่า {count}", + "variable": "ตัวแปร {count}" + }, + "documentation": { + "add_description": "เพิ่มคำอธิบายสำหรับคอลเลกชันนี้ที่นี่...", + "add_description_placeholder": "เพิ่มคำอธิบายที่นี่...", + "add_request_description": "เพิ่มคำอธิบายสำหรับคำขอที่นี่...", + "auth": { + "access_key": "Access Key", + "access_token": "Access Token", + "add_to": "เพิ่มไปยัง", + "akamai_edgegrid": "Akamai EdgeGrid", + "algorithm": "อัลกอริทึม", + "api_key": "API Key", + "app_id": "App ID", + "auth_id": "Auth ID", + "auth_key": "Auth Key", + "auth_url": "Auth URL", + "aws_signature": "AWS Signature", + "basic_auth": "Basic Auth", + "bearer_token": "Bearer Token", + "client_id": "Client ID", + "client_nonce": "Client Nonce", + "client_secret": "Client Secret", + "client_token": "Client Token", + "delegation": "การมอบสิทธิ์", + "digest_auth": "Digest Auth", + "extra_data": "ข้อมูลเพิ่มเติม", + "grant_type": "Grant Type", + "hawk_auth": "HAWK Auth", + "headers_to_sign": "ส่วนหัวที่จะลงนาม", + "host": "Host", + "include_payload_hash": "รวม Payload Hash", + "jwt_auth": "JWT Auth", + "max_body_size": "ขนาดเนื้อหาสูงสุด", + "no_auth": "ไม่มีการยืนยันตัวตน", + "nonce": "Nonce", + "oauth_2": "OAuth 2.0", + "opaque": "Opaque", + "password": "รหัสผ่าน", + "payload": "Payload", + "qop": "QOP", + "realm": "Realm", + "region": "ภูมิภาค", + "scope": "Scope", + "secret_key": "Secret Key", + "service_name": "ชื่อบริการ", + "timestamp": "Timestamp", + "title": "การยืนยันตัวตน", + "token_url": "Token URL", + "user": "ผู้ใช้", + "username": "ชื่อผู้ใช้" + }, + "body": { + "content_type": "Content Type", + "no_body": "ไม่ได้กำหนดเนื้อหา", + "title": "เนื้อหา" + }, + "copied_to_clipboard": "คัดลอกไปยังคลิปบอร์ดแล้ว!", + "curl": { + "click_to_load": "คลิกเพื่อโหลดคำสั่ง cURL", + "copied": "คัดลอกคำสั่ง cURL ไปยังคลิปบอร์ดแล้ว!", + "copy_to_clipboard": "คัดลอกไปยังคลิปบอร์ด", + "generating": "กำลังสร้างคำสั่ง cURL...", + "load": "โหลด cURL", + "title": "cURL" + }, + "description": "คำอธิบาย", + "error_rendering_markdown": "เกิดข้อผิดพลาดในการแสดงผล markdown:", + "fetching_documentation": "กำลังดึงเอกสาร...", + "generate": "สร้างเอกสาร", + "generate_message": "นำเข้าคอลเลกชัน Hoppscotch ใดก็ได้เพื่อสร้างเอกสาร API ได้ทันที", + "headers": { + "no_headers": "ไม่ได้กำหนดส่วนหัว", + "title": "ส่วนหัว" + }, + "hide_all_documentation": "ซ่อนเอกสารทั้งหมด", + "inherited_from": "สืบทอดมาจาก {name}", + "inherited_with_type": "สืบทอด {type} มาจาก {name}", + "key": "คีย์", + "loading": "กำลังโหลด...", + "loading_collection_data": "กำลังโหลดข้อมูลคอลเลกชัน...", + "no": "ไม่", + "no_collection_data": "ไม่มีข้อมูลคอลเลกชัน", + "no_documentation_found": "ไม่พบเอกสารสำหรับโฟลเดอร์หรือคำขอ", + "no_request_data": "ไม่มีข้อมูลคำขอ", + "no_requests_or_folders": "ไม่มีคำขอหรือโฟลเดอร์", + "not_set": "ยังไม่ได้ตั้งค่า", + "open_request_in_new_tab": "เปิดคำขอในแท็บใหม่", + "parameters": { + "no_params": "ไม่ได้กำหนดพารามิเตอร์", + "title": "พารามิเตอร์" + }, + "percent_complete": "% เสร็จสมบูรณ์", + "processing_documentation": "กำลังประมวลผลเอกสาร", + "publish": { + "already_published": "คอลเลกชันนี้เผยแพร่แล้ว", + "auto_sync": "ซิงค์อัตโนมัติกับคอลเลกชัน", + "auto_sync_description": "อัปเดตเอกสารที่เผยแพร่โดยอัตโนมัติเมื่อคอลเลกชันเปลี่ยนแปลง", + "button": "เผยแพร่", + "copy_url": "คัดลอก URL", + "delete": "ลบเอกสาร", + "unpublish_doc": "คุณแน่ใจหรือไม่ว่าต้องการยกเลิกการเผยแพร่เอกสารนี้?", + "delete_success": "ลบเอกสารที่เผยแพร่เรียบร้อยแล้ว", + "doc_title": "ชื่อ", + "doc_version": "เวอร์ชัน", + "edit_published_doc": "แก้ไขเอกสารที่เผยแพร่", + "last_updated": "อัปเดตล่าสุด", + "metadata": "ข้อมูลเมตา (JSON)", + "open_published_doc": "เปิดเอกสารที่เผยแพร่ในแท็บใหม่", + "publish_error": "เผยแพร่เอกสารไม่สำเร็จ", + "publish_success": "เผยแพร่เอกสารเรียบร้อยแล้ว!", + "published": "เผยแพร่แล้ว", + "published_url": "URL ที่เผยแพร่", + "title": "เผยแพร่เอกสาร", + "update_button": "อัปเดต", + "update_error": "อัปเดตเอกสารไม่สำเร็จ", + "update_published_docs": "อัปเดตเอกสารที่เผยแพร่", + "update_success": "อัปเดตเอกสารเรียบร้อยแล้ว!", + "unpublish": "ยกเลิกการเผยแพร่", + "update_title": "อัปเดตเอกสารที่เผยแพร่", + "url_copied": "คัดลอก URL ไปยังคลิปบอร์ดแล้ว!", + "view_published": "ดูเอกสารที่เผยแพร่", + "view_title": "ภาพรวมเอกสารที่เผยแพร่", + "versions": "เวอร์ชัน", + "create_new_version": "สร้างเวอร์ชันใหม่", + "invalid_version": "เวอร์ชันต้องประกอบด้วยอักขระตัวอักษรและตัวเลข จุด และยัติภังค์เท่านั้น", + "live": "สด", + "snapshot": "สแนปช็อต", + "version_immutable": "เวอร์ชันที่เผยแพร่เป็นสแนปช็อตแบบอ่านอย่างเดียว", + "view_snapshot": "ดูสแนปช็อต", + "current_version": "ปัจจุบัน", + "not_found": "ไม่พบเอกสารที่เผยแพร่", + "no_doc_id": "ไม่ได้ระบุ ID ของเอกสาร", + "version_label": "v{version}", + "unpublish_version": "ยกเลิกการเผยแพร่เวอร์ชันนี้", + "loading_snapshot": "กำลังโหลดสแนปช็อต...", + "retry_snapshot": "ลองใหม่", + "sensitive_data_warning": "โปรดตรวจสอบให้แน่ใจว่าไม่มีข้อมูลที่ละเอียดอ่อนถูกเปิดเผยในเอกสารที่เผยแพร่", + "snapshot_preview": "ตัวอย่างสแนปช็อต", + "snapshot_load_error": "โหลดตัวอย่างสแนปช็อตไม่สำเร็จ", + "snapshot_empty": "ไม่มีคำขอหรือโฟลเดอร์ในสแนปช็อตนี้", + "snapshot_item_count": "{count} รายการ", + "auto_sync_live_notice": "เวอร์ชันนี้ซิงค์อัตโนมัติกับคอลเลกชันสด", + "snapshot_promote_warning": "การเปิดใช้งานการซิงค์อัตโนมัติจะแทนที่สแนปช็อตที่ตรึงไว้นี้ด้วยโครงสร้างคอลเลกชันสด การดำเนินการนี้ไม่สามารถยกเลิกได้", + "live_freeze_notice": "การซิงค์อัตโนมัติจะถูกปิด และเวอร์ชันนี้จะถูกตรึงไว้ที่สถานะคอลเลกชันปัจจุบัน", + "untitled_project": "โปรเจกต์ที่ไม่มีชื่อ", + "environment": "สภาพแวดล้อม", + "no_environment": "ไม่มีสภาพแวดล้อม", + "environment_description": "แนบสภาพแวดล้อมเพื่อแก้ค่าตัวแปรในเอกสารที่เผยแพร่" + }, + "request_opened_in_new_tab": "เปิดคำขอในแท็บใหม่แล้ว!", + "response": { + "body": "เนื้อหาการตอบกลับ", + "copy": "คัดลอกการตอบกลับ", + "example_copied": "คัดลอกตัวอย่างการตอบกลับไปยังคลิปบอร์ดแล้ว!", + "example_copy_failed": "คัดลอกตัวอย่างการตอบกลับไม่สำเร็จ", + "headers": "ส่วนหัวการตอบกลับ", + "no_examples": "ไม่มีตัวอย่างการตอบกลับ", + "title": "ตัวอย่างการตอบกลับ" + }, + "save_error": "เกิดข้อผิดพลาดในการบันทึกเอกสาร", + "save_success": "บันทึกเอกสารเรียบร้อยแล้ว", + "saved_items_status": "บันทึก {success} รายการ บันทึก {failure} รายการไม่สำเร็จ", + "show_all_documentation": "แสดงเอกสารทั้งหมด", + "source": "แหล่งที่มา", + "title": "เอกสาร", + "unsaved_changes": "คุณมีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก {count} รายการ โปรดบันทึกก่อนปิด", + "untitled_collection": "คอลเลกชันที่ไม่มีชื่อ", + "untitled_request": "คำขอที่ไม่มีชื่อ", + "value": "ค่า", + "variables": { + "no_vars": "ไม่ได้กำหนดตัวแปร", + "title": "ตัวแปร" + }, + "yes": "ใช่" + }, + "empty": { + "activity_logs": "ไม่พบบันทึกกิจกรรม", + "authorization": "คำขอนี้ไม่ได้ใช้การให้สิทธิ์ใด ๆ", + "body": "คำขอนี้ไม่มีเนื้อหา", + "collection": "คอลเลกชันว่างเปล่า", + "collections": "คอลเลกชันว่างเปล่า", + "documentation": "เชื่อมต่อกับเอนด์พอยต์ GraphQL เพื่อดูเอกสารประกอบ", + "empty_schema": "ไม่พบสคีมา", + "endpoint": "เอนด์พอยต์ต้องไม่ว่างเปล่า", + "environments": "สภาพแวดล้อมว่างเปล่า", + "collection_variables": "ตัวแปรของคอลเลกชันว่างเปล่า", + "folder": "โฟลเดอร์ว่างเปล่า", + "headers": "คำขอนี้ไม่มีส่วนหัวใด ๆ", + "history": "ประวัติว่างเปล่า", + "invites": "รายการคำเชิญว่างเปล่า", + "members": "พื้นที่ทำงานว่างเปล่า", + "parameters": "คำขอนี้ไม่มีพารามิเตอร์ใด ๆ", + "pending_invites": "ไม่มีคำเชิญที่รอดำเนินการสำหรับพื้นที่ทำงานนี้", + "profile": "เข้าสู่ระบบเพื่อดูโปรไฟล์ของคุณ", + "protocols": "โปรโตคอลว่างเปล่า", + "request_variables": "คำขอนี้ไม่มีตัวแปรของคำขอใด ๆ", + "schema": "เชื่อมต่อกับเอนด์พอยต์ GraphQL เพื่อดูสคีมา", + "search_environment": "ไม่พบสภาพแวดล้อมที่ตรงกันสำหรับ", + "secret_environments": "ความลับไม่ได้ซิงค์ไปยัง Hoppscotch", + "shared_requests": "คำขอที่แชร์ว่างเปล่า", + "shared_requests_logout": "เข้าสู่ระบบเพื่อดูคำขอที่แชร์ของคุณ หรือสร้างใหม่", + "subscription": "การสมัครสมาชิกว่างเปล่า", + "team_name": "ชื่อพื้นที่ทำงานว่างเปล่า", + "teams": "คุณไม่ได้อยู่ในพื้นที่ทำงานใด ๆ", + "tests": "ไม่มีการทดสอบสำหรับคำขอนี้", + "access_tokens": "โทเค็นการเข้าถึงว่างเปล่า", + "response": "ไม่ได้รับการตอบกลับ", + "mock_servers": "ไม่พบ Mock Server" + }, + "environment": { + "heading": "สภาพแวดล้อม", + "add_to_global": "เพิ่มไปยัง Global", + "added": "เพิ่มตัวแปรสภาพแวดล้อมแล้ว", + "create_new": "สร้างสภาพแวดล้อมใหม่", + "created": "สร้างสภาพแวดล้อมแล้ว", + "current_value": "ค่าปัจจุบัน", + "deleted": "ลบตัวแปรสภาพแวดล้อมแล้ว", + "duplicated": "ทำสำเนาสภาพแวดล้อมแล้ว", + "edit": "แก้ไขสภาพแวดล้อม", + "empty_variables": "ไม่มีตัวแปร", + "global": "Global", + "global_variables": "ตัวแปร Global", + "import_or_create": "นำเข้าหรือสร้างสภาพแวดล้อม", + "initial_value": "ค่าเริ่มต้น", + "invalid_name": "โปรดระบุชื่อสำหรับสภาพแวดล้อม", + "list": "ตัวแปรสภาพแวดล้อม", + "my_environments": "สภาพแวดล้อมส่วนตัว", + "name": "ชื่อ", + "nested_overflow": "ตัวแปรสภาพแวดล้อมแบบซ้อนจำกัดอยู่ที่ 10 ระดับ", + "new": "สภาพแวดล้อมใหม่", + "no_active_environment": "ไม่มีสภาพแวดล้อมที่ใช้งานอยู่", + "no_environment": "ไม่มีสภาพแวดล้อม", + "no_environment_description": "ไม่ได้เลือกสภาพแวดล้อม เลือกว่าจะทำอย่างไรกับตัวแปรต่อไปนี้", + "quick_peek": "ดูสภาพแวดล้อมอย่างรวดเร็ว", + "replace_all_current_with_initial": "แทนที่ค่าปัจจุบันทั้งหมดด้วยค่าเริ่มต้น", + "replace_all_initial_with_current": "แทนที่ค่าเริ่มต้นทั้งหมดด้วยค่าปัจจุบัน", + "replace_current_with_initial": "แทนที่ด้วยค่าเริ่มต้น", + "replace_initial_with_current": "แทนที่ด้วยค่าปัจจุบัน", + "replace_with_variable": "แทนที่ด้วยตัวแปร", + "scope": "ขอบเขต", + "secrets": "Secrets", + "secret_value": "ค่า Secret", + "select": "เลือกสภาพแวดล้อม", + "set": "ตั้งค่าสภาพแวดล้อม", + "set_as_environment": "ตั้งเป็นสภาพแวดล้อม", + "short_name": "ชื่อสภาพแวดล้อมต้องมีอย่างน้อย 1 ตัวอักษร", + "team_environments": "สภาพแวดล้อมของพื้นที่ทำงาน", + "title": "สภาพแวดล้อม", + "updated": "อัปเดตสภาพแวดล้อมแล้ว", + "value": "ค่า", + "variable": "ตัวแปร", + "variables": "ตัวแปร", + "variable_list": "รายการตัวแปร", + "properties": "คุณสมบัติสภาพแวดล้อม", + "details": "รายละเอียด" + }, + "error": { + "network": { + "heading": "ข้อผิดพลาดเครือข่าย", + "description": "การเชื่อมต่อเครือข่ายล้มเหลว {message}: {cause}" + }, + "timeout": { + "heading": "ข้อผิดพลาดหมดเวลา", + "description": "คำขอหมดเวลาในระหว่าง {phase} {message}" + }, + "certificate": { + "heading": "ข้อผิดพลาดใบรับรอง", + "description": "ใบรับรองไม่ถูกต้อง {message}: {cause}" + }, + "auth": { + "heading": "ข้อผิดพลาดการตรวจสอบสิทธิ์", + "description": "การเข้าถึงถูกปฏิเสธ {message}: {cause}" + }, + "proxy": { + "heading": "ข้อผิดพลาด Proxy", + "description": "การเชื่อมต่อ Proxy ล้มเหลว {message}: {cause}" + }, + "parse": { + "heading": "ข้อผิดพลาดการแยกวิเคราะห์", + "description": "ไม่สามารถแยกวิเคราะห์การตอบกลับได้ {message}: {cause}" + }, + "version": { + "heading": "ข้อผิดพลาดเวอร์ชัน", + "description": "เวอร์ชันไม่เข้ากัน {message}: {cause}" + }, + "abort": { + "heading": "คำขอถูกยกเลิก", + "description": "การดำเนินการถูกยกเลิก {message}: {cause}" + }, + "unknown": { + "heading": "ข้อผิดพลาดที่ไม่รู้จัก", + "description": "เกิดข้อผิดพลาดที่ไม่รู้จัก", + "cause": "สาเหตุที่ไม่รู้จัก" + }, + "extension": { + "heading": "ข้อผิดพลาดส่วนขยาย", + "description": "การเรียกใช้คำขอบนส่วนขยายล้มเหลว" + }, + "authproviders_load_error": "ไม่สามารถโหลดผู้ให้บริการการตรวจสอบสิทธิ์ได้", + "browser_support_sse": "เบราว์เซอร์นี้ดูเหมือนจะไม่รองรับ Server Sent Events", + "check_console_details": "ตรวจสอบบันทึกคอนโซลเพื่อดูรายละเอียด", + "check_how_to_add_origin": "ตรวจสอบวิธีการเพิ่ม origin", + "curl_invalid_format": "cURL มีรูปแบบไม่ถูกต้อง", + "danger_zone": "โซนอันตราย", + "delete_account": "ปัจจุบันบัญชีของคุณเป็นเจ้าของเพียงผู้เดียวในพื้นที่ทำงานเหล่านี้:", + "delete_account_description": "คุณต้องนำตัวเองออก โอนความเป็นเจ้าของ หรือลบพื้นที่ทำงานเหล่านี้ก่อนจึงจะสามารถลบบัญชีของคุณได้", + "delete_activity_log": "ลบบันทึกกิจกรรมล้มเหลว", + "delete_all_activity_logs": "ลบบันทึกกิจกรรมทั้งหมดล้มเหลว", + "email_already_exists": "อีเมลนี้มีอยู่แล้วกับบัญชีอื่น โปรดตั้งค่าที่อยู่อีเมลอื่น", + "empty_email_address": "ที่อยู่อีเมลต้องไม่ว่างเปล่า", + "empty_profile_name": "ชื่อโปรไฟล์ต้องไม่ว่างเปล่า", + "empty_req_name": "ชื่อคำขอว่างเปล่า", + "fetch_activity_logs": "ดึงบันทึกกิจกรรมล้มเหลว", + "f12_details": "(กด F12 เพื่อดูรายละเอียด)", + "gql_prettify_invalid_query": "ไม่สามารถจัดรูปแบบเคียวรีที่ไม่ถูกต้องได้ โปรดแก้ไขข้อผิดพลาดไวยากรณ์ของเคียวรีแล้วลองอีกครั้ง", + "incomplete_config_urls": "URL การกำหนดค่าไม่สมบูรณ์", + "incorrect_email": "อีเมลไม่ถูกต้อง", + "invalid_file_type": "ชนิดไฟล์ไม่ถูกต้องสำหรับ `{filename}`", + "invalid_link": "ลิงก์ไม่ถูกต้อง", + "invalid_link_description": "ลิงก์ที่คุณคลิกไม่ถูกต้องหรือหมดอายุแล้ว", + "invalid_embed_link": "การฝังนี้ไม่มีอยู่หรือไม่ถูกต้อง", + "json_parsing_failed": "JSON ไม่ถูกต้อง", + "json_prettify_invalid_body": "ไม่สามารถจัดรูปแบบเนื้อหาที่ไม่ถูกต้องได้ โปรดแก้ไขข้อผิดพลาดไวยากรณ์ json แล้วลองอีกครั้ง", + "network_error": "ดูเหมือนจะมีข้อผิดพลาดเครือข่าย โปรดลองอีกครั้ง", + "network_fail": "ไม่สามารถส่งคำขอได้", + "no_collections_to_export": "ไม่มีคอลเลกชันให้ส่งออก โปรดสร้างคอลเลกชันเพื่อเริ่มต้น", + "no_duration": "ไม่มีระยะเวลา", + "no_environments_to_export": "ไม่มีสภาพแวดล้อมให้ส่งออก โปรดสร้างสภาพแวดล้อมเพื่อเริ่มต้น", + "no_results_found": "ไม่พบรายการที่ตรงกัน", + "page_not_found": "ไม่พบหน้านี้", + "please_install_extension": "โปรดติดตั้งส่วนขยายและเพิ่ม origin ลงในส่วนขยาย", + "proxy_error": "ข้อผิดพลาด Proxy", + "same_email_address": "ที่อยู่อีเมลที่อัปเดตเหมือนกับที่อยู่อีเมลปัจจุบัน", + "same_profile_name": "ชื่อโปรไฟล์ที่อัปเดตเหมือนกับชื่อโปรไฟล์ปัจจุบัน", + "script_fail": "ไม่สามารถเรียกใช้สคริปต์ก่อนคำขอได้", + "something_went_wrong": "เกิดข้อผิดพลาดบางอย่าง", + "subscription_error": "สมัครรับข้อมูลหัวข้อล้มเหลว: {error}", + "post_request_script_fail": "ไม่สามารถเรียกใช้สคริปต์หลังคำขอได้", + "reading_files": "เกิดข้อผิดพลาดขณะอ่านไฟล์หนึ่งไฟล์หรือมากกว่า", + "fetching_access_tokens_list": "เกิดข้อผิดพลาดบางอย่างขณะดึงรายการโทเค็น", + "generate_access_token": "เกิดข้อผิดพลาดบางอย่างขณะสร้างโทเค็นการเข้าถึง", + "delete_access_token": "เกิดข้อผิดพลาดบางอย่างขณะลบโทเค็นการเข้าถึง", + "extension_not_found": "ไม่พบส่วนขยาย", + "invalid_request": "ข้อมูลคำขอไม่ถูกต้อง" + }, + "export": { + "as_json": "ส่งออกเป็น JSON", + "as_openapi": "ส่งออกเป็น OpenAPI", + "as_yaml": "ส่งออกเป็น YAML", + "choose_format": "เลือกรูปแบบการส่งออก", + "collection": "ส่งออกคอลเลกชัน", + "collections": "ส่งออกคอลเลกชัน", + "format_hoppscotch": "ส่งออกเป็น Hoppscotch JSON", + "format_openapi_json": "ส่งออกเป็น OpenAPI 3.1 (JSON)", + "format_openapi_yaml": "ส่งออกเป็น OpenAPI 3.1 (YAML)", + "openapi_lossy_summary": "การส่งออก OpenAPI จะตัดสคริปต์ ข้อมูลรับรองการให้สิทธิ์ รายการที่ไม่ได้ใช้งาน เส้นทางที่ซ้ำกัน และรหัสสถานะการตอบกลับที่ซ้ำกันออก", + "create_secret_gist": "สร้าง Gist ลับ", + "create_secret_gist_tooltip_text": "ส่งออกเป็น Gist ลับ", + "failed": "เกิดข้อผิดพลาดบางอย่างขณะส่งออก", + "secret_gist_success": "ส่งออกเป็น Gist ลับสำเร็จ", + "require_github": "เข้าสู่ระบบด้วย GitHub เพื่อสร้าง Gist ลับ", + "title": "ส่งออก", + "success": "ส่งออกสำเร็จ" + }, + "file_upload": { + "choose_file": "เลือกไฟล์", + "max_size_format": "สูงสุด 5MB รองรับ JPEG, PNG, GIF, WebP", + "profile_photo_updated": "อัปเดตรูปโปรไฟล์สำเร็จ", + "profile_photo_removed": "นำรูปโปรไฟล์ออกสำเร็จ", + "org_logo_updated": "อัปเดตโลโก้องค์กรสำเร็จ", + "error_size_limit": "ขนาดไฟล์ต้องน้อยกว่า 5MB", + "error_invalid_format": "ไฟล์ต้องเป็นรูปภาพ (JPEG, PNG, GIF หรือ WebP)", + "error_invalid_upload_type": "ประเภทการอัปโหลดไม่ถูกต้อง", + "error_invalid_org_id": "รูปแบบ ID องค์กรไม่ถูกต้อง", + "error_upload_failed": "อัปโหลดล้มเหลว กรุณาลองใหม่อีกครั้ง", + "error_network_failed": "เกิดข้อผิดพลาดของเครือข่าย กรุณาตรวจสอบการเชื่อมต่อของคุณ", + "error_timeout": "การอัปโหลดหมดเวลาหลังจาก 30 วินาที กรุณาลองใหม่อีกครั้ง", + "error_missing_backend_url": "ยังไม่ได้กำหนดค่า URL ของแบ็กเอนด์ กรุณาตั้งค่าตัวแปรสภาพแวดล้อม VITE_BACKEND_API_URL ในการตั้งค่าสภาพแวดล้อมของคุณ", + "error_invalid_backend_url": "การกำหนดค่า URL ของแบ็กเอนด์ไม่ถูกต้อง" + }, + "filename": { + "cookie_key_value_pairs": "Cookie", + "codegen": "{request_name} - code", + "graphql_response": "GraphQL-Response", + "lens": "{request_name} - response", + "realtime_response": "Realtime-Response", + "response_interface": "Response-Interface" + }, + "filter": { + "all": "ทั้งหมด", + "none": "ไม่มี", + "starred": "ติดดาว" + }, + "folder": { + "created": "สร้างโฟลเดอร์แล้ว", + "edit": "แก้ไขโฟลเดอร์", + "invalid_name": "โปรดระบุชื่อสำหรับโฟลเดอร์", + "name_length_insufficient": "ชื่อโฟลเดอร์ควรมีความยาวอย่างน้อย 3 ตัวอักษร", + "new": "โฟลเดอร์ใหม่", + "run": "เรียกใช้โฟลเดอร์", + "renamed": "เปลี่ยนชื่อโฟลเดอร์แล้ว", + "sorted": "จัดเรียงโฟลเดอร์แล้ว" + }, + "graphql": { + "arguments": "อาร์กิวเมนต์", + "connection_switch_confirm": "คุณต้องการเชื่อมต่อกับปลายทาง GraphQL ล่าสุดหรือไม่?", + "connection_error_http": "ไม่สามารถดึง GraphQL Schema ได้เนื่องจากข้อผิดพลาดของเครือข่าย", + "connection_switch_new_url": "การสลับไปยังแท็บจะตัดการเชื่อมต่อคุณจากการเชื่อมต่อ GraphQL ที่ใช้งานอยู่ URL การเชื่อมต่อใหม่คือ", + "connection_switch_url": "คุณเชื่อมต่อกับปลายทาง GraphQL แล้ว URL การเชื่อมต่อคือ", + "deprecated": "เลิกใช้แล้ว", + "fields": "ฟิลด์", + "mutation": "Mutation", + "mutations": "Mutations", + "schema": "Schema", + "show_depricated_values": "แสดงค่าที่เลิกใช้แล้ว", + "subscription": "Subscription", + "subscriptions": "Subscriptions", + "switch_connection": "สลับการเชื่อมต่อ", + "url_placeholder": "ป้อน URL ปลายทาง GraphQL", + "query": "เคียวรี" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "เวลา", + "url": "URL" + }, + "header": { + "install_pwa": "ติดตั้งแอป", + "login": "เข้าสู่ระบบ", + "save_workspace": "บันทึกพื้นที่ทำงานของฉัน" + }, + "helpers": { + "authorization": "ส่วนหัวการให้สิทธิ์จะถูกสร้างขึ้นโดยอัตโนมัติเมื่อคุณส่งคำขอ", + "collection_properties_authorization": " การให้สิทธิ์นี้จะถูกตั้งค่าสำหรับทุกคำขอในคอลเลกชันนี้", + "collection_properties_header": "ส่วนหัวนี้จะถูกตั้งค่าสำหรับทุกคำขอในคอลเลกชันนี้", + "collection_properties_scripts": "สคริปต์เหล่านี้จะทำงานสำหรับทุกคำขอในคอลเลกชันนี้ สคริปต์ก่อนคำขอจะทำงานก่อนคำขอ ส่วนสคริปต์ทดสอบจะทำงานหลังการตอบกลับ", + "generate_documentation_first": "สร้างเอกสารก่อน", + "network_fail": "ไม่สามารถเข้าถึงปลายทาง API ได้ ตรวจสอบการเชื่อมต่อเครือข่ายของคุณหรือเลือก Interceptor อื่นแล้วลองอีกครั้ง", + "offline": "คุณกำลังใช้ Hoppscotch แบบออฟไลน์ การอัปเดตจะซิงค์เมื่อคุณออนไลน์ ตามการตั้งค่าพื้นที่ทำงาน", + "offline_short": "คุณกำลังใช้ Hoppscotch แบบออฟไลน์", + "post_request_tests": "สคริปต์หลังคำขอเขียนด้วย JavaScript และจะทำงานหลังจากได้รับการตอบกลับ", + "pre_request_script": "สคริปต์ก่อนคำขอเขียนด้วย JavaScript และจะทำงานก่อนที่จะส่งคำขอ", + "script_fail": "ดูเหมือนว่าจะมีข้อผิดพลาดในสคริปต์ก่อนคำขอ ตรวจสอบข้อผิดพลาดด้านล่างและแก้ไขสคริปต์ให้ถูกต้อง", + "post_request_script_fail": "ดูเหมือนว่าจะมีข้อผิดพลาดในสคริปต์หลังคำขอ โปรดแก้ไขข้อผิดพลาดและรันการทดสอบอีกครั้ง", + "post_request_script": "เขียนสคริปต์หลังคำขอเพื่อทำการดีบักอัตโนมัติ" + }, + "hide": { + "collection": "ยุบแผงคอลเลกชัน", + "more": "ซ่อนเพิ่มเติม", + "preview": "ซ่อนตัวอย่าง", + "sidebar": "ยุบแถบด้านข้าง", + "password": "ซ่อนรหัสผ่าน" + }, + "import": { + "collections": "นำเข้าคอลเลกชัน", + "curl": "นำเข้า cURL", + "environments_from_gist": "นำเข้าจาก Gist", + "environments_from_gist_description": "นำเข้าสภาพแวดล้อมของ Hoppscotch จาก Gist", + "failed": "เกิดข้อผิดพลาดขณะนำเข้า: ไม่รู้จักรูปแบบ", + "from_file": "นำเข้าจากไฟล์", + "from_gist": "นำเข้าจาก Gist", + "from_gist_description": "นำเข้าจาก URL ของ Gist", + "from_gist_import_summary": "นำเข้าฟีเจอร์ทั้งหมดของ hoppscotch แล้ว", + "from_hoppscotch_importer_summary": "นำเข้าฟีเจอร์ทั้งหมดของ hoppscotch แล้ว", + "from_insomnia": "นำเข้าจาก Insomnia", + "from_insomnia_description": "นำเข้าจากคอลเลกชัน Insomnia", + "from_insomnia_import_summary": "คอลเลกชันและคำขอจะถูกนำเข้า", + "from_json": "นำเข้าจาก Hoppscotch", + "from_json_description": "นำเข้าจากไฟล์คอลเลกชัน Hoppscotch", + "from_my_collections": "นำเข้าจากคอลเลกชันส่วนตัว", + "from_my_collections_description": "นำเข้าจากไฟล์คอลเลกชันส่วนตัว", + "from_all_collections": "นำเข้าจากพื้นที่ทำงานอื่น", + "from_all_collections_description": "นำเข้าคอลเลกชันใด ๆ จากพื้นที่ทำงานอื่นมายังพื้นที่ทำงานปัจจุบัน", + "from_openapi": "นำเข้าจาก OpenAPI", + "from_openapi_description": "นำเข้าจากไฟล์ข้อกำหนด OpenAPI (YML/JSON)", + "from_openapi_import_summary": "คอลเลกชัน ( จะถูกสร้างจากแท็ก ), คำขอ และตัวอย่างการตอบกลับจะถูกนำเข้า", + "from_postman": "นำเข้าจาก Postman", + "from_postman_description": "นำเข้าจากคอลเลกชัน Postman", + "from_postman_import_summary": "คอลเลกชัน คำขอ และตัวอย่างการตอบกลับจะถูกนำเข้า", + "import_scripts": "นำเข้าสคริปต์", + "import_scripts_description": "รองรับ Postman Collection v2.0/v2.1", + "from_url": "นำเข้าจาก URL", + "gist_url": "ป้อน URL ของ Gist", + "from_har": "นำเข้าจาก HAR", + "from_har_description": "นำเข้าจากไฟล์ HAR", + "from_har_import_summary": "คำขอจะถูกนำเข้าไปยังคอลเลกชันเริ่มต้น", + "gql_collections_from_gist_description": "นำเข้าคอลเลกชัน GraphQL จาก Gist", + "hoppscotch_environment": "สภาพแวดล้อม Hoppscotch", + "hoppscotch_environment_description": "นำเข้าไฟล์ JSON สภาพแวดล้อมของ Hoppscotch", + "import_from_url_invalid_fetch": "ไม่สามารถดึงข้อมูลจาก URL ได้", + "import_from_url_invalid_file_format": "เกิดข้อผิดพลาดขณะนำเข้าคอลเลกชัน", + "import_from_url_invalid_type": "ประเภทที่ไม่รองรับ ค่าที่ยอมรับได้คือ 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "นำเข้าคอลเลกชันแล้ว", + "insomnia_environment_description": "นำเข้าสภาพแวดล้อม Insomnia จากไฟล์ JSON/YAML", + "json_description": "นำเข้าคอลเลกชันจากไฟล์ JSON คอลเลกชันของ Hoppscotch", + "postman_environment": "สภาพแวดล้อม Postman", + "postman_environment_description": "นำเข้าสภาพแวดล้อม Postman จากไฟล์ JSON", + "title": "นำเข้า", + "file_size_limit_exceeded_warning_multiple_files": "ไฟล์ที่เลือกเกินขีดจำกัดที่แนะนำ {sizeLimit}MB จะนำเข้าเฉพาะ {files} ไฟล์แรกที่เลือกเท่านั้น", + "file_size_limit_exceeded_warning_single_file": "ไฟล์ที่เลือกในขณะนี้เกินขีดจำกัดที่แนะนำ {sizeLimit}MB กรุณาเลือกไฟล์อื่น", + "success": "นำเข้าสำเร็จ", + "import_summary_collections_title": "คอลเลกชัน", + "import_summary_requests_title": "คำขอ", + "import_summary_responses_title": "การตอบกลับ", + "import_summary_pre_request_scripts_title": "สคริปต์ก่อนคำขอ", + "import_summary_post_request_scripts_title": "สคริปต์หลังคำขอ", + "import_summary_not_supported_by_hoppscotch_import": "ขณะนี้เรายังไม่รองรับการนำเข้า {featureLabel} จากแหล่งนี้", + "import_summary_script_found": "พบสคริปต์แต่ไม่ได้นำเข้า", + "import_summary_scripts_found": "พบสคริปต์แต่ไม่ได้นำเข้า", + "import_summary_enable_experimental_sandbox": "หากต้องการนำเข้าสคริปต์ Postman ให้เปิดใช้งาน 'Experimental scripting sandbox' ในการตั้งค่า หมายเหตุ: ฟีเจอร์นี้อยู่ในขั้นทดลอง", + "cors_error_modal": { + "title": "ตรวจพบข้อผิดพลาด CORS", + "description": "การนำเข้าล้มเหลวเนื่องจากข้อจำกัด CORS (Cross-Origin Resource Sharing) ที่กำหนดโดยเซิร์ฟเวอร์", + "explanation": "นี่เป็นฟีเจอร์ด้านความปลอดภัยที่ป้องกันไม่ให้หน้าเว็บส่งคำขอไปยังโดเมนอื่น คุณสามารถลองใหม่โดยใช้บริการพร็อกซีของเราเพื่อหลีกเลี่ยงข้อจำกัดนี้ได้", + "url_label": "URL ที่พยายามเข้าถึง", + "retry_with_proxy": "ลองใหม่ด้วยพร็อกซี" + } + }, + "instances": { + "switch": "สลับอินสแตนซ์ Hoppscotch", + "enter_server_url": "เชื่อมต่อกับอินสแตนซ์ที่โฮสต์เอง", + "already_connected": "คุณเชื่อมต่อกับอินสแตนซ์นี้อยู่แล้ว", + "recent_connections": "การเชื่อมต่อล่าสุด", + "add_instance": "เพิ่มอินสแตนซ์", + "add_new": "เพิ่มอินสแตนซ์ใหม่", + "confirm_remove": "ยืนยันการนำออก", + "remove_warning": "คุณแน่ใจหรือไม่ว่าต้องการนำอินสแตนซ์นี้ออก?", + "clear_cached_bundles": "ล้างบันเดิลที่แคชไว้", + "opening_add_modal": "กำลังเปิดกล่องโต้ตอบเพิ่มอินสแตนซ์", + "closed_add_modal": "ปิดกล่องโต้ตอบเพิ่มอินสแตนซ์แล้ว", + "cancelled_removal": "ยกเลิกการนำอินสแตนซ์ออกแล้ว", + "connection_cancelled": "การเชื่อมต่อถูกยกเลิกโดยการตรวจสอบก่อนเชื่อมต่อ", + "post_connect_completed": "การตั้งค่าหลังการเชื่อมต่อเสร็จสมบูรณ์", + "connecting": "กำลังเชื่อมต่อกับอินสแตนซ์...", + "confirm_removal": "ยืนยันการนำอินสแตนซ์ออก", + "removal_cancelled": "การนำอินสแตนซ์ออกถูกยกเลิกโดยการตรวจสอบก่อนนำออก", + "post_remove_completed": "การล้างข้อมูลหลังการนำออกเสร็จสมบูรณ์", + "removing": "กำลังนำอินสแตนซ์ออก...", + "clearing_cache": "กำลังล้างแคช...", + "initialized": "เริ่มต้นตัวสลับอินสแตนซ์แล้ว", + "connecting_state": "กำลังสร้างการเชื่อมต่อ...", + "connected_state": "เชื่อมต่อกับอินสแตนซ์สำเร็จ", + "disconnected_state": "ตัดการเชื่อมต่อจากอินสแตนซ์แล้ว", + "stream_error": "การตรวจสอบสถานะการเชื่อมต่อล้มเหลว", + "recent_instances_error": "ไม่สามารถโหลดอินสแตนซ์ล่าสุดได้", + "instance_changed": "สลับไปยังอินสแตนซ์แล้ว", + "current_instance_error": "ไม่สามารถติดตามอินสแตนซ์ปัจจุบันได้", + "not_available": "ไม่สามารถใช้การสลับอินสแตนซ์ได้", + "cleanup_completed": "การล้างข้อมูลตัวสลับอินสแตนซ์เสร็จสมบูรณ์", + "self_hosted": "อินสแตนซ์ที่โฮสต์เอง" + }, + "inspections": { + "description": "ตรวจสอบข้อผิดพลาดที่อาจเกิดขึ้น", + "environment": { + "add_environment": "เพิ่มลงในสภาพแวดล้อม", + "add_environment_value": "เพิ่มค่า", + "empty_value": "ค่าสภาพแวดล้อมว่างเปล่าสำหรับตัวแปร '{variable}' ", + "not_found": "ไม่พบตัวแปรสภาพแวดล้อม '{environment}'" + }, + "header": { + "cookie": "เบราว์เซอร์ไม่อนุญาตให้ Hoppscotch ตั้งค่าส่วนหัว Cookie โปรดใช้ส่วนหัวการให้สิทธิ์แทน อย่างไรก็ตาม แอป Hoppscotch Desktop ของเราเปิดให้ใช้งานแล้วและรองรับ Cookies" + }, + "response": { + "401_error": "โปรดตรวจสอบข้อมูลรับรองการตรวจสอบสิทธิ์ของคุณ", + "404_error": "โปรดตรวจสอบ URL ของคำขอและชนิดเมธอด", + "cors_error": "โปรดตรวจสอบการกำหนดค่า Cross-Origin Resource Sharing ของคุณ", + "default_error": "โปรดตรวจสอบคำขอของคุณ", + "network_error": "โปรดตรวจสอบการเชื่อมต่อเครือข่ายของคุณ" + }, + "title": "ตัวตรวจสอบ", + "url": { + "extension_not_installed": "ไม่ได้ติดตั้งส่วนขยาย", + "extension_unknown_origin": "ตรวจสอบให้แน่ใจว่าคุณได้เพิ่ม origin ของจุดสิ้นสุด API ลงในรายการ Hoppscotch Browser Extension แล้ว", + "extention_enable_action": "เปิดใช้งาน Browser Extension", + "extention_not_enabled": "ไม่ได้เปิดใช้งานส่วนขยาย", + "localaccess_unsupported": "ตัวสกัดกั้นปัจจุบันไม่รองรับการเข้าถึงภายในเครื่อง โปรดพิจารณาใช้ตัวสกัดกั้น Agent, Extension หรือแอป Desktop" + }, + "auth": { + "digest": "แนะนำให้ใช้ตัวสกัดกั้น Agent บนเว็บแอปหรือตัวสกัดกั้น Native บนแอป Desktop เมื่อใช้การให้สิทธิ์ Digest", + "hawk": "แนะนำให้ใช้ตัวสกัดกั้น Agent บนเว็บแอปหรือตัวสกัดกั้น Native บนแอป Desktop เมื่อใช้การให้สิทธิ์ Hawk" + }, + "body": { + "binary": "ยังไม่รองรับการส่งข้อมูลไบนารีผ่านตัวสกัดกั้นปัจจุบัน" + }, + "scripting_interceptor": { + "pre_request": "สคริปต์ก่อนคำขอ", + "post_request": "สคริปต์หลังคำขอ", + "both_scripts": "สคริปต์ก่อนคำขอและหลังคำขอ", + "unsupported_interceptor": "{scriptType} ของคุณใช้ {apiUsed} เพื่อการเรียกใช้สคริปต์ที่เชื่อถือได้ โปรดเปลี่ยนไปใช้ตัวสกัดกั้น Agent (เว็บแอป) หรือตัวสกัดกั้น Native (แอป Desktop) ตัวสกัดกั้น {interceptor} รองรับการเรียกใช้สคริปต์คำขอได้จำกัดและอาจทำงานไม่เป็นไปตามที่คาดไว้", + "same_origin_csrf_warning": "คำเตือนความปลอดภัย: {scriptType} ของคุณส่งคำขอแบบ same-origin โดยใช้ {apiUsed} เนื่องจากแพลตฟอร์มนี้ใช้การตรวจสอบสิทธิ์แบบคุกกี้ คำขอเหล่านี้จะรวมคุกกี้เซสชันของคุณโดยอัตโนมัติ ซึ่งอาจทำให้สคริปต์ที่เป็นอันตรายดำเนินการที่ไม่ได้รับอนุญาตได้ โปรดใช้ตัวสกัดกั้น Agent สำหรับคำขอแบบ same-origin หรือเรียกใช้เฉพาะสคริปต์ที่คุณเชื่อถือเท่านั้น" + } + }, + "interceptor": { + "native": { + "name": "เนทีฟ", + "settings_title": "เนทีฟ" + }, + "agent": { + "name": "Agent", + "settings_title": "Agent" + }, + "proxy": { + "name": "Proxy", + "settings_title": "Proxy" + }, + "browser": { + "name": "เบราว์เซอร์", + "settings_title": "เบราว์เซอร์" + }, + "extension": { + "name": "ส่วนขยาย", + "settings_title": "ส่วนขยาย" + } + }, + "layout": { + "collapse_collection": "ยุบหรือขยายคอลเลกชัน", + "collapse_sidebar": "ยุบหรือขยายแถบด้านข้าง", + "column": "เลย์เอาต์แนวตั้ง", + "name": "เลย์เอาต์", + "row": "เลย์เอาต์แนวนอน" + }, + "modal": { + "close_unsaved_tab": "คุณมีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก", + "collections": "คอลเลกชัน", + "confirm": "ยืนยัน", + "customize_request": "ปรับแต่งคำขอ", + "edit_request": "แก้ไขคำขอ", + "edit_response": "แก้ไขการตอบกลับ", + "import_export": "นำเข้า / ส่งออก", + "response_name": "ชื่อการตอบกลับ", + "share_request": "แชร์คำขอ" + }, + "mqtt": { + "already_subscribed": "คุณได้สมัครรับข้อมูลหัวข้อนี้แล้ว", + "clean_session": "Clean Session", + "clear_input": "ล้างอินพุต", + "clear_input_on_send": "ล้างอินพุตเมื่อส่ง", + "client_id": "Client ID", + "color": "เลือกสี", + "communication": "การสื่อสาร", + "connection_config": "การกำหนดค่าการเชื่อมต่อ", + "connection_not_authorized": "การเชื่อมต่อ MQTT นี้ไม่ได้ใช้การตรวจสอบสิทธิ์ใดๆ", + "invalid_topic": "โปรดระบุหัวข้อสำหรับการสมัครรับข้อมูล", + "keep_alive": "Keep Alive", + "log": "บันทึก", + "lw_message": "ข้อความ Last-Will", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "หัวข้อ Last-Will", + "message": "ข้อความ", + "new": "การสมัครรับข้อมูลใหม่", + "not_connected": "โปรดเริ่มการเชื่อมต่อ MQTT ก่อน", + "publish": "เผยแพร่", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "สมัครรับข้อมูล", + "topic": "หัวข้อ", + "topic_name": "ชื่อหัวข้อ", + "topic_title": "เผยแพร่ / สมัครรับข้อมูลหัวข้อ", + "unsubscribe": "ยกเลิกการสมัครรับข้อมูล", + "url": "URL" + }, + "navigation": { + "admin_dashboard": "แดชบอร์ด", + "doc": "เอกสาร", + "graphql": "GraphQL", + "profile": "โปรไฟล์", + "realtime": "เรียลไทม์", + "rest": "REST", + "mock_servers": "Mock Server", + "settings": "การตั้งค่า", + "goto_app": "ไปที่แอป", + "authentication": "การยืนยันตัวตน" + }, + "mock_server": { + "confirm_delete_log": "คุณแน่ใจหรือไม่ว่าต้องการลบบันทึกนี้?", + "create_mock_server": "กำหนดค่า Mock Server", + "mock_server_configuration": "การกำหนดค่า Mock Server", + "mock_server_name": "ชื่อ Mock Server", + "mock_server_name_placeholder": "กรอกชื่อ mock server", + "base_url": "Base URL", + "start_server": "เริ่มเซิร์ฟเวอร์", + "stop_server": "หยุดเซิร์ฟเวอร์", + "mock_server_created": "สร้าง mock server สำเร็จแล้ว", + "mock_server_started": "เริ่ม mock server สำเร็จแล้ว", + "mock_server_stopped": "หยุด mock server สำเร็จแล้ว", + "active": "Mock server กำลังทำงาน", + "inactive": "Mock server ไม่ได้ทำงาน", + "edit_mock_server": "แก้ไข Mock Server", + "path_based_url": "URL แบบอิงพาธ", + "subdomain_based_url": "URL แบบอิงซับโดเมน", + "mock_server_updated": "อัปเดต mock server สำเร็จแล้ว", + "no_collection": "ไม่มีคอลเลกชัน", + "collection_deleted": "คอลเลกชันที่เชื่อมโยงถูกลบแล้ว", + "private_access_hint": "สำหรับ mock server แบบส่วนตัว ให้ใส่ส่วนหัว 'x-api-key' พร้อม Personal Access Token ของคุณ (สร้างได้จากโปรไฟล์ของคุณ)", + "private_access_instruction": "หากต้องการเข้าถึง mock server ส่วนตัวนี้ ให้ใส่ส่วนหัว 'x-api-key' พร้อม Personal Access Token ของคุณ", + "create_token_here": "สร้างที่นี่", + "status": "สถานะ", + "server_running": "เซิร์ฟเวอร์กำลังทำงาน", + "server_stopped": "เซิร์ฟเวอร์หยุดทำงาน", + "delay_ms": "การหน่วงเวลาการตอบกลับ (มิลลิวินาที)", + "delay_placeholder": "กรอกการหน่วงเวลาเป็นมิลลิวินาที", + "delay_description": "เพิ่มการหน่วงเวลาเทียมให้กับการตอบกลับจำลอง", + "public_access": "การเข้าถึงสาธารณะ", + "public": "สาธารณะ", + "private": "ส่วนตัว", + "public_description": "ใครก็ตามที่มี URL สามารถเข้าถึง mock server นี้ได้", + "private_description": "เฉพาะผู้ใช้ที่ยืนยันตัวตนแล้วเท่านั้นที่สามารถเข้าถึง mock server นี้ได้", + "select_collection": "เลือกคอลเลกชัน", + "select_collection_error": "โปรดเลือกคอลเลกชัน", + "invalid_collection_error": "ไม่สามารถสร้าง mock server สำหรับคอลเลกชันได้", + "url_copied": "คัดลอก URL ไปยังคลิปบอร์ดแล้ว", + "make_public": "ทำให้เป็นสาธารณะ", + "view_logs": "ดูบันทึก", + "logs_title": "บันทึกของ Mock Server", + "no_logs": "ไม่มีบันทึก", + "request_headers": "ส่วนหัวของคำขอ", + "request_body": "เนื้อหาของคำขอ", + "response_status": "สถานะการตอบกลับ", + "response_headers": "ส่วนหัวของการตอบกลับ", + "response_body": "เนื้อหาของการตอบกลับ", + "log_deleted": "ลบบันทึกสำเร็จแล้ว", + "description": "Mock server ช่วยให้คุณจำลองการตอบกลับของ API โดยอิงจากตัวอย่างการตอบกลับในคอลเลกชันของคุณ", + "set_in_environment": "ตั้งค่าในสภาพแวดล้อม", + "set_in_environment_hint": "URL ของ mock server จะถูกเพิ่มเป็นตัวแปร 'mockUrl' ในสภาพแวดล้อมของคอลเลกชันโดยอัตโนมัติ", + "environment_variable_added": "เพิ่ม Mock URL ไปยังสภาพแวดล้อมแล้ว", + "environment_variable_updated": "อัปเดต Mock URL ในสภาพแวดล้อมแล้ว", + "environment_created_with_variable": "สร้างสภาพแวดล้อมพร้อม Mock URL แล้ว", + "add_example_request": "เพิ่มคำขอตัวอย่าง", + "add_example_request_hint": "คอลเลกชันจะถูกสร้างพร้อมคำขอตัวอย่างที่สาธิตวิธีใช้ mock server", + "create_example_collection": "สร้างคอลเลกชันตัวอย่าง", + "create_example_collection_hint": "สร้างคอลเลกชันตัวอย่างร้านขายสัตว์เลี้ยงพร้อมคำขอตัวอย่าง (GET, POST, PUT, DELETE)", + "creating_example_collection": "กำลังสร้างคอลเลกชันตัวอย่าง...", + "failed_to_create_collection": "ไม่สามารถสร้างคอลเลกชันตัวอย่างได้", + "enable_example_collection_hint": "โปรดเปิดใช้งานสวิตช์ 'สร้างคอลเลกชันตัวอย่าง' สำหรับโหมดคอลเลกชันใหม่", + "new_collection_name_hint": "คอลเลกชันจะถูกสร้างโดยใช้ชื่อเดียวกับ mock server ของคุณ", + "existing_collection": "คอลเลกชันที่มีอยู่", + "new_collection": "คอลเลกชันใหม่" + }, + "preRequest": { + "javascript_code": "โค้ด JavaScript", + "learn": "อ่านเอกสารประกอบ", + "script": "สคริปต์ก่อนคำขอ", + "snippets": "ตัวอย่างโค้ด" + }, + "profile": { + "app_settings": "การตั้งค่าแอป", + "default_hopp_displayname": "ผู้ใช้ที่ไม่มีชื่อ", + "editor": "ผู้แก้ไข", + "editor_description": "ผู้แก้ไขสามารถเพิ่ม แก้ไข และลบคำขอได้", + "email_verification_mail": "ส่งอีเมลยืนยันไปยังที่อยู่อีเมลของคุณแล้ว โปรดคลิกที่ลิงก์เพื่อยืนยันที่อยู่อีเมลของคุณ", + "no_permission": "คุณไม่มีสิทธิ์ในการดำเนินการนี้", + "owner": "เจ้าของ", + "owner_description": "เจ้าของสามารถเพิ่ม แก้ไข และลบคำขอ คอลเลกชัน และสมาชิกพื้นที่ทำงานได้", + "roles": "บทบาท", + "roles_description": "บทบาทใช้เพื่อควบคุมการเข้าถึงคอลเลกชันที่แชร์", + "updated": "อัปเดตโปรไฟล์แล้ว", + "viewer": "ผู้ดู", + "viewer_description": "ผู้ดูสามารถดูและใช้คำขอได้เท่านั้น", + "verified_email_sent": "ส่งอีเมลยืนยันไปยังที่อยู่อีเมลของคุณแล้ว โปรดรีเฟรชหน้าหลังจากยืนยันที่อยู่อีเมลของคุณ คุณจะได้รับอีเมลหากอีเมลนี้ไม่ได้เชื่อมโยงกับบัญชีอื่นใด" + }, + "remove": { + "star": "นำดาวออก" + }, + "request": { + "added": "เพิ่มคำขอแล้ว", + "add": "เพิ่มคำขอ", + "authorization": "การให้สิทธิ์", + "body": "เนื้อหาของคำขอ", + "choose_language": "เลือกภาษา", + "content_type": "Content Type", + "content_type_titles": { + "others": "อื่นๆ", + "structured": "แบบมีโครงสร้าง", + "text": "ข้อความ", + "binary": "ไบนารี" + }, + "show_content_type": "แสดง Content Type", + "different_collection": "ไม่สามารถจัดเรียงคำขอจากคอลเลกชันต่างกันได้", + "duplicated": "ทำสำเนาคำขอแล้ว", + "duration": "ระยะเวลา", + "enter_curl": "กรอกคำสั่ง cURL", + "generate_code": "สร้างโค้ด", + "generated_code": "โค้ดที่สร้างขึ้น", + "go_to_authorization_tab": "ไปที่แท็บการให้สิทธิ์", + "go_to_body_tab": "ไปที่แท็บเนื้อหา", + "header_list": "รายการส่วนหัว", + "invalid_name": "โปรดระบุชื่อสำหรับคำขอ", + "method": "เมธอด", + "moved": "ย้ายคำขอแล้ว", + "name": "ชื่อคำขอ", + "new": "คำขอใหม่", + "order_changed": "อัปเดตลำดับคำขอแล้ว", + "override": "แทนที่", + "override_help": "ตั้งค่า Content-Type ในส่วนหัว", + "overriden": "แทนที่แล้ว", + "parameter_list": "พารามิเตอร์เคียวรี", + "parameters": "พารามิเตอร์", + "path": "พาธ", + "payload": "เพย์โหลด", + "query": "เคียวรี", + "raw_body": "เนื้อหาของคำขอแบบดิบ", + "rename": "เปลี่ยนชื่อคำขอ", + "renamed": "เปลี่ยนชื่อคำขอแล้ว", + "request_variables": "ตัวแปรของคำขอ", + "response_name_exists": "ชื่อการตอบกลับมีอยู่แล้ว", + "run": "เรียกใช้", + "save": "บันทึก", + "save_as": "บันทึกเป็น", + "saved": "บันทึกคำขอแล้ว", + "share": "แชร์", + "share_description": "แชร์ Hoppscotch กับเพื่อนของคุณ", + "share_request": "แชร์คำขอ", + "stop": "หยุด", + "title": "คำขอ", + "type": "ประเภทคำขอ", + "url": "URL", + "url_placeholder": "กรอก URL หรือวางคำสั่ง cURL", + "variables": "ตัวแปร", + "view_my_links": "ดูลิงก์ของฉัน", + "generate_name_error": "ไม่สามารถสร้างชื่อคำขอได้" + }, + "response": { + "audio": "เสียง", + "body": "เนื้อหาการตอบกลับ", + "duplicated": "ทำสำเนาการตอบกลับแล้ว", + "duplicate_name_error": "มีการตอบกลับชื่อเดียวกันอยู่แล้ว", + "filter_response_body": "กรองเนื้อหาการตอบกลับ JSON (ใช้ไวยากรณ์ jq)", + "headers": "ส่วนหัว", + "request_headers": "ส่วนหัวคำขอ", + "html": "HTML", + "image": "รูปภาพ", + "json": "JSON", + "pdf": "PDF", + "please_save_request": "บันทึกคำขอเพื่อสร้างตัวอย่าง", + "preview_html": "ดูตัวอย่าง HTML", + "raw": "ดิบ", + "renamed": "เปลี่ยนชื่อการตอบกลับแล้ว", + "same_name_inspector_warning": "มีชื่อการตอบกลับนี้อยู่แล้วสำหรับคำขอนี้ หากบันทึกจะเขียนทับการตอบกลับที่มีอยู่", + "size": "ขนาด", + "status": "สถานะ", + "time": "เวลา", + "title": "การตอบกลับ", + "video": "วิดีโอ", + "waiting_for_connection": "กำลังรอการเชื่อมต่อ", + "xml": "XML", + "generate_data_schema": "สร้างสคีมาข้อมูล", + "data_schema": "สคีมาข้อมูล", + "saved": "บันทึกการตอบกลับแล้ว", + "invalid_name": "โปรดระบุชื่อสำหรับการตอบกลับ" + }, + "script": { + "inheriting": "กำลังสืบทอดสคริปต์จาก", + "inheriting_from_count": "สืบทอดจาก {count} คอลเลกชัน | สืบทอดจาก {count} คอลเลกชัน", + "inherited_scripts": "สคริปต์ที่สืบทอด", + "view_inherited": "ดูสคริปต์ที่สืบทอด" + }, + "settings": { + "accent_color": "สีเน้น", + "account": "บัญชี", + "account_deleted": "บัญชีของคุณถูกลบแล้ว", + "account_description": "ปรับแต่งการตั้งค่าบัญชีของคุณ", + "account_email_description": "ที่อยู่อีเมลหลักของคุณ", + "account_name_description": "นี่คือชื่อที่แสดงของคุณ", + "additional": "การตั้งค่าเพิ่มเติม", + "agent_not_running": "ไม่พบ Hoppscotch Agent โปรดตรวจสอบว่า Agent กำลังทำงานอยู่หรือไม่", + "agent_not_running_short": "ตรวจสอบสถานะของ Agent", + "agent_running": "Hoppscotch Agent กำลังทำงานอยู่", + "agent_running_short": "Hoppscotch Agent กำลังทำงานอยู่", + "agent_discard_registration": "ยกเลิกการลงทะเบียน Agent", + "agent_registered": "ลงทะเบียน Agent แล้ว", + "agent_registration_successful": "ลงทะเบียน Agent สำเร็จ", + "agent_registration_fetch_failed": "ไม่สามารถดึงข้อมูลการลงทะเบียน Agent ได้ โปรดลงทะเบียน Agent ใหม่อีกครั้ง", + "agent_registration_already_in_progress": "การลงทะเบียน Agent กำลังดำเนินการอยู่ โปรดทำให้เสร็จหรือยกเลิกการลงทะเบียนปัจจุบันแล้วลองอีกครั้ง", + "auto_encode_mode": "อัตโนมัติ", + "auto_encode_mode_tooltip": "เข้ารหัสพารามิเตอร์ในคำขอเฉพาะเมื่อมีอักขระพิเศษบางตัวอยู่เท่านั้น", + "background": "พื้นหลัง", + "black_mode": "ดำ", + "choose_language": "เลือกภาษา", + "dark_mode": "มืด", + "delete_account": "ลบบัญชี", + "delete_account_description": "เมื่อคุณลบบัญชี ข้อมูลทั้งหมดของคุณจะถูกลบอย่างถาวร การกระทำนี้ไม่สามารถยกเลิกได้", + "desktop": "เดสก์ท็อป", + "desktop_description": "อัปเดตพฤติกรรม การจัดการคีย์บอร์ด และการตั้งค่าการแสดงผลสำหรับแอป Hoppscotch บนเดสก์ท็อป", + "desktop_display": "การแสดงผล", + "desktop_keyboard": "คีย์บอร์ด", + "desktop_keyboard_strategy_label": "จับคู่ทางลัดตามตัวอักษรที่พิมพ์หรือตำแหน่งทางกายภาพ", + "desktop_keyboard_strategy_description": "บนเลย์เอาต์ที่ไม่ใช่ QWERTY ตัวอักษรเดียวกันอาจมาจากปุ่มทางกายภาพที่ต่างกัน ค่าเริ่มต้นใช้ได้กับเลย์เอาต์ส่วนใหญ่ ให้สลับตัวเลือกหากทางลัดไม่ทำงานตามที่คาดหวังบนเลย์เอาต์ของคุณ", + "desktop_keyboard_strategy_hybrid": "อัจฉริยะ (แนะนำ)", + "desktop_keyboard_strategy_hybrid_description": "ใช้ตัวอักษรที่พิมพ์สำหรับอักขระละติน และย้อนกลับไปใช้ตำแหน่งปุ่มทางกายภาพสำหรับเลย์เอาต์ที่ไม่ใช่ละติน (ซีริลลิก, CJK)", + "desktop_keyboard_strategy_key": "ตัวอักษรที่พิมพ์", + "desktop_keyboard_strategy_key_description": "ใช้ตัวอักษรที่พิมพ์เสมอ เลือกตัวเลือกนี้หากทางลัดไม่ทำงานตามที่คาดหวังบนเลย์เอาต์ของคุณ", + "desktop_keyboard_strategy_code": "ตำแหน่งปุ่มทางกายภาพ", + "desktop_keyboard_strategy_code_description": "ใช้ตำแหน่งทางกายภาพแบบ US-QWERTY เสมอ เลือกตัวเลือกนี้หากคุณคุ้นเคยกับ QWERTY บนเลย์เอาต์ที่ไม่ใช่ละติน", + "desktop_updates": "การอัปเดต", + "disable_encode_mode_tooltip": "ไม่เข้ารหัสพารามิเตอร์ในคำขอเลย", + "disable_update_checks": "ปิดการตรวจสอบการอัปเดตอัตโนมัติ", + "disable_update_checks_description": "ข้ามการตรวจสอบการอัปเดตเมื่อเริ่มแอป ใช้ปุ่มด้านบนเพื่อตรวจสอบตามต้องการ", + "zoom_level": "ระดับการซูม", + "zoom_level_description": "ปรับขนาดอินเทอร์เฟซทั้งหมด ค่าที่สูงขึ้นจะทำให้ข้อความและการควบคุมใหญ่ขึ้นบนหน้าจอความละเอียดสูง", + "zoom_level_100": "100%", + "zoom_level_110": "110%", + "zoom_level_125": "125%", + "zoom_level_150": "150%", + "enable_encode_mode_tooltip": "เข้ารหัสพารามิเตอร์ในคำขอเสมอ", + "enter_otp": "ป้อนรหัสของ Agent", + "expand_navigation": "ขยายการนำทาง", + "experiments": "การทดลอง", + "experiments_notice": "นี่คือชุดการทดลองที่เรากำลังพัฒนาอยู่ ซึ่งอาจกลายเป็นสิ่งที่มีประโยชน์ สนุก ทั้งสองอย่าง หรือไม่เลยก็ได้ สิ่งเหล่านี้ยังไม่เป็นที่สิ้นสุดและอาจไม่เสถียร ดังนั้นหากมีอะไรแปลก ๆ เกิดขึ้น อย่าตกใจ แค่ปิดมันเสีย พูดเล่นนะ ", + "extension_ver_not_reported": "ไม่ได้รายงาน", + "extension_version": "เวอร์ชันส่วนขยาย", + "extensions": "ส่วนขยายเบราว์เซอร์", + "extensions_use_toggle": "ใช้ส่วนขยายเบราว์เซอร์เพื่อส่งคำขอ (หากมี)", + "follow": "ติดตามเรา", + "general": "ทั่วไป", + "general_description": " การตั้งค่าทั่วไปที่ใช้ในแอปพลิเคชัน", + "interceptor": "Interceptor", + "interceptor_description": "มิดเดิลแวร์ระหว่างแอปพลิเคชันและ API", + "kernel_interceptor": "Interceptor", + "kernel_interceptor_description": "มิดเดิลแวร์ระหว่างแอปพลิเคชันและ API", + "language": "ภาษา", + "light_mode": "สว่าง", + "official_proxy_hosting": "Official Proxy โฮสต์โดย Hoppscotch", + "query_parameters_encoding": "การเข้ารหัสพารามิเตอร์เคียวรี", + "query_parameters_encoding_description": "กำหนดค่าการเข้ารหัสสำหรับพารามิเตอร์เคียวรีในคำขอ", + "profile": "โปรไฟล์", + "profile_description": "อัปเดตรายละเอียดโปรไฟล์ของคุณ", + "profile_email": "ที่อยู่อีเมล", + "profile_name": "ชื่อโปรไฟล์", + "profile_photo": "รูปโปรไฟล์", + "proxy": "Proxy", + "proxy_url": "Proxy URL", + "proxy_url_invalid": "Proxy URL ต้องขึ้นต้นด้วย http(s):// และไม่มีช่องว่าง", + "proxy_use_toggle": "ใช้มิดเดิลแวร์ proxy เพื่อส่งคำขอ", + "read_the": "อ่าน", + "register_agent": "ลงทะเบียน Agent", + "reset_default": "ใช้ Proxy เริ่มต้น", + "short_codes": "รหัสย่อ", + "short_codes_description": "รหัสย่อที่คุณสร้างขึ้น", + "sidebar_on_left": "แถบด้านข้างทางซ้าย", + "ai_experiments": "การทดลอง AI", + "ai_request_naming_style": "รูปแบบการตั้งชื่อคำขอ", + "ai_request_naming_style_descriptive_with_spaces": "อธิบายพร้อมเว้นวรรค", + "ai_request_naming_style_camel_case": "Camel Case ( camelCase )", + "ai_request_naming_style_snake_case": "Snake Case ( snake_case )", + "ai_request_naming_style_pascal_case": "Pascal Case ( PascalCase )", + "ai_request_naming_style_custom": "กำหนดเอง", + "ai_request_naming_style_custom_placeholder": "ป้อนเทมเพลตรูปแบบการตั้งชื่อที่กำหนดเองของคุณ...", + "experimental_scripting_sandbox": "แซนด์บ็อกซ์สำหรับเขียนสคริปต์ (ทดลอง)", + "enable_experimental_mock_servers": "เปิดใช้งาน Mock Servers", + "enable_experimental_documentation": "เปิดใช้งานเอกสารประกอบ", + "sync": "ซิงโครไนซ์", + "sync_collections": "คอลเลกชัน", + "sync_description": "การตั้งค่าเหล่านี้จะถูกซิงค์ไปยังคลาวด์", + "sync_environments": "สภาพแวดล้อม", + "sync_history": "ประวัติ", + "history_disabled": "ประวัติถูกปิดใช้งาน โปรดติดต่อผู้ดูแลระบบขององค์กรเพื่อเปิดใช้งานประวัติ", + "system_mode": "ระบบ", + "telemetry": "เทเลเมทรี", + "telemetry_helps_us": "เทเลเมทรีช่วยให้เราปรับแต่งการดำเนินงานและมอบประสบการณ์ที่ดีที่สุดให้กับคุณ", + "theme": "ธีม", + "theme_description": "ปรับแต่งธีมของแอปพลิเคชันของคุณ", + "update_check_description": "ตรวจสอบเวอร์ชันใหม่และติดตั้งด้วยตนเอง ทำงานได้ไม่ว่าจะตั้งค่าสวิตช์ด้านล่างไว้อย่างไร", + "update_check_now": "ตรวจสอบการอัปเดต", + "update_checking": "กำลังตรวจสอบ…", + "update_download_version": "ดาวน์โหลด v{version}", + "update_downloading": "กำลังดาวน์โหลด…", + "update_downloading_percent": "กำลังดาวน์โหลด {percent}%", + "update_installing": "กำลังติดตั้ง…", + "update_restart_now": "รีสตาร์ทเพื่อใช้การอัปเดต", + "update_up_to_date": "เป็นเวอร์ชันล่าสุดแล้ว", + "use_experimental_url_bar": "ใช้แถบ URL แบบทดลองพร้อมการเน้นสภาพแวดล้อม", + "user": "ผู้ใช้", + "verified_email": "อีเมลที่ยืนยันแล้ว", + "verify_email": "ยืนยันอีเมล", + "validate_certificates": "ตรวจสอบใบรับรอง SSL/TLS", + "verify_host": "ยืนยันโฮสต์", + "verify_peer": "ยืนยันเพียร์", + "follow_redirects": "ติดตามการเปลี่ยนเส้นทาง", + "client_certificates": "ใบรับรองไคลเอนต์", + "certificate_settings": "การตั้งค่าใบรับรอง", + "certificate": "ใบรับรอง", + "key": "คีย์ส่วนตัว", + "pfx_or_p12": "PFX/PKCS#12", + "password": "รหัสผ่าน", + "select_file": "เลือกไฟล์", + "domain": "โดเมน", + "add_certificate": "เพิ่มใบรับรอง", + "add_cert_file": "เพิ่มไฟล์ใบรับรอง", + "add_key_file": "เพิ่มไฟล์คีย์", + "add_pfx_file": "เพิ่มไฟล์ PFX", + "global_defaults": "ค่าเริ่มต้นส่วนกลาง", + "add_domain_override": "เพิ่มการแทนที่โดเมน", + "domain_override": "การแทนที่โดเมน", + "manage_domains_overrides": "จัดการการแทนที่โดเมน", + "add_domain": "เพิ่มโดเมน", + "remove_domain": "นำโดเมนออก", + "ca_certificate": "ใบรับรอง CA", + "ca_certificates": "ใบรับรอง CA", + "ca_certificates_support": "Hoppscotch รองรับไฟล์ .crt, .cer หรือ .pem ที่มีใบรับรองหนึ่งรายการหรือมากกว่า", + "proxy_capabilities": "Hoppscotch Agent และแอปเดสก์ท็อปรองรับ proxy แบบ HTTP/HTTPS/SOCKS พร้อมการรองรับ NTLM และ Basic Auth", + "proxy_auth": "คุณยังสามารถใส่ชื่อผู้ใช้และรหัสผ่านใน URL ได้ด้วย" + }, + "shared_requests": { + "button": "ปุ่ม", + "button_info": "สร้างปุ่ม 'Run in Hoppscotch' สำหรับเว็บไซต์ บล็อก หรือ README ของคุณ", + "copy_html": "คัดลอก HTML", + "copy_link": "คัดลอกลิงก์", + "copy_markdown": "คัดลอก Markdown", + "creating_widget": "กำลังสร้างวิดเจ็ต", + "customize": "ปรับแต่ง", + "deleted": "ลบคำขอที่แชร์แล้ว", + "description": "เลือกวิดเจ็ต คุณสามารถเปลี่ยนและปรับแต่งได้ในภายหลัง", + "embed": "ฝัง", + "embed_info": "เพิ่ม 'Hoppscotch API Playground' ขนาดเล็กลงในเว็บไซต์ บล็อก หรือเอกสารของคุณ", + "link": "ลิงก์", + "link_info": "สร้างลิงก์ที่แชร์ได้เพื่อแชร์กับใครก็ตามบนอินเทอร์เน็ตด้วยสิทธิ์การดู", + "modified": "แก้ไขคำขอที่แชร์แล้ว", + "not_found": "ไม่พบคำขอที่แชร์", + "open_new_tab": "เปิดในแท็บใหม่", + "preview": "ดูตัวอย่าง", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "มืด", + "light": "สว่าง", + "system": "ระบบ", + "title": "ธีม" + }, + "action": "การกระทำ", + "clear_filter": "ล้างตัวกรอง", + "confirm_request_deletion": "ยืนยันการลบคำขอที่แชร์ที่เลือก?", + "copy": "คัดลอก", + "created_on": "สร้างเมื่อ", + "delete": "ลบ", + "email": "อีเมล", + "filter": "ตัวกรอง", + "filter_by_email": "กรองตามอีเมล", + "id": "ID", + "load_list_error": "ไม่สามารถโหลดรายการคำขอที่แชร์ได้", + "no_requests": "ไม่พบคำขอที่แชร์", + "open_request": "เปิดคำขอ", + "properties": "คุณสมบัติ", + "request": "คำขอ", + "show_more": "แสดงเพิ่มเติม", + "title": "คำขอที่แชร์", + "url": "URL" + }, + "shortcut": { + "general": { + "close_current_menu": "ปิดเมนูปัจจุบัน", + "command_menu": "เมนูค้นหาและคำสั่ง", + "help_menu": "เมนูช่วยเหลือ", + "show_all": "แป้นพิมพ์ลัด", + "title": "ทั่วไป", + "comment_uncomment": "ใส่/ยกเลิกคอมเมนต์", + "close_tab": "ปิดแท็บ", + "undo": "เลิกทำ", + "redo": "ทำซ้ำ" + }, + "miscellaneous": { + "invite": "เชิญผู้คนเข้าสู่ Hoppscotch", + "title": "อื่น ๆ" + }, + "navigation": { + "back": "กลับไปยังหน้าก่อนหน้า", + "documentation": "ไปยังหน้าเอกสารประกอบ", + "forward": "ไปยังหน้าถัดไป", + "graphql": "ไปยังหน้า GraphQL", + "profile": "ไปยังหน้าโปรไฟล์", + "realtime": "ไปยังหน้า Realtime", + "rest": "ไปยังหน้า REST", + "settings": "ไปยังหน้าการตั้งค่า", + "title": "การนำทาง" + }, + "others": { + "prettify": "จัดรูปแบบเนื้อหาของเอดิเตอร์", + "title": "อื่น ๆ" + }, + "request": { + "delete_method": "เลือกเมธอด DELETE", + "get_method": "เลือกเมธอด GET", + "head_method": "เลือกเมธอด HEAD", + "import_curl": "นำเข้า cURL", + "method": "เมธอด", + "next_method": "เลือกเมธอดถัดไป", + "post_method": "เลือกเมธอด POST", + "previous_method": "เลือกเมธอดก่อนหน้า", + "put_method": "เลือกเมธอด PUT", + "rename": "เปลี่ยนชื่อคำขอ", + "reset_request": "รีเซ็ตคำขอ", + "save_request": "บันทึกคำขอ", + "save_to_collections": "บันทึกไปยังคอลเลกชัน", + "send_request": "ส่งคำขอ", + "share_request": "แชร์คำขอ", + "show_code": "สร้างสไนปเป็ตโค้ด", + "title": "คำขอ", + "focus_url": "โฟกัสที่แถบ URL" + }, + "response": { + "copy": "คัดลอกการตอบกลับไปยังคลิปบอร์ด", + "download": "ดาวน์โหลดการตอบกลับเป็นไฟล์", + "title": "การตอบกลับ" + }, + "tabs": { + "title": "แท็บ", + "new_tab": "แท็บใหม่", + "close_tab": "ปิดแท็บ", + "reopen_tab": "เปิดแท็บที่ปิดไปใหม่", + "next_tab": "แท็บถัดไป", + "previous_tab": "แท็บก่อนหน้า", + "first_tab": "สลับไปยังแท็บแรก", + "last_tab": "สลับไปยังแท็บสุดท้าย", + "mru_switch": "สลับไปยังแท็บที่ใช้ล่าสุด (MRU)", + "mru_switch_reverse": "สลับไปยังแท็บที่ใช้ล่าสุดก่อนหน้า (MRU)" + }, + "theme": { + "black": "สลับธีมเป็นโหมดดำ", + "dark": "สลับธีมเป็นโหมดมืด", + "light": "สลับธีมเป็นโหมดสว่าง", + "system": "สลับธีมเป็นโหมดตามระบบ", + "title": "ธีม" + } + }, + "show": { + "code": "แสดงโค้ด", + "collection": "ขยายแผงคอลเลกชัน", + "more": "แสดงเพิ่มเติม", + "sidebar": "ขยายแถบด้านข้าง", + "password": "แสดงรหัสผ่าน" + }, + "socketio": { + "communication": "การสื่อสาร", + "connection_not_authorized": "การเชื่อมต่อ SocketIO นี้ไม่ได้ใช้การยืนยันตัวตนใด ๆ", + "event_name": "ชื่ออีเวนต์/หัวข้อ", + "events": "อีเวนต์", + "log": "บันทึก", + "url": "URL" + }, + "spotlight": { + "change_language": "เปลี่ยนภาษา", + "environments": { + "delete": "ลบสภาพแวดล้อมปัจจุบัน", + "duplicate": "ทำสำเนาสภาพแวดล้อมปัจจุบัน", + "duplicate_global": "ทำสำเนาสภาพแวดล้อมส่วนกลาง", + "edit": "แก้ไขสภาพแวดล้อมปัจจุบัน", + "edit_global": "แก้ไขสภาพแวดล้อมส่วนกลาง", + "new": "สร้างสภาพแวดล้อมใหม่", + "new_variable": "สร้างตัวแปรสภาพแวดล้อมใหม่", + "title": "สภาพแวดล้อม" + }, + "general": { + "chat": "แชทกับฝ่ายสนับสนุน", + "help_menu": "ความช่วยเหลือและการสนับสนุน", + "open_docs": "อ่านเอกสาร", + "open_github": "เปิดที่เก็บข้อมูล GitHub", + "open_keybindings": "แป้นพิมพ์ลัด", + "social": "โซเชียล", + "title": "ทั่วไป" + }, + "graphql": { + "connect": "เชื่อมต่อกับเซิร์ฟเวอร์", + "disconnect": "ตัดการเชื่อมต่อจากเซิร์ฟเวอร์" + }, + "miscellaneous": { + "invite": "เชิญเพื่อนของคุณมาใช้ Hoppscotch", + "title": "เบ็ดเตล็ด" + }, + "request": { + "save_as_new": "บันทึกเป็นคำขอใหม่", + "select_method": "เลือกเมธอด", + "switch_to": "สลับไปยัง", + "tab_authorization": "แท็บการให้สิทธิ์", + "tab_body": "แท็บเนื้อหา", + "tab_headers": "แท็บส่วนหัว", + "tab_parameters": "แท็บพารามิเตอร์", + "tab_pre_request_script": "แท็บสคริปต์ก่อนคำขอ", + "tab_query": "แท็บเคียวรี", + "tab_tests": "แท็บการทดสอบ", + "tab_variables": "แท็บตัวแปร" + }, + "response": { + "copy": "คัดลอกการตอบกลับ", + "download": "ดาวน์โหลดการตอบกลับเป็นไฟล์", + "title": "การตอบกลับ" + }, + "section": { + "interceptor": "Interceptor", + "interface": "อินเทอร์เฟซ", + "theme": "ธีม", + "user": "ผู้ใช้" + }, + "settings": { + "change_interceptor": "เปลี่ยน Interceptor", + "change_language": "เปลี่ยนภาษา", + "theme": { + "black": "ดำ", + "dark": "มืด", + "light": "สว่าง", + "system": "ตามการตั้งค่าระบบ" + } + }, + "tab": { + "close_current": "ปิดแท็บปัจจุบัน", + "close_others": "ปิดแท็บอื่นทั้งหมด", + "duplicate": "ทำสำเนาแท็บปัจจุบัน", + "new_tab": "เปิดแท็บใหม่", + "next": "สลับไปยังแท็บถัดไป", + "previous": "สลับไปยังแท็บก่อนหน้า", + "switch_to_first": "สลับไปยังแท็บแรก", + "switch_to_last": "สลับไปยังแท็บสุดท้าย", + "mru_switch": "สลับไปยังแท็บล่าสุด", + "mru_switch_reverse": "สลับไปยังแท็บล่าสุดก่อนหน้า", + "title": "แท็บ" + }, + "workspace": { + "delete": "ลบพื้นที่ทำงานปัจจุบัน", + "edit": "แก้ไขพื้นที่ทำงานปัจจุบัน", + "invite": "เชิญผู้คนเข้าสู่พื้นที่ทำงาน", + "new": "สร้างพื้นที่ทำงานใหม่", + "switch_to_personal": "สลับไปยังพื้นที่ทำงานส่วนตัวของคุณ", + "title": "พื้นที่ทำงาน" + }, + "phrases": { + "try": "ลอง", + "import_collections": "นำเข้าคอลเลกชัน", + "create_environment": "สร้างสภาพแวดล้อม", + "create_workspace": "สร้างพื้นที่ทำงาน", + "share_request": "แชร์คำขอ" + } + }, + "sse": { + "event_type": "ชนิดเหตุการณ์", + "log": "บันทึก", + "url": "URL" + }, + "state": { + "bulk_mode": "แก้ไขแบบกลุ่ม", + "bulk_mode_placeholder": "แต่ละรายการคั่นด้วยการขึ้นบรรทัดใหม่\nคีย์และค่าคั่นด้วย :\nใส่ # นำหน้าแถวที่ต้องการเพิ่มแต่ปิดใช้งานไว้", + "cleared": "ล้างแล้ว", + "connected": "เชื่อมต่อแล้ว", + "connected_to": "เชื่อมต่อกับ {name} แล้ว", + "connecting_to": "กำลังเชื่อมต่อกับ {name}...", + "connection_error": "เชื่อมต่อไม่สำเร็จ", + "connection_failed": "การเชื่อมต่อล้มเหลว", + "connection_lost": "การเชื่อมต่อขาดหาย", + "copied_interface_to_clipboard": "คัดลอกชนิดอินเทอร์เฟซ {language} ไปยังคลิปบอร์ดแล้ว", + "copied_to_clipboard": "คัดลอกไปยังคลิปบอร์ดแล้ว", + "deleted": "ลบแล้ว", + "deprecated": "เลิกใช้แล้ว", + "disabled": "ปิดใช้งานแล้ว", + "disconnected": "ตัดการเชื่อมต่อแล้ว", + "disconnected_from": "ตัดการเชื่อมต่อจาก {name} แล้ว", + "docs_generated": "สร้างเอกสารแล้ว", + "download_failed": "ดาวน์โหลดไม่สำเร็จ", + "download_started": "เริ่มดาวน์โหลดแล้ว", + "enabled": "เปิดใช้งานแล้ว", + "experimental": "ทดลอง", + "file_imported": "นำเข้าไฟล์แล้ว", + "finished_in": "เสร็จสิ้นใน {duration} มิลลิวินาที", + "hide": "ซ่อน", + "history_deleted": "ลบประวัติแล้ว", + "linewrap": "ตัดบรรทัด", + "loading": "กำลังโหลด...", + "message_received": "ข้อความ: {message} มาถึงในหัวข้อ: {topic}", + "mqtt_subscription_failed": "เกิดข้อผิดพลาดขณะสมัครรับข้อมูลหัวข้อ: {topic}", + "no_content_found": "ไม่พบเนื้อหา", + "none": "ไม่มี", + "nothing_found": "ไม่พบสิ่งใดสำหรับ", + "published_error": "เกิดข้อผิดพลาดขณะเผยแพร่ข้อความ: {topic} ไปยังหัวข้อ: {message}", + "published_message": "เผยแพร่ข้อความ: {message} ไปยังหัวข้อ: {topic} แล้ว", + "reconnection_error": "เชื่อมต่อใหม่ไม่สำเร็จ", + "saved": "บันทึกแล้ว", + "show": "แสดง", + "subscribed_failed": "สมัครรับข้อมูลหัวข้อ: {topic} ไม่สำเร็จ", + "subscribed_success": "สมัครรับข้อมูลหัวข้อ: {topic} สำเร็จ", + "unsubscribed_failed": "ยกเลิกการสมัครรับข้อมูลหัวข้อ: {topic} ไม่สำเร็จ", + "unsubscribed_success": "ยกเลิกการสมัครรับข้อมูลหัวข้อ: {topic} สำเร็จ", + "waiting_send_request": "กำลังรอส่งคำขอ", + "user_deactivated": "บัญชีของคุณถูกปิดใช้งาน โปรดติดต่อผู้ดูแลระบบเพื่อเปิดใช้งานบัญชีของคุณอีกครั้ง", + "add_user_failure": "เพิ่มผู้ใช้เข้าพื้นที่ทำงานไม่สำเร็จ!!", + "add_user_success": "ผู้ใช้เป็นสมาชิกของพื้นที่ทำงานแล้ว!!", + "admin_failure": "ตั้งผู้ใช้เป็นผู้ดูแลระบบไม่สำเร็จ!!", + "admin_success": "ผู้ใช้เป็นผู้ดูแลระบบแล้ว!!", + "and": "และ", + "clear_selection": "ล้างการเลือก", + "configure_auth": "โปรดตั้งค่าผู้ให้บริการการตรวจสอบสิทธิ์จากการตั้งค่าผู้ดูแลระบบ หรือดูเอกสารเพื่อกำหนดค่าผู้ให้บริการการตรวจสอบสิทธิ์", + "confirm_admin_to_user": "คุณต้องการนำสถานะผู้ดูแลระบบออกจากผู้ใช้รายนี้หรือไม่?", + "confirm_admins_to_users": "คุณต้องการนำสถานะผู้ดูแลระบบออกจากผู้ใช้ที่เลือกหรือไม่?", + "confirm_delete_infra_token": "คุณแน่ใจหรือไม่ว่าต้องการลบโทเค็นโครงสร้างพื้นฐาน {tokenLabel}?", + "confirm_delete_invite": "คุณต้องการเพิกถอนคำเชิญที่เลือกหรือไม่?", + "confirm_delete_invites": "คุณต้องการเพิกถอนคำเชิญที่เลือกหรือไม่?", + "confirm_user_deletion": "ยืนยันการลบผู้ใช้?", + "confirm_users_deletion": "คุณต้องการลบผู้ใช้ที่เลือกหรือไม่?", + "confirm_user_to_admin": "คุณต้องการตั้งผู้ใช้รายนี้เป็นผู้ดูแลระบบหรือไม่?", + "confirm_users_to_admin": "คุณต้องการตั้งผู้ใช้ที่เลือกเป็นผู้ดูแลระบบหรือไม่?", + "confirm_user_deactivation": "ยืนยันการปิดใช้งานผู้ใช้?", + "confirm_logout": "ยืนยันการออกจากระบบ", + "created_on": "สร้างเมื่อ", + "continue_email": "ดำเนินการต่อด้วยอีเมล", + "continue_github": "ดำเนินการต่อด้วย Github", + "continue_google": "ดำเนินการต่อด้วย Google", + "continue_microsoft": "ดำเนินการต่อด้วย Microsoft", + "create_team_failure": "สร้างพื้นที่ทำงานไม่สำเร็จ!!", + "create_team_success": "สร้างพื้นที่ทำงานสำเร็จ!!", + "data_sharing_failure": "อัปเดตการตั้งค่าการแชร์ข้อมูลไม่สำเร็จ", + "delete_infra_token_failure": "เกิดข้อผิดพลาดขณะลบโทเค็นโครงสร้างพื้นฐาน", + "delete_invite_failure": "ลบคำเชิญไม่สำเร็จ!!", + "delete_invites_failure": "ลบคำเชิญที่เลือกไม่สำเร็จ!!", + "delete_invite_success": "ลบคำเชิญสำเร็จ!!", + "delete_invites_success": "ลบคำเชิญที่เลือกสำเร็จ!!", + "delete_request_failure": "ลบคำขอที่แชร์ไม่สำเร็จ!!", + "delete_request_success": "ลบคำขอที่แชร์สำเร็จ!!", + "delete_team_failure": "ลบพื้นที่ทำงานไม่สำเร็จ!!", + "delete_team_success": "ลบพื้นที่ทำงานสำเร็จ!!", + "delete_some_users_failure": "จำนวนผู้ใช้ที่ไม่ได้ลบ: {count}", + "delete_some_users_success": "จำนวนผู้ใช้ที่ลบแล้ว: {count}", + "delete_user_failed_only_one_admin": "ลบผู้ใช้ไม่สำเร็จ ต้องมีผู้ดูแลระบบอย่างน้อยหนึ่งคน!!", + "delete_user_failure": "ลบผู้ใช้ไม่สำเร็จ!!", + "delete_users_failure": "ลบผู้ใช้ที่เลือกไม่สำเร็จ!!", + "delete_user_success": "ลบผู้ใช้สำเร็จ!!", + "delete_users_success": "ลบผู้ใช้ที่เลือกสำเร็จ!!", + "email": "อีเมล", + "email_failure": "ส่งคำเชิญไม่สำเร็จ", + "email_signin_failure": "เข้าสู่ระบบด้วยอีเมลไม่สำเร็จ", + "email_success": "ส่งคำเชิญทางอีเมลสำเร็จ", + "emails_cannot_be_same": "คุณไม่สามารถเชิญตัวเองได้ โปรดเลือกที่อยู่อีเมลอื่น!!", + "enter_team_email": "โปรดป้อนอีเมลของเจ้าของพื้นที่ทำงาน!!", + "error": "เกิดข้อผิดพลาดบางอย่าง", + "error_auth_providers": "ไม่สามารถโหลดผู้ให้บริการการตรวจสอบสิทธิ์ได้", + "generate_infra_token_failure": "เกิดข้อผิดพลาดขณะสร้างโทเค็นโครงสร้างพื้นฐาน", + "github_signin_failure": "เข้าสู่ระบบด้วย Github ไม่สำเร็จ", + "google_signin_failure": "เข้าสู่ระบบด้วย Google ไม่สำเร็จ", + "infra_token_label_short": "ความยาวของป้ายชื่อโทเค็นโครงสร้างพื้นฐานสั้นเกินไป!!", + "invalid_email": "โปรดป้อนที่อยู่อีเมลที่ถูกต้อง", + "link_copied_to_clipboard": "คัดลอกลิงก์ไปยังคลิปบอร์ดแล้ว", + "logged_out": "ออกจากระบบแล้ว", + "login_as_admin": "และเข้าสู่ระบบด้วยบัญชีผู้ดูแลระบบ", + "login_using_email": "โปรดขอให้ผู้ใช้ตรวจสอบอีเมลหรือแชร์ลิงก์ด้านล่าง", + "login_using_link": "โปรดขอให้ผู้ใช้เข้าสู่ระบบโดยใช้ลิงก์ด้านล่าง", + "logout": "ออกจากระบบ", + "magic_link_sign_in": "คลิกที่ลิงก์เพื่อเข้าสู่ระบบ", + "magic_link_success": "เราได้ส่งลิงก์เวทมนตร์ไปยัง", + "microsoft_signin_failure": "เข้าสู่ระบบด้วย Microsoft ไม่สำเร็จ", + "newsletter_failure": "ไม่สามารถอัปเดตการตั้งค่าจดหมายข่าวได้", + "non_admin_logged_in": "เข้าสู่ระบบในฐานะผู้ใช้ที่ไม่ใช่ผู้ดูแลระบบ", + "non_admin_login": "คุณเข้าสู่ระบบแล้ว แต่คุณไม่ใช่ผู้ดูแลระบบ", + "owner_not_present": "ทีมต้องมีเจ้าของอย่างน้อยหนึ่งคน!!", + "privacy_policy": "นโยบายความเป็นส่วนตัว", + "reenter_email": "ป้อนอีเมลอีกครั้ง", + "remove_admin_failure": "นำสถานะผู้ดูแลระบบออกไม่สำเร็จ!!", + "remove_admin_failure_only_one_admin": "นำสถานะผู้ดูแลระบบออกไม่สำเร็จ ต้องมีผู้ดูแลระบบอย่างน้อยหนึ่งคน!!", + "remove_admin_success": "นำสถานะผู้ดูแลระบบออกแล้ว!!", + "remove_admin_from_users_failure": "นำสถานะผู้ดูแลระบบออกจากผู้ใช้ที่เลือกไม่สำเร็จ!!", + "remove_admin_from_users_success": "นำสถานะผู้ดูแลระบบออกจากผู้ใช้ที่เลือกแล้ว!!", + "remove_admin_to_delete_user": "นำสิทธิ์ผู้ดูแลระบบออกเพื่อลบผู้ใช้!!", + "remove_owner_to_delete_user": "นำสถานะเจ้าของทีมออกเพื่อลบผู้ใช้!!", + "remove_owner_failure_only_one_owner": "นำสมาชิกออกไม่สำเร็จ ทีมต้องมีเจ้าของอย่างน้อยหนึ่งคน!!", + "remove_admin_for_deletion": "นำสถานะผู้ดูแลระบบออกก่อนพยายามลบ!!", + "remove_owner_for_deletion": "ผู้ใช้หนึ่งคนหรือมากกว่าเป็นเจ้าของทีม อัปเดตความเป็นเจ้าของก่อนลบ!!", + "remove_invitee_failure": "นำผู้ได้รับเชิญออกไม่สำเร็จ!!", + "remove_invitee_success": "นำผู้ได้รับเชิญออกสำเร็จ!!", + "remove_member_failure": "ไม่สามารถนำสมาชิกออกได้!!", + "remove_member_success": "นำสมาชิกออกสำเร็จ!!", + "rename_team_failure": "เปลี่ยนชื่อพื้นที่ทำงานไม่สำเร็จ!!", + "rename_team_success": "เปลี่ยนชื่อพื้นที่ทำงานสำเร็จ!", + "rename_user_failure": "เปลี่ยนชื่อผู้ใช้ไม่สำเร็จ!!", + "rename_user_success": "เปลี่ยนชื่อผู้ใช้สำเร็จ!!", + "require_auth_provider": "คุณต้องตั้งค่าผู้ให้บริการการตรวจสอบสิทธิ์อย่างน้อยหนึ่งรายเพื่อเข้าสู่ระบบ", + "role_update_failed": "อัปเดตบทบาทไม่สำเร็จ!!", + "role_update_success": "อัปเดตบทบาทสำเร็จ!!", + "selected": "เลือกแล้ว {count} รายการ", + "self_host_docs": "เอกสารการโฮสต์ด้วยตนเอง", + "send_magic_link": "ส่งลิงก์เวทมนตร์", + "setup_failure": "การตั้งค่าล้มเหลว!!", + "setup_success": "ตั้งค่าเสร็จสมบูรณ์!!", + "sign_in_agreement": "การเข้าสู่ระบบถือว่าคุณยอมรับ", + "sign_in_options": "ตัวเลือกการเข้าสู่ระบบทั้งหมด", + "sign_out": "ออกจากระบบ", + "something_went_wrong": "เกิดข้อผิดพลาดบางอย่าง", + "team_name_too_short": "ชื่อพื้นที่ทำงานต้องมีความยาวอย่างน้อย 6 อักขระ!!", + "user_already_invited": "ส่งคำเชิญไม่สำเร็จ ผู้ใช้ได้รับเชิญแล้ว!!", + "user_not_found": "ไม่พบผู้ใช้ในโครงสร้างพื้นฐาน!!", + "users_to_admin_success": "ยกระดับผู้ใช้ที่เลือกเป็นสถานะผู้ดูแลระบบแล้ว!!", + "users_to_admin_failure": "ยกระดับผู้ใช้ที่เลือกเป็นสถานะผู้ดูแลระบบไม่สำเร็จ!!", + "loading_workspaces": "กำลังโหลดพื้นที่ทำงาน", + "loading_collections_in_workspace": "กำลังโหลดคอลเลกชันในพื้นที่ทำงาน" + }, + "support": { + "changelog": "อ่านเพิ่มเติมเกี่ยวกับรุ่นล่าสุด", + "chat": "มีคำถามใช่ไหม? แชทกับเรา!", + "community": "ถามคำถามและช่วยเหลือผู้อื่น", + "documentation": "อ่านเพิ่มเติมเกี่ยวกับ Hoppscotch", + "forum": "ถามคำถามและรับคำตอบ", + "github": "ติดตามเราบน Github", + "shortcuts": "เรียกดูแอปได้เร็วขึ้น", + "title": "การสนับสนุน", + "twitter": "ติดตามเราบน Twitter" + }, + "tab": { + "authorization": "การให้สิทธิ์", + "body": "เนื้อหา", + "close": "ปิดแท็บ", + "close_others": "ปิดแท็บอื่น", + "collections": "คอลเลกชัน", + "documentation": "เอกสาร", + "duplicate": "ทำสำเนาแท็บ", + "environments": "สภาพแวดล้อม", + "headers": "ส่วนหัว", + "history": "ประวัติ", + "mqtt": "MQTT", + "parameters": "พารามิเตอร์", + "post_request_script": "สคริปต์หลังคำขอ", + "pre_request_script": "สคริปต์ก่อนคำขอ", + "scripts": "สคริปต์", + "queries": "เคียวรี", + "query": "เคียวรี", + "schema": "สคีมา", + "shared_requests": "คำขอที่แชร์", + "codegen": "สร้างโค้ด", + "code_snippet": "ตัวอย่างโค้ด", + "mock_servers": "เซิร์ฟเวอร์จำลอง", + "share_tab_request": "แชร์คำขอในแท็บ", + "socketio": "Socket.IO", + "sse": "SSE", + "types": "ชนิด", + "variables": "ตัวแปร", + "websocket": "WebSocket", + "all_tests": "การทดสอบทั้งหมด", + "passed": "ผ่าน", + "failed": "ไม่ผ่าน" + }, + "team": { + "activity_logs": "บันทึกกิจกรรม", + "already_member": "อีเมลนี้เชื่อมโยงกับผู้ใช้ที่มีอยู่แล้ว", + "create_new": "สร้างพื้นที่ทำงานใหม่", + "deleted": "ลบพื้นที่ทำงานแล้ว", + "delete_all_activity_logs": "ลบบันทึกกิจกรรมทั้งหมด", + "successfully_deleted_all_activity_logs": "ลบบันทึกกิจกรรมทั้งหมดสำเร็จแล้ว", + "delete_activity_log": "ลบบันทึกกิจกรรม", + "deleted_activity_log": "ลบบันทึกกิจกรรมที่เลือกแล้ว", + "deleted_all_activity_logs": "ลบบันทึกกิจกรรมทั้งหมดแล้ว", + "edit": "แก้ไขพื้นที่ทำงาน", + "email": "อีเมล", + "email_do_not_match": "อีเมลไม่ตรงกับรายละเอียดบัญชีของคุณ โปรดติดต่อเจ้าของพื้นที่ทำงาน", + "exit": "ออกจากพื้นที่ทำงาน", + "exit_disabled": "เฉพาะเจ้าของเท่านั้นที่ไม่สามารถออกจากพื้นที่ทำงานได้", + "failed_invites": "การเชิญที่ล้มเหลว", + "invalid_coll_id": "รหัสคอลเลกชันไม่ถูกต้อง", + "invalid_email_format": "รูปแบบอีเมลไม่ถูกต้อง", + "invalid_id": "รหัสพื้นที่ทำงานไม่ถูกต้อง โปรดติดต่อเจ้าของพื้นที่ทำงาน", + "invalid_invite_link": "ลิงก์เชิญไม่ถูกต้อง", + "invalid_invite_link_description": "ลิงก์ที่คุณตามไปไม่ถูกต้อง โปรดติดต่อเจ้าของพื้นที่ทำงาน", + "invalid_member_permission": "โปรดระบุสิทธิ์ที่ถูกต้องให้กับสมาชิกของพื้นที่ทำงาน", + "invite": "เชิญ", + "invite_more": "เชิญเพิ่มเติม", + "invite_tooltip": "เชิญผู้คนเข้าสู่พื้นที่ทำงานนี้", + "invited_to_team": "{owner} เชิญคุณเข้าร่วม {workspace}", + "join": "ยอมรับคำเชิญแล้ว", + "join_team": "เข้าร่วม {workspace}", + "joined_team": "คุณได้เข้าร่วม {workspace} แล้ว", + "joined_team_description": "ตอนนี้คุณเป็นสมาชิกของพื้นที่ทำงานนี้แล้ว", + "left": "คุณออกจากพื้นที่ทำงานแล้ว", + "login_to_continue": "เข้าสู่ระบบเพื่อดำเนินการต่อ", + "login_to_continue_description": "คุณต้องเข้าสู่ระบบเพื่อเข้าร่วมพื้นที่ทำงาน", + "logout_and_try_again": "ออกจากระบบและเข้าสู่ระบบด้วยบัญชีอื่น", + "member_has_invite": "ผู้ใช้มีคำเชิญอยู่แล้ว โปรดขอให้พวกเขาตรวจสอบกล่องจดหมายหรือเพิกถอนและส่งคำเชิญใหม่", + "member_not_found": "ไม่พบสมาชิก โปรดติดต่อเจ้าของพื้นที่ทำงาน", + "member_removed": "นำผู้ใช้ออกแล้ว", + "member_role_updated": "อัปเดตบทบาทของผู้ใช้แล้ว", + "members": "สมาชิก", + "more_members": "+{count} เพิ่มเติม", + "name_length_insufficient": "ชื่อพื้นที่ทำงานต้องไม่ว่างเปล่า", + "name_updated": "อัปเดตชื่อพื้นที่ทำงานแล้ว", + "new": "พื้นที่ทำงานใหม่", + "new_created": "สร้างพื้นที่ทำงานใหม่แล้ว", + "new_name": "พื้นที่ทำงานใหม่ของฉัน", + "no_access": "คุณไม่มีสิทธิ์แก้ไขพื้นที่ทำงานนี้", + "no_invite_found": "ไม่พบคำเชิญ โปรดติดต่อเจ้าของพื้นที่ทำงาน", + "no_request_found": "ไม่พบคำขอ", + "not_found": "ไม่พบพื้นที่ทำงาน โปรดติดต่อเจ้าของพื้นที่ทำงาน", + "not_valid_viewer": "คุณไม่ใช่ผู้ดูที่ถูกต้อง โปรดติดต่อเจ้าของพื้นที่ทำงาน", + "parent_coll_move": "ไม่สามารถย้ายคอลเลกชันไปยังคอลเลกชันลูกได้", + "pending_invites": "คำเชิญที่รอดำเนินการ", + "permissions": "สิทธิ์", + "same_target_destination": "เป้าหมายและปลายทางเดียวกัน", + "saved": "บันทึกพื้นที่ทำงานแล้ว", + "select_a_team": "เลือกพื้นที่ทำงาน", + "success_invites": "การเชิญที่สำเร็จ", + "title": "พื้นที่ทำงาน", + "we_sent_invite_link": "กำลังส่งคำเชิญ", + "invite_sent_smtp_disabled": "สร้างลิงก์เชิญแล้ว", + "we_sent_invite_link_description": " ผู้รับเชิญใหม่จะได้รับลิงก์เพื่อเข้าร่วมพื้นที่ทำงาน สมาชิกที่มีอยู่และผู้รับเชิญที่รอดำเนินการจะไม่ได้รับลิงก์ใหม่", + "invite_sent_smtp_disabled_description": "การส่งอีเมลเชิญถูกปิดใช้งานสำหรับอินสแตนซ์นี้ของ Hoppscotch โปรดใช้ปุ่มคัดลอกลิงก์เพื่อคัดลอกและแชร์ลิงก์เชิญด้วยตนเอง", + "copy_invite_link": "คัดลอกลิงก์เชิญ", + "search_title": "คำขอของทีม", + "user_not_found": "ไม่พบผู้ใช้ในอินสแตนซ์นี้", + "invite_members": "เชิญสมาชิก" + }, + "team_environment": { + "deleted": "ลบสภาพแวดล้อมแล้ว", + "duplicate": "ทำซ้ำสภาพแวดล้อมแล้ว", + "not_found": "ไม่พบสภาพแวดล้อม" + }, + "test": { + "requests": "คำขอ", + "selection": "การเลือก", + "failed": "การทดสอบล้มเหลว", + "javascript_code": "โค้ด JavaScript", + "learn": "อ่านเอกสาร", + "passed": "การทดสอบผ่าน", + "report": "รายงานการทดสอบ", + "results": "ผลการทดสอบ", + "script": "สคริปต์", + "snippets": "ตัวอย่างโค้ด", + "run": "เรียกใช้", + "run_again": "เรียกใช้อีกครั้ง", + "stop": "หยุด", + "new_run": "การเรียกใช้ใหม่", + "iterations": "จำนวนรอบ", + "duration": "ระยะเวลา", + "avg_resp": "เวลาตอบกลับเฉลี่ย" + }, + "websocket": { + "communication": "การสื่อสาร", + "log": "บันทึก", + "message": "ข้อความ", + "protocols": "โปรโตคอล", + "url": "URL" + }, + "workspace": { + "change": "เปลี่ยนพื้นที่ทำงาน", + "personal": "พื้นที่ทำงานส่วนตัว", + "other_workspaces": "พื้นที่ทำงานของฉัน", + "team": "พื้นที่ทำงาน", + "title": "พื้นที่ทำงาน" + }, + "site_protection": { + "login_to_continue": "เข้าสู่ระบบเพื่อดำเนินการต่อ", + "login_to_continue_description": "คุณต้องเข้าสู่ระบบเพื่อเข้าถึง Hoppscotch Enterprise Instance นี้", + "error_fetching_site_protection_status": "เกิดข้อผิดพลาดขณะดึงสถานะการป้องกันไซต์" + }, + "access_tokens": { + "tab_title": "โทเค็น", + "section_title": "โทเค็นการเข้าถึงส่วนบุคคล", + "section_description": "โทเค็นการเข้าถึงส่วนบุคคลช่วยให้คุณเชื่อมต่อ CLI เข้ากับบัญชี Hoppscotch ของคุณ", + "last_used_on": "ใช้งานล่าสุดเมื่อ", + "expires_on": "หมดอายุเมื่อ", + "no_expiration": "ไม่มีวันหมดอายุ", + "expired": "หมดอายุแล้ว", + "copy_token_warning": "อย่าลืมคัดลอกโทเค็นการเข้าถึงส่วนบุคคลของคุณตอนนี้ คุณจะไม่สามารถดูได้อีก!", + "token_purpose": "โทเค็นนี้ใช้สำหรับอะไร?", + "expiration_label": "การหมดอายุ", + "scope_label": "ขอบเขต", + "workspace_read_only_access": "การเข้าถึงข้อมูลพื้นที่ทำงานแบบอ่านอย่างเดียว", + "personal_workspace_access_limitation": "โทเค็นการเข้าถึงส่วนบุคคลไม่สามารถเข้าถึงพื้นที่ทำงานส่วนตัวของคุณได้", + "generate_token": "สร้างโทเค็น", + "invalid_label": "โปรดระบุป้ายกำกับสำหรับโทเค็น", + "no_expiration_verbose": "โทเค็นนี้จะไม่มีวันหมดอายุ!", + "token_expires_on": "โทเค็นนี้จะหมดอายุในวันที่", + "generate_new_token": "สร้างโทเค็นใหม่", + "generate_modal_title": "โทเค็นการเข้าถึงส่วนบุคคลใหม่", + "deletion_success": "ลบโทเค็นการเข้าถึง {label} แล้ว" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "Collection ID นี้จะถูกใช้โดยตัวเรียกใช้คอลเลกชัน CLI สำหรับ Hoppscotch", + "cli_environment_id_description": "Environment ID นี้จะถูกใช้โดยตัวเรียกใช้คอลเลกชัน CLI สำหรับ Hoppscotch", + "include_active_environment": "รวมสภาพแวดล้อมที่ใช้งานอยู่:", + "cli": "CLI", + "cli_comming_soon_for_personal_collection": "ตัวเรียกใช้คอลเลกชันสำหรับคอลเลกชันส่วนตัวใน CLI กำลังจะมาเร็วๆ นี้", + "delay": "หน่วงเวลา", + "negative_delay": "การหน่วงเวลาต้องไม่เป็นค่าลบ", + "ui": "ตัวเรียกใช้", + "running_collection": "กำลังเรียกใช้คอลเลกชัน", + "run_config": "การกำหนดค่าการเรียกใช้", + "advanced_settings": "การตั้งค่าขั้นสูง", + "stop_on_error": "หยุดการเรียกใช้หากเกิดข้อผิดพลาด", + "persist_responses": "เก็บการตอบกลับไว้", + "keep_variable_values": "เก็บค่าตัวแปรไว้", + "collection_not_found": "ไม่พบคอลเลกชัน อาจถูกลบหรือย้าย", + "empty_collection": "คอลเลกชันว่างเปล่า เพิ่มคำขอเพื่อเรียกใช้", + "no_response_persist": "ปัจจุบันตัวเรียกใช้คอลเลกชันถูกกำหนดค่าให้ไม่เก็บการตอบกลับไว้ การตั้งค่านี้จะป้องกันไม่ให้แสดงข้อมูลการตอบกลับ หากต้องการแก้ไขลักษณะการทำงานนี้ โปรดเริ่มการกำหนดค่าการเรียกใช้ใหม่", + "select_request": "เลือกคำขอเพื่อดูการตอบกลับและผลการทดสอบ", + "response_body_lost_rerun": "เนื้อหาการตอบกลับสูญหาย เรียกใช้คอลเลกชันอีกครั้งเพื่อรับเนื้อหาการตอบกลับ", + "cli_command_generation_description_cloud": "คัดลอกคำสั่งด้านล่างและเรียกใช้จาก CLI โปรดระบุโทเค็นการเข้าถึงส่วนตัว", + "cli_command_generation_description_sh": "คัดลอกคำสั่งด้านล่างและเรียกใช้จาก CLI โปรดระบุโทเค็นการเข้าถึงส่วนตัวและตรวจสอบ URL เซิร์ฟเวอร์อินสแตนซ์ SH ที่สร้างขึ้น", + "cli_command_generation_description_sh_with_server_url_placeholder": "คัดลอกคำสั่งด้านล่างและเรียกใช้จาก CLI โปรดระบุโทเค็นการเข้าถึงส่วนตัวและ URL เซิร์ฟเวอร์อินสแตนซ์ SH", + "run_collection": "เรียกใช้คอลเลกชัน", + "no_passed_tests": "ไม่มีการทดสอบที่ผ่าน", + "no_failed_tests": "ไม่มีการทดสอบที่ล้มเหลว" + }, + "ai_experiments": { + "generate_request_name": "สร้างชื่อคำขอโดยใช้ AI", + "generate_or_modify_request_body": "สร้างหรือแก้ไขเนื้อหาของคำขอ", + "modify_with_ai": "แก้ไขด้วย AI", + "generate": "สร้าง", + "generate_or_modify_request_body_input_placeholder": "กรอกพรอมต์เพื่อแก้ไขเนื้อหาของคำขอ", + "accept_change": "ยอมรับการเปลี่ยนแปลง", + "feedback_success": "ส่งความคิดเห็นสำเร็จแล้ว", + "feedback_failure": "ไม่สามารถส่งความคิดเห็นได้", + "feedback_thank_you": "ขอบคุณสำหรับความคิดเห็นของคุณ!", + "feedback_cta_text_long": "ให้คะแนนการสร้าง ช่วยให้เราปรับปรุงได้", + "feedback_cta_request_name": "คุณชอบชื่อที่สร้างขึ้นหรือไม่?", + "modify_request_body_error": "ไม่สามารถแก้ไขเนื้อหาของคำขอได้", + "generate_or_modify_prerequest_input_placeholder": "กรอกพรอมต์เพื่อสร้างหรือแก้ไขสคริปต์ก่อนคำขอ", + "generate_or_modify_post_request_script_input_placeholder": "กรอกพรอมต์เพื่อสร้างหรือแก้ไขสคริปต์หลังคำขอ", + "modify_post_request_script_error": "ไม่สามารถแก้ไขสคริปต์หลังคำขอได้", + "modify_prerequest_error": "ไม่สามารถแก้ไขสคริปต์ก่อนคำขอได้" + }, + "configs": { + "auth_providers": { + "callback_url": "CALLBACK URL", + "client_id": "CLIENT ID", + "client_secret": "CLIENT SECRET", + "description": "กำหนดค่าผู้ให้บริการการยืนยันตัวตนสำหรับเซิร์ฟเวอร์ของคุณ", + "provider_not_specified": "โปรดเปิดใช้งานผู้ให้บริการการยืนยันตัวตนอย่างน้อยหนึ่งราย", + "scope": "SCOPE", + "tenant": "TENANT", + "title": "ผู้ให้บริการการยืนยันตัวตน", + "update_failure": "ไม่สามารถอัปเดตการกำหนดค่าผู้ให้บริการการยืนยันตัวตนได้!!" + }, + "confirm_changes": "เซิร์ฟเวอร์ Hoppscotch ต้องรีสตาร์ทเพื่อให้การเปลี่ยนแปลงใหม่มีผล ยืนยันการเปลี่ยนแปลงที่ทำกับการกำหนดค่าเซิร์ฟเวอร์หรือไม่?", + "input_empty": "โปรดกรอกข้อมูลทุกช่องก่อนอัปเดตการกำหนดค่า", + "data_sharing": { + "title": "การแชร์ข้อมูล", + "description": "ช่วยปรับปรุง Hoppscotch โดยการแชร์ข้อมูลแบบไม่ระบุตัวตน", + "enable": "เปิดใช้งานการแชร์ข้อมูล", + "secondary_title": "การกำหนดค่าการแชร์ข้อมูล", + "see_shared": "ดูว่ามีการแชร์อะไรบ้าง", + "toggle_description": "แชร์ข้อมูลแบบไม่ระบุตัวตน", + "update_failure": "ไม่สามารถอัปเดตการกำหนดค่าการแชร์ข้อมูลได้!!" + }, + "load_error": "ไม่สามารถโหลดการกำหนดค่าเซิร์ฟเวอร์ได้", + "mail_configs": { + "address_from": "MAILER FROM ADDRESS", + "custom_smtp_configs": "ใช้การกำหนดค่า SMTP แบบกำหนดเอง", + "description": " กำหนดค่า smtp", + "enable_email_auth": "เปิดใช้งานการยืนยันตัวตนผ่านอีเมล", + "enable_smtp": "เปิดใช้งาน SMTP", + "host": "MAILER HOST", + "password": "MAILER PASSWORD", + "port": "MAILER PORT", + "secure": "MAILER SECURE", + "smtp_url": "MAILER SMTP URL", + "tls_reject_unauthorized": "TLS REJECT UNAUTHORIZED", + "title": "การกำหนดค่า SMTP", + "toggle_failure": "ไม่สามารถสลับสถานะ smtp ได้!!", + "update_failure": "ไม่สามารถอัปเดตการกำหนดค่า smtp ได้!!", + "user": "MAILER USER" + }, + "reset": { + "confirm_reset": "เซิร์ฟเวอร์ Hoppscotch ต้องรีสตาร์ทเพื่อให้การเปลี่ยนแปลงใหม่มีผล ยืนยันการรีเซ็ตการกำหนดค่าเซิร์ฟเวอร์หรือไม่?", + "description": "การกำหนดค่าเริ่มต้นจะถูกโหลดตามที่ระบุไว้ในไฟล์สภาพแวดล้อม", + "failure": "ไม่สามารถรีเซ็ตการกำหนดค่าได้!!", + "title": "รีเซ็ตการกำหนดค่า", + "info": "รีเซ็ตการกำหนดค่าเซิร์ฟเวอร์" + }, + "restart": { + "description": "เหลืออีก {duration} วินาทีก่อนที่หน้านี้จะโหลดใหม่โดยอัตโนมัติ", + "initiate": "กำลังเริ่มรีสตาร์ทเซิร์ฟเวอร์...", + "title": "เซิร์ฟเวอร์กำลังรีสตาร์ท" + }, + "save_changes": "บันทึกการเปลี่ยนแปลง", + "title": "การกำหนดค่า", + "update_failure": "ไม่สามารถอัปเดตการกำหนดค่าเซิร์ฟเวอร์ได้", + "restrict_access": "จำกัดการเข้าถึง", + "site_protection": { + "control_access": "ควบคุมว่าใครสามารถเข้าถึงแอป Hoppscotch ได้", + "description": "ปรับแต่งวิธีที่ผู้เยี่ยมชมเข้าถึงแอป Hoppscotch ของคุณโดยใช้การตั้งค่าการป้องกันไซต์", + "enable": "เปิดใช้งานการป้องกันไซต์", + "note": "ผู้ใช้ที่เยี่ยมชมแอป Hoppscotch จะเห็นหน้าเข้าสู่ระบบ จำเป็นต้องเข้าสู่ระบบเพื่อเข้าถึงแอป ยังคงต้องได้รับการอนุมัติจากผู้ดูแลระบบเพื่อการให้สิทธิ์", + "update_failure": "ไม่สามารถอัปเดตการกำหนดค่าการป้องกันไซต์ได้!!" + }, + "domain_whitelisting": { + "add_domain": "เพิ่มโดเมนใหม่", + "description": "ผู้ใช้ที่มี ID อีเมลลงทะเบียนในโดเมนที่อยู่ในบัญชีขาว ไม่จำเป็นต้องได้รับการอนุมัติอย่างชัดเจนจากผู้ดูแลระบบเพื่อเข้าถึงแอป Hoppscotch", + "enable": "เปิดใช้งานการกำหนดบัญชีขาวโดเมน", + "enter_domain": "ป้อนโดเมน", + "title": "โดเมนในบัญชีขาว", + "toggle_failure": "ไม่สามารถสลับสถานะการกำหนดบัญชีขาวโดเมนได้", + "update_failure": "ไม่สามารถอัปเดตการกำหนดค่าการกำหนดบัญชีขาวโดเมนได้!!" + }, + "oidc_configs": { + "auth_url": "Auth URL", + "callback_url": "Callback URL", + "client_id": "Client ID", + "client_secret": "Client Secret", + "description": "กำหนดค่า OIDC", + "enable": "เปิดใช้งานการยืนยันตัวตนผ่าน OIDC", + "issuer": "Issuer", + "provider_name": "ชื่อผู้ให้บริการ", + "scope": "Scope", + "title": "การกำหนดค่า OIDC", + "token_url": "Token URL", + "update_failure": "ไม่สามารถอัปเดตการกำหนดค่า OIDC ได้!!", + "user_info_url": "User Info URL" + }, + "saml": { + "audience": "Audience", + "callback_url": "Callback URL", + "certificate": "Certificate", + "description": "กำหนดค่า SAML", + "enable": "เปิดใช้งานการยืนยันตัวตนผ่าน SAML", + "entry_point": "Entry Point", + "issuer": "Issuer", + "title": "การกำหนดค่า SAML", + "update_failure": "ไม่สามารถอัปเดตการกำหนดค่า SAML SSO ได้!!", + "want_assertions_signed": "ลงนาม Assertions", + "want_response_signed": "ลงนาม Response" + } + }, + "data_sharing": { + "description": "แชร์ข้อมูลการใช้งานแบบไม่ระบุตัวตนเพื่อปรับปรุง Hoppscotch", + "enable": "เปิดใช้งานการแชร์ข้อมูล", + "see_shared": "ดูสิ่งที่แชร์", + "toggle_description": "แชร์ข้อมูลและทำให้ Hoppscotch ดีขึ้น", + "title": "ทำให้ Hoppscotch ดีขึ้น", + "welcome": "ยินดีต้อนรับสู่" + }, + "infra_tokens": { + "copy_token_warning": "อย่าลืมคัดลอก infra token ของคุณตอนนี้ คุณจะไม่สามารถดูได้อีก!", + "deletion_success": "ลบ infra token {label} แล้ว", + "empty": "ไม่มี infra token", + "expired": "หมดอายุแล้ว", + "expiration_label": "การหมดอายุ", + "expires_on": "หมดอายุในวันที่", + "generate_modal_title": "Infra Token ใหม่", + "generate_new_token": "สร้างโทเค็นใหม่", + "generate_token": "สร้างโทเค็น", + "invalid_label": "โปรดระบุป้ายกำกับสำหรับโทเค็น", + "last_used_on": "ใช้งานล่าสุดเมื่อ", + "no_expiration": "ไม่มีการหมดอายุ", + "no_expiration_verbose": "โทเค็นนี้จะไม่มีวันหมดอายุ!", + "section_description": "จัดการผู้ใช้ Hoppscotch ของคุณผ่าน API ด้วย Infra token", + "section_title": "Infra Tokens", + "tab_title": "Infra Tokens", + "token_expires_on": "โทเค็นนี้จะหมดอายุในวันที่", + "token_purpose": "กรอกป้ายกำกับเพื่อระบุโทเค็นนี้" + }, + "metrics": { + "dashboard": "แดชบอร์ด", + "no_metrics": "ไม่พบเมตริก", + "total_collections": "คอลเลกชันทั้งหมด", + "total_requests": "คำขอทั้งหมด", + "total_teams": "พื้นที่ทำงานทั้งหมด", + "total_users": "ผู้ใช้ทั้งหมด" + }, + "newsletter": { + "description": "รับข่าวสารล่าสุดของเรา", + "subscribe": "สมัครรับข้อมูล", + "title": "ติดต่อกับเรา", + "toggle_description": "รับข่าวสารล่าสุดเกี่ยวกับ Hoppscotch", + "unsubscribe": "ยกเลิกการสมัครรับข้อมูล" + }, + "teams": { + "add_member": "เพิ่มสมาชิก", + "add_members": "เพิ่มสมาชิก", + "add_new": "เพิ่มใหม่", + "admin": "ผู้ดูแลระบบ", + "admin_Email": "อีเมลผู้ดูแลระบบ", + "admin_id": "ID ผู้ดูแลระบบ", + "cancel": "ยกเลิก", + "confirm_team_deletion": "ยืนยันการลบพื้นที่ทำงานหรือไม่?", + "copy": "คัดลอก", + "create_team": "สร้างพื้นที่ทำงาน", + "date": "วันที่", + "delete_team": "ลบพื้นที่ทำงาน", + "details": "รายละเอียด", + "edit": "แก้ไข", + "editor": "ผู้แก้ไข", + "editor_description": "ผู้แก้ไขสามารถเพิ่ม แก้ไข และลบคำขอและคอลเลกชันได้", + "email": "อีเมลเจ้าของพื้นที่ทำงาน", + "email_address": "ที่อยู่อีเมล", + "email_title": "อีเมล", + "empty_name": "ชื่อทีมต้องไม่ว่างเปล่า!!", + "error": "เกิดข้อผิดพลาดบางอย่าง โปรดลองใหม่อีกครั้งในภายหลัง", + "id": "ID พื้นที่ทำงาน", + "invited_email": "อีเมลผู้ได้รับเชิญ", + "invited_on": "เชิญเมื่อ", + "invites": "คำเชิญ", + "load_info_error": "ไม่สามารถโหลดข้อมูลพื้นที่ทำงานได้", + "load_list_error": "ไม่สามารถโหลดรายการพื้นที่ทำงานได้", + "members": "จำนวนสมาชิก", + "no_invite": "ไม่มีคำเชิญ", + "no_invite_description": "เชิญทีมของคุณเพื่อเริ่มทำงานร่วมกัน", + "owner": "เจ้าของ", + "owner_description": " เจ้าของสามารถเพิ่ม แก้ไข และลบคำขอ คอลเลกชัน และสมาชิกพื้นที่ทำงานได้", + "permissions": "สิทธิ์", + "name": "ชื่อพื้นที่ทำงาน", + "no_members": "ไม่มีสมาชิกในพื้นที่ทำงานนี้ เพิ่มสมาชิกเข้าพื้นที่ทำงานนี้เพื่อทำงานร่วมกัน", + "no_pending_invites": "ไม่มีคำเชิญที่รอดำเนินการ", + "no_teams": "ไม่พบพื้นที่ทำงาน..", + "no_teams_description": "สร้างพื้นที่ทำงานเพื่อทำงานร่วมกับทีมของคุณ", + "pending_invites": "คำเชิญที่รอดำเนินการ", + "roles": "บทบาท", + "roles_description": "บทบาทใช้เพื่อควบคุมการเข้าถึงคอลเลกชันที่แชร์", + "remove": "นำออก", + "rename": "เปลี่ยนชื่อ", + "save": "บันทึก", + "save_changes": "บันทึกการเปลี่ยนแปลง", + "send_invite": "ส่งคำเชิญ", + "show_more": "แสดงเพิ่มเติม", + "team_details": "รายละเอียดพื้นที่ทำงาน", + "team_members": "สมาชิก", + "team_members_tab": "สมาชิกพื้นที่ทำงาน", + "teams": "พื้นที่ทำงาน", + "uid": "UID", + "unnamed": "(พื้นที่ทำงานที่ไม่มีชื่อ)", + "viewer": "ผู้ดู", + "viewer_description": "ผู้ดูสามารถดูและใช้คำขอได้เท่านั้น", + "valid_name": "โปรดป้อนชื่อพื้นที่ทำงานที่ถูกต้อง", + "valid_owner_email": "โปรดป้อนอีเมลเจ้าของที่ถูกต้อง" + }, + "users": { + "add_user": "เพิ่มผู้ใช้", + "admin": "ผู้ดูแลระบบ", + "admin_id": "รหัสผู้ดูแลระบบ", + "cancel": "ยกเลิก", + "created_on": "สร้างเมื่อ", + "copy_invite_link": "คัดลอกลิงก์คำเชิญ", + "copy_link": "คัดลอกลิงก์", + "date": "วันที่", + "delete": "ลบ", + "delete_user": "ลบผู้ใช้", + "delete_users": "ลบผู้ใช้", + "details": "รายละเอียด", + "edit": "แก้ไข", + "email": "อีเมล", + "email_address": "ที่อยู่อีเมล", + "empty_name": "ชื่อต้องไม่ว่างเปล่า!!", + "id": "รหัสผู้ใช้", + "invalid_user": "ผู้ใช้ไม่ถูกต้อง", + "invite_load_list_error": "ไม่สามารถโหลดรายการผู้ใช้ที่ได้รับเชิญได้", + "invite_user": "เชิญผู้ใช้", + "invited_by": "เชิญโดย", + "invited_on": "เชิญเมื่อ", + "invited_users": "ผู้ใช้ที่ได้รับเชิญ", + "invitee_email": "อีเมลผู้รับเชิญ", + "last_active_on": "ใช้งานล่าสุด", + "load_info_error": "ไม่สามารถโหลดข้อมูลผู้ใช้ได้", + "load_list_error": "ไม่สามารถโหลดรายการผู้ใช้ได้", + "make_admin": "ตั้งเป็นผู้ดูแลระบบ", + "name": "ชื่อ", + "new_user_added": "เพิ่มผู้ใช้ใหม่แล้ว", + "no_invite": "ไม่พบคำเชิญที่รอดำเนินการ", + "no_invite_description": "ไม่มีคำเชิญที่รอดำเนินการ! เริ่มเชิญเพื่อนร่วมทีมของคุณมาที่ Hoppscotch", + "no_shared_requests": "ไม่มีคำขอที่แชร์ซึ่งสร้างโดยผู้ใช้รายนี้", + "no_users": "ไม่พบผู้ใช้", + "not_available": "ไม่พร้อมใช้งาน", + "not_found": "ไม่พบผู้ใช้", + "pending_invites": "คำเชิญที่รอดำเนินการ", + "remove_admin_privilege": "นำสิทธิ์ผู้ดูแลระบบออก", + "remove_admin_status": "นำสถานะผู้ดูแลระบบออก", + "rename": "เปลี่ยนชื่อ", + "revoke_invitation": "เพิกถอนคำเชิญ", + "searchbar_placeholder": "ค้นหาตามชื่อหรืออีเมล..", + "send_invite": "ส่งคำเชิญ", + "show_more": "แสดงเพิ่มเติม", + "uid": "UID", + "unnamed": "(ผู้ใช้ไม่มีชื่อ)", + "user_not_found": "ไม่พบผู้ใช้ในระบบ!!", + "users": "ผู้ใช้", + "valid_email": "โปรดป้อนที่อยู่อีเมลที่ถูกต้อง", + "deactivate": "ปิดใช้งาน", + "deactivate_user": "ปิดใช้งานผู้ใช้" + }, + "organization": { + "login_to_continue_description": "คุณต้องเข้าสู่ระบบเพื่อเข้าร่วมอินสแตนซ์ขององค์กร", + "create_an_organization": "สร้างองค์กร", + "deactivate_user_failure": "ปิดใช้งานผู้ใช้ล้มเหลว!!", + "deactivate_user_success": "ปิดใช้งานผู้ใช้สำเร็จ!!", + "delete_account_description": "การดำเนินการนี้จะลบข้อมูลทั้งหมดที่เกี่ยวข้องกับบัญชี Hoppscotch ของคุณ รวมถึงองค์กรนี้และองค์กรอื่นๆ ที่คุณเป็นสมาชิก", + "delete_account": "ลบบัญชี Hoppscotch", + "user_deletion_failed_sole_admin": "ผู้ใช้เป็นผู้ดูแลระบบเพียงคนเดียวของอินสแตนซ์องค์กรอย่างน้อยหนึ่งแห่ง โปรดลดระดับผู้ใช้ก่อนพยายามลบ", + "user_deletion_failed_sole_team_owner": "ผู้ใช้เป็นเจ้าของทีมเพียงคนเดียวของอินสแตนซ์องค์กรอย่างน้อยหนึ่งแห่ง โปรดโอนความเป็นเจ้าของหรือลบพื้นที่ทำงานที่เกี่ยวข้องก่อนพยายามลบ", + "no_organizations": "คุณไม่ได้เป็นสมาชิกขององค์กรใดๆ", + "admin": "ผู้ดูแลระบบ" + }, + "organization_sidebar": { + "hoppscotch_cloud": "Hoppscotch Cloud", + "cloud_locked": "ไม่สามารถนำอินสแตนซ์เริ่มต้นออกได้", + "admin": "ผู้ดูแลระบบ", + "error_loading": "ไม่สามารถโหลดองค์กรได้", + "inactive_orgs": "องค์กรที่ไม่ใช้งาน", + "no_orgs_found": "ไม่พบองค์กร", + "no_active_orgs_found": "ไม่มีองค์กรที่ใช้งานอยู่", + "organizations_for": "องค์กรสำหรับ {email}", + "multi_account_notice": "แต่ละองค์กรจะเก็บการเข้าสู่ระบบของตนเอง โดยใช้บัญชีที่เข้าถึงล่าสุด", + "inactive_orgs_tooltip": "ติดต่อฝ่ายสนับสนุนเพื่อขอความช่วยเหลือ" + }, + "billing": { + "confirm": { + "update_seat_count": "คุณแน่ใจหรือไม่ว่าต้องการอัปเดตจำนวนที่นั่งเป็น {newSeatCount}?", + "update_billing_cycle": "คุณแน่ใจหรือไม่ว่าต้องการอัปเดตรอบการเรียกเก็บเงินเป็น {newBillingCycle}?" + }, + "cancel_subscription": "ยกเลิกการสมัครสมาชิก" + }, + "app_console": { + "entries": "รายการคอนโซล", + "no_entries": "ไม่มีรายการ" + }, + "mockServer": { + "create_modal": { + "title": "สร้าง Mock Server", + "name_label": "ชื่อ Mock Server", + "name_placeholder": "ป้อนชื่อ Mock Server", + "name_required": "ต้องระบุชื่อ Mock Server", + "collection_source_label": "แหล่งที่มาของคอลเลกชัน", + "existing_collection": "คอลเลกชันที่มีอยู่", + "new_collection": "คอลเลกชันใหม่", + "select_collection_label": "เลือกคอลเลกชัน", + "select_collection_placeholder": "เลือกคอลเลกชัน", + "collection_required": "กรุณาเลือกคอลเลกชัน", + "collection_name_label": "ชื่อคอลเลกชัน", + "collection_name_placeholder": "ป้อนชื่อคอลเลกชัน", + "collection_name_required": "ต้องระบุชื่อคอลเลกชัน", + "request_config_label": "การกำหนดค่าคำขอ", + "add_request": "เพิ่มคำขอ", + "create_button": "สร้าง Mock Server", + "success": "สร้าง Mock Server สำเร็จ", + "error": "ไม่สามารถสร้าง Mock Server ได้", + "no_collections": "ไม่มีคอลเลกชันที่ใช้ได้" + }, + "edit_modal": { + "title": "แก้ไข Mock Server", + "name_label": "ชื่อ Mock Server", + "name_placeholder": "ป้อนชื่อ Mock Server", + "name_required": "ต้องระบุชื่อ Mock Server", + "active_label": "ใช้งานอยู่", + "url_label": "URL ของ Mock Server", + "collection_label": "คอลเลกชัน", + "update_button": "อัปเดต Mock Server", + "success": "อัปเดต Mock Server สำเร็จ", + "error": "ไม่สามารถอัปเดต Mock Server ได้", + "url_copied": "คัดลอก URL ไปยังคลิปบอร์ดแล้ว" + }, + "dashboard": { + "title": "Mock Server", + "subtitle": "สร้างและจัดการ Mock Server ของ API ของคุณ", + "create_button": "สร้าง Mock Server", + "create_first": "สร้าง Mock Server แรกของคุณ", + "empty_title": "ไม่พบ Mock Server", + "empty_description": "สร้าง Mock Server จากคอลเลกชัน API ของคุณ เพื่อให้สามารถพัฒนาฝั่งหน้าเว็บและมือถือได้โดยไม่ต้องพึ่งพาแบ็กเอนด์", + "collection": "คอลเลกชัน", + "active": "ใช้งานอยู่", + "inactive": "ไม่ใช้งาน", + "mock_url": "Mock URL", + "endpoints": "เอนด์พอยต์", + "created": "สร้างเมื่อ", + "view_collection": "ดูคอลเลกชัน", + "documentation": "เอกสารประกอบ", + "doc_description": "ใช้ URL นี้เป็น URL ฐานของ API ในแอปพลิเคชันของคุณ:", + "url_copied": "คัดลอก URL ของ Mock Server ไปยังคลิปบอร์ดแล้ว", + "delete_title": "ลบ Mock Server", + "delete_description": "คุณแน่ใจหรือไม่ว่าต้องการลบ Mock Server นี้?", + "delete_success": "ลบ Mock Server สำเร็จ", + "delete_error": "ไม่สามารถลบ Mock Server ได้" + } + } +} diff --git a/packages/hoppscotch-common/locales/tr.json b/packages/hoppscotch-common/locales/tr.json new file mode 100644 index 0000000..cb3a0b9 --- /dev/null +++ b/packages/hoppscotch-common/locales/tr.json @@ -0,0 +1,2330 @@ +{ + "action": { + "add": "Ekle", + "autoscroll": "Oto-Kaydırma", + "cancel": "İptal", + "choose_file": "Bir dosya seçin", + "choose_workspace": "Bir çalışma alanı seçin", + "choose_collection": "Bir koleksiyon seçin", + "select_workspace": "Bir çalışma alanı seçin", + "clear": "Temizle", + "clear_all": "Hepsini temizle", + "clear_cache": "Önbelleği Temizle", + "clear_history": "Tüm Geçmişi Temizle", + "clear_unpinned": "Sabitlenmemişleri Temizle", + "clear_response": "Yanıtı Temizle", + "close": "Kapat", + "confirm": "Onayla", + "connect": "Bağlan", + "connecting": "Bağlanıyor", + "copy": "Kopyala", + "create": "Oluştur", + "delete": "Sil", + "disconnect": "Bağlantıyı kes", + "dismiss": "Boşver", + "done": "Tamam", + "dont_save": "Kaydetme", + "download_file": "Dosyayı İndir", + "download_test_report": "Test raporunu indir", + "drag_to_reorder": "Yeniden sıralamak için sürükleyin", + "duplicate": "Klonla", + "edit": "Düzenle", + "filter": "Filtre", + "go_back": "Geri git", + "go_forward": "İleri git", + "group_by": "Gruplama", + "hide_secret": "Özel veriyi Gizle", + "label": "Etiket", + "learn_more": "Daha fazla bilgi edin", + "download_here": "Buradan indir", + "less": "Daha az", + "more": "Daha fazla", + "new": "Yeni", + "no": "Hayır", + "open": "Aç", + "open_workspace": "Çalışma alanını aç", + "paste": "Yapıştır", + "prettify": "Güzelleştir", + "properties": "Özellikler", + "register": "Kayıt Ol", + "remove": "Kaldır", + "remove_instance": "Sunucuyu kaldır", + "rename": "Yeniden Adlandır", + "restore": "Onar", + "retry": "Tekrar Dene", + "save": "Kaydet", + "save_as_example": "Örnek olarak kaydet", + "add_example": "Örnek ekle", + "invalid_request": "Geçersiz istek verisi", + "scroll_to_bottom": "Aşağı kaydır", + "scroll_to_top": "Yukarı kaydır", + "search": "Arama", + "send": "Gönder", + "share": "Paylaş", + "show_secret": "Özel veriyi göster", + "sort": "Sırala", + "start": "Başla", + "starting": "Başlatılıyor", + "stop": "Dur", + "to_close": "kapatmak için", + "to_navigate": "yönlendirme için", + "to_select": "seçim için", + "turn_off": "Kapat", + "turn_on": "Aç", + "undo": "Geri al", + "unpublish": "Yayından Kaldır", + "yes": "Evet", + "verify": "Doğrula", + "enable": "Etkinleştir", + "disable": "Devre Dışı Bırak", + "assign": "Ata" + }, + "activity_logs": { + "ACTIVITY_LOG_DELETE": "Etkinlik günlüğü silindi", + "WORKSPACE_CREATE": "Yeni çalışma alanı oluşturuldu: {name}", + "WORKSPACE_RENAME": "Çalışma alanı {old_name} adından {new_name} adına yeniden adlandırıldı", + "WORKSPACE_USER_ADD": "{user} çalışma alanına {role} rolüyle eklendi", + "WORKSPACE_USER_INVITE": "{user}, {inviteeEmail} adresini {role} rolüyle davet etti", + "WORKSPACE_USER_INVITE_REVOKE": "{inviteeEmail} kullanıcısının {inviteeRole} rolündeki daveti iptal edildi", + "WORKSPACE_USER_INVITE_ACCEPT": "{inviteeEmail}, {inviteeRole} rolüyle daveti kabul etti", + "WORKSPACE_USER_REMOVE": "{user} çalışma alanından kaldırıldı", + "WORKSPACE_USER_ROLE_UPDATE": "{user} kullanıcısının rolü {old_role} rolünden {new_role} rolüne güncellendi", + "COLLECTION_CREATE": "Yeni koleksiyon oluşturuldu: {title}", + "COLLECTION_RENAME": "Koleksiyon {old_title} adından {new_title} adına yeniden adlandırıldı", + "COLLECTION_IMPORT": "{count} koleksiyon içe aktarıldı", + "COLLECTION_DELETE": "Koleksiyon silindi: {title}", + "COLLECTION_DUPLICATE": "Koleksiyon çoğaltıldı: {parentTitle}", + "REQUEST_CREATE": "Yeni istek oluşturuldu: {title}", + "REQUEST_RENAME": "İstek {old_title} adından {new_title} adına yeniden adlandırıldı", + "REQUEST_DELETE": "İstek silindi: {title}" + }, + "add": { + "new": "Yeni ekle", + "star": "Yıldız ekle" + }, + "agent": { + "registration_instruction": "Devam etmek için lütfen Hoppscotch Agent'ı web istemcinize kaydedin.", + "enter_otp_instruction": "Lütfen Hoppscotch Agent tarafından oluşturulan doğrulama kodunu girin ve kaydı tamamlayın", + "otp_label": "Doğrulama Kodu", + "processing": "İsteğiniz işleniyor...", + "not_running_title": "Agent algılanamadı", + "registration_title": "Agent kaydı", + "verify_ssl_certs": "SSL Sertifikalarını Doğrula", + "ca_certs": "CA Sertifikaları", + "client_certs": "İstemci Sertifikaları", + "use_http_proxy": "HTTP Proxy Kullan", + "proxy_capabilities": "Hoppscotch Agent, HTTP/HTTPS/SOCKS proxy desteğinin yanı sıra bu proxy'lerde NTLM ve Basic Auth desteği sunar. Proxy kimlik doğrulaması için kullanıcı adı ve şifreyi doğrudan URL içine ekleyin.", + "add_cert_file": "Sertifika Dosyası Ekle", + "add_client_cert": "İstemci Sertifikası Ekle", + "add_key_file": "Anahtar Dosyası Ekle", + "domain": "Alan Adı", + "cert": "Sertifika", + "key": "Anahtar", + "pfx_or_pkcs": "PFX/PKCS12", + "pfx_or_pkcs_file": "PFX/PKCS12 Dosyası", + "add_pfx_or_pkcs_file": "PFX/PKCS12 Dosyası Ekle" + }, + "app": { + "additional_links": { + "macOS": "macOS", + "windows": "Windows", + "linux": "Linux", + "web_app": "Web Uygulaması", + "cli": "CLI" + }, + "downloads": "İndirmeler", + "chat_with_us": "Bizimle sohbet et", + "contact_us": "Bize ulaş", + "cookies": "Çerezler", + "copy": "Kopyala", + "copy_interface_type": "Arayüz tipini kopyala", + "copy_user_id": "Kullanıcı Oturum Anahtarını Kopyala", + "developer_option": "Geliştirici ayarları", + "developer_option_description": "Hoppscotch'un geliştirilmesine ve bakımına yardımcı olan geliştirici araçları.", + "discord": "Discord", + "documentation": "Dokümanlar", + "github": "GitHub", + "help": "Yardım, geri bildirim ve dokümanlar", + "home": "Ana sayfa", + "invite": "Davet et", + "invite_description": "Hoppscotch'ta API'lerinizi oluşturmak ve yönetmek için basit ve sezgisel bir arayüz tasarladık. Hoppscotch, API'lerinizi oluşturmanıza, test etmenize, belgelemenize ve paylaşmanıza yardımcı olan bir araçtır.", + "invite_your_friends": "Arkadaşlarını davet et", + "join_discord_community": "Discord topluluğumuza katıl", + "keyboard_shortcuts": "Klavye kısayolları", + "name": "Hoppscotch", + "new_version_found": "Yeni sürüm hazır! Güncellemek için yenileyin.", + "open_in_hoppscotch": "Hoppscotch'ta Aç", + "options": "Ayarlar", + "powered_by": "Hoppscotch tarafından desteklenmektedir", + "proxy_privacy_policy": "Proxy Gizlilik İlkesi", + "reload": "Tekrar yükle", + "search": "Arama", + "share": "Paylaş", + "shortcuts": "Kısayollar", + "social_description": "En son haberler, güncellemeler ve duyurulardan haberdar olmak için bizi sosyal medyada takip edin.", + "social_links": "sosyal bağlantılar", + "spotlight": "Spot ışığı", + "status": "Durum", + "status_description": "Web sitesinin durumunu kontrol edin", + "terms_and_privacy": "Gizlilik ve Şartlar", + "twitter": "Twitter", + "type_a_command_search": "Bir komut yazın veya arayın…", + "we_use_cookies": "Çerezleri kullanıyoruz", + "updated_text": "Hoppscotch v{version} sürümüne yükseltildi! 🎉", + "whats_new": "Ne var ne yok?", + "see_whats_new": "Yeni güncellemeleri gör", + "wiki": "Wiki", + "collapse_sidebar": "Kenar Çubuğunu Daralt", + "continue_to_dashboard": "Kontrol Paneline Devam Et", + "expand_sidebar": "Kenar Çubuğunu Genişlet", + "no_name": "İsimsiz", + "open_navigation": "Gezinmeyi Aç", + "read_documentation": "Belgeleri Oku", + "default": "varsayılan: {value}" + }, + "auth": { + "account_deactivated": "Hesabınız devre dışı bırakıldı. Erişim için yöneticiyle iletişime geçin.", + "account_exists": "Farklı kimlik bilgilerine sahip hesap var - Her iki hesabı birbirine bağlamak için giriş yapın", + "all_sign_in_options": "Tüm oturum açma seçenekleri", + "continue_with_auth_provider": "{provider} ile devam et", + "continue_with_email": "E-posta ile devam et", + "continue_with_github": "GitHub ile devam et", + "continue_with_github_enterprise": "GitHub Enterprise ile devam et", + "continue_with_google": "Google ile devam et", + "continue_with_microsoft": "Microsoft ile devam et", + "email": "E-posta", + "logged_out": "Çıkış yapıldı", + "login": "Giriş", + "login_success": "Başarıyla giriş yapıldı", + "login_to_hoppscotch": "Hoppscotch'a giriş yapın", + "logout": "Çıkış", + "re_enter_email": "E-posta adresinizi yeniden girin", + "send_magic_link": "Sihirli bir bağlantı gönder", + "sync": "Senkronizasyon", + "we_sent_magic_link": "Size sihirli bir bağlantı gönderdik!", + "we_sent_magic_link_description": "Gelen kutunuzu kontrol edin - {email} adresine bir e-posta gönderdik. Giriş yapmanızı sağlayacak sihirli bir bağlantı içerir." + }, + "authorization": { + "generate_token": "Anahtar oluştur", + "refresh_token": "Yenileme Token'ı", + "graphql_headers": "Yetki Başlıkları, connection_init'e yük parçası olarak gönderilir", + "include_in_url": "URL'e dahil et", + "inherited_from": "{auth}, {collection} adlı üst koleksiyondan devralındı", + "learn": "Öğren", + "oauth": { + "redirect_auth_server_returned_error": "Oturum Sunucusu bir hata durumu döndürdü", + "redirect_auth_token_request_failed": "Kimlik doğrulama anahtarı alma isteği başarısız oldu", + "redirect_auth_token_request_invalid_response": "Kimlik doğrulama anahtarı için istek yapılırken Anahtar Uç Noktasından geçersiz yanıt alındı", + "redirect_invalid_state": "Yönlendirmede geçersiz bir durum değeri mevcut", + "redirect_no_auth_code": "Yönlendirmede yetkilendirme kodu bulunamadı", + "redirect_no_client_id": "Müşteri Kimliği tanımlı değil", + "redirect_no_client_secret": "Müşteri Gizli Verisi tanımlı değil", + "redirect_no_code_verifier": "Kod Doğrulayıcısı tanımlı değil", + "redirect_no_token_endpoint": "Anahtar Uç Noktası tanımlı değil", + "something_went_wrong_on_oauth_redirect": "OAuth yönlendirmesi sırasında bir şeyler ters gitti", + "something_went_wrong_on_token_generation": "Anahtar oluşturma sırasında bir şeyler ters gitti", + "token_generation_oidc_discovery_failed": "Anahtar oluşturma hatası: OpenID Connect keşfi başarısız oldu", + "grant_type": "Yetki Türü", + "grant_type_auth_code": "Yetkilendirme Kodu", + "token_fetched_successfully": "Anahtar başarıyla alındı", + "token_fetch_failed": "Anahtar alınamadı", + "validation_failed": "Doğrulama başarısız oldu, lütfen form alanlarını kontrol edin", + "no_refresh_token_present": "Yenileme token'ı bulunamadı. Lütfen token oluşturma akışını tekrar çalıştırın", + "refresh_token_request_failed": "Yenileme token'ı isteği başarısız oldu", + "token_refreshed_successfully": "Token başarıyla yenilendi", + "label_authorization_endpoint": "Yetkilendirme Uç Noktası", + "label_client_id": "Müşteri Kimliği", + "label_client_secret": "Müşteri Gizli Verisi", + "label_code_challenge": "Kod Doğrulama", + "label_code_challenge_method": "Kod Doğrulama Yöntemi", + "label_code_verifier": "Kod Doğrulayıcısı", + "label_scopes": "Kapmsamlar", + "label_token_endpoint": "Anahtar Uç Noktası", + "label_use_pkce": "PKCE Kullan", + "label_implicit": "Dolaylı", + "label_password": "Şifre", + "label_username": "Kullanıcı Adı", + "label_auth_code": "Yetkilendirme Kodu", + "label_client_credentials": "Müşteri Kimlik Bilgileri", + "label_send_as": "İstemci Kimlik Doğrulaması", + "label_send_in_body": "Kimlik Bilgilerini Gövdede Gönder", + "label_send_as_basic_auth": "Kimlik Bilgilerini Basic Auth Olarak Gönder", + "enter_value": "Değer girin", + "auth_request": "Kimlik Doğrulama İsteği", + "token_request": "Token İsteği", + "refresh_request": "Yenileme İsteği", + "send_in": "Gönderim Yeri" + }, + "pass_key_by": "Şunla anahtar ekleyin", + "pass_by_query_params_label": "Sorgu parametreleri", + "pass_by_headers_label": "Başlıklar", + "password": "Şifre", + "save_to_inherit": "Lütfen yetkilendirmeyi devralmak için bu isteği herhangi bir koleksiyona kaydedin", + "token": "Anahtar", + "access_token": "Erişim Token'ı", + "client_token": "İstemci Token'ı", + "client_secret": "İstemci Gizli Anahtarı", + "timestamp": "Zaman Damgası", + "host": "Sunucu", + "type": "Yetki Türü", + "username": "Kullanıcı adı", + "advance_config": "Gelişmiş Yapılandırma", + "advance_config_description": "Hoppscotch, açık bir değer belirtilmediğinde belirli alanlara otomatik olarak varsayılan değerler atar", + "algorithm": "Algoritma", + "payload": "Yük", + "secret": "Gizli Anahtar", + "aws_signature": { + "access_key": "Erişim Anahtarı", + "secret_key": "Gizli Anahtar", + "service_name": "Hizmet Adı", + "aws_region": "AWS Bölgesi", + "service_token": "Hizmet Token'ı" + }, + "digest": { + "realm": "Realm", + "nonce": "Nonce", + "algorithm": "Algoritma", + "qop": "qop", + "nonce_count": "Nonce Sayısı", + "client_nonce": "İstemci Nonce", + "opaque": "Opaque", + "disable_retry": "İsteği Yeniden Denemeyi Devre Dışı Bırak" + }, + "akamai": { + "headers_to_sign": "İmzalanacak Başlıklar", + "max_body_size": "Maksimum Gövde Boyutu" + }, + "hawk": { + "id": "HAWK Auth Kimliği", + "key": "HAWK Auth Anahtarı", + "ext": "ext", + "app": "app", + "dlg": "dlg", + "include": "Payload Hash Dahil Et" + }, + "jwt": { + "params_name": "Parametre Adı", + "param_name": "Parametre Adı", + "header_prefix": "Başlık Ön Eki", + "placeholder_request_header": "İstek başlık ön eki", + "placeholder_request_param": "İstek parametre adı", + "secret_base64_encoded": "Base64 Kodlanmış Gizli Anahtar", + "headers": "JWT Başlıkları", + "private_key": "Özel Anahtar", + "placeholder_headers": "JWT Başlıkları" + }, + "ntlm": { + "domain": "Alan Adı", + "workstation": "İş İstasyonu", + "disable_retrying_request": "İsteği Yeniden Denemeyi Devre Dışı Bırak" + }, + "asap": { + "issuer": "Veren", + "audience": "Hedef Kitle", + "expires_in": "Süre Sonu", + "key_id": "Anahtar Kimliği", + "optional_config": "İsteğe Bağlı Yapılandırma", + "subject": "Konu", + "additional_claims": "Ek Talepler" + } + }, + "collection": { + "title": "Koleksiyon", + "run": "Koleksiyonu Çalıştır", + "created": "Koleksiyon oluşturuldu", + "different_parent": "Koleksiyonu farklı bir üst öğeyle yeniden sıralayamazsınız", + "edit": "Koleksiyonu düzenle", + "import_or_create": "Koleksiyon oluşturun veya içe aktarın", + "import_collection": "Koleksiyonu İçe Aktar", + "invalid_name": "Lütfen koleksiyon için geçerli bir ad girin", + "invalid_root_move": "Koleksiyon zaten kök dizinde", + "moved": "Başarıyla taşındı", + "my_collections": "Koleksiyonlarım", + "name": "Yeni Koleksiyonum", + "name_length_insufficient": "Koleksiyon adı en az 3 karakter uzunluğunda olmalıdır", + "new": "Yeni koleksiyon", + "order_changed": "Koleksiyon sıralaması güncellendi", + "properties": "Koleksiyon Özellikleri", + "properties_updated": "Koleksiyon özellikleri güncellendi", + "renamed": "Koleksiyon yeniden adlandırıldı", + "request_in_use": "İstek kullanımda", + "save_as": "Farklı kaydet", + "save_to_collection": "Koleksiyona Kaydet", + "select": "Bir Koleksiyon Seçin", + "select_location": "Konum seçin", + "sorted": "Koleksiyon sıralandı", + "details": "Detaylar", + "duplicated": "Koleksiyon çoğaltıldı" + }, + "confirm": { + "close_unsaved_tab": "Bu sekmeyi kapatmak istediğinizden emin misiniz?", + "close_unsaved_tabs": "Tüm sekmeleri kapatmak istediğinizden emin misiniz? {count} kaydedilmemiş sekme kaybolacak.", + "delete_all_activity_logs": "Tüm etkinlik günlüklerini silmek istediğinizden emin misiniz?", + "exit_team": "Bu takımdan ayrılmak istediğinizden emin misiniz?", + "logout": "Oturumu kapatmak istediğinizden emin misiniz?", + "remove_collection": "Bu koleksiyonu kalıcı olarak silmek istediğinizden emin misiniz?", + "remove_environment": "Bu ortamı kalıcı olarak silmek istediğinizden emin misiniz?", + "remove_folder": "Bu klasörü kalıcı olarak silmek istediğinizden emin misiniz?", + "remove_history": "Tüm geçmişi kalıcı olarak silmek istediğinizden emin misiniz?", + "remove_request": "Bu isteği kalıcı olarak silmek istediğinizden emin misiniz?", + "remove_response": "Bu yanıtı kalıcı olarak silmek istediğinizden emin misiniz?", + "remove_shared_request": "Bu paylaşılan isteği kalıcı olarak silmek istediğinizden emin misiniz?", + "remove_team": "Bu takımı silmek istediğinizden emin misiniz?", + "remove_telemetry": "Telemetriyi devre dışı bırakmak istediğinizden emin misiniz?", + "request_change": "Mevcut isteği iptal etmek istediğinizden emin misiniz? Kaydedilmemiş değişiklikler kaybolacak.", + "save_unsaved_tab": "Bu sekmede yapılan değişiklikleri kaydetmek istiyor musunuz?", + "sync": "Bu çalışma alanını senkronize etmek istediğinizden emin misiniz?", + "delete_access_token": "Erişim anahtarı {tokenLabel} öğesini silmek istediğinizden emin misiniz?", + "delete_mock_server": "Bu sahte sunucuyu silmek istediğinizden emin misiniz?" + }, + "context_menu": { + "add_parameters": "Parametrelere ekle", + "open_request_in_new_tab": "İsteği yeni sekmede aç", + "set_environment_variable": "Değişken olarak ata", + "encode_uri_component": "URL bileşenini kodla", + "decode_uri_component": "URL bileşeninin kodunu çöz" + }, + "cookies": { + "modal": { + "cookie_expires": "Süresi Dolma Tarihi", + "cookie_name": "Adı", + "cookie_path": "Yol", + "cookie_string": "Çerez Dizesi", + "cookie_value": "Değer", + "empty_domain": "Alan adı boş", + "empty_domains": "Alan adı listesi boş", + "enter_cookie_string": "Çerez dizesi girin", + "interceptor_no_support": "Şu anda seçili olan engelleyici çerezleri desteklemiyor. Farklı bir engelleyici seçip tekrar deneyin.", + "managed_tab": "Yönetilen", + "new_domain_name": "Yeni alan adı", + "no_cookies_in_domain": "Bu alan adına ait tanımlı çerez yok", + "raw_tab": "Ham", + "set": "Çerez ayarla" + } + }, + "count": { + "currentValue": "Mevcut değer {count}", + "description": "Açıklama {count}", + "header": "Başlık {count}", + "initialValue": "Başlangıç değeri {count}", + "key": "Anahtar {count}", + "message": "Mesaj {count}", + "parameter": "Parametre {count}", + "protocol": "Protokol {count}", + "value": "Değer {count}", + "variable": "Değişken {count}" + }, + "documentation": { + "add_description": "Bu koleksiyon için buraya açıklama ekleyin...", + "add_description_placeholder": "Buraya açıklama ekleyin...", + "add_request_description": "İstek için açıklama ekleyin...", + "auth": { + "access_key": "Erişim Anahtarı", + "access_token": "Erişim Token'ı", + "add_to": "Ekle", + "akamai_edgegrid": "Akamai EdgeGrid", + "algorithm": "Algoritma", + "api_key": "API Anahtarı", + "app_id": "Uygulama ID", + "auth_id": "Auth ID", + "auth_key": "Auth Anahtarı", + "auth_url": "Auth URL", + "aws_signature": "AWS İmzası", + "basic_auth": "Temel Kimlik Doğrulama", + "bearer_token": "Bearer Token", + "client_id": "İstemci ID", + "client_nonce": "İstemci Nonce", + "client_secret": "İstemci Gizli Anahtarı", + "client_token": "İstemci Token'ı", + "delegation": "Yetkilendirme", + "digest_auth": "Digest Auth", + "extra_data": "Ek Veri", + "grant_type": "Yetki Türü", + "hawk_auth": "HAWK Auth", + "headers_to_sign": "İmzalanacak Header'lar", + "host": "Sunucu", + "include_payload_hash": "Payload Hash'i Dahil Et", + "jwt_auth": "JWT Auth", + "max_body_size": "Maksimum Gövde Boyutu", + "no_auth": "Kimlik doğrulama yok", + "nonce": "Nonce", + "oauth_2": "OAuth 2.0", + "opaque": "Opaque", + "password": "Parola", + "payload": "Payload", + "qop": "QOP", + "realm": "Realm", + "region": "Bölge", + "scope": "Kapsam", + "secret_key": "Gizli Anahtar", + "service_name": "Hizmet Adı", + "timestamp": "Zaman Damgası", + "title": "Kimlik Doğrulama", + "token_url": "Token URL", + "user": "Kullanıcı", + "username": "Kullanıcı Adı" + }, + "body": { + "content_type": "İçerik Türü", + "no_body": "Gövde tanımlanmadı", + "title": "Gövde" + }, + "copied_to_clipboard": "Panoya kopyalandı!", + "curl": { + "click_to_load": "cURL komutunu yüklemek için tıklayın", + "copied": "cURL komutu panoya kopyalandı!", + "copy_to_clipboard": "Panoya kopyala", + "generating": "cURL komutu oluşturuluyor...", + "load": "cURL Yükle", + "title": "cURL" + }, + "description": "Açıklama", + "error_rendering_markdown": "Markdown oluşturma hatası:", + "fetching_documentation": "Dokümantasyon getiriliyor...", + "generate": "Belge oluştur", + "generate_message": "Güncel API belgelerini oluşturmak için herhangi bir Hoppscotch koleksiyonunu içe aktarın.", + "headers": { + "no_headers": "Header tanımlanmadı", + "title": "Header'lar" + }, + "hide_all_documentation": "Tüm Dokümantasyonu Gizle", + "inherited_from": "{name} kaynağından miras alındı", + "inherited_with_type": "{name} kaynağından {type} miras alındı", + "key": "Anahtar", + "loading": "Yükleniyor...", + "loading_collection_data": "Koleksiyon Verileri Yükleniyor...", + "no": "Hayır", + "no_collection_data": "Koleksiyon verisi mevcut değil", + "no_documentation_found": "Klasörler veya istekler için dokümantasyon bulunamadı", + "no_request_data": "İstek verisi mevcut değil", + "no_requests_or_folders": "İstek veya klasör yok", + "not_set": "Ayarlanmadı", + "open_request_in_new_tab": "İsteği yeni sekmede aç", + "parameters": { + "no_params": "Parametre tanımlanmadı", + "title": "Parametreler" + }, + "percent_complete": "% tamamlandı", + "processing_documentation": "Dokümantasyon İşleniyor", + "publish": { + "already_published": "Bu koleksiyon zaten yayımlanmış", + "auto_sync": "Koleksiyonla otomatik senkronizasyon", + "auto_sync_description": "Koleksiyon değiştiğinde yayımlanan dokümanları otomatik olarak güncelle", + "button": "Yayımla", + "copy_url": "URL Kopyala", + "delete": "Dokümantasyonu Sil", + "unpublish_doc": "Dokümantasyonu yayından kaldırmak istediğinizden emin misiniz?", + "delete_success": "Yayımlanan dokümantasyon başarıyla silindi", + "doc_title": "Başlık", + "doc_version": "Sürüm", + "edit_published_doc": "Yayımlanan Dokümanı Düzenle", + "last_updated": "Son Güncelleme", + "metadata": "Meta Veri (JSON)", + "open_published_doc": "Yayımlanan Dokümantasyonu yeni sekmede aç", + "publish_error": "Dokümantasyon yayımlanamadı", + "publish_success": "Dokümantasyon başarıyla yayımlandı!", + "published": "Yayımlandı", + "published_url": "Yayımlanan URL", + "title": "Dokümantasyonu Yayımla", + "update_button": "Güncelle", + "update_error": "Dokümantasyon güncellenemedi", + "update_published_docs": "Yayımlanan Dokümanları Güncelle", + "update_success": "Dokümantasyon başarıyla güncellendi!", + "unpublish": "Yayından Kaldır", + "update_title": "Yayımlanan Dokümantasyonu Güncelle", + "url_copied": "URL panoya kopyalandı!", + "view_published": "Yayımlanan Dokümanları Görüntüle", + "view_title": "Yayımlanan Dokümantasyon Anlık Görüntüsü", + "versions": "Sürümler", + "create_new_version": "Yeni Sürüm Oluştur", + "invalid_version": "Sürüm yalnızca alfanümerik karakterler, noktalar ve tire içerebilir", + "snapshot_description": "Bu, mevcut dokümantasyonun bu sürüm olarak anlık görüntüsünü alacak", + "live": "Canlı", + "snapshot": "Anlık Görüntü", + "version_immutable": "Yayımlanan sürümler salt okunur anlık görüntülerdir", + "view_snapshot": "Anlık Görüntüyü Görüntüle", + "current_version": "GÜNCEL", + "not_found": "Yayımlanan dokümantasyon bulunamadı", + "no_doc_id": "Doküman ID'si sağlanmadı", + "version_label": "v{version}", + "unpublish_version": "Bu sürümü yayından kaldır", + "loading_snapshot": "Anlık görüntü yükleniyor...", + "retry_snapshot": "Tekrar Dene", + "sensitive_data_warning": "Lütfen yayımlanan dokümantasyonda hassas verilerin açığa çıkmadığından emin olun.", + "snapshot_preview": "Anlık Görüntü Önizlemesi", + "snapshot_load_error": "Anlık görüntü önizlemesi yüklenemedi", + "snapshot_empty": "Bu anlık görüntüde istek veya klasör yok", + "snapshot_item_count": "{count} öğe", + "auto_sync_live_notice": "Bu sürüm canlı koleksiyonla otomatik senkronize edilir", + "untitled_project": "Başlıksız Proje", + "first_publish_hint": "Dokümantasyonunuz, koleksiyonunuzla otomatik olarak senkronize kalan canlı bir sürüm olarak yayımlanacak", + "environment": "Ortam", + "no_environment": "Ortam yok", + "environment_description": "Yayımlanan dokümantasyondaki değişkenleri çözümlemek için bir ortam ekleyin" + }, + "request_opened_in_new_tab": "İstek yeni sekmede açıldı!", + "response": { + "body": "Yanıt Gövdesi", + "copy": "Yanıtı kopyala", + "example_copied": "Yanıt örneği panoya kopyalandı!", + "example_copy_failed": "Yanıt örneği kopyalanamadı", + "headers": "Yanıt Header'ları", + "no_examples": "Yanıt örneği mevcut değil", + "title": "Yanıt Örnekleri" + }, + "save_error": "Dokümantasyon kaydedilirken hata oluştu", + "save_success": "Dokümantasyon başarıyla kaydedildi", + "saved_items_status": "{success} öğe kaydedildi. {failure} öğe kaydedilemedi.", + "show_all_documentation": "Tüm Dokümantasyonu Göster", + "source": "Kaynak", + "title": "Dokümantasyon", + "unsaved_changes": "{count} kaydedilmemiş değişikliğiniz var. Lütfen kapatmadan önce kaydedin.", + "untitled_collection": "Başlıksız Koleksiyon", + "untitled_request": "Başlıksız İstek", + "value": "Değer", + "variables": { + "no_vars": "Değişken tanımlanmadı", + "title": "Değişkenler" + }, + "yes": "Evet" + }, + "empty": { + "activity_logs": "Etkinlik günlüğü bulunamadı", + "authorization": "Bu istek herhangi bir yetkilendirme kullanmıyor", + "body": "Bu isteğin bir gövdesi yok", + "collection": "Koleksiyon boş", + "collections": "Koleksiyonlar boş", + "documentation": "Dokümanları görmek için GraphQL uç noktasını bağlayın", + "empty_schema": "Şema bulunamadı", + "endpoint": "Uç nokta boş olamaz", + "environments": "Ortamlar boş", + "collection_variables": "Koleksiyon değişkenleri boş", + "folder": "Klasör boş", + "headers": "Bu isteğin herhangi bir başlığı yok", + "history": "Geçmiş boş", + "invites": "Davet listesi boş", + "members": "Takım boş", + "parameters": "Bu isteğin herhangi bir parametresi yok", + "pending_invites": "Bu takım için bekleyen bir istek yok", + "profile": "Bu profili görüntülemek için giriş yapın", + "protocols": "Protokoller boş", + "request_variables": "Bu isteğin herhangi bir istek değişkeni yok", + "schema": "Bir GraphQL uç noktasına bağlanma", + "search_environment": "Eşleşen ortam bulunamadı", + "secret_environments": "Gizli veriler Hoppscotch ile senkronize edilmiyor", + "shared_requests": "Paylaşılan istekler boş", + "shared_requests_logout": "Paylaşılan isteklerinizi görüntülemek veya yeni bir istek oluşturmak için giriş yapın", + "subscription": "Abonelikler boş", + "team_name": "Takım adı boş", + "teams": "Takımlar boş", + "tests": "Bu istek için test yok", + "access_tokens": "Erişim anahtarları boş", + "response": "Yanıt alınamadı", + "mock_servers": "Mock sunucu bulunamadı" + }, + "environment": { + "heading": "Ortam", + "add_to_global": "Globale ekle", + "added": "Ortam eklendi", + "create_new": "Yeni ortam oluştur", + "created": "Ortam oluşturuldu", + "current_value": "Mevcut değer", + "deleted": "Ortam silindi", + "duplicated": "Değişkenler klonlandı", + "edit": "Ortamı düzenle", + "empty_variables": "Değişken yok", + "global": "Global", + "global_variables": "Global değişkenler", + "import_or_create": "Değişkenleri içe aktar ya da oluştur", + "initial_value": "Başlangıç değeri", + "invalid_name": "Lütfen ortam için geçerli bir ad girin", + "list": "Ortam değişkenleri", + "my_environments": "Ortamlarım", + "name": "İsim", + "nested_overflow": "İç içe ortam değişkenleri 10 seviyeyle sınırlıdır", + "new": "Yeni ortam", + "no_active_environment": "Aktif ortam yok", + "no_environment": "Ortam yok", + "no_environment_description": "Hiçbir ortam seçilmedi. Aşağıdaki değişkenlerle ne yapacağınızı seçin.", + "quick_peek": "Ortam Değişkenlerine Hızlı Bakış", + "replace_all_current_with_initial": "Tüm mevcut değerleri başlangıç değerleriyle değiştir", + "replace_all_initial_with_current": "Tüm başlangıç değerlerini mevcut değerlerle değiştir", + "replace_current_with_initial": "Başlangıç değeriyle değiştir", + "replace_initial_with_current": "Mevcut değerle değiştir", + "replace_with_variable": "Değişkenle değiştir", + "scope": "Kapsam", + "secrets": "Gizli Veriler", + "secret_value": "Gizli değer", + "select": "Ortam seçin", + "set": "Ortama ata", + "set_as_environment": "Ortam olarak ata", + "short_name": "Ortam adı en az 1 karakter içermelidir", + "team_environments": "Takım Ortamları", + "title": "Ortamlar", + "updated": "Ortam güncellendi", + "value": "Değer", + "variable": "Değişken", + "variables": "Değişkenler", + "variable_list": "Değişken listesi", + "properties": "Ortam Özellikleri", + "details": "Detaylar" + }, + "error": { + "network": { + "heading": "Ağ Hatası", + "description": "Ağ bağlantısı başarısız oldu. {message}: {cause}" + }, + "timeout": { + "heading": "Zaman Aşımı Hatası", + "description": "{phase} sırasında istek zaman aşımına uğradı. {message}" + }, + "certificate": { + "heading": "Sertifika Hatası", + "description": "Geçersiz sertifika. {message}: {cause}" + }, + "auth": { + "heading": "Kimlik Doğrulama Hatası", + "description": "Erişim reddedildi. {message}: {cause}" + }, + "proxy": { + "heading": "Proxy Hatası", + "description": "Proxy bağlantısı başarısız oldu. {message}: {cause}" + }, + "parse": { + "heading": "Ayrıştırma Hatası", + "description": "Yanıt ayrıştırılamadı. {message}: {cause}" + }, + "version": { + "heading": "Sürüm Hatası", + "description": "Uyumsuz sürümler. {message}: {cause}" + }, + "abort": { + "heading": "İstek İptal Edildi", + "description": "İşlem iptal edildi. {message}: {cause}" + }, + "unknown": { + "heading": "Bilinmeyen Hata", + "description": "Bilinmeyen bir hata oluştu.", + "cause": "Bilinmeyen neden" + }, + "extension": { + "heading": "Eklenti hatası", + "description": "İstek eklentide çalıştırılamadı" + }, + "authproviders_load_error": "Kimlik sağlayıcıları yüklenemedi", + "browser_support_sse": "Bu tarayıcıda SSE desteği yok gibi görünüyor.", + "check_console_details": "Ayrıntılar için konsol günlüğünü kontrol edin.", + "check_how_to_add_origin": "Kaynağı nasıl ekleyebileceğinizi kontrol edin", + "curl_invalid_format": "cURL düzgün biçimlendirilmemiş", + "danger_zone": "Tehlike Alanı", + "delete_account": "Hesabınız şu anda bu ekiplerin sahibi:", + "delete_account_description": "Hesabınızı silmeden önce kendinizi kaldırmalı, sahipliği devretmeli veya bu ekipleri silmelisiniz.", + "delete_activity_log": "Etkinlik günlüğü silinemedi", + "delete_all_activity_logs": "Tüm etkinlik günlükleri silinemedi", + "email_already_exists": "Bu e-posta adresi farklı bir hesapla zaten mevcut. Lütfen farklı bir e-posta adresi belirleyin.", + "empty_email_address": "E-posta Adresi boş olamaz", + "empty_profile_name": "Profil adı boş olamaz", + "empty_req_name": "Boş İstek Adı", + "fetch_activity_logs": "Etkinlik günlükleri getirilemedi", + "f12_details": "(Ayrıntılar için F12)", + "gql_prettify_invalid_query": "Geçersiz bir sorgu güzelleştirilemedi, sorgu sözdizimi hatalarını çözüp tekrar deneyin.", + "incomplete_config_urls": "Eksik yapılandırma URL'leri", + "incorrect_email": "Geçersiz e-posta", + "invalid_file_type": "`{filename}` için geçersiz dosya türü.", + "invalid_link": "Geçersiz bağlantı", + "invalid_link_description": "Tıkladığınız bağlantı süresi dolmuş veya geçersiz.", + "invalid_embed_link": "Gömülü bağlantı mevcut değil veya geçersiz.", + "json_parsing_failed": "Geçersiz JSON", + "json_prettify_invalid_body": "Geçersiz bir gövde güzelleştirilemedi, JSON sözdizimi hatalarını çözüp tekrar deneyin.", + "network_error": "Görünüşe göre bir ağ hatası oluştu. Lütfen tekrar deneyin.", + "network_fail": "İstek gönderilemedi", + "no_collections_to_export": "Dışa aktarılacak koleksiyon yok. Başlamak için bir koleksiyon oluşturun.", + "no_duration": "Süre belirtilmedi", + "no_environments_to_export": "Dışa aktarılacak ortam yok. Başlamak için bir ortam oluşturun.", + "no_results_found": "Eşleşen sonuç bulunamadı", + "page_not_found": "Bu sayfa bulunamadı", + "please_install_extension": "Eklentiyi yükleyin ve kaynağı eklentiye ekleyin.", + "proxy_error": "Proxy hatası", + "same_email_address": "Güncellenen e-posta adresi, geçerli e-posta adresiyle aynıdır", + "same_profile_name": "Güncellenen profil adı mevcut profil adıyla aynı", + "script_fail": "Ön istek komut dosyası çalıştırılamadı", + "something_went_wrong": "Bir şeyler yanlış gitti", + "subscription_error": "Konuya abone olunamadı: {error}", + "post_request_script_fail": "Sonraki istek komut dosyası çalıştırılamadı", + "reading_files": "Bir veya daha fazla dosya okunurken hata oluştu.", + "fetching_access_tokens_list": "Anahtar listesi alınırken bir sorun oluştu.", + "generate_access_token": "Erişim anahtarı oluşturulurken bir sorun oluştu.", + "delete_access_token": "Erişim anahtarı silinirken bir sorun oluştu.", + "extension_not_found": "Eklenti bulunamadı", + "invalid_request": "Geçersiz istek verisi" + }, + "export": { + "as_json": "JSON olarak dışa aktar", + "create_secret_gist": "Gizli Gist oluştur", + "create_secret_gist_tooltip_text": "Gizli Gist olarak dışa aktar", + "failed": "Dışa aktarma sırasında bir şeyler ters gitti.", + "secret_gist_success": "Gizli Gist olarak başarıyla dışa aktarıldı.", + "require_github": "Gizli Gist oluşturmak için GitHub ile giriş yapın.", + "title": "Dışarı Aktar", + "success": "Başarıyla dışa aktarıldı." + }, + "file_upload": { + "choose_file": "Dosya seç", + "max_size_format": "Maks. 5MB. JPEG, PNG, GIF, WebP destekler", + "profile_photo_updated": "Profil fotoğrafı başarıyla güncellendi", + "profile_photo_removed": "Profil fotoğrafı başarıyla kaldırıldı", + "org_logo_updated": "Organizasyon logosu başarıyla güncellendi", + "error_size_limit": "Dosya boyutu 5MB'den küçük olmalıdır", + "error_invalid_format": "Dosya bir görsel olmalıdır (JPEG, PNG, GIF veya WebP)", + "error_invalid_upload_type": "Geçersiz yükleme türü", + "error_invalid_org_id": "Geçersiz organizasyon ID formatı", + "error_upload_failed": "Yükleme başarısız oldu. Lütfen tekrar deneyin", + "error_network_failed": "Ağ hatası. Lütfen bağlantınızı kontrol edin", + "error_timeout": "Yükleme 30 saniye sonra zaman aşımına uğradı. Lütfen tekrar deneyin", + "error_missing_backend_url": "Backend URL yapılandırılmamış. Lütfen ortam ayarlarınızda VITE_BACKEND_API_URL ortam değişkenini ayarlayın", + "error_invalid_backend_url": "Geçersiz backend URL yapılandırması" + }, + "filename": { + "cookie_key_value_pairs": "Cookie", + "codegen": "{request_name} - kod", + "graphql_response": "GraphQL-Yanıt", + "lens": "{request_name} - yanıt", + "realtime_response": "Gerçek-Zamanlı-Yanıt", + "response_interface": "Yanıt-Arayüzü" + }, + "filter": { + "all": "Tümü", + "none": "Hiçbiri", + "starred": "Yıldızlı" + }, + "folder": { + "created": "Klasör oluşturuldu", + "edit": "Klasörü düzenle", + "invalid_name": "Lütfen klasör için bir ad girin", + "name_length_insufficient": "Klasör adı en az 3 karakterden oluşmalıdır", + "new": "Yeni Klasör", + "run": "Klasörü Çalıştır", + "renamed": "Klasör yeniden adlandırıldı", + "sorted": "Klasör sıralandı" + }, + "graphql": { + "arguments": "Argümanlar", + "connection_switch_confirm": "En son GraphQL uç noktasına bağlanmak ister misiniz?", + "connection_error_http": "Ağ hatası nedeniyle GraphQL Şeması getirilemedi.", + "connection_switch_new_url": "Bir sekmeye geçmek sizi etkin GraphQL bağlantısından ayıracaktır. Yeni bağlantı URL'si", + "connection_switch_url": "Bir GraphQL uç noktasına bağlandınız, bağlantı URL'si şu şekildedir:", + "deprecated": "Kullanımdan kaldırıldı", + "fields": "Alanlar", + "mutation": "Mutation", + "mutations": "Mutasyonlar", + "schema": "Şema", + "show_depricated_values": "Kullanımdan kaldırılan değerleri göster", + "subscription": "Subscription", + "subscriptions": "Abonelikler", + "switch_connection": "Bağlantı değiştir", + "url_placeholder": "Bir GraphQL Uç Nokta Adresi Gir", + "query": "Query" + }, + "graphql_collections": { + "title": "GraphQL Koleksiyonları" + }, + "group": { + "time": "Süre", + "url": "URL" + }, + "header": { + "install_pwa": "Uygulamayı yükle", + "login": "Giriş yap", + "save_workspace": "Çalışma alanımı kaydet" + }, + "helpers": { + "authorization": "Yetkilendirme başlığı, isteği gönderdiğinizde otomatik olarak oluşturulur.", + "collection_properties_authorization": "Bu yetkilendirme bu koleksiyondaki her istek için ayarlanacaktır.", + "collection_properties_header": "Bu başlık bu koleksiyondaki her istek için ayarlanacaktır.", + "generate_documentation_first": "Önce belgeleri oluşturun", + "network_fail": "API uç noktasına ulaşılamıyor. Ağ bağlantınızı kontrol edin ve tekrar deneyin.", + "offline": "Çevrimdışı görünüyorsun. Bu çalışma alanındaki veriler güncel olmayabilir.", + "offline_short": "Çevrimdışı görünüyorsun.", + "post_request_tests": "Test komut dosyaları JavaScript'te yazılır ve yanıt alındıktan sonra çalıştırılır.", + "pre_request_script": "Ön istek komut dosyaları JavaScript'te yazılır ve istek gönderilmeden önce çalıştırılır.", + "script_fail": "Ön istek komut dosyasında bir aksaklık var gibi görünüyor. Aşağıdaki hatayı kontrol edin ve komut dosyasını buna göre düzeltin.", + "post_request_script_fail": "Test komutunda bir hata var gibi görünüyor. Lütfen hataları düzeltin ve testleri tekrar çalıştırın", + "post_request_script": "Hata ayıklamayı otomatikleştirmek için bir test komut dosyası yazın." + }, + "hide": { + "collection": "Koleksiyon Panelini Daralt", + "more": "Daha fazlasını gizle", + "preview": "Önizlemeyi Gizle", + "sidebar": "Kenar çubuğunu gizle", + "password": "Parolayı Gizle" + }, + "import": { + "collections": "Koleksiyonları içe aktar", + "curl": "cURL'ü içe aktar", + "environments_from_gist": "Gist'ten İçe Aktar", + "environments_from_gist_description": "Gist'ten Hoppscotch Ortamlarını İçe Aktar", + "failed": "İçe aktarılamadı", + "from_file": "Dosyadan içe aktar", + "from_gist": "Gist'ten içe aktar", + "from_gist_description": "Gist ile içe aktar", + "from_gist_import_summary": "Tüm Hoppscotch özellikleri içe aktarılır.", + "from_hoppscotch_importer_summary": "Tüm Hoppscotch özellikleri içe aktarılır.", + "from_insomnia": "Insomnia'dan ile içe aktar", + "from_insomnia_description": "Insomnia koleksiyonunu içe aktar", + "from_insomnia_import_summary": "Koleksiyonlar ve İstekler içe aktarılacak.", + "from_json": "Hoppscotch ile içe aktar", + "from_json_description": "Hoppscotch koleksiyonunu içe aktar", + "from_my_collections": "Koleksiyonlarımdan içe aktar", + "from_my_collections_description": "Koleksiyon dosyalarımdan içe aktar", + "from_all_collections": "Başka Bir Çalışma Alanından İçe Aktar", + "from_all_collections_description": "Başka bir çalışma alanından mevcut çalışma alanına herhangi bir koleksiyonu içe aktarın", + "from_openapi": "OpenAPI'den içe aktar", + "from_openapi_description": "OpenAPI belirtilmiş dosyasından içe aktar (YML/JSON)", + "from_openapi_import_summary": "Koleksiyonlar (etiketlerden oluşturulacak), İstekler ve yanıt örnekleri içe aktarılacak.", + "from_postman": "Postman'den içe aktar", + "from_postman_description": "Postman koleksiyonunu içe aktar", + "from_postman_import_summary": "Koleksiyonlar, İstekler ve yanıt örnekleri içe aktarılacak.", + "import_scripts": "Betikleri içe aktar", + "import_scripts_description": "Postman Collection v2.0/v2.1 destekler.", + "from_url": "Bağlantı'yı içe aktar", + "gist_url": "Gist bağlantısını girin", + "from_har": "HAR'dan İçe Aktar", + "from_har_description": "HAR dosyasından içe aktar", + "from_har_import_summary": "İstekler varsayılan bir koleksiyona aktarılacak.", + "gql_collections_from_gist_description": "Gist'ten GraphQL Koleksiyonlarını İçe Aktar", + "hoppscotch_environment": "Hoppscotch Ortamları", + "hoppscotch_environment_description": "Hoppscotch Ortam JSON Dosyasını İçe Aktar", + "import_from_url_invalid_fetch": "URL'den veri alınamadı", + "import_from_url_invalid_file_format": "Koleksiyonlar içe aktarılırken hata oluştu", + "import_from_url_invalid_type": "Desteklenmeyen tür. Kabul edilen değerler: 'hoppscotch', 'openapi', 'postman' ve 'insomnia'", + "import_from_url_success": "Koleksiyonlar İçe Aktarıldı", + "insomnia_environment_description": "Insomnia Ortamını bir JSON/YAML dosyasından içe aktarın", + "json_description": "Hoppscotch Koleksiyonlar JSON dosyasından koleksiyonları içe aktarın", + "postman_environment": "Postman Ortamı", + "postman_environment_description": "Postman Ortamını bir JSON dosyasından içe aktarın", + "title": "İçe aktar", + "file_size_limit_exceeded_warning_multiple_files": "Seçilen dosyalar önerilen {sizeLimit}MB sınırını aşıyor. Sadece seçilen ilk {files} içe aktarılacak", + "file_size_limit_exceeded_warning_single_file": "Şu anda seçilen dosya önerilen {sizeLimit}MB sınırını aşıyor. Lütfen başka bir dosya seçin.", + "success": "Başarıyla içe aktarıldı", + "import_summary_collections_title": "Koleksiyonlar", + "import_summary_requests_title": "İstekler", + "import_summary_responses_title": "Yanıtlar", + "import_summary_pre_request_scripts_title": "İstek öncesi betikler", + "import_summary_post_request_scripts_title": "İstek sonrası betikler", + "import_summary_not_supported_by_hoppscotch_import": "Şu anda bu kaynaktan {featureLabel} içe aktarımını desteklemiyoruz.", + "import_summary_script_found": "betik bulundu ancak içe aktarılmadı", + "import_summary_scripts_found": "betik bulundu ancak içe aktarılmadı", + "import_summary_enable_experimental_sandbox": "Postman betiklerini içe aktarmak için ayarlarda 'Deneysel betik sandbox'ını etkinleştirin. Not: Bu özellik deneyseldir.", + "cors_error_modal": { + "title": "CORS Hatası Tespit Edildi", + "description": "İçe aktarma, sunucunun uyguladığı CORS (Cross-Origin Resource Sharing) kısıtlamaları nedeniyle başarısız oldu.", + "explanation": "Bu, web sayfalarının farklı alan adlarına istek göndermesini engelleyen bir güvenlik özelliğidir. Bu kısıtlamayı aşmak için proxy hizmetimizi kullanarak tekrar deneyebilirsiniz.", + "url_label": "Denenen URL", + "retry_with_proxy": "Proxy ile Tekrar Dene" + } + }, + "instances": { + "switch": "Hoppscotch Sunucusunu Değiştir", + "enter_server_url": "Kendi barındırdığınız bir sunucuya bağlanın", + "already_connected": "Bu sunucuya zaten bağlısınız", + "recent_connections": "Son Bağlantılar", + "add_instance": "Sunucu ekle", + "add_new": "Yeni bir sunucu ekle", + "confirm_remove": "Kaldırmayı Onayla", + "remove_warning": "Bu sunucuyu kaldırmak istediğinizden emin misiniz?", + "clear_cached_bundles": "Önbelleğe alınmış paketleri temizle", + "opening_add_modal": "Sunucu ekleme penceresi açılıyor", + "closed_add_modal": "Sunucu ekleme penceresi kapatıldı", + "cancelled_removal": "Sunucu kaldırma işlemi iptal edildi", + "connection_cancelled": "Bağlantı, ön bağlantı doğrulaması tarafından iptal edildi", + "post_connect_completed": "Bağlantı sonrası kurulum tamamlandı", + "connecting": "Sunucuya bağlanılıyor...", + "confirm_removal": "Sunucu kaldırmayı onayla", + "removal_cancelled": "Sunucu kaldırma, ön kaldırma doğrulaması tarafından iptal edildi", + "post_remove_completed": "Kaldırma sonrası temizlik tamamlandı", + "removing": "Sunucu kaldırılıyor...", + "clearing_cache": "Önbellek temizleniyor...", + "initialized": "Sunucu değiştirici başlatıldı", + "connecting_state": "Bağlantı kuruluyor...", + "connected_state": "Sunucuya başarıyla bağlanıldı", + "disconnected_state": "Sunucu bağlantısı kesildi", + "stream_error": "Bağlantı durumu izleme başarısız oldu", + "recent_instances_error": "Son sunucular yüklenemedi", + "instance_changed": "Sunucu değiştirildi", + "current_instance_error": "Mevcut sunucu izlenemedi", + "not_available": "Sunucu değiştirme kullanılamıyor", + "cleanup_completed": "Sunucu değiştirici temizliği tamamlandı", + "self_hosted": "Kendi barındırılan sunucular" + }, + "inspections": { + "description": "Olası hataları inceleyin", + "environment": { + "add_environment": "Ortama Ekle", + "add_environment_value": "Değer ekle", + "empty_value": "'{variable}' değişkeni için ortam değeri boş", + "not_found": "'{environment}' ortam değişkeni bulunamadı." + }, + "header": { + "cookie": "Tarayıcı Hoppscotch'un Çerez Başlığını ayarlamasına izin vermiyor. Hoppscotch Masaüstü Uygulaması üzerinde çalışırken (yakında geliyor), lütfen bunun yerine Yetkilendirme Başlığını kullanın." + }, + "response": { + "401_error": "Kimlik doğrulama bilgilerinizi kontrol edin.", + "404_error": "İstek URL'sini ve yöntem türünü kontrol edin.", + "cors_error": "Çapraz Kaynak Paylaşımı (CORS) yapılandırmanızı kontrol edin.", + "default_error": "İsteğinizi kontrol edin.", + "network_error": "Ağ bağlantınızı kontrol edin." + }, + "title": "Denetleyici", + "url": { + "extension_not_installed": "Eklenti yüklenmemiş.", + "extension_unknown_origin": "API uç noktasının kaynağını Hoppscotch Tarayıcı Eklentisi listesine eklediğinizden emin olun.", + "extention_enable_action": "Tarayıcı Eklentisini Etkinleştir", + "extention_not_enabled": "Eklenti etkin değil.", + "localaccess_unsupported": "Mevcut interceptor yerel erişimi desteklemiyor, lütfen Agent, Extension interceptor veya Masaüstü Uygulamasını kullanmayı düşünün" + }, + "auth": { + "digest": "Digest Authorization kullanırken web uygulamasında Agent interceptor veya Masaüstü uygulamasında Native interceptor önerilir.", + "hawk": "Hawk Authorization kullanırken web uygulamasında Agent interceptor veya Masaüstü uygulamasında Native interceptor önerilir." + }, + "body": { + "binary": "Mevcut interceptor üzerinden ikili veri gönderimi henüz desteklenmiyor." + }, + "scripting_interceptor": { + "pre_request": "istek öncesi betik", + "post_request": "istek sonrası betik", + "both_scripts": "istek öncesi ve istek sonrası betikler", + "unsupported_interceptor": "{scriptType} betiğiniz {apiUsed} kullanıyor. Güvenilir betik çalıştırma için Agent interceptor (web uygulaması) veya Native interceptor (Masaüstü uygulaması) kullanın. {interceptor} interceptor, betik istekleri için sınırlı desteğe sahiptir ve beklendiği gibi çalışmayabilir.", + "same_origin_csrf_warning": "Güvenlik Uyarısı: {scriptType} betiğiniz {apiUsed} kullanarak aynı kaynağa istek gönderiyor. Bu platform çerez tabanlı kimlik doğrulama kullandığından, bu istekler oturum çerezlerinizi otomatik olarak içerir ve kötü amaçlı betiklerin yetkisiz işlemler yapmasına olanak tanıyabilir. Aynı kaynağa yapılan istekler için Agent interceptor kullanın veya yalnızca güvendiğiniz betikleri çalıştırın." + } + }, + "interceptor": { + "native": { + "name": "Yerel", + "settings_title": "Yerel" + }, + "agent": { + "name": "Agent", + "settings_title": "Agent" + }, + "proxy": { + "name": "Proxy", + "settings_title": "Proxy" + }, + "browser": { + "name": "Tarayıcı", + "settings_title": "Tarayıcı" + }, + "extension": { + "name": "Uzantı", + "settings_title": "Uzantı" + } + }, + "layout": { + "collapse_collection": "Koleksiyonları Daralt veya Genişlet", + "collapse_sidebar": "Yan paneli Daralt veya Genişlet", + "column": "Dikey görünüm", + "name": "Görünüm", + "row": "Yatay görünüm" + }, + "modal": { + "close_unsaved_tab": "Kaydedilmemiş değişiklikleriniz var", + "collections": "Koleksiyonlar", + "confirm": "Onayla", + "customize_request": "İsteği Özelleştir", + "edit_request": "İsteği Düzenle", + "edit_response": "Yanıtı Düzenle", + "import_export": "İçe/Dışa Aktarma", + "response_name": "Yanıt Adı", + "share_request": "İsteği Paylaş" + }, + "mqtt": { + "already_subscribed": "Bu konuya zaten abone oldunuz.", + "clean_session": "Temiz Oturum", + "clear_input": "Girişi Temizle", + "clear_input_on_send": "Gönderildiğinde girişi temizle", + "client_id": "Müşteri ID", + "color": "Bir renk seçin", + "communication": "İletişim", + "connection_config": "Bağlantı Yapılandırması", + "connection_not_authorized": "Bu MQTT bağlantısı herhangi bir kimlik doğrulama kullanmıyor.", + "invalid_topic": "Lütfen abonelik için bir konu girin", + "keep_alive": "Canlı Tut", + "log": "Kayıt", + "lw_message": "Last-Will Mesajı", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Konusu", + "message": "İleti", + "new": "Yeni Abonelik", + "not_connected": "Lütfen önce bir MQTT bağlantısı başlatın.", + "publish": "Yayınla", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Abone Ol", + "topic": "Başlık", + "topic_name": "Konu adı", + "topic_title": "Konuyu Yayınla / Abone Ol", + "unsubscribe": "Aboneliği iptal et", + "url": "Bağlantı" + }, + "navigation": { + "admin_dashboard": "Gösterge Paneli", + "doc": "Dokümanlar", + "graphql": "GraphQL", + "profile": "Profil", + "realtime": "Gerçek Zamanlı", + "rest": "REST", + "mock_servers": "Mock Sunucular", + "settings": "Ayarlar", + "goto_app": "Uygulamaya Git", + "authentication": "Kimlik Doğrulama" + }, + "mock_server": { + "confirm_delete_log": "Bu günlüğü silmek istediğinizden emin misiniz?", + "create_mock_server": "Mock Sunucuyu Yapılandır", + "mock_server_configuration": "Mock Sunucu Yapılandırması", + "mock_server_name": "Mock Sunucu Adı", + "mock_server_name_placeholder": "Mock sunucu adını girin", + "base_url": "Temel URL", + "start_server": "Sunucuyu Başlat", + "stop_server": "Sunucuyu Durdur", + "mock_server_created": "Mock sunucu başarıyla oluşturuldu", + "mock_server_started": "Mock sunucu başarıyla başlatıldı", + "mock_server_stopped": "Mock sunucu başarıyla durduruldu", + "active": "Mock sunucu aktif", + "inactive": "Mock sunucu pasif", + "edit_mock_server": "Mock Sunucuyu Düzenle", + "path_based_url": "Yol tabanlı URL", + "subdomain_based_url": "Alt alan adı tabanlı URL", + "mock_server_updated": "Mock sunucu başarıyla güncellendi", + "no_collection": "Koleksiyon yok", + "collection_deleted": "ilişkili koleksiyon silindi.", + "private_access_hint": "Özel mock sunucular için, Kişisel Erişim Token'ınızla birlikte 'x-api-key' header'ını ekleyin (profilinizden oluşturabilirsiniz).", + "private_access_instruction": "Bu özel mock sunucuya erişmek için, Kişisel Erişim Token'ınızla birlikte 'x-api-key' header'ını ekleyin.", + "create_token_here": "Burada oluşturun", + "status": "Durum", + "server_running": "Sunucu çalışıyor", + "server_stopped": "Sunucu durduruldu", + "delay_ms": "Yanıt Gecikmesi (ms)", + "delay_placeholder": "Gecikmeyi milisaniye cinsinden girin", + "delay_description": "Mock yanıtlara yapay gecikme ekleyin", + "public_access": "Herkese Açık Erişim", + "public": "Herkese Açık", + "private": "Özel", + "public_description": "URL'ye sahip herkes bu mock sunucuya erişebilir", + "private_description": "Bu mock sunucuya yalnızca kimliği doğrulanmış kullanıcılar erişebilir", + "select_collection": "Bir koleksiyon seçin", + "select_collection_error": "Lütfen bir koleksiyon seçin", + "invalid_collection_error": "Koleksiyon için mock sunucu oluşturulamadı.", + "url_copied": "URL panoya kopyalandı", + "make_public": "Herkese Açık Yap", + "view_logs": "Günlükleri görüntüle", + "logs_title": "Mock Sunucu Günlükleri", + "no_logs": "Kullanılabilir günlük yok", + "request_headers": "İstek Header'ları", + "request_body": "İstek Gövdesi", + "response_status": "Yanıt Durumu", + "response_headers": "Yanıt Header'ları", + "response_body": "Yanıt Gövdesi", + "log_deleted": "Günlük başarıyla silindi", + "description": "Mock sunucular, koleksiyonunuzun örnek yanıtlarına dayalı olarak API yanıtlarını simüle etmenizi sağlar.", + "set_in_environment": "Ortama ayarla", + "set_in_environment_hint": "Mock sunucu URL'si koleksiyonun ortamına otomatik olarak 'mockUrl' değişkeni olarak eklenecektir", + "environment_variable_added": "Mock URL ortama eklendi", + "environment_variable_updated": "Ortamdaki mock URL güncellendi", + "environment_created_with_variable": "Mock URL ile ortam oluşturuldu", + "add_example_request": "Örnek istek ekle", + "add_example_request_hint": "Koleksiyon, mock sunucunun nasıl kullanılacağını gösteren örnek bir istekle oluşturulacaktır", + "create_example_collection": "Örnek koleksiyon oluştur", + "create_example_collection_hint": "Örnek isteklerle (GET, POST, PUT, DELETE) bir evcil hayvan mağazası örnek koleksiyonu oluşturun", + "creating_example_collection": "Örnek koleksiyon oluşturuluyor...", + "failed_to_create_collection": "Örnek koleksiyon oluşturulamadı", + "enable_example_collection_hint": "Yeni koleksiyon modu için lütfen 'Örnek koleksiyon oluştur' düğmesini etkinleştirin", + "new_collection_name_hint": "Koleksiyon, mock sunucunuzla aynı adla oluşturulacaktır", + "existing_collection": "Mevcut Koleksiyon", + "new_collection": "Yeni Koleksiyon" + }, + "preRequest": { + "javascript_code": "JavaScript Kodu", + "learn": "Belgeleri okuyun", + "script": "Ön İstek Komut Dosyası", + "snippets": "Bölümler" + }, + "profile": { + "app_settings": "Uygulama ayarları", + "default_hopp_displayname": "Adsız Kullanıcı", + "editor": "Editör", + "editor_description": "Editörler istekleri ekleyebilir, düzenleyebilir ve silebilir.", + "email_verification_mail": "Doğrulama bağlantısı e-postanıza gönderildi. E-postanızı doğrulamak için gelen bağlantıya tıklayınız.", + "no_permission": "Bu eylemi gerçekleştirmek için gerekli yetkiniz yok.", + "owner": "Sahip", + "owner_description": "Sahip istekleri, koleksiyonları ve takımları yönetebilir.", + "roles": "Roller", + "roles_description": "Roller, paylaşılan koleksiyonlara erişimi kontrol etmek için kullanılır.", + "updated": "Profil güncellendi", + "viewer": "Misafir", + "viewer_description": "Misafirler sadece istekleri görüntüleyebilir ve kullanabilir.", + "verified_email_sent": "E-posta adresinize bir doğrulama e-postası gönderildi. Lütfen e-postanızı doğruladıktan sonra sayfayı yenileyin. Bu e-posta başka bir hesapla ilişkili değilse bir e-posta alacaksınız." + }, + "remove": { + "star": "Yıldızı kaldır" + }, + "request": { + "added": "İstek eklendi", + "add": "İstek Ekle", + "authorization": "Yetki", + "body": "İstek gövdesi", + "choose_language": "Dil seçiniz", + "content_type": "İçerik türü", + "content_type_titles": { + "others": "Diğerleri", + "structured": "Yapılandırılmış", + "text": "Metin", + "binary": "İkili" + }, + "show_content_type": "İçerik Türünü Göster", + "different_collection": "Farklı koleksiyonlardan istek sıralanamaz", + "duplicated": "İstek kopyalanmış", + "duration": "Süre", + "enter_curl": "cURL'yi girin", + "generate_code": "Kodunu oluşturun", + "generated_code": "Oluşturulan kod", + "go_to_authorization_tab": "Yetkilendirme sekmesine git", + "go_to_body_tab": "Gövde sekmesine git", + "header_list": "Başlık Listesi", + "invalid_name": "Lütfen istek için bir ad girin", + "method": "Yöntem", + "moved": "İstek taşındı", + "name": "İstek adı", + "new": "Yeni İstek", + "order_changed": "İstek Sırası Güncellendi", + "override": "Geçersiz kıl", + "override_help": "Content-Type Başlıklarda ayarlayın", + "overriden": "Geçersiz kılındı", + "parameter_list": "Sorgu parametreleri", + "parameters": "Parametreler", + "path": "Yol", + "payload": "Yük", + "query": "Sorgu", + "raw_body": "Ham istek gövdesi", + "rename": "İsteği Yeniden Adlandır", + "renamed": "Yeniden adlandırılmış istek", + "request_variables": "İstek değişkenleri", + "response_name_exists": "Yanıt adı zaten mevcut", + "run": "Çalıştır", + "save": "Kaydet", + "save_as": "Farklı kaydet", + "saved": "İstek kaydedildi", + "share": "Paylaş", + "share_description": "Hoppscotch'ı arkadaşlarınızla paylaşın", + "share_request": "İsteği Paylaş", + "stop": "Durdur", + "title": "İstek", + "type": "İstek türü", + "url": "URL", + "url_placeholder": "Bir URL girin veya bir cURL komutu yapıştırın", + "variables": "Değişkenler", + "view_my_links": "Bağlantılarımı Görüntüle", + "generate_name_error": "İstek adı oluşturulamadı." + }, + "response": { + "audio": "Ses", + "body": "Yanıt gövdesi", + "duplicated": "Yanıt kopyalandı", + "duplicate_name_error": "Aynı isimde yanıt zaten mevcut", + "filter_response_body": "JSON yanıt gövdesini filtrele (jq sözdizimi kullanır)", + "headers": "Başlıklar", + "request_headers": "İstek Header'ları", + "html": "HTML", + "image": "Resim", + "json": "JSON", + "pdf": "PDF", + "please_save_request": "Örnek oluşturmak için isteği kaydedin", + "preview_html": "HTML'i önizle", + "raw": "Ham", + "renamed": "Yanıt yeniden adlandırıldı", + "same_name_inspector_warning": "Bu istek için aynı adda yanıt zaten mevcut, kaydedilirse mevcut yanıtın üzerine yazılacaktır", + "size": "Boyut", + "status": "Durum", + "time": "Süre", + "title": "Yanıt", + "video": "Video", + "waiting_for_connection": "Bağlantı için bekleniyor", + "xml": "XML", + "generate_data_schema": "Veri Şeması Oluştur", + "data_schema": "Veri Şeması", + "saved": "Yanıt kaydedildi", + "invalid_name": "Lütfen yanıt için bir ad girin" + }, + "settings": { + "accent_color": "Vurgu rengi", + "account": "Hesap", + "account_deleted": "Hesabınız silindi", + "account_description": "Hesap ayarlarınızı özelleştirin.", + "account_email_description": "Birincil e-posta adresiniz.", + "account_name_description": "Bu sizin görünen adınız.", + "additional": "Ekstra Ayarlar", + "agent_not_running": "Hoppscotch Agent algılanamadı. Lütfen Agent'ın çalışıp çalışmadığını kontrol edin.", + "agent_not_running_short": "Agent'ın durumunu kontrol edin.", + "agent_running": "Hoppscotch Agent çalışıyor.", + "agent_running_short": "Hoppscotch Agent çalışıyor.", + "agent_discard_registration": "Agent Kaydını İptal Et", + "agent_registered": "Agent Kayıtlı", + "agent_registration_successful": "Agent Başarıyla Kaydedildi", + "agent_registration_fetch_failed": "Agent kayıt bilgileri alınamadı. Lütfen Agent'ı yeniden kaydedin.", + "agent_registration_already_in_progress": "Agent kaydı zaten devam ediyor. Lütfen mevcut kaydı tamamlayın veya iptal edin ve tekrar deneyin.", + "auto_encode_mode": "Otomatik", + "auto_encode_mode_tooltip": "İstekteki parametreleri yalnızca bazı özel karakterler varsa kodla", + "background": "Arka plan", + "black_mode": "Siyah", + "choose_language": "Dil seçiniz", + "dark_mode": "Karanlık", + "delete_account": "Hesabı sil", + "delete_account_description": "Hesabınızı sildiğinizde, tüm verileriniz kalıcı olarak silinecektir. Bu işlem geri alınamaz.", + "disable_encode_mode_tooltip": "İstekteki parametreleri asla kodlama", + "enable_encode_mode_tooltip": "İstekteki parametreleri her zaman kodla", + "enter_otp": "Agent kodunu girin", + "expand_navigation": "Gezinmeyi genişlet", + "experiments": "Deneyler", + "experiments_notice": "Bu, üzerinde çalıştığımız, yararlı, eğlenceli, her ikisi de ya da hiçbiri olabilecek bir deneyler koleksiyonudur. Nihai değiller ve istikrarlı olmayabilirler, bu yüzden aşırı garip bir şey olursa panik yapmayın.", + "extension_ver_not_reported": "Rapor edilmemiş", + "extension_version": "Uzantı Sürümü", + "extensions": "Uzantılar", + "extensions_use_toggle": "İstek göndermek için tarayıcı uzantısını kullanın (varsa)", + "follow": "Bizi Takip Et", + "general": "Genel", + "general_description": "Uygulamada kullanılan genel ayarlar", + "interceptor": "Önleyici", + "interceptor_description": "Uygulama ve API'ler arasındaki ara katman yazılımı.", + "kernel_interceptor": "Interceptor", + "kernel_interceptor_description": "Uygulama ve API'ler arasındaki ara katman.", + "language": "Dil", + "light_mode": "Aydınlık", + "official_proxy_hosting": "Resmi Proxy, Hoppscotch tarafından barındırılmaktadır.", + "query_parameters_encoding": "Sorgu Parametreleri Kodlaması", + "query_parameters_encoding_description": "İsteklerdeki sorgu parametreleri için kodlamayı yapılandırın", + "profile": "Profil", + "profile_description": "Profil detaylarını güncelle", + "profile_email": "E-posta adresi", + "profile_name": "Profil ismi", + "profile_photo": "Profil fotoğrafı", + "proxy": "Proxy", + "proxy_url": "Proxy URL'si", + "proxy_use_toggle": "İstek göndermek için proxy ara yazılımını kullanın", + "read_the": "Oku", + "register_agent": "Agent'ı Kaydet", + "reset_default": "Varsayılana sıfırla", + "short_codes": "Kısa kodlar", + "short_codes_description": "Sizin oluşturduğunuz kısa kodlar.", + "sidebar_on_left": "Soldaki kenar çubuğu", + "ai_experiments": "Yapay Zeka Deneyleri", + "ai_request_naming_style": "İstek Adlandırma Stili", + "ai_request_naming_style_descriptive_with_spaces": "Boşluklu Açıklayıcı", + "ai_request_naming_style_camel_case": "Camel Case ( camelCase )", + "ai_request_naming_style_snake_case": "Snake Case ( snake_case )", + "ai_request_naming_style_pascal_case": "Pascal Case ( PascalCase )", + "ai_request_naming_style_custom": "Özel", + "ai_request_naming_style_custom_placeholder": "Özel adlandırma stili şablonunuzu girin...", + "experimental_scripting_sandbox": "Deneysel betik sandbox'ı", + "enable_experimental_mock_servers": "Mock Sunucuları Etkinleştir", + "enable_experimental_documentation": "Dokümantasyonu Etkinleştir", + "sync": "Senkronize et", + "sync_collections": "Koleksiyonlar", + "sync_description": "Bu ayarlar bulutla senkronize edilir.", + "sync_environments": "Ortamlar", + "sync_history": "Tarih", + "history_disabled": "Geçmiş devre dışı. Geçmişi etkinleştirmek için organizasyon yöneticinizle iletişime geçin", + "system_mode": "Sistem", + "telemetry": "Telemetri", + "telemetry_helps_us": "Telemetri, operasyonlarımızı kişiselleştirmemize ve size en iyi deneyimi sunmamıza yardımcı olur.", + "theme": "Tema", + "theme_description": "Uygulama temanızı özelleştirin.", + "use_experimental_url_bar": "Ortam vurgulamalı deneysel URL çubuğunu kullanın", + "user": "Kullanıcı", + "verified_email": "Doğrulanmış e-posta", + "verify_email": "E-postayı doğrula", + "validate_certificates": "SSL/TLS Sertifikalarını Doğrula", + "verify_host": "Ana Bilgisayarı Doğrula", + "verify_peer": "Eşi Doğrula", + "follow_redirects": "Yönlendirmeleri Takip Et", + "client_certificates": "İstemci Sertifikaları", + "certificate_settings": "Sertifika Ayarları", + "certificate": "Sertifika", + "key": "Özel Anahtar", + "pfx_or_p12": "PFX/PKCS#12", + "password": "Parola", + "select_file": "Dosya Seç", + "domain": "Alan Adı", + "add_certificate": "Sertifika Ekle", + "add_cert_file": "Sertifika Dosyası Ekle", + "add_key_file": "Anahtar Dosyası Ekle", + "add_pfx_file": "PFX Dosyası Ekle", + "global_defaults": "Genel Varsayılanlar", + "add_domain_override": "Alan Adı Geçersiz Kılma Ekle", + "domain_override": "Alan Adı Geçersiz Kılma", + "manage_domains_overrides": "Alan Adı Geçersiz Kılmalarını Yönet", + "add_domain": "Alan Adı Ekle", + "remove_domain": "Alan Adını Kaldır", + "ca_certificate": "CA Sertifikası", + "ca_certificates": "CA Sertifikaları", + "ca_certificates_support": "Hoppscotch, bir veya daha fazla sertifika içeren .crt, .cer veya .pem dosyalarını destekler.", + "proxy_capabilities": "Hoppscotch Agent ve Masaüstü Uygulaması, NTLM ve Basic Auth desteği ile HTTP/HTTPS/SOCKS proxy'lerini destekler.", + "proxy_auth": "Kullanıcı adı ve parolayı URL'ye de ekleyebilirsiniz." + }, + "shared_requests": { + "button": "Buton", + "button_info": "'Hoppscotch'ta Çalıştır' butonunu web siteniz, blogunuz veya README'niz için oluşturun.", + "copy_html": "HTML'i Kopyala", + "copy_link": "Bağlantıyı Kopyala", + "copy_markdown": "Markdown'ı Kopyala", + "creating_widget": "Widget oluşturuluyor", + "customize": "Özelleştir", + "deleted": "Paylaşılan istek silindi", + "description": "Bir widget seçin, bunu daha sonra değiştirebilir ve özelleştirebilirsiniz.", + "embed": "Göm", + "embed_info": "Web sitenize, blogunuza veya belgelemenize küçük bir 'Hoppscotch API Playground' ekleyin.", + "link": "Bağlantı", + "link_info": "İnternette kimseyle görüntüleme erişimi olan paylaşılabilir bir bağlantı oluşturun.", + "modified": "Paylaşılan istek değiştirildi", + "not_found": "Paylaşılan istek bulunamadı", + "open_new_tab": "Yeni sekmede aç", + "preview": "Önizleme", + "run_in_hoppscotch": "Hoppscotch'ta Çalıştır", + "theme": { + "dark": "Karanlık", + "light": "Aydınlık", + "system": "Sistem", + "title": "Tema" + }, + "action": "İşlem", + "clear_filter": "Filtreyi Temizle", + "confirm_request_deletion": "Seçilen paylaşılan isteğin silinmesini onaylıyor musunuz?", + "copy": "Kopyala", + "created_on": "Oluşturulma Tarihi", + "delete": "Sil", + "email": "E-posta", + "filter": "Filtrele", + "filter_by_email": "E-postaya göre filtrele", + "id": "ID", + "load_list_error": "Paylaşılan istekler listesi yüklenemedi", + "no_requests": "Paylaşılan istek bulunamadı", + "open_request": "İsteği Aç", + "properties": "Özellikler", + "request": "İstek", + "show_more": "Daha fazla göster", + "title": "Paylaşılan İstekler", + "url": "URL" + }, + "shortcut": { + "general": { + "close_current_menu": "Güncel menüyü kapat", + "command_menu": "Arama ve komut menüsü", + "help_menu": "Yardım menüsü", + "show_all": "Klavye kısayolları", + "title": "Genel", + "comment_uncomment": "Yorum Yap/Kaldır", + "close_tab": "Sekmeyi Kapat", + "undo": "Geri Al", + "redo": "Yinele" + }, + "miscellaneous": { + "invite": "İnsanları Hoppscotch'a davet edin", + "title": "Çeşitli" + }, + "navigation": { + "back": "Önceki sayfaya geri dön", + "documentation": "Belgeler sayfasına git", + "forward": "Sonraki sayfaya ilerle", + "graphql": "GraphQL sayfasına git", + "profile": "Profil sayfasına git", + "realtime": "Gerçek zamanlı sayfasına git", + "rest": "REST sayfasına git", + "settings": "Ayarlar sayfasına git", + "title": "Navigasyon" + }, + "others": { + "prettify": "Editör içeriğini güzelleştir", + "title": "Diğer" + }, + "request": { + "delete_method": "DELETE yöntemini seçin", + "get_method": "GET yöntemini seçin", + "head_method": "HEAD yöntemini seçin", + "import_curl": "cURL'yi içe aktar", + "method": "Yöntem", + "next_method": "Sonraki yöntemi seçin", + "post_method": "POST yöntemini seçin", + "previous_method": "Önceki yöntemi seçin", + "put_method": "PUT yöntemini seçin", + "rename": "İsteği yeniden adlandır", + "reset_request": "İsteği sıfırla", + "save_request": "İsteği kaydet", + "save_to_collections": "Koleksiyonlara kaydet", + "send_request": "İstek gönder", + "share_request": "İsteği paylaş", + "show_code": "Kod örneği oluştur", + "title": "İstek", + "focus_url": "URL çubuğuna odaklan" + }, + "response": { + "copy": "Yanıtı panoya kopyala", + "download": "Yanıtı dosya olarak indir", + "title": "Yanıt" + }, + "tabs": { + "title": "Sekmeler", + "new_tab": "Yeni Sekme", + "close_tab": "Sekmeyi Kapat", + "reopen_tab": "Kapatılan Sekmeyi Yeniden Aç", + "next_tab": "Sonraki Sekme", + "previous_tab": "Önceki Sekme", + "first_tab": "İlk Sekmeye Geç", + "last_tab": "Son Sekmeye Geç", + "mru_switch": "Son kullanılan sekmeye geç (MRU)", + "mru_switch_reverse": "Önceki son kullanılan sekmeye geç (MRU)" + }, + "theme": { + "black": "Temayı siyah mod olarak ayarla", + "dark": "Temayı karanlık mod olarak ayarla", + "light": "Temayı aydınlık mod olarak ayarla", + "system": "Temayı sistem teması olarak ayarla", + "title": "Tema" + } + }, + "show": { + "code": "Kodu göster", + "collection": "Koleksiyon Panelini Genişlet", + "more": "Daha fazla göster", + "sidebar": "Kenar çubuğunu göster", + "password": "Şifreyi Göster" + }, + "socketio": { + "communication": "İletişim", + "connection_not_authorized": "Bu SocketIO bağlantısı herhangi bir kimlik doğrulama kullanmıyor.", + "event_name": "Etkinlik ismi", + "events": "Olaylar", + "log": "Kayıt", + "url": "Bağlantı" + }, + "spotlight": { + "change_language": "Dil Değiştir", + "environments": { + "delete": "Mevcut ortamı sil", + "duplicate": "Mevcut ortamı kopyala", + "duplicate_global": "Küresel ortamı kopyala", + "edit": "Mevcut ortamı düzenle", + "edit_global": "Küresel ortamı düzenle", + "new": "Yeni ortam oluştur", + "new_variable": "Yeni ortam değişkeni oluştur", + "title": "Ortamlar" + }, + "general": { + "chat": "Destek ile sohbet et", + "help_menu": "Yardım ve destek", + "open_docs": "Belgeleri oku", + "open_github": "GitHub deposunu aç", + "open_keybindings": "Klavye kısayollarını aç", + "social": "Sosyal", + "title": "Genel" + }, + "graphql": { + "connect": "Sunucuya bağlan", + "disconnect": "Sunucudan debağlan" + }, + "miscellaneous": { + "invite": "Arkadaşlarını Hoppscotch'a davet et", + "title": "Çeşitli" + }, + "request": { + "save_as_new": "Yeni istek olarak kaydet", + "select_method": "Yöntem seç", + "switch_to": "Şuna geç", + "tab_authorization": "Yetkilendirme sekmesi", + "tab_body": "Body sekmesi", + "tab_headers": "Başlıklar sekmesi", + "tab_parameters": "Parametreler sekmesi", + "tab_pre_request_script": "Ön istek betiği sekmesi", + "tab_query": "Sorgu sekmesi", + "tab_tests": "Testler sekmesi", + "tab_variables": "Değişkenler sekmesi" + }, + "response": { + "copy": "Yanıtı kopyala", + "download": "Yanıtı dosya olarak indir", + "title": "Yanıt" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Arayüz", + "theme": "Tema", + "user": "Kullanıcı" + }, + "settings": { + "change_interceptor": "Interceptor değiştir", + "change_language": "Dil değiştir", + "theme": { + "black": "Siyah", + "dark": "Karanlık", + "light": "Aydınlık", + "system": "Sistem tercihi" + } + }, + "tab": { + "close_current": "Mevcut sekmeyi kapat", + "close_others": "Diğer tüm sekmeleri kapat", + "duplicate": "Mevcut sekmeyi kopyala", + "new_tab": "Yeni sekme aç", + "next": "Sonraki sekmeye geç", + "previous": "Önceki sekmeye geç", + "switch_to_first": "İlk sekmeye geç", + "switch_to_last": "Son sekmeye geç", + "mru_switch": "Son kullanılan sekmeye geç", + "mru_switch_reverse": "Önceki son kullanılan sekmeye geç", + "title": "Sekmeler" + }, + "workspace": { + "delete": "Mevcut takımı sil", + "edit": "Mevcut takımı düzenle", + "invite": "Takıma insanları davet et", + "new": "Yeni takım oluştur", + "switch_to_personal": "Kişisel çalışma alanına geç", + "title": "Takımlar" + }, + "phrases": { + "try": "Dene", + "import_collections": "Koleksiyonları içe aktar", + "create_environment": "Ortam oluştur", + "create_workspace": "Çalışma alanı oluştur", + "share_request": "İsteği paylaş" + } + }, + "sse": { + "event_type": "Etkinlik tipi", + "log": "Kayıt", + "url": "Bağlantı" + }, + "state": { + "bulk_mode": "Toplu düzenleme", + "bulk_mode_placeholder": "Girişler yeni satırla ayrılır\nAnahtarlar ve değerler şu şekilde ayrılır:\nEklemek istediğiniz ancak devre dışı bırakmak istediğiniz herhangi bir satırın başına # ekleyin", + "cleared": "Temizlendi", + "connected": "Bağlandı", + "connected_to": "{name} ile bağlantı kuruldu", + "connecting_to": "{name} adlı kullanıcıya bağlanılıyor...", + "connection_error": "Bağlantı kurulamadı", + "connection_failed": "Bağlantı başarısız", + "connection_lost": "Bağlantı kayboldu", + "copied_interface_to_clipboard": "{language} arayüz tipi panoya kopyalandı", + "copied_to_clipboard": "Panoya kopyalandı", + "deleted": "Silindi", + "deprecated": "KULLANIMDAN KALDIRILMIŞ", + "disabled": "Pasif", + "disconnected": "Bağlantı kesildi", + "disconnected_from": "{name} ile bağlantı kesildi", + "docs_generated": "Dokümantasyon oluşturuldu", + "download_failed": "İndirme başarısız", + "download_started": "İndirme başladı", + "enabled": "Etkinleştirildi", + "experimental": "Deneysel", + "file_imported": "Dosya içe aktarıldı", + "finished_in": "{duration} ms içinde tamamlandı", + "hide": "Gizle", + "history_deleted": "Geçmiş silindi", + "linewrap": "Satır Bölme", + "loading": "Yükleniyor...", + "message_received": "Mesaj: {message} konuya: {topic} ulaştı", + "mqtt_subscription_failed": "Konuya abone olurken bir hata oluştu: {topic}", + "no_content_found": "İçerik bulunamadı", + "none": "Hiçbiri", + "nothing_found": "hiçbir şey bulunamadı", + "published_error": "Mesaj yayınlanırken bir hata oluştu: {topic} konuya mesaj: {message}", + "published_message": "Mesaj yayınlandı: {message} konuya: {topic}", + "reconnection_error": "Yeniden bağlanılamadı", + "saved": "Kaydedildi", + "show": "Göster", + "subscribed_failed": "Konuya abone olamadı: {topic}", + "subscribed_success": "Başarıyla abone olundu konuya: {topic}", + "unsubscribed_failed": "Konu aboneliğinden çıkılamadı: {topic}", + "unsubscribed_success": "Başarıyla konu aboneliğinden çıkıldı: {topic}", + "waiting_send_request": "istek göndermek için bekleniyor", + "user_deactivated": "Hesabınız devre dışı bırakıldı. Hesabınızı yeniden etkinleştirmek için lütfen yöneticiyle iletişime geçin.", + "add_user_failure": "Kullanıcı çalışma alanına eklenemedi!!", + "add_user_success": "Kullanıcı artık çalışma alanının bir üyesi!!", + "admin_failure": "Kullanıcı yönetici yapılamadı!!", + "admin_success": "Kullanıcı artık bir yönetici!!", + "and": "ve", + "clear_selection": "Seçimi Temizle", + "configure_auth": "Lütfen yönetici ayarlarından bir kimlik doğrulama sağlayıcısı kurun veya kimlik doğrulama sağlayıcılarını yapılandırmak için belgelere göz atın.", + "confirm_admin_to_user": "Bu kullanıcının yönetici durumunu kaldırmak istiyor musunuz?", + "confirm_admins_to_users": "Seçili kullanıcıların yönetici durumunu kaldırmak istiyor musunuz?", + "confirm_delete_infra_token": "{tokenLabel} altyapı token'ını silmek istediğinizden emin misiniz?", + "confirm_delete_invite": "Seçili daveti iptal etmek istiyor musunuz?", + "confirm_delete_invites": "Seçili davetleri iptal etmek istiyor musunuz?", + "confirm_user_deletion": "Kullanıcı silme onaylansın mı?", + "confirm_users_deletion": "Seçili kullanıcıları silmek istiyor musunuz?", + "confirm_user_to_admin": "Bu kullanıcıyı yönetici yapmak istiyor musunuz?", + "confirm_users_to_admin": "Seçili kullanıcıları yönetici yapmak istiyor musunuz?", + "confirm_user_deactivation": "Kullanıcı devre dışı bırakma onaylansın mı?", + "confirm_logout": "Çıkışı Onayla", + "created_on": "Oluşturulma Tarihi", + "continue_email": "E-posta ile Devam Et", + "continue_github": "Github ile Devam Et", + "continue_google": "Google ile Devam Et", + "continue_microsoft": "Microsoft ile Devam Et", + "create_team_failure": "Çalışma alanı oluşturulamadı!!", + "create_team_success": "Çalışma alanı başarıyla oluşturuldu!!", + "data_sharing_failure": "Veri paylaşım ayarları güncellenemedi", + "delete_infra_token_failure": "Altyapı token'ı silinirken bir hata oluştu", + "delete_invite_failure": "Davet silinemedi!!", + "delete_invites_failure": "Seçili davetler silinemedi!!", + "delete_invite_success": "Davet başarıyla silindi!!", + "delete_invites_success": "Seçili davetler başarıyla silindi!!", + "delete_request_failure": "Paylaşılan istek silme başarısız oldu!!", + "delete_request_success": "Paylaşılan istek başarıyla silindi!!", + "delete_team_failure": "Çalışma alanı silme başarısız oldu!!", + "delete_team_success": "Çalışma alanı başarıyla silindi!!", + "delete_some_users_failure": "Silinemeyen Kullanıcı Sayısı: {count}", + "delete_some_users_success": "Silinen Kullanıcı Sayısı: {count}", + "delete_user_failed_only_one_admin": "Kullanıcı silinemedi. En az bir yönetici bulunmalıdır!!", + "delete_user_failure": "Kullanıcı silme başarısız oldu!!", + "delete_users_failure": "Seçili kullanıcılar silinemedi!!", + "delete_user_success": "Kullanıcı başarıyla silindi!!", + "delete_users_success": "Seçili kullanıcılar başarıyla silindi!!", + "email": "E-posta", + "email_failure": "Davet gönderilemedi", + "email_signin_failure": "E-posta ile giriş yapılamadı", + "email_success": "E-posta daveti başarıyla gönderildi", + "emails_cannot_be_same": "Kendinizi davet edemezsiniz, lütfen farklı bir e-posta adresi seçin!!", + "enter_team_email": "Lütfen çalışma alanı sahibinin e-postasını girin!!", + "error": "Bir hata oluştu", + "error_auth_providers": "Kimlik doğrulama sağlayıcıları yüklenemedi", + "generate_infra_token_failure": "Altyapı token'ı oluşturulurken bir hata oluştu", + "github_signin_failure": "Github ile giriş yapılamadı", + "google_signin_failure": "Google ile giriş yapılamadı", + "infra_token_label_short": "Altyapı Token Etiketi karakter uzunluğu çok kısa!!", + "invalid_email": "Lütfen geçerli bir e-posta adresi girin", + "link_copied_to_clipboard": "Bağlantı panoya kopyalandı", + "logged_out": "Çıkış yapıldı", + "login_as_admin": "ve bir yönetici hesabıyla giriş yapın.", + "login_using_email": "Lütfen kullanıcıdan e-postasını kontrol etmesini isteyin veya aşağıdaki bağlantıyı paylaşın", + "login_using_link": "Lütfen kullanıcıdan aşağıdaki bağlantıyı kullanarak giriş yapmasını isteyin", + "logout": "Çıkış Yap", + "magic_link_sign_in": "Giriş yapmak için bağlantıya tıklayın.", + "magic_link_success": "Sihirli bağlantı şu adrese gönderildi", + "microsoft_signin_failure": "Microsoft ile giriş yapılamadı", + "newsletter_failure": "Bülten ayarları güncellenemedi", + "non_admin_logged_in": "Yönetici olmayan kullanıcı olarak giriş yapıldı.", + "non_admin_login": "Giriş yaptınız. Ancak yönetici değilsiniz", + "owner_not_present": "Takımda en az bir sahip bulunmalıdır!!", + "privacy_policy": "Gizlilik Politikası", + "reenter_email": "E-postayı tekrar girin", + "remove_admin_failure": "Yönetici durumu kaldırılamadı!!", + "remove_admin_failure_only_one_admin": "Yönetici durumu kaldırılamadı. En az bir yönetici bulunmalıdır!!", + "remove_admin_success": "Yönetici durumu kaldırıldı!!", + "remove_admin_from_users_failure": "Seçili kullanıcıların yönetici durumu kaldırılamadı!!", + "remove_admin_from_users_success": "Seçili kullanıcıların yönetici durumu kaldırıldı!!", + "remove_admin_to_delete_user": "Kullanıcıyı silmek için yönetici yetkisini kaldırın!!", + "remove_owner_to_delete_user": "Kullanıcıyı silmek için takım sahipliği durumunu kaldırın!!", + "remove_owner_failure_only_one_owner": "Üye kaldırılamadı. Bir takımda en az bir sahip bulunmalıdır!!", + "remove_admin_for_deletion": "Silme işleminden önce yönetici durumunu kaldırın!!", + "remove_owner_for_deletion": "Bir veya daha fazla kullanıcı takım sahibidir. Silmeden önce sahipliği güncelleyin!!", + "remove_invitee_failure": "Davetli kaldırma başarısız oldu!!", + "remove_invitee_success": "Davetli başarıyla kaldırıldı!!", + "remove_member_failure": "Üye kaldırılamadı!!", + "remove_member_success": "Üye başarıyla kaldırıldı!!", + "rename_team_failure": "Çalışma alanı yeniden adlandırılamadı!!", + "rename_team_success": "Çalışma alanı başarıyla yeniden adlandırıldı!", + "rename_user_failure": "Kullanıcı yeniden adlandırılamadı!!", + "rename_user_success": "Kullanıcı başarıyla yeniden adlandırıldı!!", + "require_auth_provider": "Giriş yapmak için en az bir kimlik doğrulama sağlayıcısı ayarlamalısınız.", + "role_update_failed": "Roller güncellenemedi!!", + "role_update_success": "Roller başarıyla güncellendi!!", + "selected": "{count} seçili", + "self_host_docs": "Kendi Sunucunuzda Barındırma Belgeleri", + "send_magic_link": "Sihirli bağlantı gönder", + "setup_failure": "Kurulum başarısız oldu!!", + "setup_success": "Kurulum başarıyla tamamlandı!!", + "sign_in_agreement": "Giriş yaparak şunları kabul etmiş olursunuz:", + "sign_in_options": "Tüm giriş seçenekleri", + "sign_out": "Çıkış yap", + "something_went_wrong": "Bir hata oluştu", + "team_name_too_short": "Çalışma alanı adı en az 6 karakter uzunluğunda olmalıdır!!", + "user_already_invited": "Davet gönderilemedi. Kullanıcı zaten davet edilmiş!!", + "user_not_found": "Kullanıcı altyapıda bulunamadı!!", + "users_to_admin_success": "Seçili kullanıcılar yönetici yapıldı!!", + "users_to_admin_failure": "Seçili kullanıcılar yönetici yapılamadı!!", + "loading_workspaces": "Çalışma alanları yükleniyor", + "loading_collections_in_workspace": "Çalışma alanındaki koleksiyonlar yükleniyor" + }, + "support": { + "changelog": "En son sürümler hakkında daha fazla bilgi edin", + "chat": "Sorun mu var? Bizle sohbet et!", + "community": "Sorular sorun ve başkalarına yardım edin", + "documentation": "Hoppscotch hakkında daha fazla bilgi edin", + "forum": "Sorular sorun ve cevaplar alın", + "github": "Bizi Github'da takip edin", + "shortcuts": "Uygulamaya daha hızlı göz atın", + "title": "Destek", + "twitter": "Bizi Twitter'da takip edin" + }, + "tab": { + "authorization": "Yetkilendirme", + "body": "Gövde", + "close": "Sekmeyi Kapat", + "close_others": "Diğer Sekmeleri Kapat", + "collections": "Koleksiyonlar", + "documentation": "Belgeler", + "duplicate": "Sekmeyi Klonla", + "environments": "Ortam", + "headers": "Başlıklar", + "history": "Geçmiş", + "mqtt": "MQTT", + "parameters": "Parametreler", + "post_request_script": "İstek Sonrası Script", + "pre_request_script": "Ön İstek Komut Dosyası", + "queries": "Sorgular", + "query": "Sorgu", + "schema": "Şema", + "shared_requests": "Paylaşılan İstekler", + "codegen": "Kod Üret", + "code_snippet": "Kod parçası", + "mock_servers": "Mock Sunucular", + "share_tab_request": "İstek sekmesini paylaş", + "socketio": "Socket.IO", + "sse": "SSE", + "types": "Türler", + "variables": "Değişkenler", + "websocket": "WebSocket", + "all_tests": "Tüm Testler", + "passed": "Başarılı", + "failed": "Başarısız" + }, + "team": { + "activity_logs": "Etkinlik Günlükleri", + "already_member": "Siz zaten bu ekibin bir üyesisiniz. Takım sahibinizle iletişime geçin.", + "create_new": "Yeni takım oluştur", + "deleted": "Takım silindi", + "delete_all_activity_logs": "Tüm etkinlik günlüklerini sil", + "successfully_deleted_all_activity_logs": "Tüm etkinlik günlükleri başarıyla silindi", + "delete_activity_log": "Etkinlik günlüğünü sil", + "deleted_activity_log": "Seçili etkinlik günlüğü silindi", + "deleted_all_activity_logs": "Tüm etkinlik günlükleri silindi", + "edit": "Takımı düzenle", + "email": "e-posta", + "email_do_not_match": "E-posta, hesap ayrıntılarınızla eşleşmiyor. Takım sahibinizle iletişime geçin.", + "exit": "Takımdan Çık", + "exit_disabled": "Takımın kurucusu çıkamaz.", + "failed_invites": "Başarısız davetler", + "invalid_coll_id": "Geçersiz Koleksiyon ID", + "invalid_email_format": "E-posta biçimi geçersiz", + "invalid_id": "Geçersiz ekip kimliği. Takım sahibinizle iletişime geçin.", + "invalid_invite_link": "Geçersiz davet bağlantısı", + "invalid_invite_link_description": "Takip ettiğiniz bağlantı geçersiz. Takım sahibinizle iletişime geçin.", + "invalid_member_permission": "Lütfen takım üyesine geçerli bir izin verin", + "invite": "Davet et", + "invite_more": "Daha fazla davet et", + "invite_tooltip": "İnsanları bu çalışma alanına davet edin", + "invited_to_team": "{owner} sizi {workspace} çalışma alanına katılmaya davet etti", + "join": "Davet kabul edildi", + "join_team": "{workspace} çalışma alanına katıl", + "joined_team": "{workspace} çalışma alanına katıldınız", + "joined_team_description": "Artık bu takımın bir üyesisin", + "left": "Takımdan ayrıldın", + "login_to_continue": "Devam etmek için giriş yapın", + "login_to_continue_description": "Bir ekibe katılmak için oturum açmanız gerekir.", + "logout_and_try_again": "Çıkış yapın ve başka bir hesapla oturum açın", + "member_has_invite": "Bu e-posta kimliğinin zaten bir davetiyesi var. Takım sahibinizle iletişime geçin.", + "member_not_found": "Üye bulunamadı. Takım sahibinizle iletişime geçin.", + "member_removed": "Kullanıcı kaldırıldı", + "member_role_updated": "Kullanıcı rolleri güncellendi", + "members": "Üyeler", + "more_members": "+{count} üye daha", + "name_length_insufficient": "Takım adı en az 6 karakter uzunluğunda olmalıdır", + "name_updated": "Takım ismi güncellendi", + "new": "Yeni takım", + "new_created": "Yeni takım oluşturuldu", + "new_name": "Yeni Takımım", + "no_access": "Bu koleksiyonlar için düzenleme erişiminiz yok", + "no_invite_found": "Davetiye bulunamadı. Takım sahibinizle iletişime geçin.", + "no_request_found": "İstek bulunamadı.", + "not_found": "Takım bulunamadı. Takım sahibinizle iletişime geçin.", + "not_valid_viewer": "Geçerli bir misafir değilsiniz. Takım sahibinizle iletişime geçin.", + "parent_coll_move": "Koleksiyon bir alt koleksiyona taşınamıyor", + "pending_invites": "Bekleyen davetler", + "permissions": "İzinler", + "same_target_destination": "Aynı hedef ve varış noktası", + "saved": "Takım kaydedildi", + "select_a_team": "Takım seç", + "success_invites": "Başarılı davetler", + "title": "Başlık", + "we_sent_invite_link": "Tüm davetlilere bir davet bağlantısı gönderdik!", + "invite_sent_smtp_disabled": "Davet bağlantıları oluşturuldu", + "we_sent_invite_link_description": "Tüm davetlilerden gelen kutularını kontrol etmelerini isteyin. Ekibe katılmak için bağlantıya tıklayın.", + "invite_sent_smtp_disabled_description": "Bu Hoppscotch örneği için davet e-postaları gönderme devre dışı bırakıldı. Lütfen davet bağlantısını manuel olarak kopyalamak ve paylaşmak için Bağlantıyı kopyala düğmesini kullanın.", + "copy_invite_link": "Davet Bağlantısını Kopyala", + "search_title": "Takım İstekleri", + "user_not_found": "Kullanıcı bu örnekte bulunamadı.", + "invite_members": "Üyeleri Davet Et" + }, + "team_environment": { + "deleted": "Ortam Silindi", + "duplicate": "Ortam Çoğaltıldı", + "not_found": "Ortam Bulunamadı." + }, + "test": { + "requests": "İstekler", + "selection": "Seçim", + "failed": "Test başarısız", + "javascript_code": "JavaScript Kodu", + "learn": "Belgeleri okuyun", + "passed": "Test başarılı", + "report": "Test raporu", + "results": "Test sonuçları", + "script": "Komut dosyası", + "snippets": "Parçalar", + "run": "Çalıştır", + "run_again": "Tekrar çalıştır", + "stop": "Durdur", + "new_run": "Yeni Çalıştırma", + "iterations": "Yinelemeler", + "duration": "Süre", + "avg_resp": "Ort. Yanıt Süresi" + }, + "websocket": { + "communication": "İletişim", + "log": "Kayıt", + "message": "Mesaj", + "protocols": "Protokoller", + "url": "Bağlantı" + }, + "workspace": { + "change": "Çalışma alanını değiştir", + "personal": "Kişisel Çalışma Alanım", + "other_workspaces": "Diğer Çalışma Alanlarım", + "team": "Takım Çalışma Alanı", + "title": "Çalışma Alanları" + }, + "site_protection": { + "login_to_continue": "Devam etmek için giriş yap", + "login_to_continue_description": "Bu Hoppscotch Enterprise Örneğine erişebilmek için giriş yapmanız gerekiyor.", + "error_fetching_site_protection_status": "Site koruma durumu alınırken bir hata oluştu" + }, + "access_tokens": { + "tab_title": "Anahtarlar", + "section_title": "Kişisel Erişim Anahtarları", + "section_description": "Kişisel erişim anahtarları, şu anda CLI'yı Hoppscotch hesabınıza bağlamanıza yardımcı olur.", + "last_used_on": "Son kullanıldığı tarih", + "expires_on": "Son kullanma tarihi", + "no_expiration": "Son kullanma tarihi yok", + "expired": "Süresi dolmuş", + "copy_token_warning": "Kişisel erişim anahtarınızı şimdi kopyaladığınızdan emin olun. Bir daha göremezsiniz!", + "token_purpose": "Bu anahtar ne için?", + "expiration_label": "Son kullanma tarihi", + "scope_label": "Kapsam", + "workspace_read_only_access": "Çalışma alanı verilerine salt okunur erişim.", + "personal_workspace_access_limitation": "Kişisel Erişim Anahtarları, kişisel çalışma alanınıza erişemez.", + "generate_token": "Anahtar Oluştur", + "invalid_label": "Lütfen anahtar için bir etiket girin", + "no_expiration_verbose": "Bu anahtar asla süresi dolmayacak!", + "token_expires_on": "Bu anahtar şu tarihte süresi dolacak", + "generate_new_token": "Yeni anahtar oluştur", + "generate_modal_title": "Yeni Kişisel Erişim Anahtarı", + "deletion_success": "Erişim anahtarı {label} silindi" + }, + "collection_runner": { + "collection_id": "Koleksiyon ID'si", + "environment_id": "Ortam ID'si", + "cli_collection_id_description": "Bu koleksiyon ID'si, Hoppscotch için CLI koleksiyon çalıştırıcısı tarafından kullanılacaktır.", + "cli_environment_id_description": "Bu ortam ID'si, Hoppscotch için CLI koleksiyon çalıştırıcısı tarafından kullanılacaktır.", + "include_active_environment": "Aktif ortamı dahil et:", + "cli": "CLI", + "cli_comming_soon_for_personal_collection": "Kişisel koleksiyonlar için CLI'da Koleksiyon Çalıştırıcı desteği yakında eklenecek.", + "delay": "Gecikme", + "negative_delay": "Gecikme negatif olamaz", + "ui": "Runner (yakında)", + "running_collection": "Koleksiyon çalıştırılıyor", + "run_config": "Çalıştırma Yapılandırması", + "advanced_settings": "Gelişmiş Ayarlar", + "stop_on_error": "Hata oluşursa çalıştırmayı durdur", + "persist_responses": "Yanıtları sakla", + "keep_variable_values": "Değişken değerlerini koru", + "collection_not_found": "Koleksiyon bulunamadı. Silinmiş veya taşınmış olabilir.", + "empty_collection": "Koleksiyon boş. Çalıştırmak için istek ekleyin.", + "no_response_persist": "Koleksiyon çalıştırıcısı şu anda yanıtları saklamayacak şekilde yapılandırılmıştır. Bu ayar, yanıt verilerinin gösterilmesini engeller. Bu davranışı değiştirmek için yeni bir çalıştırma yapılandırması başlatın.", + "select_request": "Yanıt ve test sonuçlarını görmek için bir istek seçin", + "response_body_lost_rerun": "Yanıt gövdesi kayboldu. Yanıt gövdesini almak için koleksiyonu tekrar çalıştırın.", + "cli_command_generation_description_cloud": "Aşağıdaki komutu kopyalayın ve CLI üzerinden çalıştırın. Lütfen bir kişisel erişim anahtarı belirtin.", + "cli_command_generation_description_sh": "Aşağıdaki komutu kopyalayın ve CLI üzerinden çalıştırın. Lütfen bir kişisel erişim anahtarı belirtin ve oluşturulan SH örneği sunucu URL'sini doğrulayın.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Aşağıdaki komutu kopyalayın ve CLI üzerinden çalıştırın. Lütfen bir kişisel erişim anahtarı ve SH örneği sunucu URL'sini belirtin.", + "run_collection": "Koleksiyonu Çalıştır", + "no_passed_tests": "Başarılı test yok", + "no_failed_tests": "Başarısız test yok" + }, + "ai_experiments": { + "generate_request_name": "AI Kullanarak İstek Adı Oluştur", + "generate_or_modify_request_body": "İstek Gövdesini Oluştur veya Değiştir", + "modify_with_ai": "AI ile Değiştir", + "generate": "Oluştur", + "generate_or_modify_request_body_input_placeholder": "İstek gövdesini değiştirmek için komutunuzu girin", + "accept_change": "Değişikliği Kabul Et", + "feedback_success": "Geri bildirim başarıyla gönderildi", + "feedback_failure": "Geri bildirim gönderilemedi", + "feedback_thank_you": "Geri bildiriminiz için teşekkürler!", + "feedback_cta_text_long": "Üretimi değerlendirin, iyileştirmemize yardımcı olun", + "feedback_cta_request_name": "Oluşturulan adı beğendiniz mi?", + "modify_request_body_error": "İstek gövdesi değiştirilemedi", + "generate_or_modify_prerequest_input_placeholder": "İstek öncesi betiği oluşturmak veya değiştirmek için bir komut girin", + "generate_or_modify_post_request_script_input_placeholder": "İstek sonrası betiği oluşturmak veya değiştirmek için bir komut girin", + "modify_post_request_script_error": "İstek sonrası betik değiştirilemedi", + "modify_prerequest_error": "İstek öncesi betik değiştirilemedi" + }, + "configs": { + "auth_providers": { + "callback_url": "GERİ ÇAĞIRMA URL'Sİ", + "client_id": "İSTEMCİ KİMLİĞİ", + "client_secret": "İSTEMCİ GİZLİ ANAHTARI", + "description": "Sunucunuz için kimlik doğrulama sağlayıcılarını yapılandırın", + "provider_not_specified": "Lütfen en az bir kimlik doğrulama sağlayıcısını etkinleştirin", + "scope": "KAPSAM", + "tenant": "KİRACI", + "title": "Kimlik Doğrulama Sağlayıcıları", + "update_failure": "Kimlik doğrulama sağlayıcısı yapılandırmaları güncellenemedi!" + }, + "confirm_changes": "Yeni değişikliklerin yansıması için Hoppscotch sunucusunun yeniden başlatılması gerekir. Sunucu yapılandırmalarında yapılan değişiklikleri onaylıyor musunuz?", + "input_empty": "Yapılandırmaları güncellemeden önce lütfen tüm alanları doldurun", + "data_sharing": { + "title": "Veri Paylaşımı", + "description": "Anonim veri paylaşarak Hoppscotch'u iyileştirmeye yardımcı olun", + "enable": "Veri Paylaşımını Etkinleştir", + "secondary_title": "Veri Paylaşımı Yapılandırmaları", + "see_shared": "Nelerin paylaşıldığını gör", + "toggle_description": "Anonim veri paylaş", + "update_failure": "Veri paylaşımı yapılandırmaları güncellenemedi!" + }, + "load_error": "Sunucu yapılandırmaları yüklenemedi", + "mail_configs": { + "address_from": "GÖNDERİCİ ADRESİ", + "custom_smtp_configs": "Özel SMTP Yapılandırmaları Kullan", + "description": "SMTP yapılandırmalarını ayarlayın", + "enable_email_auth": "E-posta tabanlı kimlik doğrulamayı etkinleştir", + "enable_smtp": "SMTP'yi Etkinleştir", + "host": "POSTA SUNUCUSU", + "password": "POSTA ŞİFRESİ", + "port": "POSTA PORTU", + "secure": "POSTA GÜVENLİ", + "smtp_url": "POSTA SMTP URL'Sİ", + "tls_reject_unauthorized": "TLS YETKİSİZ BAĞLANTIYI REDDET", + "title": "SMTP Yapılandırmaları", + "toggle_failure": "SMTP değiştirilemedi!", + "update_failure": "SMTP yapılandırmaları güncellenemedi!", + "user": "POSTA KULLANICISI" + }, + "reset": { + "confirm_reset": "Yeni değişikliklerin yansıması için Hoppscotch sunucusunun yeniden başlatılması gerekir. Sunucu yapılandırmalarının sıfırlanmasını onaylıyor musunuz?", + "description": "Ortam dosyasında belirtilen varsayılan yapılandırmalar yüklenecektir", + "failure": "Yapılandırmalar sıfırlanamadı!", + "title": "Yapılandırmaları Sıfırla", + "info": "Sunucu yapılandırmalarını sıfırla" + }, + "restart": { + "description": "Bu sayfa otomatik olarak yenilenmeden önce {duration} saniye kaldı", + "initiate": "Sunucu yeniden başlatılıyor...", + "title": "Sunucu yeniden başlatılıyor" + }, + "save_changes": "Değişiklikleri Kaydet", + "title": "Yapılandırmalar", + "update_failure": "Sunucu yapılandırmaları güncellenemedi", + "restrict_access": "Erişimi Kısıtla", + "site_protection": { + "control_access": "Hoppscotch uygulamasına kimin erişebileceğini kontrol edin", + "description": "Site koruma ayarlarını kullanarak ziyaretçilerin Hoppscotch uygulamanıza nasıl erişeceğini özelleştirin.", + "enable": "Site korumasını etkinleştir", + "note": "Hoppscotch uygulamasını ziyaret eden kullanıcılar giriş sayfasını görecektir, uygulamaya erişmek için giriş zorunludur. Yetkilendirme için yönetici onayı hâlâ gereklidir", + "update_failure": "Site koruma yapılandırmaları güncellenemedi!" + }, + "domain_whitelisting": { + "add_domain": "Yeni Alan Adı Ekle", + "description": "Beyaz listedeki alan adlarında kayıtlı e-posta adresine sahip kullanıcıların Hoppscotch uygulamasına erişmek için yönetici onayına ihtiyacı yoktur", + "enable": "Alan adı beyaz listesini etkinleştir", + "enter_domain": "Alan adı girin", + "title": "Beyaz Listedeki Alan Adları", + "toggle_failure": "Alan adı beyaz listesi değiştirilemedi", + "update_failure": "Alan adı beyaz listesi yapılandırmaları güncellenemedi!" + }, + "oidc_configs": { + "auth_url": "Auth URL", + "callback_url": "Geri Çağırma URL'si", + "client_id": "İstemci Kimliği", + "client_secret": "İstemci Gizli Anahtarı", + "description": "OIDC yapılandırmalarını ayarlayın", + "enable": "OIDC tabanlı kimlik doğrulamayı etkinleştir", + "issuer": "Veren", + "provider_name": "Sağlayıcı Adı", + "scope": "Kapsam", + "title": "OIDC Yapılandırmaları", + "token_url": "Token URL", + "update_failure": "OIDC yapılandırmaları güncellenemedi!", + "user_info_url": "Kullanıcı Bilgisi URL'si" + }, + "saml": { + "audience": "Hedef Kitle", + "callback_url": "Geri Çağırma URL'si", + "certificate": "Sertifika", + "description": "SAML yapılandırmalarını ayarlayın", + "enable": "SAML tabanlı kimlik doğrulamayı etkinleştir", + "entry_point": "Giriş Noktası", + "issuer": "Veren", + "title": "SAML Yapılandırmaları", + "update_failure": "SAML SSO yapılandırmaları güncellenemedi!", + "want_assertions_signed": "Bildirimleri İmzala", + "want_response_signed": "Yanıtı İmzala" + } + }, + "data_sharing": { + "description": "Hoppscotch'u iyileştirmek için anonim kullanım verisi paylaşın", + "enable": "Veri Paylaşımını Etkinleştir", + "see_shared": "Nelerin paylaşıldığını gör", + "toggle_description": "Veri paylaşın ve Hoppscotch'u daha iyi yapın", + "title": "Hoppscotch'u Daha İyi Yapın", + "welcome": "Hoş geldiniz" + }, + "infra_tokens": { + "copy_token_warning": "Altyapı token'ınızı şimdi kopyaladığınızdan emin olun. Tekrar görüntüleyemezsiniz!", + "deletion_success": "{label} altyapı token'ı silindi", + "empty": "Altyapı token'ları boş", + "expired": "Süresi Dolmuş", + "expiration_label": "Son Kullanma", + "expires_on": "Bitiş tarihi", + "generate_modal_title": "Yeni Altyapı Token'ı", + "generate_new_token": "Yeni token oluştur", + "generate_token": "Token Oluştur", + "invalid_label": "Lütfen token için bir etiket girin", + "last_used_on": "Son kullanım tarihi", + "no_expiration": "Süresiz", + "no_expiration_verbose": "Bu token'ın süresi asla dolmayacak!", + "section_description": "Altyapı token'ları ile Hoppscotch kullanıcılarınızı API'ler aracılığıyla yönetin", + "section_title": "Altyapı Token'ları", + "tab_title": "Altyapı Token'ları", + "token_expires_on": "Bu token'ın süresi şu tarihte dolacak:", + "token_purpose": "Bu token'ı tanımlamak için bir etiket girin" + }, + "metrics": { + "dashboard": "Gösterge Paneli", + "no_metrics": "Metrik bulunamadı", + "total_collections": "Toplam Koleksiyon", + "total_requests": "Toplam İstek", + "total_teams": "Toplam Çalışma Alanı", + "total_users": "Toplam Kullanıcı" + }, + "newsletter": { + "description": "En son haberlerimiz hakkında güncellemeler alın", + "subscribe": "Abone Ol", + "title": "İletişimde Kalın", + "toggle_description": "Hoppscotch'taki en son gelişmeler hakkında güncellemeler alın", + "unsubscribe": "Abonelikten Çık" + }, + "teams": { + "add_member": "Üye Ekle", + "add_members": "Üyeleri Ekle", + "add_new": "Yeni Ekle", + "admin": "Yönetici", + "admin_Email": "Yönetici E-postası", + "admin_id": "Yönetici ID", + "cancel": "İptal", + "confirm_team_deletion": "Çalışma alanını silmeyi onaylıyor musunuz?", + "copy": "Kopyala", + "create_team": "Çalışma Alanı Oluştur", + "date": "Tarih", + "delete_team": "Çalışma Alanını Sil", + "details": "Ayrıntılar", + "edit": "Düzenle", + "editor": "EDİTÖR", + "editor_description": "Editörler istek ve koleksiyon ekleyebilir, düzenleyebilir ve silebilir.", + "email": "Çalışma alanı sahibi e-postası", + "email_address": "E-posta Adresi", + "email_title": "E-posta", + "empty_name": "Takım adı boş olamaz!!", + "error": "Bir hata oluştu. Lütfen daha sonra tekrar deneyin.", + "id": "Çalışma Alanı ID", + "invited_email": "Davetli E-postası", + "invited_on": "Davet Tarihi", + "invites": "Davetler", + "load_info_error": "Çalışma alanı bilgileri yüklenemedi", + "load_list_error": "Çalışma Alanı Listesi Yüklenemedi", + "members": "Üye sayısı", + "no_invite": "Davet yok", + "no_invite_description": "İş birliğine başlamak için takımınızı davet edin", + "owner": "SAHİP", + "owner_description": "Sahipler istek, koleksiyon ve çalışma alanı üyelerini ekleyebilir, düzenleyebilir ve silebilir.", + "permissions": "İzinler", + "name": "Çalışma Alanı Adı", + "no_members": "Bu çalışma alanında üye yok. İş birliği yapmak için bu çalışma alanına üye ekleyin", + "no_pending_invites": "Bekleyen davet yok", + "no_teams": "Çalışma alanı bulunamadı..", + "no_teams_description": "Takımınızla iş birliği yapmak için bir çalışma alanı oluşturun", + "pending_invites": "Bekleyen davetler", + "roles": "Roller", + "roles_description": "Roller, paylaşılan koleksiyonlara erişimi kontrol etmek için kullanılır.", + "remove": "Kaldır", + "rename": "Yeniden Adlandır", + "save": "Kaydet", + "save_changes": "Değişiklikleri Kaydet", + "send_invite": "Davet Gönder", + "show_more": "Daha fazla göster", + "team_details": "Çalışma alanı ayrıntıları", + "team_members": "Üyeler", + "team_members_tab": "Çalışma alanı üyeleri", + "teams": "Çalışma Alanı", + "uid": "UID", + "unnamed": "(Adsız Çalışma Alanı)", + "viewer": "İZLEYİCİ", + "viewer_description": "İzleyiciler yalnızca istekleri görüntüleyebilir ve kullanabilir", + "valid_name": "Lütfen geçerli bir çalışma alanı adı girin", + "valid_owner_email": "Lütfen geçerli bir sahip e-postası girin" + }, + "users": { + "add_user": "Kullanıcı Ekle", + "admin": "Yönetici", + "admin_id": "Yönetici ID", + "cancel": "İptal", + "created_on": "Oluşturulma Tarihi", + "copy_invite_link": "Davet Bağlantısını Kopyala", + "copy_link": "Bağlantıyı Kopyala", + "date": "Tarih", + "delete": "Sil", + "delete_user": "Kullanıcıyı Sil", + "delete_users": "Kullanıcıları Sil", + "details": "Ayrıntılar", + "edit": "Düzenle", + "email": "E-posta", + "email_address": "E-posta Adresi", + "empty_name": "Ad boş olamaz!!", + "id": "Kullanıcı ID", + "invalid_user": "Geçersiz Kullanıcı", + "invite_load_list_error": "Davet Edilen Kullanıcılar Listesi Yüklenemedi", + "invite_user": "Kullanıcı Davet Et", + "invited_by": "Davet Eden", + "invited_on": "Davet Tarihi", + "invited_users": "Davet Edilen Kullanıcılar", + "invitee_email": "Davetli E-postası", + "last_active_on": "Son Etkinlik", + "load_info_error": "Kullanıcı bilgileri yüklenemedi", + "load_list_error": "Kullanıcı Listesi Yüklenemedi", + "make_admin": "Yönetici Yap", + "name": "Ad", + "new_user_added": "Yeni Kullanıcı Eklendi", + "no_invite": "Bekleyen davet bulunamadı", + "no_invite_description": "Bekleyen davet yok! Takım arkadaşlarınızı Hoppscotch'a davet etmeye başlayın", + "no_shared_requests": "Kullanıcı tarafından oluşturulmuş paylaşılan istek yok", + "no_users": "Kullanıcı bulunamadı", + "not_available": "Mevcut Değil", + "not_found": "Kullanıcı bulunamadı", + "pending_invites": "Bekleyen Davetler", + "remove_admin_privilege": "Yönetici Yetkisini Kaldır", + "remove_admin_status": "Yönetici Durumunu Kaldır", + "rename": "Yeniden Adlandır", + "revoke_invitation": "Daveti İptal Et", + "searchbar_placeholder": "Ad veya e-posta ile arayın..", + "send_invite": "Davet Gönder", + "show_more": "Daha fazla göster", + "uid": "UID", + "unnamed": "(Adsız Kullanıcı)", + "user_not_found": "Kullanıcı altyapıda bulunamadı!!", + "users": "Kullanıcılar", + "valid_email": "Lütfen geçerli bir e-posta adresi girin", + "deactivate": "Devre Dışı Bırak", + "deactivate_user": "Kullanıcıyı Devre Dışı Bırak" + }, + "organization": { + "login_to_continue_description": "Bir organizasyon örneğine katılmak için giriş yapmanız gerekir.", + "create_an_organization": "Bir organizasyon oluşturun", + "deactivate_user_failure": "Kullanıcı devre dışı bırakma başarısız oldu!", + "deactivate_user_success": "Kullanıcı başarıyla devre dışı bırakıldı!", + "delete_account_description": "Bu işlem, Hoppscotch hesabınızla ilişkili tüm verileri silecektir; bu hesap ve parçası olduğunuz diğer tüm organizasyonlar dahil.", + "delete_account": "Hoppscotch Hesabını Sil", + "user_deletion_failed_sole_admin": "Kullanıcı bir veya daha fazla organizasyon örneğinin tek yöneticisidir. Silme işleminden önce lütfen kullanıcıyı yöneticilikten çıkarın.", + "user_deletion_failed_sole_team_owner": "Kullanıcı bir veya daha fazla organizasyon örneğinde tek takım sahibidir. Silme işleminden önce lütfen sahipliği devredin veya ilgili çalışma alanlarını silin.", + "no_organizations": "Herhangi bir organizasyonun üyesi değilsiniz", + "admin": "Yönetici" + }, + "organization_sidebar": { + "hoppscotch_cloud": "Hoppscotch Cloud", + "cloud_locked": "Varsayılan örnek kaldırılamaz", + "admin": "Yönetici", + "error_loading": "Organizasyonlar yüklenemedi", + "inactive_orgs": "Pasif Organizasyonlar", + "no_orgs_found": "Organizasyon bulunamadı", + "no_active_orgs_found": "Aktif organizasyon bulunamadı", + "organizations_for": "{email} için organizasyonlar", + "multi_account_notice": "Her organizasyon kendi oturumunu tutar ve en son erişilen hesabı kullanır.", + "inactive_orgs_tooltip": "Yardım için destek ekibiyle iletişime geçin." + }, + "billing": { + "confirm": { + "update_seat_count": "Kullanıcı sayısını {newSeatCount} olarak güncellemek istediğinizden emin misiniz?", + "update_billing_cycle": "Fatura döngüsünü {newBillingCycle} olarak güncellemek istediğinizden emin misiniz?" + }, + "cancel_subscription": "Aboneliği iptal et" + }, + "app_console": { + "entries": "Konsol kayıtları", + "no_entries": "Kayıt yok" + }, + "mockServer": { + "create_modal": { + "title": "Mock Sunucu Oluştur", + "name_label": "Mock Sunucu Adı", + "name_placeholder": "Mock sunucu adını girin", + "name_required": "Mock sunucu adı gereklidir", + "collection_source_label": "Koleksiyon Kaynağı", + "existing_collection": "Mevcut Koleksiyon", + "new_collection": "Yeni Koleksiyon", + "select_collection_label": "Koleksiyon Seç", + "select_collection_placeholder": "Bir koleksiyon seçin", + "collection_required": "Lütfen bir koleksiyon seçin", + "collection_name_label": "Koleksiyon Adı", + "collection_name_placeholder": "Koleksiyon adını girin", + "collection_name_required": "Koleksiyon adı gereklidir", + "request_config_label": "İstek Yapılandırması", + "add_request": "İstek Ekle", + "create_button": "Mock Sunucu Oluştur", + "success": "Mock sunucu başarıyla oluşturuldu", + "error": "Mock sunucu oluşturulamadı", + "no_collections": "Kullanılabilir koleksiyon yok" + }, + "edit_modal": { + "title": "Mock Sunucuyu Düzenle", + "name_label": "Mock Sunucu Adı", + "name_placeholder": "Mock sunucu adını girin", + "name_required": "Mock sunucu adı gereklidir", + "active_label": "Aktif", + "url_label": "Mock Sunucu URL'si", + "collection_label": "Koleksiyon", + "update_button": "Mock Sunucuyu Güncelle", + "success": "Mock sunucu başarıyla güncellendi", + "error": "Mock sunucu güncellenemedi", + "url_copied": "URL panoya kopyalandı" + }, + "dashboard": { + "title": "Mock Sunucular", + "subtitle": "API mock sunucularınızı oluşturun ve yönetin", + "create_button": "Mock Sunucu Oluştur", + "create_first": "İlk mock sunucunuzu oluşturun", + "empty_title": "Mock sunucu bulunamadı", + "empty_description": "Arka uç bağımlılığı olmadan ön uç ve mobil geliştirmeyi etkinleştirmek için API koleksiyonlarınıza dayalı mock sunucular oluşturun.", + "collection": "Koleksiyon", + "active": "Aktif", + "inactive": "Pasif", + "mock_url": "Mock URL", + "endpoints": "Uç Noktalar", + "created": "Oluşturulma", + "view_collection": "Koleksiyonu Görüntüle", + "documentation": "Dokümantasyon", + "doc_description": "Bu URL'yi uygulamalarınızda API temel URL'si olarak kullanın:", + "url_copied": "Mock sunucu URL'si panoya kopyalandı", + "delete_title": "Mock Sunucuyu Sil", + "delete_description": "Bu mock sunucuyu silmek istediğinizden emin misiniz?", + "delete_success": "Mock sunucu başarıyla silindi", + "delete_error": "Mock sunucu silinemedi" + } + } +} diff --git a/packages/hoppscotch-common/locales/tw.json b/packages/hoppscotch-common/locales/tw.json new file mode 100644 index 0000000..b80a645 --- /dev/null +++ b/packages/hoppscotch-common/locales/tw.json @@ -0,0 +1,1122 @@ +{ + "action": { + "add": "新增", + "autoscroll": "自動捲動", + "cancel": "取消", + "choose_file": "選擇一個檔案", + "choose_workspace": "選擇工作區", + "choose_collection": "選擇集合", + "select_workspace": "選擇工作區", + "clear": "清除", + "clear_all": "全部清除", + "clear_cache": "清除快取", + "clear_history": "清除所有歷史記錄", + "clear_unpinned": "清除未釘選", + "close": "關閉", + "confirm": "確認", + "connect": "連線", + "connecting": "連線中", + "copy": "複製", + "create": "建立", + "delete": "刪除", + "disconnect": "中斷連線", + "dismiss": "忽略", + "done": "完成", + "dont_save": "不要儲存", + "download_file": "下載檔案", + "download_test_report": "下載測試報告", + "drag_to_reorder": "拖曳以重新排序", + "duplicate": "複製", + "edit": "編輯", + "filter": "篩選", + "go_back": "返回", + "go_forward": "前進", + "group_by": "分組", + "hide_secret": "Hide secret", + "label": "標籤", + "learn_more": "瞭解更多", + "download_here": "下載到這裡", + "less": "更少", + "more": "更多", + "new": "新增", + "no": "否", + "open_workspace": "開啟工作區", + "paste": "貼上", + "prettify": "格式化", + "properties": "屬性", + "register": "註冊", + "remove": "移除", + "remove_instance": "移除實例", + "rename": "重新命名", + "restore": "還原", + "retry": "重試", + "save": "儲存", + "save_as_example": "儲存為範本", + "scroll_to_bottom": "捲動至底部", + "scroll_to_top": "捲動至頂部", + "search": "搜尋", + "send": "送出", + "share": "分享", + "show_secret": "顯示祕密", + "start": "開始", + "starting": "正在開始", + "stop": "停止", + "to_close": "以關閉", + "to_navigate": "以瀏覽", + "to_select": "以選擇", + "turn_off": "關閉", + "turn_on": "開啟", + "undo": "復原", + "yes": "是", + "verify": "驗證", + "enable": "啟用", + "disable": "停用", + "assign": "指派" + }, + "add": { + "new": "新增", + "star": "加上星號" + }, + "app": { + "chat_with_us": "與我們交談", + "contact_us": "聯絡我們", + "cookies": "Cookies", + "copy": "複製", + "copy_interface_type": "Copy interface type", + "copy_user_id": "複製使用者驗證權杖", + "developer_option": "開發者選項", + "developer_option_description": "協助開發和維護 Hoppscotch 的工具。", + "discord": "Discord", + "documentation": "幫助文件", + "github": "GitHub", + "help": "幫助與回饋", + "home": "主頁", + "invite": "邀請", + "invite_description": "Hoppscotch 是一個開源的 API 開發生態系統。我們設計了簡單而直觀的介面來建立和管理您的 API。Hoppscotch 是一個幫助您構建、測試、記錄與分享您的 API 的工具。", + "invite_your_friends": "邀請您的夥伴", + "join_discord_community": "加入我們的 Discord 社群", + "keyboard_shortcuts": "鍵盤快捷鍵", + "name": "Hoppscotch", + "new_version_found": "已發現新版本。重新整理頁面以更新。", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "選項", + "proxy_privacy_policy": "Proxy 隱私政策", + "reload": "重新載入", + "search": "搜尋", + "share": "分享", + "shortcuts": "快捷方式", + "social_description": "在社交媒體上追蹤我們即可在第一時間得知新聞、更新、以及新版本的消息。", + "social_links": "社群連結", + "spotlight": "聚光燈", + "status": "狀態", + "status_description": "檢查網站狀態", + "terms_and_privacy": "隱私條款", + "twitter": "Twitter", + "type_a_command_search": "輸入命令或搜尋內容……", + "we_use_cookies": "我們使用 cookies", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "新功能", + "see_whats_new": "See what’s new", + "wiki": "維基" + }, + "auth": { + "account_exists": "帳號存在不同的憑證 - 登入後可連結兩個帳號", + "all_sign_in_options": "所有登入選項", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "使用電子信箱登入", + "continue_with_github": "使用 GitHub 登入", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "使用 Google 登入", + "continue_with_microsoft": "使用 Microsoft 登入", + "email": "電子信箱地址", + "logged_out": "登出", + "login": "登入", + "login_success": "登入成功", + "login_to_hoppscotch": "登入 Hoppscotch", + "logout": "登出", + "re_enter_email": "重新輸入電子郵件", + "send_magic_link": "傳送魔術連結", + "sync": "同步", + "we_sent_magic_link": "已傳送魔術連結!", + "we_sent_magic_link_description": "請檢查您的收件匣 - 我們向 {email} 傳送了一封郵件,其中包含了能夠讓您登入的魔術連結。" + }, + "authorization": { + "generate_token": "產生權杖", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "包含在網址", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "瞭解更多", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "傳遞方式", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "密碼", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "權杖", + "type": "授權類型", + "username": "使用者名稱" + }, + "collection": { + "created": "集合已建立", + "different_parent": "無法為父集合不同的集合重新排序", + "edit": "編輯集合", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "請提供有效的集合名稱", + "invalid_root_move": "集合已在根目錄", + "moved": "移動成功", + "my_collections": "我的集合", + "name": "我的新集合", + "name_length_insufficient": "集合名稱至少要有 3 個字元。", + "new": "建立集合", + "order_changed": "集合順序已更新", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "集合已重新命名", + "request_in_use": "請求正在使用中", + "save_as": "另存為", + "save_to_collection": "儲存到集合", + "select": "選擇一個集合", + "select_location": "選擇位置", + "details": "Details", + "select_team": "選擇一個團隊", + "team_collections": "團隊集合" + }, + "confirm": { + "close_unsaved_tab": "您確定要關閉此分頁嗎?", + "close_unsaved_tabs": "您確定要關閉所有分頁嗎?{count} 個未儲存的分頁將會遺失。", + "exit_team": "您確定要離開此團隊嗎?", + "logout": "您確定要登出嗎?", + "remove_collection": "您確定要永久刪除該集合嗎?", + "remove_environment": "您確定要永久刪除該環境嗎?", + "remove_folder": "您確定要永久刪除該資料夾嗎?", + "remove_history": "您確定要永久刪除全部歷史記錄嗎?", + "remove_request": "您確定要永久刪除該請求嗎?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "您確定要刪除該團隊嗎?", + "remove_telemetry": "您確定要退出遙測服務嗎?", + "request_change": "您確定要捨棄目前的請求嗎?未儲存的變更將遺失。", + "save_unsaved_tab": "您要儲存在此分頁做出的改動嗎?", + "sync": "您想從雲端恢復您的工作區嗎?這將丟棄您的本地進度。", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "新增至參數", + "open_request_in_new_tab": "在新分頁開啟請求", + "set_environment_variable": "設為變數" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "請求標頭 {count}", + "message": "訊息 {count}", + "parameter": "參數 {count}", + "protocol": "協定 {count}", + "value": "值 {count}", + "variable": "變數 {count}" + }, + "documentation": { + "generate": "產生文件", + "generate_message": "匯入 Hoppscotch 集合以隨時隨地產生 API 文件。" + }, + "empty": { + "authorization": "該請求沒有使用任何授權", + "body": "該請求沒有任何請求主體", + "collection": "集合為空", + "collections": "集合為空", + "documentation": "連線到 GraphQL 端點以檢視文件", + "endpoint": "端點不能留空", + "environments": "環境為空", + "folder": "資料夾為空", + "headers": "該請求沒有任何請求標頭", + "history": "歷史記錄為空", + "invites": "邀請名單為空", + "members": "團隊為空", + "parameters": "該請求沒有任何參數", + "pending_invites": "這個團隊沒有待定的邀請", + "profile": "登入以檢視您的設定檔", + "protocols": "協定為空", + "request_variables": "This request does not have any request variables", + "schema": "連線至 GraphQL 端點", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "訂閱為空", + "team_name": "團隊名稱為空", + "teams": "團隊為空", + "tests": "沒有針對該請求的測試", + "access_tokens": "Access tokens are empty", + "shortcodes": "Shortcodes 為空" + }, + "environment": { + "add_to_global": "新增至全域", + "added": "新增環境", + "create_new": "建立新環境", + "created": "已建立環境", + "deleted": "刪除環境", + "duplicated": "已複製環境", + "edit": "編輯環境", + "empty_variables": "無變數", + "global": "全域", + "global_variables": "全域變數", + "import_or_create": "Import or create a environment", + "invalid_name": "請提供有效的環境名稱", + "list": "環境變數", + "my_environments": "我的環境", + "name": "名稱", + "nested_overflow": "巢狀環境變數不得大於 10 層", + "new": "建立環境", + "no_active_environment": "無使用中的環境", + "no_environment": "無環境", + "no_environment_description": "未選取任何環境。請選擇要對以下變數進行的動作。", + "quick_peek": "快速預覽環境", + "replace_with_variable": "以變數替代", + "scope": "範圍", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "選擇環境", + "set": "設定環境", + "set_as_environment": "設為環境", + "team_environments": "團隊環境", + "title": "環境", + "updated": "更新環境", + "value": "數值", + "variable": "變數", + "variables": "Variables", + "variable_list": "變數列表", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "此瀏覽器似乎不支援 SSE。", + "check_console_details": "檢查控制台日誌以獲悉詳情", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "cURL 格式不正確", + "danger_zone": "危險地帶", + "delete_account": "您的帳號目前為這些團隊的擁有者:", + "delete_account_description": "您在刪除帳號前必須先將您自己從團隊中移除、轉移擁有權,或是刪除團隊。", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "空請求名稱", + "f12_details": "(按下 F12 以獲悉詳情)", + "gql_prettify_invalid_query": "無法美化無效的查詢,處理查詢語法錯誤並重試", + "incomplete_config_urls": "設置網址不完整", + "incorrect_email": "錯誤的電子信箱", + "invalid_link": "連結無效", + "invalid_link_description": "您點擊的連結無效或已過期。", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "JSON 無效", + "json_prettify_invalid_body": "無法美化無效的請求主體,處理 JSON 語法錯誤並重試", + "network_error": "似乎有網路錯誤。請再試一次。", + "network_fail": "無法傳送請求", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "無持續時間", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "找不到結果", + "page_not_found": "找不到此頁面", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy 錯誤", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "無法執行預請求指令碼", + "something_went_wrong": "發生了一些錯誤", + "post_request_script_fail": "無法執行測試指令碼", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "匯出為 JSON", + "create_secret_gist": "建立私密 Gist", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "使用 GitHub 登入以建立私密 Gist", + "title": "匯出", + "success": "Successfully exported", + "gist_created": "已建立 Gist" + }, + "filter": { + "all": "全部", + "none": "無", + "starred": "已加星號" + }, + "folder": { + "created": "已建立資料夾", + "edit": "編輯資料夾", + "invalid_name": "請提供資料夾的名稱", + "name_length_insufficient": "資料夾名稱至少要有 3 個字元。", + "new": "新資料夾", + "renamed": "資料夾已重新命名" + }, + "graphql": { + "connection_switch_confirm": "您要使用最新的 GraphQL 端點連線嗎?", + "connection_switch_new_url": "切換至分頁將斷開使用中的 GraphQL 連線。新的連線網址為 ", + "connection_switch_url": "您已連接至 GraphQL 端點。連線網址為 ", + "mutations": "變體", + "schema": "綱要", + "subscriptions": "訂閱", + "switch_connection": "切換連線", + "url_placeholder": "輸入 GraphQL 端點 URL" + }, + "graphql_collections": { + "title": "GraphQL 集合" + }, + "group": { + "time": "時間", + "url": "網址" + }, + "header": { + "install_pwa": "安裝應用程式", + "login": "登入", + "save_workspace": "儲存我的工作區" + }, + "helpers": { + "authorization": "授權標頭將會在您傳送請求時自動產生。", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "請先產生文件", + "network_fail": "無法到達 API 端點。請檢查網路連線並重試。", + "offline": "您似乎處於離線狀態,該工作區中的資料可能不是最新。", + "offline_short": "您似乎處於離線狀態。", + "post_request_tests": "測試指令碼使用 JavaScript 編寫,並在收到回應後執行。", + "pre_request_script": "預請求指令碼使用 JavaScript 編寫,並在請求傳送前執行。", + "script_fail": "預請求指令碼似乎有問題。請檢查下方的錯誤並進行相應修正。", + "post_request_script_fail": "測試指令碼似乎有誤,請修復錯誤並重新執行測試。", + "post_request_script": "編寫測試指令碼以自動除錯。" + }, + "hide": { + "collection": "隱藏集合面板", + "more": "隱藏更多", + "preview": "隱藏預覽", + "sidebar": "隱藏側邊欄" + }, + "import": { + "collections": "匯入集合", + "curl": "匯入 cURL", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "匯入失敗", + "from_file": "Import from File", + "from_gist": "從 Gist 匯入", + "from_gist_description": "從 Gist 網址匯入", + "from_insomnia": "從 Insomnia 匯入", + "from_insomnia_description": "從 Insomnia 集合匯入", + "from_json": "從 Hoppscotch 匯入", + "from_json_description": "從 Hoppscotch 集合檔匯入", + "from_my_collections": "從我的集合匯入", + "from_my_collections_description": "從我的集合檔匯入", + "from_openapi": "從 OpenAPI 匯入", + "from_openapi_description": "從 OpenAPI 規格檔 (YML/JSON) 匯入", + "from_postman": "從 Postman 匯入", + "from_postman_description": "從 Postman 集合匯入", + "from_url": "從網址匯入", + "gist_url": "輸入 Gist 網址", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "無法從網址取得資料", + "import_from_url_invalid_file_format": "匯入集合時發生錯誤", + "import_from_url_invalid_type": "不支援此類型。可接受的值為 'hoppscotch'、'openapi'、'postman'、'insomnia'", + "import_from_url_success": "已匯入集合", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "從 Hoppscotch 集合 JSON 檔匯入集合", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "匯入", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "檢查潛在錯誤", + "environment": { + "add_environment": "新增至環境", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "找不到環境變數 “{environment}”。" + }, + "header": { + "cookie": "瀏覽器不允許 Hoppscotch 設定 Cookie 標頭。在我們推出 Hoppscotch 桌面版前,請先使用 Authorization 標頭。" + }, + "response": { + "401_error": "請檢查您的授權認證。", + "404_error": "請檢查您的請求網址和方式類型。", + "cors_error": "請檢查您的跨來源資源共用設定。", + "default_error": "請檢查您的請求。", + "network_error": "請檢查您的網路連線。" + }, + "title": "檢查工具", + "url": { + "extension_not_installed": "未安裝擴充套件。", + "extension_unknown_origin": "請確認您是否已將 API 端點的來源加入 Hoppscotch 擴充套件的清單。", + "extention_enable_action": "啟用瀏覽器擴充套件", + "extention_not_enabled": "未啟用擴充套件。" + } + }, + "layout": { + "collapse_collection": "隱藏或顯示集合", + "collapse_sidebar": "隱藏或顯示側邊欄", + "column": "垂直版面", + "name": "配置", + "row": "水平版面" + }, + "modal": { + "close_unsaved_tab": "您有未儲存的改動", + "collections": "集合", + "confirm": "確認", + "customize_request": "Customize Request", + "edit_request": "編輯請求", + "import_export": "匯入/匯出", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "您已經訂閱了此主題。", + "clean_session": "清理工作階段", + "clear_input": "清除輸入", + "clear_input_on_send": "傳送後清除輸入", + "client_id": "客戶端 ID", + "color": "選擇顏色", + "communication": "通訊", + "connection_config": "連線設定", + "connection_not_authorized": "此 MQTT 連線未使用任何驗證。", + "invalid_topic": "請提供該訂閱的主題", + "keep_alive": "Keep Alive", + "log": "日誌", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "訊息", + "new": "新訂閱", + "not_connected": "請先啟動 MQTT 連線。", + "publish": "發佈", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "訂閱", + "topic": "主題", + "topic_name": "主題名稱", + "topic_title": "發佈/訂閱主題", + "unsubscribe": "取消訂閱", + "url": "網址" + }, + "navigation": { + "doc": "文件", + "graphql": "GraphQL", + "profile": "設定檔", + "realtime": "即時", + "rest": "REST", + "settings": "設定" + }, + "preRequest": { + "javascript_code": "JavaScript 程式碼", + "learn": "閱讀文件", + "script": "預請求指令碼", + "snippets": "程式碼片段" + }, + "profile": { + "app_settings": "應用程式設定", + "default_hopp_displayname": "未命名使用者", + "editor": "編輯者", + "editor_description": "編輯者可以新增、編輯和刪除請求。", + "email_verification_mail": "已將驗證信寄送至您的電子郵件地址。請點擊信中連結以驗證您的電子郵件地址。", + "no_permission": "您沒有權限執行此操作。", + "owner": "擁有者", + "owner_description": "擁有者可以新增、編輯和刪除請求、集合和團隊成員。", + "roles": "角色", + "roles_description": "角色用來控制對共用集合的存取權。", + "updated": "已更新個人檔案", + "viewer": "檢視者", + "viewer_description": "檢視者只能檢視和使用請求。" + }, + "remove": { + "star": "移除星號" + }, + "request": { + "added": "已新增請求", + "authorization": "授權", + "body": "請求本體", + "choose_language": "選擇語言", + "content_type": "內容類型", + "content_type_titles": { + "others": "其他", + "structured": "結構", + "text": "文字" + }, + "different_collection": "無法重新排列來自不同集合的請求", + "duplicated": "已複製請求", + "duration": "持續時間", + "enter_curl": "輸入 cURL", + "generate_code": "產生程式碼", + "generated_code": "已產生程式碼", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "請求標頭列表", + "invalid_name": "請提供請求名稱", + "method": "方法", + "moved": "已移動請求", + "name": "請求名稱", + "new": "新請求", + "order_changed": "已更新請求順序", + "override": "覆寫", + "override_help": "在標頭設置 Content-Type", + "overriden": "已覆寫", + "parameter_list": "查詢參數", + "parameters": "參數", + "path": "路徑", + "payload": "負載", + "query": "查詢", + "raw_body": "原始請求本體", + "rename": "重新命名請求", + "renamed": "請求已重新命名", + "request_variables": "Request variables", + "run": "執行", + "save": "儲存", + "save_as": "另存為", + "saved": "請求已儲存", + "share": "分享", + "share_description": "與您的朋友分享 Hoppscotch", + "share_request": "Share Request", + "stop": "Stop", + "title": "請求", + "type": "請求類型", + "url": "網址", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "變數", + "view_my_links": "檢視我的連結", + "copy_link": "複製連結" + }, + "response": { + "audio": "音訊", + "body": "回應本體", + "filter_response_body": "篩選 JSON 回應本體 (使用 jq 語法)", + "headers": "回應標頭", + "html": "HTML", + "image": "影像", + "json": "JSON", + "pdf": "PDF", + "preview_html": "預覽 HTML", + "raw": "原始內容", + "size": "大小", + "status": "狀態", + "time": "時間", + "title": "回應", + "video": "視訊", + "waiting_for_connection": "等待連線", + "xml": "XML" + }, + "settings": { + "accent_color": "強調色", + "account": "帳號", + "account_deleted": "已刪除您的帳號", + "account_description": "自訂您的帳號設定。", + "account_email_description": "您的主要電子郵件地址。", + "account_name_description": "這是您的顯示名稱。", + "additional": "Additional Settings", + "background": "背景", + "black_mode": "黑色", + "choose_language": "選擇語言", + "dark_mode": "暗色", + "delete_account": "刪除帳號", + "delete_account_description": "一旦您刪除了您的帳號,您的所有資料將被永久刪除。此操作無法復原。", + "expand_navigation": "展開導航欄", + "experiments": "實驗功能", + "experiments_notice": "下面是我們正在開發中的一些實驗功能,這些功能可能會很有用,可能很有趣,又或者二者都是或都不是。這些功能並非最終版本且可能不穩定,所以如果發生了一些過於奇怪的事情,不要驚慌,關掉它們就好了。玩笑歸玩笑,", + "extension_ver_not_reported": "未報告", + "extension_version": "擴充套件版本", + "extensions": "擴充套件", + "extensions_use_toggle": "使用瀏覽器擴充套件傳送請求(如果存在)", + "follow": "關注我們", + "interceptor": "攔截器", + "interceptor_description": "應用程式和 API 之間的中介軟體。", + "language": "語言", + "light_mode": "亮色", + "official_proxy_hosting": "官方 Proxy 由 Hoppscotch 託管。", + "profile": "設定檔", + "profile_description": "更新您的設定檔詳細資料", + "profile_email": "電子郵件地址", + "profile_name": "設定檔名稱", + "proxy": "Proxy", + "proxy_url": "Proxy 網址", + "proxy_use_toggle": "使用 Proxy 中介軟體傳送請求", + "read_the": "閱讀", + "reset_default": "重置為預設", + "short_codes": "快捷碼", + "short_codes_description": "您建立的快捷碼。", + "sidebar_on_left": "左側邊欄", + "sync": "同步", + "sync_collections": "集合", + "sync_description": "這些設定會同步到雲端。", + "sync_environments": "環境", + "sync_history": "歷史", + "system_mode": "系統", + "telemetry": "遙測服務", + "telemetry_helps_us": "遙測服務能夠幫助我們進行個人化操作,為您提供最佳體驗。", + "theme": "主題", + "theme_description": "自訂您的應用程式主題。", + "use_experimental_url_bar": "使用帶有環境醒目標示的實驗性網址欄", + "user": "使用者", + "verified_email": "已確認電子郵件地址", + "verify_email": "確認電子郵件地址" + }, + "shared_requests": { + "button": "按鈕", + "button_info": "為你的網站、部落格或 README 文件建立「在 Hoppscotch 執行」按鈕。", + "copy_html": "複製 HTML", + "copy_link": "複製連結", + "copy_markdown": "複製 Markdown", + "creating_widget": "正在建立元件", + "customize": "自訂", + "deleted": "已刪除共享請求", + "description": "選擇一個元件,你可以稍後進行修改與自訂。", + "embed": "嵌入", + "embed_info": "在你的網站、部落格或文件中新增「Hoppscotch API Playground」小工具。", + "link": "連結", + "link_info": "建立任何人均可檢視的分享連結。", + "modified": "已修改共享請求", + "not_found": "找不到共享請求", + "open_new_tab": "在新分頁開啟", + "preview": "預覽", + "run_in_hoppscotch": "在 Hoppscotch 執行", + "theme": { + "dark": "暗色", + "light": "亮色", + "system": "系統預設", + "title": "佈景主題" + } + }, + "shortcut": { + "general": { + "close_current_menu": "關閉目前選單", + "command_menu": "搜尋與命令選單", + "help_menu": "幫助選單", + "show_all": "鍵盤快捷鍵", + "title": "一般" + }, + "miscellaneous": { + "invite": "邀請使用 Hoppscotch", + "title": "雜項" + }, + "navigation": { + "back": "返回上一頁面", + "documentation": "前往文件頁面", + "forward": "前往下一頁面", + "graphql": "前往 GraphQL 頁面", + "profile": "前往個人檔案頁面", + "realtime": "前往即時頁面", + "rest": "前往 REST 頁面", + "settings": "前往設定頁面", + "title": "導航" + }, + "others": { + "prettify": "美化編輯器的內容", + "title": "其他" + }, + "request": { + "delete_method": "選擇 DELETE 方法", + "get_method": "選擇 GET 方法", + "head_method": "選擇 HEAD 方法", + "import_curl": "匯入 cURL", + "method": "方法", + "next_method": "選擇下一個方法", + "post_method": "選擇 POST 方法", + "previous_method": "選擇上一個方法", + "put_method": "選擇 PUT 方法", + "rename": "重新命名請求", + "reset_request": "重置請求", + "save_request": "儲存請求", + "save_to_collections": "儲存到集合", + "send_request": "傳送請求", + "share_request": "分享請求", + "show_code": "產生程式碼片段", + "title": "請求", + "copy_request_link": "複製請求連結" + }, + "response": { + "copy": "複製回應至剪貼簿", + "download": "下載回應", + "title": "回應" + }, + "theme": { + "black": "將主題切換至黑色模式", + "dark": "將主題切換至暗色模式", + "light": "將主題切換至亮色模式", + "system": "跟隨作業系統主題", + "title": "主題" + } + }, + "show": { + "code": "顯示程式碼", + "collection": "顯示集合面板", + "more": "顯示更多", + "sidebar": "顯示側邊欄" + }, + "socketio": { + "communication": "通訊", + "connection_not_authorized": "此 SocketIO 連線未使用任何驗證。", + "event_name": "事件名稱", + "events": "事件", + "log": "日誌", + "url": "網址" + }, + "spotlight": { + "change_language": "變更語言", + "environments": { + "delete": "刪除目前環境", + "duplicate": "複製目前環境", + "duplicate_global": "複製全域環境", + "edit": "編輯目前環境", + "edit_global": "編輯全域環境", + "new": "建立新環境", + "new_variable": "建立新環境變數", + "title": "環境" + }, + "general": { + "chat": "與客服對話", + "help_menu": "幫助與支援", + "open_docs": "閱讀說明文件", + "open_github": "開啟 GitHub 儲存庫", + "open_keybindings": "鍵盤快捷鍵", + "social": "社交", + "title": "一般" + }, + "graphql": { + "connect": "連接至伺服器", + "disconnect": "斷開與伺服器的連線" + }, + "miscellaneous": { + "invite": "邀請您的朋友使用 Hoppscotch", + "title": "雜項" + }, + "request": { + "save_as_new": "儲存為新請求", + "select_method": "選擇方法", + "switch_to": "切換至", + "tab_authorization": "授權分頁", + "tab_body": "本體分頁", + "tab_headers": "標頭分頁", + "tab_parameters": "參數分頁", + "tab_pre_request_script": "預請求腳本分頁", + "tab_query": "查詢分頁", + "tab_tests": "測試分頁", + "tab_variables": "變數分頁" + }, + "response": { + "copy": "複製回應", + "download": "下載回應", + "title": "回應" + }, + "section": { + "interceptor": "攔截器", + "interface": "介面", + "theme": "主題", + "user": "使用者" + }, + "settings": { + "change_interceptor": "變更攔截器", + "change_language": "變更語言", + "theme": { + "black": "黑色", + "dark": "暗色", + "light": "亮色", + "system": "跟隨系統" + } + }, + "tab": { + "close_current": "關閉目前分頁", + "close_others": "關閉所有其他分頁", + "duplicate": "複製目前分頁", + "new_tab": "開啟新分頁", + "title": "分頁" + }, + "workspace": { + "delete": "刪除目前團隊", + "edit": "編輯目前團隊", + "invite": "邀請他人加入團隊", + "new": "建立新團隊", + "switch_to_personal": "切換至您的個人工作區", + "title": "團隊" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "事件類型", + "log": "日誌", + "url": "網址" + }, + "state": { + "bulk_mode": "大量編輯", + "bulk_mode_placeholder": "條目之間使用換行符分隔\n鍵和值之間使用冒號分隔\n將 # 置於行首以新增該行條目並保持停用", + "cleared": "已清除", + "connected": "已連線", + "connected_to": "已連線到 {name}", + "connecting_to": "正在連線到 {name}……", + "connection_error": "連線失敗", + "connection_failed": "連線失敗", + "connection_lost": "失去連線", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "已複製到剪貼簿", + "deleted": "已刪除", + "deprecated": "已棄用", + "disabled": "已停用", + "disconnected": "斷開連線", + "disconnected_from": "與 {name} 斷開連線", + "docs_generated": "已產生檔案", + "download_failed": "Download failed", + "download_started": "開始下載", + "enabled": "啟用", + "file_imported": "檔案已匯入", + "finished_in": "在 {duration} 毫秒內完成", + "hide": "Hide", + "history_deleted": "歷史記錄已刪除", + "linewrap": "換行", + "loading": "正在載入……", + "message_received": "訊息:{message}已抵達主題:{topic}", + "mqtt_subscription_failed": "訂閱此主題時發生錯誤:{topic}", + "none": "無", + "nothing_found": "沒有找到", + "published_error": "將訊息:{topic} 發布至主題:{message} 時發生錯誤", + "published_message": "已將此訊息:{message} 發布至主題:{topic}", + "reconnection_error": "重新連線失敗", + "show": "Show", + "subscribed_failed": "無法訂閱此主題:{topic}", + "subscribed_success": "成功訂閱此主題:{topic}", + "unsubscribed_failed": "無法取消訂閱此主題:{topic}", + "unsubscribed_success": "成功取消訂閱此主題:{topic}", + "waiting_send_request": "等待傳送請求" + }, + "support": { + "changelog": "閱讀更多有關最新版本的內容", + "chat": "有問題?來和我們交流吧!", + "community": "提問與互助", + "documentation": "閱讀更多有關 Hoppscotch 的內容", + "forum": "答疑解惑", + "github": "在 GitHub 關注我們", + "shortcuts": "更快瀏覽應用程式", + "title": "支援", + "twitter": "在 Twitter 關注我們", + "team": "與團隊保持聯絡" + }, + "tab": { + "authorization": "授權", + "body": "請求本體", + "close": "關閉分頁", + "close_others": "關閉其他分頁", + "collections": "集合", + "documentation": "幫助文件", + "duplicate": "複製分頁", + "environments": "環境", + "headers": "請求標頭", + "history": "歷史記錄", + "mqtt": "MQTT", + "parameters": "參數", + "pre_request_script": "預請求指令碼", + "queries": "查詢", + "query": "查詢", + "schema": "綱要", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "測試", + "types": "類型", + "variables": "變數", + "websocket": "WebSocket" + }, + "team": { + "already_member": "您已經是這個團隊的成員了。請聯絡您的團隊擁有者。", + "create_new": "建立新團隊", + "deleted": "團隊已刪除", + "edit": "編輯團隊", + "email": "電子信箱", + "email_do_not_match": "電子信箱與您的帳號資料不一致。請聯絡您的團隊擁有者。", + "exit": "退出團隊", + "exit_disabled": "團隊擁有者無法退出團隊", + "failed_invites": "Failed invites", + "invalid_coll_id": "集合 ID 無效", + "invalid_email_format": "電子信箱格式無效", + "invalid_id": "團隊 ID 無效。請聯絡您的團隊擁有者。", + "invalid_invite_link": "邀請連結無效", + "invalid_invite_link_description": "您點擊的連結無效。請聯絡您的團隊擁有者。", + "invalid_member_permission": "請為團隊成員提供有效的許可權", + "invite": "邀請", + "invite_more": "邀請更多", + "invite_tooltip": "邀請他人到這個工作區", + "invited_to_team": "{owner} 邀請您加入 {team}", + "join": "已接受邀請", + "join_team": "加入 {team}", + "joined_team": "您已加入 {team}", + "joined_team_description": "您現在是這個團隊的成員", + "left": "您已離開團隊", + "login_to_continue": "登入以繼續", + "login_to_continue_description": "您需要登入才能加入團隊。", + "logout_and_try_again": "登出並以其他帳號登入", + "member_has_invite": "這個電子信箱已經有一個邀請。請聯絡您的團隊擁有者。", + "member_not_found": "未找到成員。請聯絡您的團隊擁有者。", + "member_removed": "使用者已移除", + "member_role_updated": "使用者角色已更新", + "members": "成員", + "more_members": "還有 {count} 位", + "name_length_insufficient": "團隊名稱至少為 6 個字元", + "name_updated": "團隊名稱已更新", + "new": "新團隊", + "new_created": "已建立新團隊", + "new_name": "我的新團隊", + "no_access": "您沒有編輯集合的許可權", + "no_invite_found": "未找到邀請。請聯絡您的團隊擁有者。", + "no_request_found": "找不到請求。", + "not_found": "找不到團隊。請聯絡您的團隊擁有者。", + "not_valid_viewer": "您不是一個有效的檢視者。請聯絡您的團隊擁有者。", + "parent_coll_move": "無法將集合移動至子集合", + "pending_invites": "待定邀請", + "permissions": "許可權", + "same_target_destination": "目標和目的地相同", + "saved": "團隊已儲存", + "select_a_team": "選擇團隊", + "success_invites": "Success invites", + "title": "團隊", + "we_sent_invite_link": "我們向所有受邀者傳送了邀請連結!", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "請所有受邀者檢查他們的收件匣。點擊連結加入團隊。", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "加入 Beta 計畫以存取團隊。" + }, + "team_environment": { + "deleted": "已刪除環境", + "duplicate": "已複製環境", + "not_found": "找不到環境。" + }, + "test": { + "failed": "測試未通過", + "javascript_code": "JavaScript 程式碼", + "learn": "閱讀文件", + "passed": "測試通過", + "report": "測試報告", + "results": "測試結果", + "script": "指令碼", + "snippets": "程式碼片段" + }, + "websocket": { + "communication": "通訊", + "log": "日誌", + "message": "資訊", + "protocols": "協定", + "url": "網址" + }, + "workspace": { + "change": "切換工作區", + "personal": "我的工作區", + "other_workspaces": "My Workspaces", + "team": "團隊工作區", + "title": "工作區" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "操作", + "created_on": "建立於", + "deleted": "已刪除快捷碼", + "method": "方法", + "not_found": "找不到快捷碼", + "short_code": "快捷碼", + "url": "網址" + } +} diff --git a/packages/hoppscotch-common/locales/uk.json b/packages/hoppscotch-common/locales/uk.json new file mode 100644 index 0000000..6f154ed --- /dev/null +++ b/packages/hoppscotch-common/locales/uk.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Add", + "autoscroll": "Автопрокручування", + "cancel": "Скасувати", + "choose_file": "Виберіть файл", + "clear": "Очистити", + "clear_all": "Очистити все", + "clear_history": "Clear all History", + "close": "Закрити", + "connect": "Підключитись", + "connecting": "Підключення", + "copy": "Копіювати", + "create": "Create", + "delete": "Видалити", + "disconnect": "Відключитись", + "dismiss": "Відхилити", + "dont_save": "Не зберігати", + "download_file": "Завантажити файл", + "drag_to_reorder": "Перетягніть для зміни порядку", + "duplicate": "Дублювати", + "edit": "Редагувати", + "filter": "Фільтрувати відповіді", + "go_back": "Повернутись", + "go_forward": "Go forward", + "group_by": "Групувати за", + "hide_secret": "Hide secret", + "label": "Мітка", + "learn_more": "Дізнатись більше", + "download_here": "Download here", + "less": "Менше", + "more": "Більше", + "new": "Новий", + "no": "Ні", + "open_workspace": "Відкрити робочу область", + "paste": "Вставити", + "prettify": "Форматувати", + "properties": "Properties", + "remove": "Видалити", + "rename": "Rename", + "restore": "Відновити", + "save": "Зберегти", + "scroll_to_bottom": "Прокрутити вниз", + "scroll_to_top": "Прокрутити вгору", + "search": "Пошук", + "send": "Надіслати", + "share": "Share", + "show_secret": "Show secret", + "start": "Почати", + "starting": "Розпочинається", + "stop": "Зупити", + "to_close": "щоб закрити", + "to_navigate": "для навігації", + "to_select": "щоб вибрати", + "turn_off": "Вимкнути", + "turn_on": "Ввімкнути", + "undo": "Скасувати", + "yes": "Так" + }, + "add": { + "new": "Додати новий", + "star": "Додати зірочку" + }, + "app": { + "chat_with_us": "Написати нам", + "contact_us": "Зв'яжіться з нами", + "cookies": "Cookies", + "copy": "Копіювати", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Скопіювати токен автентифікації користувача", + "developer_option": "Параметри розробника", + "developer_option_description": "Інструменти розробника, що допомагають у розробці та підтримці Hoppscotch.", + "discord": "Discord", + "documentation": "Документація", + "github": "GitHub", + "help": "Довідка, відгуки та документація", + "home": "Додому", + "invite": "Запросити", + "invite_description": "У Hoppscotch ми розробили простий та інтуїтивно зрозумілий інтерфейс для створення та управління вашими API. Hoppscotch - це інструмент, який допомагає вам створювати, тестувати, документувати та ділитися своїми API.", + "invite_your_friends": "Запросіть своїх друзів", + "join_discord_community": "Приєднуйтесь до нашої спільноти у Discord", + "keyboard_shortcuts": "Гарячі клавіши", + "name": "Hoppscotch", + "new_version_found": "Знайдено нову версію. Перезавантажте сторінку, щоб оновити.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Опції", + "proxy_privacy_policy": "Політика конфіденційності проксі", + "reload": "Перезавантажити", + "search": "Пошук", + "share": "Поділитися", + "shortcuts": "Ярлики", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "У центрі уваги", + "status": "Статус", + "status_description": "Перевірити статус сайту", + "terms_and_privacy": "Умови та конфіденційність", + "twitter": "Twitter", + "type_a_command_search": "Введіть команду або виконайте пошук…", + "we_use_cookies": "Ми використовуємо файли cookie", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "Що нового?", + "see_whats_new": "See what’s new", + "wiki": "Wiki" + }, + "auth": { + "account_exists": "Обліковий запис існує з різними обліковими даними - увійдіть, щоб зв'язати обидва облікові записи", + "all_sign_in_options": "Усі параметри входу", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "Продовжити з електронною поштою", + "continue_with_github": "Продовжити з GitHub", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Продовжити з Google", + "continue_with_microsoft": "Продовжити з Microsoft", + "email": "Електронна пошта", + "logged_out": "Ви вийшли з облікового запису", + "login": "Увійти", + "login_success": "Вхід успішно здійснено", + "login_to_hoppscotch": "Увійдіть до Hoppscotch", + "logout": "Вийти", + "re_enter_email": "Введіть електронну адресу ще раз", + "send_magic_link": "Надішліть чарівне посилання", + "sync": "Синхронізувати", + "we_sent_magic_link": "Ми надіслали Вам чарівне посилання!", + "we_sent_magic_link_description": "Перевірте свою поштову скриньку - ми надіслали електронний лист на адресу {email}. Він містить чарівне посилання, яке дозволить вам увійти." + }, + "authorization": { + "generate_token": "Створіть маркер", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "Включити в URL", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "Дізнайтесь, як", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "Пропустити", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "Пароль", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "Токен", + "type": "Тип авторизації", + "username": "Ім'я користувача" + }, + "collection": { + "created": "Колекція створена", + "different_parent": "Cannot reorder collection with different parent", + "edit": "Редагувати колекцію", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "Укажіть дійсну назву колекції", + "invalid_root_move": "Collection already in the root", + "moved": "Moved Successfully", + "my_collections": "Мої колекції", + "name": "Моя нова колекція", + "name_length_insufficient": "Назва колекції має містити принаймні 3 символи", + "new": "Нова колекція", + "order_changed": "Collection Order Updated", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "Колекція перейменована", + "request_in_use": "Запит використовується", + "save_as": "Зберегти як", + "save_to_collection": "Save to Collection", + "select": "Виберіть колекцію", + "select_location": "Виберіть місце розташування", + "details": "Details", + "select_team": "Виберіть команду", + "team_collections": "Колекції команд" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "exit_team": "Ви впевнені, що хочете покинути цю команду?", + "logout": "Ви впевнені, що хочете вийти?", + "remove_collection": "Ви впевнені, що хочете назавжди видалити цю колекцію?", + "remove_environment": "Ви впевнені, що хочете назавжди видалити це середовище?", + "remove_folder": "Ви впевнені, що хочете назавжди видалити цю папку?", + "remove_history": "Ви впевнені, що хочете назавжди видалити всю історію?", + "remove_request": "Ви впевнені, що хочете назавжди видалити цей запит?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "Ви впевнені, що хочете видалити цю команду?", + "remove_telemetry": "Ви впевнені, що хочете відмовитися від телеметрії?", + "request_change": "Ви дійсно бажаєте скасувати поточний запит? Незбережені зміни будуть втрачені.", + "save_unsaved_tab": "Do you want to save changes made in this tab?", + "sync": "Ви впевнені, що хочете синхронізувати цю робочу область?", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "Заголовок {count}", + "message": "Повідомлення {count}", + "parameter": "Параметр {count}", + "protocol": "Протокол {count}", + "value": "Значення {count}", + "variable": "Змінна {count}" + }, + "documentation": { + "generate": "Створення документації", + "generate_message": "Імпортуйте будь-яку колекцію Hoppscotch для автоматичного створення API-документації на ходу." + }, + "empty": { + "authorization": "У цьому запиті не використовується авторизація", + "body": "Цей запит не має тіла", + "collection": "Колекція порожня", + "collections": "Колекції порожні", + "documentation": "Під'єднайтесь до кінцевої точки GraphQL для перегляду документації", + "endpoint": "Кінцева точка не може бути порожньою", + "environments": "Середовища порожні", + "folder": "Папка порожня", + "headers": "Цей запит не має заголовків", + "history": "Історія порожня", + "invites": "Список запрошень порожній", + "members": "Команда порожня", + "parameters": "Цей запит не має жодних параметрів", + "pending_invites": "Немає запрошень в очікуванні для цієї команди", + "profile": "Увійдіть для перегляду вашого профілю", + "protocols": "Протоколи порожні", + "request_variables": "This request does not have any request variables", + "schema": "Підключіться до кінцевої точки GraphQL", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "Підписки порожні", + "team_name": "Назва команди порожня", + "teams": "Команди порожні", + "tests": "Для цього запиту немає тестів", + "access_tokens": "Access tokens are empty", + "shortcodes": "Короткі коди порожні" + }, + "environment": { + "add_to_global": "Додати до Глобального середовища", + "added": "Додавання середовища", + "create_new": "Створити нове середовище", + "created": "Середовище створено", + "deleted": "Видалення середовища", + "duplicated": "Environment duplicated", + "edit": "Редагувати середовище", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create a environment", + "invalid_name": "Укажіть дійсну назву середовища", + "list": "Environment variables", + "my_environments": "Мої середовища", + "name": "Name", + "nested_overflow": "вкладені змінні середовища обмежені до 10 рівнів", + "new": "Нове середовище", + "no_active_environment": "No active environment", + "no_environment": "Жодного середовища", + "no_environment_description": "Не було обрано жодного середовища. Виберіть, що робити з наступними змінними.", + "quick_peek": "Environment Quick Peek", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "Виберіть середовище", + "set": "Set environment", + "set_as_environment": "Set as environment", + "team_environments": "Командні середовища", + "title": "Середовища", + "updated": "Оновлення середовища", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Список змінних", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "Схоже, цей браузер не підтримує події, надіслані сервером.", + "check_console_details": "Детальніше перевірте журнал консолі.", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "cURL неправильно форматовано", + "danger_zone": "Небезпечна зона", + "delete_account": "Ваш обліковий запис на разі володіє цими командами:", + "delete_account_description": "Ви повинні або видалити себе, або передати право власності, або видалити ці команди, перш ніж ви зможете видалити свій обліковий запис.", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "Пуста назва запиту", + "f12_details": "(F12 для деталей)", + "gql_prettify_invalid_query": "Не вдалося попередньо визначити недійсний запит, вирішити синтаксичні помилки запиту та повторити спробу", + "incomplete_config_urls": "Неповні конфігураційні адреси", + "incorrect_email": "Невірна електронна адреса", + "invalid_link": "Невірне посилання", + "invalid_link_description": "Посилання, яке ви натиснули, є недійсним або застарілим.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "Неправильний JSON", + "json_prettify_invalid_body": "Не вдалося заздалегідь визначити недійсне тіло, вирішити синтаксичні помилки json і повторити спробу", + "network_error": "Здається, виникла мережева помилка. Будь ласка, спробуйте ще раз.", + "network_fail": "Не вдалося надіслати запит", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "Без тривалості", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "Збігів не знайдено", + "page_not_found": "Ця сторінка не знайдена", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "Не вдалося виконати сценарій попереднього запиту", + "something_went_wrong": "Щось пішло не так", + "post_request_script_fail": "Не вдалося виконати скрипт після запиту", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "Експортувати як JSON", + "create_secret_gist": "Створити секретний GitHub Gist", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "Увійдіть за допомогою GitHub, щоб створити секретний Gist", + "title": "Експортувати", + "success": "Successfully exported", + "gist_created": "Gist створений" + }, + "filter": { + "all": "Всі", + "none": "Жодного", + "starred": "З Зірочкою" + }, + "folder": { + "created": "Папка створена", + "edit": "Редагувати папку", + "invalid_name": "Укажіть назву папки", + "name_length_insufficient": "Ім'я папки повинне містити принаймні 3 символи", + "new": "Нова папка", + "renamed": "Папка перейменована" + }, + "graphql": { + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "mutations": "Мутації", + "schema": "Схема", + "subscriptions": "Підписки", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "Час", + "url": "URL" + }, + "header": { + "install_pwa": "Встановити додаток", + "login": "Увійти", + "save_workspace": "Зберегти мою робочу область" + }, + "helpers": { + "authorization": "Заголовок авторизації буде автоматично сформований під час надсилання запиту.", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "Спочатку згенеруйте документацію", + "network_fail": "Не вдається зв'язатися з кінцевою точкою API. Перевірте підключення до мережі та повторіть спробу.", + "offline": "Ви, здається, не в мережі. Дані в цій робочій області можуть бути не актуальними.", + "offline_short": "Ви, здається, не в мережі.", + "post_request_tests": "Тестові скрипти записуються на JavaScript і запускаються після отримання відповіді.", + "pre_request_script": "Скрипти написані на JavaScript і запускаються перед надсиланням запиту.", + "script_fail": "Схоже, є збій у скрипті. Перевірте помилку нижче та виправте відповідним чином сценарій.", + "post_request_script_fail": "Здається виникла помилка з тестовим скриптом. Будь ласка, виправте помилки і спробуйте знову", + "post_request_script": "Напишіть тестовий скрипт для автоматизації налагодження." + }, + "hide": { + "collection": "Згорнути панель колекції", + "more": "Приховати більше", + "preview": "Приховати попередній перегляд", + "sidebar": "Приховати бічну панель" + }, + "import": { + "collections": "Імпортувати колекції", + "curl": "Імпортувати cURL", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "Не вдалося імпортувати", + "from_file": "Import from File", + "from_gist": "Імпорт з Gist", + "from_gist_description": "Імпорт з Gist URL", + "from_insomnia": "Імпорт з Insomnia", + "from_insomnia_description": "Імпорт із колекції Insomnia", + "from_json": "Імпорт з Hoppscotch", + "from_json_description": "Імпорт з файлу колекції Hoppscotch", + "from_my_collections": "Імпортувати з моїх колекцій", + "from_my_collections_description": "Імпортувати з мого файлу колекцій", + "from_openapi": "Імпорт з OpenAPI", + "from_openapi_description": "Імпорт з файлу специфікації OpenAPI (YML/JSON)", + "from_postman": "Імпортувати з Postman", + "from_postman_description": "Імпорт із колекції Postman", + "from_url": "Імпорт з URL", + "gist_url": "Введіть URL-адресу Gist", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "Не вдалося отримати дані з url", + "import_from_url_invalid_file_format": "Помилка при імпорті колекцій", + "import_from_url_invalid_type": "Непідтримуваний тип. Допустимими значеннями є 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Колекції імпортовано", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Імпортувати колекції з колекцій Hoppscotch JSON файлу", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "Імпортувати", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled." + } + }, + "layout": { + "collapse_collection": "Згорнути або розширити колекції", + "collapse_sidebar": "Згорнути або розширити бічну панель", + "column": "Вертикальне розташування", + "name": "Макет", + "row": "Горизонтальне розташування" + }, + "modal": { + "close_unsaved_tab": "You have unsaved changes", + "collections": "Колекції", + "confirm": "Підтвердити", + "customize_request": "Customize Request", + "edit_request": "Редагувати запит", + "import_export": "Імпорт-експорт", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "Ви вже підписані на цю тему.", + "clean_session": "Очистити сесію.", + "clear_input": "Очистити вхідні дані", + "clear_input_on_send": "Очистити вхідні дані після надсилання", + "client_id": "ID Клієнта", + "color": "Оберіть колір", + "communication": "Комунікації", + "connection_config": "Налаштування підключення", + "connection_not_authorized": "Це MQTT-з'єднання не використовує автентифікацію.", + "invalid_topic": "Будь ласка, вкажіть тему для підписки", + "keep_alive": "Keep Alive", + "log": "Журнал", + "lw_message": "Last-Will Message", + "lw_qos": "Last-Will QoS", + "lw_retain": "Last-Will Retain", + "lw_topic": "Last-Will Topic", + "message": "повідомлення", + "new": "Нова Підписка", + "not_connected": "Будь ласка, спочатку створіть з'єднання MQTT.", + "publish": "Опублікувати", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Підписатись", + "topic": "Тема", + "topic_name": "Назва теми", + "topic_title": "Опублікувати / підписатися на тему", + "unsubscribe": "Скасувати підписку", + "url": "URL" + }, + "navigation": { + "doc": "Документи", + "graphql": "GraphQL", + "profile": "Профіль", + "realtime": "Реальний час", + "rest": "REST", + "settings": "Налаштування" + }, + "preRequest": { + "javascript_code": "Код JavaScript", + "learn": "Прочитати документацію", + "script": "Сценарій попереднього запиту", + "snippets": "Сніпети" + }, + "profile": { + "app_settings": "Параметри програми", + "default_hopp_displayname": "Користувач без імені", + "editor": "Редактор", + "editor_description": "Редактори можуть додавати, змінювати та видаляти запити.", + "email_verification_mail": "Лист для підтвердження електронної пошти було надіслано на вашу електронну адресу. Будь ласка, натисніть на посилання для підтвердження своєї адреси електронної пошти.", + "no_permission": "Ви не маєте дозволу на виконання цієї дії.", + "owner": "Власник", + "owner_description": "Власники можуть додавати, змінювати та видаляти запити, колекції та учасників команди.", + "roles": "Ролі", + "roles_description": "Ролі використовуються для керування доступом до спільних колекцій.", + "updated": "Профіль оновлено", + "viewer": "Переглядач", + "viewer_description": "Переглядачі можуть переглядати й використовувати лише запити." + }, + "remove": { + "star": "Видалити зірочку" + }, + "request": { + "added": "Запит додано", + "authorization": "Авторизація", + "body": "Орган запиту", + "choose_language": "Виберіть мову", + "content_type": "Тип вмісту", + "content_type_titles": { + "others": "Інші", + "structured": "Структуровано", + "text": "Текст" + }, + "different_collection": "Cannot reorder requests from different collections", + "duplicated": "Request duplicated", + "duration": "Тривалість", + "enter_curl": "Введіть cURL", + "generate_code": "Сформувати код", + "generated_code": "Сформований код", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "Список заголовків", + "invalid_name": "Будь ласка, вкажіть назву запиту", + "method": "Метод", + "moved": "Request moved", + "name": "Назва запиту", + "new": "Новий запит", + "order_changed": "Request Order Updated", + "override": "Перевизначити", + "override_help": "Встановити тип вмісту в заголовках", + "overriden": "Перевизначений", + "parameter_list": "Параметри запиту", + "parameters": "Параметри", + "path": "Шлях", + "payload": "Корисне навантаження", + "query": "Запит", + "raw_body": "Сировина запиту", + "rename": "Rename Request", + "renamed": "Запит перейменовано", + "request_variables": "Request variables", + "run": "Біжи", + "save": "Зберегти", + "save_as": "Зберегти як", + "saved": "Запит збережено", + "share": "Поділитися", + "share_description": "Поділіться Hoppscotch зі своїми друзями", + "share_request": "Share Request", + "stop": "Stop", + "title": "Запит", + "type": "Тип запиту", + "url": "URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "Змінні", + "view_my_links": "Переглянути мої посилання", + "copy_link": "Скопіювати посилання" + }, + "response": { + "audio": "Audio", + "body": "Орган реагування", + "filter_response_body": "Фільтр тіла відповідей JSON (використовує синтаксис jq)", + "headers": "Заголовки", + "html": "HTML", + "image": "Зображення", + "json": "JSON", + "pdf": "PDF", + "preview_html": "Попередній перегляд HTML", + "raw": "Сирий", + "size": "Розмір", + "status": "Статус", + "time": "Час", + "title": "Відповідь", + "video": "Video", + "waiting_for_connection": "очікування підключення", + "xml": "XML" + }, + "settings": { + "accent_color": "Колір акценту", + "account": "Обліковий запис", + "account_deleted": "Ваш обліковий запис успішно видалено.", + "account_description": "Налаштуйте налаштування свого облікового запису.", + "account_email_description": "Ваша основна електронна адреса.", + "account_name_description": "Це ваше відображуване ім'я.", + "additional": "Additional Settings", + "background": "Колір фону", + "black_mode": "Чорний", + "choose_language": "Виберіть мову", + "dark_mode": "Темний", + "delete_account": "Видалити обліковий запис", + "delete_account_description": "Якщо Ви видалите обліковий запис, усі дані будуть втрачені без можливості їх відновлення.", + "expand_navigation": "Розгорнути навігацію", + "experiments": "Експерименти", + "experiments_notice": "Це збірка експериментів, над якими ми працюємо, які можуть виявитися корисними, веселими, обома чи ні. Вони не остаточні і можуть бути нестійкими, тому, якщо трапиться щось надто дивне, не панікуйте. Просто вимкніть небезпеку. Жарти в сторону,", + "extension_ver_not_reported": "Не повідомляється", + "extension_version": "Версія розширення", + "extensions": "Розширення", + "extensions_use_toggle": "Використовуйте розширення браузера для надсилання запитів (якщо вони є)", + "follow": "Слідуйте за нами", + "interceptor": "Перехоплювач", + "interceptor_description": "Проміжне програмне забезпечення між додатками та API.", + "language": "Мова", + "light_mode": "Світло", + "official_proxy_hosting": "Офіційний проксі розміщений компанією Hoppscotch.", + "profile": "Профіль", + "profile_description": "Оновити дані вашого профілю", + "profile_email": "Електронна адреса", + "profile_name": "Ім'я профілю", + "proxy": "Проксі", + "proxy_url": "URL проксі", + "proxy_use_toggle": "Для надсилання запитів використовуйте проксі -сервер", + "read_the": "Читати", + "reset_default": "Скинути налаштування за замовчуванням", + "short_codes": "Короткі коди", + "short_codes_description": "Короткі коди, які були створені вами.", + "sidebar_on_left": "Бічна панель зліва", + "sync": "Синхронізувати", + "sync_collections": "Колекції", + "sync_description": "Ці налаштування синхронізуються з хмарою.", + "sync_environments": "Середовища", + "sync_history": "Історія", + "system_mode": "Система", + "telemetry": "Телеметрія", + "telemetry_helps_us": "Телеметрія допомагає нам персоналізувати наші операції та забезпечити вам найкращий досвід.", + "theme": "Тема", + "theme_description": "Налаштуйте тему програми.", + "use_experimental_url_bar": "Використовуйте експериментальний рядок URL з виділенням середовища", + "user": "Користувач", + "verified_email": "Підтверджений email", + "verify_email": "Підтвердити електронну адресу" + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Закрити поточне меню", + "command_menu": "Меню пошуку та команд", + "help_menu": "Меню довідки", + "show_all": "Гарячі клавіши", + "title": "Загальні" + }, + "miscellaneous": { + "invite": "Запросіть людей до Hoppscotch", + "title": "Різне" + }, + "navigation": { + "back": "Повернутися на попередню сторінку", + "documentation": "Перейдіть на сторінку Документація", + "forward": "Перейти до наступної сторінки", + "graphql": "Перейдіть на сторінку GraphQL", + "profile": "Перейти на сторінку профілю", + "realtime": "Перейдіть на сторінку реального часу", + "rest": "Перейдіть на сторінку REST", + "settings": "Перейдіть на сторінку Налаштування", + "title": "Навігація" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "Виберіть метод ВИДАЛИТИ", + "get_method": "Виберіть метод GET", + "head_method": "Виберіть метод HEAD", + "import_curl": "Import cURL", + "method": "Метод", + "next_method": "Виберіть наступний метод", + "post_method": "Виберіть метод POST", + "previous_method": "Виберіть Попередній метод", + "put_method": "Виберіть метод PUT", + "rename": "Rename Request", + "reset_request": "Скинути запит", + "save_request": "Save Request", + "save_to_collections": "Зберегти в колекції", + "send_request": "Відправляти запит", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "Запит", + "copy_request_link": "Скопіювати посилання на запит" + }, + "response": { + "copy": "Скопіювати відповідь в буфер обміну", + "download": "Завантажити відповідь як файл", + "title": "Відповідь" + }, + "theme": { + "black": "Переключити тему в чорний режим", + "dark": "Переключити тему в темний режим", + "light": "Переключити тему в світлий режим", + "system": "Переключити тему в системний режим", + "title": "Тема" + } + }, + "show": { + "code": "Показати код", + "collection": "Розгорнути панель колекції", + "more": "Показати більше", + "sidebar": "Показати бічну панель" + }, + "socketio": { + "communication": "Спілкування", + "connection_not_authorized": "Це SocketIO з'єднання не використовує жодної автентифікації.", + "event_name": "Назва події", + "events": "Події", + "log": "Журнал", + "url": "URL" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current team", + "edit": "Edit current team", + "invite": "Invite people to team", + "new": "Create new team", + "switch_to_personal": "Switch to your personal workspace", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "Тип події", + "log": "Журнал", + "url": "URL" + }, + "state": { + "bulk_mode": "Масове редагування", + "bulk_mode_placeholder": "Записи розділені новим рядком\nКлючі та значення розділені:\nДодати # до будь -якого рядка, який потрібно додати, але залишити вимкненим", + "cleared": "Очищено", + "connected": "Підключено", + "connected_to": "Підключено до {name}", + "connecting_to": "Під'єднання до {name} ...", + "connection_error": "Не вдалося з’єднатися", + "connection_failed": "Помилка зʼєднання", + "connection_lost": "З'єднання втрачено", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "Скопійовано в буфер обміну", + "deleted": "Видалено", + "deprecated": "ЗНИЖЕНО", + "disabled": "Інвалід", + "disconnected": "Відключено", + "disconnected_from": "Від'єднано від {name}", + "docs_generated": "Сформована документація", + "download_failed": "Download failed", + "download_started": "Завантаження розпочато", + "enabled": "Увімкнено", + "file_imported": "Файл імпортовано", + "finished_in": "Завершено за {duration} мс", + "hide": "Hide", + "history_deleted": "Історію видалено", + "linewrap": "Лінії загортання", + "loading": "Завантаження ...", + "message_received": "Повідомлення: {message} отримано по темі: {topic}", + "mqtt_subscription_failed": "Щось пішло не так при підписці на тему: {topic}", + "none": "Жодного", + "nothing_found": "Нічого не знайдено", + "published_error": "Щось пішло не так під час публікації повідомлення: {topic} на тему: {message}", + "published_message": "Опубліковане повідомлення: {message} до теми: {topic}", + "reconnection_error": "Не вдалося перепід'єднатися", + "show": "Show", + "subscribed_failed": "Не вдалося підписатися на тему: {topic}", + "subscribed_success": "Успішно підписано на тему: {topic}", + "unsubscribed_failed": "Не вдалося відписатися від теми: {topic}", + "unsubscribed_success": "Успішно відписано від теми: {topic}", + "waiting_send_request": "Очікується надсилання запиту" + }, + "support": { + "changelog": "Докладніше про останні випуски", + "chat": "Питання? Спілкуйтеся з нами!", + "community": "Задавайте питання та допомагайте іншим", + "documentation": "Детальніше про Hoppscotch", + "forum": "Задавайте питання і отримуйте відповіді", + "github": "Слідкуйте за нами на Github", + "shortcuts": "Швидше переглядайте програми", + "title": "Підтримка", + "twitter": "Слідкуйте за нами у Twitter", + "team": "Зв'яжіться з командою" + }, + "tab": { + "authorization": "Авторизація", + "body": "Тіло", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "Колекції", + "documentation": "Документація", + "duplicate": "Duplicate Tab", + "environments": "Environments", + "headers": "Заголовки", + "history": "Історія", + "mqtt": "MQTT", + "parameters": "Параметри", + "pre_request_script": "Сценарій попереднього запиту", + "queries": "Запити", + "query": "Запит", + "schema": "Схема", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Тести", + "types": "Типи", + "variables": "Змінні", + "websocket": "Веб-сокет" + }, + "team": { + "already_member": "Ви вже є членом цієї команди. Зв'яжіться з власником команди.", + "create_new": "Створіть нову команду", + "deleted": "Команда видалена", + "edit": "Редагувати команду", + "email": "Електронна пошта", + "email_do_not_match": "Електронна пошта не відповідає відомостям вашого облікового запису. Зверніться до свого власника команди.", + "exit": "Вийти з команди", + "exit_disabled": "Вийти з команди не може тільки власник", + "failed_invites": "Failed invites", + "invalid_coll_id": "Invalid collection ID", + "invalid_email_format": "Формат електронної пошти недійсний", + "invalid_id": "Недійсний ID команди. Зв'яжіться з власником вашої команди.", + "invalid_invite_link": "Неправильне посилання для запрошення", + "invalid_invite_link_description": "Посилання, за яким ви перейшли, недійсне. Зверніться до власника вашої команди.", + "invalid_member_permission": "Надайте дійсний дозвіл члену команди", + "invite": "Запросити", + "invite_more": "Запросити більше", + "invite_tooltip": "Запросити людей в це робоче середовище", + "invited_to_team": "{owner} запросив вас приєднатися до {team}", + "join": "Запрошення прийнято", + "join_team": "Приєднатися до {team}", + "joined_team": "Ви приєдналися до {team}", + "joined_team_description": "Ви тепер учасник цієї команди", + "left": "Ви покинули команду", + "login_to_continue": "Увійдіть щоб продовжити", + "login_to_continue_description": "Ви повинні увійти в систему, щоб приєднатися до команди.", + "logout_and_try_again": "Вийти і ввійти в систему за допомогою іншого облікового запису", + "member_has_invite": "Цей email вже має запрошення. Зв'яжіться з власником команди.", + "member_not_found": "Учасника не знайдено. Зв’яжіться з власником команди.", + "member_removed": "Користувача видалено", + "member_role_updated": "Оновлено ролі користувачів", + "members": "Члени", + "more_members": "+{count} more", + "name_length_insufficient": "Назва команди має містити щонайменше 6 символів", + "name_updated": "Назва команди оновлено", + "new": "Нова команда", + "new_created": "Створена нова команда", + "new_name": "Моя нова команда", + "no_access": "Ви не маєте доступу до редагування цих колекцій", + "no_invite_found": "Запрошення не знайдено. Зв’яжіться з власником команди.", + "no_request_found": "Request not found.", + "not_found": "Команда не знайдена. Зв'яжіться з власником команди.", + "not_valid_viewer": "Ви не є дійсним глядачем. Зв'яжіться з власником команди.", + "parent_coll_move": "Cannot move collection to a child collection", + "pending_invites": "Очікувані запрошення", + "permissions": "Дозволи", + "same_target_destination": "Same target and destination", + "saved": "Команда збережена", + "select_a_team": "Виберіть команду", + "success_invites": "Success invites", + "title": "Команди", + "we_sent_invite_link": "Ми надіслали запрошення всім запрошеним!", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "Попросіть всіх запрошених перевірити свої поштові скриньки. Перейдіть за посиланням, щоб приєднатися до команди.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "Приєднуйтесь до бета -програми, щоб отримати доступ до команд." + }, + "team_environment": { + "deleted": "Середовище видалено", + "duplicate": "Середовище дубльовано", + "not_found": "Середовище не знайдено." + }, + "test": { + "failed": "Помилка тесту", + "javascript_code": "Код JavaScript", + "learn": "Прочитайте документацію", + "passed": "Тест пройдено", + "report": "Протокол випробування", + "results": "Результати тесту", + "script": "Скрипти", + "snippets": "Сніпети" + }, + "websocket": { + "communication": "Спілкування", + "log": "Журнал", + "message": "повідомлення", + "protocols": "Протоколи", + "url": "URL" + }, + "workspace": { + "change": "Change workspace", + "personal": "My Workspace", + "other_workspaces": "My Workspaces", + "team": "Team Workspace", + "title": "Workspaces" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "Дії", + "created_on": "Дата створення", + "deleted": "Короткий код видалено", + "method": "Метод", + "not_found": "Короткий код не знайдено", + "short_code": "Короткий код", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/locales/vi.json b/packages/hoppscotch-common/locales/vi.json new file mode 100644 index 0000000..2e65e25 --- /dev/null +++ b/packages/hoppscotch-common/locales/vi.json @@ -0,0 +1,1106 @@ +{ + "action": { + "add": "Add", + "autoscroll": "Tự động cuộn", + "cancel": "Hủy", + "choose_file": "Chọn tệp", + "clear": "Xóa", + "clear_all": "Xóa tất cả", + "clear_history": "Clear all History", + "close": "Đóng", + "connect": "Kết nối", + "connecting": "Đang kết nối", + "copy": "Sao chép", + "create": "Create", + "delete": "Xóa", + "disconnect": "Ngắt kết nối", + "dismiss": "Bỏ qua", + "dont_save": "Không lưu", + "download_file": "Tải xuống tệp", + "drag_to_reorder": "Kéo để sắp xếp lại", + "duplicate": "Nhân bản", + "edit": "Chỉnh sửa", + "filter": "Lọc", + "go_back": "Quay lại", + "go_forward": "Tiến về phía trước", + "group_by": "Nhóm theo", + "hide_secret": "Hide secret", + "label": "Nhãn", + "learn_more": "Tìm hiểu thêm", + "download_here": "Download here", + "less": "Ít hơn", + "more": "Thêm", + "new": "Mới", + "no": "Không", + "open_workspace": "Mở không gian làm việc", + "paste": "Dán", + "prettify": "Làm đẹp", + "properties": "Properties", + "remove": "Xóa", + "rename": "Rename", + "restore": "Khôi phục", + "save": "Lưu", + "scroll_to_bottom": "Cuộn xuống cuối", + "scroll_to_top": "Cuộn lên đầu", + "search": "Tìm kiếm", + "send": "Gửi", + "share": "Share", + "show_secret": "Show secret", + "start": "Bắt đầu", + "starting": "Đang bắt đầu", + "stop": "Dừng", + "to_close": "để đóng", + "to_navigate": "để điều hướng", + "to_select": "để chọn", + "turn_off": "Tắt", + "turn_on": "Bật", + "undo": "Hoàn tác", + "yes": "Có" + }, + "add": { + "new": "Thêm mới", + "star": "Thêm sao" + }, + "app": { + "chat_with_us": "Trò chuyện với chúng tôi", + "contact_us": "Liên hệ chúng tôi", + "cookies": "Cookies", + "copy": "Sao chép", + "copy_interface_type": "Copy interface type", + "copy_user_id": "Sao chép User Auth Token", + "developer_option": "Tùy chọn nhà phát triển", + "developer_option_description": "Công cụ nhà phát triển giúp phát triển và bảo trì Hoppscotch.", + "discord": "Discord", + "documentation": "Tài liệu", + "github": "GitHub", + "help": "Trợ giúp và phản hồi", + "home": "Trang chủ", + "invite": "Mời", + "invite_description": "Hoppscotch là một hệ sinh thái phát triển API mã nguồn mở. Chúng tôi thiết kế một giao diện đơn giản và trực quan để tạo và quản lý API của bạn. Hoppscotch là một công cụ giúp bạn xây dựng, kiểm tra, tài liệu và chia sẻ API của mình.", + "invite_your_friends": "Mời bạn bè của bạn", + "join_discord_community": "Tham gia cộng đồng Discord của chúng tôi", + "keyboard_shortcuts": "Phím tắt bàn phím", + "name": "Hoppscotch", + "new_version_found": "Phát hiện phiên bản mới. Làm mới để cập nhật.", + "open_in_hoppscotch": "Open in Hoppscotch", + "options": "Tùy chọn", + "proxy_privacy_policy": "Chính sách bảo mật Proxy", + "reload": "Tải lại", + "search": "Tìm kiếm", + "share": "Chia sẻ", + "shortcuts": "Phím tắt", + "social_description": "Follow us on social media to stay updated with the latest news, updates and releases.", + "social_links": "Social links", + "spotlight": "Spotlight", + "status": "Trạng thái", + "status_description": "Kiểm tra trạng thái của trang web", + "terms_and_privacy": "Điều khoản và quyền riêng tư", + "twitter": "Twitter", + "type_a_command_search": "Nhập một lệnh hoặc tìm kiếm...", + "we_use_cookies": "Chúng tôi sử dụng cookie", + "updated_text": "Hoppscotch has been updated to v{version} 🎉", + "whats_new": "Có gì mới?", + "see_whats_new": "See what’s new", + "wiki": "Wiki" + }, + "auth": { + "account_exists": "Tài khoản đã tồn tại với thông tin đăng nhập khác - Đăng nhập để liên kết hai tài khoản", + "all_sign_in_options": "Tất cả các tùy chọn đăng nhập", + "continue_with_auth_provider": "Continue with {provider}", + "continue_with_email": "Tiếp tục bằng Email", + "continue_with_github": "Tiếp tục bằng GitHub", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", + "continue_with_google": "Tiếp tục bằng Google", + "continue_with_microsoft": "Tiếp tục bằng Microsoft", + "email": "Email", + "logged_out": "Đã đăng xuất", + "login": "Đăng nhập", + "login_success": "Đăng nhập thành công", + "login_to_hoppscotch": "Đăng nhập vào Hoppscotch", + "logout": "Đăng xuất", + "re_enter_email": "Nhập lại email", + "send_magic_link": "Gửi magic link", + "sync": "Đồng bộ", + "we_sent_magic_link": "Chúng tôi đã gửi cho bạn một magic link!", + "we_sent_magic_link_description": "Kiểm tra hộp thư đến - chúng tôi đã gửi một email đến {email}. Nó chứa một magic link sẽ giúp bạn đăng nhập." + }, + "authorization": { + "generate_token": "Tạo token", + "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", + "include_in_url": "Bao gồm trong URL", + "inherited_from": "Inherited from {auth} from Parent Collection {collection} ", + "learn": "Tìm hiểu thêm", + "oauth": { + "redirect_auth_server_returned_error": "Auth Server returned an error state", + "redirect_auth_token_request_failed": "Request to get the auth token failed", + "redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token", + "redirect_invalid_state": "Invalid State value present in the redirect", + "redirect_no_auth_code": "No Authorization Code present in the redirect", + "redirect_no_client_id": "No Client ID defined", + "redirect_no_client_secret": "No Client Secret Defined", + "redirect_no_code_verifier": "No Code Verifier Defined", + "redirect_no_token_endpoint": "No Token Endpoint Defined", + "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", + "something_went_wrong_on_token_generation": "Something went wrong on token generation", + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" + }, + "pass_key_by": "Truyền qua", + "pass_by_query_params_label": "Query Parameters", + "pass_by_headers_label": "Headers", + "password": "Mật khẩu", + "save_to_inherit": "Please save this request in any collection to inherit the authorization", + "token": "Token", + "type": "Loại ủy quyền", + "username": "Tên người dùng" + }, + "collection": { + "created": "Tạo bộ sưu tập thành công", + "different_parent": "Không thể sắp xếp lại bộ sưu tập với cha khác", + "edit": "Chỉnh sửa bộ sưu tập", + "import_or_create": "Import or create a collection", + "import_collection": "Import Collection", + "invalid_name": "Vui lòng cung cấp tên cho bộ sưu tập", + "invalid_root_move": "Bộ sưu tập đã nằm trong thư mục gốc", + "moved": "Di chuyển thành công", + "my_collections": "Bộ sưu tập của tôi", + "name": "Bộ sưu tập mới của tôi", + "name_length_insufficient": "Tên bộ sưu tập phải có ít nhất 3 ký tự", + "new": "Bộ sưu tập mới", + "order_changed": "Cập nhật thứ tự bộ sưu tập thành công", + "properties": "Collection Properties", + "properties_updated": "Collection Properties Updated", + "renamed": "Đổi tên bộ sưu tập thành công", + "request_in_use": "Request đang được sử dụng", + "save_as": "Lưu thành", + "save_to_collection": "Save to Collection", + "select": "Chọn một bộ sưu tập", + "select_location": "Chọn vị trí", + "details": "Details", + "select_team": "Chọn một nhóm", + "team_collections": "Bộ sưu tập của nhóm" + }, + "confirm": { + "close_unsaved_tab": "Are you sure you want to close this tab?", + "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", + "exit_team": "Bạn có chắc chắn muốn rời khỏi nhóm này?", + "logout": "Bạn có chắc chắn muốn đăng xuất?", + "remove_collection": "Bạn có chắc chắn muốn xóa vĩnh viễn bộ sưu tập này?", + "remove_environment": "Bạn có chắc chắn muốn xóa vĩnh viễn môi trường này?", + "remove_folder": "Bạn có chắc chắn muốn xóa vĩnh viễn thư mục này?", + "remove_history": "Bạn có chắc chắn muốn xóa vĩnh viễn toàn bộ lịch sử?", + "remove_request": "Bạn có chắc chắn muốn xóa vĩnh viễn request này?", + "remove_shared_request": "Are you sure you want to permanently delete this shared request?", + "remove_team": "Bạn có chắc chắn muốn xóa nhóm này?", + "remove_telemetry": "Bạn có chắc chắn muốn tắt Telemetry?", + "request_change": "Bạn có chắc chắn muốn hủy request hiện tại? Những thay đổi chưa được lưu sẽ bị mất.", + "save_unsaved_tab": "Bạn có muốn lưu các thay đổi đã được thực hiện trong tab này?", + "sync": "Bạn có muốn khôi phục không gian làm việc từ đám mây? Điều này sẽ xóa bỏ tiến trình địa phương của bạn.", + "delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?" + }, + "context_menu": { + "add_parameters": "Add to parameters", + "open_request_in_new_tab": "Open request in new tab", + "set_environment_variable": "Set as variable" + }, + "cookies": { + "modal": { + "cookie_expires": "Expires", + "cookie_name": "Name", + "cookie_path": "Path", + "cookie_string": "Cookie string", + "cookie_value": "Value", + "empty_domain": "Domain is empty", + "empty_domains": "Domain list is empty", + "enter_cookie_string": "Enter cookie string", + "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", + "managed_tab": "Managed", + "new_domain_name": "New domain name", + "no_cookies_in_domain": "No cookies set for this domain", + "raw_tab": "Raw", + "set": "Set a cookie" + } + }, + "count": { + "header": "Tiêu đề {count}", + "message": "Thông điệp {count}", + "parameter": "Tham số {count}", + "protocol": "Giao thức {count}", + "value": "Giá trị {count}", + "variable": "Biến {count}" + }, + "documentation": { + "generate": "Tạo tài liệu", + "generate_message": "Nhập bất kỳ bộ sưu tập Hoppscotch nào để tạo tài liệu API ngay lập tức." + }, + "empty": { + "authorization": "Request này không sử dụng bất kỳ ủy quyền nào", + "body": "Request này không có phần thân", + "collection": "Bộ sưu tập trống rỗng", + "collections": "Danh sách bộ sưu tập trống rỗng", + "documentation": "Kết nối với điểm cuối GraphQL để xem tài liệu", + "endpoint": "Điểm cuối không thể để trống", + "environments": "Danh sách môi trường trống rỗng", + "folder": "Thư mục trống rỗng", + "headers": "Request này không có bất kỳ tiêu đề nào", + "history": "Lịch sử trống rỗng", + "invites": "Danh sách lời mời trống rỗng", + "members": "Nhóm trống rỗng", + "parameters": "Request này không có bất kỳ tham số nào", + "pending_invites": "Không có lời mời đang chờ cho nhóm này", + "profile": "Đăng nhập để xem hồ sơ của bạn", + "protocols": "Danh sách giao thức trống rỗng", + "request_variables": "This request does not have any request variables", + "schema": "Kết nối với điểm cuối GraphQL để xem lược đồ", + "secret_environments": "Secrets are not synced to Hoppscotch", + "shared_requests": "Shared requests are empty", + "shared_requests_logout": "Login to view your shared requests or create a new one", + "subscription": "Danh sách đăng ký trống rỗng", + "team_name": "Tên nhóm trống rỗng", + "teams": "Bạn không thuộc bất kỳ nhóm nào", + "tests": "Không có bài kiểm tra cho request này", + "access_tokens": "Access tokens are empty", + "shortcodes": "Danh sách mã ngắn trống rỗng" + }, + "environment": { + "add_to_global": "Thêm vào Global", + "added": "Thêm môi trường thành công", + "create_new": "Tạo môi trường mới", + "created": "Môi trường đã được tạo", + "deleted": "Xóa môi trường thành công", + "duplicated": "Environment duplicated", + "edit": "Chỉnh sửa môi trường", + "empty_variables": "No variables", + "global": "Global", + "global_variables": "Global variables", + "import_or_create": "Import or create a environment", + "invalid_name": "Vui lòng cung cấp tên cho môi trường", + "list": "Environment variables", + "my_environments": "Môi trường của tôi", + "name": "Name", + "nested_overflow": "Biến môi trường lồng nhau giới hạn tối đa 10 cấp", + "new": "Môi trường mới", + "no_active_environment": "No active environment", + "no_environment": "Không có môi trường", + "no_environment_description": "Không có môi trường nào được chọn. Chọn điều gì để thực hiện với các biến sau đây.", + "quick_peek": "Environment Quick Peek", + "replace_with_variable": "Replace with variable", + "scope": "Scope", + "secrets": "Secrets", + "secret_value": "Secret value", + "select": "Chọn môi trường", + "set": "Set environment", + "set_as_environment": "Set as environment", + "team_environments": "Môi trường nhóm", + "title": "Môi trường", + "updated": "Cập nhật môi trường thành công", + "value": "Value", + "variable": "Variable", + "variables": "Variables", + "variable_list": "Danh sách biến", + "properties": "Environment Properties", + "details": "Details" + }, + "error": { + "authproviders_load_error": "Unable to load auth providers", + "browser_support_sse": "Trình duyệt này không hỗ trợ Server Sent Events.", + "check_console_details": "Kiểm tra log console để biết thêm chi tiết.", + "check_how_to_add_origin": "Check how you can add an origin", + "curl_invalid_format": "cURL không được định dạng đúng", + "danger_zone": "Vùng nguy hiểm", + "delete_account": "Tài khoản của bạn hiện là chủ sở hữu trong các nhóm sau:", + "delete_account_description": "Bạn phải xóa bản thân, chuyển quyền sở hữu hoặc xóa các nhóm này trước khi bạn có thể xóa tài khoản của mình.", + "empty_email_address": "Email Address cannot be empty", + "empty_profile_name": "Profile name cannot be empty", + "empty_req_name": "Tên request trống", + "f12_details": "(Nhấn F12 để xem chi tiết)", + "gql_prettify_invalid_query": "Không thể định dạng đẹp một truy vấn không hợp lệ, giải quyết lỗi cú pháp truy vấn và thử lại", + "incomplete_config_urls": "URL cấu hình không đầy đủ", + "incorrect_email": "Email không chính xác", + "invalid_link": "Liên kết không hợp lệ", + "invalid_link_description": "Liên kết bạn đã nhấp vào không hợp lệ hoặc đã hết hạn.", + "invalid_embed_link": "The embed does not exist or is invalid.", + "json_parsing_failed": "JSON không hợp lệ", + "json_prettify_invalid_body": "Không thể định dạng đẹp một phần thân không hợp lệ, giải quyết lỗi cú pháp JSON và thử lại", + "network_error": "Có vẻ như có lỗi mạng. Vui lòng thử lại.", + "network_fail": "Không thể gửi request", + "no_collections_to_export": "No collections to export. Please create a collection to get started.", + "no_duration": "Không có khoảng thời gian", + "no_environments_to_export": "No environments to export. Please create an environment to get started.", + "no_results_found": "Không tìm thấy kết quả", + "page_not_found": "Không tìm thấy trang này", + "please_install_extension": "Please install the extension and add origin to the extension.", + "proxy_error": "Proxy error", + "same_email_address": "Updated email address is same as the current email address", + "same_profile_name": "Updated profile name is same as the current profile name", + "script_fail": "Không thể thực thi kịch bản trước request", + "something_went_wrong": "Đã xảy ra lỗi", + "post_request_script_fail": "Không thể thực thi kịch bản sau request", + "reading_files": "Error while reading one or more files.", + "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", + "generate_access_token": "Something went wrong while generating the access token", + "delete_access_token": "Something went wrong while deleting the access token" + }, + "export": { + "as_json": "Xuất dưới dạng JSON", + "create_secret_gist": "Tạo Gist bí mật", + "create_secret_gist_tooltip_text": "Export as secret Gist", + "failed": "Something went wrong while exporting", + "secret_gist_success": "Successfully exported as secret Gist", + "require_github": "Đăng nhập bằng GitHub để tạo Gist bí mật", + "title": "Xuất", + "success": "Successfully exported", + "gist_created": "Gist đã được tạo" + }, + "filter": { + "all": "Tất cả", + "none": "Không", + "starred": "Đã đánh dấu" + }, + "folder": { + "created": "Thư mục đã được tạo", + "edit": "Chỉnh sửa Thư mục", + "invalid_name": "Vui lòng cung cấp tên cho thư mục", + "name_length_insufficient": "Tên thư mục phải có ít nhất 3 ký tự", + "new": "Thư mục mới", + "renamed": "Thư mục đã đổi tên" + }, + "graphql": { + "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", + "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "mutations": "Mutations", + "schema": "Schema", + "subscriptions": "Subscriptions", + "switch_connection": "Switch connection", + "url_placeholder": "Enter a GraphQL endpoint URL" + }, + "graphql_collections": { + "title": "GraphQL Collections" + }, + "group": { + "time": "Thời gian", + "url": "URL" + }, + "header": { + "install_pwa": "Cài đặt ứng dụng", + "login": "Đăng nhập", + "save_workspace": "Lưu Không gian làm việc của tôi" + }, + "helpers": { + "authorization": "Tiêu đề ủy quyền sẽ được tự động tạo khi bạn gửi request.", + "collection_properties_authorization": " This authorization will be set for every request in this collection.", + "collection_properties_header": "This header will be set for every request in this collection.", + "generate_documentation_first": "Tạo tài liệu trước", + "network_fail": "Không thể kết nối đến điểm cuối API. Kiểm tra kết nối mạng của bạn hoặc chọn một Interceptor khác và thử lại.", + "offline": "Có vẻ như bạn đang offline. Dữ liệu trong không gian làm việc này có thể không được cập nhật.", + "offline_short": "Có vẻ như bạn đang offline.", + "post_request_tests": "Kịch bản kiểm tra được viết bằng JavaScript và được chạy sau khi nhận phản hồi.", + "pre_request_script": "Kịch bản trước request được viết bằng JavaScript và được chạy trước khi gửi request.", + "script_fail": "Có vẻ như có lỗi trong kịch bản trước request. Kiểm tra lỗi bên dưới và sửa kịch bản tương ứng.", + "post_request_script_fail": "Có vẻ như có lỗi trong kịch bản kiểm tra. Vui lòng sửa các lỗi và chạy lại kiểm tra", + "post_request_script": "Viết một kịch bản kiểm tra để tự động hóa việc gỡ lỗi." + }, + "hide": { + "collection": "Thu gọn bảng điều khiển bộ sưu tập", + "more": "Ẩn thêm", + "preview": "Ẩn Xem trước", + "sidebar": "Thu gọn thanh bên" + }, + "import": { + "collections": "Nhập bộ sưu tập", + "curl": "Nhập cURL", + "environments_from_gist": "Import From Gist", + "environments_from_gist_description": "Import Hoppscotch Environments From Gist", + "failed": "Lỗi khi nhập: định dạng không được nhận dạng", + "from_file": "Import from File", + "from_gist": "Nhập từ Gist", + "from_gist_description": "Nhập từ đường dẫn Gist", + "from_insomnia": "Nhập từ Insomnia", + "from_insomnia_description": "Nhập từ bộ sưu tập Insomnia", + "from_json": "Nhập từ Hoppscotch", + "from_json_description": "Nhập từ tệp bộ sưu tập Hoppscotch", + "from_my_collections": "Nhập từ bộ sưu tập của tôi", + "from_my_collections_description": "Nhập từ tệp bộ sưu tập của tôi", + "from_openapi": "Nhập từ OpenAPI", + "from_openapi_description": "Nhập từ tệp đặc tả OpenAPI (YML/JSON)", + "from_postman": "Nhập từ Postman", + "from_postman_description": "Nhập từ bộ sưu tập Postman", + "from_url": "Nhập từ URL", + "gist_url": "Nhập URL Gist", + "gql_collections_from_gist_description": "Import GraphQL Collections From Gist", + "hoppscotch_environment": "Hoppscotch Environment", + "hoppscotch_environment_description": "Import Hoppscotch Environment JSON file", + "import_from_url_invalid_fetch": "Không thể lấy dữ liệu từ URL", + "import_from_url_invalid_file_format": "Lỗi khi nhập bộ sưu tập", + "import_from_url_invalid_type": "Loại không được hỗ trợ. Giá trị chấp nhận được là 'hoppscotch', 'openapi', 'postman', 'insomnia'", + "import_from_url_success": "Bộ sưu tập đã được nhập", + "insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file", + "json_description": "Nhập bộ sưu tập từ tệp JSON Hoppscotch", + "postman_environment": "Postman Environment", + "postman_environment_description": "Import Postman Environment from a JSON file", + "title": "Nhập", + "file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported", + "file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.", + "success": "Successfully imported" + }, + "inspections": { + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request.", + "network_error": "Please check your network connection." + }, + "title": "Inspector", + "url": { + "extension_not_installed": "Extension not installed.", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", + "extention_enable_action": "Enable Browser Extension", + "extention_not_enabled": "Extension not enabled." + } + }, + "layout": { + "collapse_collection": "Thu gọn hoặc mở rộng bộ sưu tập", + "collapse_sidebar": "Thu gọn hoặc mở rộng thanh bên", + "column": "Bố cục dọc", + "name": "Bố cục", + "row": "Bố cục ngang" + }, + "modal": { + "close_unsaved_tab": "Bạn có các thay đổi chưa được lưu", + "collections": "Bộ sưu tập", + "confirm": "Xác nhận", + "customize_request": "Customize Request", + "edit_request": "Chỉnh sửa request", + "import_export": "Nhập / Xuất", + "share_request": "Share Request" + }, + "mqtt": { + "already_subscribed": "Bạn đã đăng ký theo dõi chủ đề này.", + "clean_session": "Phiên làm việc sạch", + "clear_input": "Xóa đầu vào", + "clear_input_on_send": "Xóa đầu vào sau khi gửi", + "client_id": "ID Khách hàng", + "color": "Chọn màu", + "communication": "Giao tiếp", + "connection_config": "Cấu hình Kết nối", + "connection_not_authorized": "Kết nối MQTT này không sử dụng xác thực nào.", + "invalid_topic": "Vui lòng cung cấp một chủ đề cho việc đăng ký theo dõi", + "keep_alive": "Giữ Kết nối", + "log": "Nhật ký", + "lw_message": "Tin nhắn Last-Will", + "lw_qos": "QoS Last-Will", + "lw_retain": "Giữ Last-Will", + "lw_topic": "Chủ đề Last-Will", + "message": "Tin nhắn", + "new": "Đăng ký mới", + "not_connected": "Vui lòng bắt đầu một kết nối MQTT trước.", + "publish": "Gửi", + "qos": "QoS", + "ssl": "SSL", + "subscribe": "Theo dõi", + "topic": "Chủ đề", + "topic_name": "Tên chủ đề", + "topic_title": "Chủ đề Xuất bản/Theo dõi", + "unsubscribe": "Hủy theo dõi", + "url": "URL" + }, + "navigation": { + "doc": "Tài liệu", + "graphql": "GraphQL", + "profile": "Hồ sơ", + "realtime": "Thời gian thực", + "rest": "REST", + "settings": "Cài đặt" + }, + "preRequest": { + "javascript_code": "Mã JavaScript", + "learn": "Đọc tài liệu", + "script": "Kịch bản trước request", + "snippets": "Mẫu mã" + }, + "profile": { + "app_settings": "Cài đặt ứng dụng", + "default_hopp_displayname": "Người dùng Không tên", + "editor": "Biên tập viên", + "editor_description": "Biên tập viên có thể thêm, chỉnh sửa và xóa request.", + "email_verification_mail": "Một email xác minh đã được gửi đến địa chỉ email của bạn. Vui lòng nhấp vào liên kết để xác minh địa chỉ email của bạn.", + "no_permission": "Bạn không có quyền thực hiện hành động này.", + "owner": "Chủ sở hữu", + "owner_description": "Chủ sở hữu có thể thêm, chỉnh sửa và xóa request, bộ sưu tập và thành viên nhóm.", + "roles": "Vai trò", + "roles_description": "Vai trò được sử dụng để kiểm soát quyền truy cập vào các bộ sưu tập được chia sẻ.", + "updated": "Hồ sơ đã được cập nhật", + "viewer": "Người xem", + "viewer_description": "Người xem chỉ có thể xem và sử dụng request." + }, + "remove": { + "star": "Xóa dấu sao" + }, + "request": { + "added": "Request đã được thêm", + "authorization": "Xác thực", + "body": "Nội dung request", + "choose_language": "Chọn ngôn ngữ", + "content_type": "Kiểu nội dung", + "content_type_titles": { + "others": "Khác", + "structured": "Cấu trúc", + "text": "Văn bản" + }, + "different_collection": "Không thể sắp xếp lại các request từ các bộ sưu tập khác nhau", + "duplicated": "Request đã được nhân bản", + "duration": "Thời lượng", + "enter_curl": "Nhập lệnh cURL", + "generate_code": "Tạo mã", + "generated_code": "Mã được tạo", + "go_to_authorization_tab": "Go to Authorization tab", + "go_to_body_tab": "Go to Body tab", + "header_list": "Danh sách header", + "invalid_name": "Vui lòng cung cấp một tên cho request", + "method": "Phương thức", + "moved": "Request đã được di chuyển", + "name": "Tên", + "new": "Request mới", + "order_changed": "Cập nhật thứ tự requests", + "override": "Ghi đè", + "override_help": "Đặt Content-Type trong headers", + "overriden": "Đã ghi đè", + "parameter_list": "Danh sách tham số truy vấn", + "parameters": "Tham số", + "path": "Đường dẫn", + "payload": "Dữ liệu", + "query": "Truy vấn", + "raw_body": "Nội dung thô", + "rename": "Rename Request", + "renamed": "Đã đổi tên", + "request_variables": "Request variables", + "run": "Chạy", + "save": "Lưu", + "save_as": "Lưu như", + "saved": "Đã lưu", + "share": "Chia sẻ", + "share_description": "Chia sẻ Hoppscotch với bạn bè của bạn", + "share_request": "Share Request", + "stop": "Stop", + "title": "Request", + "type": "Loại request", + "url": "URL", + "url_placeholder": "Enter a URL or paste a cURL command", + "variables": "Biến", + "view_my_links": "Xem các liên kết của tôi", + "copy_link": "Sao chép liên kết" + }, + "response": { + "audio": "Âm thanh", + "body": "Nội dung Phản hồi", + "filter_response_body": "Lọc nội dung phản hồi JSON (sử dụng cú pháp jq)", + "headers": "Headers", + "html": "HTML", + "image": "Hình ảnh", + "json": "JSON", + "pdf": "PDF", + "preview_html": "Xem trước HTML", + "raw": "Thô", + "size": "Kích thước", + "status": "Trạng thái", + "time": "Thời gian", + "title": "Phản hồi", + "video": "Video", + "waiting_for_connection": "đang chờ kết nối", + "xml": "XML" + }, + "settings": { + "accent_color": "Accent color", + "account": "Tài khoản", + "account_deleted": "Tài khoản của bạn đã bị xóa", + "account_description": "Tùy chỉnh cài đặt tài khoản của bạn.", + "account_email_description": "Địa chỉ email chính của bạn.", + "account_name_description": "Đây là tên hiển thị của bạn.", + "additional": "Additional Settings", + "background": "Nền", + "black_mode": "Chế độ Đen", + "choose_language": "Chọn ngôn ngữ", + "dark_mode": "Chế độ Tối", + "delete_account": "Xóa tài khoản", + "delete_account_description": "Sau khi xóa tài khoản, tất cả dữ liệu của bạn sẽ bị xóa vĩnh viễn. Hành động này không thể hoàn tác.", + "expand_navigation": "Mở rộng điều hướng", + "experiments": "Thử nghiệm", + "experiments_notice": "Đây là một bộ sưu tập các thử nghiệm mà chúng tôi đang làm việc và có thể hữu ích, vui nhộn, hoặc cả hai. Chúng không phải là phiên bản cuối cùng và có thể không ổn định, nên nếu có điều gì đó quá kỳ quặc xảy ra, hãy bình tĩnh. Chỉ cần tắt cái mớ đó đi. Nói đùa chơi, ", + "extension_ver_not_reported": "Không được báo cáo", + "extension_version": "Phiên bản tiện ích mở rộng", + "extensions": "Tiện ích trình duyệt", + "extensions_use_toggle": "Sử dụng tiện ích mở rộng trình duyệt để gửi các request (nếu có)", + "follow": "Theo dõi chúng tôi", + "interceptor": "Interceptor", + "interceptor_description": "Phần mềm trung gian giữa ứng dụng và các API.", + "language": "Ngôn ngữ", + "light_mode": "Chế độ Sáng", + "official_proxy_hosting": "Proxy chính thức được lưu trữ bởi Hoppscotch.", + "profile": "Hồ sơ", + "profile_description": "Cập nhật thông tin hồ sơ của bạn", + "profile_email": "Địa chỉ email", + "profile_name": "Tên hồ sơ", + "proxy": "Proxy", + "proxy_url": "URL Proxy", + "proxy_use_toggle": "Sử dụng middleware proxy để gửi request", + "read_the": "Đọc", + "reset_default": "Đặt lại về mặc định", + "short_codes": "Mã ngắn", + "short_codes_description": "Các mã ngắn được tạo bởi bạn.", + "sidebar_on_left": "Thanh bên trái", + "sync": "Đồng bộ hóa", + "sync_collections": "Bộ sưu tập", + "sync_description": "Các cài đặt này được đồng bộ hóa với đám mây.", + "sync_environments": "Môi trường", + "sync_history": "Lịch sử", + "system_mode": "Chế độ Hệ thống", + "telemetry": "Telemetry", + "telemetry_helps_us": "Telemetry giúp chúng tôi cá nhân hóa hoạt động và mang lại trải nghiệm tốt nhất cho bạn.", + "theme": "Chủ đề", + "theme_description": "Tùy chỉnh chủ đề ứng dụng của bạn.", + "use_experimental_url_bar": "Sử dụng thanh URL thử nghiệm với đánh dấu môi trường", + "user": "Người sử dụng", + "verified_email": "Email đã xác minh", + "verify_email": "Xác minh email" + }, + "shared_requests": { + "button": "Button", + "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", + "copy_html": "Copy HTML", + "copy_link": "Copy Link", + "copy_markdown": "Copy Markdown", + "creating_widget": "Creating widget", + "customize": "Customize", + "deleted": "Shared request deleted", + "description": "Select a widget, you can change and customize this later", + "embed": "Embed", + "embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.", + "link": "Link", + "link_info": "Create a shareable link to share with anyone on the internet with view access.", + "modified": "Shared request modified", + "not_found": "Shared request not found", + "open_new_tab": "Open in new tab", + "preview": "Preview", + "run_in_hoppscotch": "Run in Hoppscotch", + "theme": { + "dark": "Dark", + "light": "Light", + "system": "System", + "title": "Theme" + } + }, + "shortcut": { + "general": { + "close_current_menu": "Đóng menu hiện tại", + "command_menu": "Tìm kiếm và lệnh", + "help_menu": "Menu trợ giúp", + "show_all": "Phím tắt bàn phím", + "title": "Chung" + }, + "miscellaneous": { + "invite": "Mời mọi người tham gia Hoppscotch", + "title": "Đa dạng" + }, + "navigation": { + "back": "Quay lại trang trước", + "documentation": "Đi đến trang Tài liệu", + "forward": "Tiến tới trang tiếp theo", + "graphql": "Đi đến trang GraphQL", + "profile": "Đi đến trang Hồ sơ", + "realtime": "Đi đến trang Thời gian thực", + "rest": "Đi đến trang REST", + "settings": "Đi đến trang Cài đặt", + "title": "Điều hướng" + }, + "others": { + "prettify": "Prettify Editor's Content", + "title": "Others" + }, + "request": { + "delete_method": "Chọn phương thức DELETE", + "get_method": "Chọn phương thức GET", + "head_method": "Chọn phương thức HEAD", + "import_curl": "Import cURL", + "method": "Phương thức", + "next_method": "Chọn phương thức tiếp theo", + "post_method": "Chọn phương thức POST", + "previous_method": "Chọn phương thức trước đó", + "put_method": "Chọn phương thức PUT", + "rename": "Rename Request", + "reset_request": "Đặt lại request", + "save_request": "Save Request", + "save_to_collections": "Lưu vào bộ sưu tập", + "send_request": "Gửi request", + "share_request": "Share Request", + "show_code": "Generate code snippet", + "title": "Request", + "copy_request_link": "Sao chép liên kết request" + }, + "response": { + "copy": "Sao chép phản hồi vào clipboard", + "download": "Tải xuống phản hồi dưới dạng tệp", + "title": "Phản hồi" + }, + "theme": { + "black": "Chuyển sang chế độ giao diện màu đen", + "dark": "Chuyển sang chế độ giao diện tối", + "light": "Chuyển sang chế độ giao diện sáng", + "system": "Chuyển sang chế độ giao diện hệ thống", + "title": "Giao diện" + } + }, + "show": { + "code": "Hiển thị mã", + "collection": "Mở rộng bảng điều khiển bộ sưu tập", + "more": "Hiển thị thêm", + "sidebar": "Mở rộng thanh bên" + }, + "socketio": { + "communication": "Giao tiếp", + "connection_not_authorized": "Kết nối SocketIO này không sử dụng xác thực.", + "event_name": "Tên Sự kiện/Chủ đề", + "events": "Sự kiện", + "log": "Nhật ký", + "url": "URL" + }, + "spotlight": { + "change_language": "Change Language", + "environments": { + "delete": "Delete current environment", + "duplicate": "Duplicate current environment", + "duplicate_global": "Duplicate global environment", + "edit": "Edit current environment", + "edit_global": "Edit global environment", + "new": "Create new environment", + "new_variable": "Create a new environment variable", + "title": "Environments" + }, + "general": { + "chat": "Chat with support", + "help_menu": "Help and support", + "open_docs": "Read Documentation", + "open_github": "Open GitHub repository", + "open_keybindings": "Keyboard shortcuts", + "social": "Social", + "title": "General" + }, + "graphql": { + "connect": "Connect to server", + "disconnect": "Disconnect from server" + }, + "miscellaneous": { + "invite": "Invite your friends to Hoppscotch", + "title": "Miscellaneous" + }, + "request": { + "save_as_new": "Save as new request", + "select_method": "Select method", + "switch_to": "Switch to", + "tab_authorization": "Authorization tab", + "tab_body": "Body tab", + "tab_headers": "Headers tab", + "tab_parameters": "Parameters tab", + "tab_pre_request_script": "Pre-request script tab", + "tab_query": "Query tab", + "tab_tests": "Tests tab", + "tab_variables": "Variables tab" + }, + "response": { + "copy": "Copy response", + "download": "Download response as file", + "title": "Response" + }, + "section": { + "interceptor": "Interceptor", + "interface": "Interface", + "theme": "Theme", + "user": "User" + }, + "settings": { + "change_interceptor": "Change Interceptor", + "change_language": "Change Language", + "theme": { + "black": "Black", + "dark": "Dark", + "light": "Light", + "system": "System preference" + } + }, + "tab": { + "close_current": "Close current tab", + "close_others": "Close all other tabs", + "duplicate": "Duplicate current tab", + "new_tab": "Open a new tab", + "title": "Tabs" + }, + "workspace": { + "delete": "Delete current team", + "edit": "Edit current team", + "invite": "Invite people to team", + "new": "Create new team", + "switch_to_personal": "Switch to your personal workspace", + "title": "Teams" + }, + "phrases": { + "try": "Try", + "import_collections": "Import collections", + "create_environment": "Create environment", + "create_workspace": "Create workspace", + "share_request": "Share request" + } + }, + "sse": { + "event_type": "Loại sự kiện", + "log": "Nhật ký", + "url": "URL" + }, + "state": { + "bulk_mode": "Chế độ chỉnh sửa hàng loạt", + "bulk_mode_placeholder": "Các mục được phân tách bằng dòng mới\nKhóa và giá trị được phân tách bằng dấu :\nThêm # vào đầu mỗi dòng bạn muốn thêm nhưng vẫn bị vô hiệu hóa", + "cleared": "Đã xóa", + "connected": "Đã kết nối", + "connected_to": "Đã kết nối với {name}", + "connecting_to": "Đang kết nối đến {name}...", + "connection_error": "Không thể kết nối", + "connection_failed": "Kết nối thất bại", + "connection_lost": "Mất kết nối", + "copied_interface_to_clipboard": "Copied {language} interface type to clipboard", + "copied_to_clipboard": "Đã sao chép vào clipboard", + "deleted": "Đã xóa", + "deprecated": "Đà LỖI THỜI", + "disabled": "Đã vô hiệu hóa", + "disconnected": "Đã ngắt kết nối", + "disconnected_from": "Đã ngắt kết nối từ {name}", + "docs_generated": "Tài liệu đã được tạo", + "download_failed": "Download failed", + "download_started": "Đã bắt đầu tải xuống", + "enabled": "Đã kích hoạt", + "file_imported": "Đã nhập tệp", + "finished_in": "Hoàn thành trong {duration} ms", + "hide": "Hide", + "history_deleted": "Lịch sử đã bị xóa", + "linewrap": "Xuống dòng", + "loading": "Đang tải...", + "message_received": "Thông điệp: {message} đã được nhận trên chủ đề: {topic}", + "mqtt_subscription_failed": "Đã xảy ra sự cố khi đăng ký chủ đề: {topic}", + "none": "Không", + "nothing_found": "Không tìm thấy cho", + "published_error": "Đã xảy ra sự cố khi xuất bản tin nhắn: {message} đến chủ đề: {topic}", + "published_message": "Đã xuất bản tin nhắn: {message} đến chủ đề: {topic}", + "reconnection_error": "Không thể kết nối lại", + "show": "Show", + "subscribed_failed": "Không thể đăng ký chủ đề: {topic}", + "subscribed_success": "Đã đăng ký thành công chủ đề: {topic}", + "unsubscribed_failed": "Không thể hủy đăng ký khỏi chủ đề: {topic}", + "unsubscribed_success": "Đã hủy đăng ký thành công khỏi chủ đề: {topic}", + "waiting_send_request": "Đang chờ gửi request" + }, + "support": { + "changelog": "Đọc thêm về các phiên bản mới nhất", + "chat": "Có câu hỏi? Trò chuyện với chúng tôi!", + "community": "Hỏi câu hỏi và giúp đỡ người khác", + "documentation": "Đọc thêm về Hoppscotch", + "forum": "Hỏi câu hỏi và nhận câu trả lời", + "github": "Theo dõi chúng tôi trên Github", + "shortcuts": "Duyệt ứng dụng nhanh hơn", + "title": "Hỗ trợ", + "twitter": "Theo dõi chúng tôi trên Twitter", + "team": "Liên hệ với nhóm" + }, + "tab": { + "authorization": "Xác thực", + "body": "Nội dung", + "close": "Close Tab", + "close_others": "Close other Tabs", + "collections": "Bộ sưu tập", + "documentation": "Tài liệu", + "duplicate": "Duplicate Tab", + "environments": "Môi trường", + "headers": "Tiêu đề", + "history": "Lịch sử", + "mqtt": "MQTT", + "parameters": "Tham số", + "pre_request_script": "Kịch bản trước request", + "queries": "Truy vấn", + "query": "Truy vấn", + "schema": "Schema", + "shared_requests": "Shared Requests", + "codegen": "Generate Code", + "code_snippet": "Code snippet", + "share_tab_request": "Share tab request", + "socketio": "Socket.IO", + "sse": "SSE", + "tests": "Kiểm tra", + "types": "Kiểu dữ liệu", + "variables": "Biến", + "websocket": "WebSocket" + }, + "team": { + "already_member": "Bạn đã là thành viên của nhóm này. Liên hệ với chủ sở hữu nhóm.", + "create_new": "Tạo nhóm mới", + "deleted": "Nhóm đã bị xóa", + "edit": "Chỉnh sửa Nhóm", + "email": "Email", + "email_do_not_match": "Email không khớp với thông tin tài khoản của bạn. Liên hệ với chủ sở hữu nhóm.", + "exit": "Rời khỏi Nhóm", + "exit_disabled": "Chỉ chủ sở hữu không thể rời khỏi nhóm", + "failed_invites": "Failed invites", + "invalid_coll_id": "ID bộ sưu tập không hợp lệ", + "invalid_email_format": "Định dạng email không hợp lệ", + "invalid_id": "ID nhóm không hợp lệ. Liên hệ với chủ sở hữu nhóm.", + "invalid_invite_link": "Liên kết mời không hợp lệ", + "invalid_invite_link_description": "Liên kết bạn đã theo dõi không hợp lệ. Liên hệ với chủ sở hữu nhóm.", + "invalid_member_permission": "Vui lòng cung cấp quyền hợp lệ cho thành viên nhóm", + "invite": "Mời", + "invite_more": "Mời thêm", + "invite_tooltip": "Mời người khác vào không gian làm việc này", + "invited_to_team": "{owner} đã mời bạn tham gia {team}", + "join": "Đã chấp nhận lời mời", + "join_team": "Tham gia {team}", + "joined_team": "Bạn đã tham gia {team}", + "joined_team_description": "Bạn hiện là thành viên của nhóm này", + "left": "Bạn đã rời khỏi nhóm", + "login_to_continue": "Đăng nhập để tiếp tục", + "login_to_continue_description": "Bạn cần đăng nhập để tham gia một nhóm.", + "logout_and_try_again": "Đăng xuất và đăng nhập bằng tài khoản khác", + "member_has_invite": "ID email này đã có lời mời. Liên hệ với chủ sở hữu nhóm.", + "member_not_found": "Không tìm thấy thành viên. Liên hệ với chủ sở hữu nhóm.", + "member_removed": "Người dùng đã bị xóa", + "member_role_updated": "Vai trò người dùng đã được cập nhật", + "members": "Thành viên", + "more_members": "+{count} người khác", + "name_length_insufficient": "Tên nhóm phải có ít nhất 6 ký tự", + "name_updated": "Tên nhóm đã được cập nhật", + "new": "Nhóm mới", + "new_created": "Đã tạo nhóm mới", + "new_name": "Nhóm mới của tôi", + "no_access": "Bạn không có quyền chỉnh sửa các bộ sưu tập này", + "no_invite_found": "Không tìm thấy lời mời. Liên hệ với chủ sở hữu nhóm.", + "no_request_found": "Không tìm thấy request.", + "not_found": "Không tìm thấy nhóm. Liên hệ với chủ sở hữu nhóm.", + "not_valid_viewer": "Bạn không phải là người xem hợp lệ. Liên hệ với chủ sở hữu nhóm.", + "parent_coll_move": "Không thể di chuyển bộ sưu tập đến bộ sưu tập con", + "pending_invites": "Lời mời đang chờ", + "permissions": "Quyền hạn", + "same_target_destination": "Cùng mục tiêu và đích", + "saved": "Đã lưu nhóm", + "select_a_team": "Chọn một nhóm", + "success_invites": "Success invites", + "title": "Nhóm", + "we_sent_invite_link": "Chúng tôi đã gửi một liên kết mời đến tất cả người được mời!", + "invite_sent_smtp_disabled": "Invite links generated", + "we_sent_invite_link_description": "Yêu cầu tất cả người được mời kiểm tra hộp thư đến của họ. Nhấp vào liên kết để tham gia nhóm.", + "invite_sent_smtp_disabled_description": "Sending invite emails is disabled for this instance of Hoppscotch. Please use the Copy link button to copy and share the invite link manually.", + "copy_invite_link": "Copy Invite Link", + "search_title": "Team Requests", + "join_beta": "Tham gia chương trình beta để truy cập vào nhóm." + }, + "team_environment": { + "deleted": "Môi trường đã bị xóa", + "duplicate": "Môi trường đã được sao chép", + "not_found": "Không tìm thấy môi trường." + }, + "test": { + "failed": "Kiểm tra không thành công", + "javascript_code": "Mã JavaScript", + "learn": "Đọc tài liệu", + "passed": "Kiểm tra đã thành công", + "report": "Báo cáo Kiểm tra", + "results": "Kết quả Kiểm tra", + "script": "Kịch bản", + "snippets": "Đoạn mã" + }, + "websocket": { + "communication": "Giao tiếp", + "log": "Nhật ký", + "message": "Tin nhắn", + "protocols": "Giao thức", + "url": "URL" + }, + "workspace": { + "change": "Thay đổi không gian làm việc", + "personal": "Không gian làm việc của tôi", + "other_workspaces": "My Workspaces", + "team": "Không gian làm việc nhóm", + "title": "Không gian làm việc" + }, + "site_protection": { + "login_to_continue": "Login to continue", + "login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.", + "error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status" + }, + "access_tokens": { + "tab_title": "Tokens", + "section_title": "Personal Access Tokens", + "section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account", + "last_used_on": "Last used on", + "expires_on": "Expires on", + "no_expiration": "No expiration", + "expired": "Expired", + "copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "token_purpose": "What's this token for?", + "expiration_label": "Expiration", + "scope_label": "Scope", + "workspace_read_only_access": "Read-only access to workspace data.", + "personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "no_expiration_verbose": "This token will never expire!", + "token_expires_on": "This token will expire on", + "generate_new_token": "Generate new token", + "generate_modal_title": "New Personal Access Token", + "deletion_success": "The access token {label} has been deleted" + }, + "collection_runner": { + "collection_id": "Collection ID", + "environment_id": "Environment ID", + "cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.", + "cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.", + "include_active_environment": "Include active environment:", + "cli": "CLI", + "ui": "Runner (coming soon)", + "cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.", + "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", + "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", + "run_collection": "Run collection" + }, + "shortcodes": { + "actions": "Hành động", + "created_on": "Được tạo vào", + "deleted": "Shortcode đã bị xóa", + "method": "Phương thức", + "not_found": "Không tìm thấy Shortcode", + "short_code": "Short code", + "url": "URL" + } +} diff --git a/packages/hoppscotch-common/meta.ts b/packages/hoppscotch-common/meta.ts new file mode 100644 index 0000000..f7c58dd --- /dev/null +++ b/packages/hoppscotch-common/meta.ts @@ -0,0 +1,130 @@ +import { IHTMLTag } from "vite-plugin-html-config" + +export const APP_INFO = { + name: "Hoppscotch", + shortDescription: "Open source API development ecosystem", + description: + "Helps you create requests faster, saving precious time on development.", + keywords: + "hoppscotch, hopp scotch, hoppscotch online, hoppscotch app, postwoman, postwoman chrome, postwoman online, postwoman for mac, postwoman app, postwoman for windows, postwoman google chrome, postwoman chrome app, get postwoman, postwoman web, postwoman android, postwoman app for chrome, postwoman mobile app, postwoman web app, api, request, testing, tool, rest, websocket, sse, graphql, socketio", + app: { + background: "#181818", + lightThemeColor: "#ffffff", + darkThemeColor: "#181818", + }, + social: { + twitter: "@hoppscotch_io", + }, +} as const + +export const META_TAGS = (env: Record): IHTMLTag[] => [ + { + name: "keywords", + content: APP_INFO.keywords, + }, + { + name: "X-UA-Compatible", + content: "IE=edge, chrome=1", + }, + { + name: "name", + content: `${APP_INFO.name} • ${APP_INFO.shortDescription}`, + }, + { + name: "description", + content: APP_INFO.description, + }, + { + name: "image", + content: `${env.VITE_BASE_URL}/banner.png`, + }, + // Open Graph tags + { + name: "og:title", + content: `${APP_INFO.name} • ${APP_INFO.shortDescription}`, + }, + { + name: "og:description", + content: APP_INFO.description, + }, + { + name: "og:image", + content: `${env.VITE_BASE_URL}/banner.png`, + }, + // Twitter tags + { + name: "twitter:card", + content: "summary_large_image", + }, + { + name: "twitter:site", + content: APP_INFO.social.twitter, + }, + { + name: "twitter:creator", + content: APP_INFO.social.twitter, + }, + { + name: "twitter:title", + content: `${APP_INFO.name} • ${APP_INFO.shortDescription}`, + }, + { + name: "twitter:description", + content: APP_INFO.description, + }, + { + name: "twitter:image", + content: `${env.VITE_BASE_URL}/banner.png`, + }, + // Add to homescreen for Chrome on Android. Fallback for PWA (handled by nuxt) + { + name: "application-name", + content: APP_INFO.name, + }, + // Windows phone tile icon + { + name: "msapplication-TileImage", + content: `${env.VITE_BASE_URL}/icon.png`, + }, + { + name: "msapplication-TileColor", + content: APP_INFO.app.background, + }, + { + name: "msapplication-tap-highlight", + content: "no", + }, + // iOS Safari + { + name: "apple-mobile-web-app-title", + content: APP_INFO.name, + }, + { + name: "apple-mobile-web-app-capable", + content: "yes", + }, + { + name: "apple-mobile-web-app-status-bar-style", + content: "black-translucent", + }, + // PWA + { + name: "theme-color", + content: APP_INFO.app.darkThemeColor, + media: "(prefers-color-scheme: dark)", + }, + { + name: "theme-color", + content: APP_INFO.app.lightThemeColor, + media: "(prefers-color-scheme: light)", + }, + { + name: "supported-color-schemes", + content: "light dark", + }, + { + name: "mask-icon", + content: "/icon.png", + color: APP_INFO.app.background, + }, +] diff --git a/packages/hoppscotch-common/package.json b/packages/hoppscotch-common/package.json new file mode 100644 index 0000000..5955d76 --- /dev/null +++ b/packages/hoppscotch-common/package.json @@ -0,0 +1,193 @@ +{ + "name": "@hoppscotch/common", + "private": true, + "version": "2026.6.0", + "scripts": { + "dev": "pnpm exec npm-run-all -p -l dev:*", + "test": "vitest --run", + "test:watch": "vitest", + "dev:vite": "vite", + "dev:gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml --watch dotenv_config_path=\"../../.env\"", + "lint": "eslint src", + "lint:ts": "vue-tsc --noEmit", + "prod-lint": "cross-env HOPP_LINT_FOR_PROD=true pnpm run lint", + "lintfix": "eslint --fix src", + "preview": "vite preview", + "gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml dotenv_config_path=\"../../.env\"", + "postinstall": "pnpm run gql-codegen", + "do-test": "pnpm run test", + "do-lint": "pnpm run prod-lint", + "do-typecheck": "node type-check.mjs", + "do-lintfix": "pnpm run lintfix" + }, + "dependencies": { + "@apidevtools/swagger-parser": "12.1.0", + "@codemirror/autocomplete": "6.20.0", + "@codemirror/commands": "6.10.0", + "@codemirror/lang-javascript": "6.2.4", + "@codemirror/lang-json": "6.0.2", + "@codemirror/lang-xml": "6.1.0", + "@codemirror/language": "6.11.3", + "@codemirror/legacy-modes": "6.5.2", + "@codemirror/lint": "6.9.2", + "@codemirror/merge": "6.11.2", + "@codemirror/search": "6.5.11", + "@codemirror/state": "6.5.2", + "@codemirror/view": "6.38.8", + "@guolao/vue-monaco-editor": "1.6.0", + "@hoppscotch/codemirror-lang-graphql": "workspace:^", + "@hoppscotch/data": "workspace:^", + "@hoppscotch/httpsnippet": "3.0.9", + "@hoppscotch/js-sandbox": "workspace:^", + "@hoppscotch/kernel": "workspace:^", + "@hoppscotch/plugin-appload": "github:CuriousCorrelation/tauri-plugin-appload#9d4528be4f385bccbe46859631d31aa2ee1ec0b6", + "@hoppscotch/ui": "0.2.6", + "@hoppscotch/vue-toasted": "0.1.0", + "@lezer/highlight": "1.2.1", + "@noble/curves": "2.2.0", + "@scure/base": "2.2.0", + "@shopify/lang-jsonc": "1.0.1", + "@tauri-apps/api": "2.1.1", + "@tauri-apps/plugin-store": "2.4.1", + "@types/hawk": "9.0.7", + "@types/markdown-it": "14.1.2", + "@types/node": "24.10.1", + "@unhead/vue": "2.1.12", + "@urql/core": "6.0.2", + "@urql/devtools": "2.0.3", + "@urql/exchange-auth": "3.0.0", + "@vueuse/core": "14.3.0", + "acorn-walk": "8.3.5", + "aws4fetch": "1.0.20", + "axios": "1.18.0", + "buffer": "6.0.3", + "cookie-es": "2.0.0", + "dioc": "3.0.2", + "dompurify": "3.4.11", + "esprima": "4.0.1", + "events": "3.3.0", + "fp-ts": "2.16.11", + "globalthis": "1.0.4", + "graphql": "16.13.2", + "graphql-language-service-interface": "2.10.2", + "graphql-tag": "2.12.7", + "hawk": "9.0.2", + "highlight.js": "11.11.1", + "highlightjs-curl": "1.3.0", + "insomnia-importers": "3.6.0", + "io-ts": "2.2.22", + "jq-wasm": "1.1.0-jq-1.8.1", + "js-md5": "0.8.3", + "js-yaml": "4.2.0", + "jsonc-parser": "3.3.1", + "lodash-es": "4.18.1", + "lossless-json": "4.3.0", + "markdown-it": "14.2.0", + "minisearch": "7.2.0", + "monaco-editor": "0.55.1", + "nprogress": "0.2.0", + "paho-mqtt": "1.1.0", + "path": "0.12.7", + "postman-collection": "5.3.0", + "process": "0.11.10", + "qs": "6.15.2", + "quicktype-core": "23.2.6", + "rollup": "4.60.2", + "rxjs": "7.8.2", + "set-cookie-parser": "2.7.2", + "set-cookie-parser-es": "1.0.5", + "socket.io-client-v2": "npm:socket.io-client@2.5.0", + "socket.io-client-v3": "npm:socket.io-client@3.1.3", + "socket.io-client-v4": "npm:socket.io-client@4.8.1", + "socketio-wildcard": "2.0.0", + "splitpanes": "3.1.5", + "stream-browserify": "3.0.0", + "subscriptions-transport-ws": "0.11.0", + "superjson": "2.2.6", + "tern": "0.24.3", + "timers": "0.1.1", + "tippy.js": "6.3.7", + "url": "0.11.4", + "util": "0.12.5", + "uuid": "13.0.0", + "verzod": "0.4.0", + "vue": "3.5.38", + "vue-i18n": "11.4.6", + "vue-json-pretty": "2.6.0", + "vue-pdf-embed": "2.1.4", + "vue-router": "4.6.4", + "vue-tippy": "6.7.1", + "vuedraggable-es": "4.1.1", + "wonka": "6.3.6", + "workbox-window": "7.4.1", + "xml-formatter": "3.7.0", + "yargs-parser": "22.0.0", + "zod": "3.25.32" + }, + "devDependencies": { + "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@esbuild-plugins/node-modules-polyfill": "0.2.2", + "@eslint/eslintrc": "3.3.5", + "@eslint/js": "9.39.2", + "@graphql-codegen/add": "6.0.1", + "@graphql-codegen/cli": "6.3.1", + "@graphql-codegen/typed-document-node": "6.1.8", + "@graphql-codegen/typescript": "5.0.10", + "@graphql-codegen/typescript-operations": "5.1.0", + "@graphql-codegen/typescript-urql-graphcache": "3.1.1", + "@graphql-codegen/urql-introspection": "3.0.1", + "@graphql-typed-document-node/core": "3.2.0", + "@iconify-json/lucide": "1.2.114", + "@import-meta-env/cli": "0.7.4", + "@intlify/unplugin-vue-i18n": "11.2.4", + "@relmify/jest-fp-ts": "2.1.1", + "@rushstack/eslint-patch": "1.16.1", + "@types/har-format": "1.2.16", + "@types/js-yaml": "4.0.9", + "@types/lodash-es": "4.17.12", + "@types/nprogress": "0.2.3", + "@types/paho-mqtt": "1.0.10", + "@types/postman-collection": "3.5.11", + "@types/qs": "6.15.1", + "@types/splitpanes": "2.2.6", + "@types/yargs-parser": "21.0.3", + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@vitejs/plugin-vue": "6.0.7", + "@vue/compiler-sfc": "3.5.38", + "@vue/eslint-config-typescript": "14.8.0", + "@vue/runtime-core": "3.5.38", + "autoprefixer": "10.5.0", + "cross-env": "10.1.0", + "dotenv": "17.4.2", + "eslint": "9.39.2", + "eslint-plugin-prettier": "5.5.6", + "eslint-plugin-vue": "10.9.2", + "glob": "13.0.6", + "globals": "16.5.0", + "jsdom": "27.4.0", + "npm-run-all": "4.1.5", + "openapi-types": "12.1.3", + "postcss": "8.5.15", + "prettier": "3.8.4", + "prettier-plugin-tailwindcss": "0.7.2", + "rollup-plugin-polyfill-node": "0.13.0", + "sass": "1.101.0", + "tailwindcss": "3.4.16", + "tsup": "8.5.1", + "typescript": "5.9.3", + "unplugin-fonts": "1.4.0", + "unplugin-icons": "22.5.0", + "unplugin-vue-components": "30.0.0", + "vite": "7.3.2", + "vite-plugin-checker": "0.12.0", + "vite-plugin-fonts": "0.7.0", + "vite-plugin-html-config": "2.0.2", + "vite-plugin-pages": "0.33.3", + "vite-plugin-pages-sitemap": "1.7.1", + "vite-plugin-pwa": "1.2.0", + "vite-plugin-vue-layouts": "0.11.0", + "vitest": "4.1.9", + "vue-tsc": "1.8.8" + } +} diff --git a/packages/hoppscotch-common/public/CNAME b/packages/hoppscotch-common/public/CNAME new file mode 100644 index 0000000..45224db --- /dev/null +++ b/packages/hoppscotch-common/public/CNAME @@ -0,0 +1 @@ +hoppscotch.io \ No newline at end of file diff --git a/packages/hoppscotch-common/public/badge-dark.svg b/packages/hoppscotch-common/public/badge-dark.svg new file mode 100644 index 0000000..831b4af --- /dev/null +++ b/packages/hoppscotch-common/public/badge-dark.svg @@ -0,0 +1 @@ +▶ Run in Hoppscotch diff --git a/packages/hoppscotch-common/public/badge-light.svg b/packages/hoppscotch-common/public/badge-light.svg new file mode 100644 index 0000000..828836e --- /dev/null +++ b/packages/hoppscotch-common/public/badge-light.svg @@ -0,0 +1 @@ +▶ Run in Hoppscotch diff --git a/packages/hoppscotch-common/public/badge.svg b/packages/hoppscotch-common/public/badge.svg new file mode 100644 index 0000000..2c4897b --- /dev/null +++ b/packages/hoppscotch-common/public/badge.svg @@ -0,0 +1 @@ +▶ Run in Hoppscotch diff --git a/packages/hoppscotch-common/public/banner.png b/packages/hoppscotch-common/public/banner.png new file mode 100644 index 0000000..1997efa Binary files /dev/null and b/packages/hoppscotch-common/public/banner.png differ diff --git a/packages/hoppscotch-common/public/favicon.ico b/packages/hoppscotch-common/public/favicon.ico new file mode 100644 index 0000000..7411836 Binary files /dev/null and b/packages/hoppscotch-common/public/favicon.ico differ diff --git a/packages/hoppscotch-common/public/icon.png b/packages/hoppscotch-common/public/icon.png new file mode 100644 index 0000000..df8b691 Binary files /dev/null and b/packages/hoppscotch-common/public/icon.png differ diff --git a/packages/hoppscotch-common/public/icons/pwa-1024x1024.png b/packages/hoppscotch-common/public/icons/pwa-1024x1024.png new file mode 100644 index 0000000..f99ad60 Binary files /dev/null and b/packages/hoppscotch-common/public/icons/pwa-1024x1024.png differ diff --git a/packages/hoppscotch-common/public/icons/pwa-128x128.png b/packages/hoppscotch-common/public/icons/pwa-128x128.png new file mode 100644 index 0000000..300c68c Binary files /dev/null and b/packages/hoppscotch-common/public/icons/pwa-128x128.png differ diff --git a/packages/hoppscotch-common/public/icons/pwa-16x16.png b/packages/hoppscotch-common/public/icons/pwa-16x16.png new file mode 100644 index 0000000..62d6427 Binary files /dev/null and b/packages/hoppscotch-common/public/icons/pwa-16x16.png differ diff --git a/packages/hoppscotch-common/public/icons/pwa-192x192.png b/packages/hoppscotch-common/public/icons/pwa-192x192.png new file mode 100644 index 0000000..cffff24 Binary files /dev/null and b/packages/hoppscotch-common/public/icons/pwa-192x192.png differ diff --git a/packages/hoppscotch-common/public/icons/pwa-256x256.png b/packages/hoppscotch-common/public/icons/pwa-256x256.png new file mode 100644 index 0000000..77ad9a5 Binary files /dev/null and b/packages/hoppscotch-common/public/icons/pwa-256x256.png differ diff --git a/packages/hoppscotch-common/public/icons/pwa-32x32.png b/packages/hoppscotch-common/public/icons/pwa-32x32.png new file mode 100644 index 0000000..c497149 Binary files /dev/null and b/packages/hoppscotch-common/public/icons/pwa-32x32.png differ diff --git a/packages/hoppscotch-common/public/icons/pwa-512x512.png b/packages/hoppscotch-common/public/icons/pwa-512x512.png new file mode 100644 index 0000000..313d518 Binary files /dev/null and b/packages/hoppscotch-common/public/icons/pwa-512x512.png differ diff --git a/packages/hoppscotch-common/public/images/banner-dark.png b/packages/hoppscotch-common/public/images/banner-dark.png new file mode 100644 index 0000000..966ab4b Binary files /dev/null and b/packages/hoppscotch-common/public/images/banner-dark.png differ diff --git a/packages/hoppscotch-common/public/images/banner-light.png b/packages/hoppscotch-common/public/images/banner-light.png new file mode 100644 index 0000000..0e284d3 Binary files /dev/null and b/packages/hoppscotch-common/public/images/banner-light.png differ diff --git a/packages/hoppscotch-common/public/images/cover.svg b/packages/hoppscotch-common/public/images/cover.svg new file mode 100644 index 0000000..f1a8ee4 --- /dev/null +++ b/packages/hoppscotch-common/public/images/cover.svg @@ -0,0 +1 @@ + diff --git a/packages/hoppscotch-common/public/images/states/black/add_category.svg b/packages/hoppscotch-common/public/images/states/black/add_category.svg new file mode 100644 index 0000000..52a9dd5 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/black/add_category.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/black/add_comment.svg b/packages/hoppscotch-common/public/images/states/black/add_comment.svg new file mode 100644 index 0000000..de30e58 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/black/add_comment.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/black/add_files.svg b/packages/hoppscotch-common/public/images/states/black/add_files.svg new file mode 100644 index 0000000..bc6d9a7 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/black/add_files.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/black/add_group.svg b/packages/hoppscotch-common/public/images/states/black/add_group.svg new file mode 100644 index 0000000..89e8ae0 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/black/add_group.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/black/blockchain.svg b/packages/hoppscotch-common/public/images/states/black/blockchain.svg new file mode 100644 index 0000000..6e2b564 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/black/blockchain.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/black/login.svg b/packages/hoppscotch-common/public/images/states/black/login.svg new file mode 100644 index 0000000..79e5dea --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/black/login.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/black/pack.svg b/packages/hoppscotch-common/public/images/states/black/pack.svg new file mode 100644 index 0000000..3a24d73 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/black/pack.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/black/time.svg b/packages/hoppscotch-common/public/images/states/black/time.svg new file mode 100644 index 0000000..801d13e --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/black/time.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/black/upload_error.svg b/packages/hoppscotch-common/public/images/states/black/upload_error.svg new file mode 100644 index 0000000..35b0a27 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/black/upload_error.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/black/upload_single_file.svg b/packages/hoppscotch-common/public/images/states/black/upload_single_file.svg new file mode 100644 index 0000000..a6192f3 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/black/upload_single_file.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/black/validation.svg b/packages/hoppscotch-common/public/images/states/black/validation.svg new file mode 100644 index 0000000..007ecd6 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/black/validation.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/black/youre_lost.svg b/packages/hoppscotch-common/public/images/states/black/youre_lost.svg new file mode 100644 index 0000000..b192e60 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/black/youre_lost.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/dark/add_category.svg b/packages/hoppscotch-common/public/images/states/dark/add_category.svg new file mode 100644 index 0000000..80b6c60 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/dark/add_category.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/dark/add_comment.svg b/packages/hoppscotch-common/public/images/states/dark/add_comment.svg new file mode 100644 index 0000000..91928b1 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/dark/add_comment.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/dark/add_files.svg b/packages/hoppscotch-common/public/images/states/dark/add_files.svg new file mode 100644 index 0000000..b0ddbe9 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/dark/add_files.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/dark/add_group.svg b/packages/hoppscotch-common/public/images/states/dark/add_group.svg new file mode 100644 index 0000000..2e00f06 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/dark/add_group.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/dark/blockchain.svg b/packages/hoppscotch-common/public/images/states/dark/blockchain.svg new file mode 100644 index 0000000..7cbfbef --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/dark/blockchain.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/dark/login.svg b/packages/hoppscotch-common/public/images/states/dark/login.svg new file mode 100644 index 0000000..e1b04e3 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/dark/login.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/dark/pack.svg b/packages/hoppscotch-common/public/images/states/dark/pack.svg new file mode 100644 index 0000000..56a669b --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/dark/pack.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/dark/time.svg b/packages/hoppscotch-common/public/images/states/dark/time.svg new file mode 100644 index 0000000..202e8fc --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/dark/time.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/dark/upload_error.svg b/packages/hoppscotch-common/public/images/states/dark/upload_error.svg new file mode 100644 index 0000000..651fe1f --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/dark/upload_error.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/dark/upload_single_file.svg b/packages/hoppscotch-common/public/images/states/dark/upload_single_file.svg new file mode 100644 index 0000000..a0f27b2 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/dark/upload_single_file.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/dark/validation.svg b/packages/hoppscotch-common/public/images/states/dark/validation.svg new file mode 100644 index 0000000..be0ca79 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/dark/validation.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/dark/youre_lost.svg b/packages/hoppscotch-common/public/images/states/dark/youre_lost.svg new file mode 100644 index 0000000..7ab86c0 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/dark/youre_lost.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/light/add_category.svg b/packages/hoppscotch-common/public/images/states/light/add_category.svg new file mode 100644 index 0000000..f912759 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/light/add_category.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/light/add_comment.svg b/packages/hoppscotch-common/public/images/states/light/add_comment.svg new file mode 100644 index 0000000..c8b3c0f --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/light/add_comment.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/light/add_files.svg b/packages/hoppscotch-common/public/images/states/light/add_files.svg new file mode 100644 index 0000000..cb72cf9 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/light/add_files.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/light/add_group.svg b/packages/hoppscotch-common/public/images/states/light/add_group.svg new file mode 100644 index 0000000..0345dcc --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/light/add_group.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/light/blockchain.svg b/packages/hoppscotch-common/public/images/states/light/blockchain.svg new file mode 100644 index 0000000..475c9c1 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/light/blockchain.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/light/login.svg b/packages/hoppscotch-common/public/images/states/light/login.svg new file mode 100644 index 0000000..8000306 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/light/login.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/light/pack.svg b/packages/hoppscotch-common/public/images/states/light/pack.svg new file mode 100644 index 0000000..d5ea82e --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/light/pack.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/light/time.svg b/packages/hoppscotch-common/public/images/states/light/time.svg new file mode 100644 index 0000000..732a959 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/light/time.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/light/upload_error.svg b/packages/hoppscotch-common/public/images/states/light/upload_error.svg new file mode 100644 index 0000000..d6504cb --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/light/upload_error.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/light/upload_single_file.svg b/packages/hoppscotch-common/public/images/states/light/upload_single_file.svg new file mode 100644 index 0000000..d459347 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/light/upload_single_file.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/light/validation.svg b/packages/hoppscotch-common/public/images/states/light/validation.svg new file mode 100644 index 0000000..455ab80 --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/light/validation.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/images/states/light/youre_lost.svg b/packages/hoppscotch-common/public/images/states/light/youre_lost.svg new file mode 100644 index 0000000..637aa6a --- /dev/null +++ b/packages/hoppscotch-common/public/images/states/light/youre_lost.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/public/logo.png b/packages/hoppscotch-common/public/logo.png new file mode 100644 index 0000000..787c4b6 Binary files /dev/null and b/packages/hoppscotch-common/public/logo.png differ diff --git a/packages/hoppscotch-common/public/logo.svg b/packages/hoppscotch-common/public/logo.svg new file mode 100644 index 0000000..d78a86b --- /dev/null +++ b/packages/hoppscotch-common/public/logo.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/src/App.vue b/packages/hoppscotch-common/src/App.vue new file mode 100644 index 0000000..8d9d7e4 --- /dev/null +++ b/packages/hoppscotch-common/src/App.vue @@ -0,0 +1,52 @@ + + + diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts new file mode 100644 index 0000000..46b4f39 --- /dev/null +++ b/packages/hoppscotch-common/src/components.d.ts @@ -0,0 +1,344 @@ +/* eslint-disable */ +// @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 + +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + AccessTokens: typeof import('./components/accessTokens/index.vue')['default'] + AccessTokensGenerateModal: typeof import('./components/accessTokens/GenerateModal.vue')['default'] + AccessTokensList: typeof import('./components/accessTokens/List.vue')['default'] + AccessTokensOverview: typeof import('./components/accessTokens/Overview.vue')['default'] + AiexperimentsMergeView: typeof import('./components/aiexperiments/MergeView.vue')['default'] + AiexperimentsModifyBodyModal: typeof import('./components/aiexperiments/ModifyBodyModal.vue')['default'] + AiexperimentsModifyPreRequestModal: typeof import('./components/aiexperiments/ModifyPreRequestModal.vue')['default'] + AiexperimentsModifyTestScriptModal: typeof import('./components/aiexperiments/ModifyTestScriptModal.vue')['default'] + AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default'] + AppBanner: typeof import('./components/app/Banner.vue')['default'] + AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default'] + AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default'] + AppFooter: typeof import('./components/app/Footer.vue')['default'] + AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default'] + AppHeader: typeof import('./components/app/Header.vue')['default'] + AppInspection: typeof import('./components/app/Inspection.vue')['default'] + AppKernelInterceptor: typeof import('./components/app/KernelInterceptor.vue')['default'] + AppLogo: typeof import('./components/app/Logo.vue')['default'] + AppMarkdown: typeof import('./components/app/Markdown.vue')['default'] + AppOptions: typeof import('./components/app/Options.vue')['default'] + AppPaneLayout: typeof import('./components/app/PaneLayout.vue')['default'] + AppShare: typeof import('./components/app/Share.vue')['default'] + AppShortcuts: typeof import('./components/app/Shortcuts.vue')['default'] + AppShortcutsEntry: typeof import('./components/app/ShortcutsEntry.vue')['default'] + AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default'] + AppSidenav: typeof import('./components/app/Sidenav.vue')['default'] + AppSpotlight: typeof import('./components/app/spotlight/index.vue')['default'] + AppSpotlightEntry: typeof import('./components/app/spotlight/Entry.vue')['default'] + AppSpotlightEntryGQLHistory: typeof import('./components/app/spotlight/entry/GQLHistory.vue')['default'] + AppSpotlightEntryGQLRequest: typeof import('./components/app/spotlight/entry/GQLRequest.vue')['default'] + AppSpotlightEntryIconSelected: typeof import('./components/app/spotlight/entry/IconSelected.vue')['default'] + AppSpotlightEntryRESTHistory: typeof import('./components/app/spotlight/entry/RESTHistory.vue')['default'] + AppSpotlightEntryRESTRequest: typeof import('./components/app/spotlight/entry/RESTRequest.vue')['default'] + AppSpotlightEntryRESTTeamRequestEntry: typeof import('./components/app/spotlight/entry/RESTTeamRequestEntry.vue')['default'] + AppSpotlightSearch: typeof import('./components/app/SpotlightSearch.vue')['default'] + AppSupport: typeof import('./components/app/Support.vue')['default'] + AppWhatsNewDialog: typeof import('./components/app/WhatsNewDialog.vue')['default'] + Collections: typeof import('./components/collections/index.vue')['default'] + CollectionsAdd: typeof import('./components/collections/Add.vue')['default'] + CollectionsAddFolder: typeof import('./components/collections/AddFolder.vue')['default'] + CollectionsAddRequest: typeof import('./components/collections/AddRequest.vue')['default'] + CollectionsCollection: typeof import('./components/collections/Collection.vue')['default'] + CollectionsDocumentation: typeof import('./components/collections/documentation/index.vue')['default'] + CollectionsDocumentationCollectionPreview: typeof import('./components/collections/documentation/CollectionPreview.vue')['default'] + CollectionsDocumentationCollectionStructure: typeof import('./components/collections/documentation/CollectionStructure.vue')['default'] + CollectionsDocumentationEnvironmentPicker: typeof import('./components/collections/documentation/EnvironmentPicker.vue')['default'] + CollectionsDocumentationFolderItem: typeof import('./components/collections/documentation/FolderItem.vue')['default'] + CollectionsDocumentationLazyDocumentationItem: typeof import('./components/collections/documentation/LazyDocumentationItem.vue')['default'] + CollectionsDocumentationMarkdownEditor: typeof import('./components/collections/documentation/MarkdownEditor.vue')['default'] + CollectionsDocumentationPreview: typeof import('./components/collections/documentation/Preview.vue')['default'] + CollectionsDocumentationPublishDocForm: typeof import('./components/collections/documentation/PublishDocForm.vue')['default'] + CollectionsDocumentationPublishDocModal: typeof import('./components/collections/documentation/PublishDocModal.vue')['default'] + CollectionsDocumentationPublishDocSnapshotPreview: typeof import('./components/collections/documentation/PublishDocSnapshotPreview.vue')['default'] + CollectionsDocumentationRequestItem: typeof import('./components/collections/documentation/RequestItem.vue')['default'] + CollectionsDocumentationRequestPreview: typeof import('./components/collections/documentation/RequestPreview.vue')['default'] + CollectionsDocumentationSectionsAuth: typeof import('./components/collections/documentation/sections/Auth.vue')['default'] + CollectionsDocumentationSectionsCurlView: typeof import('./components/collections/documentation/sections/CurlView.vue')['default'] + CollectionsDocumentationSectionsHeaders: typeof import('./components/collections/documentation/sections/Headers.vue')['default'] + CollectionsDocumentationSectionsParameters: typeof import('./components/collections/documentation/sections/Parameters.vue')['default'] + CollectionsDocumentationSectionsRequestBody: typeof import('./components/collections/documentation/sections/RequestBody.vue')['default'] + CollectionsDocumentationSectionsResponse: typeof import('./components/collections/documentation/sections/Response.vue')['default'] + CollectionsDocumentationSectionsVariables: typeof import('./components/collections/documentation/sections/Variables.vue')['default'] + CollectionsEdit: typeof import('./components/collections/Edit.vue')['default'] + CollectionsEditFolder: typeof import('./components/collections/EditFolder.vue')['default'] + CollectionsEditRequest: typeof import('./components/collections/EditRequest.vue')['default'] + CollectionsEditResponse: typeof import('./components/collections/EditResponse.vue')['default'] + CollectionsExampleResponse: typeof import('./components/collections/ExampleResponse.vue')['default'] + CollectionsExportFormatList: typeof import('./components/collections/ExportFormatList.vue')['default'] + CollectionsExportFormatModal: typeof import('./components/collections/ExportFormatModal.vue')['default'] + CollectionsExportFormatStep: typeof import('./components/collections/ExportFormatStep.vue')['default'] + CollectionsGraphql: typeof import('./components/collections/graphql/index.vue')['default'] + CollectionsGraphqlAdd: typeof import('./components/collections/graphql/Add.vue')['default'] + CollectionsGraphqlAddFolder: typeof import('./components/collections/graphql/AddFolder.vue')['default'] + CollectionsGraphqlAddRequest: typeof import('./components/collections/graphql/AddRequest.vue')['default'] + CollectionsGraphqlCollection: typeof import('./components/collections/graphql/Collection.vue')['default'] + CollectionsGraphqlEdit: typeof import('./components/collections/graphql/Edit.vue')['default'] + CollectionsGraphqlEditFolder: typeof import('./components/collections/graphql/EditFolder.vue')['default'] + CollectionsGraphqlEditRequest: typeof import('./components/collections/graphql/EditRequest.vue')['default'] + CollectionsGraphqlFolder: typeof import('./components/collections/graphql/Folder.vue')['default'] + CollectionsGraphqlImportExport: typeof import('./components/collections/graphql/ImportExport.vue')['default'] + CollectionsGraphqlRequest: typeof import('./components/collections/graphql/Request.vue')['default'] + CollectionsImportExport: typeof import('./components/collections/ImportExport.vue')['default'] + CollectionsMyCollections: typeof import('./components/collections/MyCollections.vue')['default'] + CollectionsProperties: typeof import('./components/collections/Properties.vue')['default'] + CollectionsRequest: typeof import('./components/collections/Request.vue')['default'] + CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default'] + CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default'] + CollectionsVariables: typeof import('./components/collections/Variables.vue')['default'] + ConsoleItem: typeof import('./components/console/Item.vue')['default'] + ConsolePanel: typeof import('./components/console/Panel.vue')['default'] + ConsoleValue: typeof import('./components/console/Value.vue')['default'] + CookiesAllModal: typeof import('./components/cookies/AllModal.vue')['default'] + CookiesEditCookie: typeof import('./components/cookies/EditCookie.vue')['default'] + DocumentationContent: typeof import('./components/documentation/Content.vue')['default'] + DocumentationHeader: typeof import('./components/documentation/Header.vue')['default'] + DocumentationSkeleton: typeof import('./components/documentation/Skeleton.vue')['default'] + Embeds: typeof import('./components/embeds/index.vue')['default'] + EmbedsHeader: typeof import('./components/embeds/Header.vue')['default'] + EmbedsRequest: typeof import('./components/embeds/Request.vue')['default'] + Environments: typeof import('./components/environments/index.vue')['default'] + EnvironmentsAdd: typeof import('./components/environments/Add.vue')['default'] + EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default'] + EnvironmentsMy: typeof import('./components/environments/my/index.vue')['default'] + EnvironmentsMyDetails: typeof import('./components/environments/my/Details.vue')['default'] + EnvironmentsMyEnvironment: typeof import('./components/environments/my/Environment.vue')['default'] + EnvironmentsProperties: typeof import('./components/environments/Properties.vue')['default'] + EnvironmentsSelector: typeof import('./components/environments/Selector.vue')['default'] + EnvironmentsTeams: typeof import('./components/environments/teams/index.vue')['default'] + EnvironmentsTeamsDetails: typeof import('./components/environments/teams/Details.vue')['default'] + EnvironmentsTeamsEnvironment: typeof import('./components/environments/teams/Environment.vue')['default'] + FirebaseLogin: typeof import('./components/firebase/Login.vue')['default'] + FirebaseLogout: typeof import('./components/firebase/Logout.vue')['default'] + GraphqlArgument: typeof import('./components/graphql/Argument.vue')['default'] + GraphqlArguments: typeof import('./components/graphql/Arguments.vue')['default'] + GraphqlAuthorization: typeof import('./components/graphql/Authorization.vue')['default'] + GraphqlDefaultValue: typeof import('./components/graphql/DefaultValue.vue')['default'] + GraphqlDirectives: typeof import('./components/graphql/Directives.vue')['default'] + GraphqlDocExplorer: typeof import('./components/graphql/DocExplorer.vue')['default'] + GraphqlEnumValues: typeof import('./components/graphql/EnumValues.vue')['default'] + GraphqlExplorerSection: typeof import('./components/graphql/ExplorerSection.vue')['default'] + GraphqlField: typeof import('./components/graphql/Field.vue')['default'] + GraphqlFieldDocumentation: typeof import('./components/graphql/FieldDocumentation.vue')['default'] + GraphqlFieldLink: typeof import('./components/graphql/FieldLink.vue')['default'] + GraphqlFields: typeof import('./components/graphql/Fields.vue')['default'] + GraphqlHeaders: typeof import('./components/graphql/Headers.vue')['default'] + GraphqlImplementsInterfaces: typeof import('./components/graphql/ImplementsInterfaces.vue')['default'] + GraphqlQuery: typeof import('./components/graphql/Query.vue')['default'] + GraphqlRequest: typeof import('./components/graphql/Request.vue')['default'] + GraphqlRequestOptions: typeof import('./components/graphql/RequestOptions.vue')['default'] + GraphqlRequestTab: typeof import('./components/graphql/RequestTab.vue')['default'] + GraphqlResponse: typeof import('./components/graphql/Response.vue')['default'] + GraphqlResponseMeta: typeof import('./components/graphql/ResponseMeta.vue')['default'] + GraphqlSchemaDocumentation: typeof import('./components/graphql/SchemaDocumentation.vue')['default'] + GraphqlSchemaSearch: typeof import('./components/graphql/SchemaSearch.vue')['default'] + GraphqlSidebar: typeof import('./components/graphql/Sidebar.vue')['default'] + GraphqlSubscriptionLog: typeof import('./components/graphql/SubscriptionLog.vue')['default'] + GraphqlTabHead: typeof import('./components/graphql/TabHead.vue')['default'] + GraphqlType: typeof import('./components/graphql/Type.vue')['default'] + GraphqlTypeDocumentation: typeof import('./components/graphql/TypeDocumentation.vue')['default'] + GraphqlTypeLink: typeof import('./components/graphql/TypeLink.vue')['default'] + GraphqlVariable: typeof import('./components/graphql/Variable.vue')['default'] + History: typeof import('./components/history/index.vue')['default'] + HistoryGraphqlCard: typeof import('./components/history/graphql/Card.vue')['default'] + HistoryPersonal: typeof import('./components/history/Personal.vue')['default'] + HistoryRestCard: typeof import('./components/history/rest/Card.vue')['default'] + HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary'] + HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary'] + HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor'] + HoppSmartCheckbox: typeof import('@hoppscotch/ui')['HoppSmartCheckbox'] + HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal'] + HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip'] + HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput'] + HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection'] + HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem'] + HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink'] + HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal'] + HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture'] + HoppSmartPlaceholder: typeof import('@hoppscotch/ui')['HoppSmartPlaceholder'] + HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing'] + HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio'] + HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup'] + HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper'] + HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver'] + HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'] + HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab'] + HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs'] + HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle'] + HoppSmartTree: typeof import('@hoppscotch/ui')['HoppSmartTree'] + HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow'] + HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows'] + HttpAuthorization: typeof import('./components/http/Authorization.vue')['default'] + HttpAuthorizationAkamaiEG: typeof import('./components/http/authorization/AkamaiEG.vue')['default'] + HttpAuthorizationApiKey: typeof import('./components/http/authorization/ApiKey.vue')['default'] + HttpAuthorizationASAP: typeof import('./components/http/authorization/ASAP.vue')['default'] + HttpAuthorizationAWSSign: typeof import('./components/http/authorization/AWSSign.vue')['default'] + HttpAuthorizationBasic: typeof import('./components/http/authorization/Basic.vue')['default'] + HttpAuthorizationDigest: typeof import('./components/http/authorization/Digest.vue')['default'] + HttpAuthorizationHAWK: typeof import('./components/http/authorization/HAWK.vue')['default'] + HttpAuthorizationJWT: typeof import('./components/http/authorization/JWT.vue')['default'] + HttpAuthorizationNTLM: typeof import('./components/http/authorization/NTLM.vue')['default'] + HttpAuthorizationOAuth2: typeof import('./components/http/authorization/OAuth2.vue')['default'] + HttpBody: typeof import('./components/http/Body.vue')['default'] + HttpBodyBinary: typeof import('./components/http/BodyBinary.vue')['default'] + HttpBodyParameters: typeof import('./components/http/BodyParameters.vue')['default'] + HttpCodegen: typeof import('./components/http/Codegen.vue')['default'] + HttpCodegenModal: typeof import('./components/http/CodegenModal.vue')['default'] + HttpExampleLenseBodyRenderer: typeof import('./components/http/example/LenseBodyRenderer.vue')['default'] + HttpExampleResponse: typeof import('./components/http/example/Response.vue')['default'] + HttpExampleResponseMeta: typeof import('./components/http/example/ResponseMeta.vue')['default'] + HttpExampleResponseRequest: typeof import('./components/http/example/ResponseRequest.vue')['default'] + HttpExampleResponseTab: typeof import('./components/http/example/ResponseTab.vue')['default'] + HttpHeaders: typeof import('./components/http/Headers.vue')['default'] + HttpImportCurl: typeof import('./components/http/ImportCurl.vue')['default'] + HttpInheritedScriptsModal: typeof import('./components/http/InheritedScriptsModal.vue')['default'] + HttpKeyValue: typeof import('./components/http/KeyValue.vue')['default'] + HttpParameters: typeof import('./components/http/Parameters.vue')['default'] + HttpPreRequestScript: typeof import('./components/http/PreRequestScript.vue')['default'] + HttpRawBody: typeof import('./components/http/RawBody.vue')['default'] + HttpReqChangeConfirmModal: typeof import('./components/http/ReqChangeConfirmModal.vue')['default'] + HttpRequest: typeof import('./components/http/Request.vue')['default'] + HttpRequestOptions: typeof import('./components/http/RequestOptions.vue')['default'] + HttpRequestTab: typeof import('./components/http/RequestTab.vue')['default'] + HttpRequestVariables: typeof import('./components/http/RequestVariables.vue')['default'] + HttpResponse: typeof import('./components/http/Response.vue')['default'] + HttpResponseInterface: typeof import('./components/http/ResponseInterface.vue')['default'] + HttpResponseMeta: typeof import('./components/http/ResponseMeta.vue')['default'] + HttpSaveResponseName: typeof import('./components/http/SaveResponseName.vue')['default'] + HttpSidebar: typeof import('./components/http/Sidebar.vue')['default'] + HttpTabHead: typeof import('./components/http/TabHead.vue')['default'] + HttpTestEnv: typeof import('./components/http/test/Env.vue')['default'] + HttpTestFolder: typeof import('./components/http/test/Folder.vue')['default'] + HttpTestRequest: typeof import('./components/http/test/Request.vue')['default'] + HttpTestResponse: typeof import('./components/http/test/Response.vue')['default'] + HttpTestResult: typeof import('./components/http/TestResult.vue')['default'] + HttpTestResultEntry: typeof import('./components/http/TestResultEntry.vue')['default'] + HttpTestResultEnv: typeof import('./components/http/TestResultEnv.vue')['default'] + HttpTestResultFolder: typeof import('./components/http/test/ResultFolder.vue')['default'] + HttpTestResultReport: typeof import('./components/http/TestResultReport.vue')['default'] + HttpTestResultRequest: typeof import('./components/http/test/ResultRequest.vue')['default'] + HttpTestRunner: typeof import('./components/http/test/Runner.vue')['default'] + HttpTestRunnerMeta: typeof import('./components/http/test/RunnerMeta.vue')['default'] + HttpTestRunnerModal: typeof import('./components/http/test/RunnerModal.vue')['default'] + HttpTestRunnerResult: typeof import('./components/http/test/RunnerResult.vue')['default'] + HttpTests: typeof import('./components/http/Tests.vue')['default'] + HttpTestTestResult: typeof import('./components/http/test/TestResult.vue')['default'] + HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default'] + IconLucideActivity: typeof import('~icons/lucide/activity')['default'] + IconLucideAlertCircle: typeof import('~icons/lucide/alert-circle')['default'] + IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default'] + IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] + IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default'] + IconLucideBrush: typeof import('~icons/lucide/brush')['default'] + IconLucideCheck: typeof import('~icons/lucide/check')['default'] + IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default'] + IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default'] + IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default'] + IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default'] + IconLucideFileQuestion: typeof import('~icons/lucide/file-question')['default'] + IconLucideFileText: typeof import('~icons/lucide/file-text')['default'] + IconLucideFileX: typeof import('~icons/lucide/file-x')['default'] + IconLucideFolder: typeof import('~icons/lucide/folder')['default'] + IconLucideFolderOpen: typeof import('~icons/lucide/folder-open')['default'] + IconLucideGlobe: typeof import('~icons/lucide/globe')['default'] + IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default'] + IconLucideInbox: typeof import('~icons/lucide/inbox')['default'] + IconLucideInfo: typeof import('~icons/lucide/info')['default'] + IconLucideLayers: typeof import('~icons/lucide/layers')['default'] + IconLucideListEnd: typeof import('~icons/lucide/list-end')['default'] + IconLucideLoader2: typeof import('~icons/lucide/loader2')['default'] + IconLucideMinus: typeof import('~icons/lucide/minus')['default'] + IconLucidePlusCircle: typeof import('~icons/lucide/plus-circle')['default'] + IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default'] + IconLucideRss: typeof import('~icons/lucide/rss')['default'] + IconLucideSearch: typeof import('~icons/lucide/search')['default'] + IconLucideTerminal: typeof import('~icons/lucide/terminal')['default'] + IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default'] + IconLucideUsers: typeof import('~icons/lucide/users')['default'] + IconLucideVerified: typeof import('~icons/lucide/verified')['default'] + IconLucideX: typeof import('~icons/lucide/x')['default'] + ImportExportBase: typeof import('./components/importExport/Base.vue')['default'] + ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default'] + ImportExportImportExportSourcesList: typeof import('./components/importExport/ImportExportSourcesList.vue')['default'] + ImportExportImportExportStepsAllCollectionImport: typeof import('./components/importExport/ImportExportSteps/AllCollectionImport.vue')['default'] + ImportExportImportExportStepsFileImport: typeof import('./components/importExport/ImportExportSteps/FileImport.vue')['default'] + ImportExportImportExportStepsImportSummary: typeof import('./components/importExport/ImportExportSteps/ImportSummary.vue')['default'] + ImportExportImportExportStepsMyCollectionImport: typeof import('./components/importExport/ImportExportSteps/MyCollectionImport.vue')['default'] + ImportExportImportExportStepsUrlImport: typeof import('./components/importExport/ImportExportSteps/UrlImport.vue')['default'] + InstanceSwitcher: typeof import('./components/instance/Switcher.vue')['default'] + LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default'] + LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default'] + LensesRenderersAudioLensRenderer: typeof import('./components/lenses/renderers/AudioLensRenderer.vue')['default'] + LensesRenderersHTMLLensRenderer: typeof import('./components/lenses/renderers/HTMLLensRenderer.vue')['default'] + LensesRenderersImageLensRenderer: typeof import('./components/lenses/renderers/ImageLensRenderer.vue')['default'] + LensesRenderersJSONLensRenderer: typeof import('./components/lenses/renderers/JSONLensRenderer.vue')['default'] + LensesRenderersPDFLensRenderer: typeof import('./components/lenses/renderers/PDFLensRenderer.vue')['default'] + LensesRenderersRawLensRenderer: typeof import('./components/lenses/renderers/RawLensRenderer.vue')['default'] + LensesRenderersVideoLensRenderer: typeof import('./components/lenses/renderers/VideoLensRenderer.vue')['default'] + LensesRenderersXMLLensRenderer: typeof import('./components/lenses/renderers/XMLLensRenderer.vue')['default'] + LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default'] + MockServerConfigureMockServerModal: typeof import('./components/mockServer/ConfigureMockServerModal.vue')['default'] + MockServerCreateNewMockServerModal: typeof import('./components/mockServer/CreateNewMockServerModal.vue')['default'] + MockServerEditMockServer: typeof import('./components/mockServer/EditMockServer.vue')['default'] + MockServerLogSection: typeof import('./components/mockServer/LogSection.vue')['default'] + MockServerMockServerCreatedInfo: typeof import('./components/mockServer/MockServerCreatedInfo.vue')['default'] + MockServerMockServerDashboard: typeof import('./components/mockServer/MockServerDashboard.vue')['default'] + MockServerMockServerLogs: typeof import('./components/mockServer/MockServerLogs.vue')['default'] + MonacoScriptEditor: typeof import('./components/MonacoScriptEditor.vue')['default'] + OrganizationSwitcher: typeof import('./components/organization/Switcher.vue')['default'] + Profile: typeof import('./components/profile/index.vue')['default'] + ProfileUserDelete: typeof import('./components/profile/UserDelete.vue')['default'] + RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default'] + RealtimeConnectionConfig: typeof import('./components/realtime/ConnectionConfig.vue')['default'] + RealtimeLog: typeof import('./components/realtime/Log.vue')['default'] + RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default'] + RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default'] + SettingsAgent: typeof import('./components/settings/Agent.vue')['default'] + SettingsAgentSubtitle: typeof import('./components/settings/AgentSubtitle.vue')['default'] + SettingsDesktop: typeof import('./components/settings/Desktop.vue')['default'] + SettingsExtension: typeof import('./components/settings/Extension.vue')['default'] + SettingsExtensionSubtitle: typeof import('./components/settings/ExtensionSubtitle.vue')['default'] + SettingsInterceptorErrorPlaceholder: typeof import('./components/settings/InterceptorErrorPlaceholder.vue')['default'] + SettingsNative: typeof import('./components/settings/Native.vue')['default'] + SettingsProxy: typeof import('./components/settings/Proxy.vue')['default'] + Share: typeof import('./components/share/index.vue')['default'] + ShareCreateModal: typeof import('./components/share/CreateModal.vue')['default'] + ShareCustomizeModal: typeof import('./components/share/CustomizeModal.vue')['default'] + ShareModal: typeof import('./components/share/Modal.vue')['default'] + ShareRequest: typeof import('./components/share/Request.vue')['default'] + ShareTemplatesButton: typeof import('./components/share/templates/Button.vue')['default'] + ShareTemplatesEmbeds: typeof import('./components/share/templates/Embeds.vue')['default'] + ShareTemplatesLink: typeof import('./components/share/templates/Link.vue')['default'] + SmartAccentModePicker: typeof import('./components/smart/AccentModePicker.vue')['default'] + SmartChangeLanguage: typeof import('./components/smart/ChangeLanguage.vue')['default'] + SmartColorModePicker: typeof import('./components/smart/ColorModePicker.vue')['default'] + SmartEncodingPicker: typeof import('./components/smart/EncodingPicker.vue')['default'] + SmartEnvInput: typeof import('./components/smart/EnvInput.vue')['default'] + TabPrimary: typeof import('./components/tab/Primary.vue')['default'] + TabSecondary: typeof import('./components/tab/Secondary.vue')['default'] + TabsNav: typeof import('./components/TabsNav.vue')['default'] + TeamsAdd: typeof import('./components/teams/Add.vue')['default'] + TeamsEdit: typeof import('./components/teams/Edit.vue')['default'] + TeamsInvite: typeof import('./components/teams/Invite.vue')['default'] + TeamsMemberStack: typeof import('./components/teams/MemberStack.vue')['default'] + TeamsModal: typeof import('./components/teams/Modal.vue')['default'] + TeamsTeam: typeof import('./components/teams/Team.vue')['default'] + TeamsView: typeof import('./components/teams/View.vue')['default'] + Tippy: typeof import('vue-tippy')['Tippy'] + WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default'] + WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default'] + } +} diff --git a/packages/hoppscotch-common/src/components/MonacoScriptEditor.vue b/packages/hoppscotch-common/src/components/MonacoScriptEditor.vue new file mode 100644 index 0000000..0b1e602 --- /dev/null +++ b/packages/hoppscotch-common/src/components/MonacoScriptEditor.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/TabsNav.vue b/packages/hoppscotch-common/src/components/TabsNav.vue new file mode 100644 index 0000000..99d900f --- /dev/null +++ b/packages/hoppscotch-common/src/components/TabsNav.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/accessTokens/GenerateModal.vue b/packages/hoppscotch-common/src/components/accessTokens/GenerateModal.vue new file mode 100644 index 0000000..b368d75 --- /dev/null +++ b/packages/hoppscotch-common/src/components/accessTokens/GenerateModal.vue @@ -0,0 +1,221 @@ + + + diff --git a/packages/hoppscotch-common/src/components/accessTokens/List.vue b/packages/hoppscotch-common/src/components/accessTokens/List.vue new file mode 100644 index 0000000..5fe7990 --- /dev/null +++ b/packages/hoppscotch-common/src/components/accessTokens/List.vue @@ -0,0 +1,128 @@ + + + diff --git a/packages/hoppscotch-common/src/components/accessTokens/Overview.vue b/packages/hoppscotch-common/src/components/accessTokens/Overview.vue new file mode 100644 index 0000000..0b60e5d --- /dev/null +++ b/packages/hoppscotch-common/src/components/accessTokens/Overview.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/hoppscotch-common/src/components/accessTokens/index.vue b/packages/hoppscotch-common/src/components/accessTokens/index.vue new file mode 100644 index 0000000..f7c23ab --- /dev/null +++ b/packages/hoppscotch-common/src/components/accessTokens/index.vue @@ -0,0 +1,208 @@ + + + diff --git a/packages/hoppscotch-common/src/components/aiexperiments/MergeView.vue b/packages/hoppscotch-common/src/components/aiexperiments/MergeView.vue new file mode 100644 index 0000000..fd70197 --- /dev/null +++ b/packages/hoppscotch-common/src/components/aiexperiments/MergeView.vue @@ -0,0 +1,81 @@ + + + diff --git a/packages/hoppscotch-common/src/components/aiexperiments/ModifyBodyModal.vue b/packages/hoppscotch-common/src/components/aiexperiments/ModifyBodyModal.vue new file mode 100644 index 0000000..910ba57 --- /dev/null +++ b/packages/hoppscotch-common/src/components/aiexperiments/ModifyBodyModal.vue @@ -0,0 +1,161 @@ + + + diff --git a/packages/hoppscotch-common/src/components/aiexperiments/ModifyPreRequestModal.vue b/packages/hoppscotch-common/src/components/aiexperiments/ModifyPreRequestModal.vue new file mode 100644 index 0000000..8025633 --- /dev/null +++ b/packages/hoppscotch-common/src/components/aiexperiments/ModifyPreRequestModal.vue @@ -0,0 +1,159 @@ + + + diff --git a/packages/hoppscotch-common/src/components/aiexperiments/ModifyTestScriptModal.vue b/packages/hoppscotch-common/src/components/aiexperiments/ModifyTestScriptModal.vue new file mode 100644 index 0000000..57f97cd --- /dev/null +++ b/packages/hoppscotch-common/src/components/aiexperiments/ModifyTestScriptModal.vue @@ -0,0 +1,159 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/ActionHandler.vue b/packages/hoppscotch-common/src/components/app/ActionHandler.vue new file mode 100644 index 0000000..2bcac28 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/ActionHandler.vue @@ -0,0 +1,45 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/Banner.vue b/packages/hoppscotch-common/src/components/app/Banner.vue new file mode 100644 index 0000000..be99f47 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/Banner.vue @@ -0,0 +1,64 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/ContextMenu.vue b/packages/hoppscotch-common/src/components/app/ContextMenu.vue new file mode 100644 index 0000000..00e7240 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/ContextMenu.vue @@ -0,0 +1,76 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/DeveloperOptions.vue b/packages/hoppscotch-common/src/components/app/DeveloperOptions.vue new file mode 100644 index 0000000..5a028bd --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/DeveloperOptions.vue @@ -0,0 +1,65 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/Footer.vue b/packages/hoppscotch-common/src/components/app/Footer.vue new file mode 100644 index 0000000..6676e5d --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/Footer.vue @@ -0,0 +1,263 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/GitHubStarButton.vue b/packages/hoppscotch-common/src/components/app/GitHubStarButton.vue new file mode 100644 index 0000000..a310ad0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/GitHubStarButton.vue @@ -0,0 +1,31 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/Header.vue b/packages/hoppscotch-common/src/components/app/Header.vue new file mode 100644 index 0000000..6bfd22e --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/Header.vue @@ -0,0 +1,665 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/Inspection.vue b/packages/hoppscotch-common/src/components/app/Inspection.vue new file mode 100644 index 0000000..fa628bf --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/Inspection.vue @@ -0,0 +1,115 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/KernelInterceptor.vue b/packages/hoppscotch-common/src/components/app/KernelInterceptor.vue new file mode 100644 index 0000000..a0342c9 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/KernelInterceptor.vue @@ -0,0 +1,97 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/Logo.vue b/packages/hoppscotch-common/src/components/app/Logo.vue new file mode 100644 index 0000000..9bcd074 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/Logo.vue @@ -0,0 +1,159 @@ + diff --git a/packages/hoppscotch-common/src/components/app/Markdown.vue b/packages/hoppscotch-common/src/components/app/Markdown.vue new file mode 100644 index 0000000..27d867f --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/Markdown.vue @@ -0,0 +1,35 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/Options.vue b/packages/hoppscotch-common/src/components/app/Options.vue new file mode 100644 index 0000000..b276319 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/Options.vue @@ -0,0 +1,102 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/PaneLayout.vue b/packages/hoppscotch-common/src/components/app/PaneLayout.vue new file mode 100644 index 0000000..75ba9f7 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/PaneLayout.vue @@ -0,0 +1,146 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/Share.vue b/packages/hoppscotch-common/src/components/app/Share.vue new file mode 100644 index 0000000..838ae23 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/Share.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/app/Shortcuts.vue b/packages/hoppscotch-common/src/components/app/Shortcuts.vue new file mode 100644 index 0000000..f4efd9f --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/Shortcuts.vue @@ -0,0 +1,108 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/ShortcutsEntry.vue b/packages/hoppscotch-common/src/components/app/ShortcutsEntry.vue new file mode 100644 index 0000000..02c202c --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/ShortcutsEntry.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/ShortcutsPrompt.vue b/packages/hoppscotch-common/src/components/app/ShortcutsPrompt.vue new file mode 100644 index 0000000..b6eb3cd --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/ShortcutsPrompt.vue @@ -0,0 +1,53 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/Sidenav.vue b/packages/hoppscotch-common/src/components/app/Sidenav.vue new file mode 100644 index 0000000..e325ae2 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/Sidenav.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/app/SpotlightSearch.vue b/packages/hoppscotch-common/src/components/app/SpotlightSearch.vue new file mode 100644 index 0000000..01acbde --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/SpotlightSearch.vue @@ -0,0 +1,24 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/Support.vue b/packages/hoppscotch-common/src/components/app/Support.vue new file mode 100644 index 0000000..e06d5bb --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/Support.vue @@ -0,0 +1,64 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/WhatsNewDialog.vue b/packages/hoppscotch-common/src/components/app/WhatsNewDialog.vue new file mode 100644 index 0000000..c0b6597 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/WhatsNewDialog.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/app/spotlight/Entry.vue b/packages/hoppscotch-common/src/components/app/spotlight/Entry.vue new file mode 100644 index 0000000..aecc5b1 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/spotlight/Entry.vue @@ -0,0 +1,125 @@ + + + + + + + diff --git a/packages/hoppscotch-common/src/components/app/spotlight/entry/GQLHistory.vue b/packages/hoppscotch-common/src/components/app/spotlight/entry/GQLHistory.vue new file mode 100644 index 0000000..55c81d0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/spotlight/entry/GQLHistory.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/spotlight/entry/GQLRequest.vue b/packages/hoppscotch-common/src/components/app/spotlight/entry/GQLRequest.vue new file mode 100644 index 0000000..bfd266f --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/spotlight/entry/GQLRequest.vue @@ -0,0 +1,65 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/spotlight/entry/IconSelected.vue b/packages/hoppscotch-common/src/components/app/spotlight/entry/IconSelected.vue new file mode 100644 index 0000000..306a15a --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/spotlight/entry/IconSelected.vue @@ -0,0 +1,3 @@ + diff --git a/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTHistory.vue b/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTHistory.vue new file mode 100644 index 0000000..8ad2a90 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTHistory.vue @@ -0,0 +1,43 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTRequest.vue b/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTRequest.vue new file mode 100644 index 0000000..95f2510 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTRequest.vue @@ -0,0 +1,71 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTTeamRequestEntry.vue b/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTTeamRequestEntry.vue new file mode 100644 index 0000000..109367c --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTTeamRequestEntry.vue @@ -0,0 +1,32 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/spotlight/index.vue b/packages/hoppscotch-common/src/components/app/spotlight/index.vue new file mode 100644 index 0000000..285d9da --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/spotlight/index.vue @@ -0,0 +1,309 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/Add.vue b/packages/hoppscotch-common/src/components/collections/Add.vue new file mode 100644 index 0000000..e2708ff --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/Add.vue @@ -0,0 +1,88 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/AddFolder.vue b/packages/hoppscotch-common/src/components/collections/AddFolder.vue new file mode 100644 index 0000000..faad5da --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/AddFolder.vue @@ -0,0 +1,88 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/AddRequest.vue b/packages/hoppscotch-common/src/components/collections/AddRequest.vue new file mode 100644 index 0000000..381e769 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/AddRequest.vue @@ -0,0 +1,83 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/Collection.vue b/packages/hoppscotch-common/src/components/collections/Collection.vue new file mode 100644 index 0000000..08c345d --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/Collection.vue @@ -0,0 +1,729 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/Edit.vue b/packages/hoppscotch-common/src/components/collections/Edit.vue new file mode 100644 index 0000000..7b3a7a7 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/Edit.vue @@ -0,0 +1,88 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/EditFolder.vue b/packages/hoppscotch-common/src/components/collections/EditFolder.vue new file mode 100644 index 0000000..295f7c8 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/EditFolder.vue @@ -0,0 +1,88 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/EditRequest.vue b/packages/hoppscotch-common/src/components/collections/EditRequest.vue new file mode 100644 index 0000000..ed45e7a --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/EditRequest.vue @@ -0,0 +1,173 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/EditResponse.vue b/packages/hoppscotch-common/src/components/collections/EditResponse.vue new file mode 100644 index 0000000..44bc9a1 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/EditResponse.vue @@ -0,0 +1,95 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/ExampleResponse.vue b/packages/hoppscotch-common/src/components/collections/ExampleResponse.vue new file mode 100644 index 0000000..d14952a --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/ExampleResponse.vue @@ -0,0 +1,247 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/ExportFormatList.vue b/packages/hoppscotch-common/src/components/collections/ExportFormatList.vue new file mode 100644 index 0000000..6bc870b --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/ExportFormatList.vue @@ -0,0 +1,79 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/ExportFormatModal.vue b/packages/hoppscotch-common/src/components/collections/ExportFormatModal.vue new file mode 100644 index 0000000..b6c0d25 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/ExportFormatModal.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/ExportFormatStep.vue b/packages/hoppscotch-common/src/components/collections/ExportFormatStep.vue new file mode 100644 index 0000000..6d00202 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/ExportFormatStep.vue @@ -0,0 +1,18 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/ImportExport.vue b/packages/hoppscotch-common/src/components/collections/ImportExport.vue new file mode 100644 index 0000000..051886f --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/ImportExport.vue @@ -0,0 +1,946 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/MyCollections.vue b/packages/hoppscotch-common/src/components/collections/MyCollections.vue new file mode 100644 index 0000000..dbcd055 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/MyCollections.vue @@ -0,0 +1,996 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/Properties.vue b/packages/hoppscotch-common/src/components/collections/Properties.vue new file mode 100644 index 0000000..f97f94a --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/Properties.vue @@ -0,0 +1,511 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/Request.vue b/packages/hoppscotch-common/src/components/collections/Request.vue new file mode 100644 index 0000000..1b615dc --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/Request.vue @@ -0,0 +1,495 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/SaveRequest.vue b/packages/hoppscotch-common/src/components/collections/SaveRequest.vue new file mode 100644 index 0000000..cc41478 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/SaveRequest.vue @@ -0,0 +1,683 @@ + + + + diff --git a/packages/hoppscotch-common/src/components/collections/TeamCollections.vue b/packages/hoppscotch-common/src/components/collections/TeamCollections.vue new file mode 100644 index 0000000..3a56c3a --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/TeamCollections.vue @@ -0,0 +1,1137 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/Variables.vue b/packages/hoppscotch-common/src/components/collections/Variables.vue new file mode 100644 index 0000000..07cabe1 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/Variables.vue @@ -0,0 +1,327 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/CollectionPreview.vue b/packages/hoppscotch-common/src/components/collections/documentation/CollectionPreview.vue new file mode 100644 index 0000000..589209f --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/CollectionPreview.vue @@ -0,0 +1,169 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/CollectionStructure.vue b/packages/hoppscotch-common/src/components/collections/documentation/CollectionStructure.vue new file mode 100644 index 0000000..9431c1d --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/CollectionStructure.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/EnvironmentPicker.vue b/packages/hoppscotch-common/src/components/collections/documentation/EnvironmentPicker.vue new file mode 100644 index 0000000..041ddbb --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/EnvironmentPicker.vue @@ -0,0 +1,191 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/FolderItem.vue b/packages/hoppscotch-common/src/components/collections/documentation/FolderItem.vue new file mode 100644 index 0000000..a3b11d8 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/FolderItem.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/LazyDocumentationItem.vue b/packages/hoppscotch-common/src/components/collections/documentation/LazyDocumentationItem.vue new file mode 100644 index 0000000..8d9577e --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/LazyDocumentationItem.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/MarkdownEditor.vue b/packages/hoppscotch-common/src/components/collections/documentation/MarkdownEditor.vue new file mode 100644 index 0000000..b277fa4 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/MarkdownEditor.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/Preview.vue b/packages/hoppscotch-common/src/components/collections/documentation/Preview.vue new file mode 100644 index 0000000..44a5e58 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/Preview.vue @@ -0,0 +1,512 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/PublishDocForm.vue b/packages/hoppscotch-common/src/components/collections/documentation/PublishDocForm.vue new file mode 100644 index 0000000..92c1599 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/PublishDocForm.vue @@ -0,0 +1,157 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/PublishDocModal.vue b/packages/hoppscotch-common/src/components/collections/documentation/PublishDocModal.vue new file mode 100644 index 0000000..4c7ad3f --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/PublishDocModal.vue @@ -0,0 +1,283 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/PublishDocSnapshotPreview.vue b/packages/hoppscotch-common/src/components/collections/documentation/PublishDocSnapshotPreview.vue new file mode 100644 index 0000000..4ecc9a4 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/PublishDocSnapshotPreview.vue @@ -0,0 +1,477 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/RequestItem.vue b/packages/hoppscotch-common/src/components/collections/documentation/RequestItem.vue new file mode 100644 index 0000000..5a839e9 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/RequestItem.vue @@ -0,0 +1,70 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/RequestPreview.vue b/packages/hoppscotch-common/src/components/collections/documentation/RequestPreview.vue new file mode 100644 index 0000000..bc6af30 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/RequestPreview.vue @@ -0,0 +1,554 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/index.vue b/packages/hoppscotch-common/src/components/collections/documentation/index.vue new file mode 100644 index 0000000..6100f36 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/index.vue @@ -0,0 +1,1137 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/Auth.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/Auth.vue new file mode 100644 index 0000000..b364c7c --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/Auth.vue @@ -0,0 +1,512 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/CurlView.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/CurlView.vue new file mode 100644 index 0000000..31c8162 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/CurlView.vue @@ -0,0 +1,566 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/Headers.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/Headers.vue new file mode 100644 index 0000000..707fea8 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/Headers.vue @@ -0,0 +1,102 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/Parameters.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/Parameters.vue new file mode 100644 index 0000000..008595f --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/Parameters.vue @@ -0,0 +1,66 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/RequestBody.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/RequestBody.vue new file mode 100644 index 0000000..6d7edcf --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/RequestBody.vue @@ -0,0 +1,101 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/Response.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/Response.vue new file mode 100644 index 0000000..b2367bb --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/Response.vue @@ -0,0 +1,248 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/Variables.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/Variables.vue new file mode 100644 index 0000000..9c25d5b --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/Variables.vue @@ -0,0 +1,83 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/graphql/Add.vue b/packages/hoppscotch-common/src/components/collections/graphql/Add.vue new file mode 100644 index 0000000..1cf3060 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/graphql/Add.vue @@ -0,0 +1,93 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/graphql/AddFolder.vue b/packages/hoppscotch-common/src/components/collections/graphql/AddFolder.vue new file mode 100644 index 0000000..0d69104 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/graphql/AddFolder.vue @@ -0,0 +1,80 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/graphql/AddRequest.vue b/packages/hoppscotch-common/src/components/collections/graphql/AddRequest.vue new file mode 100644 index 0000000..98d55c4 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/graphql/AddRequest.vue @@ -0,0 +1,81 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue b/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue new file mode 100644 index 0000000..14803af --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue @@ -0,0 +1,402 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/graphql/Edit.vue b/packages/hoppscotch-common/src/components/collections/graphql/Edit.vue new file mode 100644 index 0000000..8018dd0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/graphql/Edit.vue @@ -0,0 +1,89 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/graphql/EditFolder.vue b/packages/hoppscotch-common/src/components/collections/graphql/EditFolder.vue new file mode 100644 index 0000000..e668113 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/graphql/EditFolder.vue @@ -0,0 +1,81 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/graphql/EditRequest.vue b/packages/hoppscotch-common/src/components/collections/graphql/EditRequest.vue new file mode 100644 index 0000000..98519a0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/graphql/EditRequest.vue @@ -0,0 +1,197 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/graphql/Folder.vue b/packages/hoppscotch-common/src/components/collections/graphql/Folder.vue new file mode 100644 index 0000000..2b710b1 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/graphql/Folder.vue @@ -0,0 +1,366 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/graphql/ImportExport.vue b/packages/hoppscotch-common/src/components/collections/graphql/ImportExport.vue new file mode 100644 index 0000000..f1b72cc --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/graphql/ImportExport.vue @@ -0,0 +1,259 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/graphql/Request.vue b/packages/hoppscotch-common/src/components/collections/graphql/Request.vue new file mode 100644 index 0000000..fe0b3ba --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/graphql/Request.vue @@ -0,0 +1,253 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/graphql/index.vue b/packages/hoppscotch-common/src/components/collections/graphql/index.vue new file mode 100644 index 0000000..cd454fc --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/graphql/index.vue @@ -0,0 +1,669 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/index.vue b/packages/hoppscotch-common/src/components/collections/index.vue new file mode 100644 index 0000000..efa2785 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/index.vue @@ -0,0 +1,3851 @@ + + + diff --git a/packages/hoppscotch-common/src/components/console/Item.vue b/packages/hoppscotch-common/src/components/console/Item.vue new file mode 100644 index 0000000..e4385a7 --- /dev/null +++ b/packages/hoppscotch-common/src/components/console/Item.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/console/Panel.vue b/packages/hoppscotch-common/src/components/console/Panel.vue new file mode 100644 index 0000000..4e48045 --- /dev/null +++ b/packages/hoppscotch-common/src/components/console/Panel.vue @@ -0,0 +1,55 @@ + + + diff --git a/packages/hoppscotch-common/src/components/console/Value.vue b/packages/hoppscotch-common/src/components/console/Value.vue new file mode 100644 index 0000000..d23a411 --- /dev/null +++ b/packages/hoppscotch-common/src/components/console/Value.vue @@ -0,0 +1,140 @@ + + + + + + + diff --git a/packages/hoppscotch-common/src/components/cookies/AllModal.vue b/packages/hoppscotch-common/src/components/cookies/AllModal.vue new file mode 100644 index 0000000..22de516 --- /dev/null +++ b/packages/hoppscotch-common/src/components/cookies/AllModal.vue @@ -0,0 +1,414 @@ + + + diff --git a/packages/hoppscotch-common/src/components/cookies/EditCookie.vue b/packages/hoppscotch-common/src/components/cookies/EditCookie.vue new file mode 100644 index 0000000..9e2413e --- /dev/null +++ b/packages/hoppscotch-common/src/components/cookies/EditCookie.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/documentation/Content.vue b/packages/hoppscotch-common/src/components/documentation/Content.vue new file mode 100644 index 0000000..7361eeb --- /dev/null +++ b/packages/hoppscotch-common/src/components/documentation/Content.vue @@ -0,0 +1,226 @@ + + + diff --git a/packages/hoppscotch-common/src/components/documentation/Header.vue b/packages/hoppscotch-common/src/components/documentation/Header.vue new file mode 100644 index 0000000..79bb8dd --- /dev/null +++ b/packages/hoppscotch-common/src/components/documentation/Header.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/documentation/Skeleton.vue b/packages/hoppscotch-common/src/components/documentation/Skeleton.vue new file mode 100644 index 0000000..1b6991e --- /dev/null +++ b/packages/hoppscotch-common/src/components/documentation/Skeleton.vue @@ -0,0 +1,94 @@ + diff --git a/packages/hoppscotch-common/src/components/embeds/Header.vue b/packages/hoppscotch-common/src/components/embeds/Header.vue new file mode 100644 index 0000000..8cd6d2d --- /dev/null +++ b/packages/hoppscotch-common/src/components/embeds/Header.vue @@ -0,0 +1,33 @@ + + + diff --git a/packages/hoppscotch-common/src/components/embeds/Request.vue b/packages/hoppscotch-common/src/components/embeds/Request.vue new file mode 100644 index 0000000..dea18bb --- /dev/null +++ b/packages/hoppscotch-common/src/components/embeds/Request.vue @@ -0,0 +1,189 @@ + + + diff --git a/packages/hoppscotch-common/src/components/embeds/index.vue b/packages/hoppscotch-common/src/components/embeds/index.vue new file mode 100644 index 0000000..f1460ab --- /dev/null +++ b/packages/hoppscotch-common/src/components/embeds/index.vue @@ -0,0 +1,74 @@ + + + diff --git a/packages/hoppscotch-common/src/components/environments/Add.vue b/packages/hoppscotch-common/src/components/environments/Add.vue new file mode 100644 index 0000000..1a9ec22 --- /dev/null +++ b/packages/hoppscotch-common/src/components/environments/Add.vue @@ -0,0 +1,250 @@ + + + diff --git a/packages/hoppscotch-common/src/components/environments/ImportExport.vue b/packages/hoppscotch-common/src/components/environments/ImportExport.vue new file mode 100644 index 0000000..b1a168d --- /dev/null +++ b/packages/hoppscotch-common/src/components/environments/ImportExport.vue @@ -0,0 +1,498 @@ + + + diff --git a/packages/hoppscotch-common/src/components/environments/Properties.vue b/packages/hoppscotch-common/src/components/environments/Properties.vue new file mode 100644 index 0000000..ede8f81 --- /dev/null +++ b/packages/hoppscotch-common/src/components/environments/Properties.vue @@ -0,0 +1,122 @@ + + + diff --git a/packages/hoppscotch-common/src/components/environments/Selector.vue b/packages/hoppscotch-common/src/components/environments/Selector.vue new file mode 100644 index 0000000..a553ab2 --- /dev/null +++ b/packages/hoppscotch-common/src/components/environments/Selector.vue @@ -0,0 +1,739 @@ + + + diff --git a/packages/hoppscotch-common/src/components/environments/index.vue b/packages/hoppscotch-common/src/components/environments/index.vue new file mode 100644 index 0000000..a74cafa --- /dev/null +++ b/packages/hoppscotch-common/src/components/environments/index.vue @@ -0,0 +1,426 @@ + + + diff --git a/packages/hoppscotch-common/src/components/environments/my/Details.vue b/packages/hoppscotch-common/src/components/environments/my/Details.vue new file mode 100644 index 0000000..3843229 --- /dev/null +++ b/packages/hoppscotch-common/src/components/environments/my/Details.vue @@ -0,0 +1,652 @@ + + + diff --git a/packages/hoppscotch-common/src/components/environments/my/Environment.vue b/packages/hoppscotch-common/src/components/environments/my/Environment.vue new file mode 100644 index 0000000..5cb94a7 --- /dev/null +++ b/packages/hoppscotch-common/src/components/environments/my/Environment.vue @@ -0,0 +1,251 @@ + + + diff --git a/packages/hoppscotch-common/src/components/environments/my/index.vue b/packages/hoppscotch-common/src/components/environments/my/index.vue new file mode 100644 index 0000000..bc50d35 --- /dev/null +++ b/packages/hoppscotch-common/src/components/environments/my/index.vue @@ -0,0 +1,228 @@ + + + diff --git a/packages/hoppscotch-common/src/components/environments/teams/Details.vue b/packages/hoppscotch-common/src/components/environments/teams/Details.vue new file mode 100644 index 0000000..94f23d9 --- /dev/null +++ b/packages/hoppscotch-common/src/components/environments/teams/Details.vue @@ -0,0 +1,670 @@ + + + diff --git a/packages/hoppscotch-common/src/components/environments/teams/Environment.vue b/packages/hoppscotch-common/src/components/environments/teams/Environment.vue new file mode 100644 index 0000000..62e80df --- /dev/null +++ b/packages/hoppscotch-common/src/components/environments/teams/Environment.vue @@ -0,0 +1,245 @@ + + + diff --git a/packages/hoppscotch-common/src/components/environments/teams/index.vue b/packages/hoppscotch-common/src/components/environments/teams/index.vue new file mode 100644 index 0000000..2f3ab3d --- /dev/null +++ b/packages/hoppscotch-common/src/components/environments/teams/index.vue @@ -0,0 +1,293 @@ + + + diff --git a/packages/hoppscotch-common/src/components/firebase/Login.vue b/packages/hoppscotch-common/src/components/firebase/Login.vue new file mode 100644 index 0000000..6cd6017 --- /dev/null +++ b/packages/hoppscotch-common/src/components/firebase/Login.vue @@ -0,0 +1,406 @@ + + + diff --git a/packages/hoppscotch-common/src/components/firebase/Logout.vue b/packages/hoppscotch-common/src/components/firebase/Logout.vue new file mode 100644 index 0000000..c0ef6be --- /dev/null +++ b/packages/hoppscotch-common/src/components/firebase/Logout.vue @@ -0,0 +1,83 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Argument.vue b/packages/hoppscotch-common/src/components/graphql/Argument.vue new file mode 100644 index 0000000..fc7b4bf --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Argument.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Arguments.vue b/packages/hoppscotch-common/src/components/graphql/Arguments.vue new file mode 100644 index 0000000..3c0a4e9 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Arguments.vue @@ -0,0 +1,32 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Authorization.vue b/packages/hoppscotch-common/src/components/graphql/Authorization.vue new file mode 100644 index 0000000..a19ff78 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Authorization.vue @@ -0,0 +1,350 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/DefaultValue.vue b/packages/hoppscotch-common/src/components/graphql/DefaultValue.vue new file mode 100644 index 0000000..b19f688 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/DefaultValue.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Directives.vue b/packages/hoppscotch-common/src/components/graphql/Directives.vue new file mode 100644 index 0000000..0274864 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Directives.vue @@ -0,0 +1,21 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/DocExplorer.vue b/packages/hoppscotch-common/src/components/graphql/DocExplorer.vue new file mode 100644 index 0000000..8d6a249 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/DocExplorer.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/graphql/EnumValues.vue b/packages/hoppscotch-common/src/components/graphql/EnumValues.vue new file mode 100644 index 0000000..bc3d686 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/EnumValues.vue @@ -0,0 +1,79 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/ExplorerSection.vue b/packages/hoppscotch-common/src/components/graphql/ExplorerSection.vue new file mode 100644 index 0000000..3648760 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/ExplorerSection.vue @@ -0,0 +1,63 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Field.vue b/packages/hoppscotch-common/src/components/graphql/Field.vue new file mode 100644 index 0000000..d901174 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Field.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/graphql/FieldDocumentation.vue b/packages/hoppscotch-common/src/components/graphql/FieldDocumentation.vue new file mode 100644 index 0000000..b39d161 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/FieldDocumentation.vue @@ -0,0 +1,62 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/FieldLink.vue b/packages/hoppscotch-common/src/components/graphql/FieldLink.vue new file mode 100644 index 0000000..d4ba445 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/FieldLink.vue @@ -0,0 +1,53 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Fields.vue b/packages/hoppscotch-common/src/components/graphql/Fields.vue new file mode 100644 index 0000000..0dff190 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Fields.vue @@ -0,0 +1,54 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Headers.vue b/packages/hoppscotch-common/src/components/graphql/Headers.vue new file mode 100644 index 0000000..fc16f3d --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Headers.vue @@ -0,0 +1,702 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/ImplementsInterfaces.vue b/packages/hoppscotch-common/src/components/graphql/ImplementsInterfaces.vue new file mode 100644 index 0000000..20c2966 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/ImplementsInterfaces.vue @@ -0,0 +1,26 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Query.vue b/packages/hoppscotch-common/src/components/graphql/Query.vue new file mode 100644 index 0000000..abf2afe --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Query.vue @@ -0,0 +1,268 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Request.vue b/packages/hoppscotch-common/src/components/graphql/Request.vue new file mode 100644 index 0000000..ce06d5c --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Request.vue @@ -0,0 +1,170 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue b/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue new file mode 100644 index 0000000..991cc8f --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/graphql/RequestTab.vue b/packages/hoppscotch-common/src/components/graphql/RequestTab.vue new file mode 100644 index 0000000..7e3f26b --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/RequestTab.vue @@ -0,0 +1,53 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Response.vue b/packages/hoppscotch-common/src/components/graphql/Response.vue new file mode 100644 index 0000000..a3be45c --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Response.vue @@ -0,0 +1,174 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/ResponseMeta.vue b/packages/hoppscotch-common/src/components/graphql/ResponseMeta.vue new file mode 100644 index 0000000..a0f43e8 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/ResponseMeta.vue @@ -0,0 +1,163 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/SchemaDocumentation.vue b/packages/hoppscotch-common/src/components/graphql/SchemaDocumentation.vue new file mode 100644 index 0000000..b5b5289 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/SchemaDocumentation.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/graphql/SchemaSearch.vue b/packages/hoppscotch-common/src/components/graphql/SchemaSearch.vue new file mode 100644 index 0000000..c7207a1 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/SchemaSearch.vue @@ -0,0 +1,355 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Sidebar.vue b/packages/hoppscotch-common/src/components/graphql/Sidebar.vue new file mode 100644 index 0000000..a1f1361 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Sidebar.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/graphql/SubscriptionLog.vue b/packages/hoppscotch-common/src/components/graphql/SubscriptionLog.vue new file mode 100644 index 0000000..f00a683 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/SubscriptionLog.vue @@ -0,0 +1,129 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/TabHead.vue b/packages/hoppscotch-common/src/components/graphql/TabHead.vue new file mode 100644 index 0000000..3609554 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/TabHead.vue @@ -0,0 +1,119 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Type.vue b/packages/hoppscotch-common/src/components/graphql/Type.vue new file mode 100644 index 0000000..15c551c --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Type.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/graphql/TypeDocumentation.vue b/packages/hoppscotch-common/src/components/graphql/TypeDocumentation.vue new file mode 100644 index 0000000..0235003 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/TypeDocumentation.vue @@ -0,0 +1,25 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/TypeLink.vue b/packages/hoppscotch-common/src/components/graphql/TypeLink.vue new file mode 100644 index 0000000..40c7336 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/TypeLink.vue @@ -0,0 +1,48 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Variable.vue b/packages/hoppscotch-common/src/components/graphql/Variable.vue new file mode 100644 index 0000000..71b5da0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Variable.vue @@ -0,0 +1,176 @@ + + + diff --git a/packages/hoppscotch-common/src/components/history/Personal.vue b/packages/hoppscotch-common/src/components/history/Personal.vue new file mode 100644 index 0000000..82600e8 --- /dev/null +++ b/packages/hoppscotch-common/src/components/history/Personal.vue @@ -0,0 +1,368 @@ + + + diff --git a/packages/hoppscotch-common/src/components/history/graphql/Card.vue b/packages/hoppscotch-common/src/components/history/graphql/Card.vue new file mode 100644 index 0000000..077df13 --- /dev/null +++ b/packages/hoppscotch-common/src/components/history/graphql/Card.vue @@ -0,0 +1,110 @@ + + + diff --git a/packages/hoppscotch-common/src/components/history/index.vue b/packages/hoppscotch-common/src/components/history/index.vue new file mode 100644 index 0000000..6c5d3b2 --- /dev/null +++ b/packages/hoppscotch-common/src/components/history/index.vue @@ -0,0 +1,44 @@ + + + diff --git a/packages/hoppscotch-common/src/components/history/rest/Card.vue b/packages/hoppscotch-common/src/components/history/rest/Card.vue new file mode 100644 index 0000000..dbc6bc0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/history/rest/Card.vue @@ -0,0 +1,136 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/Authorization.vue b/packages/hoppscotch-common/src/components/http/Authorization.vue new file mode 100644 index 0000000..6c1af45 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/Authorization.vue @@ -0,0 +1,418 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/Body.vue b/packages/hoppscotch-common/src/components/http/Body.vue new file mode 100644 index 0000000..91f82b8 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/Body.vue @@ -0,0 +1,215 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/BodyBinary.vue b/packages/hoppscotch-common/src/components/http/BodyBinary.vue new file mode 100644 index 0000000..ccfe995 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/BodyBinary.vue @@ -0,0 +1,74 @@ + + diff --git a/packages/hoppscotch-common/src/components/http/BodyParameters.vue b/packages/hoppscotch-common/src/components/http/BodyParameters.vue new file mode 100644 index 0000000..9ae1051 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/BodyParameters.vue @@ -0,0 +1,628 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/http/Codegen.vue b/packages/hoppscotch-common/src/components/http/Codegen.vue new file mode 100644 index 0000000..912dfa4 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/Codegen.vue @@ -0,0 +1,391 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/CodegenModal.vue b/packages/hoppscotch-common/src/components/http/CodegenModal.vue new file mode 100644 index 0000000..b19cb0e --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/CodegenModal.vue @@ -0,0 +1,78 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/Headers.vue b/packages/hoppscotch-common/src/components/http/Headers.vue new file mode 100644 index 0000000..3ffa1d9 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/Headers.vue @@ -0,0 +1,742 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/http/ImportCurl.vue b/packages/hoppscotch-common/src/components/http/ImportCurl.vue new file mode 100644 index 0000000..93057b3 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/ImportCurl.vue @@ -0,0 +1,187 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/InheritedScriptsModal.vue b/packages/hoppscotch-common/src/components/http/InheritedScriptsModal.vue new file mode 100644 index 0000000..3f0b348 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/InheritedScriptsModal.vue @@ -0,0 +1,145 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/KeyValue.vue b/packages/hoppscotch-common/src/components/http/KeyValue.vue new file mode 100644 index 0000000..01285a2 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/KeyValue.vue @@ -0,0 +1,188 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/Parameters.vue b/packages/hoppscotch-common/src/components/http/Parameters.vue new file mode 100644 index 0000000..c262880 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/Parameters.vue @@ -0,0 +1,402 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/http/PreRequestScript.vue b/packages/hoppscotch-common/src/components/http/PreRequestScript.vue new file mode 100644 index 0000000..12fab90 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/PreRequestScript.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/http/RawBody.vue b/packages/hoppscotch-common/src/components/http/RawBody.vue new file mode 100644 index 0000000..9cb5179 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/RawBody.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/http/ReqChangeConfirmModal.vue b/packages/hoppscotch-common/src/components/http/ReqChangeConfirmModal.vue new file mode 100644 index 0000000..45b38a5 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/ReqChangeConfirmModal.vue @@ -0,0 +1,67 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/Request.vue b/packages/hoppscotch-common/src/components/http/Request.vue new file mode 100644 index 0000000..fa19dce --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/Request.vue @@ -0,0 +1,686 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/RequestOptions.vue b/packages/hoppscotch-common/src/components/http/RequestOptions.vue new file mode 100644 index 0000000..91db528 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/RequestOptions.vue @@ -0,0 +1,208 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/RequestTab.vue b/packages/hoppscotch-common/src/components/http/RequestTab.vue new file mode 100644 index 0000000..ce25ae0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/RequestTab.vue @@ -0,0 +1,73 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/RequestVariables.vue b/packages/hoppscotch-common/src/components/http/RequestVariables.vue new file mode 100644 index 0000000..6c90c0b --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/RequestVariables.vue @@ -0,0 +1,420 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/Response.vue b/packages/hoppscotch-common/src/components/http/Response.vue new file mode 100644 index 0000000..40cd066 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/Response.vue @@ -0,0 +1,170 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/ResponseInterface.vue b/packages/hoppscotch-common/src/components/http/ResponseInterface.vue new file mode 100644 index 0000000..732714c --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/ResponseInterface.vue @@ -0,0 +1,279 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/ResponseMeta.vue b/packages/hoppscotch-common/src/components/http/ResponseMeta.vue new file mode 100644 index 0000000..700c284 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/ResponseMeta.vue @@ -0,0 +1,213 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/SaveResponseName.vue b/packages/hoppscotch-common/src/components/http/SaveResponseName.vue new file mode 100644 index 0000000..b818c71 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/SaveResponseName.vue @@ -0,0 +1,117 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/Sidebar.vue b/packages/hoppscotch-common/src/components/http/Sidebar.vue new file mode 100644 index 0000000..36ad55d --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/Sidebar.vue @@ -0,0 +1,99 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/TabHead.vue b/packages/hoppscotch-common/src/components/http/TabHead.vue new file mode 100644 index 0000000..c986d1b --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/TabHead.vue @@ -0,0 +1,235 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/TestResult.vue b/packages/hoppscotch-common/src/components/http/TestResult.vue new file mode 100644 index 0000000..cf84cf7 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/TestResult.vue @@ -0,0 +1,349 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/TestResultEntry.vue b/packages/hoppscotch-common/src/components/http/TestResultEntry.vue new file mode 100644 index 0000000..b4a8294 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/TestResultEntry.vue @@ -0,0 +1,125 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/TestResultEnv.vue b/packages/hoppscotch-common/src/components/http/TestResultEnv.vue new file mode 100644 index 0000000..84d529e --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/TestResultEnv.vue @@ -0,0 +1,93 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/TestResultReport.vue b/packages/hoppscotch-common/src/components/http/TestResultReport.vue new file mode 100644 index 0000000..bfa9d4a --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/TestResultReport.vue @@ -0,0 +1,49 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/Tests.vue b/packages/hoppscotch-common/src/components/http/Tests.vue new file mode 100644 index 0000000..5ef7852 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/Tests.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/http/URLEncodedParams.vue b/packages/hoppscotch-common/src/components/http/URLEncodedParams.vue new file mode 100644 index 0000000..e2ab3ab --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/URLEncodedParams.vue @@ -0,0 +1,466 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/http/authorization/ASAP.vue b/packages/hoppscotch-common/src/components/http/authorization/ASAP.vue new file mode 100644 index 0000000..971053c --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/authorization/ASAP.vue @@ -0,0 +1,215 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/authorization/AWSSign.vue b/packages/hoppscotch-common/src/components/http/authorization/AWSSign.vue new file mode 100644 index 0000000..c0b1915 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/authorization/AWSSign.vue @@ -0,0 +1,159 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/authorization/AkamaiEG.vue b/packages/hoppscotch-common/src/components/http/authorization/AkamaiEG.vue new file mode 100644 index 0000000..b179a7f --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/authorization/AkamaiEG.vue @@ -0,0 +1,124 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/authorization/ApiKey.vue b/packages/hoppscotch-common/src/components/http/authorization/ApiKey.vue new file mode 100644 index 0000000..c2eb2c0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/authorization/ApiKey.vue @@ -0,0 +1,106 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/authorization/Basic.vue b/packages/hoppscotch-common/src/components/http/authorization/Basic.vue new file mode 100644 index 0000000..74b4d61 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/authorization/Basic.vue @@ -0,0 +1,44 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/authorization/Digest.vue b/packages/hoppscotch-common/src/components/http/authorization/Digest.vue new file mode 100644 index 0000000..f8a75ce --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/authorization/Digest.vue @@ -0,0 +1,191 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/authorization/HAWK.vue b/packages/hoppscotch-common/src/components/http/authorization/HAWK.vue new file mode 100644 index 0000000..632459f --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/authorization/HAWK.vue @@ -0,0 +1,183 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/authorization/JWT.vue b/packages/hoppscotch-common/src/components/http/authorization/JWT.vue new file mode 100644 index 0000000..1cf9988 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/authorization/JWT.vue @@ -0,0 +1,320 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/authorization/NTLM.vue b/packages/hoppscotch-common/src/components/http/authorization/NTLM.vue new file mode 100644 index 0000000..5c7e335 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/authorization/NTLM.vue @@ -0,0 +1,90 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/authorization/OAuth2.vue b/packages/hoppscotch-common/src/components/http/authorization/OAuth2.vue new file mode 100644 index 0000000..f9b0f73 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/authorization/OAuth2.vue @@ -0,0 +1,825 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/example/LenseBodyRenderer.vue b/packages/hoppscotch-common/src/components/http/example/LenseBodyRenderer.vue new file mode 100644 index 0000000..4cc0bef --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/example/LenseBodyRenderer.vue @@ -0,0 +1,85 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/example/Response.vue b/packages/hoppscotch-common/src/components/http/example/Response.vue new file mode 100644 index 0000000..b99dfcf --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/example/Response.vue @@ -0,0 +1,23 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/example/ResponseMeta.vue b/packages/hoppscotch-common/src/components/http/example/ResponseMeta.vue new file mode 100644 index 0000000..2063ab0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/example/ResponseMeta.vue @@ -0,0 +1,70 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/example/ResponseRequest.vue b/packages/hoppscotch-common/src/components/http/example/ResponseRequest.vue new file mode 100644 index 0000000..4dba1dd --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/example/ResponseRequest.vue @@ -0,0 +1,266 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/example/ResponseTab.vue b/packages/hoppscotch-common/src/components/http/example/ResponseTab.vue new file mode 100644 index 0000000..33e5e1d --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/example/ResponseTab.vue @@ -0,0 +1,68 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/test/Env.vue b/packages/hoppscotch-common/src/components/http/test/Env.vue new file mode 100644 index 0000000..2d1eb80 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/test/Env.vue @@ -0,0 +1,104 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/test/Folder.vue b/packages/hoppscotch-common/src/components/http/test/Folder.vue new file mode 100644 index 0000000..b942d1f --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/test/Folder.vue @@ -0,0 +1,90 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/test/Request.vue b/packages/hoppscotch-common/src/components/http/test/Request.vue new file mode 100644 index 0000000..2e51206 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/test/Request.vue @@ -0,0 +1,86 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/test/Response.vue b/packages/hoppscotch-common/src/components/http/test/Response.vue new file mode 100644 index 0000000..b8cb8f0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/test/Response.vue @@ -0,0 +1,77 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/test/ResultFolder.vue b/packages/hoppscotch-common/src/components/http/test/ResultFolder.vue new file mode 100644 index 0000000..180f62b --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/test/ResultFolder.vue @@ -0,0 +1,91 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/test/ResultRequest.vue b/packages/hoppscotch-common/src/components/http/test/ResultRequest.vue new file mode 100644 index 0000000..2480378 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/test/ResultRequest.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/http/test/Runner.vue b/packages/hoppscotch-common/src/components/http/test/Runner.vue new file mode 100644 index 0000000..9290cae --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/test/Runner.vue @@ -0,0 +1,444 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/test/RunnerMeta.vue b/packages/hoppscotch-common/src/components/http/test/RunnerMeta.vue new file mode 100644 index 0000000..90f5606 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/test/RunnerMeta.vue @@ -0,0 +1,21 @@ + + diff --git a/packages/hoppscotch-common/src/components/http/test/RunnerModal.vue b/packages/hoppscotch-common/src/components/http/test/RunnerModal.vue new file mode 100644 index 0000000..121346b --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/test/RunnerModal.vue @@ -0,0 +1,402 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/test/RunnerResult.vue b/packages/hoppscotch-common/src/components/http/test/RunnerResult.vue new file mode 100644 index 0000000..00e2448 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/test/RunnerResult.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/http/test/TestResult.vue b/packages/hoppscotch-common/src/components/http/test/TestResult.vue new file mode 100644 index 0000000..34b5497 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/test/TestResult.vue @@ -0,0 +1,306 @@ + + + diff --git a/packages/hoppscotch-common/src/components/importExport/Base.vue b/packages/hoppscotch-common/src/components/importExport/Base.vue new file mode 100644 index 0000000..16dd078 --- /dev/null +++ b/packages/hoppscotch-common/src/components/importExport/Base.vue @@ -0,0 +1,239 @@ + + diff --git a/packages/hoppscotch-common/src/components/importExport/ImportExportList.vue b/packages/hoppscotch-common/src/components/importExport/ImportExportList.vue new file mode 100644 index 0000000..e357f15 --- /dev/null +++ b/packages/hoppscotch-common/src/components/importExport/ImportExportList.vue @@ -0,0 +1,79 @@ + + + diff --git a/packages/hoppscotch-common/src/components/importExport/ImportExportSourcesList.vue b/packages/hoppscotch-common/src/components/importExport/ImportExportSourcesList.vue new file mode 100644 index 0000000..7414e32 --- /dev/null +++ b/packages/hoppscotch-common/src/components/importExport/ImportExportSourcesList.vue @@ -0,0 +1,33 @@ + + + diff --git a/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/AllCollectionImport.vue b/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/AllCollectionImport.vue new file mode 100644 index 0000000..bbf8adf --- /dev/null +++ b/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/AllCollectionImport.vue @@ -0,0 +1,272 @@ + + + diff --git a/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/FileImport.vue b/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/FileImport.vue new file mode 100644 index 0000000..1f85aa6 --- /dev/null +++ b/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/FileImport.vue @@ -0,0 +1,222 @@ + + + diff --git a/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/ImportSummary.vue b/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/ImportSummary.vue new file mode 100644 index 0000000..72966e9 --- /dev/null +++ b/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/ImportSummary.vue @@ -0,0 +1,334 @@ + + + diff --git a/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/MyCollectionImport.vue b/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/MyCollectionImport.vue new file mode 100644 index 0000000..4a134fe --- /dev/null +++ b/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/MyCollectionImport.vue @@ -0,0 +1,70 @@ + + + diff --git a/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/UrlImport.vue b/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/UrlImport.vue new file mode 100644 index 0000000..2adbc67 --- /dev/null +++ b/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/UrlImport.vue @@ -0,0 +1,196 @@ + + + diff --git a/packages/hoppscotch-common/src/components/importExport/types.ts b/packages/hoppscotch-common/src/components/importExport/types.ts new file mode 100644 index 0000000..a5aed58 --- /dev/null +++ b/packages/hoppscotch-common/src/components/importExport/types.ts @@ -0,0 +1,37 @@ +import { HoppCollection } from "@hoppscotch/data" +import { Component, Ref } from "vue" +import { defineStep } from "~/composables/step-components" + +export type SupportedImportFormat = + | "hoppscotch" + | "postman" + | "insomnia" + | "openapi" + | "har" + +// TODO: move the metadata except disabled and isLoading to importers.ts +export type ImporterOrExporter = { + metadata: { + id: string + name: string + icon: any + title: string + disabled: boolean + applicableTo: Array<"personal-workspace" | "team-workspace" | "url-import"> + isLoading?: Ref + format?: SupportedImportFormat + } + supported_sources?: { + id: string + name: string + icon: Component + step: ReturnType + }[] + importSummary?: Ref<{ + showImportSummary: boolean + importedCollections: HoppCollection[] | null + }> + component?: ReturnType + action?: (...args: any[]) => any + onSelect?: () => boolean +} diff --git a/packages/hoppscotch-common/src/components/instance/Switcher.vue b/packages/hoppscotch-common/src/components/instance/Switcher.vue new file mode 100644 index 0000000..5549a28 --- /dev/null +++ b/packages/hoppscotch-common/src/components/instance/Switcher.vue @@ -0,0 +1,832 @@ + + + diff --git a/packages/hoppscotch-common/src/components/lenses/HeadersRenderer.vue b/packages/hoppscotch-common/src/components/lenses/HeadersRenderer.vue new file mode 100644 index 0000000..9032517 --- /dev/null +++ b/packages/hoppscotch-common/src/components/lenses/HeadersRenderer.vue @@ -0,0 +1,69 @@ + + + diff --git a/packages/hoppscotch-common/src/components/lenses/HeadersRendererEntry.vue b/packages/hoppscotch-common/src/components/lenses/HeadersRendererEntry.vue new file mode 100644 index 0000000..f56530b --- /dev/null +++ b/packages/hoppscotch-common/src/components/lenses/HeadersRendererEntry.vue @@ -0,0 +1,97 @@ + + + diff --git a/packages/hoppscotch-common/src/components/lenses/ResponseBodyRenderer.vue b/packages/hoppscotch-common/src/components/lenses/ResponseBodyRenderer.vue new file mode 100644 index 0000000..1598a50 --- /dev/null +++ b/packages/hoppscotch-common/src/components/lenses/ResponseBodyRenderer.vue @@ -0,0 +1,208 @@ + + + diff --git a/packages/hoppscotch-common/src/components/lenses/renderers/AudioLensRenderer.vue b/packages/hoppscotch-common/src/components/lenses/renderers/AudioLensRenderer.vue new file mode 100644 index 0000000..4227f96 --- /dev/null +++ b/packages/hoppscotch-common/src/components/lenses/renderers/AudioLensRenderer.vue @@ -0,0 +1,131 @@ + + + diff --git a/packages/hoppscotch-common/src/components/lenses/renderers/HTMLLensRenderer.vue b/packages/hoppscotch-common/src/components/lenses/renderers/HTMLLensRenderer.vue new file mode 100644 index 0000000..6d5d8ec --- /dev/null +++ b/packages/hoppscotch-common/src/components/lenses/renderers/HTMLLensRenderer.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/lenses/renderers/ImageLensRenderer.vue b/packages/hoppscotch-common/src/components/lenses/renderers/ImageLensRenderer.vue new file mode 100644 index 0000000..97904f7 --- /dev/null +++ b/packages/hoppscotch-common/src/components/lenses/renderers/ImageLensRenderer.vue @@ -0,0 +1,153 @@ + + + diff --git a/packages/hoppscotch-common/src/components/lenses/renderers/JSONLensRenderer.vue b/packages/hoppscotch-common/src/components/lenses/renderers/JSONLensRenderer.vue new file mode 100644 index 0000000..20165be --- /dev/null +++ b/packages/hoppscotch-common/src/components/lenses/renderers/JSONLensRenderer.vue @@ -0,0 +1,582 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/lenses/renderers/PDFLensRenderer.vue b/packages/hoppscotch-common/src/components/lenses/renderers/PDFLensRenderer.vue new file mode 100644 index 0000000..96be1ac --- /dev/null +++ b/packages/hoppscotch-common/src/components/lenses/renderers/PDFLensRenderer.vue @@ -0,0 +1,113 @@ + + + diff --git a/packages/hoppscotch-common/src/components/lenses/renderers/RawLensRenderer.vue b/packages/hoppscotch-common/src/components/lenses/renderers/RawLensRenderer.vue new file mode 100644 index 0000000..8ce7530 --- /dev/null +++ b/packages/hoppscotch-common/src/components/lenses/renderers/RawLensRenderer.vue @@ -0,0 +1,245 @@ + + + diff --git a/packages/hoppscotch-common/src/components/lenses/renderers/VideoLensRenderer.vue b/packages/hoppscotch-common/src/components/lenses/renderers/VideoLensRenderer.vue new file mode 100644 index 0000000..ed4f085 --- /dev/null +++ b/packages/hoppscotch-common/src/components/lenses/renderers/VideoLensRenderer.vue @@ -0,0 +1,131 @@ + + + diff --git a/packages/hoppscotch-common/src/components/lenses/renderers/XMLLensRenderer.vue b/packages/hoppscotch-common/src/components/lenses/renderers/XMLLensRenderer.vue new file mode 100644 index 0000000..bbcebbd --- /dev/null +++ b/packages/hoppscotch-common/src/components/lenses/renderers/XMLLensRenderer.vue @@ -0,0 +1,234 @@ + + + diff --git a/packages/hoppscotch-common/src/components/lenses/renderers/mixins/TextContentRendererMixin.js b/packages/hoppscotch-common/src/components/lenses/renderers/mixins/TextContentRendererMixin.js new file mode 100644 index 0000000..f040d79 --- /dev/null +++ b/packages/hoppscotch-common/src/components/lenses/renderers/mixins/TextContentRendererMixin.js @@ -0,0 +1,15 @@ +export default { + props: { + response: {}, + }, + computed: { + responseBodyText() { + if (typeof this.response.body === "string") return this.response.body + + const res = new TextDecoder("utf-8").decode(this.response.body) + + // HACK: Temporary trailing null character issue from the extension fix + return res.replace(/\0+$/, "") + }, + }, +} diff --git a/packages/hoppscotch-common/src/components/mockServer/ConfigureMockServerModal.vue b/packages/hoppscotch-common/src/components/mockServer/ConfigureMockServerModal.vue new file mode 100644 index 0000000..af10c31 --- /dev/null +++ b/packages/hoppscotch-common/src/components/mockServer/ConfigureMockServerModal.vue @@ -0,0 +1,322 @@ + + + diff --git a/packages/hoppscotch-common/src/components/mockServer/CreateNewMockServerModal.vue b/packages/hoppscotch-common/src/components/mockServer/CreateNewMockServerModal.vue new file mode 100644 index 0000000..160d385 --- /dev/null +++ b/packages/hoppscotch-common/src/components/mockServer/CreateNewMockServerModal.vue @@ -0,0 +1,482 @@ + + + diff --git a/packages/hoppscotch-common/src/components/mockServer/EditMockServer.vue b/packages/hoppscotch-common/src/components/mockServer/EditMockServer.vue new file mode 100644 index 0000000..33d0fdb --- /dev/null +++ b/packages/hoppscotch-common/src/components/mockServer/EditMockServer.vue @@ -0,0 +1,303 @@ + + + diff --git a/packages/hoppscotch-common/src/components/mockServer/LogSection.vue b/packages/hoppscotch-common/src/components/mockServer/LogSection.vue new file mode 100644 index 0000000..29f4465 --- /dev/null +++ b/packages/hoppscotch-common/src/components/mockServer/LogSection.vue @@ -0,0 +1,110 @@ + + + diff --git a/packages/hoppscotch-common/src/components/mockServer/MockServerCreatedInfo.vue b/packages/hoppscotch-common/src/components/mockServer/MockServerCreatedInfo.vue new file mode 100644 index 0000000..c281762 --- /dev/null +++ b/packages/hoppscotch-common/src/components/mockServer/MockServerCreatedInfo.vue @@ -0,0 +1,93 @@ + + + diff --git a/packages/hoppscotch-common/src/components/mockServer/MockServerDashboard.vue b/packages/hoppscotch-common/src/components/mockServer/MockServerDashboard.vue new file mode 100644 index 0000000..c0253e3 --- /dev/null +++ b/packages/hoppscotch-common/src/components/mockServer/MockServerDashboard.vue @@ -0,0 +1,371 @@ + + + diff --git a/packages/hoppscotch-common/src/components/mockServer/MockServerLogs.vue b/packages/hoppscotch-common/src/components/mockServer/MockServerLogs.vue new file mode 100644 index 0000000..e06ed87 --- /dev/null +++ b/packages/hoppscotch-common/src/components/mockServer/MockServerLogs.vue @@ -0,0 +1,210 @@ + + + diff --git a/packages/hoppscotch-common/src/components/organization/Switcher.vue b/packages/hoppscotch-common/src/components/organization/Switcher.vue new file mode 100644 index 0000000..6a26c47 --- /dev/null +++ b/packages/hoppscotch-common/src/components/organization/Switcher.vue @@ -0,0 +1,26 @@ + + + diff --git a/packages/hoppscotch-common/src/components/profile/UserDelete.vue b/packages/hoppscotch-common/src/components/profile/UserDelete.vue new file mode 100644 index 0000000..d651cd3 --- /dev/null +++ b/packages/hoppscotch-common/src/components/profile/UserDelete.vue @@ -0,0 +1,208 @@ + + + diff --git a/packages/hoppscotch-common/src/components/profile/index.vue b/packages/hoppscotch-common/src/components/profile/index.vue new file mode 100644 index 0000000..d274a08 --- /dev/null +++ b/packages/hoppscotch-common/src/components/profile/index.vue @@ -0,0 +1,202 @@ + + + diff --git a/packages/hoppscotch-common/src/components/realtime/Communication.vue b/packages/hoppscotch-common/src/components/realtime/Communication.vue new file mode 100644 index 0000000..7b9ac0f --- /dev/null +++ b/packages/hoppscotch-common/src/components/realtime/Communication.vue @@ -0,0 +1,278 @@ + + diff --git a/packages/hoppscotch-common/src/components/realtime/ConnectionConfig.vue b/packages/hoppscotch-common/src/components/realtime/ConnectionConfig.vue new file mode 100644 index 0000000..b8e878c --- /dev/null +++ b/packages/hoppscotch-common/src/components/realtime/ConnectionConfig.vue @@ -0,0 +1,135 @@ + + + diff --git a/packages/hoppscotch-common/src/components/realtime/Log.vue b/packages/hoppscotch-common/src/components/realtime/Log.vue new file mode 100644 index 0000000..2ccaa9c --- /dev/null +++ b/packages/hoppscotch-common/src/components/realtime/Log.vue @@ -0,0 +1,126 @@ + + + diff --git a/packages/hoppscotch-common/src/components/realtime/LogEntry.vue b/packages/hoppscotch-common/src/components/realtime/LogEntry.vue new file mode 100644 index 0000000..a760b19 --- /dev/null +++ b/packages/hoppscotch-common/src/components/realtime/LogEntry.vue @@ -0,0 +1,410 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/realtime/Subscription.vue b/packages/hoppscotch-common/src/components/realtime/Subscription.vue new file mode 100644 index 0000000..7044f9d --- /dev/null +++ b/packages/hoppscotch-common/src/components/realtime/Subscription.vue @@ -0,0 +1,145 @@ + + + diff --git a/packages/hoppscotch-common/src/components/settings/Agent.vue b/packages/hoppscotch-common/src/components/settings/Agent.vue new file mode 100644 index 0000000..b996ffc --- /dev/null +++ b/packages/hoppscotch-common/src/components/settings/Agent.vue @@ -0,0 +1,684 @@ + + + diff --git a/packages/hoppscotch-common/src/components/settings/AgentSubtitle.vue b/packages/hoppscotch-common/src/components/settings/AgentSubtitle.vue new file mode 100644 index 0000000..6e39812 --- /dev/null +++ b/packages/hoppscotch-common/src/components/settings/AgentSubtitle.vue @@ -0,0 +1,157 @@ + + + diff --git a/packages/hoppscotch-common/src/components/settings/Desktop.vue b/packages/hoppscotch-common/src/components/settings/Desktop.vue new file mode 100644 index 0000000..d09a2b5 --- /dev/null +++ b/packages/hoppscotch-common/src/components/settings/Desktop.vue @@ -0,0 +1,417 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/settings/Extension.vue b/packages/hoppscotch-common/src/components/settings/Extension.vue new file mode 100644 index 0000000..7d3bc91 --- /dev/null +++ b/packages/hoppscotch-common/src/components/settings/Extension.vue @@ -0,0 +1,57 @@ + + + diff --git a/packages/hoppscotch-common/src/components/settings/ExtensionSubtitle.vue b/packages/hoppscotch-common/src/components/settings/ExtensionSubtitle.vue new file mode 100644 index 0000000..057528f --- /dev/null +++ b/packages/hoppscotch-common/src/components/settings/ExtensionSubtitle.vue @@ -0,0 +1,67 @@ + + + diff --git a/packages/hoppscotch-common/src/components/settings/InterceptorErrorPlaceholder.vue b/packages/hoppscotch-common/src/components/settings/InterceptorErrorPlaceholder.vue new file mode 100644 index 0000000..6818b1c --- /dev/null +++ b/packages/hoppscotch-common/src/components/settings/InterceptorErrorPlaceholder.vue @@ -0,0 +1,90 @@ + + + diff --git a/packages/hoppscotch-common/src/components/settings/Native.vue b/packages/hoppscotch-common/src/components/settings/Native.vue new file mode 100644 index 0000000..fa9c540 --- /dev/null +++ b/packages/hoppscotch-common/src/components/settings/Native.vue @@ -0,0 +1,665 @@ + + + diff --git a/packages/hoppscotch-common/src/components/settings/Proxy.vue b/packages/hoppscotch-common/src/components/settings/Proxy.vue new file mode 100644 index 0000000..8cf6319 --- /dev/null +++ b/packages/hoppscotch-common/src/components/settings/Proxy.vue @@ -0,0 +1,126 @@ + + + diff --git a/packages/hoppscotch-common/src/components/share/CreateModal.vue b/packages/hoppscotch-common/src/components/share/CreateModal.vue new file mode 100644 index 0000000..ee24486 --- /dev/null +++ b/packages/hoppscotch-common/src/components/share/CreateModal.vue @@ -0,0 +1,144 @@ + + + diff --git a/packages/hoppscotch-common/src/components/share/CustomizeModal.vue b/packages/hoppscotch-common/src/components/share/CustomizeModal.vue new file mode 100644 index 0000000..63180ae --- /dev/null +++ b/packages/hoppscotch-common/src/components/share/CustomizeModal.vue @@ -0,0 +1,466 @@ + + + diff --git a/packages/hoppscotch-common/src/components/share/Modal.vue b/packages/hoppscotch-common/src/components/share/Modal.vue new file mode 100644 index 0000000..38c0c1b --- /dev/null +++ b/packages/hoppscotch-common/src/components/share/Modal.vue @@ -0,0 +1,173 @@ + + + diff --git a/packages/hoppscotch-common/src/components/share/Request.vue b/packages/hoppscotch-common/src/components/share/Request.vue new file mode 100644 index 0000000..b660761 --- /dev/null +++ b/packages/hoppscotch-common/src/components/share/Request.vue @@ -0,0 +1,163 @@ + + + diff --git a/packages/hoppscotch-common/src/components/share/index.vue b/packages/hoppscotch-common/src/components/share/index.vue new file mode 100644 index 0000000..9f7d58e --- /dev/null +++ b/packages/hoppscotch-common/src/components/share/index.vue @@ -0,0 +1,518 @@ + + + diff --git a/packages/hoppscotch-common/src/components/share/templates/Button.vue b/packages/hoppscotch-common/src/components/share/templates/Button.vue new file mode 100644 index 0000000..4d1e8e4 --- /dev/null +++ b/packages/hoppscotch-common/src/components/share/templates/Button.vue @@ -0,0 +1,15 @@ + + + diff --git a/packages/hoppscotch-common/src/components/share/templates/Embeds.vue b/packages/hoppscotch-common/src/components/share/templates/Embeds.vue new file mode 100644 index 0000000..b2ce645 --- /dev/null +++ b/packages/hoppscotch-common/src/components/share/templates/Embeds.vue @@ -0,0 +1,107 @@ + + + diff --git a/packages/hoppscotch-common/src/components/share/templates/Link.vue b/packages/hoppscotch-common/src/components/share/templates/Link.vue new file mode 100644 index 0000000..2bf5552 --- /dev/null +++ b/packages/hoppscotch-common/src/components/share/templates/Link.vue @@ -0,0 +1,59 @@ + + + diff --git a/packages/hoppscotch-common/src/components/smart/AccentModePicker.vue b/packages/hoppscotch-common/src/components/smart/AccentModePicker.vue new file mode 100644 index 0000000..7ad3bc8 --- /dev/null +++ b/packages/hoppscotch-common/src/components/smart/AccentModePicker.vue @@ -0,0 +1,43 @@ + + + diff --git a/packages/hoppscotch-common/src/components/smart/ChangeLanguage.vue b/packages/hoppscotch-common/src/components/smart/ChangeLanguage.vue new file mode 100644 index 0000000..e6b893d --- /dev/null +++ b/packages/hoppscotch-common/src/components/smart/ChangeLanguage.vue @@ -0,0 +1,112 @@ + + + diff --git a/packages/hoppscotch-common/src/components/smart/ColorModePicker.vue b/packages/hoppscotch-common/src/components/smart/ColorModePicker.vue new file mode 100644 index 0000000..c69c7b8 --- /dev/null +++ b/packages/hoppscotch-common/src/components/smart/ColorModePicker.vue @@ -0,0 +1,65 @@ + + + diff --git a/packages/hoppscotch-common/src/components/smart/EncodingPicker.vue b/packages/hoppscotch-common/src/components/smart/EncodingPicker.vue new file mode 100644 index 0000000..6486622 --- /dev/null +++ b/packages/hoppscotch-common/src/components/smart/EncodingPicker.vue @@ -0,0 +1,60 @@ + + + diff --git a/packages/hoppscotch-common/src/components/smart/EnvInput.vue b/packages/hoppscotch-common/src/components/smart/EnvInput.vue new file mode 100644 index 0000000..c9487df --- /dev/null +++ b/packages/hoppscotch-common/src/components/smart/EnvInput.vue @@ -0,0 +1,792 @@ + + + + + + + diff --git a/packages/hoppscotch-common/src/components/tab/Primary.vue b/packages/hoppscotch-common/src/components/tab/Primary.vue new file mode 100644 index 0000000..8215f3b --- /dev/null +++ b/packages/hoppscotch-common/src/components/tab/Primary.vue @@ -0,0 +1,71 @@ + + + diff --git a/packages/hoppscotch-common/src/components/tab/Secondary.vue b/packages/hoppscotch-common/src/components/tab/Secondary.vue new file mode 100644 index 0000000..95ac7c2 --- /dev/null +++ b/packages/hoppscotch-common/src/components/tab/Secondary.vue @@ -0,0 +1,77 @@ + + + diff --git a/packages/hoppscotch-common/src/components/teams/Add.vue b/packages/hoppscotch-common/src/components/teams/Add.vue new file mode 100644 index 0000000..de0203d --- /dev/null +++ b/packages/hoppscotch-common/src/components/teams/Add.vue @@ -0,0 +1,115 @@ + + + diff --git a/packages/hoppscotch-common/src/components/teams/Edit.vue b/packages/hoppscotch-common/src/components/teams/Edit.vue new file mode 100644 index 0000000..5c0a41a --- /dev/null +++ b/packages/hoppscotch-common/src/components/teams/Edit.vue @@ -0,0 +1,432 @@ + + + diff --git a/packages/hoppscotch-common/src/components/teams/Invite.vue b/packages/hoppscotch-common/src/components/teams/Invite.vue new file mode 100644 index 0000000..dbf3832 --- /dev/null +++ b/packages/hoppscotch-common/src/components/teams/Invite.vue @@ -0,0 +1,705 @@ + + + diff --git a/packages/hoppscotch-common/src/components/teams/MemberStack.vue b/packages/hoppscotch-common/src/components/teams/MemberStack.vue new file mode 100644 index 0000000..498ba35 --- /dev/null +++ b/packages/hoppscotch-common/src/components/teams/MemberStack.vue @@ -0,0 +1,89 @@ + + + diff --git a/packages/hoppscotch-common/src/components/teams/Modal.vue b/packages/hoppscotch-common/src/components/teams/Modal.vue new file mode 100644 index 0000000..59f34f2 --- /dev/null +++ b/packages/hoppscotch-common/src/components/teams/Modal.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/hoppscotch-common/src/components/teams/Team.vue b/packages/hoppscotch-common/src/components/teams/Team.vue new file mode 100644 index 0000000..eafcd1c --- /dev/null +++ b/packages/hoppscotch-common/src/components/teams/Team.vue @@ -0,0 +1,250 @@ + + + diff --git a/packages/hoppscotch-common/src/components/teams/View.vue b/packages/hoppscotch-common/src/components/teams/View.vue new file mode 100644 index 0000000..b7bf407 --- /dev/null +++ b/packages/hoppscotch-common/src/components/teams/View.vue @@ -0,0 +1,156 @@ + + + diff --git a/packages/hoppscotch-common/src/components/workspace/Current.vue b/packages/hoppscotch-common/src/components/workspace/Current.vue new file mode 100644 index 0000000..f54be66 --- /dev/null +++ b/packages/hoppscotch-common/src/components/workspace/Current.vue @@ -0,0 +1,45 @@ + + + diff --git a/packages/hoppscotch-common/src/components/workspace/Selector.vue b/packages/hoppscotch-common/src/components/workspace/Selector.vue new file mode 100644 index 0000000..81535d8 --- /dev/null +++ b/packages/hoppscotch-common/src/components/workspace/Selector.vue @@ -0,0 +1,212 @@ + + + diff --git a/packages/hoppscotch-common/src/composables/ai-experiments.ts b/packages/hoppscotch-common/src/composables/ai-experiments.ts new file mode 100644 index 0000000..a71e78c --- /dev/null +++ b/packages/hoppscotch-common/src/composables/ai-experiments.ts @@ -0,0 +1,312 @@ +import { computed, Ref, ref } from "vue" +import { useReadonlyStream } from "./stream" +import { platform } from "~/platform" +import { useSetting } from "./settings" +import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data" +import { useToast } from "@composables/toast" +import { useI18n } from "@composables/i18n" +import * as E from "fp-ts/Either" +import { useRoute } from "vue-router" +import { invokeAction } from "~/helpers/actions" + +export const useRequestNameGeneration = (targetNameRef: Ref) => { + const toast = useToast() + const t = useI18n() + const route = useRoute() + + const targetPage = computed(() => { + return route.fullPath.includes("/graphql") ? "gql" : "rest" + }) + + const isGenerateRequestNamePending = ref(false) + + const generateRequestNameForPlatform = + platform.experiments?.aiExperiments?.generateRequestName + + const currentUser = useReadonlyStream( + platform.auth.getCurrentUserStream(), + platform.auth.getCurrentUser() + ) + + const ENABLE_AI_EXPERIMENTS = useSetting("ENABLE_AI_EXPERIMENTS") + + const canDoRequestNameGeneration = computed(() => { + return ENABLE_AI_EXPERIMENTS.value && !!platform.experiments?.aiExperiments + }) + + const lastTraceID = ref(null) + + const generateRequestName = async ( + requestContext: HoppRESTRequest | HoppGQLRequest | null + ) => { + if (!currentUser.value) { + invokeAction("modals.login.toggle") + return + } + + if (!requestContext || !generateRequestNameForPlatform) { + toast.error(t("request.generate_name_error")) + return + } + + const namingStyle = useSetting("AI_REQUEST_NAMING_STYLE").value + const customNamingStyle = useSetting("CUSTOM_NAMING_STYLE").value + + isGenerateRequestNamePending.value = true + + platform.analytics?.logEvent({ + type: "EXPERIMENTS_GENERATE_REQUEST_NAME_WITH_AI", + platform: targetPage.value, + }) + + const result = await generateRequestNameForPlatform( + JSON.stringify(requestContext), + namingStyle === "CUSTOM" ? customNamingStyle : namingStyle + ) + + if (result && E.isLeft(result)) { + toast.error(t("request.generate_name_error")) + + isGenerateRequestNamePending.value = false + + return + } + + targetNameRef.value = result.right.request_name + lastTraceID.value = result.right.trace_id + + isGenerateRequestNamePending.value = false + } + + return { + generateRequestName, + isGenerateRequestNamePending, + canDoRequestNameGeneration, + lastTraceID, + } +} + +export const useAIExperiments = () => { + const ENABLE_AI_EXPERIMENTS = useSetting("ENABLE_AI_EXPERIMENTS") + + const shouldEnableAIFeatures = computed(() => { + return ENABLE_AI_EXPERIMENTS.value && !!platform.experiments?.aiExperiments + }) + + return { + shouldEnableAIFeatures, + } +} + +export const useModifyRequestBody = ( + currentRequestBody: string, + userPromptRef: Ref, + generatedRequestBodyRef: Ref +) => { + const toast = useToast() + const t = useI18n() + + const lastTraceID = ref(null) + + const isModifyRequestBodyPending = ref(false) + + const modifyRequestBodyForPlatform = + platform.experiments?.aiExperiments?.modifyRequestBody + + const modifyRequestBody = async () => { + isModifyRequestBodyPending.value = true + + if (!modifyRequestBodyForPlatform) { + toast.error(t("ai_experiments.modify_request_body_error")) + isModifyRequestBodyPending.value = false + return + } + + const result = await modifyRequestBodyForPlatform( + currentRequestBody ?? "", + userPromptRef.value + ) + + if (result && E.isLeft(result)) { + toast.error(t("ai_experiments.modify_request_body_error")) + isModifyRequestBodyPending.value = false + return + } + + generatedRequestBodyRef.value = result.right.modified_body + lastTraceID.value = result.right.trace_id + + isModifyRequestBodyPending.value = false + return result.right + } + + return { + modifyRequestBody, + isModifyRequestBodyPending, + lastTraceID, + } +} + +export const useSubmitFeedback = () => { + const submitFeedbackForPlatform = + platform.experiments?.aiExperiments?.submitFeedback + + const t = useI18n() + const toast = useToast() + + const isSubmitFeedbackPending = ref(false) + + const submitFeedback = async ( + rating: "positive" | "negative", + traceID: string + ) => { + if (!submitFeedbackForPlatform) { + toast.error(t("ai_experiments.feedback_failure")) + + return + } + + isSubmitFeedbackPending.value = true + + const res = await submitFeedbackForPlatform( + rating === "positive" ? 1 : -1, + traceID + ) + + if (E.isLeft(res)) { + toast.error(t("ai_experiments.feedback_failure")) + isSubmitFeedbackPending.value = false + return + } + + isSubmitFeedbackPending.value = false + + return E.right(undefined) + } + + return { + submitFeedback, + isSubmitFeedbackPending, + } +} + +export const useModifyPreRequestScript = ( + currentScript: string, + userPromptRef: Ref, + generatedScriptRef: Ref, + requestInfo: HoppRESTRequest +) => { + const toast = useToast() + const t = useI18n() + const lastTraceID = ref(null) + const isModifyPreRequestPending = ref(false) + + const modifyPreRequestScriptForPlatform = + platform.experiments?.aiExperiments?.modifyPreRequestScript + + const modifyPreRequestScript = async () => { + isModifyPreRequestPending.value = true + + if (!modifyPreRequestScriptForPlatform) { + toast.error(t("ai_experiments.modify_prerequest_error")) + isModifyPreRequestPending.value = false + return + } + + const result = await modifyPreRequestScriptForPlatform( + buildRequestInfoString(requestInfo, currentScript), + userPromptRef.value + ) + + if (result && E.isLeft(result)) { + toast.error(t("ai_experiments.modify_prerequest_error")) + isModifyPreRequestPending.value = false + return + } + + generatedScriptRef.value = result.right.modified_script + lastTraceID.value = result.right.trace_id + + isModifyPreRequestPending.value = false + return result.right + } + + return { + modifyPreRequestScript, + isModifyPreRequestPending, + lastTraceID, + } +} + +export const useModifyTestScript = ( + currentScript: string, + userPromptRef: Ref, + generatedScriptRef: Ref, + requestInfo: HoppRESTRequest +) => { + const toast = useToast() + const t = useI18n() + const lastTraceID = ref(null) + const isModifyTestScriptPending = ref(false) + + const modifyTestScriptForPlatform = + platform.experiments?.aiExperiments?.modifyTestScript + + const modifyTestScript = async () => { + isModifyTestScriptPending.value = true + + if (!modifyTestScriptForPlatform) { + toast.error(t("ai_experiments.modify_post_request_script_error")) + isModifyTestScriptPending.value = false + return + } + + const result = await modifyTestScriptForPlatform( + buildRequestInfoString(requestInfo, currentScript), + userPromptRef.value + ) + + if (result && E.isLeft(result)) { + toast.error(t("ai_experiments.modify_post_request_script_error")) + isModifyTestScriptPending.value = false + return + } + + generatedScriptRef.value = result.right.modified_script + lastTraceID.value = result.right.trace_id + + isModifyTestScriptPending.value = false + return result.right + } + + return { + modifyTestScript, + isModifyTestScriptPending, + lastTraceID, + } +} + +const buildRequestInfoString = ( + request: HoppRESTRequest, + currentScript: string +) => { + return ` + METHOD: + ${request.method} + + URL: + ${request.endpoint} + + BODY: + ${JSON.stringify(request.body) ?? ""} + + PARAMS: + ${JSON.stringify(request.params, null, 2)} + + HEADERS: + ${JSON.stringify(request.headers, null, 2)} + + EXISTING SCRIPT: + ${currentScript} + ` +} diff --git a/packages/hoppscotch-common/src/composables/auth.ts b/packages/hoppscotch-common/src/composables/auth.ts new file mode 100644 index 0000000..247d6e1 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/auth.ts @@ -0,0 +1,62 @@ +import { platform } from "~/platform" +import { AuthEvent, HoppUser } from "~/platform/auth" +import { Subscription } from "rxjs" +import { onBeforeUnmount, onMounted, watch, WatchStopHandle } from "vue" +import { useReadonlyStream } from "./stream" + +/** + * A Vue composable function that is called when the auth status + * is being updated to being logged in (fired multiple times), + * this is also called on component mount if the login + * was already resolved before mount. + */ +export function onLoggedIn(exec: (user: HoppUser) => void) { + const currentUser = useReadonlyStream( + platform.auth.getCurrentUserStream(), + platform.auth.getCurrentUser() + ) + + let watchStop: WatchStopHandle | null = null + + onMounted(() => { + if (currentUser.value) exec(currentUser.value) + + watchStop = watch(currentUser, (newVal, prev) => { + if (prev === null && newVal !== null) { + exec(newVal) + } + }) + }) + + onBeforeUnmount(() => { + watchStop?.() + }) +} + +/** + * A Vue composable function that calls its param function + * when a new event (login, logout etc.) happens in + * the auth system. + * + * NOTE: Unlike `onLoggedIn` for which the callback will be called once on mount with the current state, + * here the callback will only be called on authentication event occurrences. + * You might want to check the auth state from an `onMounted` hook or something + * if you want to access the initial state + * + * @param func A function which accepts an event + */ +export function onAuthEvent(func: (ev: AuthEvent) => void) { + const authEvents$ = platform.auth.getAuthEventsStream() + + let sub: Subscription | null = null + + onMounted(() => { + sub = authEvents$.subscribe((ev) => { + func(ev) + }) + }) + + onBeforeUnmount(() => { + sub?.unsubscribe() + }) +} diff --git a/packages/hoppscotch-common/src/composables/codemirror.ts b/packages/hoppscotch-common/src/composables/codemirror.ts new file mode 100644 index 0000000..1127523 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/codemirror.ts @@ -0,0 +1,687 @@ +import { + keymap, + EditorView, + ViewPlugin, + ViewUpdate, + placeholder, + tooltips, +} from "@codemirror/view" +import { + Extension, + EditorState, + Compartment, + EditorSelection, + Prec, +} from "@codemirror/state" +import { + Language, + LanguageSupport, + StreamLanguage, + syntaxHighlighting, +} from "@codemirror/language" +import { + defaultKeymap, + indentLess, + insertTab, + redo, +} from "@codemirror/commands" +import { Completion, autocompletion } from "@codemirror/autocomplete" +import { linter } from "@codemirror/lint" +import { watch, ref, Ref, onMounted, onBeforeUnmount } from "vue" +import { javascriptLanguage } from "@codemirror/lang-javascript" +import { xmlLanguage } from "@codemirror/lang-xml" +import { jsoncLanguage } from "@shopify/lang-jsonc" +import { GQLLanguage } from "@hoppscotch/codemirror-lang-graphql" +import { html } from "@codemirror/legacy-modes/mode/xml" +import { shell } from "@codemirror/legacy-modes/mode/shell" +import { yaml } from "@codemirror/legacy-modes/mode/yaml" +import { rust } from "@codemirror/legacy-modes/mode/rust" +import { go } from "@codemirror/legacy-modes/mode/go" +import { clojure } from "@codemirror/legacy-modes/mode/clojure" +import { http } from "@codemirror/legacy-modes/mode/http" +import { csharp, java } from "@codemirror/legacy-modes/mode/clike" +import { powerShell } from "@codemirror/legacy-modes/mode/powershell" +import { python } from "@codemirror/legacy-modes/mode/python" +import { r } from "@codemirror/legacy-modes/mode/r" +import { ruby } from "@codemirror/legacy-modes/mode/ruby" +import { swift } from "@codemirror/legacy-modes/mode/swift" +import { isJSONContentType } from "@helpers/utils/contenttypes" +import { useStreamSubscriber } from "@composables/stream" +import { Completer } from "@helpers/editor/completion" +import { LinterDefinition } from "@helpers/editor/linting/linter" +import { MODULE_PREFIX } from "@hoppscotch/js-sandbox/scripting" +import { + basicSetup, + baseTheme, + baseHighlightStyle, +} from "@helpers/editor/themes/baseTheme" +import { HoppEnvironmentPlugin } from "@helpers/editor/extensions/HoppEnvironment" +import xmlFormat from "xml-formatter" +import { platform } from "~/platform" +import { + invokeAction, + registerCodeMirrorView, + unregisterCodeMirrorView, +} from "~/helpers/actions" +import { useDebounceFn } from "@vueuse/core" +// TODO: Migrate from legacy mode + +import * as E from "fp-ts/Either" +import { HoppPredefinedVariablesPlugin } from "~/helpers/editor/extensions/HoppPredefinedVariables" + +type ExtendedEditorConfig = { + mode: string + useLang: boolean + placeholder: string + readOnly: boolean + lineWrapping: boolean +} + +type CodeMirrorOptions = { + extendedEditorConfig: Partial + linter: LinterDefinition | null + completer: Completer | null + + // NOTE: This property is not reactive + environmentHighlights: boolean + + /** + * Whether or not to highlight predefined variables, such as: `<<$guid>>`. + * - These are special variables that starts with a dolar sign. + */ + predefinedVariablesHighlights?: boolean + + additionalExts?: Extension[] + + contextMenuEnabled?: boolean + + // callback on editor update + onUpdate?: (view: ViewUpdate) => void + onChange?: (value: string) => void + + // callback on view initialization + onInit?: (view: EditorView) => void +} + +const hoppCompleterExt = (completer: Completer): Extension => { + return autocompletion({ + override: [ + async (context) => { + // Expensive operation! Disable on bigger files ? + const text = context.state.doc.toJSON().join(context.state.lineBreak) + + const line = context.state.doc.lineAt(context.pos) + const lineStart = line.from + const lineNo = line.number - 1 + const ch = context.pos - lineStart + + // Only do trigger on type when typing a word token, else stop (unless explicit) + if (!context.matchBefore(/\w+/) && !context.explicit) + return { + from: context.pos, + options: [], + } + + const result = await completer(text, { line: lineNo, ch }) + + // Use more completion features ? + const completions = + result?.completions.map((comp) => ({ + label: comp.text, + detail: comp.meta, + })) ?? [] + + return { + from: context.state.wordAt(context.pos)?.from ?? context.pos, + options: completions, + } + }, + ], + }) +} + +const hoppLinterExt = (hoppLinter: LinterDefinition | undefined): Extension => { + return linter(async (view) => { + if (!hoppLinter) return [] + + // Requires full document scan, hence expensive on big files, force disable on big files ? + const linterResult = await hoppLinter( + view.state.doc.toJSON().join(view.state.lineBreak) + ) + + return linterResult.map((result) => { + const startPos = + view.state.doc.line(result.from.line).from + result.from.ch - 1 + const endPos = view.state.doc.line(result.to.line).from + result.to.ch - 1 + + return { + from: startPos < 0 ? 0 : startPos, + to: endPos > view.state.doc.length ? view.state.doc.length : endPos, + message: result.message, + severity: result.severity, + } + }) + }) +} + +const hoppLang = ( + language: Language | undefined, + linter?: LinterDefinition | undefined, + completer?: Completer | undefined +): Extension | LanguageSupport => { + const exts: Extension[] = [] + + exts.push(hoppLinterExt(linter)) + if (completer) exts.push(hoppCompleterExt(completer)) + + // Add comment token configuration for JSONC to enable comment toggle + if (language === jsoncLanguage) { + exts.push( + EditorState.languageData.of(() => [{ commentTokens: { line: "//" } }]) + ) + } + + return language ? new LanguageSupport(language, exts) : exts +} + +/** + * Map of language MIME types to their corresponding language definitions + * where the import name matches the langMime string exactly. + * These are used with `StreamLanguage.define(...)` to register the language. + */ +const streamLanguageMap: Record = { + rust, + clojure, + csharp, + go, + http, + java, + powershell: powerShell, + python, + shell, + html, + r, + ruby, + swift, +} + +/** + * Returns the appropriate CodeMirror language object based on the provided MIME type. + * + * Handles specific content types like JSON, JavaScript, GraphQL, XML, etc. + * For simpler languages that directly match the import name, uses a lookup map + * to reduce repetition and automatically defines the StreamLanguage. + * + * @param langMime - The MIME type or shorthand language identifier (e.g., "javascript", "go", "python") + * @returns The corresponding CodeMirror Language object + */ +const getLanguage = (langMime: string): Language | null => { + // Special case for JSON types + if (isJSONContentType(langMime)) { + return jsoncLanguage + } else if ( + langMime === "application/javascript" || + langMime === "javascript" + ) { + return javascriptLanguage + } else if (langMime === "graphql") { + return GQLLanguage + } else if (langMime === "application/xml") { + return xmlLanguage + } else if (langMime === "htmlmixed") { + return StreamLanguage.define(html) + } else if (langMime === "application/x-sh") { + return StreamLanguage.define(shell) + } else if (langMime === "text/x-yaml") { + return StreamLanguage.define(yaml) + } + + // Handle cases where langMime directly matches the import name + const streamLang = streamLanguageMap[langMime] + if (streamLang) { + return StreamLanguage.define(streamLang) + } + + // If no match is found, return null + return null +} + +const formatXML = (doc: string) => { + try { + const formatted = xmlFormat(doc, { + indentation: " ", + collapseContent: true, + lineSeparator: "\n", + whiteSpaceAtEndOfSelfclosingTag: true, + }) + + return E.right(formatted) + } catch (e) { + return E.left(e) + } +} + +/** + * Uses xml-formatter to format the XML document + * @param doc Document to parse + * @param langMime Language mime type + * @returns Parsed document if mime type is xml, else returns the original document + */ +const parseDoc = ( + doc: string | undefined, + langMime: string +): string | undefined => { + if (langMime === "application/xml" && doc) { + const xmlFormatingResult = formatXML(doc) + if (E.isRight(xmlFormatingResult)) return xmlFormatingResult.right + } + + return doc +} + +const getEditorLanguage = ( + langMime: string, + linter: LinterDefinition | undefined, + completer: Completer | undefined +): Extension => hoppLang(getLanguage(langMime) ?? undefined, linter, completer) + +/** + * Strips the `export {};\n` prefix from the value for display in the editor. + * The prefix is used internally for Monaco editor's module scope, + * and should not be visible in the CodeMirror editor. + */ +const stripModulePrefixForDisplay = (value?: string): string | undefined => { + return value?.startsWith(MODULE_PREFIX) + ? value.slice(MODULE_PREFIX.length) + : value +} + +/** + * Maximum selection size in characters for context menu display. + * Selections larger than this will not trigger the context menu to prevent performance issues. + */ +const MAX_CONTEXT_MENU_CHAR_COUNT = 100_000 + +export function useCodemirror( + el: Ref, + value: Ref, + options: CodeMirrorOptions +): { + cursor: Ref<{ line: number; ch: number }> +} { + const { subscribeToStream } = useStreamSubscriber() + + // Set default value for contextMenuEnabled if not provided + options.contextMenuEnabled = options.contextMenuEnabled ?? true + options.extendedEditorConfig.useLang = + options.extendedEditorConfig.useLang ?? true + + const additionalExts = new Compartment() + const language = new Compartment() + const lineWrapping = new Compartment() + const placeholderConfig = new Compartment() + + const cachedCursor = ref({ + line: 0, + ch: 0, + }) + const cursor = ref({ + line: 0, + ch: 0, + }) + + const cachedValue = ref(value.value) + + const view = ref() + + const environmentTooltip = options.environmentHighlights + ? new HoppEnvironmentPlugin(subscribeToStream, view) + : null + + const closeContextMenu = () => { + invokeAction("contextmenu.open", { + position: { + top: 0, + left: 0, + }, + text: null, + }) + } + const predefinedVariable: HoppPredefinedVariablesPlugin | null = + options.predefinedVariablesHighlights + ? new HoppPredefinedVariablesPlugin() + : null + + function handleTextSelection() { + const selection = view.value?.state.selection.main + if (selection) { + const { from, to } = selection + + // If the selection is empty, hide the context menu + if (from === to) { + closeContextMenu() + return + } + + // Skip context menu for very large selections (> 100,000 characters) + const selectionSize = to - from + + if (selectionSize > MAX_CONTEXT_MENU_CHAR_COUNT) { + closeContextMenu() + return + } + + // Only extract text if selection is reasonably sized + const text = view.value?.state.doc.sliceString(from, to) + const coords = view.value?.coordsAtPos(to) + const top = coords?.top ?? 0 + const left = coords?.left ?? 0 + if (text?.trim()) { + invokeAction("contextmenu.open", { + position: { + top, + left, + }, + text, + }) + } else { + closeContextMenu() + } + } + } + + // Debounce text selection to prevent rapid-fire calls from double clicks and key repeats + const debouncedTextSelection = useDebounceFn(() => { + handleTextSelection() + }, 140) + + const initView = (el: any) => { + if (el) platform.ui?.onCodemirrorInstanceMount?.(el) + + const extensions = [ + basicSetup, + baseTheme, + syntaxHighlighting(baseHighlightStyle, { fallback: true }), + + ViewPlugin.fromClass( + class { + constructor() { + // Only add event listeners if context menu is enabled in the editor + if (options.contextMenuEnabled) { + el.addEventListener("mouseup", debouncedTextSelection) + el.addEventListener("keyup", debouncedTextSelection) + } + } + + update(update: ViewUpdate) { + if (options.onUpdate) { + options.onUpdate(update) + } + + const cursorPos = update.state.selection.main.head + const line = update.state.doc.lineAt(cursorPos) + + cachedCursor.value = { + line: line.number - 1, + ch: cursorPos - line.from, + } + + cursor.value = { + line: cachedCursor.value.line, + ch: cachedCursor.value.ch, + } + + if (update.docChanged) { + // Expensive on big files ? + cachedValue.value = update.state.doc + .toJSON() + .join(update.state.lineBreak) + if (!options.extendedEditorConfig.readOnly) { + // Only update if the value is actually different to prevent circular updates + if (value.value !== cachedValue.value) { + value.value = cachedValue.value + } + if (options.onChange) { + options.onChange(cachedValue.value) + } + } + } + } + + destroy() { + if (options.contextMenuEnabled) { + el.removeEventListener("mouseup", debouncedTextSelection) + el.removeEventListener("keyup", debouncedTextSelection) + } + } + } + ), + + EditorView.domEventHandlers({ + scroll(event, view) { + // HACK: This is a workaround to fix the issue in CodeMirror where the content doesn't load when the editor is not in view. + view.requestMeasure() + + if (event.target && options.contextMenuEnabled) { + // close the context menu when the editor is scrolled + closeContextMenu() + } + }, + }), + EditorView.updateListener.of((update) => { + if (options.extendedEditorConfig.readOnly) { + update.view.contentDOM.inputMode = "none" + } + }), + EditorState.changeFilter.of(() => !options.extendedEditorConfig.readOnly), + placeholderConfig.of( + placeholder(options.extendedEditorConfig.placeholder ?? "") + ), + language.of( + getEditorLanguage( + options.extendedEditorConfig.useLang + ? ((options.extendedEditorConfig.mode as any) ?? "") + : "", + options.linter ?? undefined, + options.completer ?? undefined + ) + ), + lineWrapping.of( + options.extendedEditorConfig.lineWrapping + ? [EditorView.lineWrapping] + : [] + ), + keymap.of([ + ...defaultKeymap, + { + key: "Tab", + preventDefault: true, + run: insertTab, + }, + { + key: "Shift-Tab", + preventDefault: true, + run: indentLess, + }, + ]), + Prec.highest( + keymap.of([ + { + key: "Ctrl-y", + mac: "Cmd-y", + preventDefault: true, + run: redo, + }, + ]) + ), + Prec.highest( + keymap.of([ + { + key: "Ctrl-Enter" /* Windows */, + mac: "Cmd-Enter" /* Mac */, + preventDefault: true, + run: () => true, + }, + ]) + ), + tooltips({ + parent: document.body, + position: "absolute", + }), + EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }), + additionalExts.of(options.additionalExts ?? []), + ] + + if (environmentTooltip) extensions.push(environmentTooltip.extension) + if (predefinedVariable) extensions.push(predefinedVariable.extension) + + view.value = new EditorView({ + parent: el, + state: EditorState.create({ + doc: parseDoc( + stripModulePrefixForDisplay(value.value), + options.extendedEditorConfig.mode ?? "" + ), + extensions, + }), + // scroll to top when mounting + scrollTo: EditorView.scrollIntoView(0), + }) + + // Register the view for global access + registerCodeMirrorView(view.value.dom, view.value) + + options.onInit?.(view.value) + } + + onMounted(() => { + if (el.value) { + if (!view.value) initView(el.value) + } + }) + + watch(el, () => { + if (el.value) { + if (view.value) { + unregisterCodeMirrorView(view.value.dom) + view.value.destroy() + } + initView(el.value) + } else { + if (view.value) { + unregisterCodeMirrorView(view.value.dom) + view.value.destroy() + } + view.value = undefined + } + }) + + onBeforeUnmount(() => { + view.value?.destroy() + }) + + watch(value, (newVal) => { + if (newVal === undefined) { + if (view.value) { + unregisterCodeMirrorView(view.value.dom) + view.value.destroy() + } + view.value = undefined + return + } + + if (!view.value && el.value) { + initView(el.value) + } + + // Strip `export {};\n` before displaying in CodeMirror + const displayValue = stripModulePrefixForDisplay(newVal) ?? "" + + if (cachedValue.value !== newVal) { + view.value?.dispatch({ + filter: false, + changes: { + from: 0, + to: view.value.state.doc.length, + insert: displayValue, + }, + }) + } + cachedValue.value = newVal + }) + + watch( + () => [ + options.extendedEditorConfig.mode, + options.linter, + options.completer, + ], + () => { + view.value?.dispatch({ + effects: language.reconfigure( + getEditorLanguage( + options.extendedEditorConfig.useLang + ? ((options.extendedEditorConfig.mode as any) ?? "") + : "", + options.linter ?? undefined, + options.completer ?? undefined + ) + ), + }) + } + ) + + watch( + () => options.additionalExts, + (newExts) => { + view.value?.dispatch({ + effects: additionalExts.reconfigure(newExts ?? []), + }) + } + ) + + watch( + () => options.extendedEditorConfig.lineWrapping, + (newMode) => { + view.value?.dispatch({ + effects: lineWrapping.reconfigure( + newMode ? [EditorView.lineWrapping] : [] + ), + }) + } + ) + + watch( + () => options.extendedEditorConfig.placeholder, + (newValue) => { + view.value?.dispatch({ + effects: placeholderConfig.reconfigure(placeholder(newValue ?? "")), + }) + } + ) + + watch(cursor, (newPos) => { + if (view.value) { + if ( + cachedCursor.value.line !== newPos.line || + cachedCursor.value.ch !== newPos.ch + ) { + const line = view.value.state.doc.line(newPos.line + 1) + const ch = newPos.ch === -1 ? line.length : newPos.ch + const selUpdate = EditorSelection.cursor(line.from + ch) + + view.value?.focus() + + view.value.dispatch({ + scrollIntoView: true, + selection: selUpdate, + effects: EditorView.scrollIntoView(selUpdate), + }) + } + } + }) + + return { + cursor, + } +} diff --git a/packages/hoppscotch-common/src/composables/desktop-settings.ts b/packages/hoppscotch-common/src/composables/desktop-settings.ts new file mode 100644 index 0000000..e48152e --- /dev/null +++ b/packages/hoppscotch-common/src/composables/desktop-settings.ts @@ -0,0 +1,227 @@ +import { reactive, ref, readonly } from "vue" +import * as E from "fp-ts/Either" +import { invoke } from "@tauri-apps/api/core" + +// Bind to the unified, process-wide store rather than the org-scoped +// default `Store`. Desktop settings are machine-level configuration +// (for example the "disable update checks" toggle), and the Tauri +// shell reads them through its own `kernel/store.ts` wrapper at the +// same physical path. Going through the org-scoped store would route +// writes to a different file and the shell would never see them. +// +// Relative imports rather than the `~/` alias because this module is +// consumed by both the web entry (where `~` resolves to common's src) +// and the desktop shell entry (where `~` resolves to the shell's own +// src). The package-name alias `@hoppscotch/common/...` would work +// under Vite dev but fails under Rollup build, which treats the +// rewritten `@hoppscotch/common/src/...` as an unresolved package +// specifier. Relative paths resolve identically under TS, both Vite +// configs, and the production build. +import { UnifiedStore as Store } from "../kernel/store" +import { + DESKTOP_SETTINGS_SCHEMA, + DESKTOP_SETTINGS_STORE_KEY, + DESKTOP_SETTINGS_STORE_NAMESPACE, + parseDesktopSettings, + type DesktopSettings, +} from "../platform/desktop-settings" +import { setKeyboardLayoutStrategy } from "../helpers/keyboard-strategy" +import { Log } from "../kernel/log" + +const LOG_TAG = "useDesktopSettings" + +/** + * Webview-side accessor for the desktop-app user settings. + * + * Reads and writes through `tauri-plugin-store` under the same namespace + * and key as the Tauri shell's persistence service, and mirrors every + * webview-originated write into the Rust-side `DESKTOP_CONFIG` mailbox + * via the `set_desktop_config` Tauri command. + * + * Why the webview handles its own Rust sync rather than relying on the + * shell's watch-based sync: the shell window closes once `appload` loads + * this webview bundle, which tears down the shell's persistence service + * and its watch subscription. Writes made after that point have no shell + * listener to forward them, so the webview owns the sync for its own + * lifetime. The shell's sync handles initial prime at app startup plus + * any shell-originated writes (the portable welcome screen) during its + * short pre-webview life. + * + * `update(key, value)` is transactional: the reactive object is mutated + * first so callers see an optimistic update, but a persist failure rolls + * the reactive back to its previous value and rethrows, so in-memory + * state never drifts from what's in the store. + * + * Module-level singleton: every caller shares the same reactive object + * so the settings section and any other consumer bound to these values + * stay coherent. + */ + +type UpdateFn = ( + key: K, + value: DesktopSettings[K] +) => Promise + +// Singleton state, initialized on first `useDesktopSettings()` call. +const settings = reactive(DESKTOP_SETTINGS_SCHEMA.parse({})) +const loaded = ref(false) +let initPromise: Promise | undefined + +async function loadInitial(): Promise { + // Open the unified store before reading. The shell already inits this + // path through its own `DesktopPersistenceService.init`, but the + // webview runs in a separate window with its own process state, so + // the underlying Tauri store still needs to be opened here. Repeat + // calls land on the same on-disk file and are harmless. + const initResult = await Store.init() + if (E.isLeft(initResult)) { + Log.warn(LOG_TAG, "Failed to init unified store", initResult.left) + } + + const result = await Store.get( + DESKTOP_SETTINGS_STORE_NAMESPACE, + DESKTOP_SETTINGS_STORE_KEY + ) + const raw = E.isRight(result) ? result.right : undefined + Object.assign(settings, parseDesktopSettings(raw)) + setKeyboardLayoutStrategy(settings.keyboardLayoutStrategy) + loaded.value = true + + // Subscribe to external writes (for example the Tauri shell's portable + // welcome screen) so the reactive object stays current. One subscription + // per process is enough because the reactive object is a module-level + // singleton. + try { + const emitter = await Store.watch( + DESKTOP_SETTINGS_STORE_NAMESPACE, + DESKTOP_SETTINGS_STORE_KEY + ) + emitter.on("change", ({ value }: { value?: unknown }) => { + if (value !== undefined) { + Object.assign(settings, parseDesktopSettings(value)) + setKeyboardLayoutStrategy(settings.keyboardLayoutStrategy) + } + }) + } catch (err) { + Log.warn(LOG_TAG, "Failed to subscribe to store", err) + } +} + +async function persist(): Promise { + const validated = DESKTOP_SETTINGS_SCHEMA.parse(settings) + const writeResult = await Store.set( + DESKTOP_SETTINGS_STORE_NAMESPACE, + DESKTOP_SETTINGS_STORE_KEY, + validated + ) + if (E.isLeft(writeResult)) { + // `StoreError` is a tagged union. Formatting `kind` and `message` + // explicitly keeps the thrown error readable. A plain + // `${writeResult.left}` interpolation stringifies to + // `[object Object]` and hides the actual cause from stack traces. + const err = writeResult.left + Log.error(LOG_TAG, "Failed to write desktopSettings", err) + throw new Error( + `Failed to write desktopSettings: ${err.kind}: ${err.message}` + ) + } + + // Mirror to Rust. Non-fatal on failure because Rust falls back to + // its compile-time defaults when the mailbox is empty, so a missed + // sync degrades to "Rust reads an older value" rather than rejecting + // the write the user already committed to. + try { + await invoke("set_desktop_config", { config: validated }) + } catch (err) { + Log.warn(LOG_TAG, "Failed to push DesktopSettings to Rust", err) + } +} + +export function useDesktopSettings(): { + /** Reactive settings object. Read-only externally, bind via refs in templates. */ + settings: Readonly + /** True once the initial store read has completed. */ + loaded: Readonly + /** Updates a single setting and persists immediately, rolling back on failure. */ + update: UpdateFn + /** + * Resolves once the initial store read has completed (success or + * failure). Synchronous readers of `settings` that need the persisted + * value rather than the schema default await this first. Callers that + * already react to `settings` through Vue reactivity do not need it, + * since `Object.assign(settings, ...)` inside `loadInitial()` triggers + * watchers when the persisted value arrives. + */ + ready: () => Promise +} { + if (!initPromise) { + initPromise = loadInitial().catch((err) => { + Log.error(LOG_TAG, "Initial load failed", err) + // Swallow so repeat calls retry on next `update()`. + initPromise = undefined + throw err + }) + } + + const ready: () => Promise = async () => { + if (!initPromise) return + try { + await initPromise + } catch { + // Load failed. Caller can check `loaded.value` to decide whether + // to proceed against the schema defaults or retry. Swallowing + // matches the pattern `update()` uses below. + } + } + + const update: UpdateFn = async (key, value) => { + // Wait for the initial load before mutating. Without this, a + // user clicking a toggle immediately after mount could interleave + // with `loadInitial()`: the optimistic mutation and persist would + // land first, and then `loadInitial()` would resolve and call + // `Object.assign(settings, ...)` with the old on-disk value, + // overwriting the user's change in memory. + if (initPromise) { + try { + await initPromise + } catch { + // Load failed. The caller's `update` will still attempt a + // persist below, which is the right behaviour: the user + // wants their change saved even if the initial read failed. + } + } + + const previous = settings[key] + settings[key] = value + // Mirror the change into the keyboard-strategy holder eagerly so the + // next keypress respects the new strategy without waiting for the + // store-watch callback to round-trip. The watch fires later with the + // same value, and the redundant write is cheap. + if (key === "keyboardLayoutStrategy") { + setKeyboardLayoutStrategy( + value as DesktopSettings["keyboardLayoutStrategy"] + ) + } + try { + await persist() + } catch (err) { + // Restore the reactive state so the in-memory view reflects what's + // actually in the store. Without this, a failed persist leaves the + // settings object holding a value the next app start will not find. + settings[key] = previous + if (key === "keyboardLayoutStrategy") { + setKeyboardLayoutStrategy( + previous as DesktopSettings["keyboardLayoutStrategy"] + ) + } + throw err + } + } + + return { + settings: readonly(settings) as Readonly, + loaded: readonly(loaded), + update, + ready, + } +} diff --git a/packages/hoppscotch-common/src/composables/desktop-zoom.ts b/packages/hoppscotch-common/src/composables/desktop-zoom.ts new file mode 100644 index 0000000..4709ed5 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/desktop-zoom.ts @@ -0,0 +1,65 @@ +import { watch, type WatchStopHandle } from "vue" +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow" +// Relative imports rather than the `~/` alias because this module is +// consumed by both the web entry (where `~` resolves to common's src) +// and the desktop shell entry (where `~` resolves to the shell's own +// src). The package-name alias `@hoppscotch/common/...` +// would work under Vite dev (which honors pnpm symlinks via esbuild) +// but fails under Rollup build, which treats the rewritten +// `@hoppscotch/common/src/...` as an unresolved package specifier. +// Relative paths resolve identically under TS, both Vite configs, and +// the production build. +import { useDesktopSettings } from "./desktop-settings" +import { Log } from "../kernel/log" + +const LOG_TAG = "useDesktopZoomEffect" + +/** + * Applies the persisted desktop `zoomLevel` to the current Tauri webview + * and keeps it in sync with the setting. + * + * Each desktop entry point calls this once during startup. The launcher + * window and the bundled web window each get their own invocation, and + * each watcher addresses its own window through + * `getCurrentWebviewWindow()`, which is the only window the webview can + * see. The settings store is the shared source of truth between the two + * windows, so a write in one reaches the other through the unified store + * watch that `useDesktopSettings()` already maintains, and each window's + * own watcher then re-applies the zoom locally. + * + * The watcher tracks both `loaded` and `zoomLevel` and skips the apply + * until `loaded` flips true. A naive `{ immediate: true }` on `zoomLevel` + * alone would fire synchronously with the schema default (1.0) before + * `loadInitial()` hydrates from disk, undoing any Rust-side pre-mount + * apply on the bundled window. For a user persisted at `1.5`, the + * sequence would be Rust paints 1.5, JS immediate apply runs setZoom(1.0) + * and overrides it, then `loadInitial()` resolves and the watcher fires + * again with 1.5, producing a 1.5 -> 1.0 -> 1.5 flash on every connect. + * Gating on `loaded` means the first apply uses the persisted value + * directly, leaving the pre-mount apply intact. Subsequent user edits + * flow through the same watcher as ordinary reactive changes. + */ +export function useDesktopZoomEffect(): WatchStopHandle { + const desktopSettings = useDesktopSettings() + + return watch( + () => + [ + desktopSettings.loaded.value, + desktopSettings.settings.zoomLevel, + ] as const, + async ([isLoaded, factor]) => { + if (!isLoaded) return + try { + await getCurrentWebviewWindow().setZoom(factor) + } catch (err) { + // setZoom can reject on Linux WebKitGTK when called very early + // in the window lifecycle, before the webview is fully attached. + // The next change re-applies, and a fresh launch retries via the + // initial fire of this same watcher after `loaded` flips true. + Log.warn(LOG_TAG, `setZoom(${factor}) failed`, err) + } + }, + { immediate: true } + ) +} diff --git a/packages/hoppscotch-common/src/composables/documentationVisibility.ts b/packages/hoppscotch-common/src/composables/documentationVisibility.ts new file mode 100644 index 0000000..cecebf8 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/documentationVisibility.ts @@ -0,0 +1,23 @@ +import { computed } from "vue" + +import { useSetting } from "~/composables/settings" + +/** + * Composable to determine documentation visibility based on experimental flags + */ +export function useDocumentationVisibility() { + const ENABLE_EXPERIMENTAL_DOCUMENTATION = useSetting( + "ENABLE_EXPERIMENTAL_DOCUMENTATION" + ) + + /** + * Check if documentation should be visible based on experimental flag + */ + const isDocumentationVisible = computed( + () => ENABLE_EXPERIMENTAL_DOCUMENTATION.value + ) + + return { + isDocumentationVisible, + } +} diff --git a/packages/hoppscotch-common/src/composables/graphql.ts b/packages/hoppscotch-common/src/composables/graphql.ts new file mode 100644 index 0000000..a7ae600 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/graphql.ts @@ -0,0 +1,236 @@ +import * as E from "fp-ts/Either" +import { pipe } from "fp-ts/function" +import { + reactive, + ref, + Ref, + unref, + isRef, + watchEffect, + WatchStopHandle, + watchSyncEffect, + watch, + nextTick, +} from "vue" +import { + client, + GQLError, + parseGQLErrorString, +} from "@helpers/backend/GQLClient" +import { + AnyVariables, + createRequest, + GraphQLRequest, + OperationResult, + TypedDocumentNode, +} from "@urql/core" +import { Source, pipe as wonkaPipe, onEnd, subscribe } from "wonka" + +type MaybeRef = X | Ref + +type UseQueryOptions = { + query: TypedDocumentNode + variables?: MaybeRef + + updateSubs?: MaybeRef[]> + defer?: boolean + pollDuration?: number | undefined + + /** + * Determines the behavior of the loading state during polling. + * If true, the loading state toggles each time the poller runs; + * if false, it is set to true only once when the poller starts. + */ + pollLoadingEnabled?: boolean +} + +export const useGQLQuery = < + DocType, + DocVarType extends AnyVariables, + DocErrorType extends string, +>( + _args: UseQueryOptions +) => { + const stops: WatchStopHandle[] = [] + + const args = reactive(_args) + + const loading: Ref = ref(true) + const isStale: Ref = ref(true) + const data: Ref, DocType>> = ref() as any + + if (!args.updateSubs) args.updateSubs = [] + + const isPaused: Ref = ref(args.defer ?? false) + + const pollDuration: Ref = ref(args.pollDuration ?? null) + + const pollLoadingEnabled: Ref = ref(args.pollLoadingEnabled ?? true) + + const request: Ref> = ref( + createRequest( + args.query, + unref(args.variables as any) as any + ) + ) as any + + const source: Ref | undefined> = ref() + + // Toggles between true and false to cause the polling operation to tick + const pollerTick: Ref = ref(true) + + stops.push( + watchEffect((onInvalidate) => { + if (pollDuration.value !== null && !isPaused.value) { + const handle = setInterval(() => { + pollerTick.value = !pollerTick.value + }, pollDuration.value) + + onInvalidate(() => { + clearInterval(handle) + }) + } + }) + ) + + stops.push( + watchEffect( + () => { + const newRequest = createRequest( + args.query, + unref(args.variables as any) as any + ) + + if (request.value.key !== newRequest.key) { + request.value = newRequest + } + }, + { flush: "pre" } + ) + ) + + const rerunQuery = () => { + if (pollLoadingEnabled.value) { + loading.value = true + } + + source.value = !isPaused.value + ? client.value?.executeQuery(request.value, { + requestPolicy: "network-only", + }) + : undefined + } + + stops.push( + watch( + pollerTick, + () => { + rerunQuery() + }, + { + flush: "pre", + } + ) + ) + + watchSyncEffect((onInvalidate) => { + if (source.value) { + if (pollLoadingEnabled.value) { + loading.value = true + } + isStale.value = false + + const invalidateStops = args.updateSubs!.map((sub) => { + return wonkaPipe( + client.value!.executeSubscription(sub), + onEnd(() => { + if (source.value) execute() + }), + subscribe(() => { + return execute() + }) + ).unsubscribe + }) + + invalidateStops.push( + wonkaPipe( + source.value, + onEnd(() => { + loading.value = false + isStale.value = false + }), + subscribe((res) => { + if (res.operation.key === request.value.key) { + data.value = pipe( + // The target + res.data as DocType | undefined, + // Define what happens if data does not exist (it is an error) + E.fromNullable( + pipe( + // Take the network error value + res.error?.networkError, + // If it null, set the left to the generic error name + E.fromNullable(res.error?.message), + E.match( + // The left case (network error was null) + (gqlErr) => + >{ + type: "gql_error", + error: parseGQLErrorString( + gqlErr ?? "" + ) as DocErrorType, + }, + // The right case (it was a GraphQL Error) + (networkErr) => + >{ + type: "network_error", + error: networkErr, + } + ) + ) + ) + ) + loading.value = false + } + }) + ).unsubscribe + ) + + onInvalidate(() => invalidateStops.forEach((unsub) => unsub())) + } + }) + + const execute = (updatedVars?: DocVarType) => { + if (updatedVars) { + if (isRef(args.variables)) { + args.variables.value = updatedVars + } else { + args.variables = updatedVars + } + nextTick(rerunQuery) + } else { + rerunQuery() + } + + isPaused.value = false + } + + const pause = () => { + isPaused.value = true + } + + const unpause = () => { + isPaused.value = false + } + + const response = reactive({ + loading, + data, + pause, + unpause, + isStale, + execute, + }) + + return response +} diff --git a/packages/hoppscotch-common/src/composables/head.ts b/packages/hoppscotch-common/src/composables/head.ts new file mode 100644 index 0000000..0f2ecfd --- /dev/null +++ b/packages/hoppscotch-common/src/composables/head.ts @@ -0,0 +1 @@ +export { useHead as usePageHead } from "@unhead/vue" diff --git a/packages/hoppscotch-common/src/composables/i18n.ts b/packages/hoppscotch-common/src/composables/i18n.ts new file mode 100644 index 0000000..c330264 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/i18n.ts @@ -0,0 +1,7 @@ +import { useI18n as _useI18n } from "vue-i18n" + +export function useI18n() { + return _useI18n().t +} + +export const useFullI18n = _useI18n diff --git a/packages/hoppscotch-common/src/composables/lens-actions.ts b/packages/hoppscotch-common/src/composables/lens-actions.ts new file mode 100644 index 0000000..ff070b4 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/lens-actions.ts @@ -0,0 +1,189 @@ +import { HoppRESTResponse } from "@helpers/types/HoppRESTResponse" +import { copyToClipboard } from "@helpers/utils/clipboard" +import { refAutoReset } from "@vueuse/core" +import { computed, ComputedRef, ref, Ref, watch } from "vue" + +import jsonToLanguage from "~/helpers/utils/json-to-language" +import { platform } from "~/platform" +import IconCheck from "~icons/lucide/check" +import IconCopy from "~icons/lucide/copy" +import IconDownload from "~icons/lucide/download" +import { useI18n } from "./i18n" +import { useToast } from "./toast" +import { HoppRESTRequestResponse } from "@hoppscotch/data" + +export function useCopyInterface(responseBodyText: Ref) { + const toast = useToast() + const t = useI18n() + + const copyInterfaceIcon = refAutoReset(IconCopy, 1000) + + const copyInterface = async (targetLanguage: string) => { + jsonToLanguage(targetLanguage, responseBodyText.value).then((res) => { + copyToClipboard(res.lines.join("\n")) + copyInterfaceIcon.value = IconCheck + toast.success( + t("state.copied_interface_to_clipboard", { language: targetLanguage }) + ) + }) + } + + return { + copyInterfaceIcon, + copyInterface, + } +} + +export function useCopyResponse(responseBodyText: Ref) { + const toast = useToast() + const t = useI18n() + + const copyIcon = refAutoReset(IconCopy, 1000) + + const copyResponse = () => { + copyToClipboard(responseBodyText.value) + copyIcon.value = IconCheck + toast.success(`${t("state.copied_to_clipboard")}`) + } + + return { + copyIcon, + copyResponse, + } +} + +export type downloadResponseReturnType = (() => void) | Ref + +export function useDownloadResponse( + contentType: string, + responseBody: Ref, + filename: string +) { + const downloadIcon = refAutoReset(IconDownload, 1000) + + const toast = useToast() + const t = useI18n() + + const downloadResponse = async () => { + const dataToWrite = responseBody.value + + // TODO: Look at the mime type and determine extension ? + const result = await platform.kernelIO.saveFileWithDialog({ + data: dataToWrite, + contentType: contentType, + suggestedFilename: filename, + }) + + // Assume success if unknown as we cannot determine + if (result.type === "unknown" || result.type === "saved") { + downloadIcon.value = IconCheck + toast.success(`${t("state.download_started")}`) + } + } + + return { + downloadIcon, + downloadResponse, + } +} + +export function usePreview( + previewEnabled: Ref, + responseBodyText: Ref +): { + previewFrame: Ref + previewEnabled: Ref + togglePreview: () => void +} { + const previewFrame: Ref = ref(null) + const url = ref("") + + const updatePreviewFrame = () => { + if ( + previewEnabled.value && + previewFrame.value && + shouldUpdatePreviewFrame.value + ) { + // Use DOMParser to parse document HTML. + const previewDocument = new DOMParser().parseFromString( + responseBodyText.value, + "text/html" + ) + // Inject tag to head, to fix relative CSS/HTML paths. + previewDocument.head.innerHTML = + `` + previewDocument.head.innerHTML + + // Finally, set the iframe source to the resulting HTML. + previewFrame.value.srcdoc = previewDocument.documentElement.outerHTML + previewFrame.value.setAttribute("data-previewing-url", url.value) + + // Enable sandboxing for the iframe but this can have security implications + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox + // https://stackoverflow.com/a/30785417 + // previewFrame.value.setAttribute( + // "sandbox", + // "allow-scripts allow-same-origin" + // ) + } + } + + // `previewFrame` is a template ref that gets attached to the `iframe` element when the component mounts + // Ensures the HTML content is rendered immediately after a request, persists between tab switches, and is not limited to preview toggles + // Also watches for changes in the `previewEnabled` state to update the `iframe` element attributes + watch( + previewEnabled, + () => { + updatePreviewFrame() + }, + { + immediate: true, + } + ) + + // Prevent updating the `iframe` element attributes during preview toggle actions after they are set initially + const shouldUpdatePreviewFrame = computed( + () => previewFrame.value?.getAttribute("data-previewing-url") !== url.value + ) + + const togglePreview = () => { + previewEnabled.value = !previewEnabled.value + updatePreviewFrame() + } + + return { + previewFrame, + previewEnabled, + togglePreview, + } +} + +export function useResponseBody( + response: HoppRESTResponse | HoppRESTRequestResponse +): { + responseBodyText: ComputedRef +} { + const responseBodyText = computed(() => { + if ("type" in response) { + if ( + response.type === "loading" || + response.type === "network_fail" || + response.type === "script_fail" || + response.type === "fail" || + response.type === "extension_error" + ) + return "" + } + return getResponseBodyText(response.body) + }) + return { + responseBodyText, + } +} + +export function getResponseBodyText(body: ArrayBuffer | string): string { + if (typeof body === "string") return body + + const res = new TextDecoder("utf-8").decode(body) + // HACK: Temporary trailing null character issue from the extension fix + return res.replace(/\0+$/, "") +} diff --git a/packages/hoppscotch-common/src/composables/mockServer.ts b/packages/hoppscotch-common/src/composables/mockServer.ts new file mode 100644 index 0000000..0ef2c71 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/mockServer.ts @@ -0,0 +1,70 @@ +import { computed } from "vue" +import { useReadonlyStream } from "~/composables/stream" +import { mockServers$ } from "~/newstore/mockServers" +import type { MockServer } from "~/helpers/backend/types/MockServer" + +/** + * Composable to get mock server status for collections + */ +export function useMockServerStatus() { + const mockServers = useReadonlyStream(mockServers$, []) + + /** + * Get mock server for a specific collection + */ + const getMockServerForCollection = ( + collectionId: string + ): MockServer | null => { + return ( + mockServers.value.find( + (server) => + server.collection?.id === collectionId || + server.collectionID === collectionId + ) || null + ) + } + + /** + * Check if a collection has an active mock server + */ + const hasActiveMockServer = (collectionId: string): boolean => { + const mockServer = getMockServerForCollection(collectionId) + return mockServer?.isActive === true + } + + /** + * Check if a collection has any mock server (active or inactive) + */ + const hasMockServer = (collectionId: string): boolean => { + return getMockServerForCollection(collectionId) !== null + } + + /** + * Get mock server status for a collection + */ + const getMockServerStatus = (collectionId: string) => { + const mockServer = getMockServerForCollection(collectionId) + + if (!mockServer) { + return { + exists: false, + isActive: false, + mockServer: null, + } + } + + return { + exists: true, + isActive: mockServer.isActive, + mockServer, + } + } + + return { + mockServers: computed(() => mockServers.value), + getMockServerForCollection, + hasActiveMockServer, + hasMockServer, + getMockServerStatus, + } +} diff --git a/packages/hoppscotch-common/src/composables/mockServerVisibility.ts b/packages/hoppscotch-common/src/composables/mockServerVisibility.ts new file mode 100644 index 0000000..63d53ff --- /dev/null +++ b/packages/hoppscotch-common/src/composables/mockServerVisibility.ts @@ -0,0 +1,23 @@ +import { computed } from "vue" + +import { useSetting } from "~/composables/settings" + +/** + * Composable to determine mock server visibility based on experimental flags + */ +export function useMockServerVisibility() { + const ENABLE_EXPERIMENTAL_MOCK_SERVERS = useSetting( + "ENABLE_EXPERIMENTAL_MOCK_SERVERS" + ) + + /** + * Check if mock servers should be visible based on experimental flag + */ + const isMockServerVisible = computed( + () => ENABLE_EXPERIMENTAL_MOCK_SERVERS.value + ) + + return { + isMockServerVisible, + } +} diff --git a/packages/hoppscotch-common/src/composables/mockServerWorkspace.ts b/packages/hoppscotch-common/src/composables/mockServerWorkspace.ts new file mode 100644 index 0000000..f975ca9 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/mockServerWorkspace.ts @@ -0,0 +1,52 @@ +import { useService } from "dioc/vue" +import { watch } from "vue" +import { loadMockServers, setMockServers } from "~/newstore/mockServers" +import { platform } from "~/platform" +import { WorkspaceService } from "~/services/workspace.service" +import { useMockServerVisibility } from "./mockServerVisibility" +import { useReadonlyStream } from "./stream" + +/** + * Composable to handle mock server state when workspace changes + * This ensures mock servers are cleared immediately when switching workspaces + * to prevent showing stale data from the previous workspace + */ +export function useMockServerWorkspaceSync() { + const workspaceService = useService(WorkspaceService) + const { isMockServerVisible } = useMockServerVisibility() + + const currentUser = useReadonlyStream( + platform.auth.getCurrentUserStream(), + platform.auth.getCurrentUser() + ) + + const loadServers = () => { + if (!currentUser.value || !isMockServerVisible.value) return + loadMockServers().catch(() => setMockServers([])) + } + + // Load mock servers when authentication or visibility changes + watch([currentUser, isMockServerVisible], loadServers, { + immediate: true, + }) + + // Watch for workspace changes and clear mock servers immediately + watch( + () => workspaceService.currentWorkspace.value, + (newWorkspace, oldWorkspace) => { + if (!currentUser.value || !isMockServerVisible.value) return + + // Clear mock servers when workspace changes to prevent stale data + if ( + newWorkspace?.type !== oldWorkspace?.type || + (newWorkspace?.type === "team" && + oldWorkspace?.type === "team" && + newWorkspace.teamID !== oldWorkspace.teamID) + ) { + setMockServers([]) + loadServers() + } + }, + { deep: true } + ) +} diff --git a/packages/hoppscotch-common/src/composables/oauth2/useOAuth2AdvancedParams.ts b/packages/hoppscotch-common/src/composables/oauth2/useOAuth2AdvancedParams.ts new file mode 100644 index 0000000..6178264 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/oauth2/useOAuth2AdvancedParams.ts @@ -0,0 +1,301 @@ +import { HoppGQLAuthOAuth2, HoppRESTAuthOAuth2 } from "@hoppscotch/data" +import { Ref, ref, watch } from "vue" + +export type AuthRequestParam = { + id: number + key: string + value: string + active: boolean + sendIn?: "headers" | "url" | "body" +} + +export type TokenRequestParam = { + id: number + key: string + value: string + active: boolean + sendIn: "headers" | "url" | "body" +} + +let paramsIdCounter = 1000 + +export const useOAuth2AdvancedParams = ( + auth: Ref +) => { + // Auth Request Parameters + const workingAuthRequestParams = ref([ + { id: paramsIdCounter++, key: "", value: "", active: true }, + ]) + + // Token Request Parameters + const workingTokenRequestParams = ref([ + { + id: paramsIdCounter++, + key: "", + value: "", + sendIn: "body", + active: true, + }, + ]) + + // Refresh Request Parameters + const workingRefreshRequestParams = ref([ + { + id: paramsIdCounter++, + key: "", + value: "", + sendIn: "body", + active: true, + }, + ]) + + // Watch for changes in working auth request params + watch( + workingAuthRequestParams, + (newParams: AuthRequestParam[]) => { + // Auto-add empty row when the last row is filled + if (newParams.length > 0 && newParams[newParams.length - 1].key !== "") { + workingAuthRequestParams.value.push({ + id: paramsIdCounter++, + key: "", + value: "", + active: true, + }) + } + + // Update auth.value.grantTypeInfo with non-empty params + const nonEmptyParams = newParams.filter( + (p: AuthRequestParam) => p.key !== "" || p.value !== "" + ) + + if ("authRequestParams" in auth.value.grantTypeInfo) { + auth.value.grantTypeInfo.authRequestParams = nonEmptyParams.map( + (param) => ({ + id: param.id, + key: param.key, + value: param.value, + active: param.active, + }) + ) + } + }, + { deep: true } + ) + + // Watch for changes in working token request params + watch( + workingTokenRequestParams, + (newParams: TokenRequestParam[]) => { + // Auto-add empty row when the last row is filled + if (newParams.length > 0 && newParams[newParams.length - 1].key !== "") { + workingTokenRequestParams.value.push({ + id: paramsIdCounter++, + key: "", + value: "", + sendIn: "body", + active: true, + }) + } + + // Update auth.value.grantTypeInfo with non-empty params + const nonEmptyParams = newParams.filter( + (p: TokenRequestParam) => p.key !== "" || p.value !== "" + ) + + if ("tokenRequestParams" in auth.value.grantTypeInfo) { + auth.value.grantTypeInfo.tokenRequestParams = nonEmptyParams.map( + (param) => ({ + id: param.id, + key: param.key, + value: param.value, + sendIn: param.sendIn, + active: param.active, + }) + ) + } + }, + { deep: true } + ) + + // Watch for changes in working refresh request params + watch( + workingRefreshRequestParams, + (newParams: TokenRequestParam[]) => { + // Auto-add empty row when the last row is filled + if (newParams.length > 0 && newParams[newParams.length - 1].key !== "") { + workingRefreshRequestParams.value.push({ + id: paramsIdCounter++, + key: "", + value: "", + sendIn: "body", + active: true, + }) + } + + // Update auth.value.grantTypeInfo with non-empty params + const nonEmptyParams = newParams.filter( + (p: TokenRequestParam) => p.key !== "" || p.value !== "" + ) + + if ("refreshRequestParams" in auth.value.grantTypeInfo) { + auth.value.grantTypeInfo.refreshRequestParams = nonEmptyParams.map( + (param) => ({ + id: param.id, + key: param.key, + value: param.value, + sendIn: param.sendIn, + active: param.active, + }) + ) + } + }, + { deep: true } + ) + + // Functions for auth request params management + const addAuthRequestParam = () => { + workingAuthRequestParams.value.push({ + id: paramsIdCounter++, + key: "", + value: "", + active: true, + }) + } + + const updateAuthRequestParam = (index: number, payload: AuthRequestParam) => { + workingAuthRequestParams.value[index] = payload + } + + const deleteAuthRequestParam = (index: number) => { + if (workingAuthRequestParams.value.length > 1) { + workingAuthRequestParams.value.splice(index, 1) + } + } + + // Functions for token request params management + const addTokenRequestParam = () => { + workingTokenRequestParams.value.push({ + id: paramsIdCounter++, + key: "", + value: "", + sendIn: "body", + active: true, + }) + } + + const updateTokenRequestParam = ( + index: number, + payload: TokenRequestParam + ) => { + workingTokenRequestParams.value[index] = payload + } + + const deleteTokenRequestParam = (index: number) => { + if (workingTokenRequestParams.value.length > 1) { + workingTokenRequestParams.value.splice(index, 1) + } + } + + // Functions for refresh request params management + const addRefreshRequestParam = () => { + workingRefreshRequestParams.value.push({ + id: paramsIdCounter++, + key: "", + value: "", + sendIn: "body", + active: true, + }) + } + + const updateRefreshRequestParam = ( + index: number, + payload: TokenRequestParam + ) => { + workingRefreshRequestParams.value[index] = payload + } + + const deleteRefreshRequestParam = (index: number) => { + if (workingRefreshRequestParams.value.length > 1) { + workingRefreshRequestParams.value.splice(index, 1) + } + } + + // Initialize advanced parameters from the auth object when component mounts + const initializeParams = () => { + if ( + "authRequestParams" in auth.value.grantTypeInfo && + auth.value.grantTypeInfo.authRequestParams && + auth.value.grantTypeInfo.authRequestParams.length > 0 + ) { + workingAuthRequestParams.value = + auth.value.grantTypeInfo.authRequestParams.map((param) => ({ + id: param.id || paramsIdCounter++, + key: param.key, + value: param.value, + active: param.active, + })) + } + + if ( + "tokenRequestParams" in auth.value.grantTypeInfo && + auth.value.grantTypeInfo.tokenRequestParams && + auth.value.grantTypeInfo.tokenRequestParams.length > 0 + ) { + workingTokenRequestParams.value = [ + ...auth.value.grantTypeInfo.tokenRequestParams.map((param) => ({ + id: param.id || paramsIdCounter++, + key: param.key, + value: param.value, + sendIn: param.sendIn || "body", + active: param.active, + })), + { + id: paramsIdCounter++, + key: "", + value: "", + sendIn: "body", + active: true, + }, + ] + } + + if ( + "refreshRequestParams" in auth.value.grantTypeInfo && + auth.value.grantTypeInfo.refreshRequestParams && + auth.value.grantTypeInfo.refreshRequestParams.length > 0 + ) { + workingRefreshRequestParams.value = [ + ...auth.value.grantTypeInfo.refreshRequestParams.map((param) => ({ + id: param.id || paramsIdCounter++, + key: param.key, + value: param.value, + sendIn: param.sendIn || "body", + active: param.active, + })), + { + id: paramsIdCounter++, + key: "", + value: "", + sendIn: "body", + active: true, + }, + ] + } + } + + return { + workingAuthRequestParams, + workingTokenRequestParams, + workingRefreshRequestParams, + addAuthRequestParam, + updateAuthRequestParam, + deleteAuthRequestParam, + addTokenRequestParam, + updateTokenRequestParam, + deleteTokenRequestParam, + addRefreshRequestParam, + updateRefreshRequestParam, + deleteRefreshRequestParam, + initializeParams, + } +} diff --git a/packages/hoppscotch-common/src/composables/oauth2/useOAuth2GrantTypes.ts b/packages/hoppscotch-common/src/composables/oauth2/useOAuth2GrantTypes.ts new file mode 100644 index 0000000..598b094 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/oauth2/useOAuth2GrantTypes.ts @@ -0,0 +1,948 @@ +import { HoppGQLAuthOAuth2, HoppRESTAuthOAuth2 } from "@hoppscotch/data" +import * as E from "fp-ts/Either" +import { Ref, computed } from "vue" +import { z } from "zod" +import { useI18n } from "~/composables/i18n" +import { useToast } from "~/composables/toast" +import { refWithCallbackOnChange } from "~/composables/ref" +import { + replaceTemplateString, + replaceTemplateStringsInObjectValues, +} from "~/helpers/auth" +import authCode, { + AuthCodeOauthFlowParams, + AuthCodeOauthRefreshParams, + getDefaultAuthCodeOauthFlowParams, +} from "~/services/oauth/flows/authCode" +import clientCredentials, { + ClientCredentialsFlowParams, + getDefaultClientCredentialsFlowParams, +} from "~/services/oauth/flows/clientCredentials" +import implicit, { + ImplicitOauthFlowParams, + getDefaultImplicitOauthFlowParams, +} from "~/services/oauth/flows/implicit" +import passwordFlow, { + PasswordFlowParams, + getDefaultPasswordFlowParams, +} from "~/services/oauth/flows/password" +import { AuthRequestParam, TokenRequestParam } from "./useOAuth2AdvancedParams" + +export type GrantTypes = z.infer< + typeof HoppRESTAuthOAuth2 +>["grantTypeInfo"]["grantType"] + +export const useOAuth2GrantTypes = ( + auth: Ref, + setAccessTokenInActiveContext: ( + accessToken?: string, + refreshToken?: string + ) => void, + workingAuthRequestParams: Ref, + workingTokenRequestParams: Ref, + workingRefreshRequestParams: Ref, + pkceTippyActions: Ref, + clientAuthenticationTippyActions: Ref +) => { + const t = useI18n() + const toast = useToast() + + // Token type dropdown options (shared across all grant types). The OAuth2 + // dropdown component reads `ref.value.{id,label}` and assigns the whole + // option object on click, so the ref must hold an `{id,label}` object — not + // the raw string union. + const tokenTypeOptions = [ + { id: "access_token" as const, label: "Access Token" }, + { id: "id_token" as const, label: "ID Token" }, + ] + + const tokenTypeOptionFor = (value: string | undefined) => + tokenTypeOptions.find((o) => o.id === value) ?? tokenTypeOptions[0] + + // Helper function to prepare request parameters + const prepareRequestParams = ( + params: Ref + ) => { + return params.value + .filter((p) => p.active && p.key && p.value) + .map((p) => ({ + id: p.id, + key: replaceTemplateString(p.key), + value: replaceTemplateString(p.value), + active: p.active, + sendIn: p.sendIn || "body", + })) + } + + const preparedAuthRequestParams = computed(() => { + return prepareRequestParams(workingAuthRequestParams) + }) + + const preparedTokenRequestParams = computed(() => { + return prepareRequestParams(workingTokenRequestParams) + }) + + const preparedRefreshRequestParams = computed(() => { + return prepareRequestParams(workingRefreshRequestParams) + }) + + const grantTypeMap: Record< + GrantTypes, + "authCode" | "clientCredentials" | "password" | "implicit" + > = { + AUTHORIZATION_CODE: "authCode", + CLIENT_CREDENTIALS: "clientCredentials", + IMPLICIT: "implicit", + PASSWORD: "password", + } as const + + const grantTypeDefaultPayload = { + AUTHORIZATION_CODE: getDefaultAuthCodeOauthFlowParams, + CLIENT_CREDENTIALS: getDefaultClientCredentialsFlowParams, + IMPLICIT: getDefaultImplicitOauthFlowParams, + PASSWORD: getDefaultPasswordFlowParams, + } as const + + const supportedGrantTypes = [ + { + id: "authCode" as const, + label: t("authorization.oauth.label_auth_code"), + formElements: computed(() => { + if (!(auth.value.grantTypeInfo.grantType === "AUTHORIZATION_CODE")) { + return + } + + const grantType = auth.value.grantTypeInfo + + const authEndpoint = refWithCallbackOnChange( + grantType?.authEndpoint, + (value) => { + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + authEndpoint: value, + } + } + ) + + const tokenEndpoint = refWithCallbackOnChange( + grantType?.tokenEndpoint, + (value) => { + if (!("tokenEndpoint" in auth.value.grantTypeInfo)) { + return + } + + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + tokenEndpoint: value, + } + } + ) + + const clientID = refWithCallbackOnChange( + grantType?.clientID, + (value) => { + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + clientID: value, + } + } + ) + + const clientSecret = refWithCallbackOnChange( + grantType?.clientSecret, + (value) => { + if (!("clientSecret" in auth.value.grantTypeInfo)) { + return + } + + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + clientSecret: value ?? "", + } + } + ) + + const scopes = refWithCallbackOnChange( + grantType?.scopes ? grantType.scopes : undefined, + (value) => { + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + scopes: value, + } + } + ) + + const isPKCE = refWithCallbackOnChange( + auth.value.grantTypeInfo.isPKCE, + (value) => { + if (!("isPKCE" in auth.value.grantTypeInfo)) { + return + } + + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + isPKCE: value, + } + } + ) + + const codeChallenge: Ref<{ + id: "plain" | "S256" + label: string + } | null> = refWithCallbackOnChange( + // If the collection was imported before `codeVerifierMethod` existed, + // default to 'plain' when PKCE is enabled so the UI and validation + // remain consistent. + auth.value.grantTypeInfo.codeVerifierMethod + ? { + id: auth.value.grantTypeInfo.codeVerifierMethod, + label: + auth.value.grantTypeInfo.codeVerifierMethod === "plain" + ? "Plain" + : "SHA-256", + } + : auth.value.grantTypeInfo.isPKCE + ? { + id: "plain", + label: "Plain", + } + : null, + (value) => { + if ( + !value || + auth.value.grantTypeInfo.grantType !== "AUTHORIZATION_CODE" + ) { + return + } + + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + codeVerifierMethod: value.id, + } + } + ) + + const tokenType = refWithCallbackOnChange( + tokenTypeOptionFor(auth.value.grantTypeInfo.tokenType), + (value) => { + if (!("tokenType" in auth.value.grantTypeInfo)) { + return + } + + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + tokenType: value.id, + } + } + ) + + const refreshToken = async () => { + const grantTypeInfo = auth.value.grantTypeInfo + + if (!("refreshToken" in grantTypeInfo)) { + return E.left("NO_REFRESH_TOKEN_PRESENT" as const) + } + + const refreshToken = grantTypeInfo.refreshToken + + if (!refreshToken) { + return E.left("NO_REFRESH_TOKEN_PRESENT" as const) + } + + const params: AuthCodeOauthRefreshParams = { + clientID: clientID.value, + clientSecret: clientSecret.value, + tokenEndpoint: tokenEndpoint.value, + refreshToken, + } + + const unwrappedParams = replaceTemplateStringsInObjectValues(params) + + const refreshTokenFunc = authCode.refreshToken + + if (!refreshTokenFunc) { + return E.left("REFRESH_TOKEN_FUNCTION_NOT_DEFINED" as const) + } + + const res = await refreshTokenFunc(unwrappedParams) + + if (E.isLeft(res)) { + return E.left("OAUTH_REFRESH_TOKEN_FAILED" as const) + } + + setAccessTokenInActiveContext( + res.right.access_token, + res.right.refresh_token + ) + + return E.right(undefined) + } + + const runAction = async () => { + const params: AuthCodeOauthFlowParams = { + authEndpoint: authEndpoint.value, + tokenEndpoint: tokenEndpoint.value, + clientID: clientID.value, + clientSecret: clientSecret.value, + scopes: scopes.value, + isPKCE: isPKCE.value, + // Ensure older collections without `codeVerifierMethod` get a default + // so schema validation does not fail. Default to 'plain' when PKCE + // is enabled. + codeVerifierMethod: + codeChallenge.value?.id ?? + (isPKCE.value ? ("plain" as const) : undefined), + authRequestParams: preparedAuthRequestParams.value, + tokenRequestParams: preparedTokenRequestParams.value, + refreshRequestParams: preparedRefreshRequestParams.value, + tokenType: tokenType.value.id, + } + + const unwrappedParams = replaceTemplateStringsInObjectValues(params) + + const parsedArgs = authCode.params.safeParse(unwrappedParams) + + if (!parsedArgs.success) { + return E.left("VALIDATION_FAILED" as const) + } + + const res = await authCode.init(parsedArgs.data) + + if (E.isLeft(res)) { + return res + } + + return E.right(undefined) + } + + const pkceElements = computed(() => { + const checkbox = { + id: "isPKCE", + label: t("authorization.oauth.label_use_pkce"), + type: "checkbox" as const, + ref: isPKCE, + onChange: (e: Event) => { + const target = e.target as HTMLInputElement + isPKCE.value = target.checked + }, + } + + return isPKCE.value + ? [ + checkbox, + { + id: "codeChallenge", + label: t("authorization.oauth.label_code_challenge"), + type: "dropdown" as const, + ref: codeChallenge, + tippyRefName: "pkceTippyActions", + tippyRef: pkceTippyActions, + options: [ + { + id: "plain" as const, + label: "Plain", + }, + { + id: "S256" as const, + label: "SHA-256", + }, + ], + }, + ] + : [checkbox] + }) + + const elements = computed(() => { + return [ + ...pkceElements.value, + { + id: "authEndpoint", + label: t("authorization.oauth.label_authorization_endpoint"), + type: "text" as const, + ref: authEndpoint, + }, + { + id: "tokenEndpoint", + label: t("authorization.oauth.label_token_endpoint"), + type: "text" as const, + ref: tokenEndpoint, + }, + { + id: "clientId", + label: t("authorization.oauth.label_client_id"), + type: "text" as const, + ref: clientID, + }, + { + id: "clientSecret", + label: t("authorization.oauth.label_client_secret"), + type: "text" as const, + ref: clientSecret, + }, + { + id: "scopes", + label: t("authorization.oauth.label_scopes"), + type: "text" as const, + ref: scopes, + }, + { + id: "tokenType", + label: "Token Type", + type: "dropdown" as const, + ref: tokenType, + tippyRefName: "tokenTypeTippyActions", + tippyRef: { value: null }, + options: tokenTypeOptions, + }, + ] + }) + + return { + runAction, + refreshToken, + elements, + } + }), + }, + { + id: "clientCredentials" as const, + label: t("authorization.oauth.label_client_credentials"), + formElements: computed(() => { + if (!(auth.value.grantTypeInfo.grantType === "CLIENT_CREDENTIALS")) { + return + } + + const grantTypeInfo = auth.value.grantTypeInfo + + const authEndpoint = refWithCallbackOnChange( + grantTypeInfo?.authEndpoint, + (value) => { + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + authEndpoint: value, + } + } + ) + + const clientID = refWithCallbackOnChange( + grantTypeInfo?.clientID, + (value) => { + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + clientID: value, + } + } + ) + + const clientSecret = refWithCallbackOnChange( + grantTypeInfo?.clientSecret, + (value) => { + if (!("clientSecret" in auth.value.grantTypeInfo)) { + return + } + + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + clientSecret: value, + } + } + ) + + const scopes = refWithCallbackOnChange( + grantTypeInfo?.scopes ? grantTypeInfo.scopes : undefined, + (value) => { + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + scopes: value, + } + } + ) + + const clientAuthentication = refWithCallbackOnChange( + grantTypeInfo.clientAuthentication + ? grantTypeInfo.clientAuthentication === "AS_BASIC_AUTH_HEADERS" + ? { + id: "AS_BASIC_AUTH_HEADERS" as const, + label: t("authorization.oauth.label_send_as_basic_auth"), + } + : { + id: "IN_BODY" as const, + label: t("authorization.oauth.label_send_in_body"), + } + : { + id: "IN_BODY" as const, + label: t("authorization.oauth.label_send_in_body"), + }, + (value) => { + if (!("clientAuthentication" in auth.value.grantTypeInfo)) { + return + } + + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + clientAuthentication: value.id, + } + } + ) + + const tokenType = refWithCallbackOnChange( + tokenTypeOptionFor(auth.value.grantTypeInfo.tokenType), + (value) => { + if (!("tokenType" in auth.value.grantTypeInfo)) { + return + } + + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + tokenType: value.id, + } + } + ) + + const runAction = async () => { + const values: ClientCredentialsFlowParams = + replaceTemplateStringsInObjectValues({ + authEndpoint: authEndpoint.value, + clientID: clientID.value, + clientSecret: clientSecret.value, + scopes: scopes.value, + clientAuthentication: clientAuthentication.value.id, + tokenRequestParams: preparedTokenRequestParams.value, + refreshRequestParams: preparedRefreshRequestParams.value, + tokenType: tokenType.value.id, + }) + + const parsedArgs = clientCredentials.params.safeParse(values) + + if (!parsedArgs.success) { + return E.left("VALIDATION_FAILED" as const) + } + + const res = await clientCredentials.init(parsedArgs.data) + + if (E.isLeft(res)) { + return E.left("OAUTH_TOKEN_FETCH_FAILED" as const) + } + + setAccessTokenInActiveContext(res.right?.access_token) + + toast.success(t("authorization.oauth.token_fetched_successfully")) + + return E.right(undefined) + } + + const elements = computed(() => { + return [ + { + id: "authEndpoint", + label: t("authorization.oauth.label_authorization_endpoint"), + type: "text" as const, + ref: authEndpoint, + }, + { + id: "clientId", + label: t("authorization.oauth.label_client_id"), + type: "text" as const, + ref: clientID, + }, + { + id: "clientSecret", + label: t("authorization.oauth.label_client_secret"), + type: "text" as const, + ref: clientSecret, + }, + { + id: "scopes", + label: t("authorization.oauth.label_scopes"), + type: "text" as const, + ref: scopes, + }, + { + id: "clientAuthentication", + label: t("authorization.oauth.label_send_as"), + type: "dropdown" as const, + ref: clientAuthentication, + tippyRefName: "clientAuthenticationTippyActions", + tippyRef: clientAuthenticationTippyActions, + options: [ + { + id: "IN_BODY" as const, + label: t("authorization.oauth.label_send_in_body"), + }, + { + id: "AS_BASIC_AUTH_HEADERS" as const, + label: t("authorization.oauth.label_send_as_basic_auth"), + }, + ], + }, + { + id: "tokenType", + label: "Token Type", + type: "dropdown" as const, + ref: tokenType, + tippyRefName: "tokenTypeTippyActions", + tippyRef: { value: null }, + options: tokenTypeOptions, + }, + ] + }) + + return { + runAction, + elements, + } + }), + }, + { + id: "password" as const, + label: t("authorization.oauth.label_password"), + formElements: computed(() => { + if (!(auth.value.grantTypeInfo.grantType === "PASSWORD")) { + return + } + + const grantTypeInfo = auth.value.grantTypeInfo + + const authEndpoint = refWithCallbackOnChange( + grantTypeInfo?.authEndpoint, + (value) => { + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + authEndpoint: value, + } + } + ) + + const clientID = refWithCallbackOnChange( + grantTypeInfo?.clientID, + (value) => { + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + clientID: value, + } + } + ) + + const clientSecret = refWithCallbackOnChange( + grantTypeInfo?.clientSecret, + (value) => { + if (!("clientSecret" in auth.value.grantTypeInfo)) { + return + } + + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + clientSecret: value, + } + } + ) + + const scopes = refWithCallbackOnChange( + grantTypeInfo?.scopes ? grantTypeInfo.scopes : undefined, + (value) => { + auth.value.grantTypeInfo.scopes = value + } + ) + + const username = refWithCallbackOnChange( + grantTypeInfo?.username, + (value) => { + if (!("username" in auth.value.grantTypeInfo)) { + return + } + + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + username: value, + } + } + ) + + const password = refWithCallbackOnChange( + grantTypeInfo?.password, + (value) => { + if (!("password" in auth.value.grantTypeInfo)) { + return + } + + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + password: value, + } + } + ) + + const tokenType = refWithCallbackOnChange( + tokenTypeOptionFor(auth.value.grantTypeInfo.tokenType), + (value) => { + if (!("tokenType" in auth.value.grantTypeInfo)) { + return + } + + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + tokenType: value.id, + } + } + ) + + const runAction = async () => { + const values: PasswordFlowParams = + replaceTemplateStringsInObjectValues({ + authEndpoint: authEndpoint.value, + clientID: clientID.value, + clientSecret: clientSecret.value, + scopes: scopes.value, + username: username.value, + password: password.value, + tokenRequestParams: preparedTokenRequestParams.value, + refreshRequestParams: preparedRefreshRequestParams.value, + tokenType: tokenType.value.id, + }) + + const parsedArgs = passwordFlow.params.safeParse(values) + + if (!parsedArgs.success) { + return E.left("VALIDATION_FAILED" as const) + } + + const res = await passwordFlow.init(parsedArgs.data) + + if (E.isLeft(res)) { + return E.left("OAUTH_TOKEN_FETCH_FAILED" as const) + } + + setAccessTokenInActiveContext(res.right?.access_token) + + toast.success(t("authorization.oauth.token_fetched_successfully")) + + return E.right(undefined) + } + + const elements = computed(() => { + return [ + { + id: "authEndpoint", + label: t("authorization.oauth.label_authorization_endpoint"), + type: "text" as const, + ref: authEndpoint, + }, + { + id: "clientId", + label: t("authorization.oauth.label_client_id"), + type: "text" as const, + ref: clientID, + }, + { + id: "clientSecret", + label: t("authorization.oauth.label_client_secret"), + type: "text" as const, + ref: clientSecret, + }, + { + id: "username", + label: t("authorization.oauth.label_username"), + type: "text" as const, + ref: username, + }, + { + id: "password", + label: t("authorization.oauth.label_password"), + type: "text" as const, + ref: password, + }, + { + id: "scopes", + label: t("authorization.oauth.label_scopes"), + type: "text" as const, + ref: scopes, + }, + { + id: "tokenType", + label: "Token Type", + type: "dropdown" as const, + ref: tokenType, + tippyRefName: "tokenTypeTippyActions", + tippyRef: { value: null }, + options: tokenTypeOptions, + }, + ] + }) + + return { + runAction, + elements, + } + }), + }, + { + id: "implicit" as const, + label: t("authorization.oauth.label_implicit"), + formElements: computed(() => { + const grantTypeInfo = auth.value.grantTypeInfo + + const authEndpoint = refWithCallbackOnChange( + grantTypeInfo?.authEndpoint, + (value) => { + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + authEndpoint: value, + } + } + ) + + const clientID = refWithCallbackOnChange( + grantTypeInfo?.clientID, + (value) => { + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + clientID: value, + } + } + ) + + const scopes = refWithCallbackOnChange( + grantTypeInfo?.scopes ? grantTypeInfo.scopes : undefined, + (value) => { + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + scopes: value, + } + } + ) + + const tokenType = refWithCallbackOnChange( + tokenTypeOptionFor(auth.value.grantTypeInfo.tokenType), + (value) => { + if (!("tokenType" in auth.value.grantTypeInfo)) { + return + } + + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + tokenType: value.id, + } + } + ) + + const runAction = () => { + const values: ImplicitOauthFlowParams = + replaceTemplateStringsInObjectValues({ + authEndpoint: authEndpoint.value, + clientID: clientID.value, + scopes: scopes.value, + authRequestParams: preparedAuthRequestParams.value, + refreshRequestParams: preparedRefreshRequestParams.value, + tokenType: tokenType.value.id, + }) + + const unwrappedValues = replaceTemplateStringsInObjectValues(values) + + const parsedArgs = implicit.params.safeParse(unwrappedValues) + + if (!parsedArgs.success) { + return E.left("VALIDATION_FAILED" as const) + } + + implicit.init(parsedArgs.data) + + return E.right(undefined) + } + + const elements = computed(() => { + return [ + { + id: "authEndpoint", + label: t("authorization.oauth.label_authorization_endpoint"), + type: "text" as const, + ref: authEndpoint, + }, + { + id: "clientId", + label: t("authorization.oauth.label_client_id"), + type: "text" as const, + ref: clientID, + }, + { + id: "scopes", + label: t("authorization.oauth.label_scopes"), + type: "text" as const, + ref: scopes, + }, + { + id: "tokenType", + label: "Token Type", + type: "dropdown" as const, + ref: tokenType, + tippyRefName: "tokenTypeTippyActions", + tippyRef: { value: null }, + options: tokenTypeOptions, + }, + ] + }) + + return { + runAction, + elements, + } + }), + }, + ] + + const selectedGrantTypeID = computed(() => { + const currentGrantType = auth.value.grantTypeInfo.grantType + return grantTypeMap[currentGrantType] + }) + + const selectedGrantType = computed(() => { + return supportedGrantTypes.find( + (grantType) => grantType.id === selectedGrantTypeID.value + ) + }) + + const changeSelectedGrantType = ( + grantType: (typeof supportedGrantTypes)[number]["id"] + ) => { + const keys = Object.keys(grantTypeMap) as GrantTypes[] + + const grantTypeToSet = keys.find((key) => grantTypeMap[key] === grantType) + + if (grantTypeToSet) { + auth.value.grantTypeInfo.grantType = grantTypeToSet + + const getDefaultPayload = grantTypeDefaultPayload[grantTypeToSet] + + if (getDefaultPayload) { + auth.value.grantTypeInfo = { + ...getDefaultPayload(), + ...auth.value.grantTypeInfo, + } + } + } + } + + const runAction = computed(() => { + return selectedGrantType.value?.formElements.value?.runAction + }) + + const runTokenRefresh = computed(() => { + if (selectedGrantType.value?.id === "authCode") { + return selectedGrantType.value?.formElements.value?.refreshToken + } + return null + }) + + const currentOAuthGrantTypeFormElements = computed(() => { + return selectedGrantType.value?.formElements.value?.elements.value + }) + + return { + supportedGrantTypes, + selectedGrantTypeID, + selectedGrantType, + changeSelectedGrantType, + runAction, + runTokenRefresh, + currentOAuthGrantTypeFormElements, + } +} diff --git a/packages/hoppscotch-common/src/composables/picker.ts b/packages/hoppscotch-common/src/composables/picker.ts new file mode 100644 index 0000000..690fb7d --- /dev/null +++ b/packages/hoppscotch-common/src/composables/picker.ts @@ -0,0 +1,138 @@ +import { ref, reactive, computed } from "vue" +import { useFileDialog } from "@vueuse/core" + +export interface CertFiles { + pem_cert: File | null + pem_key: File | null + pfx: File | null + ca: File[] +} + +export interface CertPickerOptions { + onPEMCertChange?: (file: File | null) => void + onPEMKeyChange?: (file: File | null) => void + onPFXChange?: (file: File | null) => void + onCACertAdd?: (file: File) => void + onCACertRemove?: (index: number) => void +} + +export function useCertificatePicker(options: CertPickerOptions = {}) { + const certFiles = reactive({ + pem_cert: null, + pem_key: null, + pfx: null, + ca: [], + }) + + const certType = ref<"pem" | "pfx">("pem") + const pfxPassword = ref("") + + const pemCertPicker = useFileDialog({ + accept: ".pem,.crt", + reset: true, + multiple: false, + }) + + const pemKeyPicker = useFileDialog({ + accept: ".pem,.key", + reset: true, + multiple: false, + }) + + const pfxPicker = useFileDialog({ + accept: ".pfx,.p12", + reset: true, + multiple: false, + }) + + const caCertPicker = useFileDialog({ + accept: ".pem,.crt", + reset: true, + multiple: false, + }) + + function pickPEMCertificate() { + pemCertPicker.onChange((files) => { + const selectedFile = files?.item(0) + if (selectedFile) { + certFiles.pem_cert = selectedFile + certFiles.pfx = null + options.onPEMCertChange?.(selectedFile) + } + pemCertPicker.reset() + }) + pemCertPicker.open() + } + + function pickPEMKey() { + pemKeyPicker.onChange((files) => { + const selectedFile = files?.item(0) + if (selectedFile) { + certFiles.pem_key = selectedFile + certFiles.pfx = null + options.onPEMKeyChange?.(selectedFile) + } + pemKeyPicker.reset() + }) + pemKeyPicker.open() + } + + function pickPFXCertificate() { + pfxPicker.onChange((files) => { + const selectedFile = files?.item(0) + if (selectedFile) { + certFiles.pfx = selectedFile + certFiles.pem_cert = null + certFiles.pem_key = null + options.onPFXChange?.(selectedFile) + } + pfxPicker.reset() + }) + pfxPicker.open() + } + + function pickCACertificate() { + caCertPicker.onChange((files) => { + const selectedFile = files?.item(0) + if (selectedFile) { + certFiles.ca.push(selectedFile) + options.onCACertAdd?.(selectedFile) + } + caCertPicker.reset() + }) + caCertPicker.open() + } + + function removeCACertificate(index: number) { + certFiles.ca.splice(index, 1) + options.onCACertRemove?.(index) + } + + function reset() { + certFiles.pem_cert = null + certFiles.pem_key = null + certFiles.pfx = null + certFiles.ca = [] + pfxPassword.value = "" + certType.value = "pem" + } + + const isValidCertConfig = computed(() => + certType.value === "pem" + ? !!(certFiles.pem_cert && certFiles.pem_key) + : !!(certFiles.pfx && pfxPassword.value) + ) + + return { + certFiles, + certType, + pfxPassword, + pickPEMCertificate, + pickPEMKey, + pickPFXCertificate, + pickCACertificate, + removeCACertificate, + reset, + isValidCertConfig, + } +} diff --git a/packages/hoppscotch-common/src/composables/poll.ts b/packages/hoppscotch-common/src/composables/poll.ts new file mode 100644 index 0000000..24c5856 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/poll.ts @@ -0,0 +1,31 @@ +import { onBeforeUnmount, Ref, shallowRef } from "vue" + +export function usePolled( + pollDurationMS: number, + pollFunc: (stopPolling: () => void) => T +): Ref { + let polling = true + let handle: ReturnType | undefined + + const stopPolling = () => { + if (handle) { + clearInterval(handle) + handle = undefined + polling = false + } + } + + const result = shallowRef(pollFunc(stopPolling)) + + if (polling) { + handle = setInterval(() => { + result.value = pollFunc(stopPolling) + }, pollDurationMS) + } + + onBeforeUnmount(() => { + if (polling) stopPolling() + }) + + return result +} diff --git a/packages/hoppscotch-common/src/composables/pwa.ts b/packages/hoppscotch-common/src/composables/pwa.ts new file mode 100644 index 0000000..70391c1 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/pwa.ts @@ -0,0 +1,43 @@ +import { useI18n } from "@composables/i18n" +import { useToast } from "@composables/toast" +import { pwaNeedsRefresh, refreshAppForPWAUpdate } from "@modules/pwa" +import { watch } from "vue" + +export const usePwaPrompt = function () { + const toast = useToast() + const t = useI18n() + + watch( + pwaNeedsRefresh, + (value) => { + if (value) { + showUpdateToast() + } + }, + { + immediate: true, + } + ) + + function showUpdateToast() { + toast.show(`${t("app.new_version_found")}`, { + position: "bottom-center", + duration: 0, + action: [ + { + text: `${t("action.dismiss")}`, + onClick: (_, toastObject) => { + toastObject.goAway(0) + }, + }, + { + text: `${t("app.reload")}`, + onClick: (_, toastObject) => { + toastObject.goAway(0) + refreshAppForPWAUpdate() + }, + }, + ], + }) + } +} diff --git a/packages/hoppscotch-common/src/composables/ref.ts b/packages/hoppscotch-common/src/composables/ref.ts new file mode 100644 index 0000000..5d9cdbd --- /dev/null +++ b/packages/hoppscotch-common/src/composables/ref.ts @@ -0,0 +1,46 @@ +import { customRef, onBeforeUnmount, ref, Ref, UnwrapRef, watch } from "vue" + +export function pluckRef(ref: Ref, key: K): Ref { + return customRef((track, trigger) => { + const stopWatching = watch(ref, (newVal, oldVal) => { + if (newVal[key] !== oldVal[key]) { + trigger() + } + }) + + onBeforeUnmount(() => { + stopWatching() + }) + + return { + get() { + track() + return ref.value[key] + }, + set(value: T[K]) { + trigger() + ref.value = Object.assign(ref.value, { [key]: value }) + }, + } + }) +} + +export function pluckMultipleFromRef>( + sourceRef: Ref, + keys: K +): { [key in K[number]]: Ref } { + return Object.fromEntries(keys.map((x) => [x, pluckRef(sourceRef, x)])) as any +} + +export const refWithCallbackOnChange = ( + initialValue: T, + callback: (value: UnwrapRef) => void +) => { + const targetRef = ref(initialValue) + + watch(targetRef, (value) => { + callback(value) + }) + + return targetRef +} diff --git a/packages/hoppscotch-common/src/composables/settings.ts b/packages/hoppscotch-common/src/composables/settings.ts new file mode 100644 index 0000000..8cfce63 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/settings.ts @@ -0,0 +1,75 @@ +import { Ref } from "vue" +import { settingsStore, SettingsDef } from "~/newstore/settings" +import { pluck, distinctUntilChanged } from "rxjs/operators" +import { useStream, useStreamStatic } from "./stream" + +export function useSetting( + settingKey: K +): Ref { + return useStream( + settingsStore.subject$.pipe(pluck(settingKey), distinctUntilChanged()), + settingsStore.value[settingKey], + (value: SettingsDef[K]) => { + settingsStore.dispatch({ + dispatcher: "applySetting", + payload: { + // @ts-expect-error TS is not able to understand the type semantics here + settingKey, + // @ts-expect-error TS is not able to understand the type semantics here + value, + }, + }) + } + ) +} + +export function useNestedSetting< + K extends keyof SettingsDef, + P extends keyof SettingsDef[K], +>(settingKey: K, property: P): Ref { + return useStream( + settingsStore.subject$.pipe( + pluck(settingKey), + pluck(property), + distinctUntilChanged() + ), + settingsStore.value[settingKey][property], + (value: SettingsDef[K][P]) => { + settingsStore.dispatch({ + dispatcher: "applyNestedSetting", + payload: { + // @ts-expect-error TS is not able to understand the type semantics here + settingKey, + // @ts-expect-error TS is not able to understand the type semantics here + property, + // @ts-expect-error TS is not able to understand the type semantics here + value, + }, + }) + } + ) +} + +/** + * A static version (does not require component setup) + * of `useSetting` + */ +export function useSettingStatic( + settingKey: K +): [Ref, () => void] { + return useStreamStatic( + settingsStore.subject$.pipe(pluck(settingKey), distinctUntilChanged()), + settingsStore.value[settingKey], + (value: SettingsDef[K]) => { + settingsStore.dispatch({ + dispatcher: "applySetting", + payload: { + // @ts-expect-error TS is not able to understand the type semantics here + settingKey, + // @ts-expect-error TS is not able to understand the type semantics here + value, + }, + }) + } + ) +} diff --git a/packages/hoppscotch-common/src/composables/step-components.ts b/packages/hoppscotch-common/src/composables/step-components.ts new file mode 100644 index 0000000..da85843 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/step-components.ts @@ -0,0 +1,69 @@ +import { computed, defineComponent, ref } from "vue" + +export function useSteps() { + type Step = ReturnType + + const steps: Step[] = [] + + const currentStepIndex = ref(0) + + const currentStep = computed(() => { + return steps[currentStepIndex.value] + }) + + const backHistoryIndexes = ref([0]) + + const hasPreviousStep = computed(() => { + return currentStepIndex.value > 0 + }) + + const addStep = (step: Step) => { + steps.push(step) + } + + const goToNextStep = () => { + currentStepIndex.value++ + backHistoryIndexes.value.push(currentStepIndex.value) + } + + const goToStep = (stepId: string) => { + currentStepIndex.value = steps.findIndex((step) => step.id === stepId) + backHistoryIndexes.value.push(currentStepIndex.value) + } + + const goToPreviousStep = () => { + if (backHistoryIndexes.value.length !== 1) { + backHistoryIndexes.value.pop() + currentStepIndex.value = + backHistoryIndexes.value[backHistoryIndexes.value.length - 1] + } + } + + return { + steps, + currentStep, + addStep, + goToPreviousStep, + goToNextStep, + goToStep, + hasPreviousStep, + } +} + +export function defineStep< + StepComponent extends ReturnType, +>( + id: string, + component: StepComponent, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + props: () => InstanceType["$props"] +) { + const step = { + id, + component, + props, + } + + return step +} diff --git a/packages/hoppscotch-common/src/composables/stream.ts b/packages/hoppscotch-common/src/composables/stream.ts new file mode 100644 index 0000000..3f5304b --- /dev/null +++ b/packages/hoppscotch-common/src/composables/stream.ts @@ -0,0 +1,176 @@ +import { clone, cloneDeep } from "lodash-es" +import { Observable, Subscription } from "rxjs" +import { customRef, onBeforeUnmount, readonly, Ref } from "vue" + +type CloneMode = "noclone" | "shallow" | "deep" + +/** + * Returns a readonly (no writes) ref for an RxJS Observable + * @param stream$ The RxJS Observable to listen to + * @param initialValue The initial value to apply until the stream emits a value + * @param cloneMode Determines whether or not and how deep to clone the emitted value. + * Useful for issues in reactivity due to reference sharing. Defaults to shallow clone + * @returns A readonly ref which has the latest value from the stream + */ +export function useReadonlyStream( + stream$: Observable, + initialValue?: T, + cloneMode: CloneMode = "shallow" +): Ref { + let sub: Subscription | null = null + + onBeforeUnmount(() => { + if (sub) { + sub.unsubscribe() + } + }) + + const r = customRef((track, trigger) => { + let val = initialValue + + sub = stream$.subscribe((value) => { + if (cloneMode === "noclone") { + val = value + } else if (cloneMode === "shallow") { + val = clone(value) + } else if (cloneMode === "deep") { + val = cloneDeep(value) + } + + trigger() + }) + + return { + get() { + track() + return val + }, + set() { + trigger() // <- Not exactly needed here + throw new Error("Cannot write to a ref from useReadonlyStream") + }, + } + }) + + // Casting to still maintain the proper type signature for ease of use + return readonly(r) as Ref +} + +export function useStream( + stream$: Observable, + initialValue: T, + setter: (val: T) => void +) { + let sub: Subscription | null = null + + onBeforeUnmount(() => { + if (sub) { + sub.unsubscribe() + } + }) + + return customRef((track, trigger) => { + let value = initialValue + + sub = stream$.subscribe((val) => { + value = val + trigger() + }) + + return { + get() { + track() + return value + }, + set(value: T) { + trigger() + setter(value) + }, + } + }) +} + +/** A static (doesn't cleanup on itself and does + * not require component instance) version of useStream + */ +export function useStreamStatic( + stream$: Observable, + initialValue: T, + setter: (val: T) => void +): [Ref, () => void] { + let sub: Subscription | null = null + + const stopper = () => { + if (sub) { + sub.unsubscribe() + } + } + + return [ + customRef((track, trigger) => { + let value = initialValue + + sub = stream$.subscribe((val) => { + value = val + trigger() + }) + + return { + get() { + track() + return value + }, + set(value: T) { + trigger() + setter(value) + }, + } + }), + stopper, + ] +} + +export type StreamSubscriberFunc = ( + stream: Observable, + next?: ((value: T) => void) | undefined, + error?: ((e: any) => void) | undefined, + complete?: (() => void) | undefined +) => void + +/** + * A composable that provides the ability to run streams + * and subscribe to them and respect the component lifecycle. + */ +export function useStreamSubscriber(): { + subscribeToStream: StreamSubscriberFunc +} { + const subs: Subscription[] = [] + + const runAndSubscribe = ( + stream: Observable, + next?: (value: T) => void, + error?: (e: any) => void, + complete?: () => void + ) => { + let sub: Subscription | null = null + + sub = stream.subscribe({ + next, + error, + complete: () => { + if (complete) complete() + if (sub) subs.splice(subs.indexOf(sub), 1) + }, + }) + + subs.push(sub) + } + + onBeforeUnmount(() => { + subs.forEach((sub) => sub.unsubscribe()) + }) + + return { + subscribeToStream: runAndSubscribe, + } +} diff --git a/packages/hoppscotch-common/src/composables/theming.ts b/packages/hoppscotch-common/src/composables/theming.ts new file mode 100644 index 0000000..4d06d0e --- /dev/null +++ b/packages/hoppscotch-common/src/composables/theming.ts @@ -0,0 +1,4 @@ +import { inject } from "vue" +import { HoppColorMode } from "~/modules/theming" + +export const useColorMode = () => inject("colorMode") as HoppColorMode diff --git a/packages/hoppscotch-common/src/composables/toast.ts b/packages/hoppscotch-common/src/composables/toast.ts new file mode 100644 index 0000000..842f73c --- /dev/null +++ b/packages/hoppscotch-common/src/composables/toast.ts @@ -0,0 +1,3 @@ +import { useToasted } from "@hoppscotch/vue-toasted" + +export const useToast = useToasted diff --git a/packages/hoppscotch-common/src/composables/update-check.ts b/packages/hoppscotch-common/src/composables/update-check.ts new file mode 100644 index 0000000..101a694 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/update-check.ts @@ -0,0 +1,383 @@ +import { ref, readonly, type Ref } from "vue" +import * as E from "fp-ts/Either" +import { invoke } from "@tauri-apps/api/core" +import { listen, type UnlistenFn } from "@tauri-apps/api/event" + +// Bind to the unified, process-wide store rather than the org-scoped +// default `Store`. Persisted `UpdateState` is machine-level, not +// per-org, and the Tauri shell reads the same physical file through +// its own `kernel/store.ts` wrapper. Going through the org-scoped +// store would route writes to a file the shell never reads. +import { UnifiedStore as Store } from "~/kernel/store" +import { + UPDATE_STATE_SCHEMA, + UPDATE_STATE_STORE_KEY, + UPDATE_STATE_STORE_NAMESPACE, + type DownloadProgress as WireDownloadProgress, + type UpdateState as PersistedUpdateState, +} from "~/platform/update-state" +import { Log } from "~/kernel/log" + +const LOG_TAG = "useUpdateCheck" + +/** + * Webview-side accessor for the desktop updater. + * + * Wraps the Tauri updater commands (`check_for_updates`, + * `download_and_install_update`, `restart_application`, `cancel_update`) + * and the `updater-event` Tauri channel into a single reactive accessor. + * + * State is modelled as a discriminated union where each variant carries + * exactly the fields that variant needs (the `available` variant carries + * `latestVersion`, the `downloading` variant carries `progress`, and so + * on). Impossible combinations ("available without a version", "not + * downloading but progress is set") are unrepresentable by construction, + * and callers narrow through `state.kind`. + * + * State transitions are owned by a single pure `applyEvent` function + * driven by the `updater-event` channel. Action wrappers (`check`, + * `download`, `restart`, `cancel`) await initialization before invoking + * so the listener is guaranteed to be subscribed before any command + * fires, and rely on the event stream for the transitions rather than + * mutating state themselves. This removes the "fast path + event" drift + * that made two paths responsible for updating the same refs. + * + * Module-level singleton: every caller gets the same reactive state so + * any consumer (settings page, portable welcome, startup flow) sees the + * same value. + */ + +// Download progress with a derived `percentage`. The wire form from +// Rust and the persisted form only carry `downloaded` and optional +// `total`. The `percentage` is computed on top so the UI has a +// ready-to-bind field. +export interface DownloadProgress extends WireDownloadProgress { + percentage: number +} + +// Response from the `check_for_updates` Tauri command. Used only to +// invoke the command. Actual state transitions arrive on the event +// channel. +interface UpdateInfo { + available: boolean + currentVersion: string + latestVersion?: string + releaseNotes?: string +} + +// Tauri event payload variants. Must match the `UpdateEvent` tagged union in +// `hoppscotch-desktop/src/services/updater.client.ts`. Centralizing this +// type into common would remove the duplication, but the event channel is +// a Rust-to-webview wire contract that currently lives in the shell, so +// keeping the mirror here scoped to this composable is acceptable until +// that contract gets its own shared module. +type UpdateEvent = + | { type: "CheckStarted" } + | { type: "CheckCompleted"; info: UpdateInfo } + | { type: "CheckFailed"; error: string } + | { type: "DownloadStarted"; totalBytes?: number } + // The Rust-emitted payload only carries `downloaded` and optional + // `total`. The reducer derives `percentage` and the persisted + // `DownloadProgress` form below extends with that derived field. + | { type: "DownloadProgress"; progress: WireDownloadProgress } + | { type: "DownloadCompleted" } + | { type: "InstallStarted" } + | { type: "InstallCompleted" } + | { type: "RestartRequired" } + | { type: "UpdateCancelled" } + | { type: "Error"; message: string } + +// The composable's internal state. Each variant carries exactly the +// fields that variant needs. `currentVersion` rides along with any +// post-check variant so the UI can display "currently on vX" context +// regardless of whether an update was found. +export type UpdateState = + | { kind: "idle" } + | { kind: "checking" } + | { + kind: "available" + currentVersion: string + latestVersion: string + } + | { kind: "not_available"; currentVersion: string } + | { kind: "downloading"; progress: DownloadProgress } + | { kind: "installing" } + | { kind: "ready_to_restart" } + | { kind: "error"; message: string } + +// String-literal helper for consumers that want to compare without +// destructuring `state.kind` directly. `UpdateState["kind"]` gives the +// same union at the type level. +export const UpdateKind = { + IDLE: "idle", + CHECKING: "checking", + AVAILABLE: "available", + NOT_AVAILABLE: "not_available", + DOWNLOADING: "downloading", + INSTALLING: "installing", + READY_TO_RESTART: "ready_to_restart", + ERROR: "error", +} as const satisfies Record + +// Singleton state. +const state = ref({ kind: "idle" }) +let initPromise: Promise | undefined +let unlistenFn: UnlistenFn | undefined + +function percentageOf(downloaded: number, total: number | undefined): number { + if (!total || total <= 0) return 0 + return (downloaded / total) * 100 +} + +/** + * Derives the composable's internal `UpdateState` from the flat + * persisted form. The persisted form is a wire contract with Rust and + * older shell code, and translating on read keeps that contract + * unchanged while the composable gets the richer internal type. + */ +function fromPersisted( + persisted: PersistedUpdateState | null | undefined +): UpdateState { + if (!persisted) return { kind: "idle" } + + switch (persisted.status) { + case "idle": + return { kind: "idle" } + case "checking": + return { kind: "checking" } + case "available": + // The persisted form is optional on `version`. If the writer + // omitted it, fall back to idle rather than fabricating a version. + return persisted.version + ? { + kind: "available", + currentVersion: "", + latestVersion: persisted.version, + } + : { kind: "idle" } + case "not_available": + return { kind: "not_available", currentVersion: "" } + case "downloading": { + const downloaded = persisted.progress?.downloaded ?? 0 + const total = persisted.progress?.total + return { + kind: "downloading", + progress: { + downloaded, + total, + percentage: percentageOf(downloaded, total), + }, + } + } + case "installing": + return { kind: "installing" } + case "ready_to_restart": + return { kind: "ready_to_restart" } + case "error": + return { kind: "error", message: persisted.message ?? "Unknown error" } + } +} + +/** + * Pure reducer from current state + incoming event to next state. Kept + * pure (no ref access, no side effects) so it can be exercised in + * isolation and so the full transition table is readable at a glance. + */ +function nextState(current: UpdateState, event: UpdateEvent): UpdateState { + switch (event.type) { + case "CheckStarted": + return { kind: "checking" } + + case "CheckCompleted": + if (event.info.available && event.info.latestVersion) { + return { + kind: "available", + currentVersion: event.info.currentVersion, + latestVersion: event.info.latestVersion, + } + } + return { + kind: "not_available", + currentVersion: event.info.currentVersion, + } + + case "CheckFailed": + return { kind: "error", message: event.error } + + case "DownloadStarted": + return { + kind: "downloading", + progress: { + downloaded: 0, + total: event.totalBytes, + percentage: 0, + }, + } + + case "DownloadProgress": + // The wire form has no `percentage`. Without computing it + // here, `Math.round(progress.percentage)` in the view runs on + // `undefined` and the button label renders "Downloading NaN%" + // for every progress tick. `DownloadStarted` above takes the + // same approach. + return { + kind: "downloading", + progress: { + downloaded: event.progress.downloaded, + total: event.progress.total, + percentage: percentageOf( + event.progress.downloaded, + event.progress.total + ), + }, + } + + case "DownloadCompleted": + return { kind: "installing" } + + case "InstallStarted": + return { kind: "installing" } + + case "InstallCompleted": + // Install is a short step that transitions straight into awaiting a + // restart. The `RestartRequired` event follows and flips the state, + // so keep the current state here rather than double-transitioning. + return current + + case "RestartRequired": + return { kind: "ready_to_restart" } + + case "UpdateCancelled": + return { kind: "idle" } + + case "Error": + return { kind: "error", message: event.message } + } +} + +async function loadPersistedState(): Promise { + // Open the unified store before reading. The shell already opens + // this path through `DesktopPersistenceService.init`, but the + // webview runs in a separate window with its own process state, so + // the underlying Tauri store still needs to be opened here. Repeat + // calls land on the same on-disk file and are harmless. + const initResult = await Store.init() + if (E.isLeft(initResult)) { + Log.warn(LOG_TAG, "Failed to init unified store", initResult.left) + } + + const result = await Store.get( + UPDATE_STATE_STORE_NAMESPACE, + UPDATE_STATE_STORE_KEY + ) + if (E.isRight(result) && result.right) { + const parsed = UPDATE_STATE_SCHEMA.safeParse(result.right) + if (parsed.success) { + state.value = fromPersisted(parsed.data) + } + } +} + +async function subscribeToEvents(): Promise { + if (unlistenFn) return + unlistenFn = await listen("updater-event", (event) => { + state.value = nextState(state.value, event.payload) + }) +} + +async function ensureInitialized(): Promise { + if (!initPromise) { + initPromise = (async () => { + await loadPersistedState() + await subscribeToEvents() + })().catch((err) => { + Log.error(LOG_TAG, "Initialization failed", err) + initPromise = undefined + throw err + }) + } + await initPromise +} + +// Action wrappers. Each awaits initialization so the event listener is +// guaranteed subscribed before the Tauri command runs, then invokes the +// command. State transitions arrive via the event channel, so the +// wrappers do not mutate `state` on success. On `invoke` failure they +// feed a synthetic "failed" event through the same reducer so the +// transition path stays uniform. +async function check(): Promise { + await ensureInitialized() + try { + await invoke("check_for_updates", { showNativeDialog: false }) + } catch (err) { + state.value = nextState(state.value, { + type: "CheckFailed", + error: err instanceof Error ? err.message : String(err), + }) + } +} + +async function download(): Promise { + await ensureInitialized() + try { + await invoke("download_and_install_update") + } catch (err) { + state.value = nextState(state.value, { + type: "Error", + message: err instanceof Error ? err.message : String(err), + }) + } +} + +async function restart(): Promise { + await ensureInitialized() + try { + await invoke("restart_application") + } catch (err) { + state.value = nextState(state.value, { + type: "Error", + message: err instanceof Error ? err.message : String(err), + }) + } +} + +async function cancel(): Promise { + await ensureInitialized() + try { + await invoke("cancel_update") + // State advances to `idle` via the `updater-event` channel. The + // Rust updater emits `UpdateCancelled` on success, so the + // subscribed listener applies the transition. Applying it here + // as well would produce two `idle` transitions per cancel, which + // is harmless today but would double-fire any future side effect + // added to the `UpdateCancelled` case in `nextState`. + } catch (err) { + state.value = nextState(state.value, { + type: "Error", + message: err instanceof Error ? err.message : String(err), + }) + } +} + +export function useUpdateCheck(): { + state: Readonly> + check: () => Promise + download: () => Promise + restart: () => Promise + cancel: () => Promise +} { + // Fire-and-forget initialization so the composable returns synchronously. + // Actions await initialization internally before invoking commands, so + // race-with-subscription is not possible through the action path. A + // consumer that reads `state.value` immediately sees `idle`, which is + // the correct default for a fresh mount. + void ensureInitialized() + + return { + state: readonly(state), + check, + download, + restart, + cancel, + } +} diff --git a/packages/hoppscotch-common/src/composables/useDocumentationWorker.ts b/packages/hoppscotch-common/src/composables/useDocumentationWorker.ts new file mode 100644 index 0000000..211a3e7 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/useDocumentationWorker.ts @@ -0,0 +1,162 @@ +import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data" +import { ref, readonly } from "vue" + +export interface DocumentationItem { + type: "folder" | "request" + item: HoppCollection | HoppRESTRequest + parentPath: string + id: string + pathOrID?: string | null + folderPath?: string | null + requestIndex?: number | null + requestID?: string | null +} + +interface QueueItem { + collection: HoppCollection + pathOrID: string | null + isTeamCollection: boolean + resolve: (items: DocumentationItem[]) => void + reject: (error: Error) => void +} + +const worker = new Worker( + new URL("../helpers/workers/documentation.worker.ts", import.meta.url), + { + type: "module", + } +) + +// Global queue state +const queue: QueueItem[] = [] +let isWorkerBusy = false + +// Global state refs (shared across composables) +const isProcessing = ref(false) +const progress = ref(0) +const processedCount = ref(0) +const totalCount = ref(0) + +// Worker message handler +worker.onmessage = (event) => { + const { type } = event.data + + switch (type) { + case "DOCUMENTATION_PROGRESS": + progress.value = event.data.progress + processedCount.value = event.data.processed + totalCount.value = event.data.total + break + + case "DOCUMENTATION_RESULT": + if (queue.length > 0) { + const currentItem = queue[0] // The item currently being processed + + // Parse the stringified items + const items = JSON.parse(event.data.items) as DocumentationItem[] + currentItem.resolve(items) + + // Remove completed item and process next + queue.shift() + processQueue() + } + break + + case "DOCUMENTATION_ERROR": + if (queue.length > 0) { + const currentItem = queue[0] + currentItem.reject(new Error(event.data.error)) + + // Remove failed item and process next + queue.shift() + processQueue() + } + break + } +} + +worker.onerror = (error) => { + if (queue.length > 0) { + const currentItem = queue[0] + currentItem.reject(new Error(`Worker error: ${error.message}`)) + + // Remove failed item and process next + queue.shift() + processQueue() + } +} + +function processQueue() { + if (queue.length === 0) { + isWorkerBusy = false + isProcessing.value = false + progress.value = 100 // Ensure progress shows complete + return + } + + isWorkerBusy = true + isProcessing.value = true + progress.value = 0 + processedCount.value = 0 + totalCount.value = 0 + + const nextItem = queue[0] + + try { + const collectionString = JSON.stringify(nextItem.collection) + worker.postMessage({ + type: "GATHER_DOCUMENTATION", + collection: collectionString, + pathOrID: nextItem.pathOrID, + isTeamCollection: nextItem.isTeamCollection, + }) + } catch (error) { + nextItem.reject( + new Error( + `Failed to serialize collection: ${error instanceof Error ? error.message : String(error)}` + ) + ) + queue.shift() + processQueue() + } +} + +export function useDocumentationWorker() { + /** + * Process documentation using the worker + */ + function processDocumentation( + collection: HoppCollection, + pathOrID: string | null, + isTeamCollection: boolean = false + ): Promise { + return new Promise((resolve, reject) => { + if (!collection) { + resolve([]) + return + } + + // Add to queue + queue.push({ + collection, + pathOrID, + isTeamCollection, + resolve, + reject, + }) + + // If worker is not busy, start processing + if (!isWorkerBusy) { + processQueue() + } + }) + } + + return { + isProcessing: readonly(isProcessing), + progress: readonly(progress), + processedCount: readonly(processedCount), + totalCount: readonly(totalCount), + processDocumentation, + } +} diff --git a/packages/hoppscotch-common/src/composables/useFileUpload.ts b/packages/hoppscotch-common/src/composables/useFileUpload.ts new file mode 100644 index 0000000..ac972cf --- /dev/null +++ b/packages/hoppscotch-common/src/composables/useFileUpload.ts @@ -0,0 +1,249 @@ +import { ref } from "vue" +import * as E from "fp-ts/Either" +import { useToast } from "./toast" +import { useI18n } from "./i18n" + +export type UploadType = "org-logo" + +export interface UploadResult { + success: boolean + url?: string + message?: string + statusCode?: number +} + +export interface UploadError { + message: string + statusCode?: number +} + +const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB +const UPLOAD_TIMEOUT_MS = 30000 // 30 seconds +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"] + +/** + * Validates an image file before upload + * @param file The file to validate + * @returns null if valid, error message string if invalid + */ +export function validateImageFile(file: File): string | null { + // Check file size + if (file.size > MAX_FILE_SIZE) { + return "file_upload.error_size_limit" + } + + // Check file type + if (!ALLOWED_TYPES.includes(file.type)) { + return "file_upload.error_invalid_format" + } + + return null +} + +/** + * Creates a preview URL for an image file + * @param file The file to preview + * @returns Preview URL (remember to revoke with URL.revokeObjectURL when done) + */ +export function createImagePreview(file: File): string { + return URL.createObjectURL(file) +} + +/** + * Composable for handling file uploads with validation and error handling + */ +export function useFileUpload() { + const toast = useToast() + const t = useI18n() + const uploading = ref(false) + const previewUrl = ref(null) + + /** + * Uploads a file to the backend (organization logos only) + * @param file The file to upload + * @param uploadType Type of upload (org-logo) + * @param orgId Organization ID for org logo uploads + * @param getAuthConfig Function to get axios auth configuration + * @returns Either error or upload result + */ + const uploadFile = async ( + file: File, + uploadType: UploadType, + orgId: string | null, + getAuthConfig: () => Promise<{ + headers: Record + withCredentials?: boolean + }> + ): Promise> => { + // Validate file + const validationError = validateImageFile(file) + if (validationError) { + return E.left({ + message: validationError, + }) + } + + // Validate backend URL early to fail fast + const backendUrl = import.meta.env.VITE_BACKEND_API_URL + if (!backendUrl) { + return E.left({ + message: "file_upload.error_missing_backend_url", + }) + } + + // Validate backend URL format to prevent SSRF attacks + try { + const url = new URL(backendUrl) + // Ensure it's HTTP/HTTPS protocol + if (!["http:", "https:"].includes(url.protocol)) { + console.error( + "Invalid backend URL protocol (expected http/https):", + url.protocol + ) + return E.left({ + message: "file_upload.error_invalid_backend_url", + }) + } + } catch (error) { + // Don't expose the malformed URL in error messages for security + console.error("Invalid backend URL format:", error) + return E.left({ + message: "file_upload.error_invalid_backend_url", + }) + } + + uploading.value = true + + try { + const formData = new FormData() + formData.append("file", file) + + // Get auth configuration + const authConfig = await getAuthConfig() + + let endpoint: string + + if (uploadType === "org-logo" && orgId) { + // Validate orgId to prevent path traversal attacks + // Organization IDs are derived from domains and must be lowercase alphanumeric with hyphens + if (!/^[a-z0-9-]+$/.test(orgId)) { + console.error("Invalid organization ID format:", orgId) + return E.left({ + message: "file_upload.error_invalid_org_id", + }) + } + endpoint = `${backendUrl}/upload/organization/${orgId}/logo` + } else { + return E.left({ + message: "file_upload.error_invalid_upload_type", + }) + } + + // Make upload request with timeout + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS) + let response: Response + try { + response = await fetch(endpoint, { + method: "POST", + headers: { + ...authConfig.headers, + }, + body: formData, + credentials: authConfig.withCredentials ? "include" : "same-origin", + signal: controller.signal, + }) + } catch (error: unknown) { + // Fetch throws DOMException with name "AbortError" when aborted (e.g., timeout via AbortController). + // Browsers may also throw a TypeError for network failures, but we handle those generically. + // See: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#exceptions + if (error instanceof DOMException && error.name === "AbortError") { + return E.left({ + message: "file_upload.error_timeout", + }) + } + // Handle network errors or other fetch failures + console.error("Upload fetch error:", error) + return E.left({ + message: "file_upload.error_network_failed", + }) + } finally { + clearTimeout(timeoutId) + } + + let result: UploadResult + try { + result = await response.json() + } catch (error: unknown) { + console.error("Failed to parse upload response:", error) + return E.left({ + message: "file_upload.error_upload_failed", + statusCode: response.status, + }) + } + + if (!response.ok || !result.success) { + return E.left({ + message: result.message || "file_upload.error_upload_failed", + statusCode: result.statusCode || response.status, + }) + } + + return E.right(result) + } finally { + uploading.value = false + } + } + + /** + * Handles file selection with preview + * @param event File input change event + * @returns Selected file or null + */ + const handleFileSelect = ( + event: Event + ): { file: File; preview: string } | null => { + const target = event.target as HTMLInputElement + const file = target.files?.[0] + + if (!file) return null + + // Validate file + const validationError = validateImageFile(file) + if (validationError) { + toast.error(t(validationError)) + return null + } + + // Create preview + if (previewUrl.value) { + URL.revokeObjectURL(previewUrl.value) + } + previewUrl.value = createImagePreview(file) + + return { + file, + preview: previewUrl.value, + } + } + + /** + * Cleans up preview URL + */ + const cleanupPreview = () => { + if (previewUrl.value) { + URL.revokeObjectURL(previewUrl.value) + previewUrl.value = null + } + } + + return { + uploading, + previewUrl, + uploadFile, + handleFileSelect, + cleanupPreview, + validateImageFile, + createImagePreview, + } +} diff --git a/packages/hoppscotch-common/src/composables/useMockServer.ts b/packages/hoppscotch-common/src/composables/useMockServer.ts new file mode 100644 index 0000000..13f6a97 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/useMockServer.ts @@ -0,0 +1,491 @@ +import { useI18n } from "@composables/i18n" +import { useReadonlyStream } from "@composables/stream" +import { useToast } from "@composables/toast" +import { useService } from "dioc/vue" +import { pipe } from "fp-ts/function" +import * as TE from "fp-ts/TaskEither" +import { translateToNewEnvironmentVariables } from "@hoppscotch/data" +import { computed } from "vue" +import { WorkspaceType } from "~/helpers/backend/graphql" +import type { MockServer } from "~/helpers/backend/types/MockServer" +import { platform } from "~/platform" +import { sync } from "~/lib/sync/defs" +import { + createTeamEnvironment, + updateTeamEnvironment, +} from "~/helpers/backend/mutations/TeamEnvironment" +import TeamEnvironmentAdapter from "~/helpers/teams/TeamEnvironmentAdapter" +import { uniqueID } from "~/helpers/utils/uniqueID" +import { restCollections$ } from "~/newstore/collections" +import { + addEnvironmentVariable, + createEnvironment, + environments$, + getSelectedEnvironmentIndex, + updateEnvironmentVariable, +} from "~/newstore/environments" +import { + addMockServer, + mockServers$, + updateMockServer as updateMockServerInStore, + loadMockServers, +} from "~/newstore/mockServers" +import { CurrentValueService } from "~/services/current-environment-value.service" +import { stripSecretVariableValuesForWire } from "~/helpers/secretVariables" +import { TeamCollectionsService } from "~/services/team-collection.service" +import { WorkspaceService } from "~/services/workspace.service" + +/** + * Picks which mock-server URL should be stored as the `mockUrl` + * environment variable. + * + * Policy: always prefer the subdomain-based URL + * (`serverUrlDomainBased`) when it's available and fall back to the + * path-based URL (`serverUrlPathBased`) otherwise. The backend only + * returns `serverUrlDomainBased` when a wildcard domain is configured, + * so the path-based URL is the universal fallback. On the cloud + * instance only `serverUrlDomainBased` is returned, so that URL is + * used there. + */ +function pickMockUrl( + server: Pick +): string { + const path = server.serverUrlPathBased ?? "" + const subdomain = server.serverUrlDomainBased ?? "" + return subdomain || path +} + +export function useMockServer() { + const t = useI18n() + const toast = useToast() + const workspaceService = useService(WorkspaceService) + const teamCollectionsService = useService(TeamCollectionsService) + const currentValueService = useService(CurrentValueService) + + const mockServers = useReadonlyStream(mockServers$, []) + const collections = useReadonlyStream(restCollections$, []) + const currentWorkspace = computed( + () => workspaceService.currentWorkspace.value + ) + + // Get collections based on current workspace + const availableCollections = computed(() => { + if ( + currentWorkspace.value.type === "team" && + currentWorkspace.value.teamID + ) { + return teamCollectionsService.collections.value || [] + } + return collections.value + }) + + // Environment management + const myEnvironments = useReadonlyStream(environments$, []) + const teamEnvironmentAdapter = new TeamEnvironmentAdapter( + currentWorkspace.value.type === "team" + ? currentWorkspace.value.teamID + : undefined + ) + + // Function to refetch collections and mock servers + const refetchData = async () => { + try { + // Refetch mock servers + await loadMockServers() + + // Refetch collections based on workspace type + if ( + currentWorkspace.value.type === "team" && + currentWorkspace.value.teamID + ) { + // For team workspace, reload team collections by re-initializing with the same team ID + teamCollectionsService.changeTeamID(currentWorkspace.value.teamID) + } else { + // For personal workspace, load REST collections only (mock servers are REST-based) + if (sync.collections.loadUserCollections) { + await sync.collections.loadUserCollections("REST") + } + } + } catch (error) { + console.error("Failed to refetch data:", error) + } + } + + // Function to add mock URL to environment + const addMockUrlToEnvironment = async ( + mockUrl: string, + collectionName: string + ) => { + const workspaceType = currentWorkspace.value.type + + if (workspaceType === "personal") { + // For personal workspace, add to selected environment or create new one. + // + // Architectural note: env variables are split into a persisted half + // (`initialValue`, goes to the store / backend) and a local half + // (`currentValue`, stored only in CurrentValueService). The persisted + // payload must always carry `currentValue: ""`; the real value is + // registered separately via `currentValueService`. + const selectedEnvIndex = getSelectedEnvironmentIndex() + + if (selectedEnvIndex.type === "MY_ENV") { + // Check if mockUrl already exists in the environment + const env = myEnvironments.value[selectedEnvIndex.index] + const existingVariableIndex = env.variables.findIndex( + (v) => v.key === "mockUrl" + ) + + if (existingVariableIndex === -1) { + // Add to existing selected environment. The new variable will be + // appended at `env.variables.length` once the dispatch lands. + const newVarIndex = env.variables.length + addEnvironmentVariable(selectedEnvIndex.index, { + key: "mockUrl", + initialValue: mockUrl, + currentValue: "", + secret: false, + }) + currentValueService.addEnvironmentVariable(env.id, { + key: "mockUrl", + currentValue: mockUrl, + varIndex: newVarIndex, + isSecret: false, + }) + toast.success(t("mock_server.environment_variable_added")) + } else { + // Update existing mockUrl variable with new value using the + // store dispatcher. Persist initial only; update the current + // value separately via the service (remove + add, since there + // is no explicit update API on the service). + updateEnvironmentVariable( + selectedEnvIndex.index, + existingVariableIndex, + { + key: "mockUrl", + initialValue: mockUrl, + currentValue: "", + } + ) + currentValueService.removeEnvironmentVariable( + env.id, + existingVariableIndex + ) + currentValueService.addEnvironmentVariable(env.id, { + key: "mockUrl", + currentValue: mockUrl, + varIndex: existingVariableIndex, + isSecret: false, + }) + toast.success(t("mock_server.environment_variable_updated")) + } + } else { + // Create a new environment with the mock URL. + // We generate the env ID up front so we can register the current + // value against the same ID without racing the dispatch. + const envName = `${collectionName} Environment` + const envID = uniqueID() + createEnvironment( + envName, + [ + { + key: "mockUrl", + initialValue: mockUrl, + currentValue: "", + secret: false, + }, + ], + envID + ) + currentValueService.addEnvironment(envID, [ + { + key: "mockUrl", + currentValue: mockUrl, + varIndex: 0, + isSecret: false, + }, + ]) + toast.success(t("mock_server.environment_created_with_variable")) + } + } else if (workspaceType === "team" && currentWorkspace.value.teamID) { + // For team workspace, create a new team environment or update existing one + const teamID = currentWorkspace.value.teamID + + // Check if there's an existing team environment for this collection + const teamEnvs = teamEnvironmentAdapter.teamEnvironmentList$.value + const existingEnv = teamEnvs.find((env) => + env.environment.name.includes(collectionName) + ) + + if (existingEnv) { + // Update existing environment (add or update the mockUrl variable) + const existingVariableIndex = + existingEnv.environment.variables.findIndex( + (v) => v.key === "mockUrl" + ) + + let updatedVariables + let successMessage + + // Track the varIndex that will hold mockUrl after the update + // so we can register the current value against the right slot. + let mockUrlVarIndex: number + + if (existingVariableIndex === -1) { + // Variable doesn't exist, append it. Team env variables follow + // the v2 schema ({ key, initialValue, currentValue, secret }). + // `currentValue` must be empty on persist — the real value is + // stored locally via CurrentValueService. + mockUrlVarIndex = existingEnv.environment.variables.length + updatedVariables = [ + ...existingEnv.environment.variables, + { + key: "mockUrl", + initialValue: mockUrl, + currentValue: "", + secret: false, + }, + ] + successMessage = t("mock_server.environment_variable_added") + } else { + // Variable exists, bump its initialValue; keep currentValue + // empty on persist and refresh the service entry below. + // + // We rebuild the v2 shape explicitly rather than spreading + // the existing variable — a legacy `{ key, value }` row + // would otherwise leak its `value` field alongside + // `initialValue` / `currentValue` and produce a mixed- + // schema payload. + mockUrlVarIndex = existingVariableIndex + updatedVariables = existingEnv.environment.variables.map((v, idx) => + idx === existingVariableIndex + ? { + key: "mockUrl", + initialValue: mockUrl, + currentValue: "", + secret: false, + } + : v + ) + successMessage = t("mock_server.environment_variable_updated") + } + + // Normalize every entry before persisting. Other variables + // in this list may still be legacy `{ key, value }` rows + // because `TeamEnvironmentAdapter` subscribes via raw + // `JSON.parse` without running the translator — if we just + // stringified `updatedVariables` as-is we could send a + // mixed-schema payload back to the backend. Running each + // row through `translateToNewEnvironmentVariables` guarantees + // all entries are in the v2 shape. + const normalizedVariables = updatedVariables.map( + translateToNewEnvironmentVariables + ) + + await pipe( + updateTeamEnvironment( + JSON.stringify( + stripSecretVariableValuesForWire(normalizedVariables) + ), + existingEnv.id, + existingEnv.environment.name + ), + TE.match( + (error) => { + console.error("Failed to update team environment:", error) + toast.error(t("error.something_went_wrong")) + }, + () => { + // Persist succeeded — now register the real current value + // against the team env's ID. Remove any stale entry at the + // same slot first (no explicit update API on the service). + currentValueService.removeEnvironmentVariable( + existingEnv.id, + mockUrlVarIndex + ) + currentValueService.addEnvironmentVariable(existingEnv.id, { + key: "mockUrl", + currentValue: mockUrl, + varIndex: mockUrlVarIndex, + isSecret: false, + }) + toast.success(successMessage) + } + ) + )() + } else { + // Create new team environment. Variables go out with an empty + // currentValue; the real value is registered locally against + // the server-assigned env ID once the mutation returns. + const envName = `${collectionName} Environment` + const variables = [ + { + key: "mockUrl", + initialValue: mockUrl, + currentValue: "", + secret: false, + }, + ] + + await pipe( + createTeamEnvironment( + JSON.stringify(stripSecretVariableValuesForWire(variables)), + teamID, + envName + ), + TE.match( + (error) => { + console.error("Failed to create team environment:", error) + toast.error(t("error.something_went_wrong")) + }, + (result) => { + const newEnvID = result.createTeamEnvironment.id + if (newEnvID) { + currentValueService.addEnvironment(newEnvID, [ + { + key: "mockUrl", + currentValue: mockUrl, + varIndex: 0, + isSecret: false, + }, + ]) + } + toast.success(t("mock_server.environment_created_with_variable")) + } + ) + )() + } + } + } + + // Create new mock server + const createMockServer = async (params: { + mockServerName: string + collectionID?: string + autoCreateCollection?: boolean + autoCreateRequestExample?: boolean + delayInMs: number + isPublic: boolean + setInEnvironment: boolean + collectionName: string + }) => { + const { + mockServerName, + collectionID, + autoCreateCollection, + autoCreateRequestExample, + delayInMs, + isPublic, + setInEnvironment, + collectionName, + } = params + + if (!mockServerName.trim()) { + return { success: false, server: null } + } + + // Exactly one of collectionID or autoCreateCollection must be provided (XOR) + if ( + (!collectionID && !autoCreateCollection) || + (collectionID && autoCreateCollection) + ) { + toast.error(t("mock_server.select_collection_error")) + return { success: false, server: null } + } + + // Determine workspace type and ID based on current workspace + const workspaceType = + currentWorkspace.value.type === "team" + ? WorkspaceType.Team + : WorkspaceType.User + const workspaceID = + currentWorkspace.value.type === "team" + ? currentWorkspace.value.teamID + : undefined + + const result = await pipe( + platform.backend.createMockServer( + mockServerName.trim(), + workspaceType, + workspaceID, + delayInMs, + isPublic, + collectionID, + autoCreateCollection, + autoCreateRequestExample + ), + TE.match( + (error) => { + toast.error(String(error) || t("error.something_went_wrong")) + return null as MockServer | null + }, + (result) => { + toast.success(t("mock_server.mock_server_created")) + // Add the new mock server to the store + addMockServer(result) + return result as MockServer + } + ) + )() + + if (!result) { + return { success: false, server: null } + } + + // Add mock URL to environment if enabled. + // Always prefer `serverUrlDomainBased`; fall back to + // `serverUrlPathBased` when the backend has no wildcard domain + // configured and the subdomain URL comes back null. + if (setInEnvironment) { + const mockUrl = pickMockUrl(result) + if (mockUrl) { + await addMockUrlToEnvironment(mockUrl, collectionName) + } + } + + // Refetch collections and mock servers to get the latest data + await refetchData() + + return { success: true, server: result } + } + + // Toggle mock server active state + const toggleMockServer = async (mockServer: MockServer) => { + const newActiveState = !mockServer.isActive + + return await pipe( + platform.backend.updateMockServer(mockServer.id, { + isActive: newActiveState, + }), + TE.match( + () => { + toast.error(t("error.something_went_wrong")) + return { success: false } + }, + () => { + toast.success( + newActiveState + ? t("mock_server.server_started") + : t("mock_server.server_stopped") + ) + + // Update the mock server in the store + updateMockServerInStore(mockServer.id, { isActive: newActiveState }) + + return { success: true } + } + ) + )() + } + + return { + // State + mockServers, + availableCollections, + currentWorkspace, + + // Functions + createMockServer, + toggleMockServer, + addMockUrlToEnvironment, + } +} diff --git a/packages/hoppscotch-common/src/composables/useScrollerRef.ts b/packages/hoppscotch-common/src/composables/useScrollerRef.ts new file mode 100644 index 0000000..d358715 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/useScrollerRef.ts @@ -0,0 +1,113 @@ +import { ref, onMounted, onBeforeUnmount } from "vue" +import { useService } from "dioc/vue" +import { ScrollService } from "~/services/scroll.service" + +/** + * A composable used to automatically restore and save scroll position + * inside a scrollable element (e.g., .cm-scroller) within a container. + * + * @param label - Label used in error logging + * @param classSelector - CSS selector for the scrollable element + * @param initialScrollTop - Optional fallback scroll position + * @param scrollKey - Unique key used for saving/restoring scroll state via ScrollService + */ +export function useScrollerRef( + label: string = "Lens", + classSelector: string = ".cm-scroller", + initialScrollTop?: number, + scrollKey?: string +) { + // Container element ref (typically the root of the scrollable section) + const containerRef = ref(null) + + // Ref for the actual scrollable element inside the container + const scrollerRef = ref(null) + + // Inject the ScrollService to access stored scroll positions + const scrollService = useService(ScrollService) + + /** + * Utility to wait until the scrollable element is actually scrollable + * (i.e., content overflows and scrolling is possible). + * Retries for a limited number of times before failing. + */ + let isUnmounted = false + function waitUntilScrollable( + maxTries = 60, + delay = 16 + ): Promise { + return new Promise((resolve, reject) => { + let tries = 0 + + const tryFind = () => { + if (isUnmounted) { + reject(new Error(`[${label}] Aborted: component unmounted`)) + return + } + + const scroller = containerRef.value?.querySelector( + classSelector + ) as HTMLElement | null + + if (scroller && scroller.scrollHeight > scroller.clientHeight) { + resolve(scroller) // Found a scrollable element + return + } + + tries++ + if (tries >= maxTries) { + reject( + new Error(`[${label}] Timeout: ${classSelector} never scrollable`) + ) + } else { + setTimeout(tryFind, delay) // Retry after delay + } + } + + tryFind() + }) + } + + // Scroll event handler to save scroll position + let onScroll: (() => void) | null = null + + onMounted(async () => { + try { + const scroller = await waitUntilScrollable() + scrollerRef.value = scroller + + // Restore scroll position from service (if available) + requestAnimationFrame(() => { + if ( + scrollKey && + scrollService.getScrollForKey(scrollKey) !== undefined + ) { + scroller.scrollTop = scrollService.getScrollForKey(scrollKey)! + } else if (initialScrollTop !== undefined) { + scroller.scrollTop = initialScrollTop + } + }) + + // Register scroll event to update position in ScrollService + onScroll = () => { + if (scrollKey) { + scrollService.setScrollForKey(scrollKey, scroller.scrollTop) + } + } + + scroller.addEventListener("scroll", onScroll) + } catch (error: any) { + console.error(`[${label}] Failed to initialize scroller:`, error.message) + } + }) + + // Clean up scroll listener on unmount + onBeforeUnmount(() => { + isUnmounted = true + if (scrollerRef.value && onScroll) { + scrollerRef.value.removeEventListener("scroll", onScroll) + } + }) + + return { containerRef, scrollerRef } +} diff --git a/packages/hoppscotch-common/src/composables/whats-new.ts b/packages/hoppscotch-common/src/composables/whats-new.ts new file mode 100644 index 0000000..547cd70 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/whats-new.ts @@ -0,0 +1,73 @@ +import { toast as sonner } from "@hoppscotch/ui" +import { markRaw } from "vue" +import WhatsNewDialog from "~/components/app/WhatsNewDialog.vue" +import { getService } from "~/modules/dioc" +import { PersistenceService } from "~/services/persistence" +import { version as hoppscotchCommonPkgVersion } from "./../../package.json" + +export async function useWhatsNewDialog() { + const persistenceService = getService(PersistenceService) + + const versionFromLocalStorage = + await persistenceService.getLocalConfig("hopp_v") + + // Set new entry `hopp_v` under `localStorage` if not present + if (!versionFromLocalStorage) { + await persistenceService.setLocalConfig( + "hopp_v", + hoppscotchCommonPkgVersion + ) + return + } + + // Already on the latest version + if (versionFromLocalStorage === hoppscotchCommonPkgVersion) { + return + } + + const getMajorVersion = (v: string) => v.split(".").slice(0, 2).join(".") + + const majorVersionFromLocalStorage = getMajorVersion(versionFromLocalStorage) + const hoppscotchCommonPkgMajorVersion = getMajorVersion( + hoppscotchCommonPkgVersion + ) + + // Skipping the minor version update. e.g. 2024.1.0 -> 2024.1.1 + // Checking major version update. e.g. 2024.1 -> 2024.2 + + // Show the release notes during a major version update + if (majorVersionFromLocalStorage !== hoppscotchCommonPkgMajorVersion) { + setTimeout(async () => { + const notesUrl = await getReleaseNotes(hoppscotchCommonPkgMajorVersion) + + if (notesUrl) { + sonner.custom(markRaw(WhatsNewDialog), { + componentProps: { + notesUrl, + version: hoppscotchCommonPkgVersion, + }, + position: "bottom-left", + style: { + bottom: "15px", + left: "30px", + }, + duration: Infinity, + }) + } + }, 10000) + } + + await persistenceService.setLocalConfig("hopp_v", hoppscotchCommonPkgVersion) +} + +async function getReleaseNotes(v: string): Promise { + try { + const { release_notes } = await fetch( + `https://releases.hoppscotch.com/releases/${v}.json` + ).then((res) => res.json()) + + return release_notes + } catch (_) { + return undefined + } +} diff --git a/packages/hoppscotch-common/src/helpers/RESTExtURLParams.ts b/packages/hoppscotch-common/src/helpers/RESTExtURLParams.ts new file mode 100644 index 0000000..6efccd8 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/RESTExtURLParams.ts @@ -0,0 +1,123 @@ +import { FormDataKeyValue, HoppRESTRequest } from "@hoppscotch/data" +import { getDefaultRESTRequest } from "./rest/default" +import { isJSONContentType } from "./utils/contenttypes" + +/** + * Handles translations for all the hopp.io REST Shareable URL params + */ +export function translateExtURLParams( + urlParams: Record, + initialReq?: HoppRESTRequest +): HoppRESTRequest { + if (urlParams.v) return parseV1ExtURL(urlParams, initialReq) + return parseV0ExtURL(urlParams, initialReq) +} + +function parseV0ExtURL( + urlParams: Record, + initialReq?: HoppRESTRequest +): HoppRESTRequest { + const resolvedReq = initialReq ?? getDefaultRESTRequest() + + if (urlParams.method && typeof urlParams.method === "string") { + resolvedReq.method = urlParams.method + } + + if (urlParams.url && typeof urlParams.url === "string") { + if (urlParams.path && typeof urlParams.path === "string") { + resolvedReq.endpoint = `${urlParams.url}/${urlParams.path}` + } else { + resolvedReq.endpoint = urlParams.url + } + } + + if (urlParams.headers && typeof urlParams.headers === "string") { + resolvedReq.headers = JSON.parse(urlParams.headers) + } + + if (urlParams.params && typeof urlParams.params === "string") { + resolvedReq.params = JSON.parse(urlParams.params) + } + + if (urlParams.httpUser && typeof urlParams.httpUser === "string") { + resolvedReq.auth = { + authType: "basic", + authActive: true, + username: urlParams.httpUser, + password: urlParams.httpPassword ?? "", + } + } + + if (urlParams.bearerToken && typeof urlParams.bearerToken === "string") { + resolvedReq.auth = { + authType: "bearer", + authActive: true, + token: urlParams.bearerToken, + } + } + + if (urlParams.contentType) { + if (urlParams.contentType === "multipart/form-data") { + resolvedReq.body = { + contentType: "multipart/form-data", + body: JSON.parse(urlParams.bodyParams || "[]").map( + (x: any) => + { + active: x.active, + key: x.key, + value: x.value, + isFile: false, + } + ), + } + } else if (isJSONContentType(urlParams.contentType)) { + if (urlParams.rawParams) { + resolvedReq.body = { + contentType: urlParams.contentType, + body: urlParams.rawParams, + } + } else { + resolvedReq.body = { + contentType: urlParams.contentType, + body: urlParams.bodyParams, + } + } + } else { + resolvedReq.body = { + contentType: urlParams.contentType, + body: urlParams.rawParams, + } + } + } + + return resolvedReq +} + +function parseV1ExtURL( + urlParams: Record, + initialReq?: HoppRESTRequest +): HoppRESTRequest { + const resolvedReq = initialReq ?? getDefaultRESTRequest() + + if (urlParams.headers && typeof urlParams.headers === "string") { + resolvedReq.headers = JSON.parse(urlParams.headers) + } + + if (urlParams.params && typeof urlParams.params === "string") { + resolvedReq.params = JSON.parse(urlParams.params) + } + + if (urlParams.method && typeof urlParams.method === "string") { + resolvedReq.method = urlParams.method + } + + if (urlParams.endpoint && typeof urlParams.endpoint === "string") { + resolvedReq.endpoint = urlParams.endpoint + } + + if (urlParams.body && typeof urlParams.body === "string") { + resolvedReq.body = JSON.parse(urlParams.body) + } + + return resolvedReq +} diff --git a/packages/hoppscotch-common/src/helpers/RequestRunner.ts b/packages/hoppscotch-common/src/helpers/RequestRunner.ts new file mode 100644 index 0000000..c83622e --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/RequestRunner.ts @@ -0,0 +1,1168 @@ +import { + Cookie, + Environment, + HoppCollectionVariable, + HoppRESTHeader, + HoppRESTHeaders, + HoppRESTRequest, + HoppRESTRequestVariable, +} from "@hoppscotch/data" +import { + SandboxPreRequestResult, + SandboxTestResult, + TestDescriptor, + TestResult, +} from "@hoppscotch/js-sandbox" +import * as A from "fp-ts/Array" +import * as E from "fp-ts/Either" +import * as O from "fp-ts/Option" +import { flow, pipe } from "fp-ts/function" +import { cloneDeep, isEqual } from "lodash-es" +import { Observable, Subject } from "rxjs" +import { filter } from "rxjs/operators" +import { Ref } from "vue" + +import { map } from "fp-ts/Either" + +import { runPreRequestScript, runTestScript } from "@hoppscotch/js-sandbox/web" +import { useSetting } from "~/composables/settings" +import { getService } from "~/modules/dioc" +import { + combineScriptsWithIIFE, + hasActualScript, +} from "@hoppscotch/js-sandbox/scripting" +import { createHoppFetchHook } from "~/helpers/hopp-fetch" +import { KernelInterceptorService } from "~/services/kernel-interceptor.service" +import { + environmentsStore, + getCurrentEnvironment, + getEnvironment, + getGlobalVariables, + SelectedEnvironmentIndex, + setGlobalEnvVariables, + updateEnvironment, +} from "~/newstore/environments" +import { platform } from "~/platform" +import { CookieJarService } from "~/services/cookie-jar.service" +import { + CurrentValueService, + Variable, +} from "~/services/current-environment-value.service" +import { + SecretEnvironmentService, + SecretVariable, +} from "~/services/secret-environment.service" +import { HoppTab } from "~/services/tab" +import { updateTeamEnvironment } from "./backend/mutations/TeamEnvironment" +import { createRESTNetworkRequestStream } from "./network" +import { HoppRequestDocument } from "./rest/document" +import { + getTemporaryVariables, + setTemporaryVariables, +} from "./runner/temp_envs" +import { HoppRESTResponse } from "./types/HoppRESTResponse" +import { HoppTestData, HoppTestResult } from "./types/HoppTestResult" +import { getEffectiveRESTRequest } from "./utils/EffectiveURL" +import { getCombinedEnvVariables } from "./utils/environments" +import { transformInheritedCollectionVariablesToAggregateEnv } from "./utils/inheritedCollectionVarTransformer" +import { isJSONContentType } from "./utils/contenttypes" +import { applyScriptRequestUpdates } from "./experimental-sandbox-integration" + +const secretEnvironmentService = getService(SecretEnvironmentService) +const currentEnvironmentValueService = getService(CurrentValueService) +// `getService(CookieJarService)` at module top level would construct +// the service during ESM evaluation. `onServiceInit` then reads +// `window.__KERNEL__.store` and throws because `createHoppApp` has +// not yet called `initKernel(...)` at that point. +const getCookieJarService = () => getService(CookieJarService) +const kernelInterceptorService = getService(KernelInterceptorService) + +const EXPERIMENTAL_SCRIPTING_SANDBOX = useSetting( + "EXPERIMENTAL_SCRIPTING_SANDBOX" +) + +export type InitialEnvironmentState = { + initialGlobalEnvs: Environment["variables"] + initialEnvID: string + initialSelectedEnvs: Environment["variables"] + initialEnvironmentIndex: SelectedEnvironmentIndex + initialEnvName: string + initialEnvs: TestResult["envs"] & { + temp: Environment["variables"] + } + initialEnvsForComparison: TestResult["envs"] +} + +/** + * Waits for the browser to commit and paint DOM updates. + * Uses double requestAnimationFrame to ensure the browser has actually rendered changes. + * This is critical for ensuring loading states (like Send → Cancel button) are visible + * before starting async work like script execution or network requests. + * + * @returns Promise that resolves after the browser has painted + */ +export const waitForBrowserPaint = (): Promise => { + return new Promise((resolve) => { + // First RAF queues callback for next frame + requestAnimationFrame(() => { + // Second RAF ensures paint has actually occurred + requestAnimationFrame(() => { + resolve() + }) + }) + }) +} + +/** + * Captures the initial environment state before request execution + * So that we can compare and update environment variables after test script execution + * because the current environment can change during the request execution. + * @returns Object containing all initial environment states needed for comparison and updates + */ +export const captureInitialEnvironmentState = (): InitialEnvironmentState => { + // Capture initial environment state before request execution + const initialGlobalEnvs = resolveEnvVars( + "Global", + cloneDeep(getGlobalVariables()) + ) + const { id: initialEnvID, variables: initialEnvVariables } = + getCurrentEnvironment() + + const initialSelectedEnvs = resolveEnvVars(initialEnvID, initialEnvVariables) + + // Capture initial environment index for later use in updateEnvsAfterTestScript + const initialEnvironmentIndex = cloneDeep( + environmentsStore.value.selectedEnvironmentIndex + ) + + // Capture the initial environment name + const initialEnvName = getCurrentEnvironment().name + + // Snapshot for the post-script diff. Both this and the sandbox receive + // secret-hydrated values from `getCombinedEnvVariables`, so reading a + // secret doesn't show up as a change in `hasScopeChanges`. + const initialEnvs = getCombinedEnvVariables() + const initialEnvsForComparison: TestResult["envs"] = { + global: initialEnvs.global, + selected: initialEnvs.selected, + } + + return { + initialGlobalEnvs, + initialEnvID, + initialSelectedEnvs, + initialEnvironmentIndex, + initialEnvName, + initialEnvs, + initialEnvsForComparison, + } +} + +export const getTestableBody = ( + res: HoppRESTResponse & { type: "success" | "fail" } +) => { + const contentTypeHeader = res.headers.find( + (h: HoppRESTHeader) => h.key.toLowerCase() === "content-type" + ) + + const rawBody = new TextDecoder("utf-8") + .decode(res.body) + .replaceAll("\x00", "") + + const x = pipe( + // This pipeline just decides whether JSON parses or not + contentTypeHeader && isJSONContentType(contentTypeHeader.value) + ? O.of(rawBody) + : O.none, + + // Try parsing, if failed, go to the fail option + O.chain((body) => O.tryCatch(() => JSON.parse(body))), + + // If JSON, return that (get), else return just the body string (else) + O.getOrElse(() => rawBody) + ) + + return x +} + +/** + * Combines the environment variables from the request and the selected, global, and temporary environments. + * The priority is as follows: + * 1. Request variables + * 2. Temporary variables (if any) + * 3. Selected environment variables + * 4. Global environment variables + * @param variables The environment variables to combine + * @returns The combined environment variables + */ +export const combineEnvVariables = (variables: { + environments: { + selected: Environment["variables"] + global: Environment["variables"] + temp?: Environment["variables"] + } + requestVariables: Environment["variables"] + collectionVariables: Environment["variables"] +}) => [ + ...variables.requestVariables, + ...variables.collectionVariables, + ...(variables.environments.temp ?? []), + ...variables.environments.selected, + ...variables.environments.global, +] + +export const executedResponses$ = new Subject< + HoppRESTResponse & { type: "success" | "fail " } +>() + +/** + * This will update the environment variables in the current environment + * and secret environment service. + * @param envs The environment variables to update + * @param type Whether the environment variables are global or selected + * @param initialEnvID The initial environment ID to use for updates + * @returns the updated environment variables + */ +const updateEnvironments = ( + envs: Environment["variables"], + type: "global" | "selected", + initialEnvID?: string +) => { + const envID = + type === "selected" ? initialEnvID || getCurrentEnvironment().id : "Global" + + const updatedSecretEnvironments: SecretVariable[] = [] + const nonSecretVariables: Variable[] = [] + + const updatedEnv = pipe( + envs, + A.mapWithIndex((index, e) => { + if (e.secret) { + updatedSecretEnvironments.push({ + key: e.key, + value: e.currentValue ?? "", + varIndex: index, + initialValue: e.initialValue ?? "", + }) + + // Secret values stay client-side only (they were saved into the + // local secret service above). Both `initialValue` and + // `currentValue` are cleared on the wire payload so the secret + // never leaves the device. + return { + key: e.key, + secret: e.secret, + initialValue: "", + currentValue: "", + } + } + + nonSecretVariables.push({ + key: e.key, + isSecret: e.secret ?? false, + varIndex: index, + currentValue: e.currentValue ?? "", + }) + + // `currentValue` is per-user/per-session by Hoppscotch convention and + // is never persisted server-side. The actual value lives in the local + // `currentEnvironmentValueService` (populated above); the wire payload + // gets it cleared so test-script env updates can't leak per-user state + // into the team backend. + return { + key: e.key, + secret: e.secret ?? false, + initialValue: e.initialValue ?? "", + currentValue: "", + } + }) + ) + + if (envID) { + secretEnvironmentService.addSecretEnvironment( + envID, + updatedSecretEnvironments + ) + + currentEnvironmentValueService.addEnvironment(envID, nonSecretVariables) + } + + return updatedEnv +} + +/** + * Get the environment variable value from the secret environment service + * @param envID The environment ID + * @param index The index of the environment variable + * @returns Current value and initial value of the environment variable + */ +const getSecretEnvironmentVariableValue = ( + envID: string, + index: number +): { + value: string + initialValue?: string +} | null => { + return secretEnvironmentService.getSecretEnvironmentVariableValue( + envID, + index + ) +} + +/** + * Get the environment variable value from the current environment + * @param envID The environment ID + * @param index The index of the environment variable + * @param isSecret Whether the environment variable is a secret + * @returns Current value of the environment variable + */ +const getEnvironmentVariableValue = ( + envID: string, + index: number +): string | undefined => { + return currentEnvironmentValueService.getEnvironmentVariableValue( + envID, + index + ) +} + +/** + * Set currentValue as initialValue if currentValue is empty + * This is set just for request runtime and it will not be persisted. + * @param env The environment variable to be transformed + * @returns The transformed environment variable with currentValue set to initialValue if empty + */ +const getTransformedEnvs = ( + env: Environment["variables"][number] +): Environment["variables"][number] => { + return { + ...env, + currentValue: env.currentValue || env.initialValue, + } +} + +/** + * Transforms the environment list to a list with unique keys with value + * and set currentValue as initialValue if currentValue is empty. + * @param envs The environment list to be transformed + * @returns The transformed environment list with keys with value + */ +export const filterNonEmptyEnvironmentVariables = ( + envs: Environment["variables"] +): Environment["variables"] => { + const envsMap = new Map() + envs.forEach((env) => { + const transformedEnv = getTransformedEnvs(env) + + if (envsMap.has(transformedEnv.key)) { + const existingEnv = envsMap.get(transformedEnv.key) + + if ( + existingEnv && + "currentValue" in existingEnv && + existingEnv.currentValue === "" && + transformedEnv.currentValue !== "" + ) { + envsMap.set(transformedEnv.key, transformedEnv) + } + } else { + envsMap.set(transformedEnv.key, transformedEnv) + } + }) + + return Array.from(envsMap.values()) +} + +const delegatePreRequestScriptRunner = ( + request: HoppRESTRequest, + envs: { + global: Environment["variables"] + selected: Environment["variables"] + temp: Environment["variables"] + }, + cookies: Cookie[] | null, + inheritedPreRequestScripts: string[] = [] +): Promise> => { + const { preRequestScript } = request + const experimentalScriptingSandbox = EXPERIMENTAL_SCRIPTING_SANDBOX.value + const target = experimentalScriptingSandbox ? "experimental" : "legacy" + + // Pre-request order: root → request. + const combinedScript = combineScriptsWithIIFE( + [...inheritedPreRequestScripts, preRequestScript], + target + ) + + // Short-circuit empty scripts to avoid unnecessary WASM initialization + if (combinedScript.length === 0) { + return Promise.resolve( + E.right({ + updatedEnvs: envs, + updatedCookies: cookies, + }) + ) + } + + if (!experimentalScriptingSandbox) { + return runPreRequestScript(combinedScript, { + envs, + experimentalScriptingSandbox: false, + }) + } + + const hoppFetchHook = createHoppFetchHook(kernelInterceptorService) + + return runPreRequestScript(combinedScript, { + envs, + request, + cookies, + experimentalScriptingSandbox: true, + hoppFetchHook, + }) +} + +const runPostRequestScript = ( + envs: TestResult["envs"], + request: HoppRESTRequest, + response: HoppRESTResponse, + cookies: Cookie[] | null, + inheritedTestScripts: string[] = [] +): Promise> => { + const { testScript } = request + const experimentalScriptingSandbox = EXPERIMENTAL_SCRIPTING_SANDBOX.value + const target = experimentalScriptingSandbox ? "experimental" : "legacy" + + // Test order: request → root (reverse of pre-request). + const combinedScript = combineScriptsWithIIFE( + [testScript, ...inheritedTestScripts.slice().reverse()], + target + ) + + // Short-circuit empty scripts to avoid unnecessary WASM initialization + if (combinedScript.length === 0) { + return Promise.resolve( + E.right({ + tests: { descriptor: "root", expectResults: [], children: [] }, + envs, + consoleEntries: [], + updatedCookies: cookies, + } satisfies SandboxTestResult) + ) + } + + if (!experimentalScriptingSandbox) { + return runTestScript(combinedScript, { + envs, + response, + experimentalScriptingSandbox: false, + }) + } + + const hoppFetchHook = createHoppFetchHook(kernelInterceptorService) + + return runTestScript(combinedScript, { + envs, + request, + response, + cookies, + experimentalScriptingSandbox: true, + hoppFetchHook, + }) +} + +export function runRESTRequest$( + tab: Ref> +): [ + () => void, + Promise< + | E.Left<"script_fail" | "cancellation"> + | E.Right> + >, +] { + let cancelCalled = false + let cancelFunc: (() => void) | null = null + + const cancel = () => { + cancelCalled = true + cancelFunc?.() + } + + const cookieJarEntries = getCookieJarEntries() + + const { request, inheritedProperties } = tab.value.document + + const requestAuth = + request.auth.authType === "inherit" && request.auth.authActive + ? inheritedProperties?.auth.inheritedAuth + : request.auth + + const inheritedHeaders = inheritedProperties?.headers + ?.filter((header) => header.inheritedHeader) + .map((header) => header.inheritedHeader!) + + const requestHeaders: HoppRESTHeaders = [ + ...(inheritedHeaders ?? []), + ...request.headers, + ] + + const resolvedRequest = { + ...tab.value.document.request, + auth: requestAuth ?? { authType: "none", authActive: false }, + headers: requestHeaders, + } + + const { + initialGlobalEnvs, + initialEnvID, + initialSelectedEnvs, + initialEnvironmentIndex, + initialEnvName, + initialEnvs, + initialEnvsForComparison, + } = captureInitialEnvironmentState() + + // Extract inherited scripts from collection hierarchy, filtering out empty/module-prefix-only scripts + const inheritedScripts = inheritedProperties?.scripts ?? [] + const inheritedPreRequestScripts = inheritedScripts + .map((s) => s.preRequestScript) + .filter(hasActualScript) + const inheritedTestScripts = inheritedScripts + .map((s) => s.testScript) + .filter(hasActualScript) + + const res = delegatePreRequestScriptRunner( + resolvedRequest, + initialEnvs, + cookieJarEntries, + inheritedPreRequestScripts + ).then(async (preRequestScriptResult) => { + if (cancelCalled) return E.left("cancellation" as const) + + if (E.isLeft(preRequestScriptResult)) { + console.error("[Pre-Request Script Error]", preRequestScriptResult.left) + return E.left("script_fail" as const) + } + + const finalRequestVariables = + tab.value.document.request.requestVariables.map( + (v: HoppRESTRequestVariable) => { + if (v.active) { + return { + key: v.key, + initialValue: v.value, + currentValue: v.value, + secret: false, + } + } + return [] + } + ) + + const collectionVariables = + transformInheritedCollectionVariablesToAggregateEnv( + tab.value.document.inheritedProperties?.variables || [] + ).map(({ key, initialValue, currentValue, secret }) => ({ + key, + initialValue, + currentValue, + secret, + })) + + const finalRequest = applyScriptRequestUpdates( + resolvedRequest, + preRequestScriptResult.right.updatedRequest + ) + + // Propagate changes to request variables from the scripting context to the UI + tab.value.document.request.requestVariables = finalRequest.requestVariables + + const finalEnvs = { + environments: preRequestScriptResult.right.updatedEnvs, + requestVariables: finalRequestVariables as Environment["variables"], + collectionVariables, + } + + const finalEnvsWithNonEmptyValues = filterNonEmptyEnvironmentVariables( + combineEnvVariables(finalEnvs) + ) + + const effectiveRequest = await getEffectiveRESTRequest(finalRequest, { + id: "env-id", + v: 2, + name: "Env", + variables: finalEnvsWithNonEmptyValues, + }) + + const [stream, cancelRun] = + await createRESTNetworkRequestStream(effectiveRequest) + cancelFunc = cancelRun + + const subscription = stream + .pipe(filter((res) => res.type === "success" || res.type === "fail")) + .subscribe(async (res) => { + if (res.type === "success" || res.type === "fail") { + executedResponses$.next(res) + + const postRequestScriptResult = await runPostRequestScript( + preRequestScriptResult.right.updatedEnvs, + res.req, + { + status: res.statusCode, + body: getTestableBody(res), + headers: res.headers, + statusText: res.statusText, + responseTime: res.meta.responseDuration, + }, + preRequestScriptResult.right.updatedCookies ?? null, + inheritedTestScripts + ) + + if (E.isRight(postRequestScriptResult)) { + // set the response in the tab so that multiple tabs can run request simultaneously + tab.value.document.response = res + + // Combine console entries from pre and post request scripts + const combinedResult = pipe( + postRequestScriptResult, + map((result) => ({ + ...result, + consoleEntries: [ + ...(preRequestScriptResult.right.consoleEntries ?? []), + ...(result.consoleEntries ?? []), + ], + })) + ) as E.Right + + tab.value.document.testResults = translateToSandboxTestResults( + combinedResult.right, + initialGlobalEnvs, + initialSelectedEnvs + ) + + // Check if scripts actually modified environment variables + if ( + hasEnvironmentChanges( + initialEnvsForComparison, // Initial environment when request started + postRequestScriptResult.right.envs // Final script environment after test script execution + ) + ) { + updateEnvsAfterTestScript( + combinedResult, + initialEnvironmentIndex, + initialEnvName, + initialEnvsForComparison, + initialEnvID + ) + } + + const updatedCookies = postRequestScriptResult.right.updatedCookies + + if (updatedCookies && cookieJarEntries !== null) { + // The script's `updatedCookies` is the post-script state of + // its pre-script view, so a set difference against the + // pre-script snapshot gives the actual mutations. Cookies + // the script returned identical to what it received get + // skipped because the response capture may have updated + // them in the jar in the interim and re-upserting the + // script's stale copy would overwrite that. Cookies the + // script omitted from its returned array are treated as + // deletes, restoring `hopp.cookies.delete` semantics. + // + // Skipped entirely when `cookieJarEntries` is null + // (cookies disabled on the platform). The previous + // `?? []` made the empty pre-script snapshot classify + // every script cookie as new and never as removed, so + // delete-by-omission silently broke on non-desktop. + await applyScriptCookieDelta(cookieJarEntries, updatedCookies) + } + } else { + console.error( + "[Post-Request Script Error]", + postRequestScriptResult.left + ) + + tab.value.document.testResults = { + description: "", + expectResults: [], + tests: [], + envDiff: { + global: { + additions: [], + deletions: [], + updations: [], + }, + selected: { + additions: [], + deletions: [], + updations: [], + }, + }, + scriptError: true, + consoleEntries: [], + } + } + + subscription.unsubscribe() + } + }) + + return E.right(stream) + }) + + return [cancel, res] +} + +function updateEnvsAfterTestScript( + runResult: E.Right, + initialEnvironmentIndex: SelectedEnvironmentIndex, + initialEnvName: string, + initialEnvsForComparison: TestResult["envs"], + initialEnvID?: string +) { + // Gate each writeback on whether its own scope actually changed. The outer + // `hasEnvironmentChanges` guard is an OR across both scopes, so without + // these per-scope checks a script that touched only the selected env would + // still trigger an `updateUserEnvironment` round-trip for the unchanged + // globals (and the same happens the other way for TEAM_ENV). + const globalChanged = hasScopeChanges( + initialEnvsForComparison.global, + runResult.right.envs.global + ) + const selectedChanged = hasScopeChanges( + initialEnvsForComparison.selected, + runResult.right.envs.selected + ) + + if (globalChanged) { + const globalEnvVariables = updateEnvironments( + runResult.right.envs.global, + "global" + ) + + setGlobalEnvVariables({ + v: 2, + variables: globalEnvVariables, + }) + } + + if (selectedChanged) { + const selectedEnvVariables = updateEnvironments( + cloneDeep(runResult.right.envs.selected), + "selected", + initialEnvID + ) + + if (initialEnvironmentIndex.type === "MY_ENV") { + const env = getEnvironment({ + type: "MY_ENV", + index: initialEnvironmentIndex.index, + }) + updateEnvironment(initialEnvironmentIndex.index, { + name: env.name, + v: 2, + id: "id" in env ? env.id : "", + variables: selectedEnvVariables, + }) + } else if (initialEnvironmentIndex.type === "TEAM_ENV") { + // Use the initial environment name to avoid issues when environment changes during request execution + // adding a fallback to current environment name just in case so it's not null + const envName = initialEnvName ?? getCurrentEnvironment().name + // `updateEnvironments` above already returns wire-shaped variables + pipe( + updateTeamEnvironment( + JSON.stringify(selectedEnvVariables), + initialEnvironmentIndex.teamEnvID, + envName + ) + )() + } + } +} + +const hasScopeChanges = ( + initial: Environment["variables"], + final: Environment["variables"] +): boolean => + getAddedEnvVariables(initial, final).length > 0 || + getRemovedEnvVariables(initial, final).length > 0 || + getUpdatedEnvVariables(initial, final).length > 0 + +const hasEnvironmentChanges = ( + initialEnvs: TestResult["envs"], + finalEnvs: TestResult["envs"] +): boolean => + hasScopeChanges(initialEnvs.global, finalEnvs.global) || + hasScopeChanges(initialEnvs.selected, finalEnvs.selected) + +const getCookieJarEntries = () => { + // Exclusive to the Desktop App + if (!platform.platformFeatureFlags.cookiesEnabled) { + return null + } + + // `cloneDeep` so the sandbox cannot mutate the live jar through + // a shared reference, and so `applyScriptCookieDelta`'s + // pre-script snapshot is independent of whatever the script + // returns. Without this a script that mutates a cookie in place + // and returns the same array would produce a pre-vs-post delta + // of "identical" and the mutation would silently drop. + const cookieJarEntries = cloneDeep( + Array.from(getCookieJarService().cookieJar.value.values()).flatMap( + (cookies) => cookies + ) + ) + + return cookieJarEntries +} + +const cookieKey = (c: { domain: string; name: string; path?: string }) => + `${getCookieJarService().canonStoreDomain(c.domain)}\u0000${c.name}\u0000${c.path && c.path.length > 0 ? c.path : "/"}` + +const applyScriptCookieDelta = async ( + preScript: Cookie[], + postScript: Cookie[] +): Promise => { + const preMap = new Map() + for (const c of preScript) { + preMap.set(cookieKey(c), c) + } + const postMap = new Map() + for (const c of postScript) { + postMap.set(cookieKey(c), c) + } + + const mutated: Cookie[] = [] + for (const [key, post] of postMap) { + const before = preMap.get(key) + if (!before || !isEqual(before, post)) { + mutated.push(post) + } + } + + const removed: Array<{ domain: string; name: string; path?: string }> = [] + for (const [key, pre] of preMap) { + if (!postMap.has(key)) { + removed.push({ domain: pre.domain, name: pre.name, path: pre.path }) + } + } + + if (mutated.length > 0) { + await getCookieJarService().upsertCookies(mutated) + } + if (removed.length > 0) { + await getCookieJarService().deleteCookies(removed) + } +} + +/** + * Run the test runner request + * @param request The request to run + * @param persistEnv Whether to persist the environment variables after running the test script + * @param inheritedVariables The inherited collection variables from the collection/folder + * @param initialEnvironmentState The initial environment state before collection run execution + * @returns The response and the test result + */ + +export async function runTestRunnerRequest( + request: HoppRESTRequest, + persistEnv = true, + inheritedVariables: HoppCollectionVariable[] = [], + initialEnvironmentState: InitialEnvironmentState, + inheritedPreRequestScripts: string[] = [], + inheritedTestScripts: string[] = [] +): Promise< + | E.Left<"script_fail"> + | E.Right<{ + response: HoppRESTResponse + testResult: HoppTestResult + updatedRequest: HoppRESTRequest + }> + | undefined +> { + const cookieJarEntries = getCookieJarEntries() + + const { + initialGlobalEnvs, + initialEnvID, + initialSelectedEnvs, + initialEnvironmentIndex, + initialEnvName, + initialEnvs, + initialEnvsForComparison, + } = initialEnvironmentState + + // Wait for browser to paint the loading state (Send -> Cancel button) + // Adds ~32ms latency but ensures immediate visual feedback + await waitForBrowserPaint() + + return delegatePreRequestScriptRunner( + request, + initialEnvs, + cookieJarEntries, + inheritedPreRequestScripts + ).then(async (preRequestScriptResult) => { + if (E.isLeft(preRequestScriptResult)) { + console.error("[Pre-Request Script Error]", preRequestScriptResult.left) + return E.left("script_fail" as const) + } + + const finalRequestVariables = pipe( + request.requestVariables, + A.filter(({ active }) => active), + A.map(({ key, value }) => ({ + key, + initialValue: value, + currentValue: value, + secret: false, + })) + ) + + // Calculate the final updated request after pre-request script changes + const finalRequest = applyScriptRequestUpdates( + request, + preRequestScriptResult.right.updatedRequest + ) + + const effectiveRequest = await getEffectiveRESTRequest(finalRequest, { + id: "env-id", + v: 2, + name: "Env", + variables: filterNonEmptyEnvironmentVariables( + combineEnvVariables({ + environments: { + ...preRequestScriptResult.right.updatedEnvs, + temp: !persistEnv ? getTemporaryVariables() : [], + }, + requestVariables: finalRequestVariables, + collectionVariables: inheritedVariables, + }) + ), + }) + + const [stream] = createRESTNetworkRequestStream(effectiveRequest) + + const requestResult = stream + .pipe(filter((res) => res.type === "success" || res.type === "fail")) + .toPromise() + .then(async (res) => { + if (res?.type === "success" || res?.type === "fail") { + executedResponses$.next(res) + + const postRequestScriptResult = await runPostRequestScript( + preRequestScriptResult.right.updatedEnvs, + res.req, + { + status: res.statusCode, + body: getTestableBody(res), + headers: res.headers, + statusText: res.statusText, + responseTime: res.meta.responseDuration, + }, + preRequestScriptResult.right.updatedCookies ?? null, + inheritedTestScripts + ) + + if (E.isRight(postRequestScriptResult)) { + // Combine console entries from pre and post request scripts + const combinedResult = { + ...postRequestScriptResult.right, + consoleEntries: [ + ...(preRequestScriptResult.right.consoleEntries ?? []), + ...(postRequestScriptResult.right.consoleEntries ?? []), + ], + } + + const sandboxTestResult = translateToSandboxTestResults( + combinedResult, + initialGlobalEnvs, + initialSelectedEnvs + ) + + // Update the environment variables after running the test script when persistEnv is true. else store the updated environment variables in the store as a temporary variable. + if (persistEnv) { + if ( + hasEnvironmentChanges( + initialEnvsForComparison, // Initial script environment when requests started + postRequestScriptResult.right.envs // Final script environment after test script execution + ) + ) { + updateEnvsAfterTestScript( + postRequestScriptResult, + initialEnvironmentIndex, + initialEnvName, + initialEnvsForComparison, + initialEnvID + ) + } + } else { + // Combine global and selected environment changes + const allChanges = [ + ...postRequestScriptResult.right.envs.global, + ...postRequestScriptResult.right.envs.selected, + ] + + setTemporaryVariables(allChanges) + } + + return E.right({ + response: res, + testResult: sandboxTestResult, + updatedRequest: finalRequest, + }) + } + + // Post-request script failed + console.error( + "[Post-Request Script Error]", + postRequestScriptResult.left + ) + + const sandboxTestResult = { + description: "", + expectResults: [], + tests: [], + envDiff: { + global: { + additions: [], + deletions: [], + updations: [], + }, + selected: { + additions: [], + deletions: [], + updations: [], + }, + }, + scriptError: true, + consoleEntries: [], + } + return E.right({ + response: res, + testResult: sandboxTestResult, + updatedRequest: finalRequest, + }) + } + }) + + if (requestResult) { + return requestResult + } + + return E.left("script_fail") + }) +} + +const getAddedEnvVariables = ( + current: Environment["variables"], + updated: Environment["variables"] +) => updated.filter((x) => current.findIndex((y) => y.key === x.key) === -1) + +const getRemovedEnvVariables = ( + current: Environment["variables"], + updated: Environment["variables"] +) => current.filter((x) => updated.findIndex((y) => y.key === x.key) === -1) + +const getUpdatedEnvVariables = ( + current: Environment["variables"], + updated: Environment["variables"] +) => + pipe( + updated, + A.filterMap( + flow( + O.of, + O.bindTo("env"), + O.bind("index", ({ env }) => + pipe( + current.findIndex((x) => x.key === env.key), + O.fromPredicate((x) => x !== -1) + ) + ), + O.chain( + O.fromPredicate( + ({ env, index }) => env.currentValue !== current[index].currentValue + ) + ), + O.map(({ env, index }) => ({ + ...env, + previousValue: current[index].currentValue, + })) + ) + ) + ) + +// Helper to resolve currentValue & initialValue for (secret/non-secret) env vars +const resolveEnvVars = ( + envID: string, + vars: Environment["variables"] +): Environment["variables"] => + vars.map((v, index) => { + const secretMeta = v.secret + ? getSecretEnvironmentVariableValue(envID, index) + : null + return { + ...v, + currentValue: + (v.secret + ? secretMeta?.value + : getEnvironmentVariableValue(envID, index)) ?? "", + // fallback to var initialValue if secretMeta is not found + initialValue: + (v.secret ? secretMeta?.initialValue : "") ?? v.initialValue, + } + }) + +function translateToSandboxTestResults( + testDesc: SandboxTestResult, + initialGlobalEnvs: Environment["variables"], + initialSelectedEnvs: Environment["variables"] +): HoppTestResult { + const translateChildTests = (child: TestDescriptor): HoppTestData => { + return { + description: child.descriptor, + // Deep clone expectResults to prevent reactive updates during async test execution + // Without this, Vue would show intermediate states as the test runner mutates the arrays + expectResults: [...child.expectResults], + tests: child.children.map(translateChildTests), + } + } + + return { + description: "", + // Deep clone expectResults to prevent reactive updates during async test execution + expectResults: [...testDesc.tests.expectResults], + tests: testDesc.tests.children.map(translateChildTests), + scriptError: false, + envDiff: { + global: { + additions: getAddedEnvVariables( + initialGlobalEnvs, + testDesc.envs.global + ), + deletions: getRemovedEnvVariables( + initialGlobalEnvs, + testDesc.envs.global + ), + updations: getUpdatedEnvVariables( + initialGlobalEnvs, + testDesc.envs.global + ), + }, + selected: { + additions: getAddedEnvVariables( + initialSelectedEnvs, + testDesc.envs.selected + ), + deletions: getRemovedEnvVariables( + initialSelectedEnvs, + testDesc.envs.selected + ), + updations: getUpdatedEnvVariables( + initialSelectedEnvs, + testDesc.envs.selected + ), + }, + }, + consoleEntries: testDesc.consoleEntries, + } +} diff --git a/packages/hoppscotch-common/src/helpers/__tests__/RequestRunner-cookie-jar-laziness.spec.ts b/packages/hoppscotch-common/src/helpers/__tests__/RequestRunner-cookie-jar-laziness.spec.ts new file mode 100644 index 0000000..441b67f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/__tests__/RequestRunner-cookie-jar-laziness.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" + +// `getService(CookieJarService)` at module top level would construct +// the service during ESM evaluation. `onServiceInit` then calls +// `Store.init()`, which reads `window.__KERNEL__.store` and throws +// because `createHoppApp` has not yet called `initKernel(...)`. The +// catch in `onServiceInit` degrades the service to in-memory mode +// but logs `[CookieJar] Kernel store unavailable` on every browser +// load. The lazy form `() => getService(...)` defers construction to +// first call, by which point `initKernel(...)` has run and +// `Store.init()` succeeds. +// +// This spec pins the source form. A re-import test would re-trigger +// RequestRunner's circular `i18n → newstore/history → RequestRunner` +// cycle and fail under vitest's module evaluation. +// +// `fileURLToPath(import.meta.url)` keeps the path resolution +// ESM-portable. A bare `__dirname` works under vitest today but +// depends on a CJS global the package's `module: "ESNext"` config +// does not guarantee. `new URL("../X", import.meta.url)` would hit +// Vite's asset-URL transform and resolve against the dev server +// origin (`http://localhost:3000/...`). +const here = dirname(fileURLToPath(import.meta.url)) + +describe("RequestRunner cookie-jar binding", () => { + test("binds CookieJarService lazily on first call", () => { + const source = readFileSync(resolve(here, "../RequestRunner.ts"), "utf8") + // The optional `\s*:\s*\S+` group catches a typed eager binding + // like `const cookieJarService: CookieJarService = getService(...)` + // which a plain `^const\s+\w+\s*=` would skip. + expect(source).not.toMatch( + /^const\s+\w+(?:\s*:\s*\S+)?\s*=\s*getService\s*\(\s*CookieJarService\s*\)/m + ) + expect(source).toMatch(/=>\s*getService\s*\(\s*CookieJarService\s*\)/) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/__tests__/dragDropValidation.spec.ts b/packages/hoppscotch-common/src/helpers/__tests__/dragDropValidation.spec.ts new file mode 100644 index 0000000..272ad2a --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/__tests__/dragDropValidation.spec.ts @@ -0,0 +1,244 @@ +import { describe, test, expect } from "vitest" +import { isDragDropAllowed, DragDropEvent } from "../dragDropValidation" + +describe("isDragDropAllowed", () => { + describe("Valid drag operations", () => { + test("allows dropping at the beginning of a list", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: 0, + }, + } + const totalItems = 5 + + expect(isDragDropAllowed(dragEvent, totalItems)).toBe(true) + }) + + test("allows dropping in the middle of a list", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: 2, + }, + } + const totalItems = 5 + + expect(isDragDropAllowed(dragEvent, totalItems)).toBe(true) + }) + + test("allows dropping at second-to-last position", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: 3, + }, + } + const totalItems = 5 + + expect(isDragDropAllowed(dragEvent, totalItems)).toBe(true) + }) + + test("allows dropping in a list with only one empty item", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: 0, + }, + } + const totalItems = 2 + + expect(isDragDropAllowed(dragEvent, totalItems)).toBe(true) + }) + }) + + describe("Invalid drag operations", () => { + test("prevents dropping at the last position (reserved for empty entry)", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: 4, + }, + } + const totalItems = 5 + + expect(isDragDropAllowed(dragEvent, totalItems)).toBe(false) + }) + + test("prevents dropping beyond the last position", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: 10, + }, + } + const totalItems = 5 + + expect(isDragDropAllowed(dragEvent, totalItems)).toBe(false) + }) + + test("prevents dropping at the last position in a minimal list", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: 1, + }, + } + const totalItems = 2 + + expect(isDragDropAllowed(dragEvent, totalItems)).toBe(false) + }) + }) + + describe("Edge cases and invalid input", () => { + test("returns false when dragEvent is null", () => { + expect(isDragDropAllowed(null as any, 5)).toBe(false) + }) + + test("returns false when dragEvent is undefined", () => { + expect(isDragDropAllowed(undefined as any, 5)).toBe(false) + }) + + test("returns false when draggedContext is missing", () => { + const dragEvent = {} as DragDropEvent + expect(isDragDropAllowed(dragEvent, 5)).toBe(false) + }) + + test("returns false when futureIndex is missing", () => { + const dragEvent: DragDropEvent = { + draggedContext: {}, + } + expect(isDragDropAllowed(dragEvent, 5)).toBe(false) + }) + + test("returns false when futureIndex is null", () => { + const dragEvent = { + draggedContext: { + futureIndex: null as any, + }, + } as DragDropEvent + expect(isDragDropAllowed(dragEvent, 5)).toBe(false) + }) + + test("returns false when futureIndex is not a number", () => { + const dragEvent = { + draggedContext: { + futureIndex: "invalid" as any, + }, + } as DragDropEvent + expect(isDragDropAllowed(dragEvent, 5)).toBe(false) + }) + + test("returns false when futureIndex is NaN", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: NaN, + }, + } + expect(isDragDropAllowed(dragEvent, 5)).toBe(false) + }) + + test("returns false when totalItems is null", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: 2, + }, + } + expect(isDragDropAllowed(dragEvent, null as any)).toBe(false) + }) + + test("returns false when totalItems is not a number", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: 2, + }, + } + expect(isDragDropAllowed(dragEvent, "invalid" as any)).toBe(false) + }) + + test("returns false when totalItems is NaN", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: 2, + }, + } + expect(isDragDropAllowed(dragEvent, NaN)).toBe(false) + }) + + test("handles negative futureIndex correctly", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: -1, + }, + } + const totalItems = 5 + + expect(isDragDropAllowed(dragEvent, totalItems)).toBe(true) + }) + + test("handles zero totalItems correctly", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: 0, + }, + } + const totalItems = 0 + + expect(isDragDropAllowed(dragEvent, totalItems)).toBe(false) + }) + + test("handles minimal valid case (totalItems = 1)", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: 0, + }, + } + const totalItems = 1 + + expect(isDragDropAllowed(dragEvent, totalItems)).toBe(false) + }) + }) + + describe("Real-world scenarios", () => { + test("simulates dragging a header to position 0 in a list of 3 items", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: 0, + }, + } + const totalItems = 3 // 2 real headers + 1 empty + + expect(isDragDropAllowed(dragEvent, totalItems)).toBe(true) + }) + + test("simulates dragging a header to position 1 in a list of 3 items", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: 1, + }, + } + const totalItems = 3 // 2 real headers + 1 empty + + expect(isDragDropAllowed(dragEvent, totalItems)).toBe(true) + }) + + test("prevents dragging a header to the empty position (position 2) in a list of 3 items", () => { + const dragEvent: DragDropEvent = { + draggedContext: { + futureIndex: 2, + }, + } + const totalItems = 3 // 2 real headers + 1 empty + + expect(isDragDropAllowed(dragEvent, totalItems)).toBe(false) + }) + + test("simulates a large list with many items", () => { + const totalItems = 100 // 99 real items + 1 empty + + // Should allow dropping anywhere except the last position + const dragEvent0: DragDropEvent = { draggedContext: { futureIndex: 0 } } + const dragEvent50: DragDropEvent = { draggedContext: { futureIndex: 50 } } + const dragEvent98: DragDropEvent = { draggedContext: { futureIndex: 98 } } + const dragEvent99: DragDropEvent = { draggedContext: { futureIndex: 99 } } + + expect(isDragDropAllowed(dragEvent0, totalItems)).toBe(true) + expect(isDragDropAllowed(dragEvent50, totalItems)).toBe(true) + expect(isDragDropAllowed(dragEvent98, totalItems)).toBe(true) + expect(isDragDropAllowed(dragEvent99, totalItems)).toBe(false) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/__tests__/editorutils.spec.js b/packages/hoppscotch-common/src/helpers/__tests__/editorutils.spec.js new file mode 100644 index 0000000..f23636f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/__tests__/editorutils.spec.js @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest" +import { getEditorLangForMimeType } from "../editorutils" + +describe("getEditorLangForMimeType", () => { + test("returns 'json' for valid JSON mimes", () => { + expect(getEditorLangForMimeType("application/json")).toMatch("json") + expect(getEditorLangForMimeType("application/hal+json")).toMatch("json") + expect(getEditorLangForMimeType("application/vnd.api+json")).toMatch("json") + }) + + test("returns 'xml' for valid XML mimes", () => { + expect(getEditorLangForMimeType("application/xml")).toMatch("xml") + expect(getEditorLangForMimeType("text/xml")).toMatch("xml") + }) + + test("returns 'html' for valid HTML mimes", () => { + expect(getEditorLangForMimeType("text/html")).toMatch("html") + }) + + test("returns text/plain for plain text mime", () => { + expect(getEditorLangForMimeType("text/plain")).toBe("text/plain") + }) + + test("returns text/plain for unimplemented mimes", () => { + expect(getEditorLangForMimeType("image/gif")).toBe("text/plain") + }) + + test("returns text/plain for null/undefined mimes", () => { + expect(getEditorLangForMimeType(null)).toBe("text/plain") + expect(getEditorLangForMimeType(undefined)).toBe("text/plain") + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/__tests__/experimental-sandbox-integration.spec.ts b/packages/hoppscotch-common/src/helpers/__tests__/experimental-sandbox-integration.spec.ts new file mode 100644 index 0000000..052d712 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/__tests__/experimental-sandbox-integration.spec.ts @@ -0,0 +1,1129 @@ +import { HoppRESTRequest, getDefaultRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" +import { applyScriptRequestUpdates } from "../experimental-sandbox-integration" + +const DEFAULT_REQUEST = getDefaultRESTRequest() + +describe("Experimental Sandbox Integration", () => { + describe("applyScriptRequestUpdates", () => { + describe("Core Functionality", () => { + test("should preserve file uploads when scripts modify other properties", () => { + const file = new File(["test content"], "test.txt", { + type: "text/plain", + }) + + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Upload Request", + endpoint: "https://api.example.com/upload", + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "file", + value: [file], + isFile: true, + active: true, + }, + { + key: "name", + value: "John", + isFile: false, + active: true, + }, + ], + }, + } + + const updatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + endpoint: "https://api.example.com/upload?timestamp=123", + headers: [ + { key: "X-Custom", value: "header", active: true, description: "" }, + ], + body: { + contentType: "multipart/form-data", + body: [ + { + key: "file", + value: "", + isFile: false, + active: true, + }, + { + key: "name", + value: "John", + isFile: false, + active: true, + }, + ], + }, + } + + const mergedRequest = applyScriptRequestUpdates( + originalRequest, + updatedRequest + ) + + expect(mergedRequest.endpoint).toBe( + "https://api.example.com/upload?timestamp=123" + ) + expect(mergedRequest.headers).toHaveLength(1) + expect(mergedRequest.headers[0].key).toBe("X-Custom") + + expect(mergedRequest.body.contentType).toBe("multipart/form-data") + if (mergedRequest.body.contentType === "multipart/form-data") { + const fileField = mergedRequest.body.body[0] + expect(fileField.isFile).toBe(true) + if (fileField.isFile && Array.isArray(fileField.value)) { + expect(fileField.value).toHaveLength(1) + expect(fileField.value[0]).toBeInstanceOf(File) + expect((fileField.value[0] as File).name).toBe("test.txt") + } + } + }) + + test("should preserve multiple files in a single field", () => { + const file1 = new File(["content1"], "file1.txt", { + type: "text/plain", + }) + const file2 = new File(["content2"], "file2.txt", { + type: "text/plain", + }) + + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Multi Upload", + endpoint: "https://api.example.com/upload", + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "files", + value: [file1, file2], + isFile: true, + active: true, + }, + ], + }, + } + + const updatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + method: "PUT", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "files", + value: "", + isFile: false, + active: true, + }, + ], + }, + } + + const mergedRequest = applyScriptRequestUpdates( + originalRequest, + updatedRequest + ) + + expect(mergedRequest.method).toBe("PUT") + + if (mergedRequest.body.contentType === "multipart/form-data") { + const filesField = mergedRequest.body.body[0] + if (filesField.isFile && Array.isArray(filesField.value)) { + expect(filesField.value).toHaveLength(2) + expect((filesField.value[0] as File).name).toBe("file1.txt") + expect((filesField.value[1] as File).name).toBe("file2.txt") + } + } + }) + + test("should preserve files while allowing text fields to be updated", () => { + const file = new File(["content"], "document.pdf", { + type: "application/pdf", + }) + + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Mixed Form", + endpoint: "https://api.example.com/submit", + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "document", + value: [file], + isFile: true, + active: true, + }, + { + key: "title", + value: "Original Title", + isFile: false, + active: true, + }, + ], + }, + } + + const updatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + body: { + contentType: "multipart/form-data", + body: [ + { + key: "document", + value: "", + isFile: false, + active: true, + }, + { + key: "title", + value: "Updated by Script", + isFile: false, + active: true, + }, + ], + }, + } + + const mergedRequest = applyScriptRequestUpdates( + originalRequest, + updatedRequest + ) + + if (mergedRequest.body.contentType === "multipart/form-data") { + const docField = mergedRequest.body.body[0] + if (docField.isFile && Array.isArray(docField.value)) { + expect(docField.value[0]).toBeInstanceOf(File) + expect((docField.value[0] as File).name).toBe("document.pdf") + } + + const titleField = mergedRequest.body.body[1] + if (!titleField.isFile) { + expect(titleField.value).toBe("Updated by Script") + } + } + }) + + test("should preserve file upload in application/octet-stream body", () => { + const file = new File(["binary content"], "data.bin", { + type: "application/octet-stream", + }) + + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Binary Upload", + endpoint: "https://api.example.com/upload", + method: "POST", + body: { + contentType: "application/octet-stream", + body: file, + }, + } + + const updatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + headers: [ + { key: "X-Auth", value: "token", active: true, description: "" }, + ], + body: { + contentType: "application/octet-stream", + body: null, + }, + } + + const mergedRequest = applyScriptRequestUpdates( + originalRequest, + updatedRequest + ) + + expect(mergedRequest.headers).toHaveLength(1) + expect(mergedRequest.body.contentType).toBe("application/octet-stream") + if (mergedRequest.body.contentType === "application/octet-stream") { + expect(mergedRequest.body.body).toBeInstanceOf(File) + expect(mergedRequest.body.body).toBe(file) + } + }) + + test("should preserve file uploads when script only modifies URL", () => { + const file = new File(["content"], "test.txt", { type: "text/plain" }) + + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Upload", + endpoint: "https://api.example.com/upload", + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "file", + value: [file], + isFile: true, + active: true, + }, + ], + }, + } + + const updatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + endpoint: "https://api.example.com/modified", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "file", + value: "", + isFile: false, + active: true, + }, + ], + }, + } + + const mergedRequest = applyScriptRequestUpdates( + originalRequest, + updatedRequest + ) + + expect(mergedRequest.endpoint).toBe("https://api.example.com/modified") + + if (mergedRequest.body.contentType === "multipart/form-data") { + const fileField = mergedRequest.body.body[0] + expect(fileField.isFile).toBe(true) + if (fileField.isFile && Array.isArray(fileField.value)) { + expect(fileField.value[0]).toBeInstanceOf(File) + } + } + }) + + test("should handle non-file form data correctly", () => { + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Form Request", + endpoint: "https://api.example.com/form", + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "name", + value: "John", + isFile: false, + active: true, + }, + { + key: "email", + value: "john@example.com", + isFile: false, + active: true, + }, + ], + }, + } + + const updatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + body: { + contentType: "multipart/form-data", + body: [ + { + key: "name", + value: "Jane", + isFile: false, + active: true, + }, + { + key: "email", + value: "john@example.com", + isFile: false, + active: true, + }, + ], + }, + } + + const mergedRequest = applyScriptRequestUpdates( + originalRequest, + updatedRequest + ) + + if (mergedRequest.body.contentType === "multipart/form-data") { + const nameField = mergedRequest.body.body[0] + if (!nameField.isFile) { + expect(nameField.value).toBe("Jane") + } + } + }) + }) + + describe("Edge Cases", () => { + test("should handle duplicate keys with mixed file and text fields", () => { + const file1 = new File(["content1"], "col.json", { + type: "application/json", + }) + const file2 = new File(["content2"], "test-postman-compat.json", { + type: "application/json", + }) + + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Upload", + endpoint: "https://api.example.com/upload", + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "file", + value: [file1], + isFile: true, + active: true, + }, + { + key: "file", + value: "test", + isFile: false, + active: true, + }, + { + key: "file", + value: [file2], + isFile: true, + active: true, + }, + { + key: "e", + value: "test", + isFile: false, + active: true, + }, + ], + }, + } + + const updatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + body: { + contentType: "multipart/form-data", + body: [ + { + key: "file", + value: "", + isFile: false, + active: true, + }, + { + key: "file", + value: "test", + isFile: false, + active: true, + }, + { + key: "file", + value: "", + isFile: false, + active: true, + }, + { + key: "text", + value: "test", + isFile: false, + active: true, + }, + ], + }, + } + + const mergedRequest = applyScriptRequestUpdates( + originalRequest, + updatedRequest + ) + + if (mergedRequest.body.contentType === "multipart/form-data") { + expect(mergedRequest.body.body).toHaveLength(4) + + const field0 = mergedRequest.body.body[0] + expect(field0.key).toBe("file") + expect(field0.isFile).toBe(true) + if (field0.isFile && Array.isArray(field0.value)) { + expect((field0.value[0] as File).name).toBe("col.json") + } + + const field1 = mergedRequest.body.body[1] + expect(field1.key).toBe("file") + expect(field1.isFile).toBe(false) + if (!field1.isFile) { + expect(field1.value).toBe("test") + } + + const field2 = mergedRequest.body.body[2] + expect(field2.key).toBe("file") + expect(field2.isFile).toBe(true) + if (field2.isFile && Array.isArray(field2.value)) { + expect((field2.value[0] as File).name).toBe( + "test-postman-compat.json" + ) + } + + const field3 = mergedRequest.body.body[3] + expect(field3.key).toBe("text") + expect(field3.isFile).toBe(false) + if (!field3.isFile) { + expect(field3.value).toBe("test") + } + } + }) + + test("should handle duplicate file keys without mixing them up", () => { + const file1 = new File(["content1"], "file1.txt", { + type: "text/plain", + }) + const file2 = new File(["content2"], "file2.txt", { + type: "text/plain", + }) + const file3 = new File(["content3"], "file3.txt", { + type: "text/plain", + }) + + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Upload", + endpoint: "https://api.example.com/upload", + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "file", + value: [file1], + isFile: true, + active: true, + }, + { + key: "file", + value: [file2], + isFile: true, + active: true, + }, + { + key: "file", + value: [file3], + isFile: true, + active: true, + }, + ], + }, + } + + const updatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + body: { + contentType: "multipart/form-data", + body: [ + { + key: "file", + value: "", + isFile: false, + active: true, + }, + { + key: "file", + value: "", + isFile: false, + active: true, + }, + { + key: "file", + value: "", + isFile: false, + active: true, + }, + ], + }, + } + + const mergedRequest = applyScriptRequestUpdates( + originalRequest, + updatedRequest + ) + + if (mergedRequest.body.contentType === "multipart/form-data") { + expect(mergedRequest.body.body).toHaveLength(3) + + const field0 = mergedRequest.body.body[0] + expect(field0.isFile).toBe(true) + if (field0.isFile && Array.isArray(field0.value)) { + expect((field0.value[0] as File).name).toBe("file1.txt") + } + + const field1 = mergedRequest.body.body[1] + expect(field1.isFile).toBe(true) + if (field1.isFile && Array.isArray(field1.value)) { + expect((field1.value[0] as File).name).toBe("file2.txt") + } + + const field2 = mergedRequest.body.body[2] + expect(field2.isFile).toBe(true) + if (field2.isFile && Array.isArray(field2.value)) { + expect((field2.value[0] as File).name).toBe("file3.txt") + } + } + }) + + test("should handle scripts adding new fields", () => { + const file = new File(["content"], "doc.pdf", { + type: "application/pdf", + }) + + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Upload", + endpoint: "https://api.example.com/upload", + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "document", + value: [file], + isFile: true, + active: true, + }, + ], + }, + } + + const updatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + body: { + contentType: "multipart/form-data", + body: [ + { + key: "document", + value: "", + isFile: false, + active: true, + }, + { + key: "metadata", + value: '{"added": "by script"}', + isFile: false, + active: true, + }, + ], + }, + } + + const mergedRequest = applyScriptRequestUpdates( + originalRequest, + updatedRequest + ) + + if (mergedRequest.body.contentType === "multipart/form-data") { + expect(mergedRequest.body.body).toHaveLength(2) + + const docField = mergedRequest.body.body[0] + if (docField.isFile && Array.isArray(docField.value)) { + expect(docField.value[0]).toBeInstanceOf(File) + } + + const metadataField = mergedRequest.body.body[1] + if (!metadataField.isFile) { + expect(metadataField.key).toBe("metadata") + } + } + }) + + test("should handle scripts removing fields", () => { + const file = new File(["content"], "doc.pdf", { + type: "application/pdf", + }) + + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Upload", + endpoint: "https://api.example.com/upload", + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "document", + value: [file], + isFile: true, + active: true, + }, + { + key: "extra", + value: "data", + isFile: false, + active: true, + }, + ], + }, + } + + const updatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + body: { + contentType: "multipart/form-data", + body: [ + { + key: "document", + value: "", + isFile: false, + active: true, + }, + ], + }, + } + + const mergedRequest = applyScriptRequestUpdates( + originalRequest, + updatedRequest + ) + + if (mergedRequest.body.contentType === "multipart/form-data") { + expect(mergedRequest.body.body).toHaveLength(1) + + const docField = mergedRequest.body.body[0] + if (docField.isFile && Array.isArray(docField.value)) { + expect(docField.value[0]).toBeInstanceOf(File) + } + } + }) + + test("should preserve inactive file fields", () => { + const file = new File(["content"], "doc.pdf", { + type: "application/pdf", + }) + + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Upload", + endpoint: "https://api.example.com/upload", + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "document", + value: [file], + isFile: true, + active: false, + }, + ], + }, + } + + const updatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + body: { + contentType: "multipart/form-data", + body: [ + { + key: "document", + value: "", + isFile: false, + active: false, + }, + ], + }, + } + + const mergedRequest = applyScriptRequestUpdates( + originalRequest, + updatedRequest + ) + + if (mergedRequest.body.contentType === "multipart/form-data") { + const docField = mergedRequest.body.body[0] + expect(docField.active).toBe(false) + if (docField.isFile && Array.isArray(docField.value)) { + expect(docField.value[0]).toBeInstanceOf(File) + } + } + }) + + test("should preserve field-level contentType metadata", () => { + const file = new File(["content"], "image.jpg", { type: "image/jpeg" }) + + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Upload", + endpoint: "https://api.example.com/upload", + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "image", + value: [file], + isFile: true, + active: true, + contentType: "image/jpeg", + }, + ], + }, + } + + const updatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + body: { + contentType: "multipart/form-data", + body: [ + { + key: "image", + value: "", + isFile: false, + active: true, + }, + ], + }, + } + + const mergedRequest = applyScriptRequestUpdates( + originalRequest, + updatedRequest + ) + + if (mergedRequest.body.contentType === "multipart/form-data") { + const imageField = mergedRequest.body.body[0] + if (imageField.isFile && Array.isArray(imageField.value)) { + expect(imageField.value[0]).toBeInstanceOf(File) + } + } + }) + + test("should handle scripts reordering fields using key-based matching", () => { + const file = new File(["content"], "doc.pdf", { + type: "application/pdf", + }) + + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Upload", + endpoint: "https://api.example.com/upload", + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "document", + value: [file], + isFile: true, + active: true, + }, + { + key: "name", + value: "John", + isFile: false, + active: true, + }, + ], + }, + } + + // Script reorders fields: name moves before document + const updatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + body: { + contentType: "multipart/form-data", + body: [ + { + key: "name", + value: "Jane", + isFile: false, + active: true, + }, + { + key: "document", + value: "", + isFile: false, + active: true, + }, + ], + }, + } + + const mergedRequest = applyScriptRequestUpdates( + originalRequest, + updatedRequest + ) + + if (mergedRequest.body.contentType === "multipart/form-data") { + expect(mergedRequest.body.body).toHaveLength(2) + + const nameField = mergedRequest.body.body[0] + expect(nameField.key).toBe("name") + expect(nameField.isFile).toBe(false) + if (!nameField.isFile) { + expect(nameField.value).toBe("Jane") + } + + const docField = mergedRequest.body.body[1] + expect(docField.key).toBe("document") + expect(docField.isFile).toBe(true) + if (docField.isFile && Array.isArray(docField.value)) { + expect(docField.value[0]).toBeInstanceOf(File) + expect((docField.value[0] as File).name).toBe("doc.pdf") + } + } + }) + + test("should handle scripts adding fields at beginning without corrupting file mapping", () => { + const file = new File(["content"], "report.pdf", { + type: "application/pdf", + }) + + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Upload", + endpoint: "https://api.example.com/upload", + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "report", + value: [file], + isFile: true, + active: true, + }, + { + key: "description", + value: "Q4 Report", + isFile: false, + active: true, + }, + ], + }, + } + + // Script adds "timestamp" field at beginning, shifting all indices + const updatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + body: { + contentType: "multipart/form-data", + body: [ + { + key: "timestamp", + value: "2024-01-01T00:00:00Z", + isFile: false, + active: true, + }, + { + key: "report", + value: "", + isFile: false, + active: true, + }, + { + key: "description", + value: "Q4 Report", + isFile: false, + active: true, + }, + ], + }, + } + + const mergedRequest = applyScriptRequestUpdates( + originalRequest, + updatedRequest + ) + + if (mergedRequest.body.contentType === "multipart/form-data") { + expect(mergedRequest.body.body).toHaveLength(3) + + const timestampField = mergedRequest.body.body[0] + expect(timestampField.key).toBe("timestamp") + expect(timestampField.isFile).toBe(false) + + const reportField = mergedRequest.body.body[1] + expect(reportField.key).toBe("report") + expect(reportField.isFile).toBe(true) + if (reportField.isFile && Array.isArray(reportField.value)) { + expect(reportField.value[0]).toBeInstanceOf(File) + expect((reportField.value[0] as File).name).toBe("report.pdf") + } + + const descField = mergedRequest.body.body[2] + expect(descField.key).toBe("description") + } + }) + + test("should not preserve files when script changes field key (intentional rename)", () => { + const file = new File(["content"], "data.csv", { type: "text/csv" }) + + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Upload", + endpoint: "https://api.example.com/upload", + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "old_key", + value: [file], + isFile: true, + active: true, + }, + ], + }, + } + + // Script renames the key + const updatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + body: { + contentType: "multipart/form-data", + body: [ + { + key: "new_key", + value: "", + isFile: false, + active: true, + }, + ], + }, + } + + const mergedRequest = applyScriptRequestUpdates( + originalRequest, + updatedRequest + ) + + if (mergedRequest.body.contentType === "multipart/form-data") { + expect(mergedRequest.body.body).toHaveLength(1) + + const field = mergedRequest.body.body[0] + expect(field.key).toBe("new_key") + expect(field.isFile).toBe(false) + if (!field.isFile) { + expect(field.value).toBe("") + } + } + }) + + test("should preserve generic Blob objects (technical support)", () => { + const blob = new Blob(["blob content"], { type: "text/plain" }) + + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Upload", + endpoint: "https://api.example.com/upload", + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "data", + value: [blob], + isFile: true, + active: true, + }, + ], + }, + } + + const updatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + body: { + contentType: "multipart/form-data", + body: [ + { + key: "data", + value: "", + isFile: false, + active: true, + }, + ], + }, + } + + const mergedRequest = applyScriptRequestUpdates( + originalRequest, + updatedRequest + ) + + if (mergedRequest.body.contentType === "multipart/form-data") { + const dataField = mergedRequest.body.body[0] + if (dataField.isFile && Array.isArray(dataField.value)) { + expect(dataField.value[0]).toBeInstanceOf(Blob) + } + } + }) + }) + + describe("Regression", () => { + test("should preserve file uploads through JSON serialization (regression test for #5443)", () => { + const file = new File(["content"], "test.txt", { type: "text/plain" }) + + const originalRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + name: "Upload", + endpoint: "https://api.example.com/upload", + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "file", + value: [file], + isFile: true, + active: true, + }, + ], + }, + } + + const brokenUpdatedRequest: HoppRESTRequest = { + ...DEFAULT_REQUEST, + body: { + contentType: "multipart/form-data", + body: [ + { + key: "file", + value: "", + isFile: false, + active: true, + }, + ], + }, + } + + const brokenMerge = { ...originalRequest, ...brokenUpdatedRequest } + + if (brokenMerge.body.contentType === "multipart/form-data") { + const brokenFileField = brokenMerge.body.body[0] + expect(brokenFileField.value).toEqual("") + expect(brokenFileField.value[0]).not.toBeInstanceOf(File) + } + + const fixedMerge = applyScriptRequestUpdates( + originalRequest, + brokenUpdatedRequest + ) + + if (fixedMerge.body.contentType === "multipart/form-data") { + const fixedFileField = fixedMerge.body.body[0] + if (fixedFileField.isFile && Array.isArray(fixedFileField.value)) { + expect(fixedFileField.value[0]).toBeInstanceOf(File) + expect((fixedFileField.value[0] as File).name).toBe("test.txt") + } + } + }) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/__tests__/globalEnvShape.spec.ts b/packages/hoppscotch-common/src/helpers/__tests__/globalEnvShape.spec.ts new file mode 100644 index 0000000..450676c --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/__tests__/globalEnvShape.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest" +import { coerceGlobalEnvironment } from "../globalEnvShape" + +describe("coerceGlobalEnvironment", () => { + it("passes through a valid v2 wrapper unchanged", () => { + const wrapper = { + v: 2 as const, + variables: [ + { key: "k", initialValue: "i", currentValue: "c", secret: false }, + ], + } + + expect(coerceGlobalEnvironment(wrapper)).toBe(wrapper) + }) + + it("wraps a bare variables array (legacy / older-backend shape)", () => { + const bareArray = [ + { key: "k", initialValue: "i", currentValue: "c", secret: false }, + ] + + expect(coerceGlobalEnvironment(bareArray)).toEqual({ + v: 2, + variables: bareArray, + }) + }) + + it("returns an empty wrapper for null", () => { + expect(coerceGlobalEnvironment(null)).toEqual({ v: 2, variables: [] }) + }) + + it("returns an empty wrapper for undefined", () => { + expect(coerceGlobalEnvironment(undefined)).toEqual({ v: 2, variables: [] }) + }) + + it("returns an empty wrapper for an object with non-array variables", () => { + expect( + coerceGlobalEnvironment({ v: 2, variables: "not-an-array" }) + ).toEqual({ v: 2, variables: [] }) + }) + + it("returns an empty wrapper for an object missing variables", () => { + expect(coerceGlobalEnvironment({ v: 2 })).toEqual({ v: 2, variables: [] }) + }) + + it("rebuilds with v:2 when an object has a variables array but missing v", () => { + const variables = [ + { key: "k", initialValue: "i", currentValue: "c", secret: false }, + ] + expect(coerceGlobalEnvironment({ variables })).toEqual({ + v: 2, + variables, + }) + }) + + it("rebuilds with v:2 when an object has a variables array but a non-2 v", () => { + const variables = [ + { key: "k", initialValue: "i", currentValue: "c", secret: false }, + ] + expect(coerceGlobalEnvironment({ v: 1, variables })).toEqual({ + v: 2, + variables, + }) + expect(coerceGlobalEnvironment({ v: "2", variables })).toEqual({ + v: 2, + variables, + }) + }) + + it("returns an empty wrapper for a primitive", () => { + expect(coerceGlobalEnvironment("string")).toEqual({ + v: 2, + variables: [], + }) + expect(coerceGlobalEnvironment(42)).toEqual({ v: 2, variables: [] }) + }) + + it("preserves an empty variables array (does not collapse to a fresh wrapper)", () => { + const wrapper = { v: 2 as const, variables: [] } + // Identity check: when the input is already a valid wrapper we want + // to pass it through, not re-allocate, so downstream `===` checks / + // distinctUntilChanged comparisons stay stable. + expect(coerceGlobalEnvironment(wrapper)).toBe(wrapper) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/__tests__/hopp-fetch.spec.ts b/packages/hoppscotch-common/src/helpers/__tests__/hopp-fetch.spec.ts new file mode 100644 index 0000000..49d1642 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/__tests__/hopp-fetch.spec.ts @@ -0,0 +1,799 @@ +import { describe, expect, it, vi, beforeEach } from "vitest" +import { createHoppFetchHook } from "../hopp-fetch" +import type { KernelInterceptorService } from "~/services/kernel-interceptor.service" +import * as E from "fp-ts/Either" + +// Mock KernelInterceptorService +const mockKernelInterceptor: KernelInterceptorService = { + execute: vi.fn(), +} as any + +describe("Common hopp-fetch", () => { + beforeEach(() => { + vi.clearAllMocks() + + // Default successful response + ;(mockKernelInterceptor.execute as any).mockReturnValue({ + response: Promise.resolve( + E.right({ + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + body: { + body: new ArrayBuffer(0), + }, + }) + ), + }) + }) + + describe("Request object property extraction", () => { + it("should extract method from Request object", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const request = new Request("https://api.example.com/data", { + method: "POST", + }) + + await hoppFetch(request) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + }) + ) + }) + + it("should extract headers from Request object", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const request = new Request("https://api.example.com/data", { + headers: { + "X-Custom-Header": "test-value", + Authorization: "Bearer token123", + }, + }) + + await hoppFetch(request) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + "x-custom-header": "test-value", + authorization: "Bearer token123", + }, + }) + ) + }) + + it("should extract body from Request object", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const request = new Request("https://api.example.com/data", { + method: "POST", + body: JSON.stringify({ key: "value" }), + }) + + await hoppFetch(request) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + kind: "text", + content: JSON.stringify({ key: "value" }), + }), + }) + ) + }) + + it("should preserve binary data from Request object with binary content-type", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + // Create binary data (e.g., image bytes) + const binaryData = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]) + + const request = new Request("https://api.example.com/upload", { + method: "POST", + headers: { "Content-Type": "image/png" }, + body: binaryData, + }) + + await hoppFetch(request) + + const call = (mockKernelInterceptor.execute as any).mock.calls[0][0] + expect(call.content.kind).toBe("binary") + expect(call.content.content).toBeInstanceOf(Uint8Array) + // Verify the binary data is preserved (not corrupted by text conversion) + expect(Array.from(call.content.content as Uint8Array)).toEqual([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, + ]) + }) + + it("should convert text content from Request object with text content-type", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const textData = new TextEncoder().encode("Hello World") + + const request = new Request("https://api.example.com/data", { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: textData, + }) + + await hoppFetch(request) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + kind: "text", + content: "Hello World", + }), + }) + ) + }) + + it("should handle JSON content from Request object with json content-type", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const jsonData = new TextEncoder().encode('{"key":"value"}') + + const request = new Request("https://api.example.com/data", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: jsonData, + }) + + await hoppFetch(request) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + kind: "text", + content: '{"key":"value"}', + }), + }) + ) + }) + + it("should prefer init options over Request properties (method)", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const request = new Request("https://api.example.com/data", { + method: "POST", + }) + + // Init overrides Request method + await hoppFetch(request, { method: "PUT" }) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + method: "PUT", + }) + ) + }) + + it("should prefer init headers over Request headers", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const request = new Request("https://api.example.com/data", { + headers: { "X-Custom": "from-request" }, + }) + + // Init overrides Request headers + await hoppFetch(request, { + headers: { "X-Custom": "from-init" }, + }) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + "x-custom": "from-init", + }, + }) + ) + }) + + it("should merge Request headers with init headers", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const request = new Request("https://api.example.com/data", { + headers: { "X-Request-Header": "value1" }, + }) + + await hoppFetch(request, { + headers: { "X-Init-Header": "value2" }, + }) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + "x-request-header": "value1", + "x-init-header": "value2", + }, + }) + ) + }) + + it("should extract all properties from Request object", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const request = new Request("https://api.example.com/data", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-API-Key": "secret", + }, + body: JSON.stringify({ update: true }), + }) + + await hoppFetch(request) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.example.com/data", + method: "PATCH", + headers: { + "content-type": "application/json", + "x-api-key": "secret", + }, + content: expect.objectContaining({ + kind: "text", + content: JSON.stringify({ update: true }), + }), + }) + ) + }) + + it("should prefer init body over Request body", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const request = new Request("https://api.example.com/data", { + method: "POST", + body: JSON.stringify({ from: "request" }), + }) + + await hoppFetch(request, { + body: JSON.stringify({ from: "init" }), + }) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: JSON.stringify({ from: "init" }), + }), + }) + ) + }) + }) + + describe("Standard fetch patterns", () => { + it("should handle string URLs", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + await hoppFetch("https://api.example.com/data") + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.example.com/data", + method: "GET", + }) + ) + }) + + it("should handle URL objects", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const url = new URL("https://api.example.com/data") + await hoppFetch(url) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.example.com/data", + }) + ) + }) + + it("should handle init options with string URL", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + await hoppFetch("https://api.example.com/data", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ test: true }), + }) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.example.com/data", + method: "POST", + headers: { + "content-type": "application/json", + }, + content: expect.objectContaining({ + kind: "text", + content: JSON.stringify({ test: true }), + }), + }) + ) + }) + }) + + describe("Edge cases", () => { + it("should default to GET when no method specified", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + await hoppFetch("https://api.example.com/data") + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + }) + ) + }) + + it("should handle Request with no headers", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const request = new Request("https://api.example.com/data") + + await hoppFetch(request) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + headers: {}, + }) + ) + }) + + it("should handle Request with no body", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const request = new Request("https://api.example.com/data") + + await hoppFetch(request) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + content: undefined, + }) + ) + }) + + it("should handle FormData body", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const formData = new FormData() + formData.append("key", "value") + + await hoppFetch("https://api.example.com/data", { + method: "POST", + body: formData, + }) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + kind: "multipart", + mediaType: "multipart/form-data", + }), + }) + ) + }) + }) + + describe("Body type handling", () => { + // Skip Blob tests in Node.js environment - Node's Blob polyfill doesn't have arrayBuffer() + it.skip("should handle Blob body", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const blob = new Blob(["test data"], { type: "text/plain" }) + + await hoppFetch("https://api.example.com/data", { + method: "POST", + body: blob, + }) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + kind: "binary", + mediaType: "text/plain", + }), + }) + ) + }) + + // Skip Blob tests in Node.js environment - Node's Blob polyfill doesn't have arrayBuffer() + it.skip("should handle Blob body with default mediaType", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const blob = new Blob(["test data"]) + + await hoppFetch("https://api.example.com/data", { + method: "POST", + body: blob, + }) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + kind: "binary", + mediaType: "application/octet-stream", + }), + }) + ) + }) + + it("should handle ArrayBuffer body", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const buffer = new ArrayBuffer(8) + + await hoppFetch("https://api.example.com/data", { + method: "POST", + body: buffer, + }) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + kind: "binary", + mediaType: "application/octet-stream", + }), + }) + ) + }) + + it("should handle Uint8Array body", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const uint8Array = new Uint8Array([1, 2, 3, 4]) + + await hoppFetch("https://api.example.com/data", { + method: "POST", + body: uint8Array, + }) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + kind: "binary", + mediaType: "application/octet-stream", + }), + }) + ) + }) + + it("should detect content-type from headers for string body", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + await hoppFetch("https://api.example.com/data", { + method: "POST", + headers: { "Content-Type": "application/xml" }, + body: "", + }) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + kind: "text", + content: "", + mediaType: "application/xml", + }), + }) + ) + }) + + it("should use default mediaType for string body without content-type header", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + await hoppFetch("https://api.example.com/data", { + method: "POST", + body: "plain text", + }) + + expect(mockKernelInterceptor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + kind: "text", + content: "plain text", + mediaType: "text/plain", + }), + }) + ) + }) + }) + + describe("Response handling", () => { + it("should return response with correct status and statusText", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + ;(mockKernelInterceptor.execute as any).mockReturnValue({ + response: Promise.resolve( + E.right({ + status: 201, + statusText: "Created", + headers: {}, + body: { body: new ArrayBuffer(0) }, + }) + ), + }) + + const response = await hoppFetch("https://api.example.com/data") + + expect(response.status).toBe(201) + expect(response.statusText).toBe("Created") + }) + + it("should set ok to true for 2xx status codes", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + ;(mockKernelInterceptor.execute as any).mockReturnValue({ + response: Promise.resolve( + E.right({ + status: 200, + statusText: "OK", + headers: {}, + body: { body: new ArrayBuffer(0) }, + }) + ), + }) + + const response = await hoppFetch("https://api.example.com/data") + + expect(response.ok).toBe(true) + }) + + it("should set ok to false for non-2xx status codes", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + ;(mockKernelInterceptor.execute as any).mockReturnValue({ + response: Promise.resolve( + E.right({ + status: 404, + statusText: "Not Found", + headers: {}, + body: { body: new ArrayBuffer(0) }, + }) + ), + }) + + const response = await hoppFetch("https://api.example.com/data") + + expect(response.ok).toBe(false) + }) + + it("should handle multiHeaders format from agent interceptor", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + ;(mockKernelInterceptor.execute as any).mockReturnValue({ + response: Promise.resolve( + E.right({ + status: 200, + statusText: "OK", + multiHeaders: [ + { key: "content-type", value: "application/json" }, + { key: "x-custom-header", value: "value" }, + { key: "set-cookie", value: "session=abc123" }, + { key: "set-cookie", value: "token=xyz789" }, + ], + body: { body: new ArrayBuffer(0) }, + }) + ), + }) + + const response = await hoppFetch("https://api.example.com/data") + + expect(response.headers.get("content-type")).toBe("application/json") + expect(response.headers.get("x-custom-header")).toBe("value") + expect(response.headers.getSetCookie()).toEqual([ + "session=abc123", + "token=xyz789", + ]) + }) + + it("should handle headers format from other interceptors", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + ;(mockKernelInterceptor.execute as any).mockReturnValue({ + response: Promise.resolve( + E.right({ + status: 200, + statusText: "OK", + headers: { + "content-type": "application/json", + "set-cookie": ["session=abc123", "token=xyz789"], + }, + body: { body: new ArrayBuffer(0) }, + }) + ), + }) + + const response = await hoppFetch("https://api.example.com/data") + + expect(response.headers.get("content-type")).toBe("application/json") + expect(response.headers.getSetCookie()).toEqual([ + "session=abc123", + "token=xyz789", + ]) + }) + + it("should handle single Set-Cookie header as string", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + ;(mockKernelInterceptor.execute as any).mockReturnValue({ + response: Promise.resolve( + E.right({ + status: 200, + statusText: "OK", + headers: { + "set-cookie": "session=abc123", + }, + body: { body: new ArrayBuffer(0) }, + }) + ), + }) + + const response = await hoppFetch("https://api.example.com/data") + + expect(response.headers.getSetCookie()).toEqual(["session=abc123"]) + }) + + it("should convert response body to byte array", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const data = new Uint8Array([72, 101, 108, 108, 111]) // "Hello" + ;(mockKernelInterceptor.execute as any).mockReturnValue({ + response: Promise.resolve( + E.right({ + status: 200, + statusText: "OK", + headers: {}, + body: { body: data.buffer }, + }) + ), + }) + + const response = await hoppFetch("https://api.example.com/data") + + expect((response as any)._bodyBytes).toEqual([72, 101, 108, 108, 111]) + }) + + it("should handle response body text conversion", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const data = new TextEncoder().encode("Hello World") + ;(mockKernelInterceptor.execute as any).mockReturnValue({ + response: Promise.resolve( + E.right({ + status: 200, + statusText: "OK", + headers: {}, + body: { body: Array.from(data) }, // Convert to plain array for serialization + }) + ), + }) + + const response = await hoppFetch("https://api.example.com/data") + const text = await response.text() + + expect(text).toBe("Hello World") + }) + + it("should handle response body json conversion", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + const jsonData = { message: "success" } + const data = new TextEncoder().encode(JSON.stringify(jsonData)) + ;(mockKernelInterceptor.execute as any).mockReturnValue({ + response: Promise.resolve( + E.right({ + status: 200, + statusText: "OK", + headers: {}, + body: { body: Array.from(data) }, // Convert to plain array for serialization + }) + ), + }) + + const response = await hoppFetch("https://api.example.com/data") + const json = await response.json() + + expect(json).toEqual(jsonData) + }) + + it("should handle body as plain array", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + ;(mockKernelInterceptor.execute as any).mockReturnValue({ + response: Promise.resolve( + E.right({ + status: 200, + statusText: "OK", + headers: {}, + body: { body: [72, 101, 108, 108, 111] }, + }) + ), + }) + + const response = await hoppFetch("https://api.example.com/data") + + expect((response as any)._bodyBytes).toEqual([72, 101, 108, 108, 111]) + }) + + it("should handle body as Buffer-like object", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + ;(mockKernelInterceptor.execute as any).mockReturnValue({ + response: Promise.resolve( + E.right({ + status: 200, + statusText: "OK", + headers: {}, + body: { body: { type: "Buffer", data: [72, 101, 108, 108, 111] } }, + }) + ), + }) + + const response = await hoppFetch("https://api.example.com/data") + + expect((response as any)._bodyBytes).toEqual([72, 101, 108, 108, 111]) + }) + }) + + describe("Error handling", () => { + it("should throw error when kernel interceptor returns Left with string", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + ;(mockKernelInterceptor.execute as any).mockReturnValue({ + response: Promise.resolve(E.left("Network error")), + }) + + await expect(hoppFetch("https://api.example.com/data")).rejects.toThrow( + "Fetch failed: Network error" + ) + }) + + it("should throw error when kernel interceptor returns Left with humanMessage object", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + ;(mockKernelInterceptor.execute as any).mockReturnValue({ + response: Promise.resolve( + E.left({ + humanMessage: { + heading: () => "Connection failed", + }, + }) + ), + }) + + await expect(hoppFetch("https://api.example.com/data")).rejects.toThrow( + "Fetch failed: Connection failed" + ) + }) + + it("should throw error when kernel interceptor returns Left with object without humanMessage", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + ;(mockKernelInterceptor.execute as any).mockReturnValue({ + response: Promise.resolve(E.left({ code: "ERROR", message: "Failed" })), + }) + + await expect(hoppFetch("https://api.example.com/data")).rejects.toThrow( + "Fetch failed: Unknown error" + ) + }) + + it("should throw error for null error value", async () => { + const hoppFetch = createHoppFetchHook(mockKernelInterceptor) + + ;(mockKernelInterceptor.execute as any).mockReturnValue({ + response: Promise.resolve(E.left(null)), + }) + + await expect(hoppFetch("https://api.example.com/data")).rejects.toThrow( + "Fetch failed: Unknown error" + ) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/__tests__/jsonParse.spec.js b/packages/hoppscotch-common/src/helpers/__tests__/jsonParse.spec.js new file mode 100644 index 0000000..1f26cad --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/__tests__/jsonParse.spec.js @@ -0,0 +1,35 @@ +import { describe, test, expect } from "vitest" +import jsonParse from "../jsonParse" + +describe("jsonParse", () => { + test("parses without errors for valid JSON", () => { + const testJSON = JSON.stringify({ + name: "hoppscotch", + url: "https://hoppscotch.io", + awesome: true, + when: 2019, + }) + + expect(() => jsonParse(testJSON)).not.toThrow() + }) + + test("throws error for invalid JSON", () => { + const testJSON = '{ "name": hopp "url": true }' + + expect(() => jsonParse(testJSON)).toThrow() + }) + + test("thrown error has proper info fields", () => { + expect.assertions(3) + + const testJSON = '{ "name": hopp "url": true }' + + try { + jsonParse(testJSON) + } catch (e) { + expect(e).toHaveProperty("start") + expect(e).toHaveProperty("end") + expect(e).toHaveProperty("message") + } + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/__tests__/keybindings.spec.ts b/packages/hoppscotch-common/src/helpers/__tests__/keybindings.spec.ts new file mode 100644 index 0000000..7b02e4b --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/__tests__/keybindings.spec.ts @@ -0,0 +1,198 @@ +import { describe, expect, test } from "vitest" +import { resolvePressedKey } from "../keybindings" + +// Fixture builder to keep individual cases readable. Layout name in the +// describe block is the conceptual layout; `key` and `code` are what the +// browser actually emits when the user presses a given physical key on +// that layout. +const ev = (key: string, code: string) => ({ key, code }) + +describe("resolvePressedKey: letter dispatch", () => { + describe("strategy: 'key' (typed letter)", () => { + test("US-QWERTY Q resolves to q", () => { + expect(resolvePressedKey(ev("q", "KeyQ"), "key")).toBe("q") + }) + + test("AZERTY 'A' keycap (physical Q position) resolves to a", () => { + // On AZERTY the physical KeyQ position types "a"; the user pressing + // the keycap labelled A expects Ctrl+A behaviour. + expect(resolvePressedKey(ev("a", "KeyQ"), "key")).toBe("a") + }) + + test("QWERTZ 'Z' keycap (physical Y position) resolves to z", () => { + expect(resolvePressedKey(ev("z", "KeyY"), "key")).toBe("z") + }) + + test("Cyrillic Q (typed 'й') falls through to non-letter handling", () => { + // 'й' is not a-z so the letter branch returns null and the rest + // of the resolver doesn't recognise it either. + expect(resolvePressedKey(ev("й", "KeyQ"), "key")).toBeNull() + }) + + test("Mac Option+Q (typed 'œ') falls through", () => { + expect(resolvePressedKey(ev("œ", "KeyQ"), "key")).toBeNull() + }) + }) + + describe("strategy: 'code' (physical position)", () => { + test("US-QWERTY Q resolves to q", () => { + expect(resolvePressedKey(ev("q", "KeyQ"), "code")).toBe("q") + }) + + test("AZERTY 'A' keycap (physical Q position) resolves to q", () => { + // Pure physical strategy; whatever the user typed is ignored. + expect(resolvePressedKey(ev("a", "KeyQ"), "code")).toBe("q") + }) + + test("Cyrillic physical Q (typed 'й') resolves to q", () => { + expect(resolvePressedKey(ev("й", "KeyQ"), "code")).toBe("q") + }) + + test("Mac Option+Q (typed 'œ') resolves to q", () => { + expect(resolvePressedKey(ev("œ", "KeyQ"), "code")).toBe("q") + }) + }) + + describe("strategy: 'hybrid' (key first, code fallback)", () => { + test("US-QWERTY Q resolves to q", () => { + expect(resolvePressedKey(ev("q", "KeyQ"), "hybrid")).toBe("q") + }) + + test("AZERTY 'A' keycap (physical Q position) resolves to a", () => { + // Latin glyph available, so use it. + expect(resolvePressedKey(ev("a", "KeyQ"), "hybrid")).toBe("a") + }) + + test("Cyrillic physical Q (typed 'й') falls back to code → q", () => { + // Non-Latin glyph, fall back to physical position. + expect(resolvePressedKey(ev("й", "KeyQ"), "hybrid")).toBe("q") + }) + + test("Mac Option+Q (typed 'œ') falls back to code → q", () => { + // 'œ' is not in [a-z] so hybrid falls through to code. + expect(resolvePressedKey(ev("œ", "KeyQ"), "hybrid")).toBe("q") + }) + + test("Dvorak 'Q' keycap typing 'q' resolves to q", () => { + // Dvorak users with Dvorak software remapping see the typed + // letter match the keycap; hybrid uses event.key directly. + expect(resolvePressedKey(ev("q", "KeyX"), "hybrid")).toBe("q") + }) + }) +}) + +describe("resolvePressedKey: digit dispatch", () => { + test("'key' uses event.key for digits", () => { + expect(resolvePressedKey(ev("1", "Digit1"), "key")).toBe("1") + }) + + test("'code' uses event.code for digits", () => { + expect(resolvePressedKey(ev("1", "Digit1"), "code")).toBe("1") + }) + + test("AZERTY digit row (typed '&', code Digit1) resolves to 1 under hybrid", () => { + expect(resolvePressedKey(ev("&", "Digit1"), "hybrid")).toBe("1") + }) + + test("AZERTY digit row resolves to 1 under code", () => { + expect(resolvePressedKey(ev("&", "Digit1"), "code")).toBe("1") + }) + + test("AZERTY digit row falls through under key (no Latin digit typed)", () => { + expect(resolvePressedKey(ev("&", "Digit1"), "key")).toBeNull() + }) +}) + +describe("resolvePressedKey: layout-stable keys", () => { + // These keys produce the same event.key regardless of layout, so + // every strategy resolves them identically. + const strategies = ["key", "code", "hybrid"] as const + + for (const strategy of strategies) { + describe(`strategy: '${strategy}'`, () => { + test("ArrowUp resolves to up", () => { + expect(resolvePressedKey(ev("ArrowUp", "ArrowUp"), strategy)).toBe("up") + }) + + test("Tab resolves to tab", () => { + expect(resolvePressedKey(ev("Tab", "Tab"), strategy)).toBe("tab") + }) + + test("Enter resolves to enter", () => { + expect(resolvePressedKey(ev("Enter", "Enter"), strategy)).toBe("enter") + }) + + test("Shift+/ (typed '?') maps to /", () => { + expect(resolvePressedKey(ev("?", "Slash"), strategy)).toBe("/") + }) + + test("[ resolves to [", () => { + expect(resolvePressedKey(ev("[", "BracketLeft"), strategy)).toBe("[") + }) + + test("Cyrillic physical [ key (typed 'х') falls back to [", () => { + // On Russian Cyrillic the physical KeyBracketLeft position + // types "х"; the resolver falls back to the bracket code so + // the shortcut still fires. + expect(resolvePressedKey(ev("х", "BracketLeft"), strategy)).toBe("[") + }) + + test("Cyrillic physical ] key (typed 'ъ') falls back to ]", () => { + expect(resolvePressedKey(ev("ъ", "BracketRight"), strategy)).toBe("]") + }) + }) + } +}) + +describe("resolvePressedKey: synthetic-event fallback", () => { + // Synthetic events (programmatic dispatch, certain older environments) + // can arrive without `event.code` set. The resolver falls back to + // `event.key` for ASCII letters under every strategy so the shortcut + // still resolves rather than being silently dropped. + test("'code' strategy falls back to event.key when code is empty", () => { + expect(resolvePressedKey(ev("a", ""), "code")).toBe("a") + }) + + test("'key' strategy resolves Latin letter without needing code", () => { + expect(resolvePressedKey(ev("a", ""), "key")).toBe("a") + }) + + test("'hybrid' strategy resolves Latin letter without needing code", () => { + expect(resolvePressedKey(ev("a", ""), "hybrid")).toBe("a") + }) +}) + +describe("resolvePressedKey: edge cases", () => { + test("empty event returns null", () => { + expect(resolvePressedKey(ev("", ""), "hybrid")).toBeNull() + }) + + test("uppercase letter is normalised to lowercase under 'key'", () => { + expect(resolvePressedKey(ev("Q", "KeyQ"), "key")).toBe("q") + }) + + test("numpad branch resolves under 'code' strategy when NumLock is on", () => { + // The digit branch normally picks up key="1" before the numpad + // branch sees the event. Strategy "code" suppresses that path + // (event.code "Numpad1" isn't "Digit1"), so resolution falls to + // the numpad branch, which gates on NumLock. + expect( + resolvePressedKey( + { key: "1", code: "Numpad1", getModifierState: () => true }, + "code" + ) + ).toBe("1") + }) + + test("numpad digit returns null without NumLock when key is navigational", () => { + // Without NumLock, browsers send the navigation function ("End" + // for Numpad1) in event.key, so neither the digit nor the numpad + // branch can resolve. + expect( + resolvePressedKey( + { key: "End", code: "Numpad1", getModifierState: () => false }, + "hybrid" + ) + ).toBeNull() + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/__tests__/platformutils.spec.js b/packages/hoppscotch-common/src/helpers/__tests__/platformutils.spec.js new file mode 100644 index 0000000..01ad7f5 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/__tests__/platformutils.spec.js @@ -0,0 +1,43 @@ +import { vi, beforeEach, describe, expect, test } from "vitest" +import { getPlatformSpecialKey } from "../platformutils" + +describe("getPlatformSpecialKey", () => { + let platformGetter + + beforeEach(() => { + platformGetter = vi.spyOn(navigator, "platform", "get") + }) + + test("returns '⌘' for Apple platforms", () => { + platformGetter.mockReturnValue("Mac") + expect(getPlatformSpecialKey()).toMatch("⌘") + + platformGetter.mockReturnValue("iPhone") + expect(getPlatformSpecialKey()).toMatch("⌘") + + platformGetter.mockReturnValue("iPad") + expect(getPlatformSpecialKey()).toMatch("⌘") + + platformGetter.mockReturnValue("iPod") + expect(getPlatformSpecialKey()).toMatch("⌘") + }) + + test("return 'Ctrl' for non-Apple platforms", () => { + platformGetter.mockReturnValue("Android") + expect(getPlatformSpecialKey()).toMatch("Ctrl") + + platformGetter.mockReturnValue("Windows") + expect(getPlatformSpecialKey()).toMatch("Ctrl") + + platformGetter.mockReturnValue("Linux") + expect(getPlatformSpecialKey()).toMatch("Ctrl") + }) + + test("returns 'Ctrl' for null/undefined platforms", () => { + platformGetter.mockReturnValue(null) + expect(getPlatformSpecialKey()).toMatch("Ctrl") + + platformGetter.mockReturnValue(undefined) + expect(getPlatformSpecialKey()).toMatch("Ctrl") + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/__tests__/postman-import.spec.ts b/packages/hoppscotch-common/src/helpers/__tests__/postman-import.spec.ts new file mode 100644 index 0000000..c1eb3c9 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/__tests__/postman-import.spec.ts @@ -0,0 +1,60 @@ +import * as E from "fp-ts/Either" +import { describe, expect, it } from "vitest" + +import { hoppPostmanImporter } from "../import-export/import/postman" + +const postmanCollectionWithArrayHeader = JSON.stringify({ + info: { + name: "Array Header Import", + schema: + "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + }, + item: [ + { + name: "Import Array Header", + request: { + method: "GET", + header: [ + { + key: "Authorization", + value: ["Basic xxxxx", "Basic xxxxxxxxxxxx="], + }, + { + key: "Content-Type", + value: "application/x-www-form-urlencoded", + }, + ], + url: "https://echo.hoppscotch.io/get", + }, + }, + ], +}) + +describe("Postman importer", () => { + it("coerces array header values instead of crashing during import", async () => { + const result = await hoppPostmanImporter([ + postmanCollectionWithArrayHeader, + ])() + + expect(E.isRight(result)).toBe(true) + + if (E.isLeft(result)) { + throw new Error("Expected Postman import to succeed") + } + + expect(result.right[0].requests[0].headers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "Authorization", + value: "Basic xxxxx,Basic xxxxxxxxxxxx=", + active: true, + }), + expect.objectContaining({ + key: "Content-Type", + value: "application/x-www-form-urlencoded", + active: true, + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/__tests__/postwomanTesting.sample b/packages/hoppscotch-common/src/helpers/__tests__/postwomanTesting.sample new file mode 100644 index 0000000..1b70474 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/__tests__/postwomanTesting.sample @@ -0,0 +1,313 @@ +import runTestScriptWithVariables from "../postwomanTesting" + +/** + * @param {string} script + * @param {number} index + */ +function getTestResult(script, index) { + return runTestScriptWithVariables(script).testResults[index].result +} + +/** + * @param {string} script + */ +function getErrors(script) { + return runTestScriptWithVariables(script).errors +} + +describe("Error handling", () => { + test("throws error at unknown test method", () => { + const testScriptWithUnknownMethod = "pw.expect(1).toBeSomeUnknownMethod()" + expect(() => { + runTestScriptWithVariables(testScriptWithUnknownMethod) + }).toThrow() + }) + test("errors array is empty on a successful test", () => { + expect(getErrors("pw.expect(1).toBe(1)")).toStrictEqual([]) + }) + test("throws error at a variable which is not declared", () => { + expect(() => { + runTestScriptWithVariables("someVariable") + }).toThrow() + }) +}) + +describe("toBe", () => { + test("test for numbers", () => { + expect(getTestResult("pw.expect(1).toBe(2)", 0)).toEqual("FAIL") + + expect(getTestResult("pw.expect(1).toBe(1)", 0)).toEqual("PASS") + }) + + test("test for strings", () => { + expect(getTestResult("pw.expect('hello').toBe('bonjour')", 0)).toEqual( + "FAIL" + ) + expect(getTestResult("pw.expect('hi').toBe('hi')", 0)).toEqual("PASS") + }) + + test("test for negative assertion (.not.toBe)", () => { + expect(getTestResult("pw.expect(1).not.toBe(1)", 0)).toEqual("FAIL") + expect(getTestResult("pw.expect(1).not.toBe(2)", 0)).toEqual("PASS") + expect(getTestResult("pw.expect('world').not.toBe('planet')", 0)).toEqual( + "PASS" + ) + expect(getTestResult("pw.expect('world').not.toBe('world')", 0)).toEqual( + "FAIL" + ) + }) +}) + +describe("toHaveProperty", () => { + const dummyResponse = { + id: 843, + description: "random", + } + + test("test for positive assertion (.toHaveProperty)", () => { + expect( + getTestResult( + `pw.expect(${JSON.stringify(dummyResponse)}).toHaveProperty("id")`, + 0 + ) + ).toEqual("PASS") + expect( + getTestResult(`pw.expect(${dummyResponse.id}).toBe(843)`, 0) + ).toEqual("PASS") + }) + test("test for negative assertion (.not.toHaveProperty)", () => { + expect( + getTestResult( + `pw.expect(${JSON.stringify( + dummyResponse + )}).not.toHaveProperty("type")`, + 0 + ) + ).toEqual("PASS") + expect( + getTestResult( + `pw.expect(${JSON.stringify(dummyResponse)}).toHaveProperty("type")`, + 0 + ) + ).toEqual("FAIL") + }) +}) + +describe("toBeLevel2xx", () => { + test("test for numbers", () => { + expect(getTestResult("pw.expect(200).toBeLevel2xx()", 0)).toEqual("PASS") + expect(getTestResult("pw.expect(200).not.toBeLevel2xx()", 0)).toEqual( + "FAIL" + ) + expect(getTestResult("pw.expect(300).toBeLevel2xx()", 0)).toEqual("FAIL") + expect(getTestResult("pw.expect(300).not.toBeLevel2xx()", 0)).toEqual( + "PASS" + ) + }) + test("test for strings", () => { + expect(getTestResult("pw.expect('200').toBeLevel2xx()", 0)).toEqual("PASS") + expect(getTestResult("pw.expect('200').not.toBeLevel2xx()", 0)).toEqual( + "FAIL" + ) + expect(getTestResult("pw.expect('300').toBeLevel2xx()", 0)).toEqual("FAIL") + expect(getTestResult("pw.expect('300').not.toBeLevel2xx()", 0)).toEqual( + "PASS" + ) + }) + test("failed to parse to integer", () => { + expect(getTestResult("pw.expect(undefined).toBeLevel2xx()", 0)).toEqual( + "FAIL" + ) + expect(getTestResult("pw.expect(null).toBeLevel2xx()", 0)).toEqual("FAIL") + expect(() => { + runTestScriptWithVariables("pw.expect(Symbol('test')).toBeLevel2xx()") + }).toThrow() + }) +}) + +describe("toBeLevel3xx()", () => { + test("test for numbers", () => { + expect(getTestResult("pw.expect(300).toBeLevel3xx()", 0)).toEqual("PASS") + expect(getTestResult("pw.expect(300).not.toBeLevel3xx()", 0)).toEqual( + "FAIL" + ) + expect(getTestResult("pw.expect(400).toBeLevel3xx()", 0)).toEqual("FAIL") + expect(getTestResult("pw.expect(400).not.toBeLevel3xx()", 0)).toEqual( + "PASS" + ) + }) + test("test for strings", () => { + expect(getTestResult("pw.expect('300').toBeLevel3xx()", 0)).toEqual("PASS") + expect(getTestResult("pw.expect('300').not.toBeLevel3xx()", 0)).toEqual( + "FAIL" + ) + expect(getTestResult("pw.expect('400').toBeLevel3xx()", 0)).toEqual("FAIL") + expect(getTestResult("pw.expect('400').not.toBeLevel3xx()", 0)).toEqual( + "PASS" + ) + }) + test("failed to parse to integer", () => { + expect(getTestResult("pw.expect(undefined).toBeLevel3xx()", 0)).toEqual( + "FAIL" + ) + expect(getTestResult("pw.expect(null).toBeLevel3xx()", 0)).toEqual("FAIL") + expect(() => { + runTestScriptWithVariables("pw.expect(Symbol('test')).toBeLevel3xx()") + }).toThrow() + }) +}) + +describe("toBeLevel4xx()", () => { + test("test for numbers", () => { + expect(getTestResult("pw.expect(400).toBeLevel4xx()", 0)).toEqual("PASS") + expect(getTestResult("pw.expect(400).not.toBeLevel4xx()", 0)).toEqual( + "FAIL" + ) + expect(getTestResult("pw.expect(500).toBeLevel4xx()", 0)).toEqual("FAIL") + expect(getTestResult("pw.expect(500).not.toBeLevel4xx()", 0)).toEqual( + "PASS" + ) + }) + test("test for strings", () => { + expect(getTestResult("pw.expect('400').toBeLevel4xx()", 0)).toEqual("PASS") + expect(getTestResult("pw.expect('400').not.toBeLevel4xx()", 0)).toEqual( + "FAIL" + ) + expect(getTestResult("pw.expect('500').toBeLevel4xx()", 0)).toEqual("FAIL") + expect(getTestResult("pw.expect('500').not.toBeLevel4xx()", 0)).toEqual( + "PASS" + ) + }) + test("failed to parse to integer", () => { + expect(getTestResult("pw.expect(undefined).toBeLevel4xx()", 0)).toEqual( + "FAIL" + ) + expect(getTestResult("pw.expect(null).toBeLevel4xx()", 0)).toEqual("FAIL") + expect(() => { + runTestScriptWithVariables("pw.expect(Symbol('test')).toBeLevel4xx()") + }).toThrow() + }) +}) + +describe("toBeLevel5xx()", () => { + test("test for numbers", () => { + expect(getTestResult("pw.expect(500).toBeLevel5xx()", 0)).toEqual("PASS") + expect(getTestResult("pw.expect(500).not.toBeLevel5xx()", 0)).toEqual( + "FAIL" + ) + expect(getTestResult("pw.expect(200).toBeLevel5xx()", 0)).toEqual("FAIL") + expect(getTestResult("pw.expect(200).not.toBeLevel5xx()", 0)).toEqual( + "PASS" + ) + }) + test("test for strings", () => { + expect(getTestResult("pw.expect('500').toBeLevel5xx()", 0)).toEqual("PASS") + expect(getTestResult("pw.expect('500').not.toBeLevel5xx()", 0)).toEqual( + "FAIL" + ) + expect(getTestResult("pw.expect('200').toBeLevel5xx()", 0)).toEqual("FAIL") + expect(getTestResult("pw.expect('200').not.toBeLevel5xx()", 0)).toEqual( + "PASS" + ) + }) + test("failed to parse to integer", () => { + expect(getTestResult("pw.expect(undefined).toBeLevel5xx()", 0)).toEqual( + "FAIL" + ) + expect(getTestResult("pw.expect(null).toBeLevel5xx()", 0)).toEqual("FAIL") + expect(() => { + runTestScriptWithVariables("pw.expect(Symbol('test')).toBeLevel5xx()") + }).toThrow() + }) +}) + +describe("toHaveLength()", () => { + test("test for strings", () => { + expect(getTestResult("pw.expect('word').toHaveLength(4)", 0)).toEqual( + "PASS" + ) + expect(getTestResult("pw.expect('word').toHaveLength(5)", 0)).toEqual( + "FAIL" + ) + expect(getTestResult("pw.expect('word').not.toHaveLength(4)", 0)).toEqual( + "FAIL" + ) + expect(getTestResult("pw.expect('word').not.toHaveLength(5)", 0)).toEqual( + "PASS" + ) + }) + test("test for arrays", () => { + const fruits = + "['apples', 'bananas', 'oranges', 'grapes', 'strawberries', 'cherries']" + expect(getTestResult(`pw.expect(${fruits}).toHaveLength(6)`, 0)).toEqual( + "PASS" + ) + expect(getTestResult(`pw.expect(${fruits}).toHaveLength(7)`, 0)).toEqual( + "FAIL" + ) + expect( + getTestResult(`pw.expect(${fruits}).not.toHaveLength(6)`, 0) + ).toEqual("FAIL") + expect( + getTestResult(`pw.expect(${fruits}).not.toHaveLength(7)`, 0) + ).toEqual("PASS") + }) +}) + +describe("toBeType()", () => { + test("test for positive assertion", () => { + expect(getTestResult("pw.expect('random').toBeType('string')", 0)).toEqual( + "PASS" + ) + expect(getTestResult("pw.expect(true).toBeType('boolean')", 0)).toEqual( + "PASS" + ) + expect(getTestResult("pw.expect(5).toBeType('number')", 0)).toEqual("PASS") + expect( + getTestResult("pw.expect(new Date()).toBeType('object')", 0) + ).toEqual("PASS") + expect( + getTestResult("pw.expect(undefined).toBeType('undefined')", 0) + ).toEqual("PASS") + expect( + getTestResult("pw.expect(BigInt(123)).toBeType('bigint')", 0) + ).toEqual("PASS") + expect( + getTestResult("pw.expect(Symbol('test')).toBeType('symbol')", 0) + ).toEqual("PASS") + expect( + getTestResult("pw.expect(function() {}).toBeType('function')", 0) + ).toEqual("PASS") + }) + test("test for negative assertion", () => { + expect( + getTestResult("pw.expect('random').not.toBeType('string')", 0) + ).toEqual("FAIL") + expect(getTestResult("pw.expect(true).not.toBeType('boolean')", 0)).toEqual( + "FAIL" + ) + expect(getTestResult("pw.expect(5).not.toBeType('number')", 0)).toEqual( + "FAIL" + ) + expect( + getTestResult("pw.expect(new Date()).not.toBeType('object')", 0) + ).toEqual("FAIL") + expect( + getTestResult("pw.expect(undefined).not.toBeType('undefined')", 0) + ).toEqual("FAIL") + expect( + getTestResult("pw.expect(BigInt(123)).not.toBeType('bigint')", 0) + ).toEqual("FAIL") + expect( + getTestResult("pw.expect(Symbol('test')).not.toBeType('symbol')", 0) + ).toEqual("FAIL") + expect( + getTestResult("pw.expect(function() {}).not.toBeType('function')", 0) + ).toEqual("FAIL") + }) + test("unexpected type", () => { + expect(getTestResult("pw.expect('random').toBeType('unknown')", 0)).toEqual( + "FAIL" + ) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/__tests__/retryAuthGuard.spec.ts b/packages/hoppscotch-common/src/helpers/__tests__/retryAuthGuard.spec.ts new file mode 100644 index 0000000..cf8cc8d --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/__tests__/retryAuthGuard.spec.ts @@ -0,0 +1,205 @@ +import { describe, test, expect, vi } from "vitest" +import { createAuthRetryGuard } from "../retryAuthGuard" + +const refreshSuccess = () => Promise.resolve(true) +const refreshFailure = () => Promise.resolve(false) +const refreshThrow = () => Promise.reject(new Error("network error")) + +describe("createAuthRetryGuard", () => { + describe("success resets failure count", () => { + test("returns true on successful refresh", async () => { + const guard = createAuthRetryGuard(vi.fn()) + + expect(await guard.execute(refreshSuccess)).toBe(true) + }) + + test("resets failure count after a success", async () => { + const onExhausted = vi.fn() + const guard = createAuthRetryGuard(onExhausted) + + // Accumulate 2 failures + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + + // Success resets the counter + await guard.execute(refreshSuccess) + + // 3 more failures needed to exhaust + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + expect(onExhausted).not.toHaveBeenCalled() + + await guard.execute(refreshFailure) + expect(onExhausted).toHaveBeenCalledOnce() + }) + }) + + describe("exhaustion after MAX_RETRIES (3)", () => { + test("calls onExhausted after 3 consecutive failures", async () => { + const onExhausted = vi.fn() + const guard = createAuthRetryGuard(onExhausted) + + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + expect(onExhausted).not.toHaveBeenCalled() + + await guard.execute(refreshFailure) + expect(onExhausted).toHaveBeenCalledOnce() + }) + + test("returns false for every failed attempt", async () => { + const guard = createAuthRetryGuard(vi.fn()) + + expect(await guard.execute(refreshFailure)).toBe(false) + expect(await guard.execute(refreshFailure)).toBe(false) + expect(await guard.execute(refreshFailure)).toBe(false) + }) + + test("short-circuits to false after exhaustion without calling refreshFn", async () => { + const guard = createAuthRetryGuard(vi.fn()) + + // Exhaust the guard + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + + const refreshFn = vi.fn(refreshSuccess) + expect(await guard.execute(refreshFn)).toBe(false) + expect(refreshFn).not.toHaveBeenCalled() + }) + }) + + describe("onExhausted runs at most once", () => { + test("does not call onExhausted again on subsequent execute calls", async () => { + const onExhausted = vi.fn() + const guard = createAuthRetryGuard(onExhausted) + + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + + expect(onExhausted).toHaveBeenCalledOnce() + }) + }) + + describe("thrown errors count as failures", () => { + test("treats a thrown refreshFn as a failed attempt", async () => { + const onExhausted = vi.fn() + const guard = createAuthRetryGuard(onExhausted) + + await guard.execute(refreshThrow) + await guard.execute(refreshThrow) + await guard.execute(refreshThrow) + + expect(onExhausted).toHaveBeenCalledOnce() + }) + + test("does not propagate the error to the caller", async () => { + const guard = createAuthRetryGuard(vi.fn()) + + await expect(guard.execute(refreshThrow)).resolves.toBe(false) + }) + }) + + describe("onExhausted failure handling", () => { + test("stays exhausted if onExhausted throws", async () => { + const onExhausted = vi.fn(() => { + throw new Error("sign-out failed") + }) + const guard = createAuthRetryGuard(onExhausted) + + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + + // Guard is still exhausted — short-circuits + const refreshFn = vi.fn(refreshSuccess) + expect(await guard.execute(refreshFn)).toBe(false) + expect(refreshFn).not.toHaveBeenCalled() + }) + + test("does not propagate onExhausted error to caller", async () => { + const guard = createAuthRetryGuard(() => + Promise.reject(new Error("sign-out failed")) + ) + + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + + await expect(guard.execute(refreshFailure)).resolves.toBe(false) + }) + }) + + describe("reset()", () => { + test("re-enables the guard after exhaustion", async () => { + const onExhausted = vi.fn() + const guard = createAuthRetryGuard(onExhausted) + + // Exhaust + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + + guard.reset() + + // Guard is usable again + expect(await guard.execute(refreshSuccess)).toBe(true) + }) + + test("resets the failure counter", async () => { + const onExhausted = vi.fn() + const guard = createAuthRetryGuard(onExhausted) + + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + + guard.reset() + + // Need 3 fresh failures to exhaust again + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + expect(onExhausted).not.toHaveBeenCalled() + + await guard.execute(refreshFailure) + expect(onExhausted).toHaveBeenCalledOnce() + }) + + test("is a no-op while onExhausted is in-flight", async () => { + let resolveExhausted!: () => void + const exhaustedPromise = new Promise((resolve) => { + resolveExhausted = resolve + }) + const onExhausted = vi.fn(() => exhaustedPromise) + const guard = createAuthRetryGuard(onExhausted) + + await guard.execute(refreshFailure) + await guard.execute(refreshFailure) + + // Start the 3rd failure — onExhausted is now in-flight. + // Don't await: we want to call reset() while it's still pending. + const thirdCall = guard.execute(refreshFailure) + + // Flush microtasks so execute() progresses past `await refreshFn()` + // and sets exhaustionPromise before we call reset(). + await Promise.resolve() + + // reset() while onExhausted hasn't resolved yet — should be a no-op + guard.reset() + + // Guard should still be exhausted + const refreshFn = vi.fn(refreshSuccess) + expect(await guard.execute(refreshFn)).toBe(false) + expect(refreshFn).not.toHaveBeenCalled() + + // Let onExhausted finish + resolveExhausted() + await thirdCall + + // Now reset() should work + guard.reset() + expect(await guard.execute(refreshSuccess)).toBe(true) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/__tests__/scripting.spec.ts b/packages/hoppscotch-common/src/helpers/__tests__/scripting.spec.ts new file mode 100644 index 0000000..b24eacf --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/__tests__/scripting.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "vitest" + +import { + hasActualScript, + stripJsonSerializedModulePrefix, +} from "@hoppscotch/js-sandbox/scripting" + +describe("hasActualScript", () => { + test("returns false for null, undefined, or empty input", () => { + expect(hasActualScript(null)).toBe(false) + expect(hasActualScript(undefined)).toBe(false) + expect(hasActualScript("")).toBe(false) + }) + + test("returns false for whitespace-only input", () => { + expect(hasActualScript(" ")).toBe(false) + expect(hasActualScript("\n\t \n")).toBe(false) + }) + + test("returns false when only the Monaco module prefix is present", () => { + expect(hasActualScript("export {};\n")).toBe(false) + expect(hasActualScript("export {};")).toBe(false) + expect(hasActualScript("export {};\n ")).toBe(false) + }) + + test("returns true when script body exists after the prefix", () => { + expect(hasActualScript("export {};\nconst x = 1;")).toBe(true) + expect(hasActualScript("const x = 1;")).toBe(true) + }) +}) + +describe("stripJsonSerializedModulePrefix", () => { + test("strips `export {};\\n` from JSON string values", () => { + const json = JSON.stringify({ + preRequestScript: "export {};\nconst x = 1;", + testScript: "export {};const y = 2;", + }) + const out = stripJsonSerializedModulePrefix(json) + const parsed = JSON.parse(out) as Record + expect(parsed.preRequestScript).toBe("const x = 1;") + expect(parsed.testScript).toBe("const y = 2;") + }) + + test("leaves values without the prefix untouched", () => { + const json = JSON.stringify({ + name: "request name", + preRequestScript: "const z = 3;", + }) + expect(stripJsonSerializedModulePrefix(json)).toBe(json) + }) + + test("preserves spacing between key delimiter and the stripped value", () => { + const json = `{"preRequestScript": "export {};const a = 1;"}` + const out = stripJsonSerializedModulePrefix(json) + expect(out).toBe(`{"preRequestScript": "const a = 1;"}`) + }) + + test("does not strip when the prefix appears mid-value", () => { + const json = JSON.stringify({ + preRequestScript: "const a = 1;\nexport {};\nconst b = 2;", + }) + expect(stripJsonSerializedModulePrefix(json)).toBe(json) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/__tests__/secretVariables.spec.ts b/packages/hoppscotch-common/src/helpers/__tests__/secretVariables.spec.ts new file mode 100644 index 0000000..d770ee5 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/__tests__/secretVariables.spec.ts @@ -0,0 +1,773 @@ +import { HoppCollection } from "@hoppscotch/data" +import { beforeEach, describe, expect, it } from "vitest" + +import { getService } from "~/modules/dioc" +import { CurrentValueService } from "~/services/current-environment-value.service" +import { SecretEnvironmentService } from "~/services/secret-environment.service" +import { + flushLocalStoresForTeamCollectionTree, + flushUnmatchedRefIdsFromTree, + indexCollectionsByRefId, + populateLocalStoresFromCollectionTree, + populateLocalStoresFromVariables, + repopulateLoadedCollectionTree, + stripSecretVariableValuesForWire, +} from "../secretVariables" + +const ENTITY_ID = "entity-1" + +describe("stripSecretVariableValuesForWire", () => { + it("clears both initialValue and currentValue for secret variables", () => { + const result = stripSecretVariableValuesForWire([ + { + key: "token", + initialValue: "should-be-stripped", + currentValue: "should-be-stripped", + secret: true, + }, + ]) + + expect(result).toEqual([ + { + key: "token", + initialValue: "", + currentValue: "", + secret: true, + }, + ]) + }) + + it("keeps both initialValue and currentValue for non-secret variables", () => { + const result = stripSecretVariableValuesForWire([ + { + key: "host", + initialValue: "https://api.example.com", + currentValue: "https://staging.example.com", + secret: false, + }, + ]) + + expect(result).toEqual([ + { + key: "host", + initialValue: "https://api.example.com", + currentValue: "https://staging.example.com", + secret: false, + }, + ]) + }) + + it("preserves extra fields on each variable (forward-compatible with future schemas)", () => { + const input = [ + { + key: "x", + initialValue: "v", + currentValue: "v", + secret: false, + // hypothetical future field + description: "the X variable", + }, + ] + + const [out] = stripSecretVariableValuesForWire(input) + expect(out.description).toBe("the X variable") + }) + + it("returns an empty array for empty input", () => { + expect(stripSecretVariableValuesForWire([])).toEqual([]) + }) +}) + +describe("populateLocalStoresFromVariables", () => { + let secretService: SecretEnvironmentService + let currentValueService: CurrentValueService + + beforeEach(() => { + secretService = getService(SecretEnvironmentService) + currentValueService = getService(CurrentValueService) + secretService.secretEnvironments.delete(ENTITY_ID) + currentValueService.environments.delete(ENTITY_ID) + }) + + it("writes secret variables to SecretEnvironmentService", () => { + populateLocalStoresFromVariables(ENTITY_ID, [ + { + key: "token", + initialValue: "init-secret", + currentValue: "current-secret", + secret: true, + }, + ]) + + expect(secretService.getSecretEnvironment(ENTITY_ID)).toEqual([ + { + key: "token", + value: "current-secret", + initialValue: "init-secret", + varIndex: 0, + }, + ]) + }) + + it("writes non-secret currentValue entries to CurrentValueService", () => { + populateLocalStoresFromVariables(ENTITY_ID, [ + { + key: "host", + initialValue: "https://api.example.com", + currentValue: "https://staging.example.com", + secret: false, + }, + ]) + + expect(currentValueService.getEnvironment(ENTITY_ID)).toEqual([ + { + key: "host", + currentValue: "https://staging.example.com", + varIndex: 0, + isSecret: false, + }, + ]) + }) + + it("preserves an explicit empty currentValue for non-secrets", () => { + // `""` is a meaningful "user cleared this" value — must NOT fall + // through to `initialValue`. Only nullish (missing) currentValue + // triggers the fallback. + populateLocalStoresFromVariables(ENTITY_ID, [ + { + key: "host", + initialValue: "https://api.example.com", + currentValue: "", + secret: false, + }, + ]) + + expect(currentValueService.getEnvironment(ENTITY_ID)).toEqual([ + { + key: "host", + currentValue: "", + varIndex: 0, + isSecret: false, + }, + ]) + }) + + it("falls back to initialValue when non-secret currentValue is absent", () => { + // Missing (undefined) currentValue — not an explicit clear — falls + // through to `initialValue`. + populateLocalStoresFromVariables(ENTITY_ID, [ + { + key: "host", + initialValue: "https://api.example.com", + currentValue: undefined as unknown as string, + secret: false, + }, + ]) + + expect(currentValueService.getEnvironment(ENTITY_ID)).toEqual([ + { + key: "host", + currentValue: "https://api.example.com", + varIndex: 0, + isSecret: false, + }, + ]) + }) + + it("preserves an explicit empty currentValue for secrets", () => { + // An explicit `""` is a deliberate clear and must not be resurrected + // from `initialValue` — otherwise rehydrating an existing global where + // the user cleared `currentValue` while keeping `initialValue` would + // silently restore the old secret on the next import/merge. + populateLocalStoresFromVariables(ENTITY_ID, [ + { + key: "token", + initialValue: "old-secret", + currentValue: "", + secret: true, + }, + ]) + + expect(secretService.getSecretEnvironment(ENTITY_ID)).toEqual([ + { + key: "token", + value: "", + initialValue: "old-secret", + varIndex: 0, + }, + ]) + }) + + it("falls back to initialValue for secrets when currentValue is absent", () => { + // Nullish (missing) `currentValue` — distinct from an explicit clear — + // falls through to `initialValue`. Covers the legacy `hoppEnv` import + // shape where only `initialValue` is present on the entry. + populateLocalStoresFromVariables(ENTITY_ID, [ + { + key: "token", + initialValue: "imported-secret", + currentValue: undefined as unknown as string, + secret: true, + }, + ]) + + expect(secretService.getSecretEnvironment(ENTITY_ID)).toEqual([ + { + key: "token", + value: "imported-secret", + initialValue: "imported-secret", + varIndex: 0, + }, + ]) + }) + + it("preserves the variable index for mixed secret + non-secret entries", () => { + populateLocalStoresFromVariables(ENTITY_ID, [ + { + key: "host", + initialValue: "init-host", + currentValue: "cur-host", + secret: false, + }, + { + key: "token", + initialValue: "init-token", + currentValue: "cur-token", + secret: true, + }, + { + key: "version", + initialValue: "v1", + currentValue: "v2", + secret: false, + }, + ]) + + expect(secretService.getSecretEnvironment(ENTITY_ID)).toEqual([ + { + key: "token", + value: "cur-token", + initialValue: "init-token", + varIndex: 1, + }, + ]) + expect(currentValueService.getEnvironment(ENTITY_ID)).toEqual([ + { + key: "host", + currentValue: "cur-host", + varIndex: 0, + isSecret: false, + }, + { + key: "version", + currentValue: "v2", + varIndex: 2, + isSecret: false, + }, + ]) + }) + + it("is a no-op when entityId is empty", () => { + populateLocalStoresFromVariables("", [ + { + key: "token", + initialValue: "init", + currentValue: "cur", + secret: true, + }, + ]) + + expect(secretService.getSecretEnvironment("")).toBeUndefined() + }) + + it("writes empty arrays to both stores when variables is empty (clears stale)", () => { + // First populate with secrets + non-secrets + populateLocalStoresFromVariables(ENTITY_ID, [ + { key: "s", initialValue: "v", currentValue: "v", secret: true }, + { key: "n", initialValue: "v", currentValue: "v", secret: false }, + ]) + + // Re-populate with empty variables — should wipe both stores + populateLocalStoresFromVariables(ENTITY_ID, []) + + expect(secretService.getSecretEnvironment(ENTITY_ID)).toEqual([]) + expect(currentValueService.getEnvironment(ENTITY_ID)).toEqual([]) + }) + + it("writes empty array to CurrentValueService when all entries are secret", () => { + populateLocalStoresFromVariables(ENTITY_ID, [ + { + key: "a", + initialValue: "a", + currentValue: "a", + secret: true, + }, + ]) + + expect(currentValueService.getEnvironment(ENTITY_ID)).toEqual([]) + }) + + it("writes empty array to SecretEnvironmentService when no entries are secret", () => { + populateLocalStoresFromVariables(ENTITY_ID, [ + { + key: "a", + initialValue: "a", + currentValue: "a", + secret: false, + }, + ]) + + expect(secretService.getSecretEnvironment(ENTITY_ID)).toEqual([]) + }) + + it("re-populating with fewer secrets clears the previous secret entries", () => { + populateLocalStoresFromVariables(ENTITY_ID, [ + { key: "old1", initialValue: "1", currentValue: "1", secret: true }, + { key: "old2", initialValue: "2", currentValue: "2", secret: true }, + ]) + + populateLocalStoresFromVariables(ENTITY_ID, [ + { key: "new", initialValue: "3", currentValue: "3", secret: true }, + ]) + + expect(secretService.getSecretEnvironment(ENTITY_ID)).toEqual([ + { + key: "new", + value: "3", + initialValue: "3", + varIndex: 0, + }, + ]) + }) +}) + +describe("populateLocalStoresFromCollectionTree", () => { + let secretService: SecretEnvironmentService + let currentValueService: CurrentValueService + + beforeEach(() => { + secretService = getService(SecretEnvironmentService) + currentValueService = getService(CurrentValueService) + secretService.secretEnvironments.clear() + currentValueService.environments.clear() + }) + + const buildCollection = ( + refId: string, + variables: HoppCollection["variables"], + folders: HoppCollection[] = [] + ): HoppCollection => ({ + v: 12, + name: `coll-${refId}`, + _ref_id: refId, + folders, + requests: [], + auth: { authType: "inherit", authActive: true }, + headers: [], + variables, + description: null, + preRequestScript: "", + testScript: "", + }) + + it("populates the secret store for the root collection", () => { + populateLocalStoresFromCollectionTree( + buildCollection("root", [ + { key: "tok", initialValue: "init", currentValue: "cur", secret: true }, + ]) + ) + + expect(secretService.getSecretEnvironment("root")).toEqual([ + { + key: "tok", + value: "cur", + initialValue: "init", + varIndex: 0, + }, + ]) + }) + + it("recurses into nested folders, populating each by its own _ref_id", () => { + const child = buildCollection("child-1", [ + { + key: "child-tok", + initialValue: "ci", + currentValue: "cc", + secret: true, + }, + ]) + const grandchild = buildCollection("gc-1", [ + { key: "gc-tok", initialValue: "gi", currentValue: "gc", secret: true }, + ]) + child.folders = [grandchild] + + populateLocalStoresFromCollectionTree( + buildCollection( + "root", + [ + { + key: "r-tok", + initialValue: "ri", + currentValue: "rc", + secret: true, + }, + ], + [child] + ) + ) + + expect(secretService.getSecretEnvironment("root")).toBeDefined() + expect(secretService.getSecretEnvironment("child-1")).toBeDefined() + expect(secretService.getSecretEnvironment("gc-1")).toEqual([ + { + key: "gc-tok", + value: "gc", + initialValue: "gi", + varIndex: 0, + }, + ]) + }) + + it("skips a node that has no _ref_id but still recurses into its folders", () => { + const child = buildCollection("child-1", [ + { key: "tok", initialValue: "i", currentValue: "c", secret: true }, + ]) + + const root = buildCollection("placeholder", [], [child]) + delete (root as Partial)._ref_id + + populateLocalStoresFromCollectionTree(root) + + expect(secretService.getSecretEnvironment("child-1")).toBeDefined() + expect(secretService.getSecretEnvironment("placeholder")).toBeUndefined() + }) + + it("promotes initialValue to value for foreign-import secrets (Postman/Insomnia)", () => { + // `postman.ts` and `insomnia/insomniaColl.ts` set `currentValue: ""` while + // putting the secret in `initialValue`. The collection-tree populate must + // promote so the imported secret survives in the secret service. + populateLocalStoresFromCollectionTree( + buildCollection("postman-style", [ + { + key: "api_key", + initialValue: "pm-secret", + currentValue: "", + secret: true, + }, + ]) + ) + + expect(secretService.getSecretEnvironment("postman-style")).toEqual([ + { + key: "api_key", + value: "pm-secret", + initialValue: "pm-secret", + varIndex: 0, + }, + ]) + }) +}) + +describe("indexCollectionsByRefId + repopulateLoadedCollectionTree", () => { + let secretService: SecretEnvironmentService + let currentValueService: CurrentValueService + + beforeEach(() => { + secretService = getService(SecretEnvironmentService) + currentValueService = getService(CurrentValueService) + secretService.secretEnvironments.clear() + currentValueService.environments.clear() + }) + + const buildCollection = ( + refId: string, + variables: HoppCollection["variables"], + folders: HoppCollection[] = [] + ): HoppCollection => ({ + v: 12, + name: `coll-${refId}`, + _ref_id: refId, + folders, + requests: [], + auth: { authType: "inherit", authActive: true }, + headers: [], + variables, + description: null, + preRequestScript: "", + testScript: "", + }) + + // The key invariant: if the backend reorders the loaded tree, the + // repopulate logic must still pair each loaded node with its original + // by `_ref_id` rather than by array index. Otherwise secrets would + // re-key onto the wrong collection. + it("re-keys secrets by ref-id when the loaded tree is reordered", () => { + const originalA = buildCollection("ref-a", [ + { key: "a-tok", initialValue: "ai", currentValue: "ac", secret: true }, + ]) + const originalB = buildCollection("ref-b", [ + { key: "b-tok", initialValue: "bi", currentValue: "bc", secret: true }, + ]) + + const originalsByRefId = new Map() + indexCollectionsByRefId([originalA, originalB], originalsByRefId) + + // Backend round-trip: same ref-ids preserved via `data._ref_id`, + // but order is reversed. Variables on the loaded tree are stripped + // (initialValue: "" for secrets, currentValue: "" everywhere). + const stripped = (refId: string, key: string) => + buildCollection(refId, [ + { key, initialValue: "", currentValue: "", secret: true }, + ]) + const loadedReordered = [ + stripped("ref-b", "b-tok"), + stripped("ref-a", "a-tok"), + ] + + loadedReordered.forEach((c) => + repopulateLoadedCollectionTree(c, originalsByRefId) + ) + + expect(secretService.getSecretEnvironment("ref-a")).toEqual([ + { key: "a-tok", value: "ac", initialValue: "ai", varIndex: 0 }, + ]) + expect(secretService.getSecretEnvironment("ref-b")).toEqual([ + { key: "b-tok", value: "bc", initialValue: "bi", varIndex: 0 }, + ]) + }) + + it("indexes nested folders into the same flat map", () => { + const grandchild = buildCollection("gc", []) + const child = buildCollection("child", [], [grandchild]) + const root = buildCollection("root", [], [child]) + + const map = new Map() + indexCollectionsByRefId([root], map) + + expect([...map.keys()].sort()).toEqual(["child", "gc", "root"]) + expect(map.get("gc")).toBe(grandchild) + }) + + it("skips loaded nodes whose ref-id is absent from the original index", () => { + const original = buildCollection("ref-a", [ + { key: "a-tok", initialValue: "ai", currentValue: "ac", secret: true }, + ]) + const map = new Map() + indexCollectionsByRefId([original], map) + + // Backend dropped the ref-id round-trip on this node — the loaded + // tree carries a fresh ref-id that the original index doesn't know. + const orphanLoaded = buildCollection("ref-a-fresh", [ + { key: "a-tok", initialValue: "", currentValue: "", secret: true }, + ]) + + repopulateLoadedCollectionTree(orphanLoaded, map) + + // Nothing was populated for the unknown ref-id, and the original's + // ref-id is also untouched (no spurious cross-population). + expect(secretService.getSecretEnvironment("ref-a-fresh")).toBeUndefined() + expect(secretService.getSecretEnvironment("ref-a")).toBeUndefined() + }) + + it("recurses through nested folders and re-keys each by its own ref-id", () => { + const childOriginal = buildCollection("child", [ + { key: "ck", initialValue: "ci", currentValue: "cc", secret: true }, + ]) + const rootOriginal = buildCollection( + "root", + [{ key: "rk", initialValue: "ri", currentValue: "rc", secret: true }], + [childOriginal] + ) + + const map = new Map() + indexCollectionsByRefId([rootOriginal], map) + + // Loaded tree from backend: same ref-ids, stripped variables. + const childLoaded = buildCollection("child", [ + { key: "ck", initialValue: "", currentValue: "", secret: true }, + ]) + const rootLoaded = buildCollection( + "root", + [{ key: "rk", initialValue: "", currentValue: "", secret: true }], + [childLoaded] + ) + + repopulateLoadedCollectionTree(rootLoaded, map) + + expect(secretService.getSecretEnvironment("root")).toEqual([ + { key: "rk", value: "rc", initialValue: "ri", varIndex: 0 }, + ]) + expect(secretService.getSecretEnvironment("child")).toEqual([ + { key: "ck", value: "cc", initialValue: "ci", varIndex: 0 }, + ]) + }) + + it("promotes foreign-import secrets when re-seeding after backend round-trip", () => { + // Selfhost-web personal-workspace flow: user imports a Postman collection, + // backend round-trip succeeds, then `repopulateLoadedCollectionTree` re-seeds + // local stores from the pre-strip original. The original still carries the + // Postman convention (`currentValue: ""` + `initialValue: "X"` for secrets), + // so the re-seed must promote — otherwise the round-trip would overwrite + // the secret service entry that the earlier collection-tree populate set. + const original = buildCollection("ref-pm", [ + { + key: "api_key", + initialValue: "pm-secret", + currentValue: "", + secret: true, + }, + ]) + + const map = new Map() + indexCollectionsByRefId([original], map) + + const loaded = buildCollection("ref-pm", [ + { key: "api_key", initialValue: "", currentValue: "", secret: true }, + ]) + + repopulateLoadedCollectionTree(loaded, map) + + expect(secretService.getSecretEnvironment("ref-pm")).toEqual([ + { + key: "api_key", + value: "pm-secret", + initialValue: "pm-secret", + varIndex: 0, + }, + ]) + }) +}) + +describe("flushUnmatchedRefIdsFromTree", () => { + let secretService: SecretEnvironmentService + let currentValueService: CurrentValueService + + beforeEach(() => { + secretService = getService(SecretEnvironmentService) + currentValueService = getService(CurrentValueService) + secretService.secretEnvironments.clear() + currentValueService.environments.clear() + }) + + const buildCollection = ( + refId: string, + variables: HoppCollection["variables"], + folders: HoppCollection[] = [] + ): HoppCollection => ({ + v: 12, + name: `coll-${refId}`, + _ref_id: refId, + folders, + requests: [], + auth: { authType: "inherit", authActive: true }, + headers: [], + variables, + description: null, + preRequestScript: "", + testScript: "", + }) + + it("deletes entries whose `_ref_id` is not in the kept set", () => { + secretService.addSecretEnvironment("ref-orphan", [ + { key: "k", value: "v", initialValue: "v", varIndex: 0 }, + ]) + currentValueService.addEnvironment("ref-orphan", [ + { key: "k", currentValue: "v", varIndex: 0, isSecret: false }, + ]) + + flushUnmatchedRefIdsFromTree([buildCollection("ref-orphan", [])], new Set()) + + expect(secretService.getSecretEnvironment("ref-orphan")).toBeUndefined() + expect(currentValueService.getEnvironment("ref-orphan")).toBeUndefined() + }) + + it("keeps entries whose `_ref_id` is in the kept set", () => { + secretService.addSecretEnvironment("ref-kept", [ + { key: "k", value: "v", initialValue: "v", varIndex: 0 }, + ]) + + flushUnmatchedRefIdsFromTree( + [buildCollection("ref-kept", [])], + new Set(["ref-kept"]) + ) + + expect(secretService.getSecretEnvironment("ref-kept")).toBeDefined() + }) + + it("recurses into folders, flushing each unmatched descendant", () => { + secretService.addSecretEnvironment("ref-root", [ + { key: "k", value: "v", initialValue: "v", varIndex: 0 }, + ]) + secretService.addSecretEnvironment("ref-child", [ + { key: "k", value: "v", initialValue: "v", varIndex: 0 }, + ]) + secretService.addSecretEnvironment("ref-kept", [ + { key: "k", value: "v", initialValue: "v", varIndex: 0 }, + ]) + + const child = buildCollection("ref-child", []) + const root = buildCollection("ref-root", [], [child]) + const kept = buildCollection("ref-kept", []) + + flushUnmatchedRefIdsFromTree([root, kept], new Set(["ref-kept"])) + + expect(secretService.getSecretEnvironment("ref-root")).toBeUndefined() + expect(secretService.getSecretEnvironment("ref-child")).toBeUndefined() + expect(secretService.getSecretEnvironment("ref-kept")).toBeDefined() + }) +}) + +describe("flushLocalStoresForTeamCollectionTree", () => { + let secretService: SecretEnvironmentService + let currentValueService: CurrentValueService + + beforeEach(() => { + secretService = getService(SecretEnvironmentService) + currentValueService = getService(CurrentValueService) + secretService.secretEnvironments.clear() + currentValueService.environments.clear() + }) + + it("deletes the top-level entry by team backend `id`", () => { + secretService.addSecretEnvironment("team-coll-1", [ + { key: "k", value: "v", initialValue: "v", varIndex: 0 }, + ]) + currentValueService.addEnvironment("team-coll-1", [ + { key: "k", currentValue: "v", varIndex: 0, isSecret: false }, + ]) + + flushLocalStoresForTeamCollectionTree({ + id: "team-coll-1", + children: null, + }) + + expect(secretService.getSecretEnvironment("team-coll-1")).toBeUndefined() + expect(currentValueService.getEnvironment("team-coll-1")).toBeUndefined() + }) + + it("recurses into `children` and flushes every descendant", () => { + secretService.addSecretEnvironment("root", [ + { key: "k", value: "v", initialValue: "v", varIndex: 0 }, + ]) + secretService.addSecretEnvironment("child-1", [ + { key: "k", value: "v", initialValue: "v", varIndex: 0 }, + ]) + secretService.addSecretEnvironment("grandchild", [ + { key: "k", value: "v", initialValue: "v", varIndex: 0 }, + ]) + + flushLocalStoresForTeamCollectionTree({ + id: "root", + children: [ + { + id: "child-1", + children: [{ id: "grandchild", children: null }], + }, + ], + }) + + expect(secretService.getSecretEnvironment("root")).toBeUndefined() + expect(secretService.getSecretEnvironment("child-1")).toBeUndefined() + expect(secretService.getSecretEnvironment("grandchild")).toBeUndefined() + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/actions.ts b/packages/hoppscotch-common/src/helpers/actions.ts new file mode 100644 index 0000000..1d4f639 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/actions.ts @@ -0,0 +1,425 @@ +/* An `action` is a unique verb that is associated with certain thing that can be done on Hoppscotch. + * For example, sending a request. + */ + +import { Ref, onBeforeUnmount, onMounted, reactive, watch, computed } from "vue" +import { BehaviorSubject } from "rxjs" +import { HoppRequestDocument } from "./rest/document" +import { Environment, HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data" +import { RESTOptionTabs } from "~/components/http/RequestOptions.vue" +import { HoppGQLSaveContext } from "./graphql/document" +import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue" +import { getKernelMode } from "@hoppscotch/kernel" +import { invoke } from "@tauri-apps/api/core" +import { undo, redo, toggleComment } from "@codemirror/commands" +import { EditorView } from "@codemirror/view" +import { isCodeMirrorEditor } from "./utils/dom" + +// Global registry for CodeMirror views +const codeMirrorViews = new WeakMap() + +/** + * Register a CodeMirror view with its DOM element + */ +export function registerCodeMirrorView(element: Element, view: EditorView) { + codeMirrorViews.set(element, view) +} + +/** + * Unregister a CodeMirror view + */ +export function unregisterCodeMirrorView(element: Element) { + codeMirrorViews.delete(element) +} + +/** + * Get the CodeMirror EditorView instance from a DOM element + */ +function getCodeMirrorView(element: Element): EditorView | null { + const editorElement = element.closest(".cm-editor") + if (editorElement) { + return codeMirrorViews.get(editorElement) || null + } + return null +} + +export type HoppAction = + | "contextmenu.open" // Send/Cancel a Hoppscotch Request + | "request.send-cancel" // Send/Cancel a Hoppscotch Request + | "request.reset" // Clear request data + | "request.share-request" // Share Request + | "request-response.save" // Save Request or Response + | "request.save-as" // Save As + | "request.rename" // Rename request on REST or GraphQL + | "request.method.next" // Select Next Method + | "request.method.prev" // Select Previous Method + | "request.method.get" // Select GET Method + | "request.method.head" // Select HEAD Method + | "request.method.post" // Select POST Method + | "request.method.put" // Select PUT Method + | "request.method.delete" // Select DELETE Method + | "request.import-curl" // Import cURL + | "request.show-code" // Show generated code + | "gql.connect" // Connect to GraphQL endpoint given + | "gql.disconnect" // Disconnect from GraphQL endpoint given + | "tab.close-current" // Close current tab + | "tab.close-other" // Close other tabs + | "tab.open-new" // Open new tab + | "tab.next" // Switch to next tab + | "tab.prev" // Switch to previous tab + | "tab.switch-to-first" // Switch to first tab + | "tab.switch-to-last" // Switch to last tab + | "tab.reopen-closed" // Reopen recently closed tab + | "tab.mru-switch" // Switch to MRU tab (Ctrl/Cmd+Alt+]) + | "tab.mru-switch-reverse" // Switch to previous MRU tab (Ctrl/Cmd+Alt+[) + | "request.focus-url" // Focus the URL bar + | "collection.new" // Create root collection + | "flyouts.chat.open" // Shows the keybinds flyout + | "flyouts.keybinds.toggle" // Shows the keybinds flyout + | "modals.collection.import" // Shows the collection import modal + | "modals.search.toggle" // Shows the search modal + | "modals.support.toggle" // Shows the support modal + | "modals.share.toggle" // Shows the share modal + | "modals.environment.add" // Show add environment modal via context menu + | "modals.environment.new" // Add new environment + | "modals.environment.delete-selected" // Delete Selected Environment + | "modals.my.environment.edit" // Edit current personal environment + | "modals.global.environment.update" // Update global environment + | "modals.team.environment.edit" // Edit current team environment + | "modals.team.new" // Add new team + | "modals.team.edit" // Edit selected team + | "modals.team.invite" // Invite selected team + | "workspace.switch.personal" // Switch to personal workspace + | "navigation.jump.rest" // Jump to REST page + | "navigation.jump.graphql" // Jump to GraphQL page + | "navigation.jump.realtime" // Jump to realtime page + | "navigation.jump.documentation" // Jump to documentation page + | "navigation.jump.settings" // Jump to settings page + | "navigation.jump.profile" // Jump to profile page + | "settings.theme.system" // Use system theme + | "settings.theme.light" // Use light theme + | "settings.theme.dark" // Use dark theme + | "settings.theme.black" // Use black theme + | "response.preview.toggle" // Toggle response preview + | "response.schema.toggle" // Toggle response data schema + | "response.file.download" // Download response as file + | "response.copy" // Copy response to clipboard + | "response.erase" // Erase/clear response + | "response.save" // Save response + | "response.save-as-example" // Save response as example + | "modals.login.toggle" // Login to Hoppscotch + | "modals.instance-switcher.toggle" // Switch Hoppscotch instances (self-hosted) + | "history.clear" // Clear REST History + | "user.login" // Login to Hoppscotch + | "user.logout" // Log out of Hoppscotch + | "editor.format" // Format editor content + | "editor.undo" // Undo editor content + | "editor.redo" // Redo editor content + | "editor.comment-toggle" // Toggle comment in editor + | "modals.team.delete" // Delete team + | "workspace.switch" // Switch workspace + | "rest.request.open" // Open REST request + | "request.open-tab" // Open REST request + | "share.request" // Share REST request + | "tab.duplicate-tab" // Duplicate REST request + | "gql.request.open" // Open GraphQL request + | "app.quit" // Quit app + +/** + * Defines the arguments, if present for a given type that is required to be passed on + * invocation and will be passed to action handlers. + * + * This type is supposed to be an object with the key being one of the actions mentioned above. + * The value to the key can be anything. + * If an action has no argument, you do not need to add it to this type. + * + * NOTE: We can't enforce type checks to make sure the key is Action, you + * will know if you got something wrong if there is a type error in this file + */ +type HoppActionArgsMap = { + "contextmenu.open": { + position: { + top: number + left: number + } + text: string | null + } + "modals.global.environment.update": { + variables?: Environment["variables"] + isSecret?: boolean + } + "modals.my.environment.edit": { + envName: string + variableName?: string + isSecret?: boolean + } + "modals.team.environment.edit": { + envName: string + variableName?: string + isSecret?: boolean + } + "modals.team.delete": { + teamId: string + } + "workspace.switch": { + teamId: string + } + "rest.request.open": { + doc: HoppRequestDocument + } + "request.save-as": + | { + requestType: "rest" + request: HoppRESTRequest | null + } + | { + requestType: "gql" + request: HoppGQLRequest + } + | undefined + "request.open-tab": { + tab: RESTOptionTabs | GQLOptionTabs + } + "share.request": { + request: HoppRESTRequest + } + "tab.duplicate-tab": { + tabID?: string + } + "gql.request.open": { + request: HoppGQLRequest + saveContext?: HoppGQLSaveContext + } + "modals.environment.add": { + envName: string + variableName: string + } +} + +type KeysWithValueUndefined = { + [K in keyof T]: undefined extends T[K] ? K : never +}[keyof T] + +/** + * HoppActions which require arguments for their invocation + */ +export type HoppActionWithArgs = keyof HoppActionArgsMap + +/** + * HoppActions which optionally takes in arguments for their invocation + */ + +export type HoppActionWithOptionalArgs = + | HoppActionWithNoArgs + | KeysWithValueUndefined + +/** + * HoppActions which do not require arguments for their invocation + */ +export type HoppActionWithNoArgs = Exclude + +/** + * Resolves the argument type for a given HoppAction + */ +type ArgOfHoppAction = A extends HoppActionWithArgs + ? HoppActionArgsMap[A] + : undefined + +/** + * Resolves the action function for a given HoppAction, used by action handler function defs + */ +type ActionFunc = A extends HoppActionWithArgs + ? (arg: ArgOfHoppAction, trigger?: InvocationTriggers) => void + : (_?: undefined, trigger?: InvocationTriggers) => void + +type BoundActionList = { + [A in HoppAction]?: Array> +} + +const boundActions: BoundActionList = reactive({}) + +export const activeActions$ = new BehaviorSubject([]) + +export function bindAction( + action: A, + handler: ActionFunc +) { + if (boundActions[action]) { + boundActions[action]?.push(handler) + } else { + // 'any' assertion because TypeScript doesn't seem to be able to figure out the links. + boundActions[action] = [handler] as any + } + + activeActions$.next(Object.keys(boundActions) as HoppAction[]) +} + +export type InvocationTriggers = "keypress" | "mouseclick" + +type InvokeActionFunc = { + ( + action: HoppActionWithOptionalArgs, + args?: undefined, + trigger?: InvocationTriggers + ): void + (action: A, args: HoppActionArgsMap[A]): void +} + +/** + * Invokes an action, triggering action handlers if any registered. + * The second and third arguments are optional + * @param action The action to fire + * @param args The argument passed to the action handler. Optional if action has no args required + * @param trigger Optionally supply the trigger that invoked the action (keypress/mouseclick) + */ +export const invokeAction: InvokeActionFunc = ( + action: A, + args?: ArgOfHoppAction, + trigger?: InvocationTriggers +) => { + boundActions[action]?.forEach((handler) => handler(args! as any, trigger)) +} + +export function unbindAction( + action: A, + handler: ActionFunc +) { + // 'any' assertion because TypeScript doesn't seem to be able to figure out the links. + boundActions[action] = boundActions[action]?.filter( + (x) => x !== handler + ) as any + + if (boundActions[action]?.length === 0) { + delete boundActions[action] + } + + activeActions$.next(Object.keys(boundActions) as HoppAction[]) +} + +/** + * Returns a ref that indicates whether a given action is bound at a given time + * + * @param action The action to check + */ +export function isActionBound(action: HoppAction): Ref { + return computed(() => !!boundActions[action]) +} + +/** + * A composable function that defines a component can handle a given + * HoppAction. The handler will be bound when the component is mounted + * and unbound when the component is unmounted. + * @param action The action to be bound + * @param handler The function to be called when the action is invoked + * @param isActive A ref that indicates whether the action is active + */ +export function defineActionHandler( + action: A, + handler: ActionFunc, + isActive: Ref | undefined = undefined +) { + let mounted = false + let bound = false + + onMounted(() => { + mounted = true + + // Only bind if isActive is undefined or true + if (isActive === undefined || isActive.value === true) { + bound = true + bindAction(action, handler) + } + }) + + onBeforeUnmount(() => { + mounted = false + bound = false + + unbindAction(action, handler) + }) + + if (isActive) { + watch( + isActive, + (active) => { + if (mounted) { + if (active) { + if (!bound) { + bound = true + bindAction(action, handler) + } + } else if (bound) { + bound = false + + unbindAction(action, handler) + } + } + }, + { immediate: true } + ) + } +} + +/** + * NOTE: This Sets up core app-level action handlers that + * should be available throughout the app's lifecycle. + * These handlers are bound immediately when the actions module + * is imported and don't depend on some component lifecycle. + */ +function setupCoreActionHandlers() { + // + // This action is triggered by either by + // keyboard shortcut: Cmd+Q (macOS) / Ctrl+Q (Windows/Linux) + // or `invokeAction("app.quit")` + // + // Desktop calls native `quit_app` command to close the app, + // and it's a no-op on web app. + // + // @see {@link https://docs.rs/tauri/latest/tauri/struct.AppHandle.html#method.exit} + // for native `app.exit` docs. + bindAction("app.quit", async () => { + if (getKernelMode() === "desktop") { + try { + await invoke("quit_app") + } catch (error) { + console.error("Failed to quit application:", error) + // NOTE: Don't call window.close() here as a fallback because + // if the native command fails, it likely means this not in + // the proper context, and window.close() would close the wrong thing. + } + } + }) +} + +// Editor action handlers +bindAction("editor.undo", () => { + const activeElement = document.activeElement + if (activeElement && isCodeMirrorEditor(activeElement)) { + const editorView = getCodeMirrorView(activeElement) + if (editorView) { + undo(editorView) + } + } +}) + +bindAction("editor.redo", () => { + const activeElement = document.activeElement + if (activeElement && isCodeMirrorEditor(activeElement)) { + const editorView = getCodeMirrorView(activeElement) + if (editorView) { + redo(editorView) + } + } +}) + +bindAction("editor.comment-toggle", () => { + const activeElement = document.activeElement + if (activeElement && isCodeMirrorEditor(activeElement)) { + const editorView = getCodeMirrorView(activeElement) + if (editorView) { + toggleComment(editorView) + } + } +}) + +setupCoreActionHandlers() diff --git a/packages/hoppscotch-common/src/helpers/app/index.ts b/packages/hoppscotch-common/src/helpers/app/index.ts new file mode 100644 index 0000000..883cb92 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/app/index.ts @@ -0,0 +1,22 @@ +import { platform } from "~/platform" +import { sync } from "~/lib/sync/defs" + +let initialized = false + +export function initializeApp() { + if (!initialized) { + try { + platform.auth.performAuthInit() + sync.settings.initSettingsSync() + sync.collections.initCollectionsSync() + sync.history.initHistorySync() + sync.environments.initEnvironmentsSync() + platform.analytics?.initAnalytics() + + initialized = true + } catch (_e) { + // initializeApp throws exception if we reinitialize + initialized = true + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/auth/auth-types.ts b/packages/hoppscotch-common/src/helpers/auth/auth-types.ts new file mode 100644 index 0000000..d10dcc0 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/auth-types.ts @@ -0,0 +1,82 @@ +import { + Environment, + HoppRESTAuth, + HoppRESTHeader, + HoppRESTParam, + HoppRESTRequest, +} from "@hoppscotch/data" +import { + generateApiKeyAuthHeaders, + generateApiKeyAuthParams, +} from "./types/api-key" +import { + generateAwsSignatureAuthHeaders, + generateAwsSignatureAuthParams, +} from "./types/aws-signature" +import { generateBasicAuthHeaders } from "./types/basic" +import { generateBearerAuthHeaders } from "./types/bearer" +import { generateDigestAuthHeaders } from "./types/digest" +import { generateHawkAuthHeaders } from "./types/hawk" +import { generateJwtAuthHeaders, generateJwtAuthParams } from "./types/jwt" +import { + generateOAuth2AuthHeaders, + generateOAuth2AuthParams, +} from "./types/oauth2" + +/** + * Generate headers for the given auth type using function-based approach + */ +export async function generateAuthHeaders( + auth: HoppRESTAuth, + request: HoppRESTRequest, + envVars: Environment["variables"], + showKeyIfSecret = false +): Promise { + switch (auth.authType) { + case "basic": + return generateBasicAuthHeaders(auth, envVars, showKeyIfSecret) + case "bearer": + return generateBearerAuthHeaders(auth, envVars, showKeyIfSecret) + case "api-key": + return auth.addTo === "HEADERS" + ? generateApiKeyAuthHeaders(auth, envVars, showKeyIfSecret) + : [] + case "oauth-2": + return generateOAuth2AuthHeaders(auth, envVars, showKeyIfSecret) + case "digest": + return generateDigestAuthHeaders(auth, request, envVars, showKeyIfSecret) + case "aws-signature": + return generateAwsSignatureAuthHeaders(auth, request, envVars) + case "hawk": + return generateHawkAuthHeaders(auth, request, envVars, showKeyIfSecret) + case "jwt": + return generateJwtAuthHeaders(auth, envVars, showKeyIfSecret) + default: + return [] + } +} + +/** + * Generate query parameters for the given auth type using function-based approach + */ +export async function generateAuthParams( + auth: HoppRESTAuth, + request: HoppRESTRequest, + envVars: Environment["variables"], + showKeyIfSecret = false +): Promise { + switch (auth.authType) { + case "api-key": + return auth.addTo === "QUERY_PARAMS" + ? generateApiKeyAuthParams(auth, envVars, showKeyIfSecret) + : [] + case "oauth-2": + return generateOAuth2AuthParams(auth, envVars, showKeyIfSecret) + case "aws-signature": + return generateAwsSignatureAuthParams(auth, request, envVars) + case "jwt": + return generateJwtAuthParams(auth, envVars) + default: + return [] + } +} diff --git a/packages/hoppscotch-common/src/helpers/auth/digest.ts b/packages/hoppscotch-common/src/helpers/auth/digest.ts new file mode 100644 index 0000000..a61b1c0 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/digest.ts @@ -0,0 +1,164 @@ +import * as E from "fp-ts/Either" +import { md5 } from "js-md5" + +import { getService } from "~/modules/dioc" +import { getI18n } from "~/modules/i18n" +import { KernelInterceptorService } from "~/services/kernel-interceptor.service" + +export interface DigestAuthParams { + username: string + password: string + realm: string + nonce: string + endpoint: string + method: string + algorithm: string + qop: string + nc?: string + opaque?: string + cnonce?: string // client nonce (optional but typically required in qop='auth') + reqBody?: string +} + +// Function to generate Digest Auth Header +export async function generateDigestAuthHeader(params: DigestAuthParams) { + const { + username, + password, + realm, + nonce, + endpoint, + method, + algorithm = "MD5", + qop, + nc = "00000001", + opaque, + cnonce, + reqBody = " ", + } = params + + const url = new URL(endpoint) + const uri = url.pathname + url.search + + // Generate client nonce if not provided + const generatedCnonce = cnonce || md5(`${Math.random()}`) + + // Step 1: Hash the username, realm, password and any additional fields based on the algorithm + const ha1 = + algorithm === "MD5-sess" + ? md5( + `${md5(`${username}:${realm}:${password}`)}:${nonce}:${generatedCnonce}` + ) + : md5(`${username}:${realm}:${password}`) + + // Step 2: Hash the method and URI + const ha2 = + qop === "auth-int" + ? md5(`${method}:${uri}:${md5(reqBody)}`) // Entity body hash for `auth-int` + : md5(`${method}:${uri}`) + + // Step 3: Compute the response hash + const response = md5(`${ha1}:${nonce}:${nc}:${generatedCnonce}:${qop}:${ha2}`) + + // Build the Digest header + let authHeader = `Digest username="${username}", realm="${realm}", nonce="${nonce}", uri="${uri}", algorithm="${algorithm}", response="${response}", qop=${qop}, nc=${nc}, cnonce="${generatedCnonce}"` + + if (opaque) { + authHeader += `, opaque="${opaque}"` + } + + return authHeader +} + +export interface DigestAuthInfo { + realm: string + nonce: string + qop: string + opaque?: string + algorithm: string +} + +export async function fetchInitialDigestAuthInfo( + url: string, + method: string +): Promise { + const t = getI18n() + + try { + const interceptorService = getService(KernelInterceptorService) + const exec = await interceptorService.execute({ + id: Date.now(), + url, + method, + version: "HTTP/1.1", + }) + + const initialResponse = await exec.response + + if (E.isLeft(initialResponse)) { + const initialFetchFailureReason = + initialResponse.left === "cancellation" + ? initialResponse.left + : initialResponse.left.humanMessage.heading(t) + + throw new Error(initialFetchFailureReason) + } + + // Check if the response status is 401 (which is expected in Digest Auth flow) + if (initialResponse.right.status === 401) { + const authHeaderEntry = Object.keys(initialResponse.right.headers).find( + (header) => header.toLowerCase() === "www-authenticate" + ) + + const authHeader = authHeaderEntry + ? (initialResponse.right.headers[authHeaderEntry] ?? null) + : null + + if (authHeader) { + const authParams = parseDigestAuthHeader(authHeader) + if ( + authParams && + authParams.realm && + authParams.nonce && + authParams.qop + ) { + return { + realm: authParams.realm, + nonce: authParams.nonce, + qop: authParams.qop, + opaque: authParams.opaque, + algorithm: authParams.algorithm, + } + } + } + + throw new Error( + "Failed to parse authentication parameters from WWW-Authenticate header" + ) + } + + throw new Error(`Unexpected response: ${initialResponse.right.status}`) + } catch (error) { + const errMsg = error instanceof Error ? error.message : error + + console.error(`Failed to fetch initial Digest Auth info: ${errMsg}`) + + throw error // Re-throw the error to handle it further up the chain if needed + } +} + +// Utility function to parse Digest auth header values +function parseDigestAuthHeader( + header: string +): { [key: string]: string } | null { + const matches = header.match(/([a-z0-9]+)="([^"]+)"/gi) + if (!matches) return null + + const authParams: { [key: string]: string } = {} + matches.forEach((match) => { + const parts = match.split("=") + authParams[parts[0]] = parts[1].replace(/"/g, "") + }) + + return authParams +} diff --git a/packages/hoppscotch-common/src/helpers/auth/index.ts b/packages/hoppscotch-common/src/helpers/auth/index.ts new file mode 100644 index 0000000..f340238 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/index.ts @@ -0,0 +1,62 @@ +import { getService } from "~/modules/dioc" +import { RESTTabService } from "~/services/tab/rest" +import { parseTemplateStringE } from "@hoppscotch/data" +import * as E from "fp-ts/Either" +import { getCombinedEnvVariables } from "../utils/environments" + +export const replaceTemplateStringsInObjectValues = < + T extends Record, +>( + obj: T, + source: "REST" | "GQL" = "REST" +) => { + const envs = getCombinedEnvVariables() + const restTabsService = getService(RESTTabService) + + const requestVariables = + source === "REST" && + restTabsService.currentActiveTab.value.document.type === "request" + ? restTabsService.currentActiveTab.value.document.request.requestVariables.map( + ({ key, value }) => ({ + key, + initialValue: value, + currentValue: value, + secret: false, + }) + ) + : [] + + // Ensure request variables are prioritized by removing any selected/global environment variables with the same key + const selectedEnvVars = envs.selected.filter( + ({ key }) => + !requestVariables.some(({ key: reqVarKey }) => reqVarKey === key) + ) + const globalEnvVars = envs.global.filter( + ({ key }) => + !requestVariables.some(({ key: reqVarKey }) => reqVarKey === key) + ) + + const envVars = [...selectedEnvVars, ...globalEnvVars, ...requestVariables] + + const newObj: Partial = {} + + for (const key in obj) { + const val = obj[key] + + if (typeof val === "string") { + const parseResult = parseTemplateStringE(val, envVars) + + newObj[key] = E.isRight(parseResult) + ? (parseResult.right as T[typeof key]) + : (val as T[typeof key]) + } else { + newObj[key] = val + } + } + + return newObj as T +} + +export const replaceTemplateString = (str: string): string => { + return replaceTemplateStringsInObjectValues({ value: str }).value +} diff --git a/packages/hoppscotch-common/src/helpers/auth/types/__tests__/api-key.spec.ts b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/api-key.spec.ts new file mode 100644 index 0000000..d8f0ffb --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/api-key.spec.ts @@ -0,0 +1,93 @@ +import { HoppRESTAuth } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" +import { generateApiKeyAuthHeaders, generateApiKeyAuthParams } from "../api-key" +import { mockEnvVars } from "./test-utils" + +describe("API Key Auth", () => { + describe("generateApiKeyAuthHeaders", () => { + test("generates headers when addTo is HEADERS", async () => { + const auth: HoppRESTAuth & { authType: "api-key" } = { + authActive: true, + authType: "api-key", + addTo: "HEADERS", + key: "X-API-Key", + value: "<>", + } + + const headers = await generateApiKeyAuthHeaders(auth, mockEnvVars) + + expect(headers).toHaveLength(1) + expect(headers[0]).toEqual({ + active: true, + key: "X-API-Key", + value: "secret-value", + description: "", + }) + }) + + test("returns empty array when addTo is not HEADERS", async () => { + const auth: HoppRESTAuth & { authType: "api-key" } = { + authActive: true, + authType: "api-key", + addTo: "QUERY_PARAMS", + key: "api_key", + value: "test-value", + } + + const headers = await generateApiKeyAuthHeaders(auth, mockEnvVars) + + expect(headers).toHaveLength(0) + }) + + test("handles template strings in key and value", async () => { + const auth: HoppRESTAuth & { authType: "api-key" } = { + authActive: true, + authType: "api-key", + addTo: "HEADERS", + key: "<>", + value: "<>", + } + + const headers = await generateApiKeyAuthHeaders(auth, mockEnvVars) + + expect(headers[0].key).toBe("test-key-123") + expect(headers[0].value).toBe("secret-value") + }) + }) + + describe("generateApiKeyAuthParams", () => { + test("generates params when addTo is QUERY_PARAMS", async () => { + const auth: HoppRESTAuth & { authType: "api-key" } = { + authActive: true, + authType: "api-key", + addTo: "QUERY_PARAMS", + key: "api_key", + value: "<>", + } + + const params = await generateApiKeyAuthParams(auth, mockEnvVars) + + expect(params).toHaveLength(1) + expect(params[0]).toEqual({ + active: true, + key: "api_key", + value: "secret-value", + description: "", + }) + }) + + test("returns empty array when addTo is not QUERY_PARAMS", async () => { + const auth: HoppRESTAuth & { authType: "api-key" } = { + authActive: true, + authType: "api-key", + addTo: "HEADERS", + key: "X-API-Key", + value: "test-value", + } + + const params = await generateApiKeyAuthParams(auth, mockEnvVars) + + expect(params).toHaveLength(0) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/auth/types/__tests__/aws-signature.spec.ts b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/aws-signature.spec.ts new file mode 100644 index 0000000..96c05e9 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/aws-signature.spec.ts @@ -0,0 +1,413 @@ +import type { Environment, HoppRESTAuth } from "@hoppscotch/data" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { + generateAwsSignatureAuthHeaders, + generateAwsSignatureAuthParams, +} from "../aws-signature" +import { createBaseRequest } from "./test-utils" + +vi.mock("aws4fetch", () => ({ + AwsV4Signer: vi.fn().mockImplementation(function (config) { + return { + sign: vi.fn().mockImplementation(function () { + return Promise.resolve({ + headers: new Map([ + [ + "Authorization", + "AWS4-HMAC-SHA256 Credential=test-key/20240101/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=test-signature", + ], + ["X-Amz-Date", "20240101T120000Z"], + ["Host", "s3.amazonaws.com"], + ]), + url: new URL(config.url), + }) + }), + } + }), +})) + +vi.mock("~/helpers/utils/EffectiveURL", () => ({ + getFinalBodyFromRequest: vi.fn().mockReturnValue("test body"), +})) + +describe("AWS Signature Auth", () => { + const mockEnvVars: Environment["variables"] = [ + { + key: "AWS_ACCESS_KEY", + secret: false, + initialValue: "test-access-key", + currentValue: "test-access-key", + }, + { + key: "AWS_SECRET_KEY", + secret: true, + initialValue: "test-secret-key", + currentValue: "test-secret-key", + }, + { + key: "AWS_REGION", + secret: false, + initialValue: "us-east-1", + currentValue: "us-east-1", + }, + { + key: "AWS_SERVICE", + secret: false, + initialValue: "s3", + currentValue: "s3", + }, + ] + + // Helper function to create base auth configuration + const createBaseAuth = ( + overrides: Partial = {} + ): HoppRESTAuth & { authType: "aws-signature" } => ({ + authType: "aws-signature", + authActive: true, + addTo: "HEADERS", + accessKey: "test-access-key", + secretKey: "test-secret-key", + region: "us-east-1", + serviceName: "s3", + serviceToken: "", + ...overrides, + }) + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.setSystemTime(new Date("2024-01-01T12:00:00Z")) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe("generateAwsSignatureAuthHeaders", () => { + test("should return empty array when addTo is not HEADERS", async () => { + const auth = createBaseAuth({ addTo: "QUERY_PARAMS" }) + const request = createBaseRequest() + + const result = await generateAwsSignatureAuthHeaders( + auth, + request, + mockEnvVars + ) + expect(result).toEqual([]) + }) + + test("should generate AWS signature headers correctly", async () => { + const auth = createBaseAuth() // uses default HEADERS addTo + const request = createBaseRequest() + + const result = await generateAwsSignatureAuthHeaders( + auth, + request, + mockEnvVars + ) + + expect(result).toHaveLength(3) + expect(result).toEqual([ + { + active: true, + key: "Authorization", + value: + "AWS4-HMAC-SHA256 Credential=test-key/20240101/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=test-signature", + description: "", + }, + { + active: true, + key: "X-Amz-Date", + value: "20240101T120000Z", + description: "", + }, + { + active: true, + key: "Host", + value: "s3.amazonaws.com", + description: "", + }, + ]) + }) + + test("should parse template strings for auth parameters", async () => { + const auth = createBaseAuth({ + accessKey: "<>", + secretKey: "<>", + region: "<>", + serviceName: "<>", + }) + const request = createBaseRequest() + + const result = await generateAwsSignatureAuthHeaders( + auth, + request, + mockEnvVars + ) + expect(result).toHaveLength(3) + }) + + test("should handle request parameters and sort them alphabetically", async () => { + const auth = createBaseAuth() + const request = createBaseRequest({ + params: [ + { active: true, key: "z-param", value: "value1", description: "" }, + { active: true, key: "a-param", value: "value2", description: "" }, + { active: false, key: "inactive", value: "value3", description: "" }, + { active: true, key: "", value: "empty-key", description: "" }, + ], + }) + + const result = await generateAwsSignatureAuthHeaders( + auth, + request, + mockEnvVars + ) + expect(result).toHaveLength(3) + }) + + test("should handle session token when provided", async () => { + const auth = createBaseAuth({ serviceToken: "test-session-token" }) + const request = createBaseRequest() + + const result = await generateAwsSignatureAuthHeaders( + auth, + request, + mockEnvVars + ) + expect(result).toHaveLength(3) + }) + + test("should default to us-east-1 region when region is empty", async () => { + const auth = createBaseAuth({ region: "" }) + const request = createBaseRequest() + + const result = await generateAwsSignatureAuthHeaders( + auth, + request, + mockEnvVars + ) + expect(result).toHaveLength(3) + }) + }) + + describe("generateAwsSignatureAuthParams", () => { + test("should return empty array when addTo is not QUERY_PARAMS", async () => { + const auth = createBaseAuth({ addTo: "HEADERS" }) + const request = createBaseRequest() + + const result = await generateAwsSignatureAuthParams( + auth, + request, + mockEnvVars + ) + expect(result).toEqual([]) + }) + + test("should generate AWS signature query parameters correctly", async () => { + const { AwsV4Signer } = await import("aws4fetch") + vi.mocked(AwsV4Signer).mockImplementation(function (config) { + return { + sign: vi.fn().mockImplementation(function () { + return Promise.resolve({ + headers: new Map(), + url: new URL( + config.url + + "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test-key%2F20240101%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20240101T120000Z&X-Amz-SignedHeaders=host&X-Amz-Signature=test-signature" + ), + }) + }), + } + }) + + const auth = createBaseAuth({ addTo: "QUERY_PARAMS" }) + const request = createBaseRequest() + + const result = await generateAwsSignatureAuthParams( + auth, + request, + mockEnvVars + ) + + expect(result).toHaveLength(5) + expect(result).toEqual([ + { + active: true, + key: "X-Amz-Algorithm", + value: "AWS4-HMAC-SHA256", + description: "", + }, + { + active: true, + key: "X-Amz-Credential", + value: "test-key/20240101/us-east-1/s3/aws4_request", + description: "", + }, + { + active: true, + key: "X-Amz-Date", + value: "20240101T120000Z", + description: "", + }, + { + active: true, + key: "X-Amz-SignedHeaders", + value: "host", + description: "", + }, + { + active: true, + key: "X-Amz-Signature", + value: "test-signature", + description: "", + }, + ]) + }) + + test("should exclude original request parameters from result", async () => { + const { AwsV4Signer } = await import("aws4fetch") + vi.mocked(AwsV4Signer).mockImplementation(function (config) { + return { + sign: vi.fn().mockImplementation(function () { + return Promise.resolve({ + headers: new Map(), + url: new URL( + config.url + + "?original-param=value&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=test-signature" + ), + }) + }), + } + }) + + const auth = createBaseAuth({ addTo: "QUERY_PARAMS" }) + const request = createBaseRequest({ + params: [ + { + active: true, + key: "original-param", + value: "value", + description: "", + }, + ], + }) + + const result = await generateAwsSignatureAuthParams( + auth, + request, + mockEnvVars + ) + + // only return AWS signature parameters, not the original parameter + expect(result).toHaveLength(2) + expect(result.find((p) => p.key === "original-param")).toBeUndefined() + expect(result.find((p) => p.key === "X-Amz-Algorithm")).toBeDefined() + expect(result.find((p) => p.key === "X-Amz-Signature")).toBeDefined() + }) + + test("should handle template strings in endpoint", async () => { + const { AwsV4Signer } = await import("aws4fetch") + vi.mocked(AwsV4Signer).mockImplementation(function (config) { + return { + sign: vi.fn().mockImplementation(function () { + return Promise.resolve({ + headers: new Map(), + url: new URL( + config.url + + "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=test-signature" + ), + }) + }), + } + }) + + const auth = createBaseAuth({ addTo: "QUERY_PARAMS" }) + + const envVarsWithHost: Environment["variables"] = [ + ...mockEnvVars, + { + key: "HOST", + secret: false, + initialValue: "s3.amazonaws.com", + currentValue: "s3.amazonaws.com", + }, + ] + + const request = createBaseRequest({ + endpoint: "https://<>/bucket/key", + }) + + const result = await generateAwsSignatureAuthParams( + auth, + request, + envVarsWithHost + ) + expect(result).toHaveLength(2) + }) + + test("should sort existing parameters alphabetically before signing", async () => { + const { AwsV4Signer } = await import("aws4fetch") + vi.mocked(AwsV4Signer).mockImplementation(function (config) { + return { + sign: vi.fn().mockImplementation(function () { + return Promise.resolve({ + headers: new Map(), + url: new URL( + config.url + + "?z-param=value1&a-param=value2&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=test-signature" + ), + }) + }), + } + }) + + const auth = createBaseAuth({ addTo: "QUERY_PARAMS" }) + const request = createBaseRequest({ + params: [ + { active: true, key: "z-param", value: "value1", description: "" }, + { active: true, key: "a-param", value: "value2", description: "" }, + { active: false, key: "inactive", value: "value3", description: "" }, + ], + }) + + const result = await generateAwsSignatureAuthParams( + auth, + request, + mockEnvVars + ) + + // exclude original parameters and only return AWS signature parameters + expect(result).toHaveLength(2) + expect(result.find((p) => p.key === "z-param")).toBeUndefined() + expect(result.find((p) => p.key === "a-param")).toBeUndefined() + expect(result.find((p) => p.key === "inactive")).toBeUndefined() + }) + + test("should handle empty or missing session token", async () => { + const { AwsV4Signer } = await import("aws4fetch") + vi.mocked(AwsV4Signer).mockImplementation(function (config) { + return { + sign: vi.fn().mockImplementation(function () { + return Promise.resolve({ + headers: new Map(), + url: new URL( + config.url + + "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=test-signature" + ), + }) + }), + } + }) + + const auth = createBaseAuth({ addTo: "QUERY_PARAMS" }) + const request = createBaseRequest() + + const result = await generateAwsSignatureAuthParams( + auth, + request, + mockEnvVars + ) + expect(result).toHaveLength(2) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/auth/types/__tests__/basic.spec.ts b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/basic.spec.ts new file mode 100644 index 0000000..0b54786 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/basic.spec.ts @@ -0,0 +1,73 @@ +import { HoppRESTAuth } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" +import { generateBasicAuthHeaders } from "../basic" +import { mockEnvVars } from "./test-utils" + +describe("Basic Auth", () => { + describe("generateBasicAuthHeaders", () => { + test("generates basic auth header with credentials", async () => { + const auth: HoppRESTAuth & { authType: "basic" } = { + authActive: true, + authType: "basic", + username: "admin", + password: "secret123", + } + + const headers = await generateBasicAuthHeaders(auth, mockEnvVars) + + expect(headers).toHaveLength(1) + expect(headers[0]).toEqual({ + active: true, + key: "Authorization", + value: `Basic ${btoa("admin:secret123")}`, + description: "", + }) + }) + + test("handles template strings in username and password", async () => { + const auth: HoppRESTAuth & { authType: "basic" } = { + authActive: true, + authType: "basic", + username: "<>", + password: "<>", + } + + const headers = await generateBasicAuthHeaders(auth, mockEnvVars) + + expect(headers[0].value).toBe(`Basic ${btoa("testuser:testpass")}`) + }) + + test("handles empty credentials", async () => { + const auth: HoppRESTAuth & { authType: "basic" } = { + authActive: true, + authType: "basic", + username: "", + password: "", + } + + const headers = await generateBasicAuthHeaders(auth, mockEnvVars) + + expect(headers[0].value).toBe(`Basic ${btoa(":")}`) + }) + + test("resolves secret environment variables before base64 encoding even when `showKeyIfSecret` is `true`", async () => { + const auth: HoppRESTAuth & { authType: "basic" } = { + authActive: true, + authType: "basic", + username: "<>", + password: "<>", + } + + // `showKeyIfSecret = true` should NOT affect base64 encoding + // Previously, this would encode "testuser:<>" instead of "testuser:testpass" + // See: https://github.com/hoppscotch/hoppscotch/issues/5863 + const headers = await generateBasicAuthHeaders( + auth, + mockEnvVars, + true // showKeyIfSecret + ) + + expect(headers[0].value).toBe(`Basic ${btoa("testuser:testpass")}`) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/auth/types/__tests__/bearer.spec.ts b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/bearer.spec.ts new file mode 100644 index 0000000..0bb5975 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/bearer.spec.ts @@ -0,0 +1,52 @@ +import { HoppRESTAuth } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" +import { generateBearerAuthHeaders } from "../bearer" +import { mockEnvVars } from "./test-utils" + +describe("Bearer Auth", () => { + describe("generateBearerAuthHeaders", () => { + test("generates bearer auth header with token", async () => { + const auth: HoppRESTAuth & { authType: "bearer" } = { + authActive: true, + authType: "bearer", + token: "abc123token", + } + + const headers = await generateBearerAuthHeaders(auth, mockEnvVars) + + expect(headers).toHaveLength(1) + expect(headers[0]).toEqual({ + active: true, + key: "Authorization", + value: "Bearer abc123token", + description: "", + }) + }) + + test("handles template strings in token", async () => { + const auth: HoppRESTAuth & { authType: "bearer" } = { + authActive: true, + authType: "bearer", + token: "<>", + } + + const headers = await generateBearerAuthHeaders(auth, mockEnvVars) + + expect(headers[0].value).toBe( + "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ) + }) + + test("handles empty token", async () => { + const auth: HoppRESTAuth & { authType: "bearer" } = { + authActive: true, + authType: "bearer", + token: "", + } + + const headers = await generateBearerAuthHeaders(auth, mockEnvVars) + + expect(headers[0].value).toBe("Bearer ") + }) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/auth/types/__tests__/digest.spec.ts b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/digest.spec.ts new file mode 100644 index 0000000..649f449 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/digest.spec.ts @@ -0,0 +1,557 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" +import { generateDigestAuthHeaders } from "../digest" +import { createBaseRequest, mockEnvVars } from "./test-utils" +import { HoppRESTAuth } from "@hoppscotch/data" + +// Mock the digest helper functions +vi.mock("~/helpers/auth/digest", () => ({ + generateDigestAuthHeader: vi.fn(), + fetchInitialDigestAuthInfo: vi.fn(), +})) + +import { + generateDigestAuthHeader, + fetchInitialDigestAuthInfo, +} from "~/helpers/auth/digest" + +describe("Digest Auth", () => { + beforeEach(() => { + vi.clearAllMocks() + + // Set up default mocks for fetchInitialDigestAuthInfo + vi.mocked(fetchInitialDigestAuthInfo).mockResolvedValue({ + realm: "Default Realm", + nonce: "default-nonce", + qop: "auth", + algorithm: "MD5", + opaque: "", + }) + }) + + describe("generateDigestAuthHeaders", () => { + test("generates digest auth header with basic configuration", async () => { + const mockDigestInfo = { + realm: "Protected Area", + nonce: "abc123", + qop: "auth", + algorithm: "MD5", + nc: "00000001", + cnonce: "", + opaque: "", + } + + const mockDigestHeader = + 'Digest username="testuser", realm="Protected Area", nonce="abc123", uri="/api/data", algorithm="MD5", response="def456", qop=auth, nc=00000001, cnonce="xyz789"' + + vi.mocked(fetchInitialDigestAuthInfo).mockResolvedValue(mockDigestInfo) + vi.mocked(generateDigestAuthHeader).mockResolvedValue(mockDigestHeader) + + const auth: HoppRESTAuth & { authType: "digest" } = { + authActive: true, + authType: "digest", + username: "testuser", + password: "testpass", + realm: "Protected Area", + nonce: "abc123", + algorithm: "MD5", + qop: "auth", + nc: "00000001", + cnonce: "", + opaque: "", + disableRetry: false, + } + + const headers = await generateDigestAuthHeaders( + auth, + createBaseRequest(), + mockEnvVars + ) + + expect(generateDigestAuthHeader).toHaveBeenCalledWith({ + username: "testuser", + password: "testpass", + realm: "Protected Area", + nonce: "abc123", + endpoint: "https://api.example.com/data", + method: "GET", + algorithm: "MD5", + qop: "auth", + opaque: "", + reqBody: "", + }) + + expect(headers).toHaveLength(1) + expect(headers[0]).toEqual({ + active: true, + key: "Authorization", + value: mockDigestHeader, + description: "", + }) + }) + + test("handles MD5-sess algorithm", async () => { + const mockDigestInfo = { + realm: "Test", + nonce: "nonce123", + qop: "auth", + algorithm: "MD5-sess", + nc: "00000001", + cnonce: "", + opaque: "", + } + + const mockDigestHeader = + 'Digest username="user", realm="Test", nonce="nonce123", uri="/api", algorithm="MD5-sess", response="response456", qop=auth, nc=00000001, cnonce="client789"' + + vi.mocked(fetchInitialDigestAuthInfo).mockResolvedValue(mockDigestInfo) + vi.mocked(generateDigestAuthHeader).mockResolvedValue(mockDigestHeader) + + const auth: HoppRESTAuth & { authType: "digest" } = { + authActive: true, + authType: "digest", + username: "user", + password: "pass", + realm: "Test", + nonce: "nonce123", + algorithm: "MD5-sess", + qop: "auth", + nc: "00000001", + cnonce: "", + opaque: "", + disableRetry: false, + } + + const headers = await generateDigestAuthHeaders( + auth, + createBaseRequest(), + mockEnvVars + ) + + expect(generateDigestAuthHeader).toHaveBeenCalledWith({ + username: "user", + password: "pass", + realm: "Test", + nonce: "nonce123", + endpoint: "https://api.example.com/data", + method: "GET", + algorithm: "MD5-sess", + qop: "auth", + opaque: "", + reqBody: "", + }) + + expect(headers[0].value).toBe(mockDigestHeader) + }) + + test("handles auth-int qop with request body", async () => { + const mockDigestHeader = + 'Digest username="user", realm="Protected", nonce="nonce456", uri="/api/update", algorithm="MD5", response="response789", qop=auth-int, nc=00000001, cnonce="client123"' + + vi.mocked(generateDigestAuthHeader).mockResolvedValue(mockDigestHeader) + + const requestWithBody = createBaseRequest({ + method: "POST", + body: { + contentType: "application/json" as const, + body: '{"name": "test", "value": 123}', + }, + }) + + const auth: HoppRESTAuth & { authType: "digest" } = { + authActive: true, + authType: "digest", + username: "user", + password: "pass", + realm: "Protected", + nonce: "nonce456", + algorithm: "MD5", + qop: "auth-int", + nc: "00000001", + cnonce: "", + opaque: "", + disableRetry: false, + } + + const headers = await generateDigestAuthHeaders( + auth, + requestWithBody, + mockEnvVars + ) + + expect(generateDigestAuthHeader).toHaveBeenCalledWith({ + username: "user", + password: "pass", + realm: "Protected", + nonce: "nonce456", + endpoint: "https://api.example.com/data", + method: "POST", + algorithm: "MD5", + qop: "auth-int", + opaque: "", + reqBody: '{"name":"test","value":123}', + }) + + expect(headers[0].value).toBe(mockDigestHeader) + }) + + test("handles template variables in username and password", async () => { + const mockDigestHeader = + 'Digest username="testuser", realm="realm", nonce="nonce", uri="/api/data", algorithm="MD5", response="response", qop=auth, nc=00000001, cnonce="cnonce"' + + vi.mocked(generateDigestAuthHeader).mockResolvedValue(mockDigestHeader) + + const auth: HoppRESTAuth & { authType: "digest" } = { + authActive: true, + authType: "digest", + username: "<>", + password: "<>", + realm: "realm", + nonce: "nonce", + algorithm: "MD5", + qop: "auth", + nc: "00000001", + cnonce: "", + opaque: "", + disableRetry: false, + } + + const headers = await generateDigestAuthHeaders( + auth, + createBaseRequest(), + mockEnvVars + ) + + expect(generateDigestAuthHeader).toHaveBeenCalledWith({ + username: "testuser", + password: "testpass", + realm: "realm", + nonce: "nonce", + endpoint: "https://api.example.com/data", + method: "GET", + algorithm: "MD5", + qop: "auth", + opaque: "", + reqBody: "", + }) + + expect(headers[0].value).toBe(mockDigestHeader) + }) + + test("handles opaque value", async () => { + const mockDigestHeader = + 'Digest username="user", realm="Protected", nonce="nonce123", uri="/api/data", algorithm="MD5", response="response456", qop=auth, nc=00000001, cnonce="cnonce789", opaque="opaque-value-123"' + + vi.mocked(generateDigestAuthHeader).mockResolvedValue(mockDigestHeader) + + const auth: HoppRESTAuth & { authType: "digest" } = { + authActive: true, + authType: "digest", + username: "user", + password: "pass", + realm: "Protected", + nonce: "nonce123", + algorithm: "MD5", + qop: "auth", + nc: "00000001", + cnonce: "cnonce789", + opaque: "opaque-value-123", + disableRetry: false, + } + + const headers = await generateDigestAuthHeaders( + auth, + createBaseRequest(), + mockEnvVars + ) + + expect(generateDigestAuthHeader).toHaveBeenCalledWith({ + username: "user", + password: "pass", + realm: "Protected", + nonce: "nonce123", + endpoint: "https://api.example.com/data", + method: "GET", + algorithm: "MD5", + qop: "auth", + opaque: "opaque-value-123", + reqBody: "", + }) + + expect(headers[0].value).toBe(mockDigestHeader) + }) + + test("auto-generates cnonce when not provided", async () => { + const mockDigestHeader = + 'Digest username="user", realm="realm", nonce="nonce", uri="/api/data", algorithm="MD5", response="response", qop=auth, nc=00000001, cnonce="auto-generated"' + + vi.mocked(generateDigestAuthHeader).mockResolvedValue(mockDigestHeader) + + const auth: HoppRESTAuth & { authType: "digest" } = { + authActive: true, + authType: "digest", + username: "user", + password: "pass", + realm: "realm", + nonce: "nonce", + algorithm: "MD5", + qop: "auth", + nc: "00000001", + cnonce: "", + opaque: "", + disableRetry: false, + } + + const headers = await generateDigestAuthHeaders( + auth, + createBaseRequest(), + mockEnvVars + ) + + expect(generateDigestAuthHeader).toHaveBeenCalledWith({ + username: "user", + password: "pass", + realm: "realm", + nonce: "nonce", + endpoint: "https://api.example.com/data", + method: "GET", + algorithm: "MD5", + qop: "auth", + opaque: "", + reqBody: "", + }) + + expect(headers[0].value).toBe(mockDigestHeader) + }) + + test("handles initial digest auth info fetch", async () => { + const mockDigestInfo = { + realm: "Fetched Realm", + nonce: "fetched-nonce-123", + qop: "auth", + algorithm: "MD5", + opaque: "fetched-opaque", + } + + const mockDigestHeader = + 'Digest username="user", realm="Fetched Realm", nonce="fetched-nonce-123", uri="/api/data", algorithm="MD5", response="response", qop=auth, nc=00000001, cnonce="cnonce"' + + vi.mocked(fetchInitialDigestAuthInfo).mockResolvedValue(mockDigestInfo) + vi.mocked(generateDigestAuthHeader).mockResolvedValue(mockDigestHeader) + + const auth: HoppRESTAuth & { authType: "digest" } = { + authActive: true, + authType: "digest", + username: "user", + password: "pass", + realm: "", // Empty realm should trigger fetch + nonce: "", + algorithm: "MD5", + qop: "auth", + nc: "00000001", + cnonce: "", + opaque: "", + disableRetry: false, + } + + const headers = await generateDigestAuthHeaders( + auth, + createBaseRequest(), + mockEnvVars + ) + + expect(fetchInitialDigestAuthInfo).toHaveBeenCalledWith( + "https://api.example.com/data", + "GET" + ) + + expect(headers[0].value).toBe(mockDigestHeader) + }) + + test("handles empty credentials", async () => { + const mockDigestHeader = + 'Digest username="", realm="realm", nonce="nonce", uri="/api/data", algorithm="MD5", response="response", qop=auth, nc=00000001, cnonce="cnonce"' + + vi.mocked(generateDigestAuthHeader).mockResolvedValue(mockDigestHeader) + + const auth: HoppRESTAuth & { authType: "digest" } = { + authActive: true, + authType: "digest", + username: "", + password: "", + realm: "realm", + nonce: "nonce", + algorithm: "MD5", + qop: "auth", + nc: "00000001", + cnonce: "", + opaque: "", + disableRetry: false, + } + + const headers = await generateDigestAuthHeaders( + auth, + createBaseRequest(), + mockEnvVars + ) + + expect(headers[0].value).toBe(mockDigestHeader) + }) + + test("handles digest auth generation failure", async () => { + vi.mocked(generateDigestAuthHeader).mockResolvedValue("") + + const auth: HoppRESTAuth & { authType: "digest" } = { + authActive: true, + authType: "digest", + username: "user", + password: "pass", + realm: "realm", + nonce: "nonce", + algorithm: "MD5", + qop: "auth", + nc: "00000001", + cnonce: "", + opaque: "", + disableRetry: false, + } + + const headers = await generateDigestAuthHeaders( + auth, + createBaseRequest(), + mockEnvVars + ) + + expect(headers).toHaveLength(1) + expect(headers[0].value).toBe("") + }) + + test("handles different HTTP methods", async () => { + const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] + + for (const method of methods) { + const mockDigestHeader = `Digest username="user", realm="realm", nonce="nonce", uri="/api/data", algorithm="MD5", response="response-${method}", qop=auth, nc=00000001, cnonce="cnonce"` + + vi.mocked(generateDigestAuthHeader).mockResolvedValue(mockDigestHeader) + + const requestWithMethod = createBaseRequest({ + method, + }) + + const auth: HoppRESTAuth & { authType: "digest" } = { + authActive: true, + authType: "digest", + username: "user", + password: "pass", + realm: "realm", + nonce: "nonce", + algorithm: "MD5", + qop: "auth", + nc: "00000001", + cnonce: "", + opaque: "", + disableRetry: false, + } + + const headers = await generateDigestAuthHeaders( + auth, + requestWithMethod, + mockEnvVars + ) + + expect(generateDigestAuthHeader).toHaveBeenCalledWith({ + username: "user", + password: "pass", + realm: "realm", + nonce: "nonce", + endpoint: "https://api.example.com/data", + method, + algorithm: "MD5", + qop: "auth", + opaque: "", + reqBody: "", + }) + + expect(headers[0].value).toBe(mockDigestHeader) + vi.clearAllMocks() + } + }) + + test("handles disable retry flag", async () => { + const mockDigestHeader = + 'Digest username="user", realm="realm", nonce="nonce", uri="/api/data", algorithm="MD5", response="response", qop=auth, nc=00000001, cnonce="cnonce"' + + vi.mocked(generateDigestAuthHeader).mockResolvedValue(mockDigestHeader) + + const auth: HoppRESTAuth & { authType: "digest" } = { + authActive: true, + authType: "digest", + username: "user", + password: "pass", + realm: "realm", + nonce: "nonce", + algorithm: "MD5", + qop: "auth", + nc: "00000001", + cnonce: "", + opaque: "", + disableRetry: true, + } + + const headers = await generateDigestAuthHeaders( + auth, + createBaseRequest(), + mockEnvVars + ) + + expect(headers[0].value).toBe(mockDigestHeader) + // The disableRetry flag would be used in the actual digest auth flow + }) + + test("handles complex URI paths with query parameters", async () => { + const mockDigestHeader = + 'Digest username="user", realm="realm", nonce="nonce", uri="/api/data?param=value&other=test", algorithm="MD5", response="response", qop=auth, nc=00000001, cnonce="cnonce"' + + vi.mocked(generateDigestAuthHeader).mockResolvedValue(mockDigestHeader) + + const requestWithQueryParams = createBaseRequest({ + endpoint: "https://api.example.com/api/data?param=value&other=test", + }) + + const auth: HoppRESTAuth & { authType: "digest" } = { + authActive: true, + authType: "digest", + username: "user", + password: "pass", + realm: "realm", + nonce: "nonce", + algorithm: "MD5", + qop: "auth", + nc: "00000001", + cnonce: "", + opaque: "", + disableRetry: false, + } + + const headers = await generateDigestAuthHeaders( + auth, + requestWithQueryParams, + mockEnvVars + ) + + expect(generateDigestAuthHeader).toHaveBeenCalledWith({ + username: "user", + password: "pass", + realm: "realm", + nonce: "nonce", + endpoint: "https://api.example.com/api/data?param=value&other=test", + method: "GET", + algorithm: "MD5", + qop: "auth", + opaque: "", + reqBody: "", + }) + + expect(headers[0].value).toBe(mockDigestHeader) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/auth/types/__tests__/hawk.spec.ts b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/hawk.spec.ts new file mode 100644 index 0000000..46b8987 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/hawk.spec.ts @@ -0,0 +1,136 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" +import { generateHawkAuthHeaders } from "../hawk" +import { createBaseRequest, mockEnvVars } from "./test-utils" +import { HoppRESTAuth } from "@hoppscotch/data" + +// Mock the calculateHawkHeader function +vi.mock("@hoppscotch/data", async () => { + const actual = await vi.importActual("@hoppscotch/data") + return { + ...actual, + calculateHawkHeader: vi.fn(), + } +}) + +// Mock the getFinalBodyFromRequest function +vi.mock("~/helpers/utils/EffectiveURL", () => ({ + getFinalBodyFromRequest: vi.fn(), +})) + +const { calculateHawkHeader } = await import("@hoppscotch/data") +const { getFinalBodyFromRequest } = await import("~/helpers/utils/EffectiveURL") + +describe("Hawk Auth", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("generateHawkAuthHeaders", () => { + test("generates hawk authorization header", async () => { + vi.mocked(calculateHawkHeader).mockResolvedValue( + 'Hawk id="test-hawk-id", ts="1234567890", nonce="abcdef", mac="xyz123"' + ) + vi.mocked(getFinalBodyFromRequest).mockReturnValue('{"test": "data"}') + + const auth: HoppRESTAuth & { authType: "hawk" } = { + authActive: true, + authType: "hawk", + authId: "<>", + authKey: "<>", + algorithm: "sha256", + includePayloadHash: true, + } + + const headers = await generateHawkAuthHeaders( + auth, + createBaseRequest(), + mockEnvVars + ) + + expect(headers).toHaveLength(1) + expect(headers[0]).toEqual({ + active: true, + key: "Authorization", + value: + 'Hawk id="test-hawk-id", ts="1234567890", nonce="abcdef", mac="xyz123"', + description: "", + }) + }) + + test("returns empty array for non-hawk auth type", async () => { + const auth: HoppRESTAuth & { authType: "basic" } = { + authActive: true, + authType: "basic", + username: "user", + password: "pass", + } + + const headers = await generateHawkAuthHeaders( + auth, + createBaseRequest(), + mockEnvVars + ) + + expect(headers).toHaveLength(0) + }) + + test("handles template strings in auth parameters", async () => { + vi.mocked(calculateHawkHeader).mockResolvedValue( + 'Hawk id="test-hawk-id", mac="xyz123"' + ) + vi.mocked(getFinalBodyFromRequest).mockReturnValue("") + + const auth: HoppRESTAuth & { authType: "hawk" } = { + authActive: true, + authType: "hawk", + authId: "<>", + authKey: "<>", + algorithm: "sha1", + includePayloadHash: false, + } + + await generateHawkAuthHeaders(auth, createBaseRequest(), mockEnvVars) + + expect(calculateHawkHeader).toHaveBeenCalledWith( + expect.objectContaining({ + id: "test-hawk-id", + key: "test-hawk-key", + algorithm: "sha1", + }) + ) + }) + + test("handles optional hawk parameters", async () => { + vi.mocked(calculateHawkHeader).mockResolvedValue( + 'Hawk id="test-hawk-id", mac="xyz123"' + ) + vi.mocked(getFinalBodyFromRequest).mockReturnValue("") + + const auth: HoppRESTAuth & { authType: "hawk" } = { + authActive: true, + authType: "hawk", + authId: "<>", + authKey: "<>", + algorithm: "sha256", + nonce: "custom-nonce", + ext: "custom-ext", + app: "custom-app", + dlg: "custom-dlg", + timestamp: "1234567890", + includePayloadHash: false, + } + + await generateHawkAuthHeaders(auth, createBaseRequest(), mockEnvVars) + + expect(calculateHawkHeader).toHaveBeenCalledWith( + expect.objectContaining({ + nonce: "custom-nonce", + ext: "custom-ext", + app: "custom-app", + dlg: "custom-dlg", + timestamp: 1234567890, + }) + ) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/auth/types/__tests__/jwt.spec.ts b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/jwt.spec.ts new file mode 100644 index 0000000..6477ac8 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/jwt.spec.ts @@ -0,0 +1,334 @@ +import { HoppRESTAuth } from "@hoppscotch/data" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { generateJwtAuthHeaders } from "../jwt" +import { mockEnvVars } from "./test-utils" + +// Mock the jwt helper +vi.mock("@hoppscotch/data", async () => { + const actual = await vi.importActual("@hoppscotch/data") + return { + ...actual, + generateJWTToken: vi.fn(), + } +}) + +import { generateJWTToken } from "@hoppscotch/data" + +describe("JWT Auth", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("generateJwtAuthHeaders", () => { + test("generates JWT auth header with basic configuration", async () => { + const mockToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + vi.mocked(generateJWTToken).mockResolvedValue(mockToken) + + const auth: HoppRESTAuth & { authType: "jwt" } = { + authActive: true, + authType: "jwt", + secret: "my-secret-key", + privateKey: "", + algorithm: "HS256", + payload: '{"sub": "1234567890", "name": "John Doe"}', + addTo: "HEADERS", + isSecretBase64Encoded: false, + headerPrefix: "Bearer ", + paramName: "token", + jwtHeaders: "{}", + } + + const headers = await generateJwtAuthHeaders(auth, mockEnvVars) + + expect(generateJWTToken).toHaveBeenCalledWith({ + secret: "my-secret-key", + algorithm: "HS256", + payload: '{"sub": "1234567890", "name": "John Doe"}', + isSecretBase64Encoded: false, + privateKey: "", + jwtHeaders: "{}", + }) + + expect(headers).toHaveLength(1) + expect(headers[0]).toEqual({ + active: true, + key: "Authorization", + value: + "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + description: "", + }) + }) + + test("adds JWT token to query params when addTo is QUERY_PARAMS", async () => { + const mockToken = "jwt.token.here" + vi.mocked(generateJWTToken).mockResolvedValue(mockToken) + + const auth: HoppRESTAuth & { authType: "jwt" } = { + authActive: true, + authType: "jwt", + secret: "secret", + privateKey: "", + algorithm: "HS256", + payload: "{}", + addTo: "QUERY_PARAMS", + isSecretBase64Encoded: false, + headerPrefix: "Bearer ", + paramName: "access_token", + jwtHeaders: "{}", + } + + const headers = await generateJwtAuthHeaders(auth, mockEnvVars) + + expect(headers).toHaveLength(0) + // Note: Query params would be handled differently in the actual implementation + }) + + test("handles template variables in secret", async () => { + const mockToken = "generated.jwt.token" + vi.mocked(generateJWTToken).mockResolvedValue(mockToken) + + const auth: HoppRESTAuth & { authType: "jwt" } = { + authActive: true, + authType: "jwt", + secret: "<>", + privateKey: "", + algorithm: "HS256", + payload: "<>", + addTo: "HEADERS", + isSecretBase64Encoded: false, + headerPrefix: "Bearer ", + paramName: "token", + jwtHeaders: "{}", + } + + const headers = await generateJwtAuthHeaders(auth, mockEnvVars) + + expect(generateJWTToken).toHaveBeenCalledWith({ + secret: "my-secret-key", + algorithm: "HS256", + payload: '{"sub": "1234567890", "name": "John Doe"}', + isSecretBase64Encoded: false, + privateKey: "", + jwtHeaders: "{}", + }) + + expect(headers[0].value).toBe("Bearer generated.jwt.token") + }) + + test("handles RSA algorithm with private key", async () => { + const mockToken = "rsa.signed.token" + vi.mocked(generateJWTToken).mockResolvedValue(mockToken) + + const auth: HoppRESTAuth & { authType: "jwt" } = { + authActive: true, + authType: "jwt", + secret: "", + privateKey: + "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4...", + algorithm: "RS256", + payload: '{"iss": "test"}', + addTo: "HEADERS", + isSecretBase64Encoded: false, + headerPrefix: "JWT ", + paramName: "token", + jwtHeaders: '{"typ": "JWT"}', + } + + const headers = await generateJwtAuthHeaders(auth, mockEnvVars) + + expect(generateJWTToken).toHaveBeenCalledWith({ + secret: "", + algorithm: "RS256", + payload: '{"iss": "test"}', + isSecretBase64Encoded: false, + privateKey: + "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4...", + jwtHeaders: '{"typ": "JWT"}', + }) + + expect(headers[0].value).toBe("JWT rsa.signed.token") + }) + + test("handles base64 encoded secret", async () => { + const mockToken = "base64.encoded.token" + vi.mocked(generateJWTToken).mockResolvedValue(mockToken) + + const auth: HoppRESTAuth & { authType: "jwt" } = { + authActive: true, + authType: "jwt", + secret: "bXktc2VjcmV0LWtleQ==", // base64 for "my-secret-key" + privateKey: "", + algorithm: "HS512", + payload: "{}", + addTo: "HEADERS", + isSecretBase64Encoded: true, + headerPrefix: "Token ", + paramName: "token", + jwtHeaders: "{}", + } + + const headers = await generateJwtAuthHeaders(auth, mockEnvVars) + + expect(generateJWTToken).toHaveBeenCalledWith({ + secret: "bXktc2VjcmV0LWtleQ==", + algorithm: "HS512", + payload: "{}", + isSecretBase64Encoded: true, + privateKey: "", + jwtHeaders: "{}", + }) + + expect(headers[0].value).toBe("Token base64.encoded.token") + }) + + test("handles custom header prefix", async () => { + const mockToken = "custom.prefix.token" + vi.mocked(generateJWTToken).mockResolvedValue(mockToken) + + const auth: HoppRESTAuth & { authType: "jwt" } = { + authActive: true, + authType: "jwt", + secret: "secret", + privateKey: "", + algorithm: "HS256", + payload: "{}", + addTo: "HEADERS", + isSecretBase64Encoded: false, + headerPrefix: "Custom-Auth ", + paramName: "token", + jwtHeaders: "{}", + } + + const headers = await generateJwtAuthHeaders(auth, mockEnvVars) + + expect(headers[0].value).toBe("Custom-Auth custom.prefix.token") + }) + + test("handles empty header prefix", async () => { + const mockToken = "no.prefix.token" + vi.mocked(generateJWTToken).mockResolvedValue(mockToken) + + const auth: HoppRESTAuth & { authType: "jwt" } = { + authActive: true, + authType: "jwt", + secret: "secret", + privateKey: "", + algorithm: "HS256", + payload: "{}", + addTo: "HEADERS", + isSecretBase64Encoded: false, + headerPrefix: "", + paramName: "token", + jwtHeaders: "{}", + } + + const headers = await generateJwtAuthHeaders(auth, mockEnvVars) + + expect(headers[0].value).toBe("no.prefix.token") + }) + + test("handles JWT generation failure", async () => { + vi.mocked(generateJWTToken).mockResolvedValue(null) + + const auth: HoppRESTAuth & { authType: "jwt" } = { + authActive: true, + authType: "jwt", + secret: "", + privateKey: "", + algorithm: "HS256", + payload: "{}", + addTo: "HEADERS", + isSecretBase64Encoded: false, + headerPrefix: "Bearer ", + paramName: "token", + jwtHeaders: "{}", + } + + const headers = await generateJwtAuthHeaders(auth, mockEnvVars) + + expect(headers).toHaveLength(0) + }) + + test("handles different JWT algorithms", async () => { + const algorithms = ["HS384", "RS384", "PS256", "ES256"] as const + + for (const algorithm of algorithms) { + const mockToken = `${algorithm.toLowerCase()}.token` + vi.mocked(generateJWTToken).mockResolvedValue(mockToken) + + const auth: HoppRESTAuth & { authType: "jwt" } = { + authActive: true, + authType: "jwt", + secret: "secret", + privateKey: algorithm.startsWith("HS") ? "" : "private-key", + algorithm, + payload: "{}", + addTo: "HEADERS", + isSecretBase64Encoded: false, + headerPrefix: "Bearer ", + paramName: "token", + jwtHeaders: "{}", + } + + const headers = await generateJwtAuthHeaders(auth, mockEnvVars) + + expect(generateJWTToken).toHaveBeenCalledWith({ + secret: "secret", + algorithm, + payload: "{}", + isSecretBase64Encoded: false, + privateKey: algorithm.startsWith("HS") ? "" : "private-key", + jwtHeaders: "{}", + }) + + expect(headers[0].value).toBe(`Bearer ${mockToken}`) + vi.clearAllMocks() + } + }) + + test("handles complex payload with claims", async () => { + const mockToken = "complex.payload.token" + vi.mocked(generateJWTToken).mockResolvedValue(mockToken) + + const complexPayload = JSON.stringify({ + iss: "https://example.com", + sub: "user123", + aud: "api.example.com", + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + nbf: Math.floor(Date.now() / 1000), + jti: "unique-id", + custom_claim: "custom_value", + }) + + const auth: HoppRESTAuth & { authType: "jwt" } = { + authActive: true, + authType: "jwt", + secret: "secret", + privateKey: "", + algorithm: "HS256", + payload: complexPayload, + addTo: "HEADERS", + isSecretBase64Encoded: false, + headerPrefix: "Bearer ", + paramName: "token", + jwtHeaders: '{"alg": "HS256", "typ": "JWT", "kid": "key-id"}', + } + + const headers = await generateJwtAuthHeaders(auth, mockEnvVars) + + expect(generateJWTToken).toHaveBeenCalledWith({ + secret: "secret", + algorithm: "HS256", + payload: complexPayload, + isSecretBase64Encoded: false, + privateKey: "", + jwtHeaders: '{"alg": "HS256", "typ": "JWT", "kid": "key-id"}', + }) + + expect(headers[0].value).toBe("Bearer complex.payload.token") + }) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/auth/types/__tests__/oauth2.spec.ts b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/oauth2.spec.ts new file mode 100644 index 0000000..0b38ef5 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/oauth2.spec.ts @@ -0,0 +1,202 @@ +import { HoppRESTAuth } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" +import { generateOAuth2AuthHeaders } from "../oauth2" +import { mockEnvVars } from "./test-utils" + +describe("OAuth2 Auth", () => { + describe("generateOAuth2AuthHeaders", () => { + test("generates OAuth2 auth header for Authorization Code grant type", async () => { + const auth: HoppRESTAuth & { authType: "oauth-2" } = { + authActive: true, + authType: "oauth-2", + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: "https://auth.example.com/oauth/authorize", + tokenEndpoint: "https://auth.example.com/oauth/token", + clientID: "my-client-id", + clientSecret: "my-client-secret", + scopes: "read write", + token: "oauth2_access_token_123", + isPKCE: false, + refreshToken: "refresh_token_456", + authRequestParams: [], + tokenRequestParams: [], + refreshRequestParams: [], + }, + addTo: "HEADERS", + } + + const headers = await generateOAuth2AuthHeaders(auth, mockEnvVars) + + expect(headers).toHaveLength(1) + expect(headers[0]).toEqual({ + active: true, + key: "Authorization", + value: "Bearer oauth2_access_token_123", + description: "", + }) + }) + + test("adds OAuth2 token to query params when addTo is QUERY_PARAMS", async () => { + const auth: HoppRESTAuth & { authType: "oauth-2" } = { + authActive: true, + authType: "oauth-2", + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: "https://auth.example.com/oauth/authorize", + tokenEndpoint: "https://auth.example.com/oauth/token", + clientID: "client-id", + clientSecret: "client-secret", + scopes: "read", + token: "query_param_token", + isPKCE: false, + authRequestParams: [], + tokenRequestParams: [], + refreshRequestParams: [], + }, + addTo: "QUERY_PARAMS", + } + + const headers = await generateOAuth2AuthHeaders(auth, mockEnvVars) + + expect(headers).toHaveLength(0) + // Note: Query params would be handled differently in the actual implementation + }) + + test("handles Client Credentials grant type", async () => { + const auth: HoppRESTAuth & { authType: "oauth-2" } = { + authActive: true, + authType: "oauth-2", + grantTypeInfo: { + grantType: "CLIENT_CREDENTIALS", + authEndpoint: "https://auth.example.com/oauth/token", + clientID: "client-credentials-id", + clientSecret: "client-credentials-secret", + scopes: "api:read api:write", + token: "client_credentials_token", + clientAuthentication: "AS_BASIC_AUTH_HEADERS", + tokenRequestParams: [], + refreshRequestParams: [], + }, + addTo: "HEADERS", + } + + const headers = await generateOAuth2AuthHeaders(auth, mockEnvVars) + + expect(headers).toHaveLength(1) + expect(headers[0]).toEqual({ + active: true, + key: "Authorization", + value: "Bearer client_credentials_token", + description: "", + }) + }) + + test("handles Password grant type", async () => { + const auth: HoppRESTAuth & { authType: "oauth-2" } = { + authActive: true, + authType: "oauth-2", + grantTypeInfo: { + grantType: "PASSWORD", + authEndpoint: "https://auth.example.com/oauth/token", + clientID: "password-client-id", + clientSecret: "password-client-secret", + scopes: "user:profile", + username: "testuser", + password: "testpass", + token: "password_grant_token", + tokenRequestParams: [], + refreshRequestParams: [], + }, + addTo: "HEADERS", + } + + const headers = await generateOAuth2AuthHeaders(auth, mockEnvVars) + + expect(headers).toHaveLength(1) + expect(headers[0]).toEqual({ + active: true, + key: "Authorization", + value: "Bearer password_grant_token", + description: "", + }) + }) + + test("handles Implicit grant type", async () => { + const auth: HoppRESTAuth & { authType: "oauth-2" } = { + authActive: true, + authType: "oauth-2", + grantTypeInfo: { + grantType: "IMPLICIT", + authEndpoint: "https://auth.example.com/oauth/authorize", + clientID: "implicit-client-id", + scopes: "read", + token: "implicit_token_123", + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: "HEADERS", + } + + const headers = await generateOAuth2AuthHeaders(auth, mockEnvVars) + + expect(headers).toHaveLength(1) + expect(headers[0]).toEqual({ + active: true, + key: "Authorization", + value: "Bearer implicit_token_123", + description: "", + }) + }) + + test("handles template variables in token", async () => { + const auth: HoppRESTAuth & { authType: "oauth-2" } = { + authActive: true, + authType: "oauth-2", + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: "https://auth.example.com/oauth/authorize", + tokenEndpoint: "https://auth.example.com/oauth/token", + clientID: "<>", + clientSecret: "<>", + scopes: "read write", + token: "<>", + isPKCE: false, + authRequestParams: [], + tokenRequestParams: [], + refreshRequestParams: [], + }, + addTo: "HEADERS", + } + + const headers = await generateOAuth2AuthHeaders(auth, mockEnvVars) + + expect(headers[0].value).toBe("Bearer oauth2_access_token_123") + }) + + test("handles empty token", async () => { + const auth: HoppRESTAuth & { authType: "oauth-2" } = { + authActive: true, + authType: "oauth-2", + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: "https://auth.example.com/oauth/authorize", + tokenEndpoint: "https://auth.example.com/oauth/token", + clientID: "client-id", + clientSecret: "client-secret", + scopes: "read", + token: "", + isPKCE: false, + authRequestParams: [], + tokenRequestParams: [], + refreshRequestParams: [], + }, + addTo: "HEADERS", + } + + const headers = await generateOAuth2AuthHeaders(auth, mockEnvVars) + + expect(headers[0].value).toBe("Bearer ") + }) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/auth/types/__tests__/test-utils.ts b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/test-utils.ts new file mode 100644 index 0000000..625d9ee --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/test-utils.ts @@ -0,0 +1,121 @@ +import { Environment, makeRESTRequest } from "@hoppscotch/data" + +// Helper function to create base request +export const createBaseRequest = ( + overrides: Partial[0]> = {} +) => { + const baseRequest: Parameters[0] = { + method: "GET", + endpoint: "https://api.example.com/data", + name: "Test Request", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + auth: { + authType: "inherit", + authActive: true, + }, + body: { + contentType: null, + body: null, + }, + requestVariables: [], + responses: {}, + } + + return makeRESTRequest({ ...baseRequest, ...overrides }) +} + +export const mockEnvVars: Environment["variables"] = [ + { + key: "API_KEY", + secret: false, + initialValue: "test-key-123", + currentValue: "test-key-123", + }, + { + key: "API_VALUE", + secret: true, + initialValue: "secret-value", + currentValue: "secret-value", + }, + { + key: "ACCESS_TOKEN", + secret: true, + initialValue: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + currentValue: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + }, + { + key: "USERNAME", + secret: false, + initialValue: "testuser", + currentValue: "testuser", + }, + { + key: "PASSWORD", + secret: true, + initialValue: "testpass", + currentValue: "testpass", + }, + { + key: "OAUTH_TOKEN", + secret: true, + initialValue: "oauth2_access_token_123", + currentValue: "oauth2_access_token_123", + }, + { + key: "JWT_SECRET", + secret: true, + initialValue: "my-secret-key", + currentValue: "my-secret-key", + }, + { + key: "JWT_PAYLOAD", + secret: false, + initialValue: '{"sub": "1234567890", "name": "John Doe"}', + currentValue: '{"sub": "1234567890", "name": "John Doe"}', + }, + { + key: "AWS_ACCESS_KEY", + secret: true, + initialValue: "AKIAIOSFODNN7EXAMPLE", + currentValue: "AKIAIOSFODNN7EXAMPLE", + }, + { + key: "AWS_SECRET_KEY", + secret: true, + initialValue: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + currentValue: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + { + key: "AWS_REGION", + secret: false, + initialValue: "us-east-1", + currentValue: "us-east-1", + }, + { + key: "HAWK_ID", + secret: false, + initialValue: "test-hawk-id", + currentValue: "test-hawk-id", + }, + { + key: "HAWK_KEY", + secret: true, + initialValue: "test-hawk-key", + currentValue: "test-hawk-key", + }, + { + key: "DIGEST_USER", + secret: false, + initialValue: "testuser", + currentValue: "testuser", + }, + { + key: "DIGEST_PASS", + secret: true, + initialValue: "testpass", + currentValue: "testpass", + }, +] diff --git a/packages/hoppscotch-common/src/helpers/auth/types/api-key.ts b/packages/hoppscotch-common/src/helpers/auth/types/api-key.ts new file mode 100644 index 0000000..16ba8c2 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/api-key.ts @@ -0,0 +1,46 @@ +import { + parseTemplateString, + HoppRESTAuth, + Environment, + HoppRESTHeader, + HoppRESTParam, +} from "@hoppscotch/data" + +export async function generateApiKeyAuthHeaders( + auth: HoppRESTAuth & { authType: "api-key" }, + envVars: Environment["variables"], + showKeyIfSecret = false +): Promise { + if (auth.addTo !== "HEADERS") return [] + + return [ + { + active: true, + key: parseTemplateString(auth.key, envVars, false, showKeyIfSecret), + value: parseTemplateString( + auth.value ?? "", + envVars, + false, + showKeyIfSecret + ), + description: "", + }, + ] +} + +export async function generateApiKeyAuthParams( + auth: HoppRESTAuth & { authType: "api-key" }, + envVars: Environment["variables"], + showKeyIfSecret = false +): Promise { + if (auth.addTo !== "QUERY_PARAMS") return [] + + return [ + { + active: true, + key: parseTemplateString(auth.key, envVars, false, showKeyIfSecret), + value: parseTemplateString(auth.value, envVars, false, showKeyIfSecret), + description: "", + }, + ] +} diff --git a/packages/hoppscotch-common/src/helpers/auth/types/aws-signature.ts b/packages/hoppscotch-common/src/helpers/auth/types/aws-signature.ts new file mode 100644 index 0000000..1a0568e --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/aws-signature.ts @@ -0,0 +1,146 @@ +import { + Environment, + HoppRESTAuth, + HoppRESTHeader, + HoppRESTParam, + HoppRESTParams, + HoppRESTRequest, + parseTemplateString, +} from "@hoppscotch/data" +import { AwsV4Signer } from "aws4fetch" +import { getFinalBodyFromRequest } from "~/helpers/utils/EffectiveURL" + +type SignOptions = { + auth: HoppRESTAuth & { authType: "aws-signature" } + request: HoppRESTRequest + envVars: Environment["variables"] + signQuery?: boolean +} + +function processQueryParameters( + params: HoppRESTParams, + envVars: Environment["variables"], + baseUrl: string +): { url: URL; sortedParams: Array<{ key: string; value: string }> } { + const url = new URL(baseUrl) + + // add existing query parameters from the request in lexicographical order as per AWS documentation + const sortedParams = params + .filter((param) => param.active && param.key !== "") + .map((param) => ({ + key: parseTemplateString(param.key, envVars), + value: parseTemplateString(param.value, envVars), + })) + .sort((a, b) => a.key.localeCompare(b.key)) + + sortedParams.forEach((param) => { + url.searchParams.append(param.key, param.value) + }) + + return { url, sortedParams } +} + +async function signAWSRequest({ + auth, + request, + envVars, + signQuery = false, +}: SignOptions) { + const currentDate = new Date() + const amzDate = currentDate.toISOString().replace(/[:-]|\.\d{3}/g, "") + + const baseUrl = parseTemplateString(request.endpoint, envVars) + const { url, sortedParams } = processQueryParameters( + request.params, + envVars, + baseUrl + ) + + const accessKeyId = parseTemplateString(auth.accessKey, envVars) + const secretAccessKey = parseTemplateString(auth.secretKey, envVars) + const region = parseTemplateString(auth.region, envVars) ?? "us-east-1" + const service = parseTemplateString(auth.serviceName, envVars) + const sessionToken = auth.serviceToken + ? parseTemplateString(auth.serviceToken, envVars) + : undefined + + const signerConfig: ConstructorParameters[0] = { + method: request.method, + datetime: amzDate, + accessKeyId, + secretAccessKey, + region, + service, + sessionToken, + url: url.toString(), + signQuery, + } + + if (!signQuery) { + const body = getFinalBodyFromRequest(request, envVars) + signerConfig.body = body?.toString() + } + + const signer = new AwsV4Signer(signerConfig) + const sign = await signer.sign() + + return { sign, sortedParams } +} + +export async function generateAwsSignatureAuthHeaders( + auth: HoppRESTAuth & { authType: "aws-signature" }, + request: HoppRESTRequest, + envVars: Environment["variables"] +): Promise { + if (auth.addTo !== "HEADERS") return [] + + const { sign } = await signAWSRequest({ + auth, + request, + envVars, + signQuery: false, + }) + const headers: HoppRESTHeader[] = [] + + sign.headers.forEach((value, key) => { + headers.push({ + active: true, + key, + value, + description: "", + }) + }) + + return headers +} + +export async function generateAwsSignatureAuthParams( + auth: HoppRESTAuth & { authType: "aws-signature" }, + request: HoppRESTRequest, + envVars: Environment["variables"] +): Promise { + if (auth.addTo !== "QUERY_PARAMS") return [] + + const { sign, sortedParams } = await signAWSRequest({ + auth, + request, + envVars, + signQuery: true, + }) + const params: HoppRESTParam[] = [] + + const originalParams = new Set(sortedParams.map((param) => param.key)) + + for (const [key, value] of sign.url.searchParams) { + if (!originalParams.has(key)) { + params.push({ + active: true, + key, + value, + description: "", + }) + } + } + + return params +} diff --git a/packages/hoppscotch-common/src/helpers/auth/types/basic.ts b/packages/hoppscotch-common/src/helpers/auth/types/basic.ts new file mode 100644 index 0000000..f38a789 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/basic.ts @@ -0,0 +1,40 @@ +import { + parseTemplateString, + HoppRESTAuth, + Environment, + HoppRESTHeader, +} from "@hoppscotch/data" + +/** + * UTF-8 safe base64 encoding. Standard btoa() throws on non-ASCII chars, + * so we encode through TextEncoder first. + */ +function utf8Btoa(str: string): string { + const bytes = new TextEncoder().encode(str) + let binary = "" + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary) +} + +export async function generateBasicAuthHeaders( + auth: HoppRESTAuth & { authType: "basic" }, + envVars: Environment["variables"], + // `showKeyIfSecret` is intentionally not forwarded to `parseTemplateString()` here. + // The base64 encoding must always use actual values, otherwise the + // Authorization header is unusable (see #5863). + _showKeyIfSecret = false +): Promise { + const username = parseTemplateString(auth.username, envVars, false, false) + const password = parseTemplateString(auth.password, envVars, false, false) + + return [ + { + active: true, + key: "Authorization", + value: `Basic ${utf8Btoa(`${username}:${password}`)}`, + description: "", + }, + ] +} diff --git a/packages/hoppscotch-common/src/helpers/auth/types/bearer.ts b/packages/hoppscotch-common/src/helpers/auth/types/bearer.ts new file mode 100644 index 0000000..14c6dc6 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/bearer.ts @@ -0,0 +1,23 @@ +import { + parseTemplateString, + HoppRESTAuth, + Environment, + HoppRESTHeader, +} from "@hoppscotch/data" + +export async function generateBearerAuthHeaders( + auth: HoppRESTAuth & { authType: "bearer" }, + envVars: Environment["variables"], + showKeyIfSecret = false +): Promise { + const token = parseTemplateString(auth.token, envVars, false, showKeyIfSecret) + + return [ + { + active: true, + key: "Authorization", + value: `Bearer ${token}`, + description: "", + }, + ] +} diff --git a/packages/hoppscotch-common/src/helpers/auth/types/digest.ts b/packages/hoppscotch-common/src/helpers/auth/types/digest.ts new file mode 100644 index 0000000..c6989af --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/digest.ts @@ -0,0 +1,65 @@ +import { + parseTemplateString, + HoppRESTAuth, + HoppRESTRequest, + Environment, + HoppRESTHeader, +} from "@hoppscotch/data" +import { + DigestAuthParams, + fetchInitialDigestAuthInfo, + generateDigestAuthHeader, +} from "../digest" +import { getFinalBodyFromRequest } from "~/helpers/utils/EffectiveURL" + +export async function generateDigestAuthHeaders( + auth: HoppRESTAuth, + request: HoppRESTRequest, + envVars: Environment["variables"], + showKeyIfSecret = false +): Promise { + if (auth.authType !== "digest") return [] + + const { method, endpoint } = request + + // Step 1: Fetch the initial auth info (nonce, realm, etc.) + const authInfo = await fetchInitialDigestAuthInfo( + parseTemplateString(endpoint, envVars), + method + ) + + // Get the body content for digest calculation + const reqBody = getFinalBodyFromRequest(request, envVars, showKeyIfSecret) + + // Step 2: Set up the parameters for the digest authentication header + const digestAuthParams: DigestAuthParams = { + username: parseTemplateString(auth.username, envVars), + password: parseTemplateString(auth.password, envVars), + realm: auth.realm + ? parseTemplateString(auth.realm, envVars) + : authInfo.realm, + nonce: auth.nonce + ? parseTemplateString(auth.nonce, envVars) + : authInfo.nonce, + endpoint: parseTemplateString(endpoint, envVars), + method, + algorithm: auth.algorithm ?? authInfo.algorithm, + qop: auth.qop ? parseTemplateString(auth.qop, envVars) : authInfo.qop, + opaque: auth.opaque + ? parseTemplateString(auth.opaque, envVars) + : authInfo.opaque, + reqBody: typeof reqBody === "string" ? reqBody : "", + } + + // Step 3: Generate the Authorization header + const authHeaderValue = await generateDigestAuthHeader(digestAuthParams) + + return [ + { + active: true, + key: "Authorization", + value: authHeaderValue, + description: "", + }, + ] +} diff --git a/packages/hoppscotch-common/src/helpers/auth/types/hawk.ts b/packages/hoppscotch-common/src/helpers/auth/types/hawk.ts new file mode 100644 index 0000000..e97b1a8 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/hawk.ts @@ -0,0 +1,54 @@ +import { + parseTemplateString, + calculateHawkHeader, + HoppRESTAuth, + HoppRESTRequest, + Environment, + HoppRESTHeader, +} from "@hoppscotch/data" +import { getFinalBodyFromRequest } from "~/helpers/utils/EffectiveURL" + +export async function generateHawkAuthHeaders( + auth: HoppRESTAuth, + request: HoppRESTRequest, + envVars: Environment["variables"], + showKeyIfSecret = false +): Promise { + if (auth.authType !== "hawk") return [] + + const { method, endpoint, body } = request + + // Get the body content for payload hash calculation + const payload = getFinalBodyFromRequest(request, envVars, showKeyIfSecret) + + const hawkHeader = await calculateHawkHeader({ + url: parseTemplateString(endpoint, envVars), + method: method, + id: parseTemplateString(auth.authId, envVars), + key: parseTemplateString(auth.authKey, envVars), + algorithm: auth.algorithm, + + // Add content type and payload + contentType: body.contentType, + payload, + + // advanced parameters (optional) + includePayloadHash: auth.includePayloadHash, + nonce: auth.nonce ? parseTemplateString(auth.nonce, envVars) : undefined, + ext: auth.ext ? parseTemplateString(auth.ext, envVars) : undefined, + app: auth.app ? parseTemplateString(auth.app, envVars) : undefined, + dlg: auth.dlg ? parseTemplateString(auth.dlg, envVars) : undefined, + timestamp: auth.timestamp + ? parseInt(parseTemplateString(auth.timestamp, envVars), 10) + : undefined, + }) + + return [ + { + active: true, + key: "Authorization", + value: hawkHeader, + description: "", + }, + ] +} diff --git a/packages/hoppscotch-common/src/helpers/auth/types/jwt.ts b/packages/hoppscotch-common/src/helpers/auth/types/jwt.ts new file mode 100644 index 0000000..0e52d62 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/jwt.ts @@ -0,0 +1,74 @@ +import { + Environment, + generateJWTToken, + HoppRESTAuth, + HoppRESTHeader, + HoppRESTParam, + parseTemplateString, +} from "@hoppscotch/data" + +export async function generateJwtAuthHeaders( + auth: HoppRESTAuth & { authType: "jwt" }, + envVars: Environment["variables"], + showKeyIfSecret = false +): Promise { + if (auth.addTo !== "HEADERS") return [] + + const token = await generateJWTToken({ + algorithm: auth.algorithm || "HS256", + secret: parseTemplateString(auth.secret, envVars, false), + privateKey: parseTemplateString(auth.privateKey, envVars, false), + payload: parseTemplateString(auth.payload, envVars, false), + jwtHeaders: parseTemplateString(auth.jwtHeaders, envVars, false), + isSecretBase64Encoded: auth.isSecretBase64Encoded, + }) + + if (!token) return [] + + // Get prefix (defaults to "Bearer " if not specified) + const headerPrefix = parseTemplateString( + auth.headerPrefix, + envVars, + false, + showKeyIfSecret + ) + + return [ + { + active: true, + key: "Authorization", + value: `${headerPrefix}${token}`, + description: "", + }, + ] +} + +export async function generateJwtAuthParams( + auth: HoppRESTAuth & { authType: "jwt" }, + envVars: Environment["variables"] +): Promise { + if (auth.addTo !== "QUERY_PARAMS") return [] + + const token = await generateJWTToken({ + algorithm: auth.algorithm || "HS256", + secret: parseTemplateString(auth.secret, envVars, false), + privateKey: parseTemplateString(auth.privateKey, envVars, false), + payload: parseTemplateString(auth.payload, envVars, false), + jwtHeaders: parseTemplateString(auth.jwtHeaders, envVars, false), + isSecretBase64Encoded: auth.isSecretBase64Encoded, + }) + + if (!token) return [] + + // Get param name (defaults to "token" if not specified) + const paramName = parseTemplateString(auth.paramName, envVars) + + return [ + { + active: true, + key: paramName, + value: token, + description: "", + }, + ] +} diff --git a/packages/hoppscotch-common/src/helpers/auth/types/oauth2.ts b/packages/hoppscotch-common/src/helpers/auth/types/oauth2.ts new file mode 100644 index 0000000..bcf08e0 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/oauth2.ts @@ -0,0 +1,55 @@ +import { + parseTemplateString, + HoppRESTAuth, + Environment, + HoppRESTHeader, + HoppRESTParam, +} from "@hoppscotch/data" + +export async function generateOAuth2AuthHeaders( + auth: HoppRESTAuth & { authType: "oauth-2" }, + envVars: Environment["variables"], + showKeyIfSecret = false +): Promise { + if (auth.addTo !== "HEADERS") return [] + + const token = parseTemplateString( + auth.grantTypeInfo.token, + envVars, + false, + showKeyIfSecret + ) + + return [ + { + active: true, + key: "Authorization", + value: `Bearer ${token}`, + description: "", + }, + ] +} + +export async function generateOAuth2AuthParams( + auth: HoppRESTAuth & { authType: "oauth-2" }, + envVars: Environment["variables"], + showKeyIfSecret = false +): Promise { + if (auth.addTo !== "QUERY_PARAMS") return [] + + const token = parseTemplateString( + auth.grantTypeInfo.token, + envVars, + false, + showKeyIfSecret + ) + + return [ + { + active: true, + key: "access_token", + value: token, + description: "", + }, + ] +} diff --git a/packages/hoppscotch-common/src/helpers/backend/GQLClient.ts b/packages/hoppscotch-common/src/helpers/backend/GQLClient.ts new file mode 100644 index 0000000..d3ac3ba --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/GQLClient.ts @@ -0,0 +1,482 @@ +import { ref } from "vue" +import { + createClient, + TypedDocumentNode, + OperationContext, + fetchExchange, + makeOperation, + createRequest, + subscriptionExchange, + errorExchange, + CombinedError, + Operation, + OperationResult, + Client, + AnyVariables, +} from "@urql/core" +import { AuthConfig, authExchange } from "@urql/exchange-auth" +// import { devtoolsExchange } from "@urql/devtools" +import { SubscriptionClient } from "subscriptions-transport-ws" +import * as E from "fp-ts/Either" +import * as TE from "fp-ts/TaskEither" +import { pipe, constVoid, flow } from "fp-ts/function" +import { subscribe, pipe as wonkaPipe } from "wonka" +import { filter, map, Subject, Subscription } from "rxjs" +import { platform } from "~/platform" +import { createAuthRetryGuard } from "~/helpers/retryAuthGuard" + +// TODO: Implement caching + +const BACKEND_GQL_URL = + import.meta.env.VITE_BACKEND_GQL_URL ?? "https://api.hoppscotch.io/graphql" +const BACKEND_WS_URL = + import.meta.env.VITE_BACKEND_WS_URL ?? "wss://api.hoppscotch.io/graphql" + +type GQLOpType = "query" | "mutation" | "subscription" +/** + * A type that defines error events that are possible during backend operations on the GQLCLient + */ +export type GQLClientErrorEvent = + | { type: "SUBSCRIPTION_CONN_CALLBACK_ERR_REPORT"; errors: Error[] } + | { type: "CLIENT_REPORTED_ERROR"; error: CombinedError; op: Operation } + | { + type: "GQL_CLIENT_REPORTED_ERROR" + opType: GQLOpType + opResult: OperationResult + } + +/** + * A stream of the errors that occur during GQLClient operations. + * Exposed to be subscribed to by systems like sentry for error reporting + */ +export const gqlClientError$ = new Subject() + +const createSubscriptionClient = () => { + return new SubscriptionClient(BACKEND_WS_URL, { + reconnect: true, + connectionParams: () => platform.auth.getBackendHeaders(), + connectionCallback(error) { + if (error?.length > 0) { + gqlClientError$.next({ + type: "SUBSCRIPTION_CONN_CALLBACK_ERR_REPORT", + errors: error, + }) + } + }, + }) +} + +const authRetryGuard = createAuthRetryGuard(() => platform.auth.signOutUser()) + +const createHoppClient = () => { + const exchanges = [ + // devtoolsExchange, + authExchange(async (): Promise => { + const probableUser = platform.auth.getProbableUser() + if (probableUser !== null) + await platform.auth.waitProbableLoginToConfirm() + + return { + addAuthToOperation(operation) { + const fetchOptions = + typeof operation.context.fetchOptions === "function" + ? operation.context.fetchOptions() + : operation.context.fetchOptions || {} + + const authHeaders = platform.auth.getBackendHeaders() + + return makeOperation(operation.kind, operation, { + ...operation.context, + fetchOptions: { + ...fetchOptions, + headers: { + ...fetchOptions.headers, + ...authHeaders, + }, + }, + }) + }, + willAuthError() { + return platform.auth.willBackendHaveAuthError() + }, + didAuthError(error) { + // Check for specific error patterns that indicate expired token + return error.graphQLErrors.some( + (e) => + e.message.includes("auth/fail") || + e.message.includes("jwt expired") || + e.extensions?.code === "UNAUTHENTICATED" + ) + }, + async refreshAuth() { + const refresh = platform.auth.refreshAuthToken + if (!refresh) return + + await authRetryGuard.execute(() => refresh.call(platform.auth)) + }, + } + }), + fetchExchange, + errorExchange({ + onError(error, op) { + gqlClientError$.next({ + type: "CLIENT_REPORTED_ERROR", + error, + op, + }) + }, + }), + ] + + if (subscriptionClient) { + exchanges.push( + subscriptionExchange({ + forwardSubscription: (operation) => { + return subscriptionClient!.request(operation) + }, + }) + ) + } + + return createClient({ + url: BACKEND_GQL_URL, + exchanges, + ...(platform.auth.getGQLClientOptions + ? platform.auth.getGQLClientOptions() + : {}), + preferGetMethod: false, + }) +} + +let subscriptionClient: SubscriptionClient | null +let authEventSubscription: Subscription | null = null +export const client = ref() + +export function initBackendGQLClient() { + client.value = createHoppClient() + + // Reset the retry guard only on successful login, not on every + // client recreation (which also fires on logout/token_refresh). + authEventSubscription?.unsubscribe() + authEventSubscription = platform.auth + .getAuthEventsStream() + .subscribe((event) => { + if (event.event === "login") { + authRetryGuard.reset() + } + }) + + platform.auth.onBackendGQLClientShouldReconnect(() => { + const currentUser = platform.auth.getCurrentUser() + + // triggering reconnect by closing the websocket client + if (currentUser && subscriptionClient) { + subscriptionClient?.client?.close() + } + + // creating new subscription + if (currentUser && !subscriptionClient) { + subscriptionClient = createSubscriptionClient() + } + + // closing existing subscription client. + if (!currentUser && subscriptionClient) { + subscriptionClient.close() + subscriptionClient = null + } + + client.value = createHoppClient() + }) +} + +type RunQueryOptions = { + query: TypedDocumentNode + variables: V + // When true, skip awaiting `platform.auth.waitOrganizationInfoReady` before + // issuing the request. This MUST be left unset for ordinary calls: it exists + // for the org-info bootstrap query itself (GetOrganizationInfoByDomain), + // which determines the org id and would otherwise deadlock waiting on itself. + skipOrgInfoWait?: boolean +} + +/** + * A wrapper type for defining errors possible in a GQL operation + */ +export type GQLError = + | { + type: "network_error" + error: Error + } + | { + type: "gql_error" + error: T + } + +export const runGQLQuery = async < + DocType, + DocVarType extends AnyVariables, + DocErrorType extends string, +>( + args: RunQueryOptions +): Promise, DocType>> => { + // Gate the request on the org info lookup so `getBackendHeaders` has the + // `x-organization-id` set by the time the call goes out. The bootstrap + // query (which resolves the org id) passes `skipOrgInfoWait` to avoid + // waiting on itself. + if (!args.skipOrgInfoWait) { + try { + await platform.auth.waitOrganizationInfoReady?.() + } catch (e: any) { + return E.left({ + type: "network_error", + error: e instanceof Error ? e : new Error(String(e)), + }) + } + } + + const request = createRequest(args.query, args.variables) + const source = client.value!.executeQuery(request, { + requestPolicy: "network-only", + }) + + return new Promise((resolve) => { + const sub = wonkaPipe( + source, + subscribe((res) => { + if (sub) { + sub.unsubscribe() + } + + pipe( + // The target + res.data as DocType | undefined, + // Define what happens if data does not exist (it is an error) + E.fromNullable( + pipe( + // Take the network error value + res.error?.networkError, + // If it null, set the left to the generic error name + E.fromNullable(res.error?.message), + E.match( + // The left case (network error was null) + (gqlErr) => { + if (res.error) { + gqlClientError$.next({ + type: "GQL_CLIENT_REPORTED_ERROR", + opType: "query", + opResult: res, + }) + } + + return >{ + type: "gql_error", + error: parseGQLErrorString(gqlErr ?? "") as DocErrorType, + } + }, + // The right case (it was a GraphQL Error) + (networkErr) => + >{ + type: "network_error", + error: networkErr, + } + ) + ) + ), + resolve + ) + }) + ) + }) +} + +// TODO: The subscription system seems to be firing multiple updates for certain subscriptions. +// Make sure to handle cases if the subscription fires with the same update multiple times +export const runGQLSubscription = < + DocType, + DocVarType extends AnyVariables, + DocErrorType extends string, +>( + args: RunQueryOptions +) => { + const result$ = new Subject, DocType>>() + + // The wonka subscription isn't created until after the org-info wait below, + // so track it here and expose a stable handle that callers can unsubscribe + // from immediately (matches the shape of the previously returned sub). + let wonkaSub: { unsubscribe: () => void } | null = null + let unsubscribeRequested = false + const sub = { + unsubscribe() { + unsubscribeRequested = true + wonkaSub?.unsubscribe() + }, + } + + ;(async () => { + // Gate the subscription on org info so `connectionParams` carries the + // `x-organization-id`. See runGQLQuery for the bootstrap skip rationale. + try { + if (!args.skipOrgInfoWait) { + await platform.auth.waitOrganizationInfoReady?.() + } + } catch (e: any) { + result$.next( + E.left({ + type: "network_error", + error: e instanceof Error ? e : new Error(String(e)), + }) + ) + return + } + + const source = client.value!.executeSubscription( + createRequest(args.query, args.variables) + ) + + wonkaSub = wonkaPipe( + source, + subscribe((res) => { + result$.next( + pipe( + // The target + res.data as DocType | undefined, + // Define what happens if data does not exist (it is an error) + E.fromNullable( + pipe( + // Take the network error value + res.error?.networkError, + // If it null, set the left to the generic error name + E.fromNullable(res.error?.message), + E.match( + // The left case (network error was null) + (gqlErr) => { + if (res.error) { + gqlClientError$.next({ + type: "GQL_CLIENT_REPORTED_ERROR", + opType: "subscription", + opResult: res, + }) + } + + return >{ + type: "gql_error", + error: parseGQLErrorString(gqlErr ?? "") as DocErrorType, + } + }, + // The right case (it was a GraphQL Error) + (networkErr) => + >{ + type: "network_error", + error: networkErr, + } + ) + ) + ) + ) + ) + }) + ) + + if (unsubscribeRequested) { + wonkaSub.unsubscribe() + } + })() + + // Returns the stream and a subscription handle to unsub + return [result$, sub] as const +} + +/** + * Same as `runGQLSubscription` but stops the subscription silently + * if there is an authentication error because of logged out + */ +export const runAuthOnlyGQLSubscription = flow( + runGQLSubscription, + ([result$, sub]) => { + const updatedResult$ = result$.pipe( + map((res) => { + if ( + E.isLeft(res) && + res.left.type === "gql_error" && + res.left.error === "auth/fail" + ) { + sub.unsubscribe() + return null + } + return res + }), + filter((res): res is Exclude => res !== null) + ) + + return [updatedResult$, sub] as const + } +) + +export const parseGQLErrorString = (s: string) => + s.startsWith("[GraphQL] ") ? s.split("[GraphQL] ")[1] : s + +export const runMutation = < + DocType, + DocVariables extends object | undefined, + DocErrors extends string, +>( + mutation: TypedDocumentNode, + variables: DocVariables, + additionalConfig?: Partial +): TE.TaskEither, DocType> => + pipe( + TE.tryCatch( + async () => { + // Gate the mutation on org info so the `x-organization-id` header is + // present. Mutations are user-triggered and never part of the org-id + // bootstrap, so they always wait. + await platform.auth.waitOrganizationInfoReady?.() + return client + .value!.mutation(mutation, variables, { + requestPolicy: "cache-and-network", + ...additionalConfig, + }) + .toPromise() + }, + (err) => + ({ + type: "network_error", + error: err instanceof Error ? err : new Error(String(err)), + }) as GQLError + ), + TE.chainEitherK((result) => + pipe( + result.data, + E.fromNullable( + // Result is null + pipe( + result.error?.networkError, + E.fromNullable(result.error?.message), + E.match( + // The left case (network error was null) + (gqlErr) => { + if (result.error) { + gqlClientError$.next({ + type: "GQL_CLIENT_REPORTED_ERROR", + opType: "mutation", + opResult: result, + }) + } + + return >{ + type: "gql_error", + error: parseGQLErrorString(gqlErr ?? ""), + } + }, + // The right case (it was a network error) + (networkErr) => + >{ + type: "network_error", + error: networkErr, + } + ) + ) + ) + ) + ) + ) diff --git a/packages/hoppscotch-common/src/helpers/backend/QueryErrors.ts b/packages/hoppscotch-common/src/helpers/backend/QueryErrors.ts new file mode 100644 index 0000000..37d1963 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/QueryErrors.ts @@ -0,0 +1,3 @@ +export type UserQueryError = "user/not_found" + +export type MyTeamsQueryError = "ea/not_invite_or_admin" diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/AcceptTeamInvitation.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/AcceptTeamInvitation.graphql new file mode 100644 index 0000000..41d1255 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/AcceptTeamInvitation.graphql @@ -0,0 +1,12 @@ +mutation AcceptTeamInvitation($inviteID: ID!) { + acceptTeamInvitation(inviteID: $inviteID) { + membershipID + role + user { + uid + displayName + photoURL + email + } + } +} \ No newline at end of file diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/ClearGlobalEnvironments.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/ClearGlobalEnvironments.graphql new file mode 100644 index 0000000..5a184fc --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/ClearGlobalEnvironments.graphql @@ -0,0 +1,5 @@ +mutation ClearGlobalEnvironments($id: ID!) { + clearGlobalEnvironments(id: $id) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateChildCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateChildCollection.graphql new file mode 100644 index 0000000..b61b1eb --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateChildCollection.graphql @@ -0,0 +1,11 @@ +mutation CreateChildCollection( + $childTitle: String! + $collectionID: ID! +) { + createChildCollection( + childTitle: $childTitle + collectionID: $collectionID + ) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateDuplicateEnvironment.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateDuplicateEnvironment.graphql new file mode 100644 index 0000000..674cd75 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateDuplicateEnvironment.graphql @@ -0,0 +1,8 @@ +mutation CreateDuplicateEnvironment($id: ID!){ + createDuplicateEnvironment (id: $id ){ + id + teamID + name + variables + } +} \ No newline at end of file diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateGQLChildUserCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateGQLChildUserCollection.graphql new file mode 100644 index 0000000..736781a --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateGQLChildUserCollection.graphql @@ -0,0 +1,14 @@ +mutation CreateGQLChildUserCollection( + $title: String! + $parentUserCollectionID: ID! + $data: String +) { + createGQLChildUserCollection( + title: $title + parentUserCollectionID: $parentUserCollectionID + data: $data + ) { + id + data + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateGQLRootUserCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateGQLRootUserCollection.graphql new file mode 100644 index 0000000..bc30e96 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateGQLRootUserCollection.graphql @@ -0,0 +1,6 @@ +mutation CreateGQLRootUserCollection($title: String!, $data: String) { + createGQLRootUserCollection(title: $title, data: $data) { + id + data + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateGQLUserRequest.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateGQLUserRequest.graphql new file mode 100644 index 0000000..02423e8 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateGQLUserRequest.graphql @@ -0,0 +1,13 @@ +mutation CreateGQLUserRequest( + $title: String! + $request: String! + $collectionID: ID! +) { + createGQLUserRequest( + title: $title + request: $request + collectionID: $collectionID + ) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateMockServer.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateMockServer.graphql new file mode 100644 index 0000000..8e48e02 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateMockServer.graphql @@ -0,0 +1,23 @@ +mutation CreateMockServer($input: CreateMockServerInput!) { + createMockServer(input: $input) { + id + name + subdomain + serverUrlPathBased + serverUrlDomainBased + workspaceType + workspaceID + delayInMs + isPublic + isActive + createdOn + updatedOn + creator { + uid + } + collection { + id + title + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateNewRootCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateNewRootCollection.graphql new file mode 100644 index 0000000..f24104a --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateNewRootCollection.graphql @@ -0,0 +1,5 @@ +mutation CreateNewRootCollection($title: String!, $teamID: ID!) { + createRootCollection(title: $title, teamID: $teamID) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreatePublishedDoc.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreatePublishedDoc.graphql new file mode 100644 index 0000000..e31cdbf --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreatePublishedDoc.graphql @@ -0,0 +1,14 @@ +mutation CreatePublishedDoc($args: CreatePublishedDocsArgs!) { + createPublishedDoc(args: $args) { + id + title + version + autoSync + url + environmentName + createdOn + updatedOn + workspaceType + workspaceID + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateRESTChildUserCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateRESTChildUserCollection.graphql new file mode 100644 index 0000000..eea3cdf --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateRESTChildUserCollection.graphql @@ -0,0 +1,14 @@ +mutation CreateRESTChildUserCollection( + $title: String! + $parentUserCollectionID: ID! + $data: String +) { + createRESTChildUserCollection( + title: $title + parentUserCollectionID: $parentUserCollectionID + data: $data + ) { + id + data + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateRESTRootUserCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateRESTRootUserCollection.graphql new file mode 100644 index 0000000..613c7d6 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateRESTRootUserCollection.graphql @@ -0,0 +1,8 @@ +mutation CreateRESTRootUserCollection($title: String!, $data: String) { + createRESTRootUserCollection(title: $title, data: $data) { + id + title + data + type + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateRESTUserRequest.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateRESTUserRequest.graphql new file mode 100644 index 0000000..de48db8 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateRESTUserRequest.graphql @@ -0,0 +1,15 @@ +mutation CreateRESTUserRequest( + $collectionID: ID! + $title: String! + $request: String! +) { + createRESTUserRequest( + collectionID: $collectionID + title: $title + request: $request + ) { + id + title + request + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateRequestInCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateRequestInCollection.graphql new file mode 100644 index 0000000..0873d71 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateRequestInCollection.graphql @@ -0,0 +1,12 @@ +mutation CreateRequestInCollection($data: CreateTeamRequestInput!, $collectionID: ID!) { + createRequestInCollection(data: $data, collectionID: $collectionID) { + id + collection { + id + team { + id + name + } + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateShortcode.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateShortcode.graphql new file mode 100644 index 0000000..134f14d --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateShortcode.graphql @@ -0,0 +1,8 @@ +mutation CreateShortcode($request: String!, $properties: String) { + createShortcode(request: $request, properties: $properties) { + id + request + createdOn + properties + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateTeam.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateTeam.graphql new file mode 100644 index 0000000..b638037 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateTeam.graphql @@ -0,0 +1,20 @@ +mutation CreateTeam($name: String!) { + createTeam(name: $name) { + id + name + members { + membershipID + role + user { + uid + displayName + email + photoURL + } + } + myRole + ownersCount + editorsCount + viewersCount + } +} \ No newline at end of file diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateTeamEnvironment.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateTeamEnvironment.graphql new file mode 100644 index 0000000..2e72455 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateTeamEnvironment.graphql @@ -0,0 +1,12 @@ +mutation CreateTeamEnvironment( + $variables: String! + $teamID: ID! + $name: String! +) { + createTeamEnvironment(variables: $variables, teamID: $teamID, name: $name) { + variables + name + teamID + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateTeamInvitation.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateTeamInvitation.graphql new file mode 100644 index 0000000..1c3272a --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateTeamInvitation.graphql @@ -0,0 +1,9 @@ +mutation CreateTeamInvitation($inviteeEmail: String!, $inviteeRole: TeamAccessRole!, $teamID: ID!) { + createTeamInvitation(inviteeRole: $inviteeRole, inviteeEmail: $inviteeEmail, teamID: $teamID) { + id + teamID + creatorUid + inviteeEmail + inviteeRole + } +} \ No newline at end of file diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateUserEnvironment.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateUserEnvironment.graphql new file mode 100644 index 0000000..1865106 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateUserEnvironment.graphql @@ -0,0 +1,9 @@ +mutation CreateUserEnvironment($name: String!, $variables: String!) { + createUserEnvironment(name: $name, variables: $variables) { + id + userUid + name + variables + isGlobal + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateUserGlobalEnvironment.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateUserGlobalEnvironment.graphql new file mode 100644 index 0000000..8e7553f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateUserGlobalEnvironment.graphql @@ -0,0 +1,5 @@ +mutation CreateUserGlobalEnvironment($variables: String!) { + createUserGlobalEnvironment(variables: $variables) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateUserHistory.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateUserHistory.graphql new file mode 100644 index 0000000..ef9da5c --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateUserHistory.graphql @@ -0,0 +1,13 @@ +mutation CreateUserHistory( + $reqData: String! + $resMetadata: String! + $reqType: ReqType! +) { + createUserHistory( + reqData: $reqData + resMetadata: $resMetadata + reqType: $reqType + ) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateUserSettings.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateUserSettings.graphql new file mode 100644 index 0000000..2ada8a1 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateUserSettings.graphql @@ -0,0 +1,5 @@ +mutation CreateUserSettings($properties: String!) { + createUserSettings(properties: $properties) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteAllUserHistory.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteAllUserHistory.graphql new file mode 100644 index 0000000..3e50858 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteAllUserHistory.graphql @@ -0,0 +1,6 @@ +mutation DeleteAllUserHistory($reqType: ReqType!) { + deleteAllUserHistory(reqType: $reqType) { + count + reqType + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteCollection.graphql new file mode 100644 index 0000000..6ba813d --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteCollection.graphql @@ -0,0 +1,3 @@ +mutation DeleteCollection($collectionID: ID!) { + deleteCollection(collectionID: $collectionID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteMockServer.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteMockServer.graphql new file mode 100644 index 0000000..5920809 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteMockServer.graphql @@ -0,0 +1,3 @@ +mutation DeleteMockServer($id: ID!) { + deleteMockServer(id: $id) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteMockServerLog.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteMockServerLog.graphql new file mode 100644 index 0000000..0c916e9 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteMockServerLog.graphql @@ -0,0 +1,3 @@ +mutation DeleteMockServerLog($logID: ID!) { + deleteMockServerLog(logID: $logID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeletePublishedDoc.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeletePublishedDoc.graphql new file mode 100644 index 0000000..e515f36 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeletePublishedDoc.graphql @@ -0,0 +1,3 @@ +mutation DeletePublishedDoc($id: ID!) { + deletePublishedDoc(id: $id) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteRequest.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteRequest.graphql new file mode 100644 index 0000000..85c69ad --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteRequest.graphql @@ -0,0 +1,3 @@ +mutation DeleteRequest($requestID: ID!) { + deleteRequest(requestID: $requestID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteShortcode.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteShortcode.graphql new file mode 100644 index 0000000..38935eb --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteShortcode.graphql @@ -0,0 +1,3 @@ +mutation DeleteShortcode($code: ID!) { + revokeShortcode(code: $code) +} \ No newline at end of file diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteTeam.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteTeam.graphql new file mode 100644 index 0000000..a63baa1 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteTeam.graphql @@ -0,0 +1,3 @@ +mutation DeleteTeam($teamID: ID!) { + deleteTeam(teamID: $teamID) +} \ No newline at end of file diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteTeamEnvironment.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteTeamEnvironment.graphql new file mode 100644 index 0000000..f012c83 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteTeamEnvironment.graphql @@ -0,0 +1,3 @@ +mutation DeleteTeamEnvironment($id: ID!){ + deleteTeamEnvironment (id: $id ) +} \ No newline at end of file diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteUser.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteUser.graphql new file mode 100644 index 0000000..8e52969 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteUser.graphql @@ -0,0 +1,3 @@ +mutation DeleteUser { + deleteUser +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteUserCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteUserCollection.graphql new file mode 100644 index 0000000..cafa7b0 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteUserCollection.graphql @@ -0,0 +1,3 @@ +mutation DeleteUserCollection($userCollectionID: ID!) { + deleteUserCollection(userCollectionID: $userCollectionID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteUserEnvironments.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteUserEnvironments.graphql new file mode 100644 index 0000000..1489719 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteUserEnvironments.graphql @@ -0,0 +1,3 @@ +mutation DeleteUserEnvironment($id: ID!) { + deleteUserEnvironment(id: $id) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteUserRequest.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteUserRequest.graphql new file mode 100644 index 0000000..c4855e8 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteUserRequest.graphql @@ -0,0 +1,3 @@ +mutation DeleteUserRequest($requestID: ID!) { + deleteUserRequest(id: $requestID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DuplicateTeamCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DuplicateTeamCollection.graphql new file mode 100644 index 0000000..ef4a60a --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DuplicateTeamCollection.graphql @@ -0,0 +1,3 @@ +mutation DuplicateTeamCollection($collectionID: String!) { + duplicateTeamCollection(collectionID: $collectionID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DuplicateUserCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DuplicateUserCollection.graphql new file mode 100644 index 0000000..c9de0cb --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DuplicateUserCollection.graphql @@ -0,0 +1,6 @@ +mutation DuplicateUserCollection($collectionID: String!, $reqType: ReqType!) { + duplicateUserCollection(collectionID: $collectionID, reqType: $reqType) { + exportedCollection + collectionType + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/ImportFromJSON.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/ImportFromJSON.graphql new file mode 100644 index 0000000..6c6bc07 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/ImportFromJSON.graphql @@ -0,0 +1,3 @@ +mutation importFromJSON($jsonString: String!, $teamID: ID!) { + importCollectionsFromJSON(jsonString: $jsonString, teamID: $teamID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/ImportUserCollectionsFromJSON.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/ImportUserCollectionsFromJSON.graphql new file mode 100644 index 0000000..beddb87 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/ImportUserCollectionsFromJSON.graphql @@ -0,0 +1,14 @@ +mutation ImportUserCollectionsFromJSON( + $jsonString: String! + $reqType: ReqType! + $parentCollectionID: ID +) { + importUserCollectionsFromJSON( + jsonString: $jsonString + reqType: $reqType + parentCollectionID: $parentCollectionID + ) { + exportedCollection + collectionType + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/LeaveTeam.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/LeaveTeam.graphql new file mode 100644 index 0000000..8a0a46c --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/LeaveTeam.graphql @@ -0,0 +1,3 @@ +mutation LeaveTeam($teamID: ID!) { + leaveTeam(teamID: $teamID) +} \ No newline at end of file diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/MoveRESTTeamCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/MoveRESTTeamCollection.graphql new file mode 100644 index 0000000..88794ca --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/MoveRESTTeamCollection.graphql @@ -0,0 +1,8 @@ +mutation MoveRESTTeamCollection($collectionID: ID!, $parentCollectionID: ID) { + moveCollection( + collectionID: $collectionID + parentCollectionID: $parentCollectionID + ) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/MoveRESTTeamRequest.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/MoveRESTTeamRequest.graphql new file mode 100644 index 0000000..c1538d3 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/MoveRESTTeamRequest.graphql @@ -0,0 +1,5 @@ +mutation MoveRESTTeamRequest($collectionID: ID!, $requestID: ID!) { + moveRequest(destCollID: $collectionID, requestID: $requestID) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/MoveUserCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/MoveUserCollection.graphql new file mode 100644 index 0000000..ac28012 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/MoveUserCollection.graphql @@ -0,0 +1,8 @@ +mutation MoveUserCollection($destCollectionID: ID, $userCollectionID: ID!) { + moveUserCollection( + destCollectionID: $destCollectionID + userCollectionID: $userCollectionID + ) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/MoveUserRequest.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/MoveUserRequest.graphql new file mode 100644 index 0000000..38f8e6c --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/MoveUserRequest.graphql @@ -0,0 +1,15 @@ +mutation MoveUserRequest( + $sourceCollectionID: ID! + $requestID: ID! + $destinationCollectionID: ID! + $nextRequestID: ID +) { + moveUserRequest( + sourceCollectionID: $sourceCollectionID + requestID: $requestID + destinationCollectionID: $destinationCollectionID + nextRequestID: $nextRequestID + ) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RemoveRequestFromHistory.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RemoveRequestFromHistory.graphql new file mode 100644 index 0000000..a0dd3e9 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RemoveRequestFromHistory.graphql @@ -0,0 +1,5 @@ +mutation RemoveRequestFromHistory($id: ID!) { + removeRequestFromHistory(id: $id) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RemoveTeamMember.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RemoveTeamMember.graphql new file mode 100644 index 0000000..4b3fba1 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RemoveTeamMember.graphql @@ -0,0 +1,3 @@ +mutation RemoveTeamMember($userUid: ID!, $teamID: ID!) { + removeTeamMember(userUid: $userUid, teamID: $teamID) +} \ No newline at end of file diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RenameCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RenameCollection.graphql new file mode 100644 index 0000000..a9dc173 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RenameCollection.graphql @@ -0,0 +1,5 @@ +mutation RenameCollection($newTitle: String!, $collectionID: ID!) { + renameCollection(newTitle: $newTitle, collectionID: $collectionID) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RenameTeam.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RenameTeam.graphql new file mode 100644 index 0000000..5670882 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RenameTeam.graphql @@ -0,0 +1,13 @@ +mutation RenameTeam($newName: String!, $teamID: ID!) { + renameTeam(newName: $newName, teamID: $teamID) { + id + name + teamMembers { + membershipID + user { + uid + } + role + } + } +} \ No newline at end of file diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RenameUserCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RenameUserCollection.graphql new file mode 100644 index 0000000..35603ce --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RenameUserCollection.graphql @@ -0,0 +1,8 @@ +mutation RenameUserCollection($userCollectionID: ID!, $newTitle: String!) { + renameUserCollection( + userCollectionID: $userCollectionID + newTitle: $newTitle + ) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RevokeTeamInvitation.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RevokeTeamInvitation.graphql new file mode 100644 index 0000000..a8652db --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/RevokeTeamInvitation.graphql @@ -0,0 +1,3 @@ +mutation RevokeTeamInvitation($inviteID: ID!) { + revokeTeamInvitation(inviteID: $inviteID) +} \ No newline at end of file diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/SortTeamCollections.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/SortTeamCollections.graphql new file mode 100644 index 0000000..c283f5e --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/SortTeamCollections.graphql @@ -0,0 +1,11 @@ +mutation SortTeamCollections( + $teamID: ID! + $parentCollectionID: ID + $sortOption: SortOptions! +) { + sortTeamCollections( + teamID: $teamID + parentCollectionID: $parentCollectionID + sortOption: $sortOption + ) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/SortUserCollections.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/SortUserCollections.graphql new file mode 100644 index 0000000..6417fd9 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/SortUserCollections.graphql @@ -0,0 +1,9 @@ +mutation SortUserCollections( + $parentCollectionID: ID + $sortOption: SortOptions! +) { + sortUserCollections( + parentCollectionID: $parentCollectionID + sortOption: $sortOption + ) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/ToggleHistoryStarStatus.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/ToggleHistoryStarStatus.graphql new file mode 100644 index 0000000..33bf9c1 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/ToggleHistoryStarStatus.graphql @@ -0,0 +1,5 @@ +mutation ToggleHistoryStarStatus($id: ID!) { + toggleHistoryStarStatus(id: $id) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateCollectionOrder.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateCollectionOrder.graphql new file mode 100644 index 0000000..cb5bff5 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateCollectionOrder.graphql @@ -0,0 +1,3 @@ +mutation UpdateCollectionOrder($collectionID: ID!, $destCollID: ID) { + updateCollectionOrder(collectionID: $collectionID, destCollID: $destCollID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateEmbedProperties.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateEmbedProperties.graphql new file mode 100644 index 0000000..804aac5 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateEmbedProperties.graphql @@ -0,0 +1,8 @@ +mutation UpdateEmbedProperties($code: ID!, $properties: String!) { + updateEmbedProperties(code: $code, properties: $properties) { + id + request + properties + createdOn + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateGQLUserRequest.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateGQLUserRequest.graphql new file mode 100644 index 0000000..253fed7 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateGQLUserRequest.graphql @@ -0,0 +1,5 @@ +mutation UpdateGQLUserRequest($id: ID!, $request: String!, $title: String) { + updateGQLUserRequest(id: $id, request: $request, title: $title) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateLookUpRequestOrder.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateLookUpRequestOrder.graphql new file mode 100644 index 0000000..972f7a6 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateLookUpRequestOrder.graphql @@ -0,0 +1,11 @@ +mutation UpdateLookUpRequestOrder( + $requestID: ID! + $nextRequestID: ID + $collectionID: ID! +) { + updateLookUpRequestOrder( + requestID: $requestID + nextRequestID: $nextRequestID + collectionID: $collectionID + ) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateMockServer.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateMockServer.graphql new file mode 100644 index 0000000..728458f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateMockServer.graphql @@ -0,0 +1,23 @@ +mutation UpdateMockServer($id: ID!, $input: UpdateMockServerInput!) { + updateMockServer(id: $id, input: $input) { + id + name + subdomain + serverUrlPathBased + serverUrlDomainBased + workspaceType + workspaceID + delayInMs + isPublic + isActive + createdOn + updatedOn + creator { + uid + } + collection { + id + title + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdatePublishedDoc.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdatePublishedDoc.graphql new file mode 100644 index 0000000..772bf57 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdatePublishedDoc.graphql @@ -0,0 +1,12 @@ +mutation UpdatePublishedDoc($id: ID!, $args: UpdatePublishedDocsArgs!) { + updatePublishedDoc(id: $id, args: $args) { + id + title + version + autoSync + url + environmentName + createdOn + updatedOn + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateRESTUserRequest.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateRESTUserRequest.graphql new file mode 100644 index 0000000..b7899df --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateRESTUserRequest.graphql @@ -0,0 +1,7 @@ +mutation UpdateRESTUserRequest($id: ID!, $title: String!, $request: String!) { + updateRESTUserRequest(id: $id, title: $title, request: $request) { + id + collectionID + request + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateRequest.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateRequest.graphql new file mode 100644 index 0000000..d556ac8 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateRequest.graphql @@ -0,0 +1,6 @@ +mutation UpdateRequest($data: UpdateTeamRequestInput!, $requestID: ID!) { + updateRequest(data: $data, requestID: $requestID) { + id + title + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateTeamCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateTeamCollection.graphql new file mode 100644 index 0000000..3ca5cdf --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateTeamCollection.graphql @@ -0,0 +1,15 @@ +mutation UpdateTeamCollection( + $collectionID: ID! + $newTitle: String + $data: String +) { + updateTeamCollection( + collectionID: $collectionID + newTitle: $newTitle + data: $data + ) { + id + title + data + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateTeamEnvironment.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateTeamEnvironment.graphql new file mode 100644 index 0000000..d2f2e3f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateTeamEnvironment.graphql @@ -0,0 +1,7 @@ +mutation UpdateTeamEnvironment($variables: String!,$id: ID!,$name: String!){ + updateTeamEnvironment( variables: $variables ,id: $id ,name: $name){ + variables + name + id + } +} \ No newline at end of file diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateTeamMemberRole.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateTeamMemberRole.graphql new file mode 100644 index 0000000..4cb0a54 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateTeamMemberRole.graphql @@ -0,0 +1,14 @@ +mutation UpdateTeamAccessRole( + $newRole: TeamAccessRole!, + $userUid: ID!, + $teamID: ID! +) { + updateTeamAccessRole( + newRole: $newRole + userUid: $userUid + teamID: $teamID + ) { + membershipID + role + } +} \ No newline at end of file diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateUserCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateUserCollection.graphql new file mode 100644 index 0000000..c5e89d2 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateUserCollection.graphql @@ -0,0 +1,15 @@ +mutation UpdateUserCollection( + $userCollectionID: ID! + $newTitle: String + $data: String +) { + updateUserCollection( + userCollectionID: $userCollectionID + newTitle: $newTitle + data: $data + ) { + id + title + data + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateUserCollectionOrder.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateUserCollectionOrder.graphql new file mode 100644 index 0000000..c9e665b --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateUserCollectionOrder.graphql @@ -0,0 +1,6 @@ +mutation UpdateUserCollectionOrder($collectionID: ID!, $nextCollectionID: ID) { + updateUserCollectionOrder( + collectionID: $collectionID + nextCollectionID: $nextCollectionID + ) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateUserDisplayName.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateUserDisplayName.graphql new file mode 100644 index 0000000..90a9eed --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateUserDisplayName.graphql @@ -0,0 +1,5 @@ +mutation UpdateUserDisplayName($updatedDisplayName: String!) { + updateDisplayName(updatedDisplayName: $updatedDisplayName) { + displayName + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateUserEnvironment.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateUserEnvironment.graphql new file mode 100644 index 0000000..c849a15 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateUserEnvironment.graphql @@ -0,0 +1,9 @@ +mutation UpdateUserEnvironment($id: ID!, $name: String!, $variables: String!) { + updateUserEnvironment(id: $id, name: $name, variables: $variables) { + id + userUid + name + variables + isGlobal + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateUserSettings.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateUserSettings.graphql new file mode 100644 index 0000000..85bb4b0 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateUserSettings.graphql @@ -0,0 +1,5 @@ +mutation UpdateUserSettings($properties: String!) { + updateUserSettings(properties: $properties) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/ExportAsJSON.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/ExportAsJSON.graphql new file mode 100644 index 0000000..831fa36 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/ExportAsJSON.graphql @@ -0,0 +1,3 @@ +query ExportAsJSON($teamID: ID!) { + exportCollectionsToJSON(teamID: $teamID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/ExportCollectionToJSON.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/ExportCollectionToJSON.graphql new file mode 100644 index 0000000..8198648 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/ExportCollectionToJSON.graphql @@ -0,0 +1,3 @@ +query ExportCollectionToJSON($teamID: ID!, $collectionID: ID!) { + exportCollectionToJSON(teamID: $teamID, collectionID: $collectionID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/ExportUserCollectionsToJSON.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/ExportUserCollectionsToJSON.graphql new file mode 100644 index 0000000..09f54ad --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/ExportUserCollectionsToJSON.graphql @@ -0,0 +1,12 @@ +query ExportUserCollectionsToJSON( + $collectionID: ID + $collectionType: ReqType! +) { + exportUserCollectionsToJSON( + collectionID: $collectionID + collectionType: $collectionType + ) { + collectionType + exportedCollection + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetCollectionChildren.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetCollectionChildren.graphql new file mode 100644 index 0000000..1a4f947 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetCollectionChildren.graphql @@ -0,0 +1,9 @@ +query GetCollectionChildren($collectionID: ID!, $cursor: ID) { + collection(collectionID: $collectionID) { + children(cursor: $cursor) { + id + title + data + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetCollectionChildrenIDs.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetCollectionChildrenIDs.graphql new file mode 100644 index 0000000..500677d --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetCollectionChildrenIDs.graphql @@ -0,0 +1,7 @@ +query GetCollectionChildrenIDs($collectionID: ID!, $cursor: ID) { + collection(collectionID: $collectionID) { + children(cursor: $cursor) { + id + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetCollectionRequests.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetCollectionRequests.graphql new file mode 100644 index 0000000..e5d0b55 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetCollectionRequests.graphql @@ -0,0 +1,7 @@ +query GetCollectionRequests($collectionID: ID!, $cursor: ID) { + requestsInCollection(collectionID: $collectionID, cursor: $cursor) { + id + title + request + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetCollectionTitleAndData.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetCollectionTitleAndData.graphql new file mode 100644 index 0000000..cbde01b --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetCollectionTitleAndData.graphql @@ -0,0 +1,6 @@ +query GetCollectionTitleAndData($collectionID: ID!) { + collection(collectionID: $collectionID) { + title + data + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetGQLRootUserCollections.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetGQLRootUserCollections.graphql new file mode 100644 index 0000000..b840ac9 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetGQLRootUserCollections.graphql @@ -0,0 +1,34 @@ +query GetGQLRootUserCollections($cursor: ID, $take: Int) { + rootGQLUserCollections(cursor: $cursor, take: $take) { + id + title + data + type + parent { + id + } + requests { + id + title + request + type + collectionID + } + childrenGQL { + id + title + data + type + parent { + id + } + requests { + id + title + request + type + collectionID + } + } + } +} \ No newline at end of file diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetGlobalEnvironments.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetGlobalEnvironments.graphql new file mode 100644 index 0000000..08d3c21 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetGlobalEnvironments.graphql @@ -0,0 +1,11 @@ +query GetGlobalEnvironments { + me { + globalEnvironments { + id + isGlobal + name + userUid + variables + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetInviteDetails.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetInviteDetails.graphql new file mode 100644 index 0000000..c1895aa --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetInviteDetails.graphql @@ -0,0 +1,16 @@ +query GetInviteDetails($inviteID: ID!) { + teamInvitation(inviteID: $inviteID) { + id + inviteeEmail + inviteeRole + team { + id + name + } + creator { + uid + displayName + email + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMockServer.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMockServer.graphql new file mode 100644 index 0000000..9839596 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMockServer.graphql @@ -0,0 +1,23 @@ +query GetMockServer($id: ID!) { + mockServer(id: $id) { + id + name + subdomain + serverUrlPathBased + serverUrlDomainBased + workspaceType + workspaceID + delayInMs + isPublic + isActive + createdOn + updatedOn + creator { + uid + } + collection { + id + title + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMockServerLogs.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMockServerLogs.graphql new file mode 100644 index 0000000..a3f1d7f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMockServerLogs.graphql @@ -0,0 +1,18 @@ +query GetMockServerLogs($mockServerID: ID!, $skip: Int, $take: Int) { + mockServerLogs(mockServerID: $mockServerID, skip: $skip, take: $take) { + id + mockServerID + requestMethod + requestPath + requestHeaders + requestBody + requestQuery + responseStatus + responseHeaders + responseBody + responseTime + ipAddress + userAgent + executedAt + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMyMockServers.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMyMockServers.graphql new file mode 100644 index 0000000..6da2d11 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMyMockServers.graphql @@ -0,0 +1,23 @@ +query GetMyMockServers($skip: Int, $take: Int) { + myMockServers(skip: $skip, take: $take) { + id + name + subdomain + serverUrlPathBased + serverUrlDomainBased + workspaceType + workspaceID + delayInMs + isPublic + isActive + createdOn + updatedOn + creator { + uid + } + collection { + id + title + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMyShortcodes.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMyShortcodes.graphql new file mode 100644 index 0000000..e1335e3 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMyShortcodes.graphql @@ -0,0 +1,8 @@ +query GetUserShortcodes($cursor: ID) { + myShortcodes(cursor: $cursor) { + id + request + createdOn + properties + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMyTeams.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMyTeams.graphql new file mode 100644 index 0000000..b2a8139 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMyTeams.graphql @@ -0,0 +1,18 @@ +query GetMyTeams($cursor: ID) { + myTeams(cursor: $cursor) { + id + name + myRole + ownersCount + teamMembers { + membershipID + user { + photoURL + displayName + email + uid + } + role + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetRestUserHistory.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetRestUserHistory.graphql new file mode 100644 index 0000000..1448615 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetRestUserHistory.graphql @@ -0,0 +1,23 @@ +query GetRESTUserHistory { + me { + RESTHistory { + id + userUid + reqType + request + responseMetadata + isStarred + executedOn + } + + GQLHistory { + id + userUid + reqType + request + responseMetadata + isStarred + executedOn + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetSMTPStatus.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetSMTPStatus.graphql new file mode 100644 index 0000000..bb8458d --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetSMTPStatus.graphql @@ -0,0 +1,3 @@ +query GetSMTPStatus { + isSMTPEnabled +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetSingleCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetSingleCollection.graphql new file mode 100644 index 0000000..398da3b --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetSingleCollection.graphql @@ -0,0 +1,10 @@ +query GetSingleCollection($collectionID: ID!) { + collection(collectionID: $collectionID) { + id + title + data + parent { + id + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetSingleRequest.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetSingleRequest.graphql new file mode 100644 index 0000000..ba7cb4c --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetSingleRequest.graphql @@ -0,0 +1,8 @@ +query GetSingleRequest($requestID: ID!) { + request(requestID: $requestID) { + id + collectionID + title + request + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetTeam.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetTeam.graphql new file mode 100644 index 0000000..852c6cd --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetTeam.graphql @@ -0,0 +1,14 @@ +query GetTeam($teamID: ID!) { + team(teamID: $teamID) { + id + name + teamMembers { + membershipID + user { + uid + email + } + role + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetTeamEnvironments.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetTeamEnvironments.graphql new file mode 100644 index 0000000..5f9b825 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetTeamEnvironments.graphql @@ -0,0 +1,10 @@ +query GetTeamEnvironments($teamID: ID!){ + team(teamID: $teamID){ + teamEnvironments{ + id + name + variables + teamID + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetTeamMembers.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetTeamMembers.graphql new file mode 100644 index 0000000..d6fa4f0 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetTeamMembers.graphql @@ -0,0 +1,12 @@ +query GetTeamMembers($teamID: ID!, $cursor: ID) { + team(teamID: $teamID) { + members(cursor: $cursor) { + membershipID + user { + uid + email + } + role + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetTeamMockServers.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetTeamMockServers.graphql new file mode 100644 index 0000000..1ac7fd4 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetTeamMockServers.graphql @@ -0,0 +1,23 @@ +query GetTeamMockServers($teamID: ID!, $skip: Int, $take: Int) { + teamMockServers(teamID: $teamID, skip: $skip, take: $take) { + id + name + subdomain + serverUrlPathBased + serverUrlDomainBased + workspaceType + workspaceID + delayInMs + isPublic + isActive + createdOn + updatedOn + creator { + uid + } + collection { + id + title + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetUserEnvironments.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetUserEnvironments.graphql new file mode 100644 index 0000000..0bbeddb --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetUserEnvironments.graphql @@ -0,0 +1,11 @@ +query GetUserEnvironments { + me { + environments { + id + isGlobal + name + userUid + variables + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetUserInfo.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetUserInfo.graphql new file mode 100644 index 0000000..458c106 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetUserInfo.graphql @@ -0,0 +1,8 @@ +query GetUserInfo { + me { + uid + displayName + email + photoURL + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetUserRootCollections.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetUserRootCollections.graphql new file mode 100644 index 0000000..57a1e05 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetUserRootCollections.graphql @@ -0,0 +1,15 @@ +query GetUserRootCollections { + # the frontend doesn't paginate right now, so giving take a big enough value to get all collections at once + rootRESTUserCollections(take: 99999) { + id + title + type + data + childrenREST { + id + title + type + data + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetUserSettings.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetUserSettings.graphql new file mode 100644 index 0000000..1ac9455 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetUserSettings.graphql @@ -0,0 +1,8 @@ +query GetUserSettings { + me { + settings { + id + properties + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/Me.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/Me.graphql new file mode 100644 index 0000000..5b89be8 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/Me.graphql @@ -0,0 +1,7 @@ +query Me { + me { + uid + displayName + photoURL + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/PublishedDoc.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/PublishedDoc.graphql new file mode 100644 index 0000000..cd80e44 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/PublishedDoc.graphql @@ -0,0 +1,24 @@ +query PublishedDoc($id: ID!) { + publishedDoc(id: $id) { + id + title + version + autoSync + url + metadata + environmentName + environmentVariables + createdOn + updatedOn + creator { + uid + displayName + email + photoURL + } + collection { + id + title + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/ResolveShortcode.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/ResolveShortcode.graphql new file mode 100644 index 0000000..16af0e2 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/ResolveShortcode.graphql @@ -0,0 +1,7 @@ +query ResolveShortcode($code: ID!) { + shortcode(code: $code) { + id + request + properties + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/RootCollectionsOfTeam.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/RootCollectionsOfTeam.graphql new file mode 100644 index 0000000..849b525 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/RootCollectionsOfTeam.graphql @@ -0,0 +1,7 @@ +query RootCollectionsOfTeam($teamID: ID!, $cursor: ID) { + rootCollectionsOfTeam(teamID: $teamID, cursor: $cursor) { + id + title + data + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/TeamPublishedDocsList.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/TeamPublishedDocsList.graphql new file mode 100644 index 0000000..23df0ed --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/TeamPublishedDocsList.graphql @@ -0,0 +1,26 @@ +query TeamPublishedDocsList( + $teamID: ID! + $collectionID: ID + $skip: Int! + $take: Int! +) { + teamPublishedDocsList( + teamID: $teamID + collectionID: $collectionID + skip: $skip + take: $take + ) { + id + title + version + autoSync + url + documentTree + environmentName + collection { + id + } + createdOn + updatedOn + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/UserPublishedDocsList.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/UserPublishedDocsList.graphql new file mode 100644 index 0000000..2ad8596 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/UserPublishedDocsList.graphql @@ -0,0 +1,16 @@ +query UserPublishedDocsList($skip: Int!, $take: Int!) { + userPublishedDocsList(skip: $skip, take: $take) { + id + title + version + autoSync + url + documentTree + environmentName + collection { + id + } + createdOn + updatedOn + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/pendingInvites.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/pendingInvites.graphql new file mode 100644 index 0000000..bbc1077 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/pendingInvites.graphql @@ -0,0 +1,10 @@ +query GetPendingInvites($teamID: ID!) { + team(teamID: $teamID) { + id + teamInvitations { + inviteeRole + inviteeEmail + id + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/ShortcodeCreated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/ShortcodeCreated.graphql new file mode 100644 index 0000000..5647751 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/ShortcodeCreated.graphql @@ -0,0 +1,8 @@ +subscription ShortcodeCreated { + myShortcodesCreated { + id + request + createdOn + properties + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/ShortcodeDeleted.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/ShortcodeDeleted.graphql new file mode 100644 index 0000000..6c65064 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/ShortcodeDeleted.graphql @@ -0,0 +1,5 @@ +subscription ShortcodeDeleted { + myShortcodesRevoked { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/ShortcodeUpdated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/ShortcodeUpdated.graphql new file mode 100644 index 0000000..cbbf6af --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/ShortcodeUpdated.graphql @@ -0,0 +1,8 @@ +subscription ShortcodeUpdated { + myShortcodesUpdated { + id + request + createdOn + properties + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamChildCollectionSorted.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamChildCollectionSorted.graphql new file mode 100644 index 0000000..a7f2356 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamChildCollectionSorted.graphql @@ -0,0 +1,3 @@ +subscription TeamChildCollectionSorted($teamID: ID!) { + teamChildCollectionsSorted(teamID: $teamID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamCollectionAdded.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamCollectionAdded.graphql new file mode 100644 index 0000000..7cd38bb --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamCollectionAdded.graphql @@ -0,0 +1,10 @@ +subscription TeamCollectionAdded($teamID: ID!) { + teamCollectionAdded(teamID: $teamID) { + id + title + data + parent { + id + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamCollectionMoved.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamCollectionMoved.graphql new file mode 100644 index 0000000..8338e1f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamCollectionMoved.graphql @@ -0,0 +1,10 @@ +subscription TeamCollectionMoved($teamID: ID!) { + teamCollectionMoved(teamID: $teamID) { + id + title + parent { + id + } + data + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamCollectionOrderUpdated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamCollectionOrderUpdated.graphql new file mode 100644 index 0000000..0509082 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamCollectionOrderUpdated.graphql @@ -0,0 +1,18 @@ +subscription TeamCollectionOrderUpdated($teamID: ID!) { + collectionOrderUpdated(teamID: $teamID) { + collection { + id + title + parent { + id + } + } + nextCollection { + id + title + parent { + id + } + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamCollectionRemoved.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamCollectionRemoved.graphql new file mode 100644 index 0000000..e9d9431 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamCollectionRemoved.graphql @@ -0,0 +1,3 @@ +subscription TeamCollectionRemoved($teamID: ID!) { + teamCollectionRemoved(teamID: $teamID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamCollectionUpdated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamCollectionUpdated.graphql new file mode 100644 index 0000000..dbc33ce --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamCollectionUpdated.graphql @@ -0,0 +1,10 @@ +subscription TeamCollectionUpdated($teamID: ID!) { + teamCollectionUpdated(teamID: $teamID) { + id + title + data + parent { + id + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamEnvironmentCreated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamEnvironmentCreated.graphql new file mode 100644 index 0000000..51aecdd --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamEnvironmentCreated.graphql @@ -0,0 +1,8 @@ +subscription TeamEnvironmentCreated ($teamID: ID!) { + teamEnvironmentCreated(teamID: $teamID) { + id + teamID + name + variables + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamEnvironmentDeleted.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamEnvironmentDeleted.graphql new file mode 100644 index 0000000..545a50f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamEnvironmentDeleted.graphql @@ -0,0 +1,5 @@ +subscription TeamEnvironmentDeleted ($teamID: ID!) { + teamEnvironmentDeleted(teamID: $teamID) { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamEnvironmentUpdated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamEnvironmentUpdated.graphql new file mode 100644 index 0000000..561054b --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamEnvironmentUpdated.graphql @@ -0,0 +1,8 @@ +subscription TeamEnvironmentUpdated ($teamID: ID!) { + teamEnvironmentUpdated(teamID: $teamID) { + id + teamID + name + variables + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamInvitationAdded.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamInvitationAdded.graphql new file mode 100644 index 0000000..878ec13 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamInvitationAdded.graphql @@ -0,0 +1,5 @@ +subscription TeamInvitationAdded($teamID: ID!) { + teamInvitationAdded(teamID: $teamID) { + id + } +} \ No newline at end of file diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamInvitationRemoved.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamInvitationRemoved.graphql new file mode 100644 index 0000000..d90488f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamInvitationRemoved.graphql @@ -0,0 +1,3 @@ +subscription TeamInvitationRemoved($teamID: ID!) { + teamInvitationRemoved(teamID: $teamID) +} \ No newline at end of file diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamMemberAdded.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamMemberAdded.graphql new file mode 100644 index 0000000..79b7199 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamMemberAdded.graphql @@ -0,0 +1,10 @@ +subscription TeamMemberAdded($teamID: ID!) { + teamMemberAdded(teamID: $teamID) { + membershipID + user { + uid + email + } + role + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamMemberRemoved.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamMemberRemoved.graphql new file mode 100644 index 0000000..36ecb40 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamMemberRemoved.graphql @@ -0,0 +1,3 @@ +subscription TeamMemberRemoved($teamID: ID!) { + teamMemberRemoved(teamID: $teamID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamMemberUpdated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamMemberUpdated.graphql new file mode 100644 index 0000000..bdf0e52 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamMemberUpdated.graphql @@ -0,0 +1,10 @@ +subscription TeamMemberUpdated($teamID: ID!) { + teamMemberUpdated(teamID: $teamID) { + membershipID + user { + uid + email + } + role + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRequestAdded.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRequestAdded.graphql new file mode 100644 index 0000000..d00b6a7 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRequestAdded.graphql @@ -0,0 +1,8 @@ +subscription TeamRequestAdded($teamID: ID!) { + teamRequestAdded(teamID: $teamID) { + id + collectionID + request + title + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRequestDeleted.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRequestDeleted.graphql new file mode 100644 index 0000000..8045e83 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRequestDeleted.graphql @@ -0,0 +1,3 @@ +subscription TeamRequestDeleted($teamID: ID!) { + teamRequestDeleted(teamID: $teamID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRequestMoved.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRequestMoved.graphql new file mode 100644 index 0000000..bda0cb0 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRequestMoved.graphql @@ -0,0 +1,8 @@ +subscription TeamRequestMoved($teamID: ID!) { + requestMoved(teamID: $teamID) { + id + collectionID + request + title + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRequestOrderUpdated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRequestOrderUpdated.graphql new file mode 100644 index 0000000..1b09ce5 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRequestOrderUpdated.graphql @@ -0,0 +1,16 @@ +subscription TeamRequestOrderUpdated($teamID: ID!) { + requestOrderUpdated(teamID: $teamID) { + request { + id + collectionID + request + title + } + nextRequest { + id + collectionID + request + title + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRequestUpdated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRequestUpdated.graphql new file mode 100644 index 0000000..ef099da --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRequestUpdated.graphql @@ -0,0 +1,8 @@ +subscription TeamRequestUpdated($teamID: ID!) { + teamRequestUpdated(teamID: $teamID) { + id + collectionID + request + title + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRootCollectionsSorted.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRootCollectionsSorted.graphql new file mode 100644 index 0000000..ba2db41 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRootCollectionsSorted.graphql @@ -0,0 +1,3 @@ +subscription TeamRootCollectionsSorted($teamID: ID!) { + teamRootCollectionsSorted(teamID: $teamID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserChildCollectionSorted.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserChildCollectionSorted.graphql new file mode 100644 index 0000000..595dbec --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserChildCollectionSorted.graphql @@ -0,0 +1,6 @@ +subscription UserChildCollectionSorted { + userChildCollectionsSorted { + parentCollectionID + sortOption + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserCollectionCreated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserCollectionCreated.graphql new file mode 100644 index 0000000..1accfec --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserCollectionCreated.graphql @@ -0,0 +1,11 @@ +subscription UserCollectionCreated { + userCollectionCreated { + parent { + id + } + id + title + type + data + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserCollectionMoved.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserCollectionMoved.graphql new file mode 100644 index 0000000..ed69fa9 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserCollectionMoved.graphql @@ -0,0 +1,9 @@ +subscription UserCollectionMoved { + userCollectionMoved { + id + parent { + id + } + type + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserCollectionOrderUpdated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserCollectionOrderUpdated.graphql new file mode 100644 index 0000000..6b181af --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserCollectionOrderUpdated.graphql @@ -0,0 +1,17 @@ +subscription UserCollectionOrderUpdated { + userCollectionOrderUpdated { + userCollection { + id + parent { + id + } + } + + nextUserCollection { + id + parent { + id + } + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserCollectionRemoved.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserCollectionRemoved.graphql new file mode 100644 index 0000000..a3deba5 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserCollectionRemoved.graphql @@ -0,0 +1,6 @@ +subscription UserCollectionRemoved { + userCollectionRemoved { + id + type + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserCollectionUpdated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserCollectionUpdated.graphql new file mode 100644 index 0000000..8b52b69 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserCollectionUpdated.graphql @@ -0,0 +1,11 @@ +subscription userCollectionUpdated { + userCollectionUpdated { + id + title + type + data + parent { + id + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserEnvironmentCreated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserEnvironmentCreated.graphql new file mode 100644 index 0000000..49fb4b6 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserEnvironmentCreated.graphql @@ -0,0 +1,9 @@ +subscription UserEnvironmentCreated { + userEnvironmentCreated { + id + isGlobal + name + userUid + variables + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserEnvironmentDeleted.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserEnvironmentDeleted.graphql new file mode 100644 index 0000000..1ceb27e --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserEnvironmentDeleted.graphql @@ -0,0 +1,5 @@ +subscription UserEnvironmentDeleted { + userEnvironmentDeleted { + id + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserEnvironmentUpdated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserEnvironmentUpdated.graphql new file mode 100644 index 0000000..730c33e --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserEnvironmentUpdated.graphql @@ -0,0 +1,9 @@ +subscription UserEnvironmentUpdated { + userEnvironmentUpdated { + id + userUid + name + variables + isGlobal + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserHistoryAllDeleted.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserHistoryAllDeleted.graphql new file mode 100644 index 0000000..2e57592 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserHistoryAllDeleted.graphql @@ -0,0 +1,3 @@ +subscription UserHistoryAllDeleted { + userHistoryAllDeleted +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserHistoryCreated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserHistoryCreated.graphql new file mode 100644 index 0000000..f513671 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserHistoryCreated.graphql @@ -0,0 +1,10 @@ +subscription UserHistoryCreated { + userHistoryCreated { + id + reqType + request + responseMetadata + isStarred + executedOn + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserHistoryDeleted.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserHistoryDeleted.graphql new file mode 100644 index 0000000..ba6540f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserHistoryDeleted.graphql @@ -0,0 +1,6 @@ +subscription userHistoryDeleted { + userHistoryDeleted { + id + reqType + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserHistoryDeletedMany.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserHistoryDeletedMany.graphql new file mode 100644 index 0000000..fae7aca --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserHistoryDeletedMany.graphql @@ -0,0 +1,6 @@ +subscription UserHistoryDeletedMany { + userHistoryDeletedMany { + count + reqType + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserHistoryUpdated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserHistoryUpdated.graphql new file mode 100644 index 0000000..a673edb --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserHistoryUpdated.graphql @@ -0,0 +1,10 @@ +subscription UserHistoryUpdated { + userHistoryUpdated { + id + reqType + request + responseMetadata + isStarred + executedOn + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserRequestCreated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserRequestCreated.graphql new file mode 100644 index 0000000..462bc24 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserRequestCreated.graphql @@ -0,0 +1,9 @@ +subscription UserRequestCreated { + userRequestCreated { + id + collectionID + title + request + type + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserRequestDeleted.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserRequestDeleted.graphql new file mode 100644 index 0000000..6ef26f1 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserRequestDeleted.graphql @@ -0,0 +1,9 @@ +subscription UserRequestDeleted { + userRequestDeleted { + id + collectionID + title + request + type + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserRequestMoved.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserRequestMoved.graphql new file mode 100644 index 0000000..01a0fb2 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserRequestMoved.graphql @@ -0,0 +1,13 @@ +subscription UserRequestMoved { + userRequestMoved { + request { + id + collectionID + type + } + nextRequest { + id + collectionID + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserRequestUpdated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserRequestUpdated.graphql new file mode 100644 index 0000000..b8c7f6e --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserRequestUpdated.graphql @@ -0,0 +1,9 @@ +subscription UserRequestUpdated { + userRequestUpdated { + id + collectionID + title + request + type + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserRootCollectionsSorted.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserRootCollectionsSorted.graphql new file mode 100644 index 0000000..ed77dd4 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserRootCollectionsSorted.graphql @@ -0,0 +1,6 @@ +subscription UserRootCollectionsSorted { + userRootCollectionsSorted { + parentCollectionID + sortOption + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserSettingsUpdated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserSettingsUpdated.graphql new file mode 100644 index 0000000..4b8f6a6 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/UserSettingsUpdated.graphql @@ -0,0 +1,6 @@ +subscription UserSettingsUpdated { + userSettingsUpdated { + id + properties + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/userCollectionDuplicated.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/userCollectionDuplicated.graphql new file mode 100644 index 0000000..401686f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/userCollectionDuplicated.graphql @@ -0,0 +1,15 @@ +subscription UserCollectionDuplicated { + userCollectionDuplicated { + id + parentID + title + type + data + childCollections + requests { + id + request + collectionID + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/helpers.ts b/packages/hoppscotch-common/src/helpers/backend/helpers.ts new file mode 100644 index 0000000..52e5fa4 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/helpers.ts @@ -0,0 +1,394 @@ +import { + CollectionVariable, + HoppCollection, + HoppCollectionVariable, + HoppRESTAuth, + HoppRESTHeaders, + HoppRESTRequest, + makeCollection, + translateToNewRequest, +} from "@hoppscotch/data" +import * as A from "fp-ts/Array" +import * as E from "fp-ts/Either" +import * as TE from "fp-ts/TaskEither" +import { flow, pipe } from "fp-ts/function" +import { z } from "zod" + +import { getI18n } from "~/modules/i18n" +import { TeamCollection } from "../teams/TeamCollection" +import { TeamRequest } from "../teams/TeamRequest" +import { GQLError, runGQLQuery } from "./GQLClient" +import { stripCollectionTreeForStore } from "~/helpers/secretVariables" +import { + ExportAsJsonDocument, + ExportCollectionToJsonDocument, + GetCollectionChildrenIDsDocument, + GetCollectionRequestsDocument, + GetCollectionTitleAndDataDocument, +} from "./graphql" +import { stripRefIdReplacer } from "../import-export/export" + +type TeamCollectionJSON = { + id: string + name: string + folders: TeamCollectionJSON[] + requests: HoppRESTRequest[] + data: string | null +} + +export type CollectionDataProps = { + auth: HoppRESTAuth + headers: HoppRESTHeaders + variables: HoppCollectionVariable[] + description: string | null + preRequestScript: string + testScript: string + // Stable local-store key, round-tripped via `data._ref_id`. The wire + // payload is opaque to the backend, which just echoes it back; the FE + // uses it to pair populated secret-store entries to the backend `id` + // (personal) or to migrate from `_ref_id` to backend `id` (team + // collection import). + _ref_id?: string +} + +export const BACKEND_PAGE_SIZE = 10 + +const getCollectionChildrenIDs = async (collID: string) => { + const collsList: string[] = [] + + while (true) { + const data = await runGQLQuery({ + query: GetCollectionChildrenIDsDocument, + variables: { + collectionID: collID, + cursor: + collsList.length > 0 ? collsList[collsList.length - 1] : undefined, + }, + }) + + if (E.isLeft(data)) { + return E.left(data.left) + } + + if (!data.right.collection) return E.right([]) + + const children = data.right.collection.children || [] + collsList.push(...children.map((x) => x.id)) + + if (children.length !== BACKEND_PAGE_SIZE) break + } + + return E.right(collsList) +} + +const getCollectionRequests = async (collID: string) => { + const reqList: TeamRequest[] = [] + + while (true) { + const data = await runGQLQuery({ + query: GetCollectionRequestsDocument, + variables: { + collectionID: collID, + cursor: reqList.length > 0 ? reqList[reqList.length - 1].id : undefined, + }, + }) + + if (E.isLeft(data)) { + return E.left(data.left) + } + + reqList.push( + ...data.right.requestsInCollection.map( + (x) => + { + id: x.id, + request: translateToNewRequest(JSON.parse(x.request)), + collectionID: collID, + title: x.title, + } + ) + ) + + if (data.right.requestsInCollection.length !== BACKEND_PAGE_SIZE) break + } + + return E.right(reqList) +} + +// Pick the value from the parsed result if it is successful, otherwise, return the default value +const parseWithDefaultValue = ( + parseResult: z.SafeParseReturnType, + defaultValue: T +): T => (parseResult.success ? parseResult.data : defaultValue) + +// Parse the incoming value for the `data` (authorization/headers) field and obtain the value in the expected format +const parseCollectionData = ( + data: string | Record | null +): CollectionDataProps => { + const defaultDataProps: CollectionDataProps = { + auth: { authType: "inherit", authActive: true }, + headers: [], + variables: [], + description: null, + preRequestScript: "", + testScript: "", + } + + if (!data) { + return defaultDataProps + } + + let parsedData: CollectionDataProps | Record | null + + if (typeof data === "string") { + try { + parsedData = JSON.parse(data) + } catch { + return defaultDataProps + } + } else { + parsedData = data + } + + const auth = parseWithDefaultValue( + HoppRESTAuth.safeParse(parsedData?.auth), + defaultDataProps.auth + ) + + const headers = parseWithDefaultValue( + HoppRESTHeaders.safeParse(parsedData?.headers), + defaultDataProps.headers + ) + + const variables = parseWithDefaultValue( + z.array(CollectionVariable).safeParse(parsedData?.variables), + defaultDataProps.variables + ) + + const description = + typeof parsedData?.description === "string" + ? parsedData.description + : defaultDataProps.description + + const preRequestScript = + typeof parsedData?.preRequestScript === "string" + ? parsedData.preRequestScript + : defaultDataProps.preRequestScript + + const testScript = + typeof parsedData?.testScript === "string" + ? parsedData.testScript + : defaultDataProps.testScript + + return { + auth, + headers, + variables, + description, + preRequestScript, + testScript, + } +} + +// Transforms the collection JSON string obtained with workspace level export to `HoppRESTCollection` +export const teamCollectionJSONToHoppRESTColl = ( + coll: TeamCollectionJSON +): HoppCollection => { + const { + auth, + headers, + variables, + description, + preRequestScript, + testScript, + } = parseCollectionData(coll.data) + + return makeCollection({ + id: coll.id, + name: coll.name, + folders: coll.folders?.map(teamCollectionJSONToHoppRESTColl), + requests: coll.requests, + auth, + headers, + variables, + description, + preRequestScript, + testScript, + }) +} + +export const getCompleteCollectionTree = ( + collID: string +): TE.TaskEither, TeamCollection> => + pipe( + TE.Do, + + TE.bind("titleAndData", () => + pipe( + () => + runGQLQuery({ + query: GetCollectionTitleAndDataDocument, + variables: { + collectionID: collID, + }, + }), + TE.map((result) => + result.collection + ? { + title: result.collection!.title, + data: result.collection!.data, + } + : null + ) + ) + ), + TE.bind("children", () => + pipe( + // TaskEither -> () => Promise + () => getCollectionChildrenIDs(collID), + TE.chain(flow(A.map(getCompleteCollectionTree), TE.sequenceArray)) + ) + ), + + TE.bind("requests", () => () => getCollectionRequests(collID)), + + TE.map( + ({ titleAndData, children, requests }) => + { + id: collID, + children, + requests, + title: titleAndData?.title, + data: titleAndData?.data, + } + ) + ) + +export const teamCollToHoppRESTColl = ( + coll: TeamCollection +): HoppCollection => { + const data = + coll.data && coll.data !== "null" + ? JSON.parse(coll.data) + : { + auth: { authType: "inherit", authActive: true }, + headers: [], + variables: [], + description: null, + preRequestScript: "", + testScript: "", + } + + const { + auth, + headers, + variables, + description, + preRequestScript, + testScript, + } = parseCollectionData(data) + + return makeCollection({ + id: coll.id, + name: coll.title, + folders: coll.children?.map(teamCollToHoppRESTColl) ?? [], + requests: coll.requests?.map((x) => x.request) ?? [], + auth: auth ?? { authType: "inherit", authActive: true }, + headers: headers ?? [], + variables: variables ?? [], + description: description ?? null, + preRequestScript: preRequestScript ?? "", + testScript: testScript ?? "", + }) +} + +/** + * Get the JSON string of all the collection of the specified team + * @param teamID - ID of the team + * @returns Either of the JSON string of the collection or the error + */ +export const getTeamCollectionJSON = async (teamID: string) => { + const data = await runGQLQuery({ + query: ExportAsJsonDocument, + variables: { + teamID, + }, + }) + + if (E.isLeft(data)) { + return E.left(data.left.error.toString()) + } + + const collections = JSON.parse(data.right.exportCollectionsToJSON) + + if (!collections.length) { + const t = getI18n() + + return E.left(t("error.no_collections_to_export")) + } + + const hoppCollections = collections + .map(teamCollectionJSONToHoppRESTColl) + .map(stripCollectionTreeForStore) + return E.right(JSON.stringify(hoppCollections, stripRefIdReplacer, 2)) +} + +/** + * Fetch a single team collection and return it as a HoppCollection. + * @param teamID - ID of the team + * @param collectionID - ID of the collection + */ +export const getTeamCollectionObject = async ( + teamID: string, + collectionID: string +): Promise | string, HoppCollection>> => { + const data = await runGQLQuery({ + query: ExportCollectionToJsonDocument, + variables: { + teamID, + collectionID, + }, + }) + + if (E.isLeft(data)) { + return E.left(data.left) + } + + try { + const collection = JSON.parse(data.right.exportCollectionToJSON) + if (!collection) { + return E.left("Collection not found") + } + return E.right(teamCollectionJSONToHoppRESTColl(collection)) + } catch { + return E.left("Failed to parse collection data") + } +} + +/** + * Get the JSON string of a single collection of the specified team + * @param teamID - ID of the team + * @param collectionID - ID of the collection + */ +export const getSingleTeamCollectionJSON = async ( + teamID: string, + collectionID: string +) => { + const result = await getTeamCollectionObject(teamID, collectionID) + + if (E.isLeft(result)) { + const errorMsg = + typeof result.left === "string" + ? result.left + : result.left.error.toString() + return E.left(errorMsg) + } + + return E.right( + JSON.stringify( + stripCollectionTreeForStore(result.right), + stripRefIdReplacer, + 2 + ) + ) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/mutations/MockServer.ts b/packages/hoppscotch-common/src/helpers/backend/mutations/MockServer.ts new file mode 100644 index 0000000..3a17f39 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/mutations/MockServer.ts @@ -0,0 +1,181 @@ +import * as TE from "fp-ts/TaskEither" +import { client } from "../GQLClient" +import { GQLError } from "../GQLClient" +import { getI18n } from "~/modules/i18n" +import { + CreateMockServerDocument, + UpdateMockServerDocument, + DeleteMockServerDocument, + WorkspaceType, +} from "../graphql" + +import type { MockServer } from "../types/MockServer" + +type CreateMockServerError = + | "mock_server/invalid_collection" + | "mock_server/invalid_collection_id" + | "mock_server/name_too_short" + | "mock_server/limit_exceeded" + | "mock_server/already_exists" + +type UpdateMockServerError = + | "mock_server/not_found" + | "mock_server/access_denied" + +type DeleteMockServerError = + | "mock_server/not_found" + | "mock_server/access_denied" + +export const createMockServer = ( + name: string, + workspaceType: WorkspaceType = WorkspaceType.User, + workspaceID?: string, + delayInMs: number = 0, + isPublic: boolean = true, + collectionID?: string, + autoCreateCollection?: boolean, + autoCreateRequestExample?: boolean +) => + TE.tryCatch( + async () => { + const result = await client + .value!.mutation(CreateMockServerDocument, { + input: { + name, + collectionID, + autoCreateCollection, + autoCreateRequestExample, + workspaceType, + workspaceID, + delayInMs, + isPublic, + }, + }) + .toPromise() + + if (result.error) { + // Try to extract a useful error message from the GraphQL error + const err: any = result.error + let message = err.message + + // urql exposes GraphQL errors in graphQLErrors array + const gqlErr = (err.graphQLErrors && err.graphQLErrors[0]) || null + if (gqlErr) { + // Prefer originalError.message from backend if present (it may be an array of messages) + const orig = + gqlErr.extensions && + gqlErr.extensions.originalError && + gqlErr.extensions.originalError.message + if (orig) { + message = Array.isArray(orig) ? orig.join(", ") : String(orig) + } else if (gqlErr.message) { + message = gqlErr.message + } + } + + throw new Error(message) + } + + if (!result.data) { + throw new Error("No data returned from create mock server mutation") + } + + const data = result.data.createMockServer + // Map the GraphQL response to frontend format + return { + ...data, + userUid: data.creator?.uid || "", // Legacy field + collectionID: data.collection?.id || collectionID || "", // Legacy field - use response collection ID if available + } as MockServer + }, + (error) => (error as Error).message as CreateMockServerError + ) + +export const updateMockServer = ( + id: string, + input: { + name?: string + isActive?: boolean + delayInMs?: number + isPublic?: boolean + } +) => + TE.tryCatch( + async () => { + const result = await client + .value!.mutation(UpdateMockServerDocument, { + id, + input, + }) + .toPromise() + + if (result.error) { + throw new Error(result.error.message || "Failed to update mock server") + } + + if (!result.data) { + throw new Error("No data returned from update mock server mutation") + } + + const data = result.data.updateMockServer + // Map the GraphQL response to frontend format + return { + ...data, + userUid: data.creator?.uid || "", // Legacy field + collectionID: data.collection?.id || "", // Legacy field + } as MockServer + }, + (error) => (error as Error).message as UpdateMockServerError + ) + +export const deleteMockServer = (id: string) => + TE.tryCatch( + async () => { + const result = await client + .value!.mutation(DeleteMockServerDocument, { id }) + .toPromise() + + if (result.error) { + throw new Error(result.error.message || "Failed to delete mock server") + } + + if (!result.data) { + throw new Error("No data returned from delete mock server mutation") + } + + return result.data.deleteMockServer as boolean + }, + (error) => (error as Error).message as DeleteMockServerError + ) + +// Centralized mapper for backend GraphQL error tokens to user-facing messages. +export const getErrorMessage = (err: GQLError | string | Error) => { + const t = getI18n() + + // Normalize to GQLError-like shape + let gErr: GQLError | null = null + + if (typeof err === "string") { + gErr = { type: "gql_error", error: err } + } else if (err instanceof Error) { + gErr = { type: "network_error", error: err } + } else if ((err as any)?.type) { + gErr = err as GQLError + } + + if (!gErr) return t("error.something_went_wrong") + + if (gErr.type === "network_error") { + return t("error.network_error") + } + + const code = String(gErr.error) + + switch (code) { + case "mock_server/invalid_collection": + case "mock_server/invalid_collection_id": + return t("mock_server.invalid_collection_error") + default: + return t("error.something_went_wrong") + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/mutations/Profile.ts b/packages/hoppscotch-common/src/helpers/backend/mutations/Profile.ts new file mode 100644 index 0000000..82a4ddf --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/mutations/Profile.ts @@ -0,0 +1,15 @@ +import { runMutation } from "../GQLClient" +import { + DeleteUserDocument, + DeleteUserMutation, + DeleteUserMutationVariables, +} from "../graphql" + +type DeleteUserErrors = "user/not_found" + +export const deleteUser = () => + runMutation< + DeleteUserMutation, + DeleteUserMutationVariables, + DeleteUserErrors + >(DeleteUserDocument, {}) diff --git a/packages/hoppscotch-common/src/helpers/backend/mutations/PublishedDocs.ts b/packages/hoppscotch-common/src/helpers/backend/mutations/PublishedDocs.ts new file mode 100644 index 0000000..e032047 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/mutations/PublishedDocs.ts @@ -0,0 +1,55 @@ +import { runMutation } from "../GQLClient" +import { + CreatePublishedDocDocument, + CreatePublishedDocMutation, + CreatePublishedDocMutationVariables, + UpdatePublishedDocDocument, + UpdatePublishedDocMutation, + UpdatePublishedDocMutationVariables, + DeletePublishedDocDocument, + DeletePublishedDocMutation, + DeletePublishedDocMutationVariables, + CreatePublishedDocsArgs, + UpdatePublishedDocsArgs, +} from "../graphql" + +type CreatePublishedDocError = + | "published_docs/creation_failed" + | "published_docs/invalid_collection" + | "team/invalid_id" + +type UpdatePublishedDocError = + | "published_docs/update_failed" + | "published_docs/not_found" + +type DeletePublishedDocError = + | "published_docs/deletion_failed" + | "published_docs/not_found" + +export const createPublishedDoc = (doc: CreatePublishedDocsArgs) => + runMutation< + CreatePublishedDocMutation, + CreatePublishedDocMutationVariables, + CreatePublishedDocError + >(CreatePublishedDocDocument, { + args: doc, + }) + +export const updatePublishedDoc = (id: string, doc: UpdatePublishedDocsArgs) => + runMutation< + UpdatePublishedDocMutation, + UpdatePublishedDocMutationVariables, + UpdatePublishedDocError + >(UpdatePublishedDocDocument, { + id, + args: doc, + }) + +export const deletePublishedDoc = (id: string) => + runMutation< + DeletePublishedDocMutation, + DeletePublishedDocMutationVariables, + DeletePublishedDocError + >(DeletePublishedDocDocument, { + id, + }) diff --git a/packages/hoppscotch-common/src/helpers/backend/mutations/Shortcode.ts b/packages/hoppscotch-common/src/helpers/backend/mutations/Shortcode.ts new file mode 100644 index 0000000..d827b4d --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/mutations/Shortcode.ts @@ -0,0 +1,37 @@ +import { HoppRESTRequest } from "@hoppscotch/data" +import { platform } from "~/platform" +import { runMutation } from "../GQLClient" +import { + DeleteShortcodeDocument, + DeleteShortcodeMutation, + DeleteShortcodeMutationVariables, + UpdateEmbedPropertiesDocument, + UpdateEmbedPropertiesMutation, + UpdateEmbedPropertiesMutationVariables, +} from "../graphql" + +type DeleteShortcodeErrors = "shortcode/not_found" + +export const createShortcode = ( + request: HoppRESTRequest, + properties?: string +) => platform.backend.createShortcode(request, properties) + +export const deleteShortcode = (code: string) => + runMutation< + DeleteShortcodeMutation, + DeleteShortcodeMutationVariables, + DeleteShortcodeErrors + >(DeleteShortcodeDocument, { + code, + }) + +export const updateEmbedProperties = (code: string, properties: string) => + runMutation< + UpdateEmbedPropertiesMutation, + UpdateEmbedPropertiesMutationVariables, + "" + >(UpdateEmbedPropertiesDocument, { + code, + properties, + }) diff --git a/packages/hoppscotch-common/src/helpers/backend/mutations/Team.ts b/packages/hoppscotch-common/src/helpers/backend/mutations/Team.ts new file mode 100644 index 0000000..75afa8b --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/mutations/Team.ts @@ -0,0 +1,125 @@ +import { pipe } from "fp-ts/function" +import * as TE from "fp-ts/TaskEither" +import { runMutation } from "../GQLClient" +import { TeamName } from "../types/TeamName" +import { + DeleteTeamDocument, + DeleteTeamMutation, + DeleteTeamMutationVariables, + LeaveTeamDocument, + LeaveTeamMutation, + LeaveTeamMutationVariables, + RemoveTeamMemberDocument, + RemoveTeamMemberMutation, + RemoveTeamMemberMutationVariables, + RenameTeamDocument, + RenameTeamMutation, + RenameTeamMutationVariables, + TeamAccessRole, + UpdateTeamAccessRoleDocument, + UpdateTeamAccessRoleMutation, + UpdateTeamAccessRoleMutationVariables, +} from "../graphql" +import { platform } from "~/platform" + +type DeleteTeamErrors = + | "team/not_required_role" + | "team/invalid_id" + | "team/member_not_found" + | "ea/not_invite_or_admin" + +type LeaveTeamErrors = + | "team/invalid_id" + | "team/member_not_found" + | "ea/not_invite_or_admin" + +type CreateTeamErrors = "team/name_invalid" | "ea/not_invite_or_admin" + +type RenameTeamErrors = + | "ea/not_invite_or_admin" + | "team/invalid_id" + | "team/not_required_role" + +type UpdateTeamAccessRoleErrors = + | "ea/not_invite_or_admin" + | "team/invalid_id" + | "team/not_required_role" + +type RemoveTeamMemberErrors = + | "ea/not_invite_or_admin" + | "team/invalid_id" + | "team/not_required_role" + +export const createTeam = (name: TeamName) => { + return pipe( + platform.backend.createTeam(name), + TE.map(({ createTeam }) => createTeam) + ) +} + +export const deleteTeam = (teamID: string) => + runMutation< + DeleteTeamMutation, + DeleteTeamMutationVariables, + DeleteTeamErrors + >( + DeleteTeamDocument, + { + teamID, + }, + { + additionalTypenames: ["Team"], + } + ) + +export const leaveTeam = (teamID: string) => + runMutation( + LeaveTeamDocument, + { + teamID, + }, + { + additionalTypenames: ["Team"], + } + ) + +export const renameTeam = (teamID: string, newName: TeamName) => + pipe( + runMutation< + RenameTeamMutation, + RenameTeamMutationVariables, + RenameTeamErrors + >(RenameTeamDocument, { + newName, + teamID, + }), + TE.map(({ renameTeam }) => renameTeam) + ) + +export const updateTeamAccessRole = ( + userUid: string, + teamID: string, + newRole: TeamAccessRole +) => + pipe( + runMutation< + UpdateTeamAccessRoleMutation, + UpdateTeamAccessRoleMutationVariables, + UpdateTeamAccessRoleErrors + >(UpdateTeamAccessRoleDocument, { + newRole, + userUid, + teamID, + }), + TE.map(({ updateTeamAccessRole }) => updateTeamAccessRole) + ) + +export const removeTeamMember = (userUid: string, teamID: string) => + runMutation< + RemoveTeamMemberMutation, + RemoveTeamMemberMutationVariables, + RemoveTeamMemberErrors + >(RemoveTeamMemberDocument, { + userUid, + teamID, + }) diff --git a/packages/hoppscotch-common/src/helpers/backend/mutations/TeamCollection.ts b/packages/hoppscotch-common/src/helpers/backend/mutations/TeamCollection.ts new file mode 100644 index 0000000..6549f32 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/mutations/TeamCollection.ts @@ -0,0 +1,174 @@ +import { runMutation } from "../GQLClient" +import { + CreateChildCollectionDocument, + CreateChildCollectionMutation, + CreateChildCollectionMutationVariables, + CreateNewRootCollectionDocument, + CreateNewRootCollectionMutation, + CreateNewRootCollectionMutationVariables, + DeleteCollectionDocument, + DeleteCollectionMutation, + DeleteCollectionMutationVariables, + DuplicateTeamCollectionDocument, + DuplicateTeamCollectionMutation, + DuplicateTeamCollectionMutationVariables, + ImportFromJsonDocument, + ImportFromJsonMutation, + ImportFromJsonMutationVariables, + MoveRestTeamCollectionDocument, + MoveRestTeamCollectionMutation, + MoveRestTeamCollectionMutationVariables, + RenameCollectionDocument, + RenameCollectionMutation, + RenameCollectionMutationVariables, + SortOptions, + SortTeamCollectionsDocument, + SortTeamCollectionsMutation, + SortTeamCollectionsMutationVariables, + UpdateCollectionOrderDocument, + UpdateCollectionOrderMutation, + UpdateCollectionOrderMutationVariables, + UpdateTeamCollectionDocument, + UpdateTeamCollectionMutation, + UpdateTeamCollectionMutationVariables, +} from "../graphql" +import { CollectionDataProps } from "../helpers" + +type CreateNewRootCollectionError = "team_coll/short_title" + +type CreateChildCollectionError = "team_coll/short_title" + +type RenameCollectionError = "team_coll/short_title" + +type DeleteCollectionError = "team/invalid_coll_id" + +type MoveRestTeamCollectionError = + | "team/invalid_coll_id" + | "team_coll/invalid_target_id" + | "team/collection_is_parent_coll" + | "team/target_and_destination_collection_are_same" + | "team/target_collection_is_already_root_collection" + +type UpdateCollectionOrderError = + | "team/invalid_coll_id" + | "team/collection_and_next_collection_are_same" + | "team/team_collections_have_different_parents" + +export const createNewRootCollection = (title: string, teamID: string) => + runMutation< + CreateNewRootCollectionMutation, + CreateNewRootCollectionMutationVariables, + CreateNewRootCollectionError + >(CreateNewRootCollectionDocument, { + title, + teamID, + }) + +export const createChildCollection = ( + childTitle: string, + collectionID: string +) => + runMutation< + CreateChildCollectionMutation, + CreateChildCollectionMutationVariables, + CreateChildCollectionError + >(CreateChildCollectionDocument, { + childTitle, + collectionID, + }) + +/** Can be used to rename both collection and folder (considered same in BE) */ +export const renameCollection = (collectionID: string, newTitle: string) => + runMutation< + RenameCollectionMutation, + RenameCollectionMutationVariables, + RenameCollectionError + >(RenameCollectionDocument, { + collectionID, + newTitle, + }) + +/** Can be used to delete both collection and folder (considered same in BE) */ +export const deleteCollection = (collectionID: string) => + runMutation< + DeleteCollectionMutation, + DeleteCollectionMutationVariables, + DeleteCollectionError + >(DeleteCollectionDocument, { + collectionID, + }) + +/** Can be used to move both collection and folder (considered same in BE) */ +export const moveRESTTeamCollection = ( + collectionID: string, + destinationCollectionID: string | null +) => + runMutation< + MoveRestTeamCollectionMutation, + MoveRestTeamCollectionMutationVariables, + MoveRestTeamCollectionError + >(MoveRestTeamCollectionDocument, { + collectionID, + parentCollectionID: destinationCollectionID, + }) + +export const updateOrderRESTTeamCollection = ( + collectionID: string, + destCollID: string | null +) => + runMutation< + UpdateCollectionOrderMutation, + UpdateCollectionOrderMutationVariables, + UpdateCollectionOrderError + >(UpdateCollectionOrderDocument, { + collectionID, + destCollID, + }) + +export const importJSONToTeam = (collectionJSON: string, teamID: string) => + runMutation( + ImportFromJsonDocument, + { + jsonString: collectionJSON, + teamID, + } + ) + +export const updateTeamCollection = ( + collectionID: string, + data?: CollectionDataProps, + newTitle?: string +) => + runMutation< + UpdateTeamCollectionMutation, + UpdateTeamCollectionMutationVariables, + "" + >(UpdateTeamCollectionDocument, { + collectionID, + data: JSON.stringify(data), + newTitle, + }) + +export const duplicateTeamCollection = (collectionID: string) => + runMutation< + DuplicateTeamCollectionMutation, + DuplicateTeamCollectionMutationVariables, + "" + >(DuplicateTeamCollectionDocument, { + collectionID, + }) + +export const sortTeamCollections = ( + teamID: string, + parentCollectionID: string | null, + sortOption: SortOptions +) => + runMutation< + SortTeamCollectionsMutation, + SortTeamCollectionsMutationVariables, + "" + >(SortTeamCollectionsDocument, { + teamID, + parentCollectionID, + sortOption, + }) diff --git a/packages/hoppscotch-common/src/helpers/backend/mutations/TeamEnvironment.ts b/packages/hoppscotch-common/src/helpers/backend/mutations/TeamEnvironment.ts new file mode 100644 index 0000000..380c2c1 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/mutations/TeamEnvironment.ts @@ -0,0 +1,69 @@ +import { runMutation } from "../GQLClient" +import { + CreateDuplicateEnvironmentDocument, + CreateDuplicateEnvironmentMutation, + CreateDuplicateEnvironmentMutationVariables, + CreateTeamEnvironmentDocument, + CreateTeamEnvironmentMutation, + CreateTeamEnvironmentMutationVariables, + DeleteTeamEnvironmentDocument, + DeleteTeamEnvironmentMutation, + DeleteTeamEnvironmentMutationVariables, + UpdateTeamEnvironmentDocument, + UpdateTeamEnvironmentMutation, + UpdateTeamEnvironmentMutationVariables, +} from "../graphql" + +type DeleteTeamEnvironmentError = "team_environment/not_found" + +type UpdateTeamEnvironmentError = "team_environment/not_found" + +type DuplicateTeamEnvironmentError = "team_environment/not_found" + +export const createTeamEnvironment = ( + variables: string, + teamID: string, + name: string +) => + runMutation< + CreateTeamEnvironmentMutation, + CreateTeamEnvironmentMutationVariables, + "" + >(CreateTeamEnvironmentDocument, { + variables, + teamID, + name, + }) + +export const deleteTeamEnvironment = (id: string) => + runMutation< + DeleteTeamEnvironmentMutation, + DeleteTeamEnvironmentMutationVariables, + DeleteTeamEnvironmentError + >(DeleteTeamEnvironmentDocument, { + id, + }) + +export const updateTeamEnvironment = ( + variables: string, + id: string, + name: string +) => + runMutation< + UpdateTeamEnvironmentMutation, + UpdateTeamEnvironmentMutationVariables, + UpdateTeamEnvironmentError + >(UpdateTeamEnvironmentDocument, { + variables, + id, + name, + }) + +export const createDuplicateEnvironment = (id: string) => + runMutation< + CreateDuplicateEnvironmentMutation, + CreateDuplicateEnvironmentMutationVariables, + DuplicateTeamEnvironmentError + >(CreateDuplicateEnvironmentDocument, { + id, + }) diff --git a/packages/hoppscotch-common/src/helpers/backend/mutations/TeamInvitation.ts b/packages/hoppscotch-common/src/helpers/backend/mutations/TeamInvitation.ts new file mode 100644 index 0000000..ae5b664 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/mutations/TeamInvitation.ts @@ -0,0 +1,55 @@ +import { pipe } from "fp-ts/function" +import * as TE from "fp-ts/TaskEither" +import { platform } from "~/platform" +import { runMutation } from "../GQLClient" +import { + RevokeTeamInvitationDocument, + RevokeTeamInvitationMutation, + RevokeTeamInvitationMutationVariables, + TeamAccessRole, +} from "../graphql" +import { Email } from "../types/Email" + +export type CreateTeamInvitationErrors = + | "invalid/email" + | "team/invalid_id" + | "team/member_not_found" + | "team_invite/already_member" + | "team_invite/member_has_invite" + | "user/not_found" + +type RevokeTeamInvitationErrors = + | "team/not_required_role" + | "team_invite/no_invite_found" + +type AcceptTeamInvitationErrors = + | "team_invite/no_invite_found" + | "team_invite/already_member" + | "team_invite/email_do_not_match" + +export const createTeamInvitation = ( + inviteeEmail: Email, + inviteeRole: TeamAccessRole, + teamID: string +) => { + return pipe( + platform.backend.createTeamInvitation( + inviteeEmail, + inviteeRole, + teamID + ), + TE.map((x) => x.createTeamInvitation) + ) +} + +export const revokeTeamInvitation = (inviteID: string) => + runMutation< + RevokeTeamInvitationMutation, + RevokeTeamInvitationMutationVariables, + RevokeTeamInvitationErrors + >(RevokeTeamInvitationDocument, { + inviteID, + }) + +export const acceptTeamInvitation = (inviteID: string) => + platform.backend.acceptTeamInvitation(inviteID) diff --git a/packages/hoppscotch-common/src/helpers/backend/mutations/TeamRequest.ts b/packages/hoppscotch-common/src/helpers/backend/mutations/TeamRequest.ts new file mode 100644 index 0000000..184f191 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/mutations/TeamRequest.ts @@ -0,0 +1,98 @@ +import { runMutation } from "../GQLClient" +import { + CreateRequestInCollectionDocument, + CreateRequestInCollectionMutation, + CreateRequestInCollectionMutationVariables, + DeleteRequestDocument, + DeleteRequestMutation, + DeleteRequestMutationVariables, + MoveRestTeamRequestDocument, + MoveRestTeamRequestMutation, + MoveRestTeamRequestMutationVariables, + UpdateLookUpRequestOrderDocument, + UpdateLookUpRequestOrderMutation, + UpdateLookUpRequestOrderMutationVariables, + UpdateRequestDocument, + UpdateRequestMutation, + UpdateRequestMutationVariables, +} from "../graphql" + +type DeleteRequestErrors = "team_req/not_found" + +type MoveRestTeamRequestErrors = + | "team_req/not_found" + | "team_req/invalid_target_id" + | "team/invalid_coll_id" + | "team_req/not_required_role" + | "bug/team_req/no_req_id" + +type UpdateLookUpRequestOrderErrors = + | "team_req/not_found" + | "team/request_and_next_request_are_same" + | "team_req/requests_not_from_same_collection" + +export const createRequestInCollection = ( + collectionID: string, + data: { + request: string + teamID: string + title: string + } +) => + runMutation< + CreateRequestInCollectionMutation, + CreateRequestInCollectionMutationVariables, + "" + >(CreateRequestInCollectionDocument, { + collectionID, + data, + }) + +export const updateTeamRequest = ( + requestID: string, + data: { + request: string + title: string + } +) => + runMutation( + UpdateRequestDocument, + { + requestID, + data, + } + ) + +export const deleteTeamRequest = (requestID: string) => + runMutation< + DeleteRequestMutation, + DeleteRequestMutationVariables, + DeleteRequestErrors + >(DeleteRequestDocument, { + requestID, + }) + +export const moveRESTTeamRequest = (collectionID: string, requestID: string) => + runMutation< + MoveRestTeamRequestMutation, + MoveRestTeamRequestMutationVariables, + MoveRestTeamRequestErrors + >(MoveRestTeamRequestDocument, { + collectionID, + requestID, + }) + +export const updateOrderRESTTeamRequest = ( + requestID: string, + nextRequestID: string | null, + collectionID: string +) => + runMutation< + UpdateLookUpRequestOrderMutation, + UpdateLookUpRequestOrderMutationVariables, + UpdateLookUpRequestOrderErrors + >(UpdateLookUpRequestOrderDocument, { + requestID, + nextRequestID, + collectionID, + }) diff --git a/packages/hoppscotch-common/src/helpers/backend/queries/MockServer.ts b/packages/hoppscotch-common/src/helpers/backend/queries/MockServer.ts new file mode 100644 index 0000000..43538cd --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/queries/MockServer.ts @@ -0,0 +1,96 @@ +import * as TE from "fp-ts/TaskEither" +import * as E from "fp-ts/Either" +import { runGQLQuery } from "../GQLClient" +import { + GetMyMockServersDocument, + GetTeamMockServersDocument, + GetMockServerDocument, + type GetMyMockServersQuery, + type GetTeamMockServersQuery, + type GetMockServerQuery, +} from "../graphql" + +type GetMyMockServersError = "user/not_authenticated" + +type GetTeamMockServersError = "team/not_found" | "team/access_denied" + +type GetMockServerError = "mock_server/not_found" | "mock_server/access_denied" + +export const getMyMockServers = (skip?: number, take?: number) => + TE.tryCatch( + async () => { + const result = await runGQLQuery({ + query: GetMyMockServersDocument, + variables: { skip, take }, + }) + + if (E.isLeft(result)) { + throw result.left + } + + const data = result.right as GetMyMockServersQuery + // Map the GraphQL response to frontend format + return data.myMockServers.map((mockServer) => ({ + ...mockServer, + creator: mockServer.creator + ? { uid: mockServer.creator.uid } + : undefined, + userUid: mockServer.creator?.uid || "", // Legacy field + collectionID: mockServer.collection?.id || "", // Legacy field + })) + }, + (error) => error as GetMyMockServersError + ) + +export const getTeamMockServers = ( + teamID: string, + skip?: number, + take?: number +) => + TE.tryCatch( + async () => { + const result = await runGQLQuery({ + query: GetTeamMockServersDocument, + variables: { teamID, skip, take }, + }) + + if (E.isLeft(result)) { + throw result.left + } + + const data = result.right as GetTeamMockServersQuery + // Map the GraphQL response to frontend format + return data.teamMockServers.map((mockServer) => ({ + ...mockServer, + creator: mockServer.creator + ? { uid: mockServer.creator.uid } + : undefined, + userUid: mockServer.creator?.uid || "", // Legacy field + collectionID: mockServer.collection?.id || "", // Legacy field + })) + }, + (error) => error as GetTeamMockServersError + ) + +export const getMockServer = (id: string) => + TE.tryCatch( + async () => { + const result = await runGQLQuery({ + query: GetMockServerDocument, + variables: { id }, + }) + + if (E.isLeft(result)) { + throw result.left + } + + const data = result.right as GetMockServerQuery + // Map the GraphQL response to frontend format + return { + ...data.mockServer, + userUid: data.mockServer.creator?.uid || "", // Legacy field + collectionID: data.mockServer.collection?.id || "", // Legacy field + } + }, + (error) => error as GetMockServerError + ) diff --git a/packages/hoppscotch-common/src/helpers/backend/queries/MockServerLogs.ts b/packages/hoppscotch-common/src/helpers/backend/queries/MockServerLogs.ts new file mode 100644 index 0000000..2e30b2c --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/queries/MockServerLogs.ts @@ -0,0 +1,55 @@ +import * as TE from "fp-ts/TaskEither" +import { client } from "../GQLClient" +import { + GetMockServerLogsDocument, + GetMockServerLogsQuery, + GetMockServerLogsQueryVariables, + DeleteMockServerLogDocument, + DeleteMockServerLogMutation, + DeleteMockServerLogMutationVariables, +} from "../graphql" + +export const getMockServerLogs = ( + mockServerID: string, + skip?: number, + take?: number +) => + TE.tryCatch( + async () => { + const result = await client + .value!.query< + GetMockServerLogsQuery, + GetMockServerLogsQueryVariables + >(GetMockServerLogsDocument, { mockServerID, skip, take }) + .toPromise() + + if (result.error) + throw new Error( + result.error.message || "Failed to fetch mock server logs" + ) + if (!result.data) throw new Error("No data returned from mockServerLogs") + + return result.data.mockServerLogs + }, + (e) => (e as Error).message + ) + +export const deleteMockServerLog = (logID: string) => + TE.tryCatch( + async () => { + const result = await client + .value!.mutation< + DeleteMockServerLogMutation, + DeleteMockServerLogMutationVariables + >(DeleteMockServerLogDocument, { logID }) + .toPromise() + if (result.error) + throw new Error( + result.error.message || "Failed to delete mock server log" + ) + if (!result.data) + throw new Error("No data returned from deleteMockServerLog") + return result.data.deleteMockServerLog as boolean + }, + (e) => (e as Error).message + ) diff --git a/packages/hoppscotch-common/src/helpers/backend/queries/PublishedDocs.ts b/packages/hoppscotch-common/src/helpers/backend/queries/PublishedDocs.ts new file mode 100644 index 0000000..806ef75 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/queries/PublishedDocs.ts @@ -0,0 +1,295 @@ +import * as TE from "fp-ts/TaskEither" +import * as E from "fp-ts/Either" +import { runGQLQuery } from "../GQLClient" +import { + UserPublishedDocsListDocument, + TeamPublishedDocsListDocument, + type UserPublishedDocsListQuery, + type TeamPublishedDocsListQuery, + PublishedDocDocument, + PublishedDocs, + type PublishedDocQuery as GqlPublishedDocQuery, +} from "../graphql" +import { + HoppCollection, + makeCollection, + translateToNewRequest, +} from "@hoppscotch/data" +import type { CollectionDataProps } from "../helpers" + +type GetUserPublishedDocsError = "user/not_authenticated" + +type GetTeamPublishedDocsError = "team/not_found" | "team/access_denied" + +// Type for a published doc item returned from list queries +export type PublishedDocListItem = { + id: string + title: string + version: string + autoSync: boolean + url: string + environmentName?: string | null + collection: { + id: string + } + createdOn: string + updatedOn: string +} + +// Type for a full published doc returned from single doc query +export type PublishedDoc = PublishedDocListItem & { + metadata?: string + creator?: { + uid: string + displayName: string + email: string + photoURL: string + } + collection: { + id: string + title: string + } + versions?: PublishedDocListItem[] +} + +// Type for the GraphQL query response +export type PublishedDocQuery = { + publishedDoc: PublishedDoc +} + +export type CollectionFolder = { + id?: string + folders: CollectionFolder[] + // Backend stores this as any, we translate it to HoppRESTRequest via translateToNewRequest + requests: any[] + name: string + data?: string +} + +// Type for the versions list in the REST response source of truth: packages/hoppscotch-backend/src/published-docs/published-docs.model.ts +export type PublishedDocsVersion = { + id: string + slug: string + version: string + title: string + autoSync: boolean + url: string + workspaceID: string + workspaceType: string + createdOn: string + updatedOn: string + creatorUid: string + metadata: string + documentTree: string +} + +export type PublishedDocREST = PublishedDocsVersion & { + versions?: PublishedDocsVersion[] +} + +/** + * Parses the data field (stringified JSON) to extract auth, headers, variables, and description + * @param data The stringified JSON data from CollectionFolder + * @returns Parsed CollectionDataProps with defaults if parsing fails + */ +function parseCollectionDataFromString(data?: string): CollectionDataProps { + const defaultDataProps: CollectionDataProps = { + auth: { authType: "inherit", authActive: true }, + headers: [], + variables: [], + description: null, + preRequestScript: "", + testScript: "", + } + + if (!data) { + return defaultDataProps + } + + try { + const parsed = JSON.parse(data) as Partial + return { + auth: parsed.auth || defaultDataProps.auth, + headers: parsed.headers || defaultDataProps.headers, + variables: parsed.variables || defaultDataProps.variables, + description: parsed.description || defaultDataProps.description, + preRequestScript: + parsed.preRequestScript || defaultDataProps.preRequestScript, + testScript: parsed.testScript || defaultDataProps.testScript, + } + } catch (error) { + console.error("Failed to parse collection data:", error) + return defaultDataProps + } +} + +/** + * Converts a CollectionFolder (from backend REST API) to HoppCollection format + * @param folder The CollectionFolder to convert + * @returns HoppCollection in the proper format + */ +export function collectionFolderToHoppCollection( + folder: CollectionFolder +): HoppCollection { + // Parse the data field to extract auth, headers, variables, and description + const { + auth, + headers, + variables, + description, + preRequestScript, + testScript, + } = parseCollectionDataFromString(folder.data) + + return makeCollection({ + name: folder.name, + folders: folder.folders.map(collectionFolderToHoppCollection), + requests: (folder.requests || []).map(translateToNewRequest), + auth, + headers, + variables, + description, + id: folder.id, + preRequestScript: preRequestScript ?? "", + testScript: testScript ?? "", + }) +} + +export const getUserPublishedDocs = (skip: number = 0, take: number = 100) => + TE.tryCatch( + async () => { + const result = await runGQLQuery({ + query: UserPublishedDocsListDocument, + variables: { skip, take }, + }) + + if (E.isLeft(result)) { + throw result.left + } + + const data = result.right as UserPublishedDocsListQuery + return data.userPublishedDocsList + }, + (error) => error as GetUserPublishedDocsError + ) + +export const getTeamPublishedDocs = ( + teamID: string, + collectionID?: string, + skip: number = 0, + take: number = 100 +) => + TE.tryCatch( + async () => { + const result = await runGQLQuery({ + query: TeamPublishedDocsListDocument, + variables: { teamID, collectionID, skip, take }, + }) + + if (E.isLeft(result)) { + throw result.left + } + + const data = result.right as TeamPublishedDocsListQuery + return data.teamPublishedDocsList + }, + (error) => error as GetTeamPublishedDocsError + ) + +// Helper to find published doc for a specific collection +export const findPublishedDocForCollection = ( + collectionID: string, + isTeam: boolean, + teamID?: string +): TE.TaskEither< + | GetUserPublishedDocsError + | GetTeamPublishedDocsError + | "published_docs/not_found", + PublishedDocListItem +> => { + const query: TE.TaskEither< + GetUserPublishedDocsError | GetTeamPublishedDocsError, + PublishedDocListItem[] + > = ( + isTeam && teamID + ? getTeamPublishedDocs(teamID, collectionID) + : getUserPublishedDocs() + ) as TE.TaskEither< + GetUserPublishedDocsError | GetTeamPublishedDocsError, + PublishedDocListItem[] + > + + return TE.chain( + ( + docs: PublishedDocListItem[] + ): TE.TaskEither< + | GetUserPublishedDocsError + | GetTeamPublishedDocsError + | "published_docs/not_found", + PublishedDocListItem + > => { + const publishedDoc = docs.find( + (doc) => doc.collection.id === collectionID + ) + return publishedDoc + ? TE.right(publishedDoc) + : TE.left("published_docs/not_found" as const) + } + )(query) +} + +type GetPublishedDocError = + | "published_docs/not_found" + | "published_docs/unauthorized" + +// Get a single published doc by ID (GraphQL) +export const getPublishedDocByID = (id: string) => + TE.tryCatch( + async () => { + const result = await runGQLQuery({ + query: PublishedDocDocument, + variables: { id }, + }) + + if (E.isLeft(result)) { + throw result.left + } + + const data = result.right as GqlPublishedDocQuery + return data.publishedDoc + }, + (error) => { + console.error("Error fetching published doc:", error) + return "published_docs/not_found" as GetPublishedDocError + } + ) + +/** + * + * @param slug - The slug of the published doc to fetch + * @param version - The version of the published doc to fetch + * @returns The published doc with the specified slug + */ +export const getPublishedDocBySlugREST = ( + slug: string, + version?: string +): TE.TaskEither => + TE.tryCatch( + async () => { + const backendUrl = import.meta.env.VITE_BACKEND_API_URL || "" + const url = version + ? `${backendUrl}/published-docs/${slug}/${version}` + : `${backendUrl}/published-docs/${slug}` + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + return await response.json() + }, + (error) => { + console.error("Error fetching published doc via REST:", error) + return "published_docs/not_found" as GetPublishedDocError + } + ) diff --git a/packages/hoppscotch-common/src/helpers/backend/types/Email.ts b/packages/hoppscotch-common/src/helpers/backend/types/Email.ts new file mode 100644 index 0000000..0fd0538 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/types/Email.ts @@ -0,0 +1,16 @@ +import * as t from "io-ts" + +const emailRegex = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + +interface EmailBrand { + readonly Email: unique symbol +} + +export const EmailCodec = t.brand( + t.string, + (x): x is t.Branded => emailRegex.test(x), + "Email" +) + +export type Email = t.TypeOf diff --git a/packages/hoppscotch-common/src/helpers/backend/types/MockServer.ts b/packages/hoppscotch-common/src/helpers/backend/types/MockServer.ts new file mode 100644 index 0000000..526627c --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/types/MockServer.ts @@ -0,0 +1,31 @@ +import { WorkspaceType } from "~/helpers/backend/graphql" + +/** + * Canonical MockServer type used across the frontend. + * Shared by BackendPlatformDef, newstore/mockServers, and mutation helpers. + */ +export type MockServer = { + id: string + name: string + subdomain: string + serverUrlPathBased?: string + serverUrlDomainBased?: string | null + workspaceType: WorkspaceType + workspaceID?: string | null + delayInMs?: number + isPublic: boolean + isActive: boolean + createdOn: Date | string + updatedOn: Date | string + creator?: { + uid: string + } | null + collection?: { + id: string + title: string + requests?: any[] + } | null + // Legacy fields for backward compatibility + userUid?: string + collectionID?: string +} diff --git a/packages/hoppscotch-common/src/helpers/backend/types/TeamName.ts b/packages/hoppscotch-common/src/helpers/backend/types/TeamName.ts new file mode 100644 index 0000000..26182ec --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/types/TeamName.ts @@ -0,0 +1,13 @@ +import * as t from "io-ts" + +interface TeamNameBrand { + readonly TeamName: unique symbol +} + +export const TeamNameCodec = t.brand( + t.string, + (x): x is t.Branded => x.trim() !== "", + "TeamName" +) + +export type TeamName = t.TypeOf diff --git a/packages/hoppscotch-common/src/helpers/collection/affectedIndex.ts b/packages/hoppscotch-common/src/helpers/collection/affectedIndex.ts new file mode 100644 index 0000000..aa354e4 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/collection/affectedIndex.ts @@ -0,0 +1,21 @@ +/** + * Get the indexes that are affected by the reorder + * @param from index of the item before reorder + * @param to index of the item after reorder + * @returns Map of from to to + */ + +export function getAffectedIndexes(from: number, to: number) { + const indexes = new Map() + indexes.set(from, to) + if (from < to) { + for (let i = from + 1; i <= to; i++) { + indexes.set(i, i - 1) + } + } else { + for (let i = from - 1; i >= to; i--) { + indexes.set(i, i + 1) + } + } + return indexes +} diff --git a/packages/hoppscotch-common/src/helpers/collection/collection.ts b/packages/hoppscotch-common/src/helpers/collection/collection.ts new file mode 100644 index 0000000..0bd7b7c --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/collection/collection.ts @@ -0,0 +1,334 @@ +import { HoppCollection } from "@hoppscotch/data" +import { getAffectedIndexes } from "./affectedIndex" +import { GetSingleRequestDocument } from "../backend/graphql" +import { runGQLQuery } from "../backend/GQLClient" +import * as E from "fp-ts/Either" +import { getService } from "~/modules/dioc" +import { RESTTabService } from "~/services/tab/rest" +import { GQLTabService } from "~/services/tab/graphql" +import { TeamCollectionsService } from "~/services/team-collection.service" +import { cascadeParentCollectionForProperties } from "~/newstore/collections" +import { stripSecretVariableValuesForWire } from "../secretVariables" +import { CollectionDataProps } from "../backend/helpers" +import { CollectionFolder } from "../backend/queries/PublishedDocs" + +/** + * Resolve save context on reorder + */ +export function resolveSaveContextOnCollectionReorder( + payload: { + lastIndex: number + newIndex: number + folderPath: string + length?: number // better way to do this? now it could be undefined + }, + type: "remove" | "drop" = "remove" +) { + const { lastIndex, folderPath, length } = payload + let { newIndex } = payload + + if (newIndex > lastIndex) newIndex-- // there is a issue when going down? better way to resolve this? + if (lastIndex === newIndex) return + + const affectedIndexes = getAffectedIndexes( + lastIndex, + newIndex === -1 ? length! : newIndex + ) + + if (newIndex === -1) { + // if (newIndex === -1) remove it from the map because it will be deleted + affectedIndexes.delete(lastIndex) + // when collection deleted opened requests from that collection be affected + if (type === "remove") { + resetSaveContextForAffectedRequests( + folderPath ? `${folderPath}/${lastIndex}` : lastIndex.toString() + ) + } + } + + // add folder path as prefix to the affected indexes + const affectedPaths = new Map() + for (const [key, value] of affectedIndexes) { + if (folderPath) { + affectedPaths.set(`${folderPath}/${key}`, `${folderPath}/${value}`) + } else { + affectedPaths.set(key.toString(), value.toString()) + } + } + + const tabService = getService(RESTTabService) + + const tabs = tabService.getTabsRefTo((tab) => { + if (tab.document.type === "test-runner") return false + return ( + tab.document.saveContext?.originLocation === "user-collection" && + affectedPaths.has(tab.document.saveContext.folderPath) + ) + }) + + for (const tab of tabs) { + if ( + tab.value.document.type !== "test-runner" && + tab.value.document.saveContext?.originLocation === "user-collection" + ) { + const newPath = affectedPaths.get( + tab.value.document.saveContext.folderPath + )! + tab.value.document.saveContext.folderPath = newPath + } + } +} + +/** + * Helper to transform team collection IDs when folders move and trim leading slashes. + * @param currentID Current collection ID + * @param oldPath Old collection path + * @param newPath New collection path + * @returns Updated collection ID + */ +const updateCollectionIDPath = ( + currentID: string | undefined, + oldPath: string, + newPath: string | null +): string | undefined => { + if (!currentID) return currentID + const replaced = currentID.replace(oldPath, newPath ?? "") + return replaced.replace(/^\/+/, "") +} + +/** + * Returns the last folder path from the given path. + * * @param path Path can be folder path or collection path + * @returns Get the last folder path from the given path + */ +const getLastParentFolderPath = (path?: string) => { + if (!path) return "" + const pathArray = path.split("/") + return pathArray[pathArray.length - 1] ?? "" +} + +/** + * Resolve save context for affected requests on drop folder + * @param oldFolderPath Old folder path + * @param newFolderPath New folder path + */ +export function updateSaveContextForAffectedRequests( + oldFolderPath: string, + newFolderPath: string | null +) { + const tabService = getService(RESTTabService) + const tabs = tabService.getTabsRefTo((tab) => { + if (tab.document.type === "test-runner") return false + + return tab.document.saveContext?.originLocation === "user-collection" + ? tab.document.saveContext.folderPath.startsWith(oldFolderPath) + : tab.document.saveContext?.originLocation === "team-collection" + ? tab.document.saveContext.collectionID!.startsWith(oldFolderPath) || + tab.document.saveContext.collectionID === oldFolderPath + : false + }) + + for (const tab of tabs) { + if (tab.value.document.type === "test-runner") return + + if ( + tab.value.document.saveContext?.originLocation === "user-collection" && + newFolderPath + ) { + tab.value.document.saveContext = { + ...tab.value.document.saveContext, + folderPath: tab.value.document.saveContext.folderPath.replace( + oldFolderPath, + newFolderPath + ), + } + } else if ( + tab.value.document.saveContext?.originLocation === "team-collection" + ) { + tab.value.document.saveContext = { + ...tab.value.document.saveContext, + collectionID: updateCollectionIDPath( + tab.value.document.saveContext.collectionID, + oldFolderPath, + newFolderPath + ), + } + } + } +} + +export function updateInheritedPropertiesForAffectedRequests( + path: string, + type: "rest" | "graphql" +) { + const tabService = + type === "rest" ? getService(RESTTabService) : getService(GQLTabService) + const teamCollectionService = getService(TeamCollectionsService) + + const effectedTabs = tabService.getTabsRefTo((tab) => { + if ("type" in tab.document && tab.document.type === "test-runner") + return false + const saveContext = tab.document.saveContext + + const saveContextPath = + saveContext?.originLocation === "team-collection" + ? saveContext.collectionID + : saveContext?.folderPath + + return ( + (saveContextPath?.startsWith(path) || + getLastParentFolderPath(saveContextPath) === + getLastParentFolderPath(path)) ?? + false + ) + }) + + effectedTabs.forEach((tab) => { + if ( + "type" in tab.value.document && + tab.value.document.type === "test-runner" + ) + return + if (!("inheritedProperties" in tab.value.document)) return + + if ( + tab.value.document.saveContext?.originLocation === "team-collection" && + tab.value.document.inheritedProperties + ) { + tab.value.document.inheritedProperties = + teamCollectionService.cascadeParentCollectionForProperties( + tab.value.document.saveContext.collectionID! + ) + } + + if ( + tab.value.document.saveContext?.originLocation === "user-collection" && + tab.value.document.inheritedProperties + ) { + tab.value.document.inheritedProperties = + cascadeParentCollectionForProperties( + tab.value.document.saveContext.folderPath, + type + ) + } + }) +} + +function resetSaveContextForAffectedRequests(folderPath: string) { + const tabService = getService(RESTTabService) + const tabs = tabService.getTabsRefTo((tab) => { + if (tab.document.type === "test-runner") return false + return ( + tab.document.saveContext?.originLocation === "user-collection" && + tab.document.saveContext.folderPath.startsWith(folderPath) + ) + }) + + for (const tab of tabs) { + if (tab.value.document.type === "test-runner") return + tab.value.document.saveContext = null + tab.value.document.isDirty = true + + if (tab.value.document.type === "request") { + // since the request is deleted, we need to remove the saved responses as well + tab.value.document.request.responses = {} + + // remove inherited properties + tab.value.document.inheritedProperties = undefined + } + } +} + +/** + * Reset save context to null if requests are deleted from the team collection or its folder + * only runs when collection or folder is deleted + */ +export async function resetTeamRequestsContext() { + const tabService = getService(RESTTabService) + const tabs = tabService.getTabsRefTo((tab) => { + if (tab.document.type === "test-runner") return false + return tab.document.saveContext?.originLocation === "team-collection" + }) + + for (const tab of tabs) { + if (tab.value.document.type === "test-runner") return + if (tab.value.document.saveContext?.originLocation === "team-collection") { + const data = await runGQLQuery({ + query: GetSingleRequestDocument, + variables: { requestID: tab.value.document.saveContext.requestID }, + }) + + if (E.isRight(data) && data.right.request === null) { + tab.value.document.saveContext = null + tab.value.document.isDirty = true + + if (tab.value.document.type === "request") { + // since the request is deleted, we need to remove the saved responses as well + tab.value.document.request.responses = {} + + // remove inherited properties + tab.value.document.inheritedProperties = undefined + } + } + } + } +} + +export function getFoldersByPath( + collections: HoppCollection[], + path: string +): HoppCollection[] { + if (!path) return collections + + // path will be like this "0/0/1" these are the indexes of the folders + const pathArray = path.split("/").map((index) => parseInt(index)) + let currentCollection = collections[pathArray[0]] + + if (pathArray.length === 1) { + return currentCollection.folders + } + + for (let i = 1; i < pathArray.length; i++) { + const folder = currentCollection.folders[pathArray[i]] + if (folder) currentCollection = folder + } + + return currentCollection.folders +} + +/** + * Transforms a collection to the format expected by team or personal collections. + * BE expects CollectionFolder format with a data field containing auth, headers, variables, and description. + * + * @param collection The collection to transform + * @returns The transformed collection + */ +export function transformCollectionForImport( + collection: HoppCollection +): CollectionFolder { + const folders = (collection.folders ?? []).map(transformCollectionForImport) + + const data: CollectionDataProps = { + auth: collection.auth, + headers: collection.headers, + variables: stripSecretVariableValuesForWire(collection.variables ?? []), + // Round-trip the local-store key so the team-collection-added handler + // (`TeamCollectionsService.addCollection`) can migrate the importer's + // secret entries from this `_ref_id` to the backend-assigned `id`. + _ref_id: collection._ref_id, + description: collection.description, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", + } + + const obj: CollectionFolder = { + name: collection.name, + folders: folders, + requests: collection.requests, + data: JSON.stringify(data), + } + + if (collection.id) obj.id = collection.id + + return obj +} diff --git a/packages/hoppscotch-common/src/helpers/collection/request.ts b/packages/hoppscotch-common/src/helpers/collection/request.ts new file mode 100644 index 0000000..f99d74e --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/collection/request.ts @@ -0,0 +1,86 @@ +import { + HoppCollection, + HoppGQLRequest, + HoppRESTRequest, + RESTReqSchemaVersion, +} from "@hoppscotch/data" +import { getAffectedIndexes } from "./affectedIndex" +import { RESTTabService } from "~/services/tab/rest" +import { getService } from "~/modules/dioc" + +/** + * Resolve save context on reorder + * @param payload + * @param payload.lastIndex + * @param payload.newIndex + * @param payload.folderPath + * @param payload.length + * @returns + */ + +export function resolveSaveContextOnRequestReorder(payload: { + lastIndex: number + folderPath: string + newIndex: number + length?: number // better way to do this? now it could be undefined +}) { + const { lastIndex, folderPath, length } = payload + let { newIndex } = payload + + if (newIndex > lastIndex) newIndex-- // there is a issue when going down? better way to resolve this? + if (lastIndex === newIndex) return + + const affectedIndexes = getAffectedIndexes( + lastIndex, + newIndex === -1 ? length! : newIndex + ) + + // if (newIndex === -1) remove it from the map because it will be deleted + if (newIndex === -1) affectedIndexes.delete(lastIndex) + + const tabService = getService(RESTTabService) + const tabs = tabService.getTabsRefTo((tab) => { + return ( + tab.document.saveContext?.originLocation === "user-collection" && + tab.document.saveContext.folderPath === folderPath && + affectedIndexes.has(tab.document.saveContext.requestIndex) + ) + }) + + for (const tab of tabs) { + if (tab.value.document.saveContext?.originLocation === "user-collection") { + const newIndex = affectedIndexes.get( + tab.value.document.saveContext?.requestIndex + )! + tab.value.document.saveContext.requestIndex = newIndex + } + } +} + +export function getRequestsByPath( + collections: HoppCollection[], + path: string +): HoppRESTRequest[] | HoppGQLRequest[] { + // path will be like this "0/0/1" these are the indexes of the folders + const pathArray = path.split("/").map((index) => parseInt(index)) + + let currentCollection = collections[pathArray[0]] + + if (pathArray.length === 1) { + const latestVersionedRequests = currentCollection.requests.filter( + (req): req is HoppRESTRequest => req.v === RESTReqSchemaVersion + ) + + return latestVersionedRequests + } + for (let i = 1; i < pathArray.length; i++) { + const folder = currentCollection.folders[pathArray[i]] + if (folder) currentCollection = folder + } + + const latestVersionedRequests = currentCollection.requests.filter( + (req): req is HoppRESTRequest => req.v === RESTReqSchemaVersion + ) + + return latestVersionedRequests +} diff --git a/packages/hoppscotch-common/src/helpers/curl/__tests__/curlparser.spec.js b/packages/hoppscotch-common/src/helpers/curl/__tests__/curlparser.spec.js new file mode 100644 index 0000000..601ab82 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/curl/__tests__/curlparser.spec.js @@ -0,0 +1,1187 @@ +// @ts-check +// ^^^ Enables Type Checking by the TypeScript compiler + +import { describe, expect, test } from "vitest" +import { makeRESTRequest, rawKeyValueEntriesToString } from "@hoppscotch/data" +import { parseCurlToHoppRESTReq } from ".." + +const samples = [ + { + command: ` + curl --request GET \ + --url https://echo.hoppscotch.io/ \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data a=b \ + --data c=d + `, + response: makeRESTRequest({ + method: "GET", + name: "Untitled", + endpoint: "https://echo.hoppscotch.io/", + auth: { authType: "inherit", authActive: true }, + body: { + contentType: "application/x-www-form-urlencoded", + body: rawKeyValueEntriesToString([ + { + active: true, + key: "a", + value: "b", + }, + { + active: true, + key: "c", + value: "d", + }, + ]), + }, + headers: [], + params: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: ` + curl 'http://avs:def@127.0.0.1:8000/api/admin/crm/brand/4' + -X PUT + -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0' + -H 'Accept: application/json, text/plain, */*' + -H 'Accept-Language: en' + --compressed + -H 'Content-Type: application/hal+json;charset=utf-8' + -H 'Origin: http://localhost:3012' + -H 'Connection: keep-alive' + -H 'Referer: http://localhost:3012/crm/company/4' + --data-raw '{"id":4,"crm_company_id":4,"industry_primary_id":2,"industry_head_id":2,"industry_body_id":2,"code":"01","barcode":"222010101","summary":"Healt-Seasoning-Basic-Hori-Kello","name":"Kellolaa","sub_code":"01","sub_name":"Hori","created_at":"2020-06-08 08:50:02","updated_at":"2020-06-08 08:50:02","company":4,"primary":{"id":2,"code":"2","name":"Healt","created_at":"2020-05-19 07:05:02","updated_at":"2020-05-19 07:09:28"},"head":{"id":2,"code":"2","name":"Seasoning","created_at":"2020-04-14 19:34:33","updated_at":"2020-04-14 19:34:33"},"body":{"id":2,"code":"2","name":"Basic","created_at":"2020-04-14 19:33:54","updated_at":"2020-04-14 19:33:54"},"contacts":[]}' + `, + response: makeRESTRequest({ + method: "PUT", + name: "Untitled", + endpoint: "http://127.0.0.1:8000/api/admin/crm/brand/4", + auth: { + authType: "basic", + authActive: true, + username: "avs", + password: "def", + }, + body: { + contentType: "application/hal+json", + body: `{ + "id": 4, + "crm_company_id": 4, + "industry_primary_id": 2, + "industry_head_id": 2, + "industry_body_id": 2, + "code": "01", + "barcode": "222010101", + "summary": "Healt-Seasoning-Basic-Hori-Kello", + "name": "Kellolaa", + "sub_code": "01", + "sub_name": "Hori", + "created_at": "2020-06-08 08:50:02", + "updated_at": "2020-06-08 08:50:02", + "company": 4, + "primary": { + "id": 2, + "code": "2", + "name": "Healt", + "created_at": "2020-05-19 07:05:02", + "updated_at": "2020-05-19 07:09:28" + }, + "head": { + "id": 2, + "code": "2", + "name": "Seasoning", + "created_at": "2020-04-14 19:34:33", + "updated_at": "2020-04-14 19:34:33" + }, + "body": { + "id": 2, + "code": "2", + "name": "Basic", + "created_at": "2020-04-14 19:33:54", + "updated_at": "2020-04-14 19:33:54" + }, + "contacts": [] +}`, + }, + headers: [ + { + active: true, + key: "User-Agent", + value: + "Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0", + description: "", + }, + { + active: true, + key: "Accept", + value: "application/json, text/plain, */*", + description: "", + }, + { + active: true, + key: "Accept-Language", + value: "en", + description: "", + }, + { + active: true, + key: "Origin", + value: "http://localhost:3012", + description: "", + }, + { + active: true, + key: "Connection", + value: "keep-alive", + description: "", + }, + { + active: true, + key: "Referer", + value: "http://localhost:3012/crm/company/4", + description: "", + }, + ], + params: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl google.com`, + response: makeRESTRequest({ + method: "GET", + name: "Untitled", + endpoint: "https://google.com/", + auth: { authType: "inherit", authActive: true }, + body: { + contentType: null, + body: null, + }, + headers: [], + params: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl -X POST -d '{"foo":"bar"}' http://localhost:1111/hello/world/?bar=baz&buzz`, + response: makeRESTRequest({ + method: "POST", + name: "Untitled", + endpoint: "http://localhost:1111/hello/world/?buzz", + auth: { authType: "inherit", authActive: true }, + body: { + contentType: "application/json", + body: `{\n "foo": "bar"\n}`, + }, + headers: [], + params: [ + { + active: true, + key: "bar", + value: "baz", + description: "", + }, + ], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl --get -d "tool=curl" -d "age=old" https://example.com`, + response: makeRESTRequest({ + method: "GET", + name: "Untitled", + endpoint: "https://example.com/", + auth: { authType: "inherit", authActive: true }, + body: { + contentType: null, + body: null, + }, + headers: [], + params: [ + { + active: true, + key: "tool", + value: "curl", + description: "", + }, + { + active: true, + key: "age", + value: "old", + description: "", + }, + ], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl -F hello=hello2 -F hello3=@hello4.txt bing.com`, + response: makeRESTRequest({ + method: "POST", + name: "Untitled", + endpoint: "https://bing.com/", + auth: { authType: "inherit", authActive: true }, + body: { + contentType: "multipart/form-data", + body: [ + { + active: true, + isFile: false, + key: "hello", + value: "hello2", + }, + { + active: true, + isFile: false, + key: "hello3", + value: "", + }, + ], + }, + headers: [], + params: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: + "curl -X GET localhost -H 'Accept: application/json' --user root:toor", + response: makeRESTRequest({ + method: "GET", + name: "Untitled", + endpoint: "http://localhost/", + auth: { + authType: "basic", + authActive: true, + username: "root", + password: "toor", + }, + body: { + contentType: null, + body: null, + }, + params: [], + headers: [ + { + active: true, + key: "Accept", + value: "application/json", + description: "", + }, + ], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: + "curl -X GET localhost --header 'Authorization: Basic dXNlcjpwYXNz'", + response: makeRESTRequest({ + method: "GET", + name: "Untitled", + endpoint: "http://localhost/", + auth: { + authType: "basic", + authActive: true, + username: "user", + password: "pass", + }, + body: { + contentType: null, + body: null, + }, + params: [], + headers: [ + { + active: true, + key: "Authorization", + value: "Basic dXNlcjpwYXNz", + description: "", + }, + ], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: + "curl -X GET localhost:9900 --header 'Authorization: Basic 77898dXNlcjpwYXNz'", + response: makeRESTRequest({ + method: "GET", + name: "Untitled", + endpoint: "http://localhost:9900/", + auth: { + authType: "inherit", + authActive: true, + }, + body: { + contentType: null, + body: null, + }, + params: [], + headers: [ + { + active: true, + key: "Authorization", + value: "Basic 77898dXNlcjpwYXNz", + description: "", + }, + ], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: + "curl -X GET localhost --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'", + response: makeRESTRequest({ + method: "GET", + name: "Untitled", + endpoint: "http://localhost/", + auth: { + authType: "bearer", + authActive: true, + token: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + }, + body: { + contentType: null, + body: null, + }, + params: [], + headers: [ + { + active: true, + key: "Authorization", + value: + "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + description: "", + }, + ], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl --get -I -d "tool=curl" -d "platform=hoppscotch" -d"io" https://hoppscotch.io`, + response: makeRESTRequest({ + method: "HEAD", + name: "Untitled", + endpoint: "https://hoppscotch.io/?io", + auth: { + authActive: true, + authType: "inherit", + }, + body: { + contentType: null, + body: null, + }, + params: [ + { + active: true, + key: "tool", + value: "curl", + description: "", + }, + { + active: true, + key: "platform", + value: "hoppscotch", + description: "", + }, + ], + headers: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl 'https://someshadywebsite.com/questionable/path/?and=params&so&stay=tuned&' \ + -H 'user-agent: Mozilla/5.0' \ + -H 'accept: text/html' \ + -H $'cookie: cookie-cookie' \ + --data $'------WebKitFormBoundaryj3oufpIISPa2DP7c\\r\\nContent-Disposition: form-data; name="EmailAddress"\\r\\n\\r\\ntest@test.com\\r\\n------WebKitFormBoundaryj3oufpIISPa2DP7c\\r\\nContent-Disposition: form-data; name="Entity"\\r\\n\\r\\n1\\r\\n------WebKitFormBoundaryj3oufpIISPa2DP7c--\\r\\n'`, + response: makeRESTRequest({ + method: "POST", + name: "Untitled", + endpoint: "https://someshadywebsite.com/questionable/path/?so", + auth: { + authActive: true, + authType: "inherit", + }, + body: { + contentType: "multipart/form-data", + body: [ + { + active: true, + isFile: false, + key: "EmailAddress", + value: "test@test.com", + }, + { + active: true, + isFile: false, + key: "Entity", + value: "1", + }, + ], + }, + params: [ + { + active: true, + key: "and", + value: "params", + description: "", + }, + { + active: true, + key: "stay", + value: "tuned", + description: "", + }, + ], + headers: [ + { + active: true, + key: "user-agent", + value: "Mozilla/5.0", + description: "", + }, + { + active: true, + key: "accept", + value: "text/html", + description: "", + }, + { + active: true, + key: "cookie", + value: "cookie-cookie", + description: "", + }, + ], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: + "curl localhost -H 'content-type: multipart/form-data; boundary=------------------------d74496d66958873e' --data '-----------------------------d74496d66958873e\\r\\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\\r\\nContent-Type: text/plain\\r\\n\\r\\nHello World\\r\\n\\r\\n-----------------------------d74496d66958873e--\\r\\n'", + response: makeRESTRequest({ + method: "POST", + name: "Untitled", + endpoint: "http://localhost/", + auth: { + authActive: true, + authType: "inherit", + }, + body: { + contentType: "multipart/form-data", + body: [ + { + active: true, + isFile: false, + key: "file", + value: "", + }, + ], + }, + params: [], + headers: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl 'https://hoppscotch.io/' \ + -H 'authority: hoppscotch.io' \ + -H 'sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="98", "Google Chrome";v="98"' \ + -H 'accept: */*' \ + -H 'user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36' \ + -H 'sec-ch-ua-platform: "Windows"' \ + -H 'accept-language: en-US,en;q=0.9,ml;q=0.8' \ + --compressed`, + response: makeRESTRequest({ + method: "GET", + name: "Untitled", + endpoint: "https://hoppscotch.io/", + auth: { authType: "inherit", authActive: true }, + body: { + contentType: null, + body: null, + }, + params: [], + headers: [ + { + active: true, + key: "authority", + value: "hoppscotch.io", + description: "", + }, + { + active: true, + key: "sec-ch-ua", + value: + '" Not A;Brand";v="99", "Chromium";v="98", "Google Chrome";v="98"', + description: "", + }, + { + active: true, + key: "accept", + value: "*/*", + description: "", + }, + { + active: true, + key: "user-agent", + value: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36", + description: "", + }, + { + active: true, + key: "sec-ch-ua-platform", + value: '"Windows"', + description: "", + }, + { + active: true, + key: "accept-language", + value: "en-US,en;q=0.9,ml;q=0.8", + description: "", + }, + ], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl --request GET \ + --url 'https://echo.hoppscotch.io/?hello=there' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --header 'something: other-thing' \ + --data a=b \ + --data c=d`, + response: makeRESTRequest({ + method: "GET", + name: "Untitled", + endpoint: "https://echo.hoppscotch.io/", + auth: { authType: "inherit", authActive: true }, + body: { + contentType: "application/x-www-form-urlencoded", + body: rawKeyValueEntriesToString([ + { + key: "a", + value: "b", + active: true, + }, + { + key: "c", + value: "d", + active: true, + }, + ]), + }, + params: [ + { + active: true, + key: "hello", + value: "there", + description: "", + }, + ], + headers: [ + { + active: true, + key: "something", + value: "other-thing", + description: "", + }, + ], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl --request POST \ + --url 'https://echo.hoppscotch.io/?hello=there' \ + --header 'content-type: multipart/form-data' \ + --header 'something: other-thing' \ + --form a=b \ + --form c=d`, + response: makeRESTRequest({ + name: "Untitled", + endpoint: "https://echo.hoppscotch.io/", + method: "POST", + auth: { authType: "inherit", authActive: true }, + headers: [ + { + active: true, + key: "something", + value: "other-thing", + description: "", + }, + ], + body: { + contentType: "multipart/form-data", + body: [ + { + active: true, + isFile: false, + key: "a", + value: "b", + }, + { + active: true, + isFile: false, + key: "c", + value: "d", + }, + ], + }, + params: [ + { + active: true, + key: "hello", + value: "there", + description: "", + }, + ], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: "curl 'muxueqz.top/skybook.html'", + response: makeRESTRequest({ + name: "Untitled", + endpoint: "https://muxueqz.top/skybook.html", + method: "GET", + auth: { authType: "inherit", authActive: true }, + headers: [], + body: { contentType: null, body: null }, + params: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: "curl -F abcd=efghi", + response: makeRESTRequest({ + name: "Untitled", + endpoint: "https://echo.hoppscotch.io/", + method: "POST", + auth: { authType: "inherit", authActive: true }, + headers: [], + body: { + contentType: "multipart/form-data", + body: [ + { + active: true, + isFile: false, + key: "abcd", + value: "efghi", + }, + ], + }, + params: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: "curl 127.0.0.1 -X custommethod", + response: makeRESTRequest({ + name: "Untitled", + endpoint: "http://127.0.0.1/", + method: "CUSTOMMETHOD", + auth: { authType: "inherit", authActive: true }, + headers: [], + body: { + contentType: null, + body: null, + }, + params: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: "curl echo.hoppscotch.io -A pinephone", + response: makeRESTRequest({ + name: "Untitled", + endpoint: "https://echo.hoppscotch.io/", + method: "GET", + auth: { authType: "inherit", authActive: true }, + headers: [ + { + active: true, + key: "User-Agent", + value: "pinephone", + description: "", + }, + ], + body: { + contentType: null, + body: null, + }, + params: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: "curl echo.hoppscotch.io -G", + response: makeRESTRequest({ + name: "Untitled", + endpoint: "https://echo.hoppscotch.io/", + method: "GET", + auth: { authType: "inherit", authActive: true }, + headers: [], + body: { + contentType: null, + body: null, + }, + params: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl --get -I -d "tool=hopp" https://example.org`, + response: makeRESTRequest({ + name: "Untitled", + endpoint: "https://example.org/", + method: "HEAD", + auth: { authType: "inherit", authActive: true }, + headers: [], + body: { + contentType: null, + body: null, + }, + params: [ + { + active: true, + key: "tool", + value: "hopp", + description: "", + }, + ], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl google.com -u userx`, + response: makeRESTRequest({ + method: "GET", + name: "Untitled", + endpoint: "https://google.com/", + auth: { + authType: "basic", + authActive: true, + username: "userx", + password: "", + }, + body: { + contentType: null, + body: null, + }, + params: [], + headers: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl google.com -H "Authorization"`, + response: makeRESTRequest({ + method: "GET", + name: "Untitled", + endpoint: "https://google.com/", + auth: { + authType: "inherit", + authActive: true, + }, + body: { + contentType: null, + body: null, + }, + params: [], + headers: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl \` + google.com -H "content-type: application/json"`, + response: makeRESTRequest({ + method: "GET", + name: "Untitled", + endpoint: "https://google.com/", + auth: { + authType: "inherit", + authActive: true, + }, + body: { + contentType: null, + body: null, + }, + params: [], + headers: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl 192.168.0.24:8080/ping`, + response: makeRESTRequest({ + method: "GET", + name: "Untitled", + endpoint: "http://192.168.0.24:8080/ping", + auth: { + authType: "inherit", + authActive: true, + }, + body: { + contentType: null, + body: null, + }, + params: [], + headers: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl https://example.com -d "alpha=beta&request_id=4"`, + response: makeRESTRequest({ + method: "POST", + name: "Untitled", + endpoint: "https://example.com/", + auth: { + authType: "inherit", + authActive: true, + }, + body: { + contentType: "application/x-www-form-urlencoded", + body: rawKeyValueEntriesToString([ + { + active: true, + key: "alpha", + value: "beta", + }, + { + active: true, + key: "request_id", + value: "4", + }, + ]), + }, + params: [], + headers: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl --location 'https://api.example.net/id/1164/requests' \ + --header 'Accept: application/vnd.test-data.v2.1+json' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'data={"type":"test","typeId":"101"}' \ + --data-urlencode 'data2={"type":"test2","typeId":"123"}'`, + response: makeRESTRequest({ + method: "POST", + name: "Untitled", + endpoint: "https://api.example.net/id/1164/requests", + auth: { + authType: "inherit", + authActive: true, + }, + body: { + contentType: "application/x-www-form-urlencoded", + body: `data: {"type":"test","typeId":"101"} +data2: {"type":"test2","typeId":"123"}`, + }, + params: [], + headers: [ + { + active: true, + key: "Accept", + value: "application/vnd.test-data.v2.1+json", + description: "", + }, + ], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, + { + command: `curl --request GET \ + --url https://echo.hoppscotch.io/ \ + --header 'Authorization:Basic YXNkZmdoOjEyMzQ=' \ + --header 'User-Agent:Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0' + --header 'foo:bar'`, + response: makeRESTRequest({ + method: "GET", + name: "Untitled", + endpoint: "https://echo.hoppscotch.io/", + auth: { + authType: "basic", + authActive: true, + username: "asdfgh", + password: "1234", + }, + body: { + contentType: null, + body: null, + }, + params: [], + headers: [ + { + active: true, + key: "Authorization", + value: "Basic YXNkZmdoOjEyMzQ=", + description: "", + }, + { + active: true, + key: "User-Agent", + value: + "Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0", + description: "", + }, + { + active: true, + key: "foo", + value: "bar", + description: "", + }, + ], + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + description: null, + }), + }, +] + +describe("Parse curl command to Hopp REST Request", () => { + test("parses json body with semicolon-only headers", () => { + const command = String.raw`curl 'https://echo.hoppscotch.io/api/process/insert' \ + -H 'Authorization-OAuth2;' \ + -H 'Authorization-OAuth2-Client;' \ + -H 'Authorization-OAuth2-Refresh;' \ + -H 'Content-Type: application/json;charset=UTF-8' \ + --data-raw '{"insertProcessDto":{"name":"测hi退回"},"formSaveDTO":{"formProps":"{\"list\":[]}"}}' \ + --insecure` + + const actual = parseCurlToHoppRESTReq(command) + + expect(actual.method).toBe("POST") + expect(actual.endpoint).toBe( + "https://echo.hoppscotch.io/api/process/insert" + ) + expect(actual.body.contentType).toBe("application/json") + + const parsedBody = JSON.parse(actual.body.body) + expect(parsedBody.insertProcessDto.name).toBe("测hi退回") + expect(JSON.parse(parsedBody.formSaveDTO.formProps)).toEqual({ list: [] }) + }) + + test("parses json body containing escaped XML", () => { + const command = String.raw`curl 'https://echo.hoppscotch.io/api/process/insert' \ + -H 'Authorization-OAuth2;' \ + -H 'Authorization-OAuth2-Client;' \ + -H 'Authorization-OAuth2-Refresh;' \ + -H 'Content-Type: application/json;charset=UTF-8' \ + --data-raw '{"insertProcessDto":{"bpmnXmlString":"\n"},"formSaveDTO":{"formProps":"{\"list\":[]}"}}' \ + --insecure` + + const actual = parseCurlToHoppRESTReq(command) + + expect(actual.method).toBe("POST") + expect(actual.endpoint).toBe( + "https://echo.hoppscotch.io/api/process/insert" + ) + expect(actual.body.contentType).toBe("application/json") + + const parsedBody = JSON.parse(actual.body.body) + expect(parsedBody.insertProcessDto.bpmnXmlString).toContain(" { + const command = `curl 'https://echo.hoppscotch.io/api/chat/completions' -d '{"response_format":{"type":"json_object"},"messages":[{"content":"Translate array of texts from en into zh and return JSON result with the same array length, do not add any additional text, and do not return code blocks, such as: {\\"translations\\": [\\"translation of input text 1\\", ...]}","role":"system"},{"content":"[\\"Epic Games CEO Tim Sweeney argues banning Twitter over its ability to AI-generate pornographic images of minors is just '''gatekeepers''' attempting to '''censor all of their political opponents'''\\"]","role":"user"}],"model":"gpt-translate","temperature":0.30000001192092896}' -H ':authority: echo.hoppscotch.io' -H 'accept: */*' -H 'content-type: application/json' -H 'accept-language: en-US;q=1.0, zh-Hans-US;q=0.9' -H 'authorization: Bearer ' -H 'accept-encoding: br;q=1.0, gzip;q=0.9, deflate;q=0.8' -H 'user-agent: TranslationExtension/1.14.5 (org.lesslab.relingo.TranslationExtension; build:104; iOS 26.2.0) Alamofire/5.10.2' -H 'priority: u=3, i' --compressed` + + const actual = parseCurlToHoppRESTReq(command) + + expect(actual.method).toBe("POST") + expect(actual.endpoint).toBe( + "https://echo.hoppscotch.io/api/chat/completions" + ) + expect(actual.body.contentType).toBe("application/json") + + const parsedBody = JSON.parse(actual.body.body) + expect(parsedBody.model).toBe("gpt-translate") + expect(parsedBody.temperature).toBe(0.30000001192092896) + expect(parsedBody.response_format).toEqual({ type: "json_object" }) + expect(parsedBody.messages).toHaveLength(2) + expect(parsedBody.messages[0].role).toBe("system") + expect(parsedBody.messages[0].content).toBe( + `Translate array of texts from en into zh and return JSON result with the same array length, do not add any additional text, and do not return code blocks, such as: {"translations": ["translation of input text 1", ...]}` + ) + expect(parsedBody.messages[1].role).toBe("user") + expect(parsedBody.messages[1].content).toBe( + `["Epic Games CEO Tim Sweeney argues banning Twitter over its ability to AI-generate pornographic images of minors is just '''gatekeepers''' attempting to '''censor all of their political opponents'''"]` + ) + }) + + test("preserves -d POST without -G as form-urlencoded", () => { + const command = `curl -X POST 'https://example.com/submit' -d 'name=alice&role=admin'` + + const actual = parseCurlToHoppRESTReq(command) + + expect(actual.method).toBe("POST") + expect(actual.endpoint).toBe("https://example.com/submit") + expect(actual.body.contentType).toBe("application/x-www-form-urlencoded") + expect(actual.body.body).toBe("name: alice\nrole: admin") + }) + + test("does not intercept -d inside a quoted header value", () => { + const command = `curl 'https://example.com/api' -H 'X-Custom: -d {"fake":1}' -d '{"real":true}'` + + const actual = parseCurlToHoppRESTReq(command) + + expect(actual.method).toBe("POST") + expect(actual.endpoint).toBe("https://example.com/api") + expect(actual.body.contentType).toBe("application/json") + expect(JSON.parse(actual.body.body)).toEqual({ real: true }) + + const customHeader = actual.headers.find((h) => h.key === "X-Custom") + expect(customHeader).toBeDefined() + expect(customHeader.value).toBe(`-d {"fake":1}`) + }) + + for (const [i, { command, response }] of samples.entries()) { + test(`for sample #${i + 1}:\n\n${command}`, () => { + const actual = parseCurlToHoppRESTReq(command) + + /** + * An object possibly carrying an internal reference id. + * @typedef {object} RefIdCarrier + * @property {unknown} [_ref_id] + */ + + /** + * @template {object} T + * @param {T & RefIdCarrier} obj + * @returns {Omit} + */ + const stripRefId = (obj) => { + const clone = { ...obj } + delete clone._ref_id + return clone + } + + // Strip off _ref_id added by makeRESTRequest for equality check because it is generated randomly + expect(stripRefId(actual)).toEqual(stripRefId(response)) + }) + } +}) diff --git a/packages/hoppscotch-common/src/helpers/curl/__tests__/detectContentType.spec.js b/packages/hoppscotch-common/src/helpers/curl/__tests__/detectContentType.spec.js new file mode 100644 index 0000000..ac0bd97 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/curl/__tests__/detectContentType.spec.js @@ -0,0 +1,163 @@ +import { describe, test, expect } from "vitest" +import { detectContentType } from "../sub_helpers/contentParser" + +describe("detect content type", () => { + test("should return null for blank input", () => { + expect(detectContentType("")).toBe(null) + }) + + describe("application/json", () => { + test('should return text/plain for "{"', () => { + expect(detectContentType("{")).toBe("text/plain") + }) + + test('should return application/json for "{}"', () => { + expect(detectContentType("{}")).toBe("application/json") + }) + + test("should return application/json for valid json data", () => { + expect( + detectContentType(` + { + "body": "some text", + "name": "interesting name", + "code": [1, 5, 6, 2] + } + `) + ).toBe("application/json") + }) + }) + + // describe("application/xml", () => { + // TODO: Figure this test situation + // test("should return text/html for XML data without XML declaration", () => { + // expect( + // detectContentType(` + // + // Everyday Italian + // Giada De Laurentiis + // 2005 + // 30.00 + // + // `) + // ).toBe("text/html") + // }) + + // TODO: Figure this test situation + // test("should return application/xml for valid XML data", () => { + // expect( + // detectContentType(` + // + // + // Everyday Italian + // Giada De Laurentiis + // 2005 + // 30.00 + // + // `) + // ).toBe("text/html") + // }) + + // TODO: Figure this test situation + // test("should return text/html for invalid XML data", () => { + // expect( + // detectContentType(` + // + // Everyday Italian + // <abcd>Giada De Laurentiis</abcd> + // <year>2005</year> + // <price>30.00</price> + // `) + // ).toBe("text/html") + // }) + // }) + + describe("text/html", () => { + // test("should return text/html for valid HTML data", () => { + // expect( + // detectContentType(` + // <!DOCTYPE html> + // <html> + // <head> + // <title>Page Title + // + // + //

This is a Heading

+ //

This is a paragraph.

+ // + // + // `) + // ).toBe("text/html") + // }) + + // TODO: Figure this test situation + // test("should return text/html for invalid HTML data", () => { + // expect( + // detectContentType(` + // + // Page Title + // + //

This is a Heading

+ // + // + // `) + // ).toBe("text/html") + // }) + + test("should return text/html for unmatched tag", () => { + expect(detectContentType("")).toBe("text/html") + }) + + test("should return text/plain for no valid tags in input", () => { + expect(detectContentType(" { + test("should return application/x-www-form-urlencoded for valid data", () => { + expect(detectContentType("hello=world&hopp=scotch")).toBe( + "application/x-www-form-urlencoded" + ) + }) + + test("should return application/x-www-form-urlencoded for empty pair", () => { + expect(detectContentType("hello=world&hopp=scotch&")).toBe( + "application/x-www-form-urlencoded" + ) + }) + + test("should return application/x-www-form-urlencoded for dangling param", () => { + expect(detectContentType("hello=world&hoppscotch")).toBe( + "application/x-www-form-urlencoded" + ) + }) + + test('should return text/plain for "="', () => { + expect(detectContentType("=")).toBe("text/plain") + }) + + test("should return application/x-www-form-urlencoded for no value field", () => { + expect(detectContentType("hello=")).toBe( + "application/x-www-form-urlencoded" + ) + }) + }) + + describe("multipart/form-data", () => { + test("should return multipart/form-data for valid data", () => { + expect( + detectContentType( + `------WebKitFormBoundaryj3oufpIISPa2DP7c\\r\\nContent-Disposition: form-data; name="EmailAddress"\\r\\n\\r\\ntest@test.com\\r\\n------WebKitFormBoundaryj3oufpIISPa2DP7c\\r\\nContent-Disposition: form-data; name="Entity"\\r\\n\\r\\n1\\r\\n------WebKitFormBoundaryj3oufpIISPa2DP7c--\\r\\n` + ) + ).toBe("multipart/form-data") + }) + + test("should return application/x-www-form-urlencoded for data with only one boundary", () => { + expect( + detectContentType( + `\\r\\nContent-Disposition: form-data; name="EmailAddress"\\r\\n\\r\\ntest@test.com\\r\\n\\r\\nContent-Disposition: form-data; name="Entity"\\r\\n\\r\\n1\\r\\n------WebKitFormBoundaryj3oufpIISPa2DP7c--\\r\\n` + ) + ).toBe("application/x-www-form-urlencoded") + }) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/curl/curlparser.ts b/packages/hoppscotch-common/src/helpers/curl/curlparser.ts new file mode 100644 index 0000000..811f560 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/curl/curlparser.ts @@ -0,0 +1,279 @@ +/** + * the direct import from yargs-parser uses fs which is a built in node module, + * just adding the /browser import as a fix for now, which does not have type info on DefinitelyTyped. + * remove/update this comment before merging the vue3 port. + */ +import { + FormDataKeyValue, + HoppRESTReqBody, + makeRESTRequest, +} from "@hoppscotch/data" +import * as A from "fp-ts/Array" +import { flow, pipe } from "fp-ts/function" +import * as O from "fp-ts/Option" +import parser from "yargs-parser/browser" +import { getAuthObject } from "./sub_helpers/auth" +import { getHeaders, recordToHoppHeaders } from "./sub_helpers/headers" +import { getCookies } from "./sub_helpers/cookies" +import { + objHasArrayProperty, + objHasProperty, +} from "~/helpers/functional/object" +import { getDefaultRESTRequest } from "../rest/default" +import { getBody, getFArgumentMultipartData } from "./sub_helpers/body" +import { getMethod } from "./sub_helpers/method" +import { + preProcessCurlCommand, + replaceJSONDataArgsWithPlaceholders, + restoreJSONDataArgsFromPlaceholders, +} from "./sub_helpers/preproc" +import { getQueries } from "./sub_helpers/queries" +import { concatParams, getURLObject } from "./sub_helpers/url" +import { HOPP_ENVIRONMENT_REGEX } from "../environment-regex" + +const defaultRESTReq = getDefaultRESTRequest() + +/** + * + * @param str The string to test for environment variables. + * @returns A boolean indicating whether the string contains environment variables. + */ +const containsEnvVariables = (str: string) => HOPP_ENVIRONMENT_REGEX.test(str) + +export const parseCurlCommand = (curlCommand: string) => { + // const isDataBinary = curlCommand.includes(" --data-binary") + // const compressed = !!parsedArguments.compressed + + curlCommand = preProcessCurlCommand(curlCommand) + + const { curlCommand: sanitizedCurlCommand, extractedJSONData } = + replaceJSONDataArgsWithPlaceholders(curlCommand) + + const args: parser.Arguments = restoreJSONDataArgsFromPlaceholders( + parser(sanitizedCurlCommand), + extractedJSONData + ) + + const parsedArguments = pipe( + args, + O.fromPredicate( + (args) => + objHasProperty("dataUrlencode", "string")(args) || + objHasProperty("dataUrlencode", "object")(args) + ), + O.map((args) => { + const urlEncodedData: string[] = Array.isArray(args.dataUrlencode) + ? args.dataUrlencode + : [args.dataUrlencode] + + const data = A.map(decodeURIComponent)(urlEncodedData) + + return { ...args, d: data } + }), + O.getOrElse(() => args) + ) + + const headerObject = getHeaders(parsedArguments) + const { headers } = headerObject + let { rawContentType } = headerObject + const hoppHeaders = pipe( + headers, + O.fromPredicate(() => Object.keys(headers).length > 0), + O.map(recordToHoppHeaders), + O.getOrElse(() => defaultRESTReq.headers) + ) + + const method = getMethod(parsedArguments) + const cookies = getCookies(parsedArguments) + + // Add cookies to headers if they exist + if (Object.keys(cookies).length > 0) { + const cookieString = Object.entries(cookies) + .map(([key, value]) => `${key}=${value}`) + .join("; ") + + hoppHeaders.push({ + key: "Cookie", + value: cookieString, + active: true, + description: "", + }) + } + + const urlObject = getURLObject(parsedArguments) + const auth = getAuthObject(parsedArguments, headers, urlObject) + + let rawData: string | string[] = pipe( + parsedArguments, + O.fromPredicate(objHasArrayProperty("d", "string")), + O.map((args) => args.d), + O.altW(() => + pipe( + parsedArguments, + O.fromPredicate(objHasProperty("d", "string")), + O.map((args) => args.d) + ) + ), + O.getOrElseW(() => "") + ) + + let body: HoppRESTReqBody["body"] = "" + let contentType: HoppRESTReqBody["contentType"] = + defaultRESTReq.body.contentType + let hasBodyBeenParsed = false + + let { queries, danglingParams } = getQueries( + Array.from(urlObject.searchParams.entries()) + ) + + const safeDecodeURIComponent = (value: string) => { + if (!value.includes("%")) return value + if (/%(?![0-9A-Fa-f]{2})/.test(value)) return value + + try { + return decodeURIComponent(value) + } catch { + return value + } + } + + const stringToPair = (pair: string): [string, string] => { + const decodedPair = safeDecodeURIComponent(pair) + const eqIndex = decodedPair.indexOf("=") + + if (eqIndex === -1) return [decodedPair, ""] + + return [decodedPair.slice(0, eqIndex), decodedPair.slice(eqIndex + 1)] + } + + const shouldParseDataAsQueries = objHasProperty( + "G", + "boolean" + )(parsedArguments) + const pairs = pipe( + rawData, + O.fromPredicate(Array.isArray), + O.map(A.map(stringToPair)), + O.alt(() => + pipe( + rawData, + O.fromPredicate((s) => shouldParseDataAsQueries && s.length > 0), + O.map((s) => [stringToPair(s)]) + ) + ), + O.getOrElseW(() => undefined) + ) + + if (shouldParseDataAsQueries && !!pairs) { + const newQueries = getQueries(pairs) + queries = [...queries, ...newQueries.queries] + danglingParams = [...danglingParams, ...newQueries.danglingParams] + hasBodyBeenParsed = true + } else if ( + (rawContentType.includes("application/x-www-form-urlencoded") || + /** + * When using the -d option with curl for a POST operation, + * curl includes a default header: Content-Type: application/x-www-form-urlencoded. + * https://everything.curl.dev/http/post/content-type.html + */ + (!rawContentType && + method === "POST" && + rawData && + rawData.length > 0)) && + !!pairs && + Array.isArray(rawData) + ) { + body = pairs.map((p) => p.join(": ")).join("\n") || null + contentType = "application/x-www-form-urlencoded" + hasBodyBeenParsed = true + } + + const concatedURL = concatParams(urlObject, danglingParams) + + const decodedURL = decodeURIComponent(concatedURL) + + // Decode the URL only if it’s safe to do so without corrupting environment variables. + // This is to ensure that environment variables are not decoded and remain in the format `<>`. + // This is useful for code generation where environment variables are used to store sensitive information + // such as API keys, secrets, etc. + // If the URL does not contain environment variables, decode it normally. + const urlString = containsEnvVariables(decodedURL) ? decodedURL : concatedURL + + let multipartUploads: Record = pipe( + O.of(parsedArguments), + O.chain(getFArgumentMultipartData), + O.match( + () => ({}), + (args) => { + hasBodyBeenParsed = true + rawContentType = "multipart/form-data" + return args + } + ) + ) + + if (!hasBodyBeenParsed) { + if (typeof rawData !== "string") { + rawData = rawData.join("") + } + const bodyObject = getBody(rawData, rawContentType, contentType) + + if (O.isSome(bodyObject)) { + const bodyObjectValue = bodyObject.value + + if (bodyObjectValue.type === "FORMDATA") { + multipartUploads = bodyObjectValue.body + } else { + body = bodyObjectValue.body.body + contentType = bodyObjectValue.body + .contentType as HoppRESTReqBody["contentType"] + } + } + } + + const finalBody: HoppRESTReqBody = pipe( + body, + O.fromNullable, + O.filter((b) => b.length > 0), + O.map((b) => { body: b, contentType }), + O.alt(() => + pipe( + multipartUploads, + O.of, + O.map((m) => Object.entries(m)), + O.filter((m) => m.length > 0), + O.map( + flow( + A.map( + ([key, value]) => + { + active: true, + isFile: false, + key, + value, + } + ), + (b) => + { body: b, contentType: "multipart/form-data" } + ) + ) + ) + ), + O.getOrElse(() => defaultRESTReq.body) + ) + + return makeRESTRequest({ + name: defaultRESTReq.name, + endpoint: urlString, + method: (method || defaultRESTReq.method).toUpperCase(), + params: queries ?? defaultRESTReq.params, + headers: hoppHeaders, + preRequestScript: defaultRESTReq.preRequestScript, + testScript: defaultRESTReq.testScript, + auth, + body: finalBody, + requestVariables: defaultRESTReq.requestVariables, + responses: {}, + description: defaultRESTReq.description, + }) +} diff --git a/packages/hoppscotch-common/src/helpers/curl/index.ts b/packages/hoppscotch-common/src/helpers/curl/index.ts new file mode 100644 index 0000000..ca47667 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/curl/index.ts @@ -0,0 +1,5 @@ +import { flow } from "fp-ts/function" +import { cloneDeep } from "lodash-es" +import { parseCurlCommand } from "./curlparser" + +export const parseCurlToHoppRESTReq = flow(parseCurlCommand, cloneDeep) diff --git a/packages/hoppscotch-common/src/helpers/curl/sub_helpers/auth.ts b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/auth.ts new file mode 100644 index 0000000..77a2342 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/auth.ts @@ -0,0 +1,116 @@ +import { HoppRESTAuth } from "@hoppscotch/data" +import parser from "yargs-parser" +import * as O from "fp-ts/Option" +import * as S from "fp-ts/string" +import { pipe } from "fp-ts/function" +import { getDefaultRESTRequest } from "~/helpers/rest/default" +import { objHasProperty } from "~/helpers/functional/object" + +const defaultRESTReq = getDefaultRESTRequest() + +const getAuthFromAuthHeader = (headers: Record) => + pipe( + headers.Authorization, + O.fromNullable, + O.map((a) => a.split(" ")), + O.filter((a) => a.length > 1), + O.chain((kv) => + O.fromNullable( + (() => { + switch (kv[0].toLowerCase()) { + case "bearer": + return { + authActive: true, + authType: "bearer", + token: kv[1], + } + case "basic": { + const [username, password] = pipe( + O.tryCatch(() => atob(kv[1])), + O.map(S.split(":")), + // can have a username with no password + O.filter((arr) => arr.length > 0), + O.map( + ([username, password]) => + <[string, string]>[username, password] + ), + O.getOrElse(() => ["", ""]) + ) + + if (!username) return undefined + + return { + authActive: true, + authType: "basic", + username, + password: password ?? "", + } + } + default: + return undefined + } + })() + ) + ) + ) + +const getAuthFromParsedArgs = (parsedArguments: parser.Arguments) => + pipe( + parsedArguments, + O.fromPredicate(objHasProperty("u", "string")), + O.chain((args) => + pipe( + args.u, + S.split(":"), + // can have a username with no password + O.fromPredicate((arr) => arr.length > 0 && arr[0].length > 0), + O.map( + ([username, password]) => <[string, string]>[username, password ?? ""] + ) + ) + ), + O.map( + ([username, password]) => + { + authActive: true, + authType: "basic", + username, + password, + } + ) + ) + +const getAuthFromURLObject = (urlObject: URL) => + pipe( + urlObject, + (url) => [url.username, url.password ?? ""], + // can have a username with no password + O.fromPredicate(([username]) => !!username && username.length > 0), + O.map( + ([username, password]) => + { + authActive: true, + authType: "basic", + username, + password, + } + ) + ) + +/** + * Preference order: + * - Auth headers + * - --user or -u argument + * - Creds provided along with URL + */ +export const getAuthObject = ( + parsedArguments: parser.Arguments, + headers: Record, + urlObject: URL +): HoppRESTAuth => + pipe( + getAuthFromAuthHeader(headers), + O.alt(() => getAuthFromParsedArgs(parsedArguments)), + O.alt(() => getAuthFromURLObject(urlObject)), + O.getOrElse(() => defaultRESTReq.auth) + ) diff --git a/packages/hoppscotch-common/src/helpers/curl/sub_helpers/body.ts b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/body.ts new file mode 100644 index 0000000..07a95fb --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/body.ts @@ -0,0 +1,169 @@ +import parser from "yargs-parser" +import { pipe, flow } from "fp-ts/function" +import * as O from "fp-ts/Option" +import * as A from "fp-ts/Array" +import * as RNEA from "fp-ts/ReadonlyNonEmptyArray" +import * as S from "fp-ts/string" +import { + HoppRESTReqBody, + HoppRESTReqBodyFormData, + ValidContentTypes, + knownContentTypes, +} from "@hoppscotch/data" +import { detectContentType, parseBody } from "./contentParser" +import { tupleToRecord } from "~/helpers/functional/record" +import { + objHasProperty, + objHasArrayProperty, +} from "~/helpers/functional/object" + +type BodyReturnType = + | { type: "FORMDATA"; body: Record } + | { + type: "NON_FORMDATA" + body: Exclude + } + +/** Parses body based on the content type + * @param rData Raw data + * @param cType Sanitized content type + * @returns Option of parsed body of type string | Record + */ +const getBodyFromContentType = + (rData: string, cType: HoppRESTReqBody["contentType"]) => (rct: string) => + pipe( + cType, + O.fromPredicate((ctype) => ctype === "multipart/form-data"), + O.chain(() => + pipe( + // pass rawContentType for boundary ascertion + parseBody(rData, cType, rct), + O.filter((parsedBody) => typeof parsedBody !== "string") + ) + ), + O.alt(() => + pipe( + parseBody(rData, cType), + O.filter( + (parsedBody) => + typeof parsedBody === "string" && parsedBody.length > 0 + ) + ) + ) + ) + +const getContentTypeFromRawContentType = (rawContentType: string) => + pipe( + rawContentType, + O.fromPredicate((rct) => rct.length > 0), + // get everything before semi-colon + O.map(flow(S.toLowerCase, S.split(";"), RNEA.head)), + // if rawContentType is valid, cast it to contentType type + O.filter((ct) => Object.keys(knownContentTypes).includes(ct)), + O.map((ct) => ct as HoppRESTReqBody["contentType"]) + ) + +const getContentTypeFromRawData = (rawData: string) => + pipe( + rawData, + O.fromPredicate((rd) => rd.length > 0), + O.map(detectContentType) + ) + +export const getBody = ( + rawData: string, + rawContentType: string, + contentType: HoppRESTReqBody["contentType"] +): O.Option => { + return pipe( + O.Do, + + O.bind("cType", () => + pipe( + // get provided content-type + contentType, + O.fromNullable, + // or figure it out + O.alt(() => getContentTypeFromRawContentType(rawContentType)), + O.alt(() => getContentTypeFromRawData(rawData)) + ) + ), + + O.bind("rData", () => + pipe( + rawData, + O.fromPredicate(() => rawData.length > 0) + ) + ), + + O.bind("ctBody", ({ cType, rData }) => + pipe(rawContentType, getBodyFromContentType(rData, cType)) + ), + + O.map(({ cType, ctBody }) => + typeof ctBody === "string" + ? { + type: "NON_FORMDATA", + body: { + body: ctBody, + contentType: cType as Exclude< + ValidContentTypes, + "multipart/form-data" + >, + }, + } + : { type: "FORMDATA", body: ctBody } + ) + ) +} + +/** + * Parses and structures multipart/form-data from -F argument of curl command + * @param parsedArguments Parsed Arguments object + * @returns Option of Record type containing key-value pairs of multipart/form-data + */ +export function getFArgumentMultipartData( + parsedArguments: parser.Arguments +): O.Option> { + // --form or -F multipart data + + return pipe( + parsedArguments, + // make it an array if not already + O.fromPredicate(objHasProperty("F", "string")), + O.map((args) => [args.F]), + O.alt(() => + pipe( + parsedArguments, + O.fromPredicate(objHasArrayProperty("F", "string")), + O.map((args) => args.F) + ) + ), + O.chain( + flow( + A.map(S.split("=")), + // can only have a key and no value + O.fromPredicate((fArgs) => fArgs.length > 0), + O.map( + flow( + A.map(([k, v]) => + pipe( + parsedArguments, + // form-string option allows for "@" and "<" prefixes + // without them being considered as files + O.fromPredicate(objHasProperty("form-string", "boolean")), + O.match( + // leave the value field empty for files + () => [k, v[0] === "@" || v[0] === "<" ? "" : v], + () => [k, v] + ) + ) + ), + A.map(([k, v]) => [k, v] as [string, string]), + tupleToRecord + ) + ) + ) + ) + ) +} diff --git a/packages/hoppscotch-common/src/helpers/curl/sub_helpers/contentParser.ts b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/contentParser.ts new file mode 100644 index 0000000..515c892 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/contentParser.ts @@ -0,0 +1,276 @@ +import { HoppRESTReqBody } from "@hoppscotch/data" +import * as O from "fp-ts/Option" +import * as RA from "fp-ts/ReadonlyArray" +import * as S from "fp-ts/string" +import { pipe, flow } from "fp-ts/function" +import { tupleToRecord } from "~/helpers/functional/record" +import { safeParseJSON } from "~/helpers/functional/json" +import { optionChoose } from "~/helpers/functional/option" +import xmlFormat from "xml-formatter" + +const isJSON = flow(safeParseJSON, O.isSome) + +const isXML = (rawData: string) => + pipe( + rawData, + O.fromPredicate(() => /<\/?[a-zA-Z][\s\S]*>/i.test(rawData)), + O.chain(prettifyXml), + O.isSome + ) + +const isHTML = (rawData: string) => + pipe( + rawData, + O.fromPredicate(() => /<\/?[a-zA-Z][\s\S]*>/i.test(rawData)), + O.isSome + ) + +const isFormData = (rawData: string) => + pipe( + rawData.match(/^-{2,}[A-Za-z0-9]+\\r\\n/), + O.fromNullable, + O.filter((boundaryMatch) => boundaryMatch.length > 0), + O.isSome + ) + +const isXWWWFormUrlEncoded = (rawData: string) => + pipe( + rawData, + O.fromPredicate((rd) => /([^&=]+)=([^&=]*)/.test(rd)), + O.isSome + ) + +/** + * Detects the content type of the input string + * @param rawData String for which content type is to be detected + * @returns Content type of the data + */ +export const detectContentType = ( + rawData: string +): HoppRESTReqBody["contentType"] => + pipe( + rawData, + optionChoose([ + [(rd) => !rd, null], + [isJSON, "application/json" as const], + [isFormData, "multipart/form-data" as const], + [isXML, "application/xml" as const], + [isHTML, "text/html" as const], + [isXWWWFormUrlEncoded, "application/x-www-form-urlencoded" as const], + ]), + O.getOrElseW(() => "text/plain" as const) + ) + +const multipartFunctions = { + getBoundary(rawData: string, rawContentType: string | undefined) { + return pipe( + rawContentType, + O.fromNullable, + O.filter((rct) => rct.length > 0), + O.match( + () => this.getBoundaryFromRawData(rawData), + (rct) => this.getBoundaryFromRawContentType(rawData, rct) + ) + ) + }, + + getBoundaryFromRawData(rawData: string) { + return pipe( + rawData.match(/(-{2,}[A-Za-z0-9]+)\\r\\n/g), + O.fromNullable, + O.filter((boundaryMatch) => boundaryMatch.length > 0), + O.map((matches) => matches[0].slice(0, -4)) + ) + }, + + getBoundaryFromRawContentType(rawData: string, rawContentType: string) { + return pipe( + rawContentType.match(/boundary=(.+)/), + O.fromNullable, + O.filter((boundaryContentMatch) => boundaryContentMatch.length > 1), + O.filter((matches) => + rawData.replaceAll("\\r\\n", "").endsWith("--" + matches[1] + "--") + ), + O.map((matches) => "--" + matches[1]) + ) + }, + + splitUsingBoundaryAndNewLines(rawData: string, boundary: string) { + return pipe( + rawData, + S.split(RegExp(`${boundary}-*`)), + RA.filter((p) => p !== "" && p.includes("name")), + RA.map((p) => + pipe( + p.replaceAll(/\\r\\n+/g, "\\r\\n"), + S.split("\\r\\n"), + RA.filter((q) => q !== "") + ) + ) + ) + }, + + getNameValuePair(pair: readonly string[]) { + return pipe( + pair, + O.fromPredicate((p) => p.length > 1), + O.chain((pair) => O.fromNullable(pair[0].match(/ name="(\w+)"/))), + O.filter((nameMatch) => nameMatch.length > 0), + O.chain((nameMatch) => + pipe( + nameMatch[0], + S.replace(/"/g, ""), + S.split("="), + O.fromPredicate((q) => q.length === 2), + O.map( + (nameArr) => + [nameArr[1], pair[0].includes("filename") ? "" : pair[1]] as [ + string, + string, + ] + ) + ) + ) + ) + }, +} + +const getFormDataBody = (rawData: string, rawContentType: string | undefined) => + pipe( + multipartFunctions.getBoundary(rawData, rawContentType), + O.map((boundary) => + pipe( + multipartFunctions.splitUsingBoundaryAndNewLines(rawData, boundary), + RA.filterMap((p) => multipartFunctions.getNameValuePair(p)), + RA.toArray + ) + ), + + O.filter((arr) => arr.length > 0), + O.map(tupleToRecord) + ) + +const getHTMLBody = flow(formatHTML, O.of) + +const getXMLBody = (rawData: string) => + pipe( + rawData, + prettifyXml, + O.alt(() => O.some(rawData)) + ) + +const getFormattedJSON = flow( + safeParseJSON, + O.map((parsedJSON) => JSON.stringify(parsedJSON, null, 2)), + O.getOrElse(() => "{ }") +) + +const getXWWWFormUrlEncodedBody = flow( + decodeURIComponent, + (decoded) => decoded.match(/(([^&=]+)=?([^&=]*))/g), + O.fromNullable, + O.map((pairs) => pairs.map((p) => p.replace("=", ": ")).join("\n")) +) + +/** + * Parses provided string according to the content type + * @param rawData Data to be parsed + * @param contentType Content type of the data + * @param rawContentType Optional parameter required for multipart/form-data + * @returns Option of parsed body as string or Record object for multipart/form-data + */ +export function parseBody( + rawData: string, + contentType: HoppRESTReqBody["contentType"], + rawContentType?: string +): O.Option> { + switch (contentType) { + case "application/hal+json": + case "application/ld+json": + case "application/vnd.api+json": + case "application/json": + return O.some(getFormattedJSON(rawData)) + + case "application/x-www-form-urlencoded": + return getXWWWFormUrlEncodedBody(rawData) + + case "multipart/form-data": + return getFormDataBody(rawData, rawContentType) + + case "text/html": + return getHTMLBody(rawData) + + case "application/xml": + return getXMLBody(rawData) + + case "text/plain": + default: + return O.some(rawData) + } +} + +/** + * Formatter Functions + */ + +/** + * Prettifies XML string using xml-formatter + * @param sourceXml The string to format + * @returns Indented XML string (uses spaces) + */ +function prettifyXml(sourceXml: string) { + return pipe( + O.tryCatch(() => { + return xmlFormat(sourceXml, { + indentation: " ", + collapseContent: true, + lineSeparator: "\n", + }) + }) + ) +} + +/** + * Prettifies HTML string + * @param htmlString The string to format + * @returns Indented HTML string (uses spaces) + */ +function formatHTML(htmlString: string) { + const tab = " " + let result = "" + let indent = "" + const emptyTags = [ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", + ] + + const spl = htmlString.split(/>\s* { + if (element.match(/^\/\w/)) { + indent = indent.substring(tab.length) + } + + result += indent + "<" + element + ">\n" + + if ( + element.match(/^]*[^/]$/) && + !emptyTags.includes(element.match(/^([a-z]*)/i)?.at(1) || "") + ) { + indent += tab + } + }) + + return result.substring(1, result.length - 2) +} diff --git a/packages/hoppscotch-common/src/helpers/curl/sub_helpers/cookies.ts b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/cookies.ts new file mode 100644 index 0000000..a51ab2e --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/cookies.ts @@ -0,0 +1,64 @@ +import { pipe } from "fp-ts/function" +import * as O from "fp-ts/Option" +import { + objHasArrayProperty, + objHasProperty, +} from "~/helpers/functional/object" + +/** + * Parses cookies from curl arguments + * Handles both -b flag and --cookie parameter + */ +export const getCookies = (parsedArguments: any): Record => { + // Handle -b or --cookie flags + return pipe( + parsedArguments, + O.fromPredicate(objHasArrayProperty("b", "string")), + O.map((args) => parseCookieStrings(args.b)), + O.altW(() => + pipe( + parsedArguments, + O.fromPredicate(objHasProperty("b", "string")), + O.map((args) => parseCookieString(args.b)) + ) + ), + O.altW(() => + pipe( + parsedArguments, + O.fromPredicate(objHasArrayProperty("cookie", "string")), + O.map((args) => parseCookieStrings(args.cookie)) + ) + ), + O.altW(() => + pipe( + parsedArguments, + O.fromPredicate(objHasProperty("cookie", "string")), + O.map((args) => parseCookieString(args.cookie)) + ) + ), + O.getOrElseW(() => ({})) + ) +} + +/** + * Parse multiple cookie strings and combine them + */ +const parseCookieStrings = (cookies: string[]): Record => { + return cookies.reduce((acc, cookie) => { + return { ...acc, ...parseCookieString(cookie) } + }, {}) +} + +/** + * Parse a single cookie string into a record + */ +const parseCookieString = (cookieString: string): Record => { + return cookieString + .split(";") + .map((pair) => pair.trim()) + .filter(Boolean) + .reduce((acc, pair) => { + const [key, value] = pair.split("=", 2) + return { ...acc, [key]: value || "" } + }, {}) +} diff --git a/packages/hoppscotch-common/src/helpers/curl/sub_helpers/headers.ts b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/headers.ts new file mode 100644 index 0000000..99cb521 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/headers.ts @@ -0,0 +1,75 @@ +import { HoppRESTHeader } from "@hoppscotch/data" +import * as A from "fp-ts/Array" +import { flow, pipe } from "fp-ts/function" +import * as O from "fp-ts/Option" +import * as S from "fp-ts/string" +import parser from "yargs-parser" +import { + objHasArrayProperty, + objHasProperty, +} from "~/helpers/functional/object" +import { tupleToRecord } from "~/helpers/functional/record" + +const getHeaderPair = flow( + S.replace(":", ": "), + S.split(": "), + // must have a key and a value + O.fromPredicate((arr) => arr.length === 2), + O.map(([k, v]) => [k.trim(), v?.trim() ?? ""] as [string, string]) +) + +export function getHeaders(parsedArguments: parser.Arguments) { + let headers: Record = {} + + headers = pipe( + parsedArguments, + // make it an array if not already + O.fromPredicate(objHasProperty("H", "string")), + O.map((args) => [args.H]), + O.alt(() => + pipe( + parsedArguments, + O.fromPredicate(objHasArrayProperty("H", "string")), + O.map((args) => args.H) + ) + ), + O.map( + flow( + A.map(getHeaderPair), + A.filterMap((a) => a), + tupleToRecord + ) + ), + O.getOrElseW(() => ({})) + ) + + if ( + objHasProperty("A", "string")(parsedArguments) || + objHasProperty("user-agent", "string")(parsedArguments) + ) + headers["User-Agent"] = parsedArguments.A ?? parsedArguments["user-agent"] + + const rawContentType = + headers["Content-Type"] ?? headers["content-type"] ?? "" + + return { + headers, + rawContentType, + } +} + +export const recordToHoppHeaders = ( + headers: Record +): HoppRESTHeader[] => + pipe( + Object.keys(headers), + A.map((key) => ({ + key, + value: headers[key], + active: true, + description: "", + })), + A.filter( + (header) => header.key !== "content-type" && header.key !== "Content-Type" + ) + ) diff --git a/packages/hoppscotch-common/src/helpers/curl/sub_helpers/method.ts b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/method.ts new file mode 100644 index 0000000..11b6cee --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/method.ts @@ -0,0 +1,68 @@ +import parser from "yargs-parser" +import { pipe } from "fp-ts/function" +import * as O from "fp-ts/Option" +import * as R from "fp-ts/Refinement" +import { getDefaultRESTRequest } from "~/helpers/rest/default" +import { + objHasProperty, + objHasArrayProperty, +} from "~/helpers/functional/object" + +const defaultRESTReq = getDefaultRESTRequest() + +const getMethodFromXArg = (parsedArguments: parser.Arguments) => + pipe( + parsedArguments, + O.fromPredicate(objHasProperty("X", "string")), + O.map((args) => args.X.trim()), + O.chain((xarg) => + pipe( + O.fromNullable( + xarg.match(/GET|POST|PUT|PATCH|DELETE|HEAD|CONNECT|OPTIONS|TRACE/i) + ), + O.alt(() => O.fromNullable(xarg.match(/[a-zA-Z]+/))) + ) + ), + O.map((method) => method[0]) + ) + +const getMethodByDeduction = (parsedArguments: parser.Arguments) => { + if ( + pipe( + objHasProperty("T", "string"), + R.or(objHasProperty("upload-file", "string")) + )(parsedArguments) + ) + return O.some("put") + else if ( + pipe( + objHasProperty("I", "boolean"), + R.or(objHasProperty("head", "boolean")) + )(parsedArguments) + ) + return O.some("head") + else if (objHasProperty("G", "boolean")(parsedArguments)) return O.some("get") + else if ( + pipe( + objHasProperty("d", "string"), + R.or(objHasArrayProperty("d", "string")), + R.or(objHasProperty("F", "string")), + R.or(objHasArrayProperty("F", "string")) + )(parsedArguments) + ) + return O.some("POST") + return O.none +} + +/** + * Get method type from X argument in curl string or + * find it out through other arguments + * @param parsedArguments Parsed Arguments object + * @returns Method string + */ +export const getMethod = (parsedArguments: parser.Arguments): string => + pipe( + getMethodFromXArg(parsedArguments), + O.alt(() => getMethodByDeduction(parsedArguments)), + O.getOrElse(() => defaultRESTReq.method) + ) diff --git a/packages/hoppscotch-common/src/helpers/curl/sub_helpers/preproc.ts b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/preproc.ts new file mode 100644 index 0000000..bed95e1 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/preproc.ts @@ -0,0 +1,300 @@ +import { pipe, flow } from "fp-ts/function" +import * as S from "fp-ts/string" +import * as O from "fp-ts/Option" +import * as A from "fp-ts/Array" + +const replaceables: { [key: string]: string } = { + "--request": "-X", + "--header": "-H", + "--url": "", + "--form": "-F", + "--data-raw": "--data", + "--data": "-d", + "--data-ascii": "-d", + "--data-binary": "-d", + "--user": "-u", + "--get": "-G", +} + +const paperCuts = flow( + // remove '\' and newlines + S.replace(/ ?\\ ?$/gm, " "), + S.replace(/\n/g, " "), + // remove all $ symbols from start of argument values + S.replace(/\$'/g, "'"), + S.replace(/\$"/g, '"'), + S.trim +) + +// replace --zargs option with -Z +const replaceLongOptions = (curlCmd: string) => + pipe(Object.keys(replaceables), A.reduce(curlCmd, replaceFunction)) + +const replaceFunction = (curlCmd: string, r: string) => + pipe( + curlCmd, + O.fromPredicate( + () => r.includes("data") || r.includes("form") || r.includes("header") + ), + O.map(S.replace(RegExp(`[ \t]${r}(["' ])`, "g"), ` ${replaceables[r]}$1`)), + O.alt(() => + pipe( + curlCmd, + S.replace(RegExp(`[ \t]${r}(["' ])`), ` ${replaceables[r]}$1`), + O.of + ) + ), + O.getOrElse(() => "") + ) + +// yargs parses -XPOST as separate arguments. just prescreen for it. +const prescreenXArgs = flow( + S.replace( + / -X(GET|POST|PUT|PATCH|DELETE|HEAD|CONNECT|OPTIONS|TRACE)/, + " -X $1" + ), + S.trim +) + +/** + * Sanitizes and makes curl string processable + * @param curlCommand Raw curl command string + * @returns Processed curl command string + */ +export const preProcessCurlCommand = (curlCommand: string) => + pipe( + curlCommand, + O.fromPredicate((curlCmd) => curlCmd.length > 0), + O.map(flow(paperCuts, replaceLongOptions, prescreenXArgs)), + O.getOrElse(() => "") + ) + +const JSON_DATA_PLACEHOLDER_PREFIX = "__HOPP_CURL_JSON_DATA_" +const JSON_DATA_PLACEHOLDER_SUFFIX = "__" + +const isWhitespace = (ch: string | undefined) => + ch === " " || ch === "\t" || ch === "\n" || ch === "\r" + +const scanJSONValueEnd = (input: string, startIndex: number) => { + const start = input[startIndex] + if (start !== "{" && start !== "[") return null + + let depth = 0 + let inString = false + let escaped = false + + for (let i = startIndex; i < input.length; i++) { + const ch = input[i] + + if (inString) { + if (escaped) { + escaped = false + continue + } + + if (ch === "\\") { + escaped = true + continue + } + + if (ch === '"') { + inString = false + } + + continue + } + + if (ch === '"') { + inString = true + continue + } + + if (ch === "{" || ch === "[") { + depth++ + continue + } + + if (ch === "}" || ch === "]") { + depth-- + if (depth === 0) return i + } + } + + return null +} + +const getJSONDataPlaceholder = (index: number) => + `${JSON_DATA_PLACEHOLDER_PREFIX}${index}${JSON_DATA_PLACEHOLDER_SUFFIX}` + +const isJSONDataPlaceholder = (value: unknown): value is string => + typeof value === "string" && + value.startsWith(JSON_DATA_PLACEHOLDER_PREFIX) && + value.endsWith(JSON_DATA_PLACEHOLDER_SUFFIX) + +const placeholderIndex = (value: string) => { + const match = value.match( + new RegExp( + `^${JSON_DATA_PLACEHOLDER_PREFIX}(\\d+)${JSON_DATA_PLACEHOLDER_SUFFIX}$` + ) + ) + return match ? Number(match[1]) : null +} + +/** + * Some exported curl commands wrap the request body in single quotes but + * contain unescaped single quotes (e.g. `'''text'''`), which breaks the + * argument tokenizer. Replace JSON bodies passed via `-d`/`--data*` with + * placeholders before yargs-parser sees them, then restore them after. + */ +export const replaceJSONDataArgsWithPlaceholders = (curlCommand: string) => { + const dataFlags = [ + "--data-raw", + "--data-binary", + "--data-ascii", + "--data", + "-d", + ] + const extractedJSONData: string[] = [] + + let output = "" + let i = 0 + let shellQuote: '"' | "'" | null = null + + while (i < curlCommand.length) { + const ch = curlCommand[i] + + // Inside a top-level shell-quoted argument, copy verbatim and watch + // for the close. Skip flag detection so an embedded `-d`/`--data` + // inside e.g. a header value doesn't get intercepted as a data flag. + if (shellQuote !== null) { + if ( + ch === shellQuote && + (shellQuote === "'" || curlCommand[i - 1] !== "\\") + ) { + shellQuote = null + } + output += ch + i++ + continue + } + + const isBoundaryBefore = i === 0 || isWhitespace(curlCommand[i - 1]) + if (!isBoundaryBefore) { + if (ch === '"' || ch === "'") shellQuote = ch + output += ch + i++ + continue + } + + const flag = dataFlags.find((f) => curlCommand.startsWith(f, i)) + + if (!flag) { + if (ch === '"' || ch === "'") shellQuote = ch + output += ch + i++ + continue + } + + const afterFlag = curlCommand[i + flag.length] + const isBoundaryAfter = + afterFlag === undefined || + isWhitespace(afterFlag) || + afterFlag === "=" || + afterFlag === "'" || + afterFlag === '"' + + if (!isBoundaryAfter) { + output += curlCommand[i] + i++ + continue + } + + // Try to parse the value part (supports `-d VALUE`, `-d'VALUE'`, `--data=VALUE`, etc.) + let j = i + flag.length + + if (curlCommand[j] === "=") j++ + while (isWhitespace(curlCommand[j])) j++ + + const maybeQuote = curlCommand[j] + const quoted = maybeQuote === "'" || maybeQuote === '"' + const quoteChar = quoted ? maybeQuote : null + if (quoted) j++ + while (isWhitespace(curlCommand[j])) j++ + + const valueStart = j + const endIndex = scanJSONValueEnd(curlCommand, valueStart) + + if (endIndex === null) { + output += curlCommand[i] + i++ + continue + } + + const jsonBody = curlCommand.slice(valueStart, endIndex + 1) + const placeholder = getJSONDataPlaceholder(extractedJSONData.length) + extractedJSONData.push(jsonBody) + + if (output.length > 0 && !isWhitespace(output[output.length - 1])) + output += " " + + output += `-d ${placeholder}` + + let nextIndex = endIndex + 1 + if (quoteChar && curlCommand[nextIndex] === quoteChar) nextIndex++ + + if ( + nextIndex < curlCommand.length && + !isWhitespace(curlCommand[nextIndex]) + ) { + output += " " + } + + i = nextIndex + } + + return { + curlCommand: output.trim(), + extractedJSONData, + } +} + +const DATA_ARG_KEYS = ["d", "data"] as const + +const restorePlaceholder = ( + value: unknown, + extractedJSONData: string[] +): unknown => { + if (typeof value === "string") { + if (!isJSONDataPlaceholder(value)) return value + const idx = placeholderIndex(value) + if (idx === null) return value + return extractedJSONData[idx] ?? value + } + + if (Array.isArray(value)) { + return value.map((v) => restorePlaceholder(v, extractedJSONData)) + } + + return value +} + +export const restoreJSONDataArgsFromPlaceholders = ( + parsedArguments: T, + extractedJSONData: string[] +): T => { + if (extractedJSONData.length === 0) return parsedArguments + if (!parsedArguments || typeof parsedArguments !== "object") { + return parsedArguments + } + + const args = parsedArguments as Record + const restored: Record = { ...args } + + for (const key of DATA_ARG_KEYS) { + if (key in restored) { + restored[key] = restorePlaceholder(restored[key], extractedJSONData) + } + } + + return restored as T +} diff --git a/packages/hoppscotch-common/src/helpers/curl/sub_helpers/queries.ts b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/queries.ts new file mode 100644 index 0000000..86eaf04 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/queries.ts @@ -0,0 +1,44 @@ +import { HoppRESTParam } from "@hoppscotch/data" +import * as A from "fp-ts/Array" +import { flow, pipe } from "fp-ts/function" +import * as O from "fp-ts/Option" +import * as Sep from "fp-ts/Separated" + +const isDangling = ([, value]: [string, string]) => !value + +/** + * Converts queries to HoppRESTParam format and separates dangling ones + * @param params Array of key value pairs of queries + * @returns Object containing separated queries and dangling queries + */ +export function getQueries(params: Array<[string, string]>): { + queries: Array + danglingParams: Array +} { + return pipe( + params, + O.of, + O.map( + flow( + A.partition(isDangling), + Sep.bimap( + A.map(([key, value]) => ({ + key, + value, + active: true, + description: "", + })), + A.map(([key]) => key) + ), + (sep) => ({ + queries: sep.left, + danglingParams: sep.right, + }) + ) + ), + O.getOrElseW(() => ({ + queries: [], + danglingParams: [], + })) + ) +} diff --git a/packages/hoppscotch-common/src/helpers/curl/sub_helpers/url.ts b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/url.ts new file mode 100644 index 0000000..2ff68f7 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/url.ts @@ -0,0 +1,119 @@ +import parser from "yargs-parser" +import { pipe } from "fp-ts/function" +import * as O from "fp-ts/Option" +import * as A from "fp-ts/Array" +import { getDefaultRESTRequest } from "~/helpers/rest/default" +import { stringArrayJoin } from "~/helpers/functional/array" + +const defaultRESTReq = getDefaultRESTRequest() + +const getProtocolFromURL = (url: string) => + pipe( + // get the base URL + /^([^\s:@]+:[^\s:@]+@)?([^:/\s]+)([:]*)/.exec(url), + O.fromNullable, + O.filter((burl) => burl.length > 1), + O.map((burl) => burl[2]), + // set protocol to http for local URLs + O.map((burl) => + burl === "localhost" || + burl === "2130706433" || + /127(\.0){0,2}\.1/.test(burl) || + /0177(\.0){0,2}\.1/.test(burl) || + /0x7f(\.0){0,2}\.1/.test(burl) || + /192\.168(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){2}/.test(burl) || + /10(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/.test(burl) + ? "http://" + url + : "https://" + url + ) + ) + +/** + * Checks if the URL is valid using the URL constructor + * @param urlString URL string (with protocol) + * @returns boolean whether the URL is valid using the inbuilt URL class + */ +const isURLValid = (urlString: string) => + pipe( + O.tryCatch(() => new URL(urlString)), + O.isSome + ) + +/** + * Checks and returns URL object for the valid URL + * @param urlText Raw URL string provided by argument parser + * @returns Option of URL object + */ +const parseURL = (urlText: string | number) => + pipe( + urlText, + O.fromNullable, + // preprocess url string + O.map((u) => + u + .toString() + .replace(/^'|'$/g, "") + .replaceAll(/[^a-zA-Z0-9_\-./?&=:@%+#,;()'<>\s]/g, "") + ), + O.filter((u) => u.length > 0), + O.chain((u) => + pipe( + u, + // check if protocol is available + O.fromPredicate( + (url: string) => /^[^:\s]+(?=:\/\/)/.exec(url) !== null + ), + O.alt(() => getProtocolFromURL(u)) + ) + ), + O.filter(isURLValid), + O.map((u) => new URL(u)) + ) + +/** + * Processes URL string and returns the URL object + * @param parsedArguments Parsed Arguments object + * @returns URL object + */ +export function getURLObject(parsedArguments: parser.Arguments) { + const location = parsedArguments.location ?? undefined + + return pipe( + // contains raw url strings + [...parsedArguments._.slice(1), location], + A.findFirstMap(parseURL), + // no url found + O.getOrElse(() => new URL(defaultRESTReq.endpoint)) + ) +} + +/** + * Joins dangling params to origin + * @param urlObject URL object containing origin and pathname + * @param danglingParams Keys of params with empty values + * @returns origin string concatenated with dangling paramas + */ +export function concatParams(urlObject: URL, danglingParams: string[]) { + return pipe( + O.Do, + + O.bind("originString", () => + pipe( + urlObject.origin, + O.fromPredicate((h) => h !== "") + ) + ), + + O.map(({ originString }) => + pipe( + danglingParams, + O.fromPredicate((dp) => dp.length > 0), + O.map(stringArrayJoin("&")), + O.map((h) => originString + (urlObject.pathname || "") + "?" + h), + O.getOrElse(() => originString + (urlObject.pathname || "")) + ) + ), + + O.getOrElse(() => defaultRESTReq.endpoint) + ) +} diff --git a/packages/hoppscotch-common/src/helpers/dev.ts b/packages/hoppscotch-common/src/helpers/dev.ts new file mode 100644 index 0000000..72b22c9 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/dev.ts @@ -0,0 +1,4 @@ +/** + * A constant specifying whether the app is running in the development server + */ +export const APP_IS_IN_DEV_MODE = import.meta.env.DEV diff --git a/packages/hoppscotch-common/src/helpers/dragDropValidation.ts b/packages/hoppscotch-common/src/helpers/dragDropValidation.ts new file mode 100644 index 0000000..1a32ab9 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/dragDropValidation.ts @@ -0,0 +1,38 @@ +export type DragDropEvent = { + draggedContext?: { + futureIndex?: number + } +} + +/** + * Validates if a drag-and-drop operation should be allowed. + * + * Prevents items from being dropped at the last position, which is reserved + * for the "add new item" empty entry in lists like headers and parameters. + * + * @param dragEvent - The drag event containing position information + * @param totalItems - The current length of the items array + * @returns true if the drop is allowed, false otherwise + */ +export function isDragDropAllowed( + dragEvent: DragDropEvent, + totalItems: number +): boolean { + const targetIndex = dragEvent?.draggedContext?.futureIndex + + // Validate input parameters + if ( + typeof targetIndex !== "number" || + isNaN(targetIndex) || + isNaN(totalItems) + ) { + return false + } + + // Prevent dropping at the last position (reserved for empty entry) + if (targetIndex >= totalItems - 1) { + return false + } + + return true +} diff --git a/packages/hoppscotch-common/src/helpers/editor/completion/gqlQuery.ts b/packages/hoppscotch-common/src/helpers/editor/completion/gqlQuery.ts new file mode 100644 index 0000000..1aec2e8 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/completion/gqlQuery.ts @@ -0,0 +1,27 @@ +import { Ref } from "vue" +import { GraphQLSchema } from "graphql" +import { getAutocompleteSuggestions } from "graphql-language-service-interface" +import { Completer, CompleterResult, CompletionEntry } from "." + +const completer: (schemaRef: Ref) => Completer = + (schemaRef: Ref) => (text, completePos) => { + if (!schemaRef.value) return Promise.resolve(null) + + const completions = getAutocompleteSuggestions(schemaRef.value, text, { + line: completePos.line, + character: completePos.ch, + } as any) + + return Promise.resolve({ + completions: completions.map( + (x, i) => + { + text: x.label!, + meta: x.detail!, + score: completions.length - i, + } + ), + }) + } + +export default completer diff --git a/packages/hoppscotch-common/src/helpers/editor/completion/index.ts b/packages/hoppscotch-common/src/helpers/editor/completion/index.ts new file mode 100644 index 0000000..bc78e61 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/completion/index.ts @@ -0,0 +1,23 @@ +export type CompletionEntry = { + text: string + meta: string + score: number +} + +export type CompleterResult = { + /** + * List of completions to display + */ + completions: CompletionEntry[] +} + +export type Completer = ( + /** + * The contents of the editor + */ + text: string, + /** + * Position where the completer is fired + */ + completePos: { line: number; ch: number } +) => Promise diff --git a/packages/hoppscotch-common/src/helpers/editor/completion/preRequest.ts b/packages/hoppscotch-common/src/helpers/editor/completion/preRequest.ts new file mode 100644 index 0000000..cc72315 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/completion/preRequest.ts @@ -0,0 +1,24 @@ +import { Completer, CompletionEntry } from "." +import { getPreRequestScriptCompletions } from "~/helpers/tern" + +const completer: Completer = async (text, completePos) => { + const results = await getPreRequestScriptCompletions( + text, + completePos.line, + completePos.ch + ) + + const completions = results.completions.map((completion: any, i: number) => { + return { + text: completion.name, + meta: completion.isKeyword ? "keyword" : completion.type, + score: results.completions.length - i, + } + }) + + return { + completions, + } +} + +export default completer diff --git a/packages/hoppscotch-common/src/helpers/editor/completion/testScript.ts b/packages/hoppscotch-common/src/helpers/editor/completion/testScript.ts new file mode 100644 index 0000000..88286ac --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/completion/testScript.ts @@ -0,0 +1,24 @@ +import { Completer, CompletionEntry } from "." +import { getTestScriptCompletions } from "~/helpers/tern" + +export const completer: Completer = async (text, completePos) => { + const results = await getTestScriptCompletions( + text, + completePos.line, + completePos.ch + ) + + const completions = results.completions.map((completion: any, i: number) => { + return { + text: completion.name, + meta: completion.isKeyword ? "keyword" : completion.type, + score: results.completions.length - i, + } + }) + + return { + completions, + } +} + +export default completer diff --git a/packages/hoppscotch-common/src/helpers/editor/extensions/HoppEnvironment.ts b/packages/hoppscotch-common/src/helpers/editor/extensions/HoppEnvironment.ts new file mode 100644 index 0000000..ffc04c7 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/extensions/HoppEnvironment.ts @@ -0,0 +1,483 @@ +import { Compartment } from "@codemirror/state" +import { + Decoration, + EditorView, + MatchDecorator, + ViewPlugin, + hoverTooltip, +} from "@codemirror/view" +import { StreamSubscriberFunc } from "@composables/stream" +import { parseTemplateStringE } from "@hoppscotch/data" +import * as E from "fp-ts/Either" +import { Ref, watch } from "vue" + +import { invokeAction } from "~/helpers/actions" +import { getService } from "~/modules/dioc" +import { + AggregateEnvironment, + aggregateEnvsWithCurrentValue$, + getAggregateEnvsWithCurrentValue, + getCurrentEnvironment, + getSelectedEnvironmentType, +} from "~/newstore/environments" +import { SecretEnvironmentService } from "~/services/secret-environment.service" +import { RESTTabService } from "~/services/tab/rest" +import { CurrentValueService } from "~/services/current-environment-value.service" + +import IconEdit from "~icons/lucide/edit?raw" +import IconUser from "~icons/lucide/user?raw" +import IconUsers from "~icons/lucide/users?raw" +import IconGlobe from "~icons/lucide/globe?raw" +import IconVariable from "~icons/lucide/variable?raw" +import IconLibrary from "~icons/lucide/library?raw" + +import { isComment } from "./helpers" +import { getEffectiveVariablesForRequest } from "~/helpers/utils/environments" +import { + ENV_VAR_NAME_REGEX, + HOPP_ENVIRONMENT_REGEX, +} from "~/helpers/environment-regex" +import { + stabilizeTooltipHover, + constrainTooltipToViewport, + createTooltipValueRow, +} from "~/helpers/utils/tooltip" + +const HOPP_ENV_HIGHLIGHT = + "cursor-help transition rounded px-1 focus:outline-none mx-0.5 env-highlight" +const HOPP_REQUEST_VARIABLE_HIGHLIGHT = "request-variable-highlight" +const HOPP_COLLECTION_ENVIRONMENT_HIGHLIGHT = "collection-variable-highlight" +const HOPP_ENVIRONMENT_HIGHLIGHT = "environment-variable-highlight" +const HOPP_GLOBAL_ENVIRONMENT_HIGHLIGHT = "global-variable-highlight" +const HOPP_ENV_HIGHLIGHT_NOT_FOUND = "environment-not-found-highlight" +// Keep value rows above overlapping CodeMirror decoration layers inside tooltip content. +const TOOLTIP_ENV_CONTAINER_Z_INDEX_CLASS = "!z-[1002]" + +const secretEnvironmentService = getService(SecretEnvironmentService) +const currentEnvironmentValueService = getService(CurrentValueService) +const restTabs = getService(RESTTabService) + +/** + * Transforms the environment list to a list with unique keys with value + * @param envs The environment list to be transformed + * @returns The transformed environment list with keys with value + */ +const filterNonEmptyEnvironmentVariables = ( + envs: AggregateEnvironment[] +): AggregateEnvironment[] => { + const envsMap = new Map() + envs.forEach((env) => { + if (envsMap.has(env.key)) { + const existingEnv = envsMap.get(env.key) + if ( + existingEnv?.currentValue === "" && + existingEnv?.initialValue === "" && + (env.currentValue || env.initialValue) + ) { + envsMap.set(env.key, env) + } + } else { + envsMap.set(env.key, env) + } + }) + return Array.from(envsMap.values()) +} + +const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) => + hoverTooltip( + (view, pos, side) => { + // Check if the current position is inside a comment then disable the tooltip + if (isComment(view.state, pos)) return null + + const { from, to, text } = view.state.doc.lineAt(pos) + + // TODO: When Codemirror 6 allows this to work (not make the + // popups appear half of the time) use this implementation + // const wordSelection = view.state.wordAt(pos) + // if (!wordSelection) return null + // const word = view.state.doc.sliceString( + // wordSelection.from - 2, + // wordSelection.to + 2 + // ) + // if (!HOPP_ENVIRONMENT_REGEX.test(word)) return null + + // Tracking the start and the end of the words + let start = pos + let end = pos + while (start > from && ENV_VAR_NAME_REGEX.test(text[start - from - 1])) + start-- + while (end < to && ENV_VAR_NAME_REGEX.test(text[end - from])) end++ + + if ( + (start === pos && side < 0) || + (end === pos && side > 0) || + !HOPP_ENVIRONMENT_REGEX.test( + text.slice(start - from - 2, end - from + 2) + ) + ) + return null + + const parsedEnvKey = text.slice(start - from, end - from) + const envsWithNoEmptyValues = + filterNonEmptyEnvironmentVariables(aggregateEnvs) + const tooltipEnv = envsWithNoEmptyValues.find( + (env) => env.key === parsedEnvKey + ) + const currentSelectedEnvironment = getCurrentEnvironment() + const envName = tooltipEnv?.sourceEnv ?? "Choose an Environment" + + let envInitialValue = tooltipEnv?.initialValue + + // If the environment is not a request variable or collection variable, get the current value from the current environment service + // For collection variables and request variables, use the value directly from tooltipEnv + let envCurrentValue = + tooltipEnv?.sourceEnv !== "RequestVariable" && + tooltipEnv?.sourceEnv !== "CollectionVariable" + ? currentEnvironmentValueService.getEnvironmentByKey( + tooltipEnv?.sourceEnv !== "Global" + ? currentSelectedEnvironment.id + : "Global", + tooltipEnv?.key ?? "" + )?.currentValue || tooltipEnv?.currentValue + : tooltipEnv?.currentValue + + const isSecret = tooltipEnv?.secret === true + const hasSource = Boolean(tooltipEnv?.sourceEnv) + + const tooltipSourceEnvID = + tooltipEnv?.sourceEnv === "Global" + ? "Global" + : tooltipEnv?.sourceEnv === "CollectionVariable" + ? tooltipEnv.sourceEnvID! + : currentSelectedEnvironment.id + + const hasSecretValueStored = secretEnvironmentService.hasSecretValue( + tooltipSourceEnvID, + tooltipEnv?.key ?? "" + ) + const hasSecretInitialValueStored = + secretEnvironmentService.hasSecretInitialValue( + tooltipSourceEnvID, + tooltipEnv?.key ?? "" + ) + + // Display secret values as "******" when stored; if no secret is saved, show "Empty" placeholders instead + if (isSecret) { + if (hasSecretValueStored && hasSecretInitialValueStored) { + envInitialValue = "******" + envCurrentValue = "******" + } else if (!hasSecretValueStored && hasSecretInitialValueStored) { + envInitialValue = "******" + } else if (hasSecretValueStored && !hasSecretInitialValueStored) { + envCurrentValue = "******" + } else { + envInitialValue = "Empty" + envCurrentValue = "Empty" + } + } else if (!hasSource) { + envInitialValue = "Not Found" + envCurrentValue = "Not Found" + } else { + // Parse templates only if needed and values are not already masked + if (!envCurrentValue && envInitialValue) { + const parsedInitial = parseTemplateStringE( + envInitialValue, + aggregateEnvs + ) + envInitialValue = E.isLeft(parsedInitial) + ? "error" + : parsedInitial.right + } else if (!envInitialValue && envCurrentValue) { + const parsedCurrent = parseTemplateStringE( + envCurrentValue, + aggregateEnvs + ) + envCurrentValue = E.isLeft(parsedCurrent) + ? "error" + : parsedCurrent.right + } + } + + const selectedEnvType = getSelectedEnvironmentType() + + // Set the icon based on the source environment + const envTypeIcon = `${ + tooltipEnv?.sourceEnv === "Global" + ? IconGlobe + : tooltipEnv?.sourceEnv === "RequestVariable" + ? IconVariable + : selectedEnvType === "TEAM_ENV" + ? IconUsers + : tooltipEnv?.sourceEnv === "CollectionVariable" + ? IconLibrary + : IconUser + }` + + const appendEditAction = (tooltip: HTMLElement) => { + const editIcon = document.createElement("button") + editIcon.className = + "ml-2 cursor-pointer text-accent hover:text-accentDark" + editIcon.addEventListener("click", () => { + let invokeActionType: + | "modals.my.environment.edit" + | "modals.team.environment.edit" + | "modals.global.environment.update" = "modals.my.environment.edit" + + if (tooltipEnv?.sourceEnv === "Global") + invokeActionType = "modals.global.environment.update" + else if (selectedEnvType === "MY_ENV") + invokeActionType = "modals.my.environment.edit" + else if (selectedEnvType === "TEAM_ENV") + invokeActionType = "modals.team.environment.edit" + else { + invokeActionType = "modals.my.environment.edit" + } + + if ( + tooltipEnv?.sourceEnv === "RequestVariable" && + restTabs.currentActiveTab.value.document.type === "request" + ) { + restTabs.currentActiveTab.value.document.optionTabPreference = + "requestVariables" + } else { + invokeAction(invokeActionType, { + envName: tooltipEnv?.sourceEnv === "Global" ? "Global" : envName, + variableName: parsedEnvKey, + isSecret: tooltipEnv?.secret, + }) + } + }) + editIcon.innerHTML = `${IconEdit}` + if (tooltipEnv?.sourceEnv !== "CollectionVariable") + tooltip.appendChild(editIcon) + } + + return { + // The start and end positions of the environment variable in the text + // We add 2 to the end position to include the closing `>>` in the tooltip + // and -1 to the start position to include the opening `<<` in the tooltip + pos: start - 1, + end: end + 2, + arrow: true, + create() { + const dom = document.createElement("div") + const tooltipContainer = document.createElement("div") + + const tooltipHeaderBlock = document.createElement("div") + tooltipHeaderBlock.className = + "flex items-center justify-between w-full space-x-2 " + tooltipContainer.appendChild(tooltipHeaderBlock) + + const iconNameContainer = document.createElement("div") + iconNameContainer.className = + "flex items-center space-x-2 flex-1 mr-4 " + tooltipHeaderBlock.appendChild(iconNameContainer) + + const icon = document.createElement("span") + icon.innerHTML = envTypeIcon + const envNameBlock = document.createElement("span") + envNameBlock.innerText = envName + + iconNameContainer.appendChild(icon) + iconNameContainer.appendChild(envNameBlock) + + if (tooltipEnv) appendEditAction(tooltipHeaderBlock) + + const envContainer = document.createElement("div") + tooltipContainer.appendChild(envContainer) + envContainer.className = `flex flex-col items-start space-y-1 flex-1 w-full mt-2 ${TOOLTIP_ENV_CONTAINER_Z_INDEX_CLASS}` + envContainer.style.overflow = "hidden" + + // Use createTooltipValueRow for overflow-safe value display + const initialValueRow = createTooltipValueRow( + "Initial", + envInitialValue + ) + const currentValueRow = createTooltipValueRow( + "Current", + envCurrentValue + ) + + envContainer.appendChild(initialValueRow) + envContainer.appendChild(currentValueRow) + + tooltipContainer.className = + "tippy-content env-tooltip-content env-tooltip-constrained" + dom.className = "tippy-box" + dom.dataset.theme = "tooltip" + dom.appendChild(tooltipContainer) + + // Apply viewport-aware overflow constraints to the tooltip + constrainTooltipToViewport(dom, tooltipContainer) + + // Apply an interactive bridge to stabilize hover transitions + stabilizeTooltipHover(dom) + + return { dom } + }, + } + }, + // HACK: This is a hack to fix hover tooltip not coming half of the time + // https://github.com/codemirror/tooltip/blob/765c463fc1d5afcc3ec93cee47d72606bed27e1d/src/tooltip.ts#L622 + // Still doesn't fix the not showing up some of the time issue, but this is atleast more consistent + { hoverTime: 1 } as any + ) + +function checkEnv(env: string, aggregateEnvs: AggregateEnvironment[]) { + let className = HOPP_ENV_HIGHLIGHT_NOT_FOUND + const envSource = aggregateEnvs.find( + (k: { key: string }) => k.key === env.slice(2, -2) + )?.sourceEnv + + if (envSource === "RequestVariable") + className = HOPP_REQUEST_VARIABLE_HIGHLIGHT + else if (envSource === "CollectionVariable") + className = HOPP_COLLECTION_ENVIRONMENT_HIGHLIGHT + else if (envSource === "Global") className = HOPP_GLOBAL_ENVIRONMENT_HIGHLIGHT + else if (envSource !== undefined) className = HOPP_ENVIRONMENT_HIGHLIGHT + + return Decoration.mark({ class: `${HOPP_ENV_HIGHLIGHT} ${className}` }) +} + +const getMatchDecorator = (aggregateEnvs: AggregateEnvironment[]) => + new MatchDecorator({ + regexp: HOPP_ENVIRONMENT_REGEX, + decoration: (m, view, pos) => { + // Check if the current position is inside a comment then disable the highlight + if (isComment(view.state, pos)) return null + return checkEnv(m[0], aggregateEnvs) + }, + }) + +export const environmentHighlightStyle = ( + aggregateEnvs: AggregateEnvironment[] +) => { + const envsWithNoEmptyValues = + filterNonEmptyEnvironmentVariables(aggregateEnvs) + const decorator = getMatchDecorator(envsWithNoEmptyValues) + return ViewPlugin.define( + (view) => ({ + decorations: decorator.createDeco(view), + update(u) { + this.decorations = decorator.updateDeco(u, this.decorations) + }, + }), + { decorations: (v) => v.decorations } + ) +} + +export class HoppEnvironmentPlugin { + private compartment = new Compartment() + private envs: AggregateEnvironment[] = [] + + constructor( + subscribeToStream: StreamSubscriberFunc, + private editorView: Ref + ) { + // Watch the current active tab to update the variables accordingly + watch( + () => restTabs.currentActiveTab.value, + (currentTab) => { + const request = + currentTab.document.type === "example-response" + ? currentTab.document.response.originalRequest + : currentTab.document.request + + const inheritedProperties = currentTab.document.inheritedProperties + + // Extract collection variables safely, handling undefined or non-inherited-property types + const collectionVariables = + inheritedProperties && "variables" in inheritedProperties + ? inheritedProperties.variables + : [] + + // Get request variables if available, otherwise use empty array + const requestVariables = + request && "requestVariables" in request + ? request.requestVariables + : [] + + this.envs = getEffectiveVariablesForRequest( + requestVariables, + collectionVariables, + getAggregateEnvsWithCurrentValue(), + false + ) + + this.editorView.value?.dispatch({ + effects: this.compartment.reconfigure([ + cursorTooltipField(this.envs), + environmentHighlightStyle(this.envs), + ]), + }) + }, + { immediate: true, deep: true } + ) + + subscribeToStream(aggregateEnvsWithCurrentValue$, (envs) => { + // Recompute request and collection variables from current tab to avoid stale closure values + const tab = restTabs.currentActiveTab.value + const request = + tab.document.type === "example-response" + ? tab.document.response.originalRequest + : tab.document.request + const inheritedProperties = tab.document.inheritedProperties + + // Get request variables if available, otherwise use empty array + const requestVariables = + request && "requestVariables" in request ? request.requestVariables : [] + + this.envs = getEffectiveVariablesForRequest( + requestVariables, + inheritedProperties?.variables ?? [], + envs, + false + ) + + this.editorView.value?.dispatch({ + effects: this.compartment.reconfigure([ + cursorTooltipField(this.envs), + environmentHighlightStyle(this.envs), + ]), + }) + }) + } + + get extension() { + return this.compartment.of([ + cursorTooltipField(this.envs), + environmentHighlightStyle(this.envs), + ]) + } +} + +export class HoppReactiveEnvPlugin { + private compartment = new Compartment() + private envs: AggregateEnvironment[] = [] + + constructor( + envsRef: Ref, + private editorView: Ref + ) { + watch( + envsRef, + (envs) => { + this.envs = envs + this.editorView.value?.dispatch({ + effects: this.compartment.reconfigure([ + cursorTooltipField(this.envs), + environmentHighlightStyle(this.envs), + ]), + }) + }, + { immediate: true, deep: true } + ) + } + + get extension() { + return this.compartment.of([ + cursorTooltipField(this.envs), + environmentHighlightStyle(this.envs), + ]) + } +} diff --git a/packages/hoppscotch-common/src/helpers/editor/extensions/HoppPredefinedVariables.ts b/packages/hoppscotch-common/src/helpers/editor/extensions/HoppPredefinedVariables.ts new file mode 100644 index 0000000..4a20280 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/extensions/HoppPredefinedVariables.ts @@ -0,0 +1,202 @@ +import { Compartment } from "@codemirror/state" +import { + Decoration, + MatchDecorator, + ViewPlugin, + hoverTooltip, +} from "@codemirror/view" +import { HOPP_SUPPORTED_PREDEFINED_VARIABLES } from "@hoppscotch/data" + +import IconSquareAsterisk from "~icons/lucide/square-asterisk?raw" +import { isComment } from "./helpers" +import { + stabilizeTooltipHover, + constrainTooltipToViewport, + truncateText, +} from "~/helpers/utils/tooltip" + +const HOPP_PREDEFINED_VARIABLES_REGEX = /(<<\$[a-zA-Z0-9-_]+>>)/g + +const HOPP_PREDEFINED_VARIABLE_HIGHLIGHT = + "cursor-help transition rounded px-1 focus:outline-none mx-0.5 predefined-variable-highlight" +const HOPP_PREDEFINED_VARIABLE_HIGHLIGHT_VALID = "predefined-variable-valid" +const HOPP_PREDEFINED_VARIABLE_HIGHLIGHT_INVALID = "predefined-variable-invalid" + +const getMatchDecorator = () => { + return new MatchDecorator({ + regexp: HOPP_PREDEFINED_VARIABLES_REGEX, + decoration: (m, view, pos) => { + // Don't highlight if the cursor is inside a comment + if (isComment(view.state, pos)) { + return null + } + return checkPredefinedVariable(m[0]) + }, + }) +} + +const cursorTooltipField = () => + hoverTooltip( + (view, pos, side) => { + // Don't show tooltip if the cursor is inside a comment + if (isComment(view.state, pos)) { + return null + } + const { from, to, text } = view.state.doc.lineAt(pos) + + // TODO: When Codemirror 6 allows this to work (not make the + // popups appear half of the time) use this implementation + // const wordSelection = view.state.wordAt(pos) + // if (!wordSelection) return null + // const word = view.state.doc.sliceString( + // wordSelection.from - 3, + // wordSelection.to + 2 + // ) + // if (!HOPP_PREDEFINED_VARIABLES_REGEX.test(word)) return null + + // Tracking the start and the end of the words + let start = pos + let end = pos + + while (start > from && /[a-zA-Z0-9-_]+/.test(text[start - from - 1])) + start-- + while (end < to && /[a-zA-Z0-9-_]+/.test(text[end - from])) end++ + + if ( + (start === pos && side < 0) || + (end === pos && side > 0) || + !HOPP_PREDEFINED_VARIABLES_REGEX.test( + text.slice(start - from - 3, end - from + 2) + ) + ) { + return null + } + + const variableName = text.slice(start - from - 1, end - from) + + const variable = HOPP_SUPPORTED_PREDEFINED_VARIABLES.find( + (VARIABLE) => VARIABLE.key === variableName + ) + + const variableIcon = `${IconSquareAsterisk}` + const variableDescription = + variable !== undefined + ? `${variableName} - ${truncateText(variable.description)}` + : `${variableName} is not a valid predefined variable.` + + return { + // The start and end positions of the environment variable in the text + // We add 2 to the end position to include the closing `>>` in the tooltip + // and -1 to the start position to include the opening `<<` in the tooltip + pos: start - 1, + end: end + 2, + arrow: true, + create() { + const dom = document.createElement("div") + + const tooltipContainer = document.createElement("div") + + const tooltipHeaderBlock = document.createElement("div") + tooltipHeaderBlock.className = + "flex items-center justify-between w-full space-x-2 " + tooltipContainer.appendChild(tooltipHeaderBlock) + + const iconNameContainer = document.createElement("div") + iconNameContainer.className = + "flex items-center space-x-2 flex-1 mr-4 " + tooltipHeaderBlock.appendChild(iconNameContainer) + + const icon = document.createElement("span") + icon.innerHTML = variableIcon + + const envNameBlock = document.createElement("span") + envNameBlock.innerText = variableName + + iconNameContainer.appendChild(icon) + iconNameContainer.appendChild(envNameBlock) + + const envContainer = document.createElement("div") + tooltipContainer.appendChild(envContainer) + envContainer.className = + "flex flex-col items-start space-y-1 flex-1 w-full mt-2" + envContainer.style.overflow = "hidden" + + const valueBlock = document.createElement("div") + valueBlock.className = "flex items-start space-x-2" + valueBlock.style.width = "100%" + const valueTitle = document.createElement("div") + const value = document.createElement("span") + value.className = "env-tooltip-value" + value.textContent = variableDescription + valueTitle.textContent = "Value" + valueTitle.className = "font-bold mr-4" + valueTitle.style.flexShrink = "0" + valueBlock.appendChild(valueTitle) + valueBlock.appendChild(value) + + envContainer.appendChild(valueBlock) + + dom.className = "tippy-box" + dom.dataset.theme = "tooltip" + + tooltipContainer.className = + "tippy-content env-tooltip-content env-tooltip-constrained" + + dom.appendChild(tooltipContainer) + + // Apply viewport-aware overflow constraints + constrainTooltipToViewport(dom, tooltipContainer) + + // Apply an interactive bridge to stabilize hover transitions + stabilizeTooltipHover(dom) + + return { dom } + }, + } + }, + // HACK: This is a hack to fix hover tooltip not coming half of the time + // https://github.com/codemirror/tooltip/blob/765c463fc1d5afcc3ec93cee47d72606bed27e1d/src/tooltip.ts#L622 + // Still doesn't fix the not showing up some of the time issue, but this is atleast more consistent + { hoverTime: 1 } as any + ) + +const checkPredefinedVariable = (variable: string) => { + const inputVariableKey = variable.slice(2, -2) + + const className = HOPP_SUPPORTED_PREDEFINED_VARIABLES.find((v) => { + return v.key === inputVariableKey + }) + ? HOPP_PREDEFINED_VARIABLE_HIGHLIGHT_VALID + : HOPP_PREDEFINED_VARIABLE_HIGHLIGHT_INVALID + + return Decoration.mark({ + class: `${HOPP_PREDEFINED_VARIABLE_HIGHLIGHT} ${className}`, + }) +} + +export const predefinedVariableHighlightStyle = () => { + const decorator = getMatchDecorator() + + return ViewPlugin.define( + (view) => ({ + decorations: decorator.createDeco(view), + update(u) { + this.decorations = decorator.updateDeco(u, this.decorations) + }, + }), + { + decorations: (v) => v.decorations, + } + ) +} + +export class HoppPredefinedVariablesPlugin { + private compartment = new Compartment() + + get extension() { + return this.compartment.of([ + cursorTooltipField(), + predefinedVariableHighlightStyle(), + ]) + } +} diff --git a/packages/hoppscotch-common/src/helpers/editor/extensions/helpers.ts b/packages/hoppscotch-common/src/helpers/editor/extensions/helpers.ts new file mode 100644 index 0000000..df78967 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/extensions/helpers.ts @@ -0,0 +1,14 @@ +import { syntaxTree } from "@codemirror/language" +import { EditorState } from "@codemirror/state" + +/** + * Check if the cursor is inside a comment + * @param state Editor state + * @param pos Position of the cursor + * @return Boolean value indicating if the cursor is inside a comment + */ +export const isComment = (state: EditorState, pos: number) => { + const tree = syntaxTree(state) + const { name } = tree.resolveInner(pos) + return name.endsWith("Comment") || name.endsWith("comment") +} diff --git a/packages/hoppscotch-common/src/helpers/editor/gql/operation.ts b/packages/hoppscotch-common/src/helpers/editor/gql/operation.ts new file mode 100644 index 0000000..8098109 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/gql/operation.ts @@ -0,0 +1,58 @@ +import { EditorState, Range } from "@codemirror/state" +import { Decoration, ViewPlugin } from "@codemirror/view" +import { syntaxTree } from "@codemirror/language" + +function getOperationDefsPosInEditor(state: EditorState) { + const tree = syntaxTree(state) + + const defs: Array<{ from: number; to: number }> = [] + + tree.iterate({ + enter({ name, from, to }) { + if (name === "OperationDefinition") { + defs.push({ from, to }) + } + }, + }) + + return defs +} + +function generateSelectedOpDecors(state: EditorState) { + const selectedPos = state.selection.main.head // Cursor Pos + + const defsPositions = getOperationDefsPosInEditor(state) + + if (defsPositions.length === 1) return Decoration.none + + const decors = defsPositions + .map(({ from, to }) => ({ + selected: selectedPos >= from && selectedPos <= to, + from, + to, + })) + .map((info) => ({ + ...info, + decor: Decoration.mark({ + class: info.selected + ? "gql-operation-highlight" + : "gql-operation-not-highlight", + inclusive: true, + }), + })) + .map(({ from, to, decor }) => >{ from, to, value: decor }) // Convert to Range (Range from "@codemirror/view") + + return Decoration.set(decors) +} + +export const selectedGQLOpHighlight = ViewPlugin.define( + (view) => ({ + decorations: generateSelectedOpDecors(view.state), + update(u) { + this.decorations = generateSelectedOpDecors(u.state) + }, + }), + { + decorations: (v) => v.decorations, + } +) diff --git a/packages/hoppscotch-common/src/helpers/editor/linting/gqlQuery.ts b/packages/hoppscotch-common/src/helpers/editor/linting/gqlQuery.ts new file mode 100644 index 0000000..146748e --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/linting/gqlQuery.ts @@ -0,0 +1,58 @@ +import { Ref } from "vue" +import { + GraphQLError, + GraphQLSchema, + parse as gqlParse, + validate as gqlValidate, +} from "graphql" +import { LinterDefinition, LinterResult } from "./linter" + +/** + * Creates a Linter function that can lint a GQL query against a given + * schema + */ +export const createGQLQueryLinter: ( + schema: Ref +) => LinterDefinition = (schema: Ref) => (text) => { + if (text === "") return Promise.resolve([]) + if (!schema.value) return Promise.resolve([]) + + try { + const doc = gqlParse(text) + + const results = gqlValidate(schema.value, doc).map( + ({ locations, message }) => + { + from: { + line: locations![0].line, + ch: locations![0].column - 1, + }, + to: { + line: locations![0].line, + ch: locations![0].column - 1, + }, + message, + severity: "error", + } + ) + + return Promise.resolve(results) + } catch (e) { + const err = e as GraphQLError + + return Promise.resolve([ + { + from: { + line: err.locations![0].line, + ch: err.locations![0].column - 1, + }, + to: { + line: err.locations![0].line, + ch: err.locations![0].column, + }, + message: err.message, + severity: "error", + }, + ]) + } +} diff --git a/packages/hoppscotch-common/src/helpers/editor/linting/json.ts b/packages/hoppscotch-common/src/helpers/editor/linting/json.ts new file mode 100644 index 0000000..46a6901 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/linting/json.ts @@ -0,0 +1,21 @@ +import { convertIndexToLineCh } from "../utils" +import { LinterDefinition, LinterResult } from "./linter" +import jsonParse from "~/helpers/jsonParse" + +const linter: LinterDefinition = (text) => { + try { + jsonParse(text) + return Promise.resolve([]) + } catch (e: any) { + return Promise.resolve([ + { + from: convertIndexToLineCh(text, e.start), + to: convertIndexToLineCh(text, e.end), + message: e.message, + severity: "error", + }, + ]) + } +} + +export default linter diff --git a/packages/hoppscotch-common/src/helpers/editor/linting/jsonc.ts b/packages/hoppscotch-common/src/helpers/editor/linting/jsonc.ts new file mode 100644 index 0000000..c0f1a70 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/linting/jsonc.ts @@ -0,0 +1,108 @@ +import { Node, parseTree, stripComments as stripComments_ } from "jsonc-parser" +import jsoncParse from "~/helpers/jsoncParse" +import { convertIndexToLineCh } from "../utils" +import { LinterDefinition, LinterResult } from "./linter" + +const linter: LinterDefinition = (text) => { + try { + jsoncParse(text) + return Promise.resolve([]) + } catch (e: any) { + return Promise.resolve([ + { + from: convertIndexToLineCh(text, e.start), + to: convertIndexToLineCh(text, e.end), + message: e.message, + severity: "error", + }, + ]) + } +} + +/** + * An internal error that is thrown when an invalid JSONC node configuration + * is encountered + */ +class InvalidJSONCNodeError extends Error { + constructor() { + super() + this.message = "Invalid JSONC node" + } +} + +// NOTE: If we choose to export this function, do refactor it to return a result discriminated union instead of throwing +/** + * @throws {InvalidJSONCNodeError} if the node is in an invalid configuration + * @returns The JSON string without comments and trailing commas + */ +function convertNodeToJSON(node: Node): string { + switch (node.type) { + case "string": + return JSON.stringify(node.value) + case "null": + return "null" + case "array": + if (!node.children) { + throw new InvalidJSONCNodeError() + } + + return `[${node.children + .map((child) => convertNodeToJSON(child)) + .join(",")}]` + case "number": + return JSON.stringify(node.value) + case "boolean": + return JSON.stringify(node.value) + case "object": + if (!node.children) { + throw new InvalidJSONCNodeError() + } + + return `{${node.children + .map((child) => convertNodeToJSON(child)) + .join(",")}}` + case "property": + if (!node.children || node.children.length !== 2) { + throw new InvalidJSONCNodeError() + } + + const [keyNode, valueNode] = node.children + + // Use keyNode.value instead of keyNode to avoid circular references. + // Attempting to JSON.stringify(keyNode) directly would throw + // "Converting circular structure to JSON" error. + // If the valueNode configuration is wrong, this will return an error, which will propagate up + return `${JSON.stringify(keyNode.value)}:${convertNodeToJSON(valueNode)}` + } +} + +function stripCommentsAndCommas(text: string): string { + const tree = parseTree(text, undefined, { + allowEmptyContent: true, + allowTrailingComma: true, + }) + + // If we couldn't parse the tree, return the original text + if (!tree) { + return text + } + + // convertNodeToJSON can throw an error if the tree is invalid + try { + return convertNodeToJSON(tree) + } catch (_) { + return text + } +} + +/** + * Removes comments from a JSON string. + * @param jsonString The JSON string with comments. + * @returns The JSON string without comments. + */ + +export function stripComments(jsonString: string) { + return stripCommentsAndCommas(stripComments_(jsonString) ?? jsonString) +} + +export default linter diff --git a/packages/hoppscotch-common/src/helpers/editor/linting/jsoncPretty.ts b/packages/hoppscotch-common/src/helpers/editor/linting/jsoncPretty.ts new file mode 100644 index 0000000..93b39c1 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/linting/jsoncPretty.ts @@ -0,0 +1,10 @@ +import { format, applyEdits } from "jsonc-parser" + +export function prettifyJSONC(str: string) { + const editResult = format(str, undefined, { + insertSpaces: true, + tabSize: 2, + insertFinalNewline: true, + }) + return applyEdits(str, editResult) +} diff --git a/packages/hoppscotch-common/src/helpers/editor/linting/linter.ts b/packages/hoppscotch-common/src/helpers/editor/linting/linter.ts new file mode 100644 index 0000000..704270c --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/linting/linter.ts @@ -0,0 +1,7 @@ +export type LinterResult = { + message: string + severity: "warning" | "error" + from: { line: number; ch: number } + to: { line: number; ch: number } +} +export type LinterDefinition = (text: string) => Promise diff --git a/packages/hoppscotch-common/src/helpers/editor/linting/preRequest.ts b/packages/hoppscotch-common/src/helpers/editor/linting/preRequest.ts new file mode 100644 index 0000000..39af686 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/linting/preRequest.ts @@ -0,0 +1,75 @@ +import * as esprima from "esprima" +import { LinterDefinition, LinterResult } from "./linter" +import { performPreRequestLinting } from "~/helpers/tern" + +const linter: LinterDefinition = async (text) => { + let results: LinterResult[] = [] + + // Semantic linting + const semanticLints = await performPreRequestLinting(text) + + results = results.concat( + semanticLints.map((lint: any) => ({ + from: { + ch: lint.from.ch + 1, + line: lint.from.line + 1, + }, + to: { + ch: lint.from.ch + 1, + line: lint.to.line + 1, + }, + severity: "error", + message: `[semantic] ${lint.message}`, + })) + ) + + // Syntax linting + try { + const res: any = esprima.parseScript(text, { tolerant: true }) + if (res.errors && res.errors.length > 0) { + results = results.concat( + res.errors.map((err: any) => { + const fromPos: { line: number; ch: number } = { + line: err.lineNumber, + ch: err.column, + } + + const toPos: { line: number; ch: number } = { + line: err.lineNumber, + ch: err.column, + } + + return { + from: fromPos, + to: toPos, + message: `[syntax] ${err.description}`, + severity: "error", + } + }) + ) + } + } catch (e: any) { + const fromPos: { line: number; ch: number } = { + line: e.lineNumber, + ch: e.column, + } + + const toPos: { line: number; ch: number } = { + line: e.lineNumber, + ch: e.column, + } + + results = results.concat([ + { + from: fromPos, + to: toPos, + message: `[syntax] ${e.description}`, + severity: "error", + }, + ]) + } + + return results +} + +export default linter diff --git a/packages/hoppscotch-common/src/helpers/editor/linting/rawKeyValue.ts b/packages/hoppscotch-common/src/helpers/editor/linting/rawKeyValue.ts new file mode 100644 index 0000000..bd83c0e --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/linting/rawKeyValue.ts @@ -0,0 +1,23 @@ +import * as E from "fp-ts/Either" +import { strictParseRawKeyValueEntriesE } from "@hoppscotch/data" +import { convertIndexToLineCh } from "../utils" +import { LinterDefinition, LinterResult } from "./linter" + +const linter: LinterDefinition = (text) => { + const result = strictParseRawKeyValueEntriesE(text) + if (E.isLeft(result)) { + const pos = convertIndexToLineCh(text, result.left.pos) + + return Promise.resolve([ + { + from: pos, + to: pos, + message: result.left.message, + severity: "error", + }, + ]) + } + return Promise.resolve([]) +} + +export default linter diff --git a/packages/hoppscotch-common/src/helpers/editor/linting/testScript.ts b/packages/hoppscotch-common/src/helpers/editor/linting/testScript.ts new file mode 100644 index 0000000..75d193f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/linting/testScript.ts @@ -0,0 +1,75 @@ +import * as esprima from "esprima" +import { LinterDefinition, LinterResult } from "./linter" +import { performTestLinting } from "~/helpers/tern" + +const linter: LinterDefinition = async (text) => { + let results: LinterResult[] = [] + + // Semantic linting + const semanticLints = await performTestLinting(text) + + results = results.concat( + semanticLints.map((lint: any) => ({ + from: { + ch: lint.from.ch + 1, + line: lint.from.line + 1, + }, + to: { + ch: lint.from.ch + 1, + line: lint.to.line + 1, + }, + severity: "error", + message: `[semantic] ${lint.message}`, + })) + ) + + // Syntax linting + try { + const res: any = esprima.parseScript(text, { tolerant: true }) + if (res.errors && res.errors.length > 0) { + results = results.concat( + res.errors.map((err: any) => { + const fromPos: { line: number; ch: number } = { + line: err.lineNumber, + ch: err.column, + } + + const toPos: { line: number; ch: number } = { + line: err.lineNumber, + ch: err.column, + } + + return { + from: fromPos, + to: toPos, + message: `[syntax] ${err.description}`, + severity: "error", + } + }) + ) + } + } catch (e: any) { + const fromPos: { line: number; ch: number } = { + line: e.lineNumber, + ch: e.column, + } + + const toPos: { line: number; ch: number } = { + line: e.lineNumber, + ch: e.column, + } + + results = results.concat([ + { + from: fromPos, + to: toPos, + message: `[syntax] ${e.description}`, + severity: "error", + }, + ]) + } + + return results +} + +export default linter diff --git a/packages/hoppscotch-common/src/helpers/editor/themes/baseTheme.ts b/packages/hoppscotch-common/src/helpers/editor/themes/baseTheme.ts new file mode 100644 index 0000000..48afefe --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/themes/baseTheme.ts @@ -0,0 +1,573 @@ +import { + EditorView, + keymap, + highlightSpecialChars, + highlightActiveLine, + drawSelection, + dropCursor, + lineNumbers, + highlightActiveLineGutter, + rectangularSelection, + crosshairCursor, +} from "@codemirror/view" +import { + HighlightStyle, + defaultHighlightStyle, + foldKeymap, + foldGutter, + codeFolding, + indentOnInput, + bracketMatching, + syntaxHighlighting, +} from "@codemirror/language" +import { tags as t } from "@lezer/highlight" +import { Extension, EditorState } from "@codemirror/state" +import { history, historyKeymap, defaultKeymap } from "@codemirror/commands" + +import { + closeBrackets, + closeBracketsKeymap, + autocompletion, + completionKeymap, +} from "@codemirror/autocomplete" +import { + searchKeymap, + highlightSelectionMatches, + search, +} from "@codemirror/search" +import { lintKeymap } from "@codemirror/lint" + +export const baseTheme = EditorView.theme({ + "&": { + fontSize: "var(--font-size-body)", + height: "100%", + width: "100%", + flex: "1", + }, + ".cm-content": { + caretColor: "var(--secondary-dark-color)", + fontFamily: "var(--font-mono)", + color: "var(--secondary-dark-color)", + backgroundColor: "transparent", + height: "100%", + }, + ".cm-cursor": { + borderColor: "var(--secondary-color)", + }, + ".cm-widgetBuffer": { + position: "absolute", + }, + ".cm-selectionBackground": { + backgroundColor: "var(--accent-dark-color)", + color: "var(--accent-contrast-color)", + borderRadius: "2px", + opacity: "0.4", + }, + ".cm-panels": { + backgroundColor: "var(--primary-light-color)", + color: "var(--secondary-light-color)", + zIndex: "1", + }, + ".cm-panels.cm-panels-top": { + borderBottom: "1px solid var(--divider-light-color)", + }, + ".cm-panels.cm-panels-bottom": { + borderTop: "1px solid var(--divider-light-color)", + }, + ".cm-search": { + display: "flex", + alignItems: "center", + flexWrap: "nowrap", + flexShrink: "0", + overflow: "auto", + padding: "0.25rem 0.5rem !important", + }, + ".cm-search label": { + display: "inline-flex", + alignItems: "center", + }, + ".cm-textfield": { + backgroundColor: "var(--primary-color)", + color: "var(--secondary-dark-color)", + borderColor: "var(--divider-light-color)", + borderRadius: "4px", + fontSize: "var(--font-size-tiny)", + fontWeight: "600", + flexShrink: "0", + border: "1px solid var(--divider-color)", + }, + ".cm-button": { + backgroundColor: "var(--primary-color)", + color: "var(--secondary-light-color)", + backgroundImage: "none", + borderRadius: "4px", + fontSize: "var(--font-size-tiny)", + fontWeight: "600", + textTransform: "capitalize", + flexShrink: "0", + border: "1px solid var(--divider-color)", + }, + ".cm-completionLabel": { + color: "var(--secondary-color)", + }, + ".cm-tooltip": { + backgroundColor: "var(--primary-dark-color)", + color: "var(--secondary-light-color)", + border: "none", + borderRadius: "4px", + }, + ".cm-tooltip-arrow": { + color: "var(--tooltip-color)", + }, + ".cm-tooltip-arrow:after": { + borderTopColor: "currentColor !important", + }, + ".cm-tooltip-arrow:before": { + borderTopColor: "currentColor !important", + }, + ".cm-tooltip.cm-tooltip-autocomplete > ul": { + fontFamily: "var(--font-mono)", + }, + ".cm-tooltip-autocomplete ul li[aria-selected]": { + backgroundColor: "var(--accent-dark-color)", + color: "var(--accent-contrast-color)", + }, + ".cm-tooltip-autocomplete ul li[aria-selected] .cm-completionLabel": { + color: "var(--accent-contrast-color)", + }, + ".cm-activeLine": { backgroundColor: "transparent" }, + ".cm-searchMatch": { + outline: "1px solid var(--accent-dark-color)", + backgroundColor: "var(--divider-dark-color)", + borderRadius: "2px", + }, + ".cm-selectionMatch": { + outline: "1px solid var(--accent-dark-color)", + backgroundColor: "var(--divider-light-color)", + borderRadius: "2px", + }, + ".cm-matchingBracket, .cm-nonmatchingBracket": { + backgroundColor: "var(--divider-color)", + outline: "1px solid var(--accent-dark-color)", + borderRadius: "2px", + }, + ".cm-gutters": { + fontFamily: "var(--font-mono)", + backgroundColor: "var(--primary-color)", + borderColor: "var(--divider-light-color)", + }, + ".cm-lineNumbers": { + minWidth: "3em", + color: "var(--secondary-light-color)", + }, + ".cm-foldGutter": { + minWidth: "2em", + color: "var(--secondary-light-color)", + }, + ".cm-foldGutter .cm-gutterElement": { + textAlign: "center", + }, + ".cm-line": { + paddingLeft: "0.5rem", + paddingRight: "0.5rem", + }, + ".cm-activeLineGutter": { + backgroundColor: "transparent", + }, + ".cm-foldPlaceholder": { + backgroundColor: "var(--divider-light-color)", + color: "var(--secondary-dark-color)", + borderColor: "var(--divider-dark-color)", + }, +}) + +export const inputTheme = EditorView.theme({ + "&": { + fontSize: "var(--font-size-body)", + height: "100%", + width: "100%", + flex: "1", + }, + ".cm-content": { + caretColor: "var(--secondary-dark-color)", + fontFamily: "var(--font-sans)", + color: "var(--secondary-dark-color)", + backgroundColor: "transparent", + height: "100%", + }, + ".cm-cursor": { + borderColor: "var(--secondary-color)", + }, + ".cm-widgetBuffer": { + position: "absolute", + }, + ".cm-selectionBackground": { + backgroundColor: "var(--accent-dark-color)", + color: "var(--accent-contrast-color)", + borderRadius: "2px", + }, + ".cm-panels": { + backgroundColor: "var(--primary-light-color)", + color: "var(--secondary-light-color)", + zIndex: "1", + }, + ".cm-panels.cm-panels-top": { + borderBottom: "1px solid var(--divider-light-color)", + }, + ".cm-panels.cm-panels-bottom": { + borderTop: "1px solid var(--divider-light-color)", + }, + ".cm-search": { + display: "flex", + alignItems: "center", + flexWrap: "nowrap", + flexShrink: "0", + overflow: "auto", + padding: "0.25rem 0.5rem !important", + }, + ".cm-search label": { + display: "inline-flex", + alignItems: "center", + }, + ".cm-textfield": { + backgroundColor: "var(--primary-color)", + color: "var(--secondary-dark-color)", + borderColor: "var(--divider-light-color)", + borderRadius: "4px", + fontSize: "var(--font-size-tiny)", + fontWeight: "600", + flexShrink: "0", + border: "1px solid var(--divider-color)", + }, + ".cm-button": { + backgroundColor: "var(--primary-color)", + color: "var(--secondary-light-color)", + backgroundImage: "none", + borderRadius: "4px", + fontSize: "var(--font-size-tiny)", + fontWeight: "600", + textTransform: "capitalize", + flexShrink: "0", + border: "1px solid var(--divider-color)", + }, + ".cm-completionLabel": { + color: "var(--secondary-color)", + }, + ".cm-tooltip": { + backgroundColor: "var(--primary-dark-color)", + color: "var(--secondary-light-color)", + border: "none", + borderRadius: "4px", + }, + ".cm-tooltip-arrow": { + color: "var(--tooltip-color)", + }, + ".cm-tooltip-arrow:after": { + borderTopColor: "currentColor !important", + }, + ".cm-tooltip-arrow:before": { + borderTopColor: "currentColor !important", + }, + ".cm-tooltip.cm-tooltip-autocomplete > ul": { + fontFamily: "var(--font-mono)", + }, + ".cm-tooltip-autocomplete ul li[aria-selected]": { + backgroundColor: "var(--accent-dark-color)", + color: "var(--accent-contrast-color)", + }, + ".cm-tooltip-autocomplete ul li[aria-selected] .cm-completionLabel": { + color: "var(--accent-contrast-color)", + }, + ".cm-activeLine": { backgroundColor: "transparent" }, + ".cm-searchMatch": { + outline: "1px solid var(--accent-dark-color)", + backgroundColor: "var(--divider-dark-color)", + borderRadius: "2px", + }, + ".cm-selectionMatch": { + outline: "1px solid var(--accent-dark-color)", + backgroundColor: "var(--divider-light-color)", + borderRadius: "2px", + }, + ".cm-matchingBracket, .cm-nonmatchingBracket": { + backgroundColor: "var(--divider-color)", + outline: "1px solid var(--accent-dark-color)", + borderRadius: "2px", + }, + ".cm-gutters": { + fontFamily: "var(--font-mono)", + backgroundColor: "var(--primary-color)", + borderColor: "var(--divider-light-color)", + }, + ".cm-lineNumbers": { + minWidth: "3em", + color: "var(--secondary-light-color)", + }, + ".cm-foldGutter": { + minWidth: "2em", + color: "var(--secondary-light-color)", + }, + ".cm-foldGutter .cm-gutterElement": { + textAlign: "center", + }, + ".cm-line": { + lineHeight: "1rem", + paddingLeft: "1rem", + paddingRight: "1rem", + paddingTop: "0.25rem", + paddingBottom: "0.25rem", + }, + ".cm-activeLineGutter": { + backgroundColor: "transparent", + }, + ".cm-foldPlaceholder": { + backgroundColor: "var(--divider-light-color)", + color: "var(--secondary-dark-color)", + borderColor: "var(--divider-dark-color)", + }, + ".cm-jsonFoldSummary": { + opacity: "0.7", + fontStyle: "italic", + background: "var(--divider-dark-color)", + }, +}) + +const editorTypeColor = "var(--editor-type-color)" +const editorNameColor = "var(--editor-name-color)" +const editorOperatorColor = "var(--editor-operator-color)" +const editorInvalidColor = "var(--editor-invalid-color)" +const editorSeparatorColor = "var(--editor-separator-color)" +const editorMetaColor = "var(--editor-meta-color)" +const editorVariableColor = "var(--editor-variable-color)" +const editorLinkColor = "var(--editor-link-color)" +const editorProcessColor = "var(--editor-process-color)" +const editorConstantColor = "var(--editor-constant-color)" +const editorKeywordColor = "var(--editor-keyword-color)" + +export const baseHighlightStyle = HighlightStyle.define([ + { tag: t.keyword, color: editorKeywordColor }, + { + tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], + color: editorNameColor, + }, + { + tag: [t.function(t.variableName), t.labelName], + color: editorVariableColor, + }, + { + tag: [t.color, t.constant(t.name), t.standard(t.name)], + color: editorConstantColor, + }, + { tag: [t.definition(t.name), t.separator], color: editorSeparatorColor }, + { + tag: [ + t.typeName, + t.className, + t.number, + t.changed, + t.annotation, + t.modifier, + t.self, + t.namespace, + ], + color: editorTypeColor, + }, + { + tag: [ + t.operator, + t.operatorKeyword, + t.url, + t.escape, + t.regexp, + t.link, + t.special(t.string), + ], + color: editorOperatorColor, + }, + { + tag: [t.meta, t.comment], + color: editorMetaColor, + }, + { tag: t.strong, fontWeight: "bold" }, + { tag: t.emphasis, fontStyle: "italic" }, + { tag: t.strikethrough, textDecoration: "line-through" }, + { tag: t.link, color: editorLinkColor, textDecoration: "underline" }, + { tag: t.heading, fontWeight: "bold", color: editorNameColor }, + { + tag: [t.atom, t.bool, t.special(t.variableName)], + color: editorConstantColor, + }, + { + tag: [t.processingInstruction, t.string, t.inserted], + color: editorProcessColor, + }, + { tag: t.invalid, color: editorInvalidColor }, +]) + +/** + * Generic body counter (array or object). + * + * @param body - String content inside `[...]` or `{...}`. + * @param trigger - The character that indicates a top-level separator (`,` or `:`). + * @param finalize - Function to adjust the final count (e.g., add +1 for arrays). + */ +function countBodyUnits( + body: string, + trigger: string, + finalize: (count: number, sawContent: boolean) => number +): number { + let inString = false + let escape = false + let bracketDepth = 0 + let braceDepth = 0 + let count = 0 + let sawContent = false + + for (let i = 0; i < body.length; i++) { + const ch = body[i] + + if (escape) { + escape = false + continue + } + if (ch === "\\") { + escape = true + continue + } + if (ch === '"') { + inString = !inString + continue + } + if (inString) continue + + if (ch === "[") bracketDepth++ + else if (ch === "]") bracketDepth-- + else if (ch === "{") braceDepth++ + else if (ch === "}") braceDepth-- + + if (!sawContent && !/\s/.test(ch)) sawContent = true + + if (ch === trigger && bracketDepth === 0 && braceDepth === 0) { + count++ + } + } + + return finalize(count, sawContent) +} + +/** + * Count the number of top-level items in an array body string. + */ +function countArrayItemsInBody(body: string): number { + return countBodyUnits(body, ",", (count, sawContent) => + !sawContent ? 0 : count + 1 + ) +} + +/** + * Count the number of top-level fields in an object body string. + */ +function countObjectFieldsInBody(body: string): number { + return countBodyUnits(body, ":", (count) => count) +} + +/** + * Compute a fold summary string for a JSON range. + * + * @param state - Current editor state + * @param from - Start position of the fold + * @param to - End position of the fold + */ +function computeJsonSummary( + state: EditorState, + from: number, + to: number +): string { + const docLength = state.doc.length + const sliceFrom = Math.max(0, from - 1) + const sliceTo = Math.min(docLength, to + 1) + const slice = state.sliceDoc(sliceFrom, sliceTo) + + // Indices relative to slice + const textStart = from - sliceFrom + const textEnd = textStart + (to - from) + + const text = slice.substring(textStart, textEnd).trim() + const prevChar = from > 0 ? slice.charAt(textStart - 1) : "" + const nextChar = textEnd < slice.length ? slice.charAt(textEnd) : "" + + // Try full JSON parse first (works if selection is a valid value) + try { + const parsed = JSON.parse(text) + if (Array.isArray(parsed)) { + return `[ … ] (${parsed.length} items)` + } + if (parsed && typeof parsed === "object") { + return `{ … } (${Object.keys(parsed).length} fields)` + } + } catch { + // Not standalone JSON → fallback to counting + } + + // Infer container type by surrounding characters + if (prevChar === "[" || nextChar === "]") { + return `[ … ] (${countArrayItemsInBody(text)} items)` + } + if (prevChar === "{" || nextChar === "}" || text.includes(":")) { + return `{ … } (${countObjectFieldsInBody(text)} fields)` + } + + return "…" +} + +/** + * Extension: JSON folding with informative summaries. + */ +export const jsonFoldSummary: Extension = codeFolding({ + preparePlaceholder: (state, range) => + computeJsonSummary(state, range.from, range.to), + placeholderDOM: (view, onclick, prepared) => { + const span = document.createElement("span") + span.className = "cm-foldPlaceholder cm-jsonFoldSummary" + span.textContent = typeof prepared === "string" ? prepared : "…" + span.addEventListener("click", onclick) + return span + }, +}) + +export const basicSetup: Extension = [ + lineNumbers(), + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + foldGutter({ + openText: "▾", + closedText: "▸", + }), + jsonFoldSummary, + drawSelection(), + dropCursor(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + syntaxHighlighting(baseHighlightStyle), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + bracketMatching(), + closeBrackets(), + autocompletion(), + rectangularSelection(), + crosshairCursor(), + highlightActiveLine(), + highlightSelectionMatches(), + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...searchKeymap, + ...historyKeymap, + ...foldKeymap, + ...completionKeymap, + ...lintKeymap, + ]), + search({ + top: true, + }), +] diff --git a/packages/hoppscotch-common/src/helpers/editor/utils.ts b/packages/hoppscotch-common/src/helpers/editor/utils.ts new file mode 100644 index 0000000..c7dcb1c --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editor/utils.ts @@ -0,0 +1,38 @@ +export function convertIndexToLineCh( + text: string, + i: number +): { line: number; ch: number } { + const lines = text.split("\n") + + let line = 0 + let counter = 0 + + while (line < lines.length) { + if (i > lines[line].length + counter) { + counter += lines[line].length + 1 + line++ + } else { + return { + line: line + 1, + ch: i - counter + 1, + } + } + } + + throw new Error("Invalid input") +} + +export function convertLineChToIndex( + text: string, + lineCh: { line: number; ch: number } +): number { + const textSplit = text.split("\n") + + if (textSplit.length < lineCh.line) throw new Error("Invalid position") + + const tillLineIndex = textSplit + .slice(0, lineCh.line) + .reduce((acc, line) => acc + line.length + 1, 0) + + return tillLineIndex + lineCh.ch +} diff --git a/packages/hoppscotch-common/src/helpers/editorutils.ts b/packages/hoppscotch-common/src/helpers/editorutils.ts new file mode 100644 index 0000000..72e1e77 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/editorutils.ts @@ -0,0 +1,13 @@ +const mimeToMode = { + "text/plain": "text/plain", + "text/xml": "application/xml", + "text/html": "htmlmixed", + "application/xml": "application/xml", + "application/hal+json": "application/ld+json", + "application/vnd.api+json": "application/ld+json", + "application/json": "application/ld+json", +} + +export function getEditorLangForMimeType(mimeType: string): string { + return mimeToMode[mimeType] || "text/plain" +} diff --git a/packages/hoppscotch-common/src/helpers/environment-regex.ts b/packages/hoppscotch-common/src/helpers/environment-regex.ts new file mode 100644 index 0000000..b462c2a --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/environment-regex.ts @@ -0,0 +1,8 @@ +// Regex to match environment variables in the format `<>`/`<>` etc. +// This is used to identify if the request contains environment variables that need to be handled specially. + +const ENV_VAR_NAME_PATTERN = "[a-zA-Z0-9_.-]+" +const HOPP_ENVIRONMENT_REGEX = new RegExp(`(<<${ENV_VAR_NAME_PATTERN}>>)`, "g") +const ENV_VAR_NAME_REGEX = new RegExp(ENV_VAR_NAME_PATTERN) + +export { HOPP_ENVIRONMENT_REGEX, ENV_VAR_NAME_REGEX } diff --git a/packages/hoppscotch-common/src/helpers/error-messages/index.ts b/packages/hoppscotch-common/src/helpers/error-messages/index.ts new file mode 100644 index 0000000..7638220 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/error-messages/index.ts @@ -0,0 +1,18 @@ +import { GQLError } from "../backend/GQLClient" + +export const getEnvActionErrorMessage = (err: GQLError) => { + if (err.type === "network_error") { + return "error.network_error" + } + + switch (err.error) { + case "team_environment/not_found": + return "team_environment.not_found" + case "team_environment/short_name": + return "environment.short_name" + case "Forbidden resource": + return "profile.no_permission" + default: + return "error.something_went_wrong" + } +} diff --git a/packages/hoppscotch-common/src/helpers/experimental-sandbox-integration.ts b/packages/hoppscotch-common/src/helpers/experimental-sandbox-integration.ts new file mode 100644 index 0000000..d40257a --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/experimental-sandbox-integration.ts @@ -0,0 +1,104 @@ +import { HoppRESTRequest } from "@hoppscotch/data" + +/** + * Applies pre-request script modifications to the original request. + * + * For legacy sandbox: Returns original request unchanged (`updatedRequest` is `undefined`). + * For experimental sandbox: Merges script changes while preserving file uploads + * lost during JSON serialization. + * + * Context: When the experimental scripting sandbox is enabled, requests are + * sent to a Web Worker for pre-request script execution. The request undergoes + * JSON serialization which converts File/Blob objects to empty objects `{}`. + * A Zod transform then converts file fields with empty arrays to text fields + * (`isFile: false`, `value: ""`). + * + * This function uses hybrid matching to handle both: + * - Duplicate keys (e.g., multiple fields with `key="file"`) via index matching + * - Field reordering by scripts via key-based fallback + * + * @param originalRequest The original request with file uploads intact + * @param updatedRequest The request returned from sandbox (undefined for legacy, modified for experimental) + * @returns Merged request with file uploads preserved and script changes applied + * + * @see https://github.com/hoppscotch/hoppscotch/issues/5443 + * @see FormDataKeyValue schema in ~/hoppscotch-data/src/rest/v/9/body.ts + */ +export const applyScriptRequestUpdates = ( + originalRequest: HoppRESTRequest, + updatedRequest?: HoppRESTRequest +): HoppRESTRequest => { + if (!updatedRequest) { + return originalRequest + } + + const originalBody = originalRequest.body + const updatedBody = updatedRequest.body + + if ( + originalBody.contentType === "multipart/form-data" && + updatedBody.contentType === "multipart/form-data" + ) { + const originalFormData = originalBody.body + const updatedFormData = updatedBody.body + const usedIndices = new Set() + + const mergedFormData = updatedFormData.map((updatedField, index) => { + // Hybrid matching: try position first (handles duplicate keys like "file", "file", "file"), + // then search by key (handles field reordering by scripts) + const samePositionMatch = + index < originalFormData.length && + !usedIndices.has(index) && + originalFormData[index].key === updatedField.key + + const matchedIndex = samePositionMatch + ? index + : originalFormData.findIndex( + (field, i) => !usedIndices.has(i) && field.key === updatedField.key + ) + + // If matched, restore file data from original (only `originalField` has `isFile=true`) + if (matchedIndex >= 0) { + usedIndices.add(matchedIndex) + const originalField = originalFormData[matchedIndex] + + if (originalField.isFile) { + return { + ...updatedField, + value: originalField.value, + isFile: true as const, + ...(originalField.contentType && { + contentType: originalField.contentType, + }), + } as typeof updatedField + } + } + + return updatedField + }) + + return { + ...originalRequest, + ...updatedRequest, + body: { ...updatedBody, body: mergedFormData }, + } + } + + if ( + originalBody.contentType === "application/octet-stream" && + updatedBody.contentType === "application/octet-stream" && + originalBody.body instanceof Blob + ) { + return { + ...originalRequest, + ...updatedRequest, + body: { ...updatedBody, body: originalBody.body }, + } + } + + // No files to preserve + return { + ...originalRequest, + ...updatedRequest, + } +} diff --git a/packages/hoppscotch-common/src/helpers/findStatusGroup.ts b/packages/hoppscotch-common/src/helpers/findStatusGroup.ts new file mode 100644 index 0000000..b14c0fb --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/findStatusGroup.ts @@ -0,0 +1,37 @@ +export default function (responseStatus: number) { + if (responseStatus >= 100 && responseStatus < 200) + return { + name: "informational", + className: "info-response", + } + + if (responseStatus >= 200 && responseStatus < 300) + return { + name: "successful", + className: "success-response", + } + + if (responseStatus >= 300 && responseStatus < 400) + return { + name: "redirection", + className: "redirect-response", + } + + if (responseStatus >= 400 && responseStatus < 500) + return { + name: "client error", + className: "critical-error-response", + } + + if (responseStatus >= 500 && responseStatus < 600) + return { + name: "server error", + className: "server-error-response", + } + + // this object is a catch-all for when no other objects match and should always be last + return { + name: "unknown", + className: "missing-data-response", + } +} diff --git a/packages/hoppscotch-common/src/helpers/fixBrokenEnvironmentVersion.ts b/packages/hoppscotch-common/src/helpers/fixBrokenEnvironmentVersion.ts new file mode 100644 index 0000000..76090d9 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/fixBrokenEnvironmentVersion.ts @@ -0,0 +1,27 @@ +import { + Environment, + translateToNewEnvironmentVariables, +} from "@hoppscotch/data" + +/** + * Fixes broken environment versions in the given environments. + * This function ensures that all environment variables are translated + * to the new format, which is necessary for compatibility with the latest + * version of the application. + * + * Some environments may have been created with an unsupported + * variable format, which can lead to issues when trying to access or manipulate those environments. + * + * + * @param envs - The array of environments to fix. + * @returns The fixed array of environments with updated variable formats. + */ +export const fixBrokenEnvironmentVersion = (envs: Environment[]) => { + if (!Array.isArray(envs)) { + return envs + } + return envs.map((env) => ({ + ...env, + variables: (env.variables ?? []).map(translateToNewEnvironmentVariables), + })) +} diff --git a/packages/hoppscotch-common/src/helpers/fixBrokenRequestVersion.ts b/packages/hoppscotch-common/src/helpers/fixBrokenRequestVersion.ts new file mode 100644 index 0000000..c9dee8f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/fixBrokenRequestVersion.ts @@ -0,0 +1,54 @@ +import { + getDefaultRESTRequest, + safelyExtractRESTRequest, +} from "@hoppscotch/data" +import { z } from "zod" +import { REST_TAB_STATE_SCHEMA } from "~/services/persistence/validation-schemas" + +type HoppRESTab = z.infer + +/** + * Fixes broken request versions in the given REST tab documents. + * This function ensures that all requests and test runners have valid + * request data, defaulting to the default REST request structure if necessary. + * + * There were requests in the REST tab that had an invalid version + * structure, with response and parent request which could lead to issues when trying to access or + * manipulate those requests. This function iterates through the + * ordered documents of the REST tab and checks each request. + * + * @param docs - The ordered documents of the REST tab to fix. + * @returns The fixed ordered documents with valid request structures. + */ +export const fixBrokenRequestVersion = ( + docs: HoppRESTab["orderedDocs"] +): HoppRESTab["orderedDocs"] => { + return docs.map((x: HoppRESTab["orderedDocs"][number]) => { + if (x.doc.type === "request") { + const req = safelyExtractRESTRequest( + x.doc.request, + getDefaultRESTRequest() + ) + if (req) { + x.doc.request = req + } + } + + if (x.doc.type === "test-runner") { + x.doc.request = safelyExtractRESTRequest( + x.doc.request, + getDefaultRESTRequest() + ) + + if (x.doc.resultCollection) { + x.doc.resultCollection.requests = x.doc.resultCollection?.requests.map( + (req) => { + return safelyExtractRESTRequest(req, getDefaultRESTRequest()) + } + ) + } + } + + return x + }) +} diff --git a/packages/hoppscotch-common/src/helpers/functional/array.ts b/packages/hoppscotch-common/src/helpers/functional/array.ts new file mode 100644 index 0000000..c642111 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/functional/array.ts @@ -0,0 +1,44 @@ +import { clone } from "lodash-es" +/** + * Sorts the array based on the sort func. + * NOTE: Creates a new array, if you don't need ref + * to original array, use `arrayUnsafeSort` for better perf + * @param sortFunc Sort function to sort against + */ +export const arraySort = + (sortFunc: (a: T, b: T) => number) => + (arr: T[]) => { + const newArr = clone(arr) + + newArr.sort(sortFunc) + + return newArr + } + +/** + * Sorts an array based on the sort func. + * Unsafe because this sort mutates the passed array + * and returns it. So use it if you do not want the + * original array for better performance + * @param sortFunc sort function to sort against (same as Array.sort) + */ +export const arrayUnsafeSort = + (sortFunc: (a: T, b: T) => number) => + (arr: T[]) => { + arr.sort(sortFunc) + + return arr + } + +/** + * Equivalent to `Array.prototype.flatMap` + * @param mapFunc The map function + * @returns + */ +export const arrayFlatMap = + (mapFunc: (value: T, index: number, arr: T[]) => U[]) => + (arr: T[]) => + arr.flatMap(mapFunc) + +export const stringArrayJoin = (separator: string) => (arr: string[]) => + arr.join(separator) diff --git a/packages/hoppscotch-common/src/helpers/functional/debug.ts b/packages/hoppscotch-common/src/helpers/functional/debug.ts new file mode 100644 index 0000000..9f886e1 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/functional/debug.ts @@ -0,0 +1,22 @@ +/** + * Logs the current value and returns the same value + * @param x The value to log + * @returns The parameter `x` passed to this + */ +export const trace = (x: T) => { + console.log(x) + return x +} + +/** + * Logs the annotated current value and returns the same value + * @param name The name of the log + * @curried_param `x` The value to log + * @returns The parameter `x` passed to this + */ +export const namedTrace = + (name: string) => + (x: T) => { + console.log(`${name}:`, x) + return x + } diff --git a/packages/hoppscotch-common/src/helpers/functional/domain-settings.ts b/packages/hoppscotch-common/src/helpers/functional/domain-settings.ts new file mode 100644 index 0000000..16d76ce --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/functional/domain-settings.ts @@ -0,0 +1,256 @@ +import { pipe } from "fp-ts/function" +import * as O from "fp-ts/Option" +import * as E from "fp-ts/Either" +import { StoreFile, RelayRequest } from "@hoppscotch/kernel" + +export type InputDomainSetting = { + version: "v1" + security?: { + certificates?: { + client?: + | { + kind: "pem" + cert?: StoreFile + key?: StoreFile + } + | { + kind: "pfx" + data?: StoreFile + password?: string + } + ca?: StoreFile[] + } + verifyHost?: boolean + verifyPeer?: boolean + } + proxy?: { + url: string + auth?: { + username?: string + password?: string + } + certificates?: { + ca?: StoreFile[] + client?: + | { + kind: "pem" + cert?: StoreFile + key?: StoreFile + } + | { + kind: "pfx" + data?: StoreFile + password?: string + } + } + } + options?: { + followRedirects?: boolean + maxRedirects?: number + timeout?: number + decompress?: boolean + cookies?: boolean + keepAlive?: boolean + } +} + +const convertStoreFile = (file: StoreFile): O.Option => + file.include === false ? O.none : O.some(file.content) + +const convertClientCert = ( + cert?: + | { + kind: "pem" + cert?: StoreFile + key?: StoreFile + } + | { + kind: "pfx" + data?: StoreFile + password?: string + } +): O.Option< + NonNullable< + NonNullable< + Pick["security"] + >["certificates"] + >["client"] +> => { + if (!cert) return O.none + + switch (cert.kind) { + case "pem": { + const certContent = pipe( + O.fromNullable(cert.cert), + O.chain(convertStoreFile) + ) + const keyContent = pipe( + O.fromNullable(cert.key), + O.chain(convertStoreFile) + ) + + return pipe( + O.Do, + O.bind("cert", () => certContent), + O.bind("key", () => keyContent), + O.map(({ cert, key }) => ({ + kind: "pem" as const, + cert, + key, + })) + ) + } + case "pfx": { + const dataContent = pipe( + O.fromNullable(cert.data), + O.chain(convertStoreFile) + ) + + return pipe( + O.Do, + O.bind("data", () => dataContent), + O.bind("password", () => O.fromNullable(cert.password)), + O.map(({ data, password }) => ({ + kind: "pfx" as const, + data, + password, + })) + ) + } + } +} + +const convertCaCerts = (certs?: StoreFile[]): O.Option => + pipe( + O.fromNullable(certs), + O.chain((certs: StoreFile[]) => { + const converted = certs + .map(convertStoreFile) + .filter(O.isSome) + .map((opt) => opt.value) + return converted.length > 0 ? O.some(converted) : O.none + }) + ) + +const convertSecurity = ( + security?: InputDomainSetting["security"] +): O.Option["security"]> => + pipe( + O.fromNullable(security), + O.chain((security) => { + const certificatesOption = pipe( + O.fromNullable(security.certificates), + O.chain((certificates) => { + const clientCert = convertClientCert(certificates.client) + const caCerts = convertCaCerts(certificates.ca) + + // Include if at least one certificate exists + return O.isSome(clientCert) || O.isSome(caCerts) + ? O.some({ + ...(O.isSome(clientCert) ? { client: clientCert.value } : {}), + ...(O.isSome(caCerts) ? { ca: caCerts.value } : {}), + }) + : O.none + }) + ) + return O.some({ + ...(O.isSome(certificatesOption) + ? { certificates: certificatesOption.value } + : {}), + // Default to `false` if not explicitly set, + // if no certificates but security object exists, still return verify settings + verifyHost: security.verifyHost ?? false, + verifyPeer: security.verifyPeer ?? false, + }) + }), + // If no security object at all, return default settings + O.alt(() => + O.some({ + verifyHost: false, + verifyPeer: false, + }) + ) + ) + +const convertProxy = ( + proxy?: InputDomainSetting["proxy"] +): O.Option["proxy"]> => + pipe( + O.fromNullable(proxy), + O.chain((proxy) => { + if (!proxy.url) return O.none + + const auth = proxy.auth && { + username: proxy.auth.username || "", + password: proxy.auth.password || "", + } + + return pipe( + O.fromNullable(proxy.certificates), + O.chain((certificates) => + pipe( + O.Do, + O.bind("client", () => convertClientCert(certificates.client)), + O.bind("ca", () => convertCaCerts(certificates.ca)), + O.map((certs) => ({ + client: certs.client, + ca: certs.ca, + })) + ) + ), + O.fold( + () => + O.some({ + url: proxy.url, + ...(auth && { auth }), + }), + (certificates) => + O.some({ + url: proxy.url, + ...(auth && { auth }), + certificates, + }) + ) + ) + }) + ) + +const convertOptions = ( + options?: InputDomainSetting["options"] +): O.Option["options"]> => + pipe( + O.fromNullable(options), + O.map((opts) => ({ + ...(opts.followRedirects !== undefined && { + followRedirects: opts.followRedirects, + }), + ...(opts.maxRedirects !== undefined && { + maxRedirects: Math.min(opts.maxRedirects, 10), + }), + ...(opts.timeout !== undefined && { timeout: opts.timeout }), + ...(opts.decompress !== undefined && { decompress: opts.decompress }), + ...(opts.cookies !== undefined && { cookies: opts.cookies }), + ...(opts.keepAlive !== undefined && { keepAlive: opts.keepAlive }), + })), + O.filter((opts) => Object.keys(opts).length > 0) + ) + +export const convertDomainSetting = ( + input: InputDomainSetting +): E.Either> => { + if (input.version !== "v1") { + return E.left(new Error("Invalid version")) + } + + const security = convertSecurity(input.security) + const proxy = convertProxy(input.proxy) + const options = convertOptions(input.options) + + const result: Pick = { + proxy: O.isSome(proxy) ? proxy.value : undefined, + security: O.isSome(security) ? security.value : undefined, + meta: O.isSome(options) ? { options: options.value } : undefined, + } + + return E.right(result) +} diff --git a/packages/hoppscotch-common/src/helpers/functional/error.ts b/packages/hoppscotch-common/src/helpers/functional/error.ts new file mode 100644 index 0000000..f91956a --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/functional/error.ts @@ -0,0 +1,3 @@ +export const throwError = (message: string): never => { + throw new Error(message) +} diff --git a/packages/hoppscotch-common/src/helpers/functional/files.ts b/packages/hoppscotch-common/src/helpers/functional/files.ts new file mode 100644 index 0000000..9a1a869 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/functional/files.ts @@ -0,0 +1,19 @@ +import * as TO from "fp-ts/TaskOption" + +export const readFileAsText = (file: File) => + TO.tryCatch( + () => + new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.onload = () => { + resolve(reader.result as string) + } + + reader.onerror = () => { + reject(new Error("File err")) + } + + reader.readAsText(file) + }) + ) diff --git a/packages/hoppscotch-common/src/helpers/functional/filter-active.ts b/packages/hoppscotch-common/src/helpers/functional/filter-active.ts new file mode 100644 index 0000000..15f5637 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/functional/filter-active.ts @@ -0,0 +1,20 @@ +import { HoppRESTRequestVariables } from "@hoppscotch/data" +import { pipe } from "fp-ts/function" +import * as A from "fp-ts/Array" + +export const filterActiveToRecord = ( + variables: HoppRESTRequestVariables +): Record => + pipe( + variables, + A.filter((variable) => variable.active), + A.map((variable): [string, string] => [variable.key, variable.value]), + (entries) => Object.fromEntries(entries) + ) + +export const filterActiveParams = (params: HoppRESTRequestVariables) => + pipe( + params, + A.filter((param) => param.active), + A.map((param): [string, string] => [param.key, param.value]) + ) diff --git a/packages/hoppscotch-common/src/helpers/functional/formData.ts b/packages/hoppscotch-common/src/helpers/functional/formData.ts new file mode 100644 index 0000000..4aa43f2 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/functional/formData.ts @@ -0,0 +1,27 @@ +type FormDataEntry = { + key: string + contentType?: string + value: string | Blob +} + +export const toFormData = (values: FormDataEntry[]) => { + const formData = new FormData() + + values.forEach(({ key, value, contentType }) => { + if (contentType) { + formData.append( + key, + new Blob([value], { + type: contentType, + }), + key + ) + + return + } + + formData.append(key, value) + }) + + return formData +} diff --git a/packages/hoppscotch-common/src/helpers/functional/json.ts b/packages/hoppscotch-common/src/helpers/functional/json.ts new file mode 100644 index 0000000..fb9a996 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/functional/json.ts @@ -0,0 +1,75 @@ +import * as O from "fp-ts/Option" +import * as E from "fp-ts/Either" +import { pipe, flow } from "fp-ts/function" + +import { MediaType, RelayResponseBody } from "@hoppscotch/kernel" + +import { decodeToString } from "~/helpers/functional/parse" +import { safeParseJSONOrYAML } from "./yaml" + +type SafeParseJSON = { + (str: string, convertToArray: true): O.Option> + (str: string, convertToArray?: false): O.Option> +} + +/** + * Checks and Parses JSON string + * @param str Raw JSON data to be parsed + * @returns Option type with some(JSON data) or none + */ +export const safeParseJSON: SafeParseJSON = (str, convertToArray = false) => + O.tryCatch(() => { + const data = JSON.parse(str) + if (convertToArray) { + return Array.isArray(data) ? data : [data] + } + return data + }) + +/** + * Generates a prettified JSON representation of an object + * @param obj The object to get the representation of + * @returns The prettified JSON string of the object + */ +export const prettyPrintJSON = (obj: unknown): O.Option => + O.tryCatch(() => JSON.stringify(obj, null, "\t")) + +/** + * Checks if given string is a JSON string + * @param str Raw string to be checked + * @returns If string is a JSON string + */ +export const isJSON = flow(safeParseJSON, O.isSome) + +export const parseBytesToJSON = (content: Uint8Array): O.Option => + pipe( + content, + decodeToString, + E.chain(parseJSONAs), + E.fold(() => O.none, O.some) + ) + +export const parseJSONAs = (str: string): E.Either => + E.tryCatch(() => JSON.parse(str) as T, E.toError) + +export const parseBodyAsJSON = (body: RelayResponseBody): O.Option => + pipe( + O.fromNullable(body.mediaType), + O.filter((type) => type.includes(MediaType.APPLICATION_JSON)), + O.chain(() => parseBytesToJSON(body.body)) + ) + +/** + * Parses response body as JSON or YAML content + * @param body Response body from RelayResponse + * @returns Option containing parsed data or none if parsing fails + */ +export const parseBodyAsJSONOrYAML = ( + body: RelayResponseBody +): O.Option => + pipe( + body.body, + decodeToString, + E.fold(() => O.none, safeParseJSONOrYAML), + O.map((data) => data as T) + ) diff --git a/packages/hoppscotch-common/src/helpers/functional/object.ts b/packages/hoppscotch-common/src/helpers/functional/object.ts new file mode 100644 index 0000000..824dc3f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/functional/object.ts @@ -0,0 +1,65 @@ +import { pipe } from "fp-ts/function" +import { isEqual, cloneDeep } from "lodash-es" +import { JSPrimitive, TypeFromPrimitive } from "./primtive" + +export const objRemoveKey = + (key: K) => + (obj: T): Omit => + pipe(cloneDeep(obj), (e) => { + delete e[key] + return e + }) + +export const objFieldMatches = + ( + fieldName: K, + matches: ReadonlyArray + ) => + // eslint-disable-next-line no-unused-vars + (obj: T): obj is T & { [_ in K]: V } => + matches.findIndex((x) => isEqual(obj[fieldName], x)) !== -1 + +export const objHasProperty = + ( + prop: K, + type?: P + ) => + // eslint-disable-next-line + (obj: O): obj is O & { [_ in K]: TypeFromPrimitive

} => + // eslint-disable-next-line + prop in obj && (type === undefined || typeof (obj as any)[prop] === type) + +type TypeFromPrimitiveArray

= + P extends "undefined" + ? undefined + : P extends "object" + ? object[] | null + : P extends "boolean" + ? boolean[] + : P extends "number" + ? number[] + : P extends "bigint" + ? bigint[] + : P extends "string" + ? string[] + : P extends "symbol" + ? symbol[] + : P extends "function" + ? Function[] + : unknown[] + +// The ban-types silence is because in this case, +// we can't get the Function type info to make a better guess + +export const objHasArrayProperty = + ( + prop: K, + type: P + ) => + // eslint-disable-next-line + (obj: O): obj is O & { [_ in K]: TypeFromPrimitiveArray

} => + prop in obj && + Array.isArray((obj as any)[prop]) && + (obj as any)[prop].every( + (val: unknown) => typeof val === type // eslint-disable-line + ) diff --git a/packages/hoppscotch-common/src/helpers/functional/option.ts b/packages/hoppscotch-common/src/helpers/functional/option.ts new file mode 100644 index 0000000..2e7bef1 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/functional/option.ts @@ -0,0 +1,19 @@ +import * as O from "fp-ts/Option" +import * as A from "fp-ts/Array" +import { pipe } from "fp-ts/function" + +/** + * Tries to match one of the given predicates. + * If a predicate is matched, the associated value is returned in a Some. + * Else if none of the predicates is matched, None is returned. + * @param choice An array of tuples having a predicate function and the selected value + * @returns A function which takes the input and returns an Option + */ +export const optionChoose = + (choice: Array<[(x: T) => boolean, V]>) => + (input: T): O.Option => + pipe( + choice, + A.findFirst(([pred]) => pred(input)), + O.map(([, value]) => value) + ) diff --git a/packages/hoppscotch-common/src/helpers/functional/parse.ts b/packages/hoppscotch-common/src/helpers/functional/parse.ts new file mode 100644 index 0000000..01f1223 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/functional/parse.ts @@ -0,0 +1,7 @@ +import * as E from "fp-ts/Either" + +export const decodeToString = (content: Uint8Array): E.Either => + E.tryCatch( + () => new TextDecoder("utf-8").decode(content).replace(/\x00/g, ""), + E.toError + ) diff --git a/packages/hoppscotch-common/src/helpers/functional/primtive.ts b/packages/hoppscotch-common/src/helpers/functional/primtive.ts new file mode 100644 index 0000000..54173d7 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/functional/primtive.ts @@ -0,0 +1,37 @@ +export type JSPrimitive = + | "undefined" + | "object" + | "boolean" + | "number" + | "bigint" + | "string" + | "symbol" + | "function" + +export type TypeFromPrimitive

= + P extends "undefined" + ? undefined + : P extends "object" + ? object | null // typeof null === "object" + : P extends "boolean" + ? boolean + : P extends "number" + ? number + : P extends "bigint" + ? bigint + : P extends "string" + ? string + : P extends "symbol" + ? symbol + : P extends "function" + ? Function + : unknown + +// The ban-types silence is because in this case, +// we can't get the Function type info to make a better guess + +export const isOfType = + (type: T) => + (value: unknown): value is T => + // eslint-disable-next-line valid-typeof + typeof value === type diff --git a/packages/hoppscotch-common/src/helpers/functional/process-request.ts b/packages/hoppscotch-common/src/helpers/functional/process-request.ts new file mode 100644 index 0000000..9894999 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/functional/process-request.ts @@ -0,0 +1,97 @@ +import type { RelayRequest } from "@hoppscotch/kernel" +import * as E from "fp-ts/Either" +import { pipe } from "fp-ts/function" +import * as O from "fp-ts/Option" +import { cloneDeep } from "lodash-es" +import superjson from "superjson" + +import { useSetting } from "~/composables/settings" + +const isEncoded = (value: string): boolean => + pipe( + E.tryCatch( + () => value !== decodeURIComponent(value), + () => false + ), + E.getOrElse(() => false) + ) + +const encodeParam = (value: string): string => + pipe( + O.some(value), + O.filter((v) => !isEncoded(v)), + O.map(encodeURIComponent), + O.getOrElse(() => value) + ) + +const processParams = (params: [string, string][]): [string, string][] => { + const encodeMode = useSetting("ENCODE_MODE").value + + const needsEncoding = (v: string) => + /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]+/.test(v) + + return params.map(([key, value]) => { + const isEncodingRequired = + encodeMode === "enable" || (encodeMode === "auto" && needsEncoding(value)) + + const encodedValue = isEncodingRequired ? encodeParam(value) : value + + return [key, encodedValue] + }) +} + +const buildQueryString = (params: [string, string][]): string => + params.map(([k, v]) => `${encodeURIComponent(k)}=${v}`).join("&") + +const combineWithExistingSearch = (urlObj: URL, queryString: string): URL => { + const existingSearch = + urlObj.search.length > 1 ? urlObj.search.substring(1) : "" + + urlObj.search = pipe( + existingSearch, + O.fromPredicate((s) => s.length > 0), + O.map((s) => `${s}&${queryString}`), + O.getOrElse(() => queryString) + ) + + return urlObj +} + +const updateUrl = ( + url: string, + params: [string, string][] +): E.Either => + pipe( + E.tryCatch( + () => new URL(url), + (e) => new Error(`Invalid URL: ${e}`) + ), + E.map((urlObj) => { + const processedParams = processParams(params) + + if (processedParams.length > 0) { + const queryString = buildQueryString(processedParams) + return combineWithExistingSearch(urlObj, queryString) + } + + return urlObj + }), + E.map((u) => u.toString()) + ) + +export const preProcessRelayRequest = (req: RelayRequest): RelayRequest => + pipe(cloneDeep(req), (req) => + req.params + ? pipe( + updateUrl(req.url, req.params), + E.map((url) => ({ ...req, url, params: {} })), + E.getOrElse(() => req) + ) + : req + ) + +export const postProcessRelayRequest = (req: RelayRequest): RelayRequest => { + const result = pipe(cloneDeep(req), (req) => superjson.serialize(req).json) + + return result +} diff --git a/packages/hoppscotch-common/src/helpers/functional/record.ts b/packages/hoppscotch-common/src/helpers/functional/record.ts new file mode 100644 index 0000000..d23abe5 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/functional/record.ts @@ -0,0 +1,44 @@ +/** + * Converts an array of key-value tuples (for e.g ["key", "value"]), into a record. + * (for eg. output -> { "key": "value" }) + * NOTE: This function will discard duplicate key occurrences and only keep the last occurrence. If you do not want that behaviour, + * use `tupleWithSamesKeysToRecord`. + * @param tuples Array of tuples ([key, value]) + * @returns A record with value corresponding to the last occurrence of that key + */ +export const tupleToRecord = < + KeyType extends string | number | symbol, + ValueType, +>( + tuples: [KeyType, ValueType][] +): Record => + tuples.length > 0 + ? (Object.assign as any)(...tuples.map(([key, val]) => ({ [key]: val }))) // This is technically valid, but we have no way of telling TypeScript it is valid. Hence the assertion + : {} + +/** + * Converts an array of key-value tuples (for e.g ["key", "value"]), into a record. + * (for eg. output -> { "key": ["value"] }) + * NOTE: If you do not want the array as values (because of duplicate keys) and want to instead get the last occurrence, use `tupleToRecord` + * @param tuples Array of tuples ([key, value]) + * @returns A Record with values being arrays corresponding to each key occurrence + */ +export const tupleWithSameKeysToRecord = < + KeyType extends string | number | symbol, + ValueType, +>( + tuples: [KeyType, ValueType][] +): Record => { + // By the end of the function we do ensure this typing, this can't be inferred now though, hence the assertion + const out = {} as Record + + for (const [key, value] of tuples) { + if (!out[key]) { + out[key] = [value] + } else { + out[key].push(value) + } + } + + return out +} diff --git a/packages/hoppscotch-common/src/helpers/functional/taskEither.ts b/packages/hoppscotch-common/src/helpers/functional/taskEither.ts new file mode 100644 index 0000000..bd88d65 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/functional/taskEither.ts @@ -0,0 +1,13 @@ +import * as TE from "fp-ts/TaskEither" + +/** + * A utility type which gives you the type of the left value of a TaskEither + */ +export type TELeftType> = + T extends TE.TaskEither< + infer U, + // eslint-disable-next-line + infer _ + > + ? U + : never diff --git a/packages/hoppscotch-common/src/helpers/functional/yaml.ts b/packages/hoppscotch-common/src/helpers/functional/yaml.ts new file mode 100644 index 0000000..76db0e7 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/functional/yaml.ts @@ -0,0 +1,16 @@ +import yaml from "js-yaml" +import * as O from "fp-ts/Option" +import { safeParseJSON } from "./json" +import { pipe } from "fp-ts/function" + +export const safeParseYAML = (str: string) => O.tryCatch(() => yaml.load(str)) + +export const safeParseJSONOrYAML = (str: string) => + pipe( + str, + safeParseJSON, + O.match( + () => safeParseYAML(str), + (data) => O.of(data) + ) + ) diff --git a/packages/hoppscotch-common/src/helpers/gist.ts b/packages/hoppscotch-common/src/helpers/gist.ts new file mode 100644 index 0000000..2fad46a --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/gist.ts @@ -0,0 +1,29 @@ +import axios from "axios" +import * as TE from "fp-ts/TaskEither" + +export const createGist = ( + content: string, + filename: string, + accessToken: string +) => { + return TE.tryCatch( + async () => + axios.post( + "https://api.github.com/gists", + { + files: { + [filename]: { + content: content, + }, + }, + }, + { + headers: { + Authorization: `token ${accessToken}`, + Accept: "application/vnd.github.v3+json", + }, + } + ), + (reason) => reason + ) +} diff --git a/packages/hoppscotch-common/src/helpers/globalEnvShape.ts b/packages/hoppscotch-common/src/helpers/globalEnvShape.ts new file mode 100644 index 0000000..756eaba --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/globalEnvShape.ts @@ -0,0 +1,44 @@ +import { + GLOBAL_ENV_LATEST_VERSION, + type GlobalEnvironment, +} from "@hoppscotch/data" + +/** + * Coerce any value to a valid latest-version `GlobalEnvironment` wrapper. + * Accepts the wrapper itself, a bare variables array (legacy/older + * backends), or malformed input. Passes the wrapper through identity- + * preserving when `v` already matches; schema migration is verzod's job + * on the load path, not this dispatcher boundary. + */ +export const coerceGlobalEnvironment = ( + entries: unknown +): GlobalEnvironment => { + if (Array.isArray(entries)) { + return { + v: GLOBAL_ENV_LATEST_VERSION, + variables: entries, + } as GlobalEnvironment + } + if ( + entries && + typeof entries === "object" && + "variables" in entries && + Array.isArray((entries as { variables: unknown }).variables) + ) { + const wrapper = entries as { + v?: unknown + variables: GlobalEnvironment["variables"] + } + if (wrapper.v === GLOBAL_ENV_LATEST_VERSION) { + return wrapper as GlobalEnvironment + } + return { + v: GLOBAL_ENV_LATEST_VERSION, + variables: wrapper.variables, + } as GlobalEnvironment + } + return { + v: GLOBAL_ENV_LATEST_VERSION, + variables: [], + } as GlobalEnvironment +} diff --git a/packages/hoppscotch-common/src/helpers/graphql/connection.ts b/packages/hoppscotch-common/src/helpers/graphql/connection.ts new file mode 100644 index 0000000..66b0512 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/graphql/connection.ts @@ -0,0 +1,666 @@ +import { + HoppGQLAuth, + HoppGQLRequest, + HoppRESTHeaders, + makeGQLRequest, +} from "@hoppscotch/data" +import { OperationType } from "@urql/core" +import { AwsV4Signer } from "aws4fetch" +import * as E from "fp-ts/Either" +import { + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLObjectType, + GraphQLSchema, + buildClientSchema, + getIntrospectionQuery, + printSchema, +} from "graphql" +import { clone } from "lodash-es" +import { Component, computed, reactive, ref } from "vue" +import { useToast } from "~/composables/toast" +import { getService } from "~/modules/dioc" +import { getI18n } from "~/modules/i18n" + +import { addGraphqlHistoryEntry, makeGQLHistoryEntry } from "~/newstore/history" + +import { KernelInterceptorService } from "~/services/kernel-interceptor.service" +import { GQLTabService } from "~/services/tab/graphql" + +import { MediaType, content, Method, RelayRequest } from "@hoppscotch/kernel" +import { GQLRequest } from "~/helpers/kernel/gql/request" +import { GQLResponse } from "~/helpers/kernel/gql/response" + +const GQL_SCHEMA_POLL_INTERVAL = 7000 + +type ConnectionRequestOptions = { + url: string + request: HoppGQLRequest + inheritedHeaders: HoppGQLRequest["headers"] + inheritedAuth?: HoppGQLAuth +} + +type RunQueryOptions = { + name?: string + url: string + request: HoppGQLRequest + inheritedHeaders: HoppGQLRequest["headers"] + inheritedAuth?: HoppGQLAuth + query: string + variables: string + operationName: string | undefined + operationType: OperationType +} + +export type GQLResponseEvent = + | { + type: "response" + time: number + operationName: string | undefined + operationType: OperationType + data: string + rawQuery?: RunQueryOptions + document?: { + type: string + statusCode: number + statusText: string + meta: { + responseSize: number + responseDuration: number + } + } + } + | { + type: "error" + error: { + type: string + message: string + component?: Component + } + } + +export type ConnectionState = + | "CONNECTING" + | "CONNECTED" + | "DISCONNECTED" + | "ERROR" +export type SubscriptionState = "SUBSCRIBING" | "SUBSCRIBED" | "UNSUBSCRIBED" + +const GQL = { + CONNECTION_INIT: "connection_init", + CONNECTION_ACK: "connection_ack", + CONNECTION_ERROR: "connection_error", + CONNECTION_KEEP_ALIVE: "ka", + START: "start", + STOP: "stop", + CONNECTION_TERMINATE: "connection_terminate", + DATA: "data", + ERROR: "error", + COMPLETE: "complete", +} + +type Connection = { + state: ConnectionState + subscriptionState: Map + socket: WebSocket | undefined + schema: GraphQLSchema | null + error?: { + type: string + message: (t: ReturnType) => string + component?: Component + } | null +} + +const tabs = getService(GQLTabService) +const currentTabID = computed(() => tabs.currentTabID.value) + +export const connection = reactive({ + state: "DISCONNECTED", + subscriptionState: new Map(), + socket: undefined, + schema: null, + error: null, +}) + +export const schema = computed(() => connection.schema) +export const subscriptionState = computed(() => + connection.subscriptionState.get(currentTabID.value) +) + +export const gqlMessageEvent = ref() + +export const schemaString = computed(() => { + if (!connection.schema || !(connection.schema instanceof GraphQLSchema)) + return "" + return printSchema(connection.schema) +}) + +export const queryFields = computed(() => { + if (!connection.schema || !(connection.schema instanceof GraphQLSchema)) + return [] + const fields = connection.schema.getQueryType()?.getFields() + return fields ? Object.values(fields) : [] +}) + +export const mutationFields = computed(() => { + if (!connection.schema || !(connection.schema instanceof GraphQLSchema)) + return [] + const fields = connection.schema.getMutationType()?.getFields() + return fields ? Object.values(fields) : [] +}) + +export const subscriptionFields = computed(() => { + if (!connection.schema || !(connection.schema instanceof GraphQLSchema)) + return [] + const fields = connection.schema.getSubscriptionType()?.getFields() + return fields ? Object.values(fields) : [] +}) + +export const graphqlTypes = computed(() => { + if (!connection.schema || !(connection.schema instanceof GraphQLSchema)) + return [] + + const typeMap = connection.schema.getTypeMap() + const queryTypeName = connection.schema.getQueryType()?.name ?? "" + const mutationTypeName = connection.schema.getMutationType()?.name ?? "" + const subscriptionTypeName = + connection.schema.getSubscriptionType()?.name ?? "" + + return Object.values(typeMap).filter((type) => { + return ( + !type.name.startsWith("__") && + ![queryTypeName, mutationTypeName, subscriptionTypeName].includes( + type.name + ) && + (type instanceof GraphQLObjectType || + type instanceof GraphQLInputObjectType || + type instanceof GraphQLEnumType || + type instanceof GraphQLInterfaceType) + ) + }) +}) + +let timeoutSubscription: any + +export const connect = async ( + options: ConnectionRequestOptions, + isRunGQLOperation = false +) => { + if (connection.state === "CONNECTED") { + throw new Error( + "A connection is already running. Close it before starting another." + ) + } + + const toast = useToast() + const t = getI18n() + + connection.state = "CONNECTING" + + const poll = async () => { + try { + await getSchema(options) + if (connection.state !== "CONNECTED") connection.state = "CONNECTED" + timeoutSubscription = setTimeout(() => { + poll() + }, GQL_SCHEMA_POLL_INTERVAL) + } catch (error) { + connection.state = "ERROR" + + if (!isRunGQLOperation) { + toast.error(t("graphql.connection_error_http")) + } + + console.error(error) + } + } + + await poll() +} + +export const disconnect = () => { + if (connection.state !== "CONNECTED") { + throw new Error("No connections are running to be disconnected") + } + + clearTimeout(timeoutSubscription) + connection.state = "DISCONNECTED" + connection.schema = null +} + +export const reset = () => { + if (connection.state === "CONNECTED") disconnect() + + connection.state = "DISCONNECTED" + connection.schema = null +} + +const getSchema = async (options: ConnectionRequestOptions) => { + try { + const { url, request, inheritedHeaders, inheritedAuth } = options + + const headers = request?.headers || [] + + const auth = + request?.auth.authType === "inherit" && request.auth.authActive + ? clone(inheritedAuth) + : clone(request.auth) + + let runHeaders: HoppGQLRequest["headers"] = [] + + if (inheritedHeaders) { + runHeaders = [ + ...inheritedHeaders, + ...clone(request.headers), + ] as HoppRESTHeaders + } else { + runHeaders = clone(request.headers) + } + + const finalHeaders: Record = {} + + const { authHeaders } = await generateAuthHeader(url, auth) + + runHeaders.forEach((header) => { + if (header.active && header.key !== "") { + finalHeaders[header.key] = header.value + } + }) + Object.assign(finalHeaders, authHeaders) + + headers + .filter((item) => item.active && item.key !== "") + .forEach(({ key, value }) => (finalHeaders[key] = value)) + + const kernelRequest: RelayRequest = { + id: Date.now(), + url: options.url, + method: "POST" as Method, + version: "HTTP/1.1", + headers: { + ...finalHeaders, + "content-type": "application/json", + }, + content: content.json( + { query: getIntrospectionQuery() }, + MediaType.APPLICATION_JSON + ), + } + + const kernelInterceptorService = getService(KernelInterceptorService) + const { response } = kernelInterceptorService.execute(kernelRequest) + + const res = await response + + if (E.isLeft(res)) { + connection.state = "ERROR" + + if (res.left !== "cancellation" && typeof res.left === "object") { + connection.error = { + type: res.left.error?.kind || "error", + message: (t: ReturnType) => { + if (res.left !== "cancellation" && typeof res.left === "object") { + return ( + res.left.humanMessage?.description(t) || + t("graphql.connection_error_http") + ) + } + return "Unknown" + }, + component: res.left.component, + } + } + + throw new Error( + typeof res.left === "string" ? res.left : res.left.error.message + ) + } + + const data = res.right + + const decoder = new TextDecoder("utf-8") + const responseText = decoder.decode(data.body.body) + + const introspectResponse = JSON.parse(responseText) + + const schemaData = buildClientSchema(introspectResponse.data) + + connection.schema = schemaData + connection.error = null + } catch (e: any) { + console.error(e) + disconnect() + } +} + +export const runGQLOperation = async (options: RunQueryOptions) => { + if (connection.state !== "CONNECTED") { + await connect( + { + url: options.url, + request: options.request, + inheritedHeaders: options.inheritedHeaders, + inheritedAuth: options.inheritedAuth, + }, + true + ) + } + + const { + url, + request, + query, + variables, + operationName, + inheritedHeaders, + inheritedAuth, + operationType, + } = options + + const headers = request?.headers || [] + + const auth = + request?.auth.authType === "inherit" && request.auth.authActive + ? clone(inheritedAuth) + : clone(request.auth) + + let runHeaders: HoppGQLRequest["headers"] = [] + + if (inheritedHeaders) { + runHeaders = [ + ...inheritedHeaders, + ...clone(request.headers), + ] as HoppRESTHeaders + } else { + runHeaders = clone(request.headers) + } + + const finalHeaders: Record = {} + + const { authHeaders, authParams } = await generateAuthHeader(url, auth) + + let finalUrl = url + if (Object.keys(authParams).length > 0) { + const urlObj = new URL(url) + for (const [key, value] of Object.entries(authParams)) { + urlObj.searchParams.append(key, value) + } + finalUrl = urlObj.toString() + } + + runHeaders.forEach((header) => { + if (header.active && header.key !== "") { + finalHeaders[header.key] = header.value + } + }) + Object.assign(finalHeaders, authHeaders) + + headers + .filter((item) => item.active && item.key !== "") + .forEach(({ key, value }) => (finalHeaders[key] = value)) + + const finalHoppHeaders: HoppRESTHeaders = Object.entries(finalHeaders).map( + ([key, value]) => ({ + active: true, + key, + value, + description: "", + }) + ) + + const gqlRequest: HoppGQLRequest = { + v: 9, + name: options.name || "Untitled Request", + url: finalUrl, + headers: finalHoppHeaders, + query, + variables, + auth: auth ?? request.auth, + } + + if (operationType === "subscription") { + return runSubscription(options, finalHeaders) + } + + try { + const kernelRequest = await GQLRequest.toRequest(gqlRequest) + + if (operationName) { + if (kernelRequest.content?.kind === "json") { + const content = kernelRequest.content.content as any + content.operationName = operationName + kernelRequest.content.content = content + } + } + + const kernelInterceptorService = getService(KernelInterceptorService) + const { response } = kernelInterceptorService.execute(kernelRequest) + + const result = await response + + if (E.isLeft(result)) { + if (result.left !== "cancellation" && typeof result.left === "object") { + connection.error = { + type: result.left.error?.kind || "error", + message: (t: ReturnType) => { + if ( + result.left !== "cancellation" && + typeof result.left === "object" + ) { + return ( + result.left.humanMessage?.description(t) || + t("graphql.operation_error") + ) + } + return "Unknown" + }, + component: result.left.component, + } + } + + throw new Error( + typeof result.left === "string" + ? result.left + : result.left.error.message + ) + } + + const relayResponse = result.right + + const parsedResponse = await GQLResponse.toResponse(relayResponse, options) + + if (parsedResponse.type === "error") { + throw new Error(parsedResponse.error.message) + } + + const timeStart = Date.now() + const timeEnd = Date.now() + + gqlMessageEvent.value = { + ...parsedResponse, + document: { + type: "success", + statusCode: relayResponse.status, + statusText: relayResponse.statusText, + meta: { + responseSize: relayResponse.body.body.byteLength, + responseDuration: timeEnd - timeStart, + }, + }, + } + + addQueryToHistory(options, parsedResponse.data) + + return parsedResponse.data + } catch (error: any) { + gqlMessageEvent.value = { + type: "error", + error: { + type: "network_error", + message: error.message || "An unknown error occurred", + }, + } + + throw error + } +} + +const generateAuthHeader = async ( + url: string, + auth: HoppGQLAuth | undefined +) => { + const finalHeaders: Record = {} + const params: Record = {} + + if (auth?.authActive) { + if (auth.authType === "basic") { + const username = auth.username + const password = auth.password + finalHeaders.Authorization = `Basic ${btoa(`${username}:${password}`)}` + } else if (auth.authType === "bearer") { + finalHeaders.Authorization = `Bearer ${auth.token}` + } else if (auth.authType === "oauth-2") { + const { addTo } = auth + + if (addTo === "HEADERS") { + finalHeaders.Authorization = `Bearer ${auth.grantTypeInfo.token}` + } else if (addTo === "QUERY_PARAMS") { + params["access_token"] = auth.grantTypeInfo.token + } + } else if (auth.authType === "api-key") { + const { key, value, addTo } = auth + if (addTo === "HEADERS") { + finalHeaders[key] = value + } else if (addTo === "QUERY_PARAMS") { + params[key] = value + } + } else if (auth.authType === "aws-signature") { + const { accessKey, secretKey, region, serviceName, addTo, serviceToken } = + auth + + const currentDate = new Date() + const amzDate = currentDate.toISOString().replace(/[:-]|\.\d{3}/g, "") + + const signer = new AwsV4Signer({ + datetime: amzDate, + signQuery: addTo === "QUERY_PARAMS", + accessKeyId: accessKey, + secretAccessKey: secretKey, + region: region ?? "us-east-1", + service: serviceName, + url, + sessionToken: serviceToken, + }) + + const sign = await signer.sign() + + if (addTo === "HEADERS") { + sign.headers.forEach((v, k) => { + finalHeaders[k] = v + }) + } else if (addTo === "QUERY_PARAMS") { + for (const [k, v] of sign.url.searchParams) { + params[k] = v + } + } + } + } + + return { authHeaders: finalHeaders, authParams: params } +} + +export const runSubscription = ( + options: RunQueryOptions, + headers?: Record +) => { + const { url, query, operationName } = options + const wsUrl = url.replace(/^http/, "ws") + + connection.subscriptionState.set(currentTabID.value, "SUBSCRIBING") + + connection.socket = new WebSocket(wsUrl, "graphql-ws") + + connection.socket.onopen = (event) => { + console.log("WebSocket is open now.", event) + + connection.socket?.send( + JSON.stringify({ + type: GQL.CONNECTION_INIT, + payload: headers ?? {}, + }) + ) + + connection.socket?.send( + JSON.stringify({ + type: GQL.START, + id: "1", + payload: { query, operationName }, + }) + ) + } + + gqlMessageEvent.value = "reset" + + connection.socket.onmessage = (event) => { + const data = JSON.parse(event.data) + switch (data.type) { + case GQL.CONNECTION_ACK: { + connection.subscriptionState.set(currentTabID.value, "SUBSCRIBED") + break + } + case GQL.CONNECTION_ERROR: { + console.error(data.payload) + break + } + case GQL.CONNECTION_KEEP_ALIVE: { + break + } + case GQL.DATA: { + gqlMessageEvent.value = { + type: "response", + time: Date.now(), + operationName, + data: JSON.stringify(data.payload), + operationType: "subscription", + } + break + } + case GQL.COMPLETE: { + console.log("completed", data.id) + break + } + } + } + + connection.socket.onclose = (event) => { + console.log("WebSocket is closed now.", event) + connection.subscriptionState.set(currentTabID.value, "UNSUBSCRIBED") + } + + addQueryToHistory(options, "") + + return connection.socket +} + +export const socketDisconnect = () => { + connection.socket?.close() +} + +const addQueryToHistory = (options: RunQueryOptions, response: string) => { + const { name, url, request, query, variables } = options + addGraphqlHistoryEntry( + makeGQLHistoryEntry({ + request: makeGQLRequest({ + name: name ?? "Untitled Request", + url, + query, + headers: request.headers, + variables, + auth: request.auth as HoppGQLAuth, + }), + response, + star: false, + }) + ) +} diff --git a/packages/hoppscotch-common/src/helpers/graphql/default.ts b/packages/hoppscotch-common/src/helpers/graphql/default.ts new file mode 100644 index 0000000..88d2321 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/graphql/default.ts @@ -0,0 +1,33 @@ +import { parse, print } from "graphql" +import { HoppGQLRequest, GQL_REQ_SCHEMA_VERSION } from "@hoppscotch/data" + +const DEFAULT_QUERY = print( + parse( + ` + query Request { + method + url + headers { + key + value + } + } + `, + { allowLegacyFragmentVariables: true } + ) +) + +export const getDefaultGQLRequest = (): HoppGQLRequest => ({ + v: GQL_REQ_SCHEMA_VERSION, + name: "Untitled", + url: "https://echo.hoppscotch.io/graphql", + headers: [], + variables: `{ + "id": "1" +}`, + query: DEFAULT_QUERY, + auth: { + authType: "inherit", + authActive: true, + }, +}) diff --git a/packages/hoppscotch-common/src/helpers/graphql/document.ts b/packages/hoppscotch-common/src/helpers/graphql/document.ts new file mode 100644 index 0000000..de49524 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/graphql/document.ts @@ -0,0 +1,88 @@ +import { HoppGQLRequest } from "@hoppscotch/data" +import { GQLResponseEvent } from "./connection" +import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue" +import { HoppInheritedProperty } from "../types/HoppInheritedProperties" + +export type HoppGQLSaveContext = + | { + /** + * The origin source of the request + */ + originLocation: "user-collection" + /** + * Path to the request folder + */ + folderPath: string + /** + * Index to the request + */ + requestIndex: number + } + | { + /** + * The origin source of the request + */ + originLocation: "team-collection" + /** + * ID of the request in the team + */ + requestID: string + /** + * ID of the team + */ + teamID?: string + /** + * ID of the collection loaded + */ + collectionID?: string + } + | null + +/** + * Defines a live 'document' (something that is open and being edited) in the app + */ +export type HoppGQLDocument = { + /** + * The request as it is in the document + */ + request: HoppGQLRequest + + /** + * Whether the request has any unsaved changes + * (atleast as far as we can say) + */ + isDirty: boolean + + /** + * The cursor position in the document + */ + cursorPosition?: number + + /** + * Info about where this request should be saved. + * This contains where the request is originated from basically. + */ + saveContext?: HoppGQLSaveContext + + /** + * The response as it is in the document + * (if any) + */ + response?: GQLResponseEvent[] | null + + /** + * Response tab preference for the current tab's document + */ + responseTabPreference?: string + + /** + * Options tab preference for the current tab's document + */ + optionTabPreference?: GQLOptionTabs + + /** + * The inherited properties from the parent collection + * (if any) + */ + inheritedProperties?: HoppInheritedProperty +} diff --git a/packages/hoppscotch-common/src/helpers/graphql/eq.ts b/packages/hoppscotch-common/src/helpers/graphql/eq.ts new file mode 100644 index 0000000..8945be8 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/graphql/eq.ts @@ -0,0 +1,52 @@ +import * as Eq from "fp-ts/Eq" +import * as S from "fp-ts/string" +import isEqual from "lodash-es/isEqual" + +/* + * Eq-s are fp-ts an interface (type class) that defines how the equality + * of 2 values of a certain type are matched as equal + */ + +/** + * Create an Eq from a non-undefinable value and makes it accept undefined + * @param eq The non nullable Eq to add to + * @returns The updated Eq which accepts undefined + */ +export const undefinedEq = (eq: Eq.Eq): Eq.Eq => ({ + equals(x: T | undefined, y: T | undefined) { + if (x !== undefined && y !== undefined) { + return eq.equals(x, y) + } + + return x === undefined && y === undefined + }, +}) + +/** + * An Eq which compares by transforming based on a mapping function and then applying the Eq to it + * @param map The mapping function to map values to + * @param eq The Eq which takes the value which the map returns + * @returns An Eq which takes the input of the mapping function + */ +export const mapThenEq = (map: (x: A) => B, eq: Eq.Eq): Eq.Eq => ({ + equals(x: A, y: A) { + return eq.equals(map(x), map(y)) + }, +}) + +/** + * An Eq which checks equality of 2 string in a case insensitive way + */ +export const stringCaseInsensitiveEq: Eq.Eq = mapThenEq( + S.toLowerCase, + S.Eq +) + +/** + * An Eq that does equality check with Lodash's isEqual function + */ +export const lodashIsEqualEq: Eq.Eq = { + equals(x: any, y: any) { + return isEqual(x, y) + }, +} diff --git a/packages/hoppscotch-common/src/helpers/graphql/explorer.ts b/packages/hoppscotch-common/src/helpers/graphql/explorer.ts new file mode 100644 index 0000000..e2c7815 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/graphql/explorer.ts @@ -0,0 +1,225 @@ +import { ref, computed, h, VNode } from "vue" +import type { + GraphQLSchema, + GraphQLNamedType, + GraphQLField, + GraphQLInputField, + GraphQLArgument, + GraphQLType, +} from "graphql" +import { + isNamedType, + isObjectType, + isInputObjectType, + isScalarType, + isEnumType, + isInterfaceType, + isUnionType, + isNonNullType, + isListType, +} from "graphql" + +/** + * Represents a field definition in the GraphQL explorer + * Can be a field, input field, or argument + */ +export type ExplorerFieldDef = + | GraphQLField + | GraphQLInputField + | GraphQLArgument + +/** + * Represents a single item in the explorer navigation stack + */ +export type ExplorerNavStackItem = { + readonly?: boolean + name: string + def?: GraphQLNamedType | ExplorerFieldDef +} + +/** + * Represents the complete navigation stack for the explorer + * Must contain at least one item + */ +export type ExplorerNavStack = [ExplorerNavStackItem, ...ExplorerNavStackItem[]] + +const initialNavStackItem: ExplorerNavStackItem = { name: "Root" } + +const navStack = ref([initialNavStackItem]) +const schema = ref() +const validationErrors = ref([]) + +/** + * Hook to manage the GraphQL schema explorer state and navigation + * @param initialSchema - Optional initial GraphQL schema + * @returns Object containing explorer state and methods + */ +export function useExplorer(initialSchema?: GraphQLSchema) { + schema.value = initialSchema ?? null + + const currentNavItem = computed(() => { + const lastItem = navStack.value[navStack.value.length - 1] + return lastItem + }) + + /** + * Adds a new item to the navigation stack + * @param item - The navigation stack item to add + */ + function push(item: ExplorerNavStackItem) { + const lastItem = navStack.value[navStack.value.length - 1] + + // Avoid pushing duplicate items + if (lastItem.def === item.def) return + + navStack.value.push(lastItem.readonly ? { ...item, readonly: true } : item) + } + + /** + * Removes the last item from the navigation stack + * Won't remove the root item + */ + function pop() { + if (navStack.value.length > 1) { + navStack.value.pop() + } + } + + /** + * Resets the navigation stack to initial state + */ + function reset() { + navStack.value = + navStack.value.length === 1 ? navStack.value : [initialNavStackItem] + } + + /** + * Updates the schema and validation errors + * Rebuilds the navigation stack if needed + * @param newSchema - The new GraphQL schema + * @param newValidationErrors - Array of validation errors + */ + function updateSchema( + newSchema: GraphQLSchema, + newValidationErrors: any[] = [] + ) { + schema.value = newSchema + validationErrors.value = newValidationErrors + + // If schema is invalid, reset navigation + if (!newSchema || newValidationErrors.length > 0) { + reset() + return + } + + // Rebuild navigation stack with new schema + rebuildNavStack() + } + + /** + * Navigates to a specific index in the navigation stack + * @param index - Target index to navigate to + */ + const navigateToIndex = (index: number) => { + while (navStack.value.length > index + 1) { + pop() + } + } + + /** + * Rebuilds the navigation stack based on the current schema + * Used when schema is updated to maintain valid navigation + */ + function rebuildNavStack() { + if (!schema.value) return + + const newNavStack: ExplorerNavStack = [initialNavStackItem] + let lastEntity: GraphQLNamedType | GraphQLField | null = null + + for (const item of navStack.value.slice(1)) { + if (item.def) { + if (isNamedType(item.def)) { + const newType = schema.value.getType(item.def.name) + if (newType) { + newNavStack.push({ + name: item.name, + def: newType, + }) + lastEntity = newType + } else { + break + } + } else if (lastEntity === null) { + break + } else if (isObjectType(lastEntity) || isInputObjectType(lastEntity)) { + const field = lastEntity.getFields()[item.name] + if (field) { + newNavStack.push({ + name: item.name, + def: field, + }) + } else { + break + } + } else if ( + isScalarType(lastEntity) || + isEnumType(lastEntity) || + isInterfaceType(lastEntity) || + isUnionType(lastEntity) + ) { + break + } else { + const field: GraphQLField = lastEntity + const arg = field.args.find((a) => a.name === item.name) + + if (arg) { + newNavStack.push({ + name: item.name, + def: field, + }) + } else { + break + } + } + } else { + lastEntity = null + newNavStack.push(item) + } + } + + navStack.value = newNavStack + } + + return { + navStack, + currentNavItem, + schema, + validationErrors, + push, + pop, + navigateToIndex, + reset, + updateSchema, + } +} + +/** + * Recursively renders a GraphQL type as a Vue virtual node + * Handles non-null types, list types, and named types + * + * @param type - The GraphQL type to render + * @param renderNamedType - Function to render named types + * @returns VNode representing the rendered type + */ +export function renderType( + type: GraphQLType, + renderNamedType: (namedType: GraphQLNamedType) => any +): VNode { + if (isNonNullType(type)) { + return h("span", {}, [renderType(type.ofType, renderNamedType), "!"]) + } + if (isListType(type)) { + return h("span", {}, ["[", renderType(type.ofType, renderNamedType), "]"]) + } + return renderNamedType(type as GraphQLNamedType) +} diff --git a/packages/hoppscotch-common/src/helpers/graphql/index.ts b/packages/hoppscotch-common/src/helpers/graphql/index.ts new file mode 100644 index 0000000..1ce9b94 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/graphql/index.ts @@ -0,0 +1,42 @@ +import { HoppGQLRequest, ValidContentTypes } from "@hoppscotch/data" +import * as Eq from "fp-ts/Eq" +import * as N from "fp-ts/number" +import * as S from "fp-ts/string" +import { lodashIsEqualEq, mapThenEq, undefinedEq } from "./eq" + +export type FormDataKeyValue = { + key: string + active: boolean +} & ({ isFile: true; value: Blob[] } | { isFile: false; value: string }) + +export type HoppGQLReqBodyFormData = { + contentType: "multipart/form-data" + body: FormDataKeyValue[] +} + +export type HoppGQLReqBody = + | { + contentType: Exclude + body: string + } + | HoppGQLReqBodyFormData + | { + contentType: null + body: null + } + +export const HoppGQLRequestEq = Eq.struct({ + id: undefinedEq(S.Eq), + v: N.Eq, + name: S.Eq, + url: S.Eq, + headers: mapThenEq( + (arr) => arr.filter((h) => h.key !== "" && h.value !== ""), + lodashIsEqualEq + ), + query: S.Eq, + variables: S.Eq, + auth: lodashIsEqualEq, +}) + +export const isEqualHoppGQLRequest = HoppGQLRequestEq.equals diff --git a/packages/hoppscotch-common/src/helpers/graphql/query.ts b/packages/hoppscotch-common/src/helpers/graphql/query.ts new file mode 100644 index 0000000..3727276 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/graphql/query.ts @@ -0,0 +1,446 @@ +import { useService } from "dioc/vue" +import { + ArgumentNode, + DocumentNode, + FieldNode, + getNamedType, + GraphQLArgument, + GraphQLField, + GraphQLType, + Kind, + OperationDefinitionNode, + OperationTypeNode, + print, +} from "graphql" +import { ref } from "vue" +import { GQLTabService } from "~/services/tab/graphql" +import { ExplorerFieldDef, ExplorerNavStackItem, useExplorer } from "./explorer" + +/** + * Makes all properties in type T mutable + */ +type Mutable = { + -readonly [K in keyof T]: T[K] +} + +const updatedQuery = ref("") +const cursorPosition = ref({ line: 0, ch: 1 }) +const operations = ref([]) + +/** + * Hook to manage GraphQL query operations and mutations + * Provides functionality for building and modifying GraphQL operations + */ +export function useQuery() { + const tabs = useService(GQLTabService) + const { navStack } = useExplorer() + + /** + * Returns default value for a GraphQL type + * @param type - GraphQL type to get default value for + */ + const getDefaultArgumentValue = (type: GraphQLType): string => { + const namedType = getNamedType(type) + const defaultValues: Record = { + String: "", + Int: "0", + Float: "0.0", + Boolean: "false", + } + return defaultValues[namedType.name] || "null" + } + + /** + * Maps operation name to GraphQL operation type + * @param name - Operation name to convert + */ + const getOperationTypeNode = (name: string): OperationTypeNode => { + const operationTypes: Record = { + Query: OperationTypeNode.QUERY, + Mutation: OperationTypeNode.MUTATION, + Subscription: OperationTypeNode.SUBSCRIPTION, + } + return operationTypes[name] || OperationTypeNode.QUERY + } + + /** + * Finds operation definition node at given cursor position + * @param cursorPosition - Position to find operation at + */ + const getOperation = (cursorPosition: number) => { + return operations.value.find( + ({ loc }) => + loc && cursorPosition >= loc.start && cursorPosition <= loc.end + ) + } + + /** + * Creates an ArgumentNode with default value + * @param argName - Name of the argument + * @param type - GraphQL type of the argument + */ + const createArgumentNode = ( + argName: string, + type: GraphQLType + ): ArgumentNode => ({ + kind: Kind.ARGUMENT, + name: { kind: Kind.NAME, value: argName }, + value: { kind: Kind.STRING, value: getDefaultArgumentValue(type) }, + }) + + /** + * Creates a FieldNode with optional arguments and nested fields + * @param name - Field name + * @param args - Field arguments + * @param hasNestedFields - Whether field has nested selections + */ + const createFieldNode = ( + name: string, + args: readonly GraphQLArgument[] | undefined, + hasNestedFields = false + ): Mutable => ({ + kind: Kind.FIELD, + name: { kind: Kind.NAME, value: name }, + arguments: args?.map((arg) => createArgumentNode(arg.name, arg.type)) || [], + directives: [], + selectionSet: hasNestedFields + ? { kind: Kind.SELECTION_SET, selections: [] } + : undefined, + }) + + /** + * Result of processing an operation, including document and field location + */ + type OperationResult = { + append?: boolean + document: DocumentNode | null + fieldLocation?: { + start: number + end: number + } + } + + /** + * Processes GraphQL operation based on navigation stack + * Handles merging with existing operations and field/argument modifications + * + * @param navItems - Navigation stack items + * @param existingOperation - Existing operation to modify (if any) + * @param isArgument - Whether processing an argument + */ + const processOperation = ( + navItems: ExplorerNavStackItem[], + existingOperation?: OperationDefinitionNode, + isArgument = false + ): OperationResult => { + const queryPath = navItems.slice(2, isArgument ? -1 : undefined) + const argumentItem = isArgument ? navItems[navItems.length - 1] : null + const lastItem = queryPath[queryPath.length - 1] + const requestedOperationType = getOperationTypeNode(navItems[1].name) + + // Handle new operations + if ( + !existingOperation || + existingOperation.operation !== requestedOperationType + ) { + // Build from bottom up starting with the last field + let currentSelection = createFieldNode( + lastItem.name, + isArgument && argumentItem + ? [argumentItem.def as GraphQLArgument] + : (lastItem.def as GraphQLField)?.args, + lastItem.def && (lastItem.def as any)?.fields?.length > 0 + ) + + for (let i = queryPath.length - 2; i >= 0; i--) { + const item = queryPath[i] + const parentField = createFieldNode(item.name, item.def?.args, true) + parentField.selectionSet!.selections = [currentSelection] + currentSelection = parentField + } + + return { + document: { + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + operation: requestedOperationType, + name: { kind: Kind.NAME, value: queryPath[0].name }, + variableDefinitions: [], + directives: [], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [currentSelection], + }, + }, + ], + }, + } + } + + // For existing operations + let currentSelectionSet = existingOperation.selectionSet + let fieldLocation: { start: number; end: number } | undefined + let append = false + + if ( + requestedOperationType === OperationTypeNode.SUBSCRIPTION && + requestedOperationType === existingOperation?.operation + ) { + // Check if paths are different at the top level + const existingTopField = existingOperation.selectionSet + .selections[0] as FieldNode + + if ( + existingTopField.name.value !== + (queryPath && queryPath[0] && queryPath[0]?.name) + ) { + append = true + currentSelectionSet.selections = [] + } + } + + for (let i = 0; i < queryPath.length; i++) { + const item = queryPath[i] + const isLastItem = i === queryPath.length - 1 + + const existingFieldIndex = currentSelectionSet.selections.findIndex( + (selection): selection is FieldNode => + selection.kind === Kind.FIELD && selection.name.value === item.name + ) + + if (existingFieldIndex !== -1) { + const existingField = currentSelectionSet.selections[ + existingFieldIndex + ] as Mutable + + if (isLastItem) { + if (isArgument && argumentItem) { + // Handle argument modifications + const argIndex = + existingField.arguments?.findIndex( + (arg) => arg.name.value === argumentItem.name + ) ?? -1 + + if (argIndex !== -1) { + existingField.arguments?.splice(argIndex, 1) + } else { + const newArg = createArgumentNode( + argumentItem.name, + (argumentItem.def as any)?.type + ) + existingField.arguments = existingField.arguments || [] + existingField.arguments.push(newArg) + } + } else { + // Remove the field if it's not an argument operation + currentSelectionSet.selections.splice(existingFieldIndex, 1) + } + + if (existingField.loc) { + fieldLocation = { + start: existingField.loc.start, + end: existingField.loc.end, + } + } + break + } + + // Ensure parent has a selection set + if (!existingField.selectionSet) { + existingField.selectionSet = { + kind: Kind.SELECTION_SET, + selections: [], + } + } + + // Move to the next level + currentSelectionSet = existingField.selectionSet ?? { + kind: Kind.SELECTION_SET, + selections: [], + } + } else { + const newField = createFieldNode( + item.name, + isLastItem && isArgument && argumentItem + ? [argumentItem.def as GraphQLArgument] + : (item.def as GraphQLField)?.args, + !isLastItem || (isLastItem && (item.def as any)?.fields?.length > 0) + ) + + // Store the approximate location where field will be added + if (currentSelectionSet.loc) { + fieldLocation = { + start: currentSelectionSet.loc.end - 1, + end: currentSelectionSet.loc.end - 1, + } + } + + if (!isLastItem) { + // Ensure non-leaf nodes have a selection set + newField.selectionSet = { + kind: Kind.SELECTION_SET, + selections: [], + } + } + + ;(currentSelectionSet.selections as Mutable).push(newField) + + if (!isLastItem) { + // Move to the next level + currentSelectionSet = newField.selectionSet! + } + } + } + + return { + document: + existingOperation.selectionSet.selections.length === 0 + ? null + : { + kind: Kind.DOCUMENT, + definitions: [existingOperation], + }, + fieldLocation, + append, + } + } + + /** + * Handles operation modifications for fields and arguments + * Updates query and cursor position based on changes + * + * @param item - Field or argument to process + * @param isArgument - Whether item is an argument + */ + const handleOperation = (item: ExplorerFieldDef, isArgument = false) => { + const currentTab = tabs.currentActiveTab.value + if (!currentTab) return + + const currentQuery = currentTab.document.request.query || "" + const selectedOperation = getOperation( + currentTab.document.cursorPosition || 0 + ) + const navItems = [...navStack.value, { name: item.name, def: item }] + + const result = processOperation( + navItems as ExplorerNavStackItem[], + selectedOperation, + isArgument + ) + + const newQuery = result.document + ? print(result.document.definitions[0]) + : "\n" + + // If operation type is different or no existing operation, + // append as a new operation + if ( + !selectedOperation || + selectedOperation.operation !== getOperationTypeNode(navItems[1].name) || + result.append + ) { + updatedQuery.value = currentQuery.trim() + ? `${currentQuery}\n\n${newQuery}` + : newQuery + cursorPosition.value = { + line: currentQuery.split("\n").length + (currentQuery.trim() ? 2 : 1), + ch: -1, + } + return + } + + // Replace existing operation if operation types match + updatedQuery.value = currentQuery.replace( + currentQuery.substring( + selectedOperation.loc!.start, + selectedOperation.loc!.end + ), + newQuery + ) + + // Update cursor position to field location + if (result.fieldLocation) { + const precedingText = currentQuery.substring( + 0, + result.fieldLocation.start + ) + const lines = precedingText.split("\n") + cursorPosition.value = { + line: lines.length - 1, + ch: -1, + } + } + } + + const isFieldInOperation = (item: ExplorerFieldDef): boolean => { + const operation = getOperation( + tabs.currentActiveTab.value?.document.cursorPosition || 0 + ) + if (!operation) return false + + // Get the current navigation path (excluding root and operation type) + const navPath = navStack.value.slice(2) + + // Start from the operation's selection set + let currentSelections = operation.selectionSet.selections + + // Follow the navigation path + for (let i = 0; i < navPath.length; i++) { + const pathItem = navPath[i] + const foundField = currentSelections.find( + (selection) => + selection.kind === Kind.FIELD && + selection.name.value === pathItem.name + ) as FieldNode | undefined + + if (!foundField?.selectionSet) return false + + currentSelections = foundField.selectionSet.selections + } + + // Check based on type + return currentSelections.some((selection) => { + if (selection.kind !== Kind.FIELD) return false + return selection.name.value === item.name + }) + } + + const isArgumentInOperation = (item: ExplorerFieldDef): boolean => { + const { cursorPosition } = tabs.currentActiveTab.value?.document + const operation = getOperation(cursorPosition) + if (!operation) return false + + // Start from the operation's selection set + let args: ArgumentNode[] = [] + + // change the currentSelections based on current Field by the cursor position + operation.selectionSet.selections.forEach((selection) => { + if (selection.kind === Kind.FIELD) { + const fieldNode = selection as FieldNode + if ( + fieldNode.loc && + fieldNode.loc.start <= cursorPosition && + fieldNode.loc.end >= cursorPosition + ) { + args = [...(fieldNode.arguments || [])] + } + } + }) + + if (args.length === 0) return false + + return args.some((arg) => arg.name.value === item.name) + } + + return { + handleAddField: (field: ExplorerFieldDef) => handleOperation(field), + handleAddArgument: (arg: ExplorerFieldDef) => handleOperation(arg, true), + updatedQuery, + cursorPosition, + operationDefinitions: operations, + isFieldInOperation, + isArgumentInOperation, + } +} diff --git a/packages/hoppscotch-common/src/helpers/handleTokenValidation.ts b/packages/hoppscotch-common/src/helpers/handleTokenValidation.ts new file mode 100644 index 0000000..04927bc --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/handleTokenValidation.ts @@ -0,0 +1,19 @@ +import { useToast } from "@composables/toast" +import { isValidUser } from "~/helpers/isValidUser" + +/** + * High-level authentication validation handler with automatic error notifications. + * + * This wrapper around `isValidUser()` provides automatic toast error messages for invalid tokens. + * Use this when you want standard error handling with user notifications. + * + * For silent validation or custom error handling, use `isValidUser()` directly. + * + * @returns {Promise} True if user is valid, false otherwise (with toast error shown) + */ +export const handleTokenValidation = async (): Promise => { + const toast = useToast() + const { valid, error } = await isValidUser() + if (!valid) toast.error(error) + return valid +} diff --git a/packages/hoppscotch-common/src/helpers/headers.ts b/packages/hoppscotch-common/src/helpers/headers.ts new file mode 100644 index 0000000..648eeb7 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/headers.ts @@ -0,0 +1,124 @@ +export const commonHeaders = [ + "WWW-Authenticate", + "Authorization", + "Proxy-Authenticate", + "Proxy-Authorization", + "Age", + "Cache-Control", + "Clear-Site-Data", + "Expires", + "Pragma", + "Warning", + "Accept-CH", + "Accept-CH-Lifetime", + "Early-Data", + "Content-DPR", + "DPR", + "Device-Memory", + "Save-Data", + "Viewport-Width", + "Width", + "Last-Modified", + "ETag", + "If-Match", + "If-None-Match", + "If-Modified-Since", + "If-Unmodified-Since", + "Vary", + "Connection", + "Keep-Alive", + "Accept", + "Accept-Charset", + "Accept-Encoding", + "Accept-Language", + "Expect", + "Max-Forwards", + "Cookie", + "Set-Cookie", + "Cookie2", + "Set-Cookie2", + "Access-Control-Allow-Origin", + "Access-Control-Allow-Credentials", + "Access-Control-Allow-Headers", + "Access-Control-Allow-Methods", + "Access-Control-Expose-Headers", + "Access-Control-Max-Age", + "Access-Control-Request-Headers", + "Access-Control-Request-Method", + "Origin", + "Service-Worker-Allowed", + "Timing-Allow-Origin", + "X-Permitted-Cross-Domain-Policies", + "DNT", + "Tk", + "Content-Disposition", + "Content-Length", + "Content-Type", + "Content-Encoding", + "Content-Language", + "Content-Location", + "Forwarded", + "X-Forwarded-For", + "X-Forwarded-Host", + "X-Forwarded-Proto", + "Via", + "Location", + "From", + "Host", + "Referer", + "Referrer-Policy", + "User-Agent", + "Allow", + "Server", + "Accept-Ranges", + "Range", + "If-Range", + "Content-Range", + "Cross-Origin-Opener-Policy", + "Cross-Origin-Resource-Policy", + "Content-Security-Policy", + "Content-Security-Policy-Report-Only", + "Expect-CT", + "Feature-Policy", + "Public-Key-Pins", + "Public-Key-Pins-Report-Only", + "Strict-Transport-Security", + "Upgrade-Insecure-Requests", + "X-Content-Type-Options", + "X-Download-Options", + "X-Frame-Options", + "X-Powered-By", + "X-XSS-Protection", + "Last-Event-ID", + "NEL", + "Ping-From", + "Ping-To", + "Report-To", + "Transfer-Encoding", + "TE", + "Trailer", + "Sec-WebSocket-Key", + "Sec-WebSocket-Extensions", + "Sec-WebSocket-Accept", + "Sec-WebSocket-Protocol", + "Sec-WebSocket-Version", + "Accept-Push-Policy", + "Accept-Signature", + "Alt-Svc", + "Date", + "Large-Allocation", + "Link", + "Push-Policy", + "Retry-After", + "Signature", + "Signed-Headers", + "Server-Timing", + "SourceMap", + "Upgrade", + "X-DNS-Prefetch-Control", + "X-Firefox-Spdy", + "X-Pingback", + "X-Requested-With", + "X-Robots-Tag", + "X-UA-Compatible", +] diff --git a/packages/hoppscotch-common/src/helpers/hopp-fetch.ts b/packages/hoppscotch-common/src/helpers/hopp-fetch.ts new file mode 100644 index 0000000..ff36b7c --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/hopp-fetch.ts @@ -0,0 +1,360 @@ +import * as E from "fp-ts/Either" +import type { HoppFetchHook, FetchCallMeta } from "@hoppscotch/js-sandbox" +import type { KernelInterceptorService } from "~/services/kernel-interceptor.service" +import type { RelayRequest } from "@hoppscotch/kernel" + +/** + * Creates a hopp.fetch() hook implementation for the web app. + * Routes fetch requests through the KernelInterceptorService to respect + * user's interceptor preference (browser/proxy/extension/native). + * + * @param kernelInterceptor - The kernel interceptor service instance + * @param onFetchCall - Optional callback to track fetch calls for inspector warnings + * @returns HoppFetchHook implementation + */ +export const createHoppFetchHook = ( + kernelInterceptor: KernelInterceptorService, + onFetchCall?: (meta: FetchCallMeta) => void +): HoppFetchHook => { + return async (input, init) => { + const urlStr = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url + const method = (init?.method || "GET").toUpperCase() + + // Track the fetch call for inspector warnings + onFetchCall?.({ + url: urlStr, + method, + timestamp: Date.now(), + }) + + // Convert Fetch API request to RelayRequest + const relayRequest = await convertFetchToRelayRequest(input, init) + + // Execute via interceptor + const execution = kernelInterceptor.execute(relayRequest) + const result = await execution.response + + if (E.isLeft(result)) { + const error = result.left + + const errorMessage = + typeof error === "string" + ? error + : typeof error === "object" && + error !== null && + "humanMessage" in error + ? typeof error.humanMessage.heading === "function" + ? error.humanMessage.heading(() => "Unknown error") + : "Unknown error" + : "Unknown error" + throw new Error(`Fetch failed: ${errorMessage}`) + } + + // Convert RelayResponse to serializable Response-like object + // Native Response objects can't cross VM boundaries + return convertRelayResponseToSerializableResponse(result.right) + } +} + +/** + * Converts Fetch API request to RelayRequest format + */ +async function convertFetchToRelayRequest( + input: RequestInfo | URL, + init?: RequestInit +): Promise { + const urlStr = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url + + // Extract method from Request object if available + const requestMethod = input instanceof Request ? input.method : undefined + const method = ( + init?.method || + requestMethod || + "GET" + ).toUpperCase() as RelayRequest["method"] + + // Convert headers - merge from Request object if present + const headers: Record = {} + + // First, add headers from Request object if input is a Request + if (input instanceof Request) { + input.headers.forEach((value, key) => { + headers[key] = value + }) + } + + // Then overlay with init.headers (takes precedence) + if (init?.headers) { + const headersObj = + init.headers instanceof Headers ? init.headers : new Headers(init.headers) + + headersObj.forEach((value, key) => { + headers[key] = value + }) + } + + // Handle body based on type + let content: RelayRequest["content"] | undefined + + // Check both init.body and Request body (init.body takes precedence) + // For Request objects, we need to clone and read the body since it's a stream + let bodyToUse: BodyInit | null | undefined + + if (init?.body !== undefined) { + bodyToUse = init.body + } else if (input instanceof Request && input.body !== null) { + // Clone the request to avoid consuming the original body + const clonedRequest = input.clone() + // Read the body as arrayBuffer to preserve binary data + // We'll convert to appropriate type based on content-type + const bodyBuffer = await clonedRequest.arrayBuffer() + + // Check content-type to determine if body is text or binary + const contentType = input.headers.get("content-type") || "" + const isTextContent = + contentType.includes("text/") || + contentType.includes("json") || + contentType.includes("xml") || + contentType.includes("javascript") || + contentType.includes("form-urlencoded") + + if (isTextContent) { + // Decode as text for text-based content types + const decoder = new TextDecoder() + bodyToUse = decoder.decode(bodyBuffer) + } else { + // Keep as ArrayBuffer for binary content + bodyToUse = bodyBuffer + } + } else { + bodyToUse = undefined + } + + if (bodyToUse) { + if (typeof bodyToUse === "string") { + // Headers API normalizes keys to lowercase during forEach iteration + const mediaType = headers["content-type"] || "text/plain" + + // Use "text" kind for string bodies (including JSON strings) + content = { + kind: "text", + content: bodyToUse, + mediaType, + } + } else if (bodyToUse instanceof FormData) { + content = { + kind: "multipart", + content: bodyToUse, + mediaType: "multipart/form-data", + } + } else if (bodyToUse instanceof URLSearchParams) { + // Handle URLSearchParams bodies + content = { + kind: "text", + content: bodyToUse.toString(), + mediaType: "application/x-www-form-urlencoded", + } + } else if (bodyToUse instanceof Blob) { + const arrayBuffer = await bodyToUse.arrayBuffer() + content = { + kind: "binary", + content: new Uint8Array(arrayBuffer), + mediaType: bodyToUse.type || "application/octet-stream", + } + } else if (bodyToUse instanceof ArrayBuffer) { + content = { + kind: "binary", + content: new Uint8Array(bodyToUse), + mediaType: "application/octet-stream", + } + } else if (ArrayBuffer.isView(bodyToUse)) { + content = { + kind: "binary", + content: new Uint8Array( + bodyToUse.buffer, + bodyToUse.byteOffset, + bodyToUse.byteLength + ), + mediaType: "application/octet-stream", + } + } + } + + const relayRequest = { + id: Math.floor(Math.random() * 1000000), // Random ID for tracking + url: urlStr, + method, + version: "HTTP/1.1", // HTTP version + headers, + params: undefined, // Undefined so preProcessRelayRequest doesn't try to process it + auth: { kind: "none" }, // Required field - no auth for fetch() + content, + // Note: auth, proxy, security are inherited from interceptor configuration + } + + return relayRequest +} + +/** + * Converts RelayResponse to a serializable Response-like object. + * + * Native Response objects can't cross the QuickJS boundary due to internal state. + * Returns a plain object with all data loaded upfront. + */ +function convertRelayResponseToSerializableResponse( + relayResponse: any +): Response { + const status = relayResponse.status || 200 + const statusText = relayResponse.statusText || "" + const ok = status >= 200 && status < 300 + + // Convert headers to plain object (serializable) + // Set-Cookie headers kept separate - commas can appear in cookie values + const headersObj: Record = {} + const setCookieHeaders: string[] = [] + + // Agent interceptor provides multiHeaders with Set-Cookie preserved separately + if (relayResponse.multiHeaders && Array.isArray(relayResponse.multiHeaders)) { + for (const header of relayResponse.multiHeaders) { + if (header.key.toLowerCase() === "set-cookie") { + setCookieHeaders.push(header.value) + } else { + headersObj[header.key] = header.value + } + } + } else if (relayResponse.headers) { + // Fallback for other interceptors: process regular headers + Object.entries(relayResponse.headers).forEach(([key, value]) => { + if (key.toLowerCase() === "set-cookie") { + // Preserve Set-Cookie headers as array for getSetCookie() compatibility + if (Array.isArray(value)) { + setCookieHeaders.push(...value) + } else { + setCookieHeaders.push(String(value)) + } + // Store first Set-Cookie for backward compatibility + headersObj[key] = Array.isArray(value) ? value[0] : String(value) + } else { + // Other headers can be safely used directly + headersObj[key] = String(value) + } + }) + } + + // Store body as plain array for VM serialization + let bodyBytes: number[] = [] + + // Extract body data - nested inside relayResponse.body.body + const actualBody = relayResponse.body?.body || relayResponse.body + + if (actualBody) { + if (Array.isArray(actualBody)) { + // Already an array + bodyBytes = actualBody + } else if (actualBody instanceof ArrayBuffer) { + // ArrayBuffer (used by Agent interceptor) - convert to plain array + bodyBytes = Array.from(new Uint8Array(actualBody)) + } else if (actualBody instanceof Uint8Array) { + // Array copy needed for VM serialization + bodyBytes = Array.from(actualBody) + } else if (ArrayBuffer.isView(actualBody)) { + // Other typed array + bodyBytes = Array.from(new Uint8Array(actualBody.buffer)) + } else if (typeof actualBody === "object") { + // Check if it's a Buffer-like object with 'type' and 'data' properties + if ("type" in actualBody && "data" in actualBody) { + // This is likely a serialized Buffer: {type: 'Buffer', data: [1,2,3,...]} + if (Array.isArray(actualBody.data)) { + bodyBytes = actualBody.data + } + } else { + // Plain object with numeric keys (like {0: 72, 1: 101, ...}) + const keys = Object.keys(actualBody) + .map(Number) + .filter((n) => !isNaN(n)) + .sort((a, b) => a - b) + bodyBytes = keys.map((k) => actualBody[k]) + } + } + } + + // Create Response-like object with all methods implemented using stored data + const serializableResponse = { + status, + statusText, + ok, + // Store raw headers data for fetch module to use + _headersData: headersObj, + headers: { + get(name: string): string | null { + // Case-insensitive header lookup + const lowerName = name.toLowerCase() + for (const [key, value] of Object.entries(headersObj)) { + if (key.toLowerCase() === lowerName) { + return value + } + } + return null + }, + has(name: string): boolean { + return this.get(name) !== null + }, + entries(): IterableIterator<[string, string]> { + return Object.entries(headersObj)[Symbol.iterator]() + }, + keys(): IterableIterator { + return Object.keys(headersObj)[Symbol.iterator]() + }, + values(): IterableIterator { + return Object.values(headersObj)[Symbol.iterator]() + }, + forEach(callback: (value: string, key: string) => void) { + Object.entries(headersObj).forEach(([key, value]) => + callback(value, key) + ) + }, + // Returns all Set-Cookie headers as array + getSetCookie(): string[] { + return setCookieHeaders + }, + }, + _bodyBytes: bodyBytes, + + // Body methods overridden by fetch module with VM-native versions + async text(): Promise { + return new TextDecoder().decode(new Uint8Array(bodyBytes)) + }, + + async json(): Promise { + const text = await this.text() + return JSON.parse(text) + }, + + async arrayBuffer(): Promise { + return new Uint8Array(bodyBytes).buffer + }, + + async blob(): Promise { + return new Blob([new Uint8Array(bodyBytes)]) + }, + + // Required Response properties + type: "basic" as ResponseType, + url: "", + redirected: false, + bodyUsed: false, + } + + // Cast to Response for type compatibility + return serializableResponse as unknown as Response +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/export/__tests__/openapi.spec.ts b/packages/hoppscotch-common/src/helpers/import-export/export/__tests__/openapi.spec.ts new file mode 100644 index 0000000..030246a --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/export/__tests__/openapi.spec.ts @@ -0,0 +1,4520 @@ +import { describe, expect, it } from "vitest" +import { + makeRESTRequest, + getDefaultRESTRequest, + makeCollection, + makeHoppRESTResponseOriginalRequest, + HoppCollection, + HoppRESTRequest, + HoppRESTRequestResponse, +} from "@hoppscotch/data" +import * as E from "fp-ts/Either" +import { OpenAPI } from "openapi-types" +import SwaggerParser from "@apidevtools/swagger-parser" +import { hoppCollectionToOpenAPI, hoppCollectionsToOpenAPI } from "../openapi" +import { + convertOpenApiDocsToHopp, + splitTagSegments, + hasSharedTagPathPrefix, + pickRequestFolderSegments, +} from "../../import/openapi" + +function buildCollection( + overrides: Partial[0]> = {} +) { + return makeCollection({ + name: "Test Collection", + requests: [], + folders: [], + auth: { authType: "inherit", authActive: true }, + headers: [], + variables: [], + description: null, + preRequestScript: "", + testScript: "", + ...overrides, + } as Parameters[0]) +} + +function buildRequest( + overrides: Partial[0]> = {} +) { + const base = getDefaultRESTRequest() + return makeRESTRequest({ + ...base, + ...overrides, + } as Parameters[0]) +} + +describe("hoppCollectionToOpenAPI", () => { + it("produces a valid OpenAPI 3.1.0 document structure", () => { + const collection = buildCollection({ name: "My API" }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.openapi).toBe("3.1.0") + expect(doc.info.title).toBe("My API") + expect(doc.info.version).toBe("1.0.0") + expect(doc.paths).toBeDefined() + }) + + it("includes collection description in info", () => { + const collection = buildCollection({ + name: "My API", + description: "A test API", + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.info.description).toBe("A test API") + }) + + it("omits description when null", () => { + const collection = buildCollection({ description: null }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.info.description).toBeUndefined() + }) + + describe("requests and paths", () => { + it("converts a root-level GET request", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Get Users", + method: "GET", + endpoint: "https://api.example.com/users", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.paths!["/users"]).toBeDefined() + expect(doc.paths!["/users"]!.get).toBeDefined() + expect(doc.paths!["/users"]!.get!.summary).toBe("Get Users") + expect(doc.paths!["/users"]!.get!.operationId).toBe("Get_Users") + }) + + it("extracts server URL from endpoint", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Get Users", + endpoint: "https://api.example.com/users", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.servers).toEqual([{ url: "https://api.example.com" }]) + }) + + it("deduplicates server URLs", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Get Users", + endpoint: "https://api.example.com/users", + }), + buildRequest({ + name: "Get Posts", + endpoint: "https://api.example.com/posts", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.servers).toHaveLength(1) + }) + + it("handles relative paths (no server)", () => { + const collection = buildCollection({ + requests: [buildRequest({ name: "Test", endpoint: "/api/test" })], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.servers).toBeUndefined() + expect(doc.paths!["/api/test"]).toBeDefined() + }) + + it("converts template variables <> to {var}", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Get User", + endpoint: "https://api.example.com/users/<>", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.paths!["/users/{id}"]).toBeDefined() + }) + + it("auto-generates path params for template variables without requestVariables", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Get User", + endpoint: "https://api.example.com/users/<>/posts/<>", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const params = doc.paths!["/users/{id}/posts/{postId}"]!.get! + .parameters as any[] + + const pathParams = params.filter((p: any) => p.in === "path") + expect(pathParams).toHaveLength(2) + expect(pathParams).toContainEqual( + expect.objectContaining({ name: "id", in: "path", required: true }) + ) + expect(pathParams).toContainEqual( + expect.objectContaining({ name: "postId", in: "path", required: true }) + ) + }) + + it("does not duplicate path params already defined in requestVariables", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Get User", + endpoint: "https://api.example.com/users/<>", + requestVariables: [{ key: "id", value: "123", active: true }], + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const params = doc.paths!["/users/{id}"]!.get!.parameters as any[] + + const pathParams = params.filter((p: any) => p.in === "path") + expect(pathParams).toHaveLength(1) + expect(pathParams[0].example).toBe("123") + }) + + it("deduplicates operationIds for requests with the same name", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Get Users", + method: "GET", + endpoint: "https://api.example.com/users", + }), + buildRequest({ + name: "Get Users", + method: "POST", + endpoint: "https://api.example.com/users", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.paths!["/users"]!.get!.operationId).toBe("Get_Users") + expect(doc.paths!["/users"]!.post!.operationId).toBe("Get_Users_2") + }) + + it("falls back to method_path operationId when name is all special characters", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "!!!", + method: "GET", + endpoint: "https://api.example.com/users", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.paths!["/users"]!.get!.operationId).toBe("get_users") + }) + + it("maps multiple HTTP methods to the same path", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Get Users", + method: "GET", + endpoint: "https://api.example.com/users", + }), + buildRequest({ + name: "Create User", + method: "POST", + endpoint: "https://api.example.com/users", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.paths!["/users"]!.get).toBeDefined() + expect(doc.paths!["/users"]!.post).toBeDefined() + }) + + it("includes request description when present", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Get Users", + endpoint: "https://api.example.com/users", + description: "Fetches all users", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.paths!["/users"]!.get!.description).toBe("Fetches all users") + }) + }) + + describe("parameters", () => { + it("converts active query params", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Search", + endpoint: "https://api.example.com/search", + params: [ + { key: "q", value: "test", active: true, description: "" }, + { + key: "inactive", + value: "skip", + active: false, + description: "", + }, + ], + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const params = doc.paths!["/search"]!.get!.parameters as any[] + + expect(params).toHaveLength(1) + expect(params[0]).toMatchObject({ + name: "q", + in: "query", + example: "test", + }) + }) + + it("converts active headers", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + headers: [ + { key: "X-Custom", value: "val", active: true, description: "" }, + ], + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const params = doc.paths!["/test"]!.get!.parameters as any[] + + expect(params).toHaveLength(1) + expect(params[0]).toMatchObject({ + name: "X-Custom", + in: "header", + example: "val", + }) + }) + + it("converts path variables as required", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Get User", + endpoint: "https://api.example.com/users/<>", + requestVariables: [{ key: "id", value: "123", active: true }], + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const params = doc.paths!["/users/{id}"]!.get!.parameters as any[] + + const pathParam = params.find((p: any) => p.in === "path") + expect(pathParam).toMatchObject({ + name: "id", + in: "path", + required: true, + example: "123", + }) + }) + + it("omits parameters when none are active", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + params: [ + { key: "skip", value: "me", active: false, description: "" }, + ], + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.paths!["/test"]!.get!.parameters).toBeUndefined() + }) + }) + + describe("request body", () => { + it("converts JSON body with parsed example", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Create User", + method: "POST", + endpoint: "https://api.example.com/users", + body: { + contentType: "application/json", + body: '{"name":"John"}', + }, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const reqBody = doc.paths!["/users"]!.post!.requestBody as any + + expect(reqBody.content["application/json"].example).toEqual({ + name: "John", + }) + }) + + it("handles invalid JSON body gracefully", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + method: "POST", + endpoint: "https://api.example.com/test", + body: { + contentType: "application/json", + body: "not json", + }, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const reqBody = doc.paths!["/test"]!.post!.requestBody as any + + expect(reqBody.content["application/json"].example).toBe("not json") + }) + + it("converts multipart/form-data body", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Upload", + method: "POST", + endpoint: "https://api.example.com/upload", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "file", + value: "", + active: true, + isFile: true, + }, + { + key: "name", + value: "test", + active: true, + isFile: false, + }, + { + key: "inactive", + value: "skip", + active: false, + isFile: false, + }, + ], + }, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const schema = (doc.paths!["/upload"]!.post!.requestBody as any).content[ + "multipart/form-data" + ].schema + + expect(schema.properties.file).toEqual({ + type: "string", + format: "binary", + }) + expect(schema.properties.name).toEqual({ + type: "string", + example: "test", + }) + expect(schema.properties.inactive).toBeUndefined() + }) + + it("converts form-urlencoded body", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Login", + method: "POST", + endpoint: "https://api.example.com/login", + body: { + contentType: "application/x-www-form-urlencoded", + // Hoppscotch persists urlencoded bodies as RawKeyValue + // (`key: value` per line) — match the on-disk format. + body: "username: admin\npassword: secret", + }, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const schema = (doc.paths!["/login"]!.post!.requestBody as any).content[ + "application/x-www-form-urlencoded" + ].schema + + expect(schema.properties.username).toEqual({ + type: "string", + example: "admin", + }) + expect(schema.properties.password).toEqual({ + type: "string", + example: "secret", + }) + }) + + it("converts plain text body", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Send Text", + method: "POST", + endpoint: "https://api.example.com/text", + body: { contentType: "text/plain", body: "Hello world" }, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const reqBody = doc.paths!["/text"]!.post!.requestBody as any + + expect(reqBody.content["text/plain"].example).toBe("Hello world") + }) + + it("omits requestBody when contentType is null", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Get", + endpoint: "https://api.example.com/test", + body: { contentType: null, body: null }, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.paths!["/test"]!.get!.requestBody).toBeUndefined() + }) + }) + + describe("authentication", () => { + it("converts basic auth", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + auth: { + authType: "basic", + authActive: true, + username: "user", + password: "pass", + }, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.components!.securitySchemes!.basicAuth).toEqual({ + type: "http", + scheme: "basic", + }) + expect(doc.paths!["/test"]!.get!.security).toEqual([{ basicAuth: [] }]) + }) + + it("converts bearer auth", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + auth: { + authType: "bearer", + authActive: true, + token: "abc123", + }, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.components!.securitySchemes!.bearerAuth).toEqual({ + type: "http", + scheme: "bearer", + }) + }) + + it("converts OAuth2 authorization code flow", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + auth: { + authType: "oauth-2", + authActive: true, + addTo: "HEADERS", + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: "https://auth.example.com/authorize", + tokenEndpoint: "https://auth.example.com/token", + clientID: "client123", + clientSecret: "secret", + scopes: "read write", + isPKCE: false, + codeVerifierMethod: "S256", + token: "", + }, + } as any, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const scheme = doc.components!.securitySchemes!.oauth2 as any + + expect(scheme.type).toBe("oauth2") + expect(scheme.flows.authorizationCode).toEqual({ + authorizationUrl: "https://auth.example.com/authorize", + tokenUrl: "https://auth.example.com/token", + scopes: { read: "", write: "" }, + }) + }) + + it("converts OAuth2 client credentials flow", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + auth: { + authType: "oauth-2", + authActive: true, + addTo: "HEADERS", + grantTypeInfo: { + grantType: "CLIENT_CREDENTIALS", + authEndpoint: "https://auth.example.com/token", + clientID: "client123", + clientSecret: "secret", + scopes: "admin", + token: "", + }, + } as any, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const scheme = doc.components!.securitySchemes!.oauth2 as any + + expect(scheme.flows.clientCredentials).toEqual({ + tokenUrl: "https://auth.example.com/token", + scopes: { admin: "" }, + }) + }) + + it("converts OAuth2 password flow", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + auth: { + authType: "oauth-2", + authActive: true, + addTo: "HEADERS", + grantTypeInfo: { + grantType: "PASSWORD", + authEndpoint: "https://auth.example.com/token", + clientID: "client123", + clientSecret: "secret", + username: "user", + password: "pass", + scopes: "", + token: "", + }, + } as any, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const scheme = doc.components!.securitySchemes!.oauth2 as any + + expect(scheme.flows.password).toEqual({ + tokenUrl: "https://auth.example.com/token", + scopes: {}, + }) + }) + + it("converts OAuth2 implicit flow", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + auth: { + authType: "oauth-2", + authActive: true, + addTo: "HEADERS", + grantTypeInfo: { + grantType: "IMPLICIT", + authEndpoint: "https://auth.example.com/authorize", + clientID: "client123", + scopes: "profile email", + token: "", + }, + } as any, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const scheme = doc.components!.securitySchemes!.oauth2 as any + + expect(scheme.flows.implicit).toEqual({ + authorizationUrl: "https://auth.example.com/authorize", + scopes: { profile: "", email: "" }, + }) + expect(doc.paths!["/test"]!.get!.security).toEqual([ + { oauth2: ["profile", "email"] }, + ]) + }) + + it("merges OAuth2 flows across requests with different grant types", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Auth Code Request", + endpoint: "https://api.example.com/a", + auth: { + authType: "oauth-2", + authActive: true, + addTo: "HEADERS", + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: "https://auth.example.com/authorize", + tokenEndpoint: "https://auth.example.com/token", + clientID: "c", + clientSecret: "s", + scopes: "read", + isPKCE: false, + codeVerifierMethod: "S256", + token: "", + }, + } as any, + }), + buildRequest({ + name: "Client Credentials Request", + endpoint: "https://api.example.com/b", + auth: { + authType: "oauth-2", + authActive: true, + addTo: "HEADERS", + grantTypeInfo: { + grantType: "CLIENT_CREDENTIALS", + authEndpoint: "https://auth.example.com/token", + clientID: "c", + clientSecret: "s", + scopes: "admin", + token: "", + }, + } as any, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const scheme = doc.components!.securitySchemes!.oauth2 as any + + expect(scheme.flows.authorizationCode).toEqual({ + authorizationUrl: "https://auth.example.com/authorize", + tokenUrl: "https://auth.example.com/token", + scopes: { read: "" }, + }) + expect(scheme.flows.clientCredentials).toEqual({ + tokenUrl: "https://auth.example.com/token", + scopes: { admin: "" }, + }) + expect(doc.paths!["/a"]!.get!.security).toEqual([{ oauth2: ["read"] }]) + expect(doc.paths!["/b"]!.get!.security).toEqual([{ oauth2: ["admin"] }]) + }) + + it("unions OAuth2 scopes across requests sharing a grant type and endpoints", () => { + const grantInfo = { + grantType: "AUTHORIZATION_CODE" as const, + authEndpoint: "https://auth.example.com/authorize", + tokenEndpoint: "https://auth.example.com/token", + clientID: "c", + clientSecret: "s", + isPKCE: false, + codeVerifierMethod: "S256" as const, + token: "", + } + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Read", + endpoint: "https://api.example.com/read", + auth: { + authType: "oauth-2", + authActive: true, + addTo: "HEADERS", + grantTypeInfo: { ...grantInfo, scopes: "read:users" }, + } as any, + }), + buildRequest({ + name: "Write", + endpoint: "https://api.example.com/write", + auth: { + authType: "oauth-2", + authActive: true, + addTo: "HEADERS", + grantTypeInfo: { ...grantInfo, scopes: "write:users" }, + } as any, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const scheme = doc.components!.securitySchemes!.oauth2 as any + + expect(scheme.flows.authorizationCode.scopes).toEqual({ + "read:users": "", + "write:users": "", + }) + expect(doc.paths!["/read"]!.get!.security).toEqual([ + { oauth2: ["read:users"] }, + ]) + expect(doc.paths!["/write"]!.get!.security).toEqual([ + { oauth2: ["write:users"] }, + ]) + }) + + it("flags OAuth2 endpoint conflicts within a shared grant type as lossy", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Service A", + endpoint: "https://a.example.com/a-resource", + auth: { + authType: "oauth-2", + authActive: true, + addTo: "HEADERS", + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: "https://a.example.com/authorize", + tokenEndpoint: "https://a.example.com/token", + clientID: "c", + clientSecret: "s", + scopes: "read", + isPKCE: false, + codeVerifierMethod: "S256", + token: "", + }, + } as any, + }), + buildRequest({ + name: "Service B", + endpoint: "https://b.example.com/b-resource", + auth: { + authType: "oauth-2", + authActive: true, + addTo: "HEADERS", + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: "https://b.example.com/authorize", + tokenEndpoint: "https://b.example.com/token", + clientID: "c", + clientSecret: "s", + scopes: "read", + isPKCE: false, + codeVerifierMethod: "S256", + token: "", + }, + } as any, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const scheme = doc.components!.securitySchemes!.oauth2 as any + + // Earlier endpoints win; later conflicting endpoints drop silently. + expect(scheme.flows.authorizationCode.authorizationUrl).toBe( + "https://a.example.com/authorize" + ) + expect(scheme.flows.authorizationCode.tokenUrl).toBe( + "https://a.example.com/token" + ) + }) + + it("converts API key auth in header", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + auth: { + authType: "api-key", + authActive: true, + key: "X-API-Key", + value: "secret", + addTo: "HEADERS", + }, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const scheme = doc.components!.securitySchemes![ + "apiKey_header_X-API-Key" + ] as any + + expect(scheme).toEqual({ + type: "apiKey", + in: "header", + name: "X-API-Key", + }) + }) + + it("converts API key auth in query params", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + auth: { + authType: "api-key", + authActive: true, + key: "api_key", + value: "secret", + addTo: "QUERY_PARAMS", + }, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const scheme = doc.components!.securitySchemes![ + "apiKey_query_api_key" + ] as any + + expect(scheme).toEqual({ + type: "apiKey", + in: "query", + name: "api_key", + }) + }) + + it("converts JWT auth", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + auth: { + authType: "jwt", + authActive: true, + secret: "s3cret", + privateKey: "", + algorithm: "HS256", + payload: "{}", + addTo: "HEADERS", + headerPrefix: "Bearer", + } as any, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.components!.securitySchemes!.jwtAuth).toEqual({ + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + }) + expect(doc.paths!["/test"]!.get!.security).toEqual([{ jwtAuth: [] }]) + }) + + it("converts digest auth", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + auth: { + authType: "digest", + authActive: true, + username: "user", + password: "pass", + realm: "", + nonce: "", + algorithm: "MD5", + qop: "auth", + nc: "", + cnonce: "", + opaque: "", + } as any, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.components!.securitySchemes!.digestAuth).toEqual({ + type: "http", + scheme: "digest", + }) + }) + + it("converts AWS Signature auth", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + auth: { + authType: "aws-signature", + authActive: true, + accessKey: "AKIA...", + secretKey: "secret", + region: "us-east-1", + serviceName: "s3", + addTo: "HEADERS", + } as any, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.components!.securitySchemes!.awsSigV4).toEqual({ + type: "apiKey", + in: "header", + name: "Authorization", + description: "AWS Signature Version 4", + }) + }) + + it("converts HAWK auth", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + auth: { + authType: "hawk", + authActive: true, + authId: "id123", + authKey: "key456", + algorithm: "sha256", + includePayloadHash: false, + timestamp: "", + nonce: "", + ext: "", + app: "", + dlg: "", + } as any, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.components!.securitySchemes!.hawkAuth).toEqual({ + type: "apiKey", + in: "header", + name: "Authorization", + description: "Hawk authentication", + }) + }) + + it("converts Akamai EdgeGrid auth", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + auth: { + authType: "akamai-eg", + authActive: true, + accessToken: "token", + clientToken: "client", + clientSecret: "secret", + baseURL: "", + timestamp: "", + nonce: "", + headersToSign: "", + maxBodySize: 131072, + } as any, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.components!.securitySchemes!.akamaiEdgeGrid).toEqual({ + type: "apiKey", + in: "header", + name: "Authorization", + description: "Akamai EdgeGrid authentication", + }) + }) + + it("skips auth when authActive is false", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + auth: { + authType: "basic", + authActive: false, + username: "user", + password: "pass", + }, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.components).toBeUndefined() + expect(doc.paths!["/test"]!.get!.security).toBeUndefined() + }) + + it("exports collection-level auth as global security", () => { + const collection = buildCollection({ + auth: { + authType: "basic", + authActive: true, + username: "user", + password: "pass", + }, + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.security).toEqual([{ basicAuth: [] }]) + expect(doc.components!.securitySchemes!.basicAuth).toEqual({ + type: "http", + scheme: "basic", + }) + }) + + it("skips global security for inherit/none collection auth", () => { + const collection = buildCollection({ + auth: { authType: "inherit", authActive: true }, + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.security).toBeUndefined() + }) + + it("sets security to empty array when authType is none and authActive is true", () => { + const collection = buildCollection({ + auth: { + authType: "basic", + authActive: true, + username: "u", + password: "p", + }, + requests: [ + buildRequest({ + name: "Public", + endpoint: "https://api.example.com/health", + auth: { authType: "none", authActive: true }, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.security).toEqual([{ basicAuth: [] }]) + expect(doc.paths!["/health"]!.get!.security).toEqual([]) + }) + }) + + describe("collection-level headers", () => { + it("includes collection-level headers in operation parameters", () => { + const collection = buildCollection({ + headers: [ + { + key: "X-Global", + value: "global-val", + active: true, + description: "", + }, + ], + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const params = doc.paths!["/test"]!.get!.parameters as any[] + + expect(params).toContainEqual( + expect.objectContaining({ + name: "X-Global", + in: "header", + example: "global-val", + }) + ) + }) + + it("request headers override collection headers (case-insensitive)", () => { + const collection = buildCollection({ + headers: [ + { + key: "X-Custom", + value: "collection-val", + active: true, + description: "", + }, + ], + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + headers: [ + { + key: "x-custom", + value: "request-val", + active: true, + description: "", + }, + ], + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const params = doc.paths!["/test"]!.get!.parameters as any[] + + const headerParams = params.filter((p: any) => p.in === "header") + expect(headerParams).toHaveLength(1) + expect(headerParams[0].example).toBe("request-val") + }) + }) + + describe("folders and tags", () => { + it("creates tags from folders", () => { + const collection = buildCollection({ + folders: [ + buildCollection({ + name: "Users", + requests: [ + buildRequest({ + name: "List Users", + endpoint: "https://api.example.com/users", + }), + ], + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.tags).toContainEqual({ name: "Users" }) + expect(doc.paths!["/users"]!.get!.tags).toEqual(["Users"]) + }) + + it("creates nested tag paths for nested folders", () => { + const collection = buildCollection({ + folders: [ + buildCollection({ + name: "API", + folders: [ + buildCollection({ + name: "Users", + requests: [ + buildRequest({ + name: "List", + endpoint: "https://api.example.com/users", + }), + ], + }), + ], + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.tags).toContainEqual({ name: "API" }) + expect(doc.tags).toContainEqual({ name: "API/Users" }) + expect(doc.paths!["/users"]!.get!.tags).toEqual(["API/Users"]) + }) + + it("root-level requests have no tags", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Health", + endpoint: "https://api.example.com/health", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.paths!["/health"]!.get!.tags).toBeUndefined() + expect(doc.tags).toBeUndefined() + }) + }) + + describe("responses", () => { + it("generates default 200 response when no responses saved", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + responses: {}, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.paths!["/test"]!.get!.responses).toEqual({ + "200": { description: "Successful response" }, + }) + }) + + it("converts saved responses with body and headers", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + responses: { + resp1: { + name: "Success", + code: 200, + status: "OK", + headers: [ + { key: "Content-Type", value: "application/json" }, + { key: "X-Request-Id", value: "abc" }, + ], + body: '{"result":"ok"}', + } as any, + }, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const resp = doc.paths!["/test"]!.get!.responses["200"] as any + + expect(resp.description).toBe("Success") + expect(resp.headers["X-Request-Id"].example).toBe("abc") + expect(resp.content["application/json"].example).toEqual({ + result: "ok", + }) + }) + + it("falls back to default 200 when every saved response has a null code", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + responses: { + resp1: { + name: "draft", + code: null, + status: "", + headers: [], + body: "", + } as any, + }, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + expect(doc.paths!["/test"]!.get!.responses).toEqual({ + "200": { description: "Successful response" }, + }) + }) + }) + + describe("dropped output", () => { + it("drops duplicate (path, method) operations — first wins", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "First", + method: "GET", + endpoint: "https://api.example.com/users", + }), + buildRequest({ + name: "Second", + method: "GET", + endpoint: "https://api.example.com/users", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.paths!["/users"]!.get!.summary).toBe("First") + }) + + it("skips operations with HTTP methods OpenAPI does not support", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Connect Tunnel", + method: "CONNECT", + endpoint: "https://api.example.com/tunnel", + }), + buildRequest({ + name: "Get Users", + method: "GET", + endpoint: "https://api.example.com/users", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.paths!["/tunnel"]).toBeUndefined() + expect(doc.paths!["/users"]).toBeDefined() + }) + }) + + describe("path template variables (regression)", () => { + // These tests document the BLOCKER fix: the WHATWG URL parser + // percent-encodes `{}` to `%7B%7D` in pathnames. The converter must + // decode those back so OpenAPI path parameters land correctly. + it("preserves {var} placeholders that the URL parser percent-encoded", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Get", + endpoint: "https://api.example.com/users/{id}", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + expect(doc.paths!["/users/{id}"]).toBeDefined() + }) + + it("converts <> in absolute URLs to {var} after URL parsing", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Get", + endpoint: "https://api.example.com/users/<>/posts/<>", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + expect(doc.paths!["/users/{id}/posts/{postId}"]).toBeDefined() + }) + + it("preserves intentionally percent-encoded path segments (e.g. %2F)", () => { + // Regression: an over-eager decodeURIComponent on the whole pathname + // would turn `%2F` into a literal `/`, splitting a single segment in two. + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Get", + endpoint: "https://api.example.com/files/a%2Fb/info", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + expect(doc.paths!["/files/a%2Fb/info"]).toBeDefined() + expect(doc.paths!["/files/a/b/info"]).toBeUndefined() + }) + + it("strips fragments from relative endpoints", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ name: "Get", endpoint: "/users/list#section" }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + expect(doc.paths!["/users/list"]).toBeDefined() + expect(doc.paths!["/users/list#section"]).toBeUndefined() + }) + + it("treats a leading <> as the server, not as a path segment", () => { + // Regression: `<>/pets/9704` was previously kept whole inside + // the path, then importer-side double-prefixed `<>` on round-trip. + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Get pet", + endpoint: "<>/pets/9704", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + + expect(doc.paths!["/pets/9704"]).toBeDefined() + // The leading variable lands in servers, not in any path key + expect(doc.paths!["/<>/pets/9704"]).toBeUndefined() + expect(doc.paths!["/{baseUrl}/pets/9704"]).toBeUndefined() + expect(doc.servers).toEqual([{ url: "<>" }]) + }) + + it("handles a bare leading <> with no path", () => { + const collection = buildCollection({ + requests: [buildRequest({ name: "Root", endpoint: "<>" })], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + expect(doc.paths!["/"]).toBeDefined() + expect(doc.servers).toEqual([{ url: "<>" }]) + }) + + it("resolves a leading <> to its collection-variable value in servers", () => { + // Regression: previously the doc shipped `servers: [{ url: "<>" }]` + // even when the collection knew the actual host. OpenAPI consumers can't + // resolve Hoppscotch placeholders, so we substitute the value here. + const collection = buildCollection({ + variables: [ + { + key: "baseUrl", + initialValue: "https://api.petstore.com", + currentValue: "https://api.petstore.com", + secret: false, + }, + ], + requests: [ + buildRequest({ + name: "Get pet", + endpoint: "<>/pets/9704", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + expect(doc.servers).toEqual([{ url: "https://api.petstore.com" }]) + expect(doc.paths!["/pets/9704"]).toBeDefined() + }) + + it("works for any variable name, not only baseUrl", () => { + const collection = buildCollection({ + variables: [ + { + key: "petstoreHost", + initialValue: "https://api.petstore.com", + currentValue: "https://api.petstore.com", + secret: false, + }, + ], + requests: [ + buildRequest({ + name: "Get pet", + endpoint: "<>/pets/9704", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + expect(doc.servers).toEqual([{ url: "https://api.petstore.com" }]) + }) + + it("does not leak secret variables into the exported servers", () => { + const collection = buildCollection({ + variables: [ + { + key: "secretHost", + initialValue: "https://internal.example.com", + currentValue: "https://internal.example.com", + secret: true, + }, + ], + requests: [ + buildRequest({ + name: "Get", + endpoint: "<>/api/x", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + // Falls back to the placeholder so the user can see something is off, + // rather than embedding a secret value into the spec. + expect(doc.servers).toEqual([{ url: "<>" }]) + }) + + it("falls back to placeholder when no matching variable exists", () => { + const collection = buildCollection({ + variables: [], + requests: [ + buildRequest({ + name: "Get", + endpoint: "<>/path", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + expect(doc.servers).toEqual([{ url: "<>" }]) + }) + + it("uses currentValue, falling back to initialValue", () => { + const collection = buildCollection({ + variables: [ + { + key: "baseUrl", + initialValue: "https://prod.example.com", + currentValue: "https://staging.example.com", + secret: false, + }, + ], + requests: [buildRequest({ name: "Get", endpoint: "<>/x" })], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + expect(doc.servers).toEqual([{ url: "https://staging.example.com" }]) + }) + + it("a deeper folder variable overrides a collection variable", () => { + const collection = buildCollection({ + variables: [ + { + key: "baseUrl", + initialValue: "https://prod.example.com", + currentValue: "https://prod.example.com", + secret: false, + }, + ], + folders: [ + buildCollection({ + name: "Sandbox", + variables: [ + { + key: "baseUrl", + initialValue: "https://sandbox.example.com", + currentValue: "https://sandbox.example.com", + secret: false, + }, + ], + requests: [ + buildRequest({ name: "Get", endpoint: "<>/x" }), + ], + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + expect(doc.servers).toEqual([{ url: "https://sandbox.example.com" }]) + }) + }) + + describe("URL query string handling", () => { + it("extracts query params from absolute URL into operation parameters", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Search", + endpoint: "https://api.example.com/search?q=hello&limit=10", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const params = doc.paths!["/search"]!.get!.parameters as any[] + + // path key has no `?` + expect(doc.paths!["/search?q=hello&limit=10"]).toBeUndefined() + const queryParams = params.filter((p: any) => p.in === "query") + expect(queryParams).toContainEqual( + expect.objectContaining({ name: "q", example: "hello" }) + ) + expect(queryParams).toContainEqual( + expect.objectContaining({ name: "limit", example: "10" }) + ) + }) + + it("strips query string from a relative endpoint and exposes its params", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Filter", + endpoint: "/users?active=true", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + expect(doc.paths!["/users"]).toBeDefined() + // path key must not contain `?` + expect(doc.paths!["/users?active=true"]).toBeUndefined() + const params = doc.paths!["/users"]!.get!.parameters as any[] + const queryParams = params.filter((p: any) => p.in === "query") + expect(queryParams).toContainEqual( + expect.objectContaining({ name: "active", example: "true" }) + ) + }) + + it("explicit request.params take precedence over duplicates in URL", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Search", + endpoint: "https://api.example.com/search?q=from-url", + params: [ + { + key: "q", + value: "from-explicit", + active: true, + description: "", + }, + ], + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const params = doc.paths!["/search"]!.get!.parameters as any[] + const queryParams = params.filter((p: any) => p.in === "query") + expect(queryParams).toHaveLength(1) + expect(queryParams[0].example).toBe("from-explicit") + }) + }) + + describe("parameter de-duplication", () => { + it("dedupes duplicate query params (first wins)", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/test", + params: [ + { key: "page", value: "1", active: true, description: "" }, + { key: "page", value: "2", active: true, description: "" }, + ], + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const params = doc.paths!["/test"]!.get!.parameters as any[] + const queryParams = params.filter((p: any) => p.in === "query") + expect(queryParams).toHaveLength(1) + expect(queryParams[0].example).toBe("1") + }) + + it("dedupes duplicate path params introduced both via requestVariables and path", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Test", + endpoint: "https://api.example.com/users/<>/posts/<>", + requestVariables: [{ key: "id", value: "42", active: true }], + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const params = doc.paths!["/users/{id}/posts/{id}"]!.get! + .parameters as any[] + const pathParams = params.filter((p: any) => p.in === "path") + expect(pathParams).toHaveLength(1) + expect(pathParams[0].name).toBe("id") + }) + }) + + describe("auth inheritance", () => { + it("operation inherits folder-level auth when request auth is inherit", () => { + const collection = buildCollection({ + folders: [ + buildCollection({ + name: "Authenticated", + auth: { + authType: "basic", + authActive: true, + username: "u", + password: "p", + }, + requests: [ + buildRequest({ + name: "Get Secure", + endpoint: "https://api.example.com/secure", + auth: { authType: "inherit", authActive: true }, + }), + ], + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + expect(doc.paths!["/secure"]!.get!.security).toEqual([{ basicAuth: [] }]) + expect(doc.components!.securitySchemes!.basicAuth).toEqual({ + type: "http", + scheme: "basic", + }) + }) + + it("nested folder auth wins over collection auth via inherit", () => { + const collection = buildCollection({ + auth: { + authType: "basic", + authActive: true, + username: "u", + password: "p", + }, + folders: [ + buildCollection({ + name: "Inner", + auth: { authType: "bearer", authActive: true, token: "t" }, + requests: [ + buildRequest({ + name: "Get", + endpoint: "https://api.example.com/inner", + auth: { authType: "inherit", authActive: true }, + }), + ], + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + expect(doc.paths!["/inner"]!.get!.security).toEqual([{ bearerAuth: [] }]) + }) + + it("folder auth=none under collection auth produces empty operation security", () => { + const collection = buildCollection({ + auth: { + authType: "basic", + authActive: true, + username: "u", + password: "p", + }, + folders: [ + buildCollection({ + name: "Public", + auth: { authType: "none", authActive: true }, + requests: [ + buildRequest({ + name: "Health", + endpoint: "https://api.example.com/public/health", + auth: { authType: "inherit", authActive: true }, + }), + ], + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + expect(doc.paths!["/public/health"]!.get!.security).toEqual([]) + expect(doc.security).toEqual([{ basicAuth: [] }]) + }) + }) + + describe("API key scheme name collisions", () => { + it("disambiguates API-key schemes that sanitize to the same name", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "A", + endpoint: "https://api.example.com/a", + auth: { + authType: "api-key", + authActive: true, + key: "X@Api-Key", + value: "v1", + addTo: "HEADERS", + }, + }), + buildRequest({ + name: "B", + endpoint: "https://api.example.com/b", + auth: { + authType: "api-key", + authActive: true, + key: "X#Api-Key", + value: "v2", + addTo: "HEADERS", + }, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const schemes = doc.components!.securitySchemes! + const schemeNames = Object.keys(schemes) + // both schemes should exist under different names; neither should silently overwrite + expect(schemeNames).toHaveLength(2) + // each operation should reference its own scheme + const aSec = doc.paths!["/a"]!.get!.security as any[] + const bSec = doc.paths!["/b"]!.get!.security as any[] + expect(Object.keys(aSec[0])[0]).not.toBe(Object.keys(bSec[0])[0]) + // but the underlying scheme.name fields should differ + const aSchemeName = Object.keys(aSec[0])[0] + const bSchemeName = Object.keys(bSec[0])[0] + expect((schemes[aSchemeName] as any).name).toBe("X@Api-Key") + expect((schemes[bSchemeName] as any).name).toBe("X#Api-Key") + }) + + it("reuses the scheme name when two API-key schemes are identical", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "A", + endpoint: "https://api.example.com/a", + auth: { + authType: "api-key", + authActive: true, + key: "X-Api-Key", + value: "v", + addTo: "HEADERS", + }, + }), + buildRequest({ + name: "B", + endpoint: "https://api.example.com/b", + auth: { + authType: "api-key", + authActive: true, + key: "X-Api-Key", + value: "v", + addTo: "HEADERS", + }, + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(collection) + const schemes = doc.components!.securitySchemes! + expect(Object.keys(schemes)).toHaveLength(1) + }) + }) +}) + +// Round-trip audit: HoppCollection -> OpenAPI 3.1 -> HoppCollection. +// Tests labelled `LOSSY` document a known and disclosed loss; tests without +// that label assert exact preservation. New regressions surfaced by the +// audit land here. + +const buildResponse = ( + overrides: { + name?: string + code?: number + status?: string + headers?: Array<{ key: string; value: string }> + body?: string + } = {} +): HoppRESTRequestResponse => + ({ + name: "Success", + code: 200, + status: "OK", + headers: [], + body: "", + ...overrides, + originalRequest: makeHoppRESTResponseOriginalRequest({ + name: "Test", + method: "GET", + endpoint: "https://api.example.com/test", + params: [], + headers: [], + auth: { authType: "inherit", authActive: true }, + body: { contentType: null, body: null }, + requestVariables: [], + }), + }) as HoppRESTRequestResponse + +const buildColl = ( + overrides: Partial[0]> = {} +) => + makeCollection({ + name: "Round Trip", + requests: [], + folders: [], + auth: { authType: "inherit", authActive: true }, + headers: [], + variables: [], + description: null, + preRequestScript: "", + testScript: "", + ...overrides, + } as Parameters[0]) + +const buildReq = ( + overrides: Partial[0]> = {} +) => + makeRESTRequest({ + name: "Test", + method: "GET", + endpoint: "https://api.example.com/test", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + auth: { authType: "inherit", authActive: true }, + body: { contentType: null, body: null }, + requestVariables: [], + responses: {}, + description: null, + ...overrides, + } as Parameters[0]) + +const roundTrip = async (input: HoppCollection): Promise => { + const { doc } = hoppCollectionToOpenAPI(input) + const r = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (r._tag === "Left") throw new Error(r.left as string) + return r.right[0] +} + +// `HoppCollection["requests"]` is a union of REST and GQL; every fixture below +// builds REST. These accessors narrow the union so tests can read REST-only +// fields (`requestVariables`, `responses`, `method`) without per-call casts. +const restRequestsOf = (c: HoppCollection | undefined): HoppRESTRequest[] => + (c?.requests ?? []) as HoppRESTRequest[] + +const firstReq = (c: HoppCollection): HoppRESTRequest | undefined => { + const direct = restRequestsOf(c)[0] + if (direct) return direct + const inFolder = restRequestsOf(c.folders[0])[0] + if (inFolder) return inFolder + return restRequestsOf(c.folders[0]?.folders[0])[0] +} + +// Identity fields: collection name/description, request name/description/method. +describe("round-trip — identity fields", () => { + it("preserves collection name", async () => { + const out = await roundTrip(buildColl({ name: "My API" })) + expect(out.name).toBe("My API") + }) + + it("preserves collection description", async () => { + const out = await roundTrip(buildColl({ description: "A description" })) + expect(out.description).toBe("A description") + }) + + it("preserves request name (with spaces)", async () => { + const out = await roundTrip( + buildColl({ requests: [buildReq({ name: "Get Pet By ID" })] }) + ) + expect(firstReq(out)?.name).toBe("Get Pet By ID") + }) + + it("preserves request description", async () => { + const out = await roundTrip( + buildColl({ + requests: [buildReq({ description: "Fetches a single pet" })], + }) + ) + expect(firstReq(out)?.description).toBe("Fetches a single pet") + }) + + it("preserves request method", async () => { + const out = await roundTrip( + buildColl({ requests: [buildReq({ method: "POST" })] }) + ) + expect(firstReq(out)?.method).toBe("POST") + }) +}) + +// Endpoint shape: server URL parametrization, baseUrl seeding, path templates. +describe("round-trip — endpoint variants", () => { + it("LOSSY: absolute URL gets parametrized to <> + collection variable", async () => { + // Importer seeds a `baseUrl` variable from the doc's `servers[0].url` so + // users have one place to switch hosts. Endpoints are rewritten to use + // the placeholder. The actual request still resolves to the original URL + // at runtime via the variable. + const out = await roundTrip( + buildColl({ + requests: [buildReq({ endpoint: "https://api.example.com/pets/9704" })], + }) + ) + expect(firstReq(out)?.endpoint).toBe("<>/pets/9704") + expect(out.variables[0]?.key).toBe("baseUrl") + expect(out.variables[0]?.initialValue).toBe("https://api.example.com") + // Importer leaves currentValue empty; runtime values live in + // CurrentValueService and are not synced server-side. + expect(out.variables[0]?.currentValue).toBe("") + }) + + it("preserves leading <> when collection has no value for it", async () => { + const out = await roundTrip( + buildColl({ + requests: [buildReq({ endpoint: "<>/pets/9704" })], + }) + ) + expect(firstReq(out)?.endpoint).toBe("<>/pets/9704") + }) + + it("LOSSY: variable name normalizes to baseUrl when value is known", async () => { + // Original variable was `petstoreHost` with a real URL. Exporter resolves + // to the URL; importer re-creates as `baseUrl`. + const out = await roundTrip( + buildColl({ + variables: [ + { + key: "petstoreHost", + initialValue: "https://api.petstore.com", + currentValue: "https://api.petstore.com", + secret: false, + }, + ], + requests: [buildReq({ endpoint: "<>/pets" })], + }) + ) + expect(firstReq(out)?.endpoint).toBe("<>/pets") + expect(out.variables[0]?.key).toBe("baseUrl") + expect(out.variables[0]?.initialValue).toBe("https://api.petstore.com") + expect(out.variables[0]?.currentValue).toBe("") + }) + + it("preserves path template variables (under <>)", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + endpoint: "https://api.example.com/users/<>", + requestVariables: [{ key: "id", value: "42", active: true }], + }), + ], + }) + ) + // Server is parametrized; path keeps its template variable; the + // requestVariable's value round-trips via the OpenAPI `example` field. + expect(firstReq(out)?.endpoint).toBe("<>/users/<>") + expect(firstReq(out)?.requestVariables[0]?.key).toBe("id") + expect(firstReq(out)?.requestVariables[0]?.value).toBe("42") + }) +}) + +// Query params: keys, values, descriptions. +describe("round-trip — query params", () => { + it("preserves query param keys", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + params: [ + { key: "q", value: "x", active: true, description: "" }, + { key: "limit", value: "10", active: true, description: "" }, + ], + }), + ], + }) + ) + const keys = firstReq(out)?.params.map((p) => p.key) ?? [] + expect(keys).toContain("q") + expect(keys).toContain("limit") + }) + + it("preserves query param values", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + params: [ + { key: "q", value: "hello", active: true, description: "" }, + ], + }), + ], + }) + ) + const p = firstReq(out)?.params.find((p) => p.key === "q") + expect(p?.value).toBe("hello") + }) + + it("preserves query param descriptions", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + params: [ + { + key: "status", + value: "open", + active: true, + description: "Filter by status", + }, + ], + }), + ], + }) + ) + const p = firstReq(out)?.params.find((p) => p.key === "status") + expect(p?.description).toBe("Filter by status") + }) +}) + +// Per-request and collection-level headers. +describe("round-trip — headers", () => { + it("preserves request header keys and values", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + headers: [ + { + key: "X-Custom", + value: "abc", + active: true, + description: "", + }, + ], + }), + ], + }) + ) + const h = firstReq(out)?.headers.find((h) => h.key === "X-Custom") + expect(h?.value).toBe("abc") + }) + + it("collection-level headers become per-request headers on round-trip", async () => { + // OpenAPI has no concept of doc-level headers; the converter emits them + // as per-operation headers. On reimport they land on each request. + const out = await roundTrip( + buildColl({ + headers: [ + { key: "X-Global", value: "g", active: true, description: "" }, + ], + requests: [buildReq({ headers: [] })], + }) + ) + const h = firstReq(out)?.headers.find((h) => h.key === "X-Global") + expect(h?.value).toBe("g") + }) +}) + +// Auth schemes — credentials never survive (intentional: secrets stay out +// of exported docs). Only the auth type/scheme is preserved. +describe("round-trip — auth", () => { + it("LOSSY: basic auth — credentials are dropped (only scheme survives)", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + auth: { + authType: "basic", + authActive: true, + username: "alice", + password: "secret", + }, + }), + ], + }) + ) + expect(firstReq(out)?.auth.authType).toBe("basic") + expect((firstReq(out)?.auth as { username?: string }).username).toBe("") + expect((firstReq(out)?.auth as { password?: string }).password).toBe("") + }) + + it("LOSSY: bearer token is dropped", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + auth: { authType: "bearer", authActive: true, token: "abc" }, + }), + ], + }) + ) + expect(firstReq(out)?.auth.authType).toBe("bearer") + expect((firstReq(out)?.auth as { token?: string }).token).toBe("") + }) + + it("LOSSY: api-key value dropped (key name preserved)", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + auth: { + authType: "api-key", + authActive: true, + key: "X-API-Key", + value: "secret-value", + addTo: "HEADERS", + }, + }), + ], + }) + ) + const a = firstReq(out)?.auth as { + authType: string + key?: string + value?: string + } + expect(a.authType).toBe("api-key") + expect(a.key).toBe("X-API-Key") + expect(a.value).toBe("") + }) +}) + +// Body shapes: JSON, multipart, urlencoded, plain text. +describe("round-trip — body", () => { + it("preserves JSON body content", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + method: "POST", + body: { + contentType: "application/json", + body: '{"name":"Buddy"}', + }, + }), + ], + }) + ) + expect(firstReq(out)?.body.contentType).toBe("application/json") + }) + + it("preserves multipart/form-data field structure", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "name", + value: "Buddy", + active: true, + isFile: false, + }, + ], + }, + }), + ], + }) + ) + expect(firstReq(out)?.body.contentType).toBe("multipart/form-data") + }) + + it("preserves JSON body content (string)", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + method: "POST", + body: { + contentType: "application/json", + body: '{"name":"Buddy"}', + }, + }), + ], + }) + ) + const r = firstReq(out) + expect(r?.body.contentType).toBe("application/json") + if (typeof r?.body.body === "string") { + const original = JSON.parse('{"name":"Buddy"}') + const got = JSON.parse(r.body.body) + expect(got).toEqual(original) + } + }) + + it("preserves plain text body content", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + method: "POST", + body: { contentType: "text/plain", body: "hello world" }, + }), + ], + }) + ) + expect(firstReq(out)?.body.contentType).toBe("text/plain") + expect(firstReq(out)?.body.body).toBe("hello world") + }) + + it("multipart entries preserve key+isFile flag", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + method: "POST", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "name", + value: "Buddy", + active: true, + isFile: false, + }, + { + key: "avatar", + value: "", + active: true, + isFile: true, + }, + ], + }, + }), + ], + }) + ) + const body = firstReq(out)?.body + expect(body?.contentType).toBe("multipart/form-data") + if (Array.isArray(body?.body)) { + const name = body.body.find((e: { key: string }) => e.key === "name") + const avatar = body.body.find((e: { key: string }) => e.key === "avatar") + expect(name).toBeDefined() + expect(avatar?.isFile).toBe(true) + } + }) + + it("urlencoded body keys round-trip", async () => { + // Hoppscotch persists urlencoded bodies in RawKeyValue (`key: value` + // per line) format, not querystring form — fixture matches on-disk + // representation produced by the URL-encoded body editor. + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + method: "POST", + body: { + contentType: "application/x-www-form-urlencoded", + body: "user: alice\npass: secret", + }, + }), + ], + }) + ) + const body = firstReq(out)?.body + expect(body?.contentType).toBe("application/x-www-form-urlencoded") + if (typeof body?.body === "string") { + expect(body.body).toMatch(/user/) + expect(body.body).toMatch(/pass/) + } + }) +}) + +// Saved responses on a request. +describe("round-trip — saved responses", () => { + it("preserves response name and code", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + responses: { + ok: buildResponse({ + name: "Success", + code: 200, + status: "OK", + body: '{"ok":true}', + }), + }, + }), + ], + }) + ) + const responses = firstReq(out)?.responses ?? {} + const r = Object.values(responses)[0] as { name?: string; code?: number } + expect(r?.code).toBe(200) + }) + + it("preserves response status code and body for a single saved response", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + responses: { + ok: buildResponse({ + name: "OK", + code: 200, + status: "OK", + headers: [{ key: "Content-Type", value: "application/json" }], + body: '{"ok":true}', + }), + }, + }), + ], + }) + ) + const r = firstReq(out) + const responses = r?.responses ?? {} + const first = Object.values(responses)[0] as { + code?: number + body?: string + } + expect(first?.code).toBe(200) + }) +}) + +// Folder structure: deep nesting, descriptions, multiple top-level folders. +describe("round-trip — folder hierarchy", () => { + it("preserves a deeply nested folder", async () => { + const out = await roundTrip( + buildColl({ + folders: [ + buildColl({ + name: "API", + folders: [ + buildColl({ + name: "Users", + requests: [ + buildReq({ + name: "List", + endpoint: "https://api.example.com/users", + }), + ], + }), + ], + }), + ], + }) + ) + expect(out.folders[0]?.name).toBe("API") + expect(out.folders[0]?.folders[0]?.name).toBe("Users") + expect(restRequestsOf(out.folders[0]?.folders[0])[0]?.name).toBe("List") + }) + + it("preserves folder description", async () => { + const out = await roundTrip( + buildColl({ + folders: [ + buildColl({ + name: "Users", + description: "Endpoints for managing users", + requests: [ + buildReq({ + name: "Get", + endpoint: "https://api.example.com/users/me", + }), + ], + }), + ], + }) + ) + expect(out.folders[0]?.description).toBe("Endpoints for managing users") + }) + + it("preserves multiple top-level folders", async () => { + const out = await roundTrip( + buildColl({ + folders: [ + buildColl({ + name: "Pets", + requests: [ + buildReq({ + name: "List", + endpoint: "https://api.example.com/pets", + }), + ], + }), + buildColl({ + name: "Orders", + requests: [ + buildReq({ + name: "List", + endpoint: "https://api.example.com/orders", + }), + ], + }), + ], + }) + ) + const names = out.folders.map((f) => f.name).sort() + expect(names).toEqual(["Orders", "Pets"]) + }) +}) + +// Collection-level auth: stays on the collection rather than being flattened +// to per-request copies. Per-request explicit overrides still survive. +describe("round-trip — collection-level auth structure", () => { + it("collection-level basic auth stays on the collection (not duplicated on each request)", async () => { + const out = await roundTrip( + buildColl({ + auth: { + authType: "basic", + authActive: true, + username: "alice", + password: "secret", + }, + requests: [ + buildReq({ + name: "A", + endpoint: "https://api.example.com/a", + }), + buildReq({ + name: "B", + endpoint: "https://api.example.com/b", + }), + ], + }) + ) + expect(out.auth.authType).toBe("basic") + expect(restRequestsOf(out)[0]?.auth.authType).toBe("inherit") + expect(restRequestsOf(out)[1]?.auth.authType).toBe("inherit") + }) + + it("per-request explicit override coexists with collection-level default", async () => { + const out = await roundTrip( + buildColl({ + auth: { + authType: "basic", + authActive: true, + username: "alice", + password: "p", + }, + requests: [ + buildReq({ + name: "Inherits", + endpoint: "https://api.example.com/a", + }), + buildReq({ + name: "Overrides", + endpoint: "https://api.example.com/b", + auth: { authType: "bearer", authActive: true, token: "t" }, + }), + ], + }) + ) + expect(out.auth.authType).toBe("basic") + const inheritReq = restRequestsOf(out).find((r) => r.name === "Inherits") + const overrideReq = restRequestsOf(out).find((r) => r.name === "Overrides") + expect(inheritReq?.auth.authType).toBe("inherit") + expect(overrideReq?.auth.authType).toBe("bearer") + }) + + it("explicit no-auth on a request (authType=none) round-trips as none, not inherit", async () => { + const out = await roundTrip( + buildColl({ + auth: { + authType: "basic", + authActive: true, + username: "u", + password: "p", + }, + requests: [ + buildReq({ + name: "Public", + endpoint: "https://api.example.com/health", + auth: { authType: "none", authActive: true }, + }), + ], + }) + ) + expect(out.auth.authType).toBe("basic") + expect(restRequestsOf(out)[0]?.auth.authType).toBe("none") + }) + + it("collection without any auth round-trips as inherit", async () => { + const out = await roundTrip( + buildColl({ + auth: { authType: "inherit", authActive: true }, + requests: [buildReq({ endpoint: "https://api.example.com/a" })], + }) + ) + expect(out.auth.authType).toBe("inherit") + expect(restRequestsOf(out)[0]?.auth.authType).toBe("inherit") + }) +}) + +// Validates the exported doc against the OpenAPI 3.1 spec via SwaggerParser. +// Catches output that's structurally invalid even if our own importer is lenient. +describe("exported doc validates against OpenAPI 3.1 spec", () => { + const validate = async (collection: HoppCollection) => { + const { doc } = hoppCollectionToOpenAPI(collection) + // SwaggerParser mutates input; clone first. + const cloned = JSON.parse(JSON.stringify(doc)) + await SwaggerParser.validate(cloned) + } + + it("a minimal collection produces a valid OpenAPI 3.1 doc", async () => { + await expect( + validate( + buildColl({ + requests: [buildReq({ endpoint: "https://api.example.com/pets" })], + }) + ) + ).resolves.not.toThrow() + }) + + it("a collection with auth + headers + body validates", async () => { + await expect( + validate( + buildColl({ + requests: [ + buildReq({ + method: "POST", + endpoint: "https://api.example.com/pets", + headers: [ + { + key: "X-Custom", + value: "abc", + active: true, + description: "", + }, + ], + params: [ + { + key: "draft", + value: "true", + active: true, + description: "", + }, + ], + auth: { + authType: "bearer", + authActive: true, + token: "tk", + }, + body: { + contentType: "application/json", + body: '{"name":"Buddy"}', + }, + }), + ], + }) + ) + ).resolves.not.toThrow() + }) + + it("a collection with nested folders validates", async () => { + await expect( + validate( + buildColl({ + folders: [ + buildColl({ + name: "API", + folders: [ + buildColl({ + name: "Users", + requests: [ + buildReq({ + name: "List", + endpoint: "https://api.example.com/users", + }), + ], + }), + ], + }), + ], + }) + ) + ).resolves.not.toThrow() + }) + + it("a collection that resolves a baseUrl variable validates (no <<>> in spec output)", async () => { + await expect( + validate( + buildColl({ + variables: [ + { + key: "baseUrl", + initialValue: "https://api.example.com", + currentValue: "https://api.example.com", + secret: false, + }, + ], + requests: [buildReq({ endpoint: "<>/pets" })], + }) + ) + ).resolves.not.toThrow() + }) + + it("a collection with path collisions validates with x-hoppscotch-dropped-requests present", async () => { + await expect( + validate( + buildColl({ + requests: [ + buildReq({ + name: "Kept", + method: "GET", + endpoint: "https://api.example.com/items", + }), + buildReq({ + name: "Dropped", + method: "GET", + endpoint: "https://api.example.com/items", + }), + ], + }) + ) + ).resolves.not.toThrow() + }) + + it("a multi-collection workspace doc validates with x-hoppscotch-workspace-root present", async () => { + const collections = [ + buildColl({ + name: "ColA", + requests: [ + buildReq({ name: "A", endpoint: "https://api.example.com/a" }), + ], + }), + buildColl({ + name: "ColB", + requests: [ + buildReq({ name: "B", endpoint: "https://api.example.com/b" }), + ], + }), + ] + const { doc } = hoppCollectionsToOpenAPI("WS", collections) + const cloned = JSON.parse(JSON.stringify(doc)) + await expect(SwaggerParser.validate(cloned)).resolves.not.toThrow() + }) + + it("a workspace with nested folders + path collisions validates with both markers present", async () => { + const collections = [ + buildColl({ + name: "ColA", + folders: [ + buildColl({ + name: "API", + requests: [ + buildReq({ + name: "Kept", + method: "GET", + endpoint: "https://api.example.com/items", + }), + buildReq({ + name: "Dropped", + method: "GET", + endpoint: "https://api.example.com/items", + }), + ], + }), + ], + }), + ] + const { doc } = hoppCollectionsToOpenAPI("WS", collections) + const cloned = JSON.parse(JSON.stringify(doc)) + await expect(SwaggerParser.validate(cloned)).resolves.not.toThrow() + }) +}) + +// Active flag preservation. OpenAPI has no concept of inactive params/headers, +// so inactive entries are dropped on export and reimported as active=true. +describe("round-trip — active flag", () => { + it("LOSSY: inactive query params are dropped on export (only active ones survive)", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + params: [ + { + key: "kept", + value: "1", + active: true, + description: "", + }, + { + key: "skipped", + value: "x", + active: false, + description: "", + }, + ], + }), + ], + }) + ) + const keys = firstReq(out)?.params.map((p) => p.key) ?? [] + expect(keys).toContain("kept") + expect(keys).not.toContain("skipped") + }) + + it("LOSSY: inactive headers are dropped on export", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + headers: [ + { + key: "X-Kept", + value: "1", + active: true, + description: "", + }, + { + key: "X-Skipped", + value: "x", + active: false, + description: "", + }, + ], + }), + ], + }) + ) + const keys = firstReq(out)?.headers.map((h) => h.key) ?? [] + expect(keys).toContain("X-Kept") + expect(keys).not.toContain("X-Skipped") + }) + + it("active flag defaults to true on import (OpenAPI has no concept of disabled params)", async () => { + const out = await roundTrip( + buildColl({ + requests: [ + buildReq({ + params: [{ key: "q", value: "1", active: true, description: "" }], + }), + ], + }) + ) + expect(firstReq(out)?.params[0]?.active).toBe(true) + }) +}) + +// Combined-features fixture mimicking a real Petstore-shaped collection, +// to catch interactions that single-feature tests miss. +describe("round-trip — kitchen sink (combined features)", () => { + it("a real-world-shaped collection survives round-trip with the expected losses", async () => { + const original = buildColl({ + name: "Petstore", + description: "Test API", + variables: [ + { + key: "baseUrl", + initialValue: "https://api.petstore.com", + currentValue: "https://api.petstore.com", + secret: false, + }, + ], + headers: [ + { + key: "X-Tenant", + value: "default", + active: true, + description: "Tenant", + }, + ], + folders: [ + buildColl({ + name: "Pets", + description: "Pet endpoints", + requests: [ + buildReq({ + name: "Get pet by ID", + method: "GET", + endpoint: "<>/v1/pets/<>", + params: [ + { + key: "include", + value: "tags", + active: true, + description: "Comma-separated includes", + }, + ], + requestVariables: [{ key: "petId", value: "9704", active: true }], + }), + buildReq({ + name: "Add a new pet", + method: "POST", + endpoint: "<>/v1/pets", + body: { + contentType: "application/json", + body: '{"name":"Buddy","status":"available"}', + }, + }), + ], + }), + ], + }) + + const out = await roundTrip(original) + + expect(out.name).toBe("Petstore") + expect(out.description).toBe("Test API") + + const petsFolder = out.folders.find((f) => f.name === "Pets") + expect(petsFolder).toBeDefined() + expect(petsFolder?.description).toBe("Pet endpoints") + + expect(out.variables[0]?.key).toBe("baseUrl") + expect(out.variables[0]?.initialValue).toBe("https://api.petstore.com") + expect(out.variables[0]?.currentValue).toBe("") + + const getReq = restRequestsOf(petsFolder).find( + (r) => r.name === "Get pet by ID" + ) + expect(getReq?.endpoint).toBe("<>/v1/pets/<>") + expect(getReq?.params[0]?.value).toBe("tags") + expect(getReq?.params[0]?.description).toBe("Comma-separated includes") + expect(getReq?.requestVariables[0]?.value).toBe("9704") + + const addReq = restRequestsOf(petsFolder).find( + (r) => r.name === "Add a new pet" + ) + expect(addReq?.method).toBe("POST") + expect(addReq?.body.contentType).toBe("application/json") + }) +}) + +// Collection variables — only the auto-seeded baseUrl from the server URL +// survives. Other custom variables (tokens, IDs) are dropped on export. +describe("round-trip — collection variables", () => { + it("LOSSY: collection variable definitions are dropped (only the seeded baseUrl from server URL survives)", async () => { + const out = await roundTrip( + buildColl({ + variables: [ + { + key: "apiToken", + initialValue: "tk-xxx", + currentValue: "tk-xxx", + secret: true, + }, + { + key: "tenantId", + initialValue: "1234", + currentValue: "1234", + secret: false, + }, + ], + requests: [buildReq({ endpoint: "https://api.example.com/x" })], + }) + ) + expect(out.variables.map((v) => v.key)).toEqual(["baseUrl"]) + }) +}) + +// Folder hierarchy: tag splitting, marker, and importer integration. +// Pure-function tests for the segment helpers, plus integration tests that +// drive the full importer with synthetic OpenAPI fixtures. + +describe("splitTagSegments", () => { + it("splits on slash and filters empty segments", () => { + expect(splitTagSegments("API/Users")).toEqual(["API", "Users"]) + expect(splitTagSegments("/API/Users/")).toEqual(["API", "Users"]) + expect(splitTagSegments("API//Users")).toEqual(["API", "Users"]) + expect(splitTagSegments(" API / Users ")).toEqual(["API", "Users"]) + expect(splitTagSegments("Single")).toEqual(["Single"]) + expect(splitTagSegments("")).toEqual([]) + }) +}) + +describe("hasSharedTagPathPrefix", () => { + it("detects when one tag is a strict ancestor of another", () => { + expect(hasSharedTagPathPrefix(["API", "API/Users"])).toBe(true) + }) + + it("detects when two tags share a non-empty segment prefix", () => { + expect(hasSharedTagPathPrefix(["API/Users", "API/Posts"])).toBe(true) + }) + + it("returns false for unrelated single-segment tags", () => { + expect(hasSharedTagPathPrefix(["Users", "Admin", "Reports"])).toBe(false) + }) + + it("returns false for a single tag with literal slashes (no companion)", () => { + expect(hasSharedTagPathPrefix(["OAuth/PKCE"])).toBe(false) + expect(hasSharedTagPathPrefix(["Admin/API"])).toBe(false) + }) + + it("returns false when two unrelated multi-segment tags don't share a prefix", () => { + expect(hasSharedTagPathPrefix(["OAuth/PKCE", "Admin/API"])).toBe(false) + }) +}) + +describe("pickRequestFolderSegments", () => { + it("returns the single literal tag when shouldSplit is false", () => { + expect(pickRequestFolderSegments(["API/Users"], false)).toEqual([ + "API/Users", + ]) + }) + + it("picks the deepest tag among multi-tag operations", () => { + expect(pickRequestFolderSegments(["API", "API/Users"], true)).toEqual([ + "API", + "Users", + ]) + }) + + it("ties broken by first occurrence", () => { + expect(pickRequestFolderSegments(["Foo/Bar", "Baz/Qux"], true)).toEqual([ + "Foo", + "Bar", + ]) + }) + + it("returns empty for no tags", () => { + expect(pickRequestFolderSegments([], true)).toEqual([]) + expect(pickRequestFolderSegments([], false)).toEqual([]) + }) +}) + +// Test fixtures intentionally use permissively-typed OpenAPI documents so we +// can build minimal docs without satisfying every required field. +type DocOverrides = { + paths: Record + info?: { title?: string; description?: string; version?: string } + tags?: Array<{ name: string; description?: string }> + [extension: `x-${string}`]: unknown +} + +const buildOpenAPIDoc = (overrides: DocOverrides): OpenAPI.Document => + ({ + openapi: "3.1.0", + info: { title: "Test", version: "1.0.0" }, + ...overrides, + }) as unknown as OpenAPI.Document + +type OpStub = { + summary: string + operationId: string + tags: string[] + responses: Record +} + +const op = (tags: string[], summary: string): OpStub => ({ + summary, + operationId: summary.replace(/\W+/g, "_"), + tags, + responses: { "200": { description: "ok" } }, +}) + +const runImport = async (doc: OpenAPI.Document) => { + const result = await convertOpenApiDocsToHopp([doc])() + if (result._tag === "Left") throw new Error(result.left as string) + return result.right[0] +} + +describe("convertOpenApiDocsToHopp — request body examples", () => { + it("imports form-urlencoded object examples as JSON strings", async () => { + const coll = await runImport( + buildOpenAPIDoc({ + paths: { + "/submit": { + post: { + ...op([], "Submit"), + requestBody: { + content: { + "application/x-www-form-urlencoded": { + schema: { + type: "object", + properties: { + payload: { example: { nested: true } }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + ) + + expect(firstReq(coll)?.body).toEqual({ + contentType: "application/x-www-form-urlencoded", + body: 'payload: {"nested":true}', + }) + }) + + it("imports multipart object examples as JSON strings for non-file fields", async () => { + const coll = await runImport( + buildOpenAPIDoc({ + paths: { + "/upload": { + post: { + ...op([], "Upload"), + requestBody: { + content: { + "multipart/form-data": { + schema: { + type: "object", + properties: { + file: { type: "string", format: "binary" }, + metadata: { example: { title: "avatar" } }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + ) + + expect(firstReq(coll)?.body).toEqual({ + contentType: "multipart/form-data", + body: [ + { key: "file", isFile: true, value: [], active: true }, + { + key: "metadata", + isFile: false, + value: '{"title":"avatar"}', + active: true, + }, + ], + }) + }) +}) + +describe("convertOpenApiDocsToHopp — folder hierarchy", () => { + it("nests folders when tags use slash convention with prefix tree", async () => { + const doc = buildOpenAPIDoc({ + paths: { + "/users": { get: op(["API/Users"], "List") }, + "/users/{id}": { get: op(["API/Users"], "Get") }, + "/posts": { get: op(["API/Posts"], "ListPosts") }, + }, + }) + const coll = await runImport(doc) + + expect(coll.folders).toHaveLength(1) + expect(coll.folders[0].name).toBe("API") + expect(coll.folders[0].folders).toHaveLength(2) + const folderNames = coll.folders[0].folders.map((f) => f.name).sort() + expect(folderNames).toEqual(["Posts", "Users"]) + }) + + it("places multi-tag operations at the deepest tag location only (no duplication)", async () => { + const doc = buildOpenAPIDoc({ + paths: { + "/users/{id}": { get: op(["API", "API/Users"], "GetUser") }, + }, + }) + const coll = await runImport(doc) + + const apiFolder = coll.folders.find((f) => f.name === "API")! + expect(apiFolder).toBeDefined() + expect(apiFolder.requests).toHaveLength(0) + const usersFolder = apiFolder.folders.find((f) => f.name === "Users")! + expect(usersFolder.requests).toHaveLength(1) + expect(usersFolder.requests[0].name).toBe("GetUser") + }) + + it("respects the explicit Hoppscotch marker even with a single nested tag", async () => { + // Heuristic alone wouldn't split a lone "API/Users" — the marker forces it. + const doc = buildOpenAPIDoc({ + "x-hoppscotch-folder-tags": "slash", + paths: { "/x": { get: op(["API/Users"], "X") } }, + }) + const coll = await runImport(doc) + + expect(coll.folders).toHaveLength(1) + expect(coll.folders[0].name).toBe("API") + expect(coll.folders[0].folders).toHaveLength(1) + expect(coll.folders[0].folders[0].name).toBe("Users") + expect(coll.folders[0].folders[0].requests).toHaveLength(1) + }) + + it("keeps tags flat for a doc with literal-slash tag and no marker", async () => { + const doc = buildOpenAPIDoc({ + paths: { + "/auth/pkce": { get: op(["OAuth/PKCE"], "Authorize") }, + }, + }) + const coll = await runImport(doc) + + expect(coll.folders).toHaveLength(1) + expect(coll.folders[0].name).toBe("OAuth/PKCE") + expect(coll.folders[0].folders).toHaveLength(0) + }) + + it("seeds a baseUrl collection variable from servers[0].url and parametrizes endpoints", async () => { + const doc = buildOpenAPIDoc({ + servers: [{ url: "https://api.petstore.com" }], + paths: { "/v1/pets": { get: op([], "List") } }, + } as DocOverrides) + const coll = await runImport(doc) + + expect(coll.variables).toEqual([ + { + key: "baseUrl", + initialValue: "https://api.petstore.com", + currentValue: "", + secret: false, + }, + ]) + expect(restRequestsOf(coll)[0]?.endpoint).toBe("<>/v1/pets") + }) + + it("does not seed a baseUrl variable when the doc has no servers", async () => { + const doc = buildOpenAPIDoc({ + paths: { "/health": { get: op([], "Health") } }, + }) + const coll = await runImport(doc) + + expect(coll.variables).toEqual([]) + expect(restRequestsOf(coll)[0]?.endpoint).toBe("<>/health") + }) + + it("places untagged operations at the root", async () => { + const doc = buildOpenAPIDoc({ + paths: { "/health": { get: op([], "Health") } }, + }) + const coll = await runImport(doc) + + expect(coll.requests).toHaveLength(1) + expect(coll.requests[0].name).toBe("Health") + expect(coll.folders).toHaveLength(0) + }) + + it("applies tag descriptions at the matching folder level", async () => { + const doc = buildOpenAPIDoc({ + tags: [ + { name: "API", description: "Top-level API" }, + { name: "API/Users", description: "User endpoints" }, + ], + paths: { "/users": { get: op(["API/Users"], "Get") } }, + }) + const coll = await runImport(doc) + + expect(coll.folders[0].name).toBe("API") + expect(coll.folders[0].description).toBe("Top-level API") + expect(coll.folders[0].folders[0].name).toBe("Users") + expect(coll.folders[0].folders[0].description).toBe("User endpoints") + }) + + it("collapses normalized-equivalent tags (API//Users === API/Users)", async () => { + const doc = buildOpenAPIDoc({ + "x-hoppscotch-folder-tags": "slash", + tags: [ + { name: "API//Users", description: "first" }, + { name: "API/Users", description: "second" }, + ], + paths: { + "/a": { get: op(["API//Users"], "A") }, + "/b": { get: op(["API/Users"], "B") }, + }, + }) + const coll = await runImport(doc) + + expect(coll.folders).toHaveLength(1) + const usersFolder = coll.folders[0].folders[0] + expect(usersFolder.name).toBe("Users") + expect(usersFolder.requests).toHaveLength(2) + expect(usersFolder.description).toBe("first") + }) +}) + +// Export -> import round-trip specifically for folder-related concerns +// (marker emission, name preservation, leading <> handling). +describe("export → import round-trip preserves folder hierarchy", () => { + it("a single nested folder survives round-trip via the Hoppscotch marker", async () => { + const original = buildColl({ + name: "Original", + folders: [ + buildColl({ + name: "API", + folders: [ + buildColl({ + name: "Users", + requests: [ + buildReq({ + name: "Get", + endpoint: "https://api.example.com/users/<>", + }), + ], + }), + ], + }), + ], + }) + + const { doc } = hoppCollectionToOpenAPI(original) + expect((doc as Record)["x-hoppscotch-folder-tags"]).toBe( + "slash" + ) + + const imported = await (async () => { + const r = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (r._tag === "Left") throw new Error(r.left as string) + return r.right[0] + })() + + expect(imported.folders).toHaveLength(1) + expect(imported.folders[0].name).toBe("API") + expect(imported.folders[0].folders).toHaveLength(1) + expect(imported.folders[0].folders[0].name).toBe("Users") + expect(imported.folders[0].folders[0].requests).toHaveLength(1) + expect(imported.folders[0].folders[0].requests[0].name).toBe("Get") + }) + + it("preserves spaces in request names through round-trip", async () => { + // Regression: importer used to prefer `operationId` (sanitized, e.g. + // `Get_Pet`) over `summary` (human-readable, e.g. `Get Pet`), so a + // round-trip turned `Get Pet` into `Get_Pet`. + const original = buildColl({ + requests: [ + buildReq({ + name: "Get Pet", + endpoint: "https://api.example.com/pets/9704", + }), + ], + }) + const { doc } = hoppCollectionToOpenAPI(original) + + const imported = await (async () => { + const r = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (r._tag === "Left") throw new Error(r.left as string) + return r.right[0] + })() + + expect(imported.requests[0].name).toBe("Get Pet") + }) + + it("does not double-prefix endpoints that start with a Hoppscotch <>", async () => { + // Regression: `<>/pets/9704` round-tripped as + // `<>/<>/pets/9704` because the export side kept the + // variable inside the path key. After the fix, the variable becomes the + // server and the path is just `/pets/9704`. + const original = buildColl({ + name: "Pets", + requests: [ + buildReq({ + name: "Get pet", + endpoint: "<>/pets/9704", + }), + ], + }) + + const { doc } = hoppCollectionToOpenAPI(original) + + const imported = await (async () => { + const r = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (r._tag === "Left") throw new Error(r.left as string) + return r.right[0] + })() + + expect(imported.requests).toHaveLength(1) + expect(restRequestsOf(imported)[0]?.endpoint).toBe("<>/pets/9704") + }) +}) + +// Backward compatibility: OpenAPI 3.0.x and Swagger 2.0. +// The 3.1 fixes (collection-level auth, parameter value preservation, +// multipart isFile, baseUrl seeding) were primarily exercised through 3.1 +// round-trips. These tests run equivalent docs in 3.0 and 2.0 shape to +// surface any version-specific regressions. + +const importDoc = async (raw: unknown) => { + const r = await convertOpenApiDocsToHopp([raw as OpenAPI.Document])() + if (r._tag === "Left") throw new Error(r.left as string) + return r.right[0] +} + +describe("OpenAPI 3.0.x compatibility", () => { + it("imports a 3.0 doc with servers + components.securitySchemes", async () => { + const doc = { + openapi: "3.0.0", + info: { title: "Petstore 3.0", version: "1.0.0" }, + servers: [{ url: "https://api.petstore.com" }], + paths: { + "/pets": { + get: { + summary: "List pets", + operationId: "listPets", + tags: ["pets"], + parameters: [ + { + name: "limit", + in: "query", + description: "Max items", + schema: { type: "integer" }, + example: 20, + }, + ], + responses: { "200": { description: "ok" } }, + }, + }, + }, + components: { + securitySchemes: { + basicAuth: { type: "http", scheme: "basic" }, + }, + }, + security: [{ basicAuth: [] }], + } + const c = await importDoc(doc) + expect(c.variables[0]?.key).toBe("baseUrl") + expect(c.variables[0]?.initialValue).toBe("https://api.petstore.com") + expect(c.variables[0]?.currentValue).toBe("") + expect(c.auth.authType).toBe("basic") + const req = c.folders[0]?.requests[0] ?? c.requests[0] + expect(req?.auth.authType).toBe("inherit") + const limit = ( + req as { params?: Array<{ key: string; value: string }> } + )?.params?.find((p) => p.key === "limit") + expect(limit?.value).toBe("20") + }) + + it("imports a 3.0 doc with operation-level security override", async () => { + const doc = { + openapi: "3.0.0", + info: { title: "X", version: "1.0.0" }, + paths: { + "/public": { + get: { + operationId: "publicGet", + security: [], + responses: { "200": { description: "ok" } }, + }, + }, + }, + components: { + securitySchemes: { + basicAuth: { type: "http", scheme: "basic" }, + }, + }, + security: [{ basicAuth: [] }], + } + const c = await importDoc(doc) + expect(c.auth.authType).toBe("basic") + expect(c.requests[0]?.auth.authType).toBe("none") + }) +}) + +describe("Swagger 2.0 compatibility", () => { + it("seeds baseUrl from host + basePath + scheme", async () => { + const doc = { + swagger: "2.0", + info: { title: "Swagger 2", version: "1.0.0" }, + host: "api.petstore.com", + basePath: "/v2", + schemes: ["https"], + paths: { + "/pets": { + get: { + operationId: "listPets", + responses: { "200": { description: "ok" } }, + }, + }, + }, + } + const c = await importDoc(doc) + expect(c.variables[0]?.key).toBe("baseUrl") + // Should be a fully-qualified URL — without the scheme, the variable + // value isn't a usable URL at runtime. + expect(c.variables[0]?.initialValue).toBe("https://api.petstore.com/v2") + expect(c.variables[0]?.currentValue).toBe("") + expect(restRequestsOf(c)[0]?.endpoint).toBe("<>/pets") + }) + + it("falls back to https when schemes is missing", async () => { + const doc = { + swagger: "2.0", + info: { title: "X", version: "1.0.0" }, + host: "api.example.com", + basePath: "/v1", + paths: { + "/x": { + get: { + operationId: "x", + responses: { "200": { description: "ok" } }, + }, + }, + }, + } + const c = await importDoc(doc) + expect(c.variables[0]?.initialValue).toBe("https://api.example.com/v1") + expect(c.variables[0]?.currentValue).toBe("") + }) + + it("imports query param with default value", async () => { + const doc = { + swagger: "2.0", + info: { title: "X", version: "1.0.0" }, + host: "api.example.com", + paths: { + "/search": { + get: { + operationId: "search", + parameters: [ + { + name: "q", + in: "query", + type: "string", + default: "hello", + }, + ], + responses: { "200": { description: "ok" } }, + }, + }, + }, + } + const c = await importDoc(doc) + const params = + (c.requests[0] as { params?: Array<{ key: string; value: string }> }) + ?.params ?? [] + const q = params.find((p) => p.key === "q") + // V2 widely uses `default` for example values; we should preserve it. + expect(q?.value).toBe("hello") + }) + + it("imports collection-level securityDefinitions + security", async () => { + const doc = { + swagger: "2.0", + info: { title: "X", version: "1.0.0" }, + host: "api.example.com", + paths: { + "/things": { + get: { + operationId: "listThings", + responses: { "200": { description: "ok" } }, + }, + }, + }, + securityDefinitions: { + basicAuth: { type: "basic" }, + }, + security: [{ basicAuth: [] }], + } + const c = await importDoc(doc) + expect(c.auth.authType).toBe("basic") + expect(c.requests[0]?.auth.authType).toBe("inherit") + }) + + it("imports a multipart formData body with isFile flag", async () => { + const doc = { + swagger: "2.0", + info: { title: "X", version: "1.0.0" }, + host: "api.example.com", + paths: { + "/upload": { + post: { + operationId: "upload", + consumes: ["multipart/form-data"], + parameters: [ + { name: "name", in: "formData", type: "string" }, + { name: "avatar", in: "formData", type: "file" }, + ], + responses: { "200": { description: "ok" } }, + }, + }, + }, + } + const c = await importDoc(doc) + const body = + ( + c.requests[0] as { + body?: { + contentType?: string + body?: Array<{ key: string; isFile: boolean }> + } + } + )?.body ?? {} + expect(body.contentType).toBe("multipart/form-data") + if (Array.isArray(body.body)) { + const avatar = body.body.find((e) => e.key === "avatar") + expect(avatar?.isFile).toBe(true) + } + }) +}) + +describe("OpenAPI export — path-collision preservation (x-hoppscotch-dropped-requests)", () => { + it("end-to-end: nested-folder dropped request is restored with correct tagPath", async () => { + const original = buildCollection({ + name: "API", + folders: [ + buildCollection({ + name: "Users", + requests: [ + buildRequest({ + name: "Get v1", + method: "GET", + endpoint: "https://api.example.com/users", + }), + buildRequest({ + name: "Get v2", + method: "GET", + endpoint: "https://api.example.com/users", + }), + ], + }), + buildCollection({ + name: "Posts", + requests: [ + buildRequest({ + name: "Posts list", + method: "GET", + endpoint: "https://api.example.com/posts", + }), + ], + }), + ], + }) + + const { doc } = hoppCollectionToOpenAPI(original) + const dropped = (doc as Record)[ + "x-hoppscotch-dropped-requests" + ] as Array<{ tagPath: string | null; request: { name: string } }> + + expect(dropped).toHaveLength(1) + expect(dropped[0].tagPath).toBe("Users") + expect(dropped[0].request.name).toBe("Get v2") + + const result = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (E.isLeft(result)) throw new Error("import failed") + + const usersF = result.right[0].folders.find((f) => f.name === "Users") + expect(usersF?.requests.map((r) => r.name).sort()).toEqual([ + "Get v1", + "Get v2", + ]) + // Restored requests must use the same <> parameterisation as + // kept requests so the sidebar tree shows consistent endpoint forms. + expect( + usersF?.requests.every((r) => r.endpoint.startsWith("<>")) + ).toBe(true) + }) + + it("does not emit the extension when there are no collisions", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "A", + method: "GET", + endpoint: "https://api.example.com/a", + }), + buildRequest({ + name: "B", + method: "POST", + endpoint: "https://api.example.com/a", + }), + ], + }) + + const { doc } = hoppCollectionToOpenAPI(collection) + + expect( + (doc as Record)["x-hoppscotch-dropped-requests"] + ).toBeUndefined() + }) + + it("restores sibling-folder collisions including nested children without dropping any", async () => { + const original = buildCollection({ + name: "API", + folders: [ + buildCollection({ + name: "FolderA", + requests: [ + buildRequest({ + name: "A-list", + method: "GET", + endpoint: "https://api.example.com/items", + }), + ], + folders: [ + buildCollection({ + name: "Sub", + requests: [ + buildRequest({ + name: "A-sub-get", + method: "GET", + endpoint: "https://api.example.com/items", + }), + ], + }), + ], + }), + buildCollection({ + name: "FolderB", + requests: [ + buildRequest({ + name: "B-list", + method: "GET", + endpoint: "https://api.example.com/items", + }), + ], + }), + ], + }) + + const { doc } = hoppCollectionToOpenAPI(original) + const result = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (E.isLeft(result)) throw new Error("import failed") + + const folderA = result.right[0].folders.find((f) => f.name === "FolderA") + const folderB = result.right[0].folders.find((f) => f.name === "FolderB") + expect(folderA?.requests.map((r) => r.name)).toContain("A-list") + expect(folderA?.folders[0]?.requests.map((r) => r.name)).toContain( + "A-sub-get" + ) + expect(folderB?.requests.map((r) => r.name)).toContain("B-list") + }) +}) + +describe("OpenAPI export — workspace-root unwrap (x-hoppscotch-workspace-root)", () => { + it("multi-collection workspace marks the doc and unwraps to N roots on re-import", async () => { + const { doc } = hoppCollectionsToOpenAPI("MyWorkspace", [ + buildCollection({ + name: "ColA", + folders: [ + buildCollection({ + name: "Sub", + requests: [ + buildRequest({ + name: "A1", + endpoint: "https://api.example.com/a", + }), + ], + }), + ], + }), + buildCollection({ + name: "ColB", + requests: [ + buildRequest({ + name: "B1", + endpoint: "https://api.example.com/b", + }), + ], + }), + ]) + + expect( + (doc as Record)["x-hoppscotch-workspace-root"] + ).toBe(true) + + const result = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (E.isLeft(result)) throw new Error("import failed") + + expect(result.right).toHaveLength(2) + const colA = result.right.find((c) => c.name === "ColA") + const colB = result.right.find((c) => c.name === "ColB") + expect(colA?.folders.find((f) => f.name === "Sub")?.requests).toHaveLength( + 1 + ) + expect(colB?.requests).toHaveLength(1) + }) + + it("single-collection workspace export imports as 1 root collection", async () => { + const { doc } = hoppCollectionsToOpenAPI("Lonely", [ + buildCollection({ + name: "OnlyChild", + requests: [ + buildRequest({ + name: "Solo", + endpoint: "https://api.example.com/solo", + }), + ], + }), + ]) + + const result = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (E.isLeft(result)) throw new Error("import failed") + + expect(result.right).toHaveLength(1) + expect(result.right[0].name).toBe("OnlyChild") + expect(result.right[0].requests).toHaveLength(1) + }) + + it("does not unwrap a doc without the workspace-root marker", async () => { + const { doc } = hoppCollectionToOpenAPI( + buildCollection({ + name: "ThirdPartyAPI", + folders: [ + buildCollection({ + name: "Inner", + requests: [ + buildRequest({ + name: "Op", + endpoint: "https://api.example.com/op", + }), + ], + }), + ], + }) + ) + + const result = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (E.isLeft(result)) throw new Error("import failed") + + expect(result.right).toHaveLength(1) + expect(result.right[0].name).toBe("ThirdPartyAPI") + expect(result.right[0].folders).toHaveLength(1) + expect(result.right[0].folders[0].name).toBe("Inner") + }) + + it("degenerate empty workspace keeps the wrapper rather than unwrapping to nothing", async () => { + const { doc } = hoppCollectionsToOpenAPI("EmptyWS", []) + const result = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (E.isLeft(result)) throw new Error("import failed") + + expect(result.right).toHaveLength(1) + expect(result.right[0].name).toBe("EmptyWS") + }) + + it("propagates baseUrl variable and wrapper auth onto each unwrapped collection", async () => { + const collections = [ + buildCollection({ + name: "ColA", + requests: [ + buildRequest({ + name: "GetA", + endpoint: "https://api.example.com/a", + }), + ], + }), + buildCollection({ + name: "ColB", + requests: [ + buildRequest({ + name: "GetB", + endpoint: "https://api.example.com/b", + }), + ], + }), + ] + + const { doc } = hoppCollectionsToOpenAPI("WS", collections) + const result = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (E.isLeft(result)) throw new Error("import failed") + + expect(result.right).toHaveLength(2) + for (const c of result.right) { + const baseUrlVar = c.variables.find((v) => v.key === "baseUrl") + expect(baseUrlVar?.initialValue).toBe("https://api.example.com") + expect(c.requests[0]?.endpoint).toMatch(/^<>/) + // Wrapper auth (from doc.security) propagates; absent doc.security + // resolves to inherit, which is the workspace-level neutral default. + expect(c.auth.authType).toBe("inherit") + } + }) +}) + +describe("OpenAPI export — dropped-requests sanitization", () => { + it("strips auth, scripts, inactive params/headers, Authorization, Cookie (case-insensitive, multiple)", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Kept", + method: "POST", + endpoint: "https://api.example.com/login", + }), + buildRequest({ + name: "Dropped", + method: "POST", + endpoint: "https://api.example.com/login", + auth: { + authType: "basic", + authActive: false, + username: "alice", + password: "s3cret", + }, + preRequestScript: "pw.env.set('x', 1)", + testScript: "pw.test('ok', () => {})", + params: [ + { key: "active-param", value: "v", active: true, description: "" }, + { key: "stale", value: "v", active: false, description: "" }, + ], + headers: [ + { + key: "Authorization", + value: "Bearer raw-header-secret", + active: true, + description: "", + }, + { + key: "Cookie", + value: "a=cookie-secret-1", + active: true, + description: "", + }, + { + key: "cookie", + value: "b=cookie-secret-2", + active: true, + description: "", + }, + { + key: "X-Active", + value: "1", + active: true, + description: "", + }, + { + key: "X-Stale", + value: "1", + active: false, + description: "", + }, + ], + requestVariables: [ + { key: "active-var", value: "v", active: true, description: "" }, + { key: "stale-var", value: "v", active: false, description: "" }, + ], + }), + ], + }) + + const { doc } = hoppCollectionToOpenAPI(collection) + const dropped = (doc as Record)[ + "x-hoppscotch-dropped-requests" + ] as Array<{ request: HoppRESTRequest }> + + expect(dropped).toHaveLength(1) + const r = dropped[0].request + expect(r.auth.authType).toBe("basic") + expect(r.preRequestScript).toBe("") + expect(r.testScript).toBe("") + expect(r.params.map((p) => p.key)).toEqual(["active-param"]) + expect(r.headers.map((h) => h.key)).toEqual(["X-Active"]) + expect(r.requestVariables.map((v) => v.key)).toEqual(["active-var"]) + + const serialized = JSON.stringify(dropped) + expect(serialized).not.toContain("s3cret") + expect(serialized).not.toContain("alice") + expect(serialized).not.toContain("raw-header-secret") + expect(serialized).not.toContain("cookie-secret-1") + expect(serialized).not.toContain("cookie-secret-2") + }) + + it("strips padded Authorization headers from both normal export and dropped extension", () => { + const padded = [ + { + key: " Authorization ", + value: "Bearer padded-auth-secret", + active: true, + description: "", + }, + ] + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Normal", + method: "GET", + endpoint: "https://api.example.com/normal", + headers: padded, + }), + buildRequest({ + name: "Collision Kept", + method: "GET", + endpoint: "https://api.example.com/collide", + }), + buildRequest({ + name: "Collision Dropped", + method: "GET", + endpoint: "https://api.example.com/collide", + headers: padded, + }), + ], + }) + + const { doc } = hoppCollectionToOpenAPI(collection) + const serialized = JSON.stringify(doc) + + expect(serialized).not.toContain("padded-auth-secret") + }) + + it("preserves explicit no-auth on dropped requests instead of inheriting parent auth", async () => { + const collection = buildCollection({ + auth: { + authType: "bearer", + authActive: true, + token: "collection-token", + }, + requests: [ + buildRequest({ + name: "Kept", + method: "GET", + endpoint: "https://api.example.com/profile", + }), + buildRequest({ + name: "Dropped Public", + method: "GET", + endpoint: "https://api.example.com/profile", + auth: { authType: "none", authActive: true }, + }), + ], + }) + + const { doc } = hoppCollectionToOpenAPI(collection) + const dropped = (doc as Record)[ + "x-hoppscotch-dropped-requests" + ] as Array<{ request: HoppRESTRequest }> + + expect(dropped).toHaveLength(1) + expect(dropped[0].request.auth.authType).toBe("none") + + const roundTripped = await roundTrip(collection) + const restored = restRequestsOf(roundTripped).find( + (request) => request.name === "Dropped Public" + ) + expect(restored?.auth.authType).toBe("none") + }) + + it("materializes inherited collection auth into the dropped snapshot (credentials zeroed)", async () => { + const collection = buildCollection({ + auth: { + authType: "bearer", + authActive: true, + token: "collection-token", + }, + requests: [ + buildRequest({ + name: "Kept", + method: "GET", + endpoint: "https://api.example.com/profile", + }), + buildRequest({ + name: "Dropped Inheriting", + method: "GET", + endpoint: "https://api.example.com/profile", + auth: { authType: "inherit", authActive: true }, + }), + ], + }) + + const { doc } = hoppCollectionToOpenAPI(collection) + const dropped = (doc as Record)[ + "x-hoppscotch-dropped-requests" + ] as Array<{ request: HoppRESTRequest }> + + expect(dropped[0].request.auth.authType).toBe("bearer") + if (dropped[0].request.auth.authType === "bearer") { + expect(dropped[0].request.auth.token).toBe("") + } + expect(JSON.stringify(dropped)).not.toContain("collection-token") + + const roundTripped = await roundTrip(collection) + const restored = restRequestsOf(roundTripped).find( + (request) => request.name === "Dropped Inheriting" + ) + expect(restored?.auth.authType).toBe("bearer") + }) + + it("drops inactive multipart entries from dropped requests", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Kept", + method: "POST", + endpoint: "https://api.example.com/upload", + }), + buildRequest({ + name: "Dropped Upload", + method: "POST", + endpoint: "https://api.example.com/upload", + body: { + contentType: "multipart/form-data", + body: [ + { + key: "displayName", + value: "public-name", + active: true, + isFile: false, + }, + { + key: "disabledSecret", + value: "inactive-secret", + active: false, + isFile: false, + }, + { + key: "activeFile", + value: "active-file-payload", + active: true, + isFile: true, + }, + { + key: "disabledFile", + value: "inactive-file-payload", + active: false, + isFile: true, + }, + ], + }, + }), + ], + }) + + const { doc } = hoppCollectionToOpenAPI(collection) + const dropped = (doc as Record)[ + "x-hoppscotch-dropped-requests" + ] as Array<{ request: HoppRESTRequest }> + const body = dropped[0].request.body + + expect(body.contentType).toBe("multipart/form-data") + expect( + Array.isArray(body.body) ? body.body.map((entry) => entry.key) : [] + ).toEqual(["displayName", "activeFile"]) + expect( + Array.isArray(body.body) + ? body.body.find((entry) => entry.key === "activeFile")?.value + : undefined + ).toBe("") + + const serialized = JSON.stringify(dropped) + expect(serialized).not.toContain("inactive-secret") + expect(serialized).not.toContain("active-file-payload") + expect(serialized).not.toContain("inactive-file-payload") + }) + + it("strips credentials embedded in saved responses' originalRequest and body", () => { + const dropped = buildRequest({ + name: "With Secret Response", + method: "POST", + endpoint: "https://api.example.com/login", + responses: { + success: { + name: "success", + status: 200, + statusText: "OK", + headers: [{ key: "Set-Cookie", value: "session=top-cookie-secret" }], + body: '{"accessToken":"top-token-secret"}', + originalRequest: makeHoppRESTResponseOriginalRequest({ + v: "10", + name: "OG Login", + endpoint: "https://api.example.com/login", + method: "POST", + params: [], + headers: [ + { + key: "Authorization", + value: "Bearer top-header-secret", + active: true, + description: "", + }, + ], + body: { contentType: null, body: null }, + requestVariables: [], + auth: { + authType: "bearer", + authActive: true, + token: "top-original-secret", + }, + }) as HoppRESTRequestResponse["originalRequest"], + }, + } as HoppRESTRequestResponses, + }) + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Kept", + method: "POST", + endpoint: "https://api.example.com/login", + }), + dropped, + ], + }) + + const { doc } = hoppCollectionToOpenAPI(collection) + const serialized = JSON.stringify( + (doc as Record)["x-hoppscotch-dropped-requests"] + ) + + expect(serialized).not.toContain("top-token-secret") + expect(serialized).not.toContain("top-cookie-secret") + expect(serialized).not.toContain("top-header-secret") + expect(serialized).not.toContain("top-original-secret") + }) + + it("falls back to inherit auth when an unknown oauth-2 grant slips through", () => { + const collection = buildCollection({ + requests: [ + buildRequest({ + name: "Kept", + method: "GET", + endpoint: "https://api.example.com/oauth", + }), + buildRequest({ + name: "Dropped OAuth Unknown Grant", + method: "GET", + endpoint: "https://api.example.com/oauth", + auth: { + authType: "oauth-2", + authActive: true, + addTo: "HEADERS", + grantTypeInfo: { + // Force-cast a non-existent grant to exercise the default branch. + grantType: "DEVICE_CODE", + token: "oauth-secret", + }, + } as unknown as HoppRESTRequest["auth"], + }), + ], + }) + + const { doc } = hoppCollectionToOpenAPI(collection) + const dropped = (doc as Record)[ + "x-hoppscotch-dropped-requests" + ] as Array<{ request: HoppRESTRequest }> + + expect(dropped[0].request.auth.authType).toBe("inherit") + expect(JSON.stringify(dropped)).not.toContain("oauth-secret") + }) +}) + +describe("OpenAPI import — dropped-requests hardening", () => { + it("strips injected auth, scripts, and credential headers from imported dropped requests", async () => { + const injected = buildRequest({ + name: "Injected", + method: "POST", + endpoint: "https://api.example.com/login", + preRequestScript: "/* attacker-pre-script */", + testScript: "/* attacker-test-script */", + auth: { + authType: "bearer", + authActive: true, + token: "attacker-auth-token", + }, + headers: [ + { + key: "Authorization", + value: "Bearer attacker-token", + active: true, + description: "", + }, + { + key: "cookie", + value: "session=attacker-session", + active: true, + description: "", + }, + { + key: "Content-Type", + value: "application/json", + active: true, + description: "", + }, + { + key: "Accept", + value: "application/xml", + active: true, + description: "", + }, + { key: "X-Custom", value: "kept", active: true, description: "" }, + ], + }) + + const doc = { + openapi: "3.1.0", + info: { title: "Crafted", version: "1.0.0" }, + paths: { + "/login": { + post: { + summary: "Login", + responses: { "200": { description: "ok" } }, + }, + }, + }, + "x-hoppscotch-dropped-requests": [{ tagPath: null, request: injected }], + } + + const result = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (E.isLeft(result)) throw new Error("import failed") + + const restored = result.right[0].requests.find((r) => r.name === "Injected") + expect(restored).toBeDefined() + expect(restored?.auth.authType).toBe("bearer") + if (restored?.auth.authType === "bearer") { + expect(restored.auth.token).toBe("") + } + expect(restored?.preRequestScript).toBe("") + expect(restored?.testScript).toBe("") + expect(restored?.headers.map((h) => h.key.toLowerCase())).toEqual([ + "x-custom", + ]) + + const serialized = JSON.stringify(result.right) + expect(serialized).not.toContain("attacker-auth-token") + expect(serialized).not.toContain("attacker-token") + expect(serialized).not.toContain("attacker-session") + expect(serialized).not.toContain("attacker-pre-script") + expect(serialized).not.toContain("attacker-test-script") + }) + + it("skips malformed dropped entries instead of restoring silent default requests", async () => { + const doc = { + openapi: "3.1.0", + info: { title: "Crafted", version: "1.0.0" }, + paths: { + "/items": { + get: { responses: { "200": { description: "ok" } } }, + }, + }, + "x-hoppscotch-dropped-requests": [ + { tagPath: null, request: "not-an-object" }, + { tagPath: null, request: { not: "a valid request" } }, + { tagPath: null, request: null }, + ], + } + + const result = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (E.isLeft(result)) throw new Error("import failed") + + // Only the path-based request survives; malformed dropped entries are dropped. + expect(result.right[0].requests).toHaveLength(1) + expect(result.right[0].requests[0].endpoint).toMatch(/\/items$/) + }) + + it("isolates wrapper variables and auth per unwrapped child", async () => { + const collections = [ + buildCollection({ + name: "ColA", + requests: [ + buildRequest({ + name: "A", + endpoint: "https://api.example.com/a", + }), + ], + }), + buildCollection({ + name: "ColB", + requests: [ + buildRequest({ + name: "B", + endpoint: "https://api.example.com/b", + }), + ], + }), + ] + + const { doc } = hoppCollectionsToOpenAPI("WS", collections) + const result = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (E.isLeft(result)) throw new Error("import failed") + + const [colA, colB] = result.right + expect(colA.variables).not.toBe(colB.variables) + expect(colA.auth).not.toBe(colB.auth) + + colA.variables.push({ + key: "leaked", + initialValue: "x", + currentValue: "", + secret: false, + }) + expect(colB.variables.find((v) => v.key === "leaked")).toBeUndefined() + + // Entry-level mutation on A's existing baseUrl must not leak into B. + const aBaseUrl = colA.variables.find((v) => v.key === "baseUrl") + if (aBaseUrl) aBaseUrl.initialValue = "https://mutated.example.com" + expect(colB.variables.find((v) => v.key === "baseUrl")?.initialValue).toBe( + "https://api.example.com" + ) + }) + + it("drops inactive urlencoded body entries from imported dropped requests", async () => { + const injected = buildRequest({ + name: "InjectedUrlEncoded", + method: "POST", + endpoint: "https://api.example.com/form", + body: { + contentType: "application/x-www-form-urlencoded", + body: 'kept: "ok"\n# disabled\n# inactive-secret: "leak"', + showIndividualParams: false, + } as HoppRESTRequest["body"], + }) + + const doc = { + openapi: "3.1.0", + info: { title: "Crafted", version: "1.0.0" }, + paths: { + "/form": { + post: { responses: { "200": { description: "ok" } } }, + }, + }, + "x-hoppscotch-dropped-requests": [{ tagPath: null, request: injected }], + } + + const result = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (E.isLeft(result)) throw new Error("import failed") + + const restored = result.right[0].requests.find( + (r) => r.name === "InjectedUrlEncoded" + ) + + expect(restored?.body.contentType).toBe("application/x-www-form-urlencoded") + expect(JSON.stringify(restored?.body)).not.toContain("inactive-secret") + expect(JSON.stringify(restored?.body)).not.toContain("leak") + }) + + it("strips inactive params, headers, and request variables from imported dropped requests", async () => { + const injected = buildRequest({ + name: "InjectedInactive", + method: "GET", + endpoint: "https://api.example.com/items", + params: [ + { key: "kept", value: "1", active: true, description: "" }, + { + key: "inactive-param", + value: "inactive-param-secret", + active: false, + description: "", + }, + ], + headers: [ + { key: "X-Kept", value: "ok", active: true, description: "" }, + { + key: "X-Inactive", + value: "inactive-header-secret", + active: false, + description: "", + }, + ], + requestVariables: [ + { key: "kept-var", value: "v", active: true }, + { + key: "inactive-var", + value: "inactive-var-secret", + active: false, + }, + ], + }) + + const doc = { + openapi: "3.1.0", + info: { title: "Crafted", version: "1.0.0" }, + paths: { + "/items": { + get: { responses: { "200": { description: "ok" } } }, + }, + }, + "x-hoppscotch-dropped-requests": [{ tagPath: null, request: injected }], + } + + const result = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (E.isLeft(result)) throw new Error("import failed") + + const restored = result.right[0].requests.find( + (r) => r.name === "InjectedInactive" + ) + + expect(restored?.params.map((p) => p.key)).toEqual(["kept"]) + expect(restored?.headers.map((h) => h.key)).toEqual(["X-Kept"]) + expect(restored?.requestVariables.map((v) => v.key)).toEqual(["kept-var"]) + + const serialized = JSON.stringify(restored) + expect(serialized).not.toContain("inactive-param-secret") + expect(serialized).not.toContain("inactive-header-secret") + expect(serialized).not.toContain("inactive-var-secret") + }) +}) + +describe("OpenAPI round-trip — workspace + collision interaction", () => { + it("workspace unwrap restores cross-collection (path, method) collisions at their original collection", async () => { + const collections = [ + buildCollection({ + name: "ColA", + requests: [ + buildRequest({ + name: "A-keep", + method: "GET", + endpoint: "https://api.example.com/items", + }), + buildRequest({ + name: "A-collide", + method: "GET", + endpoint: "https://api.example.com/items", + }), + ], + }), + buildCollection({ + name: "ColB", + requests: [ + buildRequest({ + name: "B-collide", + method: "GET", + endpoint: "https://api.example.com/items", + }), + buildRequest({ + name: "B-other", + method: "POST", + endpoint: "https://api.example.com/items", + }), + ], + }), + ] + + const { doc } = hoppCollectionsToOpenAPI("WS", collections) + const result = await convertOpenApiDocsToHopp([doc as OpenAPI.Document])() + if (E.isLeft(result)) throw new Error("import failed") + + expect(result.right).toHaveLength(2) + const colA = result.right.find((c) => c.name === "ColA") + expect(colA?.requests.map((r) => r.name).sort()).toEqual([ + "A-collide", + "A-keep", + ]) + const colB = result.right.find((c) => c.name === "ColB") + expect(colB?.requests.map((r) => r.name).sort()).toEqual([ + "B-collide", + "B-other", + ]) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/import-export/export/environment.ts b/packages/hoppscotch-common/src/helpers/import-export/export/environment.ts new file mode 100644 index 0000000..cb28e34 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/export/environment.ts @@ -0,0 +1,64 @@ +import { Environment } from "@hoppscotch/data" +import * as E from "fp-ts/Either" +import { cloneDeep } from "lodash-es" + +import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment" +import { stripSecretVariableValuesForWire } from "~/helpers/secretVariables" +import { initializeDownloadFile } from "." + +const getEnvironmentJSON = ( + environmentObj: TeamEnvironment | Environment, + environmentIndex?: number | "Global" | null +) => { + const newEnvironment = + "environment" in environmentObj + ? cloneDeep(environmentObj.environment) + : cloneDeep(environmentObj) + + const environmentId = + environmentIndex || environmentIndex === 0 + ? environmentIndex + : environmentObj.id + + // Eliminate `currentValue` field from environment variables prior to export + const transformedEnvironment = transformEnvironmentVariables(newEnvironment) + + return environmentId !== null + ? JSON.stringify(transformedEnvironment, null, 2) + : undefined +} + +// Apply necessary transformations prior to environment exports. +// Strips `initialValue` for `secret: true` variables AND clears +// `currentValue` for all variables. +export const transformEnvironmentVariables = ({ + id, + v, + name, + variables, +}: Environment) => { + return { + id, + v, + name, + variables: stripSecretVariableValuesForWire(variables), + } +} + +export const exportAsJSON = async ( + environmentObj: Environment | TeamEnvironment, + environmentIndex?: number | "Global" | null +): Promise | E.Left> => { + const environmentJSON = getEnvironmentJSON(environmentObj, environmentIndex) + + if (!environmentJSON) { + return E.left("state.download_failed") + } + + const message = await initializeDownloadFile( + environmentJSON, + JSON.parse(environmentJSON).name + ) + + return message +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/export/environments.ts b/packages/hoppscotch-common/src/helpers/import-export/export/environments.ts new file mode 100644 index 0000000..ac77877 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/export/environments.ts @@ -0,0 +1,10 @@ +import { Environment } from "@hoppscotch/data" +import { stripSecretVariableValuesForWire } from "~/helpers/secretVariables" + +export const environmentsExporter = (myEnvironments: Environment[]) => { + const stripped = myEnvironments.map((env) => ({ + ...env, + variables: stripSecretVariableValuesForWire(env.variables), + })) + return JSON.stringify(stripped, null, 2) +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/export/gist.ts b/packages/hoppscotch-common/src/helpers/import-export/export/gist.ts new file mode 100644 index 0000000..9e82b67 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/export/gist.ts @@ -0,0 +1,19 @@ +import * as E from "fp-ts/Either" +import { createGist } from "~/helpers/gist" + +export const gistExporter = async ( + JSONFileContents: string, + accessToken: string, + fileName = "hoppscotch-collections.json" +) => { + if (!accessToken) { + return E.left("Invalid User") + } + + const res = await createGist(JSONFileContents, fileName, accessToken)() + + if (E.isLeft(res)) { + return E.left(res.left) + } + return E.right(res.right.data.html_url as string) +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/export/gqlCollections.ts b/packages/hoppscotch-common/src/helpers/import-export/export/gqlCollections.ts new file mode 100644 index 0000000..0d24b31 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/export/gqlCollections.ts @@ -0,0 +1,8 @@ +import { HoppCollection } from "@hoppscotch/data" +import { stripRefIdReplacer } from "." +import { stripCollectionTreeForStore } from "~/helpers/secretVariables" + +export const gqlCollectionsExporter = (gqlCollections: HoppCollection[]) => { + const stripped = gqlCollections.map(stripCollectionTreeForStore) + return JSON.stringify(stripped, stripRefIdReplacer, 2) +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/export/index.ts b/packages/hoppscotch-common/src/helpers/import-export/export/index.ts new file mode 100644 index 0000000..c59921d --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/export/index.ts @@ -0,0 +1,43 @@ +import * as E from "fp-ts/Either" +import { platform } from "~/platform" + +/** + * Create a downloadable file from a collection/environment and prompts the user to download it. + * @param contentsJSON - JSON string of the collection + * @param name - Name of the collection set as the file name + * @returns {Promise | E.Left>} - Returns a promise that resolves to an `Either` with `i18n` key for the status message + */ +export const initializeDownloadFile = async ( + contentsJSON: string, + name: string | null +) => { + const file = new Blob([contentsJSON], { type: "application/json" }) + const url = URL.createObjectURL(file) + + const fileName = name ?? url.split("/").pop()!.split("#")[0].split("?")[0] + + const result = await platform.kernelIO.saveFileWithDialog({ + data: contentsJSON, + contentType: "application/json", + suggestedFilename: `${fileName}.json`, + filters: [ + { + name: "Hoppscotch Collection/Environment JSON file", + extensions: ["json"], + }, + ], + }) + + if (result.type === "unknown" || result.type === "saved") { + return E.right("state.download_started") + } + + return E.left("state.download_failed") +} + +/** + * JSON replacer to remove `_ref_id` from the exported JSON + */ +export const stripRefIdReplacer = (key: string, value: any) => { + return key === "_ref_id" ? undefined : value +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/export/myCollections.ts b/packages/hoppscotch-common/src/helpers/import-export/export/myCollections.ts new file mode 100644 index 0000000..d0f576c --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/export/myCollections.ts @@ -0,0 +1,8 @@ +import { HoppCollection } from "@hoppscotch/data" +import { stripRefIdReplacer } from "." +import { stripCollectionTreeForStore } from "~/helpers/secretVariables" + +export const myCollectionsExporter = (myCollections: HoppCollection[]) => { + const stripped = myCollections.map(stripCollectionTreeForStore) + return JSON.stringify(stripped, stripRefIdReplacer, 2) +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/export/openapi.ts b/packages/hoppscotch-common/src/helpers/import-export/export/openapi.ts new file mode 100644 index 0000000..f19f5e2 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/export/openapi.ts @@ -0,0 +1,1238 @@ +import { + HoppCollection, + HoppRESTRequest, + makeCollection, + parseRawKeyValueEntries, + rawKeyValueEntriesToString, +} from "@hoppscotch/data" +import { OpenAPIV3_1 } from "openapi-types" + +type AuthLike = HoppRESTRequest["auth"] + +/** + * OpenAPI 3.1 PathItem permits only this fixed set of HTTP method keys. + * Anything else makes the document invalid and must be skipped. + */ +const OPENAPI_METHODS = new Set([ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", +]) + +/** + * OpenAPI 3.1 §4.8.14: Content-Type, Accept, and Authorization + * are expressed via requestBody/responses/security, not header parameters. + */ +const SKIP_HEADER_NAMES = new Set(["content-type", "accept", "authorization"]) + +/** + * Convert Hoppscotch template variables `<>` to OpenAPI path parameters `{var}` + */ +function convertTemplateVars(path: string): string { + return path.replace(/<<([a-zA-Z0-9_.-]+)>>/g, "{$1}") +} + +/** + * Extract base URL, path, and any URL-embedded query params from an endpoint. + * Template vars are converted to `{var}` before parsing so the WHATWG parser + * only encodes ASCII braces; we decode just those pairs back, preserving any + * other percent-encoding the user placed intentionally (e.g. `%2F`). + */ +function parseEndpoint(endpoint: string): { + server: string + path: string + queryParams: Array<{ key: string; value: string }> +} { + let converted = convertTemplateVars(endpoint) + + // Protocol-relative URL (`//host/path`) — `new URL` would reject it. + // Default to https so it still emits a valid server + path pair. + if (converted.startsWith("//") && !converted.startsWith("///")) { + converted = `https:${converted}` + } + + try { + const url = new URL(converted) + const server = `${url.protocol}//${url.host}` + // `new URL` already strips the fragment from `pathname`, so no extra work + // is needed for absolute URLs. + const path = url.pathname.replace(/%7B/gi, "{").replace(/%7D/gi, "}") || "/" + const queryParams: Array<{ key: string; value: string }> = [] + for (const [key, value] of url.searchParams) { + queryParams.push({ key, value }) + } + return { server, path, queryParams } + } catch { + // Not a valid absolute URL — strip any fragment and query string so they + // don't end up inside the OpenAPI path key. + let raw = converted + const fragIdx = raw.indexOf("#") + if (fragIdx >= 0) raw = raw.slice(0, fragIdx) + + const queryParams: Array<{ key: string; value: string }> = [] + const queryIdx = raw.indexOf("?") + if (queryIdx >= 0) { + const queryStr = raw.slice(queryIdx + 1) + raw = raw.slice(0, queryIdx) + try { + for (const [key, value] of new URLSearchParams(queryStr)) { + queryParams.push({ key, value }) + } + } catch { + // ignore malformed query string + } + } + + // A Hoppscotch endpoint that starts with a template variable like + // `<>/pets` is the convention for "use the env-provided host." + // After convertTemplateVars that leading variable is `{baseUrl}`. Split + // it off as the server (in original `<>` syntax for round-trip + // through the importer) so it doesn't end up inside the OpenAPI path key, + // which would cause `<>/<>/pets` doubling on reimport. + const leadingVarMatch = /^\{([a-zA-Z0-9_.-]+)\}(.*)$/.exec(raw) + if (leadingVarMatch) { + const varName = leadingVarMatch[1] + const rest = leadingVarMatch[2] || "" + const pathPart = rest.startsWith("/") ? rest : rest ? `/${rest}` : "/" + return { server: `<<${varName}>>`, path: pathPart, queryParams } + } + + const pathRaw = raw.startsWith("/") ? raw : `/${raw}` + return { server: "", path: pathRaw, queryParams } + } +} + +/** + * Resolve effective auth by walking parent inheritance: a request/folder with + * `authType: "inherit"` adopts its nearest non-inherit ancestor; an explicit + * `none/basic/...` overrides regardless of what the ancestor specifies. + */ +function resolveEffectiveAuth( + ownAuth: AuthLike, + inheritedAuth: AuthLike | null +): AuthLike | null { + if (ownAuth.authType === "inherit") return inheritedAuth + return ownAuth +} + +/** + * Compare two auths for OpenAPI export equivalence — would they emit the same + * security scheme? Lets the exporter skip per-operation security when it would + * duplicate the doc-level default. Credential values are not compared. + */ +function authsMatchForOpenAPI(a: AuthLike | null, b: AuthLike | null): boolean { + const aActive = a?.authActive === true + const bActive = b?.authActive === true + if (!aActive && !bActive) return true + if (aActive !== bActive) return false + // Both active here. + if (a!.authType === "none" && b!.authType === "none") return true + if (a!.authType !== b!.authType) return false + // Same type — for OpenAPI emission what matters is the resulting scheme + // identity AND the requested scopes (OAuth2 ops with different scopes + // are distinct security requirements even when sharing a scheme). + const aResult = convertAuth(a as HoppRESTRequest["auth"]) + const bResult = convertAuth(b as HoppRESTRequest["auth"]) + if (aResult?.schemeName !== bResult?.schemeName) return false + const aScopes = [...(aResult?.scopes ?? [])].sort().join(" ") + const bScopes = [...(bResult?.scopes ?? [])].sort().join(" ") + return aScopes === bScopes +} + +/** + * Create a safe operationId from a request name + */ +function sanitizeOperationId(name: string): string { + return name + .replace(/[^a-zA-Z0-9_\s-]/g, "") + .replace(/\s+/g, "_") + .replace(/-+/g, "_") +} + +/** + * Parse a space-separated scopes string into an OpenAPI scopes object. + * Filters out empty/whitespace-only entries. + */ +function parseScopes(scopes: string | undefined): Record { + if (!scopes) return {} + const entries = scopes + .split(" ") + .filter((s) => s.length > 0) + .map((s) => [s, ""] as const) + return Object.fromEntries(entries) +} + +/** + * Convert request body to OpenAPI requestBody + */ +function convertBody( + body: HoppRESTRequest["body"] +): OpenAPIV3_1.RequestBodyObject | undefined { + if (!body || body.contentType === null) return undefined + + if (body.contentType === "multipart/form-data") { + const properties: Record = {} + const bodyEntries = Array.isArray(body.body) ? body.body : [] + + for (const entry of bodyEntries) { + if (!entry.active || !entry.key) continue + properties[entry.key] = entry.isFile + ? { type: "string", format: "binary" } + : { type: "string", example: entry.value } + } + + // Skip emission when there are no active entries — an empty `properties` + // object produces an OpenAPI media object that some validators reject and + // misleads importers into creating an empty multipart body. + if (Object.keys(properties).length === 0) return undefined + + return { + content: { + "multipart/form-data": { + schema: { + type: "object", + properties, + }, + }, + }, + } + } + + if (body.contentType === "application/x-www-form-urlencoded") { + // Hoppscotch persists urlencoded bodies in RawKeyValue format (`key: + // value` per line, possibly JSON-quoted), not in querystring form, so + // parse with the same helper the runtime uses in getFinalBodyFromRequest. + const bodyStr = typeof body.body === "string" ? body.body : "" + const properties: Record = {} + + for (const entry of parseRawKeyValueEntries(bodyStr)) { + if (!entry.active || !entry.key) continue + properties[entry.key] = { type: "string", example: entry.value } + } + + if (Object.keys(properties).length === 0) return undefined + + return { + content: { + "application/x-www-form-urlencoded": { + schema: { + type: "object", + properties, + }, + }, + }, + } + } + + if (body.contentType === "application/octet-stream") { + const mediaTypeObj: OpenAPIV3_1.MediaTypeObject = { + schema: { + type: "string", + format: "binary", + }, + } + // Hopp's schema permits octet-stream body to be `File | null`, but users + // occasionally save a string snapshot. Surface it as an example rather + // than silently discarding the data. + if (typeof body.body === "string" && body.body.length > 0) { + mediaTypeObj.example = body.body + } + return { + content: { + "application/octet-stream": mediaTypeObj, + }, + } + } + + // Text-based content types (JSON, XML, HTML, plain text, etc.) + const bodyStr = typeof body.body === "string" ? body.body : "" + + const mediaTypeObj: OpenAPIV3_1.MediaTypeObject = {} + if (bodyStr) { + if (body.contentType.includes("json")) { + try { + mediaTypeObj.example = JSON.parse(bodyStr) + } catch { + mediaTypeObj.example = bodyStr + } + } else { + mediaTypeObj.example = bodyStr + } + } + + return { + content: { + [body.contentType]: mediaTypeObj, + }, + } +} + +/** + * Convert request auth to an OpenAPI security scheme entry. + * Returns { schemeName, schemeObject, securityRequirement } or null if auth should be skipped. + */ +function convertAuth(auth: HoppRESTRequest["auth"]): { + schemeName: string + scheme: OpenAPIV3_1.SecuritySchemeObject + scopes: string[] +} | null { + if (!auth.authActive) return null + + switch (auth.authType) { + case "basic": + return { + schemeName: "basicAuth", + scheme: { type: "http", scheme: "basic" }, + scopes: [], + } + case "bearer": + return { + schemeName: "bearerAuth", + scheme: { type: "http", scheme: "bearer" }, + scopes: [], + } + case "api-key": { + const addTo = auth.addTo === "QUERY_PARAMS" ? "query" : "header" + const sanitizedKey = (auth.key || "key") + .replace(/[^a-zA-Z0-9.\-_]/g, "_") + .replace(/_+/g, "_") + const name = `apiKey_${addTo}_${sanitizedKey}` + return { + schemeName: name, + scheme: { + type: "apiKey", + in: addTo, + name: auth.key || "api_key", + }, + scopes: [], + } + } + case "jwt": + return { + schemeName: "jwtAuth", + scheme: { type: "http", scheme: "bearer", bearerFormat: "JWT" }, + scopes: [], + } + case "digest": + return { + schemeName: "digestAuth", + scheme: { type: "http", scheme: "digest" }, + scopes: [], + } + case "aws-signature": + return { + schemeName: "awsSigV4", + scheme: { + type: "apiKey", + in: "header", + name: "Authorization", + description: "AWS Signature Version 4", + }, + scopes: [], + } + case "hawk": + return { + schemeName: "hawkAuth", + scheme: { + type: "apiKey", + in: "header", + name: "Authorization", + description: "Hawk authentication", + }, + scopes: [], + } + case "akamai-eg": + return { + schemeName: "akamaiEdgeGrid", + scheme: { + type: "apiKey", + in: "header", + name: "Authorization", + description: "Akamai EdgeGrid authentication", + }, + scopes: [], + } + case "oauth-2": { + const flows: OpenAPIV3_1.OAuthFlowsObject = {} + const grantInfo = auth.grantTypeInfo + const scopes = parseScopes(grantInfo.scopes) + + // OpenAPI 3.1 §4.8.30 requires `tokenUrl`/`authorizationUrl` to be + // non-empty URLs for the corresponding flow object. Skip flows whose + // endpoint(s) are missing rather than emit invalid (empty-URL) flows. + switch (grantInfo.grantType) { + case "AUTHORIZATION_CODE": + if (grantInfo.authEndpoint && grantInfo.tokenEndpoint) { + flows.authorizationCode = { + authorizationUrl: grantInfo.authEndpoint, + tokenUrl: grantInfo.tokenEndpoint, + scopes, + } + } + break + case "CLIENT_CREDENTIALS": + if (grantInfo.authEndpoint) { + flows.clientCredentials = { + tokenUrl: grantInfo.authEndpoint, + scopes, + } + } + break + case "PASSWORD": + if (grantInfo.authEndpoint) { + flows.password = { + tokenUrl: grantInfo.authEndpoint, + scopes, + } + } + break + case "IMPLICIT": + if (grantInfo.authEndpoint) { + flows.implicit = { + authorizationUrl: grantInfo.authEndpoint, + scopes, + } + } + break + } + + // If no flow could be emitted, the resulting securityScheme would be + // `{ type: "oauth2", flows: {} }` — invalid per spec. Return null and + // let the caller flag it as unsupported (lossy). + if (Object.keys(flows).length === 0) return null + + const scopeKeys = Object.keys(scopes) + return { + schemeName: "oauth2", + scheme: { type: "oauth2", flows }, + scopes: scopeKeys, + } + } + default: + return null + } +} + +/** + * Register a security scheme and return the name it was registered under. + * OAuth2 schemes merge flows by grant type and union scopes within a matching + * flow. Non-OAuth2 schemes reuse the name when identical, or get a numeric + * suffix when differing — never silently overwritten. + */ +function registerSecurityScheme( + securitySchemes: Record, + schemeName: string, + scheme: OpenAPIV3_1.SecuritySchemeObject +): string { + const existing = securitySchemes[schemeName] + + if (!existing) { + securitySchemes[schemeName] = scheme + return schemeName + } + + if (existing.type === "oauth2" && scheme.type === "oauth2") { + type MergeableFlow = { + authorizationUrl?: string + tokenUrl?: string + refreshUrl?: string + scopes: Record + } + const existingFlows = existing.flows as Record + const newFlows = scheme.flows as Record + + for (const [flowType, newFlow] of Object.entries(newFlows)) { + const existingFlow = existingFlows[flowType] + if (!existingFlow) { + existingFlows[flowType] = newFlow + continue + } + existingFlow.scopes = { ...existingFlow.scopes, ...newFlow.scopes } + } + + return schemeName + } + + // Non-OAuth2: identical schemes share a name; differing schemes get suffixed. + const equalSchemes = (a: unknown, b: unknown) => + JSON.stringify(a) === JSON.stringify(b) + + if (equalSchemes(existing, scheme)) { + return schemeName + } + + let counter = 2 + for (;;) { + const candidate = `${schemeName}_${counter}` + const candidateExisting = securitySchemes[candidate] + if (!candidateExisting) { + securitySchemes[candidate] = scheme + return candidate + } + if (equalSchemes(candidateExisting, scheme)) { + return candidate + } + counter++ + } +} + +/** + * Convert saved responses from a request to OpenAPI response objects + */ +function convertResponses( + responses: HoppRESTRequest["responses"] +): OpenAPIV3_1.ResponsesObject { + if (!responses || Object.keys(responses).length === 0) { + return { "200": { description: "Successful response" } } + } + + const result: OpenAPIV3_1.ResponsesObject = {} + + for (const [, response] of Object.entries(responses)) { + // OpenAPI's `responses` map is keyed by HTTP status code; a saved + // response without a code can't be addressed and is ambiguous, skip it. + if (response.code == null) continue + const statusCode = response.code.toString() + + const responseObj: OpenAPIV3_1.ResponseObject = { + description: response.name?.trim() || `${statusCode} response`, + } + + if (response.headers && response.headers.length > 0) { + responseObj.headers = {} + for (const header of response.headers) { + if (!header.key) continue + // OpenAPI 3.1 §4.8.17: Content-Type is expressed via the content map key + if (header.key.toLowerCase() === "content-type") continue + responseObj.headers[header.key] = { + schema: { type: "string" }, + example: header.value, + } + } + if (Object.keys(responseObj.headers).length === 0) { + delete responseObj.headers + } + } + + if (response.body) { + // Try to detect content type from response headers + const contentTypeHeader = response.headers?.find( + (h) => h.key.toLowerCase() === "content-type" + ) + const contentType = + contentTypeHeader?.value?.split(";")[0].trim() || "application/json" + + const mediaType: OpenAPIV3_1.MediaTypeObject = {} + if (contentType.includes("json")) { + try { + mediaType.example = JSON.parse(response.body) + } catch { + mediaType.example = response.body + } + } else { + mediaType.example = response.body + } + + responseObj.content = { [contentType]: mediaType } + } + + result[statusCode] = responseObj + } + + // OpenAPI 3.1 §4.8.10.1 requires at least one entry — if every saved + // response was skipped (all had null codes), fall back to the default. + if (Object.keys(result).length === 0) { + return { "200": { description: "Successful response" } } + } + return result +} + +const redactOAuth2Params = (params: T[]): T[] => + params.map((param) => ({ ...param, value: "" })) + +const redactOAuth2GrantToken = (grant: T): T => { + const redacted = { ...grant, token: "" } as T & { refreshToken?: string } + if ("refreshToken" in redacted) { + redacted.refreshToken = "" + } + return redacted +} + +function sanitizeDroppedRequestAuth(auth: AuthLike): AuthLike { + switch (auth.authType) { + case "none": + case "inherit": + return auth + case "basic": + return { ...auth, username: "", password: "" } + case "bearer": + return { ...auth, token: "" } + case "api-key": + return { ...auth, value: "" } + case "oauth-2": { + const grantTypeInfo = auth.grantTypeInfo + + switch (grantTypeInfo.grantType) { + case "AUTHORIZATION_CODE": + return { + ...auth, + grantTypeInfo: { + ...redactOAuth2GrantToken(grantTypeInfo), + clientSecret: "", + authRequestParams: redactOAuth2Params( + grantTypeInfo.authRequestParams + ), + tokenRequestParams: redactOAuth2Params( + grantTypeInfo.tokenRequestParams + ), + refreshRequestParams: redactOAuth2Params( + grantTypeInfo.refreshRequestParams + ), + }, + } + case "CLIENT_CREDENTIALS": + return { + ...auth, + grantTypeInfo: { + ...redactOAuth2GrantToken(grantTypeInfo), + clientSecret: "", + tokenRequestParams: redactOAuth2Params( + grantTypeInfo.tokenRequestParams + ), + refreshRequestParams: redactOAuth2Params( + grantTypeInfo.refreshRequestParams + ), + }, + } + case "PASSWORD": + return { + ...auth, + grantTypeInfo: { + ...redactOAuth2GrantToken(grantTypeInfo), + clientSecret: "", + username: "", + password: "", + tokenRequestParams: redactOAuth2Params( + grantTypeInfo.tokenRequestParams + ), + refreshRequestParams: redactOAuth2Params( + grantTypeInfo.refreshRequestParams + ), + }, + } + case "IMPLICIT": + return { + ...auth, + grantTypeInfo: { + ...redactOAuth2GrantToken(grantTypeInfo), + authRequestParams: redactOAuth2Params( + grantTypeInfo.authRequestParams + ), + refreshRequestParams: redactOAuth2Params( + grantTypeInfo.refreshRequestParams + ), + }, + } + default: + // Unknown grant type — fall back to inherit rather than leaking partial fields. + return { authType: "inherit", authActive: auth.authActive } + } + } + case "aws-signature": + return { + ...auth, + accessKey: "", + secretKey: "", + serviceToken: undefined, + signature: undefined, + } + case "digest": + return { + ...auth, + username: "", + password: "", + nonce: "", + cnonce: "", + opaque: "", + } + case "hawk": + return { + ...auth, + authId: "", + authKey: "", + user: undefined, + nonce: undefined, + ext: undefined, + app: undefined, + dlg: undefined, + timestamp: undefined, + } + case "akamai-eg": + return { + ...auth, + accessToken: "", + clientToken: "", + clientSecret: "", + nonce: undefined, + timestamp: undefined, + } + case "jwt": + return { + ...auth, + secret: "", + privateKey: "", + payload: "{}", + jwtHeaders: "{}", + } + } +} + +function sanitizeDroppedRequestBody( + body: HoppRESTRequest["body"] +): HoppRESTRequest["body"] { + if (!body || body.contentType === null) return body + + if (body.contentType === "multipart/form-data") { + return { + ...body, + body: Array.isArray(body.body) + ? body.body + .filter((entry) => entry.active && entry.key) + .map((entry) => (entry.isFile ? { ...entry, value: "" } : entry)) + : [], + } + } + + if (body.contentType === "application/x-www-form-urlencoded") { + const bodyStr = typeof body.body === "string" ? body.body : "" + return { + ...body, + body: rawKeyValueEntriesToString( + parseRawKeyValueEntries(bodyStr).filter( + (entry) => entry.active && entry.key + ) + ), + } + } + + return body +} + +// Keep dropped-request payloads aligned with export's no-credentials/no-scripts lossiness. +function sanitizeRequestForDroppedExtension( + request: HoppRESTRequest +): HoppRESTRequest { + return { + ...request, + auth: sanitizeDroppedRequestAuth(request.auth), + body: sanitizeDroppedRequestBody(request.body), + preRequestScript: "", + testScript: "", + params: request.params.filter((p) => p.active), + // Cookie often carries pasted auth but is not in SKIP_HEADER_NAMES. + headers: request.headers.filter((h) => { + const key = h.key.trim().toLowerCase() + return h.active && !SKIP_HEADER_NAMES.has(key) && key !== "cookie" + }), + requestVariables: request.requestVariables.filter((v) => v.active), + // Saved responses can embed original requests and raw secret payloads. + responses: {}, + } +} + +/** + * Convert a HoppCollection to an OpenAPI 3.1.0 document. + */ +export function hoppCollectionToOpenAPI(collection: HoppCollection): { + doc: OpenAPIV3_1.Document +} { + const paths: OpenAPIV3_1.PathsObject = {} + const tags: OpenAPIV3_1.TagObject[] = [] + const usedTagNames = new Set() + const securitySchemes: Record = {} + const servers = new Set() + const usedOperationIds = new Set() + // OpenAPI keeps one operation per (path, method); stash later collisions for re-import. + const droppedRequests: Array<{ + tagPath: string | null + request: HoppRESTRequest + }> = [] + + /** + * Resolve a `<>` server placeholder to the variable's actual value + * if one is in scope. Defensive: skips variables flagged `secret` so we + * don't leak credentials into an exported spec. + */ + const resolveServerPlaceholder = ( + server: string, + variableValues: Map + ): string => { + const match = /^<<([a-zA-Z0-9_.-]+)>>$/.exec(server) + if (!match) return server + const value = variableValues.get(match[1]) + return value && value.length > 0 ? value : server + } + + /** + * Build the effective variable map for the current scope by walking ancestor + * variable arrays from root → leaf, with deeper levels overriding shallower. + * Secret variables are excluded so resolveServerPlaceholder never substitutes + * them into the doc. + */ + const buildVariableValues = ( + variableLayers: ReadonlyArray + ): Map => { + const result = new Map() + for (const layer of variableLayers) { + for (const v of layer) { + if ((v as { secret?: boolean }).secret) continue + const value = + (v as { currentValue?: string }).currentValue || + (v as { initialValue?: string }).initialValue || + "" + if (value) result.set(v.key, value) + } + } + return result + } + + /** Collect the keys of every secret variable in scope (any layer). */ + const collectSecretKeys = ( + variableLayers: ReadonlyArray + ): Set => { + const result = new Set() + for (const layer of variableLayers) { + for (const v of layer) { + if ((v as { secret?: boolean }).secret) result.add(v.key) + } + } + return result + } + + /** + * Substitute `<>` tokens in a value with their resolved (non-secret) + * values. Returns `undefined` if any secret variable appears in the string — + * the param's `example` field should be omitted entirely rather than leak + * a credential placeholder into the exported spec. + */ + const resolveExampleValue = ( + value: string | undefined, + variableValues: Map, + secretKeys: Set + ): string | undefined => { + if (!value) return undefined + let containsSecret = false + const out = value.replace(/<<([a-zA-Z0-9_.-]+)>>/g, (_match, name) => { + if (secretKeys.has(name)) { + containsSecret = true + return "" + } + return variableValues.get(name) ?? `<<${name}>>` + }) + if (containsSecret) return undefined + return out + } + + function processRequests( + requests: HoppRESTRequest[], + tagPath: string | null, + inheritedHeaders: HoppCollection["headers"][], + inheritedAuth: AuthLike | null, + inheritedVariables: ReadonlyArray, + docLevelAuth: AuthLike | null + ) { + const variableValues = buildVariableValues(inheritedVariables) + const secretKeys = collectSecretKeys(inheritedVariables) + for (const request of requests) { + const { + server, + path: rawPath, + queryParams: urlQueryParams, + } = parseEndpoint(request.endpoint) + const method = request.method.toLowerCase() + + // Substitute non-secret runtime variables that landed inside the path + // (e.g. `<>/api/<>/users` -> `/prod/api/us-east-1/users`). + // True path templates — vars listed in `requestVariables` — are kept + // as `{name}` so they round-trip and remain consumer-supplied. Secret + // vars are also kept as templates rather than substituted to avoid + // leaking credentials into the spec. + const requestVarKeys = new Set( + request.requestVariables + .filter((v) => v.active && v.key) + .map((v) => v.key) + ) + const path = rawPath.replace( + /\{([a-zA-Z0-9_.-]+)\}/g, + (token, name: string) => { + if (requestVarKeys.has(name)) return token + if (secretKeys.has(name)) return token + const resolved = variableValues.get(name) + return resolved !== undefined ? resolved : token + } + ) + + // Skip methods OpenAPI 3.1 doesn't recognize as PathItem keys — + // emitting them would produce an invalid document. + if (!OPENAPI_METHODS.has(method)) continue + + const pathItem = (paths[path] = (paths[path] ?? + {}) as OpenAPIV3_1.PathItemObject) as Record< + string, + OpenAPIV3_1.OperationObject + > + if (pathItem[method]) { + // Snapshot effective auth so restored duplicates don't lose inherited auth on unwrap. + const effectiveAuth = resolveEffectiveAuth(request.auth, inheritedAuth) + droppedRequests.push({ + tagPath, + request: sanitizeRequestForDroppedExtension({ + ...request, + auth: effectiveAuth ?? request.auth, + }), + }) + continue + } + + if (server) servers.add(resolveServerPlaceholder(server, variableValues)) + + const parameters: OpenAPIV3_1.ParameterObject[] = [] + // OpenAPI requires unique (name, in) per operation — dedupe as we go, + // first-write wins (mirrors the existing "child-overrides-parent" rule + // for inherited headers). + const seenParamKeys = new Set() + const pushParam = (param: OpenAPIV3_1.ParameterObject) => { + const key = `${param.in}::${param.name.toLowerCase()}` + if (seenParamKeys.has(key)) return + seenParamKeys.add(key) + parameters.push(param) + } + + // Explicit request-level query params first (so they take precedence + // over duplicates encoded in the URL itself). + for (const param of request.params) { + if (!param.active || !param.key) continue + pushParam({ + name: param.key, + in: "query", + schema: { type: "string" }, + example: resolveExampleValue(param.value, variableValues, secretKeys), + description: param.description || undefined, + }) + } + + // Query params embedded in the endpoint URL itself. + for (const { key, value } of urlQueryParams) { + if (!key) continue + pushParam({ + name: key, + in: "query", + schema: { type: "string" }, + example: resolveExampleValue(value, variableValues, secretKeys), + }) + } + + // Request-level headers + for (const header of request.headers) { + if (!header.active || !header.key) continue + if (SKIP_HEADER_NAMES.has(header.key.trim().toLowerCase())) continue + pushParam({ + name: header.key, + in: "header", + schema: { type: "string" }, + example: resolveExampleValue( + header.value, + variableValues, + secretKeys + ), + description: header.description || undefined, + }) + } + + // Inherited headers (nearest ancestor first; first-write-wins via dedup) + for (const headers of inheritedHeaders) { + for (const header of headers) { + if (!header.active || !header.key) continue + if (SKIP_HEADER_NAMES.has(header.key.trim().toLowerCase())) continue + pushParam({ + name: header.key, + in: "header", + schema: { type: "string" }, + example: resolveExampleValue( + header.value, + variableValues, + secretKeys + ), + description: header.description || undefined, + }) + } + } + + // Path variables from requestVariables + const definedPathParams = new Set() + for (const variable of request.requestVariables) { + if (!variable.active || !variable.key) continue + definedPathParams.add(variable.key) + pushParam({ + name: variable.key, + in: "path", + required: true, + schema: { type: "string" }, + example: resolveExampleValue( + variable.value, + variableValues, + secretKeys + ), + }) + } + + // Auto-generate path params for any {var} in the path not already defined + const pathParamMatches = path.matchAll(/\{([a-zA-Z0-9_.-]+)\}/g) + for (const match of pathParamMatches) { + const paramName = match[1] + if (definedPathParams.has(paramName)) continue + definedPathParams.add(paramName) + pushParam({ + name: paramName, + in: "path", + required: true, + schema: { type: "string" }, + }) + } + + const operation: OpenAPIV3_1.OperationObject = { + summary: request.name, + responses: convertResponses(request.responses), + } + + if (request.description) { + operation.description = request.description + } + + let operationId = + sanitizeOperationId(request.name) || + `${method}_${path.replace(/[^a-zA-Z0-9]/g, "_").replace(/^_+|_+$/g, "")}` + if (usedOperationIds.has(operationId)) { + let counter = 2 + while (usedOperationIds.has(`${operationId}_${counter}`)) { + counter++ + } + operationId = `${operationId}_${counter}` + } + usedOperationIds.add(operationId) + operation.operationId = operationId + + if (tagPath) { + operation.tags = [tagPath] + } + + if (parameters.length > 0) { + operation.parameters = parameters + } + + const requestBody = convertBody(request.body) + if (requestBody) { + operation.requestBody = requestBody + } + + // Effective auth = nearest non-inherit ancestor (or own auth if explicit). + // Folder/collection auth is now respected via inheritedAuth. + const effectiveAuth = resolveEffectiveAuth(request.auth, inheritedAuth) + // Only emit `operation.security` when the request's effective auth + // *differs* from the doc-level auth. If they match, OpenAPI's natural + // inheritance from `doc.security` covers it — and skipping the emit + // is what makes round-tripping `request.auth = inherit` come back as + // `inherit` rather than as a duplicated copy on every request. + if ( + effectiveAuth?.authActive && + !authsMatchForOpenAPI(effectiveAuth, docLevelAuth) + ) { + if (effectiveAuth.authType === "none") { + operation.security = [] + } else { + const authResult = convertAuth(effectiveAuth) + if (authResult) { + const name = registerSecurityScheme( + securitySchemes, + authResult.schemeName, + authResult.scheme + ) + operation.security = [{ [name]: authResult.scopes }] + } + } + } else if ( + // Special case: collection has auth but this request's effective auth + // is explicitly disabled (`authActive: false`) — either on the request + // itself or via a folder ancestor that opts out. Emit `[]` so the + // operation is exempted from doc.security at runtime. + docLevelAuth?.authActive && + effectiveAuth && + !effectiveAuth.authActive && + effectiveAuth.authType !== "inherit" + ) { + operation.security = [] + } + + pathItem[method] = operation + } + } + + function processFolders( + folders: HoppCollection[], + parentPath: string | null, + inheritedHeaders: HoppCollection["headers"][], + inheritedAuth: AuthLike | null, + inheritedVariables: ReadonlyArray, + docLevelAuth: AuthLike | null + ) { + for (const folder of folders) { + const tagPath = parentPath ? `${parentPath}/${folder.name}` : folder.name + const folderHeaders = [folder.headers, ...inheritedHeaders] + const folderAuth = resolveEffectiveAuth( + folder.auth as AuthLike, + inheritedAuth + ) + const folderVariables = [...inheritedVariables, folder.variables] + + if (!usedTagNames.has(tagPath)) { + usedTagNames.add(tagPath) + const tag: OpenAPIV3_1.TagObject = { name: tagPath } + if (folder.description) tag.description = folder.description + tags.push(tag) + } + + processRequests( + folder.requests as HoppRESTRequest[], + tagPath, + folderHeaders, + folderAuth, + folderVariables, + docLevelAuth + ) + processFolders( + folder.folders, + tagPath, + folderHeaders, + folderAuth, + folderVariables, + docLevelAuth + ) + } + } + + const rootHeaders = [collection.headers] + const collectionAuthAsRequest = collection.auth as AuthLike + // Root inheritance: a top-level "inherit" has no parent, so treat as no auth. + const rootInheritedAuth: AuthLike | null = + collectionAuthAsRequest.authType === "inherit" + ? null + : collectionAuthAsRequest + + const rootVariables: ReadonlyArray = [ + collection.variables, + ] + // The "doc-level auth" is what the importer will set as collection.auth, + // mirrored from `doc.security`. Operations whose effective auth matches it + // can omit `operation.security` and let OpenAPI's inheritance kick in. + const docLevelAuth: AuthLike | null = rootInheritedAuth?.authActive + ? rootInheritedAuth + : null + + processRequests( + collection.requests as HoppRESTRequest[], + null, + rootHeaders, + rootInheritedAuth, + rootVariables, + docLevelAuth + ) + processFolders( + collection.folders, + null, + rootHeaders, + rootInheritedAuth, + rootVariables, + docLevelAuth + ) + + const doc: OpenAPIV3_1.Document = { + openapi: "3.1.0", + info: { + title: collection.name, + description: collection.description ?? undefined, + version: "1.0.0", + }, + paths, + } + + if (servers.size > 0) { + doc.servers = Array.from(servers).map((url) => ({ url })) + } + + if (tags.length > 0) { + doc.tags = tags + // Mark the document so the importer knows tag names use slash-as-folder + // separator. Round-trip stays exact even when there's only a single + // nested folder (which the heuristic alone couldn't detect). + ;(doc as Record)["x-hoppscotch-folder-tags"] = "slash" + } + + if (droppedRequests.length > 0) { + // Hoppscotch-only x-* payload; OpenAPI consumers should ignore it. + ;(doc as Record)["x-hoppscotch-dropped-requests"] = + droppedRequests + } + + // Collection-level auth → global security (only when explicit and non-none) + if (rootInheritedAuth?.authActive) { + if (rootInheritedAuth.authType !== "none") { + const collectionAuth = convertAuth(rootInheritedAuth) + if (collectionAuth) { + const name = registerSecurityScheme( + securitySchemes, + collectionAuth.schemeName, + collectionAuth.scheme + ) + doc.security = [{ [name]: collectionAuth.scopes }] + } + } + } + + if (Object.keys(securitySchemes).length > 0) { + doc.components = { securitySchemes } + } + + return { doc } +} + +/** + * Wrap multiple top-level collections into a single OpenAPI 3.1 document. + * Each becomes a top-level folder of a synthetic root, so the tag tree mirrors + * the workspace and remains round-trippable. (method, path) collisions across + * collections are dropped — already warned upfront by the chooser modal. + */ +export function hoppCollectionsToOpenAPI( + workspaceName: string, + collections: HoppCollection[] +): { doc: OpenAPIV3_1.Document } { + const root = makeCollection({ + name: workspaceName, + folders: collections, + requests: [], + auth: { authType: "inherit", authActive: true }, + headers: [], + variables: [], + description: null, + preRequestScript: "", + testScript: "", + }) + const { doc } = hoppCollectionToOpenAPI(root) + // Mark the synthetic wrapper so import can restore root collections. + ;(doc as Record)["x-hoppscotch-workspace-root"] = true + return { doc } +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/export/teamCollections.ts b/packages/hoppscotch-common/src/helpers/import-export/export/teamCollections.ts new file mode 100644 index 0000000..daf5e25 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/export/teamCollections.ts @@ -0,0 +1,5 @@ +import { getTeamCollectionJSON } from "~/helpers/backend/helpers" + +export const teamCollectionsExporter = (teamID: string) => { + return getTeamCollectionJSON(teamID) +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/export/testResults.ts b/packages/hoppscotch-common/src/helpers/import-export/export/testResults.ts new file mode 100644 index 0000000..a86b926 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/export/testResults.ts @@ -0,0 +1,29 @@ +import { HoppTestResult } from "~/helpers/types/HoppTestResult" +import { platform } from "~/platform" +import * as E from "fp-ts/Either" + +export const exportTestResults = async (testResults: HoppTestResult) => { + const contentsJSON = JSON.stringify(testResults, null, 2) + const file = new Blob([contentsJSON], { type: "application/json" }) + const url = URL.createObjectURL(file) + + const fileName = url.split("/").pop()!.split("#")[0].split("?")[0] + + const result = await platform.kernelIO.saveFileWithDialog({ + data: contentsJSON, + contentType: "application/json", + suggestedFilename: `${fileName}.json`, + filters: [ + { + name: "Hoppscotch Test Results JSON file", + extensions: ["json"], + }, + ], + }) + + if (result.type === "unknown" || result.type === "saved") { + return E.right("state.download_started") + } + + return E.left("state.download_failed") +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/__tests__/postmanEnv.spec.ts b/packages/hoppscotch-common/src/helpers/import-export/import/__tests__/postmanEnv.spec.ts new file mode 100644 index 0000000..788772a --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/__tests__/postmanEnv.spec.ts @@ -0,0 +1,104 @@ +import * as E from "fp-ts/Either" +import { describe, expect, it } from "vitest" + +import { postmanEnvImporter } from "../postmanEnv" + +const runImport = async (envs: object[]) => { + const result = await postmanEnvImporter([JSON.stringify(envs)])() + if (E.isLeft(result)) throw new Error(`importer failed: ${result.left}`) + return result.right +} + +describe("postmanEnvImporter — secret flag handling", () => { + it('recognizes the legacy `type: "secret"` shape (older Postman exports)', async () => { + const [env] = await runImport([ + { + name: "Legacy", + values: [ + { + key: "API_KEY", + value: "old-secret-value", + type: "secret", + enabled: true, + }, + { + key: "URL", + value: "https://example.com", + type: "default", + enabled: true, + }, + ], + }, + ]) + + expect(env.variables).toEqual([ + expect.objectContaining({ key: "API_KEY", secret: true }), + expect.objectContaining({ key: "URL", secret: false }), + ]) + }) + + it('recognizes the Postman 12+ `secret: true` boolean (with `type: "default"`)', async () => { + const [env] = await runImport([ + { + name: "PET_STORE", + values: [ + { + key: "baseUrl-secret", + value: "", + type: "default", + enabled: true, + secret: true, + }, + { + key: "baseUrl-not-secret", + value: "baseUrl-not-secret", + type: "default", + enabled: true, + }, + ], + }, + ]) + + expect(env.variables).toEqual([ + expect.objectContaining({ key: "baseUrl-secret", secret: true }), + expect.objectContaining({ key: "baseUrl-not-secret", secret: false }), + ]) + }) + + it("treats a variable as secret when EITHER signal is present", async () => { + const [env] = await runImport([ + { + name: "Mixed", + values: [ + // Both signals — still secret + { + key: "BOTH", + value: "x", + type: "secret", + secret: true, + enabled: true, + }, + // Only legacy + { key: "LEGACY", value: "x", type: "secret", enabled: true }, + // Only new + { + key: "NEW", + value: "x", + type: "default", + secret: true, + enabled: true, + }, + // Neither — not secret + { key: "PLAIN", value: "x", type: "default", enabled: true }, + ], + }, + ]) + + expect(env.variables.map((v) => [v.key, v.secret])).toEqual([ + ["BOTH", true], + ["LEGACY", true], + ["NEW", true], + ["PLAIN", false], + ]) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/har.ts b/packages/hoppscotch-common/src/helpers/import-export/import/har.ts new file mode 100644 index 0000000..3ca073b --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/har.ts @@ -0,0 +1,254 @@ +import { + HoppCollection, + HoppRESTHeader, + HoppRESTParam, + ValidContentTypesList, + ValidContentTypes, + HoppRESTReqBody, + HoppRESTReqBodyFormData, + HoppRESTRequest, + getDefaultRESTRequest, + makeCollection, +} from "@hoppscotch/data" +import * as E from "fp-ts/Either" +import { z } from "zod" + +export const harImporter = ( + content: string[] +): E.Either<"INVALID_HAR" | "SOMETHING_WENT_WRONG", HoppCollection[]> => { + try { + const harObject = JSON.parse(content[0]) + const res = harSchema.safeParse(harObject) + + if (!res.success) { + return E.left("INVALID_HAR") + } + + const har = res.data + + const requests = harToHoppscotchRequestConverter(har) + + const collection = makeCollection({ + name: "Imported from HAR", + folders: [], + requests: requests, + auth: { + authType: "none", + authActive: true, + }, + headers: [], + description: null, + variables: [], + preRequestScript: "", + testScript: "", + }) + + return E.right([collection]) + } catch (error) { + console.error(error) + return E.left("SOMETHING_WENT_WRONG") + } +} + +const harToHoppscotchRequestConverter = (har: HAR) => { + return har.log.entries.map((entry): HoppRESTRequest => { + const headers = entry.request.headers.map((header): HoppRESTHeader => { + return { + active: true, + key: header.name, + value: header.value, + description: "", + } + }) + + const params = entry.request.queryString.map((param): HoppRESTParam => { + return { + active: true, + key: param.name, + value: param.value, + description: "", + } + }) + + const body = entry.request.postData + ? convertPostDataToHoppBody(entry.request.postData) + : { body: null, contentType: null } + + const { method, url } = entry.request + + const parsedUrl = new URL(url) + const urlWithoutQueryParams = parsedUrl.origin + parsedUrl.pathname + + return { + ...getDefaultRESTRequest(), + endpoint: urlWithoutQueryParams, + params, + body, + headers, + method, + name: url, + } + }) +} + +const convertPostDataToHoppBody = ( + postData: z.infer +): HoppRESTReqBody => { + let contentType: ValidContentTypes = "text/plain" + + if (isValidContentType(postData.mimeType)) { + contentType = postData.mimeType + } else if (postData.mimeType.startsWith("multipart/form-data")) { + // some har files will have formdata formatted like multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW + contentType = "multipart/form-data" + } + + // all the contentTypes except application/x-www-form-urlencoded && multipart/form-data will have text content + if ( + contentType === "application/json" || + contentType === "application/hal+json" || + contentType === "application/ld+json" || + contentType === "application/vnd.api+json" || + contentType === "application/xml" || + contentType === "text/html" || + contentType === "text/xml" || + contentType === "text/plain" + ) { + const body: HoppRESTReqBody = { + body: postData.text ?? "", + contentType, + } + + return body + } + + if (contentType === "application/x-www-form-urlencoded") { + let bodyContent: string = "" + + if (postData.text) { + bodyContent = formatXWWWFormUrlencodedForHoppscotch(postData.text) + } else if (postData.params) { + bodyContent = postData.params + .map((param) => { + return `${param.name}:${param.value}` + }) + .join("\n") + } + + const body: HoppRESTReqBody = { + contentType: "application/x-www-form-urlencoded", + body: bodyContent, + } + + return body + } + + if (contentType === "multipart/form-data") { + const body: HoppRESTReqBodyFormData = { + contentType: "multipart/form-data", + body: + postData.params?.map((param) => { + return param.fileName + ? { + active: true, + isFile: true as const, + key: param.name, + value: [], + } + : { + active: true, + isFile: false as const, + key: param.name, + value: param.value ?? "", + } + }) ?? [], + } + + return body + } + + return { + body: "", + contentType, + } +} + +const isValidContentType = ( + contentType: string +): contentType is ValidContentTypes => + (ValidContentTypesList as string[]).includes(contentType) + +const formatXWWWFormUrlencodedForHoppscotch = (text: string) => { + const params = new URLSearchParams(text) + const result = [] + + for (const [key, value] of params) { + result.push(`${key}:${value}`) + } + + return result.join("\n") +} + +// <------har zod schema defs------> +// we only define parts of the schema that we need + +const recordSchema = z.object({ + name: z.string(), + value: z.string(), + comment: z.string().optional(), +}) + +const cookieSchema = z.object({ + name: z.string(), + value: z.string(), + path: z.string().optional(), + domain: z.string().optional(), + expires: z.string().optional(), + httpOnly: z.boolean().optional(), + secure: z.boolean().optional(), + comment: z.string().optional(), +}) + +const postDataSchema = z.object({ + mimeType: z.string(), + text: z.string().optional(), + params: z + .array( + z.object({ + name: z.string(), + value: z.string().optional(), + fileName: z.string().optional(), + contentType: z.string().optional(), + comment: z.string().optional(), + }) + ) + .optional(), + comment: z.string().optional(), +}) + +const requestSchema = z.object({ + method: z.string(), + url: z.string(), + httpVersion: z.string(), + cookies: z.array(cookieSchema), + headers: z.array(recordSchema), + queryString: z.array(recordSchema), + postData: postDataSchema.optional(), + headersSize: z.number().int(), + bodySize: z.number().int(), + comment: z.string().optional(), +}) + +const entrySchema = z.object({ + request: requestSchema, +}) + +const logSchema = z.object({ + entries: z.array(entrySchema), +}) + +const harSchema = z.object({ + log: logSchema, +}) + +type HAR = z.infer diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/hopp.ts b/packages/hoppscotch-common/src/helpers/import-export/import/hopp.ts new file mode 100644 index 0000000..4168f8e --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/hopp.ts @@ -0,0 +1,107 @@ +import { + HoppCollection, + HoppRESTRequest, + getDefaultGQLRequest, + getDefaultRESTRequest, + translateToNewRESTCollection, + HoppGQLRequest, + translateToNewGQLCollection, +} from "@hoppscotch/data" +import * as A from "fp-ts/Array" +import * as O from "fp-ts/Option" +import * as RA from "fp-ts/ReadonlyArray" +import * as TE from "fp-ts/TaskEither" +import { flow, pipe } from "fp-ts/function" +import { safeParseJSON } from "~/helpers/functional/json" +import { IMPORTER_INVALID_FILE_FORMAT } from "." + +export const hoppRESTImporter = (content: string[]) => + pipe( + content, + A.traverse(O.Applicative)((str) => safeParseJSON(str, true)), + O.chain( + flow( + A.flatten, + makeCollectionsArray, + RA.map(validateCollection), + O.sequenceArray, + O.map(RA.toArray) + ) + ), + TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT) + ) + +/** + * checks if a collection is a valid hoppscotch collection. + * else translate it into one. + */ +const validateCollection = (collection: unknown) => { + const collectionSchemaParsedResult = HoppCollection.safeParse(collection) + + if (collectionSchemaParsedResult.type === "ok") { + const requests = collectionSchemaParsedResult.value.requests.map( + (request) => { + const requestSchemaParsedResult = HoppRESTRequest.safeParse(request) + + return requestSchemaParsedResult.type === "ok" + ? requestSchemaParsedResult.value + : getDefaultRESTRequest() + } + ) + + return O.some({ + ...collectionSchemaParsedResult.value, + requests, + }) + } + + return O.some(translateToNewRESTCollection(collection)) +} + +/** + * convert single collection object into an array so it can be handled the same as multiple collections + */ +const makeCollectionsArray = (collections: unknown | unknown[]): unknown[] => + Array.isArray(collections) ? collections : [collections] + +export const hoppGQLImporter = (content: string) => + pipe( + safeParseJSON(content), + O.chain( + flow( + makeCollectionsArray, + RA.map(validateGQLCollection), + O.sequenceArray, + O.map(RA.toArray) + ) + ), + TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT) + ) + +/** + * + * @param collection the collection to validate + * @returns the collection if it is valid, else a translated version of the collection + */ +export const validateGQLCollection = (collection: unknown) => { + const collectionSchemaParsedResult = HoppCollection.safeParse(collection) + + if (collectionSchemaParsedResult.type === "ok") { + const requests = collectionSchemaParsedResult.value.requests.map( + (request) => { + const requestSchemaParsedResult = HoppGQLRequest.safeParse(request) + + return requestSchemaParsedResult.type === "ok" + ? requestSchemaParsedResult.value + : getDefaultGQLRequest() + } + ) + + return O.some({ + ...collectionSchemaParsedResult.value, + requests, + }) + } + + return O.some(translateToNewGQLCollection(collection)) +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/hoppEnv.ts b/packages/hoppscotch-common/src/helpers/import-export/import/hoppEnv.ts new file mode 100644 index 0000000..914c470 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/hoppEnv.ts @@ -0,0 +1,59 @@ +import * as O from "fp-ts/Option" +import * as TE from "fp-ts/TaskEither" +import { entityReference } from "verzod" + +import { safeParseJSON } from "~/helpers/functional/json" +import { IMPORTER_INVALID_FILE_FORMAT } from "." + +import { Environment } from "@hoppscotch/data" +import { z } from "zod" + +export const hoppEnvImporter = (contents: string[]) => { + const parsedContents = contents.map((str) => safeParseJSON(str, true)) + + if (parsedContents.some((parsed) => O.isNone(parsed))) { + return TE.left(IMPORTER_INVALID_FILE_FORMAT) + } + + const parsedValues = parsedContents.flatMap((content) => { + const unwrappedContent = O.toNullable(content) as Environment[] | null + + if (unwrappedContent) { + return unwrappedContent.map((contentEntry) => { + return { + ...contentEntry, + variables: contentEntry.variables?.map((valueEntry) => { + if ("value" in valueEntry) { + return { + ...valueEntry, + value: String(valueEntry.value), + } + } + + if ("initialValue" in valueEntry) { + return { + ...valueEntry, + initialValue: String(valueEntry.initialValue), + } + } + + return valueEntry + }), + } + }) + } + return null + }) + + const validationResult = z + .array(entityReference(Environment)) + .safeParse(parsedValues) + + if (!validationResult.success) { + return TE.left(IMPORTER_INVALID_FILE_FORMAT) + } + + const environments = validationResult.data + + return TE.right(environments) +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/hoppGql.ts b/packages/hoppscotch-common/src/helpers/import-export/import/hoppGql.ts new file mode 100644 index 0000000..5457944 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/hoppGql.ts @@ -0,0 +1,12 @@ +import { HoppCollection } from "@hoppscotch/data" +import * as E from "fp-ts/Either" + +// TODO: add zod validation +export const hoppGqlCollectionsImporter = ( + contents: string[] +): E.Either<"INVALID_JSON", HoppCollection[]> => { + return E.tryCatch( + () => contents.flatMap((content) => JSON.parse(content)), + () => "INVALID_JSON" + ) +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/import-sources/FileSource.ts b/packages/hoppscotch-common/src/helpers/import-export/import/import-sources/FileSource.ts new file mode 100644 index 0000000..2eb4dda --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/import-sources/FileSource.ts @@ -0,0 +1,28 @@ +import FileImportVue from "~/components/importExport/ImportExportSteps/FileImport.vue" +import { defineStep } from "~/composables/step-components" + +import { v4 as uuidv4 } from "uuid" +import type { Ref } from "vue" + +export function FileSource(metadata: { + acceptedFileTypes: string + caption: string + onImportFromFile: ( + content: string[], + importScripts?: boolean + ) => any | Promise + isLoading?: Ref + description?: string + showPostmanScriptOption?: boolean +}) { + const stepID = uuidv4() + + return defineStep(stepID, FileImportVue, () => ({ + acceptedFileTypes: metadata.acceptedFileTypes, + caption: metadata.caption, + onImportFromFile: metadata.onImportFromFile, + loading: metadata.isLoading?.value, + description: metadata.description, + showPostmanScriptOption: metadata.showPostmanScriptOption, + })) +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/import-sources/GistSource.ts b/packages/hoppscotch-common/src/helpers/import-export/import/import-sources/GistSource.ts new file mode 100644 index 0000000..1939b12 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/import-sources/GistSource.ts @@ -0,0 +1,78 @@ +import UrlImport from "~/components/importExport/ImportExportSteps/UrlImport.vue" +import { defineStep } from "~/composables/step-components" + +import * as E from "fp-ts/Either" +import * as O from "fp-ts/Option" +import { z } from "zod" + +import { v4 as uuidv4 } from "uuid" +import { Ref } from "vue" +import { getService } from "~/modules/dioc" +import { KernelInterceptorService } from "~/services/kernel-interceptor.service" +import { parseBodyAsJSON } from "~/helpers/functional/json" + +const interceptorService = getService(KernelInterceptorService) + +export function GistSource(metadata: { + caption: string + onImportFromGist: ( + importResult: E.Either + ) => any | Promise + isLoading?: Ref + description?: string +}) { + const stepID = uuidv4() + + return defineStep(stepID, UrlImport, () => ({ + caption: metadata.caption, + description: metadata.description, + onImportFromURL: (gistResponse: unknown) => { + const fileSchema = z.object({ + files: z.record(z.object({ content: z.string() })), + }) + + const parseResult = fileSchema.safeParse(gistResponse) + + if (!parseResult.success) { + metadata.onImportFromGist(E.left("INVALID_GIST")) + return + } + + const contents = Object.values(parseResult.data.files).map( + ({ content }) => content + ) + + metadata.onImportFromGist(E.right(contents)) + }, + fetchLogic: fetchGistFromUrl, + loading: metadata.isLoading?.value, + })) +} +const fetchGistFromUrl = async (url: string) => { + // Extract the gist ID from the URL (eg. https://gist.github.com/username/gistID/...) + const gistID = url.split("/")[4] + + const { response } = interceptorService.execute({ + id: Date.now(), + url: `https://api.github.com/gists/${gistID}`, + method: "GET", + version: "HTTP/1.1", + headers: { + Accept: "application/vnd.github.v3+json", + }, + }) + + const res = await response + + if (E.isLeft(res)) { + return E.left("REQUEST_FAILED") + } + + const responsePayload = parseBodyAsJSON(res.right.body) + + if (O.isSome(responsePayload)) { + return E.right(responsePayload.value) + } + + return E.left("REQUEST_FAILED") +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/import-sources/UrlSource.ts b/packages/hoppscotch-common/src/helpers/import-export/import/import-sources/UrlSource.ts new file mode 100644 index 0000000..6bed2bc --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/import-sources/UrlSource.ts @@ -0,0 +1,26 @@ +import UrlImport from "~/components/importExport/ImportExportSteps/UrlImport.vue" +import { defineStep } from "~/composables/step-components" + +import { v4 as uuidv4 } from "uuid" +import { Ref } from "vue" + +export function UrlSource(metadata: { + caption: string + onImportFromURL: (content: string) => any | Promise + fetchLogic?: (url: string) => Promise + isLoading?: Ref + description: string +}) { + const stepID = uuidv4() + + return defineStep(stepID, UrlImport, () => ({ + caption: metadata.caption, + onImportFromURL: (content: unknown) => { + if (typeof content === "string") { + metadata.onImportFromURL(content) + } + }, + loading: metadata.isLoading?.value, + description: metadata.description, + })) +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/importers.ts b/packages/hoppscotch-common/src/helpers/import-export/import/importers.ts new file mode 100644 index 0000000..edf6eb7 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/importers.ts @@ -0,0 +1,6 @@ +export { hoppRESTImporter } from "./hopp" +export { hoppOpenAPIImporter } from "./openapi" +export { hoppPostmanImporter } from "./postman" +export { hoppInsomniaImporter } from "./insomnia/insomniaColl" +export { toTeamsImporter } from "./myCollections" +export { harImporter } from "./har" diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/index.ts b/packages/hoppscotch-common/src/helpers/import-export/import/index.ts new file mode 100644 index 0000000..66c1ad1 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/index.ts @@ -0,0 +1,96 @@ +import * as TE from "fp-ts/TaskEither" +import type { Component } from "vue" +import { StepsOutputList } from "../steps" +import { + HoppCollection, + makeCollection, + translateToNewRESTCollection, +} from "@hoppscotch/data" + +/** + * A common error state to be used when the file formats are not expected + */ +export const IMPORTER_INVALID_FILE_FORMAT = + "importer_invalid_file_format" as const + +export type HoppImporterError = typeof IMPORTER_INVALID_FILE_FORMAT + +type HoppImporter = ( + stepValues: StepsOutputList +) => TE.TaskEither + +type HoppImporterApplicableTo = Array< + "team-collections" | "my-collections" | "url-import" +> + +/** + * Definition for importers + */ +type HoppImporterDefinition = { + /** + * the id + */ + id: string + /** + * Name of the importer, shown on the Select Importer dropdown + */ + name: string + + /** + * Icon for importer button + */ + icon: Component + + /** + * Identifier for the importer + */ + applicableTo: HoppImporterApplicableTo + + /** + * The importer function, It is a Promise because its supposed to be loaded in lazily (dynamic imports ?) + */ + importer: HoppImporter + + /** + * The steps to fetch information required to run an importer + */ + steps: Y +} + +/** + * Defines a Hoppscotch importer + */ +export const defineImporter = (input: { + id: string + name: string + icon: object | Component + importer: HoppImporter + applicableTo: HoppImporterApplicableTo + steps: StepType +}) => { + return >{ + ...input, + } +} + +/** + * Sanitize collection for import, removes old id and ref_id from collection and folders, and transforms it to + * new collection format with a newly generated ref_id. + * @param collection The collection to sanitize + * @returns The sanitized collection with new ref_id + */ +export const sanitizeCollection = ( + collection: HoppCollection +): HoppCollection => { + const { + id: _id, + _ref_id: _refId, + v: _v, + ...rest + } = translateToNewRESTCollection(collection) + + return makeCollection({ + ...rest, + folders: rest.folders.map(sanitizeCollection), + }) +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/insomnia/insomniaColl.ts b/packages/hoppscotch-common/src/helpers/import-export/import/insomnia/insomniaColl.ts new file mode 100644 index 0000000..c2b84b1 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/insomnia/insomniaColl.ts @@ -0,0 +1,376 @@ +import { + HoppCollection, + HoppCollectionVariable, + HoppRESTAuth, + HoppRESTHeader, + HoppRESTParam, + HoppRESTReqBody, + HoppRESTRequest, + HoppRESTRequestVariable, + knownContentTypes, + makeCollection, + makeRESTRequest, +} from "@hoppscotch/data" + +import * as A from "fp-ts/Array" +import * as TE from "fp-ts/TaskEither" +import * as TO from "fp-ts/TaskOption" +import * as O from "fp-ts/Option" +import { pipe } from "fp-ts/function" +import { convert } from "insomnia-importers" + +import { IMPORTER_INVALID_FILE_FORMAT } from ".." +import { replaceInsomniaTemplating } from "./insomniaEnv" +import { safeParseJSONOrYAML } from "~/helpers/functional/yaml" +import { + InsomniaDoc, + InsomniaDocV5, + InsomniaFolderResource, + InsomniaFolderV5, + InsomniaRequestResource, + InsomniaResource, + InsoReqAuth, +} from "./types" + +/** + * Used to check if the document is an Insomnia v5 document + * Insomnia v5 documents have a type field that starts with "collection.insomnia.rest/ + * @param data InsomniaDoc + * @returns true if the document is an Insomnia v5 document + */ +const isV5InsomniaDoc = (data: InsomniaDoc) => + data.type && + typeof data.type === "string" && + (data.type as string).startsWith("collection.insomnia.rest/5") + +const replacePathVarTemplating = (expression: string) => + expression.replaceAll(/:([^/]+)/g, "<<$1>>") + +const replaceVarTemplating = (expression: string, pathVar = false) => { + return pipe( + expression, + pathVar ? replacePathVarTemplating : (x) => x, + replaceInsomniaTemplating + ) +} + +const getFoldersIn = ( + folder: InsomniaFolderResource | null, + resources: InsomniaResource[] +) => + pipe( + resources, + A.filter( + (x): x is InsomniaFolderResource => + (x._type === "request_group" || x._type === "workspace") && + x.parentId === (folder?._id ?? null) + ) + ) + +const getRequestsIn = ( + folder: InsomniaFolderResource | null, + resources: InsomniaResource[] +) => + pipe( + resources, + A.filter( + (x): x is InsomniaRequestResource => + x._type === "request" && x.parentId === (folder?._id ?? null) + ) + ) + +const getCollectionVariables = ( + environment: Record | undefined, + folderRes?: InsomniaFolderResource +): HoppCollectionVariable[] => { + const env = + folderRes && folderRes.environment ? folderRes.environment : environment + + if (!env) return [] + + return Object.entries(env).map(([key, value]) => ({ + key: replaceVarTemplating(key), + currentValue: "", // set it as empty value since it is handled by currentValue service and we don't want it to sync with BE + initialValue: replaceVarTemplating(value), + secret: false, + })) +} + +const getHoppReqAuth = (req: InsomniaRequestResource): HoppRESTAuth => { + if (!req.authentication) return { authType: "none", authActive: true } + + const auth = req.authentication as InsoReqAuth + + if (auth.type === "basic") + return { + authType: "basic", + authActive: true, + username: replaceVarTemplating(auth.username ?? ""), + password: replaceVarTemplating(auth.password ?? ""), + } + else if (auth.type === "oauth2") + return { + authType: "oauth-2", + authActive: !(auth.disabled ?? false), + grantTypeInfo: { + authEndpoint: replaceVarTemplating(auth.authorizationUrl ?? ""), + clientID: replaceVarTemplating(auth.clientId ?? ""), + clientSecret: "", + grantType: "AUTHORIZATION_CODE", + scopes: replaceVarTemplating(auth.scope ?? ""), + token: "", + isPKCE: false, + tokenEndpoint: replaceVarTemplating(auth.accessTokenUrl ?? ""), + authRequestParams: [], + refreshRequestParams: [], + tokenRequestParams: [], + }, + addTo: "HEADERS", + } + else if (auth.type === "bearer") + return { + authType: "bearer", + authActive: true, + token: replaceVarTemplating(auth.token ?? ""), + } + + return { authType: "none", authActive: true } +} + +const getHoppReqBody = (req: InsomniaRequestResource): HoppRESTReqBody => { + if (!req.body) return { contentType: null, body: null } + + if (typeof req.body === "string") { + const contentType = + req.headers?.find( + (header) => header.name.toLowerCase() === "content-type" + )?.value ?? "text/plain" + + return { contentType, body: replaceVarTemplating(req.body) } + } + + if (req.body.mimeType === "multipart/form-data") { + return { + contentType: "multipart/form-data", + body: + req.body.params?.map((param) => ({ + key: replaceVarTemplating(param.name), + value: replaceVarTemplating(param.value ?? ""), + active: !(param.disabled ?? false), + isFile: false, + })) ?? [], + } + } else if (req.body.mimeType === "application/x-www-form-urlencoded") { + return { + contentType: "application/x-www-form-urlencoded", + body: + req.body.params + ?.filter((param) => !(param.disabled ?? false)) + .map( + (param) => + `${replaceVarTemplating(param.name)}: ${replaceVarTemplating( + param.value ?? "" + )}` + ) + .join("\n") ?? "", + } + } else if ( + Object.keys(knownContentTypes).includes(req.body.mimeType ?? "text/plain") + ) { + return { + contentType: (req.body.mimeType ?? "text/plain") as any, + body: replaceVarTemplating(req.body.text ?? "") as any, + } + } + + return { contentType: null, body: null } +} + +const getHoppReqHeaders = (req: InsomniaRequestResource): HoppRESTHeader[] => + req.headers?.map((header) => ({ + key: replaceVarTemplating(header.name), + value: replaceVarTemplating(header.value), + active: !header.disabled, + description: header.description ?? "", + })) ?? [] + +const getHoppReqParams = (req: InsomniaRequestResource): HoppRESTParam[] => + req.parameters?.map((param) => ({ + key: replaceVarTemplating(param.name), + value: replaceVarTemplating(param.value ?? ""), + active: !(param.disabled ?? false), + description: param.description ?? "", + })) ?? [] + +const getHoppReqVariables = ( + req: InsomniaRequestResource +): HoppRESTRequestVariable[] => + req.pathParameters?.map((variable) => ({ + key: replaceVarTemplating(variable.name), + value: replaceVarTemplating(variable.value ?? ""), + active: true, + })) ?? [] + +const getHoppRequest = (req: InsomniaRequestResource): HoppRESTRequest => + makeRESTRequest({ + name: req.name ?? "Untitled Request", + method: req.method?.toUpperCase() ?? "GET", + endpoint: replaceVarTemplating(req.url ?? "", true), + auth: getHoppReqAuth(req), + body: getHoppReqBody(req), + headers: getHoppReqHeaders(req), + params: getHoppReqParams(req), + + preRequestScript: "", + testScript: "", + + requestVariables: getHoppReqVariables(req), + + //insomnia doesn't have saved response + responses: {}, + description: req.meta?.description ?? null, + }) + +const getHoppFolder = ( + folderRes: InsomniaFolderResource, + resources: InsomniaResource[] +): HoppCollection => + makeCollection({ + name: folderRes.name ?? "", + folders: getFoldersIn(folderRes, resources).map((f) => + getHoppFolder(f, resources) + ), + requests: getRequestsIn(folderRes, resources).map(getHoppRequest), + auth: { authType: "inherit", authActive: true }, + headers: [], + variables: getCollectionVariables(undefined, folderRes), // undefined is used to indicate no environment variables for v4 and below + description: folderRes.meta?.description ?? null, + preRequestScript: "", + testScript: "", + }) + +const getHoppCollections = (docs: InsomniaDoc[]) => { + return docs.flatMap((doc) => { + return getFoldersIn(null, doc.data.resources).map((f) => + getHoppFolder(f, doc.data.resources) + ) + }) +} + +const getFolders = (collections: InsomniaDocV5["collection"]) => { + if (!collections) return [] + return collections.filter( + (x): x is InsomniaFolderV5 => "children" in x && !("url" in x) + ) +} + +const getRequests = ( + collections: InsomniaDocV5["collection"] +): InsomniaRequestResource[] => { + if (!collections) return [] + return collections.filter((x): x is InsomniaRequestResource => "url" in x) +} + +const getParsedHoppFolder = ( + name: string, + collection: InsomniaFolderV5 +): HoppCollection => { + return makeCollection({ + name: name ?? collection.name ?? "Untitled Collection", + folders: getFolders(collection.children ?? []).map((f) => + getParsedHoppFolder(f.name, f) + ), + requests: getRequests(collection.children ?? []).map(getParsedHoppRequest), + auth: { authType: "inherit", authActive: true }, + headers: [], + variables: getCollectionVariables(collection.environment), + description: collection.meta.description ?? null, + preRequestScript: "", + testScript: "", + }) +} + +const getParsedHoppRequest = (req: InsomniaRequestResource) => { + return makeRESTRequest({ + name: req.name ?? "Untitled Request", + method: req.method?.toUpperCase() ?? "GET", + endpoint: replaceVarTemplating(req.url ?? "", true), + auth: getHoppReqAuth(req), + body: getHoppReqBody(req), + headers: getHoppReqHeaders(req), + params: getHoppReqParams(req), + + preRequestScript: "", + testScript: "", + + requestVariables: getHoppReqVariables(req), + + //insomnia doesn't have saved response + responses: {}, + + description: req.meta?.description ?? null, + }) +} + +const getParsedHoppCollections = (docs: InsomniaDocV5[]): HoppCollection[] => + docs.flatMap((doc) => { + if (doc && Array.isArray(doc.collection)) { + return makeCollection({ + name: doc.name ?? "Untitled Collection", + folders: getFolders(doc.collection).map((f) => + getParsedHoppFolder(f.name, f) + ), + requests: getRequests(doc.collection).map((x) => + getParsedHoppRequest(x) + ), + auth: { authType: "inherit", authActive: true }, + headers: [], + variables: getCollectionVariables(doc.environments?.data), + description: doc.meta.description ?? null, + preRequestScript: "", + testScript: "", + }) + } + + return [] + }) + +const parseInsomniaDoc = (content: string) => + pipe( + TO.tryCatch(() => convert(content)), + TO.map((doc) => getHoppCollections([doc])), + TE.fromTaskOption(() => IMPORTER_INVALID_FILE_FORMAT) + ) + +const parseV5InsomniaDoc = (content: string) => + pipe( + safeParseJSONOrYAML(content), + TO.fromOption, + TO.map((parsed) => parsed as InsomniaDocV5), + TO.map((doc) => getParsedHoppCollections([doc])), + TE.fromTaskOption(() => IMPORTER_INVALID_FILE_FORMAT) + ) + +/** + * insomina-importers v3.6.0 does supoort insomina v5 + * (NOTE: Currently, insomnia-importers only supports v4 and below) + * https://github.com/Kong/insomnia/issues/8504 + */ +export const hoppInsomniaImporter = (fileContents: string[]) => { + return pipe( + fileContents, + A.traverse(TE.ApplicativeSeq)((content) => + pipe( + safeParseJSONOrYAML(content), + O.fold( + () => TE.left(IMPORTER_INVALID_FILE_FORMAT), + (parsed) => + isV5InsomniaDoc(parsed as InsomniaDoc) + ? parseV5InsomniaDoc(content) + : parseInsomniaDoc(content) + ) + ) + ), + TE.map(A.flatten) + ) +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/insomnia/insomniaEnv.ts b/packages/hoppscotch-common/src/helpers/import-export/import/insomnia/insomniaEnv.ts new file mode 100644 index 0000000..d7ec3a0 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/insomnia/insomniaEnv.ts @@ -0,0 +1,99 @@ +import * as TE from "fp-ts/TaskEither" +import * as O from "fp-ts/Option" + +import { IMPORTER_INVALID_FILE_FORMAT } from ".." + +import { z } from "zod" +import { + EnvironmentSchemaVersion, + NonSecretEnvironment, +} from "@hoppscotch/data" +import { safeParseJSONOrYAML } from "~/helpers/functional/yaml" +import { uniqueID } from "~/helpers/utils/uniqueID" + +const insomniaResourcesSchema = z.object({ + resources: z.array( + z + .object({ + _type: z.string(), + }) + .passthrough() + ), +}) + +const insomniaEnvSchema = z.object({ + _type: z.literal("environment"), + name: z.string(), + data: z.record(z.string()), +}) + +export const replaceInsomniaTemplating = (expression: string) => { + const regex = /\{\{ _\.([^}]+) \}\}/g + return expression.replaceAll(regex, "<<$1>>") +} + +export const insomniaEnvImporter = (contents: string[]) => { + const parsedContents = contents.map((str) => safeParseJSONOrYAML(str)) + if (parsedContents.some((parsed) => O.isNone(parsed))) { + return TE.left(IMPORTER_INVALID_FILE_FORMAT) + } + + const parsedValues = parsedContents.map((parsed) => O.toNullable(parsed)) + + const validationResult = z + .array(insomniaResourcesSchema) + .safeParse(parsedValues) + + if (!validationResult.success) { + return TE.left(IMPORTER_INVALID_FILE_FORMAT) + } + + const insomniaEnvs = validationResult.data.flatMap(({ resources }) => { + return resources + .filter((resource) => resource._type === "environment") + .map((envResource) => { + const envResourceData = envResource.data as Record + const stringifiedData: Record = {} + + Object.keys(envResourceData).forEach((key) => { + stringifiedData[key] = String(envResourceData[key]) + }) + + return { ...envResource, data: stringifiedData } + }) + }) + + const environments: NonSecretEnvironment[] = [] + + insomniaEnvs.forEach((insomniaEnv) => { + const parsedInsomniaEnv = insomniaEnvSchema.safeParse(insomniaEnv) + if (parsedInsomniaEnv.success) { + const environment: NonSecretEnvironment = { + id: uniqueID(), + v: EnvironmentSchemaVersion, + name: parsedInsomniaEnv.data.name, + variables: Object.entries(parsedInsomniaEnv.data.data).map( + ([key, value]) => ({ + key, + initialValue: value, + currentValue: value, + secret: false, + }) + ), + } + + environments.push(environment) + } + }) + + const processedEnvironments = environments.map((env) => ({ + ...env, + variables: env.variables.map((variable) => ({ + ...variable, + initialValue: replaceInsomniaTemplating(variable.initialValue), + currentValue: replaceInsomniaTemplating(variable.currentValue), + })), + })) + + return TE.right(processedEnvironments) +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/insomnia/types.ts b/packages/hoppscotch-common/src/helpers/import-export/import/insomnia/types.ts new file mode 100644 index 0000000..99fb547 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/insomnia/types.ts @@ -0,0 +1,155 @@ +import { ImportRequest, convert } from "insomnia-importers" +import { Header, Parameter } from "insomnia-importers/dist/src/entities" + +type UnwrapPromise> = + T extends Promise ? Y : never + +export type InsomniaDoc = UnwrapPromise> +export type InsomniaResource = ImportRequest + +// insomnia-importers v3.6.0 doesn't provide a type for path parameters and they have deprecated the library +export type InsomniaPathParameter = { + name: string + value: string +} + +export type InsomniaFolderResource = ImportRequest & { + _type: "request_group" + description?: string + meta?: InsomniaMetaV5 +} +export type InsomniaRequestResource = Omit< + ImportRequest, + "headers" | "parameters" +> & { + _type: "request" +} & { + pathParameters?: InsomniaPathParameter[] +} & { + headers: (Header & { description: string })[] + parameters: (Parameter & { description: string })[] + meta?: InsomniaMetaV5 +} + +/** + * The provided type by insomnia-importers, this type corrects it + */ +export type InsoReqAuth = + | { type: "basic"; disabled?: boolean; username?: string; password?: string } + | { + type: "oauth2" + disabled?: boolean + accessTokenUrl?: string + authorizationUrl?: string + clientId?: string + scope?: string + } + | { + type: "bearer" + disabled?: boolean + token?: string + } + +/** + * Insomnia v5 document types + * These types are used to represent the structure of Insomnia v5 documents. + */ +export type InsomniaDocV5 = { + type: `collection.insomnia.rest/${string}` + name: string + meta: { + id: string + created: number + modified: number + description?: string + } + collection: ( + | InsomniaFolderV5 + | InsomniaRequestResource + | InsomniaScriptOnlyV5 + )[] + cookieJar?: InsomniaCookieJarV5 + environments?: InsomniaEnvironmentV5 +} + +export type InsomniaMetaV5 = { + id: string + created: number + modified: number + description?: string + sortKey?: number +} + +export type InsomniaScriptOnlyV5 = { + name: string + meta: InsomniaMetaV5 + scripts: { + afterResponse?: string + preRequest?: string + } +} + +export type InsomniaFolderV5 = { + name: string + meta: InsomniaMetaV5 + children?: (InsomniaFolderV5 | InsomniaRequestResource)[] + environment?: Record + scripts?: { + afterResponse?: string + preRequest?: string + } +} + +export type InsomniaKeyValueV5 = { + id?: string + name: string + value: string + description?: string + disabled?: boolean + type?: string + multiline?: boolean +} + +export type InsomniaCookieJarV5 = { + name: string + meta: { + id: string + created: number + modified: number + } + cookies: { + key: string + value: string + domain: string + path: string + secure?: boolean + httpOnly?: boolean + hostOnly?: boolean + creation: string + lastAccessed: string + sameSite?: "lax" | "strict" | "none" + id: string + }[] +} + +export type InsomniaEnvironmentV5 = { + name: string + meta: { + id: string + created: number + modified: number + isPrivate?: boolean + } + data: Record + subEnvironments?: { + name: string + meta: { + id: string + created: number + modified: number + isPrivate?: boolean + sortKey?: number + } + data: Record + }[] +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/myCollections.ts b/packages/hoppscotch-common/src/helpers/import-export/import/myCollections.ts new file mode 100644 index 0000000..a8f5fcc --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/myCollections.ts @@ -0,0 +1,5 @@ +import { importJSONToTeam } from "~/helpers/backend/mutations/TeamCollection" + +export function toTeamsImporter(content: string, teamID: string) { + return importJSONToTeam(content, teamID) +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/openapi/example-generators/v2.ts b/packages/hoppscotch-common/src/helpers/import-export/import/openapi/example-generators/v2.ts new file mode 100644 index 0000000..39b9510 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/openapi/example-generators/v2.ts @@ -0,0 +1,191 @@ +import { OpenAPIV2 } from "openapi-types" +import * as O from "fp-ts/Option" +import { pipe, flow } from "fp-ts/function" +import * as A from "fp-ts/Array" +import { prettyPrintJSON } from "~/helpers/functional/json" + +type PrimitiveSchemaType = "string" | "integer" | "number" | "boolean" + +type SchemaType = "array" | "object" | PrimitiveSchemaType + +type PrimitiveRequestBodyExample = number | string | boolean + +type RequestBodyExample = + | { [name: string]: RequestBodyExample } + | Array + | PrimitiveRequestBodyExample + +const getPrimitiveTypePlaceholder = ( + schemaType: PrimitiveSchemaType +): PrimitiveRequestBodyExample => { + switch (schemaType) { + case "string": + return "string" + case "integer": + case "number": + return 1 + case "boolean": + return true + } +} + +const getSchemaTypeFromSchemaObject = ( + schema: OpenAPIV2.SchemaObject +): O.Option => + pipe( + schema.type, + O.fromNullable, + O.map( + (schemaType) => + (Array.isArray(schemaType) ? schemaType[0] : schemaType) as SchemaType + ) + ) + +const isSchemaTypePrimitive = ( + schemaType: string +): schemaType is PrimitiveSchemaType => + ["string", "integer", "number", "boolean"].includes(schemaType) + +const isSchemaTypeArray = (schemaType: string): schemaType is "array" => + schemaType === "array" + +const isSchemaTypeObject = (schemaType: string): schemaType is "object" => + schemaType === "object" + +const getSampleEnumValueOrPlaceholder = ( + schema: OpenAPIV2.SchemaObject +): RequestBodyExample => + pipe( + schema.enum, + O.fromNullable, + O.map((enums) => enums[0] as RequestBodyExample), + O.altW(() => + pipe( + schema, + getSchemaTypeFromSchemaObject, + O.filter(isSchemaTypePrimitive), + O.map(getPrimitiveTypePlaceholder) + ) + ), + O.getOrElseW(() => "") + ) + +const generateExampleArrayFromOpenAPIV2ItemsObject = ( + items: OpenAPIV2.ItemsObject +): RequestBodyExample => { + // Guard against undefined items + if (!items || !items.type) { + return [] + } + + // ItemsObject can not hold type "object" + // https://swagger.io/specification/v2/#itemsObject + + // TODO : Handle array of objects + // https://stackoverflow.com/questions/60490974/how-to-define-an-array-of-objects-in-openapi-2-0 + + return pipe( + items, + O.fromPredicate( + flow((items) => items.type as SchemaType, isSchemaTypePrimitive) + ), + O.map(flow(getSampleEnumValueOrPlaceholder, (arrayItem) => [arrayItem])), + O.getOrElse(() => + // If the type is not primitive, it is "array" + // items property is required if type is array + items.items + ? [ + generateExampleArrayFromOpenAPIV2ItemsObject( + items.items as OpenAPIV2.ItemsObject + ), + ] + : [] + ) + ) +} + +export const generateRequestBodyExampleFromOpenAPIV2BodySchema = ( + schema: OpenAPIV2.SchemaObject +): RequestBodyExample => { + if (!schema) return "" + + if (schema.example !== undefined) return schema.example as RequestBodyExample + + const primitiveTypeExample = pipe( + schema, + O.fromPredicate( + flow( + getSchemaTypeFromSchemaObject, + O.map(isSchemaTypePrimitive), + O.getOrElseW(() => false) // No schema type found in the schema object, assume non-primitive + ) + ), + O.map(getSampleEnumValueOrPlaceholder) // Use enum or placeholder to populate primitive field + ) + + if (O.isSome(primitiveTypeExample)) return primitiveTypeExample.value + + const arrayTypeExample = pipe( + schema, + O.fromPredicate( + flow( + getSchemaTypeFromSchemaObject, + O.map(isSchemaTypeArray), + O.getOrElseW(() => false) // No schema type found in the schema object, assume type to be different from array + ) + ), + O.map((schema) => schema.items as OpenAPIV2.ItemsObject), + O.filter((items) => items !== null), // Filter out null/undefined items + O.map(generateExampleArrayFromOpenAPIV2ItemsObject) + ) + + if (O.isSome(arrayTypeExample)) return arrayTypeExample.value + + return pipe( + schema, + O.fromPredicate( + flow( + getSchemaTypeFromSchemaObject, + O.map(isSchemaTypeObject), + O.getOrElseW(() => false) + ) + ), + O.chain((schema) => + pipe( + schema.properties, + O.fromNullable, + O.map( + (properties) => + Object.entries(properties) as [string, OpenAPIV2.SchemaObject][] + ) + ) + ), + O.getOrElseW(() => [] as [string, OpenAPIV2.SchemaObject][]), + A.reduce( + {} as { [name: string]: RequestBodyExample }, + (aggregatedExample, property) => { + const example = generateRequestBodyExampleFromOpenAPIV2BodySchema( + property[1] + ) + aggregatedExample[property[0]] = example + return aggregatedExample + } + ) + ) +} + +export const generateRequestBodyExampleFromOpenAPIV2Body = ( + op: OpenAPIV2.OperationObject +): string => + pipe( + (op.parameters ?? []) as OpenAPIV2.Parameter[], + A.findFirst((param) => param.in === "body"), + O.map( + flow( + (parameter) => parameter.schema, + generateRequestBodyExampleFromOpenAPIV2BodySchema + ) + ), + O.chain(prettyPrintJSON), + O.getOrElse(() => "") + ) diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/openapi/example-generators/v3.ts b/packages/hoppscotch-common/src/helpers/import-export/import/openapi/example-generators/v3.ts new file mode 100644 index 0000000..3953306 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/openapi/example-generators/v3.ts @@ -0,0 +1,133 @@ +import { OpenAPIV3 } from "openapi-types" +import { pipe } from "fp-ts/function" +import * as O from "fp-ts/Option" + +type SchemaType = + | OpenAPIV3.ArraySchemaObjectType + | OpenAPIV3.NonArraySchemaObjectType + +type PrimitiveSchemaType = Exclude + +type PrimitiveRequestBodyExample = string | number | boolean | null + +type RequestBodyExample = + | PrimitiveRequestBodyExample + | Array + | { [name: string]: RequestBodyExample } + +const isSchemaTypePrimitive = ( + schemaType: SchemaType +): schemaType is PrimitiveSchemaType => + !["array", "object"].includes(schemaType) + +const getPrimitiveTypePlaceholder = ( + primitiveType: PrimitiveSchemaType +): PrimitiveRequestBodyExample => { + switch (primitiveType) { + case "number": + return 0.0 + case "integer": + return 0 + case "string": + return "string" + case "boolean": + return true + } +} + +// Use carefully, call only when type is primitive +// TODO(agarwal): Use Enum values, if any +const generatePrimitiveRequestBodyExample = ( + schemaObject: OpenAPIV3.NonArraySchemaObject +): RequestBodyExample => + getPrimitiveTypePlaceholder(schemaObject.type as PrimitiveSchemaType) + +// Use carefully, call only when type is object +const generateObjectRequestBodyExample = ( + schemaObject: OpenAPIV3.NonArraySchemaObject +): RequestBodyExample => + pipe( + schemaObject.properties, + O.fromNullable, + O.map(Object.entries), + O.getOrElseW(() => [] as [string, OpenAPIV3.SchemaObject][]), + (entries) => + entries.reduce( + (acc, [key, propSchema]) => ({ + ...acc, + [key]: generateRequestBodyExampleFromSchemaObject( + propSchema as OpenAPIV3.SchemaObject + ), + }), + {} as Record + ) + ) + +const generateArrayRequestBodyExample = ( + schemaObject: OpenAPIV3.ArraySchemaObject +): RequestBodyExample => [ + generateRequestBodyExampleFromSchemaObject( + schemaObject.items as OpenAPIV3.SchemaObject + ), +] + +const generateRequestBodyExampleFromSchemaObject = ( + schemaObject: OpenAPIV3.SchemaObject +): RequestBodyExample => { + // TODO: Handle schema objects with allof + if (schemaObject.example) return schemaObject.example as RequestBodyExample + + // If request body can be oneof or allof several schema, choose the first schema to generate an example + if (schemaObject.oneOf) + return generateRequestBodyExampleFromSchemaObject( + schemaObject.oneOf[0] as OpenAPIV3.SchemaObject + ) + if (schemaObject.anyOf) + return generateRequestBodyExampleFromSchemaObject( + schemaObject.anyOf[0] as OpenAPIV3.SchemaObject + ) + + if (!schemaObject.type) return "" + + if (isSchemaTypePrimitive(schemaObject.type)) + return generatePrimitiveRequestBodyExample( + schemaObject as OpenAPIV3.NonArraySchemaObject + ) + + if (schemaObject.type === "object") + return generateObjectRequestBodyExample( + schemaObject as OpenAPIV3.NonArraySchemaObject + ) + + return generateArrayRequestBodyExample( + schemaObject as OpenAPIV3.ArraySchemaObject + ) +} + +export const generateRequestBodyExampleFromMediaObject = ( + mediaObject: OpenAPIV3.MediaTypeObject +): RequestBodyExample => { + // First check for direct example + if (mediaObject.example) return mediaObject.example as RequestBodyExample + + // Then check for examples object (OpenAPI v3 format) + if (mediaObject.examples) { + const firstExample = Object.values(mediaObject.examples)[0] + if ( + firstExample && + typeof firstExample === "object" && + "value" in firstExample + ) { + return firstExample.value as RequestBodyExample + } + // Fallback if examples doesn't have the expected structure + return Object.values(mediaObject.examples)[0] as RequestBodyExample + } + + // Fallback to generating from schema + return mediaObject.schema + ? generateRequestBodyExampleFromSchemaObject( + mediaObject.schema as OpenAPIV3.SchemaObject + ) + : "" +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/openapi/example-generators/v31.ts b/packages/hoppscotch-common/src/helpers/import-export/import/openapi/example-generators/v31.ts new file mode 100644 index 0000000..82fcea8 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/openapi/example-generators/v31.ts @@ -0,0 +1,145 @@ +import { OpenAPIV3_1 as OpenAPIV31 } from "openapi-types" +import { pipe } from "fp-ts/function" +import * as O from "fp-ts/Option" +import * as A from "fp-ts/Array" + +type MixedArraySchemaType = ( + | OpenAPIV31.ArraySchemaObjectType + | OpenAPIV31.NonArraySchemaObjectType +)[] + +type SchemaType = + | OpenAPIV31.ArraySchemaObjectType + | OpenAPIV31.NonArraySchemaObjectType + | MixedArraySchemaType + +type PrimitiveSchemaType = Exclude< + OpenAPIV31.NonArraySchemaObjectType, + "object" +> + +type PrimitiveRequestBodyExample = string | number | boolean | null + +type RequestBodyExample = + | PrimitiveRequestBodyExample + | Array + | { [name: string]: RequestBodyExample } + +const isSchemaTypePrimitive = ( + schemaType: SchemaType +): schemaType is PrimitiveSchemaType => + !Array.isArray(schemaType) && !["array", "object"].includes(schemaType) + +const getPrimitiveTypePlaceholder = ( + primitiveType: PrimitiveSchemaType +): PrimitiveRequestBodyExample => { + switch (primitiveType) { + case "number": + return 0.0 + case "integer": + return 0 + case "string": + return "string" + case "boolean": + return true + } + return null +} + +// Use carefully, the schema type should necessarily be primitive +// TODO(agarwal): Use Enum values, if any +const generatePrimitiveRequestBodyExample = ( + schemaObject: OpenAPIV31.NonArraySchemaObject +): RequestBodyExample => + getPrimitiveTypePlaceholder(schemaObject.type as PrimitiveSchemaType) + +// Use carefully, the schema type should necessarily be object +const generateObjectRequestBodyExample = ( + schemaObject: OpenAPIV31.NonArraySchemaObject +): RequestBodyExample => + pipe( + schemaObject.properties, + O.fromNullable, + O.map( + (properties) => + Object.entries(properties) as [string, OpenAPIV31.SchemaObject][] + ), + O.getOrElseW(() => [] as [string, OpenAPIV31.SchemaObject][]), + A.reduce( + {} as { [name: string]: RequestBodyExample }, + (aggregatedExample, property) => { + aggregatedExample[property[0]] = + generateRequestBodyExampleFromSchemaObject(property[1]) + return aggregatedExample + } + ) + ) + +// Use carefully, the schema type should necessarily be mixed array +const generateMixedArrayRequestBodyExample = ( + schemaObject: OpenAPIV31.SchemaObject +): RequestBodyExample => + pipe( + schemaObject, + (schemaObject) => schemaObject.type as MixedArraySchemaType, + A.reduce([] as Array, (aggregatedExample, itemType) => { + // TODO: Figure out how to include non-primitive types as well + if (isSchemaTypePrimitive(itemType)) { + aggregatedExample.push(getPrimitiveTypePlaceholder(itemType)) + } + return aggregatedExample + }) + ) + +const generateArrayRequestBodyExample = ( + schemaObject: OpenAPIV31.ArraySchemaObject +): RequestBodyExample => [ + generateRequestBodyExampleFromSchemaObject( + schemaObject.items as OpenAPIV31.SchemaObject + ), +] + +const generateRequestBodyExampleFromSchemaObject = ( + schemaObject: OpenAPIV31.SchemaObject +): RequestBodyExample => { + // TODO: Handle schema objects with oneof or anyof + if (schemaObject.example) return schemaObject.example as RequestBodyExample + if (schemaObject.examples) + return schemaObject.examples[0] as RequestBodyExample + if (!schemaObject.type) return "" + if (isSchemaTypePrimitive(schemaObject.type)) + return generatePrimitiveRequestBodyExample( + schemaObject as OpenAPIV31.NonArraySchemaObject + ) + if (schemaObject.type === "object") + return generateObjectRequestBodyExample(schemaObject) + if (schemaObject.type === "array") + return generateArrayRequestBodyExample(schemaObject) + return generateMixedArrayRequestBodyExample(schemaObject) +} + +export const generateRequestBodyExampleFromMediaObject = ( + mediaObject: OpenAPIV31.MediaTypeObject +): RequestBodyExample => { + // First check for direct example + if (mediaObject.example) return mediaObject.example as RequestBodyExample + + // Then check for examples object (OpenAPI v3.1 format) + if (mediaObject.examples) { + const firstExample = Object.values(mediaObject.examples)[0] + if ( + firstExample && + typeof firstExample === "object" && + "value" in firstExample + ) { + return firstExample.value as RequestBodyExample + } + // Fallback if examples doesn't have the expected structure + return Object.values(mediaObject.examples)[0] as RequestBodyExample + } + + // Fallback to generating from schema + return mediaObject.schema + ? generateRequestBodyExampleFromSchemaObject(mediaObject.schema) + : "" +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/openapi/index.ts b/packages/hoppscotch-common/src/helpers/import-export/import/openapi/index.ts new file mode 100644 index 0000000..390b9d5 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/openapi/index.ts @@ -0,0 +1,1938 @@ +import { + OpenAPI, + OpenAPIV2, + OpenAPIV3, + OpenAPIV3_1 as OpenAPIV31, +} from "openapi-types" +import SwaggerParser from "@apidevtools/swagger-parser" +import yaml from "js-yaml" +import { + FormDataKeyValue, + HoppRESTAuth, + HoppRESTHeader, + HoppRESTParam, + HoppRESTReqBody, + knownContentTypes, + makeRESTRequest, + HoppCollection, + makeCollection, + HoppRESTRequestVariable, + HoppRESTRequest, + HoppRESTRequestResponses, + HoppRESTResponseOriginalRequest, + makeHoppRESTResponseOriginalRequest, + parseRawKeyValueEntries, + rawKeyValueEntriesToString, +} from "@hoppscotch/data" +import { pipe, flow } from "fp-ts/function" +import * as A from "fp-ts/Array" +import * as S from "fp-ts/string" +import * as O from "fp-ts/Option" +import * as TE from "fp-ts/TaskEither" +import * as RA from "fp-ts/ReadonlyArray" +import * as E from "fp-ts/Either" +import { IMPORTER_INVALID_FILE_FORMAT } from ".." +import { cloneDeep } from "lodash-es" +import { getStatusCodeReasonPhrase } from "~/helpers/utils/statusCodes" +import { isNumeric } from "~/helpers/utils/number" +import { + generateRequestBodyExampleFromOpenAPIV2Body, + generateRequestBodyExampleFromOpenAPIV2BodySchema as generateV2ExampleFromSchemaObject, +} from "./example-generators/v2" +import { generateRequestBodyExampleFromMediaObject as generateV3Example } from "./example-generators/v3" +import { generateRequestBodyExampleFromMediaObject as generateV31Example } from "./example-generators/v31" + +export const OPENAPI_DEREF_ERROR = "openapi/deref_error" as const + +const worker = new Worker( + new URL("../workers/openapi-import-worker.ts", import.meta.url), + { + type: "module", + } +) + +const safeParseJSON = (str: string) => O.tryCatch(() => JSON.parse(str)) + +const safeParseYAML = (str: string) => O.tryCatch(() => yaml.load(str)) + +const objectHasProperty = ( + obj: unknown, + propName: T + // eslint-disable-next-line +): obj is { [propName in T]: unknown } => + !!obj && + typeof obj === "object" && + Object.prototype.hasOwnProperty.call(obj, propName) + +// Helper function to check for unresolved references in a document +const hasUnresolvedRefs = (obj: unknown, visited = new WeakSet()): boolean => { + // Handle non-objects or null + if (!obj || typeof obj !== "object") return false + + // Check for circular references + if (visited.has(obj)) return false + + // Add current object to visited set + visited.add(obj) + + // Check if current object has $ref property + if ("$ref" in obj && typeof obj.$ref === "string") return true + + // Check arrays + if (Array.isArray(obj)) { + return obj.some((item) => hasUnresolvedRefs(item, visited)) + } + + // Check object properties + return Object.values(obj).some((value) => hasUnresolvedRefs(value, visited)) +} + +// basic validation for OpenAPI V2 Document +const isOpenAPIV2Document = (doc: unknown): doc is OpenAPIV2.Document => { + return ( + objectHasProperty(doc, "swagger") && + typeof doc.swagger === "string" && + doc.swagger === "2.0" + ) +} + +// basic validation for OpenAPI V3 Document +const isOpenAPIV3Document = ( + doc: unknown +): doc is OpenAPIV3.Document | OpenAPIV31.Document => { + return ( + objectHasProperty(doc, "openapi") && + typeof doc.openapi === "string" && + doc.openapi.startsWith("3.") + ) +} + +type OpenAPIPathInfoType = + | OpenAPIV2.PathItemObject> + | OpenAPIV3.PathItemObject> + | OpenAPIV31.PathItemObject> + +type OpenAPIParamsType = + | OpenAPIV2.ParameterObject + | OpenAPIV3.ParameterObject + | OpenAPIV31.ParameterObject + +type OpenAPIOperationType = + | OpenAPIV2.OperationObject + | OpenAPIV3.OperationObject + | OpenAPIV31.OperationObject + +// Resolve request name: operationId > summary > title > "Untitled Request" +const getOpenAPIOperationName = (info: OpenAPIOperationType): string => { + const title = + objectHasProperty(info, "title") && typeof info.title === "string" + ? info.title + : undefined + + // OpenAPI semantics: `summary` is the short human-readable name shown in + // tools like Swagger UI; `operationId` is a machine identifier (no spaces, + // typically code-gen-friendly). Prefer the human-readable one for the + // Hoppscotch request name; fall back to operationId only if summary is + // missing. Otherwise round-tripping turns "Get Pet" into "Get_Pet". + const candidates: Array = [ + info.summary, + info.operationId, + title, + ] + + for (const candidate of candidates) { + if (typeof candidate === "string") { + const trimmed = candidate.trim() + if (trimmed !== "") { + return trimmed + } + } + } + + return "Untitled Request" +} + +// Removes the OpenAPI Path Templating to the Hoppscotch Templating (<< ? >>) +const replaceOpenApiPathTemplating = flow( + S.replace(/{/g, "<<"), + S.replace(/}/g, ">>") +) + +/** + * Read an example/default value off an OpenAPI param-like object and return + * it as a string. Used by query/header/path parsers so values round-trip. + * + * Sources tried, in order: + * - `example` (OpenAPI 3.x — what our exporter writes) + * - first entry of `examples` (OpenAPI 3.x alternative) + * - `x-example` (common Swagger 2.0 extension) + * - `default` (Swagger 2.0 — `default` carries the example value, while + * OpenAPI 3.x reserves it for schema defaults; v2 docs in the wild + * overwhelmingly use `default` for both) + * - `schema.default` (OpenAPI 3.x schema-level default) + */ +const readParamExampleAsString = (param: unknown): string => { + if (typeof param !== "object" || param === null) return "" + const p = param as { + example?: unknown + examples?: unknown + "x-example"?: unknown + default?: unknown + schema?: { default?: unknown } + } + // Strings pass through; objects/arrays are JSON-encoded so callers see the + // structured shape rather than `[object Object]`. Numbers/booleans use + // `String(...)` for their natural literal form. + const stringify = (v: unknown): string => { + if (typeof v === "string") return v + if (typeof v === "object" && v !== null) { + try { + return JSON.stringify(v) + } catch { + return String(v) + } + } + return String(v) + } + + if (p.example !== undefined && p.example !== null) return stringify(p.example) + + if (p.examples && typeof p.examples === "object") { + const first = Object.values(p.examples as Record)[0] + if (first && typeof first === "object") { + const v = (first as { value?: unknown }).value + if (v !== undefined && v !== null) return stringify(v) + } + } + + if (p["x-example"] !== undefined && p["x-example"] !== null) { + return stringify(p["x-example"]) + } + + if (p.default !== undefined && p.default !== null) return stringify(p.default) + + if (p.schema?.default !== undefined && p.schema?.default !== null) { + return stringify(p.schema.default) + } + + return "" +} + +const parseOpenAPIParams = (params: OpenAPIParamsType[]): HoppRESTParam[] => + pipe( + params, + + A.filterMap( + flow( + O.fromPredicate((param) => param.in === "query"), + O.map( + (param) => + { + key: param.name, + value: readParamExampleAsString(param), + active: true, + description: param.description ?? "", + } + ) + ) + ) + ) + +const parseOpenAPIVariables = ( + variables: OpenAPIParamsType[] +): HoppRESTRequestVariable[] => + pipe( + variables, + + A.filterMap( + flow( + O.fromPredicate((param) => param.in === "path"), + O.map( + (param) => + { + key: param.name, + value: readParamExampleAsString(param), + active: true, + } + ) + ) + ) + ) + +const parseOpenAPIV3Responses = ( + doc: OpenAPI.Document, + op: OpenAPIV3.OperationObject | OpenAPIV31.OperationObject, + originalRequest: HoppRESTResponseOriginalRequest +): HoppRESTRequestResponses => { + const responses = op.responses + if (!responses) return {} + + const res: HoppRESTRequestResponses = {} + + for (const [key, value] of Object.entries(responses)) { + const response = value as + | OpenAPIV3.ResponseObject + | OpenAPIV31.ResponseObject + + const contentType = Object.keys(response.content ?? {})[0] + const mediaObject = response.content?.[contentType] as + | OpenAPIV3.MediaTypeObject + | OpenAPIV31.MediaTypeObject + | undefined + + const name = response.description ?? key + + const code = isNumeric(key) ? Number(key) : 200 + + const status = getStatusCodeReasonPhrase(code) + + const headers: HoppRESTHeader[] = [ + { + key: "content-type", + value: contentType ?? "application/json", + description: "", + active: true, + }, + ] + + let stringifiedBody = "" + // Track whether an explicit example was found (even if empty string) + // to avoid overwriting valid empty examples with schema-generated content + let hasExplicitExample = false + + if (mediaObject) { + // Priority: example > examples > generate from schema + if (mediaObject.example !== undefined) { + // Direct example on media object + hasExplicitExample = true + try { + stringifiedBody = + typeof mediaObject.example === "string" + ? mediaObject.example + : JSON.stringify(mediaObject.example, null, 2) + } catch (_e) { + stringifiedBody = "" + } + } else if ( + mediaObject.examples && + Object.keys(mediaObject.examples).length > 0 + ) { + // Examples object (OpenAPI v3 format) + const firstExampleKey = Object.keys(mediaObject.examples)[0] + const firstExample = mediaObject.examples[firstExampleKey] + + // Skip if this is an unresolved reference + if (firstExample && "$ref" in firstExample) { + // Reference wasn't dereferenced, fall through to schema generation + } else { + // Handle Example Object (with value property) or direct value + const exampleValue = + firstExample && "value" in firstExample + ? firstExample.value + : firstExample + + hasExplicitExample = true + try { + stringifiedBody = + typeof exampleValue === "string" + ? exampleValue + : JSON.stringify(exampleValue, null, 2) + } catch (_e) { + stringifiedBody = "" + } + } + } + + // Only stringify if an example was generated (undefined indicates failure, null and other values are valid) + if (!hasExplicitExample && mediaObject.schema) { + // Generate example from schema as fallback + try { + let generatedExample: string | number | boolean | null | object + if (isOpenAPIV31Document(doc)) { + generatedExample = generateV31Example( + mediaObject as OpenAPIV31.MediaTypeObject + ) + } else { + generatedExample = generateV3Example( + mediaObject as OpenAPIV3.MediaTypeObject + ) + } + + // Only stringify if we got a valid example (null is valid in OpenAPI v3.1) + if (generatedExample !== undefined) { + stringifiedBody = + typeof generatedExample === "string" + ? generatedExample + : JSON.stringify(generatedExample, null, 2) + } + } catch (_e) { + // If generation fails, leave body empty + stringifiedBody = "" + } + } + } + + res[name] = { + name, + status, + code, + headers, + body: stringifiedBody, + originalRequest, + } + } + + return res +} + +const parseOpenAPIV2Responses = ( + op: OpenAPIV2.OperationObject, + originalRequest: HoppRESTResponseOriginalRequest +): HoppRESTRequestResponses => { + const responses = op.responses + + if (!responses) return {} + + const res: HoppRESTRequestResponses = {} + + for (const [key, value] of Object.entries(responses)) { + const response = value as OpenAPIV2.ResponseObject + + // Get content type from examples or default to application/json + const contentType = Object.keys(response.examples ?? {})[0] + + const name = response.description ?? key + + const code = isNumeric(Number(key)) ? Number(key) : 200 + const status = getStatusCodeReasonPhrase(code) + + const headers: HoppRESTHeader[] = [ + { + key: "content-type", + value: contentType ?? "application/json", + description: "", + active: true, + }, + ] + + let stringifiedBody = "" + + // Priority: examples > generate from schema + if (response.examples && contentType) { + // Use the example for the content type + const exampleBody = response.examples[contentType] + try { + stringifiedBody = + typeof exampleBody === "string" + ? exampleBody + : JSON.stringify(exampleBody, null, 2) + } catch (_e) { + stringifiedBody = "" + } + } else if (response.schema) { + // Generate example from schema as fallback + try { + const generatedExample = generateV2ExampleFromSchemaObject( + response.schema + ) + if (generatedExample !== undefined) { + stringifiedBody = + typeof generatedExample === "string" + ? generatedExample + : JSON.stringify(generatedExample, null, 2) + } + } catch (_e) { + // If generation fails, leave body empty + stringifiedBody = "" + } + } + + res[name] = { + name, + status, + code, + headers, + body: stringifiedBody, + originalRequest, + } + } + + return res +} + +const parseOpenAPIResponses = ( + doc: OpenAPI.Document, + op: OpenAPIOperationType, + originalRequest: HoppRESTResponseOriginalRequest +): HoppRESTRequestResponses => + isOpenAPIV3Operation(doc, op) + ? parseOpenAPIV3Responses(doc, op, originalRequest) + : parseOpenAPIV2Responses(op, originalRequest) + +const parseOpenAPIHeaders = (params: OpenAPIParamsType[]): HoppRESTHeader[] => + pipe( + params, + + A.filterMap( + flow( + O.fromPredicate((param) => param.in === "header"), + O.map((header) => { + return { + key: header.name, + value: readParamExampleAsString(header), + active: true, + description: header.description ?? "", + } + }) + ) + ) + ) + +const parseOpenAPIV2Body = (op: OpenAPIV2.OperationObject): HoppRESTReqBody => { + const obj = (op.consumes ?? [])[0] as string | undefined + + // Not a content-type Hoppscotch supports + if (!obj || !(obj in knownContentTypes)) + return { contentType: null, body: null } + + // For form data types, extract form fields + if ( + obj === "multipart/form-data" || + obj === "application/x-www-form-urlencoded" + ) { + const formDataValues = pipe( + (op.parameters ?? []) as OpenAPIV2.Parameter[], + + A.filterMap( + flow( + O.fromPredicate((param) => param.in === "formData"), + O.map((param) => { + const isFile = param.type === "file" + // Files don't carry a default text value; for non-file fields, + // pull `default`/`example`/`x-example` so values round-trip. + // FormDataKeyValue's discriminated union requires `Blob[]` when + // `isFile: true` and `string` when `false`. + return (isFile + ? { key: param.name, isFile: true, value: [], active: true } + : { + key: param.name, + isFile: false, + value: readParamExampleAsString(param), + active: true, + }) + }) + ) + ) + ) + + return obj === "application/x-www-form-urlencoded" + ? { + contentType: obj, + body: formDataValues + .map(({ key, value }) => `${key}: ${value ?? ""}`) + .join("\n"), + } + : { contentType: obj, body: formDataValues } + } + + // For other content types (JSON, XML, etc.) + const bodyParam = (op.parameters ?? []).find( + (param) => (param as OpenAPIV2.Parameter).in === "body" + ) as OpenAPIV2.InBodyParameterObject | undefined + + if (bodyParam) { + const result = generateRequestBodyExampleFromOpenAPIV2Body(op) + if (result) { + return { + contentType: obj as any, + body: result, + } + } + } + + // Fallback to empty body for textual content types + return { contentType: obj as any, body: "" } +} + +const parseOpenAPIV3BodyFormData = ( + contentType: "multipart/form-data" | "application/x-www-form-urlencoded", + mediaObj: OpenAPIV3.MediaTypeObject | OpenAPIV31.MediaTypeObject +): HoppRESTReqBody => { + const schema = mediaObj.schema as + | OpenAPIV3.SchemaObject + | OpenAPIV31.SchemaObject + | undefined + + if (!schema || schema.type !== "object") { + return contentType === "application/x-www-form-urlencoded" + ? { contentType, body: "" } + : { contentType, body: [] } + } + + const properties = (schema.properties ?? {}) as Record< + string, + { format?: string; example?: unknown } + > + const keys = Object.keys(properties) + + if (contentType === "application/x-www-form-urlencoded") { + return { + contentType, + body: keys + .map((key) => { + const value = readParamExampleAsString(properties[key]) + return `${key}: ${value}` + }) + .join("\n"), + } + } + return { + contentType, + body: keys.map((key) => { + const prop = properties[key] + const isFile = prop?.format === "binary" + // FormDataKeyValue requires `Blob[]` for files and `string` for others. + if (isFile) { + return { key, isFile: true, value: [], active: true } + } + const value = readParamExampleAsString(prop) + return { + key, + isFile: false, + value, + active: true, + } + }), + } +} + +const parseOpenAPIV3Body = ( + doc: OpenAPI.Document, + op: OpenAPIV3.OperationObject | OpenAPIV31.OperationObject +): HoppRESTReqBody => { + const objs = Object.entries( + ( + op.requestBody as + | OpenAPIV3.RequestBodyObject + | OpenAPIV31.RequestBodyObject + | undefined + )?.content ?? {} + ) + + if (objs.length === 0) return { contentType: null, body: null } + + // We only take the first definition + const [contentType, media]: [ + string, + OpenAPIV3.MediaTypeObject | OpenAPIV31.MediaTypeObject, + ] = objs[0] + + if (!(contentType in knownContentTypes)) + return { contentType: null, body: null } + + // Handle form data types + if ( + contentType === "multipart/form-data" || + contentType === "application/x-www-form-urlencoded" + ) + return parseOpenAPIV3BodyFormData(contentType, media) + + // Binary uploads have no string body; the schema requires `File | null`. + if (contentType === "application/octet-stream") { + return { contentType, body: null } as HoppRESTReqBody + } + + // For other content types (JSON, XML, etc.), try to generate sample from schema + if (media.schema) { + try { + let sampleBody: string | number | boolean | null | object + if (isOpenAPIV31Document(doc)) { + sampleBody = generateV31Example(media as OpenAPIV31.MediaTypeObject) + } else { + sampleBody = generateV3Example(media as OpenAPIV3.MediaTypeObject) + } + + return { + contentType, + body: + typeof sampleBody === "string" + ? sampleBody + : JSON.stringify(sampleBody, null, 2), + } as HoppRESTReqBody + } catch (_e) { + // If we can't generate a sample, check for examples + if (media.example !== undefined) { + return { + contentType, + body: + typeof media.example === "string" + ? media.example + : JSON.stringify(media.example, null, 2), + } as HoppRESTReqBody + } + // Fallback to empty body + return { contentType, body: "" } as HoppRESTReqBody + } + } + + // Check for examples if no schema + if (media.example !== undefined) { + return { + contentType, + body: + typeof media.example === "string" + ? media.example + : JSON.stringify(media.example, null, 2), + } as HoppRESTReqBody + } + + // Check for examples array (OpenAPI v3 supports multiple examples) + if (media.examples && Object.keys(media.examples).length > 0) { + const firstExampleKey = Object.keys(media.examples)[0] + const firstExample = media.examples[firstExampleKey] + + // Skip if this is an unresolved reference + if (firstExample && "$ref" in firstExample) { + // Reference wasn't dereferenced, return empty body + return { contentType, body: "" } as HoppRESTReqBody + } + + // Handle Example Object (with value property) or direct value + const exampleValue = + "value" in firstExample ? firstExample.value : firstExample + + return { + contentType, + body: + typeof exampleValue === "string" + ? exampleValue + : JSON.stringify(exampleValue, null, 2), + } as HoppRESTReqBody + } + + // Fallback to empty body for textual content types + return { contentType, body: "" } as HoppRESTReqBody +} + +const isOpenAPIV3Operation = ( + doc: OpenAPI.Document, + op: OpenAPIOperationType +): op is OpenAPIV3.OperationObject | OpenAPIV31.OperationObject => + objectHasProperty(doc, "openapi") && + typeof doc.openapi === "string" && + doc.openapi.startsWith("3.") + +const isOpenAPIV31Document = ( + doc: OpenAPI.Document +): doc is OpenAPIV31.Document => + objectHasProperty(doc, "openapi") && + typeof doc.openapi === "string" && + doc.openapi.startsWith("3.1") + +const parseOpenAPIBody = ( + doc: OpenAPI.Document, + op: OpenAPIOperationType +): HoppRESTReqBody => + isOpenAPIV3Operation(doc, op) + ? parseOpenAPIV3Body(doc, op) + : parseOpenAPIV2Body(op) + +const resolveOpenAPIV3SecurityObj = ( + scheme: OpenAPIV3.SecuritySchemeObject | OpenAPIV31.SecuritySchemeObject, + _schemeData: string[] // Used for OAuth to pass params +): HoppRESTAuth => { + if (scheme.type === "http") { + if (scheme.scheme === "basic") { + // Basic + return { authType: "basic", authActive: true, username: "", password: "" } + } else if (scheme.scheme === "bearer") { + // Bearer + return { authType: "bearer", authActive: true, token: "" } + } + // Unknown/Unsupported Scheme + return { authType: "none", authActive: true } + } else if (scheme.type === "apiKey") { + if (scheme.in === "header") { + return { + authType: "api-key", + authActive: true, + addTo: "HEADERS", + key: scheme.name, + value: "", + } + } else if (scheme.in === "query") { + return { + authType: "api-key", + authActive: true, + addTo: "QUERY_PARAMS", + key: scheme.name, + value: "", + } + } + } else if (scheme.type === "oauth2") { + // NOTE: We select flow on a first come basis on this order, authorizationCode > implicit > password > clientCredentials + if (scheme.flows.authorizationCode) { + return { + authType: "oauth-2", + authActive: true, + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: scheme.flows.authorizationCode.authorizationUrl ?? "", + clientID: "", + scopes: _schemeData.join(" "), + token: "", + isPKCE: false, + tokenEndpoint: scheme.flows.authorizationCode.tokenUrl ?? "", + clientSecret: "", + authRequestParams: [], + refreshRequestParams: [], + tokenRequestParams: [], + }, + addTo: "HEADERS", + } + } else if (scheme.flows.implicit) { + return { + authType: "oauth-2", + authActive: true, + grantTypeInfo: { + grantType: "IMPLICIT", + authEndpoint: scheme.flows.implicit.authorizationUrl ?? "", + clientID: "", + token: "", + scopes: _schemeData.join(" "), + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: "HEADERS", + } + } else if (scheme.flows.password) { + return { + authType: "oauth-2", + authActive: true, + grantTypeInfo: { + grantType: "PASSWORD", + clientID: "", + authEndpoint: scheme.flows.password.tokenUrl, + clientSecret: "", + password: "", + username: "", + token: "", + scopes: _schemeData.join(" "), + refreshRequestParams: [], + tokenRequestParams: [], + }, + addTo: "HEADERS", + } + } else if (scheme.flows.clientCredentials) { + return { + authType: "oauth-2", + authActive: true, + grantTypeInfo: { + grantType: "CLIENT_CREDENTIALS", + authEndpoint: scheme.flows.clientCredentials.tokenUrl ?? "", + clientID: "", + clientSecret: "", + scopes: _schemeData.join(" "), + token: "", + clientAuthentication: "IN_BODY", + refreshRequestParams: [], + tokenRequestParams: [], + }, + addTo: "HEADERS", + } + } + return { + authType: "oauth-2", + authActive: true, + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: "", + clientID: "", + scopes: _schemeData.join(" "), + token: "", + isPKCE: false, + tokenEndpoint: "", + clientSecret: "", + authRequestParams: [], + refreshRequestParams: [], + tokenRequestParams: [], + }, + addTo: "HEADERS", + } + } else if (scheme.type === "openIdConnect") { + return { + authType: "oauth-2", + authActive: true, + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: "", + clientID: "", + scopes: _schemeData.join(" "), + token: "", + isPKCE: false, + tokenEndpoint: "", + clientSecret: "", + authRequestParams: [], + refreshRequestParams: [], + tokenRequestParams: [], + }, + addTo: "HEADERS", + } + } + + return { authType: "none", authActive: true } +} + +const resolveOpenAPIV3SecurityScheme = ( + doc: OpenAPIV3.Document | OpenAPIV31.Document, + schemeName: string, + schemeData: string[] +): HoppRESTAuth => { + const scheme = doc.components?.securitySchemes?.[schemeName] as + | OpenAPIV3.SecuritySchemeObject + | undefined + + if (!scheme) return { authType: "none", authActive: true } + return resolveOpenAPIV3SecurityObj(scheme, schemeData) +} + +const resolveOpenAPIV3Security = ( + doc: OpenAPIV3.Document | OpenAPIV31.Document, + security: + | OpenAPIV3.SecurityRequirementObject[] + | OpenAPIV31.SecurityRequirementObject[] +): HoppRESTAuth => { + // NOTE: Hoppscotch only considers the first security requirement + const sec = security[0] as OpenAPIV3.SecurityRequirementObject | undefined + + if (!sec) return { authType: "none", authActive: true } + + // NOTE: We only consider the first security condition within the first condition + const [schemeName, schemeData] = (Object.entries(sec)[0] ?? [ + undefined, + undefined, + ]) as [string | undefined, string[] | undefined] + + if (!schemeName || !schemeData) return { authType: "none", authActive: true } + + return resolveOpenAPIV3SecurityScheme(doc, schemeName, schemeData) +} + +/** + * Per-operation auth resolution. + * + * Returns ONLY the override for a single operation: + * - `op.security` undefined → `inherit` (defer to collection-level) + * - `op.security = []` → `none, active` (explicit opt-out per + * OpenAPI 3.1 §4.8.10: "an empty array + * can be used [to remove] a top-level + * security declaration") + * - `op.security = [{schemeA: …}]` → resolve `schemeA` + * + * The doc-level fallback used to live here. It now lives in + * `parseCollectionLevelAuthV3` so the collection-vs-request distinction + * round-trips correctly. + */ +const parseOpenAPIV3Auth = ( + doc: OpenAPIV3.Document | OpenAPIV31.Document, + op: OpenAPIV3.OperationObject | OpenAPIV31.OperationObject +): HoppRESTAuth => { + if (op.security === undefined) { + return { authType: "inherit", authActive: true } + } + if (op.security.length === 0) { + return { authType: "none", authActive: true } + } + return resolveOpenAPIV3Security(doc, op.security) +} + +/** + * Doc-level auth resolution. Mirrors the per-op logic but reads `doc.security` + * — gives the collection a single source of truth that all `inherit`-typed + * requests can defer to. + */ +const parseCollectionLevelAuthV3 = ( + doc: OpenAPIV3.Document | OpenAPIV31.Document +): HoppRESTAuth => { + if (doc.security === undefined) { + return { authType: "inherit", authActive: true } + } + if (doc.security.length === 0) { + return { authType: "none", authActive: true } + } + return resolveOpenAPIV3Security(doc, doc.security) +} + +const resolveOpenAPIV2SecurityScheme = ( + scheme: OpenAPIV2.SecuritySchemeObject, + _schemeData: string[] +): HoppRESTAuth => { + if (scheme.type === "basic") { + return { authType: "basic", authActive: true, username: "", password: "" } + } else if (scheme.type === "apiKey") { + // V2 only supports in: header and in: query + return { + authType: "api-key", + addTo: scheme.in === "header" ? "HEADERS" : "QUERY_PARAMS", + authActive: true, + key: scheme.name, + value: "", + } + } else if (scheme.type === "oauth2") { + // NOTE: We select flow on a first come basis on this order, accessCode > implicit > password > application + if (scheme.flow === "accessCode") { + return { + authType: "oauth-2", + authActive: true, + grantTypeInfo: { + authEndpoint: scheme.authorizationUrl ?? "", + clientID: "", + clientSecret: "", + grantType: "AUTHORIZATION_CODE", + scopes: _schemeData.join(" "), + token: "", + isPKCE: false, + tokenEndpoint: scheme.tokenUrl ?? "", + authRequestParams: [], + refreshRequestParams: [], + tokenRequestParams: [], + }, + addTo: "HEADERS", + } + } else if (scheme.flow === "implicit") { + return { + authType: "oauth-2", + authActive: true, + grantTypeInfo: { + authEndpoint: scheme.authorizationUrl ?? "", + clientID: "", + grantType: "IMPLICIT", + scopes: _schemeData.join(" "), + token: "", + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: "HEADERS", + } + } else if (scheme.flow === "application") { + return { + authType: "oauth-2", + authActive: true, + grantTypeInfo: { + authEndpoint: scheme.tokenUrl ?? "", + clientID: "", + clientSecret: "", + grantType: "CLIENT_CREDENTIALS", + scopes: _schemeData.join(" "), + token: "", + clientAuthentication: "IN_BODY", + refreshRequestParams: [], + tokenRequestParams: [], + }, + addTo: "HEADERS", + } + } else if (scheme.flow === "password") { + return { + authType: "oauth-2", + authActive: true, + grantTypeInfo: { + grantType: "PASSWORD", + authEndpoint: scheme.tokenUrl ?? "", + clientID: "", + clientSecret: "", + password: "", + scopes: _schemeData.join(" "), + token: "", + username: "", + refreshRequestParams: [], + tokenRequestParams: [], + }, + addTo: "HEADERS", + } + } + return { + authType: "oauth-2", + authActive: true, + grantTypeInfo: { + authEndpoint: "", + clientID: "", + clientSecret: "", + grantType: "AUTHORIZATION_CODE", + scopes: _schemeData.join(" "), + token: "", + isPKCE: false, + tokenEndpoint: "", + authRequestParams: [], + refreshRequestParams: [], + tokenRequestParams: [], + }, + addTo: "HEADERS", + } + } + + return { authType: "none", authActive: true } +} + +const resolveOpenAPIV2SecurityDef = ( + doc: OpenAPIV2.Document, + schemeName: string, + schemeData: string[] +): HoppRESTAuth => { + const scheme = Object.entries(doc.securityDefinitions ?? {}).find( + ([name]) => schemeName === name + ) + + if (!scheme) return { authType: "none", authActive: true } + + const schemeObj = scheme[1] + + return resolveOpenAPIV2SecurityScheme(schemeObj, schemeData) +} + +const resolveOpenAPIV2Security = ( + doc: OpenAPIV2.Document, + security: OpenAPIV2.SecurityRequirementObject[] +): HoppRESTAuth => { + // NOTE: Hoppscotch only considers the first security requirement + const sec = security[0] as OpenAPIV2.SecurityRequirementObject | undefined + + if (!sec) return { authType: "none", authActive: true } + + // NOTE: We only consider the first security condition within the first condition + const [schemeName, schemeData] = (Object.entries(sec)[0] ?? [ + undefined, + undefined, + ]) as [string | undefined, string[] | undefined] + + if (!schemeName || !schemeData) return { authType: "none", authActive: true } + + return resolveOpenAPIV2SecurityDef(doc, schemeName, schemeData) +} + +const parseOpenAPIV2Auth = ( + doc: OpenAPIV2.Document, + op: OpenAPIV2.OperationObject +): HoppRESTAuth => { + if (op.security === undefined) { + return { authType: "inherit", authActive: true } + } + if (op.security.length === 0) { + return { authType: "none", authActive: true } + } + return resolveOpenAPIV2Security(doc, op.security) +} + +const parseCollectionLevelAuthV2 = (doc: OpenAPIV2.Document): HoppRESTAuth => { + if (doc.security === undefined) { + return { authType: "inherit", authActive: true } + } + if (doc.security.length === 0) { + return { authType: "none", authActive: true } + } + return resolveOpenAPIV2Security(doc, doc.security) +} + +/** + * Resolves the doc-level (collection-level) auth from any supported OpenAPI + * version. Used once per imported document. + */ +const parseCollectionLevelAuth = (doc: OpenAPI.Document): HoppRESTAuth => { + if (objectHasProperty(doc, "swagger")) { + return parseCollectionLevelAuthV2(doc as OpenAPIV2.Document) + } + return parseCollectionLevelAuthV3( + doc as OpenAPIV3.Document | OpenAPIV31.Document + ) +} + +const parseOpenAPIAuth = ( + doc: OpenAPI.Document, + op: OpenAPIOperationType +): HoppRESTAuth => + isOpenAPIV3Operation(doc, op) + ? parseOpenAPIV3Auth(doc as OpenAPIV3.Document | OpenAPIV31.Document, op) + : parseOpenAPIV2Auth(doc as OpenAPIV2.Document, op) + +/** + * Resolve a doc-level base URL. + * + * Returns: + * - `prefix`: the string each endpoint is prefixed with. Always + * `<>` (Hoppscotch's collection-variable convention) so users get + * a single place to edit the host instead of a hardcoded URL repeated + * across every request. + * - `baseUrlValue`: the resolved real URL if the doc declared one (so the + * importer can seed a `baseUrl` collection variable with it), or `null` + * if the doc had no `servers`/`host` to learn from. + */ +const parseOpenAPIUrl = ( + doc: OpenAPI.Document | OpenAPIV2.Document | OpenAPIV3.Document +): { prefix: string; baseUrlValue: string | null } => { + const prefix = "<>" + + // OpenAPI V2 — host + basePath at the doc root, with `schemes` providing + // the protocol(s). Without a scheme prefix the value isn't a usable URL, + // so default to `https` (common practice for public APIs) when missing. + if (objectHasProperty(doc, "swagger")) { + const host = doc.host?.trim() ?? "" + const basePath = doc.basePath?.trim() ?? "" + if (!host && !basePath) return { prefix, baseUrlValue: null } + if (!host) { + // `basePath` alone isn't a URL — fabricating `https://` + // would produce a host like `https://v2`. Skip seeding a variable; + // instead append the basePath to the placeholder prefix so endpoints + // resolve as `<>//...` once the user fills in the + // host themselves. + const normalisedBase = basePath.startsWith("/") + ? basePath + : `/${basePath}` + return { prefix: `${prefix}${normalisedBase}`, baseUrlValue: null } + } + const scheme = doc.schemes?.[0] ?? "https" + return { + prefix, + baseUrlValue: `${scheme}://${host}${basePath}`, + } + } + + // OpenAPI V3 — first entry of `servers`. + if (objectHasProperty(doc, "servers")) { + const serverUrl = doc.servers?.[0]?.url + if (!serverUrl || serverUrl === "./") { + return { prefix, baseUrlValue: null } + } + // Don't seed a `baseUrl` variable whose value is itself a Hoppscotch + // placeholder (`<>`). That would create a variable referencing another + // variable, which is confusing and round-trips badly. The placeholder will + // simply pass through to the endpoint. + if (/^<<[a-zA-Z0-9_.-]+>>$/.test(serverUrl)) { + return { prefix: serverUrl, baseUrlValue: null } + } + return { prefix, baseUrlValue: serverUrl } + } + + return { prefix, baseUrlValue: null } +} + +const convertPathToHoppReqs = ( + doc: OpenAPI.Document, + pathName: string, + pathObj: OpenAPIPathInfoType +) => + pipe( + ["get", "head", "post", "put", "delete", "options", "patch"] as const, + + // Filter and map out path info + RA.filterMap( + flow( + O.fromPredicate((method) => !!pathObj[method]), + O.map((method) => ({ method, info: pathObj[method]! })) + ) + ), + + // Construct request object + RA.map(({ method, info }) => { + const { prefix: openAPIUrl } = parseOpenAPIUrl(doc) + const openAPIPath = replaceOpenApiPathTemplating(pathName) + + const endpoint = + openAPIUrl.endsWith("/") && openAPIPath.startsWith("/") + ? openAPIUrl + openAPIPath.slice(1) + : openAPIUrl + openAPIPath + + const res: { + request: HoppRESTRequest + metadata: { + tags: string[] + } + } = { + request: makeRESTRequest({ + name: getOpenAPIOperationName(info), + description: info.description ?? null, + method: method.toUpperCase(), + endpoint, + + // We don't need to worry about reference types as the Dereferencing pass should remove them + params: parseOpenAPIParams( + (info.parameters as OpenAPIParamsType[] | undefined) ?? [] + ), + headers: parseOpenAPIHeaders( + (info.parameters as OpenAPIParamsType[] | undefined) ?? [] + ), + + auth: parseOpenAPIAuth(doc, info), + + body: parseOpenAPIBody(doc, info), + + preRequestScript: "", + testScript: "", + + requestVariables: parseOpenAPIVariables( + (info.parameters as OpenAPIParamsType[] | undefined) ?? [] + ), + + responses: parseOpenAPIResponses( + doc, + info, + makeHoppRESTResponseOriginalRequest({ + name: getOpenAPIOperationName(info), + auth: parseOpenAPIAuth(doc, info), + body: parseOpenAPIBody(doc, info), + endpoint, + // We don't need to worry about reference types as the Dereferencing pass should remove them + params: parseOpenAPIParams( + (info.parameters as OpenAPIParamsType[] | undefined) ?? [] + ), + headers: parseOpenAPIHeaders( + (info.parameters as OpenAPIParamsType[] | undefined) ?? [] + ), + method: method.toUpperCase(), + requestVariables: parseOpenAPIVariables( + (info.parameters as OpenAPIParamsType[] | undefined) ?? [] + ), + }) + ), + }), + metadata: { + tags: info.tags ?? [], + }, + } + + return res + }), + + // Disable Readonly + RA.toArray + ) + +/** + * Hoppscotch's OpenAPI export encodes nested folders as `/`-separated tag paths + * (`API/Users` = folder `Users` inside folder `API`). On import we want to + * reverse that — but slash-as-nesting is a convention, not a spec feature, so + * we only split when the tag set actually looks hierarchical or the document + * was produced by Hoppscotch (carrying our explicit marker). + * + * Heuristic: split iff at least one pair of tags has a strict segment-prefix + * relationship (e.g. `API` and `API/Users`, or `API/Users` and `API/Posts` + * which both share `API`). Single tags with literal slashes like `OAuth/PKCE` + * are kept flat to avoid mis-importing third-party docs. + */ +const HOPP_FOLDER_TAGS_MARKER = "x-hoppscotch-folder-tags" +const HOPP_DROPPED_REQUESTS_MARKER = "x-hoppscotch-dropped-requests" +const HOPP_WORKSPACE_ROOT_MARKER = "x-hoppscotch-workspace-root" + +const docHasWorkspaceRootMarker = (doc: unknown): boolean => + typeof doc === "object" && + doc !== null && + (doc as Record)[HOPP_WORKSPACE_ROOT_MARKER] === true + +type DroppedRequestEntry = { + tagPath: string | null + request: unknown +} + +type OAuth2GrantTypeInfo = Extract< + HoppRESTAuth, + { authType: "oauth-2" } +>["grantTypeInfo"] + +const redactOAuth2RequestParams = (grantTypeInfo: OAuth2GrantTypeInfo) => { + const mutableGrantTypeInfo = grantTypeInfo as Record + + for (const key of [ + "authRequestParams", + "tokenRequestParams", + "refreshRequestParams", + ] as const) { + const params = mutableGrantTypeInfo[key] + if (!Array.isArray(params)) continue + mutableGrantTypeInfo[key] = params.map((param) => + typeof param === "object" && param !== null + ? { ...param, value: "" } + : param + ) + } +} + +const sanitizeImportedDroppedRequestAuth = ( + auth: HoppRESTAuth +): HoppRESTAuth => { + const redacted = cloneDeep(auth) + + switch (redacted.authType) { + case "none": + case "inherit": + return redacted + case "basic": + return { ...redacted, username: "", password: "" } + case "bearer": + return { ...redacted, token: "" } + case "api-key": + return { ...redacted, value: "" } + case "oauth-2": { + const grantTypeInfo = redacted.grantTypeInfo + grantTypeInfo.token = "" + if ("refreshToken" in grantTypeInfo) grantTypeInfo.refreshToken = "" + if ("clientSecret" in grantTypeInfo) grantTypeInfo.clientSecret = "" + if ("username" in grantTypeInfo) grantTypeInfo.username = "" + if ("password" in grantTypeInfo) grantTypeInfo.password = "" + redactOAuth2RequestParams(grantTypeInfo) + return redacted + } + case "aws-signature": + return { + ...redacted, + accessKey: "", + secretKey: "", + serviceToken: undefined, + signature: undefined, + } + case "digest": + return { + ...redacted, + username: "", + password: "", + nonce: "", + cnonce: "", + opaque: "", + } + case "hawk": + return { + ...redacted, + authId: "", + authKey: "", + user: undefined, + nonce: undefined, + ext: undefined, + app: undefined, + dlg: undefined, + timestamp: undefined, + } + case "akamai-eg": + return { + ...redacted, + accessToken: "", + clientToken: "", + clientSecret: "", + nonce: undefined, + timestamp: undefined, + } + case "jwt": + return { + ...redacted, + secret: "", + privateKey: "", + payload: "{}", + jwtHeaders: "{}", + } + } +} + +const sanitizeImportedDroppedRequestBody = ( + body: HoppRESTReqBody +): HoppRESTReqBody => { + if (body.contentType === "multipart/form-data") { + return { + ...body, + body: Array.isArray(body.body) + ? body.body + .filter((entry) => entry.active && entry.key) + .map((entry) => (entry.isFile ? { ...entry, value: "" } : entry)) + : [], + } + } + + if (body.contentType === "application/x-www-form-urlencoded") { + const bodyStr = typeof body.body === "string" ? body.body : "" + return { + ...body, + body: rawKeyValueEntriesToString( + parseRawKeyValueEntries(bodyStr).filter( + (entry) => entry.active && entry.key + ) + ), + } + } + + return body +} + +// Mirror the export-side sanitizer's header skip-set so untrusted dropped +// payloads can't restore headers the exporter would have filtered out. +const IMPORT_SKIP_HEADER_NAMES = new Set([ + "content-type", + "accept", + "authorization", + "cookie", +]) + +// Symmetric defense for imports: a crafted OpenAPI doc could embed arbitrary +// credentials/scripts in `x-hoppscotch-dropped-requests[].request`. Preserve +// auth mode semantics, but redact credentials and raw execution surfaces. +const sanitizeImportedDroppedRequest = ( + request: HoppRESTRequest +): HoppRESTRequest => ({ + ...request, + auth: sanitizeImportedDroppedRequestAuth(request.auth), + body: sanitizeImportedDroppedRequestBody(request.body), + params: request.params.filter((p) => p.active), + headers: request.headers.filter( + ({ key, active }) => + active && !IMPORT_SKIP_HEADER_NAMES.has(key.trim().toLowerCase()) + ), + requestVariables: request.requestVariables.filter((v) => v.active), + preRequestScript: "", + testScript: "", + responses: {}, +}) + +const readDroppedRequests = (doc: unknown): DroppedRequestEntry[] => { + if (typeof doc !== "object" || doc === null) return [] + const raw = (doc as Record)[HOPP_DROPPED_REQUESTS_MARKER] + if (!Array.isArray(raw)) return [] + return raw.filter((entry): entry is DroppedRequestEntry => { + if (typeof entry !== "object" || entry === null) return false + if (!("request" in entry)) return false + // Missing tagPath is intentional: restore at top level, same as explicit null. + if (!("tagPath" in entry)) return true + const { tagPath } = entry as DroppedRequestEntry + return typeof tagPath === "string" || tagPath === null + }) +} + +export const splitTagSegments = (tag: string): string[] => + tag + .split("/") + .map((s) => s.trim()) + .filter((s) => s.length > 0) + +const tagNameToFolderSegments = ( + tagName: string, + shouldSplit: boolean +): string[] => { + if (shouldSplit) return splitTagSegments(tagName) + return tagName.length > 0 ? [tagName] : [] +} + +export const hasSharedTagPathPrefix = (allTagNames: string[]): boolean => { + const splits = allTagNames.map(splitTagSegments).filter((s) => s.length > 0) + if (splits.length < 2) return false + + // NUL byte as the path-join separator — it cannot appear inside an + // OpenAPI tag segment, so two normalised paths only collide when their + // segment lists are identical. Written as the explicit "\u0000" escape + // so the source stays reviewable rather than carrying a raw NUL byte. + const sep = "\u0000" + const fullPaths = new Set(splits.map((segs) => segs.join(sep))) + + // (a) any tag has another tag as a strict ancestor + for (const segs of splits) { + for (let i = 1; i < segs.length; i++) { + if (fullPaths.has(segs.slice(0, i).join(sep))) return true + } + } + + // (b) two tags share a non-empty segment prefix + const seenPrefixes = new Set() + for (const segs of splits) { + for (let i = 1; i < segs.length; i++) { + const prefix = segs.slice(0, i).join(sep) + if (seenPrefixes.has(prefix)) return true + seenPrefixes.add(prefix) + } + } + + return false +} + +const docHasFolderTagsMarker = (doc: unknown): boolean => + typeof doc === "object" && + doc !== null && + (doc as Record)[HOPP_FOLDER_TAGS_MARKER] === "slash" + +/** + * Among the operation's tags, pick the single canonical placement: + * the deepest (longest) split-segments list, ties broken by first occurrence. + * Avoids placing a request in `API` when it's also tagged `API/Users`. + */ +export const pickRequestFolderSegments = ( + tags: string[], + shouldSplit: boolean +): string[] => { + const segLists = tags + .map((t) => tagNameToFolderSegments(t, shouldSplit)) + .filter((s) => s.length > 0) + if (segLists.length === 0) return [] + return segLists.reduce((best, cur) => (cur.length > best.length ? cur : best)) +} + +type FolderTreeNode = { + name: string + description: string | null + requests: HoppRESTRequest[] + children: Map +} + +const createFolderTreeNode = (name: string): FolderTreeNode => ({ + name, + description: null, + requests: [], + children: new Map(), +}) + +const getOrCreateFolderNode = ( + root: FolderTreeNode, + segments: string[] +): FolderTreeNode => { + let node = root + for (const seg of segments) { + let child = node.children.get(seg) + if (!child) { + child = createFolderTreeNode(seg) + node.children.set(seg, child) + } + node = child + } + return node +} + +const folderTreeNodeToCollection = (node: FolderTreeNode): HoppCollection => + makeCollection({ + name: node.name, + description: node.description, + requests: node.requests, + folders: [...node.children.values()].map(folderTreeNodeToCollection), + auth: { authType: "inherit", authActive: true }, + headers: [], + variables: [], + preRequestScript: "", + testScript: "", + }) + +const collectOpenApiTagNames = ( + doc: OpenAPI.Document, + paths: Array<{ metadata: { tags: string[] } }> +): Set => { + const names = new Set() + for (const { metadata } of paths) { + for (const t of metadata.tags) names.add(t) + } + if ("tags" in doc && Array.isArray(doc.tags)) { + for (const t of doc.tags) { + if (t && typeof t.name === "string") names.add(t.name) + } + } + return names +} + +const extractTagDescriptions = ( + doc: OpenAPI.Document +): Record => { + const result: Record = {} + if ("tags" in doc && Array.isArray(doc.tags)) { + for (const tag of doc.tags as Array<{ + name?: unknown + description?: unknown + }>) { + if (typeof tag.name === "string" && typeof tag.description === "string") { + result[tag.name] = tag.description + } + } + } + return result +} + +export const convertOpenApiDocsToHopp = ( + docs: OpenAPI.Document[] +): TE.TaskEither => { + // checking for unresolved references before conversion + for (const doc of docs) { + if (hasUnresolvedRefs(doc)) { + console.warn( + "Document contains unresolved references which may affect import quality" + ) + // continue anyway to provide a best-effort import + } + } + + const collections = docs.flatMap((doc) => { + const paths = Object.entries(doc.paths ?? {}).flatMap( + ([pathName, pathObj]) => convertPathToHoppReqs(doc, pathName, pathObj) + ) + const allTagNames = collectOpenApiTagNames(doc, paths) + const tagDescriptions = extractTagDescriptions(doc) + const { baseUrlValue } = parseOpenAPIUrl(doc) + + // Decide whether to interpret `/` as folder nesting: either an explicit + // marker from a Hoppscotch export, or the tag set has clear hierarchical + // structure. + const shouldSplitTags = + docHasFolderTagsMarker(doc) || hasSharedTagPathPrefix([...allTagNames]) + + const root = createFolderTreeNode("") + const requestsWithoutTags: HoppRESTRequest[] = [] + + // Apply tag descriptions at their (possibly nested) folder level. + for (const tagName of allTagNames) { + const desc = tagDescriptions[tagName] + if (!desc) continue + const segs = tagNameToFolderSegments(tagName, shouldSplitTags) + if (segs.length === 0) continue + const node = getOrCreateFolderNode(root, segs) + // First description wins if multiple tags collapse to the same path + // (e.g. `API//Users` and `API/Users` after normalization). + if (node.description === null) node.description = desc + } + + // Place each operation at its canonical folder. Multi-tag operations land + // at one location only (deepest tag); duplicating across folders would + // break edit semantics on subsequent re-export. + for (const { metadata, request } of paths) { + if (metadata.tags.length === 0) { + requestsWithoutTags.push(request) + continue + } + const targetSegs = pickRequestFolderSegments( + metadata.tags, + shouldSplitTags + ) + if (targetSegs.length === 0) { + requestsWithoutTags.push(request) + continue + } + const node = getOrCreateFolderNode(root, targetSegs) + node.requests.push(cloneDeep(request)) + } + + // Rehydrate requests stashed because OpenAPI cannot represent duplicate (path, method). + for (const entry of readDroppedRequests(doc)) { + const parsed = HoppRESTRequest.safeParse(entry.request) + // Skip malformed entries rather than restoring a silent default request. + if (parsed.type !== "ok") continue + const restored = sanitizeImportedDroppedRequest(parsed.value) + // Match kept-request parameterisation so duplicates share the <> form. + if (baseUrlValue && restored.endpoint.startsWith(baseUrlValue)) { + restored.endpoint = + "<>" + restored.endpoint.slice(baseUrlValue.length) + } + if (entry.tagPath) { + const segs = tagNameToFolderSegments(entry.tagPath, shouldSplitTags) + if (segs.length === 0) { + requestsWithoutTags.push(restored) + continue + } + const node = getOrCreateFolderNode(root, segs) + node.requests.push(restored) + } else { + requestsWithoutTags.push(restored) + } + } + + // Seed a `baseUrl` collection variable from the doc's resolved server URL + // so users have one place to switch hosts (staging/prod/local) instead of + // every endpoint hardcoding a literal URL. + // + // `currentValue` is left empty to match the convention used by the other + // importers (postman, insomnia): runtime current values live in + // `CurrentValueService` and are local-only; only `initialValue` is + // persisted server-side. + const variables = + baseUrlValue !== null + ? [ + { + key: "baseUrl", + initialValue: baseUrlValue, + currentValue: "", + secret: false, + }, + ] + : [] + + const importedCollection = makeCollection({ + name: doc.info.title, + description: doc.info.description ?? null, + folders: [...root.children.values()].map(folderTreeNodeToCollection), + requests: requestsWithoutTags, + // Read doc-level security ONCE here so the collection-vs-request + // structure mirrors the OpenAPI two-layer model. Per-operation reads + // (in parseOpenAPIV3Auth / parseOpenAPIV2Auth) only return overrides; + // requests with no override get `inherit` and defer to this value. + auth: parseCollectionLevelAuth(doc), + headers: [], + variables, + preRequestScript: "", + testScript: "", + }) + + // Unwrap only Hoppscotch workspace wrappers, not arbitrary folder-only docs. + if ( + docHasWorkspaceRootMarker(doc) && + importedCollection.requests.length === 0 && + importedCollection.folders.length > 0 + ) { + // Preserve wrapper variables/auth so <> and doc.security survive unwrapping. + // Clone per child so reactive mutations on one collection don't leak to siblings. + const wrapperVariables = importedCollection.variables + const wrapperAuth = importedCollection.auth + return importedCollection.folders.map((child) => ({ + ...child, + variables: cloneDeep(wrapperVariables), + auth: cloneDeep(wrapperAuth), + })) + } + return [importedCollection] + }) + + return TE.of(collections) +} + +const parseOpenAPIDocContent = (str: string) => + pipe( + str, + safeParseJSON, + O.match( + () => safeParseYAML(str), + (data) => O.of(data) + ) + ) + +export const hoppOpenAPIImporter = (fileContents: string[]) => + pipe( + // See if we can parse JSON properly + fileContents, + A.traverse(O.Applicative)(parseOpenAPIDocContent), + TE.fromOption(() => { + return IMPORTER_INVALID_FILE_FORMAT + }), + // Try validating, else the importer is invalid file format + TE.chainW((docArr) => { + return pipe( + TE.tryCatch( + async () => { + const resultDoc = [] + + for (const docObj of docArr) { + try { + // More lenient check - if it has paths, we'll try to import it + const isValidOpenAPISpec = + objectHasProperty(docObj, "paths") && + (isOpenAPIV2Document(docObj) || + isOpenAPIV3Document(docObj) || + objectHasProperty(docObj, "info")) + + if (!isValidOpenAPISpec) { + throw new Error("INVALID_OPENAPI_SPEC") + } + + try { + const validatedDoc = await validateDocs(docObj) + resultDoc.push(validatedDoc) + } catch (validationError) { + // If validation fails but it has basic OpenAPI structure, add it anyway + if (objectHasProperty(docObj, "paths")) { + resultDoc.push(docObj as OpenAPI.Document) + } else { + throw validationError + } + } + } catch (err) { + if ( + err instanceof Error && + err.message === "INVALID_OPENAPI_SPEC" + ) { + throw new Error("INVALID_OPENAPI_SPEC") + } + + if ( + // @ts-expect-error the type for err is not exported from the library + err.files && + // @ts-expect-error the type for err is not exported from the library + err.files instanceof SwaggerParser && + // @ts-expect-error the type for err is not exported from the library + err.files.schema + ) { + // @ts-expect-error the type for err is not exported from the library + resultDoc.push(err.files.schema) + } + } + } + return resultDoc + }, + () => { + return IMPORTER_INVALID_FILE_FORMAT + } + ) + ) + }), + // Deference the references + TE.chainW((docArr) => + pipe( + TE.tryCatch( + async () => { + const resultDoc = [] + + for (const docObj of docArr) { + try { + const validatedDoc = await dereferenceDocs(docObj) + resultDoc.push(validatedDoc) + } catch (_error) { + // Check if the document has unresolved references + if (hasUnresolvedRefs(docObj)) { + console.warn( + "Document contains unresolved references which may affect import quality" + ) + } + + // If dereferencing fails, use the original document + resultDoc.push(docObj) + } + } + + return resultDoc + }, + () => { + return OPENAPI_DEREF_ERROR + } + ) + ) + ), + TE.chainW(convertOpenApiDocsToHopp) + ) + +const validateDocs = (docs: any): Promise => { + return new Promise((resolve, reject) => { + worker.postMessage({ + type: "validate", + docs, + }) + + worker.onmessage = (event) => { + if (event.data.type === "VALIDATION_RESULT") { + if (E.isLeft(event.data.data)) { + reject("COULD_NOT_VALIDATE") + } else { + resolve(event.data.data.right as OpenAPI.Document) + } + } + } + }) +} + +const dereferenceDocs = (docs: any): Promise => { + return new Promise((resolve, reject) => { + worker.postMessage({ + type: "dereference", + docs, + }) + + worker.onmessage = (event) => { + if (event.data.type === "DEREFERENCE_RESULT") { + if (E.isLeft(event.data.data)) { + reject("COULD_NOT_DEREFERENCE") + } else { + resolve(event.data.data.right as OpenAPI.Document) + } + } + } + }) +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts b/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts new file mode 100644 index 0000000..8f3f2c5 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts @@ -0,0 +1,713 @@ +import { + FormDataKeyValue, + HoppCollection, + HoppRESTAuth, + HoppRESTHeader, + HoppRESTParam, + HoppRESTReqBody, + HoppRESTRequest, + HoppRESTRequestVariable, + knownContentTypes, + makeCollection, + makeRESTRequest, + ValidContentTypes, + HoppRESTRequestResponses, + makeHoppRESTResponseOriginalRequest, + HoppCollectionVariable, +} from "@hoppscotch/data" +import * as A from "fp-ts/Array" +import { pipe } from "fp-ts/function" +import * as O from "fp-ts/Option" +import * as S from "fp-ts/string" +import * as TE from "fp-ts/TaskEither" +import { + DescriptionDefinition, + Item, + ItemGroup, + Collection as PMCollection, + QueryParam, + RequestAuthDefinition, + Variable, + VariableDefinition, + VariableList, +} from "postman-collection" +import { stringArrayJoin } from "~/helpers/functional/array" +import { PMRawLanguage } from "~/types/pm-coll-exts" +import { IMPORTER_INVALID_FILE_FORMAT } from "." + +const safeParseJSON = (jsonStr: string) => O.tryCatch(() => JSON.parse(jsonStr)) + +const isPMItem = (x: unknown): x is Item => Item.isItem(x) + +/** + * Checks if the Postman collection schema version supports scripts (v2.0+) + * @param schema - The schema URL from collection.info.schema + * @returns true if v2.0 or v2.1, false otherwise + */ +const isSchemaVersionSupported = (schema?: string): boolean => { + if (!schema) return false + // Support both schema.getpostman.com and schema.postman.com + return schema.includes("/v2.0.") || schema.includes("/v2.1.") +} + +/** + * Extracts the collection schema from raw JSON data + * Note: PMCollection SDK doesn't expose .info.schema, so we parse raw JSON + */ +const getCollectionSchema = (jsonStr: string): string | null => { + try { + const data = JSON.parse(jsonStr) + return data?.info?.schema ?? null + } catch { + return null + } +} + +export const replacePMVarTemplating = (value: unknown): string => { + const str = typeof value === "string" ? value : String(value ?? "") + return pipe(str, S.replace(/{{\s*/g, "<<"), S.replace(/\s*}}/g, ">>")) +} + +const PMRawLanguageOptionsToContentTypeMap: Record< + PMRawLanguage, + ValidContentTypes +> = { + text: "text/plain", + javascript: "text/plain", + json: "application/json", + html: "text/html", + xml: "application/xml", +} + +const isPMItemGroup = (x: unknown): x is ItemGroup => + ItemGroup.isItemGroup(x) + +const readPMCollection = (def: string) => + pipe( + def, + safeParseJSON, + O.chain((data) => + O.tryCatch(() => { + return new PMCollection(data) + }) + ) + ) + +const parseDescription = (descField?: string | DescriptionDefinition) => { + if (!descField) { + return "" + } + + if (typeof descField === "string") { + return descField + } + + return descField.content +} + +const getHoppCollVariables = ( + ig: ItemGroup +): HoppCollectionVariable[] => { + if (!("variables" in ig && ig.variables)) { + return [] + } + + return pipe( + (ig.variables as VariableList).all(), + A.filter( + (variable) => + variable.key !== undefined && + variable.key !== null && + variable.key.length > 0 + ), + A.map((variable) => { + // Postman 12+ uses `secret: true`; older exports use `type: "secret"`. + // The SDK's types don't surface the new flag, so read it off raw. + const isSecret = + variable.type === "secret" || + (variable as { secret?: boolean }).secret === true + + return { + key: replacePMVarTemplating(variable.key ?? ""), + initialValue: replacePMVarTemplating(variable.value ?? ""), + currentValue: "", + secret: isSecret, + } + }) + ) +} + +const getHoppReqHeaders = ( + headers: Item["request"]["headers"] | null +): HoppRESTHeader[] => { + if (!headers) return [] + return pipe( + headers.all(), + A.map((header) => { + const description = parseDescription(header.description) + + return { + key: replacePMVarTemplating(header.key), + value: replacePMVarTemplating(header.value), + active: !header.disabled, + description, + } + }) + ) +} + +const getHoppReqParams = ( + query: Item["request"]["url"]["query"] | null +): HoppRESTParam[] => { + { + if (!query) return [] + return pipe( + query.all(), + A.filter( + (param): param is QueryParam & { key: string } => + param.key !== undefined && param.key !== null && param.key.length > 0 + ), + A.map((param) => { + const description = parseDescription(param.description) + + return { + key: replacePMVarTemplating(param.key), + value: replacePMVarTemplating(param.value ?? ""), + active: !param.disabled, + description, + } + }) + ) + } +} + +const getHoppReqVariables = ( + variables: Item["request"]["url"]["variables"] | null +): HoppRESTRequestVariable[] => { + { + if (!variables) return [] + return pipe( + variables.all(), + A.filter( + (variable): variable is Variable => + variable.key !== undefined && + variable.key !== null && + variable.key.length > 0 + ), + A.map((variable) => { + return { + key: replacePMVarTemplating(variable.key ?? ""), + value: replacePMVarTemplating(variable.value ?? ""), + active: !variable.disabled, + } + }) + ) + } +} + +// This regex is used to remove unsupported unicode characters from the response body which fails in Prisma +// https://dba.stackexchange.com/questions/115029/unicode-error-with-u0000-on-copy-of-large-json-file-into-postgres +const UNSUPPORTED_UNICODES_REGEX = /[\u0000]/g + +const getHoppResponseBody = ( + body: string | ArrayBuffer | undefined +): string => { + if (!body) return "" + if (typeof body === "string") + return body.replace(UNSUPPORTED_UNICODES_REGEX, "") + if (body instanceof ArrayBuffer) { + return new TextDecoder() + .decode(body) + .replace(UNSUPPORTED_UNICODES_REGEX, "") + } + + return "" +} + +const getHoppResponses = ( + responses: Item["responses"] +): HoppRESTRequestResponses => { + return Object.fromEntries( + pipe( + responses.all(), + A.map((response) => { + const res = { + name: response.name, + status: response.status, + body: getHoppResponseBody(response.body), + headers: getHoppReqHeaders(response.headers), + code: response.code, + originalRequest: makeHoppRESTResponseOriginalRequest({ + auth: getHoppReqAuth(response.originalRequest?.auth), + body: getHoppReqBody({ + body: response.originalRequest?.body, + headers: response.originalRequest?.headers ?? null, + }) ?? { contentType: null, body: null }, + endpoint: getHoppReqURL(response.originalRequest?.url ?? null), + headers: getHoppReqHeaders( + response.originalRequest?.headers ?? null + ), + method: response.originalRequest?.method ?? "", + name: response.originalRequest?.name ?? response.name, + params: getHoppReqParams( + response.originalRequest?.url.query ?? null + ), + requestVariables: getHoppReqVariables( + response.originalRequest?.url.variables ?? null + ), + }), + } + return [response.name, res] + }) + ) + ) +} + +type PMRequestAuthDef< + AuthType extends RequestAuthDefinition["type"] = + RequestAuthDefinition["type"], +> = AuthType extends RequestAuthDefinition["type"] & string + ? // eslint-disable-next-line no-unused-vars + { type: AuthType } & { [x in AuthType]: VariableDefinition[] } + : { type: AuthType } + +const getVariableValue = (defs: VariableDefinition[], key: string) => + defs.find((param) => param.key === key)?.value as string | undefined + +const getHoppReqAuth = ( + hoppAuth: Item["request"]["auth"] | null +): HoppRESTAuth => { + if (!hoppAuth) return { authType: "inherit", authActive: true } + + const auth = hoppAuth as unknown as PMRequestAuthDef + + if (auth.type === "noauth") { + return { authType: "none", authActive: true } + } + + if (auth.type === "basic") { + return { + authType: "basic", + authActive: true, + username: replacePMVarTemplating( + getVariableValue(auth.basic, "username") ?? "" + ), + password: replacePMVarTemplating( + getVariableValue(auth.basic, "password") ?? "" + ), + } + } else if (auth.type === "apikey") { + return { + authType: "api-key", + authActive: true, + key: replacePMVarTemplating(getVariableValue(auth.apikey, "key") ?? ""), + value: replacePMVarTemplating( + getVariableValue(auth.apikey, "value") ?? "" + ), + addTo: + getVariableValue(auth.apikey, "in") === "query" + ? "QUERY_PARAMS" + : "HEADERS", + } + } else if (auth.type === "bearer") { + return { + authType: "bearer", + authActive: true, + token: replacePMVarTemplating( + getVariableValue(auth.bearer, "token") ?? "" + ), + } + } else if (auth.type === "oauth2") { + const accessTokenURL = replacePMVarTemplating( + getVariableValue(auth.oauth2, "accessTokenUrl") ?? "" + ) + const authURL = replacePMVarTemplating( + getVariableValue(auth.oauth2, "authUrl") ?? "" + ) + const clientId = replacePMVarTemplating( + getVariableValue(auth.oauth2, "clientId") ?? "" + ) + const scope = replacePMVarTemplating( + getVariableValue(auth.oauth2, "scope") ?? "" + ) + const token = replacePMVarTemplating( + getVariableValue(auth.oauth2, "accessToken") ?? "" + ) + const clientSecret = replacePMVarTemplating( + getVariableValue(auth.oauth2, "clientSecret") ?? "" + ) + + // Check for PKCE settings + const usePkce = getVariableValue(auth.oauth2, "usePkce") + const isPKCE = usePkce === "true" + + // Get challenge algorithm, default to S256 if PKCE is enabled but no algorithm specified + const challengeAlgorithm = getVariableValue( + auth.oauth2, + "challengeAlgorithm" + ) + let codeVerifierMethod: "plain" | "S256" | undefined + + if (isPKCE) { + // Postman uses "SHA-256" or "plain" - normalize to our format + // Default to S256 for any value other than "plain" + if (challengeAlgorithm === "plain") { + codeVerifierMethod = "plain" + } else { + // Covers "S256", "SHA-256", undefined, and any other value + codeVerifierMethod = "S256" + } + } + + return { + authType: "oauth-2", + authActive: true, + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: authURL, + clientID: clientId, + scopes: scope, + token: token, + tokenEndpoint: accessTokenURL, + clientSecret: clientSecret, + isPKCE: isPKCE, + ...(codeVerifierMethod ? { codeVerifierMethod } : {}), + authRequestParams: [], + tokenRequestParams: [], + refreshRequestParams: [], + }, + addTo: "HEADERS", + } + } + + return { authType: "inherit", authActive: true } +} + +const getHoppReqBody = ({ + body, + headers, +}: { + body: Item["request"]["body"] | null + headers: Item["request"]["headers"] | null +}): HoppRESTReqBody => { + if (!body) return { contentType: null, body: null } + + if (body.mode === "formdata") { + return { + contentType: "multipart/form-data", + body: pipe( + body.formdata?.all() ?? [], + A.map( + (param) => + { + key: replacePMVarTemplating(param.key), + value: replacePMVarTemplating( + param.type === "text" ? String(param.value) : "" + ), + active: !param.disabled, + isFile: false, // TODO: Preserve isFile state ? + } + ) + ), + } + } else if (body.mode === "urlencoded") { + return { + contentType: "application/x-www-form-urlencoded", + body: pipe( + body.urlencoded?.all() ?? [], + A.map( + (param) => + `${replacePMVarTemplating( + param.key ?? "" + )}: ${replacePMVarTemplating(String(param.value ?? ""))}` + ), + stringArrayJoin("\n") + ), + } + } else if (body.mode === "raw") { + return pipe( + O.Do, + + // Extract content-type + O.bind("contentType", () => + pipe( + // Get the info from the content-type header + getHoppReqHeaders(headers), + A.findFirst(({ key }) => key.toLowerCase() === "content-type"), + O.map((x) => x.value), + + // Make sure it is a content-type Hopp can work with + O.filter( + (contentType): contentType is ValidContentTypes => + contentType in knownContentTypes + ), + + // Back-up plan, assume language from raw language definition + O.alt(() => + pipe( + body.options?.raw?.language, + O.fromNullable, + O.map((lang) => PMRawLanguageOptionsToContentTypeMap[lang]) + ) + ), + + // If that too failed, just assume "text/plain" + O.getOrElse((): ValidContentTypes => "text/plain"), + + O.of + ) + ), + + // Extract and parse body + O.bind("body", () => + pipe(body.raw, O.fromNullable, O.map(replacePMVarTemplating)) + ), + + // Return null content-type if failed, else return parsed + O.match( + () => + { + contentType: null, + body: null, + }, + ({ contentType, body }) => + { + contentType, + body, + } + ) + ) + } else if (body.mode === "graphql") { + const formattedQuery = { + // @ts-expect-error - this is a valid option, but seems like the types are not updated + query: body.graphql?.query, + variables: pipe( + // @ts-expect-error - this is a valid option, but seems like the types are not updated + body.graphql?.variables, + safeParseJSON, + O.getOrElse(() => undefined) + ), + } + + return { + contentType: "application/json", + body: pipe( + JSON.stringify(formattedQuery, null, 2), + replacePMVarTemplating + ), + } + } + + // TODO: File + + return { contentType: null, body: null } +} + +const getHoppReqURL = (url: Item["request"]["url"] | null): string => { + if (!url) return "" + return pipe( + url.toString(false), + S.replace(/\?.+/g, ""), + replacePMVarTemplating + ) +} + +/** + * Extracts script content from a Postman event + * Handles both string format and exec array format + */ +const extractScriptFromEvent = (event: any): string => { + if (!event?.script) return "" + + if (typeof event.script === "string") { + return event.script + } + + if (event.script.exec && Array.isArray(event.script.exec)) { + return event.script.exec.join("\n") + } + + return "" +} + +const getHoppScripts = ( + item: Item, + importScripts: boolean +): { preRequestScript: string; testScript: string } => { + if (!importScripts) { + return { preRequestScript: "", testScript: "" } + } + + let preRequestScript = "" + let testScript = "" + + // Postman stores scripts in the events array + if (item.events) { + const events = item.events.all() + events.forEach((event: any) => { + if (event.listen === "prerequest") { + preRequestScript = extractScriptFromEvent(event) + } else if (event.listen === "test") { + testScript = extractScriptFromEvent(event) + } + }) + } + + return { preRequestScript, testScript } +} + +/** + * Extracts pre-request and test scripts from a Postman ItemGroup (collection/folder) + * Postman collections and folders can have their own scripts that run before/after all requests + */ +const getHoppCollectionScripts = ( + ig: ItemGroup, + importScripts: boolean +): { preRequestScript: string; testScript: string } => { + if (!importScripts) { + return { preRequestScript: "", testScript: "" } + } + + let preRequestScript = "" + let testScript = "" + + // ItemGroup (collection/folder) stores scripts in the events property + if (ig.events) { + const events = ig.events.all() + events.forEach((event: any) => { + if (event.listen === "prerequest") { + preRequestScript = extractScriptFromEvent(event) + } else if (event.listen === "test") { + testScript = extractScriptFromEvent(event) + } + }) + } + + return { preRequestScript, testScript } +} + +const getCollectionDescription = ( + docField?: string | DescriptionDefinition +): string | null => { + if (!docField) { + return null + } + + if (typeof docField === "string") { + return docField + } else if (typeof docField === "object" && "content" in docField) { + return docField.content || null + } + + return null +} + +const getRequestDescription = ( + docField?: string | DescriptionDefinition +): string | null => { + if (!docField) { + return null + } + + if (typeof docField === "string") { + return docField + } else if (typeof docField === "object" && "content" in docField) { + return docField.content || null + } + + return null +} + +const getHoppRequest = ( + item: Item, + importScripts: boolean +): HoppRESTRequest => { + const { preRequestScript, testScript } = getHoppScripts(item, importScripts) + return makeRESTRequest({ + name: item.name, + endpoint: getHoppReqURL(item.request.url), + method: item.request.method.toUpperCase(), + headers: getHoppReqHeaders(item.request.headers), + params: getHoppReqParams(item.request.url.query), + auth: getHoppReqAuth(item.request.auth), + body: getHoppReqBody({ + body: item.request.body, + headers: item.request.headers, + }), + requestVariables: getHoppReqVariables(item.request.url.variables), + responses: getHoppResponses(item.responses), + preRequestScript, + testScript, + description: getRequestDescription(item.request.description), + }) +} + +const getHoppFolder = ( + ig: ItemGroup, + importScripts: boolean +): HoppCollection => { + const { preRequestScript, testScript } = getHoppCollectionScripts( + ig, + importScripts + ) + + return makeCollection({ + name: ig.name, + folders: pipe( + ig.items.all(), + A.filter(isPMItemGroup), + A.map((folder) => getHoppFolder(folder, importScripts)) + ), + requests: pipe( + ig.items.all(), + A.filter(isPMItem), + A.map((item) => getHoppRequest(item, importScripts)) + ), + auth: getHoppReqAuth(ig.auth), + headers: [], + variables: getHoppCollVariables(ig), + description: getCollectionDescription(ig.description), + preRequestScript, + testScript, + }) +} + +export const getHoppCollections = ( + collections: PMCollection[], + importScripts: boolean +) => { + return collections.map((collection) => + getHoppFolder(collection, importScripts) + ) +} + +export const hoppPostmanImporter = ( + fileContents: string[], + importScripts = false +) => + pipe( + // Try reading + fileContents, + A.traverse(O.Applicative)(readPMCollection), + + O.map((collections) => { + // Validate schema version if importing scripts + if (importScripts && fileContents.length > 0) { + const schema = getCollectionSchema(fileContents[0]) + const isSupported = isSchemaVersionSupported(schema ?? undefined) + + if (!isSupported) { + console.warn( + `[Postman Import] Script import requested but collection schema "${schema ?? "unknown"}" does not support scripts. ` + + `Only Postman Collection Format v2.0 and v2.1 are supported. Scripts will be skipped.` + ) + // Skip script import for unsupported versions + return getHoppCollections(collections, false) + } + } + + return getHoppCollections(collections, importScripts) + }), + + TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT) + ) diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/postmanEnv.ts b/packages/hoppscotch-common/src/helpers/import-export/import/postmanEnv.ts new file mode 100644 index 0000000..76c85a5 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/postmanEnv.ts @@ -0,0 +1,70 @@ +import { Environment, EnvironmentSchemaVersion } from "@hoppscotch/data" +import * as O from "fp-ts/Option" +import * as TE from "fp-ts/TaskEither" +import { z } from "zod" + +import { safeParseJSON } from "~/helpers/functional/json" +import { IMPORTER_INVALID_FILE_FORMAT } from "." +import { uniqueID } from "~/helpers/utils/uniqueID" +import { replacePMVarTemplating } from "./postman" + +const postmanEnvSchema = z.object({ + name: z.string(), + values: z.array( + z.object({ + key: z.string(), + value: z.string(), + type: z.string(), + // Postman 12+ uses `secret: true`; older exports use `type: "secret"`. + secret: z.boolean().optional(), + }) + ), +}) + +type PostmanEnv = z.infer + +export const postmanEnvImporter = (contents: string[]) => { + const parsedContents = contents.map((str) => safeParseJSON(str, true)) + if (parsedContents.some((parsed) => O.isNone(parsed))) { + return TE.left(IMPORTER_INVALID_FILE_FORMAT) + } + + const parsedValues = parsedContents.flatMap((parsed) => { + const unwrappedEntry = O.toNullable(parsed) as PostmanEnv[] | null + + if (unwrappedEntry) { + return unwrappedEntry.map((entry) => ({ + ...entry, + values: entry.values?.map((valueEntry) => ({ + ...valueEntry, + value: String(valueEntry.value), + type: String(valueEntry.type), + })), + })) + } + return null + }) + + const validationResult = z.array(postmanEnvSchema).safeParse(parsedValues) + + if (!validationResult.success) { + return TE.left(IMPORTER_INVALID_FILE_FORMAT) + } + + // Treat as secret on legacy `type: "secret"` OR Postman 12+ `secret: true`. + const environments: Environment[] = validationResult.data.map( + ({ name, values }) => ({ + id: uniqueID(), + v: EnvironmentSchemaVersion, + name, + variables: values.map(({ key, value, type, secret }) => ({ + key, + initialValue: replacePMVarTemplating(value), + currentValue: replacePMVarTemplating(value), + secret: type === "secret" || secret === true, + })), + }) + ) + + return TE.right(environments) +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/workers/openapi-import-worker.ts b/packages/hoppscotch-common/src/helpers/import-export/import/workers/openapi-import-worker.ts new file mode 100644 index 0000000..b41594e --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/workers/openapi-import-worker.ts @@ -0,0 +1,52 @@ +import { Buffer } from "buffer" +import process from "process" + +// Set up global shims for the swagger-parser library +self.Buffer = Buffer +self.global = self +self.process = process + +import SwaggerParser from "@apidevtools/swagger-parser" +import * as E from "fp-ts/Either" + +const validateDocs = async (docs: any) => { + try { + const res = await SwaggerParser.validate(docs, { + continueOnError: true, + }) + + return E.right(res) + } catch (_error) { + return E.left("COULD_NOT_VALIDATE" as const) + } +} + +const dereferenceDocs = async (docs: any) => { + try { + const res = await SwaggerParser.dereference(docs) + + return E.right(res) + } catch (_error) { + return E.left("COULD_NOT_DEREFERENCE" as const) + } +} + +self.addEventListener("message", async (event) => { + if (event.data.type === "validate") { + const res = await validateDocs(event.data.docs) + + self.postMessage({ + type: "VALIDATION_RESULT", + data: res, + }) + } + + if (event.data.type === "dereference") { + const res = await dereferenceDocs(event.data.docs) + + self.postMessage({ + type: "DEREFERENCE_RESULT", + data: res, + }) + } +}) diff --git a/packages/hoppscotch-common/src/helpers/isValidUser.ts b/packages/hoppscotch-common/src/helpers/isValidUser.ts new file mode 100644 index 0000000..2eeb72b --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/isValidUser.ts @@ -0,0 +1,68 @@ +import { platform } from "~/platform" + +export type ValidUserResponse = { + valid: boolean + error: string +} + +export const SESSION_EXPIRED = "Session expired. Please log in again." + +/** + * Attempts to refresh the authentication token + * @returns Promise resolving to a ValidUserResponse with the result + */ +const attemptTokenRefresh = async (): Promise => { + if (!platform.auth.refreshAuthToken) + return { valid: false, error: SESSION_EXPIRED } + + try { + const refreshSuccessful = await platform.auth.refreshAuthToken() + return { + valid: refreshSuccessful, + error: refreshSuccessful ? "" : SESSION_EXPIRED, + } + } catch { + return { valid: false, error: SESSION_EXPIRED } + } +} + +/** + * Validates user authentication and token validity by making an API call. + * Refreshes tokens if they are expired. + * + * This function is kept separate from `handleTokenValidation()` to enable different use cases: + * - Silent validation for conditional UI states (e.g., disabling components on token expiration) + * - Background checks without triggering user notifications + * - Custom error handling based on validation results + * + * Use `handleTokenValidation()` when you need automatic toast error notifications. + * Use `isValidUser()` for silent validation or custom error handling scenarios. + * + * @returns {Promise} Authentication status with user existence and token validity + */ +export const isValidUser = async (): Promise => { + const user = platform.auth.getCurrentUser() + + // If no user is logged in, consider it valid (allows public actions) + if (!user) return { valid: true, error: "" } + + try { + // If the platform provides a method to verify auth tokens, use it + if (platform.auth.verifyAuthTokens) { + const hasValidTokens = await platform.auth.verifyAuthTokens() + + if (hasValidTokens) { + return { valid: true, error: "" } + } + + // Try token refresh if verification failed + return attemptTokenRefresh() + } + + // For platforms without token verification capability + return { valid: true, error: "" } + } catch (_error) { + // Handle errors from token verification + return attemptTokenRefresh() + } +} diff --git a/packages/hoppscotch-common/src/helpers/jsonParse.ts b/packages/hoppscotch-common/src/helpers/jsonParse.ts new file mode 100644 index 0000000..293a253 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/jsonParse.ts @@ -0,0 +1,397 @@ +/** + * Copyright (c) 2019 GraphQL Contributors + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +/** + * This JSON parser simply walks the input, generating an AST. Use this in lieu + * of JSON.parse if you need character offset parse errors and an AST parse tree + * with location information. + * + * If an error is encountered, a SyntaxError will be thrown, with properties: + * + * - message: string + * - start: int - the start inclusive offset of the syntax error + * - end: int - the end exclusive offset of the syntax error + * + */ +type JSONEOFValue = { + kind: "EOF" + start: number + end: number +} + +type JSONNullValue = { + kind: "Null" + start: number + end: number +} + +type JSONNumberValue = { + kind: "Number" + start: number + end: number + value: number +} + +type JSONStringValue = { + kind: "String" + start: number + end: number + value: string +} + +type JSONBooleanValue = { + kind: "Boolean" + start: number + end: number + value: boolean +} + +type JSONPrimitiveValue = + | JSONNullValue + | JSONEOFValue + | JSONStringValue + | JSONNumberValue + | JSONBooleanValue + +export type JSONObjectValue = { + kind: "Object" + start: number + end: number + // eslint-disable-next-line no-use-before-define + members: JSONObjectMember[] +} + +export type JSONArrayValue = { + kind: "Array" + start: number + end: number + // eslint-disable-next-line no-use-before-define + values: JSONValue[] +} + +export type JSONValue = JSONObjectValue | JSONArrayValue | JSONPrimitiveValue + +export type JSONObjectMember = { + kind: "Member" + start: number + end: number + key: JSONStringValue + value: JSONValue +} + +export default function jsonParse( + str: string +): JSONObjectValue | JSONArrayValue { + string = str + strLen = str.length + start = end = lastEnd = -1 + ch() + lex() + try { + const ast = parseObj() + expect("EOF") + return ast + } catch (_e) { + // Try parsing expecting a root array + const ast = parseArr() + expect("EOF") + return ast + } +} + +let string: string +let strLen: number +let start: number +let end: number +let lastEnd: number +let code: number +let kind: string + +function parseObj(): JSONObjectValue { + const nodeStart = start + const members = [] + expect("{") + if (!skip("}")) { + do { + members.push(parseMember()) + } while (skip(",")) + expect("}") + } + return { + kind: "Object", + start: nodeStart, + end: lastEnd, + members, + } +} + +function parseMember(): JSONObjectMember { + const nodeStart = start + const key = kind === "String" ? (curToken() as JSONStringValue) : null + expect("String") + expect(":") + const value = parseVal() + return { + kind: "Member", + start: nodeStart, + end: lastEnd, + key: key!, + value, + } +} + +function parseArr(): JSONArrayValue { + const nodeStart = start + const values: JSONValue[] = [] + expect("[") + if (!skip("]")) { + do { + values.push(parseVal()) + } while (skip(",")) + expect("]") + } + return { + kind: "Array", + start: nodeStart, + end: lastEnd, + values, + } +} + +function parseVal(): JSONValue { + switch (kind) { + case "[": + return parseArr() + case "{": + return parseObj() + case "String": + case "Number": + case "Boolean": + case "Null": + // eslint-disable-next-line no-case-declarations + const token = curToken() + lex() + return token + } + return expect("Value") as never +} + +function curToken(): JSONPrimitiveValue { + return { + kind: kind as any, + start, + end, + value: JSON.parse(string.slice(start, end)), + } +} + +function expect(str: string) { + if (kind === str) { + lex() + return + } + + let found + if (kind === "EOF") { + found = "[end of file]" + } else if (end - start > 1) { + found = `\`${string.slice(start, end)}\`` + } else { + const match = string.slice(start).match(/^.+?\b/) + found = `\`${match ? match[0] : string[start]}\`` + } + + throw syntaxError(`Expected ${str} but found ${found}.`) +} + +type SyntaxError = { + message: string + start: number + end: number +} + +function syntaxError(message: string): SyntaxError { + return { message, start, end } +} + +function skip(k: string) { + if (kind === k) { + lex() + return true + } +} + +function ch() { + if (end < strLen) { + end++ + code = end === strLen ? 0 : string.charCodeAt(end) + } +} + +function lex() { + lastEnd = end + + while (code === 9 || code === 10 || code === 13 || code === 32) { + ch() + } + + if (code === 0) { + kind = "EOF" + return + } + + start = end + + switch (code) { + // " + case 34: + kind = "String" + return readString() + // -, 0-9 + case 45: + case 48: + case 49: + case 50: + case 51: + case 52: + case 53: + case 54: + case 55: + case 56: + case 57: + kind = "Number" + return readNumber() + // f + case 102: + if (string.slice(start, start + 5) !== "false") { + break + } + end += 4 + ch() + + kind = "Boolean" + return + // n + case 110: + if (string.slice(start, start + 4) !== "null") { + break + } + end += 3 + ch() + + kind = "Null" + return + // t + case 116: + if (string.slice(start, start + 4) !== "true") { + break + } + end += 3 + ch() + + kind = "Boolean" + return + } + + kind = string[start] + ch() +} + +function readString() { + ch() + while (code !== 34 && code > 31) { + if (code === (92 as any)) { + // \ + ch() + switch (code) { + case 34: // " + case 47: // / + case 92: // \ + case 98: // b + case 102: // f + case 110: // n + case 114: // r + case 116: // t + ch() + break + case 117: // u + ch() + readHex() + readHex() + readHex() + readHex() + break + default: + throw syntaxError("Bad character escape sequence.") + } + } else if (end === strLen) { + throw syntaxError("Unterminated string.") + } else { + ch() + } + } + + if (code === 34) { + ch() + return + } + + throw syntaxError("Unterminated string.") +} + +function readHex() { + if ( + (code >= 48 && code <= 57) || // 0-9 + (code >= 65 && code <= 70) || // A-F + (code >= 97 && code <= 102) // a-f + ) { + return ch() + } + throw syntaxError("Expected hexadecimal digit.") +} + +function readNumber() { + if (code === 45) { + // - + ch() + } + + if (code === 48) { + // 0 + ch() + } else { + readDigits() + } + + if (code === 46) { + // . + ch() + readDigits() + } + + if (code === 69 || code === 101) { + // E e + ch() + if (code === (43 as any) || code === (45 as any)) { + // + - + ch() + } + readDigits() + } +} + +function readDigits() { + if (code < 48 || code > 57) { + // 0 - 9 + throw syntaxError("Expected decimal digit.") + } + do { + ch() + } while (code >= 48 && code <= 57) // 0 - 9 +} diff --git a/packages/hoppscotch-common/src/helpers/jsoncParse.ts b/packages/hoppscotch-common/src/helpers/jsoncParse.ts new file mode 100644 index 0000000..7d8d869 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/jsoncParse.ts @@ -0,0 +1,529 @@ +/** + * Copyright (c) 2019 GraphQL Contributors + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +/** + * This JSON parser simply walks the input, generating an AST. Use this in lieu + * of JSON.parse if you need character offset parse errors and an AST parse tree + * with location information. + * + * If an error is encountered, a SyntaxError will be thrown, with properties: + * + * - message: string + * - start: int - the start inclusive offset of the syntax error + * - end: int - the end exclusive offset of the syntax error + * + */ +type JSONEOFValue = { + kind: "EOF" + start: number + end: number +} + +// First, add the new comment types +type JSONCommentKind = "SingleLineComment" | "MultiLineComment" + +export type JSONCommentValue = { + kind: JSONCommentKind + start: number + end: number + value: string +} + +type JSONNullValue = { + kind: "Null" + start: number + end: number +} + +type JSONNumberValue = { + kind: "Number" + start: number + end: number + value: number +} + +type JSONStringValue = { + kind: "String" + start: number + end: number + value: string +} + +type JSONBooleanValue = { + kind: "Boolean" + start: number + end: number + value: boolean +} + +type JSONPrimitiveValue = + | JSONNullValue + | JSONEOFValue + | JSONStringValue + | JSONNumberValue + | JSONBooleanValue + +export type JSONObjectValue = { + kind: "Object" + start: number + end: number + // eslint-disable-next-line no-use-before-define + members: JSONObjectMember[] + comments?: JSONCommentValue[] // optional comments array +} + +export type JSONArrayValue = { + kind: "Array" + start: number + end: number + // eslint-disable-next-line no-use-before-define + values: JSONValue[] + comments?: JSONCommentValue[] // optional comments array +} + +export type JSONValue = JSONObjectValue | JSONArrayValue | JSONPrimitiveValue + +export type JSONObjectMember = { + kind: "Member" + start: number + end: number + key: JSONStringValue + value: JSONValue + comments?: JSONCommentValue[] // optional comments array +} + +let string: string +let strLen: number +let start: number +let end: number +let lastEnd: number +let code: number +let kind: string +let pendingComments: JSONCommentValue[] = [] + +export default function jsonParse( + str: string +): JSONObjectValue | JSONArrayValue { + string = str + strLen = str.length + start = end = lastEnd = -1 + pendingComments = [] // Reset pending comments + ch() + lex() + try { + const ast = parseObj() + expect("EOF") + return ast + } catch (_e) { + pendingComments = [] // Reset pending comments + const ast = parseArr() + expect("EOF") + return ast + } +} + +function parseObj(): JSONObjectValue { + const nodeStart = start + const members: JSONObjectMember[] = [] + const comments = [...pendingComments] // Capture comments before the object + pendingComments = [] + + expect("{") + + let first = true + while (!skip("}")) { + if (!first) { + // Expect a comma between members + expect(",") + + // After comma, check for closing brace (handling trailing comma) + if (skip("}")) { + break + } + } + first = false + + // Capture any comments before the member + const memberComments = [...pendingComments] + pendingComments = [] + + const member = parseMember() + if (memberComments.length > 0) { + member.comments = memberComments + } + members.push(member) + } + + // Capture any trailing comments inside the object + const trailingComments = [...pendingComments] + pendingComments = [] + + return { + kind: "Object", + start: nodeStart, + end: lastEnd, + members, + comments: + comments.length > 0 || trailingComments.length > 0 + ? [...comments, ...trailingComments] + : undefined, + } +} + +function parseArr(): JSONArrayValue { + const nodeStart = start + const values: JSONValue[] = [] + const comments = [...pendingComments] // Capture comments before the array + pendingComments = [] + + expect("[") + + let first = true + while (!skip("]")) { + if (!first) { + // Expect a comma between values + expect(",") + + // After comma, check for closing bracket (handling trailing comma) + if (skip("]")) { + break + } + } + first = false + + // Add value and attach any pending comments to it + const value = parseVal() + if (pendingComments.length > 0 && typeof value === "object") { + ;(value as JSONObjectValue).comments = [ + ...((value as JSONObjectValue).comments || []), + ...pendingComments, + ] + pendingComments = [] + } + values.push(value) + } + + // Capture any trailing comments inside the array + const trailingComments = [...pendingComments] + pendingComments = [] + + return { + kind: "Array", + start: nodeStart, + end: lastEnd, + values, + comments: + comments.length > 0 || trailingComments.length > 0 + ? [...comments, ...trailingComments] + : undefined, + } +} + +function parseMember(): JSONObjectMember { + const nodeStart = start + const memberComments = [...pendingComments] // Capture comments before the member + pendingComments = [] + + const key = kind === "String" ? (curToken() as JSONStringValue) : null + expect("String") + expect(":") + + const value = parseVal() + + return { + kind: "Member", + start: nodeStart, + end: lastEnd, + key: key!, + value, + comments: memberComments.length > 0 ? memberComments : undefined, + } +} + +function parseVal(): JSONValue { + switch (kind) { + case "[": + return parseArr() + case "{": + return parseObj() + case "String": + case "Number": + case "Boolean": + case "Null": + // eslint-disable-next-line no-case-declarations + const token = curToken() + lex() + return token + } + return expect("Value") as never +} + +function curToken(): JSONPrimitiveValue { + return { + kind: kind as any, + start, + end, + value: JSON.parse(string.slice(start, end)), + } +} + +function expect(str: string) { + if (kind === str) { + lex() + return + } + + let found + if (kind === "EOF") { + found = "[end of file]" + } else if (end - start > 1) { + found = `\`${string.slice(start, end)}\`` + } else { + const match = string.slice(start).match(/^.+?\b/) + found = `\`${match ? match[0] : string[start]}\`` + } + + throw syntaxError(`Expected ${str} but found ${found}.`) +} + +type SyntaxError = { + message: string + start: number + end: number +} + +function syntaxError(message: string): SyntaxError { + return { message, start, end } +} + +function skip(k: string) { + if (kind === k) { + lex() + return true + } +} + +function ch() { + if (end < strLen) { + end++ + code = end === strLen ? 0 : string.charCodeAt(end) + } +} + +function lex() { + lastEnd = end + + while (true) { + // Skip whitespace + while (code === 9 || code === 10 || code === 13 || code === 32) { + ch() + } + + // Handle single-line comments + if (code === 47 && string.charCodeAt(end + 1) === 47) { + const commentStart = end + ch() // Skip first '/' + ch() // Skip second '/' + + let commentText = "" + while (code !== 10 && code !== 13 && code !== 0) { + commentText += String.fromCharCode(code) + ch() + } + + pendingComments.push({ + kind: "SingleLineComment", + start: commentStart, + end, + value: commentText.trim(), + }) + continue + } + + // Handle multi-line comments + if (code === 47 && string.charCodeAt(end + 1) === 42) { + const commentStart = end + ch() // Skip '/' + ch() // Skip '*' + + let commentText = "" + while ( + code !== 0 && + !(code === 42 && string.charCodeAt(end + 1) === 47) + ) { + commentText += String.fromCharCode(code) + ch() + } + + if (code === 0) { + throw syntaxError("Unterminated multi-line comment") + } + + ch() // Skip '*' + ch() // Skip '/' + + pendingComments.push({ + kind: "MultiLineComment", + start: commentStart, + end, + value: commentText.trim(), + }) + continue + } + + break + } + + if (code === 0) { + kind = "EOF" + return + } + + start = end + + switch (code) { + // Handle strings, numbers, booleans, null, etc. + case 34: // " + kind = "String" + return readString() + case 45: // - + case 48: // 0 + case 49: // 1 + case 50: // 2 + case 51: // 3 + case 52: // 4 + case 53: // 5 + case 54: // 6 + case 55: // 7 + case 56: // 8 + case 57: // 9 + kind = "Number" + return readNumber() + case 102: // 'f' for "false" + if (string.slice(start, start + 5) === "false") { + end += 4 + ch() + kind = "Boolean" + return + } + break + case 110: // 'n' for "null" + if (string.slice(start, start + 4) === "null") { + end += 3 + ch() + kind = "Null" + return + } + break + case 116: // 't' for "true" + if (string.slice(start, start + 4) === "true") { + end += 3 + ch() + kind = "Boolean" + return + } + break + } + + kind = string[start] + ch() +} + +function readString() { + ch() + while (code !== 34 && code > 31) { + if (code === (92 as any)) { + // \ + ch() + switch (code) { + case 34: // " + case 47: // / + case 92: // \ + case 98: // b + case 102: // f + case 110: // n + case 114: // r + case 116: // t + ch() + break + case 117: // u + ch() + readHex() + readHex() + readHex() + readHex() + break + default: + throw syntaxError("Bad character escape sequence.") + } + } else if (end === strLen) { + throw syntaxError("Unterminated string.") + } else { + ch() + } + } + + if (code === 34) { + ch() + return + } + + throw syntaxError("Unterminated string.") +} + +function readHex() { + if ( + (code >= 48 && code <= 57) || // 0-9 + (code >= 65 && code <= 70) || // A-F + (code >= 97 && code <= 102) // a-f + ) { + return ch() + } + throw syntaxError("Expected hexadecimal digit.") +} + +function readNumber() { + if (code === 45) { + // - + ch() + } + + if (code === 48) { + // 0 + ch() + } else { + readDigits() + } + + if (code === 46) { + // . + ch() + readDigits() + } + + if (code === 69 || code === 101) { + // E e + ch() + if (code === (43 as any) || code === (45 as any)) { + // + - + ch() + } + readDigits() + } +} + +function readDigits() { + if (code < 48 || code > 57) { + // 0 - 9 + throw syntaxError("Expected decimal digit.") + } + do { + ch() + } while (code >= 48 && code <= 57) // 0 - 9 +} diff --git a/packages/hoppscotch-common/src/helpers/kernel/__tests__/kernel.spec.ts b/packages/hoppscotch-common/src/helpers/kernel/__tests__/kernel.spec.ts new file mode 100644 index 0000000..3e3c109 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/kernel/__tests__/kernel.spec.ts @@ -0,0 +1,353 @@ +import { describe, expect, it } from "vitest" +import { GQLResponse } from "../gql/response" +import { GQLRequest } from "../gql/request" +import { RESTResponse } from "../rest/response" +import { RESTRequest } from "../rest/request" +import { HoppRESTRequest } from "@hoppscotch/data" +import { RunQueryOptions } from "~/helpers/graphql/connection" +import { MediaType } from "@hoppscotch/kernel" +import { HoppGQLRequest } from "@hoppscotch/data" +import { EffectiveHoppRESTRequest } from "~/helpers/utils/EffectiveURL" +import { RelayResponse } from "@hoppscotch/kernel" + +describe("GraphQL Response Transformation", () => { + const baseOptions: RunQueryOptions = { + url: "https://api.example.com/graphql", + headers: [], + query: "", + variables: "", + auth: { authType: "none", authActive: true }, + operationName: "TestQuery", + operationType: "query", + } + + it("successfully transforms a valid GraphQL response with data", async () => { + const json = JSON.stringify({ data: { hello: "world" } }) + const mockResponse: RelayResponse = { + status: 200, + headers: {}, + body: { + mediaType: "application/json", + body: new Uint8Array(Buffer.from(json)), + }, + meta: { + timing: { + start: 100, + end: 200, + }, + }, + } + + const options = { + ...baseOptions, + query: "query TestQuery { hello }", + } + + const result = await GQLResponse.toResponse( + mockResponse as RelayResponse, + options + ) + + expect(result).toHaveProperty("type", "response") + if (result.type === "response") { + expect(result.operationName).toBe("TestQuery") + expect(result.time).toBe(100) + expect(JSON.parse(result.data)).toHaveProperty("data.hello", "world") + } + }) + + it("successfully transforms a GraphQL response with errors", async () => { + const mockResponse: RelayResponse = { + status: 200, + headers: {}, + body: { + mediaType: "application/json", + body: new Uint8Array( + Buffer.from( + JSON.stringify({ + errors: [ + { message: "Field 'invalid' does not exist" }, + { message: "Syntax error" }, + ], + }) + ) + ), + }, + meta: { + timing: { + start: 0, + end: 150, + }, + }, + } + + const options = { + ...baseOptions, + query: "query TestQuery { invalid }", + } + + const result = await GQLResponse.toResponse( + mockResponse as RelayResponse, + options + ) + + expect(result).toHaveProperty("type", "response") + if (result.type === "response") { + const parsedData = JSON.parse(result.data) + expect(parsedData.errors).toHaveLength(2) + expect(parsedData.errors[0].message).toBe( + "Field 'invalid' does not exist" + ) + } + }) + + it("returns transform error for non-GraphQL JSON response", async () => { + const mockResponse: RelayResponse = { + status: 200, + headers: {}, + body: { + mediaType: "application/json", + body: new Uint8Array( + Buffer.from( + JSON.stringify({ + someField: "not a graphql response", + }) + ) + ), + }, + } + + const options = { + ...baseOptions, + query: "query TestQuery { hello }", + } + + const result = await GQLResponse.toResponse( + mockResponse as RelayResponse, + options + ) + + expect(result).toHaveProperty("type", "error") + if (result.type === "error") { + expect(result.error.message).toBe("Invalid GraphQL response structure") + } + }) +}) + +describe("GraphQL Request Transformation", () => { + const baseRequest: HoppGQLRequest = { + v: 9, + name: "Test Query", + url: "https://api.example.com/graphql", + headers: [], + query: "", + variables: "", + auth: { authType: "none", authActive: true }, + } + + it("transforms a basic GraphQL request correctly", async () => { + const request: HoppGQLRequest = { + ...baseRequest, + query: "query { hello }", + } + + const result = await GQLRequest.toRequest(request) + + expect(result).toMatchObject({ + url: "https://api.example.com/graphql", + method: "POST", + version: "HTTP/1.1", + headers: { "content-type": "application/json" }, + auth: { kind: "none" }, + content: { + kind: "json", + content: { query: "query { hello }", variables: undefined }, + mediaType: "application/json", + }, + }) + + expect(result.content?.mediaType).toBe(MediaType.APPLICATION_JSON) + expect(result.content?.content).toBeDefined() + if (result.content?.body) { + const parsedBody = + typeof result.content.body === "string" + ? JSON.parse(result.content.body) + : JSON.parse(new TextDecoder().decode(result.content.body)) + + expect(parsedBody).toEqual({ + query: request.query, + variables: undefined, + }) + } + }) + + it("properly parses and includes variables when provided", async () => { + const request: HoppGQLRequest = { + ...baseRequest, + query: "query($id: ID!) { item(id: $id) { name } }", + variables: '{ "id": "123" }', + } + + const result = await GQLRequest.toRequest(request) + + expect(result.content?.kind).toBe("json") + expect(result.content?.content).toBeDefined() + if (result.content?.content) { + expect(result.content?.content).toEqual({ + query: request.query, + variables: { id: "123" }, + }) + } + }) + + it("throws error for invalid JSON in variables", async () => { + const request: HoppGQLRequest = { + ...baseRequest, + query: "query($id: ID!) { item(id: $id) { name } }", + variables: "{ invalid json }", + } + + await expect(GQLRequest.toRequest(request)).rejects.toThrow("Invalid JSON") + }) +}) + +describe("REST Response Transformation", () => { + it("transforms a successful REST response", async () => { + const mockResponse = { + body: { + body: new Uint8Array([72, 101, 108, 108, 111]), + }, + headers: { + "content-type": "text/plain", + "x-custom": "test", + }, + status: 200, + statusText: "OK", + meta: { + timing: { + start: 100, + end: 200, + }, + size: { + total: 5, + }, + }, + } + + const originalRequest: HoppRESTRequest = { + v: "15", + endpoint: "https://api.example.com", + name: "Test Request", + method: "GET", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + auth: { authType: "none", authActive: true }, + body: { contentType: null, body: null }, + requestVariables: [], + responses: {}, + } + + const result = await RESTResponse.toResponse( + mockResponse as any, + originalRequest + ) + + expect(result).toHaveProperty("type", "success") + if (result.type === "success") { + expect(result.statusCode).toBe(200) + expect(result.meta.responseDuration).toBe(100) + expect(result.meta.responseSize).toBe(5) + expect(result.headers).toHaveLength(2) + } + }) + + it("returns transform error for invalid response body", async () => { + const mockResponse = { + body: { + body: "invalid body format", + }, + } + + const originalRequest: HoppRESTRequest = { + v: "15", + endpoint: "https://api.example.com", + name: "Test Request", + method: "GET", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + auth: { authType: "none", authActive: true }, + body: { contentType: null, body: null }, + requestVariables: [], + responses: {}, + } + + const result = await RESTResponse.toResponse( + mockResponse as any, + originalRequest + ) + + expect(result).toHaveProperty("type", "fail") + if (result.type === "fail") { + expect(result.error.type).toBe("transform_error") + expect(result.error.message).toBe("Invalid response body format") + } + }) +}) + +describe("REST Request Transformation", () => { + const baseEffectiveRequest: EffectiveHoppRESTRequest = { + v: "15", + name: "Test Request", + method: "GET", + endpoint: "https://api.example.com", + effectiveFinalURL: "https://api.example.com", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + auth: { authType: "none", authActive: true }, + body: { contentType: null, body: null }, + requestVariables: [], + responses: {}, + effectiveFinalHeaders: [], + effectiveFinalParams: [], + effectiveFinalBody: null, + effectiveFinalRequestVariables: [], + } + + it("transforms a basic REST request correctly", async () => { + const result = await RESTRequest.toRequest(baseEffectiveRequest) + + expect(result).toMatchObject({ + url: baseEffectiveRequest.effectiveFinalURL, + method: "GET", + headers: {}, + params: {}, + auth: { kind: "none" }, + }) + }) + + it("includes auth parameters when basic auth is active", async () => { + const request: EffectiveHoppRESTRequest = { + ...baseEffectiveRequest, + auth: { + authType: "basic", + authActive: true, + username: "testuser", + password: "testpass", + }, + } + + const result = await RESTRequest.toRequest(request) + + expect(result.auth).toMatchObject({ + kind: "basic", + username: "testuser", + password: "testpass", + }) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/kernel/common/auth.ts b/packages/hoppscotch-common/src/helpers/kernel/common/auth.ts new file mode 100644 index 0000000..dd68617 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/kernel/common/auth.ts @@ -0,0 +1,286 @@ +import { HoppRESTRequest, HoppRESTAuthOAuth2 } from "@hoppscotch/data" +import { AuthType } from "@hoppscotch/kernel" +import * as E from "fp-ts/Either" +import * as O from "fp-ts/Option" +import * as TE from "fp-ts/TaskEither" +import { flow, pipe } from "fp-ts/function" + +type HoppAuth = HoppRESTRequest["auth"] +type OAuth2GrantType = HoppRESTAuthOAuth2["grantTypeInfo"] +type GrantType = Extract["grantType"] + +export const defaultAuth: AuthType = { kind: "none" } + +const isAuthActive = (auth: HoppAuth): boolean => + auth.authActive && auth.authType !== "none" && auth.authType !== "inherit" + +const Guards = { + basic: flow( + O.fromPredicate( + (auth: HoppAuth): auth is HoppAuth & { authType: "basic" } => + auth.authType === "basic" + ) + ), + bearer: flow( + O.fromPredicate( + (auth: HoppAuth): auth is HoppAuth & { authType: "bearer" } => + auth.authType === "bearer" + ) + ), + apiKey: flow( + O.fromPredicate( + (auth: HoppAuth): auth is HoppAuth & { authType: "api-key" } => + auth.authType === "api-key" + ) + ), + aws: flow( + O.fromPredicate( + (auth: HoppAuth): auth is HoppAuth & { authType: "aws-signature" } => + auth.authType === "aws-signature" + ) + ), + digest: flow( + O.fromPredicate( + (auth: HoppAuth): auth is HoppAuth & { authType: "digest" } => + auth.authType === "digest" + ) + ), + oauth2: flow( + O.fromPredicate( + (auth: HoppAuth): auth is HoppAuth & { authType: "oauth-2" } => + auth.authType === "oauth-2" + ) + ), + grants: { + authCode: flow( + O.fromPredicate( + ( + g: OAuth2GrantType + ): g is Extract => + g.grantType === "AUTHORIZATION_CODE" + ) + ), + clientCreds: flow( + O.fromPredicate( + ( + g: OAuth2GrantType + ): g is Extract => + g.grantType === "CLIENT_CREDENTIALS" + ) + ), + password: flow( + O.fromPredicate( + ( + g: OAuth2GrantType + ): g is Extract => + g.grantType === "PASSWORD" + ) + ), + implicit: flow( + O.fromPredicate( + ( + g: OAuth2GrantType + ): g is Extract => + g.grantType === "IMPLICIT" + ) + ), + }, +} + +type AuthProcessor = ( + auth: T +) => E.Either + +const Processors: { + basic: AuthProcessor + bearer: AuthProcessor + apiKey: AuthProcessor + aws: AuthProcessor + digest: AuthProcessor + oauth2: { + processGrant: ( + grant: OAuth2GrantType + ) => E.Either + process: AuthProcessor + } +} = { + basic: flow( + Guards.basic, + O.map((a) => ({ + kind: "basic" as const, + username: a.username, + password: a.password, + })), + E.fromOption(() => new Error("Invalid basic auth")) + ), + + bearer: flow( + Guards.bearer, + O.map((a) => ({ + kind: "bearer" as const, + token: a.token, + })), + E.fromOption(() => new Error("Invalid bearer auth")) + ), + + apiKey: flow( + Guards.apiKey, + O.map((a) => ({ + kind: "apikey" as const, + key: a.key, + value: a.value, + in: a.addTo === "HEADERS" ? "header" : "query", + })), + E.fromOption(() => new Error("Invalid API key auth")) + ), + + aws: flow( + Guards.aws, + O.map((a) => ({ + kind: "aws" as const, + accessKey: a.accessKey, + secretKey: a.secretKey, + region: a.region, + service: a.serviceName, + sessionToken: a.serviceToken, + in: a.addTo === "HEADERS" ? "header" : "query", + })), + E.fromOption(() => new Error("Invalid AWS auth")) + ), + + digest: flow( + Guards.digest, + O.map((a) => ({ + kind: "digest" as const, + username: a.username, + password: a.password, + realm: a.realm, + nonce: a.nonce, + algorithm: a.algorithm === "MD5" ? "MD5" : "SHA-256", + qop: a.qop, + nc: a.nc, + cnonce: a.cnonce, + opaque: a.opaque, + })), + E.fromOption(() => new Error("Invalid digest auth")) + ), + + oauth2: { + processGrant: ( + grant: OAuth2GrantType + ): E.Either => + pipe( + grant, + (g) => + pipe( + O.none as O.Option, + O.alt(() => + pipe( + Guards.grants.authCode(g), + O.map( + (g): GrantType => ({ + kind: "authorization_code", + authEndpoint: g.authEndpoint, + tokenEndpoint: g.tokenEndpoint, + clientId: g.clientID, + clientSecret: g.clientSecret, + }) + ) + ) + ), + O.alt(() => + pipe( + Guards.grants.clientCreds(g), + O.map( + (g): GrantType => ({ + kind: "client_credentials", + tokenEndpoint: g.authEndpoint, + clientId: g.clientID, + clientSecret: g.clientSecret, + }) + ) + ) + ), + O.alt(() => + pipe( + Guards.grants.password(g), + O.map( + (g): GrantType => ({ + kind: "password", + tokenEndpoint: g.authEndpoint, + username: g.username, + password: g.password, + }) + ) + ) + ), + O.alt(() => + pipe( + Guards.grants.implicit(g), + O.map( + (g): GrantType => ({ + kind: "implicit", + authEndpoint: g.authEndpoint, + clientId: g.clientID, + }) + ) + ) + ) + ), + E.fromOption(() => new Error("Invalid grant type")) + ), + process: flow( + Guards.oauth2, + E.fromOption(() => new Error("Invalid OAuth2 auth")), + E.chain((a) => + pipe( + Processors.oauth2.processGrant(a.grantTypeInfo), + E.map((grantType) => ({ + kind: "oauth2" as const, + accessToken: a.grantTypeInfo.token, + refreshToken: + "refreshToken" in a.grantTypeInfo + ? a.grantTypeInfo.refreshToken + : undefined, + grantType, + })) + ) + ) + ), + }, +} + +const getProcessor = ( + auth: HoppAuth +): O.Option<(auth: HoppAuth) => E.Either> => + pipe( + O.fromNullable(auth.authType), + O.chain((type) => { + switch (type) { + case "basic": + return O.some(Processors.basic) + case "bearer": + return O.some(Processors.bearer) + case "api-key": + return O.some(Processors.apiKey) + case "aws-signature": + return O.some(Processors.aws) + case "digest": + return O.some(Processors.digest) + case "oauth-2": + return O.some(Processors.oauth2.process) + default: + return O.none + } + }) + ) + +export const transformAuth = (auth: HoppAuth): TE.TaskEither => + pipe( + auth, + O.fromPredicate(isAuthActive), + O.chain(getProcessor), + O.map((processor) => processor(auth)), + O.getOrElse(() => E.right(defaultAuth)), + TE.fromEither + ) diff --git a/packages/hoppscotch-common/src/helpers/kernel/common/content.ts b/packages/hoppscotch-common/src/helpers/kernel/common/content.ts new file mode 100644 index 0000000..9a5d103 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/kernel/common/content.ts @@ -0,0 +1,152 @@ +import * as E from "fp-ts/Either" +import * as TE from "fp-ts/TaskEither" +import * as O from "fp-ts/Option" +import { pipe } from "fp-ts/function" + +import { ContentType, MediaType, content } from "@hoppscotch/kernel" +import { EffectiveHoppRESTRequest } from "~/helpers/utils/EffectiveURL" + +/** + * Content processors for converting raw body strings to standardized ContentType objects. + * + * If a processor is called, it's expected that it's correct content type. + * NOTE: Validation belongs in upper layers, not the processor layer. + */ +const Processors = { + /** + * Processes JSON content as pre-stringified JSON text. + * + * NOTE: Assumes input is valid JSON in string format since user selected "JSON". + * Uses `content.text()` with JSON media type to avoid double-encoding. + */ + json: { + process: (body: string): E.Either => + E.right(content.text(body, MediaType.APPLICATION_JSON)), + }, + + /** + * Processes binary content from Blob/File objects. + * + * Converts Blob to Uint8Array while preserving filename and content type. + * Returns TaskEither since arrayBuffer() is async. + */ + binary: { + process: (file: Blob): TE.TaskEither => + pipe( + TE.tryCatch( + () => file.arrayBuffer(), + () => new Error("Binary read failed") + ), + TE.map((buffer) => + content.binary( + new Uint8Array(buffer), + file.type || "application/octet-stream", + file instanceof File ? file.name : "unknown" + ) + ) + ), + }, + + /** + * Processes URL-encoded form data. + * + * Takes a raw string and wraps it in the urlencoded content type. + * The string is expected to be already properly encoded (e.g., "key1=value1&key2=value2"). + */ + urlencoded: { + process: (body: string): E.Either => + pipe( + E.right(body), + E.map((contents) => { + return content.urlencoded(contents) + }) + ), + }, + + /** + * Processes XML content as text with XML media type. + * + * Assumes input is valid XML string format. + */ + xml: { + process: (body: string): E.Either => + E.right(content.xml(body, MediaType.APPLICATION_XML)), + }, + + /** + * Processes plain text content. + * + * Fallback processor for any text-based content that doesn't fit other categories. + */ + text: { + process: (body: string): E.Either => + E.right(content.text(body, MediaType.TEXT_PLAIN)), + }, +} + +/** + * Maps content type strings to appropriate processor. + * + * @param contentType - MIME type string (e.g., "application/json") + * @returns Processor function that converts string body to ContentType + */ +const getProcessor = (contentType: string) => { + switch (contentType) { + case "application/json": + case "application/ld+json": + case "application/hal+json": + case "application/vnd.api+json": + return Processors.json.process + case "application/xml": + case "text/xml": + return Processors.xml.process + case "application/x-www-form-urlencoded": + return Processors.urlencoded.process + case "text/html": + case "text/plain": + return Processors.text.process + default: + return Processors.text.process + } +} + +/** + * Transforms HTTP request body content into standardized `ContentType` objects for the `relay` system. + * + * Returns None if no body content is present or if the body type doesn't match the content type. + * + * @param request - EffectiveHoppRESTRequest containing body and content type information + * @returns TaskEither> - Success with optional content, or error + */ +export const transformContent = ( + request: EffectiveHoppRESTRequest +): TE.TaskEither> => { + const { body, effectiveFinalBody } = request + + if (!body.contentType || !effectiveFinalBody) { + return TE.right(O.none) + } + + switch (body.contentType) { + case "multipart/form-data": + if (!(effectiveFinalBody instanceof FormData)) { + return TE.right(O.none) + } + return TE.right(O.some(content.multipart(effectiveFinalBody))) + + case "application/octet-stream": + if (!(effectiveFinalBody instanceof Blob)) { + return TE.right(O.none) + } + return pipe(Processors.binary.process(effectiveFinalBody), TE.map(O.some)) + + default: + if (typeof effectiveFinalBody !== "string") { + return TE.right(O.none) + } + return pipe( + TE.fromEither(getProcessor(body.contentType)(effectiveFinalBody)), + TE.map(O.some) + ) + } +} diff --git a/packages/hoppscotch-common/src/helpers/kernel/common/index.ts b/packages/hoppscotch-common/src/helpers/kernel/common/index.ts new file mode 100644 index 0000000..f7e55e5 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/kernel/common/index.ts @@ -0,0 +1,2 @@ +export { transformContent } from "./content" +export { transformAuth } from "./auth" diff --git a/packages/hoppscotch-common/src/helpers/kernel/gql/request.ts b/packages/hoppscotch-common/src/helpers/kernel/gql/request.ts new file mode 100644 index 0000000..d153cce --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/kernel/gql/request.ts @@ -0,0 +1,48 @@ +import * as TE from "fp-ts/TaskEither" +import * as T from "fp-ts/Task" +import { pipe } from "fp-ts/function" + +import { AuthType, MediaType, content } from "@hoppscotch/kernel" +import { HoppGQLRequest } from "@hoppscotch/data" + +import { transformAuth } from "~/helpers/kernel/common" +import { defaultAuth } from "~/helpers/kernel/common/auth" +import { filterActiveToRecord } from "~/helpers/functional/filter-active" + +const parseVariables = async (variables: string | null): Promise => { + if (!variables) return undefined + try { + return JSON.parse(variables) + } catch { + throw new Error("Invalid JSON") + } +} + +export const GQLRequest = { + async toRequest(request: HoppGQLRequest) { + const headers = { + ...filterActiveToRecord(request.headers), + "content-type": "application/json", + } + + const auth = await pipe( + transformAuth(request.auth), + TE.getOrElse(() => T.of(defaultAuth)) + )() + + const variables = await parseVariables(request.variables) + + return { + id: Date.now(), + url: request.url, + method: "POST", // GQL specs + version: "HTTP/1.1", + headers, + auth, + content: content.json( + { query: request.query, variables }, + MediaType.APPLICATION_JSON + ), + } + }, +} diff --git a/packages/hoppscotch-common/src/helpers/kernel/gql/response.ts b/packages/hoppscotch-common/src/helpers/kernel/gql/response.ts new file mode 100644 index 0000000..e194a27 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/kernel/gql/response.ts @@ -0,0 +1,89 @@ +import * as O from "fp-ts/Option" +import { pipe } from "fp-ts/function" +import { RelayResponse } from "@hoppscotch/kernel" +import { RunQueryOptions } from "~/helpers/graphql/connection" +import { OperationType } from "@urql/core" +import { parseBodyAsJSON } from "~/helpers/functional/json" + +export type HoppGQLSuccessResponse = { + type: "response" + time: number + operationName: string | undefined + operationType: OperationType + data: string + rawQuery?: RunQueryOptions +} + +export type GQLTransformError = { + type: "error" + error: { + type: "transform_error" + message: string + } +} + +type GQLParsedResponse = { + data?: unknown + errors?: Array<{ message: string }> +} + +const determineOperationType = (query: string): OperationType => { + const trimmed = query.trim().toLowerCase() + if (trimmed.startsWith("mutation")) return "mutation" + if (trimmed.startsWith("subscription")) return "subscription" + return "query" +} + +const createTransformError = (message: string): GQLTransformError => ({ + type: "error", + error: { + type: "transform_error" as const, + message, + }, +}) + +const validateResponse = (data: unknown): GQLParsedResponse | null => { + if (typeof data !== "object" || data === null) return null + + const hasValidData = "data" in data && data.data !== undefined + const hasValidErrors = + "errors" in data && + Array.isArray(data.errors) && + data.errors.every( + (e) => typeof e === "object" && e !== null && "message" in e + ) + + return hasValidData || hasValidErrors ? (data as GQLParsedResponse) : null +} + +export const GQLResponse = { + async toResponse( + response: RelayResponse, + options: RunQueryOptions + ): Promise { + const parsedJSON = pipe( + response.body, + parseBodyAsJSON, + O.fold( + () => createTransformError("Invalid JSON response"), + (json) => { + const validBody = validateResponse(json) + return validBody + ? { + type: "response" as const, + time: response.meta?.timing + ? response.meta.timing.end - response.meta.timing.start + : 0, + operationName: options.operationName, + operationType: determineOperationType(options.query), + data: JSON.stringify(validBody, null, 2), + rawQuery: options, + } + : createTransformError("Invalid GraphQL response structure") + } + ) + ) + + return parsedJSON + }, +} diff --git a/packages/hoppscotch-common/src/helpers/kernel/rest/index.ts b/packages/hoppscotch-common/src/helpers/kernel/rest/index.ts new file mode 100644 index 0000000..6e2e2a7 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/kernel/rest/index.ts @@ -0,0 +1,2 @@ +export { RESTRequest } from "./request" +export { RESTResponse } from "./response" diff --git a/packages/hoppscotch-common/src/helpers/kernel/rest/request.ts b/packages/hoppscotch-common/src/helpers/kernel/rest/request.ts new file mode 100644 index 0000000..585b4b6 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/kernel/rest/request.ts @@ -0,0 +1,43 @@ +import * as TE from "fp-ts/TaskEither" +import * as T from "fp-ts/Task" +import * as O from "fp-ts/Option" +import { pipe } from "fp-ts/function" + +import { Method, RelayRequest, ContentType, AuthType } from "@hoppscotch/kernel" +import { EffectiveHoppRESTRequest } from "~/helpers/utils/EffectiveURL" + +import { transformAuth, transformContent } from "~/helpers/kernel/common" +import { defaultAuth } from "~/helpers/kernel/common/auth" +import { + filterActiveToRecord, + filterActiveParams, +} from "~/helpers/functional/filter-active" + +export const RESTRequest = { + async toRequest(request: EffectiveHoppRESTRequest): Promise { + const auth = await pipe( + transformAuth(request.auth), + TE.getOrElse(() => T.of(defaultAuth)) + )() + + const content = await pipe( + transformContent(request), + TE.getOrElse(() => T.of>(O.none)), + T.map(O.toUndefined) + )() + + const headers = filterActiveToRecord(request.effectiveFinalHeaders) + const params = filterActiveParams(request.effectiveFinalParams) + + return { + id: Date.now(), + url: request.effectiveFinalURL, + method: request.method.toUpperCase() as Method, + version: "HTTP/1.1", + headers, + params, + auth, + content, + } + }, +} diff --git a/packages/hoppscotch-common/src/helpers/kernel/rest/response.ts b/packages/hoppscotch-common/src/helpers/kernel/rest/response.ts new file mode 100644 index 0000000..11f0d06 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/kernel/rest/response.ts @@ -0,0 +1,100 @@ +import { RelayResponse } from "@hoppscotch/kernel" +import { HoppRESTRequest } from "@hoppscotch/data" +import { + HoppRESTResponseHeader, + HoppRESTSuccessResponse, +} from "~/helpers/types/HoppRESTResponse" + +export type HoppRESTTransformError = { + type: "fail" + error: { + type: "transform_error" + message: string + } +} + +const extractTiming = (response: RelayResponse): number => + response.meta?.timing + ? response.meta.timing.end - response.meta.timing.start + : 0 + +const extractSize = (response: RelayResponse): number => + response.meta?.size?.total ?? 0 + +/** + * Response headers processor to handle multiple `\n` split Set-Cookie + * headers. + * + * + * TODO: This is a special case handler, a temporary workaround. + * Temporary workaround often get calcified as permanent but a complete + * solution, but the other more better option is rather involved, + * like swapping `Record`/`HashMap` with a flat array and propagating + * the refactor throughout the codebase, from the underlying networking + * to the FE. + * + * The problem with that approach is you lose that O(1) lookup. There's + * also little point in going from key-value to key-pair-value since + * you'd just be putting this same workaround somewhere else. + * A simpler approach is `HashMap>` but even that + * doesn't substantially reduces the refactor surface area. + * Given all of those issues, a temporary workaround is perhaps the best + * solution at the moment. + * + * Headers should always be present from the `RelayResponse`, this defends + * against potential serde/boundary issues between typed conversions. + */ +const processHeaders = ( + headers?: Record | null +): HoppRESTResponseHeader[] => { + const processedHeaders: HoppRESTResponseHeader[] = [] + + // `headers` should theoretically exist, always + for (const [key, value] of Object.entries(headers ?? {})) { + if (key.toLowerCase() === "set-cookie") { + // To split concatenated `Set-Cookie` headers and create separate header entries, + // see `if key.to_lowercase() == "set-cookie" {` in `transfer.rs` + const cookieStrings = value + .split("\n") + .map((s) => s.trim()) + .filter(Boolean) + for (const cookieString of cookieStrings) { + processedHeaders.push({ key: "Set-Cookie", value: cookieString }) + } + } else { + processedHeaders.push({ key, value }) + } + } + + return processedHeaders +} + +export const RESTResponse = { + async toResponse( + response: RelayResponse, + originalRequest: HoppRESTRequest + ): Promise { + if (!response.body.body || !(response.body.body instanceof Uint8Array)) { + return { + type: "fail", + error: { + type: "transform_error", + message: "Invalid response body format", + }, + } + } + + return { + type: "success", + headers: processHeaders(response.headers), + body: response.body.body.buffer, + statusCode: response.status, + statusText: response.statusText ?? "", + meta: { + responseSize: extractSize(response), + responseDuration: extractTiming(response), + }, + req: originalRequest, + } + }, +} diff --git a/packages/hoppscotch-common/src/helpers/keybindings.ts b/packages/hoppscotch-common/src/helpers/keybindings.ts new file mode 100644 index 0000000..6dd00ec --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/keybindings.ts @@ -0,0 +1,467 @@ +import { onBeforeUnmount, onMounted } from "vue" +import { HoppActionWithOptionalArgs, invokeAction } from "./actions" +import { + getKeyboardLayoutStrategy, + type KeyboardLayoutStrategy, +} from "./keyboard-strategy" +import { isAppleDevice } from "./platformutils" +import { + isCodeMirrorEditor, + isDOMElement, + isInShortcutsFlyout, + isMonacoEditor, + isTypableElement, +} from "./utils/dom" +import { getKernelMode } from "@hoppscotch/kernel" +import { listen } from "@tauri-apps/api/event" + +/** + * This variable keeps track whether keybindings are being accepted + * true -> Keybindings are checked + * false -> Key presses are ignored (Keybindings are not checked) + */ +let keybindingsEnabled = true + +/** + * Unlisten function for Tauri event + */ +let unlistenTauriEvent: (() => void) | null = null + +/** + * Alt is also regarded as macOS OPTION (⌥) key + * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!) + */ +type ModifierKeys = + | "ctrl" + | "alt" + | "shift" + | "ctrl-shift" + | "alt-shift" + | "ctrl-alt" + | "ctrl-alt-shift" + +/* eslint-disable prettier/prettier */ +// prettier-ignore +type Key = + | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" + | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" + | "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3" + | "4" | "5" | "6" | "7" | "8" | "9" | "up" | "down" | "left" + | "right" | "/" | "?" | "." | "enter" | "tab" | "delete" | "backspace" + | "[" | "]" +/* eslint-enable */ + +type ModifierBasedShortcutKey = `${ModifierKeys}-${Key}` +// Singular keybindings (these will be disabled when an input-ish area has been focused) +type SingleCharacterShortcutKey = `${Key}` + +type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey + +// Base bindings available on all platforms +const baseBindings: { + [_ in ShortcutKey]?: HoppActionWithOptionalArgs +} = { + "ctrl-enter": "request.send-cancel", + "ctrl-i": "request.reset", + "ctrl-u": "request.share-request", + "ctrl-s": "request-response.save", + "ctrl-shift-s": "request.save-as", + "alt-up": "request.method.next", + "alt-down": "request.method.prev", + "alt-g": "request.method.get", + "alt-h": "request.method.head", + "alt-p": "request.method.post", + "alt-u": "request.method.put", + "alt-x": "request.method.delete", + "ctrl-k": "modals.search.toggle", + "ctrl-/": "editor.comment-toggle", + "shift-/": "modals.support.toggle", + "ctrl-shift-/": "flyouts.keybinds.toggle", + "ctrl-m": "modals.share.toggle", + "alt-r": "navigation.jump.rest", + "alt-q": "navigation.jump.graphql", + "alt-w": "navigation.jump.realtime", + "alt-d": "navigation.jump.documentation", + "alt-s": "navigation.jump.settings", + "alt-m": "navigation.jump.profile", + "ctrl-shift-p": "response.preview.toggle", + "ctrl-j": "response.file.download", + "ctrl-.": "response.copy", + "ctrl-e": "response.save-as-example", + "ctrl-shift-l": "editor.format", + "ctrl-z": "editor.undo", + "ctrl-y": "editor.redo", + "ctrl-delete": "response.erase", + "ctrl-backspace": "response.erase", +} + +// Web-only bindings +const webBindings: { + [_ in ShortcutKey]?: HoppActionWithOptionalArgs +} = { + "ctrl-d": "tab.close-current", +} + +// Desktop-only bindings +const desktopBindings: { + [_ in ShortcutKey]?: HoppActionWithOptionalArgs +} = { + "ctrl-w": "tab.close-current", + "ctrl-t": "tab.open-new", + "ctrl-alt-left": "tab.prev", + "ctrl-alt-right": "tab.next", + "ctrl-alt-0": "tab.switch-to-last", + "ctrl-alt-9": "tab.switch-to-first", + "ctrl-q": "app.quit", + "ctrl-alt-u": "request.focus-url", + "ctrl-alt-]": "tab.mru-switch", + "ctrl-alt-[": "tab.mru-switch-reverse", +} + +/** + * Get bindings based on the current kernel mode + */ +function getActiveBindings(): typeof baseBindings { + const kernelMode = getKernelMode() + + if (kernelMode === "desktop") { + return { + ...baseBindings, + ...desktopBindings, + } + } + + return { + ...baseBindings, + ...webBindings, + } +} + +export const bindings = getActiveBindings() + +/** + * A composable that hooks to the caller component's + * lifecycle and hooks to the keyboard events to fire + * the appropriate actions based on keybindings + */ +export function hookKeybindingsListener() { + onMounted(async () => { + // Use capture phase to intercept events before browser handles them + document.addEventListener("keydown", handleKeyDown, true) + + // Listen for Tauri events (desktop only) + if (getKernelMode() === "desktop") { + try { + unlistenTauriEvent = await listen( + "hoppscotch_desktop_shortcut", + (ev) => { + console.info("Tauri shortcut ev", ev) + handleTauriShortcut(ev.payload as string) + } + ) + } catch (error) { + console.error("Failed to setup Tauri event listener:", error) + } + } + }) + + onBeforeUnmount(() => { + document.removeEventListener("keydown", handleKeyDown, true) + + if (unlistenTauriEvent) { + unlistenTauriEvent() + unlistenTauriEvent = null + } + }) +} + +function handleKeyDown(ev: KeyboardEvent) { + // Do not check keybinds if the mode is disabled + if (!keybindingsEnabled) return + + // Skip during IME composition (CJK input). Modern browsers report + // `isComposing`. Older ones use the sentinel `keyCode === 229`. + // Either way the keystroke belongs to a composition, not a shortcut. + if (ev.isComposing || ev.keyCode === 229) return + + // Skip when AltGr is the modifier. Browsers report AltGr as Ctrl+Alt + // on Windows, so QWERTZ users typing `[` via AltGr+8 would otherwise + // match Ctrl+Alt+[ (the MRU tab shortcut) and steal the keystroke. + // `getModifierState("AltGraph")` is true only for AltGr, not for + // genuine Ctrl+Alt presses. + if (ev.getModifierState("AltGraph")) return + + const binding = generateKeybindingString(ev) + if (!binding) return + + const activeBindings = getActiveBindings() + const boundAction = activeBindings[binding] + + // Special handling for Ctrl+D (tab close for web browsers) + if (binding === "ctrl-d" && boundAction) { + ev.preventDefault() + ev.stopPropagation() + ev.stopImmediatePropagation() + + if (boundAction) { + invokeAction(boundAction, undefined, "keypress") + } + return + } + + // Special handling for undo/redo - let CodeMirror and Monaco handle these in editors + if (binding === "ctrl-z" || binding === "ctrl-y") { + const target = ev.target + if ( + isDOMElement(target) && + (isCodeMirrorEditor(target) || + isMonacoEditor(target) || + isTypableElement(target)) + ) { + return + } + } + + // Special handling for comment toggle - let CodeMirror and Monaco handle this in editors + if (binding === "ctrl-/") { + const target = ev.target + + if (!isDOMElement(target)) return + + // Let editors handle it normally + if (isCodeMirrorEditor(target) || isMonacoEditor(target)) return + + // If inside shortcuts flyout, always toggle it (even if focused on search input) + // If not in editor or input, fall back to keybinds flyout + const shouldToggle = + isInShortcutsFlyout(target) || !isTypableElement(target) + + if (shouldToggle) { + invokeAction("flyouts.keybinds.toggle", undefined, "keypress") + ev.preventDefault() + return + } + + // If in a normal input field, let browser handle it + return + } + + // Special handling for shift-/ (support menu) - don't trigger in editors or inputs + if (binding === "shift-/") { + const target = ev.target + + if (!isDOMElement(target)) return + + // Let editors and inputs handle it normally (user is just typing "/") + if ( + isCodeMirrorEditor(target) || + isMonacoEditor(target) || + isTypableElement(target) + ) { + return + } + } + + // If no action is bound, do nothing + if (!boundAction) return + + ev.preventDefault() + invokeAction(boundAction, undefined, "keypress") +} + +function handleTauriShortcut(shortcut: string) { + console.info("Tauri shortcut:", shortcut) + + // Do not check keybinds if the mode is disabled + if (!keybindingsEnabled) return + + const activeBindings = getActiveBindings() + const boundAction = activeBindings[shortcut as ShortcutKey] + if (!boundAction) return + + invokeAction(boundAction, undefined, "keypress") +} + +function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { + const target = ev.target + + // We may or may not have a modifier key + const modifierKey = getActiveModifier(ev) + + // We will always have a non-modifier key + const key = getPressedKey(ev) + if (!key) return null + + // All key combos backed by modifiers are valid shortcuts (whether currently typing or not) + if (modifierKey) { + // If the modifier is shift and the target is an input or codemirror editor, we ignore + if ( + modifierKey === "shift" && + isDOMElement(target) && + (isTypableElement(target) || isCodeMirrorEditor(target)) + ) { + return null + } + + // Restrict alt+up and alt+down when the target is a CodeMirror or Monaco editor + if ( + modifierKey === "alt" && + (key === "up" || key === "down") && + (isCodeMirrorEditor(target) || isMonacoEditor(target)) + ) { + return null + } + + return `${modifierKey}-${key}` + } + + // no modifier key here then we do not do anything while on input + if (isDOMElement(target) && isTypableElement(target)) return null + + // single key while not input + return `${key}` +} + +function getPressedKey(ev: KeyboardEvent): Key | null { + return resolvePressedKey(ev, getKeyboardLayoutStrategy()) +} + +// Minimal subset of `KeyboardEvent` so unit tests can construct fixtures +// without a JSDOM event. `getModifierState` is optional because the only +// call site (numpad detection) tolerates its absence. +export type KeyboardEventLike = Pick & { + getModifierState?: KeyboardEvent["getModifierState"] +} + +/** + * Resolves a keyboard event into the registered shortcut key, dispatching + * letter and digit lookups through the active layout strategy. + * + * Strategies only affect the letter and digit branches because those are + * the keys whose `event.key` and `event.code` diverge across layouts. + * Arrow keys, Tab, Enter, brackets, and the "?" → "/" mapping are layout + * stable, so the same checks apply regardless of strategy. + * + * `"key"` uses `event.key` (the typed character), which suits AZERTY, + * QWERTZ, and Dvorak users whose keycap labels match the shortcut they + * want to fire. `"code"` uses `event.code` (the physical key position), + * which suits Cyrillic and CJK users with US-QWERTY muscle memory. + * `"hybrid"` prefers `event.key` when it produces a Latin glyph and + * falls back to `event.code` otherwise, covering both populations. + * + * Both `getPressedKey` (the in-page handler entry) and the capture-phase + * listener in `selfhost-web/main.ts` call this with the active strategy + * from `getKeyboardLayoutStrategy`. + */ +export function resolvePressedKey( + ev: KeyboardEventLike, + strategy: KeyboardLayoutStrategy +): Key | null { + const key = (ev.key ?? "").toLowerCase() + const code = ev.code ?? "" + + // Letters + const letterFromKey = + key.length === 1 && key >= "a" && key <= "z" ? (key as Key) : null + const letterFromCode = + code.startsWith("Key") && code.length === 4 + ? (code[3].toLowerCase() as Key) + : null + // The "code" branch falls back to event.key when event.code is empty + // (synthetic events, certain older environments) so a Latin-letter + // shortcut still resolves rather than silently dropping. Matches the + // pre-strategy resolver's contract. + const letter = + strategy === "key" + ? letterFromKey + : strategy === "code" + ? (letterFromCode ?? (!code ? letterFromKey : null)) + : (letterFromKey ?? letterFromCode) + if (letter) return letter + + // Arrow keys (ArrowUp → up, etc) + if (key.startsWith("arrow")) { + return key.slice(5) as Key + } + + if (key === "tab") return "tab" + if (key === "delete") return "delete" + if (key === "backspace") return "backspace" + + // Shift+/ produces "?" on most layouts but the shortcut is registered as "/" + if (key === "?") return "/" + + // Punctuation checked before digit codes because some layouts produce + // these characters from physical digit keys (e.g. AZERTY produces [ + // via AltGr+5 which has code "Digit5"). + if (key === "/" || key === "." || key === "enter") return key + if (key === "[" || key === "]") return key + + // Bracket fallback for non-Latin layouts where the physical bracket + // keys don't type [/] (e.g. Russian Cyrillic where KeyBracketLeft + // types "х"). The shortcut is registered as ctrl-alt-[ so users + // pressing the keycap labelled [ still fire it regardless of layout. + if (code === "BracketLeft") return "[" + if (code === "BracketRight") return "]" + + // Digits + const digitFromKey = + key.length === 1 && key >= "0" && key <= "9" ? (key as Key) : null + const digitFromCode = + code.startsWith("Digit") && code.length === 6 ? (code[5] as Key) : null + const digit = + strategy === "key" + ? digitFromKey + : strategy === "code" + ? digitFromCode + : (digitFromKey ?? digitFromCode) + if (digit) return digit + + // Numpad digits (Numpad0–Numpad9), only when NumLock is on. + // When NumLock is off the physical keys act as navigation (Home, End, etc) + // but event.code still returns Numpad0-Numpad9. + if ( + code.startsWith("Numpad") && + code.length === 7 && + ev.getModifierState?.("NumLock") + ) { + return code.slice(6) as Key + } + + return null +} + +function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null { + const modifierKeys = { + ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, + alt: ev.altKey, + shift: ev.shiftKey, + } + + // active modifier: ctrl | alt | ctrl-alt | ctrl-shift | ctrl-alt-shift | alt-shift + // modiferKeys object's keys are sorted to match the above order + const activeModifier = Object.keys(modifierKeys) + .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) + .join("-") + + return activeModifier === "" ? null : (activeModifier as ModifierKeys) +} + +/** + * This composable allows for the UI component to be disabled if the component in question is mounted + */ +export function useKeybindingDisabler() { + // TODO: Move to a lock based system that keeps the bindings disabled until all locks are lifted + const disableKeybindings = () => { + keybindingsEnabled = false + } + + const enableKeybindings = () => { + keybindingsEnabled = true + } + + return { + disableKeybindings, + enableKeybindings, + } +} diff --git a/packages/hoppscotch-common/src/helpers/keyboard-strategy.ts b/packages/hoppscotch-common/src/helpers/keyboard-strategy.ts new file mode 100644 index 0000000..c58f222 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/keyboard-strategy.ts @@ -0,0 +1,35 @@ +// Relative import rather than the `~/` alias because this module is +// consumed transitively by both the web entry (where `~` resolves to +// common's src) and the desktop shell entry (where `~` resolves to the +// shell's own src). See `composables/desktop-settings.ts` for the full +// rationale. +import type { DesktopSettings } from "../platform/desktop-settings" + +/** + * Web-safe holder for the keyboard layout strategy. + * + * The desktop settings composable is the only writer: it calls + * `setKeyboardLayoutStrategy` after the initial settings load and from + * its store-watch callback when the user changes the radio. Keeping + * the holder out of the composable lets `keybindings.ts` read the + * strategy without pulling in Tauri-only imports, so the same + * `getPressedKey` works on web builds where Tauri isn't available. + * + * The default `"hybrid"` matches the schema default, so a keypress + * before the composable finishes loading still resolves through the + * recommended strategy. + */ + +export type KeyboardLayoutStrategy = DesktopSettings["keyboardLayoutStrategy"] + +let currentStrategy: KeyboardLayoutStrategy = "hybrid" + +export function getKeyboardLayoutStrategy(): KeyboardLayoutStrategy { + return currentStrategy +} + +export function setKeyboardLayoutStrategy( + strategy: KeyboardLayoutStrategy +): void { + currentStrategy = strategy +} diff --git a/packages/hoppscotch-common/src/helpers/lenses/__tests__/lenses.sample b/packages/hoppscotch-common/src/helpers/lenses/__tests__/lenses.sample new file mode 100644 index 0000000..e7d1180 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/lenses/__tests__/lenses.sample @@ -0,0 +1,84 @@ +import { lenses, getSuitableLenses, getLensRenderers } from "../lenses" +import rawLens from "../rawLens" + +describe("getSuitableLenses", () => { + test("returns raw lens if no content type reported (null/undefined)", () => { + const nullResult = getSuitableLenses({ + headers: { + "content-type": null, + }, + }) + + const undefinedResult = getSuitableLenses({ + headers: {}, + }) + + expect(nullResult).toHaveLength(1) + expect(nullResult).toContainEqual(rawLens) + + expect(undefinedResult).toHaveLength(1) + expect(undefinedResult).toContainEqual(rawLens) + }) + + const contentTypes = { + JSON: [ + "application/json", + "application/ld+json", + "application/hal+json; charset=utf8", + ], + Image: [ + "image/gif", + "image/jpeg; foo=bar", + "image/png", + "image/bmp", + "image/svg+xml", + "image/x-icon", + "image/vnd.microsoft.icon", + ], + HTML: ["text/html", "application/xhtml+xml", "text/html; charset=utf-8"], + XML: [ + "text/xml", + "application/xml", + "application/xhtml+xml; charset=utf-8", + ], + } + + lenses + .filter(({ lensName }) => lensName !== rawLens.lensName) + .forEach((el) => { + test(`returns ${el.lensName} lens for its content-types`, () => { + contentTypes[el.lensName].forEach((contentType) => { + expect( + getSuitableLenses({ + headers: { + "content-type": contentType, + }, + }) + ).toContainEqual(el) + }) + }) + + test(`returns Raw Lens along with ${el.lensName} for the content types`, () => { + contentTypes[el.lensName].forEach((contentType) => { + expect( + getSuitableLenses({ + headers: { + "content-type": contentType, + }, + }) + ).toContainEqual(rawLens) + }) + }) + }) +}) + +describe("getLensRenderers", () => { + test("returns all the lens renderers", () => { + const res = getLensRenderers() + + lenses.forEach(({ renderer, rendererImport }) => { + expect(res).toHaveProperty(renderer) + expect(res[renderer]).toBe(rendererImport) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/lenses/audioLens.ts b/packages/hoppscotch-common/src/helpers/lenses/audioLens.ts new file mode 100644 index 0000000..ba696e5 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/lenses/audioLens.ts @@ -0,0 +1,16 @@ +import { defineAsyncComponent } from "vue" +import { Lens } from "./lenses" + +const audioLens: Lens = { + lensName: "response.audio", + isSupportedContentType: (contentType) => + /\baudio\/(?:wav|mpeg|mp4|aac|aacp|ogg|webm|x-caf|flac|mp3|)\b/i.test( + contentType + ), + renderer: "audiores", + rendererImport: defineAsyncComponent( + () => import("~/components/lenses/renderers/AudioLensRenderer.vue") + ), +} + +export default audioLens diff --git a/packages/hoppscotch-common/src/helpers/lenses/htmlLens.ts b/packages/hoppscotch-common/src/helpers/lenses/htmlLens.ts new file mode 100644 index 0000000..33e0f89 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/lenses/htmlLens.ts @@ -0,0 +1,14 @@ +import { defineAsyncComponent } from "vue" +import { Lens } from "./lenses" + +const htmlLens: Lens = { + lensName: "response.html", + isSupportedContentType: (contentType) => + /\btext\/html|application\/xhtml\+xml\b/i.test(contentType), + renderer: "htmlres", + rendererImport: defineAsyncComponent( + () => import("~/components/lenses/renderers/HTMLLensRenderer.vue") + ), +} + +export default htmlLens diff --git a/packages/hoppscotch-common/src/helpers/lenses/imageLens.ts b/packages/hoppscotch-common/src/helpers/lenses/imageLens.ts new file mode 100644 index 0000000..a468c7f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/lenses/imageLens.ts @@ -0,0 +1,16 @@ +import { defineAsyncComponent } from "vue" +import { Lens } from "./lenses" + +const imageLens: Lens = { + lensName: "response.image", + isSupportedContentType: (contentType) => + /\bimage\/(?:gif|jpeg|png|webp|bmp|svg\+xml|x-icon|vnd\.microsoft\.icon)\b/i.test( + contentType + ), + renderer: "imageres", + rendererImport: defineAsyncComponent( + () => import("~/components/lenses/renderers/ImageLensRenderer.vue") + ), +} + +export default imageLens diff --git a/packages/hoppscotch-common/src/helpers/lenses/jsonLens.ts b/packages/hoppscotch-common/src/helpers/lenses/jsonLens.ts new file mode 100644 index 0000000..00dc373 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/lenses/jsonLens.ts @@ -0,0 +1,39 @@ +import { defineAsyncComponent } from "vue" +import { Lens } from "./lenses" +import { isJSONContentType } from "../utils/contenttypes" + +/** + * Checks if response body contents can be parsed as valid JSON + */ +export function isValidJSONResponse(contents: string | ArrayBuffer): boolean { + if (!contents) { + return false + } + + const resolvedStr = + contents instanceof ArrayBuffer + ? new TextDecoder("utf-8").decode(contents) + : contents + + if (!resolvedStr.trim()) { + return false + } + + try { + JSON.parse(resolvedStr) + return true + } catch (_e) { + return false + } +} + +const jsonLens: Lens = { + lensName: "response.json", + isSupportedContentType: isJSONContentType, + renderer: "json", + rendererImport: defineAsyncComponent( + () => import("~/components/lenses/renderers/JSONLensRenderer.vue") + ), +} + +export default jsonLens diff --git a/packages/hoppscotch-common/src/helpers/lenses/lenses.ts b/packages/hoppscotch-common/src/helpers/lenses/lenses.ts new file mode 100644 index 0000000..3a91246 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/lenses/lenses.ts @@ -0,0 +1,111 @@ +import { HoppRESTResponse } from "../types/HoppRESTResponse" +import jsonLens, { isValidJSONResponse } from "./jsonLens" +import rawLens from "./rawLens" +import imageLens from "./imageLens" +import htmlLens from "./htmlLens" +import xmlLens from "./xmlLens" +import pdfLens from "./pdfLens" +import audioLens from "./audioLens" +import videoLens from "./videoLens" +import { defineAsyncComponent } from "vue" + +export type Lens = { + lensName: string + isSupportedContentType: (contentType: string) => boolean + renderer: string + rendererImport: ReturnType +} + +export const lenses: Lens[] = [ + jsonLens, + imageLens, + htmlLens, + xmlLens, + pdfLens, + audioLens, + videoLens, + rawLens, +] + +export function getSuitableLenses(response: HoppRESTResponse): Lens[] { + // return empty array if response is loading or error + if ( + response.type === "loading" || + response.type === "network_fail" || + response.type === "script_fail" || + response.type === "fail" || + response.type === "extension_error" + ) + return [] + + // Lowercase the content-type key because HTTP Headers are case-insensitive by spec + const contentType = response.headers.find( + (h) => h.key.toLowerCase() === "content-type" + ) + + // If no content type is found, return raw lens as fallback + if (!contentType) return [rawLens] + + // For successful responses, use a smarter approach + if (response.type === "success") { + // First, get lenses that match the content type + const matchingLenses = lenses.filter((lens) => + lens.isSupportedContentType(contentType.value) + ) + + // For text-based content types, check if content can be parsed as other formats + const isTextBased = + contentType.value.includes("text/") || + contentType.value.includes("application/javascript") || + contentType.value.includes("application/xml") || + contentType.value.includes("application/xhtml+xml") || + htmlLens.isSupportedContentType(contentType.value) + + if (isTextBased && response.body) { + // Check if content is valid JSON + if ( + isValidJSONResponse(response.body) && + !matchingLenses.includes(jsonLens) + ) { + // Add JSON lens as an additional option, but keep it after the original content type lens + // This ensures the original content type lens is selected by default + matchingLenses.push(jsonLens) + } + + // Add other content type detection here if needed + // e.g., check if content is valid XML, HTML, etc. + } + + // If no matching lenses found, include all lenses to give user full control + if (matchingLenses.length === 0) { + return lenses + } + + // Always include raw lens for viewing the raw response + if (!matchingLenses.includes(rawLens)) { + matchingLenses.push(rawLens) + } + + // Return matching lenses plus raw lens + return matchingLenses + } + + // For other response types, use the standard content type detection + const result = [] + for (const lens of lenses) { + if (lens.isSupportedContentType(contentType.value)) result.push(lens) + } + return result +} + +type LensRenderers = { + [key: string]: Lens["rendererImport"] +} + +export function getLensRenderers(): LensRenderers { + const response: LensRenderers = {} + for (const lens of lenses) { + response[lens.renderer] = lens.rendererImport + } + return response +} diff --git a/packages/hoppscotch-common/src/helpers/lenses/pdfLens.ts b/packages/hoppscotch-common/src/helpers/lenses/pdfLens.ts new file mode 100644 index 0000000..af7348d --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/lenses/pdfLens.ts @@ -0,0 +1,14 @@ +import { defineAsyncComponent } from "vue" +import { Lens } from "./lenses" + +const pdfLens: Lens = { + lensName: "response.pdf", + isSupportedContentType: (contentType) => + /\bapplication\/pdf\b/i.test(contentType), + renderer: "pdfres", + rendererImport: defineAsyncComponent( + () => import("~/components/lenses/renderers/PDFLensRenderer.vue") + ), +} + +export default pdfLens diff --git a/packages/hoppscotch-common/src/helpers/lenses/rawLens.ts b/packages/hoppscotch-common/src/helpers/lenses/rawLens.ts new file mode 100644 index 0000000..8c7515e --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/lenses/rawLens.ts @@ -0,0 +1,13 @@ +import { defineAsyncComponent } from "vue" +import { Lens } from "./lenses" + +const rawLens: Lens = { + lensName: "response.raw", + isSupportedContentType: () => true, + renderer: "raw", + rendererImport: defineAsyncComponent( + () => import("~/components/lenses/renderers/RawLensRenderer.vue") + ), +} + +export default rawLens diff --git a/packages/hoppscotch-common/src/helpers/lenses/videoLens.ts b/packages/hoppscotch-common/src/helpers/lenses/videoLens.ts new file mode 100644 index 0000000..184e7e8 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/lenses/videoLens.ts @@ -0,0 +1,16 @@ +import { defineAsyncComponent } from "vue" +import { Lens } from "./lenses" + +const videoLens: Lens = { + lensName: "response.video", + isSupportedContentType: (contentType) => + /\bvideo\/(?:webm|x-m4v|quicktime|x-ms-wmv|x-flv|mpeg|x-msvideo|x-ms-asf|mp4|)\b/i.test( + contentType + ), + renderer: "videores", + rendererImport: defineAsyncComponent( + () => import("~/components/lenses/renderers/VideoLensRenderer.vue") + ), +} + +export default videoLens diff --git a/packages/hoppscotch-common/src/helpers/lenses/xmlLens.ts b/packages/hoppscotch-common/src/helpers/lenses/xmlLens.ts new file mode 100644 index 0000000..017dc97 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/lenses/xmlLens.ts @@ -0,0 +1,13 @@ +import { defineAsyncComponent } from "vue" +import { Lens } from "./lenses" + +const xmlLens: Lens = { + lensName: "response.xml", + isSupportedContentType: (contentType) => /\bxml\b/i.test(contentType), + renderer: "xmlres", + rendererImport: defineAsyncComponent( + () => import("~/components/lenses/renderers/XMLLensRenderer.vue") + ), +} + +export default xmlLens diff --git a/packages/hoppscotch-common/src/helpers/migrations.ts b/packages/hoppscotch-common/src/helpers/migrations.ts new file mode 100644 index 0000000..9415f72 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/migrations.ts @@ -0,0 +1,15 @@ +import { settingsStore, applySetting } from "~/newstore/settings" + +/* + * This file contains all the migrations we have to perform overtime in various (persisted) + * state/store entries + */ + +export function performMigrations(): void { + // Migrate old default proxy URL to the new proxy URL (if not set / overridden) + if ( + settingsStore.value.PROXY_URL === "https://hoppscotch.apollosoftware.xyz/" + ) { + applySetting("PROXY_URL", "https://proxy.hoppscotch.io/") + } +} diff --git a/packages/hoppscotch-common/src/helpers/mockServer/exampleCollection.ts b/packages/hoppscotch-common/src/helpers/mockServer/exampleCollection.ts new file mode 100644 index 0000000..c1341ad --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/mockServer/exampleCollection.ts @@ -0,0 +1,336 @@ +import { + HoppCollection, + HoppRESTRequest, + makeCollection, + makeRESTRequest, +} from "@hoppscotch/data" +import { uniqueID } from "~/helpers/utils/uniqueID" + +const MOCK_URL_VAR = "<>" + +/** + * Returns a JSON string of the Pet Store example collection for import + * @returns JSON string representation of the collection + */ +export function getPetStoreExampleJSON(): string { + const collection = createExamplePetStoreCollection() + return JSON.stringify(collection) +} + +/** + * Creates an example Pet Store collection with 4 requests (GET, POST, PUT, DELETE) + * @param collectionName The name for the collection + * @returns A HoppCollection object with example pet store requests + */ +export function createExamplePetStoreCollection( + collectionName: string = "Pet Store Mock Server" +): HoppCollection { + const requests: HoppRESTRequest[] = [ + // GET - List all pets + makeRESTRequest({ + id: uniqueID(), + name: "Get All Pets", + method: "GET", + endpoint: `${MOCK_URL_VAR}/pets`, + params: [], + headers: [], + auth: { + authType: "inherit", + authActive: true, + }, + preRequestScript: "", + testScript: "", + body: { + contentType: null, + body: null, + }, + requestVariables: [], + responses: { + [uniqueID()]: { + status: 200, + body: JSON.stringify( + [ + { + id: 1, + name: "Buddy", + species: "Dog", + breed: "Golden Retriever", + age: 3, + status: "available", + }, + { + id: 2, + name: "Whiskers", + species: "Cat", + breed: "Siamese", + age: 2, + status: "available", + }, + { + id: 3, + name: "Charlie", + species: "Dog", + breed: "Beagle", + age: 4, + status: "adopted", + }, + ], + null, + 2 + ), + headers: [ + { + key: "Content-Type", + value: "application/json", + active: true, + description: "", + }, + ], + }, + }, + description: null, + }), + + // GET - Get a single pet by ID + makeRESTRequest({ + id: uniqueID(), + name: "Get Pet by ID", + method: "GET", + endpoint: `${MOCK_URL_VAR}/pets/1`, + params: [], + headers: [], + auth: { + authType: "inherit", + authActive: true, + }, + preRequestScript: "", + testScript: "", + body: { + contentType: null, + body: null, + }, + requestVariables: [], + responses: { + [uniqueID()]: { + status: 200, + body: JSON.stringify( + { + id: 1, + name: "Buddy", + species: "Dog", + breed: "Golden Retriever", + age: 3, + status: "available", + description: "Friendly and energetic golden retriever", + vaccinated: true, + neutered: true, + }, + null, + 2 + ), + headers: [ + { + key: "Content-Type", + value: "application/json", + active: true, + description: "", + }, + ], + }, + }, + description: null, + }), + + // POST - Create a new pet + makeRESTRequest({ + id: uniqueID(), + name: "Create New Pet", + method: "POST", + endpoint: `${MOCK_URL_VAR}/pets`, + params: [], + headers: [ + { + key: "Content-Type", + value: "application/json", + active: true, + description: "", + }, + ], + auth: { + authType: "inherit", + authActive: true, + }, + preRequestScript: "", + testScript: "", + body: { + contentType: "application/json", + body: JSON.stringify( + { + name: "Max", + species: "Dog", + breed: "Labrador", + age: 2, + status: "available", + description: "Playful labrador looking for a home", + vaccinated: true, + neutered: false, + }, + null, + 2 + ), + }, + requestVariables: [], + responses: { + [uniqueID()]: { + status: 201, + body: JSON.stringify( + { + id: 4, + name: "Max", + species: "Dog", + breed: "Labrador", + age: 2, + status: "available", + description: "Playful labrador looking for a home", + vaccinated: true, + neutered: false, + createdAt: new Date().toISOString(), + }, + null, + 2 + ), + headers: [ + { + key: "Content-Type", + value: "application/json", + active: true, + description: "", + }, + { + key: "Location", + value: "/pets/4", + active: true, + description: "", + }, + ], + }, + }, + description: null, + }), + + // PUT - Update an existing pet + makeRESTRequest({ + id: uniqueID(), + name: "Update Pet", + method: "PUT", + endpoint: `${MOCK_URL_VAR}/pets/1`, + params: [], + headers: [ + { + key: "Content-Type", + value: "application/json", + active: true, + description: "", + }, + ], + auth: { + authType: "inherit", + authActive: true, + }, + preRequestScript: "", + testScript: "", + body: { + contentType: "application/json", + body: JSON.stringify( + { + name: "Buddy", + species: "Dog", + breed: "Golden Retriever", + age: 4, + status: "adopted", + description: "Friendly golden retriever - Now adopted!", + vaccinated: true, + neutered: true, + }, + null, + 2 + ), + }, + requestVariables: [], + responses: { + [uniqueID()]: { + status: 200, + body: JSON.stringify( + { + id: 1, + name: "Buddy", + species: "Dog", + breed: "Golden Retriever", + age: 4, + status: "adopted", + description: "Friendly golden retriever - Now adopted!", + vaccinated: true, + neutered: true, + updatedAt: new Date().toISOString(), + }, + null, + 2 + ), + headers: [ + { + key: "Content-Type", + value: "application/json", + active: true, + description: "", + }, + ], + }, + }, + description: null, + }), + + // DELETE - Delete a pet + makeRESTRequest({ + id: uniqueID(), + name: "Delete Pet", + method: "DELETE", + endpoint: `${MOCK_URL_VAR}/pets/3`, + params: [], + headers: [], + auth: { + authType: "inherit", + authActive: true, + }, + preRequestScript: "", + testScript: "", + body: { + contentType: null, + body: null, + }, + requestVariables: [], + responses: { + [uniqueID()]: { + status: 204, + body: "", + headers: [], + }, + }, + description: null, + }), + ] + + return makeCollection({ + name: collectionName, + folders: [], + requests, + auth: { + authType: "inherit", + authActive: true, + }, + headers: [], + variables: [], + description: null, + preRequestScript: "", + testScript: "", + }) +} diff --git a/packages/hoppscotch-common/src/helpers/mockServer/exampleMockCollection.ts b/packages/hoppscotch-common/src/helpers/mockServer/exampleMockCollection.ts new file mode 100644 index 0000000..6d0e4f9 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/mockServer/exampleMockCollection.ts @@ -0,0 +1,356 @@ +import { pipe } from "fp-ts/function" +import * as TE from "fp-ts/TaskEither" +import * as E from "fp-ts/Either" +import { + HoppRESTRequest, + RESTReqSchemaVersion, + generateUniqueRefId, + makeCollection, +} from "@hoppscotch/data" +import { createNewRootCollection } from "~/helpers/backend/mutations/TeamCollection" +import { createRequestInCollection } from "~/helpers/backend/mutations/TeamRequest" +import { runMutation } from "~/helpers/backend/GQLClient" +import { + CreateRestRootUserCollectionDocument, + CreateRestRootUserCollectionMutation, + CreateRestRootUserCollectionMutationVariables, + CreateRestUserRequestDocument, + CreateRestUserRequestMutation, + CreateRestUserRequestMutationVariables, +} from "~/helpers/backend/graphql" +import { addRESTCollection } from "~/newstore/collections" + +/** + * Get example REST requests for mock server collection + */ +export function getExampleMockRequests(): HoppRESTRequest[] { + const petBody = JSON.stringify( + { + id: 1, + category: { + id: 1, + name: "string", + }, + name: "doggie", + photoUrls: ["string"], + tags: [], + status: "available", + }, + null, + 2 + ) + + const oauthAuth = { + authType: "oauth-2" as const, + authActive: true, + grantTypeInfo: { + authEndpoint: "<>/oauth/authorize", + clientID: "", + grantType: "IMPLICIT" as const, + scopes: "write:pets read:pets", + token: "", + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: "HEADERS" as const, + } + + const requests: HoppRESTRequest[] = [ + // addPet request + { + v: RESTReqSchemaVersion, + name: "addPet", + method: "POST", + endpoint: "<>/v2/pet", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + body: { + contentType: "application/json", + body: petBody, + }, + auth: oauthAuth, + requestVariables: [], + responses: {}, + description: "", + }, + // updatePet request + { + v: RESTReqSchemaVersion, + name: "updatePet", + method: "PUT", + endpoint: "<>/v2/pet", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + body: { + contentType: "application/json", + body: petBody, + }, + auth: oauthAuth, + requestVariables: [], + responses: {}, + description: "", + }, + // findByStatus request + { + v: RESTReqSchemaVersion, + name: "findByStatus", + method: "GET", + endpoint: "<>/v2/pet/findByStatus", + params: [ + { + key: "status", + value: "available", + active: true, + description: "", + }, + ], + headers: [], + preRequestScript: "", + testScript: "", + body: { + contentType: null, + body: null, + }, + auth: oauthAuth, + requestVariables: [], + responses: {}, + description: "", + }, + // getPetById request + { + v: RESTReqSchemaVersion, + name: "getPetById", + method: "GET", + endpoint: "<>/v2/pet/1", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + body: { + contentType: null, + body: null, + }, + auth: { + authType: "api-key", + authActive: true, + key: "api_key", + value: "", + addTo: "HEADERS", + }, + requestVariables: [], + responses: {}, + description: "", + }, + // updatePetWithForm request + { + v: RESTReqSchemaVersion, + name: "updatePetWithForm", + method: "POST", + endpoint: "<>/v2/pet/1", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + body: { + contentType: "application/x-www-form-urlencoded", + body: "name=doggie&status=available", + }, + auth: oauthAuth, + requestVariables: [], + responses: {}, + description: "", + }, + // deletePet request + { + v: RESTReqSchemaVersion, + name: "deletePet", + method: "DELETE", + endpoint: "<>/v2/pet/1", + params: [], + headers: [ + { + key: "api_key", + value: "", + active: true, + description: "", + }, + ], + preRequestScript: "", + testScript: "", + body: { + contentType: null, + body: null, + }, + auth: oauthAuth, + requestVariables: [], + responses: {}, + description: "", + }, + ] + + return requests +} + +/** + * Create a mock collection for team workspace + */ +export async function createMockCollectionForTeam( + teamID: string, + collectionName: string +): Promise> { + // Create the root collection + const collectionResult = await pipe( + createNewRootCollection(collectionName, teamID), + TE.match( + (error) => E.left(`Failed to create collection: ${error}`), + (collection) => E.right(collection) + ) + )() + + if (E.isLeft(collectionResult)) { + return collectionResult + } + + const collectionID = collectionResult.right.createRootCollection.id + + // Create requests in the collection + const requests = getExampleMockRequests() + + for (const request of requests) { + const requestResult = await pipe( + createRequestInCollection(collectionID, { + request: JSON.stringify(request), + teamID, + title: request.name, + }), + TE.match( + (error) => E.left(`Failed to create request: ${error}`), + (req) => E.right(req) + ) + )() + + if (E.isLeft(requestResult)) { + // Log error but continue with other requests + console.error( + "Failed to create request:", + request.name, + requestResult.left + ) + } + } + + return E.right({ + id: collectionID, + name: collectionName, + }) +} + +/** + * Create a mock collection for personal workspace + * Uses backend GraphQL mutations to create the collection with proper backend ID + */ +export async function createMockCollectionForPersonal( + collectionName: string +): Promise> { + const data = { + auth: { + authType: "inherit" as const, + authActive: true, + }, + headers: [], + variables: [], + _ref_id: generateUniqueRefId("coll"), + description: null, + preRequestScript: "", + testScript: "", + } + + // Create the root collection using GraphQL mutation + const collectionResult = await pipe( + runMutation< + CreateRestRootUserCollectionMutation, + CreateRestRootUserCollectionMutationVariables, + "" + >(CreateRestRootUserCollectionDocument, { + title: collectionName, + data: JSON.stringify(data), + }), + TE.match( + (error) => E.left(`Failed to create collection: ${error}`), + (response) => E.right(response) + ) + )() + + if (E.isLeft(collectionResult)) { + return collectionResult + } + + // Extract the collection ID from the response + const collectionID = collectionResult.right.createRESTRootUserCollection.id + + // Create requests in the collection using GraphQL mutation + const requests = getExampleMockRequests() + const createdRequests: HoppRESTRequest[] = [] + + for (const request of requests) { + const requestResult = await pipe( + runMutation< + CreateRestUserRequestMutation, + CreateRestUserRequestMutationVariables, + "" + >(CreateRestUserRequestDocument, { + collectionID, + title: request.name, + request: JSON.stringify(request), + }), + TE.match( + (error) => E.left(`Failed to create request: ${error}`), + (req) => E.right(req) + ) + )() + + if (E.isLeft(requestResult)) { + // Log error but continue with other requests + console.error( + "Failed to create request:", + request.name, + requestResult.left + ) + } else { + // Add the request ID to the created request + const createdRequest = { + ...request, + id: requestResult.right.createRESTUserRequest.id, + } + createdRequests.push(createdRequest) + } + } + + // Create a HoppCollection object and add it to the store immediately + const collection = makeCollection({ + name: collectionName, + folders: [], + requests: createdRequests, + auth: data.auth, + headers: data.headers, + variables: data.variables, + description: null, + preRequestScript: "", + testScript: "", + }) + + // Add the backend ID to the collection + collection.id = collectionID + + // Add the collection to the store so it's visible immediately + addRESTCollection(collection) + + return E.right({ + id: collectionID, + name: collectionName, + }) +} diff --git a/packages/hoppscotch-common/src/helpers/network.ts b/packages/hoppscotch-common/src/helpers/network.ts new file mode 100644 index 0000000..64df67e --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/network.ts @@ -0,0 +1,80 @@ +import * as TE from "fp-ts/TaskEither" +import { BehaviorSubject, Observable } from "rxjs" +import { cloneDeep } from "lodash-es" +import { HoppRESTResponse } from "./types/HoppRESTResponse" +import { EffectiveHoppRESTRequest } from "./utils/EffectiveURL" +import { getService } from "~/modules/dioc" +import { KernelInterceptorService } from "~/services/kernel-interceptor.service" +import { RESTRequest, RESTResponse } from "~/helpers/kernel/rest" +import { RelayError } from "@hoppscotch/kernel" + +export type NetworkStrategy = ( + req: EffectiveHoppRESTRequest +) => TE.TaskEither + +export function createRESTNetworkRequestStream( + request: EffectiveHoppRESTRequest +): [Observable, () => void] { + const response = new BehaviorSubject({ + type: "loading", + req: request, + }) + + const req = cloneDeep(request) + + const execResult = RESTRequest.toRequest(req).then((kernelRequest) => { + if (!kernelRequest) { + response.next({ + type: "network_fail", + req, + error: new Error("Failed to create kernel request"), + }) + response.complete() + return + } + + return service.execute(kernelRequest) + }) + + const service = getService(KernelInterceptorService) + + execResult.then((result) => { + if (!result) return + + result.response.then(async (res) => { + if (res._tag === "Right") { + const processedRes = await RESTResponse.toResponse(res.right, req) + + if (processedRes.type === "success") { + response.next(processedRes) + } else { + response.next({ + type: "network_fail", + req, + error: processedRes.error, + }) + } + } else { + response.next({ + type: "interceptor_error", + req, + error: res.left, + }) + } + response.complete() + }) + }) + + return [ + response, + async () => { + try { + const result = await execResult + if (result) await result.cancel() + } catch (_error) { + // Ignore cancel errors - request may have already completed + // This is expected behavior and not an actual error + } + }, + ] +} diff --git a/packages/hoppscotch-common/src/helpers/new-codegen/har.ts b/packages/hoppscotch-common/src/helpers/new-codegen/har.ts new file mode 100644 index 0000000..c9fc285 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/new-codegen/har.ts @@ -0,0 +1,165 @@ +import * as RA from "fp-ts/ReadonlyArray" +import * as S from "fp-ts/string" +import { pipe, flow } from "fp-ts/function" +import * as Har from "har-format" +import { HoppRESTRequest } from "@hoppscotch/data" +import { FieldEquals, objectFieldIncludes } from "../typeutils" + +// Hoppscotch support HAR Spec 1.2 +// For more info on the spec: http://www.softwareishard.com/blog/har-12-spec/ + +const splitHarQueryParams = (sep: string) => { + return (s: string): Array => { + const out = pipe(s, S.split(sep)) + const [key, ...rest] = out + return [key, rest.join(sep)] // Split by the first colon and join the rest + } +} + +const buildHarHeaders = (req: HoppRESTRequest): Har.Header[] => { + return req.headers + .filter((header) => header.active) + .map((header) => ({ + name: header.key, + value: header.value, + })) +} + +const buildHarQueryStrings = (req: HoppRESTRequest): Har.QueryString[] => { + return req.params + .filter((param) => param.active) + .map((param) => ({ + name: param.key, + value: param.value, + })) +} + +const buildHarPostParams = ( + req: HoppRESTRequest & + FieldEquals & { + body: { + contentType: "application/x-www-form-urlencoded" | "multipart/form-data" + } + } +): Har.Param[] => { + // URL Encoded strings have a string style of contents + if (req.body.contentType === "application/x-www-form-urlencoded") { + return pipe( + req.body.body, + S.split("\n"), + RA.map( + flow( + // Define how each lines are parsed + + splitHarQueryParams(":"), // Split by the first ":" + RA.map(S.trim), // Remove trailing spaces in key/value begins and ends + ([key, value]) => ({ + // Convert into a proper key value definition + name: key, + value: value ?? "", // Value can be undefined (if no ":" is present) + }) + ) + ), + RA.toArray + ) + } + // FormData has its own format + return req.body.body.flatMap((entry) => { + if (entry.isFile) { + // We support multiple files + const values = Array.isArray(entry.value) ? entry.value : [entry.value] + return values.map( + (file) => + { + name: entry.key, + fileName: entry.key, // TODO: Blob doesn't contain file info, anyway to bring file name here ? + contentType: entry.contentType + ? entry.contentType + : typeof file === "object" && file && "type" in file + ? file.type + : undefined, + } + ) + } + + if (entry.contentType) { + return { + name: entry.key, + value: entry.value as string, + fileName: entry.key, + contentType: entry.contentType, + } + } + + return { + name: entry.key, + value: entry.value as string, + contentType: entry.contentType, + } + }) +} + +const buildHarPostData = (req: HoppRESTRequest): Har.PostData | undefined => { + if (!req.body.contentType) return undefined + + if ( + objectFieldIncludes(req.body, "contentType", [ + "application/x-www-form-urlencoded", + "multipart/form-data", + ] as const) + ) { + return { + mimeType: req.body.contentType, // By default assume JSON ? + params: buildHarPostParams(req as any), + } + } + + // application/octet-stream bodies are File | null; emit @filename for file uploads + if (req.body.contentType === "application/octet-stream") { + const file = req.body.body + + if (!file) { + return { + mimeType: req.body.contentType, + text: "", + } + } + + // `path` exists in some desktop runtimes; `name` is the standard File field. + const filename = + "path" in file && typeof file.path === "string" && file.path + ? file.path + : file.name || "" + + return { + mimeType: req.body.contentType, + text: `@${filename}`, + } + } + + return { + mimeType: req.body.contentType, // Let's assume by default content type is JSON + text: (req.body.body as string) ?? "", + } +} + +export const buildHarRequest = ( + req: HoppRESTRequest +): Har.Request & { + postData: Har.PostData & Exclude +} => { + return { + bodySize: -1, // TODO: It would be cool if we can calculate the body size + headersSize: -1, // TODO: It would be cool if we can calculate the header size + httpVersion: "HTTP/1.1", + cookies: [], // Hoppscotch does not have formal support for Cookies as of right now + headers: buildHarHeaders(req), + method: req.method, + queryString: buildHarQueryStrings(req), + url: req.endpoint, + postData: buildHarPostData(req) ?? { + mimeType: "x-unknown", + params: [], + }, + } +} diff --git a/packages/hoppscotch-common/src/helpers/new-codegen/index.ts b/packages/hoppscotch-common/src/helpers/new-codegen/index.ts new file mode 100644 index 0000000..cdb989b --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/new-codegen/index.ts @@ -0,0 +1,238 @@ +import { HTTPSnippet } from "@hoppscotch/httpsnippet" +import { HoppRESTRequest } from "@hoppscotch/data" +import * as O from "fp-ts/Option" +import * as E from "fp-ts/Either" +import { pipe } from "fp-ts/function" +import { buildHarRequest } from "./har" + +// Hoppscotch's Code Generation is Powered by HTTPSnippet (https://github.com/Kong/httpsnippet) +// If you want to add support for your favorite language/library, please contribute to the HTTPSnippet repo <3 + +/** + * An array defining all the code generators and their info + */ +export const CodegenDefinitions = [ + { + name: "c-curl", + lang: "c", + mode: "libcurl", + caption: "C - cURL", + }, + { + name: "clojure-clj_http", + lang: "clojure", + mode: "clj_http", + caption: "Clojure - clj-http", + }, + { + name: "csharp-httpclient", + lang: "csharp", + mode: "httpclient", + caption: "C# - HttpClient", + }, + { + name: "csharp-restsharp", + lang: "csharp", + mode: "restsharp", + caption: "C# - RestSharp", + }, + { + name: "go-native", + lang: "go", + mode: "native", + caption: "Go", + }, + { + name: "http-http1.1", + lang: "http", + mode: "http1.1", + caption: "HTTP - HTTP 1.1 Request String", + }, + { + name: "java-asynchttp", + lang: "java", + mode: "asynchttp", + caption: "Java - AsyncHTTPClient", + }, + { + name: "java-nethttp", + lang: "java", + mode: "nethttp", + caption: "Java - java.net.http", + }, + { + name: "java-okhttp", + lang: "java", + mode: "okhttp", + caption: "Java - OkHttp", + }, + { + name: "java-unirest", + lang: "java", + mode: "unirest", + caption: "Java - Unirest", + }, + { + name: "javascript-axios", + lang: "javascript", + mode: "axios", + caption: "JavaScript - Axios", + }, + { + name: "javascript-fetch", + lang: "javascript", + mode: "fetch", + caption: "JavaScript - Fetch", + }, + { + name: "javascript-jquery", + lang: "javascript", + mode: "jquery", + caption: "JavaScript - jQuery", + }, + { + name: "javascript-xhr", + lang: "javascript", + mode: "xhr", + caption: "JavaScript - XMLHttpRequest", + }, + { + name: "kotlin-okhttp", + lang: "kotlin", + mode: "okhttp", + caption: "Kotlin - OkHttp", + }, + { + name: "objc-nsurlsession", + lang: "objc", + mode: "nsurlsession", + caption: "Objective C - NSURLSession", + }, + { + name: "ocaml-cohttp", + lang: "ocaml", + mode: "cohttp", + caption: "OCaml - cohttp", + }, + { + name: "php-curl", + lang: "php", + mode: "curl", + caption: "PHP - cURL", + }, + { + name: "powershell-restmethod", + lang: "powershell", + mode: "restmethod", + caption: "Powershell - Invoke-RestMethod", + }, + { + name: "powershell-webrequest", + lang: "powershell", + mode: "webrequest", + caption: "Powershell - Invoke-WebRequest", + }, + { + name: "python-python3", + lang: "python", + mode: "python3", + caption: "Python - Python 3 Native", + }, + { + name: "python-requests", + lang: "python", + mode: "requests", + caption: "Python - Requests", + }, + { + name: "r-httr", + lang: "r", + mode: "httr", + caption: "R - httr", + }, + { + name: "ruby-native", + lang: "ruby", + mode: "native", + caption: "Ruby - Ruby Native", + }, + { + name: "rust-reqwest", + lang: "rust", + mode: "reqwest", + caption: "Rust - Reqwest", + }, + { + name: "shell-curl", + lang: "shell", + mode: "curl", + caption: "Shell - cURL", + }, + { + name: "shell-httpie", + lang: "shell", + mode: "httpie", + caption: "Shell - HTTPie", + }, + { + name: "shell-wget", + lang: "shell", + mode: "wget", + caption: "Shell - Wget", + }, + { + name: "swift-nsurlsession", + lang: "swift", + mode: "nsurlsession", + caption: "Swift - NSURLSession", + }, +] as const + +/** + * A type which defines all the valid code generators + */ +export type CodegenName = (typeof CodegenDefinitions)[number]["name"] + +/** + * A type which defines all the valid code generator languages + */ +export type CodegenLang = (typeof CodegenDefinitions)[number]["lang"] + +/** + * Generates Source Code for the given codgen + * @param codegen The codegen to apply + * @param req The request to generate using + * @returns An Option with the generated code snippet + */ +export const generateCode = ( + codegen: CodegenName, + req: HoppRESTRequest +): O.Option => { + // Since the Type contract guarantees a match in the array, we are enforcing non-null + const codegenInfo = CodegenDefinitions.find((v) => v.name === codegen)! + + return pipe( + E.tryCatch( + () => + new HTTPSnippet({ + ...buildHarRequest(req), + }).convert(codegenInfo.lang, codegenInfo.mode, { + indent: " ", + }), + (e) => { + console.error(e) + return e + } + ), + + // Only allow string output to pass through, else none + E.chainW( + E.fromPredicate( + (val): val is string => typeof val === "string", + () => "code generator failed" as const + ) + ), + + O.fromEither + ) +} diff --git a/packages/hoppscotch-common/src/helpers/newOutline.ts b/packages/hoppscotch-common/src/helpers/newOutline.ts new file mode 100644 index 0000000..e9e7b4a --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/newOutline.ts @@ -0,0 +1,100 @@ +import { + JSONArrayValue, + JSONObjectMember, + JSONObjectValue, + JSONValue, +} from "./jsonParse" + +type RootEntry = + | { + kind: "RootObject" + astValue: JSONObjectValue + } + | { + kind: "RootArray" + astValue: JSONArrayValue + } + +type ObjectMemberEntry = { + kind: "ObjectMember" + name: string + astValue: JSONObjectMember + astParent: JSONObjectValue +} + +type ArrayMemberEntry = { + kind: "ArrayMember" + index: number + astValue: JSONValue + astParent: JSONArrayValue +} + +type PathEntry = RootEntry | ObjectMemberEntry | ArrayMemberEntry + +export function getJSONOutlineAtPos( + jsonRootAst: JSONObjectValue | JSONArrayValue, + posIndex: number +): PathEntry[] | null { + try { + const rootObj = jsonRootAst + + if (posIndex > rootObj.end || posIndex < rootObj.start) + throw new Error("Invalid position") + + let current: JSONValue = rootObj + + const path: PathEntry[] = [] + + if (rootObj.kind === "Object") { + path.push({ + kind: "RootObject", + astValue: rootObj, + }) + } else { + path.push({ + kind: "RootArray", + astValue: rootObj, + }) + } + + while (current.kind === "Object" || current.kind === "Array") { + if (current.kind === "Object") { + const next: JSONObjectMember | undefined = current.members.find( + (member) => member.start <= posIndex && member.end >= posIndex + ) + + if (!next) throw new Error("Couldn't find child") + + path.push({ + kind: "ObjectMember", + name: next.key.value, + astValue: next, + astParent: current, + }) + + current = next.value + } else { + const nextIndex = current.values.findIndex( + (value) => value.start <= posIndex && value.end >= posIndex + ) + + if (nextIndex < 0) throw new Error("Couldn't find child") + + const next: JSONValue = current.values[nextIndex] + + path.push({ + kind: "ArrayMember", + index: nextIndex, + astValue: next, + astParent: current, + }) + + current = next + } + } + + return path + } catch (_e: any) { + return null + } +} diff --git a/packages/hoppscotch-common/src/helpers/oauth.ts b/packages/hoppscotch-common/src/helpers/oauth.ts new file mode 100644 index 0000000..d0daa3d --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/oauth.ts @@ -0,0 +1,188 @@ +import * as E from "fp-ts/Either" +import { z } from "zod" + +import { getService } from "~/modules/dioc" +import { PersistenceService } from "~/services/persistence" +import { KernelInterceptorService } from "~/services/kernel-interceptor.service" +import { content } from "@hoppscotch/kernel" + +const kernelInterceptor = getService(KernelInterceptorService) +const persistenceService = getService(PersistenceService) + +const redirectUri = `${window.location.origin}/oauth` + +export type TokenRequestParams = { + oidcDiscoveryUrl: string + grantType: string + authUrl: string + accessTokenUrl: string + clientId: string + clientSecret: string + scope: string +} + +async function getTokenConfiguration(endpoint: string) { + const { response } = kernelInterceptor.execute({ + id: Date.now(), + url: endpoint, + method: "GET", + version: "HTTP/1.1", + headers: { + "Content-Type": ["application/json"], + }, + }) + + const result = await response + if (E.isLeft(result)) return E.left("OIDC_DISCOVERY_FAILED") + + const jsonContent = result.right.content + if (jsonContent.kind !== "json") return E.left("OIDC_DISCOVERY_FAILED") + + return E.right(jsonContent.content) +} + +const generateRandomString = () => { + const array = new Uint32Array(28) + window.crypto.getRandomValues(array) + return Array.from(array, (dec) => `0${dec.toString(16)}`.slice(-2)).join("") +} + +const base64urlencode = (str: ArrayBuffer) => { + const hashArray = Array.from(new Uint8Array(str)) + return btoa(String.fromCharCode.apply(null, hashArray)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, "") +} + +const pkceChallengeFromVerifier = async (v: string) => { + const encoder = new TextEncoder() + const data = encoder.encode(v) + const hashed = await window.crypto.subtle.digest("SHA-256", data) + return base64urlencode(hashed) +} + +export const tokenRequest = async ({ + oidcDiscoveryUrl, + grantType, + authUrl, + accessTokenUrl, + clientId, + clientSecret, + scope, +}: TokenRequestParams) => { + if (oidcDiscoveryUrl) { + const res = await getTokenConfiguration(oidcDiscoveryUrl) + + const OIDCConfigurationSchema = z.object({ + authorization_endpoint: z.string(), + token_endpoint: z.string(), + }) + + if (E.isLeft(res)) return E.left("OIDC_DISCOVERY_FAILED") + + const parsedOIDCConfiguration = OIDCConfigurationSchema.safeParse(res.right) + if (!parsedOIDCConfiguration.success) return E.left("OIDC_DISCOVERY_FAILED") + + authUrl = parsedOIDCConfiguration.data.authorization_endpoint + accessTokenUrl = parsedOIDCConfiguration.data.token_endpoint + } + + await persistenceService.setLocalConfig("tokenEndpoint", accessTokenUrl) + await persistenceService.setLocalConfig("client_id", clientId) + await persistenceService.setLocalConfig("client_secret", clientSecret) + + const state = generateRandomString() + await persistenceService.setLocalConfig("pkce_state", state) + + const codeVerifier = generateRandomString() + await persistenceService.setLocalConfig("pkce_codeVerifier", codeVerifier) + + const codeChallenge = await pkceChallengeFromVerifier(codeVerifier) + + const url = new URL(authUrl) + url.searchParams.set("response_type", grantType) + url.searchParams.set("client_id", clientId) + url.searchParams.set("state", state) + url.searchParams.set("scope", scope) + url.searchParams.set("redirect_uri", redirectUri) + url.searchParams.set("code_challenge", codeChallenge) + url.searchParams.set("code_challenge_method", "S256") + + window.location.assign(url.toString()) +} + +export const handleOAuthRedirect = async () => { + const queryParams = Object.fromEntries( + new URLSearchParams(window.location.search) + ) + + if (queryParams.error) return E.left("AUTH_SERVER_RETURNED_ERROR") + if (!queryParams.code) return E.left("NO_AUTH_CODE") + if ( + (await persistenceService.getLocalConfig("pkce_state")) !== + queryParams.state + ) { + return E.left("INVALID_STATE") + } + + const tokenEndpoint = await persistenceService.getLocalConfig("tokenEndpoint") + const clientID = await persistenceService.getLocalConfig("client_id") + const clientSecret = await persistenceService.getLocalConfig("client_secret") + const codeVerifier = + await persistenceService.getLocalConfig("pkce_codeVerifier") + + if (!tokenEndpoint) return E.left("NO_TOKEN_ENDPOINT") + if (!clientID) return E.left("NO_CLIENT_ID") + if (!clientSecret) return E.left("NO_CLIENT_SECRET") + if (!codeVerifier) return E.left("NO_CODE_VERIFIER") + + const requestParams = { + grant_type: "authorization_code", + code: queryParams.code, + client_id: clientID, + client_secret: clientSecret, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + } + + const { response } = kernelInterceptor.execute({ + id: Date.now(), + url: tokenEndpoint, + method: "POST", + version: "HTTP/1.1", + headers: { + "Content-Type": ["application/x-www-form-urlencoded"], + }, + content: content.urlencoded(requestParams), + }) + + clearPKCEState() + + const result = await response + if (E.isLeft(result)) return E.left("AUTH_TOKEN_REQUEST_FAILED") + + if (result.right.content.kind !== "json") { + return E.left("AUTH_TOKEN_REQUEST_FAILED") + } + + const withAccessTokenSchema = z.object({ + access_token: z.string(), + }) + + const parsedTokenResponse = withAccessTokenSchema.safeParse( + result.right.content.content + ) + + return parsedTokenResponse.success + ? E.right(parsedTokenResponse.data) + : E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE") +} + +const clearPKCEState = async () => { + await persistenceService.removeLocalConfig("pkce_state") + await persistenceService.removeLocalConfig("pkce_codeVerifier") + await persistenceService.removeLocalConfig("tokenEndpoint") + await persistenceService.removeLocalConfig("client_id") + await persistenceService.removeLocalConfig("client_secret") +} diff --git a/packages/hoppscotch-common/src/helpers/oauth2Params.ts b/packages/hoppscotch-common/src/helpers/oauth2Params.ts new file mode 100644 index 0000000..b12e94c --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/oauth2Params.ts @@ -0,0 +1,51 @@ +export const commonOAuth2AuthParams = [ + "audience", + "scope", + "state", + "nonce", + "prompt", + "max_age", + "ui_locales", + "id_token_hint", + "login_hint", + "acr_values", + "response_mode", + "display", + "claims", + "request", + "request_uri", +] + +export const commonOAuth2TokenParams = [ + "grant_type", + "code", + "redirect_uri", + "client_id", + "client_secret", + "code_verifier", + "username", + "password", + "scope", + "audience", + "resource", + "assertion", + "assertion_type", + "refresh_token", +] + +export const commonOAuth2RefreshParams = [ + "grant_type", + "refresh_token", + "client_id", + "client_secret", + "scope", + "audience", + "resource", +] + +export const sendInOptions = ["body", "url", "headers"] as const +export const sendInOptionsLabels = { + body: "Request Body", + url: "Request URL", + headers: "Request Headers", +} diff --git a/packages/hoppscotch-common/src/helpers/platformutils.ts b/packages/hoppscotch-common/src/helpers/platformutils.ts new file mode 100644 index 0000000..deafe80 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/platformutils.ts @@ -0,0 +1,11 @@ +export function isAppleDevice() { + return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) +} + +export function getPlatformSpecialKey() { + return isAppleDevice() ? "⌘" : "Ctrl" +} + +export function getPlatformAlternateKey() { + return isAppleDevice() ? "⌥" : "Alt" +} diff --git a/packages/hoppscotch-common/src/helpers/preRequestScriptSnippets.ts b/packages/hoppscotch-common/src/helpers/preRequestScriptSnippets.ts new file mode 100644 index 0000000..2e899a7 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/preRequestScriptSnippets.ts @@ -0,0 +1,21 @@ +export default [ + { + name: "Environment: Set an environment variable", + script: `\n\n// Set an environment variable +pw.env.set("variable", "value");`, + }, + { + name: "Environment: Set timestamp variable", + script: `\n\n// Set timestamp variable +const currentTime = Date.now(); +pw.env.set("timestamp", currentTime.toString());`, + }, + { + name: "Environment: Set random number variable", + script: `\n\n// Set random number variable +const min = 1 +const max = 1000 +const randomArbitrary = Math.random() * (max - min) + min +pw.env.set("randomNumber", randomArbitrary.toString());`, + }, +] diff --git a/packages/hoppscotch-common/src/helpers/proxyUrl.ts b/packages/hoppscotch-common/src/helpers/proxyUrl.ts new file mode 100644 index 0000000..13be3f0 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/proxyUrl.ts @@ -0,0 +1,32 @@ +import { platform } from "~/platform" +import * as E from "fp-ts/Either" + +// Default proxy URL +export const DEFAULT_HOPP_PROXY_URL = "https://proxy.hoppscotch.io/" + +// Mirrors the backend validateUrl regex (packages/hoppscotch-backend/src/utils.ts). +// Keep these in sync — the backend rejects PROXY_APP_URL values that don't match. +export const PROXY_URL_REGEX = /^(http|https):\/\/[^ "]+$/ + +export const isValidProxyUrl = (value: string): boolean => + PROXY_URL_REGEX.test(value) + +// Get default proxy URL from platform or return default. +// Validates the server response so a legacy/empty DB row or a bad env-sync +// can't seed buildDefaultSettings() with junk that would then bypass the +// store-side validation in KernelInterceptorProxyStore. +export const getDefaultProxyUrl = async () => { + const proxyAppUrl = platform?.infra?.getProxyAppUrl + + if (proxyAppUrl) { + const res = await proxyAppUrl() + + if (E.isRight(res) && isValidProxyUrl(res.right.value)) { + return res.right.value + } + + return DEFAULT_HOPP_PROXY_URL + } + + return DEFAULT_HOPP_PROXY_URL +} diff --git a/packages/hoppscotch-common/src/helpers/realtime/MQTTConnection.ts b/packages/hoppscotch-common/src/helpers/realtime/MQTTConnection.ts new file mode 100644 index 0000000..8d601f5 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/realtime/MQTTConnection.ts @@ -0,0 +1,287 @@ +import Paho, { ConnectionOptions } from "paho-mqtt" +import { BehaviorSubject, Subject } from "rxjs" +import { platform } from "~/platform" + +export type MQTTConnectionConfig = { + username?: string + password?: string + keepAlive?: string + cleanSession?: boolean + lwTopic?: string + lwMessage: string + lwQos: 2 | 1 | 0 + lwRetain: boolean +} + +export type MQTTMessage = { topic: string; message: string } +export type MQTTError = + | { type: "CONNECTION_NOT_ESTABLISHED"; value: unknown } + | { type: "CONNECTION_LOST" } + | { type: "CONNECTION_FAILED" } + | { type: "SUBSCRIPTION_FAILED"; topic: string } + | { type: "PUBLISH_ERROR"; topic: string; message: string } + +export type MQTTEvent = { time: number } & ( + | { type: "CONNECTING" } + | { type: "CONNECTED" } + | { type: "MESSAGE_SENT"; message: MQTTMessage } + | { type: "SUBSCRIBED"; topic: string } + | { type: "SUBSCRIPTION_FAILED"; topic: string } + | { type: "MESSAGE_RECEIVED"; message: MQTTMessage } + | { type: "DISCONNECTED"; manual: boolean } + | { type: "ERROR"; error: MQTTError } +) + +export type MQTTTopic = { + name: string + color: string + qos: 2 | 1 | 0 +} + +export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED" + +export const QOS_VALUES = [2, 1, 0] as const + +export class MQTTConnection { + subscribing$ = new BehaviorSubject(false) + subscriptionState$ = new BehaviorSubject(false) + connectionState$ = new BehaviorSubject("DISCONNECTED") + event$: Subject = new Subject() + subscribedTopics$ = new BehaviorSubject([]) + + private mqttClient: Paho.Client | undefined + private manualDisconnect = false + + private addEvent(event: MQTTEvent) { + this.event$.next(event) + } + + connect(url: string, clientID: string, config: MQTTConnectionConfig) { + try { + this.connectionState$.next("CONNECTING") + + this.addEvent({ + time: Date.now(), + type: "CONNECTING", + }) + + const parseUrl = new URL(url) + const { hostname, pathname, port } = parseUrl + this.mqttClient = new Paho.Client( + `${hostname + (pathname !== "/" ? pathname : "")}`, + port !== "" ? Number(port) : 8081, + clientID ?? "hoppscotch" + ) + const connectOptions: ConnectionOptions = { + onSuccess: this.onConnectionSuccess.bind(this), + onFailure: this.onConnectionFailure.bind(this), + timeout: 3, + keepAliveInterval: Number(config.keepAlive) ?? 60, + cleanSession: config.cleanSession ?? true, + useSSL: parseUrl.protocol !== "ws:", + } + + const { username, password, lwTopic, lwMessage, lwQos, lwRetain } = config + + if (username) { + connectOptions.userName = username + } + if (password) { + connectOptions.password = password + } + + if (lwTopic?.length) { + const willmsg = new Paho.Message(lwMessage) + willmsg.qos = lwQos + willmsg.destinationName = lwTopic + willmsg.retained = lwRetain + connectOptions.willMessage = willmsg + } + + this.mqttClient.connect(connectOptions) + this.mqttClient.onConnectionLost = this.onConnectionLost.bind(this) + this.mqttClient.onMessageArrived = this.onMessageArrived.bind(this) + } catch (e) { + this.handleError(e) + } + + platform.analytics?.logEvent({ + type: "HOPP_REQUEST_RUN", + platform: "mqtt", + }) + } + + onConnectionFailure() { + this.connectionState$.next("DISCONNECTED") + this.addEvent({ + time: Date.now(), + type: "ERROR", + error: { + type: "CONNECTION_FAILED", + }, + }) + } + + onConnectionSuccess() { + this.connectionState$.next("CONNECTED") + this.addEvent({ + type: "CONNECTED", + time: Date.now(), + }) + } + + onConnectionLost() { + this.connectionState$.next("DISCONNECTED") + if (this.manualDisconnect) { + this.addEvent({ + time: Date.now(), + type: "DISCONNECTED", + manual: this.manualDisconnect, + }) + } else { + this.addEvent({ + time: Date.now(), + type: "ERROR", + error: { + type: "CONNECTION_LOST", + }, + }) + } + this.manualDisconnect = false + this.subscriptionState$.next(false) + this.subscribedTopics$.next([]) + } + + onMessageArrived({ + payloadString: message, + destinationName: topic, + }: { + payloadString: string + destinationName: string + }) { + this.addEvent({ + time: Date.now(), + type: "MESSAGE_RECEIVED", + message: { + topic, + message, + }, + }) + } + + private handleError(error: unknown) { + this.disconnect() + this.addEvent({ + time: Date.now(), + type: "ERROR", + error: { + type: "CONNECTION_NOT_ESTABLISHED", + value: error, + }, + }) + } + + publish(topic: string, message: string) { + if (this.connectionState$.value === "DISCONNECTED") return + + try { + // it was publish + this.mqttClient?.send(topic, message, 0, false) + this.addEvent({ + time: Date.now(), + type: "MESSAGE_SENT", + message: { + topic, + message, + }, + }) + } catch (_e) { + this.addEvent({ + time: Date.now(), + type: "ERROR", + error: { + type: "PUBLISH_ERROR", + topic, + message, + }, + }) + } + } + + subscribe(topic: MQTTTopic) { + this.subscribing$.next(true) + try { + this.mqttClient?.subscribe(topic.name, { + onSuccess: this.subSuccess.bind(this, topic), + onFailure: this.usubFailure.bind(this, topic.name), + qos: topic.qos, + }) + } catch (_e) { + this.subscribing$.next(false) + this.addEvent({ + time: Date.now(), + type: "ERROR", + error: { + type: "SUBSCRIPTION_FAILED", + topic: topic.name, + }, + }) + } + } + + subSuccess(topic: MQTTTopic) { + this.subscribing$.next(false) + this.subscriptionState$.next(!this.subscriptionState$.value) + this.addSubscription(topic) + this.addEvent({ + time: Date.now(), + type: "SUBSCRIBED", + topic: topic.name, + }) + } + + usubSuccess(topic: string) { + this.subscribing$.next(false) + this.removeSubscription(topic) + } + + usubFailure(topic: string) { + this.subscribing$.next(false) + this.addEvent({ + time: Date.now(), + type: "ERROR", + error: { + type: "SUBSCRIPTION_FAILED", + topic, + }, + }) + } + + unsubscribe(topic: string) { + this.mqttClient?.unsubscribe(topic, { + onSuccess: this.usubSuccess.bind(this, topic), + onFailure: this.usubFailure.bind(this, topic), + }) + } + + addSubscription(topic: MQTTTopic) { + const subscriptions = this.subscribedTopics$.getValue() + subscriptions.push({ + name: topic.name, + color: topic.color, + qos: topic.qos, + }) + this.subscribedTopics$.next(subscriptions) + } + + removeSubscription(topic: string) { + const subscriptions = this.subscribedTopics$.getValue() + this.subscribedTopics$.next(subscriptions.filter((t) => t.name !== topic)) + } + + disconnect() { + this.manualDisconnect = true + this.mqttClient?.disconnect() + this.connectionState$.next("DISCONNECTED") + } +} diff --git a/packages/hoppscotch-common/src/helpers/realtime/SIOClients.ts b/packages/hoppscotch-common/src/helpers/realtime/SIOClients.ts new file mode 100644 index 0000000..abb4b05 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/realtime/SIOClients.ts @@ -0,0 +1,92 @@ +import wildcard from "socketio-wildcard" +import ClientV2 from "socket.io-client-v2" +import { io as ClientV4, Socket as SocketV4 } from "socket.io-client-v4" +import { io as ClientV3, Socket as SocketV3 } from "socket.io-client-v3" + +type Options = { + path: string + auth?: { + token: string | undefined + } +} + +type PossibleEvent = + | "connect" + | "connect_error" + | "reconnect_error" + | "error" + | "disconnect" + | "*" + +export interface SIOClient { + connect(url: string, opts?: Options): void + on(event: PossibleEvent, cb: (data: any) => void): void + emit(event: string, data: any, cb: (data: any) => void): void + close(): void +} + +export class SIOClientV4 implements SIOClient { + private client: SocketV4 | undefined + connect(url: string, opts?: Options) { + this.client = ClientV4(url, opts) + } + + on(event: PossibleEvent, cb: (data: any) => void) { + if (event === "*") { + this.client?.onAny((...data) => { + cb({ data }) + }) + } else this.client?.on(event, cb) + } + + emit(event: string, data: any, cb: (data: any) => void): void { + this.client?.emit(event, data, cb) + } + + close(): void { + this.client?.close() + } +} + +export class SIOClientV3 implements SIOClient { + private client: SocketV3 | undefined + connect(url: string, opts?: Options) { + this.client = ClientV3(url, opts) + } + + on(event: PossibleEvent, cb: (data: any) => void): void { + if (event === "*") { + this.client?.onAny((...data) => { + cb({ data }) + }) + } else this.client?.on(event, cb) + } + + emit(event: string, data: any, cb: (data: any) => void): void { + this.client?.emit(event, data, cb) + } + + close(): void { + this.client?.close() + } +} + +export class SIOClientV2 implements SIOClient { + private client: any | undefined + connect(url: string, opts?: Options) { + this.client = new ClientV2(url, opts) + wildcard(ClientV2.Manager)(this.client) + } + + on(event: PossibleEvent, cb: (data: any) => void): void { + this.client?.on(event, cb) + } + + emit(event: string, data: any, cb: (data: any) => void): void { + this.client?.emit(event, data, cb) + } + + close(): void { + this.client?.close() + } +} diff --git a/packages/hoppscotch-common/src/helpers/realtime/SIOConnection.ts b/packages/hoppscotch-common/src/helpers/realtime/SIOConnection.ts new file mode 100644 index 0000000..f010705 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/realtime/SIOConnection.ts @@ -0,0 +1,164 @@ +import { BehaviorSubject, Subject } from "rxjs" +import { SIOClientV2, SIOClientV3, SIOClientV4, SIOClient } from "./SIOClients" +import { SIOClientVersion } from "~/newstore/SocketIOSession" +import { platform } from "~/platform" + +export const SOCKET_CLIENTS = { + v2: SIOClientV2, + v3: SIOClientV3, + v4: SIOClientV4, +} as const + +type SIOAuth = { type: "None" } | { type: "Bearer"; token: string } + +export type ConnectionOption = { + url: string + path: string + clientVersion: SIOClientVersion + auth: SIOAuth | undefined +} + +export type SIOMessage = { + eventName: string + value: unknown +} + +type SIOErrorType = "CONNECTION" | "RECONNECT_ERROR" | "UNKNOWN" +export type SIOError = { + type: SIOErrorType + value: unknown +} + +export type SIOEvent = { time: number } & ( + | { type: "CONNECTING" } + | { type: "CONNECTED" } + | { type: "MESSAGE_SENT"; message: SIOMessage } + | { type: "MESSAGE_RECEIVED"; message: SIOMessage } + | { type: "DISCONNECTED"; manual: boolean } + | { type: "ERROR"; error: SIOError } +) + +export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED" + +export class SIOConnection { + connectionState$: BehaviorSubject + event$: Subject = new Subject() + socket: SIOClient | undefined + constructor() { + this.connectionState$ = new BehaviorSubject("DISCONNECTED") + } + + private addEvent(event: SIOEvent) { + this.event$.next(event) + } + + connect({ url, path, clientVersion, auth }: ConnectionOption) { + this.connectionState$.next("CONNECTING") + this.addEvent({ + time: Date.now(), + type: "CONNECTING", + }) + try { + this.socket = new SOCKET_CLIENTS[clientVersion]() + + if (auth?.type === "Bearer") { + this.socket.connect(url, { + path, + auth: { + token: auth.token, + }, + }) + } else { + this.socket.connect(url, { path }) + } + + this.socket.on("connect", () => { + this.connectionState$.next("CONNECTED") + this.addEvent({ + type: "CONNECTED", + time: Date.now(), + }) + }) + + this.socket.on("*", ({ data }: { data: string[] }) => { + const [eventName, message] = data + this.addEvent({ + message: { eventName, value: message }, + type: "MESSAGE_RECEIVED", + time: Date.now(), + }) + }) + + this.socket.on("connect_error", (error: unknown) => { + this.handleError(error, "CONNECTION") + }) + + this.socket.on("reconnect_error", (error: unknown) => { + this.handleError(error, "RECONNECT_ERROR") + }) + + this.socket.on("error", (error: unknown) => { + this.handleError(error, "UNKNOWN") + }) + + this.socket.on("disconnect", () => { + this.connectionState$.next("DISCONNECTED") + this.addEvent({ + type: "DISCONNECTED", + time: Date.now(), + manual: true, + }) + }) + } catch (error) { + this.handleError(error, "CONNECTION") + } + + platform.analytics?.logEvent({ + type: "HOPP_REQUEST_RUN", + platform: "socketio", + }) + } + + private handleError(error: unknown, type: SIOErrorType) { + this.disconnect() + this.addEvent({ + time: Date.now(), + type: "ERROR", + error: { + type, + value: error, + }, + }) + } + + sendMessage(event: { message: string; eventName: string }) { + if (this.connectionState$.value === "DISCONNECTED") return + const { message, eventName } = event + + this.socket?.emit(eventName, message, (data) => { + // receive response from server + this.addEvent({ + time: Date.now(), + type: "MESSAGE_RECEIVED", + message: { + eventName, + value: data, + }, + }) + }) + + this.addEvent({ + time: Date.now(), + type: "MESSAGE_SENT", + message: { + eventName, + value: message, + }, + }) + } + + disconnect() { + this.socket?.close() + this.connectionState$.next("DISCONNECTED") + } +} diff --git a/packages/hoppscotch-common/src/helpers/realtime/SSEConnection.ts b/packages/hoppscotch-common/src/helpers/realtime/SSEConnection.ts new file mode 100644 index 0000000..3603335 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/realtime/SSEConnection.ts @@ -0,0 +1,89 @@ +import { BehaviorSubject, Subject } from "rxjs" +import { platform } from "~/platform" + +export type SSEEvent = { time: number } & ( + | { type: "STARTING" } + | { type: "STARTED" } + | { type: "MESSAGE_RECEIVED"; message: string } + | { type: "STOPPED"; manual: boolean } + | { type: "ERROR"; error: Event | null } +) + +export type ConnectionState = "STARTING" | "STARTED" | "STOPPED" + +export class SSEConnection { + connectionState$: BehaviorSubject + event$: Subject = new Subject() + sse: EventSource | undefined + constructor() { + this.connectionState$ = new BehaviorSubject("STOPPED") + } + + private addEvent(event: SSEEvent) { + this.event$.next(event) + } + + start(url: string, eventType: string) { + this.connectionState$.next("STARTING") + this.addEvent({ + time: Date.now(), + type: "STARTING", + }) + if (typeof EventSource !== "undefined") { + try { + this.sse = new EventSource(url) + this.sse.onopen = () => { + this.connectionState$.next("STARTED") + this.addEvent({ + type: "STARTED", + time: Date.now(), + }) + } + this.sse.onerror = (e) => { + this.handleError(e) + this.stop() + } + this.sse.addEventListener(eventType, ({ data }) => { + this.addEvent({ + type: "MESSAGE_RECEIVED", + message: data, + time: Date.now(), + }) + }) + } catch (error) { + // A generic event type returned if anything goes wrong or browser doesn't support SSE + // https://developer.mozilla.org/en-US/docs/Web/API/EventSource/error_event#event_type + this.handleError(error as Event) + } + } else { + this.addEvent({ + type: "ERROR", + time: Date.now(), + error: null, + }) + } + + platform.analytics?.logEvent({ + type: "HOPP_REQUEST_RUN", + platform: "sse", + }) + } + + private handleError(error: Event) { + this.addEvent({ + time: Date.now(), + type: "ERROR", + error, + }) + } + + stop() { + this.sse?.close() + this.connectionState$.next("STOPPED") + this.addEvent({ + type: "STOPPED", + time: Date.now(), + manual: true, + }) + } +} diff --git a/packages/hoppscotch-common/src/helpers/realtime/WSConnection.ts b/packages/hoppscotch-common/src/helpers/realtime/WSConnection.ts new file mode 100644 index 0000000..9fd42f3 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/realtime/WSConnection.ts @@ -0,0 +1,103 @@ +import { BehaviorSubject, Subject } from "rxjs" +import { platform } from "~/platform" + +export type WSErrorMessage = SyntaxError | Event + +export type WSEvent = { time: number } & ( + | { type: "CONNECTING" } + | { type: "CONNECTED" } + | { type: "MESSAGE_SENT"; message: string } + | { type: "MESSAGE_RECEIVED"; message: string } + | { type: "DISCONNECTED"; manual: boolean } + | { type: "ERROR"; error: WSErrorMessage } +) + +export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED" + +export class WSConnection { + connectionState$: BehaviorSubject + event$: Subject = new Subject() + socket: WebSocket | undefined + + constructor() { + this.connectionState$ = new BehaviorSubject("DISCONNECTED") + } + + private addEvent(event: WSEvent) { + this.event$.next(event) + } + + connect(url: string, protocols: string[]) { + try { + this.connectionState$.next("CONNECTING") + this.socket = new WebSocket(url, protocols) + + this.addEvent({ + time: Date.now(), + type: "CONNECTING", + }) + + this.socket.onopen = () => { + this.connectionState$.next("CONNECTED") + this.addEvent({ + type: "CONNECTED", + time: Date.now(), + }) + } + + this.socket.onerror = (error) => { + this.handleError(error) + } + + this.socket.onclose = () => { + this.connectionState$.next("DISCONNECTED") + this.addEvent({ + type: "DISCONNECTED", + time: Date.now(), + manual: true, + }) + } + + this.socket.onmessage = ({ data }) => { + this.addEvent({ + time: Date.now(), + type: "MESSAGE_RECEIVED", + message: data, + }) + } + } catch (error) { + // We will have SyntaxError if anything goes wrong with WebSocket constructor + // See https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket#exceptions + this.handleError(error as SyntaxError) + } + + platform.analytics?.logEvent({ + type: "HOPP_REQUEST_RUN", + platform: "wss", + }) + } + + private handleError(error: WSErrorMessage) { + this.disconnect() + this.addEvent({ + time: Date.now(), + type: "ERROR", + error, + }) + } + + sendMessage(event: { message: string; eventName: string }) { + if (this.connectionState$.value === "DISCONNECTED") return + const { message } = event + this.socket?.send(message) + this.addEvent({ + time: Date.now(), + type: "MESSAGE_SENT", + message, + }) + } + + disconnect() { + this.socket?.close() + } +} diff --git a/packages/hoppscotch-common/src/helpers/requestParams.js b/packages/hoppscotch-common/src/helpers/requestParams.js new file mode 100644 index 0000000..362c017 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/requestParams.js @@ -0,0 +1,33 @@ +export function hasPathParams(params) { + return params + .filter((item) => + Object.prototype.hasOwnProperty.call(item, "active") + ? item.active === true + : true + ) + .some(({ type }) => type === "path") +} + +export function addPathParamsToVariables(params, variables) { + params + .filter((item) => + Object.prototype.hasOwnProperty.call(item, "active") + ? item.active === true + : true + ) + .filter(({ key }) => !!key) + .filter(({ type }) => type === "path") + .forEach(({ key, value }) => (variables[key] = value)) + return variables +} + +export function getQueryParams(params) { + return params + .filter((item) => + Object.prototype.hasOwnProperty.call(item, "active") + ? item.active === true + : true + ) + .filter(({ key }) => !!key) + .filter(({ type }) => type !== "path") +} diff --git a/packages/hoppscotch-common/src/helpers/rest/default.ts b/packages/hoppscotch-common/src/helpers/rest/default.ts new file mode 100644 index 0000000..d420f13 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/rest/default.ts @@ -0,0 +1,23 @@ +import { HoppRESTRequest, RESTReqSchemaVersion } from "@hoppscotch/data" + +export const getDefaultRESTRequest = (): HoppRESTRequest => ({ + v: RESTReqSchemaVersion, + endpoint: "https://echo.hoppscotch.io", + name: "Untitled", + params: [], + headers: [], + method: "GET", + auth: { + authType: "inherit", + authActive: true, + }, + preRequestScript: "", + testScript: "", + body: { + contentType: null, + body: null, + }, + requestVariables: [], + responses: {}, + description: null, +}) diff --git a/packages/hoppscotch-common/src/helpers/rest/document.ts b/packages/hoppscotch-common/src/helpers/rest/document.ts new file mode 100644 index 0000000..95c6558 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/rest/document.ts @@ -0,0 +1,284 @@ +import { + HoppCollection, + HoppRESTRequest, + HoppRESTRequestResponse, +} from "@hoppscotch/data" +import { RESTOptionTabs } from "~/components/http/RequestOptions.vue" +import { HoppInheritedProperty } from "../types/HoppInheritedProperties" +import { HoppRESTResponse } from "../types/HoppRESTResponse" +import { HoppTestResult } from "../types/HoppTestResult" +import { TestRunnerRequest } from "~/services/test-runner/test-runner.service" + +export type HoppRESTSaveContext = + | { + /** + * The origin source of the request + */ + originLocation: "user-collection" + /** + * Path to the request folder + */ + folderPath: string + /** + * Index to the request + */ + requestIndex?: number + /** + * ID of the example response + */ + exampleID?: string + /** + * Reference ID of the request, if available + */ + requestRefID?: string + } + | { + /** + * The origin source of the request + */ + originLocation: "team-collection" + /** + * ID of the request in the team + */ + requestID: string + /** + * ID of the team + */ + teamID?: string + /** + * ID of the collection loaded + */ + collectionID?: string + /** + * ID of the example response + */ + exampleID?: string + /** + * Reference ID of the request, if available + */ + requestRefID?: string + } + | null + +/** + * Defines a live 'document' (something that is open and being edited) in the app + */ + +export type HoppCollectionSaveContext = + | { + /** + * The origin source of the request + */ + originLocation: "user-collection" + /** + * Path to the request folder + */ + folderPath: string + } + | { + /** + * The origin source of the request + */ + originLocation: "team-collection" + /** + * ID of the team + */ + teamID?: string + /** + * ID of the collection loaded + */ + collectionID?: string + /** + * ID of the request in the team + */ + requestID: string + } + | null + +export type TestRunnerConfig = { + iterations: number + delay: number + stopOnError: boolean + persistResponses: boolean + keepVariableValues: boolean +} + +export type HoppTestRunnerDocument = { + /** + * The document type + */ + type: "test-runner" + + /** + * The test runner configuration + */ + config: TestRunnerConfig + + /** + * initiate test runner on tab open + */ + status: "idle" | "running" | "stopped" | "error" + + /** + * The collection as it is in the document + */ + collection: HoppCollection + + /** + * The type of the collection + */ + collectionType: "my-collections" | "team-collections" + + /** + * collection ID to be used for team collections + * (if it's my-collections, the _ref_id will be used as collectionID) + */ + collectionID: string + + /** + * Selected request id + * (if any) + */ + selectedRequestPath?: string + + /** + * The request as it is in the document + */ + resultCollection?: HoppCollection + + /** + * The test runner meta information + */ + testRunnerMeta: { + totalRequests: number + completedRequests: number + totalTests: number + passedTests: number + failedTests: number + totalTime: number + } + + /** + * Selected test runner request + */ + request: TestRunnerRequest | null + + /** + * The response of the selected request in collections after running the test + * (if any) + */ + response?: HoppRESTResponse | null + + /** + * The test results of the selected request in collections after running the test + * (if any) + */ + testResults?: HoppTestResult | null + + /** + * Whether the request has any unsaved changes + * (atleast as far as we can say) + */ + isDirty: boolean + + /** + * The inherited properties from the parent collection also the collection itself + * (if any) - Used for team collections + */ + inheritedProperties?: HoppInheritedProperty +} + +export type HoppRequestDocument = { + /** + * The document type + */ + type: "request" + + /** + * The request as it is in the document + */ + request: HoppRESTRequest + + /** + * Whether the request has any unsaved changes + * (atleast as far as we can say) + */ + isDirty: boolean + + /** + * Info about where this request should be saved. + * This contains where the request is originated from basically. + */ + saveContext?: HoppRESTSaveContext + + /** + * The response as it is in the document + * (if any) + */ + response?: HoppRESTResponse | null + + /** + * The test results as it is in the document + * (if any) + */ + testResults?: HoppTestResult | null + + /** + * Response tab preference for the current tab's document + */ + responseTabPreference?: string + + /** + * Options tab preference for the current tab's document + */ + optionTabPreference?: RESTOptionTabs + + /** + * The inherited properties from the parent collection + * (if any) + */ + inheritedProperties?: HoppInheritedProperty + + /** + * The function responsible for cancelling the tab request call + */ + cancelFunction?: () => void +} + +export type HoppSavedExampleDocument = { + /** + * The type of the document + */ + type: "example-response" + + /** + * The response as it is in the document + */ + response: HoppRESTRequestResponse + + /** + * Info about where this response should be saved. + * This contains where the response is originated from basically. + */ + saveContext?: HoppRESTSaveContext + + /** + * Whether the response has any unsaved changes + * (atleast as far as we can say) + */ + isDirty: boolean + + /** + * The inherited properties from the parent collection + * (if any) + */ + inheritedProperties?: HoppInheritedProperty +} + +/** + * Defines a live 'document' (something that is open and being edited) in the app + */ +export type HoppTabDocument = + | HoppSavedExampleDocument + | HoppRequestDocument + | HoppTestRunnerDocument diff --git a/packages/hoppscotch-common/src/helpers/rest/labelColoring.ts b/packages/hoppscotch-common/src/helpers/rest/labelColoring.ts new file mode 100644 index 0000000..4f18644 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/rest/labelColoring.ts @@ -0,0 +1,35 @@ +import { pipe } from "fp-ts/function" +import * as O from "fp-ts/Option" +import * as RR from "fp-ts/ReadonlyRecord" + +export const REQUEST_METHOD_LABEL_COLORS = { + get: "var(--method-get-color)", + post: "var(--method-post-color)", + put: "var(--method-put-color)", + patch: "var(--method-patch-color)", + delete: "var(--method-delete-color)", + head: "var(--method-head-color)", + options: "var(--method-options-color)", + default: "var(--method-default-color)", +} as const + +/** + * Returns the label color tailwind class for a request + * @param method The HTTP VERB of the request + * @returns The class value for the given HTTP VERB, if not, a generic verb class + */ +export function getMethodLabelColorClassOf(method: string) { + return pipe( + REQUEST_METHOD_LABEL_COLORS, + RR.lookup(method.toLowerCase()), + O.getOrElseW(() => REQUEST_METHOD_LABEL_COLORS.default) + ) +} + +export function getMethodLabelColor(method: string) { + return pipe( + REQUEST_METHOD_LABEL_COLORS, + RR.lookup(method.toLowerCase()), + O.getOrElseW(() => REQUEST_METHOD_LABEL_COLORS.default) + ) +} diff --git a/packages/hoppscotch-common/src/helpers/retryAuthGuard.ts b/packages/hoppscotch-common/src/helpers/retryAuthGuard.ts new file mode 100644 index 0000000..546ae63 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/retryAuthGuard.ts @@ -0,0 +1,78 @@ +/** + * Maximum number of consecutive auth refresh failures before signing out. + * @see https://github.com/hoppscotch/hoppscotch/issues/5885 + */ +const MAX_RETRIES = 3 + +/** + * Creates an auth retry guard that tracks consecutive refresh failures + * and triggers a sign-out after {@link MAX_RETRIES} consecutive failures. + * + * After exhaustion, subsequent calls short-circuit to `false` without + * invoking `refreshFn` or `onExhausted` again. Call `reset()` on + * successful login to re-enable refresh attempts. + * + * @see https://github.com/hoppscotch/hoppscotch/issues/5885 + */ +export function createAuthRetryGuard(onExhausted: () => void | Promise) { + let failCount = 0 + let isExhausted = false + let exhaustionPromise: Promise | null = null + + return { + /** + * Wraps an auth refresh attempt with retry tracking. + * Resets on success. Calls `onExhausted` after {@link MAX_RETRIES} + * consecutive failures and stays exhausted until `reset()` is called. + */ + async execute(refreshFn: () => Promise): Promise { + // isExhausted covers the normal path; failCount >= MAX_RETRIES covers + // the concurrent-call edge case where two callers both passed the check + // at failCount = 2 before either could set isExhausted = true. + if (isExhausted || failCount >= MAX_RETRIES) { + return false + } + + let success: boolean + try { + success = await refreshFn() + } catch (_) { + // Treat thrown errors (network failures, etc.) as a failed refresh + // so they count toward exhaustion and don't bypass the guard. + success = false + } + + if (success) { + failCount = 0 + return true + } + + failCount++ + if (failCount >= MAX_RETRIES && !isExhausted) { + isExhausted = true + try { + exhaustionPromise = Promise.resolve().then(() => onExhausted()) + await exhaustionPromise + } catch (_) { + // Sign-out failed (e.g. network error), but the guard stays + // exhausted so we don't re-enter the refresh loop. + } finally { + exhaustionPromise = null + } + } + + return false + }, + + /** + * Reset the failure counter (e.g. on login or manual logout). + * No-op while an exhaustion callback (sign-out) is still in-flight. + */ + reset() { + if (exhaustionPromise) return + + failCount = 0 + isExhausted = false + }, + } +} diff --git a/packages/hoppscotch-common/src/helpers/rules/BodyTransition.ts b/packages/hoppscotch-common/src/helpers/rules/BodyTransition.ts new file mode 100644 index 0000000..4a54667 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/rules/BodyTransition.ts @@ -0,0 +1,192 @@ +/** + * Defines how body should be updated for movement between different + * content-types + */ + +import { pipe } from "fp-ts/function" +import * as A from "fp-ts/Array" +import { + FormDataKeyValue, + HoppRESTReqBody, + ValidContentTypes, + parseRawKeyValueEntries, + rawKeyValueEntriesToString, + RawKeyValueEntry, +} from "@hoppscotch/data" + +const ANY_TYPE = Symbol("TRANSITION_RULESET_IGNORE_TYPE") +// eslint-disable-next-line no-redeclare +type ANY_TYPE = typeof ANY_TYPE + +type BodyType = + T extends ValidContentTypes + ? HoppRESTReqBody & { contentType: T } + : HoppRESTReqBody + +type TransitionDefinition< + FromType extends ValidContentTypes | null | ANY_TYPE, + ToType extends ValidContentTypes | null | ANY_TYPE, +> = { + from: FromType + to: ToType + definition: ( + currentBody: BodyType, + targetType: BodyType["contentType"] + ) => BodyType +} + +const rule = < + FromType extends ValidContentTypes | null | ANY_TYPE, + ToType extends ValidContentTypes | null | ANY_TYPE, +>( + input: TransitionDefinition +) => input + +// Use ANY_TYPE to ignore from/dest type +// Rules apply from top to bottom +const transitionRuleset = [ + rule({ + from: null, + to: "multipart/form-data", + definition: () => ({ + contentType: "multipart/form-data", + body: [], + }), + }), + rule({ + from: ANY_TYPE, + to: null, + definition: () => ({ + contentType: null, + body: null, + }), + }), + rule({ + from: null, + to: ANY_TYPE, + definition: (_, targetType) => ({ + contentType: targetType as unknown as Exclude< + // This is guaranteed by the above rules, we just can't tell TS this + ValidContentTypes, + "multipart/form-data" + >, + body: "", + }), + }), + rule({ + from: "multipart/form-data", + to: "application/x-www-form-urlencoded", + definition: (currentBody, targetType) => ({ + contentType: targetType, + body: pipe( + currentBody.body, + A.map( + ({ key, value, isFile, active }) => + { + key, + value: isFile ? "" : value, + active, + } + ), + rawKeyValueEntriesToString + ), + }), + }), + rule({ + from: "application/x-www-form-urlencoded", + to: "multipart/form-data", + definition: (currentBody, targetType) => ({ + contentType: targetType, + body: pipe( + currentBody.body, + parseRawKeyValueEntries, + A.map( + ({ key, value, active }) => + { + key, + value, + active, + isFile: false, + } + ) + ), + }), + }), + rule({ + from: ANY_TYPE, + to: "multipart/form-data", + definition: () => ({ + contentType: "multipart/form-data", + body: [], + }), + }), + rule({ + from: "multipart/form-data", + to: ANY_TYPE, + definition: (_, target) => ({ + contentType: target as Exclude, + body: "", + }), + }), + rule({ + from: "application/x-www-form-urlencoded", + to: ANY_TYPE, + definition: (_, target) => ({ + contentType: target as Exclude, + body: "", + }), + }), + rule({ + from: ANY_TYPE, + to: "application/x-www-form-urlencoded", + definition: () => ({ + contentType: "application/x-www-form-urlencoded", + body: "", + }), + }), + rule({ + from: ANY_TYPE, + to: ANY_TYPE, + definition: (curr, targetType) => ({ + contentType: targetType as Exclude< + // Above rules ensure this will be the case + ValidContentTypes, + "multipart/form-data" | "application/x-www-form-urlencoded" + >, + // Again, above rules ensure this will be the case, can't convince TS tho + body: ( + curr as HoppRESTReqBody & { + contentType: Exclude< + ValidContentTypes, + "multipart/form-data" | "application/x-www-form-urlencoded" + > + } + ).body, + }), + }), +] as const + +export const applyBodyTransition = ( + current: HoppRESTReqBody, + target: T +): HoppRESTReqBody & { contentType: T } => { + if (current.contentType === target) { + console.warn( + `Tried to transition body from and to the same content-type '${target}'` + ) + return current as any + } + + const transitioner = transitionRuleset.find( + (def) => + (def.from === current.contentType || def.from === ANY_TYPE) && + (def.to === target || def.to === ANY_TYPE) + ) + + if (!transitioner) { + throw new Error("Body Type Transition Ruleset is invalid :(") + } + + // TypeScript won't be able to figure this out easily :( + return (transitioner.definition as any)(current, target) +} diff --git a/packages/hoppscotch-common/src/helpers/runner/adapter.ts b/packages/hoppscotch-common/src/helpers/runner/adapter.ts new file mode 100644 index 0000000..e87f0a4 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/runner/adapter.ts @@ -0,0 +1,177 @@ +import { HoppCollection } from "@hoppscotch/data" +import { ChildrenResult, SmartTreeAdapter } from "@hoppscotch/ui/helpers" +import { computed, Ref } from "vue" +import { TestRunnerRequest } from "~/services/test-runner/test-runner.service" + +export type Collection = { + type: "collections" + isLastItem: boolean + data: { + parentIndex: null + data: HoppCollection + } +} + +type Folder = { + type: "folders" + isLastItem: boolean + data: { + parentIndex: string + data: HoppCollection + } +} + +type Requests = { + type: "requests" + isLastItem: boolean + data: { + parentIndex: string + data: TestRunnerRequest + } +} + +export type CollectionNode = Collection | Folder | Requests + +export class TestRunnerCollectionsAdapter implements SmartTreeAdapter { + constructor( + public data: Ref, + private show: Ref<"all" | "passed" | "failed"> + ) {} + + private shouldShowRequest(request: TestRunnerRequest): boolean { + // Always show requests that are still loading or haven't run yet + if (!request.testResults || request.isLoading) return true + + const { passed, failed } = this.countTestResults(request.testResults) + + switch (this.show.value) { + case "passed": + return passed > 0 + case "failed": + return failed > 0 + default: + return true + } + } + + private countTestResults(testResult: any) { + let passed = 0 + let failed = 0 + + // Count direct expect results + if (testResult.expectResults) { + for (const result of testResult.expectResults) { + if (result.status === "pass") passed++ + else if (result.status === "fail") failed++ + } + } + + // Count nested test results + if (testResult.tests) { + for (const test of testResult.tests) { + const counts = this.countTestResults(test) + passed += counts.passed + failed += counts.failed + } + } + + return { passed, failed } + } + + navigateToFolderWithIndexPath( + collections: HoppCollection[], + indexPaths: number[] + ) { + if (indexPaths.length === 0) return null + + let target = collections[indexPaths.shift() as number] + + while (indexPaths.length > 0) + target = target.folders[indexPaths.shift() as number] + + return target !== undefined ? target : null + } + + getChildren(id: string | null): Ref> { + return computed(() => { + if (id === null) { + const data = this.data.value.map((item, index) => ({ + id: `folder-${index.toString()}`, + data: { + type: "collections", + isLastItem: index === this.data.value.length - 1, + data: { + parentIndex: null, + data: item, + }, + }, + })) + return { + status: "loaded", + data: data, + } as ChildrenResult + } + + const childType = id.split("-")[0] + + if (childType === "request") { + return { + status: "loaded", + data: [], + } + } + + const folderId = id.split("-")[1] + const indexPath = folderId.split("/").map((x) => parseInt(x)) + const item = this.navigateToFolderWithIndexPath( + this.data.value, + indexPath + ) + + if (item && Object.keys(item).length) { + // Always include all folders for smooth transitions + const folderData = item.folders.map((folder, index) => ({ + id: `folder-${folderId}/${index}`, + data: { + isLastItem: + index === item.folders.length - 1 && item.requests.length === 0, + type: "folders", + isSelected: true, + data: { + parentIndex: id, + data: folder, + }, + }, + })) + + const requestData = item.requests.map((request, index) => { + const shouldShow = this.shouldShowRequest( + request as TestRunnerRequest + ) + return { + id: `request-${id}/${index}`, + data: { + isLastItem: index === item.requests.length - 1, + type: "requests", + isSelected: true, + hidden: !shouldShow, + data: { + parentIndex: id, + data: request, + }, + }, + } + }) + + return { + status: "loaded", + data: [...folderData, ...requestData], + } as ChildrenResult + } + return { + status: "loaded", + data: [], + } + }) + } +} diff --git a/packages/hoppscotch-common/src/helpers/runner/collection-tree.ts b/packages/hoppscotch-common/src/helpers/runner/collection-tree.ts new file mode 100644 index 0000000..57102cd --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/runner/collection-tree.ts @@ -0,0 +1,42 @@ +import { ComposerTranslation } from "vue-i18n" +import { GQLError } from "../backend/GQLClient" + +export const getErrorMessage = ( + err: GQLError, + t: ComposerTranslation +) => { + console.error(err) + if (err.type === "network_error") { + return t("error.network_error") + } + switch (err.error) { + case "team_coll/short_title": + return t("collection.name_length_insufficient") + case "team/invalid_coll_id": + case "bug/team_coll/no_coll_id": + case "team_req/invalid_target_id": + return t("team.invalid_coll_id") + case "team/not_required_role": + return t("profile.no_permission") + case "team_req/not_required_role": + return t("profile.no_permission") + case "Forbidden resource": + return t("profile.no_permission") + case "team_req/not_found": + return t("team.no_request_found") + case "bug/team_req/no_req_id": + return t("team.no_request_found") + case "team/collection_is_parent_coll": + return t("team.parent_coll_move") + case "team/target_and_destination_collection_are_same": + return t("team.same_target_destination") + case "team/target_collection_is_already_root_collection": + return t("collection.invalid_root_move") + case "team_req/requests_not_from_same_collection": + return t("request.different_collection") + case "team/team_collections_have_different_parents": + return t("collection.different_parent") + default: + return t("error.something_went_wrong") + } +} diff --git a/packages/hoppscotch-common/src/helpers/runner/temp_envs.ts b/packages/hoppscotch-common/src/helpers/runner/temp_envs.ts new file mode 100644 index 0000000..8877257 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/runner/temp_envs.ts @@ -0,0 +1,20 @@ +import { ref } from "vue" +import { GlobalEnvironmentVariable } from "@hoppscotch/data" + +export const temporaryVariables = ref([]) + +export function getTemporaryVariables() { + return temporaryVariables.value +} + +export function setTemporaryVariables(variables: GlobalEnvironmentVariable[]) { + temporaryVariables.value = variables +} + +export function clearTemporaryVariables() { + temporaryVariables.value = [] +} + +export function addTemporaryVariable(variable: GlobalEnvironmentVariable) { + temporaryVariables.value.push(variable) +} diff --git a/packages/hoppscotch-common/src/helpers/secretVariables.ts b/packages/hoppscotch-common/src/helpers/secretVariables.ts new file mode 100644 index 0000000..e8658be --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/secretVariables.ts @@ -0,0 +1,271 @@ +import { generateUniqueRefId, type HoppCollection } from "@hoppscotch/data" + +import { getService } from "../modules/dioc" +import { CurrentValueService } from "../services/current-environment-value.service" +import { SecretEnvironmentService } from "../services/secret-environment.service" + +/** Shape shared by collection and environment variables. */ +type SecretCapableVariable = { + key: string + initialValue: string + currentValue: string + secret: boolean +} + +/** + * Strip raw values that must never reach the backend before the wire write. + * + * For SECRET variables we clear BOTH `initialValue` and `currentValue` — the + * secret value lives only in the local secret store (see #6279). + * + * For NON-SECRET variables `currentValue` is a normal, syncable value and + * MUST be preserved on the wire — otherwise environment / global-env updates + * persist an empty `currentValue` and the value is lost on reload. + */ +export const stripSecretVariableValuesForWire = < + T extends SecretCapableVariable, +>( + variables: T[] +): T[] => + variables.map((v) => ({ + ...v, + initialValue: v.secret ? "" : v.initialValue, + currentValue: v.secret ? "" : v.currentValue, + })) + +/** + * Seed the local secret + currentValue stores from raw variables. + * + * IMPORTANT: pass RAW (pre-strip) inputs. A stripped SECRET variable has + * empty `initialValue` / `currentValue` and will persist as blank. + * + * ─── `??` vs `||` — read before "fixing" this ─── + * Both branches below use `??` so an explicit `""` is preserved. This is + * the correct semantic for the REHYDRATION path (`""` means "user + * deliberately cleared" — must not be resurrected from `initialValue`). + * + * For the IMPORT path, `""` means "stripped on wire" and the real value + * lives in `initialValue`. That gap is bridged at the import-site + * boundary by `promoteInitialValueForImport` (called from + * `populateLocalStoresFromCollectionTree`, `repopulateLoadedCollectionTree`, + * and the env-import callers in `environments/ImportExport.vue`). + * + * Do not switch these to `||` — that re-introduces the rehydration + * regression for both secrets and non-secrets. If a new import path + * silently stores blanks, the fix is to route it through + * `promoteInitialValueForImport` upstream, not to change this function. + */ +export const populateLocalStoresFromVariables = ( + entityId: string, + variables: readonly SecretCapableVariable[] +) => { + if (!entityId) return + + const secretEnvironmentService = getService(SecretEnvironmentService) + const currentEnvironmentValueService = getService(CurrentValueService) + + const secrets = variables.flatMap((v, index) => + v.secret + ? [ + { + key: v.key, + value: v.currentValue ?? v.initialValue ?? "", + initialValue: v.initialValue ?? "", + varIndex: index, + }, + ] + : [] + ) + + const nonSecrets = variables.flatMap((v, index) => + !v.secret + ? [ + { + key: v.key, + currentValue: v.currentValue ?? v.initialValue ?? "", + varIndex: index, + isSecret: false as const, + }, + ] + : [] + ) + + secretEnvironmentService.addSecretEnvironment(entityId, secrets) + currentEnvironmentValueService.addEnvironment(entityId, nonSecrets) +} + +/** + * Foreign-import convention normalizer for variables exported with + * `currentValue: ""` and the real value in `initialValue` — Postman / + * Insomnia format, plus legacy Hoppscotch exports from before non-secret + * `currentValue` was preserved on the wire. Promote so the local stores + * see the imported value. + * + * The global-env rehydration path bypasses this step intentionally — there + * `""` means "user deliberately cleared," not "value lives in initialValue." + * Idempotent — promoted entries are unchanged on a second pass. + */ +export const promoteInitialValueForImport = ( + variables: readonly SecretCapableVariable[] +): SecretCapableVariable[] => + variables.map((v) => + !v.currentValue && v.initialValue + ? { ...v, currentValue: v.initialValue } + : v + ) + +export const populateLocalStoresFromCollectionTree = ( + collection: HoppCollection +) => { + if (collection._ref_id) { + populateLocalStoresFromVariables( + collection._ref_id, + promoteInitialValueForImport(collection.variables ?? []) + ) + } else { + // All current callers of THIS function run `ensureRefIds` upstream + // so this should be unreachable; the warn exists so a future caller + // that skips it is debuggable instead of silently dropping secrets. + // Scope note: the equivalent "missing ref-id" case on the + // backstop-repopulate path is NOT covered here — + // `repopulateLoadedCollectionTree` calls `populateLocalStoresFromVariables` + // directly and bypasses this branch. That gap is covered by the + // `unpairedCount` warning in + // `selfhost-web/.../web/import.ts:importToPersonalWorkspace`. + console.warn( + "[populateLocalStoresFromCollectionTree] collection has no `_ref_id`; secret values will not be persisted locally", + collection.name + ) + } + ;(collection.folders ?? []).forEach(populateLocalStoresFromCollectionTree) +} + +/** Fresh node objects + `folders` arrays; other fields alias the input. */ +export const ensureRefIds = (collection: HoppCollection): HoppCollection => ({ + ...collection, + _ref_id: collection._ref_id ?? generateUniqueRefId("coll"), + folders: (collection.folders ?? []).map(ensureRefIds), +}) + +/** Reallocates `variables` and `folders`; other fields alias the input. */ +export const stripCollectionTreeForStore = ( + collection: HoppCollection +): HoppCollection => ({ + ...collection, + variables: stripSecretVariableValuesForWire(collection.variables ?? []), + folders: (collection.folders ?? []).map(stripCollectionTreeForStore), +}) + +/** Flat `_ref_id → collection` index across a tree. Mutates `out`. */ +export const indexCollectionsByRefId = ( + collections: HoppCollection[], + out: Map +) => { + collections.forEach((c) => { + if (c._ref_id) out.set(c._ref_id, c) + if (c.folders?.length) indexCollectionsByRefId(c.folders, out) + }) +} + +/** + * Re-seed local stores after bulk-import using `_ref_id` (round-tripped + * via `data._ref_id`) instead of array index — backend may reorder. + * Assumes the backend preserves `data._ref_id` at EVERY level (root + + * nested folders); unpaired nodes fall through to `flushUnmatchedRefIdsFromTree`. + */ +export const repopulateLoadedCollectionTree = ( + loaded: HoppCollection, + originalsByRefId: Map +) => { + if (loaded._ref_id) { + const original = originalsByRefId.get(loaded._ref_id) + if (original) { + // Same promote as `populateLocalStoresFromCollectionTree` — `original` + // is the pre-strip input tree, which for Postman/Insomnia imports + // still carries the secret in `initialValue` with `currentValue: ""`. + populateLocalStoresFromVariables( + loaded._ref_id, + promoteInitialValueForImport(original.variables ?? []) + ) + } + } + ;(loaded.folders ?? []).forEach((loadedFolder) => { + repopulateLoadedCollectionTree(loadedFolder, originalsByRefId) + }) +} + +/** + * Flush every node's local-store entries on delete. Flushes by both + * `_ref_id` (personal) and `id` (team); services no-op on missing keys. + */ +export const flushLocalStoresForCollectionTree = ( + collection: HoppCollection +) => { + const secretEnvironmentService = getService(SecretEnvironmentService) + const currentEnvironmentValueService = getService(CurrentValueService) + + const walk = (node: HoppCollection) => { + if (node._ref_id) { + secretEnvironmentService.deleteSecretEnvironment(node._ref_id) + currentEnvironmentValueService.deleteEnvironment(node._ref_id) + } + if (node.id) { + secretEnvironmentService.deleteSecretEnvironment(node.id) + currentEnvironmentValueService.deleteEnvironment(node.id) + } + ;(node.folders ?? []).forEach(walk) + } + walk(collection) +} + +/** + * Flush local-store entries keyed by `_ref_id`s in `tree` that aren't + * present in `keptRefIds`. Used after `repopulateLoadedCollectionTree` + * to clean up orphans on old SH backends that drop the `data._ref_id` + * round-trip — upstream populate seeded entries under originals' refIds + * that the loaded tree (with fresh UUIDs) can't reach. + */ +export const flushUnmatchedRefIdsFromTree = ( + tree: HoppCollection[], + keptRefIds: ReadonlySet +) => { + const secretEnvironmentService = getService(SecretEnvironmentService) + const currentEnvironmentValueService = getService(CurrentValueService) + + const walk = (nodes: HoppCollection[]) => { + nodes.forEach((node) => { + if (node._ref_id && !keptRefIds.has(node._ref_id)) { + secretEnvironmentService.deleteSecretEnvironment(node._ref_id) + currentEnvironmentValueService.deleteEnvironment(node._ref_id) + } + walk(node.folders ?? []) + }) + } + walk(tree) +} + +/** + * Recursive flush for a team-collection subtree. Walks `children` (not + * `folders` — different shape from `HoppCollection`) and deletes each + * descendant's secret/current entries by backend `id`. Without this, + * deleting a team collection leaves nested folders' secrets orphaned in + * the secret service. + */ +type TeamCollectionNode = { + id: string + children: TeamCollectionNode[] | null | undefined +} + +export const flushLocalStoresForTeamCollectionTree = ( + collection: TeamCollectionNode +) => { + const secretEnvironmentService = getService(SecretEnvironmentService) + const currentEnvironmentValueService = getService(CurrentValueService) + + const walk = (node: TeamCollectionNode) => { + secretEnvironmentService.deleteSecretEnvironment(node.id) + currentEnvironmentValueService.deleteEnvironment(node.id) + ;(node.children ?? []).forEach(walk) + } + walk(collection) +} diff --git a/packages/hoppscotch-common/src/helpers/shortcode/Shortcode.ts b/packages/hoppscotch-common/src/helpers/shortcode/Shortcode.ts new file mode 100644 index 0000000..01e4d76 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/shortcode/Shortcode.ts @@ -0,0 +1,9 @@ +/** + * Defines how a Shortcode is represented in the ShortcodeListAdapter + */ +export interface Shortcode { + id: string + request: string + properties?: string | null + createdOn: Date +} diff --git a/packages/hoppscotch-common/src/helpers/shortcode/ShortcodeListAdapter.ts b/packages/hoppscotch-common/src/helpers/shortcode/ShortcodeListAdapter.ts new file mode 100644 index 0000000..dad064d --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/shortcode/ShortcodeListAdapter.ts @@ -0,0 +1,199 @@ +import * as E from "fp-ts/Either" +import { BehaviorSubject, Subscription } from "rxjs" +import { Subscription as WSubscription } from "wonka" +import { GQLError, runAuthOnlyGQLSubscription } from "../backend/GQLClient" +import { + GetUserShortcodesQuery, + ShortcodeCreatedDocument, + ShortcodeDeletedDocument, + ShortcodeUpdatedDocument, +} from "../backend/graphql" +import { BACKEND_PAGE_SIZE } from "../backend/helpers" +import { Shortcode } from "./Shortcode" +import { platform } from "~/platform" + +export default class ShortcodeListAdapter { + error$: BehaviorSubject | null> + loading$: BehaviorSubject + shortcodes$: BehaviorSubject + hasMoreShortcodes$: BehaviorSubject + + private isDispose: boolean + + private shortcodeCreated: Subscription | null + private shortcodeRevoked: Subscription | null + private shortcodeUpdated: Subscription | null + + private shortcodeCreatedSub: WSubscription | null + private shortcodeRevokedSub: WSubscription | null + private shortcodeUpdatedSub: WSubscription | null + + constructor(deferInit = false) { + this.error$ = new BehaviorSubject | null>(null) + this.loading$ = new BehaviorSubject(false) + this.shortcodes$ = new BehaviorSubject< + GetUserShortcodesQuery["myShortcodes"] + >([]) + this.hasMoreShortcodes$ = new BehaviorSubject(true) + this.isDispose = true + this.shortcodeCreated = null + this.shortcodeRevoked = null + this.shortcodeUpdated = null + this.shortcodeCreatedSub = null + this.shortcodeRevokedSub = null + this.shortcodeUpdatedSub = null + + if (!deferInit) this.initialize() + } + + unsubscribeSubscriptions() { + this.shortcodeCreated?.unsubscribe() + this.shortcodeRevoked?.unsubscribe() + this.shortcodeUpdated?.unsubscribe() + this.shortcodeCreatedSub?.unsubscribe() + this.shortcodeRevokedSub?.unsubscribe() + this.shortcodeUpdatedSub?.unsubscribe() + } + + initialize() { + if (!this.isDispose) throw new Error(`Adapter is already initialized`) + + this.isDispose = false + this.fetchList() + this.registerSubscriptions() + } + + /** + * Returns whether the shortcode adapter is active and initialized + */ + public isInitialized() { + return !this.isDispose + } + + public dispose() { + if (this.isDispose) throw new Error(`Adapter has been disposed`) + this.isDispose = true + this.shortcodes$.next([]) + this.unsubscribeSubscriptions() + } + + fetchList() { + this.loadMore(true) + } + + async loadMore(forcedAttempt = false) { + if (!this.hasMoreShortcodes$.value && !forcedAttempt) return + + this.loading$.next(true) + + const lastCodeID = + this.shortcodes$.value.length > 0 + ? this.shortcodes$.value[this.shortcodes$.value.length - 1].id + : undefined + + const result = await platform.backend.getUserShortcodes(lastCodeID) + + if (E.isLeft(result)) { + this.error$.next(result.left) + console.error(result.left) + this.loading$.next(false) + + throw new Error(`Failed fetching shortcodes list: ${result.left}`) + } + + const fetchedResult = result.right.myShortcodes + + this.pushNewShortcode(fetchedResult) + + if (fetchedResult.length !== BACKEND_PAGE_SIZE) { + this.hasMoreShortcodes$.next(false) + } + + this.loading$.next(false) + } + + private pushNewShortcode(results: Shortcode[]) { + const userShortcodes = this.shortcodes$.value + + userShortcodes.push(...results) + + this.shortcodes$.next(userShortcodes) + } + + private createShortcode(shortcode: Shortcode) { + const userShortcode = this.shortcodes$.value + + userShortcode.unshift(shortcode) + + this.shortcodes$.next(userShortcode) + } + + private deleteSharedRequest(codeId: string) { + const newShortcode = this.shortcodes$.value.filter( + ({ id }) => id !== codeId + ) + + this.shortcodes$.next(newShortcode) + } + + private updateSharedRequest(shortcode: Shortcode) { + const newShortcode = this.shortcodes$.value.map((oldShortcode) => + oldShortcode.id === shortcode.id ? shortcode : oldShortcode + ) + + this.shortcodes$.next(newShortcode) + } + + private registerSubscriptions() { + const [shortcodeCreated$, shortcodeCreatedSub] = runAuthOnlyGQLSubscription( + { + query: ShortcodeCreatedDocument, + variables: {}, + } + ) + + this.shortcodeCreatedSub = shortcodeCreatedSub + this.shortcodeCreated = shortcodeCreated$.subscribe((result) => { + if (E.isLeft(result)) { + console.error(result.left) + throw new Error(`Shortcode Create Error ${result.left}`) + } + + this.createShortcode(result.right.myShortcodesCreated) + }) + + const [shortcodeRevoked$, shortcodeRevokedSub] = runAuthOnlyGQLSubscription( + { + query: ShortcodeDeletedDocument, + variables: {}, + } + ) + + this.shortcodeRevokedSub = shortcodeRevokedSub + this.shortcodeRevoked = shortcodeRevoked$.subscribe((result) => { + if (E.isLeft(result)) { + console.error(result.left) + throw new Error(`Shortcode Delete Error ${result.left}`) + } + + this.deleteSharedRequest(result.right.myShortcodesRevoked.id) + }) + + const [shortcodeUpdated$, shortcodeUpdatedSub] = runAuthOnlyGQLSubscription( + { + query: ShortcodeUpdatedDocument, + variables: {}, + } + ) + + this.shortcodeUpdatedSub = shortcodeUpdatedSub + this.shortcodeUpdated = shortcodeUpdated$.subscribe((result) => { + if (E.isLeft(result)) { + console.error(result.left) + throw new Error(`Shortcode Update Error ${result.left}`) + } + + this.updateSharedRequest(result.right.myShortcodesUpdated) + }) + } +} diff --git a/packages/hoppscotch-common/src/helpers/shortcuts.ts b/packages/hoppscotch-common/src/helpers/shortcuts.ts new file mode 100644 index 0000000..b6aa1f5 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/shortcuts.ts @@ -0,0 +1,229 @@ +import { getPlatformAlternateKey, getPlatformSpecialKey } from "./platformutils" +import { getKernelMode } from "@hoppscotch/kernel" + +export type ShortcutDef = { + label: string + keys: string[] + section: string +} + +export function getShortcuts(t: (x: string) => string): ShortcutDef[] { + const kernelMode = getKernelMode() + const isDesktop = kernelMode === "desktop" + + const baseShortcuts: ShortcutDef[] = [ + // General + { + label: t("shortcut.general.help_menu"), + keys: ["?"], + section: t("shortcut.general.title"), + }, + { + label: t("shortcut.general.command_menu"), + keys: [getPlatformSpecialKey(), "K"], + section: t("shortcut.general.title"), + }, + { + label: t("shortcut.general.show_all"), + keys: [getPlatformSpecialKey(), "/"], + section: t("shortcut.general.title"), + }, + { + label: t("shortcut.general.close_current_menu"), + keys: ["ESC"], + section: t("shortcut.general.title"), + }, + { + label: t("shortcut.general.undo"), + keys: [getPlatformSpecialKey(), "Z"], + section: t("shortcut.general.title"), + }, + { + label: t("shortcut.general.redo"), + keys: [getPlatformSpecialKey(), "Y"], + section: t("shortcut.general.title"), + }, + { + label: t("shortcut.general.comment_uncomment"), + keys: [getPlatformSpecialKey(), "/"], + section: t("shortcut.general.title"), + }, + + // Request + { + label: t("shortcut.request.send_request"), + keys: [getPlatformSpecialKey(), "↩"], + section: t("shortcut.request.title"), + }, + { + keys: [getPlatformSpecialKey(), "S"], + label: t("shortcut.request.save_to_collections"), + section: t("shortcut.request.title"), + }, + { + keys: [getPlatformSpecialKey(), "U"], + label: t("shortcut.request.share_request"), + section: t("shortcut.request.title"), + }, + { + keys: [getPlatformSpecialKey(), "I"], + label: t("shortcut.request.reset_request"), + section: t("shortcut.request.title"), + }, + { + keys: [getPlatformAlternateKey(), "↑"], + label: t("shortcut.request.next_method"), + section: t("shortcut.request.title"), + }, + { + keys: [getPlatformAlternateKey(), "↓"], + label: t("shortcut.request.previous_method"), + section: t("shortcut.request.title"), + }, + { + keys: [getPlatformAlternateKey(), "G"], + label: t("shortcut.request.get_method"), + section: t("shortcut.request.title"), + }, + { + keys: [getPlatformAlternateKey(), "H"], + label: t("shortcut.request.head_method"), + section: t("shortcut.request.title"), + }, + { + keys: [getPlatformAlternateKey(), "P"], + label: t("shortcut.request.post_method"), + section: t("shortcut.request.title"), + }, + { + keys: [getPlatformAlternateKey(), "U"], + label: t("shortcut.request.put_method"), + section: t("shortcut.request.title"), + }, + { + keys: [getPlatformAlternateKey(), "X"], + label: t("shortcut.request.delete_method"), + section: t("shortcut.request.title"), + }, + + // Response + { + keys: [getPlatformSpecialKey(), "J"], + label: t("shortcut.response.download"), + section: t("shortcut.response.title"), + }, + { + keys: [getPlatformSpecialKey(), "."], + label: t("shortcut.response.copy"), + section: t("shortcut.response.title"), + }, + + // Navigation + { + keys: [getPlatformSpecialKey(), "←"], + label: t("shortcut.navigation.back"), + section: t("shortcut.navigation.title"), + }, + { + keys: [getPlatformSpecialKey(), "→"], + label: t("shortcut.navigation.forward"), + section: t("shortcut.navigation.title"), + }, + { + keys: [getPlatformAlternateKey(), "R"], + label: t("shortcut.navigation.rest"), + section: t("shortcut.navigation.title"), + }, + { + keys: [getPlatformAlternateKey(), "Q"], + label: t("shortcut.navigation.graphql"), + section: t("shortcut.navigation.title"), + }, + { + keys: [getPlatformAlternateKey(), "W"], + label: t("shortcut.navigation.realtime"), + section: t("shortcut.navigation.title"), + }, + { + keys: [getPlatformAlternateKey(), "S"], + label: t("shortcut.navigation.settings"), + section: t("shortcut.navigation.title"), + }, + { + keys: [getPlatformAlternateKey(), "M"], + label: t("shortcut.navigation.profile"), + section: t("shortcut.navigation.title"), + }, + + // Miscellaneous + { + keys: [getPlatformSpecialKey(), "M"], + label: t("shortcut.miscellaneous.invite"), + section: t("shortcut.miscellaneous.title"), + }, + ] + + // Web-only shortcuts + const webShortcuts: ShortcutDef[] = [ + { + label: t("shortcut.general.close_tab"), + keys: [getPlatformSpecialKey(), "D"], + section: t("shortcut.general.title"), + }, + ] + + // Desktop-only shortcuts + const desktopShortcuts: ShortcutDef[] = [ + { + keys: [getPlatformSpecialKey(), getPlatformAlternateKey(), "U"], + label: t("shortcut.request.focus_url"), + section: t("shortcut.request.title"), + }, + { + keys: [getPlatformSpecialKey(), "T"], + label: t("shortcut.tabs.new_tab"), + section: t("shortcut.tabs.title"), + }, + { + keys: [getPlatformSpecialKey(), "W"], + label: t("shortcut.tabs.close_tab"), + section: t("shortcut.tabs.title"), + }, + { + keys: [getPlatformSpecialKey(), getPlatformAlternateKey(), "→"], + label: t("shortcut.tabs.next_tab"), + section: t("shortcut.tabs.title"), + }, + { + keys: [getPlatformSpecialKey(), getPlatformAlternateKey(), "←"], + label: t("shortcut.tabs.previous_tab"), + section: t("shortcut.tabs.title"), + }, + { + keys: [getPlatformSpecialKey(), getPlatformAlternateKey(), "9"], + label: t("shortcut.tabs.first_tab"), + section: t("shortcut.tabs.title"), + }, + { + keys: [getPlatformSpecialKey(), getPlatformAlternateKey(), "0"], + label: t("shortcut.tabs.last_tab"), + section: t("shortcut.tabs.title"), + }, + { + keys: [getPlatformSpecialKey(), getPlatformAlternateKey(), "]"], + label: t("shortcut.tabs.mru_switch"), + section: t("shortcut.tabs.title"), + }, + { + keys: [getPlatformSpecialKey(), getPlatformAlternateKey(), "["], + label: t("shortcut.tabs.mru_switch_reverse"), + section: t("shortcut.tabs.title"), + }, + ] + + // Return base shortcuts + platform-specific shortcuts + if (isDesktop) { + return [...baseShortcuts, ...desktopShortcuts] + } + return [...baseShortcuts, ...webShortcuts] +} diff --git a/packages/hoppscotch-common/src/helpers/syntax/gqlQueryLangMode.js b/packages/hoppscotch-common/src/helpers/syntax/gqlQueryLangMode.js new file mode 100644 index 0000000..29939e4 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/syntax/gqlQueryLangMode.js @@ -0,0 +1,118 @@ +export function defineGQLLanguageMode(ace) { + // Highlighting + ace.define( + "ace/mode/gql-query-highlight", + ["require", "exports", "ace/lib/oop", "ace/mode/text_highlight_rules"], + (aceRequire, exports) => { + const oop = aceRequire("ace/lib/oop") + + const TextHighlightRules = aceRequire( + "ace/mode/text_highlight_rules" + ).TextHighlightRules + + const GQLQueryTextHighlightRules = function () { + const keywords = + "type|interface|union|enum|schema|input|implements|extends|scalar|fragment|query|mutation|subscription" + + const dataTypes = "Int|Float|String|ID|Boolean" + + const literalValues = "true|false|null" + + const escapeRe = /\\(?:u[\da-fA-f]{4}|.)/ + + const keywordMapper = this.createKeywordMapper( + { + keyword: keywords, + "storage.type": dataTypes, + "constant.language": literalValues, + }, + "identifier" + ) + + this.$rules = { + start: [ + { + token: "comment", + regex: "#.*$", + }, + { + token: "paren.lparen", + regex: /[[({]/, + next: "start", + }, + { + token: "paren.rparen", + regex: /[\])}]/, + }, + { + token: keywordMapper, + regex: "[a-zA-Z_][a-zA-Z0-9_$]*\\b", + }, + { + token: "string", // character + regex: `'(?:${escapeRe}|.)?'`, + }, + { + token: "string.start", + regex: '"', + stateName: "qqstring", + next: [ + { token: "string", regex: /\\\s*$/, next: "qqstring" }, + { token: "constant.language.escape", regex: escapeRe }, + { token: "string.end", regex: '"|$', next: "start" }, + { defaultToken: "string" }, + ], + }, + { + token: "string.start", + regex: "'", + stateName: "singleQuoteString", + next: [ + { token: "string", regex: /\\\s*$/, next: "singleQuoteString" }, + { token: "constant.language.escape", regex: escapeRe }, + { token: "string.end", regex: "'|$", next: "start" }, + { defaultToken: "string" }, + ], + }, + { + token: "constant.numeric", + regex: /\d+\.?\d*[eE]?[+-]?\d*/, + }, + { + token: "variable", + regex: /\$[_A-Za-z][_0-9A-Za-z]*/, + }, + ], + } + this.normalizeRules() + } + + oop.inherits(GQLQueryTextHighlightRules, TextHighlightRules) + + exports.GQLQueryTextHighlightRules = GQLQueryTextHighlightRules + } + ) + + // Language Mode Definition + ace.define( + "ace/mode/gql-query", + ["require", "exports", "ace/mode/text", "ace/mode/gql-query-highlight"], + (aceRequire, exports) => { + const oop = aceRequire("ace/lib/oop") + const TextMode = aceRequire("ace/mode/text").Mode + const GQLQueryTextHighlightRules = aceRequire( + "ace/mode/gql-query-highlight" + ).GQLQueryTextHighlightRules + const FoldMode = aceRequire("ace/mode/folding/cstyle").FoldMode + + const Mode = function () { + this.HighlightRules = GQLQueryTextHighlightRules + this.foldingRules = new FoldMode() + } + + oop.inherits(Mode, TextMode) + + exports.Mode = Mode + } + ) +} diff --git a/packages/hoppscotch-common/src/helpers/teams/TeamCollection.ts b/packages/hoppscotch-common/src/helpers/teams/TeamCollection.ts new file mode 100644 index 0000000..5665cfd --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/teams/TeamCollection.ts @@ -0,0 +1,33 @@ +import { runGQLQuery } from "../backend/GQLClient" +import { + GetCollectionChildrenDocument, + GetSingleCollectionDocument, +} from "../backend/graphql" +import { TeamRequest } from "./TeamRequest" + +/** + * Defines how a Team Collection is represented in the TeamCollectionAdapter + */ +export interface TeamCollection { + id: string + title: string + children: TeamCollection[] | null + requests: TeamRequest[] | null + data?: string | null +} + +export const getSingleCollection = (collectionID: string) => + runGQLQuery({ + query: GetSingleCollectionDocument, + variables: { + collectionID, + }, + }) + +export const getCollectionChildCollections = (collectionID: string) => + runGQLQuery({ + query: GetCollectionChildrenDocument, + variables: { + collectionID, + }, + }) diff --git a/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts b/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts new file mode 100644 index 0000000..94ff869 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts @@ -0,0 +1,1215 @@ +import * as E from "fp-ts/Either" +import { BehaviorSubject, Subscription } from "rxjs" +import { HoppCollectionVariable, translateToNewRequest } from "@hoppscotch/data" +import { pull, remove } from "lodash-es" +import { hasActualScript } from "@hoppscotch/js-sandbox/scripting" +import { CollectionDataProps } from "~/helpers/backend/helpers" +import { Subscription as WSubscription } from "wonka" +import { runGQLQuery, runGQLSubscription } from "../backend/GQLClient" +import { TeamCollection } from "./TeamCollection" +import { TeamRequest } from "./TeamRequest" +import { + RootCollectionsOfTeamDocument, + TeamCollectionAddedDocument, + TeamCollectionUpdatedDocument, + TeamCollectionRemovedDocument, + TeamRequestAddedDocument, + TeamRequestUpdatedDocument, + TeamRequestDeletedDocument, + GetCollectionChildrenDocument, + GetCollectionRequestsDocument, + TeamRequestMovedDocument, + TeamCollectionMovedDocument, + TeamRequestOrderUpdatedDocument, + TeamCollectionOrderUpdatedDocument, +} from "~/helpers/backend/graphql" +import { HoppInheritedProperty } from "../types/HoppInheritedProperties" +import { SecretEnvironmentService } from "~/services/secret-environment.service" +import { CurrentValueService } from "~/services/current-environment-value.service" +import { getService } from "~/modules/dioc" + +export const TEAMS_BACKEND_PAGE_SIZE = 10 + +/** + * Finds the parent of a collection and returns the REFERENCE (or null) + * + * @param {TeamCollection[]} tree - The tree to look in + * @param {string} collID - ID of the collection to find the parent of + * @param {TeamCollection} currentParent - (used for recursion, do not set) The parent in the current iteration (undefined if root) + * + * @returns REFERENCE to the collection or null if not found or the collection is in root + */ +function findParentOfColl( + tree: TeamCollection[], + collID: string, + currentParent?: TeamCollection +): TeamCollection | null { + for (const coll of tree) { + // If the root is parent, return null + if (coll.id === collID) return currentParent || null + + // Else run it in children + if (coll.children) { + const result = findParentOfColl(coll.children, collID, coll) + if (result) return result + } + } + + return null +} + +/** + * Finds and returns a REFERENCE collection in the given tree (or null) + * + * @param {TeamCollection[]} tree - The tree to look in + * @param {string} targetID - The ID of the collection to look for + * + * @returns REFERENCE to the collection or null if not found + */ +function findCollInTree( + tree: TeamCollection[], + targetID: string +): TeamCollection | null { + for (const coll of tree) { + // If the direct child matched, then return that + if (coll.id === targetID) return coll + + // Else run it in the children + if (coll.children) { + const result = findCollInTree(coll.children, targetID) + if (result) return result + } + } + + // If nothing matched, return null + return null +} + +/** + * Deletes a collection in the tree + * + * @param {TeamCollection[]} tree - The tree to delete in (THIS WILL BE MUTATED!) + * @param {string} targetID - ID of the collection to delete + */ +function deleteCollInTree(tree: TeamCollection[], targetID: string) { + // Get the parent owning the collection + const parent = findParentOfColl(tree, targetID) + + // If we found a parent, update it + if (parent && parent.children) { + parent.children = parent.children.filter((coll) => coll.id !== targetID) + } + + // If there is no parent, it could mean: + // 1. The collection with that ID does not exist + // 2. The collection is in root (therefore, no parent) + + // Let's look for element, if not exist, then stop + const el = findCollInTree(tree, targetID) + if (!el) return + + // Collection exists, so this should be in root, hence removing element + pull(tree, el) +} + +/** + * Updates a collection in the tree with the specified data + * + * @param {TeamCollection[]} tree - The tree to update in (THIS WILL BE MUTATED!) + * @param {Partial & Pick} updateColl - An object defining all the fields that should be updated (ID is required to find the target collection) + */ +function updateCollInTree( + tree: TeamCollection[], + updateColl: Partial & Pick +) { + const el = findCollInTree(tree, updateColl.id) + + // If no match, stop the operation + if (!el) return + + // Update all the specified keys + Object.assign(el, updateColl) +} + +/** + * Finds and returns a REFERENCE to the request with the given ID (or null) + * + * @param {TeamCollection[]} tree - The tree to look in + * @param {string} reqID - The ID of the request to look for + * + * @returns REFERENCE to the request or null if request not found + */ +function findReqInTree( + tree: TeamCollection[], + reqID: string +): TeamRequest | null { + for (const coll of tree) { + // Check in root collections (if expanded) + if (coll.requests) { + const match = coll.requests.find((req) => req.id === reqID) + if (match) return match + } + + // Check in children of collections + if (coll.children) { + const match = findReqInTree(coll.children, reqID) + if (match) return match + } + } + + // No matches + return null +} + +/** + * Finds and returns a REFERENCE to the collection containing a given request ID in tree (or null) + * + * @param {TeamCollection[]} tree - The tree to look in + * @param {string} reqID - The ID of the request to look for + * + * @returns REFERENCE to the collection or null if request not found + */ +function findCollWithReqIDInTree( + tree: TeamCollection[], + reqID: string +): TeamCollection | null { + for (const coll of tree) { + // Check in root collections (if expanded) + if (coll.requests) { + if (coll.requests.find((req) => req.id === reqID)) return coll + } + + // Check in children of collections + if (coll.children) { + const result = findCollWithReqIDInTree(coll.children, reqID) + if (result) return result + } + } + + // No matches + return null +} + +type EntityType = "request" | "collection" +type EntityID = `${EntityType}-${string}` + +export default class NewTeamCollectionAdapter { + collections$: BehaviorSubject + + // Stream to the list of collections/folders that are being loaded in + loadingCollections$: BehaviorSubject + + /** + * Stores the entity (collection/request/folder) ids of all the loaded entities. + * Used for preventing duplication of data which definitely is not possible (duplication due to network problems etc.) + */ + private entityIDs: Set + + private teamCollectionAdded$: Subscription | null + private teamCollectionUpdated$: Subscription | null + private teamCollectionRemoved$: Subscription | null + private teamRequestAdded$: Subscription | null + private teamRequestUpdated$: Subscription | null + private teamRequestDeleted$: Subscription | null + private teamRequestMoved$: Subscription | null + private teamCollectionMoved$: Subscription | null + private teamRequestOrderUpdated$: Subscription | null + private teamCollectionOrderUpdated$: Subscription | null + + private teamCollectionAddedSub: WSubscription | null + private teamCollectionUpdatedSub: WSubscription | null + private teamCollectionRemovedSub: WSubscription | null + private teamRequestAddedSub: WSubscription | null + private teamRequestUpdatedSub: WSubscription | null + private teamRequestDeletedSub: WSubscription | null + private teamRequestMovedSub: WSubscription | null + private teamCollectionMovedSub: WSubscription | null + private teamRequestOrderUpdatedSub: WSubscription | null + private teamCollectionOrderUpdatedSub: WSubscription | null + + //collection variables current value and secret value + private secretEnvironmentService = getService(SecretEnvironmentService) + private currentEnvironmentValueService = getService(CurrentValueService) + + constructor(private teamID: string | null) { + this.collections$ = new BehaviorSubject([]) + this.loadingCollections$ = new BehaviorSubject([]) + + this.entityIDs = new Set() + + this.teamCollectionAdded$ = null + this.teamCollectionUpdated$ = null + this.teamCollectionRemoved$ = null + this.teamRequestAdded$ = null + this.teamRequestDeleted$ = null + this.teamRequestUpdated$ = null + this.teamRequestMoved$ = null + this.teamCollectionMoved$ = null + this.teamRequestOrderUpdated$ = null + this.teamCollectionOrderUpdated$ = null + + this.teamCollectionAddedSub = null + this.teamCollectionUpdatedSub = null + this.teamCollectionRemovedSub = null + this.teamRequestAddedSub = null + this.teamRequestDeletedSub = null + this.teamRequestUpdatedSub = null + this.teamRequestMovedSub = null + this.teamCollectionMovedSub = null + this.teamRequestOrderUpdatedSub = null + this.teamCollectionOrderUpdatedSub = null + + if (this.teamID) this.initialize() + } + + changeTeamID(newTeamID: string | null) { + this.teamID = newTeamID + this.collections$.next([]) + this.entityIDs.clear() + + this.loadingCollections$.next([]) + + this.unsubscribeSubscriptions() + + if (this.teamID) this.initialize() + } + + /** + * Unsubscribes from the subscriptions + * NOTE: Once this is called, no new updates to the tree will be detected + */ + unsubscribeSubscriptions() { + this.teamCollectionAdded$?.unsubscribe() + this.teamCollectionUpdated$?.unsubscribe() + this.teamCollectionRemoved$?.unsubscribe() + this.teamRequestAdded$?.unsubscribe() + this.teamRequestDeleted$?.unsubscribe() + this.teamRequestUpdated$?.unsubscribe() + this.teamRequestMoved$?.unsubscribe() + this.teamCollectionMoved$?.unsubscribe() + this.teamRequestOrderUpdated$?.unsubscribe() + this.teamCollectionOrderUpdated$?.unsubscribe() + + this.teamCollectionAddedSub?.unsubscribe() + this.teamCollectionUpdatedSub?.unsubscribe() + this.teamCollectionRemovedSub?.unsubscribe() + this.teamRequestAddedSub?.unsubscribe() + this.teamRequestDeletedSub?.unsubscribe() + this.teamRequestUpdatedSub?.unsubscribe() + this.teamRequestMovedSub?.unsubscribe() + this.teamCollectionMovedSub?.unsubscribe() + this.teamRequestOrderUpdatedSub?.unsubscribe() + this.teamCollectionOrderUpdatedSub?.unsubscribe() + } + + private async initialize() { + await this.loadRootCollections() + this.registerSubscriptions() + } + + /** + * Performs addition of a collection to the tree + * + * @param {TeamCollection} collection - The collection to add to the tree + * @param {string | null} parentCollectionID - The parent of the new collection, pass null if this collection is in root + */ + private addCollection( + collection: TeamCollection, + parentCollectionID: string | null + ) { + // Check if we have it already in the entity tree, if so, we don't need it again + if (this.entityIDs.has(`collection-${collection.id}`)) return + + const tree = this.collections$.value + + if (!parentCollectionID) { + tree.push(collection) + } else { + const parentCollection = findCollInTree(tree, parentCollectionID) + + if (!parentCollection) return + + // Prevent adding child collections to a collection that has not been expanded yet incoming from GQL subscription, during import, etc + // Hence, add entries to the pre-existing list without setting 'children' if it is `null' + if (parentCollection.children !== null) { + parentCollection.children.push(collection) + } + } + + // Add to entity ids set + this.entityIDs.add(`collection-${collection.id}`) + + this.collections$.next(tree) + } + + private async loadRootCollections() { + if (this.teamID === null) throw new Error("Team ID is null") + + this.loadingCollections$.next([ + ...this.loadingCollections$.getValue(), + "root", + ]) + + const totalCollections: TeamCollection[] = [] + + while (true) { + const result = await runGQLQuery({ + query: RootCollectionsOfTeamDocument, + variables: { + teamID: this.teamID, + cursor: + totalCollections.length > 0 + ? totalCollections[totalCollections.length - 1].id + : undefined, + }, + }) + + if (E.isLeft(result)) { + this.loadingCollections$.next( + this.loadingCollections$.getValue().filter((x) => x !== "root") + ) + + throw new Error(`Error fetching root collections: ${result.left.error}`) + } + + totalCollections.push( + ...result.right.rootCollectionsOfTeam.map( + (x) => + { + ...x, + children: null, + requests: null, + } + ) + ) + + if (result.right.rootCollectionsOfTeam.length !== TEAMS_BACKEND_PAGE_SIZE) + break + } + + this.loadingCollections$.next( + this.loadingCollections$.getValue().filter((x) => x !== "root") + ) + + // Add all the collections to the entity ids list + totalCollections.forEach((coll) => + this.entityIDs.add(`collection-${coll.id}`) + ) + + this.collections$.next(totalCollections) + } + + /** + * Updates an existing collection in tree + * + * @param {Partial & Pick} collectionUpdate - Object defining the fields that need to be updated (ID is required to find the target) + */ + private updateCollection( + collectionUpdate: Partial & Pick + ) { + const tree = this.collections$.value + + updateCollInTree(tree, collectionUpdate) + + this.collections$.next(tree) + } + + /** + * Removes a collection from the tree + * + * @param {string} collectionID - ID of the collection to remove + */ + private removeCollection(collectionID: string) { + const tree = this.collections$.value + + deleteCollInTree(tree, collectionID) + + this.entityIDs.delete(`collection-${collectionID}`) + + this.collections$.next(tree) + } + + /** + * Adds a request to the tree + * + * @param {TeamRequest} request - The request to add to the tree + */ + private addRequest(request: TeamRequest) { + // Check if we have it already in the entity tree, if so, we don't need it again + if (this.entityIDs.has(`request-${request.id}`)) return + + const tree = this.collections$.value + + // Check if we have the collection (if not, then not loaded?) + const coll = findCollInTree(tree, request.collectionID) + if (!coll) return // Ignore add request + + // Collection is not expanded + if (!coll.requests) return + + // Collection is expanded hence append request + coll.requests.push(request) + + // Update the Entity IDs list + this.entityIDs.add(`request-${request.id}`) + + this.collections$.next(tree) + } + + /** + * Updates the request in tree + * + * @param {Partial & Pick} requestUpdate - Object defining all the fields to update in request (ID of the request is required) + */ + private updateRequest( + requestUpdate: Partial & Pick + ) { + const tree = this.collections$.value + + // Find request, if not present, don't update + const req = findReqInTree(tree, requestUpdate.id) + if (!req) return + + Object.assign(req, requestUpdate) + + this.collections$.next(tree) + } + + /** + * Removes a request from the tree + * + * @param {string} requestID - ID of the request to remove + */ + private removeRequest(requestID: string) { + const tree = this.collections$.value + + // Find request in tree, don't attempt if no collection or no requests (expansion?) + const coll = findCollWithReqIDInTree(tree, requestID) + if (!coll || !coll.requests) return + + // Remove the collection + remove(coll.requests, (req) => req.id === requestID) + + // Remove from entityIDs set + this.entityIDs.delete(`request-${requestID}`) + + // Publish new tree + this.collections$.next(tree) + } + + /** + * Moves a request from one collection to another + * + * @param {string} request - The request to move + */ + private async moveRequest(request: TeamRequest) { + const tree = this.collections$.value + + // Remove the request from the current collection + this.removeRequest(request.id) + + const currentRequest = request.request + + if (currentRequest === null || currentRequest === undefined) return + + // Find request in tree, don't attempt if no collection or no requests is found + const collection = findCollInTree(tree, request.collectionID) + if (!collection) return // Ignore add request + + // Collection is not expanded + if (!collection.requests) return + + this.addRequest({ + id: request.id, + collectionID: request.collectionID, + request: translateToNewRequest(request.request), + title: request.title, + }) + } + + /** + * Moves a collection from one collection to another or to root + * + * @param {string} collectionID - The ID of the collection to move + */ + private async moveCollection( + collectionID: string, + parentID: string | null, + title: string, + data?: string | null + ) { + // Remove the collection from the current position + this.removeCollection(collectionID) + + if (collectionID === null || parentID === undefined) return + + // Expand the parent collection if it is not expanded + // so that the old children is also visible when expanding + if (parentID) this.expandCollection(parentID) + + this.addCollection( + { + id: collectionID, + children: null, + requests: null, + title: title, + data, + }, + parentID ?? null + ) + } + + private reorderItems = (array: unknown[], from: number, to: number) => { + const item = array.splice(from, 1)[0] + if (from < to) { + array.splice(to - 1, 0, item) + } else { + array.splice(to, 0, item) + } + } + + public updateRequestOrder( + dragedRequestID: string, + destinationRequestID: string | null, + destinationCollectionID: string + ) { + const tree = this.collections$.value + + // If the destination request is null, then it is the last request in the collection + if (destinationRequestID === null) { + const collection = findCollInTree(tree, destinationCollectionID) + + if (!collection) return // Ignore order update + + // Collection is not expanded + if (!collection.requests) return + + const requestIndex = collection.requests.findIndex( + (req) => req.id === dragedRequestID + ) + + // If the collection index is not found, don't update + if (requestIndex === -1) return + + // Move the request to the end of the requests + collection.requests.push(collection.requests.splice(requestIndex, 1)[0]) + } else { + // Find collection in tree, don't attempt if no collection is found + const collection = findCollInTree(tree, destinationCollectionID) + if (!collection) return // Ignore order update + + // Collection is not expanded + if (!collection.requests) return + + const requestIndex = collection.requests.findIndex( + (req) => req.id === dragedRequestID + ) + const destinationIndex = collection.requests.findIndex( + (req) => req.id === destinationRequestID + ) + + if (requestIndex === -1) return + + this.reorderItems(collection.requests, requestIndex, destinationIndex) + } + + this.collections$.next(tree) + } + + public updateCollectionOrder = ( + collectionID: string, + destinationCollectionID: string | null + ) => { + const tree = this.collections$.value + + // If the destination collection is null, then it is the last collection in the tree + if (destinationCollectionID === null) { + const collLast = findParentOfColl(tree, collectionID) + if (collLast && collLast.children) { + const collectionIndex = collLast.children.findIndex( + (coll) => coll.id === collectionID + ) + + // reorder the collection to the end of the collections + collLast.children.push(collLast.children.splice(collectionIndex, 1)[0]) + } else { + const collectionIndex = tree.findIndex( + (coll) => coll.id === collectionID + ) + + // If the collection index is not found, don't update + if (collectionIndex === -1) return + + // reorder the collection to the end of the collections in the root + tree.push(tree.splice(collectionIndex, 1)[0]) + } + } else { + // Find collection in tree + const coll = findParentOfColl(tree, destinationCollectionID) + + // If the collection has a parent collection and check if it has children + if (coll && coll.children) { + const collectionIndex = coll.children.findIndex( + (coll) => coll.id === collectionID + ) + + const destinationIndex = coll.children.findIndex( + (coll) => coll.id === destinationCollectionID + ) + + // If the collection index is not found, don't update + if (collectionIndex === -1) return + + this.reorderItems(coll.children, collectionIndex, destinationIndex) + } else { + // If the collection has no parent collection, it is a root collection + const collectionIndex = tree.findIndex( + (coll) => coll.id === collectionID + ) + + const destinationIndex = tree.findIndex( + (coll) => coll.id === destinationCollectionID + ) + + // If the collection index is not found, don't update + if (collectionIndex === -1) return + + this.reorderItems(tree, collectionIndex, destinationIndex) + } + } + + this.collections$.next(tree) + } + + private registerSubscriptions() { + if (!this.teamID) return + + const [teamCollAdded$, teamCollAddedSub] = runGQLSubscription({ + query: TeamCollectionAddedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamCollectionAddedSub = teamCollAddedSub + + this.teamCollectionAdded$ = teamCollAdded$.subscribe((result) => { + if (E.isLeft(result)) + throw new Error( + `Team Collection Added Error: ${JSON.stringify(result.left)}` + ) + + this.addCollection( + { + id: result.right.teamCollectionAdded.id, + children: null, + requests: null, + title: result.right.teamCollectionAdded.title, + data: result.right.teamCollectionAdded.data ?? null, + }, + result.right.teamCollectionAdded.parent?.id ?? null + ) + }) + + const [teamCollUpdated$, teamCollUpdatedSub] = runGQLSubscription({ + query: TeamCollectionUpdatedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamCollectionUpdatedSub = teamCollUpdatedSub + this.teamCollectionUpdated$ = teamCollUpdated$.subscribe((result) => { + if (E.isLeft(result)) + throw new Error( + `Team Collection Updated Error: ${JSON.stringify(result.left)}` + ) + + this.updateCollection({ + id: result.right.teamCollectionUpdated.id, + title: result.right.teamCollectionUpdated.title, + data: result.right.teamCollectionUpdated.data, + }) + }) + + const [teamCollRemoved$, teamCollRemovedSub] = runGQLSubscription({ + query: TeamCollectionRemovedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamCollectionRemovedSub = teamCollRemovedSub + this.teamCollectionRemoved$ = teamCollRemoved$.subscribe((result) => { + if (E.isLeft(result)) + throw new Error( + `Team Collection Removed Error: ${JSON.stringify(result.left)}` + ) + + this.removeCollection(result.right.teamCollectionRemoved) + }) + + const [teamReqAdded$, teamReqAddedSub] = runGQLSubscription({ + query: TeamRequestAddedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRequestAddedSub = teamReqAddedSub + this.teamRequestAdded$ = teamReqAdded$.subscribe((result) => { + if (E.isLeft(result)) + throw new Error( + `Team Request Added Error: ${JSON.stringify(result.left)}` + ) + + this.addRequest({ + id: result.right.teamRequestAdded.id, + collectionID: result.right.teamRequestAdded.collectionID, + request: translateToNewRequest( + JSON.parse(result.right.teamRequestAdded.request) + ), + title: result.right.teamRequestAdded.title, + }) + }) + + const [teamReqUpdated$, teamReqUpdatedSub] = runGQLSubscription({ + query: TeamRequestUpdatedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRequestUpdatedSub = teamReqUpdatedSub + this.teamRequestUpdated$ = teamReqUpdated$.subscribe((result) => { + if (E.isLeft(result)) + throw new Error( + `Team Request Updated Error: ${JSON.stringify(result.left)}` + ) + + this.updateRequest({ + id: result.right.teamRequestUpdated.id, + collectionID: result.right.teamRequestUpdated.collectionID, + request: JSON.parse(result.right.teamRequestUpdated.request), + title: result.right.teamRequestUpdated.title, + }) + }) + + const [teamReqDeleted$, teamReqDeleted] = runGQLSubscription({ + query: TeamRequestDeletedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRequestDeletedSub = teamReqDeleted + this.teamRequestDeleted$ = teamReqDeleted$.subscribe((result) => { + if (E.isLeft(result)) + throw new Error( + `Team Request Deleted Error ${JSON.stringify(result.left)}` + ) + + this.removeRequest(result.right.teamRequestDeleted) + }) + + const [teamRequestMoved$, teamRequestMoved] = runGQLSubscription({ + query: TeamRequestMovedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRequestMovedSub = teamRequestMoved + this.teamRequestMoved$ = teamRequestMoved$.subscribe((result) => { + if (E.isLeft(result)) + throw new Error( + `Team Request Move Error ${JSON.stringify(result.left)}` + ) + + const { requestMoved } = result.right + + const request = { + id: requestMoved.id, + collectionID: requestMoved.collectionID, + title: requestMoved.title, + request: JSON.parse(requestMoved.request), + } + + this.moveRequest(request) + }) + + const [teamCollectionMoved$, teamCollectionMoved] = runGQLSubscription({ + query: TeamCollectionMovedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamCollectionMovedSub = teamCollectionMoved + this.teamCollectionMoved$ = teamCollectionMoved$.subscribe((result) => { + if (E.isLeft(result)) + throw new Error( + `Team Collection Move Error ${JSON.stringify(result.left)}` + ) + + const { teamCollectionMoved } = result.right + const { id, parent, title, data } = teamCollectionMoved + + const parentID = parent?.id ?? null + + this.moveCollection(id, parentID, title, data) + }) + + const [teamRequestOrderUpdated$, teamRequestOrderUpdated] = + runGQLSubscription({ + query: TeamRequestOrderUpdatedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRequestOrderUpdatedSub = teamRequestOrderUpdated + this.teamRequestOrderUpdated$ = teamRequestOrderUpdated$.subscribe( + (result) => { + if (E.isLeft(result)) + throw new Error( + `Team Request Order Update Error ${JSON.stringify(result.left)}` + ) + + const { requestOrderUpdated } = result.right + const { request } = requestOrderUpdated + const { nextRequest } = requestOrderUpdated + + this.updateRequestOrder( + request.id, + nextRequest ? nextRequest.id : null, + nextRequest ? nextRequest.collectionID : request.collectionID + ) + } + ) + + const [teamCollectionOrderUpdated$, teamCollectionOrderUpdated] = + runGQLSubscription({ + query: TeamCollectionOrderUpdatedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamCollectionOrderUpdatedSub = teamCollectionOrderUpdated + this.teamCollectionOrderUpdated$ = teamCollectionOrderUpdated$.subscribe( + (result) => { + if (E.isLeft(result)) + throw new Error( + `Team Collection Order Update Error ${JSON.stringify(result.left)}` + ) + + const { collectionOrderUpdated } = result.right + const { collection } = collectionOrderUpdated + const { nextCollection } = collectionOrderUpdated + + this.updateCollectionOrder( + collection.id, + nextCollection ? nextCollection.id : null + ) + } + ) + } + + private async getCollectionChildren( + collection: TeamCollection + ): Promise { + const collections: TeamCollection[] = [] + + while (true) { + const data = await runGQLQuery({ + query: GetCollectionChildrenDocument, + variables: { + collectionID: collection.id, + cursor: + collections.length > 0 + ? collections[collections.length - 1].id + : undefined, + }, + }) + + if (E.isLeft(data)) { + throw new Error( + `Child Collection Fetch Error for ${collection.id}: ${data.left}` + ) + } + + collections.push( + ...data.right.collection!.children.map( + (el) => + { + id: el.id, + title: el.title, + data: el.data, + children: null, + requests: null, + } + ) + ) + + if (data.right.collection!.children.length !== TEAMS_BACKEND_PAGE_SIZE) + break + } + + return collections + } + + private async getCollectionRequests( + collection: TeamCollection + ): Promise { + const requests: TeamRequest[] = [] + + while (true) { + const data = await runGQLQuery({ + query: GetCollectionRequestsDocument, + variables: { + collectionID: collection.id, + cursor: + requests.length > 0 ? requests[requests.length - 1].id : undefined, + }, + }) + + if (E.isLeft(data)) { + throw new Error(`Child Request Fetch Error for ${data}: ${data.left}`) + } + + requests.push( + ...data.right.requestsInCollection.map((el) => { + return { + id: el.id, + collectionID: collection.id, + title: el.title, + request: translateToNewRequest(JSON.parse(el.request)), + } + }) + ) + + if (data.right.requestsInCollection.length !== TEAMS_BACKEND_PAGE_SIZE) + break + } + + return requests + } + + /** + * Expands a collection on the tree + * + * When a collection is loaded initially in the adapter, children and requests are not loaded (they will be set to null) + * Upon expansion those two fields will be populated + * + * @param {string} collectionID - The ID of the collection to expand + */ + async expandCollection(collectionID: string): Promise { + // TODO: While expanding one collection, block (or queue) the expansion of the other, to avoid race conditions + const tree = this.collections$.value + + const collection = findCollInTree(tree, collectionID) + + if (!collection) return + + if (collection.children !== null) return + + this.loadingCollections$.next([ + ...this.loadingCollections$.getValue(), + collectionID, + ]) + + try { + const [collections, requests] = await Promise.all([ + this.getCollectionChildren(collection), + this.getCollectionRequests(collection), + ]) + + collection.children = collections + collection.requests = requests + + // Add to the entity ids set + collections.forEach((coll) => this.entityIDs.add(`collection-${coll.id}`)) + requests.forEach((req) => this.entityIDs.add(`request-${req.id}`)) + + this.collections$.next(tree) + } finally { + this.loadingCollections$.next( + this.loadingCollections$.getValue().filter((x) => x !== collectionID) + ) + } + } + + private getCurrentValue = ( + env: HoppCollectionVariable, + varIndex: number, + collectionID: string + ) => { + if (env && env.secret) { + return this.secretEnvironmentService.getSecretEnvironmentVariable( + collectionID, + varIndex + )?.value + } + return this.currentEnvironmentValueService.getEnvironmentVariable( + collectionID, + varIndex + )?.currentValue + } + + /** + * This function populates the values of the variables with the current values or secrets. + * @param variables Variables to populate + * @returns Populated variables with current values or secrets + */ + private populateValues( + variables: HoppCollectionVariable[], + parentID: string + ) { + return variables.map((v, index) => ({ + ...v, + currentValue: this.getCurrentValue(v, index, parentID) ?? v.currentValue, + })) + } + + /** + * Used to obtain the inherited auth and headers for a given folder path, used for both REST and GraphQL team collections + * @param folderPath the path of the folder to cascade the auth from + * @returns the inherited auth and headers for the given folder path + */ + public cascadeParentCollectionForProperties(folderPath: string) { + let auth: HoppInheritedProperty["auth"] = { + parentID: folderPath ?? "", + parentName: "", + inheritedAuth: { + authType: "none", + authActive: true, + }, + } + const headers: HoppInheritedProperty["headers"] = [] + + const variables: HoppInheritedProperty["variables"] = [] + + const scripts: HoppInheritedProperty["scripts"] = [] + + if (!folderPath) return { auth, headers, variables, scripts } + + const path = folderPath.split("/") + + // Check if the path is empty or invalid + if (!path || path.length === 0) { + console.error("Invalid path:", folderPath) + return { auth, headers, variables, scripts } + } + + // Loop through the path and get the last parent folder with authType other than 'inherit' + for (let i = 0; i < path.length; i++) { + const parentFolder = findCollInTree(this.collections$.value, path[i]) + + // Check if parentFolder is undefined or null + if (!parentFolder) { + console.error("Parent folder not found for path:", path) + return { auth, headers, variables, scripts } + } + + const data: Partial = parentFolder.data + ? JSON.parse(parentFolder.data) + : {} + + if (!data.auth) { + data.auth = { + authType: "inherit", + authActive: true, + } + auth.parentID = path.slice(0, i + 1).join("/") + auth.parentName = parentFolder.title + } + + if (!data.headers) data.headers = [] + + if (!data.variables) data.variables = [] + + const parentFolderAuth = data.auth + const parentFolderHeaders = data.headers + const parentFolderVariables = data.variables + const parentFolderPreRequestScript = data.preRequestScript ?? "" + const parentFolderTestScript = data.testScript ?? "" + + if ( + parentFolderAuth?.authType === "inherit" && + path.slice(0, i + 1).length === 1 + ) { + auth = { + parentID: path.slice(0, i + 1).join("/"), + parentName: parentFolder.title, + inheritedAuth: auth.inheritedAuth, + } + } + + if (parentFolderAuth?.authType !== "inherit") { + auth = { + parentID: path.slice(0, i + 1).join("/"), + parentName: parentFolder.title, + inheritedAuth: parentFolderAuth, + } + } + + // Update headers, overwriting duplicates by key + if (parentFolderHeaders) { + const activeHeaders = parentFolderHeaders.filter((h) => h.active) + activeHeaders.forEach((header) => { + const index = headers.findIndex( + (h) => h.inheritedHeader?.key === header.key + ) + const currentPath = path.slice(0, i + 1).join("/") + if (index !== -1) { + // Replace the existing header with the same key + headers[index] = { + parentID: currentPath, + parentName: parentFolder.title, + inheritedHeader: header, + } + } else { + headers.push({ + parentID: currentPath, + parentName: parentFolder.title, + inheritedHeader: header, + }) + } + }) + } + + // Update variables, overwriting duplicates by key + if (parentFolderVariables) { + const currentPath = [...path.slice(0, i + 1)].join("/") + + variables.push({ + parentPath: path.slice(0, i + 1).join("/"), + parentID: parentFolder.id ?? currentPath, + parentName: parentFolder.title, + inheritedVariables: this.populateValues( + parentFolderVariables, + parentFolder.id ?? currentPath + ), + }) + } + + // Update scripts + if ( + hasActualScript(parentFolderPreRequestScript) || + hasActualScript(parentFolderTestScript) + ) { + const currentPath = [...path.slice(0, i + 1)].join("/") + + scripts.push({ + parentID: parentFolder.id ?? currentPath, + parentName: parentFolder.title, + preRequestScript: parentFolderPreRequestScript, + testScript: parentFolderTestScript, + }) + } + } + + return { auth, headers, variables, scripts } + } +} diff --git a/packages/hoppscotch-common/src/helpers/teams/TeamEnvironment.ts b/packages/hoppscotch-common/src/helpers/teams/TeamEnvironment.ts new file mode 100644 index 0000000..b48880f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/teams/TeamEnvironment.ts @@ -0,0 +1,10 @@ +import { Environment } from "@hoppscotch/data" + +/** + * Defines how a Team Environment is represented in the TeamEnvironmentAdapter + */ +export interface TeamEnvironment { + id: string + teamID: string + environment: Environment +} diff --git a/packages/hoppscotch-common/src/helpers/teams/TeamEnvironmentAdapter.ts b/packages/hoppscotch-common/src/helpers/teams/TeamEnvironmentAdapter.ts new file mode 100644 index 0000000..e240fae --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/teams/TeamEnvironmentAdapter.ts @@ -0,0 +1,282 @@ +import * as E from "fp-ts/Either" +import { BehaviorSubject, Subscription } from "rxjs" +import { Subscription as WSubscription } from "wonka" +import { pipe } from "fp-ts/function" +import { GQLError, runGQLQuery, runGQLSubscription } from "../backend/GQLClient" +import { + GetTeamEnvironmentsDocument, + TeamEnvironmentCreatedDocument, + TeamEnvironmentDeletedDocument, + TeamEnvironmentUpdatedDocument, +} from "../backend/graphql" +import { TeamEnvironment } from "./TeamEnvironment" +import { + Environment, + EnvironmentSchemaVersion, + translateToNewEnvironmentVariables, +} from "@hoppscotch/data" + +type EntityType = "environment" +type EntityID = `${EntityType}-${string}` +export default class TeamEnvironmentAdapter { + error$: BehaviorSubject | null> + loading$: BehaviorSubject + teamEnvironmentList$: BehaviorSubject + + /** + * Stores the entity (environments) ids of all the loaded entities. + * Used for preventing duplication of data which definitely is not possible (duplication due to network problems etc.) + */ + private entityIDs: Set + + private isDispose: boolean + + private teamEnvironmentCreated$: Subscription | null + private teamEnvironmentUpdated$: Subscription | null + private teamEnvironmentDeleted$: Subscription | null + + private teamEnvironmentCreatedSub: WSubscription | null + private teamEnvironmentUpdatedSub: WSubscription | null + private teamEnvironmentDeletedSub: WSubscription | null + + constructor(private teamID: string | undefined) { + this.error$ = new BehaviorSubject | null>(null) + this.loading$ = new BehaviorSubject(false) + this.teamEnvironmentList$ = new BehaviorSubject([]) + this.isDispose = true + + this.entityIDs = new Set() + + this.teamEnvironmentCreated$ = null + this.teamEnvironmentDeleted$ = null + this.teamEnvironmentUpdated$ = null + + this.teamEnvironmentCreatedSub = null + this.teamEnvironmentDeletedSub = null + this.teamEnvironmentUpdatedSub = null + + if (teamID) this.initialize() + } + + unsubscribeSubscriptions() { + this.teamEnvironmentCreated$?.unsubscribe() + this.teamEnvironmentDeleted$?.unsubscribe() + this.teamEnvironmentUpdated$?.unsubscribe() + + this.teamEnvironmentCreatedSub?.unsubscribe() + this.teamEnvironmentDeletedSub?.unsubscribe() + this.teamEnvironmentUpdatedSub?.unsubscribe() + } + + changeTeamID(newTeamID: string | undefined) { + this.teamID = newTeamID + this.teamEnvironmentList$.next([]) + this.loading$.next(false) + + this.entityIDs.clear() + + this.unsubscribeSubscriptions() + + if (this.teamID) this.initialize() + } + + async initialize() { + if (!this.isDispose) throw new Error(`Adapter is already initialized`) + + await this.fetchList() + this.registerSubscriptions() + } + + public dispose() { + if (this.isDispose) throw new Error(`Adapter has been disposed`) + + this.isDispose = true + this.unsubscribeSubscriptions() + } + + async fetchList() { + if (this.teamID === undefined) throw new Error("Team ID is null") + + this.loading$.next(true) + + const results: TeamEnvironment[] = [] + + const result = await runGQLQuery({ + query: GetTeamEnvironmentsDocument, + variables: { + teamID: this.teamID, + }, + }) + + if (E.isLeft(result)) { + this.error$.next(result.left) + this.loading$.next(false) + console.error(result.left) + throw new Error(`Failed fetching team environments: ${result.left}`) + } + + if (result.right.team) { + results.push( + ...result.right.team.teamEnvironments.map((x) => { + // Keep the environment structure consistent with the new schema + const environment = { + v: EnvironmentSchemaVersion, + id: x.id, + name: x.name, + variables: JSON.parse(x.variables).map( + (variable: Environment["variables"][number]) => + translateToNewEnvironmentVariables(variable) + ), + } + + const parsedEnvironment = Environment.safeParse(environment) + + return { + id: x.id, + teamID: x.teamID, + environment: + parsedEnvironment.type === "ok" + ? parsedEnvironment.value + : environment, + } + }) + ) + } + + // Add all the environments to the entity ids list + results.forEach((env) => this.entityIDs.add(`environment-${env.id}`)) + + this.teamEnvironmentList$.next(results) + + this.loading$.next(false) + } + + private createNewTeamEnvironment(newEnvironment: TeamEnvironment) { + // Check if we have it already in the entity tree, if so, we don't need it again + if (this.entityIDs.has(`environment-${newEnvironment.id}`)) return + + const teamEnvironments = this.teamEnvironmentList$.value + + teamEnvironments.push(newEnvironment) + + // Add to entity ids set + this.entityIDs.add(`environment-${newEnvironment.id}`) + + this.teamEnvironmentList$.next(teamEnvironments) + } + + private deleteTeamEnvironment(envId: string) { + const teamEnvironments = this.teamEnvironmentList$.value.filter( + ({ id }) => id !== envId + ) + this.entityIDs.delete(`environment-${envId}`) + this.teamEnvironmentList$.next(teamEnvironments) + } + + private updateTeamEnvironment(updatedEnvironment: TeamEnvironment) { + const teamEnvironments = this.teamEnvironmentList$.value + + const environmentFound = teamEnvironments.find( + ({ id }) => id === updatedEnvironment.id + ) + + if (!environmentFound) return + + Object.assign(environmentFound, updatedEnvironment) + + this.teamEnvironmentList$.next(teamEnvironments) + } + + private registerSubscriptions() { + if (this.teamID === undefined) return + const [teamEnvironmentCreated$, teamEnvironmentCreatedSub] = + runGQLSubscription({ + query: TeamEnvironmentCreatedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamEnvironmentCreatedSub = teamEnvironmentCreatedSub + + this.teamEnvironmentCreated$ = teamEnvironmentCreated$.subscribe( + (result) => { + if (E.isLeft(result)) { + console.error(result.left) + throw new Error(`Team Environment Create Error ${result.left}`) + } + this.createNewTeamEnvironment( + pipe( + result.right.teamEnvironmentCreated, + (x) => + { + id: x.id, + teamID: x.teamID, + environment: { + v: 2, + id: x.id, + name: x.name, + variables: JSON.parse(x.variables), + }, + } + ) + ) + } + ) + + const [teamEnvironmentDeleted$, teamEnvironmentDeletedSub] = + runGQLSubscription({ + query: TeamEnvironmentDeletedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamEnvironmentDeletedSub = teamEnvironmentDeletedSub + + this.teamEnvironmentDeleted$ = teamEnvironmentDeleted$.subscribe( + (result) => { + if (E.isLeft(result)) { + console.error(result.left) + throw new Error(`Team Environment Delete Error ${result.left}`) + } + this.deleteTeamEnvironment(result.right.teamEnvironmentDeleted.id) + } + ) + + const [teamEnvironmentUpdated$, teamEnvironmentUpdatedSub] = + runGQLSubscription({ + query: TeamEnvironmentUpdatedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamEnvironmentUpdatedSub = teamEnvironmentUpdatedSub + + this.teamEnvironmentUpdated$ = teamEnvironmentUpdated$.subscribe( + (result) => { + if (E.isLeft(result)) { + console.error(result.left) + throw new Error(`Team Environment Update Error ${result.left}`) + } + this.updateTeamEnvironment( + pipe( + result.right.teamEnvironmentUpdated, + (x) => + { + id: x.id, + teamID: x.teamID, + environment: { + v: 2, + id: x.id, + name: x.name, + variables: JSON.parse(x.variables), + }, + } + ) + ) + } + ) + } +} diff --git a/packages/hoppscotch-common/src/helpers/teams/TeamListAdapter.ts b/packages/hoppscotch-common/src/helpers/teams/TeamListAdapter.ts new file mode 100644 index 0000000..e39cc71 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/teams/TeamListAdapter.ts @@ -0,0 +1,99 @@ +import * as E from "fp-ts/Either" +import { BehaviorSubject } from "rxjs" +import { platform } from "~/platform" +import { GQLError } from "../backend/GQLClient" +import { GetMyTeamsQuery } from "../backend/graphql" + +const BACKEND_PAGE_SIZE = 10 +const POLL_DURATION = 10000 + +export default class TeamListAdapter { + error$: BehaviorSubject | null> + loading$: BehaviorSubject + teamList$: BehaviorSubject + + private timeoutHandle: ReturnType | null + private isDispose: boolean + + public isInitialized: boolean + + constructor( + deferInit = false, + private doPolling = true + ) { + this.error$ = new BehaviorSubject | null>(null) + this.loading$ = new BehaviorSubject(false) + this.teamList$ = new BehaviorSubject([]) + this.timeoutHandle = null + this.isDispose = false + + this.isInitialized = false + + if (!deferInit) this.initialize() + } + + initialize() { + if (this.timeoutHandle) throw new Error(`Adapter already initialized`) + if (this.isDispose) throw new Error(`Adapter has been disposed`) + + this.isInitialized = true + + const func = async () => { + await this.fetchList() + + if (!this.isDispose && this.doPolling) { + this.timeoutHandle = setTimeout(() => func(), POLL_DURATION) + } + } + + func() + } + + public dispose() { + this.teamList$.next([]) + this.isDispose = true + clearTimeout(this.timeoutHandle as any) + this.timeoutHandle = null + this.isInitialized = false + } + + async fetchList() { + const currentUser = platform.auth.getCurrentUser() + + // if the authIdToken is not present, don't fetch the teams list, as it will fail anyway + if (!currentUser) return + + this.loading$.next(true) + + const probableUser = platform.auth.getProbableUser() + + if (probableUser !== null) { + await platform.auth.waitProbableLoginToConfirm() + } + + const results: GetMyTeamsQuery["myTeams"] = [] + + while (true) { + const cursor = + results.length > 0 ? results[results.length - 1].id : undefined + + const result = await platform.backend.getUserTeams(cursor) + + if (E.isLeft(result)) { + this.error$.next(result.left) + throw new Error( + `Failed fetching teams list: ${JSON.stringify(result.left)}` + ) + } + + results.push(...result.right.myTeams) + + // If we don't have full elements in the list, then the list is done usually, so lets stop + if (result.right.myTeams.length !== BACKEND_PAGE_SIZE) break + } + + this.teamList$.next(results) + + this.loading$.next(false) + } +} diff --git a/packages/hoppscotch-common/src/helpers/teams/TeamRequest.ts b/packages/hoppscotch-common/src/helpers/teams/TeamRequest.ts new file mode 100644 index 0000000..5fcdbe2 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/teams/TeamRequest.ts @@ -0,0 +1,41 @@ +import { HoppRESTRequest } from "@hoppscotch/data" +import { runGQLQuery } from "../backend/GQLClient" +import { + GetCollectionChildrenDocument, + GetCollectionRequestsDocument, + GetSingleRequestDocument, +} from "../backend/graphql" + +/** + * Defines how a Teams request is represented in TeamCollectionAdapter + */ +export interface TeamRequest { + id: string + collectionID: string + title: string + request: HoppRESTRequest +} + +export const getCollectionChildRequests = (collectionID: string) => + runGQLQuery({ + query: GetCollectionRequestsDocument, + variables: { + collectionID, + }, + }) + +export const getSingleRequest = (requestID: string) => + runGQLQuery({ + query: GetSingleRequestDocument, + variables: { + requestID, + }, + }) + +export const getCollectionChildCollections = (collectionID: string) => + runGQLQuery({ + query: GetCollectionChildrenDocument, + variables: { + collectionID, + }, + }) diff --git a/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts b/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts new file mode 100644 index 0000000..9a95ffc --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts @@ -0,0 +1,705 @@ +import { + HoppRESTAuth, + HoppRESTRequest, + getDefaultRESTRequest, +} from "@hoppscotch/data" +import axios from "axios" +import { Service } from "dioc" +import * as E from "fp-ts/Either" +import { Ref, ref } from "vue" +import { getSingleCollection, TeamCollection } from "./TeamCollection" +import { hasActualScript } from "@hoppscotch/js-sandbox/scripting" + +import { platform } from "~/platform" +import { HoppInheritedProperty } from "../types/HoppInheritedProperties" +import { + getSingleRequest, + getCollectionChildRequests, + TeamRequest, + getCollectionChildCollections, +} from "./TeamRequest" +import { CollectionDataProps } from "../backend/helpers" + +/** + * Parses collection data that may be double-encoded JSON + * Handles both single and double JSON stringification + */ +const parseCollectionData = (data: string): CollectionDataProps | null => { + try { + let parsed = JSON.parse(data) + // Handle double-encoded JSON (string containing JSON string) + if (typeof parsed === "string") { + parsed = JSON.parse(parsed) + } + return parsed as CollectionDataProps + } catch { + return null + } +} + +type CollectionSearchMeta = { + isSearchResult?: boolean + insertedWhileExpanding?: boolean +} + +type CollectionSearchNode = + | { + type: "request" + title: string + method: string + id: string + // parent collections + path: CollectionSearchNode[] + } + | { + type: "collection" + title: string + id: string + // parent collections + path: CollectionSearchNode[] + } + +type _SearchCollection = TeamCollection & { + parentID: string | null + meta?: CollectionSearchMeta +} + +type _SearchRequest = { + id: string + collectionID: string + title: string + request: { + name: string + method: string + } + meta?: CollectionSearchMeta +} + +function convertToTeamCollection( + node: CollectionSearchNode & { + meta?: CollectionSearchMeta + }, + existingCollections: Record, + existingRequests: Record +) { + if (node.type === "request") { + existingRequests[node.id] = { + id: node.id, + collectionID: node.path[0].id, + title: node.title, + request: { + name: node.title, + method: node.method, + }, + meta: { + isSearchResult: node.meta?.isSearchResult || false, + }, + } + + if (node.path[0]) { + // add parent collections to the collections array recursively + convertToTeamCollection( + node.path[0], + existingCollections, + existingRequests + ) + } + } else { + existingCollections[node.id] = { + id: node.id, + title: node.title, + children: [], + requests: [], + data: null, + parentID: node.path[0]?.id, + meta: { + isSearchResult: node.meta?.isSearchResult || false, + }, + } + + if (node.path[0]) { + // add parent collections to the collections array recursively + convertToTeamCollection( + node.path[0], + existingCollections, + existingRequests + ) + } + } + + return { + existingCollections, + existingRequests, + } +} + +function convertToTeamTree( + collections: (TeamCollection & { parentID: string | null })[], + requests: TeamRequest[] +) { + const collectionTree: TeamCollection[] = [] + + collections.forEach((collection) => { + const parentCollection = collection.parentID + ? collections.find((c) => c.id === collection.parentID) + : null + + const isAlreadyInserted = parentCollection?.children?.find( + (c) => c.id === collection.id + ) + + if (isAlreadyInserted) return + + if (parentCollection) { + parentCollection.children = parentCollection.children || [] + parentCollection.children.push(collection) + } else { + collectionTree.push(collection) + } + }) + + requests.forEach((request) => { + const parentCollection = collections.find( + (c) => c.id === request.collectionID + ) + + const isAlreadyInserted = parentCollection?.requests?.find( + (r) => r.id === request.id + ) + + if (isAlreadyInserted) return + + if (parentCollection) { + const requestSchemaParsedResult = HoppRESTRequest.safeParse( + request.request + ) + + const effectiveRequest = + requestSchemaParsedResult.type === "ok" + ? requestSchemaParsedResult.value + : getDefaultRESTRequest() + + parentCollection.requests = parentCollection.requests || [] + parentCollection.requests.push({ + id: request.id, + collectionID: request.collectionID, + title: request.title, + request: effectiveRequest, + }) + } + }) + + return collectionTree +} + +export class TeamSearchService extends Service { + public static readonly ID = "TeamSearchService" + + public endpoint = import.meta.env.VITE_BACKEND_API_URL + + public teamsSearchResultsLoading = ref(false) + public teamsSearchResults = ref([]) + public teamsSearchResultsFormattedForSpotlight = ref< + { + collectionTitles: string[] + request: { + id: string + name: string + method: string + } + }[] + >([]) + + searchResultsCollections: Record = {} + searchResultsRequests: Record = {} + + expandingCollections: Ref = ref([]) + expandedCollections: Ref = ref([]) + + // TODO: ideally this should return the search results / formatted results instead of directly manipulating the result set + // eg: do the spotlight formatting in the spotlight searcher and not here + searchTeams = async (query: string, teamID: string) => { + if (!query.length) { + return + } + + this.teamsSearchResultsLoading.value = true + + this.searchResultsCollections = {} + this.searchResultsRequests = {} + this.expandedCollections.value = [] + + const getAxiosPlatformConfig = async () => { + await platform.auth.waitProbableLoginToConfirm() + return platform.auth.axiosPlatformConfig?.() ?? {} + } + + const axiosPlatformConfig = await getAxiosPlatformConfig() + + try { + const searchResponse = await axios.get( + `${ + this.endpoint + }/team-collection/search/${teamID}?searchQuery=${encodeURIComponent( + query + )}`, + axiosPlatformConfig + ) + + if (searchResponse.status !== 200) { + return + } + + const searchResults = searchResponse.data.data as CollectionSearchNode[] + + searchResults + .map((node) => { + const { existingCollections, existingRequests } = + convertToTeamCollection( + { + ...node, + meta: { + isSearchResult: true, + }, + }, + {}, + {} + ) + + return { + collections: existingCollections, + requests: existingRequests, + } + }) + .forEach(({ collections, requests }) => { + this.searchResultsCollections = { + ...this.searchResultsCollections, + ...collections, + } + this.searchResultsRequests = { + ...this.searchResultsRequests, + ...requests, + } + }) + + const collectionFetchingPromises = Object.values( + this.searchResultsCollections + ).map((col) => { + return getSingleCollection(col.id) + }) + + const requestFetchingPromises = Object.values( + this.searchResultsRequests + ).map((req) => { + return getSingleRequest(req.id) + }) + + const collectionResponses = await Promise.all(collectionFetchingPromises) + const requestResponses = await Promise.all(requestFetchingPromises) + + requestResponses.map((res) => { + if (E.isLeft(res)) { + return + } + + const request = res.right.request + + if (!request) return + + this.searchResultsRequests[request.id] = { + id: request.id, + title: request.title, + request: JSON.parse(request.request) as TeamRequest["request"], + collectionID: request.collectionID, + } + }) + + collectionResponses.map((res) => { + if (E.isLeft(res)) { + return + } + + const collection = res.right.collection + + if (!collection) return + + this.searchResultsCollections[collection.id].data = + collection.data ?? null + }) + + const collectionTree = convertToTeamTree( + Object.values(this.searchResultsCollections), + // asserting because we've already added the missing properties after fetching the full details + Object.values(this.searchResultsRequests) as TeamRequest[] + ) + + this.teamsSearchResults.value = collectionTree + + this.teamsSearchResultsFormattedForSpotlight.value = Object.values( + this.searchResultsRequests + ).map((request) => { + return formatTeamsSearchResultsForSpotlight( + { + collectionID: request.collectionID, + name: request.title, + method: request.request.method, + id: request.id, + }, + Object.values(this.searchResultsCollections) + ) + }) + } catch (error) { + console.error(error) + } + + this.teamsSearchResultsLoading.value = false + } + + cascadeParentCollectionForPropertiesForSearchResults = ( + collectionID: string + ): HoppInheritedProperty => { + const defaultInheritedAuth: HoppInheritedProperty["auth"] = { + parentID: "", + parentName: "", + inheritedAuth: { + authType: "none", + authActive: true, + }, + } + + const defaultInheritedHeaders: HoppInheritedProperty["headers"] = [] + + const defaultInheritedVariables: HoppInheritedProperty["variables"] = [] + + const defaultInheritedScripts: HoppInheritedProperty["scripts"] = [] + + const collection = Object.values(this.searchResultsCollections).find( + (col) => col.id === collectionID + ) + + if (!collection) + return { + auth: defaultInheritedAuth, + headers: defaultInheritedHeaders, + variables: defaultInheritedVariables, + scripts: defaultInheritedScripts, + } + + const inheritedAuthData = this.findInheritableParentAuth(collectionID) + const inheritedHeadersData = this.findInheritableParentHeaders(collectionID) + const inheritedVariablesData = + this.findInheritableParentVariables(collectionID) + const inheritedScriptsData = this.findInheritableParentScripts(collectionID) + + return { + auth: E.isRight(inheritedAuthData) + ? inheritedAuthData.right + : defaultInheritedAuth, + headers: E.isRight(inheritedHeadersData) + ? Object.values(inheritedHeadersData.right) + : defaultInheritedHeaders, + variables: E.isRight(inheritedVariablesData) + ? Object.values(inheritedVariablesData.right) + : defaultInheritedVariables, + scripts: E.isRight(inheritedScriptsData) + ? Object.values(inheritedScriptsData.right) + : defaultInheritedScripts, + } + } + + findInheritableParentAuth = ( + collectionID: string + ): E.Either< + string, + { + parentID: string + parentName: string + inheritedAuth: HoppRESTAuth + } + > => { + const collection = Object.values(this.searchResultsCollections).find( + (col) => col.id === collectionID + ) + + if (!collection) { + return E.left("PARENT_NOT_FOUND" as const) + } + + // has inherited data + if (collection.data) { + const parentInheritedData = parseCollectionData(collection.data) + + const inheritedAuth = parentInheritedData?.auth + + if (inheritedAuth && inheritedAuth.authType !== "inherit") { + return E.right({ + parentID: collectionID, + parentName: collection.title, + inheritedAuth: inheritedAuth, + }) + } + } + + if (!collection.parentID) { + return E.left("PARENT_INHERITED_DATA_NOT_FOUND") + } + + return this.findInheritableParentAuth(collection.parentID) + } + + findInheritableParentHeaders = ( + collectionID: string, + existingHeaders: Record< + string, + HoppInheritedProperty["headers"][number] + > = {} + ): E.Either< + string, + Record + > => { + const collection = Object.values(this.searchResultsCollections).find( + (col) => col.id === collectionID + ) + + if (!collection) { + return E.left("PARENT_NOT_FOUND" as const) + } + + // see if it has headers to inherit, if yes, add it to the existing headers + if (collection.data) { + const parentInheritedData = parseCollectionData(collection.data) + + const inheritedHeaders = parentInheritedData?.headers + + if (inheritedHeaders) { + inheritedHeaders.forEach((header) => { + if (!existingHeaders[header.key]) { + existingHeaders[header.key] = { + parentID: collection.id, + parentName: collection.title, + inheritedHeader: header, + } + } + }) + } + } + + if (collection.parentID) { + return this.findInheritableParentHeaders( + collection.parentID, + existingHeaders + ) + } + + return E.right(existingHeaders) + } + + findInheritableParentVariables = ( + collectionID: string, + existingVariables: HoppInheritedProperty["variables"] = [] + ): E.Either => { + const collection = Object.values(this.searchResultsCollections).find( + (col) => col.id === collectionID + ) + + const vars = [...Object.values(existingVariables)] + + if (!collection) { + return E.left("PARENT_NOT_FOUND" as const) + } + + if (collection.data) { + const parentData = parseCollectionData(collection.data) + + const variables = parentData?.variables + + if (variables) { + vars.push({ + parentID: collection.id, + parentName: collection.title, + inheritedVariables: variables, + }) + } + } + + if (collection.parentID) { + return this.findInheritableParentVariables(collection.parentID, vars) + } + + return E.right(vars) + } + + findInheritableParentScripts = ( + collectionID: string, + existingScripts: HoppInheritedProperty["scripts"] = [] + ): E.Either => { + const collection = Object.values(this.searchResultsCollections).find( + (col) => col.id === collectionID + ) + + if (!collection) { + return E.left("PARENT_NOT_FOUND" as const) + } + + // Recurse to parent first to build root→parent→child order + let scripts = [...existingScripts] + if (collection.parentID) { + const parentResult = this.findInheritableParentScripts( + collection.parentID, + scripts + ) + if (E.isLeft(parentResult)) { + return parentResult + } + scripts = parentResult.right + } + + // Then add current collection's scripts + if (collection.data) { + const parentData = parseCollectionData(collection.data) + + const preRequestScript = parentData?.preRequestScript ?? "" + const testScript = parentData?.testScript ?? "" + + if (hasActualScript(preRequestScript) || hasActualScript(testScript)) { + scripts.push({ + parentID: collection.id, + parentName: collection.title, + preRequestScript, + testScript, + }) + } + } + + return E.right(scripts) + } + + expandCollection = async (collectionID: string) => { + if (this.expandingCollections.value.includes(collectionID)) return + + const collectionToExpand = Object.values( + this.searchResultsCollections + ).find((col) => col.id === collectionID) + + const isAlreadyExpanded = + this.expandedCollections.value.includes(collectionID) + + // only allow search result collections to be expanded + if ( + isAlreadyExpanded || + !collectionToExpand || + !( + collectionToExpand.meta?.isSearchResult || + collectionToExpand.meta?.insertedWhileExpanding + ) + ) + return + + this.expandingCollections.value.push(collectionID) + + const childCollectionsPromise = getCollectionChildCollections(collectionID) + const childRequestsPromise = getCollectionChildRequests(collectionID) + + const [childCollections, childRequests] = await Promise.all([ + childCollectionsPromise, + childRequestsPromise, + ]) + + if (E.isLeft(childCollections)) { + return + } + + if (E.isLeft(childRequests)) { + return + } + + childCollections.right.collection?.children + .map((child) => ({ + id: child.id, + title: child.title, + data: child.data ?? null, + children: [], + requests: [], + })) + .forEach((child) => { + this.searchResultsCollections[child.id] = { + ...child, + parentID: collectionID, + meta: { + isSearchResult: false, + insertedWhileExpanding: true, + }, + } + }) + + childRequests.right.requestsInCollection + .map((request) => ({ + id: request.id, + collectionID: collectionID, + title: request.title, + request: JSON.parse(request.request) as TeamRequest["request"], + })) + .forEach((request) => { + this.searchResultsRequests[request.id] = { + ...request, + meta: { + isSearchResult: false, + insertedWhileExpanding: true, + }, + } + }) + + this.teamsSearchResults.value = convertToTeamTree( + Object.values(this.searchResultsCollections), + // asserting because we've already added the missing properties after fetching the full details + Object.values(this.searchResultsRequests) as TeamRequest[] + ) + + // remove the collection after expanding + this.expandingCollections.value = this.expandingCollections.value.filter( + (colID) => colID !== collectionID + ) + + this.expandedCollections.value.push(collectionID) + } +} + +const formatTeamsSearchResultsForSpotlight = ( + request: { + collectionID: string + name: string + method: string + id: string + }, + parentCollections: (TeamCollection & { parentID: string | null })[] +) => { + let collectionTitles: string[] = [] + + let parentCollectionID: string | null = request.collectionID + + while (true) { + if (!parentCollectionID) { + break + } + + const parentCollection = parentCollections.find( + (col) => col.id === parentCollectionID + ) + + if (!parentCollection) { + break + } + + collectionTitles = [parentCollection.title, ...collectionTitles] + parentCollectionID = parentCollection.parentID + } + + return { + collectionTitles, + request: { + name: request.name, + method: request.method, + id: request.id, + }, + } +} diff --git a/packages/hoppscotch-common/src/helpers/tern.js b/packages/hoppscotch-common/src/helpers/tern.js new file mode 100644 index 0000000..4ffc095 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/tern.js @@ -0,0 +1,117 @@ +import tern from "tern" +import { registerTernLinter } from "./ternlint" +import ECMA_DEF from "~/helpers/terndoc/ecma.json" +import PW_PRE_DEF from "~/helpers/terndoc/pw-pre.json" +import PW_TEST_DEF from "~/helpers/terndoc/pw-test.json" +import PW_EXTRAS_DEF from "~/helpers/terndoc/pw-extras.json" + +const server = new tern.Server({ + defs: [ECMA_DEF, PW_EXTRAS_DEF], + plugins: { + lint: { + rules: [], + }, + }, +}) + +registerTernLinter() + +function performLinting(code) { + return new Promise((resolve, reject) => { + server.request( + { + query: { + type: "lint", + file: "doc", + lineCharPositions: true, + }, + files: [ + { + type: "full", + name: "doc", + text: code, + }, + ], + }, + (err, res) => { + if (!err) resolve(res.messages) + else reject(err) + } + ) + }) +} + +export function performPreRequestLinting(code) { + server.deleteDefs("pw-test") + server.deleteDefs("pw-pre") + server.addDefs(PW_PRE_DEF) + return performLinting(code) +} + +export function performTestLinting(code) { + server.deleteDefs("pw-test") + server.deleteDefs("pw-pre") + server.addDefs(PW_TEST_DEF) + return performLinting(code) +} + +function postProcessCompletionResult(res) { + if (res.completions) { + const index = res.completions.findIndex((el) => el.name === "pw") + + if (index !== -1) { + const result = res.completions[index] + + res.completions.splice(index, 1) + res.completions.splice(0, 0, result) + } + } + + return res +} + +function performCompletion(code, row, col) { + return new Promise((resolve, reject) => { + server.request( + { + query: { + type: "completions", + file: "doc", + end: { + line: row, + ch: col, + }, + guess: false, + types: true, + includeKeywords: true, + inLiteral: false, + }, + files: [ + { + type: "full", + name: "doc", + text: code, + }, + ], + }, + (err, res) => { + if (err) reject(err) + else resolve(postProcessCompletionResult(res)) + } + ) + }) +} + +export function getPreRequestScriptCompletions(code, row, col) { + server.deleteDefs("pw-test") + server.deleteDefs("pw-pre") + server.addDefs(PW_PRE_DEF) + return performCompletion(code, row, col) +} + +export function getTestScriptCompletions(code, row, col) { + server.deleteDefs("pw-test") + server.deleteDefs("pw-pre") + server.addDefs(PW_TEST_DEF) + return performCompletion(code, row, col) +} diff --git a/packages/hoppscotch-common/src/helpers/terndoc/ecma.json b/packages/hoppscotch-common/src/helpers/terndoc/ecma.json new file mode 100644 index 0000000..a916755 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/terndoc/ecma.json @@ -0,0 +1,1266 @@ +{ + "!name": "ecmascript", + "!define": { + "Error.prototype": "Error.prototype", + "propertyDescriptor": { + "enumerable": "bool", + "configurable": "bool", + "value": "?", + "writable": "bool", + "get": "fn() -> ?", + "set": "fn(value: ?)", + "unset": "fn(value: ?)" + }, + "Promise.prototype": { + "catch": { + "!type": "fn(onRejected: fn(reason: ?)) -> !this" + }, + "then": { + "!type": "fn(onFulfilled: fn(value: ?), onRejected: fn(reason: ?)) -> !custom:Promise_then", + "!effects": ["call !0 !this.:t"] + }, + "finally": { + "!type": "fn(onFinally: fn()) -> !custom:Promise_then" + } + }, + "Promise_reject": { + "!type": "fn(reason: ?) -> !this" + }, + "iter_prototype": { + ":Symbol.iterator": "fn() -> !this" + }, + "iter": { + "!proto": "iter_prototype", + "next": { + "!type": "fn() -> +iter_result[value=!this.:t]" + } + }, + "iter_result": { + "done": "bool", + "value": "?" + }, + "generator_prototype": { + "!proto": "iter_prototype", + "next": "fn(value?: ?) -> iter_result", + "return": "fn(value?: ?) -> iter_result", + "throw": "fn(exception: +Error)" + }, + "async_iter_prototype": { + ":Symbol.asyncIterator": "fn() -> !this" + }, + "async_iter": { + "!proto": "async_iter_prototype", + "next": { + "!type": "fn() -> +Promise[:t=+iter_result[value=!this.:t]]" + } + }, + "async_generator_prototype": { + "!proto": "async_iter_prototype", + "next": "fn(value?: ?) -> +Promise[:t=iter_result]", + "return": "fn(value?: ?) -> +Promise[:t=iter_result]", + "throw": "fn(exception: +Error)" + }, + "Proxy_handler": { + "getPrototypeOf": "fn(target: ?)", + "setPrototypeOf": "fn(target: ?, prototype: ?)", + "isExtensible": "fn(target: ?)", + "preventExtensions": "fn(target: ?)", + "getOwnPropertyDescriptor": "fn(target: ?, property: string) -> propertyDescriptor", + "defineProperty": "fn(target: ?, property: string, descriptor: propertyDescriptor)", + "has": "fn(target: ?, property: string)", + "get": "fn(target: ?, property: string)", + "set": "fn(target: ?, property: string, value: ?)", + "deleteProperty": "fn(target: ?, property: string)", + "enumerate": "fn(target: ?)", + "ownKeys": "fn(target: ?)", + "apply": "fn(target: ?, self: ?, arguments: [?])", + "construct": "fn(target: ?, arguments: [?])" + }, + "Proxy_revocable": { + "proxy": "+Proxy", + "revoke": "fn()" + }, + "TypedArray": { + "!type": "fn(size: number)", + "from": { + "!type": "fn(arrayLike: ?, mapFn?: fn(elt: ?, i: number) -> number, thisArg?: ?) -> +TypedArray", + "!effects": ["call !1 this=!2 !0. number"] + }, + "of": { + "!type": "fn(elements: number) -> +TypedArray" + }, + "BYTES_PER_ELEMENT": { + "!type": "number" + }, + "name": { + "!type": "string" + }, + "prototype": { + "": "number", + "buffer": { + "!type": "+ArrayBuffer" + }, + "byteLength": { + "!type": "number" + }, + "byteOffset": { + "!type": "number" + }, + "copyWithin": { + "!type": "fn(target: number, start: number, end?: number) -> ?" + }, + "entries": { + "!type": "fn() -> +iter[:t=number]" + }, + "every": { + "!type": "fn(callback: fn(element: number, index: number, array: TypedArray) -> bool, thisArg?: ?) -> bool", + "!effects": ["call !0 this=!1 number number !this"] + }, + "fill": { + "!type": "fn(value: number, start?: number, end?: number)" + }, + "filter": { + "!type": "fn(test: fn(element: number, i: number) -> bool, context?: ?) -> !this", + "!effects": ["call !0 this=!1 number number"] + }, + "find": { + "!type": "fn(callback: fn(element: number, index: number, array: +TypedArray) -> bool, thisArg?: ?) -> number", + "!effects": ["call !0 this=!1 number number !this"] + }, + "findIndex": { + "!type": "fn(callback: fn(element: number, index: number, array: +TypedArray) -> bool, thisArg?: ?) -> number", + "!effects": ["call !0 this=!1 number number !this"] + }, + "forEach": { + "!type": "fn(callback: fn(value: number, key: number, array: +TypedArray), thisArg?: ?)", + "!effects": ["call !0 this=!1 number number !this"] + }, + "indexOf": { + "!type": "fn(searchElement: number, fromIndex?: number) -> number" + }, + "join": { + "!type": "fn(separator?: string) -> string" + }, + "keys": { + "!type": "fn() -> +iter[:t=number]" + }, + "lastIndexOf": { + "!type": "fn(searchElement: number, fromIndex?: number) -> number" + }, + "length": { + "!type": "number" + }, + "map": { + "!type": "fn(f: fn(element: number, i: number) -> number, context?: ?) -> +TypedArray", + "!effects": ["call !0 this=!1 number number"] + }, + "reduce": { + "!type": "fn(combine: fn(sum: ?, elt: number, i: number) -> ?, init?: ?) -> !0.!ret", + "!effects": ["call !0 !1 number number"] + }, + "reduceRight": { + "!type": "fn(combine: fn(sum: ?, elt: number, i: number) -> ?, init?: ?) -> !0.!ret", + "!effects": ["call !0 !1 number number"] + }, + "reverse": { + "!type": "fn()" + }, + "set": { + "!type": "fn(array: [number], offset?: number)" + }, + "slice": { + "!type": "fn(from: number, to?: number) -> +TypedArray" + }, + "some": { + "!type": "fn(test: fn(elt: number, i: number) -> bool, context?: ?) -> bool", + "!effects": ["call !0 this=!1 number number"] + }, + "sort": { + "!type": "fn(compare?: fn(a: number, b: number) -> number)", + "!effects": ["call !0 number number"] + }, + "subarray": { + "!type": "fn(begin?: number, end?: number) -> +TypedArray" + }, + "values": { + "!type": "fn() -> +iter[:t=number]" + }, + ":Symbol.iterator": { + "!type": "fn() -> +iter[:t=number]" + } + } + } + }, + "Infinity": { + "!type": "number" + }, + "undefined": { + "!type": "?" + }, + "NaN": { + "!type": "number" + }, + "Object": { + "!type": "fn()", + "getPrototypeOf": { + "!type": "fn(obj: ?) -> ?" + }, + "create": { + "!type": "fn(proto: ?) -> !custom:Object_create" + }, + "defineProperty": { + "!type": "fn(obj: ?, prop: string, desc: propertyDescriptor) -> !custom:Object_defineProperty" + }, + "defineProperties": { + "!type": "fn(obj: ?, props: ?) -> !custom:Object_defineProperties" + }, + "getOwnPropertyDescriptor": { + "!type": "fn(obj: ?, prop: string) -> propertyDescriptor" + }, + "keys": { + "!type": "fn(obj: ?) -> [string]" + }, + "getOwnPropertyNames": { + "!type": "fn(obj: ?) -> [string]" + }, + "seal": { + "!type": "fn(obj: ?)" + }, + "isSealed": { + "!type": "fn(obj: ?) -> bool" + }, + "freeze": { + "!type": "fn(obj: ?) -> !0" + }, + "isFrozen": { + "!type": "fn(obj: ?) -> bool" + }, + "preventExtensions": { + "!type": "fn(obj: ?)" + }, + "isExtensible": { + "!type": "fn(obj: ?) -> bool" + }, + "assign": { + "!type": "fn(target: ?, source: ?, source?: ?) -> !0", + "!effects": ["copy !1 !0", "copy !2 !0", "copy !3 !0"] + }, + "getOwnPropertySymbols": { + "!type": "fn(obj: ?) -> !custom:getOwnPropertySymbols" + }, + "is": { + "!type": "fn(value1: ?, value2: ?) -> bool" + }, + "setPrototypeOf": { + "!type": "fn(obj: ?, prototype: ?)" + }, + "prototype": { + "!stdProto": "Object", + "toString": { + "!type": "fn() -> string" + }, + "toLocaleString": { + "!type": "fn() -> string" + }, + "valueOf": { + "!type": "fn() -> number" + }, + "hasOwnProperty": { + "!type": "fn(prop: string) -> bool" + }, + "propertyIsEnumerable": { + "!type": "fn(prop: string) -> bool" + }, + "isPrototypeOf": { + "!type": "fn(obj: ?) -> bool" + } + } + }, + "Function": { + "!type": "fn(body: string) -> fn()", + "prototype": { + "!stdProto": "Function", + "apply": { + "!type": "fn(this: ?, args: [?])", + "!effects": ["call and return !this this=!0 !1. !1. !1."] + }, + "call": { + "!type": "fn(this: ?, args?: ?) -> !this.!ret", + "!effects": ["call and return !this this=!0 !1 !2 !3 !4"] + }, + "bind": { + "!type": "fn(this: ?, args?: ?) -> !custom:Function_bind" + }, + "prototype": "?" + } + }, + "Array": { + "!type": "fn(size: number) -> !custom:Array_ctor", + "isArray": { + "!type": "fn(value: ?) -> bool" + }, + "from": { + "!type": "fn(arrayLike: ?, mapFn?: fn(elt: ?, i: number) -> ?, thisArg?: ?) -> [!0.]", + "!effects": ["call !1 this=!2 !0. number"] + }, + "of": { + "!type": "fn(elementN: ?) -> [!0]" + }, + "prototype": { + "!stdProto": "Array", + "length": { + "!type": "number" + }, + "concat": { + "!type": "fn(other: [?]) -> !this" + }, + "join": { + "!type": "fn(separator?: string) -> string" + }, + "splice": { + "!type": "fn(pos: number, amount: number, newelt?: ?) -> [?]" + }, + "pop": { + "!type": "fn() -> !this." + }, + "push": { + "!type": "fn(newelt: ?) -> number", + "!effects": ["propagate !0 !this."] + }, + "shift": { + "!type": "fn() -> !this." + }, + "unshift": { + "!type": "fn(newelt: ?) -> number", + "!effects": ["propagate !0 !this."] + }, + "slice": { + "!type": "fn(from?: number, to?: number) -> !this" + }, + "reverse": { + "!type": "fn()" + }, + "sort": { + "!type": "fn(compare?: fn(a: ?, b: ?) -> number)", + "!effects": ["call !0 !this. !this."] + }, + "indexOf": { + "!type": "fn(elt: ?, from?: number) -> number" + }, + "lastIndexOf": { + "!type": "fn(elt: ?, from?: number) -> number" + }, + "every": { + "!type": "fn(test: fn(elt: ?, i: number, array: +Array) -> bool, context?: ?) -> bool", + "!effects": ["call !0 this=!1 !this. number !this"] + }, + "some": { + "!type": "fn(test: fn(elt: ?, i: number, array: +Array) -> bool, context?: ?) -> bool", + "!effects": ["call !0 this=!1 !this. number !this"] + }, + "filter": { + "!type": "fn(test: fn(elt: ?, i: number, array: +Array) -> bool, context?: ?) -> !this", + "!effects": ["call !0 this=!1 !this. number !this"] + }, + "forEach": { + "!type": "fn(f: fn(elt: ?, i: number, array: +Array), context?: ?)", + "!effects": ["call !0 this=!1 !this. number !this"] + }, + "map": { + "!type": "fn(f: fn(elt: ?, i: number, array: +Array) -> ?, context?: ?) -> [!0.!ret]", + "!effects": ["call !0 this=!1 !this. number !this"] + }, + "reduce": { + "!type": "fn(combine: fn(sum: ?, elt: ?, i: number, array: +Array) -> ?, init?: ?) -> !0.!ret", + "!effects": ["call !0 !1 !this. number !this"] + }, + "reduceRight": { + "!type": "fn(combine: fn(sum: ?, elt: ?, i: number, array: +Array) -> ?, init?: ?) -> !0.!ret", + "!effects": ["call !0 !1 !this. number !this"] + }, + "copyWithin": { + "!type": "fn(target: number, start: number, end?: number) -> !this" + }, + "entries": { + "!type": "fn() -> +iter[:t=[number, !this.]]" + }, + "fill": { + "!type": "fn(value: ?, start?: number, end?: number) -> !this" + }, + "find": { + "!type": "fn(callback: fn(element: ?, index: number, array: [?]) -> bool, thisArg?: ?) -> !this.", + "!effects": ["call !0 this=!2 !this. number"] + }, + "findIndex": { + "!type": "fn(callback: fn(element: ?, index: number, array: [?]), thisArg?: ?) -> number", + "!effects": ["call !0 this=!2 !this. number"] + }, + "keys": { + "!type": "fn() -> +iter[:t=number]" + }, + "values": { + "!type": "fn() -> +iter[:t=!this.]" + }, + ":Symbol.iterator": { + "!type": "fn() -> +iter[:t=!this.]" + }, + "includes": { + "!type": "fn(value: ?, fromIndex?: number) -> bool" + } + } + }, + "String": { + "!type": "fn(value: ?) -> string", + "fromCharCode": { + "!type": "fn(code: number) -> string" + }, + "fromCodePoint": { + "!type": "fn(point: number, point?: number) -> string" + }, + "raw": { + "!type": "fn(template: [string], substitutions: ?, templateString: ?) -> string" + }, + "prototype": { + "!stdProto": "String", + "length": { + "!type": "number" + }, + "": "string", + "charAt": { + "!type": "fn(i: number) -> string" + }, + "charCodeAt": { + "!type": "fn(i: number) -> number" + }, + "indexOf": { + "!type": "fn(char: string, from?: number) -> number" + }, + "lastIndexOf": { + "!type": "fn(char: string, from?: number) -> number" + }, + "substring": { + "!type": "fn(from: number, to?: number) -> string" + }, + "substr": { + "!type": "fn(from: number, length?: number) -> string" + }, + "slice": { + "!type": "fn(from: number, to?: number) -> string" + }, + "padStart": { + "!type": "fn(targetLength: number, padString?: string) -> string" + }, + "padEnd": { + "!type": "fn(targetLength: number, padString?: string) -> string" + }, + "trim": { + "!type": "fn() -> string" + }, + "trimStart": { + "!type": "fn() -> string" + }, + "trimLeft": "String.prototype.trimStart", + "trimEnd": { + "!type": "fn() -> string" + }, + "trimRight": "String.prototype.trimEnd", + "toUpperCase": { + "!type": "fn() -> string" + }, + "toLowerCase": { + "!type": "fn() -> string" + }, + "toLocaleUpperCase": { + "!type": "fn() -> string" + }, + "toLocaleLowerCase": { + "!type": "fn() -> string" + }, + "split": { + "!type": "fn(pattern?: string|+RegExp, limit?: number) -> [string]" + }, + "concat": { + "!type": "fn(other: string) -> string" + }, + "localeCompare": { + "!type": "fn(other: string) -> number" + }, + "match": { + "!type": "fn(pattern: +RegExp) -> [string]" + }, + "replace": { + "!type": "fn(pattern: string|+RegExp, replacement: string) -> string" + }, + "search": { + "!type": "fn(pattern: +RegExp) -> number" + }, + "codePointAt": { + "!type": "fn(pos: number) -> number" + }, + "endsWith": { + "!type": "fn(searchString: string, position?: number) -> bool" + }, + "includes": { + "!type": "fn(searchString: string, position?: number) -> bool" + }, + "normalize": { + "!type": "fn(form: string) -> string" + }, + "repeat": { + "!type": "fn(count: number) -> string" + }, + "startsWith": { + "!type": "fn(searchString: string, position?: number) -> bool" + }, + ":Symbol.iterator": { + "!type": "fn() -> +iter[:t=string]" + } + } + }, + "Number": { + "!type": "fn(value: ?) -> number", + "MAX_VALUE": { + "!type": "number" + }, + "MIN_VALUE": { + "!type": "number" + }, + "POSITIVE_INFINITY": { + "!type": "number" + }, + "NEGATIVE_INFINITY": { + "!type": "number" + }, + "prototype": { + "!stdProto": "Number", + "toString": { + "!type": "fn(radix?: number) -> string" + }, + "toFixed": { + "!type": "fn(digits: number) -> string" + }, + "toExponential": { + "!type": "fn(digits: number) -> string" + }, + "toPrecision": { + "!type": "fn(digits: number) -> string" + } + }, + "EPSILON": { + "!type": "number" + }, + "MAX_SAFE_INTEGER": { + "!type": "number" + }, + "MIN_SAFE_INTEGER": { + "!type": "number" + }, + "isFinite": { + "!type": "fn(testValue: ?) -> bool" + }, + "isInteger": { + "!type": "fn(testValue: ?) -> bool" + }, + "isNaN": { + "!type": "fn(testValue: ?) -> bool" + }, + "isSafeInteger": { + "!type": "fn(testValue: ?) -> bool" + }, + "parseFloat": { + "!type": "fn(string: string) -> number" + }, + "parseInt": { + "!type": "fn(string: string, radix?: number) -> number" + } + }, + "Boolean": { + "!type": "fn(value: ?) -> bool", + "prototype": { + "!stdProto": "Boolean" + } + }, + "RegExp": { + "!type": "fn(source: string, flags?: string)", + "prototype": { + "!stdProto": "RegExp", + "exec": { + "!type": "fn(input: string) -> [string]" + }, + "test": { + "!type": "fn(input: string) -> bool" + }, + "global": { + "!type": "bool" + }, + "ignoreCase": { + "!type": "bool" + }, + "multiline": { + "!type": "bool" + }, + "source": { + "!type": "string" + }, + "lastIndex": { + "!type": "number" + }, + "flags": { + "!type": "string" + }, + "sticky": { + "!type": "bool" + }, + "unicode": { + "!type": "bool" + } + } + }, + "Date": { + "!type": "fn(ms: number)", + "parse": { + "!type": "fn(source: string) -> +Date" + }, + "UTC": { + "!type": "fn(year: number, month: number, date: number, hour?: number, min?: number, sec?: number, ms?: number) -> number" + }, + "now": { + "!type": "fn() -> number" + }, + "prototype": { + "toUTCString": { + "!type": "fn() -> string" + }, + "toISOString": { + "!type": "fn() -> string" + }, + "toDateString": { + "!type": "fn() -> string" + }, + "toGMTString": { + "!type": "fn() -> string" + }, + "toTimeString": { + "!type": "fn() -> string" + }, + "toLocaleDateString": { + "!type": "fn() -> string" + }, + "toLocaleFormat": { + "!type": "fn(formatString: string) -> string" + }, + "toLocaleString": { + "!type": "fn(locales?: string, options?: ?) -> string" + }, + "toLocaleTimeString": { + "!type": "fn() -> string" + }, + "toSource": { + "!type": "fn() -> string" + }, + "toString": { + "!type": "fn() -> string" + }, + "valueOf": { + "!type": "fn() -> number" + }, + "getTime": { + "!type": "fn() -> number" + }, + "getFullYear": { + "!type": "fn() -> number" + }, + "getYear": { + "!type": "fn() -> number" + }, + "getMonth": { + "!type": "fn() -> number" + }, + "getUTCMonth": { + "!type": "fn() -> number" + }, + "getDate": { + "!type": "fn() -> number" + }, + "getUTCDate": { + "!type": "fn() -> number" + }, + "getDay": { + "!type": "fn() -> number" + }, + "getUTCDay": { + "!type": "fn() -> number" + }, + "getUTCFullYear": { + "!type": "fn() -> number" + }, + "getHours": { + "!type": "fn() -> number" + }, + "getUTCHours": { + "!type": "fn() -> number" + }, + "getMinutes": { + "!type": "fn() -> number" + }, + "getUTCMinutes": { + "!type": "fn() -> number" + }, + "getSeconds": { + "!type": "fn() -> number" + }, + "getUTCSeconds": { + "!type": "fn() -> number" + }, + "getMilliseconds": { + "!type": "fn() -> number" + }, + "getUTCMilliseconds": { + "!type": "fn() -> number" + }, + "getTimezoneOffset": { + "!type": "fn() -> number" + }, + "setTime": { + "!type": "fn(date: +Date) -> number" + }, + "setFullYear": { + "!type": "fn(year: number) -> number" + }, + "setUTCFullYear": { + "!type": "fn(year: number) -> number" + }, + "setYear": { + "!type": "fn(yearValue: number) -> number" + }, + "setMonth": { + "!type": "fn(month: number) -> number" + }, + "setUTCMonth": { + "!type": "fn(month: number) -> number" + }, + "setDate": { + "!type": "fn(day: number) -> number" + }, + "setUTCDate": { + "!type": "fn(day: number) -> number" + }, + "setHours": { + "!type": "fn(hour: number) -> number" + }, + "setUTCHours": { + "!type": "fn(hour: number) -> number" + }, + "setMinutes": { + "!type": "fn(min: number) -> number" + }, + "setUTCMinutes": { + "!type": "fn(min: number) -> number" + }, + "setSeconds": { + "!type": "fn(sec: number) -> number" + }, + "setUTCSeconds": { + "!type": "fn(sec: number) -> number" + }, + "setMilliseconds": { + "!type": "fn(ms: number) -> number" + }, + "setUTCMilliseconds": { + "!type": "fn(ms: number) -> number" + }, + "toJSON": { + "!type": "fn() -> string" + } + }, + "nested_overflow": "nested environment variables are limited to 10 levels" + }, + "error": { + "!type": "fn(message: string)", + "prototype": { + "name": { + "!type": "string" + }, + "message": { + "!type": "string" + } + } + }, + "SyntaxError": { + "!type": "fn(message: string)", + "prototype": "Error.prototype" + }, + "ReferenceError": { + "!type": "fn(message: string)", + "prototype": "Error.prototype" + }, + "URIError": { + "!type": "fn(message: string)", + "prototype": "Error.prototype" + }, + "EvalError": { + "!type": "fn(message: string)", + "prototype": "Error.prototype" + }, + "RangeError": { + "!type": "fn(message: string)", + "prototype": "Error.prototype" + }, + "TypeError": { + "!type": "fn(message: string)", + "prototype": "Error.prototype" + }, + "parseInt": { + "!type": "fn(string: string, radix?: number) -> number" + }, + "parseFloat": { + "!type": "fn(string: string) -> number" + }, + "isNaN": { + "!type": "fn(value: number) -> bool" + }, + "isFinite": { + "!type": "fn(value: number) -> bool" + }, + "eval": { + "!type": "fn(code: string) -> ?" + }, + "encodeURI": { + "!type": "fn(uri: string) -> string" + }, + "encodeURIComponent": { + "!type": "fn(uri: string) -> string" + }, + "decodeURI": { + "!type": "fn(uri: string) -> string" + }, + "decodeURIComponent": { + "!type": "fn(uri: string) -> string" + }, + "Math": { + "E": { + "!type": "number" + }, + "LN2": { + "!type": "number" + }, + "LN10": { + "!type": "number" + }, + "LOG2E": { + "!type": "number" + }, + "LOG10E": { + "!type": "number" + }, + "SQRT1_2": { + "!type": "number" + }, + "SQRT2": { + "!type": "number" + }, + "PI": { + "!type": "number" + }, + "abs": { + "!type": "fn(number) -> number" + }, + "cos": { + "!type": "fn(number) -> number" + }, + "sin": { + "!type": "fn(number) -> number" + }, + "tan": { + "!type": "fn(number) -> number" + }, + "acos": { + "!type": "fn(number) -> number" + }, + "asin": { + "!type": "fn(number) -> number" + }, + "atan": { + "!type": "fn(number) -> number" + }, + "atan2": { + "!type": "fn(y: number, x: number) -> number" + }, + "ceil": { + "!type": "fn(number) -> number" + }, + "floor": { + "!type": "fn(number) -> number" + }, + "round": { + "!type": "fn(number) -> number" + }, + "exp": { + "!type": "fn(number) -> number" + }, + "log": { + "!type": "fn(number) -> number" + }, + "sqrt": { + "!type": "fn(number) -> number" + }, + "pow": { + "!type": "fn(number, number) -> number" + }, + "max": { + "!type": "fn(number, number) -> number" + }, + "min": { + "!type": "fn(number, number) -> number" + }, + "random": { + "!type": "fn() -> number" + }, + "acosh": { + "!type": "fn(x: number) -> number" + }, + "asinh": { + "!type": "fn(x: number) -> number" + }, + "atanh": { + "!type": "fn(x: number) -> number" + }, + "cbrt": { + "!type": "fn(x: number) -> number" + }, + "clz32": { + "!type": "fn(x: number) -> number" + }, + "cosh": { + "!type": "fn(x: number) -> number" + }, + "expm1": { + "!type": "fn(x: number) -> number" + }, + "fround": { + "!type": "fn(x: number) -> number" + }, + "hypot": { + "!type": "fn(value: number) -> number" + }, + "imul": { + "!type": "fn(a: number, b: number) -> number" + }, + "log10": { + "!type": "fn(x: number) -> number" + }, + "log1p": { + "!type": "fn(x: number) -> number" + }, + "log2": { + "!type": "fn(x: number) -> number" + }, + "sign": { + "!type": "fn(x: number) -> number" + }, + "sinh": { + "!type": "fn(x: number) -> number" + }, + "tanh": { + "!type": "fn(x: number) -> number" + }, + "trunc": { + "!type": "fn(x: number) -> number" + } + }, + "JSON": { + "parse": { + "!type": "fn(json: string, reviver?: fn(key: string, value: ?) -> ?) -> ?" + }, + "stringify": { + "!type": "fn(value: ?, replacer?: fn(key: string, value: ?) -> ?, space?: string|number) -> string" + } + }, + "ArrayBuffer": { + "!type": "fn(length: number)", + "isView": { + "!type": "fn(arg: +ArrayBuffer) -> bool" + }, + "prototype": { + "byteLength": { + "!type": "number" + }, + "slice": { + "!type": "fn(begin: number, end?: number) -> +ArrayBuffer" + } + } + }, + "DataView": { + "!type": "fn(buffer: +ArrayBuffer, byteOffset?: number, byteLength?: number)", + "prototype": { + "buffer": { + "!type": "+ArrayBuffer" + }, + "byteLength": { + "!type": "number" + }, + "byteOffset": { + "!type": "number" + }, + "getFloat32": { + "!type": "fn(byteOffset: number, littleEndian?: bool) -> number" + }, + "getFloat64": { + "!type": "fn(byteOffset: number, littleEndian?: bool) -> number" + }, + "getInt16": { + "!type": "fn(byteOffset: number, littleEndian?: bool) -> number" + }, + "getInt32": { + "!type": "fn(byteOffset: number, littleEndian?: bool) -> number" + }, + "getInt8": { + "!type": "fn(byteOffset: number, littleEndian?: bool) -> number" + }, + "getUint16": { + "!type": "fn(byteOffset: number, littleEndian?: bool) -> number" + }, + "getUint32": { + "!type": "fn(byteOffset: number, littleEndian?: bool) -> number" + }, + "getUint8": { + "!type": "fn(byteOffset: number) -> number" + }, + "setFloat32": { + "!type": "fn(byteOffset: number, value: number, littleEndian?: bool)" + }, + "setFloat64": { + "!type": "fn(byteOffset: number, value: number, littleEndian?: bool)" + }, + "setInt16": { + "!type": "fn(byteOffset: number, value: number, littleEndian?: bool)" + }, + "setInt32": { + "!type": "fn(byteOffset: number, value: number, littleEndian?: bool)" + }, + "setInt8": { + "!type": "fn(byteOffset: number, value: number)" + }, + "setUint16": { + "!type": "fn(byteOffset: number, value: number, littleEndian?: bool)" + }, + "setUint32": { + "!type": "fn(byteOffset: number, value: number, littleEndian?: bool)" + }, + "setUint8": { + "!type": "fn(byteOffset: number, value: number)" + } + } + }, + "Float32Array": "TypedArray", + "Float64Array": "TypedArray", + "Int16Array": "TypedArray", + "Int32Array": "TypedArray", + "Int8Array": "TypedArray", + "Map": { + "!type": "fn(iterable?: [?])", + "prototype": { + "clear": { + "!type": "fn()" + }, + "delete": { + "!type": "fn(key: ?)" + }, + "entries": { + "!type": "fn() -> +iter[:t=[!this.:key, !this.:value]]" + }, + "forEach": { + "!type": "fn(callback: fn(value: ?, key: ?, map: +Map), thisArg?: ?)", + "!effects": ["call !0 this=!1 !this.:value !this.:key !this"] + }, + "get": { + "!type": "fn(key: ?) -> !this.:value" + }, + "has": { + "!type": "fn(key: ?) -> bool" + }, + "keys": { + "!type": "fn() -> +iter[:t=!this.:key]" + }, + "set": { + "!type": "fn(key: ?, value: ?) -> !this", + "!effects": ["propagate !0 !this.:key", "propagate !1 !this.:value"] + }, + "size": { + "!type": "number" + }, + "values": { + "!type": "fn() -> +iter[:t=!this.:value]" + }, + ":Symbol.iterator": { + "!type": "fn() -> +iter[:t=[!this.:key, !this.:value]]" + } + } + }, + "Promise": { + "!type": "fn(executor: fn(resolve: fn(value: ?), reject: fn(reason: ?))) -> !custom:Promise_ctor", + "all": { + "!type": "fn(iterable: [+Promise]) -> +Promise[:t=[!0..:t]]" + }, + "race": { + "!type": "fn(iterable: [+Promise]) -> !0." + }, + "reject": "Promise_reject", + "resolve": { + "!type": "fn(value: ?) -> !custom:Promise_resolve" + }, + "prototype": "Promise.prototype" + }, + "Proxy": { + "!type": "fn(target: ?, handler: Proxy_handler)", + "revocable": { + "!type": "fn(target: ?, handler: Proxy_handler) -> Proxy_revocable" + } + }, + "Reflect": { + "apply": { + "!type": "fn(target: fn(), thisArg?: ?, argumentList?: [?]) -> !0.!ret" + }, + "construct": { + "!type": "fn(target: fn(), argumentList?: [?]) -> ?" + }, + "defineProperty": { + "!type": "fn(target: ?, property: string, descriptor: propertyDescriptor) -> bool" + }, + "deleteProperty": { + "!type": "fn(target: ?, property: string) -> bool" + }, + "enumerate": { + "!type": "fn(target: ?) -> +iter[:t=string]" + }, + "get": { + "!type": "fn(target: ?, property: string) -> ?" + }, + "getOwnPropertyDescriptor": { + "!type": "fn(target: ?, property: string) -> ?" + }, + "getPrototypeOf": { + "!type": "fn(target: ?) -> ?" + }, + "has": { + "!type": "fn(target: ?, property: string) -> bool" + }, + "isExtensible": { + "!type": "fn(target: ?) -> bool" + }, + "ownKeys": { + "!type": "fn(target: ?) -> [string]" + }, + "preventExtensions": { + "!type": "fn(target: ?) -> bool" + }, + "set": { + "!type": "fn(target: ?, property: string, value: ?) -> bool" + }, + "setPrototypeOf": { + "!type": "fn(target: ?, prototype: ?) -> bool" + } + }, + "Set": { + "!type": "fn(iterable?: [?])", + "prototype": { + "add": { + "!type": "fn(value: ?) -> !this", + "!effects": ["propagate !0 !this.:t"] + }, + "clear": { + "!type": "fn()" + }, + "delete": { + "!type": "fn(value: ?) -> bool" + }, + "entries": { + "!type": "fn() -> +iter[:t=[!this.:t]]" + }, + "forEach": { + "!type": "fn(callback: fn(value: ?, value2: ?, set: +Set), thisArg?: ?)", + "!effects": ["call !0 this=!1 !this.:t number !this"] + }, + "has": { + "!type": "fn(value: ?) -> bool" + }, + "keys": { + "!type": "fn() -> +iter[:t=!this.:t]" + }, + "size": { + "!type": "number" + }, + "values": { + "!type": "fn() -> +iter[:t=!this.:t]" + }, + ":Symbol.iterator": { + "!type": "fn() -> +iter[:t=!this.:t]" + } + } + }, + "Symbol": { + "!type": "fn(description?: string) -> !custom:getSymbol", + "for": { + "!type": "fn(key: string) -> !custom:getSymbol" + }, + "keyFor": { + "!type": "fn(sym: +Symbol) -> string" + }, + "hasInstance": ":Symbol.hasInstance", + "isConcatSpreadable": ":Symbol.isConcatSpreadable", + "iterator": ":Symbol.iterator", + "asyncIterator": ":Symbol.asyncIterator", + "match": ":Symbol.match", + "replace": ":Symbol.replace", + "search": ":Symbol.search", + "species": ":Symbol.species", + "split": ":Symbol.split", + "toStringTag": ":Symbol.toStringTag", + "unscopables": ":Symbol.unscopables", + "prototype": { + "!stdProto": "Symbol" + } + }, + "Uint16Array": "TypedArray", + "Uint32Array": "TypedArray", + "Uint8Array": "TypedArray", + "Uint8ClampedArray": "TypedArray", + "WeakMap": { + "!type": "fn(iterable?: [?])", + "prototype": { + "delete": { + "!type": "fn(key: ?) -> bool" + }, + "get": { + "!type": "fn(key: ?) -> !this.:value" + }, + "has": { + "!type": "fn(key: ?) -> bool" + }, + "set": { + "!type": "fn(key: ?, value: ?)", + "!effects": ["propagate !0 !this.:key", "propagate !1 !this.:value"] + } + } + }, + "WeakSet": { + "!type": "fn(iterable?: [?])", + "prototype": { + "add": { + "!type": "fn(value: ?)" + }, + "delete": { + "!type": "fn(value: ?) -> bool" + }, + "has": { + "!type": "fn(value: ?) -> bool" + } + } + }, + "btoa": { + "!type": "fn(data: string) -> string" + }, + "atob": { + "!type": "fn(data: string) -> string" + } +} diff --git a/packages/hoppscotch-common/src/helpers/terndoc/pw-extras.json b/packages/hoppscotch-common/src/helpers/terndoc/pw-extras.json new file mode 100644 index 0000000..c7ea6cc --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/terndoc/pw-extras.json @@ -0,0 +1,51 @@ +{ + "!name": "pw-extra", + "console": { + "assert": { + "!type": "fn(assertion: bool, text: string)" + }, + "clear": { + "!type": "fn()" + }, + "count": { + "!type": "fn(label?: string)" + }, + "debug": "console.log", + "dir": { + "!type": "fn(object: ?)" + }, + "error": { + "!type": "fn(...msg: ?)" + }, + "group": { + "!type": "fn(label?: string)" + }, + "groupCollapsed": { + "!type": "fn(label?: string)" + }, + "groupEnd": { + "!type": "fn()" + }, + "info": { + "!type": "fn(...msg: ?)" + }, + "log": { + "!type": "fn(...msg: ?)" + }, + "table": { + "!type": "fn(data: []|?, columns?: [])" + }, + "time": { + "!type": "fn(label: string)" + }, + "timeEnd": { + "!type": "fn(label: string)" + }, + "trace": { + "!type": "fn()" + }, + "warn": { + "!type": "fn(...msg: ?)" + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/terndoc/pw-pre.json b/packages/hoppscotch-common/src/helpers/terndoc/pw-pre.json new file mode 100644 index 0000000..2a34c18 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/terndoc/pw-pre.json @@ -0,0 +1,12 @@ +{ + "!name": "pw-pre", + "pw": { + "env": { + "set": "fn(key: string, value: string)", + "unset": "fn(key: string)", + "get": "fn(key: string) -> string", + "getResolve": "fn(key: string) -> string", + "resolve": "fn(value: string) -> string" + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/terndoc/pw-test.json b/packages/hoppscotch-common/src/helpers/terndoc/pw-test.json new file mode 100644 index 0000000..3b58835 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/terndoc/pw-test.json @@ -0,0 +1,32 @@ +{ + "!name": "pw-test", + "!define": { + "Expectation": { + "not": "Expectation", + "toBe": "fn(value: ?)", + "toBeLevel2xx": "fn()", + "toBeLevel3xx": "fn()", + "toBeLevel4xx": "fn()", + "toBeLevel5xx": "fn()", + "toBeType": "fn(type: string)", + "toHaveLength": "fn(length: number)", + "toInclude": "fn(value: ?)" + } + }, + "pw": { + "expect": "fn(value: ?) -> Expectation", + "response": { + "status": "number", + "headers": "?", + "body": "?" + }, + "env": { + "set": "fn(key: string, value: string)", + "unset": "fn(key: string)", + "get": "fn(key: string) -> string", + "getResolve": "fn(key: string) -> string", + "resolve": "fn(value: string) -> string" + }, + "test": "fn(name: string, func: fn())" + } +} diff --git a/packages/hoppscotch-common/src/helpers/ternlint.js b/packages/hoppscotch-common/src/helpers/ternlint.js new file mode 100644 index 0000000..3325597 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/ternlint.js @@ -0,0 +1,722 @@ +/* eslint-disable */ + +import infer from "tern/lib/infer" +import tern from "tern/lib/tern" +import * as walk from "acorn-walk" + + +var defaultRules = { + UnknownProperty: { severity: "warning" }, + UnknownIdentifier: { severity: "warning" }, + NotAFunction: { severity: "error" }, + InvalidArgument: { severity: "error" }, + UnusedVariable: { severity: "warning" }, + UnknownModule: { severity: "error" }, + MixedReturnTypes: { severity: "warning" }, + ObjectLiteral: { severity: "error" }, + TypeMismatch: { severity: "warning" }, + Array: { severity: "error" }, + ES6Modules: { severity: "error" }, +} + +function makeVisitors(server, query, file, messages) { + function addMessage(node, msg, severity) { + var error = makeError(node, msg, severity) + messages.push(error) + } + + function makeError(node, msg, severity) { + var pos = getPosition(node) + var error = { + message: msg, + from: tern.outputPos(query, file, pos.start), + to: tern.outputPos(query, file, pos.end), + severity: severity, + } + if (query.lineNumber) { + error.lineNumber = query.lineCharPositions + ? error.from.line + : tern.outputPos({ lineCharPositions: true }, file, pos.start).line + } + if (!query.groupByFiles) error.file = file.name + return error + } + + function getNodeName(node) { + if (node.callee) { + // This is a CallExpression node. + // We get the position of the function name. + return getNodeName(node.callee) + } else if (node.property) { + // This is a MemberExpression node. + // We get the name of the property. + return node.property.name + } else { + return node.name + } + } + + function getNodeValue(node) { + if (node.callee) { + // This is a CallExpression node. + // We get the position of the function name. + return getNodeValue(node.callee) + } else if (node.property) { + // This is a MemberExpression node. + // We get the value of the property. + return node.property.value + } else { + if (node.type === "Identifier") { + var query = { type: "definition", start: node.start, end: node.end } + var expr = tern.findQueryExpr(file, query) + var type = infer.expressionType(expr) + var objExpr = type.getType() + if (objExpr && objExpr.originNode) + return getNodeValue(objExpr.originNode) + return null + } + return node.value + } + } + + function getPosition(node) { + if (node.callee) { + // This is a CallExpression node. + // We get the position of the function name. + return getPosition(node.callee) + } + if (node.property) { + // This is a MemberExpression node. + // We get the position of the property. + return node.property + } + return node + } + + function getTypeName(type) { + if (!type) return "Unknown type" + if (type.types) { + // multiple types + var types = type.types, + s = "" + for (var i = 0; i < types.length; i++) { + if (i > 0) s += "|" + var t = getTypeName(types[i]) + if (t != "Unknown type") s += t + } + return s == "" ? "Unknown type" : s + } + if (type.name) { + return type.name + } + return type.proto ? type.proto.name : "Unknown type" + } + + function hasProto(expectedType, name) { + if (!expectedType) return false + if (!expectedType.proto) return false + return expectedType.proto.name === name + } + + function isRegexExpected(expectedType) { + return hasProto(expectedType, "RegExp.prototype") + } + + function isEmptyType(val) { + return !val || (val.types && val.types.length == 0) + } + + function compareType(expected, actual) { + if (isEmptyType(expected) || isEmptyType(actual)) return true + if (expected.types) { + for (var i = 0; i < expected.types.length; i++) { + if (actual.types) { + for (var j = 0; j < actual.types.length; j++) { + if (compareType(expected.types[i], actual.types[j])) return true + } + } else { + if (compareType(expected.types[i], actual.getType())) return true + } + } + return false + } else if (actual.types) { + for (var i = 0; i < actual.types.length; i++) { + if (compareType(expected.getType(), actual.types[i])) return true + } + } + var expectedType = expected.getType(), + actualType = actual.getType() + if (!expectedType || !actualType) return true + var currentProto = actualType.proto + while (currentProto) { + if (expectedType.proto && expectedType.proto.name === currentProto.name) + return true + currentProto = currentProto.proto + } + return false + } + + function checkPropsInObject(node, expectedArg, actualObj, invalidArgument) { + var properties = node.properties, + expectedObj = expectedArg.getType() + for (var i = 0; i < properties.length; i++) { + var property = properties[i], + key = property.key, + prop = key && key.name, + value = property.value + if (prop) { + var expectedType = expectedObj.hasProp(prop) + if (!expectedType) { + // key doesn't exists + addMessage( + key, + "Invalid property at " + + (i + 1) + + ": " + + prop + + " is not a property in " + + getTypeName(expectedArg), + invalidArgument.severity + ) + } else { + // test that each object literal prop is the correct type + var actualType = actualObj.props[prop] + if (!compareType(expectedType, actualType)) { + addMessage( + value, + "Invalid property at " + + (i + 1) + + ": cannot convert from " + + getTypeName(actualType) + + " to " + + getTypeName(expectedType), + invalidArgument.severity + ) + } + } + } + } + } + + function checkItemInArray(node, expectedArg, state, invalidArgument) { + var elements = node.elements, + expectedType = expectedArg.hasProp("") + for (var i = 0; i < elements.length; i++) { + var elt = elements[i], + actualType = infer.expressionType({ node: elt, state: state }) + if (!compareType(expectedType, actualType)) { + addMessage( + elt, + "Invalid item at " + + (i + 1) + + ": cannot convert from " + + getTypeName(actualType) + + " to " + + getTypeName(expectedType), + invalidArgument.severity + ) + } + } + } + + function isObjectLiteral(type) { + var objType = type.getObjType() + return objType && objType.proto && objType.proto.name == "Object.prototype" + } + + function getFunctionLint(fnType) { + if (fnType.lint) return fnType.lint + if (fnType.metaData) { + fnType.lint = getLint(fnType.metaData["!lint"]) + return fnType.lint + } + } + + function isFunctionType(type) { + if (type.types) { + for (var i = 0; i < type.types.length; i++) { + if (isFunctionType(type.types[i])) return true + } + } + return type.proto && type.proto.name == "Function.prototype" + } + + function validateCallExpression(node, state, c) { + var notAFunctionRule = getRule("NotAFunction"), + invalidArgument = getRule("InvalidArgument") + if (!notAFunctionRule && !invalidArgument) return + var type = infer.expressionType({ node: node.callee, state: state }) + if (!type.isEmpty()) { + // If type.isEmpty(), it is handled by MemberExpression/Identifier already. + + // An expression can have multiple possible (guessed) types. + // If one of them is a function, type.getFunctionType() will return it. + var fnType = type.getFunctionType() + if (fnType == null) { + if (notAFunctionRule && !isFunctionType(type)) + addMessage( + node, + "'" + getNodeName(node) + "' is not a function", + notAFunctionRule.severity + ) + return + } + var fnLint = getFunctionLint(fnType) + var continueLint = fnLint ? fnLint(node, addMessage, getRule) : true + if (continueLint && fnType.args) { + // validate parameters of the function + if (!invalidArgument) return + var actualArgs = node.arguments + if (!actualArgs) return + var expectedArgs = fnType.args + for (var i = 0; i < expectedArgs.length; i++) { + var expectedArg = expectedArgs[i] + if (actualArgs.length > i) { + var actualNode = actualArgs[i] + if (isRegexExpected(expectedArg.getType())) { + var value = getNodeValue(actualNode) + if (value) { + try { + var regex = new RegExp(value) + } catch (e) { + addMessage( + actualNode, + "Invalid argument at " + (i + 1) + ": " + e, + invalidArgument.severity + ) + } + } + } else { + var actualArg = infer.expressionType({ + node: actualNode, + state: state, + }) + // if actual type is an Object literal and expected type is an object, we ignore + // the comparison type since object literal properties validation is done inside "ObjectExpression". + if (!(expectedArg.getObjType() && isObjectLiteral(actualArg))) { + if (!compareType(expectedArg, actualArg)) { + addMessage( + actualNode, + "Invalid argument at " + + (i + 1) + + ": cannot convert from " + + getTypeName(actualArg) + + " to " + + getTypeName(expectedArg), + invalidArgument.severity + ) + } + } + } + } + } + } + } + } + + function validateAssignment(nodeLeft, nodeRight, rule, state) { + if (!nodeLeft || !nodeRight) return + if (!rule) return + var leftType = infer.expressionType({ node: nodeLeft, state: state }), + rightType = infer.expressionType({ node: nodeRight, state: state }) + if (!compareType(leftType, rightType)) { + addMessage( + nodeRight, + "Type mismatch: cannot convert from " + + getTypeName(leftType) + + " to " + + getTypeName(rightType), + rule.severity + ) + } + } + + function validateDeclaration(node, state, c) { + function isUsedVariable(varNode, varState, file, srv) { + var name = varNode.name + + for ( + var scope = varState; + scope && !(name in scope.props); + scope = scope.prev + ) {} + if (!scope) return false + + var hasRef = false + function searchRef(file) { + return function (node, scopeHere) { + if (node != varNode) { + hasRef = true + throw new Error() // throw an error to stop the search. + } + } + } + + try { + if (scope.node) { + // local scope + infer.findRefs(scope.node, scope, name, scope, searchRef(file)) + } else { + // global scope + infer.findRefs(file.ast, file.scope, name, scope, searchRef(file)) + for (var i = 0; i < srv.files.length && !hasRef; ++i) { + var cur = srv.files[i] + if (cur != file) + infer.findRefs(cur.ast, cur.scope, name, scope, searchRef(cur)) + } + } + } catch (_e) {} + return hasRef + } + + var unusedRule = getRule("UnusedVariable"), + mismatchRule = getRule("TypeMismatch") + if (!unusedRule && !mismatchRule) return + switch (node.type) { + case "VariableDeclaration": + for (var i = 0; i < node.declarations.length; ++i) { + var decl = node.declarations[i], + varNode = decl.id + if (varNode.name != "✖") { + // unused variable + if (unusedRule && !isUsedVariable(varNode, state, file, server)) + addMessage( + varNode, + "Unused variable '" + getNodeName(varNode) + "'", + unusedRule.severity + ) + // type mismatch? + if (mismatchRule) + validateAssignment(varNode, decl.init, mismatchRule, state) + } + } + break + case "FunctionDeclaration": + if (unusedRule) { + var varNode = node.id + if ( + varNode.name != "✖" && + !isUsedVariable(varNode, state, file, server) + ) + addMessage( + varNode, + "Unused function '" + getNodeName(varNode) + "'", + unusedRule.severity + ) + } + break + } + } + + function getArrType(type) { + if (type instanceof infer.Arr) { + return type.getObjType() + } else if (type.types) { + for (var i = 0; i < type.types.length; i++) { + if (getArrType(type.types[i])) return type.types[i] + } + } + } + + var visitors = { + VariableDeclaration: validateDeclaration, + FunctionDeclaration: validateDeclaration, + ReturnStatement: function (node, state, c) { + if (!node.argument) return + var rule = getRule("MixedReturnTypes") + if (!rule) return + if (state.fnType && state.fnType.retval) { + var actualType = infer.expressionType({ + node: node.argument, + state: state, + }), + expectedType = state.fnType.retval + if (!compareType(expectedType, actualType)) { + addMessage( + node, + "Invalid return type : cannot convert from " + + getTypeName(actualType) + + " to " + + getTypeName(expectedType), + rule.severity + ) + } + } + }, + // Detects expressions of the form `object.property` + MemberExpression: function (node, state, c) { + var rule = getRule("UnknownProperty") + if (!rule) return + var prop = node.property && node.property.name + if (!prop || prop == "✖") return + var type = infer.expressionType({ node: node, state: state }) + var parentType = infer.expressionType({ node: node.object, state: state }) + + if (node.computed) { + // Bracket notation. + // Until we figure out how to handle these properly, we ignore these nodes. + return + } + + if (!parentType.isEmpty() && type.isEmpty()) { + // The type of the property cannot be determined, which means + // that the property probably doesn't exist. + + // We only do this check if the parent type is known, + // otherwise we will generate errors for an entire chain of unknown + // properties. + + // Also, the expression may be valid even if the parent type is unknown, + // since the inference engine cannot detect the type in all cases. + + var propertyDefined = false + + // In some cases the type is unknown, even if the property is defined + if (parentType.types) { + // We cannot use parentType.hasProp or parentType.props - in the case of an AVal, + // this may contain properties that are not really defined. + parentType.types.forEach(function (potentialType) { + // Obj#hasProp checks the prototype as well + if ( + typeof potentialType.hasProp == "function" && + potentialType.hasProp(prop, true) + ) { + propertyDefined = true + } + }) + } + + if (!propertyDefined) { + addMessage( + node, + "Unknown property '" + getNodeName(node) + "'", + rule.severity + ) + } + } + }, + // Detects top-level identifiers, e.g. the object in + // `object.property` or just `object`. + Identifier: function (node, state, c) { + var rule = getRule("UnknownIdentifier") + if (!rule) return + var type = infer.expressionType({ node: node, state: state }) + + if (type.originNode != null || type.origin != null) { + // The node is defined somewhere (could be this node), + // regardless of whether or not the type is known. + } else if (type.isEmpty()) { + // The type of the identifier cannot be determined, + // and the origin is unknown. + addMessage( + node, + "Unknown identifier '" + getNodeName(node) + "'", + rule.severity + ) + } else { + // Even though the origin node is unknown, the type is known. + // This is typically the case for built-in identifiers (e.g. window or document). + } + }, + // Detects function calls. + // `node.callee` is the expression (Identifier or MemberExpression) + // the is called as a function. + NewExpression: validateCallExpression, + CallExpression: validateCallExpression, + AssignmentExpression: function (node, state, c) { + var rule = getRule("TypeMismatch") + validateAssignment(node.left, node.right, rule, state) + }, + ObjectExpression: function (node, state, c) { + // validate properties of the object literal + var rule = getRule("ObjectLiteral") + if (!rule) return + var actualType = node.objType + var ctxType = infer.typeFromContext(file.ast, { + node: node, + state: state, + }), + expectedType = null + if (ctxType instanceof infer.Obj) { + expectedType = ctxType.getObjType() + } else if (ctxType && ctxType.makeupType) { + var objType = ctxType.makeupType() + if (objType && objType.getObjType()) { + expectedType = objType.getObjType() + } + } + if (expectedType && expectedType != actualType) { + // expected type is known. Ex: config object of RequireJS + checkPropsInObject(node, expectedType, actualType, rule) + } + }, + ArrayExpression: function (node, state, c) { + // validate elements of the Array + var rule = getRule("Array") + if (!rule) return + //var actualType = infer.expressionType({node: node, state: state}); + var ctxType = infer.typeFromContext(file.ast, { + node: node, + state: state, + }), + expectedType = getArrType(ctxType) + if (expectedType /*&& expectedType != actualType*/) { + // expected type is known. Ex: config object of RequireJS + checkItemInArray(node, expectedType, state, rule) + } + }, + ImportDeclaration: function (node, state, c) { + // Validate ES6 modules from + specifiers + var rule = getRule("ES6Modules") + if (!rule) return + var me = infer.cx().parent.mod.modules + if (!me) return // tern plugin modules.js is not loaded + var source = node.source + if (!source) return + // Validate ES6 modules "from" + var modType = me.getModType(source) + if (!modType) { + addMessage( + source, + "Invalid modules from '" + source.value + "'", + rule.severity + ) + return + } + // Validate ES6 modules "specifiers" + var specifiers = node.specifiers, + specifier + if (!specifiers) return + for (var i = 0; i < specifiers.length; i++) { + var specifier = specifiers[i], + imported = specifier.imported + if (imported) { + var name = imported.name + if (!modType.hasProp(name)) + addMessage( + imported, + "Invalid modules specifier '" + getNodeName(imported) + "'", + rule.severity + ) + } + } + }, + } + + return visitors +} + +// Adapted from infer.searchVisitor. +// Record the scope and pass it through in the state. +// VariableDeclaration in infer.searchVisitor breaks things for us. +var scopeVisitor = walk.make({ + Function: function (node, _st, c) { + var scope = node.scope + if (node.id) c(node.id, scope) + for (var i = 0; i < node.params.length; ++i) c(node.params[i], scope) + c(node.body, scope, "ScopeBody") + }, + Statement: function (node, st, c) { + c(node, node.scope || st) + }, +}) + +// Validate one file + +export function validateFile(server, query, file) { + try { + var messages = [], + ast = file.ast, + state = file.scope + var visitors = makeVisitors(server, query, file, messages) + walk.simple(ast, visitors, infer.searchVisitor, state) + return { messages: messages } + } catch (e) { + console.error(e.stack) + return { messages: [] } + } +} + +export function registerTernLinter() { + tern.defineQueryType("lint", { + takesFile: true, + run: function (server, query, file) { + return validateFile(server, query, file) + }, + }) + + tern.defineQueryType("lint-full", { + run: function (server, query) { + return validateFiles(server, query) + }, + }) + + tern.registerPlugin("lint", function (server, options) { + server._lint = { + rules: getRules(options), + } + return { + passes: {}, + loadFirst: true, + } + }) +} + +// Validate the whole files of the server + +export function validateFiles(server, query) { + try { + var messages = [], + files = server.files, + groupByFiles = query.groupByFiles == true + for (var i = 0; i < files.length; ++i) { + var messagesFile = groupByFiles ? [] : messages, + file = files[i], + ast = file.ast, + state = file.scope + var visitors = makeVisitors(server, query, file, messagesFile) + walk.simple(ast, visitors, infer.searchVisitor, state) + if (groupByFiles) + messages.push({ file: file.name, messages: messagesFile }) + } + return { messages: messages } + } catch (e) { + console.error(e.stack) + return { messages: [] } + } +} + +var lints = Object.create(null) + +var getLint = (tern.getLint = function (name) { + if (!name) return null + return lints[name] +}) + +function getRules(options) { + var rules = {} + for (var ruleName in defaultRules) { + if ( + options && + options.rules && + options.rules[ruleName] && + options.rules[ruleName].severity + ) { + if (options.rules[ruleName].severity != "none") + rules[ruleName] = options.rules[ruleName] + } else { + rules[ruleName] = defaultRules[ruleName] + } + } + return rules +} + +function getRule(ruleName) { + const cx = infer.cx() + const server = cx.parent + const rules = + server && server._lint && server._lint.rules + ? server._lint.rules + : defaultRules + return rules[ruleName] +} diff --git a/packages/hoppscotch-common/src/helpers/testSnippets.ts b/packages/hoppscotch-common/src/helpers/testSnippets.ts new file mode 100644 index 0000000..002d395 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/testSnippets.ts @@ -0,0 +1,49 @@ +export default [ + { + name: "Environment: Set an environment variable", + script: `\n\n// Set an environment variable +pw.env.set("variable", "value");`, + }, + { + name: "Response: Status code is 200", + script: `\n\n// Check status code is 200 +pw.test("Status code is 200", ()=> { + pw.expect(pw.response.status).toBe(200); +});`, + }, + { + name: "Response: Assert property from body", + script: `\n\n// Check JSON response property +pw.test("Check JSON response property", ()=> { + pw.expect(pw.response.body.method).toBe("GET"); +});`, + }, + { + name: "Status code: Status code is 2xx", + script: `\n\n// Check status code is 2xx +pw.test("Status code is 2xx", ()=> { + pw.expect(pw.response.status).toBeLevel2xx(); +});`, + }, + { + name: "Status code: Status code is 3xx", + script: `\n\n// Check status code is 3xx +pw.test("Status code is 3xx", ()=> { + pw.expect(pw.response.status).toBeLevel3xx(); +});`, + }, + { + name: "Status code: Status code is 4xx", + script: `\n\n// Check status code is 4xx +pw.test("Status code is 4xx", ()=> { + pw.expect(pw.response.status).toBeLevel4xx(); +});`, + }, + { + name: "Status code: Status code is 5xx", + script: `\n\n// Check status code is 5xx +pw.test("Status code is 5xx", ()=> { + pw.expect(pw.response.status).toBeLevel5xx(); +});`, + }, +] diff --git a/packages/hoppscotch-common/src/helpers/types/HoppInheritedProperties.ts b/packages/hoppscotch-common/src/helpers/types/HoppInheritedProperties.ts new file mode 100644 index 0000000..bfb986a --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/types/HoppInheritedProperties.ts @@ -0,0 +1,32 @@ +import { + GQLHeader, + HoppGQLAuth, + HoppRESTHeader, + HoppRESTAuth, + HoppCollectionVariable, +} from "@hoppscotch/data" + +export type HoppInheritedProperty = { + auth: { + parentID: string + parentName: string + inheritedAuth: HoppRESTAuth | HoppGQLAuth + } + headers: { + parentID: string + parentName: string + inheritedHeader: HoppRESTHeader | GQLHeader + }[] + variables: { + parentPath?: string + parentID: string + parentName: string + inheritedVariables: HoppCollectionVariable[] + }[] + scripts: { + parentID: string + parentName: string + preRequestScript: string + testScript: string + }[] +} diff --git a/packages/hoppscotch-common/src/helpers/types/HoppPicked.ts b/packages/hoppscotch-common/src/helpers/types/HoppPicked.ts new file mode 100644 index 0000000..4cc7765 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/types/HoppPicked.ts @@ -0,0 +1,47 @@ +/** + * Picked is used to differentiate + * the select item in the save request dialog + * The save request dialog can be used + * to save a request, folder, or a collection + * separately for 'my' and 'teams' for REST. + * also for graphQL collections + */ +export type Picked = + | { + pickedType: "my-request" + folderPath: string + requestIndex: number + } + | { + pickedType: "my-folder" + folderPath: string + } + | { + pickedType: "my-collection" + collectionIndex: number + } + | { + pickedType: "teams-request" + requestID: string + } + | { + pickedType: "teams-folder" + folderID: string + } + | { + pickedType: "teams-collection" + collectionID: string + } + | { + pickedType: "gql-my-request" + folderPath: string + requestIndex: number + } + | { + pickedType: "gql-my-folder" + folderPath: string + } + | { + pickedType: "gql-my-collection" + collectionIndex: number + } diff --git a/packages/hoppscotch-common/src/helpers/types/HoppRESTResponse.ts b/packages/hoppscotch-common/src/helpers/types/HoppRESTResponse.ts new file mode 100644 index 0000000..a1036b2 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/types/HoppRESTResponse.ts @@ -0,0 +1,69 @@ +import { HoppRESTRequest } from "@hoppscotch/data" +import { Component } from "vue" +import { KernelInterceptorError } from "~/services/kernel-interceptor.service" + +export type HoppRESTResponseHeader = { key: string; value: string } + +export type HoppRESTSuccessResponse = { + type: "success" + headers: HoppRESTResponseHeader[] + body: ArrayBuffer + statusCode: number + statusText: string + meta: { + responseSize: number // in bytes + responseDuration: number // in millis + } + req: HoppRESTRequest +} + +export type HoppRESTFailureResponse = { + type: "failure" + headers: HoppRESTResponseHeader[] + body: ArrayBuffer + statusCode: number + statusText: string + meta: { + responseSize: number // in bytes + responseDuration: number // in millis + } + req: HoppRESTRequest +} + +export type HoppRESTFailureNetwork = { + type: "network_fail" + error: unknown + req: HoppRESTRequest +} + +export type HoppRESTFailureScript = { + type: "script_fail" + error: Error +} + +export type HoppRESTErrorExtension = { + type: "extension_error" + error: string + component: Component + req: HoppRESTRequest +} + +export type HoppRESTErrorInterceptor = { + type: "interceptor_error" + error: KernelInterceptorError + req: HoppRESTRequest +} + +export type HoppRESTLoadingResponse = { + type: "loading" + req: HoppRESTRequest +} + +export type HoppRESTResponse = + | HoppRESTLoadingResponse + | HoppRESTSuccessResponse + | HoppRESTFailureResponse + | HoppRESTFailureNetwork + | HoppRESTFailureScript + | HoppRESTFailureExtension + | HoppRESTFailureInterceptor diff --git a/packages/hoppscotch-common/src/helpers/types/HoppRealtimeLog.ts b/packages/hoppscotch-common/src/helpers/types/HoppRealtimeLog.ts new file mode 100644 index 0000000..9756fce --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/types/HoppRealtimeLog.ts @@ -0,0 +1,9 @@ +export type HoppRealtimeLogLine = { + prefix?: string + payload: string + source: string + color?: string + ts: number | undefined +} + +export type HoppRealtimeLog = HoppRealtimeLogLine[] diff --git a/packages/hoppscotch-common/src/helpers/types/HoppRequestSaveContext.ts b/packages/hoppscotch-common/src/helpers/types/HoppRequestSaveContext.ts new file mode 100644 index 0000000..9650fe4 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/types/HoppRequestSaveContext.ts @@ -0,0 +1,57 @@ +import { HoppRESTRequest } from "@hoppscotch/data" + +/** + * We use the save context to figure out + * how a loaded request is to be saved. + * These will be set when the request is loaded + * into the request session + */ +export type HoppRequestSaveContext = + | { + /** + * The origin source of the request + */ + originLocation: "user-collection" + /** + * Path to the request folder + */ + folderPath: string + /** + * Index to the request + */ + requestIndex: number + /** + * Current request + */ + req?: HoppRESTRequest + /** + * Reference ID of the request, if available + */ + requestRefID?: string + } + | { + /** + * The origin source of the request + */ + originLocation: "team-collection" + /** + * ID of the request in the team + */ + requestID: string + /** + * ID of the team + */ + teamID?: string + /** + * ID of the collection loaded + */ + collectionID?: string + /** + * Current request + */ + req?: HoppRESTRequest + /** + * Reference ID of the request, if available + */ + requestRefID?: string + } diff --git a/packages/hoppscotch-common/src/helpers/types/HoppTestResult.ts b/packages/hoppscotch-common/src/helpers/types/HoppTestResult.ts new file mode 100644 index 0000000..755ad48 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/types/HoppTestResult.ts @@ -0,0 +1,39 @@ +import { Environment } from "@hoppscotch/data" +import { SandboxTestResult } from "@hoppscotch/js-sandbox" + +export type HoppTestExpectResult = { + status: "fail" | "pass" | "error" + message: string +} + +export type HoppTestData = { + description: string + expectResults: HoppTestExpectResult[] + tests: HoppTestData[] +} + +export type HoppTestResult = { + tests: HoppTestData[] + expectResults: HoppTestExpectResult[] + description: string + scriptError: boolean + + envDiff: { + global: { + additions: Environment["variables"] + updations: Array< + Environment["variables"][number] & { previousValue: string } + > + deletions: Environment["variables"] + } + selected: { + additions: Environment["variables"] + updations: Array< + Environment["variables"][number] & { previousValue: string } + > + deletions: Environment["variables"] + } + } + + consoleEntries: SandboxTestResult["consoleEntries"] +} diff --git a/packages/hoppscotch-common/src/helpers/typeutils.ts b/packages/hoppscotch-common/src/helpers/typeutils.ts new file mode 100644 index 0000000..f1415b9 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/typeutils.ts @@ -0,0 +1,20 @@ +export type FieldEquals = { + // eslint-disable-next-line + [_x in K]: Vals[number] +} + +export const objectFieldIncludes = < + T, + K extends keyof T, + V extends readonly T[K][], +>( + obj: T, + field: K, + values: V + // eslint-disable-next-line +): obj is T & { [_x in K]: V[number] } => values.includes(obj[field]) + +export const valueIncludes = ( + obj: T, + values: V +): obj is V[number] => values.includes(obj) diff --git a/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts b/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts new file mode 100644 index 0000000..9b5a076 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts @@ -0,0 +1,464 @@ +import { + Environment, + FormDataKeyValue, + HoppRESTAuth, + HoppRESTHeader, + HoppRESTHeaders, + HoppRESTParam, + HoppRESTParams, + HoppRESTReqBody, + HoppRESTRequest, + parseBodyEnvVariables, + parseRawKeyValueEntriesE, + parseTemplateString, + parseTemplateStringE, +} from "@hoppscotch/data" +import * as A from "fp-ts/Array" +import * as E from "fp-ts/Either" +import { flow, pipe } from "fp-ts/function" +import * as O from "fp-ts/Option" +import * as RA from "fp-ts/ReadonlyArray" +import * as S from "fp-ts/string" +import qs from "qs" +import { combineLatest, Observable } from "rxjs" +import { map } from "rxjs/operators" + +import { generateAuthHeaders, generateAuthParams } from "../auth/auth-types" +import { stripComments } from "../editor/linting/jsonc" +import { arrayFlatMap, arraySort } from "../functional/array" +import { toFormData } from "../functional/formData" +import { tupleWithSameKeysToRecord } from "../functional/record" +import { isJSONContentType } from "./contenttypes" + +export interface EffectiveHoppRESTRequest extends HoppRESTRequest { + /** + * The effective final URL. + * + * This contains path, params and environment variables all applied to it + */ + effectiveFinalURL: string + effectiveFinalHeaders: HoppRESTHeaders + effectiveFinalParams: HoppRESTParams + effectiveFinalBody: FormData | string | null | File | Blob + effectiveFinalRequestVariables: { key: string; value: string }[] +} + +/** + * Get headers that can be generated by authorization config of the request + * @param req Request to check + * @param envVars Currently active environment variables + * @param auth Authorization config to check + * @param parse Whether to parse the template strings + * @param showKeyIfSecret Whether to show the key if the value is a secret + * @returns The list of headers + */ +export const getComputedAuthHeaders = async ( + envVars: Environment["variables"], + req?: + | HoppRESTRequest + | { + auth: HoppRESTAuth + headers: HoppRESTHeaders + }, + auth?: HoppRESTRequest["auth"], + showKeyIfSecret = false +) => { + const request = auth ? { auth: auth ?? { authActive: false } } : req + + if (!request || !request.auth || !request.auth.authActive) return [] + + return await generateAuthHeaders( + request.auth, + req as HoppRESTRequest, + envVars, + showKeyIfSecret + ) +} + +/** + * Get headers that can be generated by body config of the request + * @param req Request to check + * @returns The list of headers + */ +export const getComputedBodyHeaders = ( + req: + | HoppRESTRequest + | { + auth: HoppRESTAuth + headers: HoppRESTHeaders + } +): HoppRESTHeader[] => { + // If a content-type is already defined, that will override this + if ( + req.headers.find( + (req) => req.active && req.key.toLowerCase() === "content-type" + ) + ) + return [] + + if (!("body" in req)) return [] + + // Body should have a non-null content-type + if (!req.body || req.body.contentType === null) return [] + + if ( + req.body && + req.body.contentType === "application/octet-stream" && + req.body.body + ) { + const filename = req.body.body.name + const fileType = req.body.body.type + + const contentType = fileType ? fileType : "application/octet-stream" + + return [ + { + active: true, + key: "content-type", + value: contentType, + description: "", + }, + { + active: true, + key: "Content-Disposition", + value: `attachment; filename="${filename}"`, + description: "", + }, + ] + } + + return [ + { + active: true, + key: "content-type", + value: req.body.contentType, + description: "", + }, + ] +} + +export type ComputedHeader = { + source: "auth" | "body" + header: HoppRESTHeader +} + +/** + * Returns a list of headers that will be added during execution of the request + * For e.g, Authorization headers maybe added if an Auth Mode is defined on REST + * @param req The request to check + * @param envVars The environment variables active + * @param parse Whether to parse the template strings + * @param showKeyIfSecret Whether to show the key if the value is a secret + * @returns The headers that are generated along with the source of that header + */ +export const getComputedHeaders = async ( + req: + | HoppRESTRequest + | { + auth: HoppRESTAuth + headers: HoppRESTHeaders + }, + envVars: Environment["variables"], + showKeyIfSecret = false +): Promise => { + return [ + ...( + await getComputedAuthHeaders(envVars, req, undefined, showKeyIfSecret) + ).map((header) => ({ + source: "auth" as const, + header, + })), + ...getComputedBodyHeaders(req).map((header) => ({ + source: "body" as const, + header, + })), + ] +} + +export type ComputedParam = { + source: "auth" + param: HoppRESTParam +} + +/** + * Returns a list of params that will be added during execution of the request + * For e.g, Authorization params (like API-key) maybe added if an Auth Mode is defined on REST + * @param req The request to check + * @param envVars The environment variables active + * @returns The params that are generated along with the source of that header + */ +export const getComputedParams = async ( + req: HoppRESTRequest, + envVars: Environment["variables"] +): Promise => { + if (!req.auth || !req.auth.authActive) return [] + + const params = await generateAuthParams(req.auth, req, envVars) + return params.map((param) => ({ source: "auth" as const, param })) +} + +// Resolves environment variables in the body +export const resolvesEnvsInBody = ( + body: HoppRESTReqBody, + env: Environment +): HoppRESTReqBody => { + if (!body.contentType) return body + + if (body.contentType === "application/octet-stream") { + return body + } + + if (body.contentType === "multipart/form-data") { + if (!body.body) { + return { + contentType: null, + body: null, + } + } + + return { + contentType: "multipart/form-data", + body: body.body.map( + (entry) => + { + active: entry.active, + isFile: entry.isFile, + key: parseTemplateString(entry.key, env.variables, false, true), + value: entry.isFile + ? entry.value + : parseTemplateString(entry.value, env.variables, false, true), + contentType: entry.contentType, + } + ), + } + } + + let bodyContent = "" + + // WORKAROUND: body.body can be null when creating example responses programmatically + // (see PR #5652), despite the Zod schema requiring string with .catch(""). The ?? "" + // fallback guards against stripComments() crash when passed null/empty, since the + // underlying stripComments_("") from jsonc-parser returns null. This pattern is used + // consistently throughout this file (see also getFinalBodyFromRequest). + if (isJSONContentType(body.contentType)) + bodyContent = stripComments(body.body ?? "") + + if (body.contentType === "application/x-www-form-urlencoded") { + bodyContent = body.body + } + + return { + contentType: body.contentType, + body: parseTemplateString(bodyContent, env.variables, false, true), + } +} + +export function getFinalBodyFromRequest( + request: HoppRESTRequest, + envVariables: Environment["variables"], + showKeyIfSecret = false +): FormData | Blob | string | null { + if (request.body.contentType === null) return null + + if (request.body.contentType === "application/x-www-form-urlencoded") { + const parsedBodyRecord = pipe( + request.body.body ?? "", + parseRawKeyValueEntriesE, + E.map( + flow( + RA.toArray, + /** + * Filtering out empty keys and non-active pairs. + */ + A.filter(({ active, key }) => active && !S.isEmpty(key)), + + /** + * Mapping each key-value to template-string-parser with either on array, + * which will be resolved in further steps. + */ + A.map(({ key, value }) => [ + parseTemplateStringE(key, envVariables, false, showKeyIfSecret), + parseTemplateStringE(value, envVariables, false, showKeyIfSecret), + ]), + + /** + * Filtering and mapping only right-eithers for each key-value as [string, string]. + */ + A.filterMap(([key, value]) => + E.isRight(key) && E.isRight(value) + ? O.some([key.right, value.right] as [string, string]) + : O.none + ), + tupleWithSameKeysToRecord, + (obj) => qs.stringify(obj, { indices: false }) + ) + ) + ) + return E.isRight(parsedBodyRecord) ? parsedBodyRecord.right : null + } + + if (request.body.contentType === "multipart/form-data") { + return pipe( + request.body.body ?? [], + A.filter( + (x) => + x.key !== "" && + x.active && + (typeof x.value === "string" || + (x.value.length > 0 && x.value[0] instanceof File)) + ), // Remove empty keys and unsetted file + + // Sort files down + arraySort((a, b) => { + if (a.isFile) return 1 + if (b.isFile) return -1 + return 0 + }), + + // FormData allows only a single blob in an entry, + // we split array blobs into separate entries (FormData will then join them together during exec) + arrayFlatMap((x) => + x.isFile + ? (Array.isArray(x.value) ? x.value : [x.value]).map((v) => ({ + key: parseTemplateString(x.key, envVariables), + value: v as string | Blob, + contentType: x.contentType, + })) + : [ + { + key: parseTemplateString(x.key, envVariables), + value: parseTemplateString(x.value, envVariables), + contentType: x.contentType, + }, + ] + ), + toFormData + ) + } + + if (request.body.contentType === "application/octet-stream") { + return request.body.body + } + + let bodyContent = request.body.body ?? "" + + if (isJSONContentType(request.body.contentType)) + bodyContent = stripComments(request.body.body ?? "") + + // body can be null if the content-type is not set + return parseBodyEnvVariables(bodyContent, envVariables) +} + +/** + * Outputs an executable request format with environment variables applied + * + * @param request The request to source from + * @param environment The environment to apply + * @param showKeyIfSecret Whether to show the key if the value is a secret + * @param showKeyIfNotFound Whether to show the key if the value is not found + * + * @returns An object with extra fields defining a complete request + */ +export async function getEffectiveRESTRequest( + request: HoppRESTRequest, + environment: Environment, + showKeyIfSecret = false, + showKeyIfNotFound = false +): Promise { + const effectiveFinalHeaders = pipe( + ( + await getComputedHeaders(request, environment.variables, showKeyIfSecret) + ).map((h) => h.header), + A.concat(request.headers), + A.filter((x) => x.active && x.key !== ""), + A.map((x) => ({ + active: true, + key: parseTemplateString( + x.key, + environment.variables, + false, + showKeyIfSecret + ), + value: parseTemplateString( + x.value, + environment.variables, + false, + showKeyIfSecret + ), + description: x.description, + })) + ) + + const effectiveFinalParams = pipe( + (await getComputedParams(request, environment.variables)).map( + (p) => p.param + ), + A.concat(request.params), + A.filter((x) => x.active && x.key !== ""), + A.map((x) => ({ + active: true, + key: parseTemplateString( + x.key, + environment.variables, + false, + showKeyIfSecret + ), + value: parseTemplateString( + x.value, + environment.variables, + false, + showKeyIfSecret + ), + description: x.description, + })) + ) + + const effectiveFinalRequestVariables = pipe( + request.requestVariables, + A.filter((x) => x.active && x.key !== ""), + A.map((x) => ({ + active: true, + key: parseTemplateString(x.key, environment.variables), + value: parseTemplateString(x.value, environment.variables), + })) + ) + + const effectiveFinalBody = getFinalBodyFromRequest( + request, + environment.variables, + showKeyIfSecret + ) + + return { + ...request, + effectiveFinalURL: parseTemplateString( + request.endpoint, + environment.variables, + false, + showKeyIfSecret, + showKeyIfNotFound + ), + effectiveFinalHeaders, + effectiveFinalParams, + effectiveFinalBody, + effectiveFinalRequestVariables, + } +} + +/** + * Creates an Observable Stream that emits HoppRESTRequests whenever + * the input streams emit a value + * + * @param request$ The request stream containing request data + * @param environment$ The environment stream containing environment data to apply + * + * @returns Observable Stream for the Effective Request Object + */ +export function getEffectiveRESTRequestStream( + request$: Observable, + environment$: Observable +): Observable> { + return combineLatest([request$, environment$]).pipe( + map(async ([request, env]) => await getEffectiveRESTRequest(request, env)) + ) +} diff --git a/packages/hoppscotch-common/src/helpers/utils/JsonFormattedError.ts b/packages/hoppscotch-common/src/helpers/utils/JsonFormattedError.ts new file mode 100644 index 0000000..32e03d7 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/utils/JsonFormattedError.ts @@ -0,0 +1,5 @@ +export class JsonFormattedError extends Error { + constructor(jsonObject: any) { + super(JSON.stringify(jsonObject, null, 2)) + } +} diff --git a/packages/hoppscotch-common/src/helpers/utils/StreamUtils.ts b/packages/hoppscotch-common/src/helpers/utils/StreamUtils.ts new file mode 100644 index 0000000..342e9b4 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/utils/StreamUtils.ts @@ -0,0 +1,24 @@ +import { combineLatest, Observable } from "rxjs" +import { map } from "rxjs/operators" + +/** + * Constructs a stream of a object from a collection of other observables + * + * @param streamObj The object containing key of observables to assemble from + * + * @returns The constructed object observable + */ +export function constructFromStreams(streamObj: { + [key in keyof T]: Observable +}): Observable { + return combineLatest(Object.values>(streamObj)).pipe( + map((streams) => { + const keys = Object.keys(streamObj) as (keyof T)[] + + return keys.reduce( + (acc, s, i) => Object.assign(acc, { [s]: streams[i] }), + {} + ) as T + }) + ) +} diff --git a/packages/hoppscotch-common/src/helpers/utils/__tests__/b64.spec.js b/packages/hoppscotch-common/src/helpers/utils/__tests__/b64.spec.js new file mode 100644 index 0000000..b19d625 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/utils/__tests__/b64.spec.js @@ -0,0 +1,12 @@ +import { describe, expect, test } from "vitest" +import { decodeB64StringToArrayBuffer } from "../b64" + +describe("decodeB64StringToArrayBuffer", () => { + test("decodes content correctly", () => { + expect( + decodeB64StringToArrayBuffer("aG9wcHNjb3RjaCBpcyBhd2Vzb21lIQ==") + ).toEqual(Buffer.from("hoppscotch is awesome!", "utf-8").buffer) + }) + + // TODO : More tests for binary data ? +}) diff --git a/packages/hoppscotch-common/src/helpers/utils/__tests__/contenttypes.spec.js b/packages/hoppscotch-common/src/helpers/utils/__tests__/contenttypes.spec.js new file mode 100644 index 0000000..6d55632 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/utils/__tests__/contenttypes.spec.js @@ -0,0 +1,41 @@ +import { describe, expect, test } from "vitest" +import { isJSONContentType } from "../contenttypes" + +describe("isJSONContentType", () => { + test("returns true for JSON content types", () => { + expect(isJSONContentType("application/json")).toBe(true) + expect(isJSONContentType("application/vnd.api+json")).toBe(true) + expect(isJSONContentType("application/hal+json")).toBe(true) + expect(isJSONContentType("application/ld+json")).toBe(true) + }) + + test("returns true for JSON types with charset specified", () => { + expect(isJSONContentType("application/json; charset=utf-8")).toBe(true) + expect(isJSONContentType("application/vnd.api+json; charset=utf-8")).toBe( + true + ) + expect(isJSONContentType("application/hal+json; charset=utf-8")).toBe(true) + expect(isJSONContentType("application/ld+json; charset=utf-8")).toBe(true) + }) + + test("returns false for non-JSON content types", () => { + expect(isJSONContentType("application/xml")).toBe(false) + expect(isJSONContentType("text/html")).toBe(false) + expect(isJSONContentType("application/x-www-form-urlencoded")).toBe(false) + expect(isJSONContentType("foo/jsoninword")).toBe(false) + }) + + test("returns false for non-JSON content types with charset", () => { + expect(isJSONContentType("application/xml; charset=utf-8")).toBe(false) + expect(isJSONContentType("text/html; charset=utf-8")).toBe(false) + expect( + isJSONContentType("application/x-www-form-urlencoded; charset=utf-8") + ).toBe(false) + expect(isJSONContentType("foo/jsoninword; charset=utf-8")).toBe(false) + }) + + test("returns false for null/undefined", () => { + expect(isJSONContentType(null)).toBe(false) + expect(isJSONContentType(undefined)).toBe(false) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/utils/__tests__/debounce.spec.js b/packages/hoppscotch-common/src/helpers/utils/__tests__/debounce.spec.js new file mode 100644 index 0000000..0ef2ca7 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/utils/__tests__/debounce.spec.js @@ -0,0 +1,39 @@ +import { vi, describe, test, expect } from "vitest" +import debounce from "../debounce" + +describe("debounce", () => { + test("doesn't call function right after calling", () => { + const fn = vi.fn() + + const debFunc = debounce(fn, 100) + debFunc() + + expect(fn).not.toHaveBeenCalled() + }) + + test("calls the function after the given timeout", () => { + const fn = vi.fn() + + vi.useFakeTimers() + + const debFunc = debounce(fn, 100) + debFunc() + + vi.runAllTimers() + + expect(fn).toHaveBeenCalled() + // expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 100) + }) + + test("calls the function only one time within the timeframe", () => { + const fn = vi.fn() + + const debFunc = debounce(fn, 1000) + + for (let i = 0; i < 100; i++) debFunc() + + vi.runAllTimers() + + expect(fn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/utils/__tests__/dom.spec.js b/packages/hoppscotch-common/src/helpers/utils/__tests__/dom.spec.js new file mode 100644 index 0000000..8240a64 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/utils/__tests__/dom.spec.js @@ -0,0 +1,77 @@ +import { describe, expect, test } from "vitest" +import { isDOMElement, isTypableElement, isCodeMirrorEditor } from "../dom" + +describe("isDOMElement", () => { + test("returns true for valid HTMLElement", () => { + const div = document.createElement("div") + expect(isDOMElement(div)).toBe(true) + }) + + test("returns false for non-HTMLElement inputs", () => { + expect(isDOMElement(null)).toBe(false) + expect(isDOMElement(undefined)).toBe(false) + expect(isDOMElement("div")).toBe(false) + expect(isDOMElement(123)).toBe(false) + expect(isDOMElement({})).toBe(false) + }) +}) + +describe("isTypableElement", () => { + test("returns true for enabled ", () => { + const input = document.createElement("input") + expect(isTypableElement(input)).toBe(true) + }) + + test("returns false for disabled ", () => { + const input = document.createElement("input") + input.disabled = true + expect(isTypableElement(input)).toBe(false) + }) + + test("returns true for enabled + + + + + + +

+ + + + +
+
+

+ {{ t("settings.theme") }} +

+

+ {{ t("settings.theme_description") }} +

+
+
+
+

+ {{ t("settings.background") }} +

+
+ {{ t(getColorModeName(colorMode.preference)) }} + + ({{ t(getColorModeName(colorMode.value)) }}) + +
+
+ +
+
+
+

+ {{ t("settings.accent_color") }} +

+
+ {{ ACCENT_COLOR.charAt(0).toUpperCase() + ACCENT_COLOR.slice(1) }} +
+
+ +
+
+
+
+ +
+
+

+ {{ t("settings.kernel_interceptor") }} +

+

+ {{ t("settings.kernel_interceptor_description") }} +

+
+
+
+

+ {{ t("settings.kernel_interceptor") }} +

+ +
+
+

+ {{ settings.title(t) }} +

+ +
+
+
+ + + + + + + + diff --git a/packages/hoppscotch-common/src/pages/view/_id/_version.vue b/packages/hoppscotch-common/src/pages/view/_id/_version.vue new file mode 100644 index 0000000..ea93548 --- /dev/null +++ b/packages/hoppscotch-common/src/pages/view/_id/_version.vue @@ -0,0 +1,378 @@ + + + + + +meta: + layout: empty + diff --git a/packages/hoppscotch-common/src/platform/additionalLinks.ts b/packages/hoppscotch-common/src/platform/additionalLinks.ts new file mode 100644 index 0000000..38ba4e6 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/additionalLinks.ts @@ -0,0 +1,8 @@ +import { Container, ServiceClassInstance } from "dioc" +import { AdditionalLinksService } from "~/services/additionalLinks.service" + +export type AdditionalLinksPlatformDef = Array< + ServiceClassInstance & { + new (c: Container): AdditionalLinksService + } +> diff --git a/packages/hoppscotch-common/src/platform/analytics.ts b/packages/hoppscotch-common/src/platform/analytics.ts new file mode 100644 index 0000000..96d5fb7 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/analytics.ts @@ -0,0 +1,75 @@ +export type HoppRequestEvent = + | { + platform: "rest" + strategy: string + workspaceType: "personal" | "team" + } + | { + platform: "graphql-query" | "graphql-schema" + strategy: string + } + | { platform: "wss" | "sse" | "socketio" | "mqtt" } + +export type HoppSpotlightSessionEventData = { + action?: "success" | "close" + inputLength?: number + method?: "keyboard-shortcut" | "click-spotlight-bar" + rank?: string | null + searcherID?: string | null + sessionDuration?: string +} + +export type AnalyticsEvent = + | ({ type: "HOPP_REQUEST_RUN" } & HoppRequestEvent) + | { + type: "HOPP_CREATE_ENVIRONMENT" + workspaceType: "personal" | "team" + } + | { + type: "HOPP_CREATE_COLLECTION" + platform: "rest" | "gql" + isRootCollection: boolean + workspaceType: "personal" | "team" + } + | { type: "HOPP_CREATE_TEAM" } + | { + type: "HOPP_SAVE_REQUEST" + createdNow: boolean + workspaceType: "personal" | "team" + platform: "rest" | "gql" + } + | { type: "HOPP_SHORTCODE_CREATED" } + | { type: "HOPP_SHORTCODE_RESOLVED" } + | { type: "HOPP_REST_NEW_TAB_OPENED" } + | { + type: "HOPP_IMPORT_COLLECTION" + importer: string + workspaceType: "personal" | "team" + platform: "rest" | "gql" + } + | { + type: "HOPP_IMPORT_ENVIRONMENT" + workspaceType: "personal" | "team" + platform: "rest" | "gql" + } + | { + type: "HOPP_EXPORT_COLLECTION" + exporter: string + platform: "rest" | "gql" + } + | { type: "HOPP_EXPORT_ENVIRONMENT"; platform: "rest" | "gql" } + | { type: "HOPP_REST_CODEGEN_OPENED" } + | { type: "HOPP_REST_IMPORT_CURL" } + | ({ + type: "HOPP_SPOTLIGHT_SESSION" + } & HoppSpotlightSessionEventData) + | { + type: "EXPERIMENTS_GENERATE_REQUEST_NAME_WITH_AI" + platform: "rest" | "gql" + } + +export type AnalyticsPlatformDef = { + initAnalytics: () => void + logEvent: (ev: AnalyticsEvent) => void + logPageView: (pagePath: string) => void +} diff --git a/packages/hoppscotch-common/src/platform/auth.ts b/packages/hoppscotch-common/src/platform/auth.ts new file mode 100644 index 0000000..2fc9974 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/auth.ts @@ -0,0 +1,302 @@ +import { ClientOptions } from "@urql/core" +import { Observable } from "rxjs" +import { Component } from "vue" +import { getI18n } from "~/modules/i18n" +import * as E from "fp-ts/Either" +import { AxiosRequestConfig } from "axios" +import { GQLError } from "~/helpers/backend/GQLClient" + +/** + * A common (and required) set of fields that describe a user. + */ +export type HoppUser = { + /** A unique ID identifying the user */ + uid: string + + /** The name to be displayed as the user's */ + displayName: string | null + + /** The user's email address */ + email: string | null + + /** URL to the profile picture of the user */ + photoURL: string | null + + // Regarding `provider` and `accessToken`: + // The current implementation and use case for these 2 fields are super weird due to legacy. + // Currently these fields are only basically populated for Github Auth as we need the access token issued + // by it to implement Gist submission. I would really love refactor to make this thing more sane. + + /** Name of the provider authenticating (NOTE: See notes on `platform/auth.ts`) */ + provider?: string + /** Access Token for the auth of the user against the given `provider`. */ + accessToken?: string + emailVerified: boolean +} + +export type AuthEvent = + | { event: "probable_login"; user: HoppUser } // We have previous login state, but the app is waiting for authentication + | { event: "login"; user: HoppUser } // We are authenticated + | { event: "logout" } // No authentication and we have no previous state + | { event: "token_refresh"; user: HoppUser } // We have refreshed our tokens and have new ones now + +export type GithubSignInResult = + | { type: "success"; user: HoppUser } // The authentication was a success + | { type: "account-exists-with-different-cred"; link: () => Promise } // We authenticated correctly, but the provider didn't match, so we give the user the opportunity to link to continue completing auth + | { type: "error"; err: unknown } // Auth failed completely and we don't know why + +export type SetEmailAddressResult = + | { type: "success" } // The email address was set successfully + | { type: "email-already-in-use" } // The email address is already in use by another account + | { type: "requires-recent-login"; link: () => Promise } // The user needs to re-authenticate to set the email address + | { type: "no-user-logged-in" } // No user is currently logged in, so we can't set the email address + | { type: "error"; err: unknown } // An error occurred while setting the email address + +export type LoginItemDef = { + id: string + icon: Component + text: (t: ReturnType) => string + onClick: () => Promise | void +} + +export type AuthPlatformDef = { + /** + * Whether this platform shows a custom login selector UI. Used for situations + * where we don't want to render the traditional UI and want to replace it + * with something else + */ + customLoginSelectorUI?: Component + + /** + * Returns an observable that emits the current user as per the auth implementation. + * + * NOTES: + * 1. Make sure to emit non-null values once you have credentials to perform backend operations. (Get required tokens ?) + * 2. It is best to let the stream emit a value immediately on subscription (we can do that by basing this on a BehaviourSubject) + * + * @returns An observable which returns a `HoppUser` or null if not logged in (or login not completed) + */ + getCurrentUserStream: () => Observable + + /** + * Returns a stream to events happening in the auth mechanism. Common uses these events to + * let subsystems know something is changed by the authentication status and to react accordingly + * + * @returns An observable which emits an AuthEvent over time + */ + getAuthEventsStream: () => Observable + + /** + * Similar to `getCurrentUserStream` but deals with the authentication being `probable`. + * Probable User for states where, "We haven't authed yet but we are guessing this person will auth eventually". + * This allows for things like Header component to presumpt a state until we auth properly and avoid flashing a "logged out" state. + * + * NOTES: + * 1. It is best to let the stream emit a value immediately on subscription (we can do that by basing this on a BehaviourSubject) + * 2. Once the authentication is confirmed, this stream should emit the same values as `getCurrentUserStream`. + * + * @returns An obsverable which returns a `HoppUser` for the probable user (or confirmed user if authed) or null if we don't know about a probable user + */ + getProbableUserStream: () => Observable + + /** + * Returns the currently authed user. (Similar rules apply as `getCurrentUserStream`) + * @returns The authenticated user or null if not logged in + */ + getCurrentUser: () => HoppUser | null + + /** + * Returns the most probable to complete auth user. (Similar rules apply as `getProbableUserStream`) + * @returns The probable user or null if have no idea who will auth in + */ + getProbableUser: () => HoppUser | null + + /** + * [This is only for Common Init logic to call!] + * Called by Common when it is time to perform initialization activities for authentication. + * (This is the best place to do init work for the auth subsystem in the platform). + */ + performAuthInit: () => Promise + + /** + * Returns the headers that should be applied by the backend GQL API client (see GQLClient) + * inorder to talk to the backend (like apply auth headers ?) + * @returns An object with the header key and header values as strings + */ + getBackendHeaders: () => Record + + /** + * Resolves once any org-scoped context required by `getBackendHeaders` (e.g. + * the `x-organization-id` header) has settled. On org subdomains this waits + * for the org info lookup to succeed or fail before letting backend calls + * proceed, so requests don't go out with a missing org id on reload. + * + * Returns immediately when not in an org context or when the info is already + * determined. May be absent on platforms that don't scope backend calls by org. + */ + waitOrganizationInfoReady?: () => Promise + + /** + * Called when the backend GQL API client encounters an auth error to check if with the + * current state, if an auth error is possible. This lets the backend GQL client know that + * it can expect an auth error and we should wait and (possibly retry) to re-execute an operation. + * This is useful for cases where queries might fail as the tokens just expired and need to be refreshed, + * so the app can get the new token and GQL client knows to re-execute the same query. + + * @returns Whether an error is expected or not + */ + willBackendHaveAuthError: () => boolean + + /** + * Used to register a callback where the backend GQL client should reconnect/reconfigure subscriptions + * as some communication parameter changed over time. Like for example, the backend subscription system + * on a id token based mechanism should be let known that the id token has changed and reconnect the subscription + * connection with the updated params. + * @param func The callback function to call + */ + onBackendGQLClientShouldReconnect: (func: () => void) => void + + /** + * Called by the platform to provide additional/different config options when + * setting up the URQL based GQLCLient instance + * @returns + */ + getGQLClientOptions?: () => Partial + + /** + * called by the platform to provide additional/different config options when + * sending requests with axios + * eg: SH needs to include cookies in the request, while Central doesn't and throws a cors error if it does + * Ensure to invoke `platform.auth.waitProbableLoginToConfirm()` before accessing + * + * @returns AxiosRequestConfig + */ + axiosPlatformConfig?: () => AxiosRequestConfig + + /** + * Returns the string content that should be returned when the user selects to + * copy auth token from Developer Options. + * + * @returns The auth token (or equivalent) as a string if we have one to give, else null + */ + getDevOptsBackendIDToken: () => string | null + + /** + * Returns an empty promise that only resolves when the current probable user because confirmed. + * + * Note: + * 1. Make sure there is a probable user before waiting, as if not, this function will throw + * 2. If the probable user is already confirmed, this function will return an immediately resolved promise + */ + waitProbableLoginToConfirm: () => Promise + + /** + * Called to sign in user with email (magic link). This should send backend calls to send the auth email. + * @param email The email that is logging in. + * @returns An empty promise that is resolved when the operation is complete + */ + signInWithEmail: (email: string) => Promise + + /** + * Check whether a given link is a valid sign in with email, magic link response url. + * (i.e, a URL that COULD be from a magic link email) + * @param url The url to check + * @returns Whether this is valid or not (NOTE: This is just a structural check not whether this is accepted (hence, not async)) + */ + isSignInWithEmailLink: (url: string) => boolean + + /** + * Function that validates the magic link redirect and signs in the user + * + * @param email - Email to log in to + * @param url - The action URL which is used to validate login + * @returns A promise that resolves with the user info when auth is completed + */ + signInWithEmailLink: (email: string, url: string) => Promise + + /** + * function that validates the magic link & signs the user in + */ + processMagicLink: () => Promise + + /** + * Sends email verification email (the checkmark besides the email) + * @returns When the check has succeed and completed + */ + verifyEmailAddress: () => Promise + + /** + * Signs user in with Google. + * @returns A promise that resolves with the user info when auth is completed + */ + signInUserWithGoogle: () => Promise + /** + * Signs user in with Github. + * @returns A promise that resolves with the auth status, giving an opportunity to link if or handle failures + */ + signInUserWithGithub: () => Promise | Promise + /** + * Signs user in with Microsoft. + * @returns A promise that resolves with the user info when auth is completed + */ + signInUserWithMicrosoft: () => Promise + + /** + * Signs out the user from auth + * @returns An empty promise that is resolved when the operation is complete + */ + signOutUser: () => Promise + + /** + * Updates the email address of the user + * + * NOTES: + * 1. This will return an error if the email is already in use by another account + * 2. This will return an error if the user needs to re-authenticate + * 3. This will return undefined if no user is logged in, so check for that before calling this + * + * @param email The new email to set this to. + * @returns A promise that resolves with the email update status when the operation is complete + */ + setEmailAddress: ( + email: string + ) => Promise | Promise + + /** + * Updates the display name of the user + * @param name The new name to set this to. + * @returns A promise that resolves with the display name update status when the operation is complete + */ + setDisplayName: ( + name: string + ) => Promise, undefined>> + + /** + * Returns the list of allowed auth providers for the platform ( the currently supported ones are GOOGLE, GITHUB, EMAIL, MICROSOFT, SAML ) + */ + getAllowedAuthProviders: () => Promise> + + /** + * Defines the additional login items that should be shown in the login screen + */ + additionalLoginItems?: LoginItemDef[] + + /** + * Whether the email address is editable by the user or not. + * This is used to determine whether the email address field should disabled in the user settings. + * If a value is not given, then the value is assumed to be false. + */ + isEmailEditable?: boolean + + /** Verifies if the current user's authentication tokens are valid + * For self-hosted, this should verify the tokens with the backend + * @returns True if tokens are valid, false otherwise + */ + verifyAuthTokens?: () => Promise + + /** Refreshes the authentication tokens for the current user + * For self-hosted, this should refresh the tokens with the backend + * @returns True if tokens were refreshed successfully, false otherwise + */ + refreshAuthToken?: () => Promise +} diff --git a/packages/hoppscotch-common/src/platform/backend.ts b/packages/hoppscotch-common/src/platform/backend.ts new file mode 100644 index 0000000..bdbf011 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/backend.ts @@ -0,0 +1,162 @@ +import { HoppRESTRequest } from "@hoppscotch/data" +import * as TE from "fp-ts/TaskEither" +import * as E from "fp-ts/lib/Either" + +import { GQLError } from "~/helpers/backend/GQLClient" +import { + AcceptTeamInvitationMutation, + CreatePublishedDocMutation, + CreatePublishedDocsArgs, + CreateShortcodeMutation, + CreateTeamInvitationMutation, + CreateTeamMutation, + DeletePublishedDocMutation, + GetInviteDetailsQuery, + GetInviteDetailsQueryVariables, + GetMockServerLogsQuery, + GetMyTeamsQuery, + PublishedDocQuery, + GetUserShortcodesQuery, + TeamAccessRole, + TeamPublishedDocsListQuery, + UpdatePublishedDocMutation, + UpdatePublishedDocsArgs, + UserPublishedDocsListQuery, + WorkspaceType, +} from "~/helpers/backend/graphql" + +import { useGQLQuery } from "~/composables/graphql" +import { Email } from "~/helpers/backend/types/Email" +import type { MockServer } from "~/helpers/backend/types/MockServer" +import { TeamName } from "~/helpers/backend/types/TeamName" + +export type BackendPlatformDef = { + // Read actions via GQL queries + getInviteDetails: ( + inviteID: string + ) => ReturnType< + typeof useGQLQuery< + GetInviteDetailsQuery, + GetInviteDetailsQueryVariables, + GetInviteDetailsError + > + > + + getUserShortcodes: ( + cursor?: string + ) => Promise, GetUserShortcodesQuery>> + + // Sample use case for `matchAllTeams` would be at the cloud platform level where the list of teams across instances is fetched + // and not limited to a single instance. + getUserTeams: ( + cursor?: string, + matchAllTeams?: boolean + ) => Promise, GetMyTeamsQuery>> + + // Write actions via GQL mutations + createTeam: ( + name: TeamName + ) => TE.TaskEither, CreateTeamMutation> + + createTeamInvitation: ( + inviteeEmail: Email, + inviteeRole: TeamAccessRole, + teamID: string + ) => TE.TaskEither< + GQLError, + CreateTeamInvitationMutation + > + + acceptTeamInvitation: ( + inviteID: string + ) => TE.TaskEither< + GQLError, + AcceptTeamInvitationMutation + > + + createShortcode: ( + request: HoppRESTRequest, + properties?: string + ) => TE.TaskEither, CreateShortcodeMutation> + + // Mock server operations + createMockServer: ( + name: string, + workspaceType?: WorkspaceType, + workspaceID?: string, + delayInMs?: number, + isPublic?: boolean, + collectionID?: string, + autoCreateCollection?: boolean, + autoCreateRequestExample?: boolean + ) => TE.TaskEither + + updateMockServer: ( + id: string, + input: { + name?: string + isActive?: boolean + delayInMs?: number + isPublic?: boolean + } + ) => TE.TaskEither + + deleteMockServer: (id: string) => TE.TaskEither + + getMockServer: (id: string) => TE.TaskEither + + getMyMockServers: ( + skip?: number, + take?: number + ) => TE.TaskEither + + getTeamMockServers: ( + teamID: string, + skip?: number, + take?: number + ) => TE.TaskEither + + getMockServerLogs: ( + mockServerID: string, + skip?: number, + take?: number + ) => TE.TaskEither + + deleteMockServerLog: (logID: string) => TE.TaskEither + + // Published docs operations + createPublishedDoc: ( + doc: CreatePublishedDocsArgs + ) => TE.TaskEither, CreatePublishedDocMutation> + + updatePublishedDoc: ( + id: string, + doc: UpdatePublishedDocsArgs + ) => TE.TaskEither, UpdatePublishedDocMutation> + + deletePublishedDoc: ( + id: string + ) => TE.TaskEither, DeletePublishedDocMutation> + + getPublishedDocByID: ( + id: string + ) => TE.TaskEither + + getUserPublishedDocs: ( + skip?: number, + take?: number + ) => TE.TaskEither< + string, + UserPublishedDocsListQuery["userPublishedDocsList"] + > + + getTeamPublishedDocs: ( + teamID: string, + collectionID?: string, + skip?: number, + take?: number + ) => TE.TaskEither< + string, + TeamPublishedDocsListQuery["teamPublishedDocsList"] + > +} diff --git a/packages/hoppscotch-common/src/platform/collections.ts b/packages/hoppscotch-common/src/platform/collections.ts new file mode 100644 index 0000000..118c40a --- /dev/null +++ b/packages/hoppscotch-common/src/platform/collections.ts @@ -0,0 +1,12 @@ +import { HoppCollection } from "@hoppscotch/data" +import { ReqType } from "~/helpers/backend/graphql" +import * as E from "fp-ts/Either" + +export type CollectionsPlatformDef = { + initCollectionsSync: () => void + loadUserCollections?: (collectionType: "REST" | "GQL") => Promise + importToPersonalWorkspace?: ( + collections: HoppCollection[], + reqType: ReqType + ) => Promise> +} diff --git a/packages/hoppscotch-common/src/platform/desktop-settings.ts b/packages/hoppscotch-common/src/platform/desktop-settings.ts new file mode 100644 index 0000000..cdde5ab --- /dev/null +++ b/packages/hoppscotch-common/src/platform/desktop-settings.ts @@ -0,0 +1,84 @@ +import { z } from "zod" + +/** + * Shared schema and types for the Tauri desktop app's user settings. + * + * The settings live in `tauri-plugin-store` under namespace + * `DESKTOP_SETTINGS_STORE_NAMESPACE`, key `DESKTOP_SETTINGS_STORE_KEY`. + * Both the Tauri shell (`hoppscotch-desktop`) and the webview + * (`hoppscotch-selfhost-web`, loaded via appload) read and write + * through the same namespace and key. + * + * The schema is the contract between the two sides. A change here + * without coordinating both sides leaves the shell and the webview + * disagreeing about what is in the store. This module lives in + * `hoppscotch-common` because both packages already depend on common + * and neither depends on the other directly, so common is the only + * place a shared definition can live. + * + * Every field has a Zod `.default()` that preserves the pre-settings-epic + * behavior, so a partial read (missing keys, corrupt blob, fresh install) + * parses cleanly into a fully-populated object. Use `parseDesktopSettings()` + * to read raw values safely. + */ + +// Store coordinates. Both sides must use these constants, never string +// literals, so a rename is a single edit and a typo is a compile error. +export const DESKTOP_SETTINGS_STORE_NAMESPACE = "hoppscotch-desktop.v1" +export const DESKTOP_SETTINGS_STORE_KEY = "desktopSettings" + +// Fields are grouped by the area of the app they affect. Defaults +// preserve today's hardcoded behavior so any field not yet bound to a +// control in the settings UI ships without a visible change for existing +// users. +export const DESKTOP_SETTINGS_SCHEMA = z.object({ + // Migrated from the legacy portable-only `PortableSettings`. A future + // epic ticket promotes `disableUpdateNotifications` to a user-facing + // control on all builds. For now it stays portable-only. + disableUpdateNotifications: z.boolean().default(false), + autoSkipWelcome: z.boolean().default(false), + + // Connection and startup behavior. The `connectionTimeoutMs` default + // matches `API_TIMEOUT_SECS` in `config.rs`. User-facing controls for + // these fields are future scope. + connectionTimeoutMs: z.number().int().positive().default(30_000), + autoReconnectLastInstance: z.boolean().default(true), + + // Update-pipeline controls. `disable*` polarity matches the existing + // `disableUpdateNotifications` field so all three update-related + // booleans read uniformly, and the on-by-default framing ("Disable X" + // with default false) nudges users toward keeping the update flow + // active. `disableUpdateChecks` is bound to a toggle in the current + // settings UI. `disableUpdateDownloads` is future scope. + disableUpdateChecks: z.boolean().default(false), + disableUpdateDownloads: z.boolean().default(false), + + // Display and UX. The bounds match Tauri v2's `setZoom` accepted + // range (0.2 to 10.0). A value outside that range would either be + // silently rejected by the WebView or trigger engine-level layout + // breakage, so the schema gate makes a corrupted store fall back + // to the default rather than reaching the runtime apply. + zoomLevel: z.number().min(0.2).max(10.0).default(1.0), + + // Keyboard shortcut dispatch strategy. The hybrid strategy prefers + // event.key when it produces a Latin glyph (so AZERTY's "A" key fires + // Ctrl+A regardless of physical position) and falls back to event.code + // for non-Latin layouts (Cyrillic, CJK). The `key` and `code` strategies + // are escape valves for users on layouts where the hybrid heuristic + // guesses wrong. + keyboardLayoutStrategy: z.enum(["key", "code", "hybrid"]).default("hybrid"), +}) + +export type DesktopSettings = z.infer + +/** + * Parses a raw value into `DesktopSettings`, falling back to full defaults + * on any validation failure. Never throws. + */ +export const parseDesktopSettings = (raw: unknown): DesktopSettings => { + const parsed = DESKTOP_SETTINGS_SCHEMA.safeParse(raw ?? {}) + if (!parsed.success) { + return DESKTOP_SETTINGS_SCHEMA.parse({}) + } + return parsed.data +} diff --git a/packages/hoppscotch-common/src/platform/environments.ts b/packages/hoppscotch-common/src/platform/environments.ts new file mode 100644 index 0000000..6ba4f0f --- /dev/null +++ b/packages/hoppscotch-common/src/platform/environments.ts @@ -0,0 +1,3 @@ +export type EnvironmentsPlatformDef = { + initEnvironmentsSync: () => void +} diff --git a/packages/hoppscotch-common/src/platform/experiments.ts b/packages/hoppscotch-common/src/platform/experiments.ts new file mode 100644 index 0000000..d9535d1 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/experiments.ts @@ -0,0 +1,47 @@ +import * as E from "fp-ts/Either" + +export type ExperimentsPlatformDef = { + aiExperiments?: { + enableAIExperiments: boolean + generateRequestName?: ( + requestInfo: string, + namingStyle: string + ) => Promise< + E.Either< + string, + { + request_name: string + trace_id: string + } + > + > + modifyRequestBody?: ( + requestBody: string, + userPrompt: string + ) => Promise< + E.Either< + string, + { + modified_body: string + trace_id: string + } + > + > + submitFeedback?: ( + rating: -1 | 1, + traceID: string + ) => Promise> + modifyPreRequestScript?: ( + requestInfo: string, + userPrompt: string + ) => Promise< + E.Either + > + modifyTestScript?: ( + requestInfo: string, + userPrompt: string + ) => Promise< + E.Either + > + } +} diff --git a/packages/hoppscotch-common/src/platform/history.ts b/packages/hoppscotch-common/src/platform/history.ts new file mode 100644 index 0000000..08864b3 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/history.ts @@ -0,0 +1,10 @@ +import { Ref } from "vue" + +export type HistoryPlatformDef = { + initHistorySync: () => void + requestHistoryStore: { + isHistoryStoreEnabled: Ref + isFetchingHistoryStoreStatus: Ref + hasErrorFetchingHistoryStoreStatus: Ref + } +} diff --git a/packages/hoppscotch-common/src/platform/index.ts b/packages/hoppscotch-common/src/platform/index.ts new file mode 100644 index 0000000..d1ba84a --- /dev/null +++ b/packages/hoppscotch-common/src/platform/index.ts @@ -0,0 +1,76 @@ +import { ServiceClassInstance } from "dioc" +import { Ref } from "vue" +import { HoppModule } from "~/modules" +import { AnalyticsPlatformDef } from "./analytics" +import { AuthPlatformDef } from "./auth" +import { ExperimentsPlatformDef } from "./experiments" +import { InfraPlatformDef } from "./infra" +import { InspectorsPlatformDef } from "./inspectors" +import { KernelInterceptorsPlatformDef } from "./kernel-interceptors" +import { LimitsPlatformDef } from "./limits" +import { SpotlightPlatformDef } from "./spotlight" +import { UIPlatformDef } from "./ui" +import { BackendPlatformDef } from "./backend" +import { OrganizationPlatformDef } from "./organization" +import { KernelIO } from "./kernel-io" +import { AdditionalLinksPlatformDef } from "./additionalLinks" +import { InstancePlatformDef } from "./instance" +import { SyncPlatformDef } from "./sync" + +export type PlatformDef = { + ui?: UIPlatformDef + addedHoppModules?: HoppModule[] + addedServices?: Array> + auth: AuthPlatformDef + analytics?: AnalyticsPlatformDef + kernelIO: KernelIO + instance: InstancePlatformDef + kernelInterceptors: KernelInterceptorsPlatformDef + additionalInspectors?: InspectorsPlatformDef + spotlight?: SpotlightPlatformDef + platformFeatureFlags: { + exportAsGIST: boolean + hasTelemetry: boolean + + /** + * Whether the platform supports cookies (affects whether the cookies footer item is shown) + * If a value is not given, then the value is assumed to be false + */ + cookiesEnabled?: boolean + + /** + * Whether the platform should prompt the user that cookies are being used. + * This will result in the user being notified a cookies advisory and is meant for web apps. + * + * If a value is not given, then the value is assumed to be true + */ + promptAsUsingCookies?: boolean + + /** + * Whether to show the A/B testing workspace switcher click login flow or not + */ + workspaceSwitcherLogin?: Ref + + /** + * Whether the platform uses cookie-based authentication. + * This affects CSRF security warnings for same-origin fetch calls in scripts. + * Self-hosted web instances use cookies, while cloud/desktop use bearer tokens. + * + * If not provided, defaults to false (no cookie-based auth). + */ + hasCookieBasedAuth?: boolean + } + limits?: LimitsPlatformDef + infra?: InfraPlatformDef + experiments?: ExperimentsPlatformDef + backend: BackendPlatformDef + organization?: OrganizationPlatformDef + additionalLinks?: AdditionalLinksPlatformDef + sync?: SyncPlatformDef +} + +export let platform: PlatformDef + +export function setPlatformDef(def: PlatformDef) { + platform = def +} diff --git a/packages/hoppscotch-common/src/platform/infra.ts b/packages/hoppscotch-common/src/platform/infra.ts new file mode 100644 index 0000000..d9591e2 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/infra.ts @@ -0,0 +1,11 @@ +import * as E from "fp-ts/Either" + +type ProxyAppUrl = { + value: string + name: string +} + +export type InfraPlatformDef = { + getIsSMTPEnabled?: () => Promise> + getProxyAppUrl?: () => Promise> +} diff --git a/packages/hoppscotch-common/src/platform/inspectors.ts b/packages/hoppscotch-common/src/platform/inspectors.ts new file mode 100644 index 0000000..cf392b1 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/inspectors.ts @@ -0,0 +1,17 @@ +import { Container, ServiceClassInstance } from "dioc" +import { Inspector } from "~/services/inspection" + +/** + * Defines an added interceptor by the platform + */ +export type PlatformInspectorsDef = { + // We are keeping this as the only mode for now + // So that if we choose to add other modes, we can do without breaking + type: "service" + // TODO: I don't think this type is effective, we have to come up with a better impl + service: ServiceClassInstance & { + new (c: Container): Inspector + } +} + +export type InspectorsPlatformDef = PlatformInspectorsDef[] diff --git a/packages/hoppscotch-common/src/platform/instance.ts b/packages/hoppscotch-common/src/platform/instance.ts new file mode 100644 index 0000000..dbcdacf --- /dev/null +++ b/packages/hoppscotch-common/src/platform/instance.ts @@ -0,0 +1,222 @@ +import { Observable } from "rxjs" +import { Component } from "vue" + +import { type LoadOptions } from "@hoppscotch/plugin-appload" + +export type InstanceKind = "on-prem" | "cloud" | "cloud-org" | "vendored" + +export type Instance = { + kind: InstanceKind + serverUrl: string + displayName: string + version: string + lastUsed: string + bundleName?: string +} + +export const VENDORED_INSTANCE_CONFIG: Instance = { + kind: "vendored" as const, + serverUrl: "app://hoppscotch", + displayName: "Hoppscotch Desktop", + version: "26.6.0", + lastUsed: new Date().toISOString(), + bundleName: "Hoppscotch", +} + +export type ConnectionState = + | { status: "idle" } + | { status: "connecting"; target: string } + | { status: "connected"; instance: Instance } + | { status: "error"; target: string; message: string } + +export type OperationResult = { + success: boolean + message: string + data?: any +} + +export type InstancePlatformDef = { + /** + * Whether instance switching is enabled for this platform + */ + instanceSwitchingEnabled: boolean + + /** + * Custom instance switcher component to replace the default UI + */ + customInstanceSwitcherComponent?: Component + + /** + * Returns an observable stream of the current connection state + */ + getConnectionStateStream?: () => Observable + + /** + * Returns an observable stream of recent instances + */ + getRecentInstancesStream?: () => Observable + + /** + * Returns an observable stream of the current active instance + */ + getCurrentInstanceStream?: () => Observable + + /** + * Gets the current connection state synchronously + */ + getCurrentConnectionState?: () => ConnectionState + + /** + * Gets the list of recent instances synchronously + */ + getRecentInstances?: () => Instance[] + + /** + * Gets the current active instance synchronously + */ + getCurrentInstance?: () => Instance | null + + /** + * Connects to an instance with the given options + */ + connectToInstance?: ( + serverUrl: string, + instanceKind: InstanceKind, + displayName?: string, + options?: Partial + ) => Promise + + /** + * Downloads an instance bundle without connecting + */ + downloadInstance?: ( + serverUrl: string, + instanceKind: InstanceKind, + displayName?: string + ) => Promise + + /** + * Loads a previously downloaded instance + */ + loadInstance?: ( + instance: Instance, + options?: Partial + ) => Promise + + /** + * Removes an instance and its associated data + */ + removeInstance?: (instance: Instance) => Promise + + /** + * Disconnects from the current instance + */ + disconnect?: () => Promise + + /** + * Clears all cached instances and data + */ + clearCache?: () => Promise + + /** + * Validates and normalizes a server URL + */ + normalizeUrl?: (url: string) => string | null + + /** + * Hook called before connecting to an instance + * Return false to prevent the connection + */ + beforeConnect?: ( + serverUrl: string, + instanceKind: InstanceKind, + displayName?: string + ) => Promise + + /** + * Hook called after successful connection + */ + afterConnect?: (instance: Instance) => Promise + + /** + * Hook called before disconnecting from an instance + * Return false to prevent the disconnection + */ + beforeDisconnect?: (instance: Instance) => Promise + + /** + * Hook called after successful disconnection + */ + afterDisconnect?: () => Promise + + /** + * Hook called before removing an instance + * Return false to prevent the removal + */ + beforeRemove?: (instance: Instance) => Promise + + /** + * Hook called after successful instance removal + */ + afterRemove?: (instance: Instance) => Promise + + /** + * Custom error handling for connection failures + */ + onConnectionError?: (error: string, target: string) => void + + /** + * Custom error handling for download failures + */ + onDownloadError?: (error: string, serverUrl: string) => void + + /** + * Custom error handling for load failures + */ + onLoadError?: (error: string, instance: Instance) => void + + /** + * Custom error handling for removal failures + */ + onRemoveError?: (error: string, instance: Instance) => void + + /** + * Validates if a server URL is reachable and compatible + */ + validateServerUrl?: (url: string) => Promise<{ + valid: boolean + version?: string + instanceKind?: InstanceKind + error?: string + }> + + /** + * Configuration options for instance management + */ + config?: { + /** + * Maximum number of recent instances to keep + */ + maxRecentInstances?: number + + /** + * Default window options for loaded instances + */ + defaultWindowOptions?: LoadOptions["window"] + + /** + * Whether to automatically reconnect to the last instance on startup + */ + autoReconnect?: boolean + + /** + * Timeout for connection attempts in milliseconds + */ + connectionTimeout?: number + + /** + * Whether to show confirmation dialogs for destructive operations + */ + confirmDestructiveOperations?: boolean + } +} diff --git a/packages/hoppscotch-common/src/platform/io.ts b/packages/hoppscotch-common/src/platform/io.ts new file mode 100644 index 0000000..f93cfb2 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/io.ts @@ -0,0 +1,91 @@ +/** + * Defines how to save a file to the user's filesystem. + */ +export type SaveFileWithDialogOptions = { + /** + * The data to be saved + */ + data: string | ArrayBuffer + + /** + * The suggested filename for the file. This name will be shown in the + * save dialog by default when a save is initiated. + */ + suggestedFilename: string + + /** + * The content type mime type of the data to be saved. + * + * NOTE: The usage of this data might be platform dependent. + * For example, this field is used in the web, but not in the desktop app. + */ + contentType: string + + /** + * Defines the filters (like in Windows, on the right side, where you can + * select the file type) for the file dialog. + * + * NOTE: The usage of this data might be platform dependent. + * For example, this field is used in the web, but not in the desktop app. + */ + filters?: Array<{ + /** + * The name of the filter (in Windows, if the filter looks + * like "Images (*.png, *.jpg)", the name would be "Images") + */ + name: string + + /** + * The array of extensions that are supported, without the dot. + */ + extensions: string[] + }> +} + +export type SaveFileResponse = + | { + /** + * The implementation was unable to determine the status of the save operation. + * This cannot be considered a success or a failure and should be handled as an uncertainty. + * The browser standard implementation (std) returns this value as there is no way to + * check if the user downloaded the file or not. + */ + type: "unknown" + } + | { + /** + * The result is known and the user cancelled the save. + */ + type: "cancelled" + } + | { + /** + * The result is known and the user saved the file. + */ + type: "saved" + + /** + * The full path of where the file was saved + */ + path: string + } + +/** + * Platform definitions for how to handle IO operations. + */ +export type IOPlatformDef = { + /** + * Defines how to save a file to the user's filesystem. + * The expected behaviour is for the browser to show a prompt to save the file. + */ + saveFileWithDialog: ( + opts: SaveFileWithDialogOptions + ) => Promise + + /** + * Opens a link in the user's browser. + * The expected behaviour is for the browser to open a new tab/window (for example in desktop app) with the given URL. + * @param url The URL to open + */ + openExternalLink: (url: string) => Promise +} diff --git a/packages/hoppscotch-common/src/platform/kernel-interceptors.ts b/packages/hoppscotch-common/src/platform/kernel-interceptors.ts new file mode 100644 index 0000000..1ec8abe --- /dev/null +++ b/packages/hoppscotch-common/src/platform/kernel-interceptors.ts @@ -0,0 +1,19 @@ +import { Container, ServiceClassInstance } from "dioc" +import { KernelInterceptor } from "~/services/kernel-interceptor.service" + +export type KernelInterceptorDef = + | { + type: "standalone" + interceptor: KernelInterceptor + } + | { + type: "service" + service: ServiceClassInstance & { + new (container: Container): KernelInterceptor + } + } + +export type KernelInterceptorsPlatformDef = { + default: string + interceptors: KernelInterceptorDef[] +} diff --git a/packages/hoppscotch-common/src/platform/kernel-io.ts b/packages/hoppscotch-common/src/platform/kernel-io.ts new file mode 100644 index 0000000..dbefeef --- /dev/null +++ b/packages/hoppscotch-common/src/platform/kernel-io.ts @@ -0,0 +1,128 @@ +/** + * Defines how to save a file to the user's filesystem. + */ +export type SaveFileWithDialogOptions = { + /** + * The data to be saved + */ + data: string | ArrayBuffer + + /** + * The suggested filename for the file. This name will be shown in the + * save dialog by default when a save is initiated. + */ + suggestedFilename: string + + /** + * The content type mime type of the data to be saved. + * + * NOTE: The usage of this data might be platform dependent. + * For example, this field is used in the web, but not in the desktop app. + */ + contentType: string + + /** + * Defines the filters (like in Windows, on the right side, where you can + * select the file type) for the file dialog. + * + * NOTE: The usage of this data might be platform dependent. + * For example, this field is used in the web, but not in the desktop app. + */ + filters?: Array<{ + /** + * The name of the filter (in Windows, if the filter looks + * like "Images (*.png, *.jpg)", the name would be "Images") + */ + name: string + + /** + * The array of extensions that are supported, without the dot. + */ + extensions: string[] + }> +} + +/** + * Options for opening an external link. + */ +export type OpenExternalLinkOptions = { + /** + * The URL to open. + */ + url: string +} + +/** + * Response from a file save operation. + */ +export type SaveFileResponse = + | { + /** + * The implementation was unable to determine the status of the save operation. + * This cannot be considered a success or a failure and should be handled as an uncertainty. + * The browser standard implementation (std) returns this value as there is no way to + * check if the user downloaded the file or not. + */ + type: "unknown" + } + | { + /** + * The result is known and the user cancelled the save. + */ + type: "cancelled" + } + | { + /** + * The result is known and the user saved the file. + */ + type: "saved" + + /** + * The full path of where the file was saved + */ + path: string + } + +/** + * Response from an external link operation. + */ +export type OpenExternalLinkResponse = + | { + /** + * The implementation was unable to determine the status of the open operation. + */ + type: "unknown" + } + | { + /** + * The result is known and the user cancelled the open action. + */ + type: "cancelled" + } + | { + /** + * The result is known and the link was opened successfully. + */ + type: "opened" + } + +/** + * Platform definitions for how to handle IO operations. + */ +export type KernelIO = { + /** + * Defines how to save a file to the user's filesystem. + * The expected behaviour is for the browser to show a prompt to save the file. + */ + saveFileWithDialog: ( + opts: SaveFileWithDialogOptions + ) => Promise + + /** + * Opens a link in the user's browser. + * The expected behaviour is for the browser to open a new tab/window (for example in desktop app) with the given URL. + */ + openExternalLink: ( + opts: OpenExternalLinkOptions + ) => Promise +} diff --git a/packages/hoppscotch-common/src/platform/limits.ts b/packages/hoppscotch-common/src/platform/limits.ts new file mode 100644 index 0000000..0e185d5 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/limits.ts @@ -0,0 +1,7 @@ +// Define various limits for the platform +export type LimitsPlatformDef = { + /** + * Assign an import size limit when importing collections + */ + collectionImportSizeLimit?: number +} diff --git a/packages/hoppscotch-common/src/platform/organization.ts b/packages/hoppscotch-common/src/platform/organization.ts new file mode 100644 index 0000000..a44d9df --- /dev/null +++ b/packages/hoppscotch-common/src/platform/organization.ts @@ -0,0 +1,30 @@ +import type { Component } from "vue" + +export type OrganizationPlatformDef = { + isDefaultCloudInstance: boolean + getOrgInfo: () => Promise<{ + orgID: string + orgDomain: string + name?: string + logo?: string | null + isAdmin: boolean + } | null> + getRootDomain: () => string + initiateOnboarding: () => void + + /** + * Custom component for the organization switcher dropdown. + * If provided, organization switching is considered enabled and the component + * will be rendered in the unified header dropdown alongside the instance switcher. + * The component should emit 'close-dropdown' when the dropdown should close. + */ + customOrganizationSwitcherComponent?: Component + + /** + * Switch to a specific organization instance or default cloud instance + * For web: redirects to the target URL + * For desktop: loads the organization bundle and connects + * @param orgDomain - The organization domain (null for default cloud instance) + */ + switchToInstance?: (orgDomain: string | null) => void | Promise +} diff --git a/packages/hoppscotch-common/src/platform/settings.ts b/packages/hoppscotch-common/src/platform/settings.ts new file mode 100644 index 0000000..fb79b2a --- /dev/null +++ b/packages/hoppscotch-common/src/platform/settings.ts @@ -0,0 +1,3 @@ +export type SettingsPlatformDef = { + initSettingsSync: () => void +} diff --git a/packages/hoppscotch-common/src/platform/spotlight.ts b/packages/hoppscotch-common/src/platform/spotlight.ts new file mode 100644 index 0000000..8e0f3d2 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/spotlight.ts @@ -0,0 +1,10 @@ +import { Container, ServiceClassInstance } from "dioc" +import { SpotlightSearcher } from "~/services/spotlight" + +export type SpotlightPlatformDef = { + additionalSearchers?: Array< + ServiceClassInstance & { + new (c: Container): SpotlightSearcher + } + > +} diff --git a/packages/hoppscotch-common/src/platform/std/backend.ts b/packages/hoppscotch-common/src/platform/std/backend.ts new file mode 100644 index 0000000..f90bc8e --- /dev/null +++ b/packages/hoppscotch-common/src/platform/std/backend.ts @@ -0,0 +1,171 @@ +import { useGQLQuery } from "~/composables/graphql" +import { TeamName } from "~/helpers/backend/types/TeamName" +import { BackendPlatformDef } from "~/platform/backend" + +import { HoppRESTRequest } from "@hoppscotch/data" +import { runGQLQuery, runMutation } from "~/helpers/backend/GQLClient" + +import { Email } from "~/helpers/backend/types/Email" +import { + AcceptTeamInvitationDocument, + AcceptTeamInvitationMutation, + AcceptTeamInvitationMutationVariables, + CreateShortcodeDocument, + CreateShortcodeMutation, + CreateShortcodeMutationVariables, + CreateTeamDocument, + CreateTeamInvitationDocument, + CreateTeamInvitationMutation, + CreateTeamInvitationMutationVariables, + CreateTeamMutation, + CreateTeamMutationVariables, + GetInviteDetailsDocument, + GetInviteDetailsQuery, + GetInviteDetailsQueryVariables, + GetMyTeamsDocument, + GetMyTeamsQuery, + GetMyTeamsQueryVariables, + GetUserShortcodesDocument, + GetUserShortcodesQuery, + GetUserShortcodesQueryVariables, + TeamAccessRole, +} from "../../helpers/backend/graphql" + +import { + createMockServer, + updateMockServer, + deleteMockServer, +} from "../../helpers/backend/mutations/MockServer" +import { + getMockServer, + getMyMockServers, + getTeamMockServers, +} from "../../helpers/backend/queries/MockServer" +import { + getMockServerLogs, + deleteMockServerLog, +} from "../../helpers/backend/queries/MockServerLogs" +import { + createPublishedDoc, + updatePublishedDoc, + deletePublishedDoc, +} from "../../helpers/backend/mutations/PublishedDocs" +import { + getPublishedDocByID, + getUserPublishedDocs, + getTeamPublishedDocs, +} from "../../helpers/backend/queries/PublishedDocs" + +const getInviteDetails = ( + inviteID: string +) => { + return useGQLQuery< + GetInviteDetailsQuery, + GetInviteDetailsQueryVariables, + GetInviteDetailsError + >({ + query: GetInviteDetailsDocument, + variables: { + inviteID, + }, + defer: true, + }) +} + +const getUserShortcodes = (cursor?: string) => { + return runGQLQuery< + GetUserShortcodesQuery, + GetUserShortcodesQueryVariables, + "" + >({ + query: GetUserShortcodesDocument, + variables: { + cursor, + }, + }) +} + +const getUserTeams = (cursor?: string) => { + return runGQLQuery({ + query: GetMyTeamsDocument, + variables: { + cursor, + }, + }) +} + +export const createTeam = (name: TeamName) => { + return runMutation< + CreateTeamMutation, + CreateTeamMutationVariables, + CreateTeamErrors + >(CreateTeamDocument, { + name, + }) +} + +export const createTeamInvitation = ( + inviteeEmail: Email, + inviteeRole: TeamAccessRole, + teamID: string +) => { + return runMutation< + CreateTeamInvitationMutation, + CreateTeamInvitationMutationVariables, + CreateTeamInvitationErrors + >(CreateTeamInvitationDocument, { + inviteeEmail, + inviteeRole, + teamID, + }) +} + +export const acceptTeamInvitation = ( + inviteID: string +) => { + return runMutation< + AcceptTeamInvitationMutation, + AcceptTeamInvitationMutationVariables, + AcceptTeamInvitationErrors + >(AcceptTeamInvitationDocument, { + inviteID, + }) +} + +export const createShortcode = ( + request: HoppRESTRequest, + properties?: string +) => { + return runMutation< + CreateShortcodeMutation, + CreateShortcodeMutationVariables, + "" + >(CreateShortcodeDocument, { + request: JSON.stringify(request), + properties, + }) +} + +export const def: BackendPlatformDef = { + getInviteDetails, + getUserShortcodes, + getUserTeams, + createTeam, + createTeamInvitation, + acceptTeamInvitation, + createShortcode, + createMockServer, + updateMockServer, + deleteMockServer, + getMockServer, + getMyMockServers, + getTeamMockServers, + getMockServerLogs, + deleteMockServerLog, + createPublishedDoc, + updatePublishedDoc, + deletePublishedDoc, + getPublishedDocByID, + getUserPublishedDocs, + getTeamPublishedDocs, +} diff --git a/packages/hoppscotch-common/src/platform/std/io.ts b/packages/hoppscotch-common/src/platform/std/io.ts new file mode 100644 index 0000000..70e5539 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/std/io.ts @@ -0,0 +1,45 @@ +import { IOPlatformDef } from "../io" +import { pipe } from "fp-ts/function" +import * as S from "fp-ts/string" +import * as RNEA from "fp-ts/ReadonlyNonEmptyArray" + +/** + * Implementation for how to handle IO operations in the browser. + */ +export const browserIODef: IOPlatformDef = { + saveFileWithDialog(opts) { + const file = new Blob([opts.data], { type: opts.contentType }) + const a = document.createElement("a") + const url = URL.createObjectURL(file) + + a.href = url + a.download = + opts.suggestedFilename ?? + pipe( + url, + S.split("/"), + RNEA.last, + S.split("#"), + RNEA.head, + S.split("?"), + RNEA.head + ) + + document.body.appendChild(a) + a.click() + + setTimeout(() => { + document.body.removeChild(a) + URL.revokeObjectURL(url) + }, 1000) + + // Browsers provide no way for us to know the save went successfully. + return Promise.resolve({ type: "unknown" }) + }, + openExternalLink(url) { + window.open(url, "_blank") + + // Browsers provide no way for us to know the open went successfully. + return Promise.resolve() + }, +} diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/index.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/index.ts new file mode 100644 index 0000000..feef903 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/index.ts @@ -0,0 +1,221 @@ +import { Service } from "dioc" +import { markRaw } from "vue" +import { + body, + relayRequestToNativeAdapter, + RelayRequest, + RelayResponse, + RelayCapabilities, +} from "@hoppscotch/kernel" +import * as E from "fp-ts/Either" +import { pipe } from "fp-ts/function" +import axios, { CancelTokenSource } from "axios" +import { + postProcessRelayRequest, + preProcessRelayRequest, +} from "~/helpers/functional/process-request" +import type { getI18n } from "~/modules/i18n" +import { + KernelInterceptor, + KernelInterceptorError, + ExecutionResult, +} from "~/services/kernel-interceptor.service" +import { KernelInterceptorAgentStore } from "./store" +import SettingsAgent from "~/components/settings/Agent.vue" +import SettingsAgentSubtitle from "~/components/settings/AgentSubtitle.vue" +import InterceptorsErrorPlaceholder from "~/components/settings/InterceptorErrorPlaceholder.vue" +import { CookieJarService } from "~/services/cookie-jar.service" + +export class AgentKernelInterceptorService + extends Service + implements KernelInterceptor +{ + public static readonly ID = "AGENT_KERNEL_INTERCEPTOR_SERVICE" + + private store = this.bind(KernelInterceptorAgentStore) + private readonly cookieJar = this.bind(CookieJarService) + + public readonly id = "agent" + public readonly name = (t: ReturnType) => + t("interceptor.agent.name") + public readonly settingsEntry = markRaw({ + title: (t: ReturnType) => + t("interceptor.agent.settings_title"), + component: SettingsAgent, + }) + public readonly subtitle = markRaw(SettingsAgentSubtitle) + public selectable = { type: "selectable" as const } + public readonly capabilities: RelayCapabilities = { + method: new Set([ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS", + ]), + header: new Set(["stringvalue", "arrayvalue", "multivalue"]), + content: new Set([ + "text", + "json", + "xml", + "form", + "binary", + "multipart", + "urlencoded", + "compression", + ]), + auth: new Set(["basic", "bearer", "apikey", "digest", "aws", "hawk"]), + security: new Set([ + "clientcertificates", + "cacertificates", + "certificatevalidation", + "hostverification", + "peerverification", + ]), + proxy: new Set(["http", "https", "authentication", "certificates"]), + advanced: new Set(["redirects", "cookies", "localaccess"]), + } as const + + public execute(request: RelayRequest): ExecutionResult { + const reqID = Date.now() + const cancelToken = axios.CancelToken.source() + + return { + cancel: async () => { + cancelToken.cancel() + await this.store.cancelRequest(reqID) + }, + response: pipe( + this.executeRequest(request, reqID, cancelToken), + (promise) => + promise.then((either) => + pipe( + either, + E.mapLeft((error): KernelInterceptorError => { + if (error === "cancellation") return "cancellation" + + return { + humanMessage: { + heading: (t) => t("error.network_fail"), + description: (t) => t("helpers.network_fail"), + }, + error, + component: InterceptorsErrorPlaceholder, + } + }) + ) + ) + ), + } + } + + private async executeRequest( + request: RelayRequest, + reqID: number, + cancelToken: CancelTokenSource + ): Promise> { + try { + await this.store.checkAgentStatus() + + if (!this.store.isAgentRunning.value || !this.store.isAuthKeyPresent()) { + throw new Error("Agent not running") + } + + const effectiveRequest = this.store.completeRequest( + preProcessRelayRequest(request) + ) + await this.cookieJar.applyCookiesToRequest(effectiveRequest) + + const existingUserAgentHeader = Object.keys( + effectiveRequest.headers || {} + ).find((header) => header.toLowerCase() === "user-agent") + + // A temporary workaround to add a User-Agent header to the request + // This will be removed once the agent is updated to add User-Agent header by default + const effectiveRequestWithUserAgent = { + ...effectiveRequest, + headers: { + ...effectiveRequest.headers, + "User-Agent": existingUserAgentHeader + ? effectiveRequest.headers[existingUserAgentHeader] + : "HoppscotchKernel/0.2.0", + }, + } + + const nativeRequest = await relayRequestToNativeAdapter( + effectiveRequestWithUserAgent + ) + const postProcessedRequest = postProcessRelayRequest(nativeRequest) + + const [nonceB16, encryptedReq] = await this.store.encryptRequest( + postProcessedRequest, + reqID + ) + + const response = await axios.post( + "http://localhost:9119/execute", + encryptedReq, + { + headers: { + Authorization: `Bearer ${this.store.authKey.value}`, + "X-Hopp-Nonce": nonceB16, + "Content-Type": "application/octet-stream", + }, + cancelToken: cancelToken.token, + responseType: "arraybuffer", + } + ) + + const responseNonceB16 = response.headers["x-hopp-nonce"] + const decryptedResponse = await this.store.decryptResponse( + responseNonceB16, + response.data + ) + + const transformedBody = body.body( + decryptedResponse.body.body, + decryptedResponse.body.mediaType + ) + + // Process Set-Cookie headers for multiHeaders support + const multiHeaders: Array<{ key: string; value: string }> = [] + if (decryptedResponse.headers) { + for (const [key, value] of Object.entries(decryptedResponse.headers)) { + if (key.toLowerCase() === "set-cookie") { + // Split concatenated Set-Cookie headers + const cookieStrings = value + .split("\n") + .map((s) => s.trim()) + .filter(Boolean) + for (const cookieString of cookieStrings) { + multiHeaders.push({ key: "Set-Cookie", value: cookieString }) + } + } else { + multiHeaders.push({ key, value }) + } + } + } + + const transformedResponse = { + ...decryptedResponse, + body: { ...transformedBody }, + multiHeaders: multiHeaders.length > 0 ? multiHeaders : undefined, + } + + await this.cookieJar.captureResponseCookies( + transformedResponse, + effectiveRequest.url + ) + + return E.right(transformedResponse) + } catch (e) { + if (axios.isCancel(e)) { + return E.left("cancellation") + } + + return E.left(e instanceof Error ? e : new Error("Unknown error")) + } + } +} diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/store.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/store.ts new file mode 100644 index 0000000..6d59dd8 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/store.ts @@ -0,0 +1,394 @@ +import { Service } from "dioc" +import { ref } from "vue" +import * as E from "fp-ts/Either" +import axios from "axios" +import { Store } from "~/kernel/store" +import type { PluginRequest, PluginResponse } from "@hoppscotch/kernel" +import { x25519 } from "@noble/curves/ed25519.js" +import { base16 } from "@scure/base" +import { + InputDomainSetting, + convertDomainSetting, +} from "~/helpers/functional/domain-settings" + +const STORE_NAMESPACE = "interceptors.agent.v1" + +const STORE_KEYS = { + SETTINGS: "settings", +} as const + +interface StoredData { + version: string + auth: { + key: string | null + sharedSecret: string | null + } + domains: Record + lastUpdated: string +} + +const defaultDomainConfig: InputDomainSetting = { + version: "v1", + security: { + verifyHost: true, + verifyPeer: true, + }, + proxy: undefined, + options: { + followRedirects: true, + }, +} + +export class KernelInterceptorAgentStore extends Service { + public static readonly ID = "AGENT_INTERCEPTOR_STORE" + private static readonly GLOBAL_DOMAIN = "*" + private static readonly DEFAULT_GLOBAL_SETTINGS: InputDomainSetting = { + ...defaultDomainConfig, + version: "v1", + } + + private domainSettings = new Map() + + public isAgentRunning = ref(false) + public authKey = ref(null) + private sharedSecretB16 = ref(null) + + // AgentSubtitle component shared variables for unified display across multiple components + public hasInitiatedRegistration = ref(false) + public maskedAuthKey = ref("") + public hasCheckedAgent = ref(false) + public registrationOTP = ref(this.authKey.value ? null : "") + public isRegistering = ref(false) + + override async onServiceInit() { + const initResult = await Store.init() + if (E.isLeft(initResult)) { + console.error("[AgentStore] Failed to initialize store:", initResult.left) + return + } + + await this.loadStore() + this.setupWatchers() + } + + private async loadStore(): Promise { + const loadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.SETTINGS + ) + + if (E.isRight(loadResult) && loadResult.right) { + const store = loadResult.right + this.domainSettings = new Map(Object.entries(store.domains)) + this.authKey.value = store.auth.key + this.sharedSecretB16.value = store.auth.sharedSecret + } + + if (!this.domainSettings.has(KernelInterceptorAgentStore.GLOBAL_DOMAIN)) { + this.domainSettings.set( + KernelInterceptorAgentStore.GLOBAL_DOMAIN, + KernelInterceptorAgentStore.DEFAULT_GLOBAL_SETTINGS + ) + await this.persistStore() + } + } + + private async setupWatchers() { + const watcher = await Store.watch(STORE_NAMESPACE, STORE_KEYS.SETTINGS) + watcher.on("change", async ({ value }: { value: unknown }) => { + if (value) { + const store = value as StoredData + this.domainSettings = new Map(Object.entries(store.domains)) + this.authKey.value = store.auth.key + this.sharedSecretB16.value = store.auth.sharedSecret + } + }) + } + + private async persistStore(): Promise { + const store: StoredData = { + version: "v1", + auth: { + key: this.authKey.value, + sharedSecret: this.sharedSecretB16.value, + }, + domains: Object.fromEntries(this.domainSettings), + lastUpdated: new Date().toISOString(), + } + + const saveResult = await Store.set( + STORE_NAMESPACE, + STORE_KEYS.SETTINGS, + store + ) + if (E.isLeft(saveResult)) { + console.error("[AgentStore] Failed to save store:", saveResult.left) + } + } + + public async resetAuthKey(): Promise { + this.authKey.value = null + this.sharedSecretB16.value = null + await this.persistStore() + } + + private mergeSecurity( + ...settings: (Required["security"] | undefined)[] + ): Required["security"] | undefined { + return settings.reduce( + (acc, setting) => (setting ? { ...acc, ...setting } : acc), + undefined as Required["security"] | undefined + ) + } + + private mergeProxy( + ...settings: (Required["proxy"] | undefined)[] + ): Required["proxy"] | undefined { + return settings.reduce( + (acc, setting) => (setting ? { ...acc, ...setting } : acc), + undefined as Required["proxy"] | undefined + ) + } + + private mergeOptions( + ...settings: (Required["options"] | undefined)[] + ): Required["options"] | undefined { + return settings.reduce( + (acc, setting) => (setting ? { ...acc, ...setting } : acc), + undefined as Required["options"] | undefined + ) + } + + private getMergedSettings(domain: string): InputDomainSetting { + const domainSettings = this.domainSettings.get(domain) + const globalSettings = + domain !== KernelInterceptorAgentStore.GLOBAL_DOMAIN + ? this.domainSettings.get(KernelInterceptorAgentStore.GLOBAL_DOMAIN) + : undefined + + const result = { + security: this.mergeSecurity( + globalSettings?.security, + domainSettings?.security + ), + proxy: this.mergeProxy(globalSettings?.proxy, domainSettings?.proxy), + options: this.mergeOptions( + globalSettings?.options, + domainSettings?.options + ), + } + + return { version: "v1", ...result } + } + + public completeRequest( + request: Omit + ): PluginRequest { + const host = new URL(request.url).host + const settings = this.getMergedSettings(host) + const effective = convertDomainSetting(settings) + + if (E.isLeft(effective)) { + throw effective.left + } + + return { ...request, ...effective.right } + } + + public async checkAgentStatus(): Promise { + try { + const handshakeResponse = await axios.get( + "http://localhost:9119/handshake" + ) + this.isAgentRunning.value = + handshakeResponse.data.status === "success" && + handshakeResponse.data.__hoppscotch__agent__ === true + } catch { + this.isAgentRunning.value = false + } + } + + public isAuthKeyPresent(): boolean { + return this.authKey.value !== null + } + + public async initiateRegistration() { + const otp = Math.floor(100000 + Math.random() * 900000).toString() + + const response = await axios.post( + "http://localhost:9119/receive-registration", + { registration: otp } + ) + + if (response.data.message !== "Registration received and stored") { + throw new Error(response.data.message ?? "Registration failed") + } + + return otp + } + + public async verifyRegistration(otp: string): Promise { + const myPrivateKey = crypto.getRandomValues(new Uint8Array(32)) + const myPublicKey = x25519.getPublicKey(myPrivateKey) + const myPublicKeyB16 = base16.encode(myPublicKey).toLowerCase() + + const response = await axios.post( + "http://localhost:9119/verify-registration", + { + registration: otp, + client_public_key_b16: myPublicKeyB16, + } + ) + + const { auth_key: newAuthKey, agent_public_key_b16: agentPublicKeyB16 } = + response.data + + if (typeof newAuthKey !== "string") + throw new Error("Invalid auth key received") + + const agentPublicKey = new Uint8Array( + base16.decode(agentPublicKeyB16.toUpperCase()) + ) + const sharedSecret = x25519.getSharedSecret(myPrivateKey, agentPublicKey) + const sharedSecretB16 = base16.encode(sharedSecret).toLowerCase() + + this.authKey.value = newAuthKey + this.sharedSecretB16.value = sharedSecretB16 + await this.persistStore() + } + + public async fetchRegistrationInfo(): Promise<{ + registered_at: Date + auth_key_hash: string + }> { + try { + const response = await axios.get("http://localhost:9119/registration", { + headers: { + Authorization: `Bearer ${this.authKey.value}`, + }, + responseType: "arraybuffer", + }) + + const nonceB16 = response.headers["x-hopp-nonce"] + if (!nonceB16) { + throw new Error("No nonce received from server") + } + + return await this.decryptResponse(nonceB16, response.data) + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response?.status === 401) { + this.authKey.value = null + await this.persistStore() + } + } + throw error + } + } + + public async encryptRequest( + request: PluginRequest, + reqID: number + ): Promise<[string, ArrayBuffer]> { + const fullRequest = { ...request, id: reqID } + const reqJSON = JSON.stringify(fullRequest) + const reqJSONBytes = new TextEncoder().encode(reqJSON) + const nonce = window.crypto.getRandomValues(new Uint8Array(12)) + const nonceB16 = base16.encode(nonce).toLowerCase() + + const sharedSecretKeyBytes = new Uint8Array( + base16.decode(this.sharedSecretB16.value!.toUpperCase()) + ) + const sharedSecretKey = await window.crypto.subtle.importKey( + "raw", + sharedSecretKeyBytes, + "AES-GCM", + true, + ["encrypt", "decrypt"] + ) + + const encryptedReq = await window.crypto.subtle.encrypt( + { name: "AES-GCM", iv: nonce }, + sharedSecretKey, + reqJSONBytes + ) + + return [nonceB16, encryptedReq] + } + + public async decryptResponse( + nonceB16: string, + encryptedResponse: ArrayBuffer + ): Promise { + const nonce = new Uint8Array(base16.decode(nonceB16.toUpperCase())) + const sharedSecretKeyBytes = new Uint8Array( + base16.decode(this.sharedSecretB16.value!.toUpperCase()) + ) + const sharedSecretKey = await window.crypto.subtle.importKey( + "raw", + sharedSecretKeyBytes, + "AES-GCM", + true, + ["encrypt", "decrypt"] + ) + + const decryptedBytes = await window.crypto.subtle.decrypt( + { name: "AES-GCM", iv: nonce }, + sharedSecretKey, + encryptedResponse + ) + + return JSON.parse(new TextDecoder().decode(decryptedBytes)) + } + + public async cancelRequest(reqId: number): Promise { + try { + await axios.post( + `http://localhost:9119/cancel/${reqId}`, + {}, + { + headers: { + Authorization: `Bearer ${this.authKey.value}`, + }, + } + ) + } catch (error) { + console.error("Error cancelling request:", error) + } + } + + public getDomainSettings(domain: string): InputDomainSetting { + return ( + this.domainSettings.get(domain) ?? { + ...defaultDomainConfig, + version: "v1", + } + ) + } + + public async saveDomainSettings( + domain: string, + settings: Partial + ): Promise { + const updatedSettings: InputDomainSetting = { + ...settings, + version: "v1", + } + + this.domainSettings.set(domain, updatedSettings) + await this.persistStore() + } + + public async clearDomainSettings(domain: string): Promise { + this.domainSettings.delete(domain) + await this.persistStore() + } + + public getDomains(): string[] { + return Array.from(this.domainSettings.keys()) + } + + public getAllDomainSettings(): Map { + return new Map(this.domainSettings) + } +} diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/browser/index.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/browser/index.ts new file mode 100644 index 0000000..4817ab7 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/browser/index.ts @@ -0,0 +1,149 @@ +import { pipe } from "fp-ts/function" +import * as E from "fp-ts/Either" + +import { Service } from "dioc" +import type { RelayRequest } from "@hoppscotch/kernel" + +import { Relay } from "~/kernel/relay" +import type { + KernelInterceptor, + ExecutionResult, + KernelInterceptorError, +} from "~/services/kernel-interceptor.service" + +import { getI18n } from "~/modules/i18n" +import { preProcessRelayRequest } from "~/helpers/functional/process-request" + +import InterceptorsErrorPlaceholder from "~/components/settings/InterceptorErrorPlaceholder.vue" + +export class BrowserKernelInterceptorService + extends Service + implements KernelInterceptor +{ + public static readonly ID = "BROWSER_KERNEL_INTERCEPTOR_SERVICE" + + public readonly id = "browser" + public readonly name = (t: ReturnType) => + t("interceptor.browser.name") + public readonly selectable = { type: "selectable" as const } + public readonly capabilities = { + method: new Set([ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS", + ]), + header: new Set(["stringvalue"]), + content: new Set([ + "text", + "json", + "xml", + "form", + "multipart", + "urlencoded", + ]), + auth: new Set(["basic", "bearer", "apikey"]), + security: new Set([]), + proxy: new Set([]), + advanced: new Set([]), + } as const + + public execute( + request: RelayRequest + ): ExecutionResult { + const processedRequest = preProcessRelayRequest(request) + const relayExecution = Relay.execute(processedRequest) + + const response = pipe(relayExecution.response, (promise) => + promise.then((either) => + pipe( + either, + E.mapLeft((error): KernelInterceptorError => { + const humanMessage = { + heading: (t: ReturnType) => { + switch (error.kind) { + case "network": + return t("error.network.heading") + case "timeout": + return t("error.timeout.heading") + case "certificate": + return t("error.certificate.heading") + case "auth": + return t("error.auth.heading") + case "proxy": + return t("error.proxy.heading") + case "parse": + return t("error.parse.heading") + case "version": + return t("error.version.heading") + case "abort": + return t("error.aborted.heading") + default: + return t("error.unknown.heading") + } + }, + description: (t: ReturnType) => { + switch (error.kind) { + case "network": + return t("error.network.description", { + message: error.message, + cause: error.cause ?? t("error.unknown.cause"), + }) + case "timeout": + return t("error.timeout.description", { + message: error.message, + phase: error.phase ?? t("error.unknown.phase"), + }) + case "certificate": + return t("error.certificate.description", { + message: error.message, + cause: error.cause ?? t("error.unknown.cause"), + }) + case "auth": + return t("error.auth.description", { + message: error.message, + cause: error.cause ?? t("error.unknown.cause"), + }) + case "proxy": + return t("error.proxy.description", { + message: error.message, + cause: error.cause ?? t("error.unknown.cause"), + }) + case "parse": + return t("error.parse.description", { + message: error.message, + cause: error.cause ?? t("error.unknown.cause"), + }) + case "version": + return t("error.version.description", { + message: error.message, + cause: error.cause ?? t("error.unknown.cause"), + }) + case "abort": + return t("error.aborted.description", { + message: error.message, + }) + default: + return t("error.unknown.description") + } + }, + } + return { + humanMessage, + error, + component: InterceptorsErrorPlaceholder, + } + }) + ) + ) + ) + + return { + cancel: relayExecution.cancel, + response, + } + } +} diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/browser/store.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/browser/store.ts new file mode 100644 index 0000000..1ad08da --- /dev/null +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/browser/store.ts @@ -0,0 +1,57 @@ +import { Service } from "dioc" +import { Store } from "~/kernel/store" +import * as E from "fp-ts/Either" + +const STORE_NAMESPACE = "interceptors.browser.v1" +const SETTINGS_KEY = "settings" + +type BrowserSettings = { + version: "v1" +} + +interface StoredData { + version: string + settings: BrowserSettings + lastUpdated: string +} + +const DEFAULT_SETTINGS: BrowserSettings = { + version: "v1", +} + +export class KernelInterceptorBrowserStore extends Service { + public static readonly ID = "KERNEL_BROWSER_INTERCEPTOR_STORE" + private settings: BrowserSettings = { ...DEFAULT_SETTINGS } + + async onServiceInit(): Promise { + const initResult = await Store.init() + if (E.isLeft(initResult)) { + console.error( + "[BrowserStore] Failed to initialize store:", + initResult.left + ) + return + } + + await this.loadSettings() + } + + private async loadSettings(): Promise { + const loadResult = await Store.get( + STORE_NAMESPACE, + SETTINGS_KEY + ) + + if (E.isRight(loadResult) && loadResult.right) { + const storedData = loadResult.right + this.settings = { + ...DEFAULT_SETTINGS, + ...storedData.settings, + } + } + } + + public getSettings(): BrowserSettings { + return { ...this.settings } + } +} diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/extension/index.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/extension/index.ts new file mode 100644 index 0000000..08aa763 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/extension/index.ts @@ -0,0 +1,554 @@ +import { computed, markRaw, ref } from "vue" +import { Service } from "dioc" +import type { RelayRequest, RelayResponse } from "@hoppscotch/kernel" +import { body } from "@hoppscotch/kernel" +import SettingsExtension from "~/components/settings/Extension.vue" +import SettingsExtensionSubtitle from "~/components/settings/ExtensionSubtitle.vue" +import * as E from "fp-ts/Either" +import { getI18n } from "~/modules/i18n" +import { until } from "@vueuse/core" +import { preProcessRelayRequest } from "~/helpers/functional/process-request" +import { browserIsChrome, browserIsFirefox } from "~/helpers/utils/userAgent" +import type { + KernelInterceptor, + ExecutionResult, + KernelInterceptorError, +} from "~/services/kernel-interceptor.service" + +export const defineSubscribableObject = (obj: T) => { + const proxyObject = { + ...obj, + _subscribers: {} as { + // eslint-disable-next-line no-unused-vars + [key in keyof T]?: ((...args: any[]) => any)[] + }, + subscribe(prop: keyof T, func: (...args: any[]) => any): void { + if (Array.isArray(this._subscribers[prop])) { + this._subscribers[prop]?.push(func) + } else { + this._subscribers[prop] = [func] + } + }, + } + + type SubscribableProxyObject = typeof proxyObject + + return new Proxy(proxyObject, { + set(obj, prop, newVal) { + obj[prop as keyof SubscribableProxyObject] = newVal + + const currentSubscribers = obj._subscribers[prop as keyof T] + + if (Array.isArray(currentSubscribers)) { + for (const subscriber of currentSubscribers) { + subscriber(newVal) + } + } + + return true + }, + }) +} + +export type ExtensionStatus = "available" | "unknown-origin" | "waiting" + +export const cancelRunningExtensionRequest = async () => { + window.__POSTWOMAN_EXTENSION_HOOK__?.cancelRequest() +} + +export class ExtensionKernelInterceptorService + extends Service + implements KernelInterceptor +{ + public static readonly ID = "KERNEL_EXTENSION_INTERCEPTOR_SERVICE" + + private _extensionStatus = ref("waiting") + + public readonly id = "extension" + public readonly name = (t: ReturnType) => { + const version = this.extensionVersion.value + + if (this.extensionStatus.value === "available" && version) { + return `${t("settings.extensions")}: v${version.major}.${version.minor}` + } + return `${t("settings.extensions")}` + } + + public readonly selectable = { type: "selectable" as const } + public readonly capabilities = { + method: new Set([ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS", + ]), + header: new Set(["stringvalue", "arrayvalue", "multivalue"]), + content: new Set([ + "text", + "json", + "xml", + "form", + "binary", + "multipart", + "urlencoded", + "compression", + ]), + auth: new Set(["basic", "bearer", "apikey"]), + security: new Set([ + "clientcertificates", + "cacertificates", + "certificatevalidation", + "hostverification", + "peerverification", + ]), + proxy: new Set(["http", "https", "authentication", "certificates"]), + advanced: new Set(["localaccess"]), + } as const + + public readonly extensionStatus = computed(() => this._extensionStatus.value) + + public readonly extensionVersion = computed(() => { + if (this.extensionStatus.value === "available") { + return window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion() || null + } + return null + }) + + public readonly settingsEntry = markRaw({ + title: (t: ReturnType) => t("settings.extensions"), + component: SettingsExtension, + }) + + public readonly subtitle = markRaw(SettingsExtensionSubtitle) + + /** + * Whether the extension is installed in Chrome or not. + */ + public readonly chromeExtensionInstalled = computed( + () => this.extensionStatus.value === "available" && browserIsChrome() + ) + + /** + * Whether the extension is installed in Firefox or not. + */ + public readonly firefoxExtensionInstalled = computed( + () => this.extensionStatus.value === "available" && browserIsFirefox() + ) + + override async onServiceInit(): Promise { + this.setupExtensionStatusListener() + } + + private setupExtensionStatusListener(): void { + const extensionPollIntervalId = ref>() + + if (window.__HOPP_EXTENSION_STATUS_PROXY__) { + this._extensionStatus.value = + window.__HOPP_EXTENSION_STATUS_PROXY__.status + window.__HOPP_EXTENSION_STATUS_PROXY__.subscribe( + "status", + (status: ExtensionStatus) => { + this._extensionStatus.value = status + } + ) + } else { + const statusProxy = defineSubscribableObject({ + status: "waiting" as ExtensionStatus, + }) + + window.__HOPP_EXTENSION_STATUS_PROXY__ = statusProxy + statusProxy.subscribe( + "status", + (status: ExtensionStatus) => (this._extensionStatus.value = status) + ) + + // Check if extension is already available + if (this.tryDetectExtension()) { + return + } + + /** + * Keeping identifying extension backward compatible + * We are assuming the default version is 0.24 or later. So if the extension exists, its identified immediately, + * then we use a poll to find the version, this will get the version for 0.24 and any other version + * of the extension, but will have a slight lag. + * 0.24 users will get the benefits of 0.24, while the extension won't break for the old users + */ + extensionPollIntervalId.value = setInterval(() => { + if (this.tryDetectExtension() && extensionPollIntervalId.value) { + clearInterval(extensionPollIntervalId.value) + } + }, 2000) + } + } + + public tryDetectExtension(): boolean { + if (typeof window.__POSTWOMAN_EXTENSION_HOOK__ !== "undefined") { + const version = window.__POSTWOMAN_EXTENSION_HOOK__.getVersion() + + this._extensionStatus.value = "available" + + // When the version is not 0.24 or higher, the extension wont do this. so we have to do it manually + if ( + version.major === 0 && + version.minor <= 23 && + window.__HOPP_EXTENSION_STATUS_PROXY__ + ) { + window.__HOPP_EXTENSION_STATUS_PROXY__.status = "available" + } + return true + } + return false + } + + private async executeExtensionRequest( + request: RelayRequest + ): Promise> { + // Wait for the extension to resolve (not waiting forever) + await until(this.extensionStatus).toMatch( + (status) => status !== "waiting", + { timeout: 1000 } + ) + + if (!window.__POSTWOMAN_EXTENSION_HOOK__) { + return E.left({ + humanMessage: { + heading: (t) => t("error.extension_not_found"), + description: (t) => t("error.network_fail"), + }, + error: { + kind: "extension", + message: "Extension hook not found", + }, + }) + } + + try { + const startTime = Date.now() + let requestData: any = null + + if (request.content) { + switch (request.content.kind) { + case "text": + // Text content - pass string directly + requestData = + typeof request.content.content === "string" + ? request.content.content + : String(request.content.content) + break + + case "json": + // For JSON, we need to stringify it before sending it to extension, + // see extension source code for more info on this. + // Also if it's already a string, we can use it as is, otherwise we stringify. + requestData = + typeof request.content.content === "string" + ? request.content.content + : JSON.stringify(request.content.content) + break + + case "binary": + if ( + request.content.content instanceof Blob || + request.content.content instanceof File + ) { + requestData = request.content.content + } else if (typeof request.content.content === "string") { + try { + const base64 = + request.content.content.split(",")[1] || + request.content.content + const binaryString = window.atob(base64) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + // Pass the Uint8Array directly, not .buffer, to avoid offset issues. + // Explanation: If you use bytes.buffer, you may include unused portions of the ArrayBuffer, + // because Uint8Array can be a view with a non-zero offset or a length less than the buffer size. + // Passing the Uint8Array directly ensures only the intended bytes are included in the Blob. + requestData = new Blob([bytes]) + } catch (e) { + console.error("Error converting binary data:", e) + requestData = request.content.content + } + } else if (request.content.content instanceof Uint8Array) { + // Pass Uint8Array directly; the extension's sendRequest() method is responsible for handling it, + // typically by accessing its underlying ArrayBuffer for transmission. + requestData = request.content.content + } else { + console.warn( + "[Extension Interceptor] Unknown binary content type:", + typeof request.content.content, + request.content.content + ) + requestData = request.content.content + } + break + + case "urlencoded": + // URL-encoded form data - pass string directly + requestData = + typeof request.content.content === "string" + ? request.content.content + : String(request.content.content) + break + + case "multipart": + // FormData for multipart - pass directly (extension should handle FormData) + requestData = request.content.content + break + + case "xml": + // XML content - pass string directly + requestData = + typeof request.content.content === "string" + ? request.content.content + : String(request.content.content) + break + + case "form": + // Form data - pass directly + requestData = request.content.content + break + + default: + // Fallback for any other content types + requestData = request.content.content + } + } + + // Always use wantsBinary: true - required for correct data handling + // Note: Extension may log TypeError in console due to internal ArrayBuffer conversion, + // but this is expected behavior and doesn't affect response data integrity + // Compatibility: Older extension versions expect binary request bodies + // to be base64 strings and attempt a `.replace()` on them before decoding. + // Newer versions (supporting wantsBinary=true) can accept Uint8Array/ArrayBuffer. + // We detect non-string binary inputs and safely convert them to base64 to + // prevent `input.replace is not a function` errors inside the extension. + const toBase64 = (u8: Uint8Array): string => { + let bin = "" + // Build binary string in manageable chunks to avoid stack/heap pressure for large payloads + const CHUNK_SIZE = 0x8000 + for (let i = 0; i < u8.length; i += CHUNK_SIZE) { + const chunk = u8.subarray(i, i + CHUNK_SIZE) + bin += String.fromCharCode(...chunk) + } + return btoa(bin) + } + + let transportedData: any = requestData + let encodedAsBase64 = false + try { + if (requestData instanceof Uint8Array) { + transportedData = toBase64(requestData) + encodedAsBase64 = true + } else if (requestData instanceof ArrayBuffer) { + transportedData = toBase64(new Uint8Array(requestData)) + encodedAsBase64 = true + } else if (typeof Blob !== "undefined" && requestData instanceof Blob) { + const buf = await requestData.arrayBuffer() + transportedData = toBase64(new Uint8Array(buf)) + encodedAsBase64 = true + } else if (typeof File !== "undefined" && requestData instanceof File) { + const buf = await requestData.arrayBuffer() + transportedData = toBase64(new Uint8Array(buf)) + encodedAsBase64 = true + } + } catch (e) { + // Fallback: leave transportedData as original on any conversion error + console.warn( + "[Extension Interceptor] Failed to convert binary body to base64, sending raw:", + e + ) + transportedData = requestData + encodedAsBase64 = false + } + + const extensionResponse = + await window.__POSTWOMAN_EXTENSION_HOOK__.sendRequest({ + url: request.url, + method: request.method, + headers: request.headers ?? {}, + // If we base64 encoded, pass the string; extension will decode gracefully. + // Otherwise pass original data (newer extension builds tolerate raw binary). + data: transportedData, + wantsBinary: true, + // Hint for future extension versions (ignored by older ones): indicates body encoding. + __hopp_meta: encodedAsBase64 ? { bodyEncoding: "base64" } : undefined, + }) + + const endTime = Date.now() + + const headersSize = JSON.stringify(extensionResponse.headers).length + + const timingMeta = extensionResponse.timeData + ? { + start: extensionResponse.timeData.startTime, + end: extensionResponse.timeData.endTime, + } + : { + start: startTime, + end: endTime, + } + + // Handle response data - extension with wantsBinary: true returns ArrayBuffer or Uint8Array + let responseData: Uint8Array + + if ( + !extensionResponse.data || + extensionResponse.data === null || + extensionResponse.data === undefined + ) { + // No response body + responseData = new Uint8Array(0) + } else if (extensionResponse.data instanceof Uint8Array) { + // Extension returned Uint8Array - use directly + responseData = extensionResponse.data + } else if (extensionResponse.data instanceof ArrayBuffer) { + // Extension returned ArrayBuffer - convert to Uint8Array + responseData = new Uint8Array(extensionResponse.data) + } else if (typeof extensionResponse.data === "string") { + // Extension returned string - encode as UTF-8 + responseData = new TextEncoder().encode(extensionResponse.data) + } else if (extensionResponse.data instanceof Blob) { + // Extension returned Blob - convert to Uint8Array + const arrayBuffer = await extensionResponse.data.arrayBuffer() + responseData = new Uint8Array(arrayBuffer) + } else { + // Unexpected type - handle gracefully + console.warn("[Extension Interceptor] Unexpected response data type:", { + type: typeof extensionResponse.data, + constructor: extensionResponse.data?.constructor?.name, + }) + try { + // Try to convert to string and encode + const dataString = + typeof extensionResponse.data === "object" + ? JSON.stringify(extensionResponse.data) + : String(extensionResponse.data) + responseData = new TextEncoder().encode(dataString) + } catch (err) { + console.error( + "[Extension Interceptor] Failed to convert response data:", + err + ) + responseData = new Uint8Array(0) + } + } + + // Calculate sizes using the decoded response data + const bodySize = responseData.byteLength + const totalSize = headersSize + bodySize + + return E.right({ + id: request.id, + status: extensionResponse.status, + statusText: extensionResponse.statusText, + version: request.version, + headers: extensionResponse.headers, + body: body.body( + responseData || new Uint8Array(0), + extensionResponse.headers["content-type"] + ), + meta: { + timing: timingMeta, + size: { + headers: headersSize, + body: bodySize, + total: totalSize, + }, + }, + }) + } catch (e) { + console.error(e) + + if (e instanceof Error && "response" in e) { + const response = (e as any).response + if (response) { + const headersSize = JSON.stringify(response.headers).length + const bodySize = response.data?.byteLength || 0 + const totalSize = headersSize + bodySize + + const timingMeta = response.timeData + ? { + start: response.timeData.startTime, + end: response.timeData.endTime, + } + : { + // Fallback timing - at least show it took some time, + // this is mainly for cross compat with other interceptor settings. + start: Date.now() - 1, + end: Date.now(), + } + + return E.right({ + id: request.id, + status: response.status, + statusText: response.statusText, + version: request.version, + headers: response.headers, + body: body.body(response.data, response.headers["content-type"]), + meta: { + timing: timingMeta, + size: { + headers: headersSize, + body: bodySize, + total: totalSize, + }, + }, + }) + } + } + + return E.left({ + humanMessage: { + heading: (t) => t("error.extension.heading"), + description: (t) => t("error.extension.description"), + }, + error: { + kind: "extension", + message: e instanceof Error ? e.message : "Unknown error", + }, + }) + } + } + + public execute( + request: RelayRequest + ): ExecutionResult { + if (this._extensionStatus.value !== "available") { + return { + cancel: async () => { + // Nothing to cancel if extension is not available + }, + response: Promise.resolve( + E.left({ + humanMessage: { + heading: (t: ReturnType) => + t("error.extension.heading"), + description: (t: ReturnType) => + t("error.extension.description"), + }, + error: { + kind: "extension", + message: "Extension not available", + }, + }) + ), + } + } + + const extensionExecution = { + cancel: async () => { + window.__POSTWOMAN_EXTENSION_HOOK__?.cancelRequest() + }, + response: this.executeExtensionRequest(preProcessRelayRequest(request)), + } + + return extensionExecution + } +} diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/extension/store.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/extension/store.ts new file mode 100644 index 0000000..4adc757 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/extension/store.ts @@ -0,0 +1,224 @@ +import { Service } from "dioc" +import { Store } from "~/kernel/store" +import * as E from "fp-ts/Either" +import * as O from "fp-ts/Option" +import { ref } from "vue" + +const STORE_NAMESPACE = "interceptors.extension.v1" +const SETTINGS_KEY = "settings" + +export type ExtensionStatus = "available" | "unknown-origin" | "waiting" + +export type ExtensionVersion = { + major: number + minor: number +} + +export const defineSubscribableObject = (obj: T) => { + const proxyObject = { + ...obj, + _subscribers: {} as { + // eslint-disable-next-line no-unused-vars + [key in keyof T]?: ((...args: any[]) => any)[] + }, + subscribe(prop: keyof T, func: (...args: any[]) => any): void { + if (Array.isArray(this._subscribers[prop])) { + this._subscribers[prop]?.push(func) + } else { + this._subscribers[prop] = [func] + } + }, + } + + type SubscribableProxyObject = typeof proxyObject + + return new Proxy(proxyObject, { + set(obj, prop, newVal) { + obj[prop as keyof SubscribableProxyObject] = newVal + + const currentSubscribers = obj._subscribers[prop as keyof T] + + if (Array.isArray(currentSubscribers)) { + for (const subscriber of currentSubscribers) { + subscriber(newVal) + } + } + + return true + }, + }) +} + +type ExtensionSettings = { + version: "v1" + status: ExtensionStatus + extensionVersion?: ExtensionVersion +} + +interface StoredData { + version: string + settings: ExtensionSettings + lastUpdated: string +} + +const DEFAULT_SETTINGS: ExtensionSettings = { + version: "v1", + status: "waiting", +} + +export class KernelInterceptorExtensionStore extends Service { + public static readonly ID = "KERNEL_EXTENSION_INTERCEPTOR_STORE" + + private settings: ExtensionSettings = { ...DEFAULT_SETTINGS } + private extensionPollIntervalId = ref>() + + async onServiceInit(): Promise { + const initResult = await Store.init() + if (E.isLeft(initResult)) { + console.error( + "[ExtensionStore] Failed to initialize store:", + initResult.left + ) + return + } + + await this.loadSettings() + + if (!this.tryDetectExtension()) { + this.setupExtensionStatusListener() + } + + const watcher = await Store.watch(STORE_NAMESPACE, SETTINGS_KEY) + watcher.on("change", async ({ value }) => { + if (value) { + const storedData = value as StoredData + this.settings = storedData.settings + } + }) + } + + public getExtensionVersion(): O.Option { + return O.fromNullable(this.settings.extensionVersion) + } + + public getExtensionStatus(): ExtensionStatus { + return this.settings.status + } + + private setupExtensionStatusListener(): void { + if (window.__HOPP_EXTENSION_STATUS_PROXY__) { + this.settings.status = window.__HOPP_EXTENSION_STATUS_PROXY__.status + window.__HOPP_EXTENSION_STATUS_PROXY__.subscribe( + "status", + (status: ExtensionStatus) => { + this.updateSettings({ status }) + } + ) + } else { + const statusProxy = defineSubscribableObject({ + status: "waiting" as ExtensionStatus, + }) + + window.__HOPP_EXTENSION_STATUS_PROXY__ = statusProxy + statusProxy.subscribe("status", (status: ExtensionStatus) => + this.updateSettings({ status }) + ) + + if (this.tryDetectExtension()) { + return + } + + /** + * Keeping identifying extension backward compatible + * We are assuming the default version is 0.24 or later. So if the extension exists, its identified immediately, + * then we use a poll to find the version, this will get the version for 0.24 and any other version + * of the extension, but will have a slight lag. + * 0.24 users will get the benefits of 0.24, while the extension won't break for the old users + */ + this.extensionPollIntervalId.value = setInterval(() => { + if (this.tryDetectExtension() && this.extensionPollIntervalId.value) { + clearInterval(this.extensionPollIntervalId.value) + } + }, 2000) + } + } + + public tryDetectExtension(): boolean { + if (typeof window.__POSTWOMAN_EXTENSION_HOOK__ !== "undefined") { + const version = window.__POSTWOMAN_EXTENSION_HOOK__.getVersion() + + this.settings = { + ...this.settings, + extensionVersion: version, + status: "available", + } + + // We don't need to persist right away to thing eventually resolving is good enough + this.persistSettings() + + // When the version is not 0.24 or higher, the extension wont do this. so we have to do it manually + if ( + version.major === 0 && + version.minor <= 23 && + window.__HOPP_EXTENSION_STATUS_PROXY__ + ) { + window.__HOPP_EXTENSION_STATUS_PROXY__.status = "available" + } + return true + } + return false + } + + private async loadSettings(): Promise { + const loadResult = await Store.get( + STORE_NAMESPACE, + SETTINGS_KEY + ) + + if (E.isRight(loadResult) && loadResult.right) { + const storedData = loadResult.right + this.settings = { + ...DEFAULT_SETTINGS, + ...storedData.settings, + } + } else { + await this.persistSettings() + } + } + + private async persistSettings(): Promise { + const storedData: StoredData = { + version: "v1", + settings: this.settings, + lastUpdated: new Date().toISOString(), + } + + const saveResult = await Store.set( + STORE_NAMESPACE, + SETTINGS_KEY, + storedData + ) + + if (E.isLeft(saveResult)) { + console.error( + "[ExtensionStore] Failed to save settings:", + saveResult.left + ) + } + } + + public async updateSettings( + settings: Partial + ): Promise { + this.settings = { + ...this.settings, + ...settings, + } + + await this.persistSettings() + } + + public getSettings(): ExtensionSettings { + return { ...this.settings } + } +} diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/index.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/index.ts new file mode 100644 index 0000000..b3feeae --- /dev/null +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/index.ts @@ -0,0 +1,226 @@ +import { markRaw } from "vue" +import * as E from "fp-ts/Either" +import { pipe } from "fp-ts/function" +import { getI18n } from "~/modules/i18n" +import { + postProcessRelayRequest, + preProcessRelayRequest, +} from "~/helpers/functional/process-request" +import { + relayRequestToNativeAdapter, + type RelayCapabilities, + type RelayRequest, + type RelayResponse, +} from "@hoppscotch/kernel" +import { Relay } from "~/kernel/relay" +import { Service } from "dioc" +import type { + KernelInterceptor, + ExecutionResult, + KernelInterceptorError, +} from "~/services/kernel-interceptor.service" +import { CookieJarService } from "~/services/cookie-jar.service" +import InterceptorsErrorPlaceholder from "~/components/settings/InterceptorErrorPlaceholder.vue" +import SettingsNative from "~/components/settings/Native.vue" +import { KernelInterceptorNativeStore } from "./store" + +export class NativeKernelInterceptorService + extends Service + implements KernelInterceptor +{ + public static readonly ID = "NATIVE_KERNEL_INTERCEPTOR_SERVICE" + + private readonly store = this.bind(KernelInterceptorNativeStore) + private readonly cookieJar = this.bind(CookieJarService) + + public readonly id = "native" + public readonly name = (t: ReturnType) => + t("interceptor.native.name") + public readonly selectable = { type: "selectable" as const } + public readonly capabilities: RelayCapabilities = { + method: new Set([ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS", + ]), + header: new Set(["stringvalue", "arrayvalue", "multivalue"]), + content: new Set([ + "text", + "json", + "xml", + "form", + "binary", + "multipart", + "urlencoded", + "compression", + ]), + auth: new Set(["basic", "bearer", "apikey", "digest", "aws", "hawk"]), + security: new Set([ + "clientcertificates", + "cacertificates", + "certificatevalidation", + "hostverification", + "peerverification", + ]), + proxy: new Set(["http", "https", "authentication", "certificates"]), + advanced: new Set(["redirects", "cookies", "localaccess"]), + } as const + public readonly settingsEntry = markRaw({ + title: (t: ReturnType) => + t("interceptor.native.settings_title"), + component: SettingsNative, + }) + + public execute( + request: RelayRequest + ): ExecutionResult { + let relayExecution: { cancel: () => Promise } | null = null + + return { + cancel: async () => { + if (relayExecution) { + await relayExecution.cancel() + } + }, + response: pipe( + this.executeRequest(request, (execution) => { + relayExecution = execution + }), + (promise) => + promise.then((either) => + pipe( + either, + E.mapLeft((error): KernelInterceptorError => { + const humanMessage = { + heading: (t: ReturnType) => { + switch (error.kind) { + case "network": + return t("error.network.heading") + case "timeout": + return t("error.timeout.heading") + case "certificate": + return t("error.certificate.heading") + case "auth": + return t("error.auth.heading") + case "proxy": + return t("error.proxy.heading") + case "parse": + return t("error.parse.heading") + case "version": + return t("error.version.heading") + case "abort": + return t("error.aborted.heading") + default: + return t("error.unknown.heading") + } + }, + description: (t: ReturnType) => { + switch (error.kind) { + case "network": + return t("error.network.description", { + message: error.message, + cause: error.cause ?? t("error.unknown.cause"), + }) + case "timeout": + return t("error.timeout.description", { + message: error.message, + phase: error.phase ?? t("error.unknown.phase"), + }) + case "certificate": + return t("error.certificate.description", { + message: error.message, + cause: error.cause ?? t("error.unknown.cause"), + }) + case "auth": + return t("error.auth.description", { + message: error.message, + cause: error.cause ?? t("error.unknown.cause"), + }) + case "proxy": + return t("error.proxy.description", { + message: error.message, + cause: error.cause ?? t("error.unknown.cause"), + }) + case "parse": + return t("error.parse.description", { + message: error.message, + cause: error.cause ?? t("error.unknown.cause"), + }) + case "version": + return t("error.version.description", { + message: error.message, + cause: error.cause ?? t("error.unknown.cause"), + }) + case "abort": + return t("error.aborted.description", { + message: error.message, + }) + default: + return t("error.unknown.description") + } + }, + } + return { + humanMessage, + error, + component: InterceptorsErrorPlaceholder, + } + }) + ) + ) + ), + } + } + + private async executeRequest( + request: RelayRequest, + setRelayExecution: (execution: { cancel: () => Promise }) => void + ): Promise> { + try { + const effectiveRequest = this.store.completeRequest( + preProcessRelayRequest(request) + ) + + await this.cookieJar.applyCookiesToRequest(effectiveRequest) + + const existingUserAgentHeader = Object.keys( + effectiveRequest.headers || {} + ).find((header) => header.toLowerCase() === "user-agent") + + // A temporary workaround to add a User-Agent header to the request + // This will be removed once the kernel/relay is updated to add User-Agent header by default + const effectiveRequestWithUserAgent = { + ...effectiveRequest, + headers: { + ...effectiveRequest.headers, + "User-Agent": existingUserAgentHeader + ? effectiveRequest.headers[existingUserAgentHeader] + : "HoppscotchKernel/0.2.0", + }, + } + + const nativeRequest = await relayRequestToNativeAdapter( + effectiveRequestWithUserAgent + ) + const postProcessedRequest = postProcessRelayRequest(nativeRequest) + const relayExecution = Relay.execute(postProcessedRequest) + + setRelayExecution(relayExecution) + + const relayResponse = await relayExecution.response + if (E.isRight(relayResponse)) { + await this.cookieJar.captureResponseCookies( + relayResponse.right, + effectiveRequest.url + ) + } + return relayResponse + } catch (e) { + return E.left(e) + } + } +} diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/store.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/store.ts new file mode 100644 index 0000000..75c1821 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/store.ts @@ -0,0 +1,202 @@ +import { Service } from "dioc" +import type { RelayRequest } from "@hoppscotch/kernel" +import { Store } from "~/kernel/store" +import * as E from "fp-ts/Either" +import { + InputDomainSetting, + convertDomainSetting, +} from "~/helpers/functional/domain-settings" + +const STORE_NAMESPACE = "interceptors.native.v1" + +const STORE_KEYS = { + SETTINGS: "settings", +} as const + +interface StoredData { + version: string + domains: Record + lastUpdated: string +} + +const defaultDomainConfig: InputDomainSetting = { + version: "v1", + security: { + verifyHost: true, + verifyPeer: true, + }, + proxy: undefined, + options: { + followRedirects: true, + }, +} + +export class KernelInterceptorNativeStore extends Service { + public static readonly ID = "KERNEL_NATIVE_INTERCEPTOR_STORE" + private static readonly GLOBAL_DOMAIN = "*" + private static readonly DEFAULT_GLOBAL_SETTINGS: InputDomainSetting = { + ...defaultDomainConfig, + version: "v1", + } + + private domainSettings = new Map() + + async onServiceInit(): Promise { + const initResult = await Store.init() + if (E.isLeft(initResult)) { + console.error( + "[NativeStore] Failed to initialize store:", + initResult.left + ) + return + } + + await this.loadStore() + this.setupWatchers() + } + + private async loadStore(): Promise { + const loadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.SETTINGS + ) + + if (E.isRight(loadResult) && loadResult.right) { + const storedData = loadResult.right + this.domainSettings = new Map(Object.entries(storedData.domains)) + } + + if (!this.domainSettings.has(KernelInterceptorNativeStore.GLOBAL_DOMAIN)) { + this.domainSettings.set( + KernelInterceptorNativeStore.GLOBAL_DOMAIN, + KernelInterceptorNativeStore.DEFAULT_GLOBAL_SETTINGS + ) + await this.persistStore() + } + } + + private async setupWatchers() { + const watcher = await Store.watch(STORE_NAMESPACE, STORE_KEYS.SETTINGS) + watcher.on("change", async ({ value }) => { + if (value) { + const store = value as StoredData + this.domainSettings = new Map(Object.entries(store.domains)) + } + }) + } + + private async persistStore(): Promise { + const store: StoredData = { + version: "v1", + domains: Object.fromEntries(this.domainSettings), + lastUpdated: new Date().toISOString(), + } + + const saveResult = await Store.set( + STORE_NAMESPACE, + STORE_KEYS.SETTINGS, + store + ) + if (E.isLeft(saveResult)) { + console.error("[AgentStore] Failed to save store:", saveResult.left) + } + } + + private mergeSecurity( + ...settings: (Required["security"] | undefined)[] + ): Required["security"] | undefined { + return settings.reduce( + (acc, setting) => (setting ? { ...acc, ...setting } : acc), + undefined as Required["security"] | undefined + ) + } + + private mergeProxy( + ...settings: (Required["proxy"] | undefined)[] + ): Required["proxy"] | undefined { + return settings.reduce( + (acc, setting) => (setting ? { ...acc, ...setting } : acc), + undefined as Required["proxy"] | undefined + ) + } + + private mergeOptions( + ...settings: (Required["options"] | undefined)[] + ): Required["options"] | undefined { + return settings.reduce( + (acc, setting) => (setting ? { ...acc, ...setting } : acc), + undefined as Required["options"] | undefined + ) + } + + private getMergedSettings(domain: string): InputDomainSetting { + const domainSettings = this.domainSettings.get(domain) + const globalSettings = + domain !== KernelInterceptorNativeStore.GLOBAL_DOMAIN + ? this.domainSettings.get(KernelInterceptorNativeStore.GLOBAL_DOMAIN) + : undefined + + const result = { + security: this.mergeSecurity( + globalSettings?.security, + domainSettings?.security + ), + proxy: this.mergeProxy(globalSettings?.proxy, domainSettings?.proxy), + options: this.mergeOptions( + globalSettings?.options, + domainSettings?.options + ), + } + + return { version: "v1", ...result } + } + + public completeRequest( + request: Omit + ): RelayRequest { + const host = new URL(request.url).host + const settings = this.getMergedSettings(host) + const effective = convertDomainSetting(settings) + + if (E.isLeft(effective)) { + throw effective.left + } + + return { ...request, ...effective.right } + } + + public getDomainSettings(domain: string): InputDomainSetting { + return ( + this.domainSettings.get(domain) ?? { + ...defaultDomainConfig, + version: "v1", + } + ) + } + + public async saveDomainSettings( + domain: string, + settings: Partial + ): Promise { + const updatedSettings: InputDomainSetting = { + ...settings, + version: "v1", + } + + this.domainSettings.set(domain, updatedSettings) + await this.persistStore() + } + + public async clearDomainSettings(domain: string): Promise { + this.domainSettings.delete(domain) + await this.persistStore() + } + + public getDomains(): string[] { + return Array.from(this.domainSettings.keys()) + } + + public getAllDomainSettings(): Map { + return new Map(this.domainSettings) + } +} diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/index.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/index.ts new file mode 100644 index 0000000..00c9217 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/index.ts @@ -0,0 +1,505 @@ +import { markRaw } from "vue" +import type { + RelayRequest, + RelayResponse, + RelayError, + ContentType, + Method, + Version, +} from "@hoppscotch/kernel" +import { MediaType } from "@hoppscotch/kernel" +import { Service } from "dioc" +import { Relay } from "~/kernel/relay" +import SettingsProxy from "~/components/settings/Proxy.vue" +import { KernelInterceptorProxyStore } from "./store" +import { CookieJarService } from "~/services/cookie-jar.service" +import type { + KernelInterceptor, + ExecutionResult, + KernelInterceptorError, +} from "~/services/kernel-interceptor.service" +import * as E from "fp-ts/Either" +import * as O from "fp-ts/Option" +import { pipe } from "fp-ts/function" +import { getI18n } from "~/modules/i18n" +import { v4 } from "uuid" + +import { preProcessRelayRequest } from "~/helpers/functional/process-request" +import { parseBytesToJSON } from "~/helpers/functional/json" +import { decodeB64StringToArrayBuffer } from "~/helpers/utils/b64" + +type ProxyRequest = { + url: string + method: string + headers: Record + params: Record + data: string + wantsBinary: boolean + accessToken: string + auth?: { + username: string + password: string + } +} + +type ProxyResponse = { + success: boolean + isBinary: boolean + status: number + data: string + statusText: string + headers: Record +} + +export class ProxyKernelInterceptorService + extends Service + implements KernelInterceptor +{ + public static readonly ID = "KERNEL_PROXY_INTERCEPTOR_SERVICE" + private readonly store = this.bind(KernelInterceptorProxyStore) + private readonly cookieJar = this.bind(CookieJarService) + + public readonly id = "proxy" + public readonly name = (t: ReturnType) => + t("interceptor.proxy.name") + public readonly selectable = { type: "selectable" as const } + public readonly capabilities = { + method: new Set([ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS", + ]), + header: new Set(["stringvalue"]), + content: new Set(["text"]), + auth: new Set(["basic"]), + security: new Set([]), + proxy: new Set([]), + // Proxy now attaches stored cookies to outgoing requests through + // the shared send path. Receive-side capture is a follow-up + // because proxyscotch returns Set-Cookie as a header string. + advanced: new Set(["cookies"]), + } as const + public readonly settingsEntry = markRaw({ + title: (t: ReturnType) => + t("interceptor.proxy.settings_title"), + component: SettingsProxy, + }) + + private constructProxyRequest( + request: RelayRequest, + accessToken: string + ): ProxyRequest { + // NOTE: This should be conditional but for now setting it to true for backwards compat, + // see std/interceptor/proxy.ts for more info. + const wantsBinary = true + let requestData: any = null + + // This is required for backwards compatibility with current proxyscotch impl + if (request.content) { + switch (request.content.kind) { + case "text": + // Text content - pass string directly + requestData = + typeof request.content.content === "string" + ? request.content.content + : String(request.content.content) + break + + case "json": + requestData = + typeof request.content.content === "string" + ? request.content.content + : JSON.stringify(request.content.content) + break + + case "binary": + if ( + request.content.content instanceof Blob || + request.content.content instanceof File + ) { + requestData = request.content.content + } else if (typeof request.content.content === "string") { + // This is rather rare but just in case + try { + const base64 = + request.content.content.split(",")[1] || request.content.content + const binaryString = window.atob(base64) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + // Pass the Uint8Array directly, not .buffer, to avoid offset issues + requestData = new Blob([bytes]) + } catch (e) { + console.error("Error converting binary data:", e) + requestData = request.content.content + } + } else if (request.content.content instanceof Uint8Array) { + // Wrap Uint8Array in Blob for proxy compatibility, avoiding .buffer to prevent offset issues + requestData = new Blob([request.content.content]) + } else { + requestData = request.content.content + } + break + + case "multipart": + // `multipart` has separate handling in `execute`, + // where we combine request with request body + // so removing that part right now + requestData = "" + break + + case "urlencoded": + // URL-encoded form data - pass string directly + requestData = + typeof request.content.content === "string" + ? request.content.content + : String(request.content.content) + break + + case "xml": + // XML content - pass string directly + requestData = + typeof request.content.content === "string" + ? request.content.content + : String(request.content.content) + break + + case "form": + // Form data - convert to URLSearchParams for JSON serialization + // FormData objects are not JSON-serializable and will be lost when proxied + if (request.content.content instanceof FormData) { + const params = new URLSearchParams() + for (const [key, value] of request.content.content.entries()) { + // Only handle string values - File/Blob uploads not supported via proxy + if (typeof value === "string") { + params.append(key, value) + } + } + requestData = params.toString() + } else { + requestData = request.content.content + } + break + + default: + requestData = request.content.content + } + } + + return { + accessToken, + wantsBinary, + url: request.url, + method: request.method, + headers: request.headers, + params: request.params, + data: requestData, + auth: + request.auth?.kind === "basic" + ? { + username: request.auth.username, + password: request.auth.password, + } + : undefined, + } + } + + public execute( + request: RelayRequest + ): ExecutionResult { + let innerCancel: (() => Promise) | null = null + let cancelled = false + let resolveCancelled: + | ((result: E.Either) => void) + | null = null + + const cancelledResult: E.Either = E.left({ + kind: "abort", + message: "cancelled", + }) + + const cancellation = new Promise>( + (resolve) => { + resolveCancelled = resolve + } + ) + + // Wait for persisted proxy settings to hydrate before constructing the + // request, otherwise the in-memory default (`proxy.hoppscotch.io`) is used + // when `execute()` is called during initial page load — e.g. the OAuth + // redirect handler on `/oauth`. + const pending = this.store.whenReady().then(async () => { + if (cancelled) return null + + const settings = this.store.getSettings() + const accessToken = settings.accessToken + const proxyUrl = settings.proxyUrl + + const processedRequest = preProcessRelayRequest(request) + + // Same shared send path as native and agent. proxyscotch returns + // Set-Cookie as a header string rather than structured cookies, so + // receive-side capture for the proxy path is a separate follow-up. + await this.cookieJar.applyCookiesToRequest(processedRequest) + + let content: ContentType + const multipartKey = `proxyRequestData-${v4()}` + + if ( + processedRequest.content && + processedRequest.content.kind === "multipart" && + processedRequest.content.content instanceof FormData + ) { + const modifiedRequest = { ...processedRequest } + + const proxyRequest = this.constructProxyRequest( + modifiedRequest, + accessToken + ) + + const formData = processedRequest.content.content as FormData + const newFormData = new FormData() + + for (const [key, value] of formData.entries()) { + newFormData.append(key, value) + } + + const proxyRequestString = JSON.stringify(proxyRequest) + newFormData.append(multipartKey, proxyRequestString) + + content = { + kind: "multipart", + content: newFormData, + mediaType: MediaType.MULTIPART_FORM_DATA, + } + } else { + const proxyRequest = this.constructProxyRequest( + processedRequest, + accessToken + ) + + content = { + kind: "json", + content: proxyRequest, + mediaType: MediaType.APPLICATION_JSON, + } + } + + const proxyRelayRequest: RelayRequest = { + id: Date.now(), + url: proxyUrl, + method: "POST" as Method, + version: "HTTP/1.1" as Version, + headers: { + "content-type": content.mediaType, + ...(content.kind === "multipart" + ? { + "multipart-part-key": multipartKey, + } + : {}), + }, + content, + } + + const relayExecution = Relay.execute(proxyRelayRequest) + innerCancel = relayExecution.cancel + return relayExecution + }) + + const response = pipe( + Promise.race([ + pending.then( + ( + relayExecution + ): + | Promise> + | E.Either => { + if (!relayExecution || cancelled) { + return cancelledResult + } + return relayExecution.response + } + ), + cancellation, + ]), + (promise) => + promise.then((either) => + pipe( + either, + E.mapLeft((error): KernelInterceptorError => { + const humanMessage = { + heading: (t: ReturnType) => { + switch (error.kind) { + case "network": + return t("error.network.heading") + case "timeout": + return t("error.timeout.heading") + case "certificate": + return t("error.certificate.heading") + case "auth": + return t("error.auth.heading") + case "proxy": + return t("error.proxy.heading") + case "parse": + return t("error.parse.heading") + case "version": + return t("error.version.heading") + case "abort": + return t("error.aborted.heading") + default: + return t("error.unknown.heading") + } + }, + description: (t: ReturnType) => { + switch (error.kind) { + case "network": + return t("error.network.description", { + message: error.message, + }) + case "timeout": + return t("error.timeout.description", { + phase: error.phase ?? t("error.unknown.phase"), + }) + case "certificate": + return t("error.certificate.description", { + message: error.message, + }) + case "auth": + return t("error.auth.description", { + message: error.message, + }) + case "proxy": + return t("error.proxy.description", { + message: error.message, + }) + case "parse": + return t("error.parse.description", { + message: error.message, + }) + case "version": + return t("error.version.description", { + message: error.message, + }) + case "abort": + return t("error.aborted.description", { + message: error.message, + }) + default: + return t("error.unknown.description") + } + }, + } + return { humanMessage, error } + }), + E.chain((res) => { + const proxyResponse = parseBytesToJSON( + res.body.body + ) + + if (O.isNone(proxyResponse)) { + return E.left({ + humanMessage: { + heading: (t) => t("error.network.heading"), + description: (t) => + t("error.network.description", { + message: "Proxy request failed", + cause: "Proxy server may be unresponsive", + }), + }, + error: { + kind: "network", + message: "Proxy request failed", + }, + }) + } + + const parsedProxyResponse = proxyResponse.value + + if (!parsedProxyResponse?.success) { + return E.left({ + humanMessage: { + heading: (t) => t("error.network.heading"), + description: (t) => + t("error.network.description", { + message: "Proxy request failed", + cause: "Proxy server may be unresponsive", + }), + }, + error: { + kind: "network", + message: "Proxy request failed", + }, + }) + } + + // NOTE: This should be conditional but seems to be hit always, + // see std/interceptor/proxy.ts for more info. Also see the above similar note. + if (parsedProxyResponse.isBinary) { + const decodedData = new Uint8Array( + decodeB64StringToArrayBuffer(parsedProxyResponse.data) + ) + + // NOTE: This is also for backwards compat, + // better solution would be to ask for raw bytes from proxyscotch. + const jsonResult = parseBytesToJSON(decodedData) + + if (O.isSome(jsonResult)) { + return E.right({ + ...res, + status: parsedProxyResponse.status, + statusText: parsedProxyResponse.statusText, + headers: parsedProxyResponse.headers, + body: { + body: new TextEncoder().encode( + JSON.stringify(jsonResult.value) + ), + mediaType: "application/json", + }, + }) + } + return E.right({ + ...res, + status: parsedProxyResponse.status, + statusText: parsedProxyResponse.statusText, + headers: parsedProxyResponse.headers, + body: { + body: decodedData, + mediaType: + parsedProxyResponse.headers["content-type"] || + "application/octet-stream", + }, + }) + } + + return E.right({ + ...res, + status: parsedProxyResponse.status, + statusText: parsedProxyResponse.statusText, + headers: parsedProxyResponse.headers, + body: { + body: new TextEncoder().encode(parsedProxyResponse.data), + mediaType: + parsedProxyResponse.headers["content-type"] || "text/plain", + }, + }) + }) + ) + ) + ) + + return { + cancel: async () => { + if (cancelled) return + cancelled = true + resolveCancelled?.(cancelledResult) + if (innerCancel) await innerCancel() + }, + response, + } + } +} diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/store.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/store.ts new file mode 100644 index 0000000..b78d61f --- /dev/null +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/store.ts @@ -0,0 +1,207 @@ +import { ref, readonly, toRaw, type Ref, type DeepReadonly } from "vue" +import { Service } from "dioc" +import { Store } from "~/kernel/store" +import { + getDefaultProxyUrl, + DEFAULT_HOPP_PROXY_URL, + isValidProxyUrl, +} from "~/helpers/proxyUrl" +import * as E from "fp-ts/Either" + +const STORE_NAMESPACE = "interceptors.proxy.v1" +const SETTINGS_KEY = "settings" + +export type ProxySettings = { + version: "v1" + proxyUrl: string + accessToken: string +} + +interface StoredData { + version: string + settings: ProxySettings + lastUpdated: string +} + +/** + * Build fresh default settings. + * Called as a function (not a static const) so that `getDefaultProxyUrl()` + * can resolve against the current platform, which isn't available at + * module-load time. + */ +async function buildDefaultSettings(): Promise { + return { + version: "v1", + proxyUrl: await getDefaultProxyUrl(), + accessToken: import.meta.env.VITE_PROXYSCOTCH_ACCESS_TOKEN ?? "", + } +} + +/** + * Reactive proxy settings store. + * + * Exposes `settings$` as a readonly ref, any component or service that reads + * `settings$.value` will automatically re-render when settings change. + */ +export class KernelInterceptorProxyStore extends Service { + public static readonly ID = "KERNEL_PROXY_INTERCEPTOR_STORE" + + private readonly _settings = ref({ + version: "v1", + proxyUrl: DEFAULT_HOPP_PROXY_URL, + accessToken: import.meta.env.VITE_PROXYSCOTCH_ACCESS_TOKEN ?? "", + }) + + private _resolveReady!: () => void + // Resolves once persisted settings have been loaded into `_settings`. + // Callers that read `proxyUrl` on initial page load (e.g. the OAuth redirect + // handler) must await this to avoid using the in-memory default URL. + private readonly _ready: Promise = new Promise((resolve) => { + this._resolveReady = resolve + }) + + /** + * Reactive, read-only view of the current proxy settings. + */ + public readonly settings$: DeepReadonly> = readonly( + this._settings + ) + + async onServiceInit(): Promise { + try { + const initResult = await Store.init() + if (E.isLeft(initResult)) { + console.error( + "[ProxyStore] Failed to initialize store:", + initResult.left + ) + return + } + + await this.loadSettings() + + const watcher = await Store.watch(STORE_NAMESPACE, SETTINGS_KEY) + watcher.on("change", async ({ value }: { value?: unknown }) => { + if (value) { + const storedData = value as StoredData + const incomingProxyUrl = storedData.settings?.proxyUrl + this._settings.value = { + ...this._settings.value, + // Only sync user-configurable fields from external changes. + // Keep the current value if the incoming one is missing (older + // schema) or invalid (another tab wrote junk via a path that + // bypassed validation). accessToken stays env-derived. + proxyUrl: + incomingProxyUrl && isValidProxyUrl(incomingProxyUrl) + ? incomingProxyUrl + : this._settings.value.proxyUrl, + } + } + }) + } catch (error) { + console.error("[ProxyStore] Failed to finish setup:", error) + } finally { + // Always resolve readiness so consumers never hang forever. + this._resolveReady() + } + } + + public whenReady(): Promise { + return this._ready + } + + private async loadSettings(): Promise { + const loadResult = await Store.get( + STORE_NAMESPACE, + SETTINGS_KEY + ) + + const defaults = await buildDefaultSettings() + + if (E.isRight(loadResult) && loadResult.right) { + const storedData = loadResult.right + // Reject persisted proxyUrl that wouldn't survive backend validation + // (e.g. junk left over from before client-side validation existed). + // Without this, execute() would post requests to an invalid URL. + const persistedProxyUrl = storedData.settings?.proxyUrl + const proxyUrl = + persistedProxyUrl && isValidProxyUrl(persistedProxyUrl) + ? persistedProxyUrl + : defaults.proxyUrl + this._settings.value = { + ...defaults, + // Only restore user-configurable fields from storage. + // accessToken is env-derived (VITE_PROXYSCOTCH_ACCESS_TOKEN) and + // must always reflect the current deployment, not a stale persisted value. + proxyUrl, + } + // If we had to discard a bad persisted value, write the corrected + // settings back so we don't repeat the fallback on every boot. + if (proxyUrl !== persistedProxyUrl) { + await this.persistSettings() + } + } else { + this._settings.value = { ...defaults } + await this.persistSettings() + } + } + + private async persistSettings(): Promise { + const rawSettings = toRaw(this._settings.value) + + const storedData: StoredData = { + version: "v1", + settings: { ...rawSettings }, + lastUpdated: new Date().toISOString(), + } + + const saveResult = await Store.set( + STORE_NAMESPACE, + SETTINGS_KEY, + storedData + ) + + if (E.isLeft(saveResult)) { + console.error("[ProxyStore] Failed to save settings:", saveResult.left) + } + } + + /** + * Update user-configurable proxy settings. + * Only `proxyUrl` is user-configurable, `accessToken` and `version` are + * derived at runtime and cannot be overwritten by callers. + */ + public async updateSettings( + patch: Pick + ): Promise { + // Belt-and-suspenders: the settings UI already gates this, but the + // store is a public API any future caller can hit, and execute() uses + // proxyUrl directly. Reject invalid input here so the interceptor + // can't be silently broken from outside the settings screen. + if (!isValidProxyUrl(patch.proxyUrl)) { + console.warn( + "[ProxyStore] Refused to persist invalid proxy URL:", + patch.proxyUrl + ) + return + } + this._settings.value = { + ...this._settings.value, + proxyUrl: patch.proxyUrl, + } + await this.persistSettings() + } + + /** + * @deprecated Use `settings$` for reactive access. This exists only for + * non-reactive contexts (e.g. inside `execute()` in the interceptor service). + */ + public getSettings(): ProxySettings { + return { ...this._settings.value } + } + + public async resetSettings(): Promise { + this._settings.value = await buildDefaultSettings() + await this.persistSettings() + } +} diff --git a/packages/hoppscotch-common/src/platform/std/kernel-io.ts b/packages/hoppscotch-common/src/platform/std/kernel-io.ts new file mode 100644 index 0000000..2293baf --- /dev/null +++ b/packages/hoppscotch-common/src/platform/std/kernel-io.ts @@ -0,0 +1,11 @@ +import { KernelIO } from "~/platform/kernel-io" +import { Io } from "~/kernel/io" + +export const kernelIO: KernelIO = { + saveFileWithDialog(opts) { + return Io.saveFileWithDialog(opts) + }, + openExternalLink(opts) { + return Io.openExternalLink(opts) + }, +} diff --git a/packages/hoppscotch-common/src/platform/std/ui/footerItem.ts b/packages/hoppscotch-common/src/platform/std/ui/footerItem.ts new file mode 100644 index 0000000..c5983ad --- /dev/null +++ b/packages/hoppscotch-common/src/platform/std/ui/footerItem.ts @@ -0,0 +1,25 @@ +import { HoppFooterMenuItem } from "../../ui" +import IconGift from "~icons/lucide/gift" +import IconActivity from "~icons/lucide/activity" + +export const whatsNew: HoppFooterMenuItem = { + id: "whats-new", + text: (t) => t("app.whats_new"), + icon: IconGift, + action: { + type: "link", + href: "https://docs.hoppscotch.io/documentation/changelog", + }, +} + +export const status: HoppFooterMenuItem = { + id: "status", + text: (t) => t("app.status"), + icon: IconActivity, + action: { + type: "link", + href: "https://status.hoppscotch.io", + }, +} + +export const stdFooterItems = [whatsNew, status] diff --git a/packages/hoppscotch-common/src/platform/std/ui/supportOptionsItem.ts b/packages/hoppscotch-common/src/platform/std/ui/supportOptionsItem.ts new file mode 100644 index 0000000..a294248 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/std/ui/supportOptionsItem.ts @@ -0,0 +1,100 @@ +import { invokeAction } from "~/helpers/actions" +import { HoppSupportOptionsMenuItem } from "~/platform/ui" +import IconBook from "~icons/lucide/book" +import IconGift from "~icons/lucide/gift" +import IconZap from "~icons/lucide/zap" +import IconGitHub from "~icons/lucide/github" +import IconTwitter from "~icons/brands/twitter" +import IconDiscord from "~icons/brands/discord" +import IconUserPlus from "~icons/lucide/user-plus" + +export const documentation: HoppSupportOptionsMenuItem = { + id: "documentation", + text: (t) => t("app.documentation"), + subtitle: (t) => t("support.documentation"), + icon: IconBook, + action: { + type: "link", + href: "https://docs.hoppscotch.io", + }, +} + +export const shortcuts: HoppSupportOptionsMenuItem = { + id: "shortcuts", + text: (t) => t("app.keyboard_shortcuts"), + subtitle: (t) => t("support.shortcuts"), + icon: IconZap, + action: { + type: "custom", + do() { + invokeAction("flyouts.keybinds.toggle") + }, + }, +} + +export const changelog: HoppSupportOptionsMenuItem = { + id: "changelog", + text: (t) => t("app.whats_new"), + subtitle: (t) => t("support.changelog"), + icon: IconGift, + action: { + type: "link", + href: "https://docs.hoppscotch.io/documentation/changelog", + }, +} + +export const github: HoppSupportOptionsMenuItem = { + id: "github", + text: (t) => t("app.github"), + subtitle: (t) => t("support.github"), + icon: IconGitHub, + action: { + type: "link", + href: "https://hoppscotch.io/github", + }, +} + +export const discord: HoppSupportOptionsMenuItem = { + id: "discord", + text: (t) => t("app.join_discord_community"), + subtitle: (t) => t("support.community"), + icon: IconDiscord, + action: { + type: "link", + href: "https://hoppscotch.io/discord", + }, +} + +export const twitter: HoppSupportOptionsMenuItem = { + id: "discord", + text: (t) => t("app.twitter"), + subtitle: (t) => t("support.twitter"), + icon: IconTwitter, + action: { + type: "link", + href: "https://hoppscotch.io/twitter", + }, +} + +export const invite: HoppSupportOptionsMenuItem = { + id: "invite", + text: (t) => t("app.invite"), + subtitle: (t) => t("shortcut.miscellaneous.invite"), + icon: IconUserPlus, + action: { + type: "custom", + do() { + invokeAction("modals.share.toggle") + }, + }, +} + +export const stdSupportOptionItems: HoppSupportOptionsMenuItem[] = [ + documentation, + shortcuts, + changelog, + github, + invite, + discord, + twitter, +] diff --git a/packages/hoppscotch-common/src/platform/sync.ts b/packages/hoppscotch-common/src/platform/sync.ts new file mode 100644 index 0000000..71b60cf --- /dev/null +++ b/packages/hoppscotch-common/src/platform/sync.ts @@ -0,0 +1,41 @@ +/** + * Per-feature sync overrides. The default sync code in `lib/sync/*` talks + * to the upstream Postgres backend directly; when a shell's backend + * diverges (e.g. cloud has a dedicated `updateUserGlobalEnvironment` + * mutation that legacy/selfhost backends don't), the shell can inject a + * replacement here and the sync layer will feature-detect. + * + * Each field is optional — when omitted the sync layer falls back to its + * legacy code path (currently: `updateUserEnvironment` with an empty + * `name`, which the legacy backend treats as a global update). + */ +export type SyncPlatformDef = { + environment?: { + /** + * Update the user's global environment. Receives the backend env id + * and the pre-serialised JSON payload (the `{ v, variables }` + * wrapper). Fire-and-forget — the sync layer doesn't await the + * return. + */ + updateUserGlobalEnvironment?: (id: string, variables: string) => unknown + } + history?: { + /** + * Resolve whether the backend currently has user-history storing + * enabled. Self-host exposes this through the `isUserHistoryEnabled` + * infra-config; backends without that admin toggle (e.g. cloud) omit + * this hook and the sync layer treats history as always enabled. + * Rejects on fetch failure so the sync layer can flag its error state. + */ + getHistoryStoreStatus?: () => Promise + /** + * Subscribe to backend-side history-enabled toggles (the self-host + * infra-config `USER_HISTORY_STORE_ENABLED`). `onStatusChange` fires + * with each new enabled state; returns an unsubscribe handle the sync + * layer tears down alongside the other history subscriptions. + */ + subscribeToHistoryStoreStatus?: ( + onStatusChange: (enabled: boolean) => void + ) => { unsubscribe: () => void } + } +} diff --git a/packages/hoppscotch-common/src/platform/tab.ts b/packages/hoppscotch-common/src/platform/tab.ts new file mode 100644 index 0000000..83e6a66 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/tab.ts @@ -0,0 +1,11 @@ +import { PersistableTabState } from "~/services/tab" +import { HoppUser } from "./auth" +import { HoppTabDocument } from "~/helpers/rest/document" + +export type TabStatePlatformDef = { + loadTabStateFromSync: () => Promise | null> + writeCurrentTabState: ( + user: HoppUser, + persistableTabState: PersistableTabState + ) => Promise +} diff --git a/packages/hoppscotch-common/src/platform/ui.ts b/packages/hoppscotch-common/src/platform/ui.ts new file mode 100644 index 0000000..b199660 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/ui.ts @@ -0,0 +1,83 @@ +import { Ref, Component } from "vue" +import { getI18n } from "~/modules/i18n" + +export type HoppFooterMenuItem = { + id: string + text: (t: ReturnType) => string + icon: Component + action: { type: "link"; href: string } | { type: "custom"; do: () => void } +} + +export type HoppSupportOptionsMenuItem = { + id: string + text: (t: ReturnType) => string + subtitle: (t: ReturnType) => string + icon: Component + action: { type: "link"; href: string } | { type: "custom"; do: () => void } +} + +export type UIPlatformDef = { + appHeader?: { + paddingTop?: Ref + paddingLeft?: Ref + + /** + * A function which is called when the header area of the app receives a click event + */ + onHeaderAreaClick?: () => void + } + onCodemirrorInstanceMount?: (element: HTMLElement) => void + + /** + * Additional menu items shown in the "Help and Feedback" menu + * in the app footer. + */ + additionalFooterMenuItems?: HoppFooterMenuItem[] + + /** + * Additional Support Options menu items shown in the app header + */ + additionalSupportOptionsMenuItems?: HoppSupportOptionsMenuItem[] + + /** + * Additional Settings Section components in the settings page + */ + additionalSettingsSections?: Component[] + + /** + * Additional profile Section components in the profile page + */ + additionalProfileSections?: Component[] + + /** + * Custom history related components to be shown in the history page + */ + additionalHistoryComponent?: Component + + /** + * Custom sidebar header item to be shown in the sidebar header + */ + additionalSidebarHeaderItem?: Component + + /** + * Custom invite component to be shown in the team invite page + */ + additionalTeamInviteComponent?: Component + + /** + * Custom edit component to be shown in the team edit page + */ + additionalTeamEditComponent?: Component + + /** + * More info shown in the danger zone section while attempting user deletion + * Sample use case includes displaying the instance information on cloud instances + */ + additionalUserDeletionSoleTeamOwnerInfo?: Component + + /** + * Customize embeds appearance at the platform level + * Sample use case includes bringing embeds behind auth on sub domain based cloud instances + */ + additionalEmbedsComponent?: Component +} diff --git a/packages/hoppscotch-common/src/platform/update-state.ts b/packages/hoppscotch-common/src/platform/update-state.ts new file mode 100644 index 0000000..6235b95 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/update-state.ts @@ -0,0 +1,69 @@ +import { z } from "zod" + +/** + * Shared schema and types for the desktop app's auto-updater state. + * + * The updater state is written to `tauri-plugin-store` by the Tauri shell + * (`hoppscotch-desktop/src/utils/updater.ts`) and read by both the shell's + * persistence service and the webview's settings page. This module is the + * single source of truth for the definition that crosses all three + * boundaries (Rust, shell JS, webview JS via the store file). + * + * Persisted form is deliberately flat (status + optional fields). The + * webview's `useUpdateCheck` composable derives a discriminated union + * over this flat form for its internal state, but the wire format that + * hits disk stays simple so existing Rust writers continue to work + * unchanged. + */ + +// Store coordinates. Both the shell persistence service and the webview +// composable reference these constants rather than string literals. +export const UPDATE_STATE_STORE_NAMESPACE = "hoppscotch-desktop.v1" +export const UPDATE_STATE_STORE_KEY = "updateState" + +// `UpdateStatus` as a `const` object rather than a TS `enum` so: +// 1. The values are plain string literals, so they cross the store +// boundary as JSON without extra conversion. +// 2. The inferred union type (`"idle" | "checking" | ...`) narrows +// cleanly in switch statements and matches Zod's `z.enum` output. +// 3. It imports zero-cost into the webview bundle, where TS enums can +// produce runtime objects that tree-shaking sometimes fails to drop. +export const UpdateStatus = { + IDLE: "idle", + CHECKING: "checking", + AVAILABLE: "available", + NOT_AVAILABLE: "not_available", + DOWNLOADING: "downloading", + INSTALLING: "installing", + READY_TO_RESTART: "ready_to_restart", + ERROR: "error", +} as const + +export type UpdateStatus = (typeof UpdateStatus)[keyof typeof UpdateStatus] + +export const UPDATE_STATUS_SCHEMA = z.enum([ + UpdateStatus.IDLE, + UpdateStatus.CHECKING, + UpdateStatus.AVAILABLE, + UpdateStatus.NOT_AVAILABLE, + UpdateStatus.DOWNLOADING, + UpdateStatus.INSTALLING, + UpdateStatus.READY_TO_RESTART, + UpdateStatus.ERROR, +]) + +export const DOWNLOAD_PROGRESS_SCHEMA = z.object({ + downloaded: z.number(), + total: z.number().optional(), +}) + +export type DownloadProgress = z.infer + +export const UPDATE_STATE_SCHEMA = z.object({ + status: UPDATE_STATUS_SCHEMA, + version: z.string().optional(), + message: z.string().optional(), + progress: DOWNLOAD_PROGRESS_SCHEMA.optional(), +}) + +export type UpdateState = z.infer diff --git a/packages/hoppscotch-common/src/services/__tests__/banner.service.spec.ts b/packages/hoppscotch-common/src/services/__tests__/banner.service.spec.ts new file mode 100644 index 0000000..66f749f --- /dev/null +++ b/packages/hoppscotch-common/src/services/__tests__/banner.service.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest" +import { TestContainer } from "dioc/testing" +import { getI18n } from "~/modules/i18n" +import { + BannerService, + BANNER_PRIORITY_LOW, + BANNER_PRIORITY_HIGH, + BannerContent, +} from "../banner.service" + +describe("BannerService", () => { + const container = new TestContainer() + const banner = container.bind(BannerService) + + it("should be able to show and remove a banner", () => { + const bannerContent: BannerContent = { + type: "info", + text: (t: ReturnType) => t("Info Banner"), + score: BANNER_PRIORITY_LOW, + } + + const bannerId = banner.showBanner(bannerContent) + expect(banner.content.value).toEqual({ + id: bannerId, + content: bannerContent, + }) + + banner.removeBanner(bannerId) + expect(banner.content.value).toBeNull() + }) + + it("should show the banner with the highest score", () => { + const lowPriorityBanner: BannerContent = { + type: "info", + text: (t: ReturnType) => t("Low Priority Banner"), + score: BANNER_PRIORITY_LOW, + } + + const highPriorityBanner: BannerContent = { + type: "warning", + text: (t: ReturnType) => t("High Priority Banner"), + score: BANNER_PRIORITY_HIGH, + } + + banner.showBanner(lowPriorityBanner) + const highPriorityBannerID = banner.showBanner(highPriorityBanner) + + expect(banner.content.value).toEqual({ + id: highPriorityBannerID, + content: highPriorityBanner, + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/__tests__/cookie-jar.service.spec.ts b/packages/hoppscotch-common/src/services/__tests__/cookie-jar.service.spec.ts new file mode 100644 index 0000000..0229496 --- /dev/null +++ b/packages/hoppscotch-common/src/services/__tests__/cookie-jar.service.spec.ts @@ -0,0 +1,440 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { TestContainer } from "dioc/testing" +import { Cookie } from "@hoppscotch/data" + +import { CookieJarService } from "../cookie-jar.service" + +const cookie = ( + overrides: Partial & Pick +): Cookie => ({ + domain: "example.com", + path: "/", + httpOnly: false, + secure: false, + sameSite: "Lax", + ...overrides, +}) + +describe("CookieJarService", () => { + let container: TestContainer + let service: CookieJarService + + beforeEach(async () => { + container = new TestContainer() + service = container.bind(CookieJarService) + // `Store.init` fails outside the desktop runtime, the service + // sets `initFailed` and stays operational on its in-memory + // map. `whenReady` resolves either way. + await service.whenReady() + }) + + describe("canonStoreDomain", () => { + it("strips a leading dot, lowercases, and trims", () => { + expect(service.canonStoreDomain(".Example.COM")).toBe("example.com") + expect(service.canonStoreDomain(" example.com ")).toBe("example.com") + expect(service.canonStoreDomain("Example.COM")).toBe("example.com") + }) + }) + + describe("toMap migration on hydrate", () => { + it("canonicalizes a leading-dot domain key from on-disk payload", () => { + const map = (service as any).toMap({ + ".Example.COM": [ + cookie({ name: "a", value: "1", domain: ".Example.COM" }), + ], + }) + expect(map.has("example.com")).toBe(true) + expect(map.get("example.com")?.[0].domain).toBe("example.com") + }) + + it("merges two non-canonical keys that collapse to the same canon", () => { + const map = (service as any).toMap({ + ".Example.COM": [ + cookie({ name: "a", value: "1", domain: ".Example.COM" }), + ], + "EXAMPLE.com": [ + cookie({ name: "b", value: "2", domain: "EXAMPLE.com" }), + ], + }) + expect(map.get("example.com")).toHaveLength(2) + }) + + it("dedupes by (name, path) across non-canonical keys that collapse onto the same canon", () => { + const map = (service as any).toMap({ + ".Example.COM": [cookie({ name: "sid", value: "old", path: "/" })], + "EXAMPLE.com": [cookie({ name: "sid", value: "new", path: "/" })], + }) + const bucket = map.get("example.com") + expect(bucket).toHaveLength(1) + expect(bucket?.[0].value).toBe("new") + }) + + it("drops an entry whose key canonicalizes to empty", () => { + const map = (service as any).toMap({ + ".": [cookie({ name: "a", value: "1" })], + }) + expect(map.size).toBe(0) + }) + }) + + describe("getCookiesForURL", () => { + it("matches the exact host per RFC 6265 5.1.3", async () => { + await service.upsertCookies([cookie({ name: "a", value: "1" })]) + const cookies = service.getCookiesForURL(new URL("https://example.com/")) + expect(cookies).toHaveLength(1) + expect(cookies[0].value).toBe("1") + }) + + it("does not match `evil-example.com` against `example.com`", async () => { + await service.upsertCookies([cookie({ name: "a", value: "1" })]) + const cookies = service.getCookiesForURL( + new URL("https://evil-example.com/") + ) + expect(cookies).toHaveLength(0) + }) + + it("matches a subdomain when the cookie has a parent domain", async () => { + await service.upsertCookies([cookie({ name: "a", value: "1" })]) + const cookies = service.getCookiesForURL( + new URL("https://api.example.com/") + ) + expect(cookies).toHaveLength(1) + }) + + it("compares the host case-insensitively per RFC 6265 5.1.2", async () => { + await service.upsertCookies([cookie({ name: "a", value: "1" })]) + const cookies = service.getCookiesForURL(new URL("https://EXAMPLE.com/")) + expect(cookies).toHaveLength(1) + }) + + it("sorts results longest-path-first per RFC 6265 5.4", async () => { + await service.upsertCookies([ + cookie({ name: "sid", value: "root", path: "/" }), + cookie({ name: "sid", value: "admin", path: "/admin" }), + ]) + const cookies = service.getCookiesForURL( + new URL("https://example.com/admin/users") + ) + expect(cookies).toHaveLength(2) + expect(cookies[0].path).toBe("/admin") + expect(cookies[1].path).toBe("/") + }) + + it("drops secure cookies on http URLs", async () => { + await service.upsertCookies([ + cookie({ name: "a", value: "1", secure: true }), + ]) + expect( + service.getCookiesForURL(new URL("http://example.com/")) + ).toHaveLength(0) + expect( + service.getCookiesForURL(new URL("https://example.com/")) + ).toHaveLength(1) + }) + + it("treats a non-finite expires as a session cookie, not expired", async () => { + await service.upsertCookies([ + cookie({ name: "a", value: "1", expires: "not-a-date" }), + ]) + expect( + service.getCookiesForURL(new URL("https://example.com/")) + ).toHaveLength(1) + }) + + it("respects path boundary per RFC 6265 5.1.4", async () => { + await service.upsertCookies([ + cookie({ name: "a", value: "1", path: "/admin" }), + ]) + expect( + service.getCookiesForURL(new URL("https://example.com/admin")) + ).toHaveLength(1) + expect( + service.getCookiesForURL(new URL("https://example.com/admin/x")) + ).toHaveLength(1) + expect( + service.getCookiesForURL(new URL("https://example.com/administrator")) + ).toHaveLength(0) + }) + }) + + describe("upsertCookies", () => { + it("canonicalizes domain (strip leading dot, lowercase, trim)", async () => { + await service.upsertCookies([ + cookie({ name: "a", value: "1", domain: " .Example.COM " }), + ]) + const cookies = service.getCookiesForURL(new URL("https://example.com/")) + expect(cookies).toHaveLength(1) + }) + + it("normalizes an empty-string path to `/`", async () => { + await service.upsertCookies([cookie({ name: "a", value: "1", path: "" })]) + const stored = service.cookieJar.value.get("example.com")?.[0] + expect(stored?.path).toBe("/") + }) + + it("skips a cookie with empty domain", async () => { + await service.upsertCookies([ + cookie({ name: "a", value: "1", domain: "" }), + ]) + expect(service.cookieJar.value.size).toBe(0) + }) + + it("skips a cookie whose canonicalized domain has internal whitespace", async () => { + await service.upsertCookies([ + cookie({ name: "a", value: "1", domain: "ex ample.com" }), + ]) + expect(service.cookieJar.value.size).toBe(0) + }) + + it("defaults secure to false when a script-set cookie omits the flag", async () => { + await service.upsertCookies([ + { name: "a", value: "1", domain: "example.com", path: "/" } as Cookie, + ]) + const stored = service.cookieJar.value.get("example.com")?.[0] + expect(stored?.secure).toBe(false) + }) + + it("defaults httpOnly to false when a script-set cookie omits the flag", async () => { + await service.upsertCookies([ + { name: "a", value: "1", domain: "example.com", path: "/" } as Cookie, + ]) + const stored = service.cookieJar.value.get("example.com")?.[0] + expect(stored?.httpOnly).toBe(false) + }) + + it("skips a script-set cookie whose name is not a string", async () => { + await service.upsertCookies([ + { + name: 42 as unknown as string, + value: "1", + domain: "example.com", + } as Cookie, + ]) + expect(service.cookieJar.value.size).toBe(0) + }) + + it("produces a cookie that parseStored accepts after upsert", async () => { + await service.upsertCookies([ + { name: "a", value: "1", domain: "example.com" } as Cookie, + ]) + const stored = service.cookieJar.value.get("example.com") + expect(stored).toBeDefined() + // Round-trip through parseStored to confirm the upsert output + // satisfies the load-time shape check. + const payload = { + domains: { "example.com": stored }, + } + expect(() => (service as any).parseStored(payload)).not.toThrow() + }) + + it("merges by (domain, name, path) instead of duplicating", async () => { + await service.upsertCookies([ + cookie({ name: "a", value: "old" }), + cookie({ name: "a", value: "new" }), + ]) + const stored = service.cookieJar.value.get("example.com") + expect(stored).toHaveLength(1) + expect(stored?.[0].value).toBe("new") + }) + }) + + describe("deleteCookies", () => { + it("canonicalizes the target domain", async () => { + await service.upsertCookies([cookie({ name: "a", value: "1" })]) + await service.deleteCookies([ + { domain: ".Example.COM", name: "a", path: "/" }, + ]) + expect(service.cookieJar.value.size).toBe(0) + }) + + it("treats an empty path as `/`", async () => { + await service.upsertCookies([cookie({ name: "a", value: "1" })]) + await service.deleteCookies([ + { domain: "example.com", name: "a", path: "" }, + ]) + expect(service.cookieJar.value.size).toBe(0) + }) + }) + + describe("applyCookiesToRequest", () => { + it("attaches matching cookies to the Cookie header", async () => { + await service.upsertCookies([cookie({ name: "a", value: "1" })]) + const req = { + url: "https://example.com/", + headers: {} as Record, + } + await service.applyCookiesToRequest(req) + expect(req.headers["Cookie"]).toBe("a=1") + }) + + it("yields to a user-set non-empty Cookie header (any case)", async () => { + await service.upsertCookies([cookie({ name: "a", value: "1" })]) + const req = { + url: "https://example.com/", + headers: { cookie: "user=set" } as Record, + } + await service.applyCookiesToRequest(req) + expect(req.headers["cookie"]).toBe("user=set") + expect(req.headers["Cookie"]).toBeUndefined() + }) + + it("strips empty case-variant Cookie headers before setting the canonical one", async () => { + await service.upsertCookies([cookie({ name: "a", value: "1" })]) + const req = { + url: "https://example.com/", + headers: { cookie: "" } as Record, + } + await service.applyCookiesToRequest(req) + expect(req.headers["Cookie"]).toBe("a=1") + expect(req.headers["cookie"]).toBeUndefined() + }) + + it("does not set Cookie when the URL is unparseable", async () => { + const req = { + url: "not-a-url", + headers: {} as Record, + } + await service.applyCookiesToRequest(req) + expect(req.headers["Cookie"]).toBeUndefined() + }) + + it("strips an empty case-variant Cookie header even when the jar has no match", async () => { + const req = { + url: "https://no-match.example.com/", + headers: { cookie: "" } as Record, + } + await service.applyCookiesToRequest(req) + expect(req.headers["cookie"]).toBeUndefined() + expect(req.headers["Cookie"]).toBeUndefined() + }) + + it('does not send `Cookie: ""` when every matching cookie fails value validation', async () => { + await service.upsertCookies([cookie({ name: "a", value: "has;semi" })]) + const req = { + url: "https://example.com/", + headers: {} as Record, + } + await service.applyCookiesToRequest(req) + expect(req.headers["Cookie"]).toBeUndefined() + }) + }) + + describe("parseStored", () => { + it("rejects a payload whose cookie has non-string path", () => { + expect(() => + (service as any).parseStored({ + domains: { + "example.com": [ + { + name: "a", + value: "1", + domain: "example.com", + path: 1, + secure: false, + }, + ], + }, + }) + ).toThrow() + }) + + it("rejects a payload whose cookie has non-boolean secure", () => { + expect(() => + (service as any).parseStored({ + domains: { + "example.com": [ + { + name: "a", + value: "1", + domain: "example.com", + path: "/", + secure: "false", + }, + ], + }, + }) + ).toThrow() + }) + }) + + describe("extractFromResponse", () => { + it("defaults the path to the request directory per RFC 6265 5.1.4", async () => { + await service.extractFromResponse( + [{ name: "a", value: "1" }], + new URL("https://example.com/v1/users/42") + ) + expect( + service.getCookiesForURL(new URL("https://example.com/v1/users")) + ).toHaveLength(1) + expect( + service.getCookiesForURL(new URL("https://example.com/other")) + ).toHaveLength(0) + }) + + it("tolerates an Invalid Date in `expires`", async () => { + await service.extractFromResponse( + [{ name: "a", value: "1", expires: new Date("not-a-date") }], + new URL("https://example.com/") + ) + expect( + service.getCookiesForURL(new URL("https://example.com/")) + ).toHaveLength(1) + }) + + it("accepts a single-label host as a host-only cookie", async () => { + await service.extractFromResponse( + [{ name: "a", value: "1" }], + new URL("http://localhost:3000/") + ) + expect( + service.getCookiesForURL(new URL("http://localhost:3000/")) + ).toHaveLength(1) + }) + + it("rejects a single-label Domain attribute (public-suffix gate)", async () => { + await service.extractFromResponse( + [{ name: "a", value: "1", domain: "com" }], + new URL("https://example.com/") + ) + expect(service.cookieJar.value.size).toBe(0) + }) + + it("falls back to default-path when the Path attribute does not start with /", async () => { + await service.extractFromResponse( + [{ name: "a", value: "1", path: "foo" }], + new URL("https://example.com/v1/users/42") + ) + const stored = service.cookieJar.value.get("example.com")?.[0] + expect(stored?.path).toBe("/v1/users") + }) + }) + + describe("serializeCookieHeader", () => { + it("joins `name=value` pairs with `; `", () => { + expect( + service.serializeCookieHeader([ + cookie({ name: "a", value: "1" }), + cookie({ name: "b", value: "2" }), + ]) + ).toBe("a=1; b=2") + }) + + it("skips a cookie whose value contains an RFC 6265 5.4 forbidden character", () => { + expect( + service.serializeCookieHeader([ + cookie({ name: "a", value: "has;semi" }), + cookie({ name: "b", value: "ok" }), + ]) + ).toBe("b=ok") + }) + + it("skips a cookie whose name is not a valid RFC 7230 token", () => { + expect( + service.serializeCookieHeader([ + cookie({ name: "has;semi", value: "1" }), + cookie({ name: "b", value: "2" }), + ]) + ).toBe("b=2") + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/__tests__/current-environment-value.service.spec.ts b/packages/hoppscotch-common/src/services/__tests__/current-environment-value.service.spec.ts new file mode 100644 index 0000000..5adf852 --- /dev/null +++ b/packages/hoppscotch-common/src/services/__tests__/current-environment-value.service.spec.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, beforeEach } from "vitest" +import { TestContainer } from "dioc/testing" +import { + CurrentValueService, + Variable, +} from "../current-environment-value.service" + +describe("CurrentValueService", () => { + let container: TestContainer + let service: CurrentValueService + + beforeEach(() => { + container = new TestContainer() + service = container.bind(CurrentValueService) + }) + + describe("addEnvironment", () => { + it("should add a new environment with the provided ID and variables", () => { + const id = "env1" + const vars: Variable[] = [ + { key: "key1", currentValue: "value1", varIndex: 1, isSecret: false }, + ] + service.addEnvironment(id, vars) + expect(service.environments.get(id)).toEqual(vars) + }) + }) + + describe("getEnvironment", () => { + it("should return the variables of the specified environment", () => { + const id = "env1" + const vars: Variable[] = [ + { key: "key1", currentValue: "value1", varIndex: 1, isSecret: false }, + ] + service.environments.set(id, vars) + expect(service.getEnvironment(id)).toEqual(vars) + }) + + it("should return undefined for non-existent environment", () => { + expect(service.getEnvironment("nonexistent")).toBeUndefined() + }) + }) + + describe("getEnvironmentVariable", () => { + it("should return the variable at the given index", () => { + const id = "env1" + const vars: Variable[] = [ + { key: "key1", currentValue: "value1", varIndex: 1, isSecret: false }, + ] + service.environments.set(id, vars) + expect(service.getEnvironmentVariable(id, 1)).toEqual(vars[0]) + }) + + it("should return undefined for non-existent variable", () => { + const id = "env1" + const vars: Variable[] = [ + { key: "key1", currentValue: "value1", varIndex: 1, isSecret: false }, + ] + service.environments.set(id, vars) + expect(service.getEnvironmentVariable(id, 2)).toBeUndefined() + }) + }) + + describe("getEnvironmentVariableValue", () => { + it("should return the value of the variable", () => { + const id = "env1" + const vars: Variable[] = [ + { key: "key1", currentValue: "value1", varIndex: 1, isSecret: false }, + ] + service.environments.set(id, vars) + expect(service.getEnvironmentVariableValue(id, 1)).toBe("value1") + }) + + it("should return undefined if variable doesn't exist", () => { + expect(service.getEnvironmentVariableValue("env1", 999)).toBeUndefined() + }) + }) + + describe("loadEnvironmentsFromPersistedState", () => { + it("should load environments correctly", () => { + const state = { + env1: [{ key: "k", currentValue: "v", varIndex: 0, isSecret: false }], + } + service.loadEnvironmentsFromPersistedState(state) + expect(service.environments.get("env1")).toEqual(state.env1) + }) + }) + + describe("deleteEnvironment", () => { + it("should delete the specified environment", () => { + const id = "env1" + service.environments.set(id, []) + service.deleteEnvironment(id) + expect(service.environments.has(id)).toBe(false) + }) + }) + + describe("removeEnvironmentVariable", () => { + it("should remove the specified variable", () => { + const id = "env1" + const vars = [ + { key: "key1", currentValue: "value1", varIndex: 1, isSecret: false }, + { key: "key2", currentValue: "value2", varIndex: 2, isSecret: false }, + ] + service.environments.set(id, vars) + service.removeEnvironmentVariable(id, 1) + expect(service.environments.get(id)).toEqual([vars[1]]) + }) + + it("should not fail if variable does not exist", () => { + const id = "env1" + const vars = [ + { key: "key1", currentValue: "value1", varIndex: 1, isSecret: false }, + ] + service.environments.set(id, vars) + service.removeEnvironmentVariable(id, 999) + expect(service.environments.get(id)).toEqual(vars) + }) + }) + + describe("updateEnvironmentID", () => { + it("should update the environment ID", () => { + const oldID = "old" + const newID = "new" + const vars = [ + { key: "k", currentValue: "v", varIndex: 0, isSecret: false }, + ] + service.environments.set(oldID, vars) + service.updateEnvironmentID(oldID, newID) + expect(service.environments.has(oldID)).toBe(false) + expect(service.environments.get(newID)).toEqual(vars) + }) + }) + + describe("hasValue", () => { + it("should return true if a variable with the key and non-empty value exists", () => { + const id = "env1" + const vars = [ + { key: "k", currentValue: "v", varIndex: 0, isSecret: true }, + ] + service.environments.set(id, vars) + expect(service.hasValue(id, "k")).toBe(true) + }) + + it("should return false if no variable with key exists or value is empty", () => { + service.environments.set("env1", [ + { key: "k", currentValue: "", varIndex: 0, isSecret: true }, + ]) + expect(service.hasValue("env1", "k")).toBe(false) + }) + }) + + describe("getEnvironmentByKey", () => { + it("should return variable by key", () => { + const id = "env1" + const vars = [ + { key: "k", currentValue: "v", varIndex: 1, isSecret: false }, + ] + service.environments.set(id, vars) + expect(service.getEnvironmentByKey(id, "k")).toEqual(vars[0]) + }) + + it("should return undefined if key not found", () => { + service.environments.set("env1", []) + expect(service.getEnvironmentByKey("env1", "k")).toBeUndefined() + }) + }) + + describe("persistableEnvironments", () => { + it("should return the environments in persistable format", () => { + const id = "env1" + const vars = [ + { key: "k", currentValue: "v", varIndex: 0, isSecret: false }, + ] + service.environments.set(id, vars) + expect(service.persistableEnvironments.value).toEqual({ [id]: vars }) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/__tests__/current-sort.service.spec.ts b/packages/hoppscotch-common/src/services/__tests__/current-sort.service.spec.ts new file mode 100644 index 0000000..60e25a3 --- /dev/null +++ b/packages/hoppscotch-common/src/services/__tests__/current-sort.service.spec.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach } from "vitest" +import { TestContainer } from "dioc/testing" +import { + CurrentSortOption, + CurrentSortValuesService, +} from "../current-sort.service" + +describe("CurrentSortValuesService", () => { + let container: TestContainer + let service: CurrentSortValuesService + + beforeEach(() => { + container = new TestContainer() + service = container.bind(CurrentSortValuesService) + }) + + describe("setSortOption & getSortOption", () => { + it("should set and retrieve a sort option for a given ID", () => { + const id = "col1" + const option: CurrentSortOption = { sortBy: "name", sortOrder: "asc" } + + service.setSortOption(id, option) + expect(service.getSortOption(id)).toEqual(option) + }) + + it("should return undefined for a non-existent ID", () => { + expect(service.getSortOption("missing")).toBeUndefined() + }) + }) + + describe("removeSortOption", () => { + it("should remove a sort option for a given ID", () => { + const id = "col1" + const option: CurrentSortOption = { sortBy: "name", sortOrder: "asc" } + + service.setSortOption(id, option) + service.removeSortOption(id) + + expect(service.getSortOption(id)).toBeUndefined() + }) + }) + + describe("clearAllSortOptions", () => { + it("should clear all sort options", () => { + service.setSortOption("col1", { sortBy: "name", sortOrder: "asc" }) + service.setSortOption("col2", { sortBy: "name", sortOrder: "desc" }) + + service.clearAllSortOptions() + + expect(service.currentSortOptions.size).toBe(0) + }) + }) + + describe("loadCurrentSortValuesFromPersistedState", () => { + it("should load sort options from persisted state", () => { + const state = { + col1: { sortBy: "name", sortOrder: "asc" } as CurrentSortOption, + col2: { sortBy: "name", sortOrder: "desc" } as CurrentSortOption, + } + + service.loadCurrentSortValuesFromPersistedState(state) + + expect(service.getSortOption("col1")).toEqual(state.col1) + expect(service.getSortOption("col2")).toEqual(state.col2) + }) + + it("should clear existing options before loading", () => { + service.setSortOption("old", { sortBy: "name", sortOrder: "asc" }) + + const state = { + new: { sortBy: "name", sortOrder: "desc" } as CurrentSortOption, + } + + service.loadCurrentSortValuesFromPersistedState(state) + + expect(service.getSortOption("old")).toBeUndefined() + expect(service.getSortOption("new")).toEqual(state.new) + }) + }) + + describe("persistableCurrentSortValues", () => { + it("should return a persistable object of current sort options", () => { + const id = "col1" + const option: CurrentSortOption = { sortBy: "name", sortOrder: "asc" } + service.setSortOption(id, option) + + expect(service.persistableCurrentSortValues.value).toEqual({ + [id]: option, + }) + }) + + it("should return an empty object when no sort options exist", () => { + expect(service.persistableCurrentSortValues.value).toEqual({}) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/__tests__/documentation.service.spec.ts b/packages/hoppscotch-common/src/services/__tests__/documentation.service.spec.ts new file mode 100644 index 0000000..409fa94 --- /dev/null +++ b/packages/hoppscotch-common/src/services/__tests__/documentation.service.spec.ts @@ -0,0 +1,761 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import * as E from "fp-ts/Either" +import { TestContainer } from "dioc/testing" +import { + HoppCollection, + HoppRESTRequest, + makeCollection, + makeRESTRequest, +} from "@hoppscotch/data" +import { + DocumentationService, + CollectionDocumentationItem, + RequestDocumentationItem, + SetCollectionDocumentationOptions, + SetRequestDocumentationOptions, + isLiveVersion, +} from "../documentation.service" +import { platform } from "~/platform" + +vi.mock("~/platform", () => ({ + platform: { + backend: { + getUserPublishedDocs: vi.fn(), + getTeamPublishedDocs: vi.fn(), + }, + }, +})) + +describe("DocumentationService", () => { + let container: TestContainer + let service: DocumentationService + + // Test data + const mockCollection: HoppCollection = makeCollection({ + name: "Test Collection", + folders: [], + requests: [], + auth: { authType: "none", authActive: true }, + headers: [], + variables: [], + id: "collection-123", + description: null, + preRequestScript: "", + testScript: "", + }) + + const mockRequest: HoppRESTRequest = makeRESTRequest({ + name: "Test Request", + endpoint: "https://api.example.com/test", + method: "GET", + headers: [], + params: [], + auth: { authType: "inherit", authActive: true }, + preRequestScript: "", + testScript: "", + body: { contentType: null, body: null }, + requestVariables: [], + responses: {}, + description: null, + }) + + const mockCollectionOptions: SetCollectionDocumentationOptions = { + isTeamItem: false, + pathOrID: "test-path", + collectionData: mockCollection, + } + + const mockTeamCollectionOptions: SetCollectionDocumentationOptions = { + isTeamItem: true, + teamID: "team-456", + pathOrID: "team-collection-789", + collectionData: mockCollection, + } + + const mockRequestOptions: SetRequestDocumentationOptions = { + isTeamItem: false, + parentCollectionID: "collection-123", + folderPath: "test-folder", + requestIndex: 0, + requestData: mockRequest, + } + + const mockTeamRequestOptions: SetRequestDocumentationOptions = { + isTeamItem: true, + teamID: "team-456", + parentCollectionID: "collection-123", + folderPath: "team-folder", + requestID: "request-789", + requestData: mockRequest, + } + + beforeEach(() => { + vi.resetAllMocks() + container = new TestContainer() + service = container.bind(DocumentationService) + }) + + describe("Collection Documentation", () => { + it("should set and get collection documentation", () => { + const collectionId = "collection-123" + const documentation = "# Test Collection\nThis is a test collection." + + service.setCollectionDocumentation( + collectionId, + documentation, + mockCollectionOptions + ) + + expect(service.getDocumentation("collection", collectionId)).toBe( + documentation + ) + }) + + it("should store complete collection documentation item", () => { + const collectionId = "collection-123" + const documentation = "# Test Collection\nThis is a test collection." + + service.setCollectionDocumentation( + collectionId, + documentation, + mockCollectionOptions + ) + + const item = service.getDocumentationItem( + "collection", + collectionId + ) as CollectionDocumentationItem + + expect(item).toEqual({ + type: "collection", + id: collectionId, + documentation, + isTeamItem: false, + teamID: undefined, + pathOrID: "test-path", + collectionData: mockCollection, + }) + }) + + it("should handle team collection documentation", () => { + const collectionId = "team-collection-789" + const documentation = "# Team Collection\nThis is a team collection." + + service.setCollectionDocumentation( + collectionId, + documentation, + mockTeamCollectionOptions + ) + + const item = service.getDocumentationItem( + "collection", + collectionId + ) as CollectionDocumentationItem + + expect(item.isTeamItem).toBe(true) + expect(item.teamID).toBe("team-456") + }) + + it("should update existing collection documentation", () => { + const collectionId = "collection-123" + const originalDoc = "Original documentation" + const updatedDoc = "Updated documentation" + + service.setCollectionDocumentation( + collectionId, + originalDoc, + mockCollectionOptions + ) + service.setCollectionDocumentation( + collectionId, + updatedDoc, + mockCollectionOptions + ) + + expect(service.getDocumentation("collection", collectionId)).toBe( + updatedDoc + ) + }) + }) + + describe("Request Documentation", () => { + it("should set and get request documentation", () => { + const requestId = "request-456" + const documentation = "## Test Request\nThis is a test request." + + service.setRequestDocumentation( + requestId, + documentation, + mockRequestOptions + ) + + expect(service.getDocumentation("request", requestId)).toBe(documentation) + }) + + it("should store complete request documentation item for personal requests", () => { + const requestId = "request-456" + const documentation = "## Test Request\nThis is a test request." + + service.setRequestDocumentation( + requestId, + documentation, + mockRequestOptions + ) + + const item = service.getDocumentationItem( + "request", + requestId + ) as RequestDocumentationItem + + expect(item).toEqual({ + type: "request", + id: requestId, + documentation, + isTeamItem: false, + teamID: undefined, + parentCollectionID: "collection-123", + folderPath: "test-folder", + requestID: undefined, + requestIndex: 0, + requestData: mockRequest, + }) + }) + + it("should store complete request documentation item for team requests", () => { + const requestId = "team-request-789" + const documentation = "## Team Request\nThis is a team request." + + service.setRequestDocumentation( + requestId, + documentation, + mockTeamRequestOptions + ) + + const item = service.getDocumentationItem( + "request", + requestId + ) as RequestDocumentationItem + + expect(item).toEqual({ + type: "request", + id: requestId, + documentation, + isTeamItem: true, + teamID: "team-456", + parentCollectionID: "collection-123", + folderPath: "team-folder", + requestID: "request-789", + requestIndex: undefined, + requestData: mockRequest, + }) + }) + + it("should get parent collection ID for request", () => { + const requestId = "request-456" + const documentation = "## Test Request\nThis is a test request." + + service.setRequestDocumentation( + requestId, + documentation, + mockRequestOptions + ) + + expect(service.getParentCollectionID(requestId)).toBe("collection-123") + }) + + it("should return undefined for parent collection ID when request not found", () => { + expect(service.getParentCollectionID("non-existent")).toBeUndefined() + }) + }) + + describe("Change Tracking", () => { + it("should track if there are changes", () => { + expect(service.hasChanges.value).toBe(false) + + service.setCollectionDocumentation( + "collection-123", + "Test documentation", + mockCollectionOptions + ) + + expect(service.hasChanges.value).toBe(true) + }) + + it("should check if specific item has changes", () => { + const collectionId = "collection-123" + const requestId = "request-456" + + expect(service.hasItemChanges("collection", collectionId)).toBe(false) + expect(service.hasItemChanges("request", requestId)).toBe(false) + + service.setCollectionDocumentation( + collectionId, + "Test documentation", + mockCollectionOptions + ) + + expect(service.hasItemChanges("collection", collectionId)).toBe(true) + expect(service.hasItemChanges("request", requestId)).toBe(false) + }) + + it("should return correct changes count", () => { + expect(service.getChangesCount()).toBe(0) + + service.setCollectionDocumentation( + "collection-123", + "Collection doc", + mockCollectionOptions + ) + + expect(service.getChangesCount()).toBe(1) + + service.setRequestDocumentation( + "request-456", + "Request doc", + mockRequestOptions + ) + + expect(service.getChangesCount()).toBe(2) + }) + + it("should get all changed items", () => { + service.setCollectionDocumentation( + "collection-123", + "Collection doc", + mockCollectionOptions + ) + service.setRequestDocumentation( + "request-456", + "Request doc", + mockRequestOptions + ) + + const changes = service.getChangedItems() + + expect(changes).toHaveLength(2) + expect(changes.some((item) => item.type === "collection")).toBe(true) + expect(changes.some((item) => item.type === "request")).toBe(true) + }) + }) + + describe("Item Management", () => { + beforeEach(() => { + // Set up some test data + service.setCollectionDocumentation( + "collection-123", + "Collection doc", + mockCollectionOptions + ) + service.setRequestDocumentation( + "request-456", + "Request doc", + mockRequestOptions + ) + }) + + it("should remove specific item", () => { + expect(service.hasItemChanges("collection", "collection-123")).toBe(true) + expect(service.getChangesCount()).toBe(2) + + service.removeItem("collection", "collection-123") + + expect(service.hasItemChanges("collection", "collection-123")).toBe(false) + expect(service.getChangesCount()).toBe(1) + }) + + it("should clear all changes", () => { + expect(service.getChangesCount()).toBe(2) + expect(service.hasChanges.value).toBe(true) + + service.clearAll() + + expect(service.getChangesCount()).toBe(0) + expect(service.hasChanges.value).toBe(false) + }) + }) + + describe("Edge Cases", () => { + it("should return undefined for non-existent documentation", () => { + expect( + service.getDocumentation("collection", "non-existent") + ).toBeUndefined() + expect( + service.getDocumentation("request", "non-existent") + ).toBeUndefined() + }) + + it("should return undefined for non-existent documentation item", () => { + expect( + service.getDocumentationItem("collection", "non-existent") + ).toBeUndefined() + expect( + service.getDocumentationItem("request", "non-existent") + ).toBeUndefined() + }) + + it("should handle empty documentation strings", () => { + const collectionId = "collection-empty" + const emptyDoc = "" + + service.setCollectionDocumentation( + collectionId, + emptyDoc, + mockCollectionOptions + ) + + expect(service.getDocumentation("collection", collectionId)).toBe( + emptyDoc + ) + }) + + it("should handle documentation with special characters", () => { + const collectionId = "collection-special" + const specialDoc = + "# Test 🚀\n\n**Bold** _italic_ `code`\n\n- List item\n- Another item" + + service.setCollectionDocumentation( + collectionId, + specialDoc, + mockCollectionOptions + ) + + expect(service.getDocumentation("collection", collectionId)).toBe( + specialDoc + ) + }) + + it("should handle very long documentation", () => { + const collectionId = "collection-long" + const longDoc = "# Long Documentation\n" + "A".repeat(10000) + + service.setCollectionDocumentation( + collectionId, + longDoc, + mockCollectionOptions + ) + + expect(service.getDocumentation("collection", collectionId)).toBe(longDoc) + }) + + it("should return undefined for parent collection ID when item is not a request", () => { + service.setCollectionDocumentation( + "collection-123", + "Collection doc", + mockCollectionOptions + ) + + // The key will be collection_collection-123, which won't match request_ prefix + expect(service.getParentCollectionID("collection-123")).toBeUndefined() + }) + }) + + describe("Reactive Properties", () => { + it("should reactively update hasChanges computed property", () => { + expect(service.hasChanges.value).toBe(false) + + service.setCollectionDocumentation( + "collection-123", + "Test doc", + mockCollectionOptions + ) + + expect(service.hasChanges.value).toBe(true) + + service.clearAll() + + expect(service.hasChanges.value).toBe(false) + }) + }) + + describe("Published Documentation", () => { + it("should fetch user published docs and update map", async () => { + const mockDocs = [ + { + id: "doc-1", + title: "Doc 1", + version: "v1", + autoSync: true, + url: "http://example.com/doc-1", + collection: { id: "coll-1" }, + createdOn: new Date().toISOString(), + updatedOn: new Date().toISOString(), + }, + ] + + vi.mocked(platform.backend.getUserPublishedDocs).mockReturnValue(() => + Promise.resolve(E.right(mockDocs as any)) + ) + + await service.fetchUserPublishedDocs() + + const status = service.getPublishedDocStatus("coll-1") + expect(status).toEqual([ + { + id: "doc-1", + title: "Doc 1", + version: "v1", + autoSync: true, + url: "http://example.com/doc-1", + collection: { id: "coll-1" }, + createdOn: mockDocs[0].createdOn, // Use the generated date for comparison + updatedOn: mockDocs[0].updatedOn, // Use the generated date for comparison + }, + ]) + }) + + it("should fetch team published docs and update map", async () => { + const mockDocs = [ + { + id: "doc-2", + title: "Doc 2", + version: "v2", + autoSync: false, + url: "url-2", + collection: { id: "col-2" }, + createdOn: new Date().toISOString(), + updatedOn: new Date().toISOString(), + }, + ] + + vi.mocked(platform.backend.getTeamPublishedDocs).mockReturnValue(() => + Promise.resolve(E.right(mockDocs as any)) + ) + + await service.fetchTeamPublishedDocs("team-1") + + const status = service.getPublishedDocStatus("col-2") + + expect(status).toEqual([ + { + id: "doc-2", + title: "Doc 2", + version: "v2", + autoSync: false, + url: "url-2", + collection: { id: "col-2" }, + createdOn: mockDocs[0].createdOn, + updatedOn: mockDocs[0].updatedOn, + }, + ]) + }) + + it("should handle error when fetching user published docs", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + vi.mocked(platform.backend.getUserPublishedDocs).mockReturnValue(() => + Promise.resolve(E.left("user/not_authenticated")) + ) + + await service.fetchUserPublishedDocs() + + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to fetch user published docs:", + "user/not_authenticated" + ) + consoleSpy.mockRestore() + }) + + it("should handle error when fetching team published docs", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + vi.mocked(platform.backend.getTeamPublishedDocs).mockReturnValue(() => + Promise.resolve(E.left("team/not_required" as any)) + ) + + await service.fetchTeamPublishedDocs("team-1") + + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to fetch team published docs:", + "team/not_required" + ) + consoleSpy.mockRestore() + }) + + it("should manually set published doc status", () => { + const info = { + id: "doc-3", + title: "Doc 3", + version: "v3", + autoSync: true, + url: "url-3", + collection: { id: "col-3" }, + createdOn: "2023-01-03", + updatedOn: "2023-01-03", + } + + service.setPublishedDocStatus("col-3", info) + + expect(service.getPublishedDocStatus("col-3")).toEqual([info]) + }) + + it("should remove published doc status", () => { + const info = { + id: "doc-3", + title: "Doc 3", + version: "v3", + autoSync: true, + url: "url-3", + collection: { id: "col-3" }, + createdOn: "2023-01-03", + updatedOn: "2023-01-03", + } + + service.setPublishedDocStatus("col-3", info) + expect(service.getPublishedDocStatus("col-3")).toBeDefined() + + service.setPublishedDocStatus("col-3", null) + expect(service.getPublishedDocStatus("col-3")).toBeUndefined() + }) + + it("should handle race conditions by ignoring stale responses", async () => { + const slowDocs = [ + { + id: "doc-slow", + title: "Slow Doc", + version: "v1", + autoSync: true, + url: "url-slow", + collection: { id: "col-1" }, + createdOn: "2023-01-01", + updatedOn: "2023-01-01", + }, + ] + + const fastDocs = [ + { + id: "doc-fast", + title: "Fast Doc", + version: "v2", + autoSync: true, + url: "url-fast", + collection: { id: "col-1" }, + createdOn: "2023-01-02", + updatedOn: "2023-01-02", + }, + ] + + let resolveSlow: (value: any) => void + const slowPromise = new Promise((resolve) => { + resolveSlow = resolve + }) + + // Mock the first call to be slow + vi.mocked(platform.backend.getUserPublishedDocs) + .mockReturnValueOnce(() => slowPromise as any) + .mockReturnValueOnce(() => Promise.resolve(E.right(fastDocs as any))) + + // Start the slow request + const firstCall = service.fetchUserPublishedDocs() + + // Start the fast request immediately after + const secondCall = service.fetchUserPublishedDocs() + + // Wait for the fast request to complete + await secondCall + + // Verify the fast response is applied + expect(service.getPublishedDocStatus("col-1")).toEqual([ + { + id: "doc-fast", + title: "Fast Doc", + version: "v2", + autoSync: true, + url: "url-fast", + collection: { id: "col-1" }, + createdOn: "2023-01-02", + updatedOn: "2023-01-02", + }, + ]) + + // Now resolve the slow request + resolveSlow!(E.right(slowDocs as any)) + await firstCall + + // Verify the state hasn't changed (slow response ignored) + expect(service.getPublishedDocStatus("col-1")).toEqual([ + { + id: "doc-fast", + title: "Fast Doc", + version: "v2", + autoSync: true, + url: "url-fast", + collection: { id: "col-1" }, + createdOn: "2023-01-02", + updatedOn: "2023-01-02", + }, + ]) + }) + + it("should get published doc by version", () => { + const info1 = { + id: "doc-1", + title: "Doc 1", + version: "v1", + autoSync: true, + url: "url-1", + collection: { id: "col-1" }, + createdOn: "2023-01-01", + updatedOn: "2023-01-01", + } + const info2 = { + id: "doc-2", + title: "Doc 2", + version: "v2", + autoSync: true, + url: "url-2", + collection: { id: "col-1" }, + createdOn: "2023-01-02", + updatedOn: "2023-01-02", + } + + service.setPublishedDocStatus("col-1", info1) + service.setPublishedDocStatus("col-1", info2) + + expect(service.getPublishedDocByVersion("col-1", "v1")).toEqual(info1) + expect(service.getPublishedDocByVersion("col-1", "v2")).toEqual(info2) + expect(service.getPublishedDocByVersion("col-1", "v3")).toBeUndefined() + }) + + it("should remove specific published doc version", () => { + const info1 = { + id: "doc-1", + title: "Doc 1", + version: "v1", + autoSync: true, + url: "url-1", + collection: { id: "col-1" }, + createdOn: "2023-01-01", + updatedOn: "2023-01-01", + } + const info2 = { + id: "doc-2", + title: "Doc 2", + version: "v2", + autoSync: true, + url: "url-2", + collection: { id: "col-1" }, + createdOn: "2023-01-02", + updatedOn: "2023-01-02", + } + + service.setPublishedDocStatus("col-1", info1) + service.setPublishedDocStatus("col-1", info2) + + expect(service.getPublishedDocStatus("col-1")).toHaveLength(2) + + service.setPublishedDocStatus("col-1", null, "doc-1") + + const status = service.getPublishedDocStatus("col-1") + expect(status).toHaveLength(1) + expect(status![0]).toEqual(info2) + }) + }) +}) + +describe("isLiveVersion", () => { + it("returns true when autoSync is true", () => { + expect(isLiveVersion({ autoSync: true })).toBe(true) + }) + + it("returns false when autoSync is false", () => { + expect(isLiveVersion({ autoSync: false })).toBe(false) + }) +}) diff --git a/packages/hoppscotch-common/src/services/__tests__/history-ui-provider.service.spec.ts b/packages/hoppscotch-common/src/services/__tests__/history-ui-provider.service.spec.ts new file mode 100644 index 0000000..40c013c --- /dev/null +++ b/packages/hoppscotch-common/src/services/__tests__/history-ui-provider.service.spec.ts @@ -0,0 +1,25 @@ +import { TestContainer } from "dioc/testing" +import { describe, expect, it } from "vitest" +import { getI18n } from "~/modules/i18n" +import { HistoryUIProviderService } from "../history-ui-provider.service" + +describe("HistoryUIProviderService", () => { + const container = new TestContainer() + const historyUI = container.bind(HistoryUIProviderService) + + it("should initialize with default values", () => { + expect(historyUI.isEnabled.value).toBe(false) + }) + + it("should return correct default title", () => { + const mockT = ((key: string) => key) as ReturnType + const title = historyUI.historyUIProviderTitle.value(mockT) + expect(title).toBe("tab.history") + }) + + it("should allow toggling enabled state", () => { + expect(historyUI.isEnabled.value).toBe(false) + historyUI.isEnabled.value = true + expect(historyUI.isEnabled.value).toBe(true) + }) +}) diff --git a/packages/hoppscotch-common/src/services/__tests__/kernel-interceptor.service.spec.ts b/packages/hoppscotch-common/src/services/__tests__/kernel-interceptor.service.spec.ts new file mode 100644 index 0000000..d230531 --- /dev/null +++ b/packages/hoppscotch-common/src/services/__tests__/kernel-interceptor.service.spec.ts @@ -0,0 +1,201 @@ +import { describe, expect, it, vi } from "vitest" +import { + KernelInterceptor, + KernelInterceptorService, +} from "../kernel-interceptor.service" +import { TestContainer } from "dioc/testing" +import { RelayRequest } from "@hoppscotch/kernel" + +describe("KernelInterceptorService", () => { + it("initially has no interceptors defined", () => { + const container = new TestContainer() + const service = container.bind(KernelInterceptorService) + + expect(service.available.value).toEqual([]) + }) + + it("current is null if no interceptors are defined", () => { + const container = new TestContainer() + const service = container.bind(KernelInterceptorService) + + expect(service.current.value).toBeNull() + }) + + it("current points to active interceptor after registration", () => { + const container = new TestContainer() + const service = container.bind(KernelInterceptorService) + + const interceptor: KernelInterceptor = { + id: "test", + name: () => "Test Interceptor", + selectable: { type: "selectable" }, + capabilities: {}, + execute: () => { + throw new Error("Not implemented") + }, + } + + service.register(interceptor) + expect(service.current.value).toBe(interceptor) + }) + + describe("register", () => { + it("adds interceptor to available list", () => { + const container = new TestContainer() + const service = container.bind(KernelInterceptorService) + + const interceptor: KernelInterceptor = { + id: "test", + name: () => "Test Interceptor", + selectable: { type: "selectable" }, + capabilities: {}, + execute: () => { + throw new Error("Not implemented") + }, + } + + service.register(interceptor) + expect(service.available.value).toEqual([interceptor]) + }) + + it("sets first registered interceptor as active", () => { + const container = new TestContainer() + const service = container.bind(KernelInterceptorService) + + const interceptor: KernelInterceptor = { + id: "test", + name: () => "Test Interceptor", + selectable: { type: "selectable" }, + capabilities: {}, + execute: () => { + throw new Error("Not implemented") + }, + } + + service.register(interceptor) + expect(service.current.value?.id).toBe("test") + }) + }) + + describe("setActive", () => { + it("cannot set unknown interceptor as active", () => { + const container = new TestContainer() + const service = container.bind(KernelInterceptorService) + + service.register({ + id: "test", + name: () => "Test Interceptor", + selectable: { type: "selectable" }, + capabilities: {}, + execute: () => { + throw new Error("Not implemented") + }, + }) + + service.setActive("unknown") + expect(service.current.value?.id).toBe("test") + }) + + it("updates current when setting valid interceptor", () => { + const container = new TestContainer() + const service = container.bind(KernelInterceptorService) + + const interceptor1: KernelInterceptor = { + id: "test1", + name: () => "Test 1", + selectable: { type: "selectable" }, + capabilities: {}, + execute: () => { + throw new Error("Not implemented") + }, + } + + const interceptor2: KernelInterceptor = { + id: "test2", + name: () => "Test 2", + selectable: { type: "selectable" }, + capabilities: {}, + execute: () => { + throw new Error("Not implemented") + }, + } + + service.register(interceptor1) + service.register(interceptor2) + + service.setActive("test2") + expect(service.current.value).toBe(interceptor2) + }) + }) + + describe("execute", () => { + it("throws error if no interceptor is active", () => { + const container = new TestContainer() + const service = container.bind(KernelInterceptorService) + + expect(() => service.execute({} as RelayRequest)).toThrow( + "No active interceptor" + ) + }) + + it("calls execute on current interceptor", () => { + const container = new TestContainer() + const service = container.bind(KernelInterceptorService) + + const interceptor: KernelInterceptor = { + id: "test", + name: () => "Test Interceptor", + selectable: { type: "selectable" }, + capabilities: {}, + execute: vi.fn(), + } + + service.register(interceptor) + + const request = {} as RelayRequest + service.execute(request) + + expect(interceptor.execute).toHaveBeenCalledWith(request) + }) + }) + + describe("validation", () => { + it("resets to selectable interceptor if current becomes unselectable", async () => { + const container = new TestContainer() + const service = container.bind(KernelInterceptorService) + + const interceptor1: KernelInterceptor = { + id: "test1", + name: () => "Test 1", + selectable: { + type: "unselectable", + reason: { + type: "text", + text: () => "Not available", + }, + }, + capabilities: {}, + execute: () => { + throw new Error("Not implemented") + }, + } + + const interceptor2: KernelInterceptor = { + id: "test2", + name: () => "Test 2", + selectable: { type: "selectable" }, + capabilities: {}, + execute: () => { + throw new Error("Not implemented") + }, + } + + service.register(interceptor2) + service.register(interceptor1) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(service.current.value?.id).toBe("test2") + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/__tests__/scroll.service.spec.ts b/packages/hoppscotch-common/src/services/__tests__/scroll.service.spec.ts new file mode 100644 index 0000000..8e76aac --- /dev/null +++ b/packages/hoppscotch-common/src/services/__tests__/scroll.service.spec.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach } from "vitest" +import { TestContainer } from "dioc/testing" +import { ScrollService } from "../scroll.service" + +describe("ScrollService", () => { + let service: ScrollService + + beforeEach(() => { + const container = new TestContainer() + service = container.bind(ScrollService) + }) + + it("should store and retrieve scroll position for tab and suffix", () => { + service.setScroll("tab1", "json", 100) + + expect(service.getScroll("tab1", "json")).toBe(100) + }) + + it("should return undefined for unset scroll position", () => { + expect(service.getScroll("tab1", "html")).toBeUndefined() + }) + + it("should clean up scroll positions for a specific tab", () => { + service.setScroll("tab1", "json", 100) + service.setScroll("tab1", "html", 200) + + service.cleanupScrollForTab("tab1") + + expect(service.getScroll("tab1", "json")).toBeUndefined() + expect(service.getScroll("tab1", "html")).toBeUndefined() + }) + + it("should clean up all scroll positions", () => { + service.setScroll("tab1", "json", 100) + service.setScroll("tab2", "html", 200) + service.cleanupAllScroll() + + expect(service.getScroll("tab1", "json")).toBeUndefined() + expect(service.getScroll("tab2", "html")).toBeUndefined() + }) + + it("should clean up scroll positions for all tabs except a specific one", () => { + service.setScroll("tab1", "json", 100) + service.setScroll("tab2", "html", 200) + service.cleanupAllScroll("tab1") + + expect(service.getScroll("tab1", "json")).toBe(100) + expect(service.getScroll("tab2", "html")).toBeUndefined() + }) + + it("should store and retrieve scroll position using a custom key", () => { + service.setScrollForKey("customKey", 999) + + expect(service.getScrollForKey("customKey")).toBe(999) + }) + + it("should return undefined for unset custom key", () => { + expect(service.getScrollForKey("nonexistentKey")).toBeUndefined() + }) + + it("should overwrite an existing scroll value", () => { + service.setScroll("tab1", "json", 100) + service.setScroll("tab1", "json", 300) + + expect(service.getScroll("tab1", "json")).toBe(300) + }) + + it("custom key and tab+suffix keys do not interfere when keys are different", () => { + service.setScroll("tab1", "json", 111) + service.setScrollForKey("custom-tab1-json", 999) + expect(service.getScroll("tab1", "json")).toBe(111) + expect(service.getScrollForKey("custom-tab1-json")).toBe(999) + }) + + it("cleanupScrollForTab should not remove other tab data", () => { + service.setScroll("tab1", "json", 100) + service.setScroll("tab2", "json", 200) + + service.cleanupScrollForTab("tab1") + + expect(service.getScroll("tab1", "json")).toBeUndefined() + expect(service.getScroll("tab2", "json")).toBe(200) + }) + + it("should handle empty tabId and suffix gracefully", () => { + service.setScroll("", "json", 123) + service.setScroll("tab1", "" as any, 456) + + expect(service.getScroll("", "json")).toBe(123) + expect(service.getScroll("tab1", "" as any)).toBe(456) + }) + + it("should overwrite scroll position for custom keys", () => { + service.setScrollForKey("key1", 100) + service.setScrollForKey("key1", 300) + + expect(service.getScrollForKey("key1")).toBe(300) + }) +}) diff --git a/packages/hoppscotch-common/src/services/__tests__/secret-environment.service.spec.ts b/packages/hoppscotch-common/src/services/__tests__/secret-environment.service.spec.ts new file mode 100644 index 0000000..a4390ec --- /dev/null +++ b/packages/hoppscotch-common/src/services/__tests__/secret-environment.service.spec.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, beforeEach } from "vitest" +import { TestContainer } from "dioc/testing" +import { SecretEnvironmentService } from "../secret-environment.service" + +describe("SecretEnvironmentService", () => { + let container: TestContainer + let service: SecretEnvironmentService + + beforeEach(() => { + container = new TestContainer() + service = container.bind(SecretEnvironmentService) + }) + + describe("addSecretEnvironment", () => { + it("should add a new secret environment with the provided ID and secret variables", () => { + const id = "testEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.addSecretEnvironment(id, secretVars) + + expect(service.secretEnvironments.get(id)).toEqual(secretVars) + }) + }) + + describe("getSecretEnvironment", () => { + it("should return the secret variables of the specified environment", () => { + const id = "testEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.secretEnvironments.set(id, secretVars) + + expect(service.getSecretEnvironment(id)).toEqual(secretVars) + }) + + it("should return undefined if the specified environment does not exist", () => { + const id = "nonExistentEnvironment" + + expect(service.getSecretEnvironment(id)).toBeUndefined() + }) + }) + + describe("getSecretEnvironmentVariable", () => { + it("should return the specified secret environment variable", () => { + const id = "testEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.secretEnvironments.set(id, secretVars) + + const result = service.getSecretEnvironmentVariable(id, 1) + + expect(result).toEqual(secretVars[0]) + }) + + it("should return undefined if the specified variable does not exist", () => { + const id = "testEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.secretEnvironments.set(id, secretVars) + + const result = service.getSecretEnvironmentVariable(id, 2) + + expect(result).toBeUndefined() + }) + + it("should return undefined if the specified environment does not exist", () => { + const id = "nonExistentEnvironment" + + const result = service.getSecretEnvironmentVariable(id, 1) + + expect(result).toBeUndefined() + }) + }) + + describe("getSecretEnvironmentVariableValue", () => { + it("should return the value and initialValue of the specified secret environment variable", () => { + const id = "testEnvironment" + const secretVars = [ + { key: "key1", value: "value1", initialValue: "init1", varIndex: 1 }, + ] + + service.secretEnvironments.set(id, secretVars) + + const result = service.getSecretEnvironmentVariableValue(id, 1) + + expect(result).toEqual({ + value: "value1", + initialValue: "init1", + }) + }) + + it("should return null if the variable has no value/initialValue", () => { + const id = "testEnvironment" + const secretVars = [{ key: "key1", varIndex: 1 }] + + service.secretEnvironments.set(id, secretVars as any) + + const result = service.getSecretEnvironmentVariableValue(id, 1) + + expect(result).toEqual({ + value: "", + initialValue: "", + }) + }) + + it("should return null if the specified variable does not exist", () => { + const id = "testEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.secretEnvironments.set(id, secretVars) + + const result = service.getSecretEnvironmentVariableValue(id, 2) + + expect(result).toBeNull() + }) + + it("should return null if the specified environment does not exist", () => { + const id = "nonExistentEnvironment" + + const result = service.getSecretEnvironmentVariableValue(id, 1) + + expect(result).toBeNull() + }) + }) + + describe("loadSecretEnvironmentsFromPersistedState", () => { + it("should load secret environments from the persisted state", () => { + const persistedState = { + testEnvironment: [{ key: "key1", value: "value1", varIndex: 1 }], + } + + service.loadSecretEnvironmentsFromPersistedState(persistedState) + + expect(service.secretEnvironments.size).toBe(1) + expect(service.secretEnvironments.get("testEnvironment")).toEqual([ + { key: "key1", value: "value1", varIndex: 1 }, + ]) + }) + }) + + describe("deleteSecretEnvironment", () => { + it("should delete the specified secret environment", () => { + const id = "testEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.secretEnvironments.set(id, secretVars) + + service.deleteSecretEnvironment(id) + + expect(service.secretEnvironments.has(id)).toBe(false) + }) + }) + + describe("removeSecretEnvironmentVariable", () => { + it("should remove the specified secret environment variable", () => { + const id = "testEnvironment" + const secretVars = [ + { key: "key1", value: "value1", varIndex: 1 }, + { key: "key2", value: "value2", varIndex: 2 }, + ] + + service.secretEnvironments.set(id, secretVars) + + service.removeSecretEnvironmentVariable(id, 1) + + expect(service.secretEnvironments.get(id)).toEqual([ + { key: "key2", value: "value2", varIndex: 2 }, + ]) + }) + + it("should do nothing if the specified variable does not exist", () => { + const id = "testEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.secretEnvironments.set(id, secretVars) + + service.removeSecretEnvironmentVariable(id, 2) + + expect(service.secretEnvironments.get(id)).toEqual(secretVars) + }) + }) + + describe("updateSecretEnvironmentID", () => { + it("should update the ID of the specified secret environment", () => { + const oldID = "oldEnvironment" + const newID = "newEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.secretEnvironments.set(oldID, secretVars) + + service.updateSecretEnvironmentID(oldID, newID) + + expect(service.secretEnvironments.has(oldID)).toBe(false) + expect(service.secretEnvironments.get(newID)).toEqual(secretVars) + }) + }) + + describe("persistableSecretEnvironments", () => { + it("should return a record of secret environments suitable for persistence", () => { + const secretVars = [ + { key: "key1", value: "value1", varIndex: 1 }, + { key: "key2", value: "value2", varIndex: 2 }, + ] + + service.secretEnvironments.set("environment1", secretVars) + + const persistedState = service.persistableSecretEnvironments.value + + expect(persistedState).toEqual({ + environment1: secretVars, + }) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/__tests__/workspace.service.spec.ts b/packages/hoppscotch-common/src/services/__tests__/workspace.service.spec.ts new file mode 100644 index 0000000..4f8c974 --- /dev/null +++ b/packages/hoppscotch-common/src/services/__tests__/workspace.service.spec.ts @@ -0,0 +1,555 @@ +import { describe, expect, vi, it, beforeEach, afterEach } from "vitest" +import { TestContainer } from "dioc/testing" +import { WorkspaceService } from "../workspace.service" +import { setPlatformDef } from "~/platform" +import { BehaviorSubject } from "rxjs" +import { effectScope, nextTick } from "vue" + +const listAdapterMock = vi.hoisted(() => ({ + isInitialized: false, + initialize: vi.fn(() => { + listAdapterMock.isInitialized = true + }), + dispose: vi.fn(() => { + listAdapterMock.isInitialized = false + }), + fetchList: vi.fn(), +})) + +vi.mock("~/helpers/teams/TeamListAdapter", () => ({ + default: class { + isInitialized = listAdapterMock.isInitialized + initialize = listAdapterMock.initialize + dispose = listAdapterMock.dispose + fetchList = listAdapterMock.fetchList + }, +})) + +// Mock TeamCollectionsService to prevent i18n dependency issues +vi.mock("../team-collection.service", () => ({ + TeamCollectionsService: class MockTeamCollectionsService { + static readonly ID = "TEAM_COLLECTIONS_SERVICE" + + changeTeamID = vi.fn() + clearCollections = vi.fn() + + onServiceInit = vi.fn() + }, +})) + +// Mock DocumentationService +vi.mock("../documentation.service", () => ({ + DocumentationService: class MockDocumentationService { + static readonly ID = "DOCUMENTATION_SERVICE" + + fetchTeamPublishedDocs = vi.fn() + fetchUserPublishedDocs = vi.fn() + + onServiceInit = vi.fn() + }, +})) + +describe("WorkspaceService", () => { + const platformMock = { + auth: { + getCurrentUserStream: vi.fn(), + getCurrentUser: vi.fn(), + waitProbableLoginToConfirm: vi.fn().mockResolvedValue(undefined), + }, + } + + beforeEach(() => { + // @ts-expect-error - We're mocking the platform + setPlatformDef(platformMock) + + platformMock.auth.getCurrentUserStream.mockReturnValue( + new BehaviorSubject(null) + ) + + platformMock.auth.getCurrentUser.mockReturnValue(null) + }) + + describe("Initialization", () => { + it("should initialize with the personal workspace selected", () => { + const container = new TestContainer() + + const service = container.bind(WorkspaceService) + + expect(service.currentWorkspace.value).toEqual({ type: "personal" }) + }) + }) + + describe("updateWorkspaceTeamName", () => { + it("should update the workspace team name if the current workspace is a team workspace", () => { + const container = new TestContainer() + + const service = container.bind(WorkspaceService) + + service.changeWorkspace({ + type: "team", + teamID: "test", + teamName: "before update", + role: null, + }) + + service.updateWorkspaceTeamName("test") + + expect(service.currentWorkspace.value).toEqual({ + type: "team", + teamID: "test", + teamName: "test", + role: null, + }) + }) + + it("should not update the workspace team name if the current workspace is a personal workspace", () => { + const container = new TestContainer() + + const service = container.bind(WorkspaceService) + + service.changeWorkspace({ + type: "personal", + }) + + service.updateWorkspaceTeamName("test") + + expect(service.currentWorkspace.value).toEqual({ type: "personal" }) + }) + }) + + describe("changeWorkspace", () => { + it("updates the current workspace value to the given workspace", () => { + const container = new TestContainer() + + const service = container.bind(WorkspaceService) + + service.changeWorkspace({ + type: "team", + teamID: "test", + teamName: "test", + role: null, + }) + + expect(service.currentWorkspace.value).toEqual({ + type: "team", + teamID: "test", + teamName: "test", + role: null, + }) + }) + }) + + describe("acquireTeamListAdapter", () => { + beforeEach(() => { + vi.useFakeTimers() + listAdapterMock.fetchList.mockClear() + }) + + afterEach(() => { + vi.clearAllTimers() + }) + + it("should not poll if the polling time is null", () => { + const container = new TestContainer() + + listAdapterMock.isInitialized = true // We need to initialize the list adapter before we can use it + const service = container.bind(WorkspaceService) + + service.acquireTeamListAdapter(null) + vi.advanceTimersByTime(100000) + + expect(listAdapterMock.fetchList).not.toHaveBeenCalled() + }) + + it("should not poll if the polling time is not null and user not logged in", async () => { + const container = new TestContainer() + + const service = container.bind(WorkspaceService) + + service.acquireTeamListAdapter(100) + await nextTick() + vi.advanceTimersByTime(110) + + platformMock.auth.getCurrentUser.mockReturnValue(null) + platformMock.auth.getCurrentUserStream.mockReturnValue( + new BehaviorSubject(null) + ) + + expect(listAdapterMock.fetchList).not.toHaveBeenCalled() + }) + + it("should poll if the polling time is not null and the user is logged in", async () => { + const container = new TestContainer() + + listAdapterMock.isInitialized = true // We need to initialize the list adapter before we can use it + + platformMock.auth.getCurrentUser.mockReturnValue({ + id: "test", + }) + platformMock.auth.getCurrentUserStream.mockReturnValue( + new BehaviorSubject({ id: "test" }) + ) + + const service = container.bind(WorkspaceService) + + const adapter = service.acquireTeamListAdapter(100) + await nextTick() + vi.advanceTimersByTime(100) + + expect(adapter!.fetchList).toHaveBeenCalledOnce() + }) + + it("emits 'managed-team-list-adapter-polled' when the service polls the adapter", async () => { + const container = new TestContainer() + + listAdapterMock.isInitialized = true + + platformMock.auth.getCurrentUser.mockReturnValue({ + id: "test", + }) + + platformMock.auth.getCurrentUserStream.mockReturnValue( + new BehaviorSubject({ id: "test" }) + ) + + const service = container.bind(WorkspaceService) + + const eventFn = vi.fn() + const sub = service.getEventStream().subscribe(eventFn) + + service.acquireTeamListAdapter(100) + await nextTick() + vi.advanceTimersByTime(100) + + expect(eventFn).toHaveBeenCalledOnce() + expect(eventFn).toHaveBeenCalledWith({ + type: "managed-team-list-adapter-polled", + }) + + sub.unsubscribe() + }) + + it("stops polling when the Vue effect scope is disposed and there is no more polling locks", async () => { + const container = new TestContainer() + + listAdapterMock.isInitialized = true + + platformMock.auth.getCurrentUser.mockReturnValue({ + id: "test", + }) + + platformMock.auth.getCurrentUserStream.mockReturnValue( + new BehaviorSubject({ id: "test" }) + ) + + const service = container.bind(WorkspaceService) + listAdapterMock.fetchList.mockClear() // Reset the counters + + const scopeHandle = effectScope() + scopeHandle.run(() => { + service.acquireTeamListAdapter(100) + }) + + await nextTick() + vi.advanceTimersByTime(100) + + expect(listAdapterMock.fetchList).toHaveBeenCalledOnce() + listAdapterMock.fetchList.mockClear() + + scopeHandle.stop() + + await nextTick() + vi.advanceTimersByTime(100) + + expect(listAdapterMock.fetchList).not.toHaveBeenCalled() + }) + }) + + describe("Workspace Synchronization", () => { + it("should call changeTeamID and fetchTeamPublishedDocs when workspace changes to a team workspace", async () => { + const container = new TestContainer() + + // Mock user for this test + platformMock.auth.getCurrentUser.mockReturnValue({ uid: "test-user" }) + platformMock.auth.getCurrentUserStream.mockReturnValue( + new BehaviorSubject({ uid: "test-user" }) + ) + + const service = container.bind(WorkspaceService) + + // Access the mocks + const teamCollectionServiceMock = (service as any).teamCollectionService + const documentationServiceMock = (service as any).documentationService + + // Change to team workspace + service.changeWorkspace({ + type: "team", + teamID: "team-123", + teamName: "Test Team", + role: null, + }) + + await nextTick() + + expect(teamCollectionServiceMock.changeTeamID).toHaveBeenCalledWith( + "team-123" + ) + expect( + documentationServiceMock.fetchTeamPublishedDocs + ).toHaveBeenCalledWith("team-123") + }) + + it("should call clearCollections and fetchUserPublishedDocs when workspace changes to personal workspace", async () => { + const container = new TestContainer() + + // Mock user for this test + platformMock.auth.getCurrentUser.mockReturnValue({ uid: "test-user" }) + platformMock.auth.getCurrentUserStream.mockReturnValue( + new BehaviorSubject({ uid: "test-user" }) + ) + + const service = container.bind(WorkspaceService) + + // Start with a team workspace + service.changeWorkspace({ + type: "team", + teamID: "team-123", + teamName: "Test Team", + role: null, + }) + + await nextTick() + + const teamCollectionServiceMock = (service as any).teamCollectionService + const documentationServiceMock = (service as any).documentationService + + teamCollectionServiceMock.clearCollections.mockClear() + documentationServiceMock.fetchUserPublishedDocs.mockClear() + + // Change to personal workspace + service.changeWorkspace({ + type: "personal", + }) + + await nextTick() + + expect(teamCollectionServiceMock.clearCollections).toHaveBeenCalled() + expect(documentationServiceMock.fetchUserPublishedDocs).toHaveBeenCalled() + }) + + it("should call clearCollections and fetchUserPublishedDocs when workspace changes to team workspace without teamID", async () => { + const container = new TestContainer() + + // Mock user for this test + platformMock.auth.getCurrentUser.mockReturnValue({ uid: "test-user" }) + platformMock.auth.getCurrentUserStream.mockReturnValue( + new BehaviorSubject({ uid: "test-user" }) + ) + + const service = container.bind(WorkspaceService) + + const teamCollectionServiceMock = (service as any).teamCollectionService + const documentationServiceMock = (service as any).documentationService + + // Change to team workspace without teamID + service.changeWorkspace({ + type: "team", + teamID: "", + teamName: "Test Team", + role: null, + }) + + await nextTick() + + expect(teamCollectionServiceMock.clearCollections).toHaveBeenCalled() + expect(documentationServiceMock.fetchUserPublishedDocs).toHaveBeenCalled() + }) + + it("should not sync when workspaces are effectively the same", async () => { + const container = new TestContainer() + const service = container.bind(WorkspaceService) + + // Start with a team workspace + service.changeWorkspace({ + type: "team", + teamID: "team-123", + teamName: "Test Team", + role: null, + }) + + await nextTick() + + const teamCollectionServiceMock = (service as any).teamCollectionService + const documentationServiceMock = (service as any).documentationService + + teamCollectionServiceMock.changeTeamID.mockClear() + documentationServiceMock.fetchTeamPublishedDocs.mockClear() + + // Change to same team workspace (different name, same ID) + service.changeWorkspace({ + type: "team", + teamID: "team-123", + teamName: "Updated Team Name", + role: null, + }) + + await nextTick() + + // Should not call sync methods again since it's the same team + expect(teamCollectionServiceMock.changeTeamID).not.toHaveBeenCalled() + expect( + documentationServiceMock.fetchTeamPublishedDocs + ).not.toHaveBeenCalled() + }) + + it("should handle errors during synchronization gracefully", async () => { + const container = new TestContainer() + const service = container.bind(WorkspaceService) + + const teamCollectionServiceMock = (service as any).teamCollectionService + teamCollectionServiceMock.changeTeamID.mockImplementation(() => { + throw new Error("Sync failed") + }) + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + // Change to team workspace (should not throw) + expect(() => { + service.changeWorkspace({ + type: "team", + teamID: "team-123", + teamName: "Test Team", + role: null, + }) + }).not.toThrow() + + await nextTick() + + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to sync workspace data:", + expect.any(Error) + ) + + consoleSpy.mockRestore() + }) + + it("should fetch user published docs only when user is authenticated", async () => { + // Case 1: No user + platformMock.auth.getCurrentUser.mockReturnValue(null) + platformMock.auth.getCurrentUserStream.mockReturnValue( + new BehaviorSubject(null) + ) + const container1 = new TestContainer() + const service1 = container1.bind(WorkspaceService) + const docMock1 = (service1 as any).documentationService + docMock1.fetchUserPublishedDocs.mockClear() + + service1.changeWorkspace({ type: "personal" }) + await nextTick() + expect(docMock1.fetchUserPublishedDocs).not.toHaveBeenCalled() + + // Case 2: With user + platformMock.auth.getCurrentUser.mockReturnValue({ uid: "test-user" }) + platformMock.auth.getCurrentUserStream.mockReturnValue( + new BehaviorSubject({ uid: "test-user" }) + ) + const container2 = new TestContainer() + const service2 = container2.bind(WorkspaceService) + const docMock2 = (service2 as any).documentationService + + // We check if it was called on initialization + await nextTick() + expect(docMock2.fetchUserPublishedDocs).toHaveBeenCalled() + }) + }) + + describe("areWorkspacesEqual", () => { + let service: WorkspaceService + + beforeEach(() => { + const container = new TestContainer() + service = container.bind(WorkspaceService) + }) + + it("should return false when newWorkspace is undefined", () => { + const result = (service as any).areWorkspacesEqual(undefined, { + type: "personal", + }) + expect(result).toBe(false) + }) + + it("should return false when oldWorkspace is undefined", () => { + const result = (service as any).areWorkspacesEqual( + { type: "personal" }, + undefined + ) + expect(result).toBe(false) + }) + + it("should return true when both workspaces are personal", () => { + const result = (service as any).areWorkspacesEqual( + { type: "personal" }, + { type: "personal" } + ) + expect(result).toBe(true) + }) + + it("should return true when both workspaces are team workspaces with same teamID", () => { + const workspace1 = { + type: "team", + teamID: "team-123", + teamName: "Team A", + role: null, + } + const workspace2 = { + type: "team", + teamID: "team-123", + teamName: "Team A Updated", + role: null, + } + + const result = (service as any).areWorkspacesEqual(workspace1, workspace2) + expect(result).toBe(true) + }) + + it("should return false when team workspaces have different teamIDs", () => { + const workspace1 = { + type: "team", + teamID: "team-123", + teamName: "Team A", + role: null, + } + const workspace2 = { + type: "team", + teamID: "team-456", + teamName: "Team B", + role: null, + } + + const result = (service as any).areWorkspacesEqual(workspace1, workspace2) + expect(result).toBe(false) + }) + + it("should return false when one is personal and other is team workspace", () => { + const personalWorkspace = { type: "personal" } + const teamWorkspace = { + type: "team", + teamID: "team-123", + teamName: "Team A", + role: null, + } + + const result1 = (service as any).areWorkspacesEqual( + personalWorkspace, + teamWorkspace + ) + const result2 = (service as any).areWorkspacesEqual( + teamWorkspace, + personalWorkspace + ) + + expect(result1).toBe(false) + expect(result2).toBe(false) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/additionalLinks.service.ts b/packages/hoppscotch-common/src/services/additionalLinks.service.ts new file mode 100644 index 0000000..827d356 --- /dev/null +++ b/packages/hoppscotch-common/src/services/additionalLinks.service.ts @@ -0,0 +1,55 @@ +import { Service } from "dioc" +import { Component, ComputedRef, Ref } from "vue" +import { getI18n } from "~/modules/i18n" + +export type Link = { + id: string + text: (t: ReturnType) => string + icon: Component + action: { type: "link"; href: string } | { type: "custom"; do: () => void } + show?: ComputedRef +} + +// The possible links IDs +type LinkID = "HEADER_DOWNLOADABLE_LINKS" | string + +export interface AdditionalLinkSet { + linkSetID: LinkID + getLinks: () => Ref +} + +/** + * Service to manage additional links in the app + * This service is used to register and retrieve link sets + * that can be displayed in the app's UI. + * Each link set can contain multiple links, each with its own action. + * The links can be displayed in different parts of the app, such as the header or footer. + */ +export class AdditionalLinksService extends Service { + public static readonly ID = "ADDITIONAL_LINKS_SERVICE" + + private additionalLinkSets: Map = new Map() + + /** + * Registers a link set with the LinksService + * @param linkSet The link set to register + */ + public registerAdditionalSet(linkSet: AdditionalLinkSet) { + this.additionalLinkSets.set(linkSet.linkSetID, linkSet) + } + + /** + * Gets all registered link sets + */ + public getAllLinkSets(): IterableIterator<[string, AdditionalLinkSet]> { + return this.additionalLinkSets.entries() + } + + /** + * Gets a link set by its ID + * @param linkSetID The ID of the link set to get + */ + public getLinkSet(linkSetID: LinkID): AdditionalLinkSet | undefined { + return this.additionalLinkSets.get(linkSetID) + } +} diff --git a/packages/hoppscotch-common/src/services/banner.service.ts b/packages/hoppscotch-common/src/services/banner.service.ts new file mode 100644 index 0000000..3a148cf --- /dev/null +++ b/packages/hoppscotch-common/src/services/banner.service.ts @@ -0,0 +1,60 @@ +import { Service } from "dioc" +import { computed, ref } from "vue" +import { getI18n } from "~/modules/i18n" + +export const BANNER_PRIORITY_LOW = 1 +export const BANNER_PRIORITY_MEDIUM = 3 +export const BANNER_PRIORITY_HIGH = 5 + +export type BannerType = "info" | "warning" | "error" + +export type BannerContent = { + type: BannerType + text: (t: ReturnType) => string + // Can be used to display an alternate text when display size is small + alternateText?: (t: ReturnType) => string + // Used to determine which banner should be displayed when multiple banners are present + score: number + dismissible?: boolean +} + +export type Banner = { + id: number + content: BannerContent +} + +// Returns the banner with the highest score +const getBannerWithHighestScore = (list: Banner[]) => { + if (list.length === 0) return null + else if (list.length === 1) return list[0] + + const highestScore = Math.max(...list.map((banner) => banner.content.score)) + return list.find((banner) => banner.content.score === highestScore) +} + +/** + * This service is used to display a banner on the app. + * It can used to display information, warnings or errors. + */ +export class BannerService extends Service { + public static readonly ID = "BANNER_SERVICE" + + private bannerID = 0 + private bannerList = ref([]) + + public content = computed(() => + getBannerWithHighestScore(this.bannerList.value) + ) + + public showBanner(banner: BannerContent) { + this.bannerID = this.bannerID + 1 + this.bannerList.value.push({ id: this.bannerID, content: banner }) + return this.bannerID + } + + public removeBanner(id: number) { + this.bannerList.value = this.bannerList.value.filter( + (banner) => id !== banner.id + ) + } +} diff --git a/packages/hoppscotch-common/src/services/context-menu/__tests__/index.spec.ts b/packages/hoppscotch-common/src/services/context-menu/__tests__/index.spec.ts new file mode 100644 index 0000000..627775d --- /dev/null +++ b/packages/hoppscotch-common/src/services/context-menu/__tests__/index.spec.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi } from "vitest" +import { ContextMenu, ContextMenuResult, ContextMenuService } from "../" +import { TestContainer } from "dioc/testing" + +const contextMenuResult: ContextMenuResult[] = [ + { + id: "result1", + text: { type: "text", text: "Sample Text" }, + icon: {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + action: () => {}, + }, +] + +const testMenu: ContextMenu = { + menuID: "menu1", + getMenuFor: () => { + return { + results: contextMenuResult, + } + }, +} + +describe("ContextMenuService", () => { + describe("registerMenu", () => { + it("should register a menu", () => { + const container = new TestContainer() + const service = container.bind(ContextMenuService) + + service.registerMenu(testMenu) + + const result = service.getMenuFor("text") + + expect(result).toContainEqual(expect.objectContaining({ id: "result1" })) + }) + + it("should not register a menu twice", () => { + const container = new TestContainer() + const service = container.bind(ContextMenuService) + + service.registerMenu(testMenu) + service.registerMenu(testMenu) + + const result = service.getMenuFor("text") + + expect(result).toHaveLength(1) + }) + + it("should register multiple menus", () => { + const container = new TestContainer() + const service = container.bind(ContextMenuService) + + const testMenu2: ContextMenu = { + menuID: "menu2", + getMenuFor: () => { + return { + results: contextMenuResult, + } + }, + } + + service.registerMenu(testMenu) + service.registerMenu(testMenu2) + + const result = service.getMenuFor("text") + + expect(result).toHaveLength(2) + }) + }) + + describe("getMenuFor", () => { + it("should get the menu", () => { + const sampleMenus = { + results: contextMenuResult, + } + + const container = new TestContainer() + const service = container.bind(ContextMenuService) + + service.registerMenu(testMenu) + + const results = service.getMenuFor("sometext") + + expect(results).toEqual(sampleMenus.results) + }) + + it("calls registered menus with correct value", () => { + const container = new TestContainer() + const service = container.bind(ContextMenuService) + + const testMenu2: ContextMenu = { + menuID: "some-id", + getMenuFor: vi.fn(() => ({ + results: contextMenuResult, + })), + } + + service.registerMenu(testMenu2) + + service.getMenuFor("sometext") + + expect(testMenu2.getMenuFor).toHaveBeenCalledWith("sometext") + }) + + it("should return empty array if no menus are registered", () => { + const container = new TestContainer() + const service = container.bind(ContextMenuService) + + const results = service.getMenuFor("sometext") + + expect(results).toEqual([]) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/context-menu/index.ts b/packages/hoppscotch-common/src/services/context-menu/index.ts new file mode 100644 index 0000000..609ca7b --- /dev/null +++ b/packages/hoppscotch-common/src/services/context-menu/index.ts @@ -0,0 +1,109 @@ +import { Service } from "dioc" +import { Component } from "vue" + +/** + * Defines how to render the text in a Context Menu Search Result + */ +export type ContextMenuTextType = + | { + type: "text" + text: string + } + | { + type: "custom" + /** + * The component to render in place of the text + */ + component: T + + /** + * The props to pass to the component + */ + componentProps: T extends Component ? Props : never + } + +/** + * Defines info about a context menu result so the UI can render it + */ +export interface ContextMenuResult { + /** + * The unique ID of the result + */ + id: string + /** + * The text to render in the result + */ + text: ContextMenuTextType + /** + * The icon to render as the signifier of the result + */ + icon: object | Component + /** + * The action to perform when the result is selected + */ + action: () => void + /** + * Additional metadata about the result + */ + meta?: { + /** + * The keyboard shortcut to trigger the result + */ + keyboardShortcut?: string[] + } +} + +/** + * Defines the state of a context menu + */ +export type ContextMenuState = { + results: ContextMenuResult[] +} + +/** + * Defines a context menu + */ +export interface ContextMenu { + /** + * The unique ID of the context menu + * This is used to identify the context menu + */ + menuID: string + /** + * Gets the context menu for the given text + * @param text The text to get the context menu for + * @returns The context menu state + */ + getMenuFor: (text: string) => ContextMenuState +} + +/** + * Defines the context menu service + * This service is used to register context menus and get context menus for text + * This service is used by the context menu UI + */ +export class ContextMenuService extends Service { + public static readonly ID = "CONTEXT_MENU_SERVICE" + + private menus: Map = new Map() + + /** + * Registers a menu with the context menu service + * @param menu The menu to register + */ + public registerMenu(menu: ContextMenu) { + this.menus.set(menu.menuID, menu) + } + + /** + * Gets the context menu for the given text + * @param text The text to get the context menu for + */ + public getMenuFor(text: string): ContextMenuResult[] { + const menus = Array.from(this.menus.values()).map((x) => x.getMenuFor(text)) + + const result = menus.flatMap((x) => x.results) + + return result + } +} diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/environment.menu.spec.ts b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/environment.menu.spec.ts new file mode 100644 index 0000000..dbc01e6 --- /dev/null +++ b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/environment.menu.spec.ts @@ -0,0 +1,70 @@ +import { TestContainer } from "dioc/testing" +import { describe, expect, it, vi } from "vitest" +import { EnvironmentMenuService } from "../environment.menu" +import { ContextMenuService } from "../.." + +vi.mock("~/modules/i18n", () => ({ + __esModule: true, + getI18n: () => (x: string) => x, +})) + +const actionsMock = vi.hoisted(() => ({ + invokeAction: vi.fn(), +})) + +vi.mock("~/helpers/actions", async () => { + return { + __esModule: true, + invokeAction: actionsMock.invokeAction, + } +}) + +describe("EnvironmentMenuService", () => { + it("registers with the contextmenu service upon initialization", () => { + const container = new TestContainer() + + const registerContextMenuFn = vi.fn() + + container.bindMock(ContextMenuService, { + registerMenu: registerContextMenuFn, + }) + + const environment = container.bind(EnvironmentMenuService) + + expect(registerContextMenuFn).toHaveBeenCalledOnce() + expect(registerContextMenuFn).toHaveBeenCalledWith(environment) + }) + + describe("getMenuFor", () => { + it("should return a menu for adding environment", () => { + const container = new TestContainer() + const environment = container.bind(EnvironmentMenuService) + + const test = "some-text" + const result = environment.getMenuFor(test) + + expect(result.results).toContainEqual( + expect.objectContaining({ id: "environment" }) + ) + }) + + it("should invoke the add environment modal", () => { + const container = new TestContainer() + const environment = container.bind(EnvironmentMenuService) + + const test = "some-text" + const result = environment.getMenuFor(test) + + const action = result.results[0].action + action() + expect(actionsMock.invokeAction).toHaveBeenCalledOnce() + expect(actionsMock.invokeAction).toHaveBeenCalledWith( + "modals.environment.add", + { + envName: "", + variableName: test, + } + ) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/parameter.menu.spec.ts b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/parameter.menu.spec.ts new file mode 100644 index 0000000..322ec8c --- /dev/null +++ b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/parameter.menu.spec.ts @@ -0,0 +1,73 @@ +import { TestContainer } from "dioc/testing" +import { describe, expect, it, vi } from "vitest" +import { ContextMenuService } from "../.." +import { ParameterMenuService } from "../parameter.menu" + +//regex containing both url and parameter +const urlAndParameterRegex = new RegExp("[^&?]*?=[^&?]*") + +vi.mock("~/modules/i18n", () => ({ + __esModule: true, + getI18n: () => (x: string) => x, +})) + +describe("ParameterMenuService", () => { + it("registers with the contextmenu service upon initialization", () => { + const container = new TestContainer() + + const registerContextMenuFn = vi.fn() + + container.bindMock(ContextMenuService, { + registerMenu: registerContextMenuFn, + }) + + const parameter = container.bind(ParameterMenuService) + + expect(registerContextMenuFn).toHaveBeenCalledOnce() + expect(registerContextMenuFn).toHaveBeenCalledWith(parameter) + }) + + describe("getMenuFor", () => { + it("validating if the text passes the regex and return the menu", () => { + const container = new TestContainer() + const parameter = container.bind(ParameterMenuService) + + const test = "https://hoppscotch.io?id=some-text" + const result = parameter.getMenuFor(test) + + if (test.match(urlAndParameterRegex)) { + expect(result.results).toContainEqual( + expect.objectContaining({ id: "parameter" }) + ) + } else { + expect(result.results).not.toContainEqual( + expect.objectContaining({ id: "parameter" }) + ) + } + }) + + it("should return a result with an action when text contains parameters", () => { + const container = new TestContainer() + const parameter = container.bind(ParameterMenuService) + + const test = "https://hoppscotch.io?id=some-text" + + const result = parameter.getMenuFor(test) + + expect(result.results).toHaveLength(1) + expect(result.results[0]).toHaveProperty("action") + expect(typeof result.results[0].action).toBe("function") + }) + + it("should return empty results when text does not contain parameters", () => { + const container = new TestContainer() + const parameter = container.bind(ParameterMenuService) + + const test = "https://hoppscotch.io" + + const result = parameter.getMenuFor(test) + + expect(result.results).toHaveLength(0) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/url.menu.spec.ts b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/url.menu.spec.ts new file mode 100644 index 0000000..80496db --- /dev/null +++ b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/url.menu.spec.ts @@ -0,0 +1,83 @@ +import { TestContainer } from "dioc/testing" +import { describe, expect, it, vi } from "vitest" +import { RESTTabService } from "~/services/tab/rest" +import { ContextMenuService } from "../.." +import { URLMenuService } from "../url.menu" +import { getDefaultRESTRequest } from "~/helpers/rest/default" + +vi.mock("~/modules/i18n", () => ({ + __esModule: true, + getI18n: () => (x: string) => x, +})) + +describe("URLMenuService", () => { + it("registers with the contextmenu service upon initialization", () => { + const container = new TestContainer() + + const registerContextMenuFn = vi.fn() + + container.bindMock(ContextMenuService, { + registerMenu: registerContextMenuFn, + }) + + const environment = container.bind(URLMenuService) + + expect(registerContextMenuFn).toHaveBeenCalledOnce() + expect(registerContextMenuFn).toHaveBeenCalledWith(environment) + }) + + describe("getMenuFor", () => { + it("validating if the text passes the regex and return the menu", () => { + function isValidURL(url: string) { + try { + new URL(url) + return true + } catch (_error) { + // Fallback to regular expression check + const pattern = /^(https?:\/\/)?([\w.-]+)(\.[\w.-]+)+([/?].*)?$/ + return pattern.test(url) + } + } + + const container = new TestContainer() + const url = container.bind(URLMenuService) + + const test = "" + const result = url.getMenuFor(test) + + if (isValidURL(test)) { + expect(result.results).toContainEqual( + expect.objectContaining({ id: "link-tab" }) + ) + } else { + expect(result).toEqual({ results: [] }) + } + }) + + it("should call the openNewTab function when action is called and a new hoppscotch tab is opened", () => { + const container = new TestContainer() + const createNewTabFn = vi.fn() + container.bindMock(RESTTabService, { + createNewTab: createNewTabFn, + }) + const url = container.bind(URLMenuService) + + const test = "https://hoppscotch.io" + const result = url.getMenuFor(test) + + result.results[0].action() + + const request = { + ...getDefaultRESTRequest(), + endpoint: test, + } + + expect(createNewTabFn).toHaveBeenCalledOnce() + expect(createNewTabFn).toHaveBeenCalledWith({ + request: request, + isDirty: false, + type: "request", + }) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/environment.menu.ts b/packages/hoppscotch-common/src/services/context-menu/menu/environment.menu.ts new file mode 100644 index 0000000..a79b304 --- /dev/null +++ b/packages/hoppscotch-common/src/services/context-menu/menu/environment.menu.ts @@ -0,0 +1,54 @@ +import { Service } from "dioc" +import { + ContextMenu, + ContextMenuResult, + ContextMenuService, + ContextMenuState, +} from "../" +import { markRaw, ref } from "vue" +import { invokeAction } from "~/helpers/actions" +import IconPlusCircle from "~icons/lucide/plus-circle" +import { getI18n } from "~/modules/i18n" + +/** + * This menu returns a single result that allows the user + * to add the selected text as an environment variable + * This menus is shown on all text selections + */ +export class EnvironmentMenuService extends Service implements ContextMenu { + public static readonly ID = "ENVIRONMENT_CONTEXT_MENU_SERVICE" + + private t = getI18n() + + public readonly menuID = "environment" + + private readonly contextMenu = this.bind(ContextMenuService) + + override onServiceInit() { + this.contextMenu.registerMenu(this) + } + + getMenuFor(text: Readonly): ContextMenuState { + const results = ref([]) + results.value = [ + { + id: "environment", + text: { + type: "text", + text: this.t("context_menu.set_environment_variable"), + }, + icon: markRaw(IconPlusCircle), + action: () => { + invokeAction("modals.environment.add", { + envName: "", + variableName: text, + }) + }, + }, + ] + const resultObj = { + results: results.value, + } + return resultObj + } +} diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/parameter.menu.ts b/packages/hoppscotch-common/src/services/context-menu/menu/parameter.menu.ts new file mode 100644 index 0000000..dfb03a2 --- /dev/null +++ b/packages/hoppscotch-common/src/services/context-menu/menu/parameter.menu.ts @@ -0,0 +1,142 @@ +import { Service } from "dioc" +import { + ContextMenu, + ContextMenuResult, + ContextMenuService, + ContextMenuState, +} from "../" +import { markRaw, ref } from "vue" +import IconArrowDownRight from "~icons/lucide/arrow-down-right" +import { getI18n } from "~/modules/i18n" +import { RESTTabService } from "~/services/tab/rest" +import { getService } from "~/modules/dioc" + +//regex containing both url and parameter +const urlAndParameterRegex = new RegExp("[^&?]*?=[^&?]*") + +interface Param { + [key: string]: string +} + +/** + * The extracted parameters from the input + * with the new URL if it was provided + */ +interface ExtractedParams { + params: Param + newURL?: string +} + +/** + * This menu returns a single result that allows the user + * to add the selected text as a parameter + * if the selected text is a valid URL + */ +export class ParameterMenuService extends Service implements ContextMenu { + public static readonly ID = "PARAMETER_CONTEXT_MENU_SERVICE" + + private t = getI18n() + + public readonly menuID = "parameter" + + private readonly contextMenu = this.bind(ContextMenuService) + + override onServiceInit() { + this.contextMenu.registerMenu(this) + } + + /** + * + * @param input The input to extract the parameters from + * @returns The extracted parameters and the new URL if it was provided + */ + private extractParams(input: string): ExtractedParams { + let text = input + let newURL: string | undefined + + // if the input is a URL, extract the parameters + if (text.startsWith("http")) { + const url = new URL(text) + newURL = url.origin + url.pathname + text = url.search.slice(1) + } + + const regex = /([^&=]+)=([^&]+)/g + const matches = text.matchAll(regex) + const params: Param = {} + + // extract the parameters from the input + for (const match of matches) { + const [, key, value] = match + params[key] = value + } + + return { params, newURL } + } + + /** + * Adds the parameters from the input to the current request + * parameters and updates the endpoint if a new URL was provided + * @param text The input to extract the parameters from + */ + private addParameter(text: string) { + const { params, newURL } = this.extractParams(text) + + const queryParams = [] + for (const [key, value] of Object.entries(params)) { + queryParams.push({ key, value, active: true }) + } + + const tabService = getService(RESTTabService) + + if (tabService.currentActiveTab.value.document.type === "test-runner") + return + + const currentActiveRequest = + tabService.currentActiveTab.value.document.type === "request" + ? tabService.currentActiveTab.value.document.request + : tabService.currentActiveTab.value.document.response.originalRequest + + // add the parameters to the current request parameters + currentActiveRequest.params = [ + ...currentActiveRequest.params, + ...queryParams.map((param) => ({ ...param, description: "" })), + ] + + if (newURL) { + currentActiveRequest.endpoint = newURL + } else { + // remove the parameter from the URL + const textRegex = new RegExp(`\\b${text.replace(/\?/g, "")}\\b`, "gi") + const sanitizedWord = currentActiveRequest.endpoint + const newURL = sanitizedWord.replace(textRegex, "") + currentActiveRequest.endpoint = newURL + } + } + + getMenuFor(text: Readonly): ContextMenuState { + const results = ref([]) + + if (urlAndParameterRegex.test(text)) { + results.value = [ + { + id: "parameter", + text: { + type: "text", + text: this.t("context_menu.add_parameters"), + }, + icon: markRaw(IconArrowDownRight), + action: () => { + this.addParameter(text) + }, + }, + ] + } + + const resultObj = { + results: results.value, + } + + return resultObj + } +} diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/url.menu.ts b/packages/hoppscotch-common/src/services/context-menu/menu/url.menu.ts new file mode 100644 index 0000000..9d6c5f2 --- /dev/null +++ b/packages/hoppscotch-common/src/services/context-menu/menu/url.menu.ts @@ -0,0 +1,168 @@ +import { Service } from "dioc" +import { markRaw, ref } from "vue" +import { getDefaultRESTRequest } from "~/helpers/rest/default" +import { getI18n } from "~/modules/i18n" +import { RESTTabService } from "~/services/tab/rest" +import IconCopyPlus from "~icons/lucide/copy-plus" +import IconLock from "~icons/lucide/lock" +import IconUnlock from "~icons/lucide/unlock" +import { + ContextMenu, + ContextMenuResult, + ContextMenuService, + ContextMenuState, +} from ".." + +/** + * Checks if a string matches a URL via the URL constructor or a regex fallback. + * The URL constructor rejects endpoints like "localhost:3000" (no protocol), + * so the regex covers common patterns without a scheme. + */ +function checkURL(url: string): boolean { + try { + new URL(url) + return true + } catch { + return /^(https?:\/\/)?([\w.-]+)(\.[\w.-]+)+([/?].*)?$/.test(url) + } +} + +/** + * Used to check if a string is a valid URL + * @param url The string to check + * @returns Whether the string is a valid URL + */ +function isValidURL(url: string) { + if (checkURL(url)) return true + + // Iteratively decode percent-encoded strings so that encode/decode + // round-trips work across multiple levels of encoding. + let current = url + for (let i = 0; i < 10 && current.includes("%"); i++) { + try { + const decoded = decodeURIComponent(current) + if (decoded === current) break + if (checkURL(decoded)) return true + current = decoded + } catch { + break + } + } + + return false +} + +export class URLMenuService extends Service implements ContextMenu { + public static readonly ID = "URL_CONTEXT_MENU_SERVICE" + + private t = getI18n() + + public readonly menuID = "url" + + private readonly contextMenu = this.bind(ContextMenuService) + private readonly restTab = this.bind(RESTTabService) + + override onServiceInit() { + this.contextMenu.registerMenu(this) + } + + /** + * Opens a new tab with the provided URL + * @param url The URL to open + */ + private openNewTab(url: string) { + //create a new request object + const request = { + ...getDefaultRESTRequest(), + endpoint: url, + } + + this.restTab.createNewTab({ + type: "request", + request: request, + isDirty: false, + }) + } + + /** + * Replaces the selected text in the current endpoint with encoded/decoded version + * @param selectedText The selected text to replace + * @param replacement The replacement text (encoded or decoded) + */ + private replaceSelectedText(selectedText: string, replacement: string) { + const currentTab = this.restTab.currentActiveTab.value + + if (!currentTab || currentTab.document.type !== "request") { + return + } + + const endpoint = currentTab.document.request.endpoint + + // Find and replace the selected text in the endpoint + const index = endpoint.indexOf(selectedText) + if (index === -1) return + + const newEndpoint = + endpoint.slice(0, index) + + replacement + + endpoint.slice(index + selectedText.length) + currentTab.document.request.endpoint = newEndpoint + } + + getMenuFor(text: Readonly): ContextMenuState { + const results = ref([]) + + if (isValidURL(text)) { + results.value = [ + { + id: "link-tab", + text: { + type: "text", + text: this.t("context_menu.open_request_in_new_tab"), + }, + icon: markRaw(IconCopyPlus), + action: () => { + this.openNewTab(text) + }, + }, + { + id: "encode-url", + text: { + type: "text", + text: this.t("context_menu.encode_uri_component"), + }, + icon: markRaw(IconLock), + action: () => { + const encoded = encodeURIComponent(text) + this.replaceSelectedText(text, encoded) + }, + }, + { + id: "decode-url", + text: { + type: "text", + text: this.t("context_menu.decode_uri_component"), + }, + icon: markRaw(IconUnlock), + action: () => { + try { + const decoded = decodeURIComponent(text) + this.replaceSelectedText(text, decoded) + } catch (error) { + console.warn( + "[URLMenuService] Failed to decode URI component:", + error + ) + } + }, + }, + ] + } + + const resultObj = { + results: results.value, + } + + return resultObj + } +} diff --git a/packages/hoppscotch-common/src/services/cookie-jar.service.ts b/packages/hoppscotch-common/src/services/cookie-jar.service.ts new file mode 100644 index 0000000..f0b18a3 --- /dev/null +++ b/packages/hoppscotch-common/src/services/cookie-jar.service.ts @@ -0,0 +1,840 @@ +import { Service } from "dioc" +import { ref } from "vue" +import { parseString as setCookieParse } from "set-cookie-parser-es" +import { Cookie } from "@hoppscotch/data" +import * as E from "fp-ts/Either" +import { Store } from "~/kernel/store" + +// Cookies are per-organization state, so they persist through the +// org-scoped `Store` (`~/kernel/store`), which resolves to +// `{org}.hoppscotch.store` on desktop. localStorage and `UnifiedStore` +// would merge one organization's session cookies into another because +// every org webview shares the same `app://{bundle}/` origin. +const STORE_NAMESPACE = "cookies.v1" + +const STORE_KEYS = { + JAR: "jar", +} as const + +interface StoredCookieJar { + version: string + domains: Record + lastUpdated: string + // Stamped on every write this process does, so the cross-process + // watcher can ignore its own echo and not overwrite concurrent + // in-memory mutations with a stale disk snapshot. + writeToken?: string +} + +// Mirror of one entry in the kernel relay response's `cookies` array +// (`RelayResponse["cookies"]`). Typed here instead of imported so the +// service does not depend on the kernel re-exporting the type. The +// relay leaves domain/path optional and gives a `Date` expiry, the +// `@hoppscotch/data` schema needs a concrete domain, path, the +// booleans, and an ISO string expiry, so capture normalizes it. +type ResponseCookie = { + name: string + value: string + domain?: string + path?: string + expires?: Date + secure?: boolean + httpOnly?: boolean + sameSite?: "Strict" | "Lax" | "None" +} + +export class CookieJarService extends Service { + public static readonly ID = "COOKIE_JAR_SERVICE" + + /** + * The cookie jar that stores all relevant cookie info. + * The keys correspond to the domain of the cookie. + */ + public cookieJar = ref(new Map()) + + // Resolves once `onServiceInit` has loaded the jar from disk + // and attached the cross-process watcher. Callers that read or + // mutate the jar in the request flow await this so a cold-start + // request does not see an empty in-memory map and then overwrite + // the persisted state with one cookie. + private hydrated: Promise | null = null + + // Set when `Store.init` fails. The service stays alive (the + // request flow keeps working without cookies) but public + // mutators no-op so a doomed `persistJar` cannot log the same + // failure on every request. + private initFailed = false + + // Writes are serialized through this chain so two concurrent + // mutations cannot race on `Store.set`. Each write captures the + // current in-memory map at the time its turn runs, so the latest + // state always lands on disk. + private writeChain: Promise = Promise.resolve() + + // The write token prefix is fresh per service instance so two + // processes (this org's webview and another writing the same + // store file) cannot collide on the counter and mis-treat each + // other's writes as self-echoes. `globalThis.crypto.randomUUID` + // is the preferred source; a `Math.random`-based fallback keeps + // the service constructable in environments (test runners with + // older jsdom, some SSR setups) where the Web Crypto API is not + // exposed. The echo guard only needs uniqueness within the + // running process, no cryptographic property is required. + private writePrefix = (() => { + const uuid = globalThis.crypto?.randomUUID?.() + if (uuid) return uuid + return `wp-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}` + })() + private writeCounter = 0 + + // Ring of recent self-write tokens. A single-slot + // `lastWriteToken` would lose the prior token whenever two writes + // ran in the same tick, so an in-process echo for the older + // write would be misclassified as foreign and reapply a stale + // snapshot. The ring tolerates burst writes up to its size. The + // cap is sized well above any realistic burst (modal save, bulk + // import, response capture with many Set-Cookies) so a token + // cannot age out before its disk-write's watcher echo arrives. + // A `Set` backs the membership check so the watcher's hot path + // is `O(1)` instead of `O(n)` against a 10k buffer, and `Set` + // iteration order is insertion order per the spec so FIFO + // eviction works by reading `values().next()`. + private readonly recentWriteTokensCap = 10_000 + private recentWriteTokens = new Set() + + async onServiceInit(): Promise { + this.hydrated = (async () => { + // `Store.init` resolves an `Either` for storage-layer failures + // but `getModule("store")` throws synchronously when the kernel + // is not bootstrapped, which happens in vitest specs that pull + // the service in transitively. Catching here keeps `hydrated` + // resolved, the service alive on its in-memory jar, and + // `initFailed` set so `persistJar` no-ops. + try { + const initResult = await Store.init() + if (E.isLeft(initResult)) { + this.initFailed = true + console.error( + "[CookieJar] Failed to initialize store:", + initResult.left + ) + return + } + + // `loadJar` and `setupWatcher` errors are caught so `hydrated` + // resolves either way. A rejected `hydrated` would make every + // later `whenReady()` reject, and the interceptors' outer + // try/catch would convert a successful HTTP response into + // `E.left` on every request, the same failure mode the + // `initFailed` flag exists to avoid. + try { + await this.loadJar() + await this.setupWatcher() + } catch (e) { + this.initFailed = true + console.error("[CookieJar] Failed during init:", e) + } + } catch (e) { + this.initFailed = true + console.error("[CookieJar] Kernel store unavailable:", e) + } + })() + await this.hydrated + } + + public async whenReady(): Promise { + if (this.hydrated) { + await this.hydrated + } + } + + private async loadJar(): Promise { + const loadResult = await Store.get(STORE_NAMESPACE, STORE_KEYS.JAR) + + // Don't degrade a read failure into "no jar yet" — that lets + // the next persistJar overwrite the disk with `{ domains: {} }`. + if (E.isLeft(loadResult)) { + this.initFailed = true + console.error( + "[CookieJar] Failed to read persisted jar:", + loadResult.left + ) + return + } + if (!loadResult.right) { + return + } + let stored: StoredCookieJar + try { + stored = this.parseStored(loadResult.right) + } catch (e) { + // Same reasoning as the read-failure path: a corrupted payload + // must not be silently replaced with an empty jar. + this.initFailed = true + console.error("[CookieJar] Initial load rejected payload:", e) + return + } + this.cookieJar.value = this.toMap(stored.domains) + this.pruneExpired() + } + + // Cross-process reload, another webview in the same org writes + // the jar and this picks it up. The watch handler ignores echoes + // of this process's own writes by comparing `writeToken`, and + // rejects malformed payloads so a future schema mismatch does + // not kill the listener. + private async setupWatcher(): Promise { + const watcher = await Store.watch(STORE_NAMESPACE, STORE_KEYS.JAR) + watcher.on("change", ({ value }: { value?: unknown }) => { + if (!value) { + return + } + let stored: StoredCookieJar + try { + stored = this.parseStored(value) + } catch (e) { + console.error("[CookieJar] Watcher rejected malformed payload:", e) + return + } + if (stored.writeToken && this.recentWriteTokens.has(stored.writeToken)) { + return + } + this.cookieJar.value = this.toMap(stored.domains) + this.pruneExpired() + }) + } + + private parseStored(value: unknown): StoredCookieJar { + if (typeof value !== "object" || value === null) { + throw new Error("payload is not an object") + } + const v = value as Partial + if (typeof v.domains !== "object" || v.domains === null) { + throw new Error("payload missing domains record") + } + // Per-domain entries must be arrays of minimally-shaped cookie + // objects. Without the per-cookie shape check a malformed + // cross-process write (cookie with non-string name or value) + // would survive parseStored, slip past `isCookieNameValid`'s + // regex when `.test` coerces undefined to the string + // "undefined", and ship `Cookie: undefined=undefined` on the + // wire. The schema-grade fields the rest of the service relies + // on are name, value, and domain, so those get the typeof + // check at the boundary. + for (const cookies of Object.values(v.domains)) { + if (!Array.isArray(cookies)) { + throw new Error("payload has non-array domain entry") + } + for (const c of cookies) { + if ( + typeof c !== "object" || + c === null || + typeof (c as { name?: unknown }).name !== "string" || + typeof (c as { value?: unknown }).value !== "string" || + typeof (c as { domain?: unknown }).domain !== "string" || + typeof (c as { path?: unknown }).path !== "string" || + typeof (c as { secure?: unknown }).secure !== "boolean" + ) { + // `path` is what `pathMatches` reads to decide whether + // a cookie applies, `secure` is what gates the HTTPS-only + // attach in `applyCookiesToRequest`, so a schema-drifted + // payload that smuggled a string `"false"` past either + // would silently mismatch path scope or attach over HTTP. + throw new Error("payload has malformed cookie") + } + } + } + return v as StoredCookieJar + } + + // Migration-aware hydration. A jar persisted before the RFC + // 6265 canon work could carry `.example.com` or `Example.COM` + // keys, and the new `domainMatches` would never line those up + // with an incoming `example.com` request, so the cookies would + // load into memory and never apply. The canon pass runs on + // every load (initial and watcher) so the on-disk shape + // upgrades on first read after the upgrade, and collisions + // between two non-canonical keys that map to the same canonical + // form dedupe by `(name, path)` with last-occurrence wins, so + // `applyCookiesToRequest` never emits two cookies with the same + // name and path in one Cookie header. Each cookie's own + // `domain` is also canonicalized so `domainMatches` sees the + // canonical form on both sides. + private toMap(domains: Record): Map { + const dedup = new Map>() + for (const [rawKey, cookies] of Object.entries(domains)) { + const key = this.canonStoreDomain(rawKey) + if (key.length === 0) { + continue + } + let bucket = dedup.get(key) + if (!bucket) { + bucket = new Map() + dedup.set(key, bucket) + } + for (const c of cookies) { + const canonized: Cookie = { + ...c, + domain: this.canonStoreDomain(c.domain ?? key) || key, + } + // NUL separator matches the `cookieKey` pattern in + // `RequestRunner.ts`. Empty-string path collapses to + // `/` so an empty and a `/` cookie dedupe onto the same + // key. + const dedupKey = `${canonized.name}\0${canonized.path && canonized.path.length > 0 ? canonized.path : "/"}` + bucket.set(dedupKey, canonized) + } + } + const map = new Map() + for (const [key, bucket] of dedup) { + map.set(key, Array.from(bucket.values())) + } + return map + } + + private persistJar(): void { + if (this.initFailed) { + return + } + const token = `${this.writePrefix}-${++this.writeCounter}` + if (this.recentWriteTokens.size >= this.recentWriteTokensCap) { + const oldest = this.recentWriteTokens.values().next().value + if (oldest !== undefined) { + this.recentWriteTokens.delete(oldest) + } + } + this.recentWriteTokens.add(token) + + this.writeChain = this.writeChain + .then(async () => { + const stored: StoredCookieJar = { + version: "v1", + domains: Object.fromEntries(this.cookieJar.value), + lastUpdated: new Date().toISOString(), + writeToken: token, + } + + const saveResult = await Store.set( + STORE_NAMESPACE, + STORE_KEYS.JAR, + stored + ) + if (E.isLeft(saveResult)) { + console.error("[CookieJar] Failed to persist jar:", saveResult.left) + } + }) + .catch((e) => { + console.error("[CookieJar] Persist chain error:", e) + }) + } + + public parseSetCookieString(setCookieString: string) { + return setCookieParse(setCookieString) + } + + // The single merge path. Response capture and the post-request + // script both route here so they reconcile by (domain, name, path) + // instead of one wholesale-replacing the other. The in-memory + // update is synchronous so callers in the request flow do not block + // on disk, the write-through runs after. + public async upsertCookies(cookies: Cookie[]): Promise { + await this.whenReady() + for (const cookie of cookies) { + // A script that returns a cookie without a domain (or with an + // empty string) would otherwise persist under the Map key + // `undefined` which serializes as the literal string + // `"undefined"`, so the entry is dropped with a warning. + if (!cookie.domain) { + console.warn( + `[CookieJar] Skipping cookie "${cookie.name}" with empty domain` + ) + continue + } + // Domain is canonicalized (strip leading dot, lowercase) at + // the entry boundary so script-supplied and modal-supplied + // entries like `.Example.COM` end up as `example.com`, the + // same form `domainMatches` compares against. + // A canonicalized domain that contains whitespace or comes + // out empty cannot match any request host, so the cookie is + // skipped instead of polluting the jar with an unreachable + // entry. + // Path is normalized too so the merge key (`name`, `path`) + // is comparable across response captures, script returns, + // and modal edits. An undefined incoming path would otherwise + // miss a stored `"/"` and produce a duplicate. + const canonDomain = this.canonStoreDomain(cookie.domain) + if (canonDomain.length === 0 || /\s/.test(canonDomain)) { + console.warn( + `[CookieJar] Skipping cookie "${cookie.name}" with invalid domain "${cookie.domain}"` + ) + continue + } + // An empty-string `path` falls through `??` (only `undefined` + // triggers it) and would otherwise produce `startsWith("")` + // matching every request path under the domain, plus a + // duplicate jar entry alongside the canonical `/` form. The + // explicit length check collapses both empty and undefined + // to `/`. + // Scripting sandbox callers (`hopp.cookies.set(...)`) are + // untyped at runtime, so a `name` or `value` that comes + // through as a non-string would later crash + // `serializeCookieHeader` and the persisted-payload + // `parseStored` check. The cookie is skipped instead of + // sneaking a bad shape onto disk. + if (typeof cookie.name !== "string" || typeof cookie.value !== "string") { + console.warn( + `[CookieJar] Skipping cookie with non-string name or value on domain "${cookie.domain}"` + ) + continue + } + const normalized: Cookie = { + ...cookie, + domain: canonDomain, + path: cookie.path && cookie.path.length > 0 ? cookie.path : "/", + // `parseStored` validates `secure` as a boolean, so a + // script-set cookie that omitted the flag (or sent a + // non-boolean) would crash the entire jar load on next + // launch via the load-time shape check. Default to + // `false` so an in-memory write survives the persist + // round-trip. + secure: typeof cookie.secure === "boolean" ? cookie.secure : false, + httpOnly: + typeof cookie.httpOnly === "boolean" ? cookie.httpOnly : false, + } + const existing = this.cookieJar.value.get(normalized.domain) ?? [] + + const idx = existing.findIndex( + (c) => c.name === normalized.name && c.path === normalized.path + ) + if (idx === -1) { + existing.push(normalized) + } else { + existing[idx] = normalized + } + + this.cookieJar.value.set(normalized.domain, existing) + } + + this.persistJar() + } + + // Kept for existing callers, now reconciles instead of blind-pushing + // duplicates. + public async bulkApplyCookiesToDomain( + cookies: Cookie[], + domain: string + ): Promise { + await this.upsertCookies(cookies.map((c) => ({ ...c, domain }))) + } + + // Trims, strips a leading dot the way RFC 6265 5.2.3 requires + // (the dot is wire-format history and is not part of the + // matching algorithm), then lowercases per RFC 6265 5.1.2. Used + // at every boundary that writes into the jar so the stored key + // always matches `domainMatches`'s lowercase comparison. Public + // so the post-request script delta in `RequestRunner` and the + // modal save in `AllModal` can build matching keys without + // re-implementing the same normalization in three places. + public canonStoreDomain(raw: string): string { + const trimmed = raw.trim() + const stripped = trimmed.startsWith(".") ? trimmed.slice(1) : trimmed + return stripped.toLowerCase() + } + + // Canonicalizes a cookie domain that came from a Set-Cookie + // `Domain` attribute. Calls `canonStoreDomain` then rejects a + // single-label domain that would let the cookie attach to every + // site under that suffix. Returns null for a domain that should + // not be stored. + private canonAttrDomain(raw: string): string | null { + const lower = this.canonStoreDomain(raw) + if (lower.length === 0 || !lower.includes(".")) { + return null + } + return lower + } + + // Canonicalizes the host used for a host-only cookie, where the + // response had no `Domain` attribute so the request host is the + // entire domain. The single-label rule does not apply here, the + // host is the literal endpoint the user spoke to. `localhost` and + // other single-label internal hostnames stay valid. + private canonHostOnly(host: string): string { + return host.toLowerCase() + } + + // RFC 6265 5.1.4 default-path. A response that omits the `Path` + // attribute defaults to the directory of the request URI path, + // i.e. the substring up to but not including the rightmost `/`. + // A path with no `/` or only the leading `/` defaults to `/`. + private defaultPath(uriPath: string): string { + if (uriPath.length === 0 || uriPath[0] !== "/") { + return "/" + } + const last = uriPath.lastIndexOf("/") + if (last === 0) { + return "/" + } + return uriPath.slice(0, last) + } + + // Normalizes the kernel relay response cookies into the + // `@hoppscotch/data` shape and merges them. Domain falls back to the + // request host (host-only cookie), path to "/", the flags default + // off, and SameSite to "Lax" matching the browser default. + public async extractFromResponse( + cookies: ResponseCookie[] | undefined, + requestURL: URL + ): Promise { + if (!cookies || cookies.length === 0) { + return + } + + const requestHost = requestURL.hostname.toLowerCase() + const normalized: Cookie[] = [] + for (const c of cookies) { + let domain: string | null + if (c.domain) { + domain = this.canonAttrDomain(c.domain) + } else { + domain = this.canonHostOnly(requestHost) + } + if (domain === null) { + console.warn( + "[CookieJar] Dropped cookie with unusable domain:", + c.domain + ) + continue + } + // `new Date(c.expires).toISOString()` throws RangeError on an + // Invalid Date (kernel passes a Date built from an unparseable + // Expires attribute). The expires field is dropped instead so + // the cookie still captures, treated as a session cookie. + let expires: string | undefined + if (c.expires) { + const t = new Date(c.expires).getTime() + if (Number.isFinite(t)) { + expires = new Date(t).toISOString() + } + } + // RFC 6265 5.1.4 requires the Path attribute to begin with + // `/`. A response that sends `Path=foo` (or anything else + // not starting with the separator) falls back to the + // default-path so the cookie is stored under a key the + // request matcher can actually hit. + const explicitPath = + c.path && c.path.length > 0 && c.path.startsWith("/") ? c.path : null + normalized.push({ + name: c.name, + value: c.value, + domain, + path: explicitPath ?? this.defaultPath(requestURL.pathname), + httpOnly: c.httpOnly ?? false, + secure: c.secure ?? false, + sameSite: c.sameSite ?? "Lax", + ...(expires !== undefined ? { expires } : {}), + }) + } + + if (normalized.length === 0) { + return + } + await this.upsertCookies(normalized) + } + + // Replaces the entire jar. The argument is defensive-copied so a + // caller that keeps the reference and continues mutating its own + // Map cannot inadvertently mutate the live jar after the swap. + public async replaceAll(jar: Map): Promise { + await this.whenReady() + const copy = new Map() + for (const [domain, cookies] of jar.entries()) { + copy.set( + domain, + cookies.map((c) => ({ ...c })) + ) + } + this.cookieJar.value = copy + this.persistJar() + } + + // Deletes the named cookies from the jar, matching by + // `(domain, name, path)`. The post-request script path computes + // a set difference between its pre-script view and the script's + // returned `updatedCookies` and calls this for the entries that + // disappeared, restoring delete-by-omission semantics without + // the wholesale-replace behaviour that clobbered concurrent + // response captures. + public async deleteCookies( + targets: Array<{ domain: string; name: string; path?: string }> + ): Promise { + await this.whenReady() + let changed = false + for (const target of targets) { + // Domain is canonicalized at the lookup boundary so a target + // built from raw user input or a script-supplied value (e.g. + // ".Example.COM") finds the canonical bucket the jar + // actually stored. + const domain = this.canonStoreDomain(target.domain) + const existing = this.cookieJar.value.get(domain) + if (!existing) { + continue + } + const path = target.path && target.path.length > 0 ? target.path : "/" + const next = existing.filter( + (c) => !(c.name === target.name && c.path === path) + ) + if (next.length === existing.length) { + continue + } + changed = true + if (next.length === 0) { + this.cookieJar.value.delete(domain) + } else { + this.cookieJar.value.set(domain, next) + } + } + if (changed) { + this.persistJar() + } + } + + // Drops expired cookies from the jar. Called on load and before + // every read so a stale cookie never gets forwarded. Persists only + // when something was actually removed. A cookie whose `expires` + // is unparseable (the kernel handed back an Invalid Date that + // serialized strangely, or a cross-process write stored something + // non-ISO) is treated as a session cookie instead of as expired, + // so a malformed payload cannot silently evaporate jar entries. + public pruneExpired(): void { + const now = Date.now() + let changed = false + + for (const [domain, cookies] of this.cookieJar.value.entries()) { + const live = cookies.filter((c) => { + if (!c.expires) return true + const t = new Date(c.expires).getTime() + return !Number.isFinite(t) || t >= now + }) + if (live.length === cookies.length) { + continue + } + changed = true + if (live.length === 0) { + this.cookieJar.value.delete(domain) + } else { + this.cookieJar.value.set(domain, live) + } + } + + if (changed) { + this.persistJar() + } + } + + // RFC 6265 5.1.3 domain matching. The host matches a stored domain + // when it is the domain or a subdomain of it. The old code used a + // bare `hostname.endsWith(domain)`, which let `evil-example.com` + // match `example.com` because there was no label boundary. Hosts + // are lowercased here, stored domains were lowercased on capture, + // so the comparison is case-insensitive per RFC 6265 5.1.2. + private domainMatches(host: string, domain: string): boolean { + const h = host.toLowerCase() + return h === domain || h.endsWith(`.${domain}`) + } + + // RFC 6265 5.1.4 path matching. The request path matches the cookie + // path when they are equal, or the cookie path is a prefix that ends + // at a "/" boundary. + private pathMatches(reqPath: string, cookiePath: string): boolean { + if (reqPath === cookiePath) { + return true + } + if (!reqPath.startsWith(cookiePath)) { + return false + } + return cookiePath.endsWith("/") || reqPath[cookiePath.length] === "/" + } + + public getCookiesForURL(url: URL): Cookie[] { + this.pruneExpired() + + const result: Cookie[] = [] + + for (const [domain, cookies] of this.cookieJar.value.entries()) { + if (!this.domainMatches(url.hostname, domain)) { + continue + } + + for (const cookie of cookies) { + const passesPath = this.pathMatches(url.pathname, cookie.path || "/") + + const passesExpires = (() => { + if (!cookie.expires) return true + const t = new Date(cookie.expires).getTime() + return !Number.isFinite(t) || t >= Date.now() + })() + + const passesSecure = !cookie.secure || url.protocol === "https:" + + if (passesPath && passesExpires && passesSecure) { + result.push(cookie) + } + } + } + + // RFC 6265 5.4 step 2, cookies with a longer path go first so a + // server that reads the first matching cookie picks the more + // specific value over an inherited one. Stable sort because a tie + // on path length keeps capture order. + result.sort((a, b) => (b.path?.length ?? 1) - (a.path?.length ?? 1)) + + return result + } + + // RFC 6265 4.1.1 cookie-name is an RFC 7230 `token`, alphanumeric + // plus `!#$%&'*+-.^_` `` ` ``|~. Anything outside that set + // (whitespace, `=`, `;`, control chars, etc.) would either + // corrupt the header or be ambiguous on parse, so the cookie is + // skipped at serialization time. + private isCookieNameValid(name: string): boolean { + return name.length > 0 && /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name) + } + + // RFC 6265 5.4 cookie-value disallows CTL, whitespace, comma, + // semicolon, double-quote, and backslash. A value with any of + // these would corrupt the header on the wire, so the cookie is + // skipped and a warning logged. Most jars hit this only for + // server-set values that arrived already malformed; the matched + // cookie still exists in the in-memory jar for inspection. + private isCookieValueValid(value: string): boolean { + return !/[\x00-\x1f\x7f\s,;"\\]/.test(value) + } + + // RFC 6265 5.4 Cookie header serialization, `name=value` pairs + // joined by "; ". + public serializeCookieHeader(cookies: Cookie[]): string { + const parts: string[] = [] + for (const c of cookies) { + if (!this.isCookieNameValid(c.name)) { + console.warn( + `[CookieJar] Skipping cookie with invalid name "${c.name}"` + ) + continue + } + if (!this.isCookieValueValid(c.value)) { + console.warn( + `[CookieJar] Skipping cookie "${c.name}" with invalid value` + ) + continue + } + parts.push(`${c.name}=${c.value}`) + } + return parts.join("; ") + } + + // Returns the parsed URL, or `null` if `raw` is unparseable. A + // request reaches the interceptor with an unresolved environment + // template, a relative URL, or a typo-ed scheme often enough that + // throwing inside the cookie path would surface as a request + // failure when the rest of the pipeline could have produced a + // clearer error. + private parseRequestURL(raw: string): URL | null { + try { + return new URL(raw) + } catch { + return null + } + } + + // The one shared send path. Native, agent, and proxy all call this + // so a request gets the same Cookie header regardless of which + // interceptor runs it, replacing the three slightly different inline + // blocks they used to carry. + public async applyCookiesToRequest(request: { + url?: string + headers?: Record + }): Promise { + await this.whenReady() + if (!request.url) { + return + } + const url = this.parseRequestURL(request.url) + if (url === null) { + return + } + + // Empty or whitespace-only `Cookie` placeholders are stripped + // unconditionally so a leftover row from a request template + // never reaches the wire, regardless of whether the jar ends + // up contributing cookies on this request. + if (request.headers) { + for (const key of Object.keys(request.headers)) { + if ( + key.toLowerCase() === "cookie" && + (request.headers[key] === undefined || + request.headers[key].trim() === "") + ) { + delete request.headers[key] + } + } + } + // A user who set a non-empty `Cookie` header through the + // request panel (any case-variant) is asserting deliberate + // intent for this request, so the jar yields and the user's + // header is preserved as written. The jar still updates from + // response capture and the manager remains the place to + // inspect or edit it. + if (request.headers) { + for (const key of Object.keys(request.headers)) { + if (key.toLowerCase() === "cookie") { + return + } + } + } + + const cookies = this.getCookiesForURL(url) + if (cookies.length === 0) { + return + } + // `serializeCookieHeader` may drop every cookie if the values + // fail validation, returning an empty string. The Cookie header + // is set only when the serialized form has content, so a request + // never goes out with `Cookie: ""` which some servers reject. + const serialized = this.serializeCookieHeader(cookies) + if (serialized.length === 0) { + return + } + + if (!request.headers) { + request.headers = {} + } + // The user may have left a case-variant `cookie` / `COOKIE` + // key with an empty value (treated as absent above and not + // suppressing the jar). Removing every case-variant before + // writing the canonical `Cookie` ensures the request goes out + // with one header line, never two. + for (const key of Object.keys(request.headers)) { + if (key !== "Cookie" && key.toLowerCase() === "cookie") { + delete request.headers[key] + } + } + request.headers["Cookie"] = serialized + } + + // The one shared receive path. Captures structured cookies the + // relay parsed out of the response into the jar. + public async captureResponseCookies( + response: { cookies?: ResponseCookie[] }, + requestUrl: string | undefined + ): Promise { + if (!requestUrl) { + return + } + const url = this.parseRequestURL(requestUrl) + if (url === null) { + return + } + await this.extractFromResponse(response.cookies, url) + } +} diff --git a/packages/hoppscotch-common/src/services/current-environment-value.service.ts b/packages/hoppscotch-common/src/services/current-environment-value.service.ts new file mode 100644 index 0000000..4edaa97 --- /dev/null +++ b/packages/hoppscotch-common/src/services/current-environment-value.service.ts @@ -0,0 +1,193 @@ +import { Container, Service } from "dioc" +import { cloneDeep } from "lodash-es" +import { reactive, computed, watch, nextTick } from "vue" + +/** + * Defines a environment variable. + */ +export type Variable = { + key: string + currentValue: string + varIndex: number + isSecret: boolean +} + +/** + * This service is used to store and manage current value of environment variables. + * The current value are not synced with the server. + * hence they are not persisted in the database. They are stored + * in the local storage of the browser. + */ +export class CurrentValueService extends Service { + public static readonly ID = "CURRENT_VALUE_SERVICE" + + constructor(c: Container) { + super(c) + // Initialize the secret environments map + this.watchCurrentEnvironments() + } + + /** + * Map of current value of environments. + * The key is the ID of the environment. + * The value is the list of environment variables. + */ + public environments = reactive(new Map()) + + /** + * Add a new environment. + * @param id ID of the environment + * @param vars List of environment variables + */ + public addEnvironment(id: string, vars: Variable[]) { + this.environments.set(id, vars) + } + + /** + * Get a environment. + * @param id ID of the environment + */ + public getEnvironment(id: string) { + return this.environments.get(id) + } + + /** + * Add a new environment variable to the environment. + * If the environment does not exist, it will be created. + * @param id ID of the environment + * @param variable Environment variable to add + */ + public addEnvironmentVariable(id: string, variable: Variable) { + const vars = this.getEnvironment(id) + if (vars) { + const newVars = cloneDeep(vars) + newVars.push(variable) + this.environments.set(id, newVars) + } else { + this.environments.set(id, [variable]) + } + } + + /** + * Get a environment variable. + * @param id ID of the environment + * @param varIndex Index of the variable in the environment + */ + public getEnvironmentVariable(id: string, varIndex: number) { + const vars = this.getEnvironment(id) + return vars?.find((v) => v.varIndex === varIndex) + } + + /** + * Used to get the value of a environment variable. + * @param id ID of the environment + * @param varIndex Index of the variable in the environment + */ + public getEnvironmentVariableValue(id: string, varIndex: number) { + const variable = this.getEnvironmentVariable(id, varIndex) + return variable?.currentValue + } + + /** + * + * @param environments Used to load environments from persisted state. + */ + public loadEnvironmentsFromPersistedState( + environments: Record + ) { + if (environments) { + this.environments.clear() + + Object.entries(environments).forEach(([id, vars]) => { + this.addEnvironment(id, vars) + }) + } + } + + /** + * Delete a environment. + * @param id ID of the environment + */ + public deleteEnvironment(id: string) { + this.environments.delete(id) + } + + /** + * Delete a environment variable. + * @param id ID of the environment + * @param varIndex Index of the variable in the environment + */ + public removeEnvironmentVariable(id: string, varIndex: number) { + const vars = this.getEnvironment(id) + const newVars = vars?.filter((v) => v.varIndex !== varIndex) + this.environments.set(id, newVars || []) + } + + /** + * Migrate entries from `oldID` to `newID`. Skips the `set` when nothing + * exists under `oldID` so a no-op migrate doesn't clobber `newID`. + */ + public updateEnvironmentID(oldID: string, newID: string) { + // No-op when keys match — otherwise the get→set→delete sequence would + // erase the just-written entry. + if (oldID === newID) return + const vars = this.getEnvironment(oldID) + if (vars !== undefined) { + this.environments.set(newID, vars) + } + this.environments.delete(oldID) + } + + /** + * + * @param id ID of the environment + * @param key Key of the variable to check the value exists + * @returns true if the key has a secret value + */ + public hasValue(id: string, key: string) { + return ( + this.environments.has(id) && + this.environments + .get(id)! + .some((v) => v.key === key && v.currentValue !== "") + ) + } + + public getEnvironmentByKey(id: string, key: string) { + const vars = this.getEnvironment(id) + return vars?.find((v) => v.key === key) + } + + /** + * Used to update the value of a environment variable. + */ + public persistableEnvironments = computed(() => { + const environments: Record = {} + this.environments.forEach((vars, id) => { + environments[id] = vars + }) + return environments + }) + + /** + * Watches the current environments for changes. + * If a secret variable is removed or has an empty key, it will be deleted. + */ + protected watchCurrentEnvironments() { + watch( + () => this.environments, + () => { + nextTick(() => { + this.environments.forEach((vars, id) => { + const filteredVars = vars.filter((v) => v.key !== "") + + if (filteredVars.length === 0) { + this.environments.delete(id) + } + }) + }) + }, + { deep: true } + ) + } +} diff --git a/packages/hoppscotch-common/src/services/current-sort.service.ts b/packages/hoppscotch-common/src/services/current-sort.service.ts new file mode 100644 index 0000000..b527c95 --- /dev/null +++ b/packages/hoppscotch-common/src/services/current-sort.service.ts @@ -0,0 +1,89 @@ +import { Service } from "dioc" +import { cloneDeep } from "lodash-es" +import { computed, reactive } from "vue" + +/** + * Defines a sort option. + * For now, we only support sorting by name, ascending or descending. + * In the future, we can add more sort options like date created, date modified, etc. + */ +export type CurrentSortOption = { + sortBy: "name" + sortOrder: "asc" | "desc" +} + +/** + * This service is used to store and manage current sort options of collections and folders. + * This can be order by name, ascending, descending, etc. + */ +export class CurrentSortValuesService extends Service { + public static readonly ID = "CURRENT_SORT_VALUES_SERVICE" + + /** + * Map of sort options for collections and folders. + * Key is the ID of the collection or folder. + * Value is the sort option. + */ + public currentSortOptions = reactive(new Map()) + + /** + * Gets the current sort option for a given collection or folder ID. + * @param id ID of the collection or folder. + * @returns Current sort option for the given ID, or `undefined` if not found. + */ + public getSortOption(id: string): CurrentSortOption | undefined { + return this.currentSortOptions.get(id) + } + + /** + * Sets the current sort option for a given collection or folder ID. + * @param id ID of the collection or folder. + * @param sortOption Sort option to set. + */ + public setSortOption(id: string, sortOption: CurrentSortOption) { + this.currentSortOptions.set(id, cloneDeep(sortOption)) + } + + /** + * Removes the current sort option for a given collection or folder ID. + * @param id ID of the collection or folder. + */ + public removeSortOption(id: string) { + this.currentSortOptions.delete(id) + } + + /** + * Clears all sort options. + * This is useful when the user logs out or switches accounts. + * */ + public clearAllSortOptions() { + this.currentSortOptions.clear() + } + + /** + * Loads current sort values from persisted state. + * @param currentSortOptions Object containing current sort options to load. + */ + public loadCurrentSortValuesFromPersistedState( + currentSortOptions: Record + ) { + if (currentSortOptions) { + this.clearAllSortOptions() + + Object.entries(currentSortOptions).forEach(([id, sortOption]) => { + this.setSortOption(id, sortOption) + }) + } + } + + /** + * Returns current sort options in a format suitable for persistence. + */ + public persistableCurrentSortValues = computed(() => { + const currentSortOptions: Record = {} + this.currentSortOptions.forEach((option, id) => { + currentSortOptions[id] = option + }) + return currentSortOptions + }) +} diff --git a/packages/hoppscotch-common/src/services/debug.service.ts b/packages/hoppscotch-common/src/services/debug.service.ts new file mode 100644 index 0000000..b025a9d --- /dev/null +++ b/packages/hoppscotch-common/src/services/debug.service.ts @@ -0,0 +1,71 @@ +import { Service } from "dioc" + +/** + * This service provides debug utilities for the application and is + * supposed to be used only in development. + * + * This service logs events from the container and also events + * from all the services that are bound to the container. Along with that + * this service exposes all registered services (including this) to global + * scope (window) under the ID of the service so it can be accessed using + * the console for debugging. + * + * This service injects couple of utilities into the global scope: + * - `_getService(id: string): Service | undefined` - Returns the service instance with the given ID or undefined. + * - `_getBoundServiceIDs(): string[]` - Returns the IDs of all the bound services. + */ +export class DebugService extends Service { + public static readonly ID = "DEBUG_SERVICE" + + override onServiceInit() { + console.debug("DebugService is initialized...") + + const container = this.getContainer() + + // Log container events + container.getEventStream().subscribe((event) => { + if (event.type === "SERVICE_BIND") { + console.debug( + "[CONTAINER] Service Bind:", + event.bounderID ?? "", + "->", + event.boundeeID + ) + } else if (event.type === "SERVICE_INIT") { + console.debug("[CONTAINER] Service Init:", event.serviceID) + + // Subscribe to event stream of the newly initialized service + const service = container.getBoundServiceWithID(event.serviceID) + + // Expose the service globally for debugging via a global variable + ;(window as any)[event.serviceID] = service + + service?.getEventStream().subscribe((ev: any) => { + console.debug(`[${event.serviceID}] Event:`, ev) + }) + } + }) + + // Subscribe to event stream of all already bound services (if any) + for (const [id, service] of container.getBoundServices()) { + service.getEventStream().subscribe((event: any) => { + console.debug(`[${id}]`, event) + }) + + // Expose the service globally for debugging via a global variable + ;(window as any)[id] = service + } + + // Inject debug utilities into the global scope + ;(window as any)._getService = this.getService.bind(this) + ;(window as any)._getBoundServiceIDs = this.getBoundServiceIDs.bind(this) + } + + private getBoundServiceIDs() { + return Array.from(this.getContainer().getBoundServices()).map(([id]) => id) + } + + private getService(id: string) { + return this.getContainer().getBoundServiceWithID(id) + } +} diff --git a/packages/hoppscotch-common/src/services/documentation.service.ts b/packages/hoppscotch-common/src/services/documentation.service.ts new file mode 100644 index 0000000..8008197 --- /dev/null +++ b/packages/hoppscotch-common/src/services/documentation.service.ts @@ -0,0 +1,428 @@ +import { Service } from "dioc" +import { reactive, computed, ref } from "vue" +import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data" +import * as E from "fp-ts/Either" +import { platform } from "~/platform" + +// Types for documentation +export type DocumentationType = "collection" | "request" + +// Published documentation info +export interface PublishedDocInfo { + id: string + title: string + version: string + autoSync: boolean + url: string + environmentName?: string | null + environmentID?: string | null + collection: { + id: string + } + createdOn: string + updatedOn: string +} + +/** + * Base documentation item with common properties + */ +export interface BaseDocumentationItem { + id: string + documentation: string + isTeamItem: boolean + teamID?: string +} + +/** + * Collection documentation item + */ +export interface CollectionDocumentationItem extends BaseDocumentationItem { + type: "collection" + + /** + * The path (for personal collections) or ID (for team collections) of the collection + */ + pathOrID: string + collectionData: HoppCollection +} + +/** + * Request documentation item (supports both team and personal requests) + */ +export interface RequestDocumentationItem extends BaseDocumentationItem { + type: "request" + parentCollectionID: string + folderPath: string + requestID?: string // For team requests + requestIndex?: number // For personal requests + requestData: HoppRESTRequest +} + +export type DocumentationItem = + | CollectionDocumentationItem + | RequestDocumentationItem + +/** + * Base options for setting documentation + */ +export interface BaseDocumentationOptions { + isTeamItem: boolean + teamID?: string +} + +/** + * Options for setting collection documentation + */ +export interface SetCollectionDocumentationOptions extends BaseDocumentationOptions { + /** + * The path (for personal collections) or ID (for team collections) of the collection + */ + pathOrID: string + collectionData: HoppCollection +} + +/** + * Request documentation + */ +export interface SetRequestDocumentationOptions extends BaseDocumentationOptions { + parentCollectionID: string + folderPath: string + requestID?: string // For team requests + requestIndex?: number // For personal requests + requestData: HoppRESTRequest +} + +/** + * The string identifier for the current live version of documentation. + * The initial version of a published doc will 'CURRENT' + */ +export const CURRENT_VERSION_TAG = "CURRENT" + +/** + * Checks whether a published doc version is the live (current) version. + * A live version is one that has auto-sync enabled — it stays in sync with + * the collection and will update whenever the collection is updated. + */ +export const isLiveVersion = (doc: { autoSync: boolean }): boolean => + doc.autoSync + +/** + * This service manages edited documentation for collections and requests. + * It temporarily stores the edited documentation in a map for efficient saving. + * So that multiple edits can be batched together. + */ +export class DocumentationService extends Service { + public static readonly ID = "DOCUMENTATION_SERVICE" + + private editedDocumentation = reactive(new Map()) + + /** + * Computed property to check if there are any unsaved changes + */ + public hasChanges = computed(() => this.editedDocumentation.size > 0) + + /** + * Map to store published docs + */ + private publishedDocsMap = ref>(new Map()) + + /** + * Counter to track the latest fetch request ID + * This prevents race conditions where a stale request overwrites a newer one + */ + private fetchRequestId = 0 + + /** + * Sets collection documentation + */ + public setCollectionDocumentation( + id: string, + documentation: string, + options: SetCollectionDocumentationOptions + ): void { + const key = `collection_${id}` + const item: CollectionDocumentationItem = { + type: "collection", + id, + documentation, + isTeamItem: options.isTeamItem, + teamID: options.teamID, + pathOrID: options.pathOrID, + collectionData: options.collectionData, + } + + this.editedDocumentation.set(key, item) + } + + /** + * Sets request documentation + */ + public setRequestDocumentation( + id: string, + documentation: string, + options: SetRequestDocumentationOptions + ): void { + const key = `request_${id}` + const item: RequestDocumentationItem = { + type: "request", + id, + documentation, + isTeamItem: options.isTeamItem, + teamID: options.teamID, + parentCollectionID: options.parentCollectionID, + folderPath: options.folderPath, + requestID: options.requestID, // Will be defined for team requests + requestIndex: options.requestIndex, // Will be defined for personal requests + requestData: options.requestData, + } + + this.editedDocumentation.set(key, item) + } + + /** + * Gets the documentation for a collection or request + * @param type The type of item ('collection' or 'request') + * @param id The ID of the collection or request + * @returns The documentation content or undefined if not found + */ + public getDocumentation( + type: DocumentationType, + id: string + ): string | undefined { + const key = `${type}_${id}` + const stored = this.editedDocumentation.get(key) + return stored?.documentation + } + + /** + * Gets the parent collection ID for a request documentation item + * @param id The ID of the request + * @returns The parent collection ID or undefined if not found or not a request + */ + public getParentCollectionID(id: string): string | undefined { + const key = `request_${id}` + const stored = this.editedDocumentation.get(key) + + if (stored?.type === "request") { + return stored.parentCollectionID + } + + return undefined + } + + /** + * Gets the complete documentation item with all metadata + * @param type The type of item ('collection' or 'request') + * @param id The ID of the collection or request + * @returns The complete documentation item or undefined if not found + */ + public getDocumentationItem( + type: DocumentationType, + id: string + ): DocumentationItem | undefined { + const key = `${type}_${id}` + return this.editedDocumentation.get(key) + } + + /** + * Gets all changed items as an array + * @returns Array of all items with changes + */ + public getChangedItems(): DocumentationItem[] { + return Array.from(this.editedDocumentation.values()) + } + + /** + * Clears all edited documentation + */ + public clearAll(): void { + this.editedDocumentation.clear() + } + + /** + * Removes a specific item from the edited documentation + * @param type The type of item ('collection' or 'request') + * @param id The ID of the collection or request + */ + public removeItem(type: DocumentationType, id: string): void { + const key = `${type}_${id}` + this.editedDocumentation.delete(key) + } + + /** + * Checks if a specific item has changes + * @param type The type of item ('collection' or 'request') + * @param id The ID of the collection or request + * @returns True if the item has changes + */ + public hasItemChanges(type: DocumentationType, id: string): boolean { + const key = `${type}_${id}` + return this.editedDocumentation.has(key) + } + + /** + * Gets the count of items with changes + * @returns Number of items with unsaved changes + */ + public getChangesCount(): number { + return this.editedDocumentation.size + } + + /** + * Fetches user published docs and updates the map + */ + public async fetchUserPublishedDocs() { + // Increment request ID to invalidate any previous pending requests + const requestId = ++this.fetchRequestId + + try { + const result = await platform.backend.getUserPublishedDocs()() + + // If a newer request has started, ignore this result + if (requestId !== this.fetchRequestId) return + + if (E.isRight(result)) { + const docs = result.right + const newMap = new Map() + docs.forEach((doc) => { + if (doc.collection?.id) { + const existing = newMap.get(doc.collection.id) || [] + existing.push({ + id: doc.id, + title: doc.title, + version: doc.version, + autoSync: doc.autoSync, + url: doc.url, + collection: { + id: doc.collection.id, + }, + createdOn: doc.createdOn, + updatedOn: doc.updatedOn, + }) + newMap.set(doc.collection.id, existing) + } + }) + this.publishedDocsMap.value = newMap + } else { + console.error("Failed to fetch user published docs:", result.left) + } + } catch (error) { + // If a newer request has started, ignore this error + if (requestId !== this.fetchRequestId) return + console.error("Failed to fetch user published docs:", error) + } + } + + /** + * Fetches published docs for team collections + */ + public async fetchTeamPublishedDocs(teamID: string) { + // Increment request ID to invalidate any previous pending requests + const requestId = ++this.fetchRequestId + + try { + // Fetch all published docs for the team (collectionID is optional now) + const result = await platform.backend.getTeamPublishedDocs(teamID)() + + // If a newer request has started, ignore this result + if (requestId !== this.fetchRequestId) return + + if (E.isRight(result)) { + const docs = result.right + const newMap = new Map() + docs.forEach((doc) => { + if (doc.collection?.id) { + const existing = newMap.get(doc.collection.id) || [] + existing.push({ + id: doc.id, + title: doc.title, + version: doc.version, + autoSync: doc.autoSync, + url: doc.url, + collection: { + id: doc.collection.id, + }, + createdOn: doc.createdOn, + updatedOn: doc.updatedOn, + }) + newMap.set(doc.collection.id, existing) + } + }) + this.publishedDocsMap.value = newMap + } else { + console.error("Failed to fetch team published docs:", result.left) + } + } catch (error) { + // If a newer request has started, ignore this error + if (requestId !== this.fetchRequestId) return + console.error("Failed to fetch team published docs:", error) + } + } + + /** + * Gets the published status of a collection (returns all versions) + * @param collectionId The ID of the collection + */ + public getPublishedDocStatus( + collectionId: string + ): PublishedDocInfo[] | undefined { + return this.publishedDocsMap.value.get(collectionId) + } + + /** + * Gets a specific published doc version for a collection + * @param collectionId The ID of the collection + * @param version The version string to find + */ + public getPublishedDocByVersion( + collectionId: string, + version: string + ): PublishedDocInfo | undefined { + const docs = this.publishedDocsMap.value.get(collectionId) + return docs?.find((doc) => doc.version === version) + } + + /** + * Manually updates the published status of a collection + * @param collectionId The ID of the collection + * @param info The new info (single doc) to add/update, or null to remove ALL docs for this collection (use carefully) + * @param removeId Optional ID to remove specifically + */ + public setPublishedDocStatus( + collectionId: string, + info: PublishedDocInfo | null, + removeId?: string + ) { + if (info && removeId) { + throw new Error( + "setPublishedDocStatus: Cannot provide both 'info' and 'removeId'. Please call separately." + ) + } + + const newMap = new Map(this.publishedDocsMap.value) + const existing = newMap.get(collectionId) || [] + + if (removeId) { + const filtered = existing.filter((doc) => doc.id !== removeId) + if (filtered.length > 0) { + newMap.set(collectionId, filtered) + } else { + newMap.delete(collectionId) + } + } else if (info) { + // Update or add + const updated = [...existing] + const index = updated.findIndex((doc) => doc.id === info.id) + if (index !== -1) { + updated[index] = info + } else { + updated.push(info) + } + newMap.set(collectionId, updated) + } else { + // Remove all if info is null and no removeId + newMap.delete(collectionId) + } + this.publishedDocsMap.value = newMap + } +} diff --git a/packages/hoppscotch-common/src/services/history-ui-provider.service.ts b/packages/hoppscotch-common/src/services/history-ui-provider.service.ts new file mode 100644 index 0000000..f69e34a --- /dev/null +++ b/packages/hoppscotch-common/src/services/history-ui-provider.service.ts @@ -0,0 +1,18 @@ +import { Service } from "dioc" +import { ref } from "vue" +import { getI18n } from "~/modules/i18n" + +type HistoryUIProviderTitle = (t: ReturnType) => string + +/** + * This service is used to provide custom UI items for the history section. + */ +export class HistoryUIProviderService extends Service { + public static readonly ID = "HISTORY_UI_PROVIDER_SERVICE" + + public readonly isEnabled = ref(false) + + public readonly historyUIProviderTitle = ref((t) => + t("tab.history") + ) +} diff --git a/packages/hoppscotch-common/src/services/initialization.service.ts b/packages/hoppscotch-common/src/services/initialization.service.ts new file mode 100644 index 0000000..d2ebf5e --- /dev/null +++ b/packages/hoppscotch-common/src/services/initialization.service.ts @@ -0,0 +1,187 @@ +import { Service } from "dioc" +import * as E from "fp-ts/Either" +import { getService } from "~/modules/dioc" + +import { PersistenceService } from "~/services/persistence" +import { RESTTabService } from "~/services/tab/rest" +import { GQLTabService } from "~/services/tab/graphql" +import { KernelInterceptorService } from "~/services/kernel-interceptor.service" + +import { platform } from "~/platform" +import { NativeKernelInterceptorService } from "~/platform/std/kernel-interceptors/native" + +import { performMigrations } from "~/helpers/migrations" +import { initBackendGQLClient } from "~/helpers/backend/GQLClient" +import { getKernelMode } from "@hoppscotch/kernel" +import { diag } from "~/kernel/log" +import { sync } from "~/lib/sync/defs" + +type InitEvent = + | { type: "STORE_READY" } + | { type: "PERSISTENCE_FIRST_READY" } + | { type: "PERSISTENCE_LATER_READY" } + | { type: "TABS_READY" } + | { type: "NATIVE_KERNEL_NETWORKING_READY" } + | { type: "AUTH_READY" } + | { type: "BACKEND_CLIENT_READY" } + | { type: "SYNC_READY" } + | { type: "ALL_READY" } + +/** + * Service responsible for coordinating the initialization sequence. + */ +export class InitializationService extends Service { + public static readonly ID = "INITIALIZATION_SERVICE" + + private initState = { + store: false, + persistenceFirst: false, + tabs: false, + nativeKernelNetworking: false, + auth: false, + sync: false, + persistenceLater: false, + backendClient: false, + } + + private async initStore() { + const persistenceService = getService(PersistenceService) + const result = await persistenceService.init() + + if (E.isLeft(result)) { + throw new Error(`Store initialization failed: ${result.left.message}`) + } + + this.initState.store = true + this.emit({ type: "STORE_READY" }) + } + + private async initPersistenceFirst() { + if (!this.initState.store) { + throw new Error("Cannot initialize persistence before store") + } + + const persistenceService = getService(PersistenceService) + await persistenceService.setupFirst() + + this.initState.persistenceFirst = true + this.emit({ type: "PERSISTENCE_FIRST_READY" }) + } + + private async initTabs() { + if (!this.initState.persistenceFirst) { + throw new Error("Cannot initialize tabs before persistence") + } + + const restTabService = getService(RESTTabService) + const gqlTabService = getService(GQLTabService) + + await Promise.all([restTabService.init(), gqlTabService.init()]) + + this.initState.tabs = true + this.emit({ type: "TABS_READY" }) + } + + private async initNativeKernelNetworking() { + const interceptorService = getService(KernelInterceptorService) + const nativeInterceptorService = getService(NativeKernelInterceptorService) + interceptorService.register(nativeInterceptorService) + interceptorService.setActive("native") + + this.initState.nativeKernelNetworking = true + this.emit({ type: "NATIVE_KERNEL_NETWORKING_READY" }) + } + + private async initAuth() { + if ( + getKernelMode() === "desktop" && + !this.initState.nativeKernelNetworking + ) { + throw new Error( + "Cannot initialize auth on desktop before native networking" + ) + } + + if (!this.initState.persistenceFirst || !this.initState.tabs) { + throw new Error("Cannot initialize auth before persistence and tabs") + } + + await platform.auth.performAuthInit() + + this.initState.auth = true + this.emit({ type: "AUTH_READY" }) + } + + private async initBackendClient() { + initBackendGQLClient() + + this.initState.backendClient = true + this.emit({ type: "BACKEND_CLIENT_READY" }) + } + + private async initPersistenceLater() { + if (!this.initState.persistenceFirst) { + throw new Error("Cannot initialize persistence before store") + } + + const persistenceService = getService(PersistenceService) + await persistenceService.setupLater() + + this.initState.persistenceLater = true + this.emit({ type: "PERSISTENCE_LATER_READY" }) + } + + private async initSync() { + if (!this.initState.auth) { + throw new Error("Cannot initialize remaining services before auth") + } + + await Promise.all([ + sync.settings.initSettingsSync(), + sync.collections.initCollectionsSync(), + sync.history.initHistorySync(), + sync.environments.initEnvironmentsSync(), + platform.analytics?.initAnalytics(), + ]) + + this.emit({ type: "SYNC_READY" }) + } + + public async initPre() { + diag("init", "initPre() start") + await this.initStore() + diag("init", "initPre() store done") + await this.initPersistenceFirst() + diag("init", "initPre() persistenceFirst done") + + if (getKernelMode() === "desktop") { + await this.initNativeKernelNetworking() + diag("init", "initPre() nativeKernelNetworking done") + } + + await this.initBackendClient() + diag("init", "initPre() backendClient done") + await this.initTabs() + diag("init", "initPre() tabs done, initPre complete") + } + + public async initAuthAndSync() { + diag("init", "initAuthAndSync() start") + await this.initAuth() + diag("init", "initAuthAndSync() auth done") + await this.initSync() + diag("init", "initAuthAndSync() sync done, initAuthAndSync complete") + } + + public async initPost() { + diag("init", "initPost() start") + await this.initPersistenceLater() + diag("init", "initPost() persistenceLater done") + performMigrations() + diag("init", "initPost() migrations done, initPost complete") + } + + public isInitialized() { + return Object.values(this.initState).every(Boolean) + } +} diff --git a/packages/hoppscotch-common/src/services/inspection/__tests__/index.spec.ts b/packages/hoppscotch-common/src/services/inspection/__tests__/index.spec.ts new file mode 100644 index 0000000..9ec138f --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/__tests__/index.spec.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi } from "vitest" +import { Inspector, InspectionService, InspectorResult } from "../" +import { TestContainer } from "dioc/testing" +import { ref } from "vue" +import { RESTTabService } from "~/services/tab/rest" + +vi.mock("~/modules/i18n", () => ({ + __esModule: true, + getI18n: () => (x: string) => x, +})) + +const inspectorResultMock: InspectorResult[] = [ + { + id: "result1", + text: { type: "text", text: "Sample Text" }, + icon: {}, + isApplicable: true, + severity: 2, + locations: { type: "url" }, + doc: { text: "Sample Doc", link: "https://example.com" }, + action: { + text: "Sample Action", + // eslint-disable-next-line @typescript-eslint/no-empty-function + apply: () => {}, + }, + }, +] + +const testInspector: Inspector = { + inspectorID: "inspector1", + getInspections: () => ref(inspectorResultMock), +} + +describe("InspectionService", () => { + describe("registerInspector", () => { + it("should register an inspector", () => { + const container = new TestContainer() + + container.bindMock(RESTTabService, { + currentActiveTab: ref({ + id: "test", + document: { + type: "request", + request: {}, + response: null, + isDirty: false, + optionTabPreference: "params", + }, + }), + tabMap: new Map([ + [ + "test", + { + id: "test", + document: { + type: "request", + request: {}, + isDirty: false, + optionTabPreference: "params", + }, + }, + ], + ]), + tabOrdering: ref(["test"]), + currentTabID: ref("test"), + }) + + const service = container.bind(InspectionService) + + service.registerInspector(testInspector) + + expect(service.inspectors.has(testInspector.inspectorID)).toEqual(true) + }) + }) + + describe("deleteTabInspectorResult", () => { + it("should delete a tab's inspector results", () => { + const container = new TestContainer() + + container.bindMock(RESTTabService, { + currentActiveTab: ref({ + id: "test", + document: { + type: "request", + request: {}, + response: null, + isDirty: false, + optionTabPreference: "params", + }, + }), + tabMap: new Map([ + [ + "test", + { + id: "test", + document: { + type: "request", + request: {}, + isDirty: false, + optionTabPreference: "params", + }, + }, + ], + ]), + tabOrdering: ref(["test"]), + currentTabID: ref("test"), + }) + + const service = container.bind(InspectionService) + + const tabID = "testTab" + service.tabs.value.set(tabID, inspectorResultMock) + + expect(service.tabs.value.has(tabID)).toEqual(true) + + service.deleteTabInspectorResult(tabID) + + expect(service.tabs.value.has(tabID)).toEqual(false) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/inspection/index.ts b/packages/hoppscotch-common/src/services/inspection/index.ts new file mode 100644 index 0000000..c049308 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/index.ts @@ -0,0 +1,231 @@ +import { + HoppRESTRequest, + HoppRESTResponseOriginalRequest, +} from "@hoppscotch/data" +import { refDebounced } from "@vueuse/core" +import { Service } from "dioc" +import { + Component, + Ref, + ref, + watch, + computed, + markRaw, + reactive, + effectScope, + EffectScope, +} from "vue" +import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" +import { RESTTabService } from "../tab/rest" +/** + * Defines how to render the text in an Inspector Result + */ +export type InspectorTextType = + | { + type: "text" + text: string[] | string + } + | { + type: "custom" + component: T + componentProps: T extends Component ? Props : never + } + +export type InspectorLocation = + | { + type: "url" + } + | { + type: "header" + position: "key" | "value" + key?: string + index?: number + } + | { + type: "parameter" + position: "key" | "value" + key?: string + index?: number + } + | { + type: "body" + key: string + index: number + } + | { + type: "response" + } + | { + type: "body-content-type-header" + } + +/** + * Defines info about an inspector result so the UI can render it + */ +export interface InspectorResult { + id: string + text: InspectorTextType + icon: object | Component + severity: number + isApplicable: boolean + action?: { + text: string + apply: () => void + showAction?: boolean + } + doc?: { + text: string + link: string + } + locations: InspectorLocation +} + +/** + * Defines the state of the inspector service + */ +export type InspectorState = { + results: InspectorResult[] +} + +/** + * Defines an inspector that can be registered with the inspector service + * Inspectors are used to perform checks on a request and return the results + */ +export interface Inspector { + /** + * The unique ID of the inspector + */ + inspectorID: string + /** + * Returns the inspector results for the request. + * NOTE: The refs passed down are readonly and are debounced to avoid performance issues + * @param req The ref to the request to inspect + * @param res The ref to the response to inspect + * @returns The ref to the inspector results + */ + getInspections: ( + req: Readonly>, + res: Readonly> + ) => Ref +} + +/** + * Defines the inspection service + * The service watches the current active tab and returns the inspector results for the request and response + */ +export class InspectionService extends Service { + public static readonly ID = "INSPECTION_SERVICE" + + private inspectors: Map = reactive(new Map()) + + private tabs: Ref> = ref(new Map()) + + private readonly restTab = this.bind(RESTTabService) + + private watcherStopHandle: (() => void) | null = null + private effectScope: EffectScope | null = null + + override onServiceInit() { + this.initializeListeners() + + // Watch for tab changes and inspector registration to reinitialize + // and create new debounced refs + watch( + () => [this.inspectors.entries(), this.restTab.currentActiveTab.value.id], + () => { + this.initializeListeners() + }, + { flush: "pre" } + ) + } + + /** + * Registers a inspector with the inspection service + * @param inspector The inspector instance to register + */ + public registerInspector(inspector: Inspector) { + // markRaw is required here so that the inspector is not made reactive + this.inspectors.set(inspector.inspectorID, markRaw(inspector)) + } + + private initializeListeners() { + // Dispose previous reactive effects + this.watcherStopHandle?.() + this.effectScope?.stop() + + // Create new effect scope for all computed refs and watchers + this.effectScope = effectScope() + + this.effectScope.run(() => { + const currentTabRequest = computed(() => { + if (this.restTab.currentActiveTab.value.document.type === "test-runner") + return null + + return this.restTab.currentActiveTab.value.document.type === "request" + ? this.restTab.currentActiveTab.value.document.request + : this.restTab.currentActiveTab.value.document.response + .originalRequest + }) + + const currentTabResponse = computed(() => { + if (this.restTab.currentActiveTab.value.document.type === "request") { + return this.restTab.currentActiveTab.value.document.response + } + return null + }) + + const debouncedReq = refDebounced(currentTabRequest, 1000, { + maxWait: 2000, + }) + const debouncedRes = refDebounced(currentTabResponse, 1000, { + maxWait: 2000, + }) + + const inspectorRefs = computed(() => { + if (debouncedReq.value === null) return [] + + return Array.from(this.inspectors.values()).map((inspector) => + inspector.getInspections( + debouncedReq as Readonly< + Ref + >, + debouncedRes + ) + ) + }) + + const activeInspections = computed(() => + inspectorRefs.value.flatMap((x) => x?.value ?? []) + ) + + this.watcherStopHandle = watch( + () => [...activeInspections.value], + () => { + this.tabs.value.set( + this.restTab.currentActiveTab.value.id, + activeInspections.value + ) + }, + { immediate: true, flush: "pre" } + ) + }) + } + + public deleteTabInspectorResult(tabID: string) { + // TODO: Move Tabs into a service and implement this with an event instead + this.tabs.value.delete(tabID) + } + + /** + * Returns a reactive view into the inspector results for a specific tab + * @param tabID The ID of the tab to get the results for + * @param filter The filter to apply to the results. + * @returns The ref into the inspector results, if the tab doesn't exist, a ref into an empty array is returned + */ + public getResultViewFor( + tabID: string, + filter: (x: InspectorResult) => boolean = () => true + ) { + return computed(() => this.tabs.value.get(tabID)?.filter(filter) ?? []) + } +} diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/environment.inspector.spec.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/environment.inspector.spec.ts new file mode 100644 index 0000000..a5e6338 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/environment.inspector.spec.ts @@ -0,0 +1,369 @@ +import { TestContainer } from "dioc/testing" +import { describe, expect, it, vi } from "vitest" +import { EnvironmentInspectorService } from "../environment.inspector" +import { InspectionService } from "../../index" +import { getDefaultRESTRequest } from "~/helpers/rest/default" +import { ref } from "vue" +import { CurrentValueService } from "~/services/current-environment-value.service" + +vi.mock("~/modules/i18n", () => ({ + __esModule: true, + getI18n: () => (x: string) => x, +})) + +vi.mock("~/newstore/environments", async () => { + const { BehaviorSubject }: any = await vi.importActual("rxjs") + + return { + __esModule: true, + aggregateEnvsWithCurrentValue$: new BehaviorSubject([ + { + key: "EXISTING_ENV_VAR", + currentValue: "test_value", + initialValue: "test_value", + secret: false, + }, + { + key: "EXISTING_ENV_VAR_2", + currentValue: "", + initialValue: "", + secret: false, + }, + ]), + getCurrentEnvironment: () => ({ + id: "1", + name: "some-env", + v: 1, + variables: { + key: "EXISTING_ENV_VAR", + currentValue: "test_value", + initialValue: "test_value", + secret: false, + }, + }), + getSelectedEnvironmentType: () => "MY_ENV", + } +}) + +describe("EnvironmentInspectorService", () => { + it("registers with the inspection service upon initialization", () => { + const container = new TestContainer() + + const registerInspectorFn = vi.fn() + + container.bindMock(InspectionService, { + registerInspector: registerInspectorFn, + }) + + const envInspector = container.bind(EnvironmentInspectorService) + + expect(registerInspectorFn).toHaveBeenCalledOnce() + expect(registerInspectorFn).toHaveBeenCalledWith(envInspector) + }) + + describe("getInspectorFor", () => { + it("should return an inspector result when the URL contains undefined environment variables", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "<>", + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toContainEqual( + expect.objectContaining({ + id: "environment-not-found-0", + isApplicable: true, + text: { + type: "text", + text: "inspections.environment.not_found", + }, + }) + ) + }) + + it("should not return an inspector result when the URL contains defined environment variables", () => { + const container = new TestContainer() + container.bindMock(CurrentValueService, { + hasValue: vi.fn((key) => { + if (key === "EXISTING_ENV_VAR_2") return false + return true + }), + }) + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "<>", + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toHaveLength(0) + }) + + it("should return an inspector result when the headers contain undefined environment variables", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [ + { + key: "<>", + value: "some-value", + active: true, + description: "", + }, + ], + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toContainEqual( + expect.objectContaining({ + id: "environment-not-found-0", + isApplicable: true, + text: { + type: "text", + text: "inspections.environment.not_found", + }, + }) + ) + }) + + it("should not return an inspector result when the headers contain defined environment variables", () => { + const container = new TestContainer() + container.bindMock(CurrentValueService, { + hasValue: vi.fn((key) => { + if (key === "EXISTING_ENV_VAR_2") return false + return true + }), + }) + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [ + { + key: "<>", + value: "some-value", + active: true, + description: "", + }, + ], + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toHaveLength(0) + }) + + it("should return an inspector result when the params contain undefined environment variables", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + params: [ + { + key: "<>", + value: "some-value", + active: true, + description: "", + }, + ], + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toContainEqual( + expect.objectContaining({ + id: "environment-not-found-0", + isApplicable: true, + text: { + type: "text", + text: "inspections.environment.not_found", + }, + }) + ) + }) + + it("should not return an inspector result when the params contain defined environment variables", () => { + const container = new TestContainer() + container.bindMock(CurrentValueService, { + hasValue: vi.fn((key) => { + if (key === "EXISTING_ENV_VAR") return false + return true + }), + }) + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [], + params: [ + { + key: "<>", + value: "some-value", + active: true, + description: "", + }, + ], + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toHaveLength(0) + }) + + it("should return an inspector result when the URL contains empty value in a environment variable", () => { + const container = new TestContainer() + container.bindMock(CurrentValueService, { + hasValue: vi.fn((key) => { + if (key === "EXISTING_ENV_VAR_2") return true + return false + }), + }) + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "<>", + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toHaveLength(1) + }) + + it("should not return an inspector result when the URL contains non empty value in a environment variable", () => { + const container = new TestContainer() + container.bindMock(CurrentValueService, { + hasValue: vi.fn((key) => { + if (key === "EXISTING_ENV_VAR") return false + return true + }), + }) + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "<>", + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toHaveLength(0) + }) + + it("should return an inspector result when the headers contain empty value in a environment variable", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [ + { + key: "<>", + value: "some-value", + active: true, + description: "", + }, + ], + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toHaveLength(1) + }) + + it("should not return an inspector result when the headers contain non empty value in a environment variable", () => { + const container = new TestContainer() + container.bindMock(CurrentValueService, { + hasValue: vi.fn((key) => { + if (key === "EXISTING_ENV_VAR") return false + return true + }), + }) + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [ + { + key: "<>", + value: "some-value", + active: true, + description: "", + }, + ], + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toHaveLength(0) + }) + + it("should return an inspector result when the params contain empty value in a environment variable", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [], + params: [ + { + key: "<>", + value: "some-value", + active: true, + description: "", + }, + ], + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toHaveLength(1) + }) + + it("should not return an inspector result when the params contain non empty value in a environment variable", () => { + const container = new TestContainer() + container.bindMock(CurrentValueService, { + hasValue: vi.fn((key) => { + if (key === "EXISTING_ENV_VAR") return false + return true + }), + }) + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [], + params: [ + { + key: "<>", + value: "some-value", + active: true, + description: "", + }, + ], + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toHaveLength(0) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/request.inspector.spec.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/request.inspector.spec.ts new file mode 100644 index 0000000..35c70a1 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/request.inspector.spec.ts @@ -0,0 +1,304 @@ +import { TestContainer } from "dioc/testing" +import { describe, expect, it, vi } from "vitest" +import { RequestInspectorService } from "../request.inspector" +import { InspectionService } from "../../index" +import { getDefaultRESTRequest } from "~/helpers/rest/default" +import { ref } from "vue" +import { KernelInterceptorService } from "~/services/kernel-interceptor.service" + +vi.mock("~/modules/i18n", () => ({ + __esModule: true, + getI18n: () => (x: string) => x, +})) + +describe("RequestInspectorService", () => { + it("registers with the inspection service upon initialization", () => { + const container = new TestContainer() + + const registerInspectorFn = vi.fn() + + container.bindMock(InspectionService, { + registerInspector: registerInspectorFn, + }) + + const requestInspector = container.bind(RequestInspectorService) + + expect(registerInspectorFn).toHaveBeenCalledOnce() + expect(registerInspectorFn).toHaveBeenCalledWith(requestInspector) + }) + + describe("cookie header inspection", () => { + it("should return an inspector result when headers contain cookies and interceptor doesn't support cookies", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + current: ref({ + capabilities: { + auth: new Set(), + content: new Set(), + advanced: new Set(), + }, + }), + }) + + const requestInspector = container.bind(RequestInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [ + { + key: "Cookie", + value: "some-cookie", + active: true, + description: "", + }, + ], + }) + + const result = requestInspector.getInspections(req) + + expect(result.value).toContainEqual( + expect.objectContaining({ + id: "cookie-header", + isApplicable: true, + locations: { + type: "header", + position: "key", + key: "Cookie", + index: 0, + }, + }) + ) + }) + + it("should return no inspector result when headers contain cookies and interceptor supports cookies", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + current: ref({ + capabilities: { + auth: new Set(), + content: new Set(), + advanced: new Set(["cookies"]), + }, + }), + }) + + const requestInspector = container.bind(RequestInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [ + { + key: "Cookie", + value: "some-cookie", + active: true, + description: "", + }, + ], + }) + + const result = requestInspector.getInspections(req) + + expect(result.value).not.toContainEqual( + expect.objectContaining({ + id: "cookie-header", + }) + ) + }) + }) + + describe("localhost access inspection", () => { + it("should return an inspector result when URL contains localhost and interceptor doesn't support local access", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + current: ref({ + capabilities: { + auth: new Set(), + content: new Set(), + advanced: new Set(), + }, + }), + }) + + const requestInspector = container.bind(RequestInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://localhost:3000/api", + }) + + const result = requestInspector.getInspections(req) + + expect(result.value).toContainEqual( + expect.objectContaining({ + id: "localaccess", + isApplicable: true, + locations: { + type: "url", + }, + }) + ) + }) + + it("should return no inspector result when URL contains localhost and interceptor supports local access", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + current: ref({ + capabilities: { + auth: new Set(), + content: new Set(), + advanced: new Set(["localaccess"]), + }, + }), + }) + + const requestInspector = container.bind(RequestInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://localhost:3000/api", + }) + + const result = requestInspector.getInspections(req) + + expect(result.value).not.toContainEqual( + expect.objectContaining({ + id: "localaccess", + }) + ) + }) + }) + + describe("digest auth inspection", () => { + it("should return an inspector result when using digest auth and interceptor doesn't support it", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + current: ref({ + capabilities: { + auth: new Set(), + content: new Set(), + advanced: new Set(), + }, + }), + }) + + const requestInspector = container.bind(RequestInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + auth: { authType: "digest", authActive: true }, + }) + + const result = requestInspector.getInspections(req) + + expect(result.value).toContainEqual( + expect.objectContaining({ + id: "digest-auth", + isApplicable: true, + locations: { + type: "url", + }, + }) + ) + }) + + it("should return no inspector result when using digest auth and interceptor supports it", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + current: ref({ + capabilities: { + auth: new Set(["digest"]), + content: new Set(), + advanced: new Set(), + }, + }), + }) + + const requestInspector = container.bind(RequestInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + auth: { authType: "digest", authActive: true }, + }) + + const result = requestInspector.getInspections(req) + + expect(result.value).not.toContainEqual( + expect.objectContaining({ + id: "digest-auth", + }) + ) + }) + }) + + describe("binary body inspection", () => { + it("should return an inspector result when using binary body and interceptor doesn't support it", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + current: ref({ + capabilities: { + auth: new Set(), + content: new Set(), + advanced: new Set(), + }, + }), + }) + + const requestInspector = container.bind(RequestInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + body: { contentType: "application/octet-stream", body: null }, + }) + + const result = requestInspector.getInspections(req) + + expect(result.value).toContainEqual( + expect.objectContaining({ + id: "binary-body", + isApplicable: true, + locations: { + type: "body-content-type-header", + }, + }) + ) + }) + + it("should return no inspector result when using binary body and interceptor supports it", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + current: ref({ + capabilities: { + auth: new Set(), + content: new Set(["binary"]), + advanced: new Set(), + }, + }), + }) + + const requestInspector = container.bind(RequestInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + body: { contentType: "application/octet-stream", body: null }, + }) + + const result = requestInspector.getInspections(req) + + expect(result.value).not.toContainEqual( + expect.objectContaining({ + id: "binary-body", + }) + ) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/response.inspector.spec.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/response.inspector.spec.ts new file mode 100644 index 0000000..7ce9de0 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/response.inspector.spec.ts @@ -0,0 +1,249 @@ +import { TestContainer } from "dioc/testing" +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest" +import { ResponseInspectorService } from "../response.inspector" +import { InspectionService } from "../../index" +import { getDefaultRESTRequest } from "~/helpers/rest/default" +import { ref } from "vue" +import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" + +vi.mock("~/modules/i18n", () => ({ + __esModule: true, + getI18n: () => (x: string) => x, +})) + +describe("ResponseInspectorService", () => { + beforeEach(() => { + vi.stubGlobal("navigator", { + onLine: true, + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it("registers with the inspection service upon initialization", () => { + const container = new TestContainer() + + const registerInspectorFn = vi.fn() + + container.bindMock(InspectionService, { + registerInspector: registerInspectorFn, + }) + + const responseInspector = container.bind(ResponseInspectorService) + + expect(registerInspectorFn).toHaveBeenCalledOnce() + expect(registerInspectorFn).toHaveBeenCalledWith(responseInspector) + }) + + describe("getInspectorFor", () => { + it("should return an empty array when response is undefined", () => { + const container = new TestContainer() + const responseInspector = container.bind(ResponseInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + }) + + const result = responseInspector.getInspections(req, ref(undefined)) + + expect(result.value).toHaveLength(0) + }) + + it("should return an inspector result when response type is not success or status code is not 200 and if the network is not available", () => { + const container = new TestContainer() + const responseInspector = container.bind(ResponseInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + }) + const res = ref({ + type: "network_fail", + error: new Error("test"), + req: req.value, + }) + + vi.stubGlobal("navigator", { + onLine: false, + }) + + const result = responseInspector.getInspections(req, res) + + expect(result.value).toContainEqual( + expect.objectContaining({ id: "url", isApplicable: true }) + ) + }) + + it("should return no inspector result when response type is not success or status code is not 200 and if the network is not available", () => { + const container = new TestContainer() + const responseInspector = container.bind(ResponseInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + }) + const res = ref({ + type: "network_fail", + error: new Error("test"), + req: req.value, + }) + + const result = responseInspector.getInspections(req, res) + + expect(result.value).toHaveLength(0) + }) + + it("should handle network_fail responses and return nothing when no network is present", () => { + const container = new TestContainer() + const responseInspector = container.bind(ResponseInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + }) + const res = ref({ + type: "network_fail", + error: new Error("test"), + req: req.value, + }) + + vi.stubGlobal("navigator", { + onLine: false, + }) + + const result = responseInspector.getInspections(req, res) + + expect(result.value).toContainEqual( + expect.objectContaining({ + text: { type: "text", text: "inspections.response.network_error" }, + }) + ) + }) + + it("should handle network_fail responses and return nothing when network is present", () => { + const container = new TestContainer() + const responseInspector = container.bind(ResponseInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + }) + const res = ref({ + type: "network_fail", + error: new Error("test"), + req: req.value, + }) + + const result = responseInspector.getInspections(req, res) + + expect(result.value).toHaveLength(0) + }) + + it("should handle fail responses", () => { + const container = new TestContainer() + const responseInspector = container.bind(ResponseInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + }) + const res = ref({ + type: "fail", + statusCode: 500, + body: Uint8Array.from([]), + headers: [], + meta: { responseDuration: 0, responseSize: 0 }, + req: req.value, + statusText: "", + }) + + const result = responseInspector.getInspections(req, res) + + expect(result.value).toContainEqual( + expect.objectContaining({ + text: { type: "text", text: "inspections.response.default_error" }, + }) + ) + }) + + it("should handle 404 responses", () => { + const container = new TestContainer() + const responseInspector = container.bind(ResponseInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + }) + const res = ref({ + type: "success", + statusCode: 404, + body: Uint8Array.from([]), + headers: [], + meta: { responseDuration: 0, responseSize: 0 }, + req: req.value, + statusText: "", + }) + + const result = responseInspector.getInspections(req, res) + + expect(result.value).toContainEqual( + expect.objectContaining({ + text: { type: "text", text: "inspections.response.404_error" }, + }) + ) + }) + + it("should handle 401 responses", () => { + const container = new TestContainer() + const responseInspector = container.bind(ResponseInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + }) + const res = ref({ + type: "success", + statusCode: 401, + body: Uint8Array.from([]), + headers: [], + meta: { responseDuration: 0, responseSize: 0 }, + req: req.value, + statusText: "", + }) + + const result = responseInspector.getInspections(req, res) + + expect(result.value).toContainEqual( + expect.objectContaining({ + text: { type: "text", text: "inspections.response.401_error" }, + }) + ) + }) + + it("should handle successful responses", () => { + const container = new TestContainer() + const responseInspector = container.bind(ResponseInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + }) + const res = ref({ + type: "success", + statusCode: 200, + body: Uint8Array.from([]), + headers: [], + meta: { responseDuration: 0, responseSize: 0 }, + req: req.value, + statusText: "", + }) + + const result = responseInspector.getInspections(req, res) + + expect(result.value).toHaveLength(0) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/scripting-interceptor.inspector.spec.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/scripting-interceptor.inspector.spec.ts new file mode 100644 index 0000000..ddbcb91 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/scripting-interceptor.inspector.spec.ts @@ -0,0 +1,592 @@ +import { TestContainer } from "dioc/testing" +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" +import { ScriptingInterceptorInspectorService } from "../scripting-interceptor.inspector" +import { InspectionService } from "../../index" +import { getDefaultRESTRequest } from "~/helpers/rest/default" +import { ref } from "vue" +import { KernelInterceptorService } from "~/services/kernel-interceptor.service" + +// Mock platform module with mutable feature flags for testing +// Cannot reference external variables in vi.mock due to hoisting +vi.mock("~/platform", () => ({ + __esModule: true, + platform: { + platformFeatureFlags: { + exportAsGIST: false, + hasTelemetry: false, + cookiesEnabled: false, + promptAsUsingCookies: false, + hasCookieBasedAuth: false, + }, + }, +})) + +vi.mock("~/modules/i18n", () => ({ + __esModule: true, + getI18n: () => (x: string, params?: Record) => { + if (!params) return x + // Simple parameter replacement for testing + return Object.entries(params).reduce( + (str, [key, value]) => str.replace(`{${key}}`, value), + x + ) + }, +})) + +// Import platform after mocking to get the mocked version +import { platform } from "~/platform" + +// Mock window.location for same-origin detection tests +const originalLocation = global.window?.location +beforeEach(() => { + if (global.window) { + delete (global.window as any).location + global.window.location = { + ...originalLocation, + origin: "https://example.com", + href: "https://example.com/", + hostname: "example.com", + } as any + } +}) + +afterEach(() => { + // Restore original location to prevent test leakage + if (global.window && originalLocation) { + delete (global.window as any).location + global.window.location = originalLocation + } +}) + +describe("ScriptingInterceptorInspectorService", () => { + it("registers with the inspection service upon initialization", () => { + const container = new TestContainer() + + const registerInspectorFn = vi.fn() + + container.bindMock(InspectionService, { + registerInspector: registerInspectorFn, + }) + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "browser", + }) + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + expect(registerInspectorFn).toHaveBeenCalledOnce() + expect(registerInspectorFn).toHaveBeenCalledWith(inspector) + }) + + describe("unsupported interceptor warnings", () => { + it("should warn when using Extension interceptor with hopp.fetch()", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "extension", + }) + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + preRequestScript: ` + const response = await hopp.fetch('https://api.example.com/data') + const data = await response.json() + `, + }) + + const result = inspector.getInspections(req, ref(null)) + + expect(result.value).toContainEqual( + expect.objectContaining({ + id: "unsupported-interceptor", + severity: 2, + isApplicable: true, + locations: { type: "response" }, + }) + ) + }) + + it("should warn when using Proxy interceptor with pm.sendRequest()", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "proxy", + }) + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + testScript: ` + pm.sendRequest('https://api.example.com/data', (err, res) => { + pm.expect(res.code).toBe(200) + }) + `, + }) + + const result = inspector.getInspections(req, ref(null)) + + expect(result.value).toContainEqual( + expect.objectContaining({ + id: "unsupported-interceptor", + severity: 2, + isApplicable: true, + }) + ) + }) + + it("should warn when using Extension interceptor with fetch()", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "extension", + }) + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + preRequestScript: ` + const response = await fetch('https://api.example.com/data') + const data = await response.json() + `, + }) + + const result = inspector.getInspections(req, ref(null)) + + expect(result.value).toContainEqual( + expect.objectContaining({ + id: "unsupported-interceptor", + severity: 2, + }) + ) + }) + + it("should NOT warn when using Agent interceptor with fetch APIs", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "agent", + }) + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + preRequestScript: "await hopp.fetch('https://api.example.com')", + }) + + const result = inspector.getInspections(req, ref(null)) + + // Should not have unsupported-interceptor warning + expect( + result.value.find((r) => r.id === "unsupported-interceptor") + ).toBeUndefined() + }) + + it("should NOT warn when using Browser interceptor with fetch APIs (unless same-origin)", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "browser", + }) + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + preRequestScript: + "await hopp.fetch('https://different-origin.com/api')", + }) + + const result = inspector.getInspections(req, ref(null)) + + // Should not have unsupported-interceptor warning for different origin + expect( + result.value.find((r) => r.id === "unsupported-interceptor") + ).toBeUndefined() + }) + }) + + describe("same-origin CSRF warnings (cookie-based auth only)", () => { + it("should warn when using Browser + relative URL with hasCookieBasedAuth", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "browser", + }) + + // Mock platform with cookie-based auth + platform.platformFeatureFlags.hasCookieBasedAuth = true + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + preRequestScript: "await hopp.fetch('/api/data')", + }) + + const result = inspector.getInspections(req, ref(null)) + + expect(result.value).toContainEqual( + expect.objectContaining({ + id: "same-origin-fetch-csrf", + severity: 2, + isApplicable: true, + }) + ) + }) + + it("should warn when using Browser + same-origin absolute URL", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "browser", + }) + + platform.platformFeatureFlags.hasCookieBasedAuth = true + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + testScript: "pm.sendRequest('https://example.com/api/data', () => {})", + }) + + const result = inspector.getInspections(req, ref(null)) + + expect(result.value).toContainEqual( + expect.objectContaining({ + id: "same-origin-fetch-csrf", + severity: 2, + }) + ) + }) + + it("should warn for pm.sendRequest with request object containing relative URL", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "browser", + }) + + platform.platformFeatureFlags.hasCookieBasedAuth = true + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + testScript: ` + pm.sendRequest({ + url: '/api/users', + method: 'POST' + }, (err, res) => { + pm.expect(res.code).toBe(200) + }) + `, + }) + + const result = inspector.getInspections(req, ref(null)) + + expect(result.value).toContainEqual( + expect.objectContaining({ + id: "same-origin-fetch-csrf", + severity: 2, + }) + ) + }) + + it("should warn for pm.sendRequest with request object containing same-origin URL", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "browser", + }) + + platform.platformFeatureFlags.hasCookieBasedAuth = true + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + preRequestScript: ` + pm.sendRequest({ + url: 'https://example.com/api/data', + method: 'GET' + }, (err, res) => {}) + `, + }) + + const result = inspector.getInspections(req, ref(null)) + + expect(result.value).toContainEqual( + expect.objectContaining({ + id: "same-origin-fetch-csrf", + }) + ) + }) + + it("should warn when script uses window.location", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "browser", + }) + + platform.platformFeatureFlags.hasCookieBasedAuth = true + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + preRequestScript: ` + const url = window.location.origin + '/api/data' + await hopp.fetch(url) + `, + }) + + const result = inspector.getInspections(req, ref(null)) + + expect(result.value).toContainEqual( + expect.objectContaining({ + id: "same-origin-fetch-csrf", + }) + ) + }) + + it("should NOT warn when hasCookieBasedAuth is false", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "browser", + }) + + // No cookie-based auth (desktop or cloud) + platform.platformFeatureFlags.hasCookieBasedAuth = false + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + preRequestScript: "await hopp.fetch('/api/data')", + }) + + const result = inspector.getInspections(req, ref(null)) + + // Should not have CSRF warning + expect( + result.value.find((r) => r.id === "same-origin-fetch-csrf") + ).toBeUndefined() + }) + + it("should NOT warn for different-origin URLs", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "browser", + }) + + platform.platformFeatureFlags.hasCookieBasedAuth = true + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + preRequestScript: "await hopp.fetch('https://different.com/api')", + }) + + const result = inspector.getInspections(req, ref(null)) + + expect( + result.value.find((r) => r.id === "same-origin-fetch-csrf") + ).toBeUndefined() + }) + + it("should NOT warn when using Agent interceptor (even with same-origin)", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "agent", + }) + + platform.platformFeatureFlags.hasCookieBasedAuth = true + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + preRequestScript: "await hopp.fetch('/api/data')", + }) + + const result = inspector.getInspections(req, ref(null)) + + // Agent doesn't have CSRF concerns + expect( + result.value.find((r) => r.id === "same-origin-fetch-csrf") + ).toBeUndefined() + }) + }) + + describe("fetch API detection", () => { + it("should detect hopp.fetch() in pre-request script", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "extension", + }) + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + preRequestScript: "const res = await hopp.fetch('https://api.com')", + }) + + const result = inspector.getInspections(req, ref(null)) + + expect(result.value.length).toBeGreaterThan(0) + }) + + it("should detect pm.sendRequest() in test script", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "extension", + }) + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + testScript: "pm.sendRequest('https://api.com', () => {})", + }) + + const result = inspector.getInspections(req, ref(null)) + + expect(result.value.length).toBeGreaterThan(0) + }) + + it("should detect fetch() in script (but not hopp.fetch)", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "extension", + }) + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + preRequestScript: "const res = await fetch('https://api.com')", + }) + + const result = inspector.getInspections(req, ref(null)) + + expect(result.value.length).toBeGreaterThan(0) + }) + + it("should NOT detect hopp.fetch when script is empty", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "extension", + }) + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + preRequestScript: "", + testScript: "", + }) + + const result = inspector.getInspections(req, ref(null)) + + expect(result.value).toEqual([]) + }) + + it("should detect fetch in both pre-request and test scripts", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "extension", + }) + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + preRequestScript: "await hopp.fetch('https://api.com/1')", + testScript: "pm.sendRequest('https://api.com/2', () => {})", + }) + + const result = inspector.getInspections(req, ref(null)) + + // Should have warning for unsupported interceptor + expect(result.value.length).toBeGreaterThan(0) + }) + }) + + describe("edge cases", () => { + it("should handle requests without scripts gracefully", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "browser", + }) + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref(getDefaultRESTRequest()) + + const result = inspector.getInspections(req, ref(null)) + + expect(result.value).toEqual([]) + }) + + it("should handle response-type requests (history)", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "browser", + }) + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + // Response-type request doesn't have preRequestScript/testScript + const req = ref({ + endpoint: "https://api.example.com", + method: "GET", + headers: [], + } as any) + + const result = inspector.getInspections(req, ref(null)) + + expect(result.value).toEqual([]) + }) + + it("should handle invalid URLs gracefully", () => { + const container = new TestContainer() + + container.bindMock(KernelInterceptorService, { + getCurrentId: () => "browser", + }) + + platform.platformFeatureFlags.hasCookieBasedAuth = true + + const inspector = container.bind(ScriptingInterceptorInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + preRequestScript: "await hopp.fetch('not-a-valid-url')", + }) + + const result = inspector.getInspections(req, ref(null)) + + // Should not crash, may or may not have warnings depending on detection + expect(result.value).toBeDefined() + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/environment.inspector.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/environment.inspector.ts new file mode 100644 index 0000000..f9f41c7 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/environment.inspector.ts @@ -0,0 +1,384 @@ +import { getI18n } from "~/modules/i18n" +import { + InspectionService, + Inspector, + InspectorLocation, + InspectorResult, +} from ".." +import { Service } from "dioc" +import { Ref, markRaw, computed } from "vue" +import IconPlusCircle from "~icons/lucide/plus-circle" +import { + HoppRESTRequest, + HoppRESTResponseOriginalRequest, +} from "@hoppscotch/data" +import { + AggregateEnvironment, + aggregateEnvsWithCurrentValue$, + getCurrentEnvironment, + getSelectedEnvironmentType, +} from "~/newstore/environments" +import { invokeAction } from "~/helpers/actions" +import { useStreamStatic } from "~/composables/stream" +import { SecretEnvironmentService } from "~/services/secret-environment.service" +import { RESTTabService } from "~/services/tab/rest" +import { CurrentValueService } from "~/services/current-environment-value.service" +import { getEffectiveVariablesForRequest } from "~/helpers/utils/environments" +import { HOPP_ENVIRONMENT_REGEX } from "~/helpers/environment-regex" + +const isENVInString = (str: string) => HOPP_ENVIRONMENT_REGEX.test(str) + +/** + * This inspector is responsible for inspecting the environment variables of a input. + * It checks if the environment variables are defined in the environment. + * It also provides an action to add the environment variable. + * + * NOTE: Initializing this service registers it as a inspector with the Inspection Service. + */ +export class EnvironmentInspectorService extends Service implements Inspector { + public static readonly ID = "ENVIRONMENT_INSPECTOR_SERVICE" + + private t = getI18n() + + public readonly inspectorID = "environment" + + private readonly inspection = this.bind(InspectionService) + private readonly secretEnvs = this.bind(SecretEnvironmentService) + private readonly currentEnvs = this.bind(CurrentValueService) + private readonly restTabs = this.bind(RESTTabService) + + private aggregateEnvsWithValue = useStreamStatic( + aggregateEnvsWithCurrentValue$, + [], + () => { + /* noop */ + } + )[0] + + override onServiceInit() { + this.inspection.registerInspector(this) + } + + /** + * Looks for environment variables in an array of strings. + * Reports variables that are referenced but not defined. + * @param target The target array to validate + * @param locations The location where results are to be displayed + * @returns The results array containing the results of the validation + */ + private validateEnvironmentVariables = ( + target: string[], + locations: InspectorLocation + ) => { + const newErrors: InspectorResult[] = [] + const currentTab = this.restTabs.currentActiveTab.value + + // Get the current request or example-response request + const currentTabRequest = + currentTab.document.type === "request" + ? currentTab.document.request + : currentTab.document.type === "example-response" + ? currentTab.document.response.originalRequest + : null + + // request → collection → environment, in the same precedence order the + // request runner uses (single source of truth) + const environmentVariables = getEffectiveVariablesForRequest( + currentTabRequest?.requestVariables, + currentTab.document.type === "request" || + currentTab.document.type === "example-response" + ? currentTab.document.inheritedProperties?.variables + : [], + this.aggregateEnvsWithValue.value + ) + const envKeysSet = new Set(environmentVariables.map((e) => e.key)) + + // Scan each string for <> patterns + target.forEach((element, index) => { + if (!isENVInString(element)) return + const matches = element.match(HOPP_ENVIRONMENT_REGEX) + matches?.forEach((exEnv) => { + const formattedExEnv = exEnv.slice(2, -2) + const itemLocation: InspectorLocation = { + type: locations.type, + position: + locations.type === "url" || + locations.type === "body" || + locations.type === "response" || + locations.type === "body-content-type-header" + ? "key" + : locations.position, + index, + key: element, + } + + if (!envKeysSet.has(formattedExEnv)) { + newErrors.push({ + id: `environment-not-found-${newErrors.length}`, + text: { + type: "text", + text: this.t("inspections.environment.not_found", { + environment: exEnv, + }), + }, + icon: markRaw(IconPlusCircle), + action: { + text: this.t("inspections.environment.add_environment"), + apply: () => + invokeAction("modals.environment.add", { + envName: formattedExEnv, + variableName: "", + }), + showAction: true, + }, + severity: 3, + isApplicable: true, + locations: itemLocation, + doc: { + text: this.t("action.learn_more"), + link: "https://docs.hoppscotch.io/documentation/features/inspections", + }, + }) + } + }) + }) + + return newErrors + } + + /** + * Keeps only unique environment variables and prefers ones with values. + * @param envs The environment list to be transformed + * @returns The transformed environment list with keys with value + */ + private filterNonEmptyEnvironmentVariables = ( + envs: AggregateEnvironment[] + ): AggregateEnvironment[] => { + const envsMap = new Map() + + envs.forEach((env) => { + if (envsMap.has(env.key)) { + const existingEnv = envsMap.get(env.key) + // Replace if existing is empty and this one has a value + if (existingEnv?.currentValue === "" && env.currentValue !== "") { + envsMap.set(env.key, env) + } + } else { + envsMap.set(env.key, env) + } + }) + + return Array.from(envsMap.values()) + } + + /** + * Looks for variables that exist but are empty (no value or secret). + * Suggests adding a value for them. + * @param target The target array to validate + * @param locations The location where results are to be displayed + * @returns The results array containing the results of the validation + */ + private validateEmptyEnvironmentVariables = ( + target: string[], + locations: InspectorLocation + ) => { + const newErrors: InspectorResult[] = [] + + target.forEach((element, index) => { + if (!isENVInString(element)) return + const matches = element.match(HOPP_ENVIRONMENT_REGEX) + matches?.forEach((exEnv) => { + const formattedExEnv = exEnv.slice(2, -2) + const currentSelectedEnvironment = getCurrentEnvironment() + const currentTab = this.restTabs.currentActiveTab.value + + // Get current request or example + const currentTabRequest = + currentTab.document.type === "request" + ? currentTab.document.request + : currentTab.document.type === "example-response" + ? currentTab.document.response.originalRequest + : null + + // request → collection → environment, matching the request runner's + // precedence and non-empty resolution (single source of truth) + const environmentVariables = this.filterNonEmptyEnvironmentVariables( + getEffectiveVariablesForRequest( + currentTabRequest?.requestVariables, + currentTab.document.type === "request" || + currentTab.document.type === "example-response" + ? currentTab.document.inheritedProperties?.variables + : [], + this.aggregateEnvsWithValue.value, + false + ) + ) + + // Check each variable for missing values + environmentVariables.forEach((env) => { + const sourceEnvID = + env.sourceEnv === "Global" + ? "Global" + : env.sourceEnv === "CollectionVariable" + ? env.sourceEnvID! + : currentSelectedEnvironment.id + + const hasSecretEnv = this.secretEnvs.hasSecretValue( + sourceEnvID, + env.key + ) + + const hasValue = + this.currentEnvs.hasValue( + env.sourceEnv !== "Global" + ? currentSelectedEnvironment.id + : "Global", + env.key + ) || + env.currentValue !== "" || + env.initialValue !== "" + + if (env.key !== formattedExEnv) return + + // Flag variables that are empty + if (env.secret ? !hasSecretEnv : !hasValue) { + const itemLocation: InspectorLocation = { + type: locations.type, + position: + locations.type === "url" || + locations.type === "body" || + locations.type === "response" || + locations.type === "body-content-type-header" + ? "key" + : locations.position, + index, + key: element, + } + + // Pick the right modal to open for editing + const currentEnvironmentType = getSelectedEnvironmentType() + const invokeActionType: + | "modals.my.environment.edit" + | "modals.team.environment.edit" + | "modals.global.environment.update" = + env.sourceEnv === "Global" + ? "modals.global.environment.update" + : currentEnvironmentType === "TEAM_ENV" + ? "modals.team.environment.edit" + : "modals.my.environment.edit" + + newErrors.push({ + id: `environment-empty-${newErrors.length}`, + text: { + type: "text", + text: this.t("inspections.environment.empty_value", { + variable: exEnv, + }), + }, + icon: markRaw(IconPlusCircle), + action: { + text: this.t("inspections.environment.add_environment_value"), + apply: () => { + // If it's a request variable, open the requestVariables tab + if ( + env.sourceEnv === "RequestVariable" && + currentTab.document.type === "request" + ) { + currentTab.document.optionTabPreference = "requestVariables" + } else { + invokeAction(invokeActionType, { + envName: + env.sourceEnv === "Global" + ? "Global" + : currentSelectedEnvironment.name, + variableName: formattedExEnv, + isSecret: env.secret, + }) + } + }, + showAction: env.sourceEnv !== "CollectionVariable", // skip collection vars for now + }, + severity: 2, + isApplicable: true, + locations: itemLocation, + doc: { + text: this.t("action.learn_more"), + link: "https://docs.hoppscotch.io/documentation/features/inspections", + }, + }) + } + }) + }) + }) + + return newErrors + } + + /** + * Runs all inspections for a given request and returns a computed list of results. + */ + getInspections( + req: Readonly> + ) { + return computed(() => { + const results: InspectorResult[] = [] + if (!req.value) return results + + const { endpoint, headers, params } = req.value + + // URL check + results.push( + ...this.validateEnvironmentVariables([endpoint], { type: "url" }), + ...this.validateEmptyEnvironmentVariables([endpoint], { type: "url" }) + ) + + // Header keys and values + const headerKeys = Object.values(headers).map((h) => h.key) + const headerValues = Object.values(headers).map((h) => h.value) + + results.push( + ...this.validateEnvironmentVariables(headerKeys, { + type: "header", + position: "key", + }), + ...this.validateEmptyEnvironmentVariables(headerKeys, { + type: "header", + position: "key", + }), + ...this.validateEnvironmentVariables(headerValues, { + type: "header", + position: "value", + }), + ...this.validateEmptyEnvironmentVariables(headerValues, { + type: "header", + position: "value", + }) + ) + + // Parameter keys and values + const paramKeys = Object.values(params).map((p) => p.key) + const paramValues = Object.values(params).map((p) => p.value) + + results.push( + ...this.validateEnvironmentVariables(paramKeys, { + type: "parameter", + position: "key", + }), + ...this.validateEmptyEnvironmentVariables(paramKeys, { + type: "parameter", + position: "key", + }), + ...this.validateEnvironmentVariables(paramValues, { + type: "parameter", + position: "value", + }), + ...this.validateEmptyEnvironmentVariables(paramValues, { + type: "parameter", + position: "value", + }) + ) + + return results + }) + } +} diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/request.inspector.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/request.inspector.ts new file mode 100644 index 0000000..a913fef --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/request.inspector.ts @@ -0,0 +1,167 @@ +import { Service } from "dioc" +import { InspectionService, Inspector, InspectorResult } from ".." +import { computed, markRaw, Ref } from "vue" +import { + HoppRESTRequest, + HoppRESTResponseOriginalRequest, +} from "@hoppscotch/data" +import IconAlertTriangle from "~icons/lucide/alert-triangle" +import { getI18n } from "~/modules/i18n" +import { KernelInterceptorService } from "~/services/kernel-interceptor.service" +import { RelayCapabilities } from "@hoppscotch/kernel" + +type CapabilityRequirement = { + type: K + name: string & RelayCapabilities[K] extends Set ? T : never +} + +interface InspectionMatch { + index?: number + key?: string +} + +interface CapabilityCheck { + matcher: ( + req: HoppRESTRequest | HoppRESTResponseOriginalRequest + ) => InspectionMatch | null + requires: CapabilityRequirement + createInspection: (match: InspectionMatch) => InspectorResult +} + +export class RequestInspectorService extends Service implements Inspector { + public static readonly ID = "REQUEST_INSPECTOR_SERVICE" + public readonly inspectorID = "request" + + private readonly t = getI18n() + private readonly inspection = this.bind(InspectionService) + private readonly kernelInterceptor = this.bind(KernelInterceptorService) + + private readonly inspectionChecks: CapabilityCheck[] = [ + { + matcher: (req) => { + const localHostURLs = ["localhost", "127.0.0.1"] + return localHostURLs.some((host) => req.endpoint.includes(host)) + ? {} + : null + }, + requires: { + type: "advanced", + name: "localaccess", + }, + createInspection: () => ({ + id: "localaccess", + icon: markRaw(IconAlertTriangle), + text: { + type: "text", + text: this.t("inspections.url.localaccess_unsupported"), + }, + severity: 2, + isApplicable: true, + locations: { type: "url" }, + }), + }, + { + matcher: (req) => (req.auth.authType === "digest" ? {} : null), + requires: { type: "auth", name: "digest" }, + createInspection: () => ({ + id: "digest-auth", + icon: markRaw(IconAlertTriangle), + text: { type: "text", text: this.t("inspections.auth.digest") }, + severity: 2, + isApplicable: true, + locations: { type: "url" }, + doc: { + text: this.t("action.learn_more"), + link: "https://docs.hoppscotch.io/documentation/features/inspections", + }, + }), + }, + { + matcher: (req) => (req.auth.authType === "hawk" ? {} : null), + requires: { type: "auth", name: "hawk" }, + createInspection: () => ({ + id: "hawk-auth", + icon: markRaw(IconAlertTriangle), + text: { type: "text", text: this.t("inspections.auth.hawk") }, + severity: 2, + isApplicable: true, + locations: { type: "url" }, + doc: { + text: this.t("action.learn_more"), + link: "https://docs.hoppscotch.io/documentation/features/inspections", + }, + }), + }, + { + matcher: (req) => { + const index = req.headers.findIndex((h) => + h.key.toLowerCase().includes("cookie") + ) + return index !== -1 ? { index, key: req.headers[index].key } : null + }, + requires: { type: "advanced", name: "cookies" }, + createInspection: (match) => ({ + id: "cookie-header", + icon: markRaw(IconAlertTriangle), + text: { type: "text", text: this.t("inspections.header.cookie") }, + severity: 2, + isApplicable: true, + locations: { + type: "header", + position: "key", + ...match, + }, + doc: { + text: this.t("action.learn_more"), + link: "https://hoppscotch.com/download", + }, + }), + }, + { + matcher: (req) => + req.body.contentType === "application/octet-stream" ? {} : null, + requires: { type: "content", name: "binary" }, + createInspection: () => ({ + id: "binary-body", + icon: markRaw(IconAlertTriangle), + text: { type: "text", text: this.t("inspections.body.binary") }, + severity: 2, + isApplicable: true, + locations: { type: "body-content-type-header" }, + doc: { + text: this.t("action.learn_more"), + link: "https://docs.hoppscotch.io/documentation/features/inspections", + }, + }), + }, + ] + + override onServiceInit() { + this.inspection.registerInspector(this) + } + + private validateCapability( + req: HoppRESTRequest | HoppRESTResponseOriginalRequest, + { matcher, requires, createInspection }: CapabilityCheck + ): InspectorResult[] { + const match = matcher(req) + if (!match) return [] + + const capabilities = this.kernelInterceptor.current.value?.capabilities + const supports = capabilities[requires.type].has(requires.name) + + return !supports ? [createInspection(match)] : [] + } + + getInspections( + req: Readonly> + ) { + return computed(() => + !req.value + ? [] + : this.inspectionChecks.flatMap((check) => + this.validateCapability(req.value, check) + ) + ) + } +} diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/response.inspector.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/response.inspector.ts new file mode 100644 index 0000000..d35ddb2 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/response.inspector.ts @@ -0,0 +1,77 @@ +import { Service } from "dioc" +import { InspectionService, Inspector, InspectorResult } from ".." +import { getI18n } from "~/modules/i18n" +import { + HoppRESTRequest, + HoppRESTResponseOriginalRequest, +} from "@hoppscotch/data" +import IconAlertTriangle from "~icons/lucide/alert-triangle" +import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" +import { computed, Ref, markRaw } from "vue" + +/** + * This inspector is responsible for inspecting the response of a request. + * It checks if the response is successful and if it contains errors. + * + * NOTE: Initializing this service registers it as a inspector with the Inspection Service. + */ +export class ResponseInspectorService extends Service implements Inspector { + public static readonly ID = "RESPONSE_INSPECTOR_SERVICE" + + private t = getI18n() + + public readonly inspectorID = "response" + + private readonly inspection = this.bind(InspectionService) + + override onServiceInit() { + this.inspection.registerInspector(this) + } + + getInspections( + _req: Readonly>, + res: Readonly> + ) { + return computed(() => { + const results: InspectorResult[] = [] + if (!res.value) return results + + const hasErrors = + res && (res.value.type !== "success" || res.value.statusCode !== 200) + + let text: string | undefined = undefined + + if (res.value.type === "network_fail" && !navigator.onLine) { + text = this.t("inspections.response.network_error") + } else if (res.value.type === "fail") { + text = this.t("inspections.response.default_error") + } else if (res.value.type === "success" && res.value.statusCode === 404) { + text = this.t("inspections.response.404_error") + } else if (res.value.type === "success" && res.value.statusCode === 401) { + text = this.t("inspections.response.401_error") + } + + if (hasErrors && text) { + results.push({ + id: "url", + icon: markRaw(IconAlertTriangle), + text: { + type: "text", + text: text, + }, + severity: 2, + isApplicable: true, + locations: { + type: "response", + }, + doc: { + text: this.t("action.learn_more"), + link: "https://docs.hoppscotch.io/documentation/features/inspections", + }, + }) + } + + return results + }) + } +} diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/scripting-interceptor.inspector.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/scripting-interceptor.inspector.ts new file mode 100644 index 0000000..d8364c2 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/scripting-interceptor.inspector.ts @@ -0,0 +1,249 @@ +import { Service } from "dioc" +import { + InspectionService, + Inspector, + InspectorResult, +} from "~/services/inspection" +import { computed, markRaw, Ref } from "vue" +import { + HoppRESTRequest, + HoppRESTResponseOriginalRequest, +} from "@hoppscotch/data" +import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" +import IconAlertTriangle from "~icons/lucide/alert-triangle" +import { getI18n } from "~/modules/i18n" +import { KernelInterceptorService } from "~/services/kernel-interceptor.service" +import { platform } from "~/platform" + +/** + * Inspector that validates proper interceptor usage when scripts make HTTP requests. + * + * This inspector warns users when: + * 1. Using Extension/Proxy interceptors with fetch/hopp.fetch/pm.sendRequest + * - Extension has limited support, Proxy behavior is unknown + * - Recommends Agent (web) or Native (desktop) for reliable scripting + * + * 2. Using Browser interceptor with same-origin requests (only when hasCookieBasedAuth=true) + * - Platforms with cookie-based auth auto-include cookies in same-origin requests + * - Creates CSRF vulnerability if script is malicious + * - Recommends Agent interceptor for same-origin requests + * - Only applies to SH web; SH desktop uses bearer tokens + */ +export class ScriptingInterceptorInspectorService + extends Service + implements Inspector +{ + public static readonly ID = "SCRIPTING_INTERCEPTOR_INSPECTOR_SERVICE" + public readonly inspectorID = "scripting-interceptor" + + private readonly t = getI18n() + private readonly inspection = this.bind(InspectionService) + private readonly kernelInterceptor = this.bind(KernelInterceptorService) + + override onServiceInit() { + this.inspection.registerInspector(this) + } + + /** + * Detects if script contains fetch(), hopp.fetch(), or pm.sendRequest() calls. + * Returns the API name if found, null otherwise. + */ + private scriptContainsFetchAPI(script: string): string | null { + if (!script || script.trim() === "") { + return null + } + + if (/pm\.sendRequest\s*\(/i.test(script)) { + return "pm.sendRequest()" + } else if (/hopp\.fetch\s*\(/i.test(script)) { + return "hopp.fetch()" + } else if (/(? pattern.test(script))) { + return true + } + + // Check for window.location usage + if (/(?:window\.)?location\.(?:origin|href|hostname)/i.test(script)) { + return true + } + + // Check for absolute URLs matching current origin in string arguments + const fetchUrlPattern = + /(?:fetch|sendRequest)\s*\(\s*['"`](https?:\/\/[^'"`]+)['"`]/gi + const matches = script.matchAll(fetchUrlPattern) + + for (const match of matches) { + const url = match[1] + try { + const urlObj = new URL(url) + if (urlObj.origin === currentOrigin) { + return true + } + } catch { + continue + } + } + + // Check for request objects with same-origin URLs (pm.sendRequest pattern) + // Matches patterns like: pm.sendRequest({url: '/path'}, ...) or pm.sendRequest({url: 'http://...'}, ...) + const requestObjectPattern = + /(?:sendRequest)\s*\(\s*\{[^}]*url\s*:\s*['"`]([^'"`]+)['"`][^}]*\}/gi + const requestObjectMatches = script.matchAll(requestObjectPattern) + + for (const match of requestObjectMatches) { + const url = match[1] + + // Check if it's a relative URL + if ( + url.startsWith("/") || + url.startsWith("./") || + url.startsWith("../") + ) { + return true + } + + // Check if it's an absolute URL matching current origin + try { + const urlObj = new URL(url) + if (urlObj.origin === currentOrigin) { + return true + } + } catch { + // Invalid URL, skip + continue + } + } + + return false + } + + getInspections( + req: Readonly>, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _res: Readonly> + ): Ref { + return computed(() => { + const results: InspectorResult[] = [] + + if (!req.value || !("preRequestScript" in req.value)) { + return results + } + + const request = req.value as HoppRESTRequest + const currentInterceptorId = this.kernelInterceptor.getCurrentId() + + // Check both scripts for fetch API usage + const preRequestAPI = this.scriptContainsFetchAPI( + request.preRequestScript + ) + const postRequestAPI = this.scriptContainsFetchAPI(request.testScript) + + if (!preRequestAPI && !postRequestAPI) { + return results + } + + // Determine which script type(s) use the API + const scriptType = preRequestAPI + ? postRequestAPI + ? this.t("inspections.scripting_interceptor.both_scripts") + : this.t("inspections.scripting_interceptor.pre_request") + : this.t("inspections.scripting_interceptor.post_request") + + const apiUsed = preRequestAPI || postRequestAPI! + + // Warning 1: Extension/Proxy interceptors don't support scripting API calls + if ( + currentInterceptorId === "extension" || + currentInterceptorId === "proxy" + ) { + results.push({ + id: "unsupported-interceptor", + icon: markRaw(IconAlertTriangle), + text: { + type: "text", + text: this.t( + "inspections.scripting_interceptor.unsupported_interceptor", + { scriptType, apiUsed, interceptor: currentInterceptorId } + ), + }, + severity: 2, + isApplicable: true, + locations: { type: "response" }, + }) + } + + // Warning 2: CSRF concern with Browser interceptor + same-origin (only for cookie-based auth) + if ( + currentInterceptorId === "browser" && + platform.platformFeatureFlags.hasCookieBasedAuth + ) { + const preRequestHasSameOrigin = this.scriptContainsSameOriginFetch( + request.preRequestScript + ) + const postRequestHasSameOrigin = this.scriptContainsSameOriginFetch( + request.testScript + ) + + if (preRequestHasSameOrigin || postRequestHasSameOrigin) { + const sameOriginScriptType = preRequestHasSameOrigin + ? postRequestHasSameOrigin + ? this.t("inspections.scripting_interceptor.both_scripts") + : this.t("inspections.scripting_interceptor.pre_request") + : this.t("inspections.scripting_interceptor.post_request") + + const sameOriginApiUsed = preRequestHasSameOrigin + ? this.scriptContainsFetchAPI(request.preRequestScript) + : this.scriptContainsFetchAPI(request.testScript) + + results.push({ + id: "same-origin-fetch-csrf", + icon: markRaw(IconAlertTriangle), + text: { + type: "text", + text: this.t( + "inspections.scripting_interceptor.same_origin_csrf_warning", + { + scriptType: sameOriginScriptType, + apiUsed: sameOriginApiUsed, + } + ), + }, + severity: 2, + isApplicable: true, + locations: { type: "response" }, + }) + } + } + + return results + }) + } +} diff --git a/packages/hoppscotch-common/src/services/kernel-interceptor.service.ts b/packages/hoppscotch-common/src/services/kernel-interceptor.service.ts new file mode 100644 index 0000000..4da8a7b --- /dev/null +++ b/packages/hoppscotch-common/src/services/kernel-interceptor.service.ts @@ -0,0 +1,173 @@ +import * as E from "fp-ts/Either" +import { Service } from "dioc" +import { Component, computed, reactive, watchEffect, markRaw } from "vue" +import type { getI18n } from "~/modules/i18n" +import { + RelayRequest, + RelayResponse, + RelayError, + RelayCapabilities, +} from "@hoppscotch/kernel" + +export function isCancellationError( + error: KernelInterceptorError +): error is "cancellation" { + return error === "cancellation" +} + +export type SelectableStatus = + | { type: "selectable" } + | { + type: "unselectable" + reason: + | { + type: "text" + text: (t: ReturnType) => string + action?: { + text: (t: ReturnType) => string + onActionClick: () => void + } + } + | { + type: "custom" + component: Component + props: Props + } + } + +export type KernelInterceptorError = + | "cancellation" + | { + humanMessage: { + heading: (t: ReturnType) => string + description: (t: ReturnType) => string + } + error: RelayError + component?: Component + } + +export type ExecutionResult< + Err extends KernelInterceptorError = KernelInterceptorError, +> = { + cancel: () => Promise + response: Promise> +} + +export type KernelInterceptor< + Err extends KernelInterceptorError = KernelInterceptorError, +> = { + id: string + name: (t: ReturnType) => string + settingsEntry?: { + title: (t: ReturnType) => string + component: Component + } + subtitle?: Component + selectable: SelectableStatus + capabilities: RelayCapabilities + execute: (request: RelayRequest) => ExecutionResult +} + +export class KernelInterceptorService extends Service { + public static readonly ID = "KERNEL_INTERCEPTOR_SERVICE" + + private readonly state = reactive({ + interceptors: new Map(), + currentId: null as string | null, + }) + + public readonly current = computed(() => + this.state.currentId + ? this.state.interceptors.get(this.state.currentId) + : null + ) + + public readonly available = computed(() => + Array.from(this.state.interceptors.values()) + ) + + public getCurrentId(): string | null { + return this.state.currentId + } + + override onServiceInit(): void { + this.setupInterceptorValidation() + } + + private setupInterceptorValidation(): void { + watchEffect(() => { + if (!this.state.currentId) return + + const currentInterceptor = this.state.interceptors.get( + this.state.currentId + ) + + if (!this.validateCurrentInterceptor(currentInterceptor)) { + this.resetToSelectableInterceptor() + } + }) + } + + private validateCurrentInterceptor( + interceptor: KernelInterceptor | undefined + ): boolean { + if (!interceptor) { + this.state.currentId = null + return false + } + + return interceptor.selectable.type === "selectable" + } + + private resetToSelectableInterceptor(): void { + const selectableInterceptor = this.available.value.find( + (int) => int.selectable.type === "selectable" + ) + this.state.currentId = selectableInterceptor?.id ?? null + } + + public register(interceptor: KernelInterceptor): void { + this.state.interceptors.set(interceptor.id, markRaw(interceptor)) + + if (!this.state.currentId) { + this.state.currentId = interceptor.id + } + } + + public setActive(id: string | null): void { + if (!id) { + this.handleNullIdActivation() + return + } + + if (!this.state.interceptors.has(id)) { + console.warn("Attempted to set unknown interceptor as active", id) + return + } + + this.state.currentId = id + } + + private handleNullIdActivation(): void { + this.state.currentId = + this.available.value.length === 0 ? null : this.state.currentId + } + + public execute(req: RelayRequest): ExecutionResult { + const interceptor = this.validateAndGetActiveInterceptor() + return interceptor.execute(req) + } + + private validateAndGetActiveInterceptor(): KernelInterceptor { + if (!this.state.currentId) { + throw new Error("No active interceptor") + } + + const interceptor = this.state.interceptors.get(this.state.currentId) + if (!interceptor) { + throw new Error("Active interceptor not found") + } + + return interceptor + } +} diff --git a/packages/hoppscotch-common/src/services/oauth/flows/authCode.ts b/packages/hoppscotch-common/src/services/oauth/flows/authCode.ts new file mode 100644 index 0000000..e674a7d --- /dev/null +++ b/packages/hoppscotch-common/src/services/oauth/flows/authCode.ts @@ -0,0 +1,378 @@ +import { PersistenceService } from "~/services/persistence" +import { + OauthAuthService, + PersistedOAuthConfig, + createFlowConfig, + decodeResponseAsJSON, + generateRandomString, +} from "../oauth.service" +import { z } from "zod" +import { getService } from "~/modules/dioc" +import * as E from "fp-ts/Either" +import { KernelInterceptorService } from "~/services/kernel-interceptor.service" +import { content } from "@hoppscotch/kernel" +import { refreshToken, OAuth2ParamSchema } from "../utils" +import { + AuthCodeGrantTypeParams, + OAuth2AuthRequestParam, +} from "@hoppscotch/data" + +const persistenceService = getService(PersistenceService) +const interceptorService = getService(KernelInterceptorService) + +// Use the existing schema from hoppscotch-data but ensure required arrays +const AuthCodeOauthFlowParamsSchema = AuthCodeGrantTypeParams.omit({ + grantType: true, + token: true, +}) + .extend({ + // Override optional arrays to be required for the service layer + authRequestParams: z.array(OAuth2AuthRequestParam), + tokenRequestParams: z.array(OAuth2ParamSchema), + refreshRequestParams: z.array(OAuth2ParamSchema), + tokenType: z.enum(["access_token", "id_token"]).optional(), + }) + .refine( + (params) => { + return ( + params.authEndpoint.length >= 1 && + params.tokenEndpoint.length >= 1 && + params.clientID.length >= 1 && + (!params.scopes || params.scopes.trim().length >= 1) + ) + }, + { + message: "Minimum length requirement not met for one or more parameters", + } + ) + .refine((params) => (params.isPKCE ? !!params.codeVerifierMethod : true), { + message: "codeVerifierMethod is required when using PKCE", + path: ["codeVerifierMethod"], + }) + +export type AuthCodeOauthFlowParams = z.infer< + typeof AuthCodeOauthFlowParamsSchema +> + +export type AuthCodeOauthRefreshParams = { + tokenEndpoint: string + clientID: string + clientSecret?: string + refreshToken: string +} + +export const getDefaultAuthCodeOauthFlowParams = + (): AuthCodeOauthFlowParams => ({ + authEndpoint: "", + tokenEndpoint: "", + clientID: "", + clientSecret: "", + scopes: undefined, + isPKCE: false, + codeVerifierMethod: "S256", + authRequestParams: [], + refreshRequestParams: [], + tokenRequestParams: [], + tokenType: "access_token", + }) + +const initAuthCodeOauthFlow = async ({ + tokenEndpoint, + clientID, + clientSecret, + scopes, + authEndpoint, + isPKCE, + codeVerifierMethod, + authRequestParams, + refreshRequestParams, + tokenRequestParams, + tokenType, +}: AuthCodeOauthFlowParams) => { + const state = generateRandomString() + + let codeVerifier: string | undefined + let codeChallenge: string | undefined + + // Ensure backward compatibility for collections that were imported before + // `codeVerifierMethod` was added. If PKCE is enabled but the method is + // missing, default to 'plain' as requested by the user. + const codeVerifierMethodNormalized = + isPKCE && !codeVerifierMethod ? ("plain" as const) : codeVerifierMethod + + if (isPKCE) { + codeVerifier = generateCodeVerifier() + // codeVerifierMethodNormalized might be undefined only if isPKCE is false, + // but here we guard with isPKCE so it's safe to pass a value. + codeChallenge = await generateCodeChallenge( + codeVerifier, + codeVerifierMethodNormalized + ) + } + + let oauthTempConfig: { + state: string + grant_type: "AUTHORIZATION_CODE" + authEndpoint: string + tokenEndpoint: string + clientSecret?: string + clientID: string + isPKCE: boolean + codeVerifier?: string + codeVerifierMethod?: string + codeChallenge?: string + scopes?: string + authRequestParams?: Array<{ + key: string + value: string + active: boolean + sendIn?: string + }> + refreshRequestParams?: Array<{ + key: string + value: string + active: boolean + sendIn?: string + }> + tokenRequestParams?: Array<{ + key: string + value: string + active: boolean + sendIn?: string + }> + tokenType?: "access_token" | "id_token" + } = { + state, + grant_type: "AUTHORIZATION_CODE", + authEndpoint, + tokenEndpoint, + clientSecret, + clientID, + isPKCE, + // Persist the normalized method so subsequent redirect handling has a value + codeVerifierMethod: codeVerifierMethodNormalized, + scopes, + authRequestParams, + refreshRequestParams, + tokenRequestParams, + tokenType, + } + + if (codeVerifier && codeChallenge) { + oauthTempConfig = { + ...oauthTempConfig, + codeVerifier, + codeChallenge, + } + } + + const localOAuthTempConfig = + await persistenceService.getLocalConfig("oauth_temp_config") + + const persistedOAuthConfig: PersistedOAuthConfig = localOAuthTempConfig + ? { ...JSON.parse(localOAuthTempConfig) } + : {} + + const { grant_type, ...rest } = oauthTempConfig + + // persist the state so we can compare it when we get redirected back + // also persist the grant_type,tokenEndpoint and clientSecret so we can use them when we get redirected back + await persistenceService.setLocalConfig( + "oauth_temp_config", + JSON.stringify({ + ...persistedOAuthConfig, + fields: rest, + grant_type, + }) + ) + + let url: URL + + try { + url = new URL(authEndpoint) + } catch (_e) { + return E.left("INVALID_AUTH_ENDPOINT") + } + + url.searchParams.set("client_id", clientID) + url.searchParams.set("state", state) + url.searchParams.set("response_type", "code") + url.searchParams.set("redirect_uri", OauthAuthService.redirectURI) + + if (scopes) url.searchParams.set("scope", scopes) + + if (codeVerifierMethod && codeChallenge) { + url.searchParams.set("code_challenge", codeChallenge) + url.searchParams.set("code_challenge_method", codeVerifierMethod) + } + + if (authRequestParams.length > 0) { + authRequestParams.forEach((param) => { + if (param.active && param.key && param.value) { + url.searchParams.set(param.key, param.value) + } + }) + } + + // Redirect to the authorization server + window.location.assign(url.toString()) + + return E.right(undefined) +} + +const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => { + // parse the query string + const params = new URLSearchParams(window.location.search) + + const code = params.get("code") + const state = params.get("state") + const error = params.get("error") + + if (error) { + return E.left("AUTH_SERVER_RETURNED_ERROR") + } + + if (!code) { + return E.left("AUTH_TOKEN_REQUEST_FAILED") + } + + const expectedSchema = z.object({ + source: z.optional(z.string()), + state: z.string(), + tokenEndpoint: z.string(), + clientSecret: z.string(), + clientID: z.string(), + codeVerifier: z.string().optional(), + codeChallenge: z.string().optional(), + tokenType: z.enum(["access_token", "id_token"]).optional(), + }) + + const decodedLocalConfig = expectedSchema.safeParse( + JSON.parse(localConfig).fields + ) + + if (!decodedLocalConfig.success) { + return E.left("INVALID_LOCAL_CONFIG") + } + + // check if the state matches + if (decodedLocalConfig.data.state !== state) { + return E.left("INVALID_STATE") + } + + // exchange the code for a token + const { response } = interceptorService.execute({ + id: Date.now(), + url: decodedLocalConfig.data.tokenEndpoint, + method: "POST", + version: "HTTP/1.1", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + content: content.urlencoded({ + code, + grant_type: "authorization_code", + client_id: decodedLocalConfig.data.clientID, + client_secret: decodedLocalConfig.data.clientSecret, + redirect_uri: OauthAuthService.redirectURI, + ...(decodedLocalConfig.data.codeVerifier && { + code_verifier: decodedLocalConfig.data.codeVerifier, + }), + }), + }) + + const res = await response + + if (E.isLeft(res)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const responsePayload = decodeResponseAsJSON(res.right) + + if (E.isLeft(responsePayload)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const withAccessTokenSchema = z + .object({ + access_token: z.string().optional(), + id_token: z.string().optional(), + refresh_token: z.string().optional(), + }) + .refine((data) => data.access_token || data.id_token, { + message: "Either access_token or id_token must be present", + }) + + const parsedTokenResponse = withAccessTokenSchema.safeParse( + responsePayload.right + ) + + if (!parsedTokenResponse.success) { + return E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) + } + + // Get the token type preference from the persisted config + const tokenTypePreference = + decodedLocalConfig.data.tokenType || "access_token" + + return E.right({ + access_token: + tokenTypePreference === "id_token" + ? parsedTokenResponse.data.id_token || + parsedTokenResponse.data.access_token || + "" + : parsedTokenResponse.data.access_token || + parsedTokenResponse.data.id_token || + "", + refresh_token: parsedTokenResponse.data.refresh_token, + }) +} + +const generateCodeVerifier = () => { + const characters = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + const length = Math.floor(Math.random() * (128 - 43 + 1)) + 43 // Random length between 43 and 128 + let codeVerifier = "" + + for (let i = 0; i < length; i++) { + const randomIndex = Math.floor(Math.random() * characters.length) + codeVerifier += characters[randomIndex] + } + + return codeVerifier +} + +const generateCodeChallenge = async ( + codeVerifier: string, + strategy: AuthCodeOauthFlowParams["codeVerifierMethod"] +) => { + if (strategy === "plain") { + return codeVerifier + } + + const encoder = new TextEncoder() + const data = encoder.encode(codeVerifier) + + const buffer = await crypto.subtle.digest("SHA-256", data) + + return encodeArrayBufferAsUrlEncodedBase64(buffer) +} + +const encodeArrayBufferAsUrlEncodedBase64 = (buffer: ArrayBuffer) => { + const hashArray = Array.from(new Uint8Array(buffer)) + const hashBase64URL = btoa(String.fromCharCode(...hashArray)) + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_") + + return hashBase64URL +} + +export default createFlowConfig( + "AUTHORIZATION_CODE" as const, + AuthCodeOauthFlowParamsSchema, + initAuthCodeOauthFlow, + handleRedirectForAuthCodeOauthFlow, + refreshToken +) diff --git a/packages/hoppscotch-common/src/services/oauth/flows/clientCredentials.ts b/packages/hoppscotch-common/src/services/oauth/flows/clientCredentials.ts new file mode 100644 index 0000000..5001681 --- /dev/null +++ b/packages/hoppscotch-common/src/services/oauth/flows/clientCredentials.ts @@ -0,0 +1,315 @@ +import { + OauthAuthService, + createFlowConfig, + decodeResponseAsJSON, +} from "../oauth.service" +import { z } from "zod" +import { getService } from "~/modules/dioc" +import * as E from "fp-ts/Either" +import * as O from "fp-ts/Option" +import { useToast } from "~/composables/toast" +import { KernelInterceptorService } from "~/services/kernel-interceptor.service" +import { RelayRequest, content } from "@hoppscotch/kernel" +import { parseBytesToJSON } from "~/helpers/functional/json" +import { refreshToken, OAuth2ParamSchema } from "../utils" +import { ClientCredentialsGrantTypeParams } from "@hoppscotch/data" + +const interceptorService = getService(KernelInterceptorService) + +// Use the existing schema from hoppscotch-data but add client authentication mode +const ClientCredentialsFlowParamsSchema = ClientCredentialsGrantTypeParams.omit( + { + grantType: true, + token: true, + } +) + .extend({ + clientAuthentication: z.enum(["AS_BASIC_AUTH_HEADERS", "IN_BODY"]), + // Override optional arrays to be required for the service layer + tokenRequestParams: z.array(OAuth2ParamSchema), + refreshRequestParams: z.array(OAuth2ParamSchema), + tokenType: z.enum(["access_token", "id_token"]).optional(), + }) + .refine( + (params) => { + return ( + params.authEndpoint.length >= 1 && + params.clientID.length >= 1 && + (!params.scopes || params.scopes.length >= 1) + ) + }, + { + message: "Minimum length requirement not met for one or more parameters", + } + ) + +export type ClientCredentialsFlowParams = z.infer< + typeof ClientCredentialsFlowParamsSchema +> + +export const getDefaultClientCredentialsFlowParams = + (): ClientCredentialsFlowParams => ({ + authEndpoint: "", + clientID: "", + clientSecret: "", + scopes: undefined, + clientAuthentication: "IN_BODY", + tokenRequestParams: [], + refreshRequestParams: [], + tokenType: "access_token", + }) + +const initClientCredentialsOAuthFlow = async ( + payload: ClientCredentialsFlowParams +) => { + const toast = useToast() + + const requestPayload = + payload.clientAuthentication === "AS_BASIC_AUTH_HEADERS" + ? getPayloadForViaBasicAuthHeader(payload) + : getPayloadForViaBody(payload) + + const { response } = interceptorService.execute(requestPayload) + + const res = await response + + if (E.isLeft(res)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const jsonResponse = decodeResponseAsJSON(res.right) + + if (E.isLeft(jsonResponse)) return E.left("AUTH_TOKEN_REQUEST_FAILED") + + const withAccessTokenSchema = z + .object({ + access_token: z.string().optional(), + id_token: z.string().optional(), + }) + .refine((data) => data.access_token || data.id_token, { + message: "Either access_token or id_token must be present", + }) + + const parsedTokenResponse = withAccessTokenSchema.safeParse( + jsonResponse.right + ) + + if (!parsedTokenResponse.success) { + toast.error("AUTH_TOKEN_REQUEST_INVALID_RESPONSE") + } + + if (parsedTokenResponse.success) { + // Return the preferred token type + const token = + payload.tokenType === "id_token" + ? parsedTokenResponse.data.id_token || + parsedTokenResponse.data.access_token + : parsedTokenResponse.data.access_token || + parsedTokenResponse.data.id_token + + if (token) { + return E.right({ access_token: token }) + } + } + + return E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) +} + +const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => { + // parse the query string + const params = new URLSearchParams(window.location.search) + + const code = params.get("code") + const state = params.get("state") + const error = params.get("error") + + if (error) { + return E.left("AUTH_SERVER_RETURNED_ERROR") + } + + if (!code) { + return E.left("AUTH_TOKEN_REQUEST_FAILED") + } + + const expectedSchema = z.object({ + state: z.string(), + tokenEndpoint: z.string(), + clientSecret: z.string(), + clientID: z.string(), + }) + + const decodedLocalConfig = expectedSchema.safeParse(JSON.parse(localConfig)) + + if (!decodedLocalConfig.success) { + return E.left("INVALID_LOCAL_CONFIG") + } + + // check if the state matches + if (decodedLocalConfig.data.state !== state) { + return E.left("INVALID_STATE") + } + + const { response } = interceptorService.execute({ + id: Date.now(), + url: decodedLocalConfig.data.tokenEndpoint, + method: "POST", + version: "HTTP/1.1", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + content: content.urlencoded({ + code, + client_id: decodedLocalConfig.data.clientID, + client_secret: decodedLocalConfig.data.clientSecret, + redirect_uri: OauthAuthService.redirectURI, + }), + }) + + const res = await response + + if (E.isLeft(res)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const withAccessTokenSchema = z.object({ + access_token: z.string(), + }) + + const responsePayload = parseBytesToJSON<{ access_token: string }>( + res.right.body.body + ) + + if (O.isSome(responsePayload)) { + const parsedTokenResponse = withAccessTokenSchema.safeParse( + responsePayload.value + ) + return parsedTokenResponse.success + ? E.right(parsedTokenResponse.data) + : E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) + } + + return E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) +} + +export default createFlowConfig( + "CLIENT_CREDENTIALS" as const, + ClientCredentialsFlowParamsSchema, + initClientCredentialsOAuthFlow, + handleRedirectForAuthCodeOauthFlow, + refreshToken +) + +const getPayloadForViaBasicAuthHeader = ({ + clientID, + clientSecret, + scopes, + authEndpoint, + tokenRequestParams, +}: ClientCredentialsFlowParams): RelayRequest => { + // RFC 6749 Section 2.3.1 states that the client ID and secret should be URL encoded. + const encodedClientID = encodeBasicAuthComponent(clientID) + const encodedClientSecret = encodeBasicAuthComponent(clientSecret || "") + const basicAuthToken = btoa(`${encodedClientID}:${encodedClientSecret}`) + + const headers: Record = { + Authorization: `Basic ${basicAuthToken}`, + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + } + + const bodyParams: Record = { + grant_type: "client_credentials", + ...(scopes && { scope: scopes }), + } + + const urlParams: Record = {} + + // Process additional token request parameters + if (tokenRequestParams) { + tokenRequestParams + .filter((param) => param.active && param.key && param.value) + .forEach((param) => { + if (param.sendIn === "headers") { + headers[param.key] = param.value + } else if (param.sendIn === "url") { + urlParams[param.key] = param.value + } else { + // Default to body + bodyParams[param.key] = param.value + } + }) + } + + const url = new URL(authEndpoint) + Object.entries(urlParams).forEach(([key, value]) => { + url.searchParams.set(key, value) + }) + + return { + id: Date.now(), + url: url.toString(), + method: "POST", + version: "HTTP/1.1", + headers, + content: content.urlencoded(bodyParams), + } +} + +const getPayloadForViaBody = ({ + clientID, + clientSecret, + scopes, + authEndpoint, + tokenRequestParams, +}: ClientCredentialsFlowParams): RelayRequest => { + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + } + + const bodyParams: Record = { + grant_type: "client_credentials", + client_id: clientID, + ...(clientSecret && { client_secret: clientSecret }), + ...(scopes && { scope: scopes }), + } + + const urlParams: Record = {} + + // Process additional token request parameters + if (tokenRequestParams) { + tokenRequestParams + .filter((param) => param.active && param.key && param.value) + .forEach((param) => { + if (param.sendIn === "headers") { + headers[param.key] = param.value + } else if (param.sendIn === "url") { + urlParams[param.key] = param.value + } else { + // Default to body + bodyParams[param.key] = param.value + } + }) + } + + const url = new URL(authEndpoint) + Object.entries(urlParams).forEach(([key, value]) => { + url.searchParams.set(key, value) + }) + + return { + id: Date.now(), + url: url.toString(), + method: "POST", + version: "HTTP/1.1", + headers, + content: content.urlencoded(bodyParams), + } +} + +const encodeBasicAuthComponent = (component: string): string => { + // application/x-www-form-urlencoded expects spaces to be encoded as '+', but + // encodeURIComponent encodes them as '%20'. + return encodeURIComponent(component).replace(/%20/g, "+") +} diff --git a/packages/hoppscotch-common/src/services/oauth/flows/implicit.ts b/packages/hoppscotch-common/src/services/oauth/flows/implicit.ts new file mode 100644 index 0000000..589a996 --- /dev/null +++ b/packages/hoppscotch-common/src/services/oauth/flows/implicit.ts @@ -0,0 +1,174 @@ +import { PersistenceService } from "~/services/persistence" +import { + OauthAuthService, + PersistedOAuthConfig, + createFlowConfig, + generateRandomString, +} from "../oauth.service" +import { z } from "zod" +import { getService } from "~/modules/dioc" +import * as E from "fp-ts/Either" +import { + ImplicitOauthFlowParams as ImplicitOauthFlowParamsData, + OAuth2AuthRequestParam, +} from "@hoppscotch/data" +import { OAuth2ParamSchema } from "../utils" + +const persistenceService = getService(PersistenceService) + +// Use the existing schema from hoppscotch-data +const ImplicitOauthFlowParamsSchema = ImplicitOauthFlowParamsData.omit({ + grantType: true, + token: true, +}) + .extend({ + // Override optional arrays to be required for the service layer + authRequestParams: z.array(OAuth2AuthRequestParam), + refreshRequestParams: z.array(OAuth2ParamSchema), + tokenType: z.enum(["access_token", "id_token"]).optional(), + }) + .refine((params) => { + return ( + params.authEndpoint.length >= 1 && + params.clientID.length >= 1 && + (params.scopes === undefined || params.scopes.length >= 1) + ) + }) + +export type ImplicitOauthFlowParams = z.infer< + typeof ImplicitOauthFlowParamsSchema +> + +export const getDefaultImplicitOauthFlowParams = + (): ImplicitOauthFlowParams => ({ + authEndpoint: "", + clientID: "", + scopes: undefined, + authRequestParams: [], + refreshRequestParams: [], + tokenType: "access_token", + }) + +const initImplicitOauthFlow = async ({ + clientID, + scopes, + authEndpoint, + authRequestParams, + tokenType, +}: ImplicitOauthFlowParams) => { + const state = generateRandomString() + + const localOAuthTempConfig = + await persistenceService.getLocalConfig("oauth_temp_config") + + const persistedOAuthConfig: PersistedOAuthConfig = localOAuthTempConfig + ? { ...JSON.parse(localOAuthTempConfig) } + : {} + + // Persist the necessary information for retrieval while getting redirected back + await persistenceService.setLocalConfig( + "oauth_temp_config", + JSON.stringify({ + ...persistedOAuthConfig, + fields: { + clientID, + authEndpoint, + scopes, + state, + authRequestParams, + tokenType, + }, + grant_type: "IMPLICIT", + }) + ) + + let url: URL + + try { + url = new URL(authEndpoint) + } catch { + return E.left("INVALID_AUTH_ENDPOINT") + } + + url.searchParams.set("client_id", clientID) + url.searchParams.set("state", state) + url.searchParams.set("response_type", "token") + url.searchParams.set("redirect_uri", OauthAuthService.redirectURI) + + if (scopes) url.searchParams.set("scope", scopes) + + // Process additional auth request parameters + if (authRequestParams) { + authRequestParams + .filter((param) => param.active && param.key && param.value) + .forEach((param) => { + url.searchParams.set(param.key, param.value) + }) + } + + // Redirect to the authorization server + window.location.assign(url.toString()) + + return E.right(undefined) +} + +const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => { + // parse the query string + const params = new URLSearchParams(window.location.search) + const paramsFromHash = new URLSearchParams(window.location.hash.substring(1)) + + const accessToken = + params.get("access_token") || paramsFromHash.get("access_token") + const idToken = params.get("id_token") || paramsFromHash.get("id_token") + const state = params.get("state") || paramsFromHash.get("state") + const error = params.get("error") || paramsFromHash.get("error") + + if (error) { + return E.left("AUTH_SERVER_RETURNED_ERROR") + } + + const expectedSchema = z.object({ + source: z.optional(z.string()), + state: z.string(), + clientID: z.string(), + tokenType: z.enum(["access_token", "id_token"]).optional(), + }) + + const decodedLocalConfig = expectedSchema.safeParse( + JSON.parse(localConfig).fields + ) + + if (!decodedLocalConfig.success) { + return E.left("INVALID_LOCAL_CONFIG") + } + + // check if the state matches + if (decodedLocalConfig.data.state !== state) { + return E.left("INVALID_STATE") + } + + // Get the token type preference from the persisted config + const tokenTypePreference = + decodedLocalConfig.data.tokenType || "access_token" + + // Return the preferred token type + const token = + tokenTypePreference === "id_token" + ? idToken || accessToken + : accessToken || idToken + + if (!token) { + return E.left("AUTH_TOKEN_REQUEST_FAILED") + } + + return E.right({ + access_token: token, + }) +} + +export default createFlowConfig( + "IMPLICIT" as const, + ImplicitOauthFlowParamsSchema, + initImplicitOauthFlow, + handleRedirectForAuthCodeOauthFlow +) diff --git a/packages/hoppscotch-common/src/services/oauth/flows/password.ts b/packages/hoppscotch-common/src/services/oauth/flows/password.ts new file mode 100644 index 0000000..b55ccce --- /dev/null +++ b/packages/hoppscotch-common/src/services/oauth/flows/password.ts @@ -0,0 +1,250 @@ +import * as E from "fp-ts/Either" +import * as O from "fp-ts/Option" +import { + OauthAuthService, + createFlowConfig, + decodeResponseAsJSON, +} from "../oauth.service" +import { z } from "zod" +import { getService } from "~/modules/dioc" +import { KernelInterceptorService } from "~/services/kernel-interceptor.service" +import { useToast } from "~/composables/toast" +import { content } from "@hoppscotch/kernel" +import { parseBytesToJSON } from "~/helpers/functional/json" +import { refreshToken, OAuth2ParamSchema } from "../utils" +import { PasswordGrantTypeParams } from "@hoppscotch/data" + +const interceptorService = getService(KernelInterceptorService) + +// Use the existing schema from hoppscotch-data +const PasswordFlowParamsSchema = PasswordGrantTypeParams.omit({ + grantType: true, + token: true, +}) + .extend({ + // Override optional arrays to be required for the service layer + tokenRequestParams: z.array(OAuth2ParamSchema), + refreshRequestParams: z.array(OAuth2ParamSchema), + tokenType: z.enum(["access_token", "id_token"]).optional(), + }) + .refine( + (params) => { + return ( + params.authEndpoint.length >= 1 && + params.clientID.length >= 1 && + params.username.length >= 1 && + params.password.length >= 1 && + (!params.scopes || params.scopes.length >= 1) + ) + }, + { + message: "Minimum length requirement not met for one or more parameters", + } + ) + +export type PasswordFlowParams = z.infer + +export const getDefaultPasswordFlowParams = (): PasswordFlowParams => ({ + authEndpoint: "", + clientID: "", + clientSecret: "", + scopes: undefined, + username: "", + password: "", + tokenRequestParams: [], + refreshRequestParams: [], + tokenType: "access_token", +}) + +const initPasswordOauthFlow = async ({ + password, + username, + clientID, + clientSecret, + scopes, + authEndpoint, + tokenRequestParams, + tokenType, +}: PasswordFlowParams) => { + const toast = useToast() + + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + } + + const bodyParams: Record = { + grant_type: "password", + client_id: clientID, + username, + password, + ...(clientSecret && { + client_secret: clientSecret, + }), + ...(scopes && { + scope: scopes, + }), + } + + const urlParams: Record = {} + + // Process additional token request parameters + if (tokenRequestParams) { + tokenRequestParams + .filter((param) => param.active && param.key && param.value) + .forEach((param) => { + if (param.sendIn === "headers") { + headers[param.key] = param.value + } else if (param.sendIn === "url") { + urlParams[param.key] = param.value + } else { + // Default to body + bodyParams[param.key] = param.value + } + }) + } + + const url = new URL(authEndpoint) + Object.entries(urlParams).forEach(([key, value]) => { + url.searchParams.set(key, value) + }) + + const { response } = interceptorService.execute({ + id: Date.now(), + url: url.toString(), + method: "POST", + version: "HTTP/1.1", + headers, + content: content.urlencoded(bodyParams), + }) + + const res = await response + + if (E.isLeft(res) || res.right.status !== 200) { + toast.error("AUTH_TOKEN_REQUEST_FAILED") + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const withAccessTokenSchema = z + .object({ + access_token: z.string().optional(), + id_token: z.string().optional(), + }) + .refine((data) => data.access_token || data.id_token, { + message: "Either access_token or id_token must be present", + }) + + const responsePayload = parseBytesToJSON<{ + access_token?: string + id_token?: string + }>(res.right.body.body) + + if (O.isSome(responsePayload)) { + const parsedTokenResponse = withAccessTokenSchema.safeParse( + responsePayload.value + ) + + if (parsedTokenResponse.success) { + // Return the preferred token type + const token = + tokenType === "id_token" + ? parsedTokenResponse.data.id_token || + parsedTokenResponse.data.access_token + : parsedTokenResponse.data.access_token || + parsedTokenResponse.data.id_token + + if (token) { + return E.right({ access_token: token }) + } + } + } + + return E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) +} + +const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => { + // parse the query string + const params = new URLSearchParams(window.location.search) + + const code = params.get("code") + const state = params.get("state") + const error = params.get("error") + + if (error) { + return E.left("AUTH_SERVER_RETURNED_ERROR") + } + + if (!code) { + return E.left("AUTH_TOKEN_REQUEST_FAILED") + } + + const expectedSchema = z.object({ + state: z.string(), + tokenEndpoint: z.string(), + clientSecret: z.string(), + clientID: z.string(), + }) + + const decodedLocalConfig = expectedSchema.safeParse(JSON.parse(localConfig)) + + if (!decodedLocalConfig.success) { + return E.left("INVALID_LOCAL_CONFIG") + } + + // check if the state matches + if (decodedLocalConfig.data.state !== state) { + return E.left("INVALID_STATE") + } + + // exchange the code for a token + const config = { + code, + client_id: decodedLocalConfig.data.clientID, + client_secret: decodedLocalConfig.data.clientSecret, + redirect_uri: OauthAuthService.redirectURI, + } + + const { response } = interceptorService.execute({ + id: Date.now(), + url: decodedLocalConfig.data.tokenEndpoint, + method: "POST", + version: "HTTP/1.1", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + content: content.urlencoded(config), + }) + + const res = await response + + if (E.isLeft(res)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const responsePayload = decodeResponseAsJSON(res.right) + + if (E.isLeft(responsePayload)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const withAccessTokenSchema = z.object({ + access_token: z.string(), + }) + + const parsedTokenResponse = withAccessTokenSchema.safeParse( + responsePayload.right + ) + + return parsedTokenResponse.success + ? E.right(parsedTokenResponse.data) + : E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) +} + +export default createFlowConfig( + "PASSWORD" as const, + PasswordFlowParamsSchema, + initPasswordOauthFlow, + handleRedirectForAuthCodeOauthFlow, + refreshToken +) diff --git a/packages/hoppscotch-common/src/services/oauth/oauth.service.ts b/packages/hoppscotch-common/src/services/oauth/oauth.service.ts new file mode 100644 index 0000000..2380853 --- /dev/null +++ b/packages/hoppscotch-common/src/services/oauth/oauth.service.ts @@ -0,0 +1,133 @@ +import { pipe } from "fp-ts/function" +import * as O from "fp-ts/Option" +import * as E from "fp-ts/Either" +import { Service } from "dioc" +import { PersistenceService } from "../persistence" +import { ZodType, z } from "zod" +import authCode, { AuthCodeOauthFlowParams } from "./flows/authCode" +import implicit, { ImplicitOauthFlowParams } from "./flows/implicit" +import { getService } from "~/modules/dioc" +import { HoppCollection } from "@hoppscotch/data" +import { TeamCollection } from "~/helpers/backend/graphql" +import { parseBytesToJSON } from "~/helpers/functional/json" +import { MediaType } from "@hoppscotch/kernel" + +const persistenceService = getService(PersistenceService) + +export type PersistedOAuthConfig = { + source: "REST" | "GraphQL" + context?: { + type: "collection-properties" | "request-tab" + metadata: { + collection?: HoppCollection | TeamCollection + collectionID?: string + } + } + grant_type: string + fields?: (AuthCodeOauthFlowParams | ImplicitOauthFlowParams) & { + state: string + } + token?: string + refresh_token?: string +} + +export const grantTypesInvolvingRedirect = ["AUTHORIZATION_CODE", "IMPLICIT"] + +export const routeOAuthRedirect = async () => { + // get the temp data from the local storage + const localOAuthTempConfig = + await persistenceService.getLocalConfig("oauth_temp_config") + + if (!localOAuthTempConfig) { + return E.left("INVALID_STATE") + } + + const expectedSchema = z.object({ + source: z.optional(z.string()), + grant_type: z.string(), + }) + + const decodedLocalConfig = expectedSchema.safeParse( + JSON.parse(localOAuthTempConfig) + ) + + if (!decodedLocalConfig.success) { + return E.left("INVALID_STATE") + } + + // route the request to the correct flow + const flowConfig = [authCode, implicit].find( + (flow) => flow.flow === decodedLocalConfig.data.grant_type + ) + + if (!flowConfig) { + return E.left("INVALID_STATE") + } + + return flowConfig?.onRedirectReceived(localOAuthTempConfig) +} + +export function createFlowConfig< + Flow extends string, + AuthParams extends Record, + InitFuncReturnObject extends Record, + RefreshTokenParams extends Record, +>( + flow: Flow, + params: ZodType, + init: ( + params: AuthParams + ) => + | E.Either + | Promise> + | E.Either + | Promise>, + onRedirectReceived: (localConfig: string) => Promise< + E.Either< + string, + { + access_token: string + refresh_token?: string + } + > + >, + refreshToken?: ( + params: RefreshTokenParams + ) => Promise< + E.Either + > +) { + return { + flow, + params, + init, + onRedirectReceived, + refreshToken, + } +} + +export const decodeResponseAsJSON = (response: { + body: { body: Uint8Array; mediaType: MediaType } +}): E.Either<"AUTH_TOKEN_REQUEST_FAILED", Record> => + pipe( + response.body.body, + parseBytesToJSON>, + O.fold( + () => E.left("AUTH_TOKEN_REQUEST_FAILED" as const), + (data) => E.right(data) + ) + ) + +export class OauthAuthService extends Service { + public static readonly ID = "OAUTH_AUTH_SERVICE" + + static redirectURI = `${window.location.origin}/oauth` +} + +export const generateRandomString = () => { + const length = 64 + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + const values = crypto.getRandomValues(new Uint8Array(length)) + return values.reduce((acc, x) => acc + possible[x % possible.length], "") +} diff --git a/packages/hoppscotch-common/src/services/oauth/utils.ts b/packages/hoppscotch-common/src/services/oauth/utils.ts new file mode 100644 index 0000000..bd4877c --- /dev/null +++ b/packages/hoppscotch-common/src/services/oauth/utils.ts @@ -0,0 +1,119 @@ +import * as E from "fp-ts/Either" +import { z } from "zod" +import { getService } from "~/modules/dioc" +import { KernelInterceptorService } from "~/services/kernel-interceptor.service" +import { content } from "@hoppscotch/kernel" +import { decodeResponseAsJSON } from "./oauth.service" +import { OAuth2AdvancedParam } from "@hoppscotch/data" + +const interceptorService = getService(KernelInterceptorService) + +// Type definition for refresh request parameters +export type RefreshRequestParam = { + id: number + key: string + value: string + active: boolean + sendIn?: "headers" | "url" | "body" +} + +// Unified refresh token parameters for all OAuth flows +export type RefreshTokenParams = { + tokenEndpoint: string + clientID: string + refreshToken: string + clientSecret?: string + refreshRequestParams?: Array +} + +/** + * Unified refresh token function for all OAuth flows + * Supports both basic flows (authCode) and advanced flows (password, clientCredentials) + * with optional advanced parameters + */ +export const refreshToken = async ({ + tokenEndpoint, + clientID, + refreshToken, + clientSecret, + refreshRequestParams, +}: RefreshTokenParams) => { + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + } + + const bodyParams: Record = { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientID, + ...(clientSecret && { + client_secret: clientSecret, + }), + } + + const urlParams: Record = {} + + // Process additional refresh request parameters (if provided) + if (refreshRequestParams) { + refreshRequestParams + .filter((param) => param.active && param.key && param.value) + .forEach((param) => { + if (param.sendIn === "headers") { + headers[param.key] = param.value + } else if (param.sendIn === "url") { + urlParams[param.key] = param.value + } else { + // Default to body + bodyParams[param.key] = param.value + } + }) + } + + const url = new URL(tokenEndpoint) + Object.entries(urlParams).forEach(([key, value]) => { + url.searchParams.set(key, value) + }) + + const { response } = interceptorService.execute({ + id: Date.now(), + url: url.toString(), + method: "POST", + version: "HTTP/1.1", + headers, + content: content.urlencoded(bodyParams), + }) + + const res = await response + + if (E.isLeft(res)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const responsePayload = decodeResponseAsJSON(res.right) + + if (E.isLeft(responsePayload)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const withAccessTokenAndRefreshTokenSchema = z.object({ + access_token: z.string(), + refresh_token: z.string().optional(), + }) + + const parsedTokenResponse = withAccessTokenAndRefreshTokenSchema.safeParse( + responsePayload.right + ) + + return parsedTokenResponse.success + ? E.right(parsedTokenResponse.data) + : E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) +} + +/** + * Common OAuth2 parameter schema + * Used for all OAuth parameter types - omit sendIn field where not needed + */ +export const OAuth2ParamSchema = OAuth2AdvancedParam.extend({ + sendIn: z.enum(["headers", "url", "body"]).optional(), +}) diff --git a/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts b/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts new file mode 100644 index 0000000..e4ee651 --- /dev/null +++ b/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts @@ -0,0 +1,276 @@ +import { + CollectionSchemaVersion, + Environment, + GlobalEnvironment, + HoppCollection, + RESTReqSchemaVersion, +} from "@hoppscotch/data" + +import { HoppGQLDocument } from "~/helpers/graphql/document" +import { HoppRequestDocument } from "~/helpers/rest/document" +import { GQLHistoryEntry, RESTHistoryEntry } from "~/newstore/history" +import { SettingsDef, getDefaultSettings } from "~/newstore/settings" +import { SecretVariable } from "~/services/secret-environment.service" +import { PersistableTabState } from "~/services/tab" + +type VUEX_DATA = { + postwoman: { + settings?: SettingsDef + collections?: HoppCollection[] + collectionsGraphql?: HoppCollection[] + environments?: Environment[] + } +} + +const DEFAULT_SETTINGS = getDefaultSettings() + +export const REST_COLLECTIONS_MOCK: HoppCollection[] = [ + { + v: CollectionSchemaVersion, + name: "Echo", + requests: [ + { + v: RESTReqSchemaVersion, + name: "Echo test", + method: "GET", + endpoint: "https://echo.hoppscotch.io", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + auth: { + authType: "none", + authActive: true, + }, + body: { + contentType: null, + body: null, + }, + requestVariables: [], + responses: {}, + description: null, + }, + ], + auth: { authType: "none", authActive: true }, + headers: [], + variables: [], + description: null, + preRequestScript: "", + testScript: "", + folders: [], + }, +] + +export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [ + { + v: CollectionSchemaVersion, + name: "Echo", + requests: [ + { + v: 9, + name: "Echo test", + url: "https://echo.hoppscotch.io/graphql", + headers: [], + query: "query Request { url }", + variables: '{\n "id": "1"\n}', + auth: { + authType: "none", + authActive: true, + }, + }, + ], + auth: { authType: "none", authActive: true }, + headers: [], + variables: [], + description: null, + preRequestScript: "", + testScript: "", + folders: [], + }, +] + +export const ENVIRONMENTS_MOCK: Environment[] = [ + { + v: 2, + id: "ENV_1", + name: "globals", + variables: [ + { + key: "test-global-key", + initialValue: "test-global-value", + currentValue: "test-global-value", + secret: false, + }, + ], + }, + { + v: 2, + id: "ENV_2", + name: "Test", + variables: [ + { + key: "test-key", + initialValue: "test-value", + currentValue: "test-value", + secret: false, + }, + ], + }, +] + +export const SELECTED_ENV_INDEX_MOCK = { + type: "MY_ENV", + index: 1, +} + +export const WEBSOCKET_REQUEST_MOCK = { + endpoint: "wss://echo-websocket.hoppscotch.io", + protocols: [], +} + +export const SOCKET_IO_REQUEST_MOCK = { + endpoint: "wss://echo-socketio.hoppscotch.io", + path: "/socket.io", + version: "v4", +} + +export const SSE_REQUEST_MOCK = { + endpoint: "https://express-eventsource.herokuapp.com/events", + eventType: "data", +} + +export const MQTT_REQUEST_MOCK = { + endpoint: "wss://test.mosquitto.org:8081", + clientID: "hoppscotch", +} + +export const GLOBAL_ENV_MOCK: GlobalEnvironment = { + v: 2, + variables: [ + { + key: "test-key", + currentValue: "test-value", + initialValue: "test-value", + secret: false, + }, + ], +} + +export const VUEX_DATA_MOCK: VUEX_DATA = { + postwoman: { + settings: { ...DEFAULT_SETTINGS, THEME_COLOR: "purple" }, + collections: REST_COLLECTIONS_MOCK, + collectionsGraphql: GQL_COLLECTIONS_MOCK, + environments: ENVIRONMENTS_MOCK, + }, +} + +export const REST_HISTORY_MOCK: RESTHistoryEntry[] = [ + { + v: 1, + request: { + auth: { authType: "none", authActive: true }, + body: { contentType: null, body: null }, + endpoint: "https://echo.hoppscotch.io", + headers: [], + method: "GET", + name: "Untitled", + params: [], + preRequestScript: "", + testScript: "", + requestVariables: [], + v: RESTReqSchemaVersion, + responses: {}, + description: null, + }, + responseMeta: { duration: 807, statusCode: 200 }, + star: false, + updatedOn: new Date("2023-11-07T05:27:32.951Z"), + }, +] + +export const GQL_HISTORY_MOCK: GQLHistoryEntry[] = [ + { + v: 1, + request: { + v: 9, + name: "Untitled", + url: "https://echo.hoppscotch.io/graphql", + query: "query Request { url }", + headers: [], + variables: "", + auth: { authType: "none", authActive: true }, + }, + response: '{"data":{"url":"/graphql"}}', + star: false, + updatedOn: new Date("2023-11-07T05:28:21.073Z"), + }, +] + +export const GQL_TAB_STATE_MOCK: PersistableTabState = { + lastActiveTabID: "5edbe8d4-65c9-4381-9354-5f1bf05d8ccc", + orderedDocs: [ + { + tabID: "5edbe8d4-65c9-4381-9354-5f1bf05d8ccc", + doc: { + request: { + v: 9, + name: "Untitled", + url: "https://echo.hoppscotch.io/graphql", + headers: [], + variables: '{\n "id": "1"\n}', + query: "query Request { url }", + auth: { authType: "none", authActive: true }, + }, + isDirty: true, + optionTabPreference: "query", + response: null, + }, + }, + ], +} + +export const REST_TAB_STATE_MOCK: PersistableTabState = { + lastActiveTabID: "e6e8d800-caa8-44a2-a6a6-b4765a3167aa", + orderedDocs: [ + { + tabID: "e6e8d800-caa8-44a2-a6a6-b4765a3167aa", + doc: { + request: { + v: RESTReqSchemaVersion, + endpoint: "https://echo.hoppscotch.io", + name: "Echo test", + params: [], + headers: [], + method: "GET", + auth: { authType: "none", authActive: true }, + preRequestScript: "", + testScript: "", + body: { contentType: null, body: null }, + requestVariables: [], + responses: {}, + description: null, + _ref_id: "req_ref_id", + }, + isDirty: false, + type: "request", + saveContext: { + originLocation: "user-collection", + folderPath: "0", + requestIndex: 0, + }, + response: null, + }, + }, + ], +} + +export const SECRET_ENVIRONMENTS_MOCK: Record = { + clryz7ir7002al4162bsj0azg: [ + { + key: "test-key", + value: "test-value", + varIndex: 1, + }, + ], +} diff --git a/packages/hoppscotch-common/src/services/persistence/__tests__/index.spec.ts b/packages/hoppscotch-common/src/services/persistence/__tests__/index.spec.ts new file mode 100644 index 0000000..f69f5af --- /dev/null +++ b/packages/hoppscotch-common/src/services/persistence/__tests__/index.spec.ts @@ -0,0 +1,2002 @@ +/* eslint-disable no-restricted-globals, no-restricted-syntax */ + +import * as E from "fp-ts/Either" + +import { + translateToNewGQLCollection, + translateToNewRESTCollection, +} from "@hoppscotch/data" +import { watchDebounced } from "@vueuse/core" +import { TestContainer } from "dioc/testing" +import { cloneDeep } from "lodash-es" +import superjson from "superjson" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { getKernelMode, initKernel } from "@hoppscotch/kernel" +import { Store } from "~/kernel" +import { MQTTRequest$, setMQTTRequest } from "~/newstore/MQTTSession" +import { SSERequest$, setSSERequest } from "~/newstore/SSESession" +import { SIORequest$, setSIORequest } from "~/newstore/SocketIOSession" +import { WSRequest$, setWSRequest } from "~/newstore/WebSocketSession" +import { + graphqlCollectionStore, + restCollectionStore, + setGraphqlCollections, + setRESTCollections, +} from "~/newstore/collections" +import { + addGlobalEnvVariable, + environments$, + globalEnv$, + replaceEnvironments, + selectedEnvironmentIndex$, + setGlobalEnvVariables, + setSelectedEnvironmentIndex, +} from "~/newstore/environments" +import { + graphqlHistoryStore, + restHistoryStore, + setGraphqlHistoryEntries, + setRESTHistoryEntries, + translateToNewGQLHistory, + translateToNewRESTHistory, +} from "~/newstore/history" +import { bulkApplyLocalState, localStateStore } from "~/newstore/localstate" +import { + applySetting, + bulkApplySettings, + performSettingsDataMigrations, + settingsStore, +} from "~/newstore/settings" +import { SecretEnvironmentService } from "~/services/secret-environment.service" +import { GQLTabService } from "~/services/tab/graphql" +import { RESTTabService } from "~/services/tab/rest" +import { + PersistenceService, + STORE_KEYS, + STORE_NAMESPACE, +} from "../../persistence" +import { + ENVIRONMENTS_MOCK, + GLOBAL_ENV_MOCK, + GQL_COLLECTIONS_MOCK, + GQL_HISTORY_MOCK, + GQL_TAB_STATE_MOCK, + MQTT_REQUEST_MOCK, + REST_COLLECTIONS_MOCK, + REST_HISTORY_MOCK, + REST_TAB_STATE_MOCK, + SECRET_ENVIRONMENTS_MOCK, + SELECTED_ENV_INDEX_MOCK, + SOCKET_IO_REQUEST_MOCK, + SSE_REQUEST_MOCK, + VUEX_DATA_MOCK, + WEBSOCKET_REQUEST_MOCK, +} from "./__mocks__" + +initKernel(getKernelMode()) + +vi.mock("~/modules/i18n", () => { + return { + __esModule: true, + getI18n: vi.fn().mockImplementation(() => () => "test"), + } +}) + +// Define modules that are shared across methods here +vi.mock("@vueuse/core", async (importOriginal) => { + const actualModule: Record = await importOriginal() + + return { + ...actualModule, + watchDebounced: vi.fn(), + } +}) + +vi.mock("~/newstore/environments", () => { + return { + addGlobalEnvVariable: vi.fn(), + setGlobalEnvVariables: vi.fn(), + replaceEnvironments: vi.fn(), + setSelectedEnvironmentIndex: vi.fn(), + environments$: { + subscribe: vi.fn(), + }, + globalEnv$: { + subscribe: vi.fn(), + }, + selectedEnvironmentIndex$: { + subscribe: vi.fn(), + }, + } +}) + +vi.mock("~/newstore/settings", () => { + return { + applySetting: vi.fn(), + } +}) + +const toastErrorFn = vi.fn() + +vi.mock("~/composables/toast", () => { + return { + useToast: () => ({ error: toastErrorFn }), + } +}) + +/** + * Helper functions + */ +const spyOnGetItem = () => vi.spyOn(Storage.prototype, "getItem") +const spyOnRemoveItem = () => vi.spyOn(Storage.prototype, "removeItem") +const spyOnSetItem = () => vi.spyOn(Storage.prototype, "setItem") + +const schemaVersionKey = `${STORE_NAMESPACE}:${STORE_KEYS.SCHEMA_VERSION}` + +const getStoreItem = async (key: string) => { + const storedData = window.localStorage.getItem(key) + + if (!storedData) return + + const parsedData = superjson.parse<{ data: string }>(storedData) + + return JSON.stringify(parsedData.data) +} + +const setStoreItem = async (key: string, value: T) => { + const storedData = { + schemaVersion: 1, + metadata: { + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + namespace: STORE_NAMESPACE, + encrypted: false, + compressed: false, + }, + data: value, + } + + window.localStorage.setItem(key, superjson.stringify(storedData)) +} + +const bindPersistenceService = ({ + mockGQLTabService = false, + mockRESTTabService = false, + mockSecretEnvironmentsService = false, + mock = {}, +}: { + mockGQLTabService?: boolean + mockRESTTabService?: boolean + mockSecretEnvironmentsService?: boolean + mock?: Record +} = {}) => { + const container = new TestContainer() + + if (mockGQLTabService) { + container.bindMock(GQLTabService, mock) + } + + if (mockRESTTabService) { + container.bindMock(RESTTabService, mock) + } + + if (mockSecretEnvironmentsService) { + container.bindMock(SecretEnvironmentService, mock) + } + + container.bind(PersistenceService) + + const service = container.bind(PersistenceService) + return service +} + +const invokeSetupLocalPersistence = async ( + serviceBindMock?: Record +) => { + const service = bindPersistenceService(serviceBindMock) + await service.setupFirst() + await service.setupLater() +} + +describe("PersistenceService", () => { + beforeEach(() => { + window.localStorage.clear() + }) + + beforeEach(async () => { + await Store.remove(STORE_NAMESPACE, STORE_KEYS.SCHEMA_VERSION) + }) + + afterEach(() => { + // Clear all mocks + vi.clearAllMocks() + + // Restore the original implementation for any spied functions + vi.restoreAllMocks() + }) + + describe("setup", () => { + describe("Check and migrate old settings", () => { + // Set of keys read from localStorage across test cases + const bgColorKey = "BG_COLOR" + const nuxtColorModeKey = "nuxt-color-mode" + const selectedEnvIndexKey = "selectedEnvIndex" + const themeColorKey = "THEME_COLOR" + const vuexKey = "vuex" + const storagePrefix = "persistence.v1:" + + beforeEach(() => { + window.localStorage.clear() + }) + + beforeEach(async () => { + await Store.remove(STORE_NAMESPACE, STORE_KEYS.SCHEMA_VERSION) + }) + + it(`sets the selected environment index type as "NO_ENV" in localStorage if the value retrieved for ${selectedEnvIndexKey} is "-1"`, async () => { + window.localStorage.setItem(selectedEnvIndexKey, "-1") + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(selectedEnvIndexKey) + expect(setItemSpy).toHaveBeenCalledWith( + `${storagePrefix}selectedEnv`, + expect.stringContaining( + JSON.stringify({ + type: "NO_ENV_SELECTED", + }) + ) + ) + }) + + it(`sets the selected environment index type as "MY_ENV" in localStorage if the value retrieved for "${selectedEnvIndexKey}" is greater than "0"`, async () => { + window.localStorage.setItem(selectedEnvIndexKey, "1") + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(selectedEnvIndexKey) + expect(setItemSpy).toHaveBeenCalledWith( + `${storagePrefix}selectedEnv`, + expect.stringContaining( + JSON.stringify({ + type: "MY_ENV", + index: 1, + }) + ) + ) + }) + + it(`skips schema parsing and setting other properties if ${vuexKey} read from localStorage is an empty entity`, async () => { + window.localStorage.setItem(vuexKey, JSON.stringify({})) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(vuexKey) + expect(toastErrorFn).not.toHaveBeenCalled() + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + `${storagePrefix}schema_version`, + expect.stringMatching(/"schemaVersion":1/) + ) + }) + + it(`shows an error and sets the entry as a backup in localStorage if "${vuexKey}" read from localStorage doesn't match the schema`, async () => { + // Invalid shape for `vuex` + // `postwoman.settings.CURRENT_KERNEL_INTERCEPTOR_ID` -> should be `string`, not `number` + const vuexData = { + ...VUEX_DATA_MOCK, + postwoman: { + ...VUEX_DATA_MOCK.postwoman, + settings: { + ...VUEX_DATA_MOCK.postwoman.settings, + CURRENT_KERNEL_INTERCEPTOR_ID: 1234, + }, + }, + } + + window.localStorage.setItem(vuexKey, JSON.stringify(vuexData)) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(vuexKey) + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(vuexKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${vuexKey}-backup`, + expect.stringContaining(JSON.stringify(vuexData)) + ) + }) + + it(`shows an error and sets the entry as a backup in localStorage if "${themeColorKey}" read from localStorage doesn't match the schema`, async () => { + const vuexData = cloneDeep(VUEX_DATA_MOCK) + window.localStorage.setItem(vuexKey, JSON.stringify(vuexData)) + + const themeColorValue = "invalid-color" + window.localStorage.setItem(themeColorKey, themeColorValue) + + const getItemSpy = spyOnGetItem() + const removeItemSpy = spyOnRemoveItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(vuexKey) + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(themeColorKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${themeColorKey}-backup`, + themeColorValue + ) + expect(applySetting).not.toHaveBeenCalledWith( + themeColorKey, + themeColorValue + ) + expect(removeItemSpy).toHaveBeenCalledWith(themeColorKey) + }) + + it(`shows an error and sets the entry as a backup in localStorage if "${nuxtColorModeKey}" read from localStorage doesn't match the schema`, async () => { + const vuexData = cloneDeep(VUEX_DATA_MOCK) + window.localStorage.setItem(vuexKey, JSON.stringify(vuexData)) + + const nuxtColorModeValue = "invalid-color" + window.localStorage.setItem(nuxtColorModeKey, nuxtColorModeValue) + + const getItemSpy = spyOnGetItem() + const removeItemSpy = spyOnRemoveItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(vuexKey) + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(nuxtColorModeKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${nuxtColorModeKey}-backup`, + nuxtColorModeValue + ) + expect(applySetting).not.toHaveBeenCalledWith( + bgColorKey, + nuxtColorModeValue + ) + expect(removeItemSpy).toHaveBeenCalledWith(nuxtColorModeKey) + }) + + it(`extracts individual properties from the key "${vuexKey}" and sets them in localStorage`, async () => { + const vuexData = cloneDeep(VUEX_DATA_MOCK) + window.localStorage.setItem(vuexKey, JSON.stringify(vuexData)) + + const themeColor = "red" + const nuxtColorMode = "dark" + + window.localStorage.setItem(themeColorKey, themeColor) + window.localStorage.setItem(nuxtColorModeKey, nuxtColorMode) + + const getItemSpy = spyOnGetItem() + const removeItemSpy = spyOnRemoveItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(vuexKey) + expect(toastErrorFn).not.toHaveBeenCalledWith(nuxtColorModeKey) + expect(setItemSpy).not.toHaveBeenCalledWith( + `${nuxtColorModeKey}-backup` + ) + + // Check settings is saved with new format + expect(setItemSpy).toHaveBeenCalledWith( + `${storagePrefix}settings`, + expect.stringContaining(JSON.stringify(vuexData.postwoman.settings)) + ) + + const { postwoman } = vuexData + delete postwoman.settings + + // Check collections is saved with new format + expect(setItemSpy).toHaveBeenCalledWith( + `${storagePrefix}restCollections`, + expect.stringContaining(JSON.stringify(postwoman.collections)) + ) + + delete postwoman.collections + + // Check graphql collections is saved with new format + expect(setItemSpy).toHaveBeenCalledWith( + `${storagePrefix}gqlCollections`, + expect.stringContaining(JSON.stringify(postwoman.collectionsGraphql)) + ) + + delete postwoman.collectionsGraphql + + // Check environments is saved with new format + expect(setItemSpy).toHaveBeenCalledWith( + `${storagePrefix}environments`, + expect.stringContaining(JSON.stringify(postwoman.environments)) + ) + + delete postwoman.environments + + // Check theme color handling + expect(getItemSpy).toHaveBeenCalledWith(themeColorKey) + expect(applySetting).toHaveBeenCalledWith(themeColorKey, themeColor) + expect(removeItemSpy).toHaveBeenCalledWith(themeColorKey) + expect(window.localStorage.getItem(themeColorKey)).toBe(null) + + // Check color mode handling + expect(getItemSpy).toHaveBeenCalledWith(nuxtColorModeKey) + expect(applySetting).toHaveBeenCalledWith(bgColorKey, nuxtColorMode) + expect(removeItemSpy).toHaveBeenCalledWith(nuxtColorModeKey) + expect(window.localStorage.getItem(nuxtColorModeKey)).toBe(null) + }) + }) + + describe("Setup local state persistence", () => { + // Key read from localStorage across test cases + const localStateKey = `${STORE_NAMESPACE}:${STORE_KEYS.LOCAL_STATE}` + + it(`shows an error and sets the entry as a backup in localStorage if "${localStateKey}" read from localStorage has a value which is not a "string" or "undefined"`, async () => { + const localStateData = { + REMEMBERED_TEAM_ID: null, + } + await setStoreItem(localStateKey, localStateData) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(localStateKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(localStateKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${localStateKey}-backup`, + expect.stringContaining(JSON.stringify(localStateData)) + ) + }) + + it(`shows an error and sets the entry as a backup in localStorage if "${localStateKey}" read from localStorage has an invalid key`, async () => { + const localStateData = { + INVALID_KEY: null, + } + await setStoreItem(localStateKey, localStateData) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(localStateKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(localStateKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${localStateKey}-backup`, + expect.stringContaining(JSON.stringify(localStateData)) + ) + }) + + it(`schema parsing succeeds if there is no "${localStateKey}" key present in localStorage where the fallback of "{}" is chosen`, async () => { + window.localStorage.removeItem(localStateKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(localStateKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(localStateKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringMatching(/"schemaVersion":1/) + ) + }) + + it(`reads the value for "${localStateKey}" key from localStorage, invokes "bulkApplyLocalState" function if a value is yielded and subscribes to "localStateStore" updates`, async () => { + vi.mock("~/newstore/localstate", () => { + return { + bulkApplyLocalState: vi.fn(), + localStateStore: { + subject$: { + subscribe: vi.fn(), + }, + }, + } + }) + + const localStateData = { + REMEMBERED_TEAM_ID: "test-id", + } + await setStoreItem(localStateKey, localStateData) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(localStateKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(localStateKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringMatching(/"schemaVersion":1/) + ) + + expect(bulkApplyLocalState).toHaveBeenCalledWith(localStateData) + expect(localStateStore.subject$.subscribe).toHaveBeenCalledWith( + expect.any(Function) + ) + }) + }) + + describe("setup settings persistence", () => { + // Key read from localStorage across test cases + const settingsKey = `${STORE_NAMESPACE}:${STORE_KEYS.SETTINGS}` + + it(`shows an error and sets the entry as a backup in localStorage if "${settingsKey}" read from localStorage doesn't match the schema`, async () => { + // Invalid shape for `settings` + // Expected values are booleans + const settings = { + EXTENSIONS_ENABLED: "true", + PROXY_ENABLED: "true", + } + await setStoreItem(settingsKey, settings) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(settingsKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(settingsKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${settingsKey}-backup`, + expect.stringContaining(JSON.stringify(settings)) + ) + }) + + it(`schema parsing succeeds if there is no "${settingsKey}" key present in localStorage where the fallback of "{}" is chosen`, async () => { + window.localStorage.removeItem(settingsKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(settingsKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(settingsKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringMatching(/"schemaVersion":1/) + ) + }) + + it(`reads the value for "${settingsKey}" from localStorage, invokes "performSettingsDataMigrations" and "bulkApplySettings" functions as required and subscribes to "settingsStore" updates`, async () => { + vi.mock("~/newstore/settings", async (importOriginal) => { + const actualModule: Record = await importOriginal() + + return { + ...actualModule, + applySetting: vi.fn(), + bulkApplySettings: vi.fn(), + performSettingsDataMigrations: vi + .fn() + .mockImplementation((data: any) => data), + settingsStore: { + subject$: { + subscribe: vi.fn(), + }, + }, + } + }) + + const { settings } = VUEX_DATA_MOCK.postwoman + await setStoreItem(settingsKey, settings) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(settingsKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(settingsKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringMatching(/"schemaVersion":1/) + ) + + expect(performSettingsDataMigrations).toHaveBeenCalledWith(settings) + expect(bulkApplySettings).toHaveBeenCalledWith(settings) + + expect(settingsStore.subject$.subscribe).toHaveBeenCalledWith( + expect.any(Function) + ) + }) + }) + + describe("setup history persistence", () => { + // Keys read from localStorage across test cases + const historyKey = `${STORE_NAMESPACE}:${STORE_KEYS.REST_HISTORY}` + const graphqlHistoryKey = `${STORE_NAMESPACE}:${STORE_KEYS.GQL_HISTORY}` + + it(`shows an error and sets the entry as a backup in localStorage if "${historyKey}" read from localStorage doesn't match the schema`, async () => { + // Invalid shape for `history` + // `v` -> `number` + const restHistoryData = [{ ...REST_HISTORY_MOCK, v: "1" }] + await setStoreItem(historyKey, restHistoryData) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(historyKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(historyKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${historyKey}-backup`, + expect.stringContaining(JSON.stringify(restHistoryData)) + ) + }) + + it(`REST history schema parsing succeeds if there is no "${historyKey}" key present in localStorage where the fallback of "[]" is chosen`, async () => { + window.localStorage.removeItem(historyKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(historyKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(historyKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringMatching(/"schemaVersion":1/) + ) + }) + + it(`shows an error and sets the entry as a backup in localStorage if "${graphqlHistoryKey}" read from localStorage doesn't match the schema`, async () => { + // Invalid shape for `graphqlHistory` + // `v` -> `number` + const graphqlHistoryData = [{ ...GQL_HISTORY_MOCK, v: "1" }] + await setStoreItem(graphqlHistoryKey, graphqlHistoryData) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(graphqlHistoryKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(graphqlHistoryKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${graphqlHistoryKey}-backup`, + expect.stringContaining(JSON.stringify(graphqlHistoryData)) + ) + }) + + it(`v=2 migration repairs entries in "${graphqlHistoryKey}" whose response field is a non-string and writes a pre-v2 backup`, async () => { + // Pre-fix sync writes round-tripped via JSON.stringify/parse, + // leaving entries with object-shaped response in localStorage. + const corruptedEntries = [ + { ...GQL_HISTORY_MOCK[0], response: {} }, + { ...GQL_HISTORY_MOCK[0], response: null }, + ] + await setStoreItem(graphqlHistoryKey, corruptedEntries) + + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + // Original is preserved at the pre-v2 backup key for recovery. + expect(setItemSpy).toHaveBeenCalledWith( + `${graphqlHistoryKey}-pre-v2-backup`, + expect.stringContaining(JSON.stringify(corruptedEntries)) + ) + + // Repaired data is written back to the live key with response coerced. + // - Object response {} stringifies to "{}", which appears in the + // serialized payload as `"response":"{}"`. + // - Null response stringifies to "null", which appears in the + // serialized payload as `"response":"null"` and preserves the + // original semantic of an empty payload. + expect(setItemSpy).toHaveBeenCalledWith( + graphqlHistoryKey, + expect.stringContaining('"response":"{}"') + ) + expect(setItemSpy).toHaveBeenCalledWith( + graphqlHistoryKey, + expect.stringContaining('"response":"null"') + ) + + // Schema version bumps to 2, so the migration won't run again. + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringMatching(/"data":"2"/) + ) + + // No Zod-failure backup since the migration repaired the shape + // before validation could reject it. + expect(toastErrorFn).not.toHaveBeenCalledWith( + expect.stringContaining(graphqlHistoryKey) + ) + }) + + it(`v=2 migration is a no-op when "${graphqlHistoryKey}" entries already have string responses`, async () => { + // Clean entries — response is already a string per the contract. + await setStoreItem(graphqlHistoryKey, GQL_HISTORY_MOCK) + + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + // No backup write since needsRepair was false. + expect(setItemSpy).not.toHaveBeenCalledWith( + `${graphqlHistoryKey}-pre-v2-backup`, + expect.anything() + ) + + // Schema version still bumps to 2 so the migration is recorded as run. + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringMatching(/"data":"2"/) + ) + }) + + it(`GQL history schema parsing succeeds if there is no "${graphqlHistoryKey}" key present in localStorage where the fallback of "[]" is chosen`, async () => { + window.localStorage.removeItem(graphqlHistoryKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(graphqlHistoryKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(graphqlHistoryKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringMatching(/"schemaVersion":1/) + ) + }) + + it("reads REST and GQL history entries from localStorage, translates them to the new format, writes back the updates and subscribes to the respective store for updates", async () => { + vi.mock("~/newstore/history", () => { + return { + setGraphqlHistoryEntries: vi.fn(), + setRESTHistoryEntries: vi.fn(), + translateToNewGQLHistory: vi + .fn() + .mockImplementation((data: any) => data), + translateToNewRESTHistory: vi + .fn() + .mockImplementation((data: any) => data), + graphqlHistoryStore: { + subject$: { + subscribe: vi.fn(), + }, + }, + restHistoryStore: { + subject$: { + subscribe: vi.fn(), + }, + }, + } + }) + + const restHistory = REST_HISTORY_MOCK + const gqlHistory = GQL_HISTORY_MOCK + + await setStoreItem(historyKey, restHistory) + await setStoreItem(graphqlHistoryKey, gqlHistory) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(historyKey) + expect(getItemSpy).toHaveBeenCalledWith(graphqlHistoryKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith( + historyKey, + graphqlHistoryKey + ) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringMatching(/"schemaVersion":1/) + ) + + expect(translateToNewRESTHistory).toHaveBeenCalled() + expect(translateToNewGQLHistory).toHaveBeenCalled() + + expect(setRESTHistoryEntries).toHaveBeenCalledWith(restHistory) + expect(setGraphqlHistoryEntries).toHaveBeenCalledWith(gqlHistory) + + expect(restHistoryStore.subject$.subscribe).toHaveBeenCalledWith( + expect.any(Function) + ) + expect(graphqlHistoryStore.subject$.subscribe).toHaveBeenCalledWith( + expect.any(Function) + ) + }) + }) + + describe("setup collections persistence", () => { + // Keys read from localStorage across test cases + const collectionsRESTKey = `${STORE_NAMESPACE}:${STORE_KEYS.REST_COLLECTIONS}` + const collectionsGraphqlKey = `${STORE_NAMESPACE}:${STORE_KEYS.GQL_COLLECTIONS}` + + it(`shows an error and sets the entry as a backup in localStorage if "${collectionsRESTKey}" read from localStorage doesn't match the schema`, async () => { + // Invalid shape for `collections` + // `v` -> `number` + const restCollectionsData = [{ ...REST_COLLECTIONS_MOCK, v: "1" }] + await setStoreItem(collectionsRESTKey, restCollectionsData) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(collectionsRESTKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(collectionsRESTKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${collectionsRESTKey}-backup`, + expect.stringContaining(JSON.stringify(restCollectionsData)) + ) + }) + + it(`REST collections schema parsing succeeds if there is no "${collectionsRESTKey}" key present in localStorage where the fallback of "[]" is chosen`, async () => { + window.localStorage.removeItem(collectionsRESTKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(collectionsRESTKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(collectionsRESTKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + }) + + it(`shows an error and sets the entry as a backup in localStorage if "${collectionsGraphqlKey}" read from localStorage doesn't match the schema`, async () => { + // Invalid shape for `collectionsGraphql` + // `v` -> `number` + const graphqlCollectionsData = [{ ...GQL_COLLECTIONS_MOCK, v: "1" }] + await setStoreItem(collectionsGraphqlKey, graphqlCollectionsData) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(collectionsGraphqlKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(collectionsGraphqlKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${collectionsGraphqlKey}-backup`, + expect.stringContaining(JSON.stringify(graphqlCollectionsData)) + ) + }) + + it(`GQL collections schema parsing succeeds if there is no "${collectionsGraphqlKey}" key present in localStorage where the fallback of "[]" is chosen`, async () => { + window.localStorage.removeItem(collectionsGraphqlKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(collectionsGraphqlKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(collectionsGraphqlKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + }) + + it("reads REST and GQL collection entries from localStorage, translates them to the new format, writes back the updates and subscribes to the respective store for updates", async () => { + vi.mock("@hoppscotch/data", async (importOriginal) => { + const actualModule: Record = await importOriginal() + + return { + ...actualModule, + translateToNewGQLCollection: vi + .fn() + .mockImplementation((data: any) => data), + translateToNewRESTCollection: vi + .fn() + .mockImplementation((data: any) => data), + } + }) + + vi.mock("~/newstore/collections", () => { + return { + setGraphqlCollections: vi.fn(), + setRESTCollections: vi.fn(), + graphqlCollectionStore: { + subject$: { + subscribe: vi.fn(), + }, + }, + restCollectionStore: { + subject$: { + subscribe: vi.fn(), + }, + }, + } + }) + + const restCollections = REST_COLLECTIONS_MOCK + const gqlCollections = GQL_COLLECTIONS_MOCK + + await setStoreItem(collectionsRESTKey, restCollections) + await setStoreItem(collectionsGraphqlKey, gqlCollections) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(collectionsRESTKey) + expect(getItemSpy).toHaveBeenCalledWith(collectionsGraphqlKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith( + collectionsRESTKey, + collectionsGraphqlKey + ) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + + expect(translateToNewGQLCollection).toHaveBeenCalled() + expect(translateToNewRESTCollection).toHaveBeenCalled() + + expect(setRESTCollections).toHaveBeenCalledWith(restCollections) + expect(setGraphqlCollections).toHaveBeenCalledWith(gqlCollections) + + expect(graphqlCollectionStore.subject$.subscribe).toHaveBeenCalledWith( + expect.any(Function) + ) + expect(restCollectionStore.subject$.subscribe).toHaveBeenCalledWith( + expect.any(Function) + ) + }) + }) + + describe("setup environments persistence", () => { + // Key read from localStorage across test cases + const environmentsKey = `${STORE_NAMESPACE}:${STORE_KEYS.ENVIRONMENTS}` + + it(`shows an error and sets the entry as a backup in localStorage if "${environmentsKey}" read from localStorage doesn't match the schema`, async () => { + // Invalid shape for `environments` + const environments = [ + // `entries` -> `variables` + // no name for the environment + { + v: 1, + id: "ENV_1", + entries: [{ key: "test-key", value: "test-value", secret: false }], + }, + ] + + await setStoreItem(environmentsKey, environments) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(environmentsKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(environmentsKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${environmentsKey}-backup`, + expect.stringContaining(JSON.stringify(environments)) + ) + }) + + it(`separates "globals" entries from "${environmentsKey}", subscribes to the "environmentStore" and updates localStorage entries`, async () => { + const environments = cloneDeep(ENVIRONMENTS_MOCK) + await setStoreItem(environmentsKey, environments) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(environmentsKey) + expect(toastErrorFn).not.toHaveBeenCalledWith(environmentsKey) + expect(setItemSpy).not.toHaveBeenCalledWith(`${environmentsKey}-backup`) + + expect(addGlobalEnvVariable).toHaveBeenCalledWith( + environments[0].variables[0] + ) + + // Removes `globals` from environments + environments.splice(0, 1) + + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringMatching(/"schemaVersion":1/) + ) + expect(replaceEnvironments).toBeCalledWith(environments) + expect(environments$.subscribe).toHaveBeenCalledWith( + expect.any(Function) + ) + }) + }) + + describe("setup selected environment persistence", () => { + // Key read from localStorage across test cases + const selectedEnvIndexKey = `${STORE_NAMESPACE}:${STORE_KEYS.SELECTED_ENV}` + + it(`shows an error and sets the entry as a backup in localStorage if "${selectedEnvIndexKey}" read from localStorage doesn't match the schema`, async () => { + // Invalid shape for `selectedEnvIndex` + // `index` -> `number` + const selectedEnvIndex = { ...SELECTED_ENV_INDEX_MOCK, index: "1" } + + await setStoreItem(selectedEnvIndexKey, selectedEnvIndex) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(selectedEnvIndexKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(selectedEnvIndexKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${selectedEnvIndexKey}-backup`, + expect.stringContaining(JSON.stringify(selectedEnvIndex)) + ) + }) + + it(`schema parsing succeeds if there is no "${selectedEnvIndexKey}" key present in localStorage where the fallback of "null" is chosen`, async () => { + window.localStorage.removeItem(selectedEnvIndexKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(selectedEnvIndexKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(selectedEnvIndexKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + }) + + it(`sets it to the store if there is a value associated with the "${selectedEnvIndexKey}" key in localStorage`, async () => { + const selectedEnvIndex = SELECTED_ENV_INDEX_MOCK + + await setStoreItem(selectedEnvIndexKey, selectedEnvIndex) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(selectedEnvIndexKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(selectedEnvIndexKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + + expect(setSelectedEnvironmentIndex).toHaveBeenCalledWith( + selectedEnvIndex + ) + expect(selectedEnvironmentIndex$.subscribe).toHaveBeenCalledWith( + expect.any(Function) + ) + }) + + it(`sets it to "NO_ENV_SELECTED" if there is no value associated with the "${selectedEnvIndexKey}" in localStorage`, async () => { + window.localStorage.removeItem(selectedEnvIndexKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(selectedEnvIndexKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(selectedEnvIndexKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + + expect(setSelectedEnvironmentIndex).toHaveBeenCalledWith({ + type: "NO_ENV_SELECTED", + }) + expect(selectedEnvironmentIndex$.subscribe).toHaveBeenCalledWith( + expect.any(Function) + ) + }) + }) + + describe("setup secret Environments persistence", () => { + // Key read from localStorage across test cases + const secretEnvironmentsKey = `${STORE_NAMESPACE}:${STORE_KEYS.SECRET_ENVIRONMENTS}` + + const loadSecretEnvironmentsFromPersistedStateFn = vi.fn() + const mock = { + loadSecretEnvironmentsFromPersistedState: + loadSecretEnvironmentsFromPersistedStateFn, + } + + it(`shows an error and sets the entry as a backup in localStorage if "${secretEnvironmentsKey}" read from localStorage doesn't match the schema`, async () => { + // Invalid shape for `secretEnvironments` + const secretEnvironments = { + clryz7ir7002al4162bsj0azg: { + key: "ENV_KEY", + value: "ENV_VALUE", + }, + } + + await setStoreItem(secretEnvironmentsKey, secretEnvironments) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(secretEnvironmentsKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(secretEnvironmentsKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${secretEnvironmentsKey}-backup`, + expect.stringContaining(JSON.stringify(secretEnvironments)) + ) + }) + + it("loads secret environments from the state persisted in localStorage and sets watcher for `persistableSecretEnvironment`", async () => { + const secretEnvironment = SECRET_ENVIRONMENTS_MOCK + await setStoreItem(secretEnvironmentsKey, secretEnvironment) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence({ + mockSecretEnvironmentsService: true, + mock, + }) + + expect(getItemSpy).toHaveBeenCalledWith(secretEnvironmentsKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(secretEnvironmentsKey) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + + expect(loadSecretEnvironmentsFromPersistedStateFn).toHaveBeenCalledWith( + secretEnvironment + ) + expect(watchDebounced).toHaveBeenCalled() + }) + + it(`skips schema parsing and the loading of persisted secret environments if there is no "${secretEnvironmentsKey}" key present in localStorage`, async () => { + window.localStorage.removeItem(secretEnvironmentsKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence({ + mockSecretEnvironmentsService: true, + mock, + }) + + expect(getItemSpy).toHaveBeenCalledWith(secretEnvironmentsKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(secretEnvironmentsKey) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + + expect(watchDebounced).toHaveBeenCalled() + }) + + it("logs an error to the console on failing to parse persisted secret environments", async () => { + await setStoreItem(secretEnvironmentsKey, "invalid-json") + + console.error = vi.fn() + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(secretEnvironmentsKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(secretEnvironmentsKey) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + + expect(console.error).toHaveBeenCalledWith( + `Failed parsing persisted SECRET_ENVIRONMENTS:`, + await getStoreItem(secretEnvironmentsKey) + ) + + expect(watchDebounced).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Function), + { debounce: 500 } + ) + }) + }) + + describe("setup WebSocket persistence", () => { + // Key read from localStorage across test cases + const wsRequestKey = `${STORE_NAMESPACE}:${STORE_KEYS.WEBSOCKET}` + + it(`shows an error and sets the entry as a backup in localStorage if "${wsRequestKey}" read from localStorage doesn't match the schema`, async () => { + // Invalid shape for `WebsocketRequest` + const request = { + ...WEBSOCKET_REQUEST_MOCK, + // `protocols` -> `[]` + protocols: {}, + } + + await setStoreItem(wsRequestKey, request) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(wsRequestKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(wsRequestKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${wsRequestKey}-backup`, + expect.stringContaining(JSON.stringify(request)) + ) + }) + + it(`schema parsing succeeds if there is no "${wsRequestKey}" key present in localStorage where the fallback of "null" is chosen`, async () => { + window.localStorage.removeItem(wsRequestKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(wsRequestKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(wsRequestKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + }) + + it(`reads the "${wsRequestKey}" entry from localStorage, sets it as the new request, subscribes to the "WSSessionStore" and updates localStorage entries`, async () => { + vi.mock("~/newstore/WebSocketSession", () => { + return { + setWSRequest: vi.fn(), + WSRequest$: { + subscribe: vi.fn(), + }, + } + }) + + const request = WEBSOCKET_REQUEST_MOCK + await setStoreItem(wsRequestKey, request) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(wsRequestKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(wsRequestKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + + expect(setWSRequest).toHaveBeenCalledWith(request) + expect(WSRequest$.subscribe).toHaveBeenCalledWith(expect.any(Function)) + }) + }) + + describe("setup Socket.IO persistence", () => { + // Key read from localStorage across test cases + const sioRequestKey = `${STORE_NAMESPACE}:${STORE_KEYS.SOCKETIO}` + + it(`shows an error and sets the entry as a backup in localStorage if "${sioRequestKey}" read from localStorage doesn't match the schema`, async () => { + // Invalid shape for `SocketIORequest` + const request = { + ...SOCKET_IO_REQUEST_MOCK, + // `v` -> `version: v4` + v: "4", + } + + await setStoreItem(sioRequestKey, request) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(sioRequestKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(sioRequestKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${sioRequestKey}-backup`, + expect.stringContaining(JSON.stringify(request)) + ) + }) + + it(`schema parsing succeeds if there is no "${sioRequestKey}" key present in localStorage where the fallback of "null" is chosen`, async () => { + window.localStorage.removeItem(sioRequestKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(sioRequestKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(sioRequestKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + }) + + it(`reads the "${sioRequestKey}" entry from localStorage, sets it as the new request, subscribes to the "SIOSessionStore" and updates localStorage entries`, async () => { + vi.mock("~/newstore/SocketIOSession", () => { + return { + setSIORequest: vi.fn(), + SIORequest$: { + subscribe: vi.fn(), + }, + } + }) + + const request = SOCKET_IO_REQUEST_MOCK + await setStoreItem(sioRequestKey, request) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(sioRequestKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(sioRequestKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + + expect(setSIORequest).toHaveBeenCalledWith(request) + expect(SIORequest$.subscribe).toHaveBeenCalledWith(expect.any(Function)) + }) + }) + + describe("setup SSE persistence", () => { + // Key read from localStorage across test cases + const sseRequestKey = `${STORE_NAMESPACE}:${STORE_KEYS.SSE}` + + it(`shows an error and sets the entry as a backup in localStorage if "${sseRequestKey}" read from localStorage doesn't match the versioned schema`, async () => { + // Invalid shape for `SSERequest` + const request = { + ...SSE_REQUEST_MOCK, + // `url` -> `endpoint` + url: "https://express-eventsource.herokuapp.com/events", + } + + await setStoreItem(sseRequestKey, request) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(sseRequestKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(sseRequestKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${sseRequestKey}-backup`, + expect.stringContaining(JSON.stringify(request)) + ) + }) + + it(`schema parsing succeeds if there is no "${sseRequestKey}" key present in localStorage where the fallback of "null" is chosen`, async () => { + window.localStorage.removeItem(sseRequestKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(sseRequestKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(sseRequestKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + }) + + it(`reads the "${sseRequestKey}" entry from localStorage, sets it as the new request, subscribes to the "SSESessionStore" and updates localStorage entries`, async () => { + vi.mock("~/newstore/SSESession", () => { + return { + setSSERequest: vi.fn(), + SSERequest$: { + subscribe: vi.fn(), + }, + } + }) + + const request = SSE_REQUEST_MOCK + await setStoreItem(sseRequestKey, request) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(sseRequestKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(sseRequestKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + + expect(setSSERequest).toHaveBeenCalledWith(request) + expect(SSERequest$.subscribe).toHaveBeenCalledWith(expect.any(Function)) + }) + }) + + describe("setup MQTT persistence", () => { + // Key read from localStorage across test cases + const mqttRequestKey = `${STORE_NAMESPACE}:${STORE_KEYS.MQTT}` + + it(`shows an error and sets the entry as a backup in localStorage if "${mqttRequestKey}" read from localStorage doesn't match the schema`, async () => { + // Invalid shape for `MQTTRequest` + const request = { + ...MQTT_REQUEST_MOCK, + // `url` -> `endpoint` + url: "wss://test.mosquitto.org:8081", + } + + await setStoreItem(mqttRequestKey, request) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(mqttRequestKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(mqttRequestKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${mqttRequestKey}-backup`, + expect.stringContaining(JSON.stringify(request)) + ) + }) + + it(`schema parsing succeeds if there is no "${mqttRequestKey}" key present in localStorage where the fallback of "null" is chosen`, async () => { + window.localStorage.removeItem(mqttRequestKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(mqttRequestKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(mqttRequestKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + }) + + it(`reads the ${mqttRequestKey}" entry from localStorage, sets it as the new request, subscribes to the "MQTTSessionStore" and updates localStorage entries`, async () => { + vi.mock("~/newstore/MQTTSession", () => { + return { + setMQTTRequest: vi.fn(), + MQTTRequest$: { + subscribe: vi.fn(), + }, + } + }) + + const request = MQTT_REQUEST_MOCK + await setStoreItem(mqttRequestKey, request) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(mqttRequestKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(mqttRequestKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + + expect(setMQTTRequest).toHaveBeenCalledWith(request) + expect(MQTTRequest$.subscribe).toHaveBeenCalledWith( + expect.any(Function) + ) + }) + }) + + describe("setup global environments persistence", () => { + // Key read from localStorage across test cases + const globalEnvKey = `${STORE_NAMESPACE}:${STORE_KEYS.GLOBAL_ENV}` + + it(`shows an error and sets the entry as a backup in localStorage if "${globalEnvKey}" read from localStorage doesn't match the schema`, async () => { + // Invalid shape for `globalEnv` + const globalEnv = [ + { + // `key` -> `string` + key: 1, + value: "test-value", + }, + ] + + await setStoreItem(globalEnvKey, globalEnv) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(globalEnvKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(globalEnvKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${globalEnvKey}-backup`, + expect.stringContaining(JSON.stringify(globalEnv)) + ) + }) + + it(`schema parsing succeeds if there is no "${globalEnvKey}" key present in localStorage where the fallback of "[]" is chosen`, async () => { + window.localStorage.removeItem(globalEnvKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(globalEnvKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(globalEnvKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + }) + + it(`reads the "globalEnv" entry from localStorage, dispatches the new value, subscribes to the "environmentsStore" and updates localStorage entries`, async () => { + const globalEnv = GLOBAL_ENV_MOCK + await setStoreItem(globalEnvKey, globalEnv) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(globalEnvKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(globalEnvKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringContaining('"schemaVersion":1') + ) + + expect(setGlobalEnvVariables).toHaveBeenCalledWith(globalEnv) + expect(globalEnv$.subscribe).toHaveBeenCalledWith(expect.any(Function)) + }) + }) + + describe("setup GQL tabs persistence", () => { + // Key read from localStorage across test cases + const gqlTabStateKey = `${STORE_NAMESPACE}:${STORE_KEYS.GQL_TABS}` + + const loadTabsFromPersistedStateFn = vi.fn() + const mock = { loadTabsFromPersistedState: loadTabsFromPersistedStateFn } + + it(`shows an error and sets the entry as a backup in localStorage if "${gqlTabStateKey}" read from localStorage doesn't match the schema`, async () => { + // Invalid shape for `gqlTabState` + // `lastActiveTabID` -> `string` + const gqlTabState = { ...GQL_TAB_STATE_MOCK, lastActiveTabID: 1234 } + + await setStoreItem(gqlTabStateKey, gqlTabState) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(gqlTabStateKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(gqlTabStateKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${gqlTabStateKey}-backup`, + expect.stringContaining(JSON.stringify(gqlTabState)) + ) + }) + + it(`skips schema parsing and the loading of persisted tabs if there is no "${gqlTabStateKey}" key present in localStorage`, async () => { + window.localStorage.removeItem(gqlTabStateKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence({ mockGQLTabService: true, mock }) + + expect(getItemSpy).toHaveBeenCalledWith(gqlTabStateKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(gqlTabStateKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringMatching(/"schemaVersion":1/) + ) + + expect(loadTabsFromPersistedStateFn).not.toHaveBeenCalled() + + expect(watchDebounced).toHaveBeenCalled() + }) + + it("loads tabs from the state persisted in localStorage and sets watcher for `persistableTabState`", async () => { + const tabState = GQL_TAB_STATE_MOCK + await setStoreItem(gqlTabStateKey, tabState) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence({ mockGQLTabService: true, mock }) + + expect(getItemSpy).toHaveBeenCalledWith(gqlTabStateKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(gqlTabStateKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringMatching(/"schemaVersion":1/) + ) + + expect(loadTabsFromPersistedStateFn).toHaveBeenCalledWith(tabState) + expect(watchDebounced).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Function), + { debounce: 500, deep: true } + ) + }) + + it("logs an error to the console on failing to parse persisted gql tab state", async () => { + await setStoreItem(gqlTabStateKey, "invalid-json") + + console.error = vi.fn() + const getItemSpy = spyOnGetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(gqlTabStateKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(gqlTabStateKey) + + expect(console.error).toHaveBeenCalledWith( + `Failed parsing persisted GQL_TABS:`, + await getStoreItem(gqlTabStateKey) + ) + expect(watchDebounced).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Function), + { debounce: 500, deep: true } + ) + }) + }) + + describe("setup REST tabs persistence", () => { + // Key read from localStorage across test cases + const restTabStateKey = `${STORE_NAMESPACE}:${STORE_KEYS.REST_TABS}` + + const loadTabsFromPersistedStateFn = vi.fn() + const mock = { loadTabsFromPersistedState: loadTabsFromPersistedStateFn } + + it(`shows an error and sets the entry as a backup in localStorage if "${restTabStateKey}" read from localStorage doesn't match the schema`, async () => { + // Invalid shape for `restTabState` + // `lastActiveTabID` -> `string` + const restTabState = { ...REST_TAB_STATE_MOCK, lastActiveTabID: 1234 } + + await setStoreItem(restTabStateKey, restTabState) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(restTabStateKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(restTabStateKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${restTabStateKey}-backup`, + expect.stringContaining(JSON.stringify(restTabState)) + ) + }) + + it(`skips schema parsing and the loading of persisted tabs if there is no "${restTabStateKey}" key present in localStorage`, async () => { + window.localStorage.removeItem(restTabStateKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence({ mockRESTTabService: true, mock }) + + expect(getItemSpy).toHaveBeenCalledWith(restTabStateKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(restTabStateKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringMatching(/"schemaVersion":1/) + ) + expect(loadTabsFromPersistedStateFn).not.toHaveBeenCalled() + + expect(watchDebounced).toHaveBeenCalled() + }) + + it("loads tabs from the state persisted in localStorage and sets watcher for `persistableTabState`", async () => { + const tabState = REST_TAB_STATE_MOCK + await setStoreItem(restTabStateKey, tabState) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + await invokeSetupLocalPersistence({ mockRESTTabService: true, mock }) + + expect(getItemSpy).toHaveBeenCalledWith(restTabStateKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(restTabStateKey) + expect(setItemSpy).toHaveBeenCalledTimes(1) + expect(setItemSpy).toHaveBeenCalledWith( + schemaVersionKey, + expect.stringMatching(/"schemaVersion":1/) + ) + + expect(loadTabsFromPersistedStateFn).toHaveBeenCalledWith(tabState) + expect(watchDebounced).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Function), + { debounce: 500, deep: true } + ) + }) + + it("logs an error to the console on failing to parse persisted rest tab state", async () => { + await setStoreItem(restTabStateKey, "invalid-json") + + console.error = vi.fn() + const getItemSpy = spyOnGetItem() + + await invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(restTabStateKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(restTabStateKey) + + expect(console.error).toHaveBeenCalledWith( + `Failed parsing persisted REST_TABS:`, + await getStoreItem(restTabStateKey) + ) + expect(watchDebounced).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Function), + { debounce: 500, deep: true } + ) + }) + }) + }) + + it("`setLocalConfig` method sets a value in localStorage", async () => { + const testKey = "test-key" + const testValue = "test-value" + + const setItemSpy = spyOnSetItem() + + const service = bindPersistenceService() + + await service.setLocalConfig(testKey, testValue) + expect(setItemSpy).toHaveBeenCalledWith( + `${STORE_NAMESPACE}:${testKey}`, + expect.stringContaining(testValue) + ) + }) + + it("`getLocalConfig` method gets a value from localStorage", async () => { + const testKey = "test-key" + const testValue = "test-value" + + const setItemSpy = spyOnSetItem() + const getItemSpy = spyOnGetItem() + + const service = bindPersistenceService() + + await service.setLocalConfig(testKey, testValue) + const retrievedValue = await service.getLocalConfig(testKey) + + expect(setItemSpy).toHaveBeenCalledWith( + `${STORE_NAMESPACE}:${testKey}`, + expect.stringContaining(testValue) + ) + expect(getItemSpy).toHaveBeenCalledWith(`${STORE_NAMESPACE}:${testKey}`) + expect(retrievedValue).toBe(testValue) + }) + + it("`removeLocalConfig` method clears a value in localStorage", async () => { + const testKey = "test-key" + const testValue = "test-value" + + const setItemSpy = spyOnSetItem() + const removeItemSpy = spyOnRemoveItem() + + const service = bindPersistenceService() + + await service.setLocalConfig(testKey, testValue) + await service.removeLocalConfig(testKey) + + expect(setItemSpy).toHaveBeenCalledWith( + `${STORE_NAMESPACE}:${testKey}`, + expect.stringContaining(testValue) + ) + expect(removeItemSpy).toHaveBeenCalledWith(`${STORE_NAMESPACE}:${testKey}`) + }) + + it("`set` method sets a value in localStorage", async () => { + const testKey = "temp" + const testValue = "test-value" + + const setItemSpy = spyOnSetItem() + + const service = bindPersistenceService() + + await service.set(testKey, testValue) + expect(setItemSpy).toHaveBeenCalledWith( + `${STORE_NAMESPACE}:${testKey}`, + expect.stringContaining(testValue) + ) + }) + + it("`get` method gets a value from localStorage", async () => { + const testKey = "temp" + const testValue = "test-value" + + const setItemSpy = spyOnSetItem() + const getItemSpy = spyOnGetItem() + + const service = bindPersistenceService() + + await service.set(testKey, testValue) + const retrievedValue = await service.get(testKey) + + expect(setItemSpy).toHaveBeenCalledWith( + `${STORE_NAMESPACE}:${testKey}`, + expect.stringContaining(testValue) + ) + expect(getItemSpy).toHaveBeenCalledWith(`${STORE_NAMESPACE}:${testKey}`) + expect(retrievedValue).toStrictEqual(E.right(testValue)) + }) + + it("`remove` method clears a value in localStorage", async () => { + const testKey = "temp" + const testValue = "test-value" + + const setItemSpy = spyOnSetItem() + const removeItemSpy = spyOnRemoveItem() + + const service = bindPersistenceService() + + await service.set(testKey, testValue) + await service.remove(testKey) + + expect(setItemSpy).toHaveBeenCalledWith( + `${STORE_NAMESPACE}:${testKey}`, + expect.stringContaining(testValue) + ) + expect(removeItemSpy).toHaveBeenCalledWith(`${STORE_NAMESPACE}:${testKey}`) + }) +}) diff --git a/packages/hoppscotch-common/src/services/persistence/index.ts b/packages/hoppscotch-common/src/services/persistence/index.ts new file mode 100644 index 0000000..e722cb2 --- /dev/null +++ b/packages/hoppscotch-common/src/services/persistence/index.ts @@ -0,0 +1,1288 @@ +/* eslint-disable no-restricted-globals, no-restricted-syntax */ + +import * as E from "fp-ts/Either" +import { z } from "zod" + +import { Service } from "dioc" +import { StorageLike, watchDebounced } from "@vueuse/core" +import { assign, clone, isEmpty, cloneDeep } from "lodash-es" + +import { + GlobalEnvironmentVariable, + translateToNewGQLCollection, + translateToNewRESTCollection, +} from "@hoppscotch/data" + +import { StoreError } from "@hoppscotch/kernel" + +import { Store } from "~/kernel/store" +import { diag } from "~/kernel/log" +import { GQLTabService } from "~/services/tab/graphql" +import { RESTTabService } from "~/services/tab/rest" +import { + SecretEnvironmentService, + SecretVariable, +} from "../secret-environment.service" + +import { useToast } from "~/composables/toast" + +import { + graphqlCollectionStore, + restCollectionStore, + setGraphqlCollections, + setRESTCollections, +} from "../../newstore/collections" + +import { + addGlobalEnvVariable, + environments$, + globalEnv$, + replaceEnvironments, + selectedEnvironmentIndex$, + setGlobalEnvVariables, + setSelectedEnvironmentIndex, +} from "../../newstore/environments" + +import { + graphqlHistoryStore, + restHistoryStore, + setGraphqlHistoryEntries, + setRESTHistoryEntries, + translateToNewGQLHistory, + translateToNewRESTHistory, +} from "../../newstore/history" + +import { bulkApplyLocalState, localStateStore } from "../../newstore/localstate" + +import { + HoppAccentColor, + HoppBgColor, + applySetting, + bulkApplySettings, + getDefaultSettings, + performSettingsDataMigrations, + settingsStore, +} from "../../newstore/settings" + +import { MQTTRequest$, setMQTTRequest } from "../../newstore/MQTTSession" +import { SSERequest$, setSSERequest } from "../../newstore/SSESession" +import { SIORequest$, setSIORequest } from "../../newstore/SocketIOSession" +import { WSRequest$, setWSRequest } from "../../newstore/WebSocketSession" + +import { + CURRENT_ENVIRONMENT_VALUE_SCHEMA, + CURRENT_SORT_VALUES_SCHEMA, + ENVIRONMENTS_SCHEMA, + GLOBAL_ENVIRONMENT_SCHEMA, + GQL_COLLECTION_SCHEMA, + GQL_HISTORY_ENTRY_SCHEMA, + GQL_TAB_STATE_SCHEMA, + LOCAL_STATE_SCHEMA, + MQTT_REQUEST_SCHEMA, + NUXT_COLOR_MODE_SCHEMA, + REST_COLLECTION_SCHEMA, + REST_HISTORY_ENTRY_SCHEMA, + REST_TAB_STATE_SCHEMA, + SECRET_ENVIRONMENT_VARIABLE_SCHEMA, + SELECTED_ENV_INDEX_SCHEMA, + SETTINGS_SCHEMA, + SOCKET_IO_REQUEST_SCHEMA, + SSE_REQUEST_SCHEMA, + THEME_COLOR_SCHEMA, + VUEX_SCHEMA, + WEBSOCKET_REQUEST_SCHEMA, +} from "./validation-schemas" +import { PersistableTabState } from "../tab" +import { HoppTabDocument } from "~/helpers/rest/document" +import { HoppGQLDocument } from "~/helpers/graphql/document" +import { + CurrentValueService, + Variable, +} from "../current-environment-value.service" +import { fixBrokenRequestVersion } from "~/helpers/fixBrokenRequestVersion" +import { fixBrokenEnvironmentVersion } from "~/helpers/fixBrokenEnvironmentVersion" +import { + CurrentSortOption, + CurrentSortValuesService, +} from "../current-sort.service" + +export const STORE_NAMESPACE = "persistence.v1" + +export const STORE_KEYS = { + VUEX: "vuex", + SETTINGS: "settings", + LOCAL_STATE: "localState", + REST_HISTORY: "restHistory", + GQL_HISTORY: "gqlHistory", + REST_COLLECTIONS: "restCollections", + GQL_COLLECTIONS: "gqlCollections", + ENVIRONMENTS: "environments", + SELECTED_ENV: "selectedEnv", + WEBSOCKET: "websocket", + SOCKETIO: "socketio", + SSE: "sse", + MQTT: "mqtt", + GLOBAL_ENV: "globalEnv", + REST_TABS: "restTabs", + GQL_TABS: "gqlTabs", + SECRET_ENVIRONMENTS: "secretEnvironments", + CURRENT_ENVIRONMENT_VALUE: "currentEnvironmentValue", + CURRENT_SORT_VALUES: "currentSortValues", + SCHEMA_VERSION: "schema_version", + LOGIN_STATE: "login_state", + EMAIL_FOR_SIGN_IN: "emailForSignIn", +} as const + +interface Migration { + version: number + migrate: () => Promise +} + +const migrations: Migration[] = [ + { + version: 1, + migrate: async () => { + const keyMappings = { + settings: STORE_KEYS.SETTINGS, + collections: STORE_KEYS.REST_COLLECTIONS, + collectionsGraphql: STORE_KEYS.GQL_COLLECTIONS, + environments: STORE_KEYS.ENVIRONMENTS, + history: STORE_KEYS.REST_HISTORY, + graphqlHistory: STORE_KEYS.GQL_HISTORY, + WebsocketRequest: STORE_KEYS.WEBSOCKET, + SocketIORequest: STORE_KEYS.SOCKETIO, + SSERequest: STORE_KEYS.SSE, + MQTTRequest: STORE_KEYS.MQTT, + globalEnv: STORE_KEYS.GLOBAL_ENV, + restTabState: STORE_KEYS.REST_TABS, + gqlTabState: STORE_KEYS.GQL_TABS, + secretEnvironments: STORE_KEYS.SECRET_ENVIRONMENTS, + } + + for (const [oldKey, newKey] of Object.entries(keyMappings)) { + const data = localStorage.getItem(oldKey) + if (data) { + try { + await Store.set(STORE_NAMESPACE, newKey, JSON.parse(data)) + localStorage.removeItem(oldKey) + } catch (err) { + console.error(err) + console.error( + `Failed parsing persisted ${oldKey}:`, + JSON.stringify(data) + ) + } + } + } + }, + }, + { + // Coerce gqlHistory entries with non-string `response` to string, + // backing up originals at `${GQL_HISTORY}-pre-v2-backup`. + version: 2, + migrate: async () => { + const result = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.GQL_HISTORY + ) + + if (!E.isRight(result) || !Array.isArray(result.right)) return + + const entries = result.right + // Only target entries with own `response` field that's non-string; + // unrelated schema mismatches still go through the Zod-fail backup. + const needsRepair = entries.some( + (entry) => + typeof entry === "object" && + entry !== null && + Object.prototype.hasOwnProperty.call(entry, "response") && + typeof (entry as { response?: unknown }).response !== "string" + ) + + if (!needsRepair) return + + // Throw on backup or repair write failure so `runMigrations` can + // skip the schema_version bump and retry on the next launch. The + // alternative — log-and-continue — would mark the migration done + // while leaving poisoned data in place, with no future retry path. + const backupResult = await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.GQL_HISTORY}-pre-v2-backup`, + entries + ) + if (E.isLeft(backupResult)) { + throw new Error( + `[v2 migration] failed to write pre-v2 backup: ${backupResult.left.kind}: ${backupResult.left.message}` + ) + } + + const repaired = entries.map((entry) => { + if (typeof entry !== "object" || entry === null) return entry + const e = entry as Record + if ( + !Object.prototype.hasOwnProperty.call(e, "response") || + typeof e.response === "string" + ) { + return e + } + // Use JSON.stringify(e.response) directly so a `null` payload + // serializes to the string `"null"` rather than `'""'`. Either + // form satisfies the `response: z.string()` schema, but `"null"` + // preserves the original semantic of an empty payload (e.g. a + // subscription that produced no data) and avoids re-stringifying + // on the next sync write. + return { ...e, response: JSON.stringify(e.response) } + }) + + const repairResult = await Store.set( + STORE_NAMESPACE, + STORE_KEYS.GQL_HISTORY, + repaired + ) + if (E.isLeft(repairResult)) { + throw new Error( + `[v2 migration] failed to write repaired ${STORE_KEYS.GQL_HISTORY}: ${repairResult.left.kind}: ${repairResult.left.message}` + ) + } + }, + }, +] + +/** + * Service that manages persistence of app state using the kernel store + */ +export class PersistenceService extends Service { + public static readonly ID = "PERSISTENCE_SERVICE" + + // TODO: Consider swapping this with platform dependent `StoreLike` impl + public hoppLocalConfigStorage: StorageLike = localStorage + + private readonly restTabService = this.bind(RESTTabService) + private readonly gqlTabService = this.bind(GQLTabService) + private readonly secretEnvironmentService = this.bind( + SecretEnvironmentService + ) + private readonly currentEnvironmentValueService = + this.bind(CurrentValueService) + + private readonly currentSortValuesService = this.bind( + CurrentSortValuesService + ) + + private showErrorToast(key: string) { + const toast = useToast() + toast.error( + `Schema validation failed for ${STORE_NAMESPACE}:${key}. A backup has been created with suffix '-backup'` + ) + } + + async init(): Promise> { + diag( + "persistence", + "PersistenceService.init() called, about to Store.init()" + ) + const initResult = await Store.init() + if (E.isLeft(initResult)) { + diag( + "persistence", + "PersistenceService Store.init() FAILED:", + initResult.left + ) + console.error( + "[PersistenceService] Failed to initialize store:", + initResult.left + ) + return initResult + } + diag("persistence", "PersistenceService Store.init() succeeded") + return initResult + } + + private async runMigrations() { + const versionResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.SCHEMA_VERSION + ) + const perhapsVersion = E.isRight(versionResult) ? versionResult.right : "0" + const currentVersion = perhapsVersion ?? "0" + const targetVersion = "2" + + if (currentVersion !== targetVersion) { + try { + for (const migration of migrations) { + if (migration.version > parseInt(currentVersion)) { + await migration.migrate() + } + } + } catch (err) { + // A migration that throws (e.g. v2 repair on a degraded store) + // aborts the schema_version bump so the next launch retries + // from the same currentVersion rather than recording an + // incomplete migration as done. + console.error( + "[persistence] migration failed; schema_version not advanced:", + err + ) + return + } + + await Store.set(STORE_NAMESPACE, STORE_KEYS.SCHEMA_VERSION, targetVersion) + } + } + + /** + * Private method to migrate settings from older versions + */ + private async checkAndMigrateOldSettings() { + const oldSelectedEnvIndex = window.localStorage.getItem("selectedEnvIndex") + if (oldSelectedEnvIndex) { + if (oldSelectedEnvIndex === "-1") { + await Store.set(STORE_NAMESPACE, STORE_KEYS.SELECTED_ENV, { + type: "NO_ENV_SELECTED" as const, + }) + } else if (Number(oldSelectedEnvIndex) >= 0) { + await Store.set(STORE_NAMESPACE, STORE_KEYS.SELECTED_ENV, { + type: "MY_ENV" as const, + index: parseInt(oldSelectedEnvIndex), + }) + } + window.localStorage.removeItem("selectedEnvIndex") + } + + const vuexKey = "vuex" + const vuexData = JSON.parse(window.localStorage.getItem(vuexKey) || "{}") + + if (!isEmpty(vuexData)) { + const result = VUEX_SCHEMA.safeParse(vuexData) + if (result.success) { + const { postwoman } = result.data + if (!isEmpty(postwoman?.settings)) { + const settingsData = assign( + clone(getDefaultSettings()), + postwoman.settings + ) + await Store.set(STORE_NAMESPACE, STORE_KEYS.SETTINGS, settingsData) + delete postwoman.settings + } + if (postwoman?.collections) { + await Store.set( + STORE_NAMESPACE, + STORE_KEYS.REST_COLLECTIONS, + postwoman.collections + ) + delete postwoman.collections + } + if (postwoman?.collectionsGraphql) { + await Store.set( + STORE_NAMESPACE, + STORE_KEYS.GQL_COLLECTIONS, + postwoman.collectionsGraphql + ) + delete postwoman.collectionsGraphql + } + if (postwoman?.environments) { + await Store.set( + STORE_NAMESPACE, + STORE_KEYS.ENVIRONMENTS, + postwoman.environments + ) + delete postwoman.environments + } + } else { + this.showErrorToast(vuexKey) + window.localStorage.setItem( + `${vuexKey}-backup`, + JSON.stringify(vuexData) + ) + } + window.localStorage.removeItem(vuexKey) + } + + // Handle old theme color + const themeColorKey = "THEME_COLOR" + const themeColor = window.localStorage.getItem(themeColorKey) + if (themeColor) { + const result = THEME_COLOR_SCHEMA.safeParse(themeColor) + if (result.success) { + applySetting("THEME_COLOR", result.data as HoppAccentColor) + } else { + this.showErrorToast(themeColorKey) + window.localStorage.setItem(`${themeColorKey}-backup`, themeColor) + } + window.localStorage.removeItem(themeColorKey) + } + + // Handle old color mode + const nuxtColorModeKey = "nuxt-color-mode" + const nuxtColorMode = window.localStorage.getItem(nuxtColorModeKey) + if (nuxtColorMode) { + const result = NUXT_COLOR_MODE_SCHEMA.safeParse(nuxtColorMode) + if (result.success) { + applySetting("BG_COLOR", result.data as HoppBgColor) + } else { + this.showErrorToast(nuxtColorModeKey) + window.localStorage.setItem(`${nuxtColorModeKey}-backup`, nuxtColorMode) + } + window.localStorage.removeItem(nuxtColorModeKey) + } + } + + private async setupLocalStatePersistence() { + const loadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.LOCAL_STATE + ) + + try { + if (E.isRight(loadResult)) { + const data = loadResult.right ?? {} + const result = LOCAL_STATE_SCHEMA.safeParse(data) + + if (result.success) { + bulkApplyLocalState(result.data) + } else { + this.showErrorToast(STORE_KEYS.LOCAL_STATE) + await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.LOCAL_STATE}-backup`, + data + ) + } + } + } catch (_e) { + console.error(`Failed parsing persisted LOCAL_STATE:`, loadResult) + } + + localStateStore.subject$.subscribe(async (state) => { + await Store.set(STORE_NAMESPACE, STORE_KEYS.LOCAL_STATE, state) + }) + } + + private async setupSettingsPersistence() { + diag( + "persistence", + "setupSettingsPersistence() loading settings from store" + ) + const loadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.SETTINGS + ) + + try { + if (E.isRight(loadResult)) { + const data = loadResult.right ?? getDefaultSettings() + diag( + "persistence", + "settings loaded, BG_COLOR:", + data?.BG_COLOR, + "THEME_COLOR:", + data?.THEME_COLOR + ) + diag( + "persistence", + "settings keys:", + data ? Object.keys(data).join(", ") : "(null/default)" + ) + const result = SETTINGS_SCHEMA.safeParse(data) + + if (result.success) { + const migratedSettings = performSettingsDataMigrations(result.data) + diag( + "persistence", + "settings migrated, BG_COLOR:", + migratedSettings?.BG_COLOR, + "THEME_COLOR:", + migratedSettings?.THEME_COLOR + ) + bulkApplySettings(migratedSettings) + diag("persistence", "settings applied via bulkApplySettings") + } else { + diag( + "persistence", + "settings schema validation FAILED:", + result.error?.message + ) + this.showErrorToast(STORE_KEYS.SETTINGS) + await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.SETTINGS}-backup`, + data + ) + } + } else { + diag( + "persistence", + "settings load returned Left (error):", + loadResult.left + ) + } + } catch (_e) { + diag("persistence", "settings parse error:", String(_e)) + console.error(`Failed parsing persisted SETTINGS:`, loadResult) + } + + settingsStore.subject$.subscribe(async (settings) => { + await Store.set(STORE_NAMESPACE, STORE_KEYS.SETTINGS, settings) + }) + } + + private async setupRESTHistoryPersistence() { + const restLoadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.REST_HISTORY + ) + + try { + if (E.isRight(restLoadResult)) { + const data = restLoadResult.right ?? [] + const result = z.array(REST_HISTORY_ENTRY_SCHEMA).safeParse(data) + + if (result.success) { + const translatedData = result.data.map(translateToNewRESTHistory) + setRESTHistoryEntries(translatedData) + } else { + this.showErrorToast(STORE_KEYS.REST_HISTORY) + await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.REST_HISTORY}-backup`, + data + ) + } + } + } catch (_e) { + console.error(`Failed parsing persisted REST_HISTORY:`, restLoadResult) + } + + restHistoryStore.subject$.subscribe(async ({ state }) => { + await Store.set(STORE_NAMESPACE, STORE_KEYS.REST_HISTORY, state) + }) + } + + private async setupGQLHistoryPersistence() { + const gqlLoadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.GQL_HISTORY + ) + + try { + if (E.isRight(gqlLoadResult)) { + const data = gqlLoadResult.right ?? [] + const result = z.array(GQL_HISTORY_ENTRY_SCHEMA).safeParse(data) + + if (result.success) { + const translatedData = result.data.map(translateToNewGQLHistory) + setGraphqlHistoryEntries(translatedData) + } else { + this.showErrorToast(STORE_KEYS.GQL_HISTORY) + await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.GQL_HISTORY}-backup`, + data + ) + } + } + } catch (_e) { + console.error(`Failed parsing persisted GQL_HISTORY:`, gqlLoadResult) + } + + graphqlHistoryStore.subject$.subscribe(async ({ state }) => { + await Store.set(STORE_NAMESPACE, STORE_KEYS.GQL_HISTORY, state) + }) + } + + private async setupRESTCollectionsPersistence() { + diag( + "persistence", + "setupRESTCollectionsPersistence() loading REST collections" + ) + const restLoadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.REST_COLLECTIONS + ) + + try { + if (E.isRight(restLoadResult)) { + const data = restLoadResult.right ?? [] + diag( + "persistence", + "REST collections loaded, count:", + Array.isArray(data) ? data.length : "(not array)", + "first name:", + Array.isArray(data) && data[0]?.name ? data[0].name : "(none)" + ) + const result = z.array(REST_COLLECTION_SCHEMA).safeParse(data) + + if (result.success) { + const translatedData = result.data.map(translateToNewRESTCollection) + diag( + "persistence", + "REST collections translated, count:", + translatedData.length + ) + setRESTCollections(translatedData) + } else { + console.error(`Failed with `, result.error, data) + this.showErrorToast(STORE_KEYS.REST_COLLECTIONS) + await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.REST_COLLECTIONS}-backup`, + data + ) + // NOTE: Still loading data to match legacy behavior + setRESTCollections(data) + } + } + } catch (_e) { + console.error( + `Failed parsing persisted REST_COLLECTIONS:`, + restLoadResult + ) + } + + restCollectionStore.subject$.subscribe(async ({ state }) => { + await Store.set(STORE_NAMESPACE, STORE_KEYS.REST_COLLECTIONS, state) + }) + } + + private async setupGQLCollectionsPersistence() { + const gqlLoadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.GQL_COLLECTIONS + ) + + try { + if (E.isRight(gqlLoadResult)) { + const data = gqlLoadResult.right ?? [] + const result = z.array(GQL_COLLECTION_SCHEMA).safeParse(data) + + if (result.success) { + const translatedData = result.data.map(translateToNewGQLCollection) + setGraphqlCollections(translatedData) + } else { + this.showErrorToast(STORE_KEYS.GQL_COLLECTIONS) + await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.GQL_COLLECTIONS}-backup`, + data + ) + // NOTE: Still loading data to match legacy behavior + setGraphqlCollections(data) + } + } + } catch (_e) { + console.error(`Failed parsing persisted GQL_COLLECTIONS:`, gqlLoadResult) + } + + graphqlCollectionStore.subject$.subscribe(async ({ state }) => { + await Store.set(STORE_NAMESPACE, STORE_KEYS.GQL_COLLECTIONS, state) + }) + } + + private async setupEnvironmentsPersistence() { + const loadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.ENVIRONMENTS + ) + + try { + if (E.isRight(loadResult)) { + const data = loadResult.right ?? [] + const environments = fixBrokenEnvironmentVersion(data) + + const result = ENVIRONMENTS_SCHEMA.safeParse(environments) + + if (result.success) { + // Check for and handle globals + const globalIndex = result.data.findIndex( + (x) => x.name.toLowerCase() === "globals" + ) + + if (globalIndex !== -1) { + const globalEnv = result.data[globalIndex] + globalEnv.variables.forEach((variable: GlobalEnvironmentVariable) => + addGlobalEnvVariable(variable) + ) + result.data.splice(globalIndex, 1) + } + + replaceEnvironments(result.data) + } else { + this.showErrorToast(STORE_KEYS.ENVIRONMENTS) + await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.ENVIRONMENTS}-backup`, + data + ) + } + } + } catch (_e) { + console.error(`Failed parsing persisted ENVIRONMENTS:`, loadResult) + } + + environments$.subscribe(async (envs) => { + await Store.set(STORE_NAMESPACE, STORE_KEYS.ENVIRONMENTS, envs) + }) + } + + private async setupSecretEnvironmentsPersistence() { + const loadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.SECRET_ENVIRONMENTS + ) + + try { + if (E.isRight(loadResult) && loadResult.right) { + const result = SECRET_ENVIRONMENT_VARIABLE_SCHEMA.safeParse( + loadResult.right + ) + + if (result.success) { + this.secretEnvironmentService.loadSecretEnvironmentsFromPersistedState( + result.data + ) + } else { + this.showErrorToast(STORE_KEYS.SECRET_ENVIRONMENTS) + await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.SECRET_ENVIRONMENTS}-backup`, + loadResult.right + ) + console.error( + `Failed parsing persisted SECRET_ENVIRONMENTS:`, + JSON.stringify(loadResult.right) + ) + } + } + } catch (_e) { + console.error(`Failed parsing persisted SECRET_ENVIRONMENTS:`, loadResult) + } + + watchDebounced( + this.secretEnvironmentService.persistableSecretEnvironments, + async (newData: Record) => { + await Store.set( + STORE_NAMESPACE, + STORE_KEYS.SECRET_ENVIRONMENTS, + newData + ) + }, + { debounce: 500 } + ) + } + + private async setupCurrentEnvironmentValuePersistence() { + const loadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.CURRENT_ENVIRONMENT_VALUE + ) + + try { + if (E.isRight(loadResult) && loadResult.right) { + const result = CURRENT_ENVIRONMENT_VALUE_SCHEMA.safeParse( + loadResult.right + ) + + if (result.success) { + this.currentEnvironmentValueService.loadEnvironmentsFromPersistedState( + result.data + ) + } else { + this.showErrorToast(STORE_KEYS.CURRENT_ENVIRONMENT_VALUE) + await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.CURRENT_ENVIRONMENT_VALUE}-backup`, + loadResult.right + ) + console.error( + `Failed parsing persisted CURRENT_ENVIRONMENT_VALUE:`, + JSON.stringify(loadResult.right) + ) + } + } + } catch (_e) { + console.error( + `Failed parsing persisted CURRENT_ENVIRONMENT_VALUE:`, + loadResult + ) + } + + watchDebounced( + this.currentEnvironmentValueService.persistableEnvironments, + async (newData: Record) => { + await Store.set( + STORE_NAMESPACE, + STORE_KEYS.CURRENT_ENVIRONMENT_VALUE, + newData + ) + }, + { debounce: 500 } + ) + } + + private async setupSelectedEnvPersistence() { + const loadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.SELECTED_ENV + ) + + try { + if (E.isRight(loadResult)) { + const data = loadResult.right ?? { type: "NO_ENV_SELECTED" as const } + const result = SELECTED_ENV_INDEX_SCHEMA.safeParse(data) + + if (result.success) { + if (result.data !== null) { + setSelectedEnvironmentIndex(result.data) + } else { + setSelectedEnvironmentIndex({ type: "NO_ENV_SELECTED" }) + } + } else { + this.showErrorToast(STORE_KEYS.SELECTED_ENV) + await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.SELECTED_ENV}-backup`, + data + ) + } + } + } catch (_e) { + console.error(`Failed parsing persisted SELECTED_ENV:`, loadResult) + } + + selectedEnvironmentIndex$.subscribe(async (index) => { + await Store.set(STORE_NAMESPACE, STORE_KEYS.SELECTED_ENV, index) + }) + } + + private async setupCurrentSortValuesPersistence() { + const loadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.CURRENT_SORT_VALUES + ) + + try { + if (E.isRight(loadResult) && loadResult.right) { + const result = CURRENT_SORT_VALUES_SCHEMA.safeParse(loadResult.right) + + if (result.success) { + this.currentSortValuesService.loadCurrentSortValuesFromPersistedState( + result.data + ) + } else { + this.showErrorToast(STORE_KEYS.CURRENT_SORT_VALUES) + await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.CURRENT_SORT_VALUES}-backup`, + loadResult.right + ) + console.error( + `Failed parsing persisted CURRENT_SORT_VALUES:`, + JSON.stringify(loadResult.right) + ) + } + } + } catch (_e) { + console.error(`Failed parsing persisted CURRENT_SORT_VALUES:`, loadResult) + } + + watchDebounced( + this.currentSortValuesService.persistableCurrentSortValues, + async (newData: Record) => { + await Store.set( + STORE_NAMESPACE, + STORE_KEYS.CURRENT_SORT_VALUES, + newData + ) + }, + { debounce: 500 } + ) + } + + private async setupWebsocketPersistence() { + const loadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.WEBSOCKET + ) + + try { + if (E.isRight(loadResult)) { + const data = loadResult.right + if (data) { + const result = WEBSOCKET_REQUEST_SCHEMA.safeParse(data) + + if (result.success) { + if (result.data !== null) { + setWSRequest(result.data) + } else { + setWSRequest(undefined) + } + } else { + this.showErrorToast(STORE_KEYS.WEBSOCKET) + await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.WEBSOCKET}-backup`, + data + ) + } + } + } + } catch (_e) { + console.error(`Failed parsing persisted WEBSOCKET:`, loadResult) + } + + WSRequest$.subscribe(async (req) => { + await Store.set(STORE_NAMESPACE, STORE_KEYS.WEBSOCKET, req) + }) + } + + private async setupSocketIOPersistence() { + const loadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.SOCKETIO + ) + + try { + if (E.isRight(loadResult)) { + const data = loadResult.right + if (data) { + const result = SOCKET_IO_REQUEST_SCHEMA.safeParse(data) + + if (result.success) { + if (result.data !== null) { + setSIORequest(result.data) + } else { + setSIORequest(undefined) + } + } else { + this.showErrorToast(STORE_KEYS.SOCKETIO) + await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.SOCKETIO}-backup`, + data + ) + } + } + } + } catch (_e) { + console.error(`Failed parsing persisted SOCKETIO:`, loadResult) + } + + SIORequest$.subscribe(async (req) => { + await Store.set(STORE_NAMESPACE, STORE_KEYS.SOCKETIO, req) + }) + } + + private async setupSSEPersistence() { + const loadResult = await Store.get(STORE_NAMESPACE, STORE_KEYS.SSE) + + try { + if (E.isRight(loadResult)) { + const data = loadResult.right + if (data) { + const result = SSE_REQUEST_SCHEMA.safeParse(data) + + if (result.success) { + if (result.data !== null) { + setSSERequest(result.data) + } else { + setSSERequest(undefined) + } + } else { + this.showErrorToast(STORE_KEYS.SSE) + await Store.set(STORE_NAMESPACE, `${STORE_KEYS.SSE}-backup`, data) + } + } + } + } catch (_e) { + console.error(`Failed parsing persisted SSE:`, loadResult) + } + + SSERequest$.subscribe(async (req) => { + await Store.set(STORE_NAMESPACE, STORE_KEYS.SSE, req) + }) + } + + private async setupMQTTPersistence() { + const loadResult = await Store.get(STORE_NAMESPACE, STORE_KEYS.MQTT) + + try { + if (E.isRight(loadResult)) { + const data = loadResult.right + if (data) { + const result = MQTT_REQUEST_SCHEMA.safeParse(data) + + if (result.success) { + if (result.data !== null) { + setMQTTRequest(result.data) + } else { + setMQTTRequest(undefined) + } + } else { + this.showErrorToast(STORE_KEYS.MQTT) + await Store.set(STORE_NAMESPACE, `${STORE_KEYS.MQTT}-backup`, data) + } + } + } + } catch (_e) { + console.error(`Failed parsing persisted MQTT:`, loadResult) + } + + MQTTRequest$.subscribe(async (req) => { + await Store.set(STORE_NAMESPACE, STORE_KEYS.MQTT, req) + }) + } + + private async setupGlobalEnvsPersistence() { + const loadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.GLOBAL_ENV + ) + + try { + if (E.isRight(loadResult)) { + const data = loadResult.right ?? [] + const result = GLOBAL_ENVIRONMENT_SCHEMA.safeParse(data) + + if (result.success) { + setGlobalEnvVariables(result.data) + } else { + this.showErrorToast(STORE_KEYS.GLOBAL_ENV) + await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.GLOBAL_ENV}-backup`, + data + ) + } + } + } catch (_e) { + console.error(`Failed parsing persisted GLOBAL_ENV:`, loadResult) + } + + globalEnv$.subscribe(async (vars) => { + await Store.set(STORE_NAMESPACE, STORE_KEYS.GLOBAL_ENV, vars) + }) + } + + private async setupRESTTabsPersistence() { + const loadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.REST_TABS + ) + + try { + if (E.isRight(loadResult) && loadResult.right) { + // Correcting the request schema for broken data + const orderedDocs = fixBrokenRequestVersion( + cloneDeep(loadResult.right.orderedDocs) ?? [] + ) + + const transformedTabs = { + ...loadResult.right, + orderedDocs, + } + const result = REST_TAB_STATE_SCHEMA.safeParse(transformedTabs) + if (result.success) { + // SAFETY: We know the schema matches + this.restTabService.loadTabsFromPersistedState( + result.data as PersistableTabState + ) + } else { + this.showErrorToast(STORE_KEYS.REST_TABS) + await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.REST_TABS}-backup`, + loadResult.right + ) + console.error( + `Failed parsing persisted REST_TABS:`, + JSON.stringify(loadResult.right) + ) + // NOTE: Still loading data to match legacy behavior + this.restTabService.loadTabsFromPersistedState(loadResult.right) + } + } + } catch (_e) { + console.error(`Failed parsing persisted REST_TABS:`, loadResult) + } + + watchDebounced( + this.restTabService.persistableTabState, + async (newData) => { + await Store.set(STORE_NAMESPACE, STORE_KEYS.REST_TABS, newData) + }, + { debounce: 500, deep: true } + ) + } + + private async setupGQLTabsPersistence() { + const loadResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.GQL_TABS + ) + + try { + if (E.isRight(loadResult) && loadResult.right) { + const result = GQL_TAB_STATE_SCHEMA.safeParse(loadResult.right) + + if (result.success) { + // SAFETY: We know the schema matches + this.gqlTabService.loadTabsFromPersistedState( + result.data as PersistableTabState + ) + } else { + this.showErrorToast(STORE_KEYS.GQL_TABS) + await Store.set( + STORE_NAMESPACE, + `${STORE_KEYS.GQL_TABS}-backup`, + loadResult.right + ) + console.error( + `Failed parsing persisted GQL_TABS:`, + JSON.stringify(loadResult.right) + ) + // NOTE: Still loading data to match legacy behavior + this.gqlTabService.loadTabsFromPersistedState(loadResult.right) + } + } + } catch (_e) { + console.error(`Failed parsing persisted GQL_TABS:`, loadResult) + } + + watchDebounced( + this.gqlTabService.persistableTabState, + async (newData) => { + await Store.set(STORE_NAMESPACE, STORE_KEYS.GQL_TABS, newData) + }, + { debounce: 500, deep: true } + ) + } + + public async setupFirst() { + diag("persistence", "setupFirst() start") + await this.init() + diag("persistence", "setupFirst() init done, running migrations") + await this.runMigrations() + await this.checkAndMigrateOldSettings() + diag("persistence", "setupFirst() complete") + } + + public async setupLater() { + diag("persistence", "setupLater() start - loading all persisted data") + await Promise.all([ + this.setupLocalStatePersistence(), + + this.setupSettingsPersistence(), + this.setupRESTHistoryPersistence(), + this.setupGQLHistoryPersistence(), + this.setupRESTCollectionsPersistence(), + this.setupGQLCollectionsPersistence(), + + this.setupEnvironmentsPersistence(), + this.setupGlobalEnvsPersistence(), + this.setupSelectedEnvPersistence(), + + this.setupWebsocketPersistence(), + this.setupSocketIOPersistence(), + this.setupSSEPersistence(), + this.setupMQTTPersistence(), + this.setupRESTTabsPersistence(), + this.setupGQLTabsPersistence(), + + this.setupSecretEnvironmentsPersistence(), + this.setupCurrentEnvironmentValuePersistence(), + + this.setupCurrentSortValuesPersistence(), + ]) + } + + /** + * Gets a typed value from persistence, deserialization is automatic. + * No need to use JSON.parse on the result, it's handled internally by the store. + * @param key The key to retrieve + * @returns Either containing the typed value or an error + */ + public async get( + key: (typeof STORE_KEYS)[keyof typeof STORE_KEYS] + ): Promise> { + return await Store.get(STORE_NAMESPACE, key) + } + + /** + * Gets a value from persistence, discards error and returns null on failure. + * No need to use JSON.parse on the result, it's handled internally by the store. + * NOTE: Use this cautiously, try to always use `get`, handling error at call site is better + * @param key The key to retrieve + * @returns The typed value or null if not found or on error + */ + public async getNullable( + key: (typeof STORE_KEYS)[keyof typeof STORE_KEYS] + ): Promise { + const r = await Store.get(STORE_NAMESPACE, key) + + if (E.isLeft(r)) return null + + return r.right ?? null + } + + /** + * Gets a value from local config + * @deprecated Use get() instead which provides automatic deserialization and type safety. + * With get(), there's no need to use JSON.parse on the result. + * @param name The name of the config to retrieve + * @returns The config value as string, null or undefined + */ + public async getLocalConfig( + name: string + ): Promise { + const result = await Store.get(STORE_NAMESPACE, name) + if (E.isRight(result)) { + return result.right + } + return null + } + + /** + * Sets a value in persistence with proper type safety and automatic serialization. + * No need to use JSON.stringify on the value, it's handled internally by the store. + * @param key The key to set + * @param value The value to set (passed directly without manual serialization) + * @returns Either containing void or an error + */ + public async set( + key: (typeof STORE_KEYS)[keyof typeof STORE_KEYS], + value: T + ): Promise> { + return await Store.set(STORE_NAMESPACE, key, value) + } + + /** + * Sets a value in persistence + * @deprecated Use set() instead which provides automatic serialization and type safety. + * With set(), there's no need to use JSON.stringify on the value before passing it. + * @param key The key to set + * @param value The value to set as string + */ + public async setLocalConfig(key: string, value: string): Promise { + await Store.set(STORE_NAMESPACE, key, value) + } + + /** + * Clear config value from persistence + * @param key The key to remove + * @returns Either containing boolean or an error + */ + public async remove( + key: (typeof STORE_KEYS)[keyof typeof STORE_KEYS] + ): Promise> { + return await Store.remove(STORE_NAMESPACE, key) + } + + /** + * Clear config value from persistence + * @deprecated Use remove() instead which provides proper error handling and type safety. + * @param key The key to remove + */ + public async removeLocalConfig(key: string): Promise { + await Store.remove(STORE_NAMESPACE, key) + } +} diff --git a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts new file mode 100644 index 0000000..65716f3 --- /dev/null +++ b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts @@ -0,0 +1,625 @@ +import { + Environment, + GQLHeader, + HoppGQLAuth, + HoppGQLRequest, + HoppRESTAuth, + HoppRESTRequest, + HoppRESTHeaders, + HoppRESTRequestResponse, + HoppCollection, + GlobalEnvironment, + CollectionVariable, +} from "@hoppscotch/data" +import { entityReference } from "verzod" +import { z } from "zod" +import { HoppAccentColors, HoppBgColors } from "~/newstore/settings" + +const ThemeColorSchema = z.enum([ + "green", + "teal", + "blue", + "indigo", + "purple", + "yellow", + "orange", + "red", + "pink", +]) + +const BgColorSchema = z.enum(["system", "light", "dark", "black"]) + +const EncodeMode = z.enum(["enable", "disable", "auto"]) + +const SettingsDefSchema = z.object({ + syncCollections: z.boolean(), + syncHistory: z.boolean(), + syncEnvironments: z.boolean(), + PROXY_URL: z.string(), + CURRENT_KERNEL_INTERCEPTOR_ID: z.string(), + URL_EXCLUDES: z.object({ + auth: z.boolean(), + httpUser: z.boolean(), + httpPassword: z.boolean(), + bearerToken: z.boolean(), + oauth2Token: z.optional(z.boolean()), + }), + THEME_COLOR: ThemeColorSchema, + BG_COLOR: BgColorSchema, + ENCODE_MODE: EncodeMode.catch("enable"), + TELEMETRY_ENABLED: z.boolean(), + EXPAND_NAVIGATION: z.boolean(), + SIDEBAR: z.boolean(), + SIDEBAR_ON_LEFT: z.boolean(), + COLUMN_LAYOUT: z.boolean(), + + WRAP_LINES: z.optional( + z.object({ + httpRequestBody: z.boolean().catch(true), + httpResponseBody: z.boolean().catch(true), + httpHeaders: z.boolean().catch(true), + httpParams: z.boolean().catch(true), + httpUrlEncoded: z.boolean().catch(true), + httpPreRequest: z.boolean().catch(true), + httpTest: z.boolean().catch(true), + httpRequestVariables: z.boolean().catch(true), + graphqlQuery: z.boolean().catch(true), + graphqlResponseBody: z.boolean().catch(true), + graphqlHeaders: z.boolean().catch(false), + graphqlVariables: z.boolean().catch(false), + graphqlSchema: z.boolean().catch(true), + importCurl: z.boolean().catch(true), + codeGen: z.boolean().catch(true), + cookie: z.boolean().catch(true), + multipartFormdata: z.boolean().catch(true), + }) + ), + + HAS_OPENED_SPOTLIGHT: z.optional(z.boolean()), + ENABLE_AI_EXPERIMENTS: z.optional(z.boolean()), + AI_REQUEST_NAMING_STYLE: z + .string() + .optional() + .catch("DESCRIPTIVE_WITH_SPACES"), + CUSTOM_NAMING_STYLE: z.string().optional().catch(""), + + EXPERIMENTAL_SCRIPTING_SANDBOX: z.optional(z.boolean()), + ENABLE_EXPERIMENTAL_MOCK_SERVERS: z.optional(z.boolean()), + ENABLE_EXPERIMENTAL_DOCUMENTATION: z.optional(z.boolean()), +}) + +const HoppRESTRequestSchema = entityReference(HoppRESTRequest) + +const HoppGQLRequestSchema = entityReference(HoppGQLRequest) + +const HoppRESTCollectionSchema = entityReference(HoppCollection) + +const HoppGQLCollectionSchema = entityReference(HoppCollection) + +export const VUEX_SCHEMA = z.object({ + postwoman: z.optional( + z.object({ + settings: z.optional(SettingsDefSchema), + //! Versioned entities + collections: z.optional(z.array(HoppRESTCollectionSchema)), + collectionsGraphql: z.optional(z.array(HoppGQLCollectionSchema)), + environments: z.optional(z.array(entityReference(Environment))), + }) + ), +}) + +export const THEME_COLOR_SCHEMA = z.enum(HoppAccentColors) + +export const NUXT_COLOR_MODE_SCHEMA = z.enum(HoppBgColors) + +export const LOCAL_STATE_SCHEMA = z.union([ + z.object({}).strict(), + z + .object({ + REMEMBERED_TEAM_ID: z.optional(z.string()), + }) + .strict(), +]) + +export const SETTINGS_SCHEMA = SettingsDefSchema.extend({ + EXTENSIONS_ENABLED: z.optional(z.boolean()), + PROXY_ENABLED: z.optional(z.boolean()), +}) + +export const REST_HISTORY_ENTRY_SCHEMA = z + .object({ + v: z.number(), + //! Versioned entity + request: HoppRESTRequestSchema, + responseMeta: z + .object({ + duration: z.nullable(z.number()), + statusCode: z.nullable(z.number()), + }) + .strict(), + star: z.boolean(), + id: z.optional(z.string()), + updatedOn: z.optional(z.union([z.date(), z.string()])), + }) + .strict() + +export const GQL_HISTORY_ENTRY_SCHEMA = z + .object({ + v: z.number(), + //! Versioned entity + request: HoppGQLRequestSchema, + response: z.string(), + star: z.boolean(), + id: z.optional(z.string()), + updatedOn: z.optional(z.union([z.date(), z.string()])), + }) + .strict() + +export const REST_COLLECTION_SCHEMA = HoppRESTCollectionSchema + +export const GQL_COLLECTION_SCHEMA = HoppGQLCollectionSchema + +export const ENVIRONMENTS_SCHEMA = z.array(entityReference(Environment)) + +export const GLOBAL_ENVIRONMENT_SCHEMA = entityReference(GlobalEnvironment) + +export const SELECTED_ENV_INDEX_SCHEMA = z.nullable( + z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("NO_ENV_SELECTED"), + }) + .strict(), + z + .object({ + type: z.literal("MY_ENV"), + index: z.number(), + }) + .strict(), + z.object({ + type: z.literal("TEAM_ENV"), + teamID: z.string(), + teamEnvID: z.string(), + environment: entityReference(Environment), + }), + ]) +) + +export const WEBSOCKET_REQUEST_SCHEMA = z.nullable( + z + .object({ + endpoint: z.string(), + protocols: z.array( + z + .object({ + value: z.string(), + active: z.boolean(), + }) + .strict() + ), + }) + .strict() +) + +export const SOCKET_IO_REQUEST_SCHEMA = z.nullable( + z + .object({ + endpoint: z.string(), + path: z.string(), + version: z.union([z.literal("v4"), z.literal("v3"), z.literal("v2")]), + }) + .strict() +) + +export const SSE_REQUEST_SCHEMA = z.nullable( + z + .object({ + endpoint: z.string(), + eventType: z.string(), + }) + .strict() +) + +export const MQTT_REQUEST_SCHEMA = z.nullable( + z + .object({ + endpoint: z.string(), + clientID: z.optional(z.string()), + }) + .strict() +) + +const EnvironmentVariablesSchema = z.union([ + z.object({ + key: z.string(), + initialValue: z.string(), + currentValue: z.string(), + secret: z.boolean(), + }), + z.object({ + key: z.string(), + value: z.string(), + }), +]) + +const OperationTypeSchema = z.enum([ + "subscription", + "query", + "mutation", + "teardown", +]) + +const RunQueryOptionsSchema = z + .object({ + name: z.optional(z.string()), + url: z.string(), + headers: z.array(GQLHeader), + query: z.string(), + variables: z.string(), + auth: HoppGQLAuth, + operationName: z.optional(z.string()), + operationType: OperationTypeSchema, + }) + .strict() + +const HoppGQLSaveContextSchema = z.nullable( + z.discriminatedUnion("originLocation", [ + z + .object({ + originLocation: z.literal("user-collection"), + folderPath: z.string(), + requestIndex: z.number(), + }) + .strict(), + z + .object({ + originLocation: z.literal("team-collection"), + requestID: z.string(), + teamID: z.optional(z.string()), + collectionID: z.optional(z.string()), + }) + .strict(), + ]) +) + +const GQLResponseEventSchema = z.array( + z + .object({ + time: z.number(), + operationName: z.optional(z.string()), + operationType: OperationTypeSchema, + data: z.string(), + rawQuery: z.optional(RunQueryOptionsSchema), + }) + .strict() +) + +const validGqlOperations = [ + "query", + "headers", + "variables", + "authorization", +] as const + +const HoppInheritedPropertySchema = z + .object({ + auth: z.object({ + parentID: z.string(), + parentName: z.string(), + inheritedAuth: z.union([HoppRESTAuth, HoppGQLAuth]), + }), + headers: z.array( + z.object({ + parentID: z.string(), + parentName: z.string(), + inheritedHeader: z.union([HoppRESTHeaders, GQLHeader]), + }) + ), + variables: z + .array( + z.object({ + parentPath: z.optional(z.string()), + parentID: z.string(), + parentName: z.string(), + inheritedVariables: z.array(CollectionVariable), + }) + ) + .catch([]), + scripts: z + .array( + z.object({ + parentID: z.string(), + parentName: z.string(), + preRequestScript: z.string(), + testScript: z.string(), + }) + ) + .catch([]), + }) + .strict() + +export const GQL_TAB_STATE_SCHEMA = z + .object({ + lastActiveTabID: z.string(), + orderedDocs: z.array( + z.object({ + tabID: z.string(), + doc: z + .object({ + // Versioned entity + request: entityReference(HoppGQLRequest), + isDirty: z.boolean(), + saveContext: z.optional(HoppGQLSaveContextSchema), + response: z.optional(z.nullable(GQLResponseEventSchema)), + responseTabPreference: z.optional(z.string()), + optionTabPreference: z.optional(z.enum(validGqlOperations)), + inheritedProperties: z.optional(HoppInheritedPropertySchema), + cursorPosition: z.optional(z.number()), + }) + .strict(), + }) + ), + }) + .strict() + +const HoppTestExpectResultSchema = z + .object({ + status: z.enum(["fail", "pass", "error"]), + message: z.string(), + }) + .strict() + +// @ts-expect-error recursive schema +const HoppTestDataSchema = z.lazy(() => + z + .object({ + description: z.string(), + expectResults: z.array(HoppTestExpectResultSchema), + tests: z.array(HoppTestDataSchema), + }) + .strict() +) + +export const SECRET_ENVIRONMENT_VARIABLE_SCHEMA = z.union([ + z.object({}).strict(), + + z.record( + z.string(), + z.array( + z + .object({ + key: z.string(), + value: z.string(), + initialValue: z.string().optional().catch(""), + varIndex: z.number(), + }) + .strict() + ) + ), +]) + +export const CURRENT_ENVIRONMENT_VALUE_SCHEMA = z.union([ + z.object({}).strict(), + + z.record( + z.string(), + z.array( + z + .object({ + key: z.string(), + currentValue: z.string(), + varIndex: z.number(), + isSecret: z.boolean().catch(false), + }) + .strict() + ) + ), +]) + +export const CURRENT_SORT_VALUES_SCHEMA = z.union([ + z.object({}).strict(), + + z.record( + z.string(), + z.object({ + sortBy: z.enum(["name"]), + sortOrder: z.enum(["asc", "desc"]), + }) + ), +]) + +const HoppTestResultSchema = z + .object({ + tests: z.array(HoppTestDataSchema), + expectResults: z.array(HoppTestExpectResultSchema), + description: z.string(), + scriptError: z.boolean(), + envDiff: z + .object({ + global: z + .object({ + additions: z.array(EnvironmentVariablesSchema), + updations: z.array( + z.intersection( + EnvironmentVariablesSchema, + z.object({ previousValue: z.optional(z.string()) }) + ) + ), + deletions: z.array(EnvironmentVariablesSchema), + }) + .strict(), + selected: z + .object({ + additions: z.array(EnvironmentVariablesSchema), + updations: z.array( + z.intersection( + EnvironmentVariablesSchema, + z.object({ previousValue: z.optional(z.string()) }) + ) + ), + deletions: z.array(EnvironmentVariablesSchema), + }) + .strict(), + }) + .strict(), + consoleEntries: z.optional(z.array(z.record(z.string(), z.unknown()))), + }) + .strict() + +const HoppRESTResponseHeaderSchema = z + .object({ + key: z.string(), + value: z.string(), + }) + .strict() + +const HoppRESTResponseSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("loading"), + // !Versioned entity + req: HoppRESTRequestSchema, + }) + .strict(), + z + .object({ + type: z.literal("fail"), + headers: z.array(HoppRESTResponseHeaderSchema), + body: z.instanceof(ArrayBuffer), + statusCode: z.number(), + meta: z + .object({ + responseSize: z.number(), + responseDuration: z.number(), + }) + .strict(), + // !Versioned entity + req: HoppRESTRequestSchema, + }) + .strict(), + z + .object({ + type: z.literal("network_fail"), + error: z.unknown(), + // !Versioned entity + req: HoppRESTRequestSchema, + }) + .strict(), + z + .object({ + type: z.literal("script_fail"), + error: z.instanceof(Error), + }) + .strict(), + z + .object({ + type: z.literal("success"), + headers: z.array(HoppRESTResponseHeaderSchema), + body: z.instanceof(ArrayBuffer), + statusCode: z.number(), + meta: z + .object({ + responseSize: z.number(), + responseDuration: z.number(), + }) + .strict(), + // !Versioned entity + req: HoppRESTRequestSchema, + }) + .strict(), +]) + +const HoppRESTSaveContextSchema = z.nullable( + z.discriminatedUnion("originLocation", [ + z + .object({ + originLocation: z.literal("user-collection"), + folderPath: z.string(), + requestIndex: z.optional(z.number()), + exampleID: z.optional(z.string()), + requestRefID: z.optional(z.string()), + }) + .strict(), + z + .object({ + originLocation: z.literal("team-collection"), + requestID: z.string(), + teamID: z.optional(z.string()), + collectionID: z.optional(z.string()), + exampleID: z.optional(z.string()), + requestRefID: z.optional(z.string()), + }) + .strict(), + ]) +) + +const validRestOperations = [ + "params", + "bodyParams", + "headers", + "authorization", + "preRequestScript", + "tests", + "requestVariables", +] as const + +export const REST_TAB_STATE_SCHEMA = z + .object({ + lastActiveTabID: z.string(), + orderedDocs: z.array( + z.object({ + tabID: z.string(), + doc: z.union([ + z.object({ + type: z.literal("test-runner").catch("test-runner"), + config: z.object({ + delay: z.number(), + iterations: z.number(), + keepVariableValues: z.boolean(), + persistResponses: z.boolean(), + stopOnError: z.boolean(), + }), + status: z.enum(["idle", "running", "stopped", "error"]), + collection: HoppRESTCollectionSchema, + collectionType: z.enum(["my-collections", "team-collections"]), + collectionID: z.optional(z.string()), + resultCollection: z.optional(HoppRESTCollectionSchema), + testRunnerMeta: z.object({ + totalRequests: z.number(), + completedRequests: z.number(), + totalTests: z.number(), + passedTests: z.number(), + failedTests: z.number(), + totalTime: z.number(), + }), + request: z.nullable(entityReference(HoppRESTRequest)), + response: z.nullable(HoppRESTResponseSchema), + testResults: z.optional(z.nullable(HoppTestResultSchema)), + isDirty: z.boolean(), + inheritedProperties: z.optional(HoppInheritedPropertySchema), + }), + z.object({ + // !Versioned entity + request: entityReference(HoppRESTRequest), + type: z.literal("request").catch("request"), + isDirty: z.boolean(), + saveContext: z.optional(HoppRESTSaveContextSchema), + response: z.optional(z.nullable(HoppRESTResponseSchema)), + testResults: z.optional(z.nullable(HoppTestResultSchema)), + responseTabPreference: z.optional(z.string()), + optionTabPreference: z.optional(z.enum(validRestOperations)), + inheritedProperties: z.optional(HoppInheritedPropertySchema), + cancelFunction: z.optional(z.function()), + }), + z.object({ + type: z.literal("example-response").catch("example-response"), + response: entityReference(HoppRESTRequestResponse), + saveContext: z.optional(HoppRESTSaveContextSchema), + isDirty: z.boolean(), + inheritedProperties: z.optional(HoppInheritedPropertySchema), + }), + ]), + }) + ), + }) + .strict() diff --git a/packages/hoppscotch-common/src/services/scroll.service.ts b/packages/hoppscotch-common/src/services/scroll.service.ts new file mode 100644 index 0000000..9f057e7 --- /dev/null +++ b/packages/hoppscotch-common/src/services/scroll.service.ts @@ -0,0 +1,98 @@ +import { Service } from "dioc" + +/** + * Suffix type for different views in the application. + * This is used to identify the type of view for which the scroll position is being stored. + */ +export type Suffix = "json" | "raw" | "html" | "xml" | "preview" + +/** + * This service is used to store and manage scroll positions for different tabs and views. + * It keeps track of scroll positions using a key-value mapping where each key + * is a combination of tab ID and view suffix (like json, raw, html, etc.). + * + * The scroll data is maintained in-memory and not persisted anywhere. + */ +export class ScrollService extends Service { + public static readonly ID = "SCROLL_SERVICE" + + /** + * Internal map to store scroll positions. + * The key format is: `${tabId}::${suffix}` or any custom key. + */ + private scrollMap = new Map() + + /** + * Set the scroll position for a specific tab and view. + * @param tabId ID of the tab (e.g., request ID or panel ID) + * @param suffix View identifier (e.g., 'json', 'html', etc.) + * @param position Scroll position to store + */ + public setScroll(tabId: string, suffix: Suffix, position: number) { + const key = `${tabId}::${suffix}` + this.scrollMap.set(key, position) + } + + /** + * Get the scroll position for a specific tab and view. + * @param tabId ID of the tab + * @param suffix View identifier + * @returns Scroll position if available, otherwise undefined + */ + public getScroll(tabId: string, suffix: Suffix): number | undefined { + const key = `${tabId}::${suffix}` + return this.scrollMap.get(key) + } + + /** + * Clear scroll positions for all suffixes (views) related to a given tab. + * @param tabId ID of the tab + */ + public cleanupScrollForTab(tabId: string) { + const keysToDelete = Array.from(this.scrollMap.keys()) + for (const key of keysToDelete) { + const tabKey = key.split("::")[0] + if (tabKey === tabId) { + this.scrollMap.delete(key) + } + } + } + + /** + * Clear all scroll positions from the service. + * If no tabId is provided, all scroll positions will be cleared. + * @param tabId - ID of the tab not to clear. + */ + public cleanupAllScroll(tabId?: string) { + if (tabId) { + const keysToDelete = Array.from(this.scrollMap.keys()) + for (const key of keysToDelete) { + const tabKey = key.split("::")[0] + if (tabKey !== tabId) { + this.scrollMap.delete(key) + } + } + } else { + this.scrollMap.clear() + } + } + + /** + * Set scroll position directly by key (without tabId and suffix). + * Useful for custom or global use cases. + * @param key Unique identifier for scroll state + * @param position Scroll position to store + */ + public setScrollForKey(key: string, position: number) { + this.scrollMap.set(key, position) + } + + /** + * Get scroll position by key. + * @param key Unique identifier for scroll state + * @returns Scroll position if available, otherwise undefined + */ + public getScrollForKey(key: string): number | undefined { + return this.scrollMap.get(key) + } +} diff --git a/packages/hoppscotch-common/src/services/secret-environment.service.ts b/packages/hoppscotch-common/src/services/secret-environment.service.ts new file mode 100644 index 0000000..ae5a136 --- /dev/null +++ b/packages/hoppscotch-common/src/services/secret-environment.service.ts @@ -0,0 +1,194 @@ +import { Container, Service } from "dioc" +import { reactive, computed, watch, nextTick } from "vue" + +/** + * Defines a secret environment variable. + * Value is the current value of the variable. + * InitialValue is the value of the variable when it was created. + * VarIndex is the index of the variable in the environment. + */ +export type SecretVariable = { + key: string + value: string + varIndex: number + initialValue?: string +} + +/** + * This service is used to store and manage secret environments. + * The secret environments are not synced with the server. + * hence they are not persisted in the database. They are stored + * in the local storage of the browser. + */ +export class SecretEnvironmentService extends Service { + public static readonly ID = "SECRET_ENVIRONMENT_SERVICE" + + constructor(c: Container) { + super(c) + // Initialize the secret environments map + this.watchSecretEnvironments() + } + + /** + * Map of secret environments. + * The key is the ID of the secret environment. + * The value is the list of secret variables. + */ + public secretEnvironments = reactive(new Map()) + + /** + * Add a new secret environment. + * @param id ID of the environment + * @param secretVars List of secret variables + */ + public addSecretEnvironment(id: string, secretVars: SecretVariable[]) { + this.secretEnvironments.set(id, secretVars) + } + + /** + * Get a secret environment. + * @param id ID of the environment + */ + public getSecretEnvironment(id: string) { + return this.secretEnvironments.get(id) + } + + /** + * Get a secret environment variable. + * @param id ID of the environment + * @param varIndex Index of the variable in the environment + */ + public getSecretEnvironmentVariable(id: string, varIndex: number) { + const secretVars = this.getSecretEnvironment(id) + return secretVars?.find((secretVar) => secretVar.varIndex === varIndex) + } + + /** + * Used to get the initial and current value of a secret environment variable. + * @param id ID of the environment + * @param varIndex Index of the variable in the environment + */ + public getSecretEnvironmentVariableValue(id: string, varIndex: number) { + const secretVar = this.getSecretEnvironmentVariable(id, varIndex) + if (!secretVar) return null + return { + value: secretVar.value || "", + initialValue: secretVar.initialValue || "", + } + } + + /** + * + * @param secretEnvironments Used to load secret environments from persisted state. + */ + public loadSecretEnvironmentsFromPersistedState( + secretEnvironments: Record + ) { + if (secretEnvironments) { + this.secretEnvironments.clear() + + Object.entries(secretEnvironments).forEach(([id, secretVars]) => { + this.addSecretEnvironment(id, secretVars) + }) + } + } + + /** + * Delete a secret environment. + * @param id ID of the environment + */ + public deleteSecretEnvironment(id: string) { + this.secretEnvironments.delete(id) + } + + /** + * Delete a secret environment variable. + * @param id ID of the environment + * @param varIndex Index of the variable in the environment + */ + public removeSecretEnvironmentVariable(id: string, varIndex: number) { + const secretVars = this.getSecretEnvironment(id) + const newSecretVars = secretVars?.filter( + (secretVar) => secretVar.varIndex !== varIndex + ) + this.secretEnvironments.set(id, newSecretVars || []) + } + + /** + * Migrate entries from `oldID` to `newID`. Skips the `set` when nothing + * exists under `oldID` so a no-op migrate doesn't clobber `newID`. + */ + public updateSecretEnvironmentID(oldID: string, newID: string) { + // No-op when keys match — without this guard the get→set→delete + // sequence would erase the just-written entry for `oldID === newID`. + if (oldID === newID) return + const secretVars = this.getSecretEnvironment(oldID) + if (secretVars !== undefined) { + this.secretEnvironments.set(newID, secretVars) + } + this.secretEnvironments.delete(oldID) + } + + /** + * + * @param id ID of the environment + * @param key Key of the variable to check the value exists + * @returns true if the key has a secret value + */ + public hasSecretValue(id: string, key: string) { + return ( + this.secretEnvironments.has(id) && + this.secretEnvironments + .get(id)! + .some((secretVar) => secretVar.key === key && secretVar.value !== "") + ) + } + + /** + * Checks if a secret variable has an initial value set. + * @param id ID of the environment + * @param key Key of the variable to check the initial value exists + * @returns true if the key has an initial value + */ + public hasSecretInitialValue(id: string, key: string) { + return ( + this.secretEnvironments.has(id) && + this.secretEnvironments + .get(id)! + .some((secretVar) => secretVar.key === key && secretVar.initialValue) + ) + } + + /** + * Used to update the value of a secret environment variable. + */ + public persistableSecretEnvironments = computed(() => { + const secretEnvironments: Record = {} + this.secretEnvironments.forEach((secretVars, id) => { + secretEnvironments[id] = secretVars + }) + return secretEnvironments + }) + + /** + * Watches the secret environments for changes. + * If a secret variable is removed or has an empty key, it will be deleted. + */ + protected watchSecretEnvironments() { + watch( + () => this.secretEnvironments, + () => { + nextTick(() => { + this.secretEnvironments.forEach((secretVars, id) => { + const filteredVars = secretVars.filter((v) => v.key !== "") + + if (filteredVars.length === 0) { + this.secretEnvironments.delete(id) + } + }) + }) + }, + { deep: true } + ) + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/__tests__/index.spec.ts b/packages/hoppscotch-common/src/services/spotlight/__tests__/index.spec.ts new file mode 100644 index 0000000..d0a918a --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/__tests__/index.spec.ts @@ -0,0 +1,638 @@ +import { TestContainer } from "dioc/testing" +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest" +import { Ref, computed, nextTick, ref, watch } from "vue" +import { setPlatformDef } from "~/platform" +import { HoppSpotlightSessionEventData } from "~/platform/analytics" +import { + SpotlightSearcher, + SpotlightSearcherResult, + SpotlightSearcherSessionState, + SpotlightService, +} from "../" + +const echoSearcher: SpotlightSearcher = { + searcherID: "echo-searcher", + searcherSectionTitle: "Echo Searcher", + createSearchSession: (query: Readonly>) => { + // A basic searcher that returns the query string as the sole result + const loading = ref(false) + const results = ref([]) + + watch( + query, + (query) => { + loading.value = true + + results.value = [ + { + id: "searcher-a-result", + text: { + type: "text", + text: query, + }, + icon: {}, + score: 1, + }, + ] + + loading.value = false + }, + { immediate: true } + ) + + const onSessionEnd = () => { + /* noop */ + } + + return [ + computed(() => ({ + loading: loading.value, + results: results.value, + })), + onSessionEnd, + ] + }, + + onResultSelect: () => { + /* noop */ + }, +} + +const emptySearcher: SpotlightSearcher = { + searcherID: "empty-searcher", + searcherSectionTitle: "Empty Searcher", + createSearchSession: () => { + const loading = ref(false) + + return [ + computed(() => ({ + loading: loading.value, + results: [], + })), + () => { + /* noop */ + }, + ] + }, + onResultSelect: () => { + /* noop */ + }, +} + +describe("SpotlightService", () => { + beforeAll(() => { + setPlatformDef({ + // @ts-expect-error We're mocking the platform + analytics: { + logEvent: vi.fn(), + }, + }) + }) + + describe("registerSearcher", () => { + it("registers a searcher with a given ID", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + + const [id, searcher] = spotlight.getAllSearchers().next().value + + expect(id).toEqual("echo-searcher") + expect(searcher).toBe(echoSearcher) + }) + + it("if 2 searchers are registered with the same ID, the last one overwrites the first one", () => { + const echoSearcherFake: SpotlightSearcher = { + searcherID: "echo-searcher", + searcherSectionTitle: "Echo Searcher", + createSearchSession: () => { + throw new Error("not implemented") + }, + onResultSelect: () => { + throw new Error("not implemented") + }, + } + + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + spotlight.registerSearcher(echoSearcherFake) + + const [id, searcher] = spotlight.getAllSearchers().next().value + + expect(id).toEqual("echo-searcher") + expect(searcher).toBe(echoSearcherFake) + }) + }) + + describe("createSearchSession", () => { + it("when the source query changes, the searchers are notified", async () => { + const container = new TestContainer() + + const notifiedFn = vi.fn() + + const sampleSearcher: SpotlightSearcher = { + searcherID: "searcher", + searcherSectionTitle: "Searcher", + createSearchSession: (query) => { + const stop = watch(query, notifiedFn, { immediate: true }) + + return [ + ref({ + loading: false, + results: [], + }), + () => { + stop() + }, + ] + }, + onResultSelect: () => { + /* noop */ + }, + } + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(sampleSearcher) + + const query = ref("test") + + const [, dispose] = spotlight.createSearchSession(query) + + query.value = "test2" + await nextTick() + + expect(notifiedFn).toHaveBeenCalledTimes(2) + + dispose() + }) + + it("when a searcher returns results, they are added to the results", async () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + await nextTick() + + expect(session.value.results).toHaveProperty("echo-searcher") + expect(session.value.results["echo-searcher"]).toEqual({ + title: "Echo Searcher", + avgScore: 1, + results: [ + { + id: "searcher-a-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }, + ], + }) + + dispose() + }) + + it("when a searcher does not return any results, they are not added to the results", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(emptySearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + + expect(session.value.results).not.toHaveProperty("empty-searcher") + expect(session.value.results).toEqual({}) + + dispose() + }) + + it("when any of the searchers report they are loading, the search session says it is loading", () => { + const container = new TestContainer() + + const loadingSearcher: SpotlightSearcher = { + searcherID: "loading-searcher", + searcherSectionTitle: "Loading Searcher", + createSearchSession: () => { + const loading = ref(true) + const results = ref([]) + + return [ + computed(() => ({ + loading: loading.value, + results: results.value, + })), + () => { + /* noop */ + }, + ] + }, + onResultSelect: () => { + /* noop */ + }, + } + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(loadingSearcher) + spotlight.registerSearcher(echoSearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + + expect(session.value.loading).toBe(true) + + dispose() + }) + + it("when all of the searchers report they are not loading, the search session says it is not loading", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + spotlight.registerSearcher(emptySearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + + expect(session.value.loading).toBe(false) + + dispose() + }) + + it("when a searcher changes its loading state after a while, the search session state updates", async () => { + const container = new TestContainer() + + const loading = ref(true) + + const loadingSearcher: SpotlightSearcher = { + searcherID: "loading-searcher", + searcherSectionTitle: "Loading Searcher", + createSearchSession: () => { + return [ + computed(() => ({ + loading: loading.value, + results: [], + })), + () => { + /* noop */ + }, + ] + }, + onResultSelect: () => { + /* noop */ + }, + } + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(loadingSearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + + expect(session.value.loading).toBe(true) + + loading.value = false + + await nextTick() + + expect(session.value.loading).toBe(false) + + dispose() + }) + + it("when the searcher updates its results, the search session state updates", async () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + + expect(session.value.results).toHaveProperty("echo-searcher") + expect(session.value.results["echo-searcher"]).toEqual({ + title: "Echo Searcher", + avgScore: 1, + results: [ + { + id: "searcher-a-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }, + ], + }) + + query.value = "test2" + await nextTick() + + expect(session.value.results).toHaveProperty("echo-searcher") + expect(session.value.results["echo-searcher"]).toEqual({ + title: "Echo Searcher", + avgScore: 1, + results: [ + { + id: "searcher-a-result", + text: { + type: "text", + text: "test2", + }, + icon: {}, + score: 1, + }, + ], + }) + + dispose() + }) + + it("when the returned dispose function is called, the searchers are notified", () => { + const container = new TestContainer() + + const disposeFn = vi.fn() + + const testSearcher: SpotlightSearcher = { + searcherID: "test-searcher", + searcherSectionTitle: "Test Searcher", + createSearchSession: () => { + return [ + computed(() => ({ + loading: false, + results: [], + })), + disposeFn, + ] + }, + onResultSelect: () => { + /* noop */ + }, + } + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(testSearcher) + + const query = ref("test") + const [, dispose] = spotlight.createSearchSession(query) + + dispose() + + expect(disposeFn).toHaveBeenCalledOnce() + }) + + it("when the search session is disposed, changes to the query are not notified to the searchers", async () => { + const container = new TestContainer() + + const notifiedFn = vi.fn() + + const testSearcher: SpotlightSearcher = { + searcherID: "test-searcher", + searcherSectionTitle: "Test Searcher", + createSearchSession: (query) => { + const dispose = watch(query, notifiedFn, { immediate: true }) + + return [ + computed(() => ({ + loading: false, + results: [], + })), + dispose, + ] + }, + onResultSelect: () => { + /* noop */ + }, + } + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(testSearcher) + + const query = ref("test") + const [, dispose] = spotlight.createSearchSession(query) + + query.value = "test2" + await nextTick() + + expect(notifiedFn).toHaveBeenCalledTimes(2) + + dispose() + + query.value = "test3" + await nextTick() + + expect(notifiedFn).toHaveBeenCalledTimes(2) + }) + + describe("selectSearchResult", () => { + const onResultSelectFn = vi.fn() + + const testSearcher: SpotlightSearcher = { + searcherID: "test-searcher", + searcherSectionTitle: "Test Searcher", + createSearchSession: () => { + return [ + computed(() => ({ + loading: false, + results: [], + })), + () => { + /* noop */ + }, + ] + }, + onResultSelect: onResultSelectFn, + } + + beforeEach(() => { + onResultSelectFn.mockReset() + }) + + it("does nothing if the searcherID is invalid", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(testSearcher) + + spotlight.selectSearchResult("invalid-searcher-id", { + id: "test-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }) + + expect(onResultSelectFn).not.toHaveBeenCalled() + }) + + it("calls the corresponding searcher's onResultSelect method", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(testSearcher) + + spotlight.selectSearchResult("test-searcher", { + id: "test-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }) + + expect(onResultSelectFn).toHaveBeenCalledOnce() + }) + + it("passes the correct information to the searcher's onResultSelect method", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(testSearcher) + + spotlight.selectSearchResult("test-searcher", { + id: "test-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }) + + expect(onResultSelectFn).toHaveBeenCalledWith({ + id: "test-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }) + }) + }) + }) + + describe("getAllSearchers", () => { + it("when no searchers are registered, it returns an empty array", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + + expect(Array.from(spotlight.getAllSearchers())).toEqual([]) + }) + + it("when a searcher is registered, it returns an array with a tuple of the searcher id and then then searcher", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + + expect(Array.from(spotlight.getAllSearchers())).toEqual([ + ["echo-searcher", echoSearcher], + ]) + }) + + it("returns all registered searchers", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + spotlight.registerSearcher(emptySearcher) + + expect(Array.from(spotlight.getAllSearchers())).toEqual([ + ["echo-searcher", echoSearcher], + ["empty-searcher", emptySearcher], + ]) + }) + }) + + describe("getAnalyticsData", () => { + const analyticsData: HoppSpotlightSessionEventData = { + method: "click-spotlight-bar", + inputLength: 0, + action: "close", + rank: null, + searcherID: null, + sessionDuration: "0.9s", + } + + it("returns the initial state of `analyticsData` in a spotlight session", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + + expect(spotlight.getAnalyticsData()).toEqual({}) + }) + + it("returns the current state of `analyticsData` in a spotlight session", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.setAnalyticsData(analyticsData) + + expect(spotlight.getAnalyticsData()).toEqual(analyticsData) + }) + }) + + describe("setAnalyticsData", () => { + const analyticsData: HoppSpotlightSessionEventData = { + method: "click-spotlight-bar", + inputLength: 0, + action: "close", + rank: null, + searcherID: null, + sessionDuration: "0.9s", + } + + it("sets analytics data for the current spotlight session by merging with existing data", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + + // Session data, maintained outside and communicated to the service + spotlight.setAnalyticsData({ + method: "click-spotlight-bar", + action: "close", + rank: null, + searcherID: null, + }) + + // Session duration and input length are computed at the service level + const analyticsDataComputedInService: Partial = + { + inputLength: 0, + sessionDuration: "0.9s", + } + spotlight.setAnalyticsData(analyticsDataComputedInService) + + expect(spotlight.getAnalyticsData()).toEqual(analyticsData) + }) + + it("resets analytics data after a spotlight session", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + + // Populate `analyticsData` in the service context with sample data + spotlight.setAnalyticsData(analyticsData) + + const analyticsDataEmptyState = {} + + // Resets the state with the supplied data by specifying `false` for the `merge` argument + spotlight.setAnalyticsData(analyticsDataEmptyState, false) + + expect(spotlight.getAnalyticsData()).toEqual(analyticsDataEmptyState) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/spotlight/index.ts b/packages/hoppscotch-common/src/services/spotlight/index.ts new file mode 100644 index 0000000..000fbe0 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/index.ts @@ -0,0 +1,268 @@ +import { Service } from "dioc" +import { Component, effectScope, reactive, ref, watch, type Ref } from "vue" + +import { platform } from "~/platform" +import { HoppSpotlightSessionEventData } from "~/platform/analytics" + +/** + * Defines how to render the entry text in a Spotlight Search Result + */ +export type SpotlightResultTextType = + | { + type: "text" + /** + * The text to render. Passing an array of strings will render each string separated by a chevron + */ + text: string[] | string + } + | { + type: "custom" + /** + * The component to render in place of the text + */ + component: T + + /** + * The props to pass to the component + */ + componentProps: T extends Component ? Props : never + } + +/** + * Defines info about a spotlight light so the UI can render it + */ +export type SpotlightSearcherResult = { + /** + * The unique ID of the result + */ + id: string + + /** + * The text to render in the result + */ + text: SpotlightResultTextType + + /** + * The icon to render as the signifier of the result + */ + icon: object | Component + + /** + * The score of the result, the UI should sort the results by this + */ + score: number + + /** + * Additional metadata about the result + */ + meta?: { + /** + * The keyboard shortcut to trigger the result + */ + keyboardShortcut?: string[] + additionalInfo?: unknown + } +} + +/** + * Defines the state of a searcher during a spotlight search session + */ +export type SpotlightSearcherSessionState = { + /** + * Whether the searcher is currently loading results + */ + loading: boolean + + /** + * The results presented by the corresponding searcher in a session + */ + results: SpotlightSearcherResult[] +} + +export interface SpotlightSearcher { + searcherID: string + searcherSectionTitle: string + + createSearchSession( + query: Readonly> + ): [Ref, () => void] + + onResultSelect(result: SpotlightSearcherResult): void +} + +/** + * Defines the state of a searcher during a search session that + * is exposed to through the spotlight service + */ +export type SpotlightSearchSearcherState = { + title: string + avgScore: number + results: SpotlightSearcherResult[] +} + +/** + * Defines the state of a spotlight search session + */ +export type SpotlightSearchState = { + /** + * Whether any of the searchers are currently loading results + */ + loading: boolean + + /** + * The results presented by the corresponding searcher in a session + */ + results: Record +} + +export class SpotlightService extends Service { + public static readonly ID = "SPOTLIGHT_SERVICE" + + private analyticsData: HoppSpotlightSessionEventData = {} + private searchers: Map = new Map() + + /** + * Registers a searcher with the spotlight service + * @param searcher The searcher instance to register + */ + public registerSearcher(searcher: SpotlightSearcher) { + this.searchers.set(searcher.searcherID, searcher) + } + + /** + * Gets an iterator over all registered searchers and their IDs + */ + public getAllSearchers(): IterableIterator<[string, SpotlightSearcher]> { + return this.searchers.entries() + } + + /** + * Creates a new search session + * @param query A ref to the query to search for, updating this ref will notify the searchers about the change + * @returns A ref to the state of the search session and a function to end the session + */ + public createSearchSession( + query: Ref + ): [Ref, () => void] { + const startTime = Date.now() + + const searchSessions = Array.from(this.searchers.values()).map( + (x) => [x, ...x.createSearchSession(query)] as const + ) + + const loadingSearchers = reactive(new Set()) + const onSessionEndList: Array<() => void> = [] + + const resultObj = ref({ + loading: false, + results: {}, + }) + + const scopeHandle = effectScope() + + scopeHandle.run(() => { + for (const [searcher, state, onSessionEnd] of searchSessions) { + watch( + state, + (newState) => { + if (newState.loading) { + loadingSearchers.add(searcher.searcherID) + } else { + loadingSearchers.delete(searcher.searcherID) + } + + if (newState.results.length === 0) { + delete resultObj.value.results[searcher.searcherID] + } else { + resultObj.value.results[searcher.searcherID] = { + title: searcher.searcherSectionTitle, + avgScore: + newState.results.reduce((acc, x) => acc + x.score, 0) / + newState.results.length, + results: newState.results, + } + } + }, + { immediate: true } + ) + + onSessionEndList.push(onSessionEnd) + } + + watch( + query, + (newQuery) => { + this.setAnalyticsData({ + inputLength: newQuery.length, + }) + }, + { immediate: true } + ) + + watch( + loadingSearchers, + (set) => { + resultObj.value.loading = set.size > 0 + }, + { immediate: true } + ) + }) + + const onSearchEnd = () => { + scopeHandle.stop() + + for (const onEnd of onSessionEndList) { + onEnd() + } + + // Sets the session duration in the state for analytics event logging + const sessionDuration = `${((Date.now() - startTime) / 1000).toFixed(2)}s` + this.setAnalyticsData({ sessionDuration }) + + platform.analytics?.logEvent({ + type: "HOPP_SPOTLIGHT_SESSION", + ...this.analyticsData, + }) + + // Reset the state + this.setAnalyticsData({}, false) + } + + return [resultObj, onSearchEnd] + } + + /** + * Selects a search result. To be called when the user selects a result + * @param searcherID The ID of the searcher that the result belongs to + * @param result The result to look at + */ + public selectSearchResult( + searcherID: string, + result: SpotlightSearcherResult + ) { + this.searchers.get(searcherID)?.onResultSelect(result) + + // Sets the action indicating `success` and selected result score in the state for analytics event logging + this.setAnalyticsData({ + action: "success", + rank: result.score.toFixed(2), + searcherID, + }) + } + + /** + * Gets the analytics data for the current search session + */ + public getAnalyticsData(): HoppSpotlightSessionEventData { + return this.analyticsData + } + + /** + * Sets Analytics data for the current search session + * @param data The data to set + * @param merge Whether to merge the data with the existing data or replace it + */ + public setAnalyticsData(data: HoppSpotlightSessionEventData, merge = true) { + this.analyticsData = merge ? { ...this.analyticsData, ...data } : data + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/__tests__/history.searcher.spec.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/__tests__/history.searcher.spec.ts new file mode 100644 index 0000000..c46dea1 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/__tests__/history.searcher.spec.ts @@ -0,0 +1,457 @@ +import { TestContainer } from "dioc/testing" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { nextTick, ref } from "vue" +import { HoppAction, HoppActionWithArgs } from "~/helpers/actions" +import { getDefaultGQLRequest } from "~/helpers/graphql/default" +import { getDefaultRESTRequest } from "~/helpers/rest/default" +import { GQLHistoryEntry, RESTHistoryEntry } from "~/newstore/history" +import { RESTTabService } from "~/services/tab/rest" +import { SpotlightService } from "../.." +import { HistorySpotlightSearcherService } from "../history.searcher" + +async function flushPromises() { + return await new Promise((r) => setTimeout(r)) +} + +vi.mock("~/modules/i18n", () => ({ + __esModule: true, + getI18n: () => (x: string) => x, +})) + +const actionsMock = vi.hoisted(() => ({ + value: [] as (HoppAction | HoppActionWithArgs)[], + invokeAction: vi.fn(), +})) + +vi.mock("~/helpers/actions", async () => { + const { BehaviorSubject }: any = await vi.importActual("rxjs") + + return { + __esModule: true, + activeActions$: new BehaviorSubject(actionsMock.value), + invokeAction: actionsMock.invokeAction, + } +}) + +const historyMock = vi.hoisted(() => ({ + restEntries: [] as RESTHistoryEntry[], + gqlEntries: [] as GQLHistoryEntry[], +})) + +vi.mock("~/newstore/history", () => ({ + __esModule: true, + restHistoryStore: { + value: { + state: historyMock.restEntries, + }, + }, + graphqlHistoryStore: { + value: { + state: historyMock.gqlEntries, + }, + }, +})) + +vi.mock("~/lib/sync/history", () => ({ + __esModule: true, + def: { + initHistorySync: vi.fn(), + requestHistoryStore: { + isHistoryStoreEnabled: { value: true }, + isFetchingHistoryStoreStatus: { value: false }, + hasErrorFetchingHistoryStoreStatus: { value: false }, + }, + }, +})) + +describe("HistorySpotlightSearcherService", () => { + beforeEach(() => { + let x = actionsMock.value.pop() + while (x) { + x = actionsMock.value.pop() + } + + let y = historyMock.restEntries.pop() + while (y) { + y = historyMock.restEntries.pop() + } + + const container = new TestContainer() + + const createNewTabFn = vi.fn() + + container.bindMock(RESTTabService, { + createNewTab: createNewTabFn, + }) + + actionsMock.invokeAction.mockReset() + createNewTabFn.mockReset() + }) + + it("registers with the spotlight service upon initialization", async () => { + const container = new TestContainer() + + const registerSearcherFn = vi.fn() + + container.bindMock(SpotlightService, { + registerSearcher: registerSearcherFn, + }) + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + expect(registerSearcherFn).toHaveBeenCalledOnce() + expect(registerSearcherFn).toHaveBeenCalledWith(history) + }) + + it("returns a clear history result if the action is available", async () => { + const container = new TestContainer() + + actionsMock.value.push("history.clear") + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("his") + const [result] = history.createSearchSession(query) + await nextTick() + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: "clear-history", + }) + ) + }) + + it("doesn't return a clear history result if the action is not available", async () => { + const container = new TestContainer() + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("his") + const [result] = history.createSearchSession(query) + await nextTick() + + expect(result.value.results).not.toContainEqual( + expect.objectContaining({ + id: "clear-history", + }) + ) + }) + + it("selecting a clear history entry invokes the clear history action", async () => { + const container = new TestContainer() + + actionsMock.value.push("history.clear") + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("his") + const [result] = history.createSearchSession(query) + await nextTick() + + history.onResultSelect(result.value.results[0]) + + expect(actionsMock.invokeAction).toHaveBeenCalledOnce() + expect(actionsMock.invokeAction).toHaveBeenCalledWith("history.clear") + }) + + it("returns all the valid rest history entries for the search term", async () => { + actionsMock.value.push("rest.request.open") + + historyMock.restEntries.push({ + request: { + ...getDefaultRESTRequest(), + endpoint: "bla.com", + }, + responseMeta: { + duration: null, + statusCode: null, + }, + star: false, + v: 1, + updatedOn: new Date(), + }) + + const container = new TestContainer() + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("bla") + const [result] = history.createSearchSession(query) + await nextTick() + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: "rest-0", + text: { + type: "custom", + component: expect.anything(), + componentProps: expect.objectContaining({ + historyEntry: historyMock.restEntries[0], + }), + }, + }) + ) + }) + + it("selecting a rest history entry invokes action to open the rest request", async () => { + actionsMock.value.push("rest.request.open") + + const historyEntry: RESTHistoryEntry = { + request: { + ...getDefaultRESTRequest(), + endpoint: "bla.com", + }, + responseMeta: { + duration: null, + statusCode: null, + }, + star: false, + v: 1, + updatedOn: new Date(), + } + + historyMock.restEntries.push(historyEntry) + + const container = new TestContainer() + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("bla") + const [result] = history.createSearchSession(query) + await nextTick() + + const doc = result.value.results[0] + + history.onResultSelect(doc) + + expect(actionsMock.invokeAction).toHaveBeenCalledOnce() + expect(actionsMock.invokeAction).toHaveBeenCalledWith("rest.request.open", { + doc: { + request: historyEntry.request, + isDirty: false, + type: "request", + }, + }) + }) + + it("returns all the valid graphql history entries for the search term", async () => { + actionsMock.value.push("gql.request.open") + + historyMock.gqlEntries.push({ + request: { + ...getDefaultGQLRequest(), + url: "bla.com", + }, + response: "{}", + star: false, + v: 1, + updatedOn: new Date(), + }) + + const container = new TestContainer() + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("bla") + const [result] = history.createSearchSession(query) + await nextTick() + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: "gql-0", + text: { + type: "custom", + component: expect.anything(), + componentProps: expect.objectContaining({ + historyEntry: historyMock.gqlEntries[0], + }), + }, + }) + ) + }) + + it("selecting a graphql history entry invokes action to open the graphql request", async () => { + actionsMock.value.push("gql.request.open") + + const historyEntry: GQLHistoryEntry = { + request: { + ...getDefaultGQLRequest(), + url: "bla.com", + }, + response: "{}", + star: false, + v: 1, + updatedOn: new Date(), + } + + historyMock.gqlEntries.push(historyEntry) + + const container = new TestContainer() + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("bla") + const [result] = history.createSearchSession(query) + await nextTick() + + const doc = result.value.results[0] + + history.onResultSelect(doc) + + expect(actionsMock.invokeAction).toHaveBeenCalledOnce() + expect(actionsMock.invokeAction).toHaveBeenCalledWith("gql.request.open", { + request: historyEntry.request, + }) + }) + + it("rest history entries are not shown when the rest open action is not registered", async () => { + actionsMock.value.push("gql.request.open") + + historyMock.gqlEntries.push({ + request: { + ...getDefaultGQLRequest(), + url: "bla.com", + }, + response: "{}", + star: false, + v: 1, + updatedOn: new Date(), + }) + + historyMock.restEntries.push({ + request: { + ...getDefaultRESTRequest(), + endpoint: "bla.com", + }, + responseMeta: { + duration: null, + statusCode: null, + }, + star: false, + v: 1, + updatedOn: new Date(), + }) + + const container = new TestContainer() + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("bla") + const [result] = history.createSearchSession(query) + await nextTick() + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: expect.stringMatching(/^gql/), + }) + ) + expect(result.value.results).not.toContainEqual( + expect.objectContaining({ + id: expect.stringMatching(/^rest/), + }) + ) + }) + + it("gql history entries are not shown when the gql open action is not registered", async () => { + actionsMock.value.push("rest.request.open") + + historyMock.gqlEntries.push({ + request: { + ...getDefaultGQLRequest(), + url: "bla.com", + }, + response: "{}", + star: false, + v: 1, + updatedOn: new Date(), + }) + + historyMock.restEntries.push({ + request: { + ...getDefaultRESTRequest(), + endpoint: "bla.com", + }, + responseMeta: { + duration: null, + statusCode: null, + }, + star: false, + v: 1, + updatedOn: new Date(), + }) + + const container = new TestContainer() + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("bla") + const [result] = history.createSearchSession(query) + await nextTick() + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: expect.stringMatching(/^rest/), + }) + ) + expect(result.value.results).not.toContainEqual( + expect.objectContaining({ + id: expect.stringMatching(/^gql/), + }) + ) + }) + + it("none of the history entries are show when neither of the open actions are registered", async () => { + historyMock.gqlEntries.push({ + request: { + ...getDefaultGQLRequest(), + url: "bla.com", + }, + response: "{}", + star: false, + v: 1, + updatedOn: new Date(), + }) + + historyMock.restEntries.push({ + request: { + ...getDefaultRESTRequest(), + endpoint: "bla.com", + }, + responseMeta: { + duration: null, + statusCode: null, + }, + star: false, + v: 1, + updatedOn: new Date(), + }) + + const container = new TestContainer() + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("bla") + const [result] = history.createSearchSession(query) + await nextTick() + + expect(result.value.results).not.toContainEqual( + expect.objectContaining({ + id: expect.stringMatching(/^rest/), + }) + ) + expect(result.value.results).not.toContainEqual( + expect.objectContaining({ + id: expect.stringMatching(/^gql/), + }) + ) + }) +}) diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/__tests__/user.searcher.spec.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/__tests__/user.searcher.spec.ts new file mode 100644 index 0000000..ec67753 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/__tests__/user.searcher.spec.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, it, expect, vi } from "vitest" +import { TestContainer } from "dioc/testing" +import { UserSpotlightSearcherService } from "../user.searcher" +import { nextTick, ref } from "vue" +import { SpotlightService } from "../.." + +async function flushPromises() { + return await new Promise((r) => setTimeout(r)) +} + +vi.mock("~/modules/i18n", () => ({ + __esModule: true, + getI18n: () => (x: string) => x, +})) + +const actionsMock = vi.hoisted(() => ({ + value: ["user.login"], + invokeAction: vi.fn(), +})) + +vi.mock("~/helpers/actions", async () => { + const { BehaviorSubject }: any = await vi.importActual("rxjs") + + return { + __esModule: true, + activeActions$: new BehaviorSubject(actionsMock.value), + invokeAction: actionsMock.invokeAction, + } +}) + +describe("UserSearcher", () => { + beforeEach(() => { + let x = actionsMock.value.pop() + while (x) { + x = actionsMock.value.pop() + } + + actionsMock.invokeAction.mockReset() + }) + + it("registers with the spotlight service upon initialization", async () => { + const container = new TestContainer() + + const registerSearcherFn = vi.fn() + + container.bindMock(SpotlightService, { + registerSearcher: registerSearcherFn, + }) + + const user = container.bind(UserSpotlightSearcherService) + await flushPromises() + + expect(registerSearcherFn).toHaveBeenCalledOnce() + expect(registerSearcherFn).toHaveBeenCalledWith(user) + }) + + it("if login action is available, the search result should have the login result", async () => { + const container = new TestContainer() + + actionsMock.value.push("user.login") + const user = container.bind(UserSpotlightSearcherService) + await flushPromises() + + const query = ref("log") + const result = user.createSearchSession(query)[0] + await nextTick() + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: "login", + }) + ) + }) + + it("if login action is not available, the search result should not have the login result", async () => { + const container = new TestContainer() + + const user = container.bind(UserSpotlightSearcherService) + await flushPromises() + + const query = ref("log") + const result = user.createSearchSession(query)[0] + await nextTick() + + expect(result.value.results).not.toContainEqual( + expect.objectContaining({ + id: "login", + }) + ) + }) + + it("if logout action is available, the search result should have the logout result", async () => { + const container = new TestContainer() + + actionsMock.value.push("user.logout") + const user = container.bind(UserSpotlightSearcherService) + await flushPromises() + + const query = ref("log") + const result = user.createSearchSession(query)[0] + await nextTick() + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: "logout", + }) + ) + }) + + it("if logout action is not available, the search result should not have the logout result", async () => { + const container = new TestContainer() + + const user = container.bind(UserSpotlightSearcherService) + await flushPromises() + + const query = ref("log") + const result = user.createSearchSession(query)[0] + await nextTick() + + expect(result.value.results).not.toContainEqual( + expect.objectContaining({ + id: "logout", + }) + ) + }) + + it("if login action and logout action are available, the search result should have both results", async () => { + const container = new TestContainer() + + actionsMock.value.push("user.login", "user.logout") + const user = container.bind(UserSpotlightSearcherService) + await flushPromises() + + const query = ref("log") + const result = user.createSearchSession(query)[0] + await nextTick() + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: "logout", + }) + ) + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: "login", + }) + ) + }) + + it("selecting the login event should invoke the login action", () => { + const container = new TestContainer() + + actionsMock.value.push("user.login") + const user = container.bind(UserSpotlightSearcherService) + const query = ref("log") + + user.createSearchSession(query)[0] + + user.onDocSelected("login") + + expect(actionsMock.invokeAction).toHaveBeenCalledOnce() + expect(actionsMock.invokeAction).toHaveBeenCalledWith("user.login") + }) + + it("selecting the logout event should invoke the logout action", () => { + const container = new TestContainer() + + actionsMock.value.push("user.logout") + const user = container.bind(UserSpotlightSearcherService) + const query = ref("log") + + user.createSearchSession(query)[0] + + user.onDocSelected("logout") + expect(actionsMock.invokeAction).toHaveBeenCalledOnce() + expect(actionsMock.invokeAction).toHaveBeenCalledWith("user.logout") + }) + + it("selecting an invalid event should not invoke any action", () => { + const container = new TestContainer() + + actionsMock.value.push("user.logout") + const user = container.bind(UserSpotlightSearcherService) + const query = ref("log") + + user.createSearchSession(query)[0] + + user.onDocSelected("bla") + expect(actionsMock.invokeAction).not.toHaveBeenCalled() + }) +}) diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/base/__tests__/static.searcher.spec.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/base/__tests__/static.searcher.spec.ts new file mode 100644 index 0000000..cf576ff --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/base/__tests__/static.searcher.spec.ts @@ -0,0 +1,422 @@ +import { describe, it, expect, vi } from "vitest" +import { + SearchResult, + StaticSpotlightSearcherService, +} from "../static.searcher" +import { nextTick, reactive, ref } from "vue" +import { SpotlightSearcherResult } from "../../.." +import { TestContainer } from "dioc/testing" +import { Container } from "dioc" + +async function flushPromises() { + return await new Promise((r) => setTimeout(r)) +} + +describe("StaticSpotlightSearcherService", () => { + it("returns docs that have excludeFromSearch set to false", async () => { + class TestSearcherService extends StaticSpotlightSearcherService< + Record + > { + public static readonly ID = "TEST_SEARCHER_SERVICE" + + public readonly searcherID = "test" + public searcherSectionTitle = "test" + + private documents: Record = reactive({ + login: { + text: "Login", + excludeFromSearch: false, + }, + logout: { + text: "Logout", + excludeFromSearch: false, + }, + }) + + // TODO: dioc > v3 does not recommend using constructors, move to onServiceInit + constructor(c: Container) { + super(c, { + searchFields: ["text"], + fieldWeights: {}, + }) + } + + override onServiceInit() { + this.setDocuments(this.documents) + } + + protected getSearcherResultForSearchResult( + result: SearchResult> + ): SpotlightSearcherResult { + return { + id: result.id, + icon: {}, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + public onDocSelected(): void { + // noop + } + } + + const container = new TestContainer() + + const service = container.bind(TestSearcherService) + await flushPromises() + + const query = ref("log") + + const [results] = service.createSearchSession(query) + await nextTick() + + expect(results.value.results).toContainEqual( + expect.objectContaining({ + id: "login", + }) + ) + }) + + it("doesn't return docs that have excludeFromSearch set to true", async () => { + class TestSearcherServiceB extends StaticSpotlightSearcherService< + Record + > { + public static readonly ID = "TEST_SEARCHER_SERVICE_B" + + public readonly searcherID = "test" + public searcherSectionTitle = "test" + + private documents: Record = reactive({ + login: { + text: "Login", + excludeFromSearch: true, + }, + logout: { + text: "Logout", + excludeFromSearch: false, + }, + }) + + // TODO: dioc > v3 does not recommend using constructors, move to onServiceInit + constructor(c: Container) { + super(c, { + searchFields: ["text"], + fieldWeights: {}, + }) + } + + override onServiceInit() { + this.setDocuments(this.documents) + } + + protected getSearcherResultForSearchResult( + result: SearchResult> + ): SpotlightSearcherResult { + return { + id: result.id, + icon: {}, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + public onDocSelected(): void { + // noop + } + } + + const container = new TestContainer() + + const service = container.bind(TestSearcherServiceB) + await flushPromises() + + const query = ref("log") + const [results] = service.createSearchSession(query) + await nextTick() + + expect(results.value.results).not.toContainEqual( + expect.objectContaining({ + id: "login", + }) + ) + + expect(results.value.results).toContainEqual( + expect.objectContaining({ + id: "logout", + }) + ) + }) + + it("returns docs that have excludeFromSearch set to undefined", async () => { + class TestSearcherServiceC extends StaticSpotlightSearcherService< + Record + > { + public static readonly ID = "TEST_SEARCHER_SERVICE_C" + + public readonly searcherID = "test" + public searcherSectionTitle = "test" + + private documents: Record = reactive({ + login: { + text: "Login", + }, + logout: { + text: "Logout", + }, + }) + + // TODO: dioc > v3 does not recommend using constructors, move to onServiceInit + constructor(c: Container) { + super(c, { + searchFields: ["text"], + fieldWeights: {}, + }) + } + + override onServiceInit() { + this.setDocuments(this.documents) + } + + protected getSearcherResultForSearchResult( + result: SearchResult> + ): SpotlightSearcherResult { + return { + id: result.id, + icon: {}, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + public onDocSelected(): void { + // noop + } + } + + const container = new TestContainer() + + const service = container.bind(TestSearcherServiceC) + await flushPromises() + + const query = ref("log") + const [results] = service.createSearchSession(query) + await nextTick() + + expect(results.value.results).toContainEqual( + expect.objectContaining({ + id: "login", + }) + ) + + expect(results.value.results).toContainEqual( + expect.objectContaining({ + id: "logout", + }) + ) + }) + + it("onDocSelected is called with a valid doc id and doc when onResultSelect is called", async () => { + class TestSearcherServiceD extends StaticSpotlightSearcherService< + Record + > { + public static readonly ID = "TEST_SEARCHER_SERVICE_D" + + public readonly searcherID = "test" + public searcherSectionTitle = "test" + + public documents: Record = reactive({ + login: { + text: "Login", + }, + logout: { + text: "Logout", + }, + }) + + // TODO: dioc > v3 does not recommend using constructors, move to onServiceInit + constructor(c: Container) { + super(c, { + searchFields: ["text"], + fieldWeights: {}, + }) + } + + override onServiceInit() { + this.setDocuments(this.documents) + } + + protected getSearcherResultForSearchResult( + result: SearchResult> + ): SpotlightSearcherResult { + return { + id: result.id, + icon: {}, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + public onDocSelected = vi.fn() + } + + const container = new TestContainer() + + const service = container.bind(TestSearcherServiceD) + await flushPromises() + + const query = ref("log") + const [results] = service.createSearchSession(query) + await nextTick() + + const doc = results.value.results[0] + + service.onResultSelect(doc) + + expect(service.onDocSelected).toHaveBeenCalledOnce() + expect(service.onDocSelected).toHaveBeenCalledWith( + doc.id, + service.documents["login"] + ) + }) + + it("returns search results from entries as specified by getSearcherResultForSearchResult", async () => { + class TestSearcherServiceE extends StaticSpotlightSearcherService< + Record + > { + public static readonly ID = "TEST_SEARCHER_SERVICE_E" + + public readonly searcherID = "test" + public searcherSectionTitle = "test" + + public documents: Record = reactive({ + login: { + text: "Login", + }, + logout: { + text: "Logout", + }, + }) + + // TODO: dioc > v3 does not recommend using constructors, move to onServiceInit + constructor(c: Container) { + super(c, { + searchFields: ["text"], + fieldWeights: {}, + }) + } + + override onServiceInit() { + this.setDocuments(this.documents) + } + + protected getSearcherResultForSearchResult( + result: SearchResult> + ): SpotlightSearcherResult { + return { + id: result.id, + icon: {}, + text: { type: "text", text: result.doc.text.toUpperCase() }, + score: result.score, + } + } + + public onDocSelected(): void { + // noop + } + } + + const container = new TestContainer() + + const service = container.bind(TestSearcherServiceE) + await flushPromises() + + const query = ref("log") + const [results] = service.createSearchSession(query) + await nextTick() + + expect(results.value.results).toContainEqual( + expect.objectContaining({ + id: "login", + text: { type: "text", text: "LOGIN" }, + }) + ) + + expect(results.value.results).toContainEqual( + expect.objectContaining({ + id: "logout", + text: { type: "text", text: "LOGOUT" }, + }) + ) + }) + + it("indexes the documents by the 'searchFields' property and obeys multiple index fields", async () => { + class TestSearcherServiceF extends StaticSpotlightSearcherService< + Record + > { + public static readonly ID = "TEST_SEARCHER_SERVICE_F" + + public readonly searcherID = "test" + public searcherSectionTitle = "test" + + public documents: Record = reactive({ + login: { + text: "Login", + alternate: ["sign in"], + }, + logout: { + text: "Logout", + alternate: ["sign out"], + }, + }) + + // TODO: dioc > v3 does not recommend using constructors, move to onServiceInit + constructor(c: Container) { + super(c, { + searchFields: ["text", "alternate"], + fieldWeights: {}, + }) + } + + override onServiceInit() { + this.setDocuments(this.documents) + } + + protected getSearcherResultForSearchResult( + result: SearchResult> + ): SpotlightSearcherResult { + return { + id: result.id, + icon: {}, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + public onDocSelected(): void { + // noop + } + } + + const container = new TestContainer() + + const service = container.bind(TestSearcherServiceF) + await flushPromises() + + const query = ref("sign") + const [results] = service.createSearchSession(query) + await nextTick() + + expect(results.value.results).toContainEqual( + expect.objectContaining({ + id: "login", + }) + ) + + expect(results.value.results).toContainEqual( + expect.objectContaining({ + id: "logout", + }) + ) + }) +}) diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/base/static.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/base/static.searcher.ts new file mode 100644 index 0000000..056fc14 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/base/static.searcher.ts @@ -0,0 +1,179 @@ +import { Container, Service } from "dioc" +import { + type SpotlightSearcher, + type SpotlightSearcherResult, + type SpotlightSearcherSessionState, +} from "../.." +import MiniSearch from "minisearch" +import { Ref, computed, effectScope, ref, watch, toValue } from "vue" + +/** + * Defines a search result and additional metadata returned by a StaticSpotlightSearcher + */ +export type SearchResult = + { + id: string + score: number + doc: Doc + } + +/** + * Options for StaticSpotlightSearcher initialization + */ +export type StaticSpotlightSearcherOptions< + Doc extends object & { excludeFromSearch?: boolean }, +> = { + /** + * The array of field names in the given documents to search against + */ + searchFields: Array + + /** + * The weights to apply to each field in the search, this allows for certain + * fields to have more priority than others in the search and update the score + */ + fieldWeights?: Partial> + + /** + * How much the score should be boosted if the search matched fuzzily. + * Increasing this value generally makes the search ignore typos, but reduces performance + */ + fuzzyWeight?: number + + /** + * How much the score should be boosted if the search matched by prefix. + * For e.g, when searching for "hop", "hoppscotch" would match by prefix. + */ + prefixWeight?: number +} + +/** + * A base class for SpotlightSearcherServices that have a static set of documents + * that can optionally be toggled against (via the `excludeFromSearch` property in the Doc) + */ +export abstract class StaticSpotlightSearcherService< + Doc extends object & { excludeFromSearch?: boolean }, +> + extends Service + implements SpotlightSearcher +{ + public abstract readonly searcherID: string + public abstract readonly searcherSectionTitle: string + + private minisearch: MiniSearch + + private loading = ref(false) + + private _documents: Record = {} + + // TODO: This pattern is no longer recommended in dioc > 3, move to something else + constructor( + c: Container, + private opts: StaticSpotlightSearcherOptions + ) { + super(c) + + this.minisearch = new MiniSearch({ + fields: opts.searchFields as string[], + }) + } + + /** + * Sets the documents to search against. + * NOTE: We generally expect this function to only be called once and we expect + * the documents to not change generally. You can pass a reactive object, if you want to toggle + * states if you want to. + * @param docs The documents to search against, this is an object, with the key being the document ID + */ + protected setDocuments(docs: Record) { + this._documents = docs + + this.addDocsToSearchIndex() + } + + private async addDocsToSearchIndex() { + this.loading.value = true + + this.minisearch = new MiniSearch({ + fields: this.opts.searchFields as string[], + }) + + await this.minisearch.addAllAsync( + Object.entries(this._documents).map(([id, doc]) => ({ + id, + ...doc, + })) + ) + + this.loading.value = false + } + + /** + * Specifies how to convert a document into the Spotlight entry format + * @param result The search result to convert + */ + protected abstract getSearcherResultForSearchResult( + result: SearchResult + ): SpotlightSearcherResult + + public createSearchSession( + query: Readonly> + ): [Ref, () => void] { + const results = ref([]) + + const resultObj = computed(() => ({ + loading: this.loading.value, + results: results.value, + })) + + const scopeHandle = effectScope() + + scopeHandle.run(() => { + watch( + [query, () => this._documents], + ([query, docs]) => { + const searchResults = this.minisearch.search(query, { + prefix: true, + boost: (this.opts.fieldWeights as any) ?? {}, + weights: { + fuzzy: this.opts.fuzzyWeight ?? 0.2, + prefix: this.opts.prefixWeight ?? 0.6, + }, + }) + + results.value = searchResults + .filter( + (result) => + this._documents[result.id].excludeFromSearch === undefined || + this._documents[result.id].excludeFromSearch === false + ) + .map((result) => { + return this.getSearcherResultForSearchResult({ + id: result.id, + score: result.score, + doc: docs[result.id], + }) + }) + }, + { immediate: true } + ) + }) + + const onSessionEnd = () => { + scopeHandle.stop() + } + + return [resultObj, onSessionEnd] + } + + /** + * Called when a document is selected from the search results + * @param id The ID of the document selected + * @param doc The document information of the document selected + */ + public abstract onDocSelected(id: string, doc: Doc): void + + public onResultSelect(result: SpotlightSearcherResult): void { + this.onDocSelected(result.id, toValue(this._documents)[result.id]) + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/collections.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/collections.searcher.ts new file mode 100644 index 0000000..d0884a4 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/collections.searcher.ts @@ -0,0 +1,364 @@ +import { Service } from "dioc" +import { + SpotlightResultTextType, + SpotlightSearcher, + SpotlightSearcherResult, + SpotlightSearcherSessionState, + SpotlightService, +} from "../" +import { Ref, computed, effectScope, markRaw, ref, watch } from "vue" +import { getI18n } from "~/modules/i18n" +import MiniSearch from "minisearch" +import { + cascadeParentCollectionForProperties, + graphqlCollectionStore, + restCollectionStore, +} from "~/newstore/collections" +import IconFolder from "~icons/lucide/folder" +import IconImport from "~icons/lucide/folder-down" +import RESTRequestSpotlightEntry from "~/components/app/spotlight/entry/RESTRequest.vue" +import GQLRequestSpotlightEntry from "~/components/app/spotlight/entry/GQLRequest.vue" +import { + HoppCollection, + HoppGQLRequest, + HoppRESTRequest, +} from "@hoppscotch/data" +import { WorkspaceService } from "~/services/workspace.service" +import { invokeAction } from "~/helpers/actions" +import { RESTTabService } from "~/services/tab/rest" +import { GQLTabService } from "~/services/tab/graphql" + +/** + * A spotlight searcher that searches through the user's collections + * + * NOTE: Initializing this service registers it as a searcher with the Spotlight Service. + */ +export class CollectionsSpotlightSearcherService + extends Service + implements SpotlightSearcher +{ + public static readonly ID = "COLLECTIONS_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public searcherID = "collections" + public searcherSectionTitle = this.t("collection.my_collections") + + private readonly restTab = this.bind(RESTTabService) + private readonly gqlTab = this.bind(GQLTabService) + + private readonly spotlight = this.bind(SpotlightService) + private readonly workspaceService = this.bind(WorkspaceService) + + override onServiceInit() { + this.spotlight.registerSearcher(this) + } + + private loadGQLDocsIntoMinisearch(minisearch: MiniSearch) { + const gqlCollsQueue = [ + ...graphqlCollectionStore.value.state.map((coll, index) => ({ + coll: coll, + index: `${index}`, + })), + ] + + while (gqlCollsQueue.length > 0) { + const { coll, index } = gqlCollsQueue.shift()! + + gqlCollsQueue.push( + ...coll.folders.map((folder, folderIndex) => ({ + coll: folder, + index: `${index}/${folderIndex}`, + })) + ) + + minisearch.addAll( + coll.requests.map((req, reqIndex) => ({ + id: `gql-${index}/${reqIndex}`, + name: req.name, + })) + ) + } + } + + private loadRESTDocsIntoMinisearch(minisearch: MiniSearch) { + const restDocsQueue = [ + ...restCollectionStore.value.state.map((coll, index) => ({ + coll: coll, + index: `${index}`, + })), + ] + + while (restDocsQueue.length > 0) { + const { coll, index } = restDocsQueue.shift()! + + restDocsQueue.push( + ...coll.folders.map((folder, folderIndex) => ({ + coll: folder, + index: `${index}/${folderIndex}`, + })) + ) + + minisearch.addAll( + coll.requests.map((req, reqIndex) => ({ + id: `rest-${index}/${reqIndex}`, + name: req.name, + })) + ) + } + } + + private getCurrentPageCategory() { + // TODO: Better logic for this ? + try { + const url = new URL(window.location.href) + + if (url.pathname.startsWith("/graphql")) { + return "graphql" + } else if (url.pathname === "/") { + return "rest" + } + return "other" + } catch (_e) { + return "other" + } + } + + public createSearchSession( + query: Readonly> + ): [Ref, () => void] { + const pageCategory = this.getCurrentPageCategory() + + const minisearch = new MiniSearch({ + fields: ["name"], + storeFields: ["name"], + searchOptions: { + prefix: true, + fuzzy: true, + boost: { + name: 2, + }, + weights: { + fuzzy: 0.2, + prefix: 0.8, + }, + }, + }) + + if (pageCategory === "rest" || pageCategory === "graphql") { + minisearch.add({ + id: `create-collection`, + name: this.t("collection.new"), + }) + minisearch.add({ + id: "import-collection", + name: this.t("collection.import"), + }) + } + + if (pageCategory === "rest") { + this.loadRESTDocsIntoMinisearch(minisearch) + } else if (pageCategory === "graphql") { + this.loadGQLDocsIntoMinisearch(minisearch) + } + + const results = ref([]) + + const scopeHandle = effectScope() + + const newCollectionText: SpotlightResultTextType = { + type: "text", + text: this.t("collection.new"), + } + + const importCollectionText: SpotlightResultTextType = { + type: "text", + text: this.t("collection.import_collection"), + } + + scopeHandle.run(() => { + const isPersonalWorkspace = computed( + () => this.workspaceService.currentWorkspace.value.type === "personal" + ) + + watch(query, (query) => { + if (!isPersonalWorkspace.value) { + results.value = [] + return + } + + if (pageCategory === "other") { + results.value = [] + return + } + const getResultText = (id: string): SpotlightResultTextType => { + if (id === "create-collection") return newCollectionText + else if (id === "import-collection") return importCollectionText + return { + type: "custom", + component: markRaw( + pageCategory === "rest" + ? RESTRequestSpotlightEntry + : GQLRequestSpotlightEntry + ), + componentProps: { + folderPath: id.split( + pageCategory === "rest" ? "rest-" : "gql-" + )[1], + }, + } + } + + const getResultIcon = (id: string) => { + if (id === "import-collection") return markRaw(IconImport) + return markRaw(IconFolder) + } + + const searchResults = minisearch.search(query).slice(0, 10) + + results.value = searchResults.map((result) => ({ + id: result.id, + text: getResultText(result.id), + icon: getResultIcon(result.id), + score: result.score, + })) + }) + }) + + const resultObj = computed(() => ({ + loading: false, + results: results.value, + })) + + return [ + resultObj, + () => { + scopeHandle.stop() + }, + ] + } + + private getRESTFolderFromFolderPath( + folderPath: string + ): HoppCollection | undefined { + try { + const folderIndicies = folderPath.split("/").map((x) => parseInt(x)) + + let currentFolder = + restCollectionStore.value.state[folderIndicies.shift()!] + + while (folderIndicies.length > 0) { + const folderIndex = folderIndicies.shift()! + + const folder = currentFolder.folders[folderIndex] + + currentFolder = folder + } + + return currentFolder + } catch (e) { + console.error(e) + return undefined + } + } + + private getGQLFolderFromFolderPath( + folderPath: string + ): HoppCollection | undefined { + try { + const folderIndicies = folderPath.split("/").map((x) => parseInt(x)) + + let currentFolder = + graphqlCollectionStore.value.state[folderIndicies.shift()!] + + while (folderIndicies.length > 0) { + const folderIndex = folderIndicies.shift()! + + const folder = currentFolder.folders[folderIndex] + + currentFolder = folder + } + + return currentFolder + } catch (e) { + console.error(e) + return undefined + } + } + + public onResultSelect(result: SpotlightSearcherResult): void { + if (result.id === "create-collection") return invokeAction("collection.new") + + if (result.id === "import-collection") + return invokeAction(`modals.collection.import`) + + const [type, path] = result.id.split("-") + + if (type === "rest") { + const folderPath = path.split("/").map((x) => parseInt(x)) + const reqIndex = folderPath.pop()! + + if (this.workspaceService.currentWorkspace.value.type !== "personal") { + this.workspaceService.changeWorkspace({ + type: "personal", + }) + } + + const possibleTab = this.restTab.getTabRefWithSaveContext({ + originLocation: "user-collection", + folderPath: folderPath.join("/"), + requestIndex: reqIndex, + }) + + if (possibleTab) { + this.restTab.setActiveTab(possibleTab.value.id) + } else { + const req = this.getRESTFolderFromFolderPath(folderPath.join("/")) + ?.requests[reqIndex] as HoppRESTRequest + + if (!req) return + + this.restTab.createNewTab( + { + type: "request", + request: req, + isDirty: false, + saveContext: { + originLocation: "user-collection", + folderPath: folderPath.join("/"), + requestIndex: reqIndex, + }, + inheritedProperties: cascadeParentCollectionForProperties( + folderPath.join("/"), + "rest" + ), + }, + true + ) + } + } else if (type === "gql") { + const folderPath = path.split("/").map((x) => parseInt(x)) + const reqIndex = folderPath.pop()! + + const req = this.getGQLFolderFromFolderPath(folderPath.join("/")) + ?.requests[reqIndex] as HoppGQLRequest + + if (!req) return + + this.gqlTab.createNewTab({ + saveContext: { + originLocation: "user-collection", + folderPath: folderPath.join("/"), + requestIndex: reqIndex, + }, + cursorPosition: 0, + request: req, + isDirty: false, + inheritedProperties: cascadeParentCollectionForProperties( + folderPath.join("/"), + "graphql" + ), + }) + } + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/environment.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/environment.searcher.ts new file mode 100644 index 0000000..90cfb99 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/environment.searcher.ts @@ -0,0 +1,493 @@ +import { + Component, + Ref, + computed, + effectScope, + markRaw, + reactive, + ref, + watch, +} from "vue" +import { activeActions$, invokeAction } from "~/helpers/actions" +import { getI18n } from "~/modules/i18n" +import { + SpotlightSearcher, + SpotlightSearcherResult, + SpotlightSearcherSessionState, + SpotlightService, +} from ".." +import { + SearchResult, + StaticSpotlightSearcherService, +} from "./base/static.searcher" + +import IconCopy from "~icons/lucide/copy" +import IconEdit from "~icons/lucide/edit" +import IconLayers from "~icons/lucide/layers" +import IconTrash2 from "~icons/lucide/trash-2" + +import { Container, Service } from "dioc" +import * as TE from "fp-ts/TaskEither" +import { pipe } from "fp-ts/function" +import { cloneDeep } from "lodash-es" +import MiniSearch from "minisearch" +import { map } from "rxjs" +import { useStreamStatic } from "~/composables/stream" +import { GQLError, runGQLQuery } from "~/helpers/backend/GQLClient" +import { deleteTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment" +import { + SelectedEnvironmentIndex, + createEnvironment, + currentEnvironment$, + duplicateEnvironment, + environmentsStore, + getGlobalVariables, + selectedEnvironmentIndex$, + setSelectedEnvironmentIndex, +} from "~/newstore/environments" + +import * as E from "fp-ts/Either" +import IconCheckCircle from "~/components/app/spotlight/entry/IconSelected.vue" +import { useToast } from "~/composables/toast" +import { GetTeamEnvironmentsDocument } from "~/helpers/backend/graphql" +import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment" +import { WorkspaceService } from "~/services/workspace.service" +import IconCircle from "~icons/lucide/circle" + +type Doc = { + text: string | string[] + alternates: string[] + icon: object | Component + excludeFromSearch?: boolean +} + +type SelectedEnv = { + selected?: boolean +} & ( + | Omit + | (SelectedEnvironmentIndex & { type: "MY_ENV" }) +) + +/** + * + * This searcher is responsible for providing environments related actions on the spotlight results. + * + * NOTE: Initializing this service registers it as a searcher with the Spotlight Service. + */ +export class EnvironmentsSpotlightSearcherService extends StaticSpotlightSearcherService { + public static readonly ID = "ENVIRONMENTS_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public readonly searcherID = "environments" + public searcherSectionTitle = this.t("spotlight.environments.title") + + private readonly workspaceService = this.bind(WorkspaceService) + + private workspace = this.workspaceService.currentWorkspace + + private readonly spotlight = this.bind(SpotlightService) + + private selectedEnvIndex = useStreamStatic( + selectedEnvironmentIndex$, + { + type: "NO_ENV_SELECTED", + }, + () => { + /* noop */ + } + )[0] + + private selectedEnv = useStreamStatic(currentEnvironment$, null, () => { + /* noop */ + })[0] + + private hasSelectedEnv = computed( + () => this.selectedEnvIndex.value?.type !== "NO_ENV_SELECTED" + ) + + private documents: Record = reactive({ + new_environment: { + text: [ + this.t("spotlight.environments.title"), + this.t("spotlight.environments.new"), + ], + alternates: ["new", "environment"], + icon: markRaw(IconLayers), + }, + new_environment_variable: { + text: [ + this.t("spotlight.environments.title"), + this.t("spotlight.environments.new_variable"), + ], + alternates: ["new", "environment", "variable"], + icon: markRaw(IconLayers), + }, + edit_selected_env: { + text: [ + this.t("spotlight.environments.title"), + this.t("spotlight.environments.edit"), + ], + alternates: ["edit", "environment"], + icon: markRaw(IconEdit), + excludeFromSearch: computed(() => !this.hasSelectedEnv.value), + }, + delete_selected_env: { + text: [ + this.t("spotlight.environments.title"), + this.t("spotlight.environments.delete"), + ], + alternates: ["delete", "environment"], + icon: markRaw(IconTrash2), + excludeFromSearch: computed(() => !this.hasSelectedEnv.value), + }, + duplicate_selected_env: { + text: [ + this.t("spotlight.environments.title"), + this.t("spotlight.environments.duplicate"), + ], + alternates: ["duplicate", "environment"], + icon: markRaw(IconCopy), + excludeFromSearch: computed(() => !this.hasSelectedEnv.value), + }, + edit_global_env: { + text: [ + this.t("spotlight.environments.title"), + this.t("spotlight.environments.edit_global"), + ], + alternates: ["edit", "global", "environment"], + icon: markRaw(IconEdit), + }, + duplicate_global_env: { + text: [ + this.t("spotlight.environments.title"), + this.t("spotlight.environments.duplicate_global"), + ], + alternates: ["duplicate", "global", "environment"], + icon: markRaw(IconCopy), + }, + }) + + // TODO: This pattern is no longer recommended in dioc > 3, move to something else + constructor(c: Container) { + super(c, { + searchFields: ["text", "alternates"], + fieldWeights: { + text: 2, + alternates: 1, + }, + }) + } + + override onServiceInit() { + this.setDocuments(this.documents) + this.spotlight.registerSearcher(this) + } + + protected getSearcherResultForSearchResult( + result: SearchResult + ): SpotlightSearcherResult { + return { + id: result.id, + icon: result.doc.icon, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + private getSelectedText() { + const selection = window.getSelection() + return selection?.toString() ?? "" + } + + duplicateGlobalEnv() { + createEnvironment( + `Global - ${this.t("action.duplicate")}`, + cloneDeep(getGlobalVariables()) + ) + // this.toast.success(`${t("environment.duplicated")}`) + } + + duplicateSelectedEnv() { + if (this.selectedEnvIndex.value?.type === "NO_ENV_SELECTED") return + + if (this.selectedEnvIndex.value?.type === "MY_ENV") { + duplicateEnvironment(this.selectedEnvIndex.value.index) + // this.toast.success(`${t("environment.duplicated")}`) + } + + if (this.selectedEnvIndex.value?.type === "TEAM_ENV") { + pipe( + deleteTeamEnvironment(this.selectedEnvIndex.value.teamEnvID), + TE.match( + (err: GQLError) => { + console.error(err) + }, + () => { + // this.toast.success(`${this.t("environment.duplicated")}`) + } + ) + )() + } + } + + public onDocSelected(id: string): void { + switch (id) { + case "new_environment": + invokeAction(`modals.environment.new`) + break + case "new_environment_variable": + invokeAction(`modals.environment.add`, { + envName: "", + variableName: this.getSelectedText(), + }) + break + case "edit_selected_env": + if (this.selectedEnv.value) { + if (this.workspace.value.type === "personal") { + invokeAction(`modals.my.environment.edit`, { + envName: this.selectedEnv.value.name, + }) + } else { + invokeAction(`modals.team.environment.edit`, { + envName: this.selectedEnv.value.name, + }) + } + } + break + case "delete_selected_env": + invokeAction(`modals.environment.delete-selected`) + break + case "duplicate_selected_env": + this.duplicateSelectedEnv() + break + case "edit_global_env": + invokeAction(`modals.global.environment.update`, {}) + break + case "duplicate_global_env": + this.duplicateGlobalEnv() + break + } + } +} + +/** + * This searcher is responsible for searching through the environment. + * And switching between them. + */ +export class SwitchEnvSpotlightSearcherService + extends Service + implements SpotlightSearcher +{ + public static readonly ID = "SWITCH_ENV_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + private toast = useToast() + + public searcherID = "switch_env" + public searcherSectionTitle = this.t("tab.environments") + + private readonly spotlight = this.bind(SpotlightService) + private readonly workspaceService = this.bind(WorkspaceService) + private teamEnvironmentList: TeamEnvironment[] = [] + + override onServiceInit() { + this.spotlight.registerSearcher(this) + } + + private selectedEnvIndex = useStreamStatic( + selectedEnvironmentIndex$, + { + type: "NO_ENV_SELECTED", + }, + () => { + /* noop */ + } + )[0] + + private environmentSearchable = useStreamStatic( + activeActions$.pipe( + map((actions) => actions.includes("modals.environment.add")) + ), + activeActions$.value.includes("modals.environment.add"), + () => { + /* noop */ + } + )[0] + + async fetchTeamEnvironmentList(teamID: string): Promise { + const results: TeamEnvironment[] = [] + + const result = await runGQLQuery({ + query: GetTeamEnvironmentsDocument, + variables: { + teamID: teamID, + }, + }) + + if (E.isRight(result)) { + if (result.right.team) { + results.push( + ...result.right.team.teamEnvironments.map( + ({ id, teamID, name, variables }) => + { + id: id, + teamID: teamID, + environment: { + name: name, + variables: JSON.parse(variables), + }, + } + ) + ) + } + } + + return results + } + + createSearchSession( + query: Readonly> + ): [Ref, () => void] { + const loading = ref(false) + const results = ref([]) + + const minisearch = new MiniSearch({ + fields: ["name", "alternates"], + storeFields: ["name"], + }) + + if (this.environmentSearchable.value) { + minisearch.addAll( + environmentsStore.value.environments.map((entry, index) => { + const id: SelectedEnv = { + type: "MY_ENV", + index, + } + + if ( + this.selectedEnvIndex.value?.type === "MY_ENV" && + this.selectedEnvIndex.value.index === index + ) { + id.selected = true + } + return { + id: JSON.stringify(id), + name: entry.name, + alternates: ["environment", "change", entry.name], + } + }) + ) + + const workspace = this.workspaceService.currentWorkspace + + if (workspace.value?.type === "team") { + this.fetchTeamEnvironmentList(workspace.value.teamID).then( + (results) => { + this.teamEnvironmentList = results + minisearch.addAll( + results.map(({ teamID, id: teamEnvID, environment }) => { + const id: SelectedEnv = { + type: "TEAM_ENV", + teamID, + teamEnvID, + } + + if ( + this.selectedEnvIndex.value?.type === "TEAM_ENV" && + this.selectedEnvIndex.value.teamEnvID === teamEnvID + ) { + id.selected = true + } + return { + id: JSON.stringify(id), + name: environment.name, + alternates: ["environment", "change", environment.name], + } + }) + ) + } + ) + } + } + + const scopeHandle = effectScope() + + scopeHandle.run(() => { + watch( + [query], + ([query]) => { + results.value = minisearch + .search(query, { + prefix: true, + fuzzy: true, + boost: { + reltime: 2, + }, + weights: { + fuzzy: 0.2, + prefix: 0.8, + }, + }) + .map(({ id, score, name }) => { + return { + id: id, + icon: markRaw( + JSON.parse(id).selected ? IconCheckCircle : IconCircle + ), + score: score, + text: { + type: "text", + text: [this.t("environment.set"), name], + }, + } + }) + }, + { immediate: true } + ) + }) + + const onSessionEnd = () => { + scopeHandle.stop() + minisearch.removeAll() + } + + const resultObj = computed(() => ({ + loading: loading.value, + results: results.value, + })) + + return [resultObj, onSessionEnd] + } + + onResultSelect(result: SpotlightSearcherResult): void { + try { + const selectedEnv = JSON.parse(result.id) as SelectedEnv + + if (selectedEnv.type === "MY_ENV") { + setSelectedEnvironmentIndex({ + type: "MY_ENV", + index: selectedEnv.index, + }) + } + + if (selectedEnv.type === "TEAM_ENV") { + const teamEnv = this.teamEnvironmentList.find( + ({ id }) => id === selectedEnv.teamEnvID + ) + if (!teamEnv) return + + const { teamID, teamEnvID } = selectedEnv + setSelectedEnvironmentIndex({ + type: "TEAM_ENV", + teamEnvID, + teamID, + environment: teamEnv.environment, + }) + } + } catch (e) { + console.error((e as Error).message) + this.toast.error(this.t("error.something_went_wrong")) + } + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/general.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/general.searcher.ts new file mode 100644 index 0000000..697f3e5 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/general.searcher.ts @@ -0,0 +1,132 @@ +import { Component, markRaw, reactive } from "vue" +import { invokeAction } from "~/helpers/actions" +import { getI18n } from "~/modules/i18n" +import { SpotlightSearcherResult, SpotlightService } from ".." +import { + SearchResult, + StaticSpotlightSearcherService, +} from "./base/static.searcher" + +import IconLinkedIn from "~icons/brands/linkedin" +import IconTwitter from "~icons/brands/twitter" +import IconDiscord from "~icons/brands/discord" +import IconGitHub from "~icons/lucide/github" +import IconBook from "~icons/lucide/book" +import IconLifeBuoy from "~icons/lucide/life-buoy" +import IconZap from "~icons/lucide/zap" +import { platform } from "~/platform" +import { Container } from "dioc" + +type Doc = { + text: string | string[] + alternates: string[] + icon: object | Component + action: () => void +} + +/** + * + * This searcher is responsible for providing general related actions on the spotlight results. + * + * NOTE: Initializing this service registers it as a searcher with the Spotlight Service. + */ +export class GeneralSpotlightSearcherService extends StaticSpotlightSearcherService { + public static readonly ID = "GENERAL_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public readonly searcherID = "general" + public searcherSectionTitle = this.t("spotlight.general.title") + + private readonly spotlight = this.bind(SpotlightService) + + private documents: Record = reactive({ + open_help: { + text: this.t("spotlight.general.help_menu"), + alternates: ["help", "hoppscotch"], + icon: markRaw(IconLifeBuoy), + action() { + invokeAction("modals.support.toggle") + }, + }, + open_docs: { + text: this.t("spotlight.general.open_docs"), + alternates: ["docs", "documentation", "hoppscotch"], + icon: markRaw(IconBook), + action: () => this.openURL("https://docs.hoppscotch.io"), + }, + open_keybindings: { + text: this.t("spotlight.general.open_keybindings"), + alternates: ["key", "shortcuts", "binding"], + icon: markRaw(IconZap), + action() { + invokeAction("flyouts.keybinds.toggle") + }, + }, + open_github: { + text: this.t("spotlight.general.open_github"), + alternates: ["repository", "github", "documentation", "hoppscotch"], + icon: markRaw(IconGitHub), + action: () => this.openURL("https://hoppscotch.io/github"), + }, + link_twitter: { + text: [this.t("spotlight.general.social"), "Twitter"], + alternates: ["social", "twitter", "link"], + icon: markRaw(IconTwitter), + action: () => this.openURL("https://x.com/hoppscotch_io"), + }, + link_discord: { + text: [this.t("spotlight.general.social"), "Discord"], + alternates: ["social", "discord", "link"], + icon: markRaw(IconDiscord), + action: () => this.openURL("https://hoppscotch.io/discord"), + }, + link_linkedin: { + text: [this.t("spotlight.general.social"), "LinkedIn"], + alternates: ["social", "linkedin", "link"], + icon: markRaw(IconLinkedIn), + action: () => + this.openURL("https://www.linkedin.com/company/hoppscotch/"), + }, + }) + + // TODO: This is not recommended as of dioc > 3. Move to onServiceInit instead + constructor(c: Container) { + super(c, { + searchFields: ["text", "alternates"], + fieldWeights: { + text: 2, + alternates: 1, + }, + }) + } + + override onServiceInit() { + this.setDocuments(this.documents) + this.spotlight.registerSearcher(this) + } + + protected getSearcherResultForSearchResult( + result: SearchResult + ): SpotlightSearcherResult { + return { + id: result.id, + icon: result.doc.icon, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + private openURL(url: string) { + platform.kernelIO.openExternalLink({ url }) + } + + public onDocSelected(id: string): void { + this.documents[id]?.action() + } + + public addCustomEntries(docs: Record) { + this.documents = { ...this.documents, ...docs } + this.setDocuments(this.documents) + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/history.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/history.searcher.ts new file mode 100644 index 0000000..d9717b8 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/history.searcher.ts @@ -0,0 +1,270 @@ +import { Service } from "dioc" +import { + SpotlightSearcher, + SpotlightSearcherResult, + SpotlightSearcherSessionState, + SpotlightService, +} from "../" +import { Ref, computed, effectScope, markRaw, ref, watch } from "vue" +import { getI18n } from "~/modules/i18n" +import MiniSearch from "minisearch" +import { graphqlHistoryStore, restHistoryStore } from "~/newstore/history" +import { useTimeAgo } from "@vueuse/core" +import IconHistory from "~icons/lucide/history" +import IconTrash2 from "~icons/lucide/trash-2" +import SpotlightRESTHistoryEntry from "~/components/app/spotlight/entry/RESTHistory.vue" +import SpotlightGQLHistoryEntry from "~/components/app/spotlight/entry/GQLHistory.vue" +import { capitalize } from "lodash-es" +import { shortDateTime } from "~/helpers/utils/date" +import { useStreamStatic } from "~/composables/stream" +import { activeActions$, invokeAction } from "~/helpers/actions" +import { map } from "rxjs/operators" +import { HoppRequestDocument } from "~/helpers/rest/document" +import { def as historySync } from "~/lib/sync/history" + +/** + * This searcher is responsible for searching through the history. + * It also provides actions to clear the history. + * + * NOTE: Initializing this service registers it as a searcher with the Spotlight Service. + */ +export class HistorySpotlightSearcherService + extends Service + implements SpotlightSearcher +{ + public static readonly ID = "HISTORY_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public searcherID = "history" + public searcherSectionTitle = this.t("tab.history") + + private readonly spotlight = this.bind(SpotlightService) + + private clearHistoryActionEnabled = useStreamStatic( + activeActions$.pipe(map((actions) => actions.includes("history.clear"))), + activeActions$.value.includes("history.clear"), + () => { + /* noop */ + } + )[0] + + private hasHistoryPlatformDef = !!historySync.requestHistoryStore + + private isHistoryEnabledPlatformRef = + historySync.requestHistoryStore?.isHistoryStoreEnabled + + private clearHistoryActionEnabledCombined = computed(() => { + // if the platform has not defined the history store, by default we consider history is enabled + if (!this.hasHistoryPlatformDef) return this.clearHistoryActionEnabled.value + + // if the platform has defined the history store, we check the defined values to determine if history is enabled + return ( + this.clearHistoryActionEnabled.value && + this.isHistoryEnabledPlatformRef?.value + ) + }) + + private restHistoryEntryOpenable = useStreamStatic( + activeActions$.pipe( + map((actions) => actions.includes("rest.request.open")) + ), + activeActions$.value.includes("rest.request.open"), + () => { + /* noop */ + } + )[0] + + private gqlHistoryEntryOpenable = useStreamStatic( + activeActions$.pipe(map((actions) => actions.includes("gql.request.open"))), + activeActions$.value.includes("gql.request.open"), + () => { + /* noop */ + } + )[0] + + override onServiceInit() { + this.spotlight.registerSearcher(this) + } + + createSearchSession( + query: Readonly> + ): [Ref, () => void] { + const loading = ref(false) + const results = ref([]) + + const minisearch = new MiniSearch({ + fields: ["url", "title", "reltime", "date"], + storeFields: ["url"], + }) + + const stopWatchHandle = watch( + this.clearHistoryActionEnabledCombined, + (enabled) => { + if (enabled) { + if (minisearch.has("clear-history")) return + + minisearch.add({ + id: "clear-history", + title: this.t("action.clear_history"), + }) + } else { + if (!minisearch.has("clear-history")) return + + minisearch.discard("clear-history") + } + }, + { immediate: true } + ) + + if (this.restHistoryEntryOpenable.value) { + minisearch.addAll( + restHistoryStore.value.state + .filter((x) => !!x.updatedOn) + .map((entry, index) => { + const relTimeString = capitalize( + useTimeAgo(entry.updatedOn!, { + updateInterval: 0, + }).value + ) + + return { + id: `rest-${index}`, + url: entry.request.endpoint, + reltime: relTimeString, + date: shortDateTime(entry.updatedOn!), + } + }) + ) + } + + if (this.gqlHistoryEntryOpenable.value) { + minisearch.addAll( + graphqlHistoryStore.value.state + .filter((x) => !!x.updatedOn) + .map((entry, index) => { + const relTimeString = capitalize( + useTimeAgo(entry.updatedOn!, { + updateInterval: 0, + }).value + ) + + return { + id: `gql-${index}`, + url: entry.request.url, + reltime: relTimeString, + date: shortDateTime(entry.updatedOn!), + } + }) + ) + } + + const scopeHandle = effectScope() + + scopeHandle.run(() => { + watch( + [query, this.clearHistoryActionEnabled], + ([query]) => { + results.value = minisearch + .search(query, { + prefix: true, + fuzzy: true, + boost: { + reltime: 2, + }, + weights: { + fuzzy: 0.2, + prefix: 0.8, + }, + }) + .map((x) => { + if (x.id === "clear-history") { + return { + id: "clear-history", + icon: markRaw(IconTrash2), + score: x.score, + text: { + type: "text", + text: this.t("action.clear_history"), + }, + } + } + if (x.id.startsWith("rest-")) { + const entry = + restHistoryStore.value.state[parseInt(x.id.split("-")[1])] + + return { + id: x.id, + icon: markRaw(IconHistory), + score: x.score, + text: { + type: "custom", + component: markRaw(SpotlightRESTHistoryEntry), + componentProps: { + historyEntry: entry, + }, + }, + } + } + // Assume gql + const entry = + graphqlHistoryStore.value.state[parseInt(x.id.split("-")[1])] + + return { + id: x.id, + icon: markRaw(IconHistory), + score: x.score, + text: { + type: "custom", + component: markRaw(SpotlightGQLHistoryEntry), + componentProps: { + historyEntry: entry, + }, + }, + } + }) + }, + { immediate: true } + ) + }) + + const onSessionEnd = () => { + scopeHandle.stop() + stopWatchHandle() + minisearch.removeAll() + } + + const resultObj = computed(() => ({ + loading: loading.value, + results: results.value, + })) + + return [resultObj, onSessionEnd] + } + + onResultSelect(result: SpotlightSearcherResult): void { + if (result.id === "clear-history") { + invokeAction("history.clear") + } else if (result.id.startsWith("rest")) { + const req = + restHistoryStore.value.state[parseInt(result.id.split("-")[1])].request + + invokeAction("rest.request.open", { + doc: { + request: req, + isDirty: false, + type: "request", + }, + }) + } else { + // Assume gql + const req = + graphqlHistoryStore.value.state[parseInt(result.id.split("-")[1])] + .request + + invokeAction("gql.request.open", { + request: req, + }) + } + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/kernel-interceptor.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/kernel-interceptor.searcher.ts new file mode 100644 index 0000000..24f08ed --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/kernel-interceptor.searcher.ts @@ -0,0 +1,123 @@ +import { Ref, computed, effectScope, markRaw, ref, unref, watch } from "vue" +import { getI18n } from "~/modules/i18n" +import { + SpotlightSearcher, + SpotlightSearcherResult, + SpotlightSearcherSessionState, + SpotlightService, +} from ".." + +import { Service } from "dioc" +import MiniSearch from "minisearch" +import IconCheckCircle from "~/components/app/spotlight/entry/IconSelected.vue" +import { KernelInterceptorService } from "~/services/kernel-interceptor.service" +import IconCircle from "~icons/lucide/circle" + +/** + * This searcher is responsible for searching through the kernel-interceptor. + * And switching between them. + */ +export class KernelInterceptorSpotlightSearcherService + extends Service + implements SpotlightSearcher +{ + public static readonly ID = "KERNEL_INTERCEPTOR_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public searcherID = "kernel-interceptor" + public searcherSectionTitle = this.t("settings.interceptor") + + private readonly spotlight = this.bind(SpotlightService) + private kernelInterceptorService = this.bind(KernelInterceptorService) + + override onServiceInit() { + this.spotlight.registerSearcher(this) + } + + createSearchSession( + query: Readonly> + ): [Ref, () => void] { + const loading = ref(false) + const results = ref([]) + + const minisearch = new MiniSearch({ + fields: ["name", "alternates"], + storeFields: ["name"], + }) + + const kernelInterceptorSelection = this.kernelInterceptorService.current + + const kernelInterceptors = this.kernelInterceptorService.available + + minisearch.addAll( + kernelInterceptors.value.map((entry) => { + const id = + entry.id === kernelInterceptorSelection.value?.id + ? `kernelInterceptor-${entry.id}-selected` + : `kernelInterceptor-${entry.id}` + const name = unref(entry.name(this.t)) + const alternates = ["interceptor", "change", name] + + return { + id, + name, + alternates, + } + }) + ) + + const scopeHandle = effectScope() + + scopeHandle.run(() => { + watch( + [query], + ([query]) => { + results.value = minisearch + .search(query, { + prefix: true, + fuzzy: true, + boost: { + reltime: 2, + }, + weights: { + fuzzy: 0.2, + prefix: 0.8, + }, + }) + .map((x) => { + return { + id: x.id, + icon: markRaw( + x.id.endsWith("-selected") ? IconCheckCircle : IconCircle + ), + score: x.score, + text: { + type: "text", + text: [this.t("spotlight.section.interceptor"), x.name], + }, + } + }) + }, + { immediate: true } + ) + }) + + const onSessionEnd = () => { + scopeHandle.stop() + minisearch.removeAll() + } + + const resultObj = computed(() => ({ + loading: loading.value, + results: results.value, + })) + + return [resultObj, onSessionEnd] + } + + onResultSelect(result: SpotlightSearcherResult): void { + const selectedInterceptor = result.id.split("-")[1] + this.kernelInterceptorService.setActive(selectedInterceptor) + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/miscellaneous.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/miscellaneous.searcher.ts new file mode 100644 index 0000000..5fd7668 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/miscellaneous.searcher.ts @@ -0,0 +1,73 @@ +import { Component, markRaw, reactive } from "vue" +import { invokeAction } from "~/helpers/actions" +import { getI18n } from "~/modules/i18n" +import { SpotlightSearcherResult, SpotlightService } from ".." +import { + SearchResult, + StaticSpotlightSearcherService, +} from "./base/static.searcher" + +import IconShare from "~icons/lucide/share" +import { Container } from "dioc" + +type Doc = { + text: string + alternates: string[] + icon: object | Component +} + +/** + * + * This searcher is responsible for providing miscellaneous related actions on the spotlight results. + * + * NOTE: Initializing this service registers it as a searcher with the Spotlight Service. + */ +export class MiscellaneousSpotlightSearcherService extends StaticSpotlightSearcherService { + public static readonly ID = "MISCELLANEOUS_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public readonly searcherID = "miscellaneous" + public searcherSectionTitle = this.t("spotlight.miscellaneous.title") + + private readonly spotlight = this.bind(SpotlightService) + + private documents: Record = reactive({ + invite_hoppscotch: { + text: this.t("spotlight.miscellaneous.invite"), + alternates: ["invite", "share", "hoppscotch"], + icon: markRaw(IconShare), + }, + }) + + // TODO: Constructors are no longer recommended as of dioc > 3, move to onServiceInit + constructor(c: Container) { + super(c, { + searchFields: ["text", "alternates"], + fieldWeights: { + text: 2, + alternates: 1, + }, + }) + } + + override onServiceInit() { + this.setDocuments(this.documents) + this.spotlight.registerSearcher(this) + } + + protected getSearcherResultForSearchResult( + result: SearchResult + ): SpotlightSearcherResult { + return { + id: result.id, + icon: result.doc.icon, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + public onDocSelected(id: string): void { + if (id === "invite_hoppscotch") invokeAction(`modals.share.toggle`) + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/navigation.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/navigation.searcher.ts new file mode 100644 index 0000000..e92a63d --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/navigation.searcher.ts @@ -0,0 +1,97 @@ +import { Component, markRaw, reactive } from "vue" +import { HoppActionWithNoArgs, invokeAction } from "~/helpers/actions" +import { getI18n } from "~/modules/i18n" +import { SpotlightSearcherResult, SpotlightService } from ".." +import { + SearchResult, + StaticSpotlightSearcherService, +} from "./base/static.searcher" + +import IconArrowRight from "~icons/lucide/arrow-right" +import { Container } from "dioc" + +type Doc = { + text: string + alternates: string[] + icon: object | Component +} + +/** + * + * This searcher is responsible for providing navigation related actions on the spotlight results. + * + * NOTE: Initializing this service registers it as a searcher with the Spotlight Service. + */ +export class NavigationSpotlightSearcherService extends StaticSpotlightSearcherService { + public static readonly ID = "NAVIGATION_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public readonly searcherID = "navigation" + public searcherSectionTitle = this.t("shortcut.navigation.title") + + private readonly spotlight = this.bind(SpotlightService) + + private documents: Record = reactive({ + settings: { + text: this.t("shortcut.navigation.settings"), + alternates: ["navigation", "settings", "preferences"], + icon: markRaw(IconArrowRight), + }, + rest: { + text: this.t("shortcut.navigation.rest"), + alternates: ["navigation", "rest", "request", "http"], + icon: markRaw(IconArrowRight), + }, + graphql: { + text: this.t("shortcut.navigation.graphql"), + alternates: ["navigation", "graphql", "gql"], + icon: markRaw(IconArrowRight), + }, + realtime: { + text: this.t("shortcut.navigation.realtime"), + alternates: ["navigation", "realtime", "socket", "ws"], + icon: markRaw(IconArrowRight), + }, + profile: { + text: this.t("shortcut.navigation.profile"), + alternates: ["navigation", "profile", "account"], + icon: markRaw(IconArrowRight), + }, + }) + + private docKeys = Object.keys(this.documents) + + // TODO: Constructors are no longer recommended as of dioc > 3, use onServiceInit instead + constructor(c: Container) { + super(c, { + searchFields: ["text", "alternates"], + fieldWeights: { + text: 2, + alternates: 1, + }, + }) + } + + override onServiceInit() { + this.setDocuments(this.documents) + this.spotlight.registerSearcher(this) + } + + protected getSearcherResultForSearchResult( + result: SearchResult + ): SpotlightSearcherResult { + return { + id: result.id, + icon: result.doc.icon, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + public onDocSelected(id: string): void { + if (this.docKeys.includes(id) === false) return + + invokeAction(`navigation.jump.${id}` as HoppActionWithNoArgs) + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/request.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/request.searcher.ts new file mode 100644 index 0000000..e066cff --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/request.searcher.ts @@ -0,0 +1,344 @@ +import { Component, computed, markRaw, reactive } from "vue" +import { invokeAction, isActionBound } from "~/helpers/actions" +import { getI18n } from "~/modules/i18n" +import { SpotlightSearcherResult, SpotlightService } from ".." +import { + SearchResult, + StaticSpotlightSearcherService, +} from "./base/static.searcher" + +import { useRoute } from "vue-router" +import { RESTOptionTabs } from "~/components/http/RequestOptions.vue" +import IconWindow from "~icons/lucide/app-window" +import IconCheckCircle from "~icons/lucide/check-circle" +import IconCode2 from "~icons/lucide/code-2" +import IconShare2 from "~icons/lucide/share-2" +import IconFileCode from "~icons/lucide/file-code" +import IconRename from "~icons/lucide/file-edit" +import IconPlay from "~icons/lucide/play" +import IconRotateCCW from "~icons/lucide/rotate-ccw" +import IconSave from "~icons/lucide/save" +import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue" +import { RESTTabService } from "~/services/tab/rest" +import { Container } from "dioc" + +type Doc = { + text: string | string[] + alternates: string[] + icon: object | Component + excludeFromSearch?: boolean +} + +/** + * + * This searcher is responsible for providing request related actions on the spotlight results. + * + * NOTE: Initializing this service registers it as a searcher with the Spotlight Service. + */ +export class RequestSpotlightSearcherService extends StaticSpotlightSearcherService { + public static readonly ID = "REQUEST_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public readonly searcherID = "request" + public searcherSectionTitle = this.t("shortcut.request.title") + + private readonly spotlight = this.bind(SpotlightService) + private readonly restTab = this.bind(RESTTabService) + + private route = useRoute() + private isRESTPage = computed( + () => + this.route.name === "index" && + this.restTab.currentActiveTab.value.document.type === "request" + ) + private isGQLPage = computed(() => this.route.name === "graphql") + private isRESTOrGQLPage = computed( + () => this.isRESTPage.value || this.isGQLPage.value + ) + private isGQLConnectBound = isActionBound("gql.connect") + private isGQLDisconnectBound = isActionBound("gql.disconnect") + + private documents: Record = reactive({ + send_request: { + text: this.t("shortcut.request.send_request"), + alternates: ["request", "send"], + icon: markRaw(IconPlay), + excludeFromSearch: computed(() => !this.isRESTOrGQLPage.value), + }, + gql_connect: { + text: [this.t("navigation.graphql"), this.t("spotlight.graphql.connect")], + alternates: ["connect", "server", "graphql"], + icon: markRaw(IconPlay), + excludeFromSearch: computed(() => !this.isGQLConnectBound.value), + }, + gql_disconnect: { + text: [ + this.t("navigation.graphql"), + this.t("spotlight.graphql.disconnect"), + ], + alternates: ["disconnect", "stop", "graphql"], + icon: markRaw(IconPlay), + excludeFromSearch: computed(() => !this.isGQLDisconnectBound.value), + }, + save_to_collections: { + text: this.t("spotlight.request.save_as_new"), + alternates: ["save", "collections"], + icon: markRaw(IconSave), + excludeFromSearch: computed(() => !this.isRESTOrGQLPage.value), + }, + save_request: { + text: this.t("shortcut.request.save_request"), + alternates: ["save", "request"], + icon: markRaw(IconSave), + excludeFromSearch: computed(() => !this.isRESTOrGQLPage.value), + }, + rename_request: { + text: this.t("shortcut.request.rename"), + alternates: ["rename", "request"], + icon: markRaw(IconRename), + excludeFromSearch: computed(() => !this.isRESTOrGQLPage.value), + }, + share_request: { + text: this.t("shortcut.request.share_request"), + alternates: ["share", "request", "copy"], + icon: markRaw(IconShare2), + excludeFromSearch: computed(() => !this.isRESTPage.value), + }, + reset_request: { + text: this.t("shortcut.request.reset_request"), + alternates: ["reset", "request"], + icon: markRaw(IconRotateCCW), + excludeFromSearch: computed(() => !this.isRESTOrGQLPage.value), + }, + import_curl: { + text: this.t("shortcut.request.import_curl"), + alternates: ["import", "curl"], + icon: markRaw(IconFileCode), + excludeFromSearch: computed(() => !this.isRESTPage.value), + }, + show_code: { + text: this.t("shortcut.request.show_code"), + alternates: ["show", "code"], + icon: markRaw(IconCode2), + excludeFromSearch: computed(() => !this.isRESTPage.value), + }, + // Change request method + get_method: { + text: [this.t("spotlight.request.select_method"), "GET"], + alternates: ["get", "method"], + icon: markRaw(IconCheckCircle), + excludeFromSearch: computed(() => !this.isRESTPage.value), + }, + head_method: { + text: [this.t("spotlight.request.select_method"), "HEAD"], + alternates: ["head", "method"], + icon: markRaw(IconCheckCircle), + excludeFromSearch: computed(() => !this.isRESTPage.value), + }, + post_method: { + text: [this.t("spotlight.request.select_method"), "POST"], + alternates: ["post", "method"], + icon: markRaw(IconCheckCircle), + excludeFromSearch: computed(() => !this.isRESTPage.value), + }, + put_method: { + text: [this.t("spotlight.request.select_method"), "PUT"], + alternates: ["put", "method"], + icon: markRaw(IconCheckCircle), + excludeFromSearch: computed(() => !this.isRESTPage.value), + }, + delete_method: { + text: [this.t("spotlight.request.select_method"), "DELETE"], + alternates: ["delete", "method"], + icon: markRaw(IconCheckCircle), + excludeFromSearch: computed(() => !this.isRESTPage.value), + }, + // Change sub tabs + tab_parameters: { + text: [ + this.t("spotlight.request.switch_to"), + this.t("spotlight.request.tab_parameters"), + ], + alternates: ["parameters", "tab"], + icon: markRaw(IconWindow), + excludeFromSearch: computed(() => !this.isRESTOrGQLPage.value), + }, + tab_body: { + text: [ + this.t("spotlight.request.switch_to"), + this.t("spotlight.request.tab_body"), + ], + alternates: ["body", "tab"], + icon: markRaw(IconWindow), + excludeFromSearch: computed(() => !this.isRESTOrGQLPage.value), + }, + tab_headers: { + text: [ + this.t("spotlight.request.switch_to"), + this.t("spotlight.request.tab_headers"), + ], + alternates: ["headers", "tab"], + icon: markRaw(IconWindow), + excludeFromSearch: computed(() => !this.isRESTOrGQLPage.value), + }, + tab_authorization: { + text: [ + this.t("spotlight.request.switch_to"), + this.t("spotlight.request.tab_authorization"), + ], + alternates: ["authorization", "tab"], + icon: markRaw(IconWindow), + excludeFromSearch: computed(() => !this.isRESTOrGQLPage.value), + }, + tab_pre_request_script: { + text: [ + this.t("spotlight.request.switch_to"), + this.t("spotlight.request.tab_pre_request_script"), + ], + alternates: ["pre-request", "script", "tab"], + icon: markRaw(IconWindow), + excludeFromSearch: computed(() => !this.isRESTPage.value), + }, + tab_tests: { + text: [ + this.t("spotlight.request.switch_to"), + this.t("spotlight.request.tab_tests"), + ], + alternates: ["tests", "tab"], + icon: markRaw(IconWindow), + excludeFromSearch: computed(() => !this.isRESTPage.value), + }, + tab_query: { + text: [ + this.t("spotlight.request.switch_to"), + this.t("spotlight.request.tab_query"), + ], + alternates: ["query", "tab"], + icon: markRaw(IconWindow), + excludeFromSearch: computed(() => !this.isGQLPage.value), + }, + tab_variables: { + text: [ + this.t("spotlight.request.switch_to"), + this.t("spotlight.request.tab_variables"), + ], + alternates: ["variables", "tab"], + icon: markRaw(IconWindow), + excludeFromSearch: computed(() => !this.isGQLPage.value), + }, + }) + + // TODO: Constructors are no longer recommended as of dioc > 3, use onServiceInit instead + constructor(c: Container) { + super(c, { + searchFields: ["text", "alternates"], + fieldWeights: { + text: 2, + alternates: 1, + }, + }) + } + + override onServiceInit() { + this.setDocuments(this.documents) + this.spotlight.registerSearcher(this) + } + + protected getSearcherResultForSearchResult( + result: SearchResult + ): SpotlightSearcherResult { + return { + id: result.id, + icon: result.doc.icon, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + private openRequestTab(tab: RESTOptionTabs | GQLOptionTabs): void { + invokeAction("request.open-tab", { + tab, + }) + } + + public onDocSelected(id: string): void { + switch (id) { + case "send_request": + invokeAction("request.send-cancel") + break + case "gql_connect": + invokeAction("gql.connect") + break + case "gql_disconnect": + invokeAction("gql.disconnect") + break + case "save_to_collections": + invokeAction("request.save-as", { + requestType: "rest", + request: + this.restTab.currentActiveTab.value?.document.type === "request" + ? this.restTab.currentActiveTab.value?.document.request + : null, + }) + break + case "save_request": + invokeAction("request-response.save") + break + case "rename_request": + invokeAction("request.rename") + break + case "share_request": + invokeAction("request.share-request") + break + case "reset_request": + invokeAction("request.reset") + break + case "get_method": + invokeAction("request.method.get") + break + case "head_method": + invokeAction("request.method.head") + break + case "post_method": + invokeAction("request.method.post") + break + case "put_method": + invokeAction("request.method.put") + break + case "delete_method": + invokeAction("request.method.delete") + break + case "import_curl": + invokeAction("request.import-curl") + break + case "show_code": + invokeAction("request.show-code") + break + case "tab_parameters": + this.openRequestTab("params") + break + case "tab_body": + this.openRequestTab("bodyParams") + break + case "tab_headers": + this.openRequestTab("headers") + break + case "tab_authorization": + this.openRequestTab("authorization") + break + case "tab_pre_request_script": + this.openRequestTab("preRequestScript") + break + case "tab_tests": + this.openRequestTab("tests") + break + case "tab_query": + this.openRequestTab("query") + break + case "tab_variables": + this.openRequestTab("variables") + break + } + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/response.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/response.searcher.ts new file mode 100644 index 0000000..88b9186 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/response.searcher.ts @@ -0,0 +1,101 @@ +import { Component, computed, markRaw, reactive } from "vue" +import { invokeAction, isActionBound } from "~/helpers/actions" +import { getI18n } from "~/modules/i18n" +import { SpotlightSearcherResult, SpotlightService } from ".." +import { + SearchResult, + StaticSpotlightSearcherService, +} from "./base/static.searcher" + +import IconDownload from "~icons/lucide/download" +import IconCopy from "~icons/lucide/copy" +import IconNetwork from "~icons/lucide/network" +import { Container } from "dioc" + +type Doc = { + text: string + alternates: string[] + icon: object | Component + excludeFromSearch?: boolean +} + +/** + * + * This searcher is responsible for providing response related actions on the spotlight results. + * + * NOTE: Initializing this service registers it as a searcher with the Spotlight Service. + */ +export class ResponseSpotlightSearcherService extends StaticSpotlightSearcherService { + public static readonly ID = "RESPONSE_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public readonly searcherID = "response" + public searcherSectionTitle = this.t("spotlight.response.title") + + private readonly spotlight = this.bind(SpotlightService) + + private copyResponseActionEnabled = isActionBound("response.copy") + + private downloadResponseActionEnabled = isActionBound( + "response.file.download" + ) + + private dataSchemaActionEnabled = isActionBound("response.schema.toggle") + + private documents: Record = reactive({ + copy_response: { + text: this.t("spotlight.response.copy"), + alternates: ["copy", "response"], + icon: markRaw(IconCopy), + excludeFromSearch: computed(() => !this.copyResponseActionEnabled.value), + }, + download_response: { + text: this.t("spotlight.response.download"), + alternates: ["download", "response"], + icon: markRaw(IconDownload), + excludeFromSearch: computed( + () => !this.downloadResponseActionEnabled.value + ), + }, + generate_data_schema: { + text: this.t("response.generate_data_schema"), + alternates: ["generate", "data", "schema", "typescript", "response"], + icon: markRaw(IconNetwork), + excludeFromSearch: computed(() => !this.dataSchemaActionEnabled.value), + }, + }) + + // TODO: Constructors are no longer recommended as of dioc > 3, move to onServiceInit + constructor(c: Container) { + super(c, { + searchFields: ["text", "alternates"], + fieldWeights: { + text: 2, + alternates: 1, + }, + }) + } + + override onServiceInit() { + this.setDocuments(this.documents) + this.spotlight.registerSearcher(this) + } + + protected getSearcherResultForSearchResult( + result: SearchResult + ): SpotlightSearcherResult { + return { + id: result.id, + icon: result.doc.icon, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + public onDocSelected(id: string): void { + if (id === "copy_response") invokeAction(`response.copy`) + if (id === "download_response") invokeAction(`response.file.download`) + if (id === "generate_data_schema") invokeAction(`response.schema.toggle`) + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/settings.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/settings.searcher.ts new file mode 100644 index 0000000..2ce4905 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/settings.searcher.ts @@ -0,0 +1,156 @@ +import { Component, computed, markRaw, reactive } from "vue" +import { useSetting } from "~/composables/settings" +import { invokeAction } from "~/helpers/actions" +import { getI18n } from "~/modules/i18n" +import { HoppBgColor, applySetting } from "~/newstore/settings" +import { SpotlightSearcherResult, SpotlightService } from ".." +import { + SearchResult, + StaticSpotlightSearcherService, +} from "./base/static.searcher" + +import IconCloud from "~icons/lucide/cloud" +import IconGlobe from "~icons/lucide/globe" +import IconMonitor from "~icons/lucide/monitor" +import IconMoon from "~icons/lucide/moon" +import IconSun from "~icons/lucide/sun" +import IconCheckCircle from "~icons/lucide/check-circle" +import { Container } from "dioc" + +type Doc = { + text: string | string[] + alternates: string[] + icon: object | Component +} + +/** + * + * This searcher is responsible for providing settings related actions on the spotlight results. + * + * NOTE: Initializing this service registers it as a searcher with the Spotlight Service. + */ +export class SettingsSpotlightSearcherService extends StaticSpotlightSearcherService { + public static readonly ID = "SETTINGS_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + private activeTheme = useSetting("BG_COLOR") + + public readonly searcherID = "settings" + public searcherSectionTitle = this.t("navigation.settings") + + private readonly spotlight = this.bind(SpotlightService) + + private documents: Record = reactive({ + theme_system: { + text: [ + this.t("spotlight.section.theme"), + this.t("spotlight.settings.theme.system"), + ], + alternates: ["theme"], + icon: computed(() => + this.activeTheme.value === "system" + ? markRaw(IconCheckCircle) + : markRaw(IconMonitor) + ), + }, + theme_light: { + text: [ + this.t("spotlight.section.theme"), + this.t("spotlight.settings.theme.light"), + ], + alternates: ["theme"], + icon: computed(() => + this.activeTheme.value === "light" + ? markRaw(IconCheckCircle) + : markRaw(IconSun) + ), + }, + theme_dark: { + text: [ + this.t("spotlight.section.theme"), + this.t("spotlight.settings.theme.dark"), + ], + alternates: ["theme"], + icon: computed(() => + this.activeTheme.value === "dark" + ? markRaw(IconCheckCircle) + : markRaw(IconCloud) + ), + }, + theme_black: { + text: [ + this.t("spotlight.section.theme"), + this.t("spotlight.settings.theme.black"), + ], + alternates: ["theme"], + icon: computed(() => + this.activeTheme.value === "black" + ? markRaw(IconCheckCircle) + : markRaw(IconMoon) + ), + }, + + change_lang: { + text: [ + this.t("spotlight.section.interface"), + this.t("spotlight.settings.change_language"), + ], + alternates: ["language", "change language"], + icon: markRaw(IconGlobe), + }, + }) + + // TODO: Constructors are no longer recommended as of dioc > 3, move to onServiceInit + constructor(c: Container) { + super(c, { + searchFields: ["text", "alternates"], + fieldWeights: { + text: 2, + alternates: 1, + }, + }) + } + + override onServiceInit() { + this.setDocuments(this.documents) + this.spotlight.registerSearcher(this) + } + + protected getSearcherResultForSearchResult( + result: SearchResult + ): SpotlightSearcherResult { + return { + id: result.id, + icon: result.doc.icon, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + changeTheme(theme: HoppBgColor) { + applySetting("BG_COLOR", theme) + } + + public onDocSelected(id: string): void { + switch (id) { + case "change_lang": + invokeAction("navigation.jump.settings") + break + + // theme actions + case "theme_system": + invokeAction("settings.theme.system") + break + case "theme_light": + invokeAction("settings.theme.light") + break + case "theme_dark": + invokeAction("settings.theme.dark") + break + case "theme_black": + invokeAction("settings.theme.black") + break + } + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/tab.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/tab.searcher.ts new file mode 100644 index 0000000..c5eec15 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/tab.searcher.ts @@ -0,0 +1,215 @@ +import { Component, computed, markRaw, reactive } from "vue" +import { getI18n } from "~/modules/i18n" +import { SpotlightSearcherResult, SpotlightService } from ".." +import { + SearchResult, + StaticSpotlightSearcherService, +} from "./base/static.searcher" + +import { useRoute } from "vue-router" +import IconCopy from "~icons/lucide/copy" +import IconCopyPlus from "~icons/lucide/copy-plus" +import IconXCircle from "~icons/lucide/x-circle" +import IconXSquare from "~icons/lucide/x-square" +import IconArrowLeft from "~icons/lucide/arrow-left" +import IconArrowRight from "~icons/lucide/arrow-right" +import IconChevronsLeft from "~icons/lucide/chevrons-left" +import IconChevronsRight from "~icons/lucide/chevrons-right" +import { invokeAction } from "~/helpers/actions" +import { RESTTabService } from "~/services/tab/rest" +import { GQLTabService } from "~/services/tab/graphql" +import { Container } from "dioc" +import { getKernelMode } from "@hoppscotch/kernel" + +type Doc = { + text: string | string[] + alternates: string[] + icon: object | Component + excludeFromSearch?: boolean +} + +/** + * + * This searcher is responsible for providing REST Tab related actions on the spotlight results. + * + * NOTE: Initializing this service registers it as a searcher with the Spotlight Service. + */ +export class TabSpotlightSearcherService extends StaticSpotlightSearcherService { + public static readonly ID = "TAB_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public readonly searcherID = "tab" + public searcherSectionTitle = this.t("spotlight.tab.title") + + private readonly spotlight = this.bind(SpotlightService) + + private route = useRoute() + private showAction = computed( + () => this.route.name === "index" || this.route.name === "graphql" + ) + + private readonly restTab = this.bind(RESTTabService) + private readonly gqlTab = this.bind(GQLTabService) + + private isOnlyTab = computed(() => + this.route.name === "graphql" + ? this.gqlTab.getActiveTabs().value.length === 1 + : this.restTab.getActiveTabs().value.length === 1 + ) + + private isDesktopMode = computed(() => getKernelMode() === "desktop") + + private documents: Record = reactive({ + duplicate_tab: { + text: [this.t("spotlight.tab.title"), this.t("spotlight.tab.duplicate")], + alternates: ["tab", "duplicate", "duplicate tab"], + icon: markRaw(IconCopy), + excludeFromSearch: computed(() => !this.showAction.value), + }, + close_current_tab: { + text: [ + this.t("spotlight.tab.title"), + this.t("spotlight.tab.close_current"), + ], + alternates: ["tab", "close", "close tab"], + icon: markRaw(IconXCircle), + excludeFromSearch: computed( + () => !this.showAction.value || this.isOnlyTab.value + ), + }, + close_other_tabs: { + text: [ + this.t("spotlight.tab.title"), + this.t("spotlight.tab.close_others"), + ], + alternates: ["tab", "close", "close all"], + icon: markRaw(IconXSquare), + excludeFromSearch: computed( + () => !this.showAction.value || this.isOnlyTab.value + ), + }, + open_new_tab: { + text: [this.t("spotlight.tab.title"), this.t("spotlight.tab.new_tab")], + alternates: ["tab", "new", "open tab"], + icon: markRaw(IconCopyPlus), + excludeFromSearch: computed(() => !this.showAction.value), + }, + // NOTE: Desktop-only actions + tab_prev: { + text: [this.t("spotlight.tab.title"), this.t("spotlight.tab.previous")], + alternates: ["tab", "previous", "prev", "switch"], + icon: markRaw(IconArrowLeft), + excludeFromSearch: computed( + () => + !this.showAction.value || + !this.isDesktopMode.value || + this.isOnlyTab.value + ), + }, + tab_next: { + text: [this.t("spotlight.tab.title"), this.t("spotlight.tab.next")], + alternates: ["tab", "next", "switch"], + icon: markRaw(IconArrowRight), + excludeFromSearch: computed( + () => + !this.showAction.value || + !this.isDesktopMode.value || + this.isOnlyTab.value + ), + }, + tab_switch_to_first: { + text: [ + this.t("spotlight.tab.title"), + this.t("spotlight.tab.switch_to_first"), + ], + alternates: ["tab", "first", "switch", "go to first"], + icon: markRaw(IconChevronsLeft), + excludeFromSearch: computed( + () => + !this.showAction.value || + !this.isDesktopMode.value || + this.isOnlyTab.value + ), + }, + tab_switch_to_last: { + text: [ + this.t("spotlight.tab.title"), + this.t("spotlight.tab.switch_to_last"), + ], + alternates: ["tab", "last", "switch", "go to last"], + icon: markRaw(IconChevronsRight), + excludeFromSearch: computed( + () => + !this.showAction.value || + !this.isDesktopMode.value || + this.isOnlyTab.value + ), + }, + tab_mru_switch: { + text: [this.t("spotlight.tab.title"), this.t("spotlight.tab.mru_switch")], + alternates: ["tab", "recent", "mru", "switch"], + icon: markRaw(IconArrowRight), + excludeFromSearch: computed( + () => + !this.showAction.value || + !this.isDesktopMode.value || + this.isOnlyTab.value + ), + }, + tab_mru_switch_reverse: { + text: [ + this.t("spotlight.tab.title"), + this.t("spotlight.tab.mru_switch_reverse"), + ], + alternates: ["tab", "recent", "mru", "previous", "switch"], + icon: markRaw(IconArrowLeft), + excludeFromSearch: computed( + () => + !this.showAction.value || + !this.isDesktopMode.value || + this.isOnlyTab.value + ), + }, + }) + + // TODO: Constructors are no longer recommended as of dioc > 3, use onServiceInit instead + constructor(c: Container) { + super(c, { + searchFields: ["text", "alternates"], + fieldWeights: { + text: 2, + alternates: 1, + }, + }) + } + + override onServiceInit() { + this.setDocuments(this.documents) + this.spotlight.registerSearcher(this) + } + + protected getSearcherResultForSearchResult( + result: SearchResult + ): SpotlightSearcherResult { + return { + id: result.id, + icon: result.doc.icon, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + public onDocSelected(id: string): void { + if (id === "duplicate_tab") invokeAction("tab.duplicate-tab", {}) + if (id === "close_current_tab") invokeAction("tab.close-current") + if (id === "close_other_tabs") invokeAction("tab.close-other") + if (id === "open_new_tab") invokeAction("tab.open-new") + if (id === "tab_prev") invokeAction("tab.prev") + if (id === "tab_next") invokeAction("tab.next") + if (id === "tab_switch_to_first") invokeAction("tab.switch-to-first") + if (id === "tab_switch_to_last") invokeAction("tab.switch-to-last") + if (id === "tab_mru_switch") invokeAction("tab.mru-switch") + if (id === "tab_mru_switch_reverse") invokeAction("tab.mru-switch-reverse") + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/teamRequest.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/teamRequest.searcher.ts new file mode 100644 index 0000000..bac2a5e --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/teamRequest.searcher.ts @@ -0,0 +1,250 @@ +import { Service } from "dioc" +import { + SpotlightResultTextType, + SpotlightSearcher, + SpotlightSearcherResult, + SpotlightSearcherSessionState, + SpotlightService, +} from ".." +import { getI18n } from "~/modules/i18n" +import { Ref, computed, effectScope, markRaw, ref, watch } from "vue" +import { TeamSearchService } from "~/helpers/teams/TeamsSearch.service" +import { cloneDeep, debounce } from "lodash-es" +import IconFolder from "~icons/lucide/folder" +import IconImport from "~icons/lucide/folder-down" +import { WorkspaceService } from "~/services/workspace.service" +import RESTTeamRequestEntry from "~/components/app/spotlight/entry/RESTTeamRequestEntry.vue" +import { RESTTabService } from "~/services/tab/rest" +import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" +import { HoppRESTRequest } from "@hoppscotch/data" +import MiniSearch from "minisearch" +import { invokeAction } from "~/helpers/actions" + +export class TeamsSpotlightSearcherService + extends Service + implements SpotlightSearcher +{ + public static readonly ID = "TEAMS_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public searcherID = "teams" + public searcherSectionTitle = this.t("team.search_title") + + private readonly spotlight = this.bind(SpotlightService) + + private readonly teamsSearch = this.bind(TeamSearchService) + + private readonly workspaceService = this.bind(WorkspaceService) + + private readonly tabs = this.bind(RESTTabService) + + override onServiceInit() { + this.spotlight.registerSearcher(this) + } + + private getCurrentPageCategory() { + // TODO: Better logic for this ? + try { + const url = new URL(window.location.href) + + if (url.pathname.startsWith("/graphql")) { + return "graphql" + } else if (url.pathname === "/") { + return "rest" + } + return "other" + } catch (_e) { + return "other" + } + } + + createSearchSession( + query: Readonly> + ): [Ref, () => void] { + const pageCategory = this.getCurrentPageCategory() + + // Only show the searcher on the REST page + if (pageCategory !== "rest") { + return [computed(() => ({ loading: false, results: [] })), () => {}] + } + + const results = ref([]) + + const minisearch = new MiniSearch({ + fields: ["name"], + storeFields: ["name"], + searchOptions: { + prefix: true, + fuzzy: true, + boost: { + name: 2, + }, + weights: { + fuzzy: 0.2, + prefix: 0.8, + }, + }, + }) + + minisearch.add({ + id: `create-collection`, + name: this.t("collection.new"), + }) + minisearch.add({ + id: "import-collection", + name: this.t("collection.import"), + }) + + const newCollectionText: SpotlightResultTextType = { + type: "text", + text: this.t("collection.new"), + } + + const importCollectionText: SpotlightResultTextType = { + type: "text", + text: this.t("collection.import_collection"), + } + + const getResultText = (id: string): SpotlightResultTextType => { + if (id === "create-collection") return newCollectionText + return importCollectionText + } + + const getResultIcon = (id: string) => { + if (id === "import-collection") return markRaw(IconImport) + return markRaw(IconFolder) + } + + const isTeamWorkspace = computed( + () => this.workspaceService.currentWorkspace.value.type === "team" + ) + + const scopeHandle = effectScope() + + scopeHandle.run(() => { + const debouncedSearch = debounce(this.teamsSearch.searchTeams, 400) + + watch( + query, + (query) => { + if (this.workspaceService.currentWorkspace.value.type === "team") { + const teamID = this.workspaceService.currentWorkspace.value.teamID + debouncedSearch(query, teamID)?.catch(() => {}) + const searchResults = minisearch.search(query).slice(0, 10) + results.value = searchResults.map((result) => ({ + id: result.id, + text: getResultText(result.id), + icon: getResultIcon(result.id), + score: result.score, + })) + } + }, + { + immediate: true, + } + ) + + // set the search section title based on the current workspace + const teamName = computed(() => { + return ( + (this.workspaceService.currentWorkspace.value.type === "team" && + this.workspaceService.currentWorkspace.value.teamName) || + this.t("team.search_title") + ) + }) + + watch( + teamName, + (newTeamName) => { + this.searcherSectionTitle = newTeamName + }, + { + immediate: true, + } + ) + }) + + const onSessionEnd = () => { + scopeHandle.stop() + } + + const resultObj = computed(() => { + if (isTeamWorkspace.value) { + const teamsSearchResults = + this.teamsSearch.teamsSearchResultsFormattedForSpotlight.value + const minisearchResults = results.value + + const mergedResults = [ + ...teamsSearchResults.map((result) => ({ + id: result.request.id, + icon: markRaw(IconFolder), + score: 1, // make a better scoring system for this + text: { + type: "custom", + component: markRaw(RESTTeamRequestEntry), + componentProps: { + collectionTitles: result.collectionTitles, + request: result.request, + }, + }, + })), + ...minisearchResults, + ] as SpotlightSearcherResult[] + + return { + loading: this.teamsSearch.teamsSearchResultsLoading.value, + results: mergedResults, + } + } + return { + loading: false, + results: [], + } + }) + return [resultObj, onSessionEnd] + } + + onResultSelect(result: SpotlightSearcherResult): void { + if (result.id === "create-collection") return invokeAction("collection.new") + + if (result.id === "import-collection") + return invokeAction(`modals.collection.import`) + + let inheritedProperties: HoppInheritedProperty | undefined = undefined + + const selectedRequest = this.teamsSearch.searchResultsRequests[result.id] + + if (!selectedRequest) return + + const collectionID = selectedRequest.collectionID + + if (!collectionID) return + + inheritedProperties = + this.teamsSearch.cascadeParentCollectionForPropertiesForSearchResults( + collectionID + ) + + const possibleTab = this.tabs.getTabRefWithSaveContext({ + originLocation: "team-collection", + requestID: result.id, + }) + + if (possibleTab) { + this.tabs.setActiveTab(possibleTab.value.id) + } else { + this.tabs.createNewTab({ + request: cloneDeep(selectedRequest.request as HoppRESTRequest), + isDirty: false, + type: "request", + saveContext: { + originLocation: "team-collection", + requestID: selectedRequest.id, + collectionID: selectedRequest.collectionID, + }, + inheritedProperties: inheritedProperties, + }) + } + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/user.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/user.searcher.ts new file mode 100644 index 0000000..2013b70 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/user.searcher.ts @@ -0,0 +1,100 @@ +import { SpotlightSearcherResult, SpotlightService } from ".." +import { + SearchResult, + StaticSpotlightSearcherService, +} from "./base/static.searcher" +import { getI18n } from "~/modules/i18n" +import { Component, computed, markRaw, reactive } from "vue" +import { useStreamStatic } from "~/composables/stream" +import IconLogin from "~icons/lucide/log-in" +import IconLogOut from "~icons/lucide/log-out" +import { activeActions$, invokeAction } from "~/helpers/actions" +import { Container } from "dioc" + +type Doc = { + text: string + excludeFromSearch?: boolean + alternates: string[] + icon: object | Component +} + +/** + * This searcher is responsible for providing user related actions on the spotlight results. + * + * NOTE: Initializing this service registers it as a searcher with the Spotlight Service. + */ +export class UserSpotlightSearcherService extends StaticSpotlightSearcherService { + public static readonly ID = "USER_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public readonly searcherID = "user" + public searcherSectionTitle = this.t("spotlight.section.user") + + private readonly spotlight = this.bind(SpotlightService) + + private activeActions = useStreamStatic(activeActions$, [], () => { + /* noop */ + })[0] + + private hasLoginAction = computed(() => + this.activeActions.value.includes("user.login") + ) + + private hasLogoutAction = computed(() => + this.activeActions.value.includes("user.logout") + ) + + private documents: Record = reactive({ + login: { + text: this.t("auth.login"), + excludeFromSearch: computed(() => !this.hasLoginAction.value), + alternates: ["sign in", "log in"], + icon: markRaw(IconLogin), + }, + logout: { + text: this.t("auth.logout"), + excludeFromSearch: computed(() => !this.hasLogoutAction.value), + alternates: ["sign out", "log out"], + icon: markRaw(IconLogOut), + }, + }) + + // TODO: Constructors are no longer recommended as of dioc > 3, move to onServiceInit + constructor(c: Container) { + super(c, { + searchFields: ["text", "alternates"], + fieldWeights: { + text: 2, + alternates: 1, + }, + }) + } + + override onServiceInit(): void { + this.setDocuments(this.documents) + this.spotlight.registerSearcher(this) + } + + protected getSearcherResultForSearchResult( + result: SearchResult + ): SpotlightSearcherResult { + return { + id: result.id, + icon: result.doc.icon, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + public onDocSelected(id: string): void { + switch (id) { + case "login": + invokeAction("user.login") + break + case "logout": + invokeAction("user.logout") + break + } + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/workspace.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/workspace.searcher.ts new file mode 100644 index 0000000..91cf6d4 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/workspace.searcher.ts @@ -0,0 +1,279 @@ +import { + Component, + Ref, + computed, + effectScope, + markRaw, + reactive, + ref, + watch, +} from "vue" +import { invokeAction } from "~/helpers/actions" +import { getI18n } from "~/modules/i18n" +import { + SpotlightSearcher, + SpotlightSearcherResult, + SpotlightSearcherSessionState, + SpotlightService, +} from ".." +import { + SearchResult, + StaticSpotlightSearcherService, +} from "./base/static.searcher" + +import { Container, Service } from "dioc" +import * as E from "fp-ts/Either" +import MiniSearch from "minisearch" +import IconCheckCircle from "~/components/app/spotlight/entry/IconSelected.vue" +import { GetMyTeamsQuery } from "~/helpers/backend/graphql" +import { platform } from "~/platform" +import { WorkspaceService } from "~/services/workspace.service" +import IconEdit from "~icons/lucide/edit" +import IconTrash2 from "~icons/lucide/trash-2" +import IconUser from "~icons/lucide/user" +import IconUserPlus from "~icons/lucide/user-plus" +import IconUsers from "~icons/lucide/users" + +type Doc = { + text: string | string[] + alternates: string[] + icon: object | Component + excludeFromSearch?: boolean +} + +/** + * + * This searcher is responsible for providing team related actions on the spotlight results. + * + * NOTE: Initializing this service registers it as a searcher with the Spotlight Service. + */ +export class WorkspaceSpotlightSearcherService extends StaticSpotlightSearcherService { + public static readonly ID = "WORKSPACE_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public readonly searcherID = "workspace" + public searcherSectionTitle = this.t("spotlight.workspace.title") + + private readonly spotlight = this.bind(SpotlightService) + private readonly workspaceService = this.bind(WorkspaceService) + + private workspace = this.workspaceService.currentWorkspace + + private isTeamSelected = computed( + () => + this.workspace.value.type === "team" && + this.workspace.value.teamID !== undefined + ) + + private documents: Record = reactive({ + new_team: { + text: [this.t("team.title"), this.t("spotlight.workspace.new")], + alternates: ["new", "team", "workspace"], + icon: markRaw(IconUsers), + }, + edit_team: { + text: [this.t("team.title"), this.t("spotlight.workspace.edit")], + alternates: ["edit", "team", "workspace"], + icon: markRaw(IconEdit), + excludeFromSearch: computed(() => !this.isTeamSelected.value), + }, + invite_members: { + text: [this.t("team.title"), this.t("spotlight.workspace.invite")], + alternates: ["invite", "members", "workspace"], + icon: markRaw(IconUserPlus), + excludeFromSearch: computed(() => !this.isTeamSelected.value), + }, + delete_team: { + text: [this.t("team.title"), this.t("spotlight.workspace.delete")], + alternates: ["delete", "team", "workspace"], + icon: markRaw(IconTrash2), + excludeFromSearch: computed(() => !this.isTeamSelected.value), + }, + switch_to_personal: { + text: [ + this.t("team.title"), + this.t("spotlight.workspace.switch_to_personal"), + ], + alternates: ["switch", "team", "workspace", "personal"], + icon: markRaw(IconUser), + excludeFromSearch: computed(() => !this.isTeamSelected.value), + }, + }) + + // TODO: Constructors are no longer recommended as of dioc > 3, move to onServiceInit + constructor(c: Container) { + super(c, { + searchFields: ["text", "alternates"], + fieldWeights: { + text: 2, + alternates: 1, + }, + }) + } + + override onServiceInit() { + this.setDocuments(this.documents) + this.spotlight.registerSearcher(this) + } + + protected getSearcherResultForSearchResult( + result: SearchResult + ): SpotlightSearcherResult { + return { + id: result.id, + icon: result.doc.icon, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + private deleteTeam(): void { + if (this.workspace.value.type === "team") + invokeAction(`modals.team.delete`, { + teamId: this.workspace.value.teamID, + }) + } + + public onDocSelected(id: string): void { + if (id === "new_team") { + if (platform.auth.getCurrentUser()) { + invokeAction(`modals.team.new`) + } else { + invokeAction(`modals.login.toggle`) + } + } else if (id === "edit_team") invokeAction(`modals.team.edit`) + else if (id === "invite_members") invokeAction(`modals.team.invite`) + else if (id === "delete_team") this.deleteTeam() + else if (id === "switch_to_personal") + invokeAction(`workspace.switch.personal`) + } +} + +/** + * This searcher is responsible for searching through the environment. + * And switching between them. + */ +export class SwitchWorkspaceSpotlightSearcherService + extends Service + implements SpotlightSearcher +{ + public static readonly ID = "SWITCH_WORKSPACE_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public searcherID = "switch_workspace" + public searcherSectionTitle = this.t("workspace.title") + + private readonly spotlight = this.bind(SpotlightService) + private readonly workspaceService = this.bind(WorkspaceService) + + override onServiceInit() { + this.spotlight.registerSearcher(this) + } + + private fetchMyTeams(): Promise { + return new Promise(async (resolve) => { + const currentUser = platform.auth.getCurrentUser() + if (!currentUser) return resolve([]) + + const results: GetMyTeamsQuery["myTeams"] = [] + + const cursor = + results.length > 0 ? results[results.length - 1].id : undefined + + const result = await platform.backend.getUserTeams(cursor) + + if (E.isRight(result)) results.push(...result.right.myTeams) + resolve(results) + }) + } + + private workspace = this.workspaceService.currentWorkspace + + createSearchSession( + query: Readonly> + ): [Ref, () => void] { + const loading = ref(false) + const results = ref([]) + + const minisearch = new MiniSearch({ + fields: ["name", "alternates"], + storeFields: ["name"], + }) + + this.fetchMyTeams().then((teams) => { + minisearch.addAll( + teams.map((entry) => { + let id = `workspace-${entry.id}` + // if id matches add -selected to it + if ( + this.workspace.value.type === "team" && + this.workspace.value.teamID === entry.id + ) { + id += "-selected" + } + return { + id, + name: entry.name, + alternates: ["team", "workspace", "change", "switch"], + } + }) + ) + }) + + const scopeHandle = effectScope() + + scopeHandle.run(() => { + watch( + [query], + ([query]) => { + results.value = minisearch + .search(query, { + prefix: true, + fuzzy: true, + boost: { + reltime: 2, + }, + weights: { + fuzzy: 0.2, + prefix: 0.8, + }, + }) + .map((x) => { + return { + id: x.id, + icon: markRaw( + x.id.endsWith("-selected") ? IconCheckCircle : IconUsers + ), + score: x.score, + text: { + type: "text", + text: [this.t("workspace.change"), x.name], + }, + } + }) + }, + { immediate: true } + ) + }) + + const onSessionEnd = () => { + scopeHandle.stop() + minisearch.removeAll() + } + + const resultObj = computed(() => ({ + loading: loading.value, + results: results.value, + })) + + return [resultObj, onSessionEnd] + } + + onResultSelect(result: SpotlightSearcherResult): void { + invokeAction("workspace.switch", { + teamId: result.id.split("-")[1], + }) + } +} diff --git a/packages/hoppscotch-common/src/services/tab/__tests__/tab.service.spec.ts b/packages/hoppscotch-common/src/services/tab/__tests__/tab.service.spec.ts new file mode 100644 index 0000000..96272dd --- /dev/null +++ b/packages/hoppscotch-common/src/services/tab/__tests__/tab.service.spec.ts @@ -0,0 +1,587 @@ +import { describe, expect, it } from "vitest" +import { TestContainer } from "dioc/testing" +import { TabService } from "../tab" +import { reactive } from "vue" + +class MockTabService extends TabService<{ request: string }> { + public static readonly ID = "MOCK_TAB_SERVICE" + + override onServiceInit() { + this.tabMap = reactive( + new Map([ + [ + "test", + { + id: "test", + document: { + request: "test request", + }, + }, + ], + ]) + ) + + this.watchCurrentTabID() + } + + public getMRUOrder(): string[] { + return [...this.mruOrder] + } + + public getMRUNavigationIndex(): number { + return this.mruNavigationIndex + } +} + +describe("TabService", () => { + it("initially only one tab is defined", () => { + const container = new TestContainer() + + const service = container.bind(MockTabService) + + expect(service.getActiveTabs().value.length).toEqual(1) + }) + + it("initially the only tab is the active tab", () => { + const container = new TestContainer() + + const service = container.bind(MockTabService) + + expect(service.getActiveTab()).not.toBeNull() + }) + + it("initially active tab id is test", () => { + const container = new TestContainer() + + const service = container.bind(MockTabService) + + expect(service.getActiveTab()?.id).toEqual("test") + }) + + it("add new tab", () => { + const container = new TestContainer() + + const service = container.bind(MockTabService) + + service.createNewTab({ + request: "new request", + }) + + expect(service.getActiveTabs().value.length).toEqual(2) + }) + + it("get active tab", () => { + const container = new TestContainer() + + const service = container.bind(MockTabService) + + console.log(service.getActiveTab()) + + expect(service.getActiveTab()?.id).toEqual("test") + }) + + it("sort tabs", () => { + const container = new TestContainer() + + const service = container.bind(MockTabService) + + const currentOrder = service.updateTabOrdering(1, 0) + + expect(currentOrder[1]).toEqual("test") + }) + + it("update tab", () => { + const container = new TestContainer() + + const service = container.bind(MockTabService) + + service.updateTab({ + id: service.currentTabID.value, + document: { + request: "updated request", + }, + }) + + expect(service.getActiveTab()?.document.request).toEqual("updated request") + }) + + it("set new active tab", () => { + const container = new TestContainer() + + const service = container.bind(MockTabService) + + service.setActiveTab("test") + + expect(service.getActiveTab()?.id).toEqual("test") + }) + + it("close other tabs", () => { + const container = new TestContainer() + + const service = container.bind(MockTabService) + + service.closeOtherTabs("test") + + expect(service.getActiveTabs().value.length).toEqual(1) + }) + + it("close tab", () => { + const container = new TestContainer() + + const service = container.bind(MockTabService) + + service.createNewTab({ + request: "new request", + }) + + expect(service.getActiveTabs().value.length).toEqual(2) + + service.closeTab("test") + + expect(service.getActiveTabs().value.length).toEqual(1) + }) + + describe("Tab Navigation", () => { + it("should navigate to next tab", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const tab2 = service.createNewTab({ request: "second request" }) + const tab3 = service.createNewTab({ request: "third request" }) + + service.setActiveTab("test") + expect(service.getActiveTab()?.id).toEqual("test") + + service.goToNextTab() + expect(service.getActiveTab()?.id).toEqual(tab2.id) + + service.goToNextTab() + expect(service.getActiveTab()?.id).toEqual(tab3.id) + + service.goToNextTab() + expect(service.getActiveTab()?.id).toEqual("test") + }) + + it("should navigate to previous tab", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const tab2 = service.createNewTab({ request: "second request" }) + const tab3 = service.createNewTab({ request: "third request" }) + + service.setActiveTab(tab3.id) + expect(service.getActiveTab()?.id).toEqual(tab3.id) + + service.goToPreviousTab() + expect(service.getActiveTab()?.id).toEqual(tab2.id) + + service.goToPreviousTab() + expect(service.getActiveTab()?.id).toEqual("test") + + service.goToPreviousTab() + expect(service.getActiveTab()?.id).toEqual(tab3.id) + }) + + it("should navigate to first tab", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + // Unused variable that improves readability. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const tab2 = service.createNewTab({ request: "second request" }) + const tab3 = service.createNewTab({ request: "third request" }) + + service.setActiveTab(tab3.id) + expect(service.getActiveTab()?.id).toEqual(tab3.id) + + service.goToFirstTab() + expect(service.getActiveTab()?.id).toEqual("test") + }) + + it("should navigate to last tab", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + // Unused variable that improves readability. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const tab2 = service.createNewTab({ request: "second request" }) + const tab3 = service.createNewTab({ request: "third request" }) + + service.setActiveTab("test") + expect(service.getActiveTab()?.id).toEqual("test") + + service.goToLastTab() + expect(service.getActiveTab()?.id).toEqual(tab3.id) + }) + + it("should navigate to tab by index", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const tab2 = service.createNewTab({ request: "second request" }) + const tab3 = service.createNewTab({ request: "third request" }) + + service.goToTabByIndex(1) + expect(service.getActiveTab()?.id).toEqual("test") + + service.goToTabByIndex(2) + expect(service.getActiveTab()?.id).toEqual(tab2.id) + + service.goToTabByIndex(3) + expect(service.getActiveTab()?.id).toEqual(tab3.id) + }) + + it("should handle invalid tab index gracefully", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const originalActiveTab = service.getActiveTab() + + service.goToTabByIndex(0) // Invalid (0-based) + expect(service.getActiveTab()?.id).toEqual(originalActiveTab?.id) + + service.goToTabByIndex(5) // Invalid (out of range) + expect(service.getActiveTab()?.id).toEqual(originalActiveTab?.id) + + service.goToTabByIndex(-1) // Invalid (negative) + expect(service.getActiveTab()?.id).toEqual(originalActiveTab?.id) + }) + + it("should handle navigation with single tab", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const originalActiveTab = service.getActiveTab() + + service.goToNextTab() + expect(service.getActiveTab()?.id).toEqual(originalActiveTab?.id) + + service.goToPreviousTab() + expect(service.getActiveTab()?.id).toEqual(originalActiveTab?.id) + + service.goToFirstTab() + expect(service.getActiveTab()?.id).toEqual(originalActiveTab?.id) + + service.goToLastTab() + expect(service.getActiveTab()?.id).toEqual(originalActiveTab?.id) + }) + }) + + // NOTE: This feature is currently WIP. + describe("Recently Closed Tabs", () => { + it("should reopen closed tab", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const tab2 = service.createNewTab({ request: "second request" }) + expect(service.getActiveTabs().value.length).toEqual(2) + + service.setActiveTab("test") + + service.closeTab(tab2.id) + expect(service.getActiveTabs().value.length).toEqual(1) + expect(service.getActiveTab()?.id).toEqual("test") + + const reopened = service.reopenClosedTab() + expect(reopened).toBe(true) + expect(service.getActiveTabs().value.length).toEqual(2) + expect(service.getActiveTab()?.id).toEqual(tab2.id) + }) + + it("should return false when no tabs to reopen", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const reopened = service.reopenClosedTab() + expect(reopened).toBe(false) + expect(service.getActiveTabs().value.length).toEqual(1) + }) + + it("should maintain closed tabs history with correct order", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const tab2 = service.createNewTab({ request: "second request" }) + const tab3 = service.createNewTab({ request: "third request" }) + const tab4 = service.createNewTab({ request: "fourth request" }) + + service.closeTab(tab2.id) + service.closeTab(tab3.id) + service.closeTab(tab4.id) + + expect(service.getActiveTabs().value.length).toEqual(1) + + service.reopenClosedTab() + expect(service.getActiveTab()?.id).toEqual(tab4.id) + + service.reopenClosedTab() + expect(service.getActiveTab()?.id).toEqual(tab3.id) + + service.reopenClosedTab() + expect(service.getActiveTab()?.id).toEqual(tab2.id) + + expect(service.getActiveTabs().value.length).toEqual(4) + }) + + it("should restore tab at correct position when reopened", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const tab2 = service.createNewTab({ request: "second request" }) + const tab3 = service.createNewTab({ request: "third request" }) + + const originalOrdering = service + .getActiveTabs() + .value.map((tab) => tab.id) + expect(originalOrdering).toEqual(["test", tab2.id, tab3.id]) + + service.closeTab(tab2.id) + expect(service.getActiveTabs().value.map((tab) => tab.id)).toEqual([ + "test", + tab3.id, + ]) + + service.reopenClosedTab() + expect(service.getActiveTabs().value.map((tab) => tab.id)).toEqual([ + "test", + tab2.id, + tab3.id, + ]) + }) + }) + + describe("MRU Tab Navigation", () => { + it("should track MRU order when switching tabs", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const tab2 = service.createNewTab({ request: "second request" }) + const tab3 = service.createNewTab({ request: "third request" }) + + expect(service.getMRUOrder()[0]).toEqual(tab3.id) + + service.setActiveTab("test") + expect(service.getMRUOrder()[0]).toEqual("test") + expect(service.getMRUOrder()[1]).toEqual(tab3.id) + expect(service.getMRUOrder()[2]).toEqual(tab2.id) + + service.setActiveTab(tab2.id) + expect(service.getMRUOrder()[0]).toEqual(tab2.id) + expect(service.getMRUOrder()[1]).toEqual("test") + expect(service.getMRUOrder()[2]).toEqual(tab3.id) + }) + + it("should navigate forward through MRU list with goToMRUTab", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const tab2 = service.createNewTab({ request: "second request" }) + const tab3 = service.createNewTab({ request: "third request" }) + + service.setActiveTab("test") + service.setActiveTab(tab2.id) + service.setActiveTab(tab3.id) + + expect(service.getActiveTab()?.id).toEqual(tab3.id) + + service.goToMRUTab() + expect(service.getActiveTab()?.id).toEqual(tab2.id) + + service.goToMRUTab() + expect(service.getActiveTab()?.id).toEqual("test") + + service.goToMRUTab() + expect(service.getActiveTab()?.id).toEqual(tab3.id) + }) + + it("should navigate backward through MRU list with goToPreviousMRUTab", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const tab2 = service.createNewTab({ request: "second request" }) + const tab3 = service.createNewTab({ request: "third request" }) + + service.setActiveTab("test") + service.setActiveTab(tab2.id) + service.setActiveTab(tab3.id) + + expect(service.getActiveTab()?.id).toEqual(tab3.id) + + service.goToPreviousMRUTab() + expect(service.getActiveTab()?.id).toEqual("test") + + service.goToPreviousMRUTab() + expect(service.getActiveTab()?.id).toEqual(tab2.id) + + service.goToPreviousMRUTab() + expect(service.getActiveTab()?.id).toEqual(tab3.id) + }) + + it("should allow bidirectional MRU navigation", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const tab2 = service.createNewTab({ request: "second request" }) + const tab3 = service.createNewTab({ request: "third request" }) + + service.setActiveTab("test") + service.setActiveTab(tab2.id) + service.setActiveTab(tab3.id) + + service.goToMRUTab() // -> tab2 + service.goToMRUTab() // -> test + expect(service.getActiveTab()?.id).toEqual("test") + + service.goToPreviousMRUTab() // -> tab2 + expect(service.getActiveTab()?.id).toEqual(tab2.id) + + service.goToMRUTab() // -> test + expect(service.getActiveTab()?.id).toEqual("test") + }) + + it("should handle MRU navigation with single tab", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const originalActiveTab = service.getActiveTab() + + service.goToMRUTab() + expect(service.getActiveTab()?.id).toEqual(originalActiveTab?.id) + + service.goToPreviousMRUTab() + expect(service.getActiveTab()?.id).toEqual(originalActiveTab?.id) + }) + + it("should remove closed tabs from MRU order", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const tab2 = service.createNewTab({ request: "second request" }) + const tab3 = service.createNewTab({ request: "third request" }) + + service.setActiveTab("test") + service.setActiveTab(tab2.id) + service.setActiveTab(tab3.id) + + expect(service.getMRUOrder()).toContain(tab2.id) + + service.closeTab(tab2.id) + + expect(service.getMRUOrder()).not.toContain(tab2.id) + expect(service.getMRUOrder().length).toEqual(2) + }) + + it("should reset MRU navigation index when tab is explicitly activated", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const tab2 = service.createNewTab({ request: "second request" }) + const tab3 = service.createNewTab({ request: "third request" }) + + service.setActiveTab("test") + service.setActiveTab(tab2.id) + service.setActiveTab(tab3.id) + + service.goToMRUTab() + expect(service.getMRUNavigationIndex()).toEqual(1) + + service.setActiveTab("test") + + expect(service.getMRUNavigationIndex()).toEqual(-1) + }) + + it("should commit MRU navigation and update order", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const tab2 = service.createNewTab({ request: "second request" }) + const tab3 = service.createNewTab({ request: "third request" }) + + service.setActiveTab("test") + service.setActiveTab(tab2.id) + service.setActiveTab(tab3.id) + + service.goToMRUTab() + expect(service.getActiveTab()?.id).toEqual(tab2.id) + expect(service.getMRUNavigationIndex()).toEqual(1) + + service.commitMRUNavigation() + + expect(service.getMRUOrder()[0]).toEqual(tab2.id) + expect(service.getMRUNavigationIndex()).toEqual(-1) + }) + + it("should reset MRU navigation without committing", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + service.createNewTab({ request: "second request" }) + service.createNewTab({ request: "third request" }) + + service.goToMRUTab() + expect(service.getMRUNavigationIndex()).toBeGreaterThan(-1) + + service.resetMRUNavigation() + + expect(service.getMRUNavigationIndex()).toEqual(-1) + }) + + it("should not add background tabs to MRU until activated", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const backgroundTab = service.createNewTab( + { request: "background request" }, + false + ) + + // (it will be added when first activated) + const mruBeforeActivation = service.getMRUOrder() + + expect(mruBeforeActivation).not.toContain(backgroundTab.id) + + expect(mruBeforeActivation[0]).toEqual("test") + + expect(mruBeforeActivation.length).toEqual(1) + + service.setActiveTab(backgroundTab.id) + + expect(service.getMRUOrder()[0]).toEqual(backgroundTab.id) + + expect(service.getMRUOrder().length).toEqual(2) + }) + + it("should initialize MRU order from persisted state", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const persistedState = { + lastActiveTabID: "persisted-tab-2", + orderedDocs: [ + { tabID: "persisted-tab-1", doc: { request: "request 1" } }, + { tabID: "persisted-tab-2", doc: { request: "request 2" } }, + { tabID: "persisted-tab-3", doc: { request: "request 3" } }, + ], + } + + service.loadTabsFromPersistedState(persistedState) + + expect(service.getMRUOrder().length).toEqual(3) + + expect(service.getMRUOrder()[0]).toEqual("persisted-tab-2") + }) + + it("should handle closeOtherTabs correctly with MRU", () => { + const container = new TestContainer() + const service = container.bind(MockTabService) + + const tab2 = service.createNewTab({ request: "second request" }) + service.createNewTab({ request: "third request" }) + + service.closeOtherTabs(tab2.id) + + expect(service.getMRUOrder()).toEqual([tab2.id]) + expect(service.getMRUNavigationIndex()).toEqual(-1) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/tab/graphql.ts b/packages/hoppscotch-common/src/services/tab/graphql.ts new file mode 100644 index 0000000..decdc4b --- /dev/null +++ b/packages/hoppscotch-common/src/services/tab/graphql.ts @@ -0,0 +1,81 @@ +import { isEqual } from "lodash-es" +import { getDefaultGQLRequest } from "~/helpers/graphql/default" +import { HoppGQLDocument, HoppGQLSaveContext } from "~/helpers/graphql/document" +import { TabService } from "./tab" +import { computed } from "vue" +import { Container } from "dioc" +import { getService } from "~/modules/dioc" +import { PersistenceService, STORE_KEYS } from "../persistence" +import { PersistableTabState } from "." + +export class GQLTabService extends TabService { + public static readonly ID = "GQL_TAB_SERVICE" + + // TODO: Moving this to `onServiceInit` breaks `persistableTabState` + // Figure out how to fix this + constructor(c: Container) { + super(c) + + this.tabMap.set("test", { + id: "test", + document: { + request: getDefaultGQLRequest(), + isDirty: false, + optionTabPreference: "query", + cursorPosition: 0, + }, + }) + + this.watchCurrentTabID() + } + + // override persistableTabState to remove response from the document + public override persistableTabState = computed(() => ({ + lastActiveTabID: this.currentTabID.value, + orderedDocs: this.tabOrdering.value.map((tabID) => { + const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key + return { + tabID: tab.id, + doc: { + ...tab.document, + response: null, + }, + } + }), + })) + + protected async loadPersistedState(): Promise | null> { + const persistenceService = getService(PersistenceService) + const savedState = await persistenceService.getNullable< + PersistableTabState + >(STORE_KEYS.GQL_TABS) + return savedState + } + + public getTabRefWithSaveContext(ctx: HoppGQLSaveContext) { + for (const tab of this.tabMap.values()) { + // For `team-collection` request id can be considered unique + if (ctx?.originLocation === "team-collection") { + if ( + tab.document.saveContext?.originLocation === "team-collection" && + tab.document.saveContext.requestID === ctx.requestID + ) { + return this.getTabRef(tab.id) + } + } else if (isEqual(ctx, tab.document.saveContext)) + return this.getTabRef(tab.id) + } + + return null + } + + public getDirtyTabsCount() { + let count = 0 + + for (const tab of this.tabMap.values()) { + if (tab.document.isDirty) count++ + } + + return count + } +} diff --git a/packages/hoppscotch-common/src/services/tab/index.ts b/packages/hoppscotch-common/src/services/tab/index.ts new file mode 100644 index 0000000..0891827 --- /dev/null +++ b/packages/hoppscotch-common/src/services/tab/index.ts @@ -0,0 +1,172 @@ +import { ComputedRef, WritableComputedRef } from "vue" + +/** + * Represents a tab in HoppScotch. + * @template Doc The type of the document associated with the tab. + */ +export type HoppTab = { + /** The unique identifier of the tab. */ + id: string + /** The document associated with the tab. */ + document: Doc +} + +export type PersistableTabState = { + lastActiveTabID: string + orderedDocs: Array<{ + tabID: string + doc: Doc + }> +} + +/** + * Represents a service for managing tabs with documents. + * @template Doc - The type of document associated with each tab. + */ +export interface TabService { + /** + * Gets the current active tab. + */ + currentActiveTab: ComputedRef> + + /** + * Creates a new tab with the given document and sets it as the active tab. + * @param document - The document to associate with the new tab. + * @returns The newly created tab. + */ + createNewTab(document: Doc): HoppTab + + /** + * Gets an array of all tabs. + * @returns An array of all tabs. + */ + getTabs(): HoppTab[] + + /** + * Gets the currently active tab. + * @returns The active tab or null if no tab is active. + */ + getActiveTab(): HoppTab | null + + /** + * Sets the active tab by its ID. + * @param tabID - The ID of the tab to set as active. + */ + setActiveTab(tabID: string): void + + /** + * Loads tabs and their ordering from a persisted state. + * @param data - The persisted tab state to load. + */ + loadTabsFromPersistedState(data: PersistableTabState): void + + /** + * Gets a read-only computed reference to the active tabs. + * @returns A computed reference to the active tabs. + */ + getActiveTabs(): Readonly[]>> + + /** + * Gets a computed reference to a specific tab by its ID. + * @param tabID - The ID of the tab to retrieve. + * @returns A computed reference to the specified tab. + * @throws An error if the tab with the specified ID does not exist. + */ + getTabRef(tabID: string): WritableComputedRef> + + /** + * Updates the properties of a tab. + * @param tabUpdate - The updated tab object. + */ + updateTab(tabUpdate: HoppTab): void + + /** + * Updates the ordering of tabs by moving a tab from one index to another. + * @param fromIndex - The current index of the tab to move. + * @param toIndex - The target index where the tab should be moved to. + */ + updateTabOrdering(fromIndex: number, toIndex: number): void + + /** + * Closes the tab with the specified ID. + * @param tabID - The ID of the tab to close. + */ + closeTab(tabID: string): void + + /** + * Closes all tabs except the one with the specified ID. + * @param tabID - The ID of the tab to keep open. + */ + closeOtherTabs(tabID: string): void + + /** + * Navigates to the next tab in the tab order. + */ + goToNextTab(): void + + /** + * Navigates to the previous tab in the tab order. + */ + goToPreviousTab(): void + + /** + * NOTE: Currently inert, plumbing is done, some platform issues around shortcuts, WIP for future. + * Navigates to a tab by its index position (1-based). + * @param index - The 1-based index of the tab to navigate to. + */ + goToTabByIndex(index: number): void + + /** + * Navigates to the first tab in the tab order. + */ + goToFirstTab(): void + + /** + * Navigates to the last tab in the tab order. + */ + goToLastTab(): void + + /** + * Reopens the most recently closed tab. + * @returns True if a tab was reopened, false if no closed tabs are available. + */ + reopenClosedTab(): boolean + + /** + * Navigates forward through the MRU list (to older tabs). + * Each call moves one step forward in the MRU history. + */ + goToMRUTab(): void + + /** + * Navigates backward through the MRU list (to more recent tabs). + * Each call moves one step backward in the MRU history. + */ + goToPreviousMRUTab(): void + + /** + * Commits the current MRU navigation selection. + * Should be called when the modifier key is released to finalize the tab switch. + */ + commitMRUNavigation(): void + + /** + * Resets MRU navigation state without committing. + */ + resetMRUNavigation(): void + + /** + * Gets a computed reference to a persistable tab state. + * @returns A computed reference to a persistable tab state object. + */ + persistableTabState: ComputedRef> + + /** + * Gets computed references to tabs that match a specified condition. + * @param func - A function that defines the condition for selecting tabs. + * @returns An array of computed references to matching tabs. + */ + getTabsRefTo( + func: (tab: HoppTab) => boolean + ): WritableComputedRef>[] +} diff --git a/packages/hoppscotch-common/src/services/tab/rest.ts b/packages/hoppscotch-common/src/services/tab/rest.ts new file mode 100644 index 0000000..35e50d2 --- /dev/null +++ b/packages/hoppscotch-common/src/services/tab/rest.ts @@ -0,0 +1,111 @@ +import { Container } from "dioc" +import { computed } from "vue" +import { getDefaultRESTRequest } from "~/helpers/rest/default" +import { HoppRESTSaveContext, HoppTabDocument } from "~/helpers/rest/document" +import { getService } from "~/modules/dioc" +import { PersistenceService, STORE_KEYS } from "../persistence" +import { TabService } from "./tab" +import { PersistableTabState } from "." + +export class RESTTabService extends TabService { + public static readonly ID = "REST_TAB_SERVICE" + + // TODO: Moving this to `onServiceInit` breaks `persistableTabState` + // Figure out how to fix this + constructor(c: Container) { + super(c) + + this.tabMap.set("test", { + id: "test", + document: { + type: "request", + request: getDefaultRESTRequest(), + isDirty: false, + optionTabPreference: "params", + }, + }) + + this.watchCurrentTabID() + } + + // override persistableTabState to remove response from the document + public override persistableTabState = computed(() => ({ + lastActiveTabID: this.currentTabID.value, + orderedDocs: this.tabOrdering.value.map((tabID) => { + const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key + + if (tab.document.type === "example-response") { + return { + tabID: tab.id, + doc: tab.document, + } + } + + if (tab.document.type === "test-runner") { + return { + tabID: tab.id, + doc: { + ...tab.document, + request: null, + response: null, + }, + } + } + + return { + tabID: tab.id, + doc: { + ...tab.document, + response: null, + }, + } + }), + })) + + protected async loadPersistedState(): Promise | null> { + const persistenceService = getService(PersistenceService) + const savedState = await persistenceService.getNullable< + PersistableTabState + >(STORE_KEYS.REST_TABS) + return savedState + } + + public getTabRefWithSaveContext(ctx: HoppRESTSaveContext) { + for (const tab of this.tabMap.values()) { + // For `team-collection` request id can be considered unique + if (tab.document.type === "test-runner") continue + + if (ctx?.originLocation === "team-collection") { + if ( + tab.document.saveContext?.originLocation === "team-collection" && + tab.document.saveContext.requestID === ctx.requestID && + tab.document.saveContext.exampleID === ctx.exampleID + ) { + return this.getTabRef(tab.id) + } + } else if ( + tab.document.saveContext?.originLocation === "user-collection" && + tab.document.saveContext.folderPath === ctx?.folderPath && + tab.document.saveContext.requestIndex === ctx?.requestIndex && + tab.document.saveContext.exampleID === ctx?.exampleID && + (ctx?.requestRefID != null + ? tab.document.saveContext.requestRefID === ctx.requestRefID + : true) + ) { + return this.getTabRef(tab.id) + } + } + + return null + } + + public getDirtyTabsCount() { + let count = 0 + + for (const tab of this.tabMap.values()) { + if (tab.document.isDirty) count++ + } + + return count + } +} diff --git a/packages/hoppscotch-common/src/services/tab/tab.ts b/packages/hoppscotch-common/src/services/tab/tab.ts new file mode 100644 index 0000000..f01eeb6 --- /dev/null +++ b/packages/hoppscotch-common/src/services/tab/tab.ts @@ -0,0 +1,407 @@ +import { refWithControl } from "@vueuse/core" +import { Service } from "dioc" +import { v4 as uuidV4 } from "uuid" +import { + ComputedRef, + computed, + nextTick, + reactive, + ref, + shallowReadonly, + watch, +} from "vue" +import { + HoppTab, + PersistableTabState, + TabService as TabServiceInterface, +} from "." + +export abstract class TabService + extends Service + implements TabServiceInterface +{ + protected tabMap = reactive(new Map>()) as Map< + string, + HoppTab + > // TODO: The implicit cast is necessary as the reactive unwraps the inner types, creating weird type errors, this needs to be refactored and removed + protected tabOrdering = ref(["test"]) + protected recentlyClosedTabs: Array<{ tab: HoppTab; index: number }> = [] + protected readonly MAX_CLOSED_TABS_HISTORY = 10 + + // MRU (Most Recently Used) tracking + // mruOrder[0] is the most recently used tab, mruOrder[n-1] is the least recently used + protected mruOrder: string[] = ["test"] + // Navigation index for cycling through MRU list while modifier key is held + // -1 means not currently navigating, 0+ means current position in mruOrder + protected mruNavigationIndex: number = -1 + + public currentTabID = refWithControl("test", { + onBeforeChange: (newTabID) => { + if (!newTabID || !this.tabMap.has(newTabID)) { + console.warn( + `Tried to set current tab id to an invalid value. (value: ${newTabID})` + ) + + // Don't allow change + return false + } + }, + }) + + public currentActiveTab = computed( + () => this.tabMap.get(this.currentTabID.value)! + ) // Guaranteed to not be undefined + + protected watchCurrentTabID() { + watch( + this.tabOrdering, + (newOrdering) => { + if ( + !this.currentTabID.value || + !newOrdering.includes(this.currentTabID.value) + ) { + this.setActiveTab(newOrdering[newOrdering.length - 1]) // newOrdering should always be non-empty + } + }, + { deep: true } + ) + } + + public async init() { + const persistedState = await this.loadPersistedState() + if (persistedState) { + this.loadTabsFromPersistedState(persistedState) + } + } + + protected abstract loadPersistedState(): Promise | null> + + public createNewTab(document: Doc, switchToIt = true): HoppTab { + const id = this.generateNewTabID() + + const tab: HoppTab = { id, document } + + this.tabMap.set(id, tab) + this.tabOrdering.value.push(id) + + if (switchToIt) { + this.setActiveTab(id) + } + // Note: We don't add to mruOrder here if switchToIt is false + // The tab will be added to mruOrder when it's first activated via setActiveTab + + return tab + } + + public getTabs(): HoppTab[] { + return Array.from(this.tabMap.values()) + } + + public getActiveTab(): HoppTab | null { + return this.tabMap.get(this.currentTabID.value) ?? null + } + + public setActiveTab(tabID: string): void { + this.currentTabID.value = tabID + this.updateMRUOrder(tabID) + } + + private updateMRUOrder(tabID: string): void { + const index = this.mruOrder.indexOf(tabID) + if (index > -1) { + this.mruOrder.splice(index, 1) + } + this.mruOrder.unshift(tabID) + + // Reset navigation index when a tab is explicitly activated + // This ensures the next MRU navigation starts fresh + this.mruNavigationIndex = -1 + } + + public loadTabsFromPersistedState(data: PersistableTabState): void { + if (data) { + this.tabMap.clear() + this.tabOrdering.value = [] + this.mruOrder = [] + this.mruNavigationIndex = -1 + + for (const doc of data.orderedDocs) { + this.tabMap.set(doc.tabID, { + id: doc.tabID, + document: doc.doc, + }) + + this.tabOrdering.value.push(doc.tabID) + this.mruOrder.push(doc.tabID) + } + + this.setActiveTab(data.lastActiveTabID) + } + } + + public getActiveTabs(): Readonly[]>> { + return shallowReadonly( + computed(() => this.tabOrdering.value.map((x) => this.tabMap.get(x)!)) + ) + } + + public getTabRef(tabID: string) { + return computed({ + get: () => { + const result = this.tabMap.get(tabID) + + if (result === undefined) throw new Error(`Invalid tab id: ${tabID}`) + + return result + }, + set: (value) => { + return this.tabMap.set(tabID, value) + }, + }) + } + + public updateTab(tabUpdate: HoppTab) { + if (!this.tabMap.has(tabUpdate.id)) { + console.warn( + `Cannot update tab as tab with that tab id does not exist (id: ${tabUpdate.id})` + ) + } + + this.tabMap.set(tabUpdate.id, tabUpdate) + } + + public updateTabOrdering(fromIndex: number, toIndex: number) { + this.tabOrdering.value.splice( + toIndex, + 0, + this.tabOrdering.value.splice(fromIndex, 1)[0] + ) + + return this.tabOrdering.value + } + + public closeTab(tabID: string) { + if (!this.tabMap.has(tabID)) { + console.warn( + `Tried to close a tab which does not exist (tab id: ${tabID})` + ) + return + } + + if (this.tabOrdering.value.length === 1) { + console.warn( + `Tried to close the only tab open, which is not allowed. (tab id: ${tabID})` + ) + return + } + + const tabIndex = this.tabOrdering.value.indexOf(tabID) + const tab = this.tabMap.get(tabID)! + + this.addToRecentlyClosedTabs(tab, tabIndex) + + const mruIndex = this.mruOrder.indexOf(tabID) + if (mruIndex > -1) { + this.mruOrder.splice(mruIndex, 1) + } + + // Reset navigation index when tabs change + this.mruNavigationIndex = -1 + + this.tabOrdering.value.splice(tabIndex, 1) + + nextTick(() => { + this.tabMap.delete(tabID) + }) + } + + public closeOtherTabs(tabID: string) { + if (!this.tabMap.has(tabID)) { + console.warn( + `The tab to close other tabs does not exist (tab id: ${tabID})` + ) + return + } + + this.tabOrdering.value = [tabID] + this.mruOrder = [tabID] + this.mruNavigationIndex = -1 + + this.tabMap.forEach((_, id) => { + if (id !== tabID) this.tabMap.delete(id) + }) + + this.currentTabID.value = tabID + } + + public goToNextTab(): void { + const currentIndex = this.tabOrdering.value.indexOf(this.currentTabID.value) + const nextIndex = (currentIndex + 1) % this.tabOrdering.value.length + const nextTabID = this.tabOrdering.value[nextIndex] + this.setActiveTab(nextTabID) + } + + public goToPreviousTab(): void { + const currentIndex = this.tabOrdering.value.indexOf(this.currentTabID.value) + const prevIndex = + currentIndex === 0 ? this.tabOrdering.value.length - 1 : currentIndex - 1 + const prevTabID = this.tabOrdering.value[prevIndex] + this.setActiveTab(prevTabID) + } + + // NOTE: Currently inert, plumbing is done, some platform issues around shortcuts, WIP for future. + public goToTabByIndex(index: number): void { + if (index >= 1 && index <= this.tabOrdering.value.length) { + const tabID = this.tabOrdering.value[index - 1] + this.setActiveTab(tabID) + } + } + + public goToFirstTab(): void { + const firstTabID = this.tabOrdering.value[0] + this.setActiveTab(firstTabID) + } + + public goToLastTab(): void { + const lastTabID = this.tabOrdering.value[this.tabOrdering.value.length - 1] + this.setActiveTab(lastTabID) + } + + public reopenClosedTab(): boolean { + if (this.recentlyClosedTabs.length === 0) { + return false + } + + const { tab, index } = this.recentlyClosedTabs.pop()! + + this.tabMap.set(tab.id, tab) + + const insertIndex = Math.min(index, this.tabOrdering.value.length) + this.tabOrdering.value.splice(insertIndex, 0, tab.id) + + this.setActiveTab(tab.id) + + return true + } + + /** + * Navigates forward through the MRU list (to older tabs). + * Each call moves one step forward in the MRU history. + * + * Behavior: + * - First call: moves from current tab (index 0) to second most recent (index 1) + * - Subsequent calls: continue moving forward through history + * - Wraps around to the beginning when reaching the end + */ + public goToMRUTab(): void { + // Clean up any stale entries + this.mruOrder = this.mruOrder.filter((tabID) => this.tabMap.has(tabID)) + + if (this.mruOrder.length <= 1) return + + // If not currently navigating, start from position 0 (current tab) + if (this.mruNavigationIndex === -1) { + this.mruNavigationIndex = 0 + } + + // Move forward in MRU list (toward older tabs) + this.mruNavigationIndex = + (this.mruNavigationIndex + 1) % this.mruOrder.length + + const targetTabID = this.mruOrder[this.mruNavigationIndex] + if (targetTabID) { + // Use currentTabID directly to avoid resetting navigation state + this.currentTabID.value = targetTabID + } + } + + /** + * Navigates backward through the MRU list (to more recent tabs). + * Each call moves one step backward in the MRU history. + * + * Behavior: + * - Moves backward from current position in MRU navigation + * - Wraps around to the end when reaching the beginning + */ + public goToPreviousMRUTab(): void { + // Clean up any stale entries + this.mruOrder = this.mruOrder.filter((tabID) => this.tabMap.has(tabID)) + + if (this.mruOrder.length <= 1) return + + // If not currently navigating, start from position 0 (current tab) + if (this.mruNavigationIndex === -1) { + this.mruNavigationIndex = 0 + } + + // Move backward in MRU list (toward more recent tabs) + this.mruNavigationIndex = + this.mruNavigationIndex === 0 + ? this.mruOrder.length - 1 + : this.mruNavigationIndex - 1 + + const targetTabID = this.mruOrder[this.mruNavigationIndex] + if (targetTabID) { + // Use currentTabID directly to avoid resetting navigation state + this.currentTabID.value = targetTabID + } + } + + /** + * Commits the current MRU navigation selection. + * Should be called when the modifier key is released to finalize the tab switch. + * This moves the selected tab to the front of the MRU order. + */ + public commitMRUNavigation(): void { + if (this.mruNavigationIndex > 0) { + const selectedTabID = this.mruOrder[this.mruNavigationIndex] + if (selectedTabID) { + // Move the selected tab to the front of MRU order + this.mruOrder.splice(this.mruNavigationIndex, 1) + this.mruOrder.unshift(selectedTabID) + } + } + this.mruNavigationIndex = -1 + } + + /** + * Resets MRU navigation state without committing. + * Useful if navigation is cancelled. + */ + public resetMRUNavigation(): void { + this.mruNavigationIndex = -1 + } + + private addToRecentlyClosedTabs(tab: HoppTab, index: number): void { + this.recentlyClosedTabs.push({ tab, index }) + + if (this.recentlyClosedTabs.length > this.MAX_CLOSED_TABS_HISTORY) { + this.recentlyClosedTabs.shift() + } + } + + public persistableTabState = computed>(() => ({ + lastActiveTabID: this.currentTabID.value, + orderedDocs: this.tabOrdering.value.map((tabID) => { + const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key + return { + tabID: tab.id, + doc: tab.document, + } + }), + })) + + public getTabsRefTo(func: (tab: HoppTab) => boolean) { + return Array.from(this.tabMap.values()) + .filter(func) + .map((tab) => this.getTabRef(tab.id)) + } + + private generateNewTabID() { + while (true) { + const id = uuidV4() + + if (!this.tabMap.has(id)) return id + } + } +} diff --git a/packages/hoppscotch-common/src/services/team-collection.service.ts b/packages/hoppscotch-common/src/services/team-collection.service.ts new file mode 100644 index 0000000..a108524 --- /dev/null +++ b/packages/hoppscotch-common/src/services/team-collection.service.ts @@ -0,0 +1,1352 @@ +import * as E from "fp-ts/Either" +import { Subscription } from "rxjs" +import { HoppCollectionVariable, translateToNewRequest } from "@hoppscotch/data" +import { pull, remove } from "lodash-es" +import { Subscription as WSubscription } from "wonka" +import { + RootCollectionsOfTeamDocument, + TeamCollectionAddedDocument, + TeamCollectionUpdatedDocument, + TeamCollectionRemovedDocument, + TeamRequestAddedDocument, + TeamRequestUpdatedDocument, + TeamRequestDeletedDocument, + GetCollectionChildrenDocument, + GetCollectionRequestsDocument, + TeamRequestMovedDocument, + TeamCollectionMovedDocument, + TeamRequestOrderUpdatedDocument, + TeamCollectionOrderUpdatedDocument, + TeamRootCollectionsSortedDocument, + TeamChildCollectionSortedDocument, +} from "~/helpers/backend/graphql" +import { SecretEnvironmentService } from "~/services/secret-environment.service" +import { CurrentValueService } from "~/services/current-environment-value.service" +import { TeamCollection } from "~/helpers/teams/TeamCollection" +import { TeamRequest } from "~/helpers/teams/TeamRequest" +import { runGQLQuery, runGQLSubscription } from "~/helpers/backend/GQLClient" +import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" +import { ref, watch } from "vue" +import { Service } from "dioc" +import { updateInheritedPropertiesForAffectedRequests } from "~/helpers/collection/collection" +import { hasActualScript } from "@hoppscotch/js-sandbox/scripting" +import { CollectionDataProps } from "~/helpers/backend/helpers" + +export const TEAMS_BACKEND_PAGE_SIZE = 10 + +const findParentOfColl = ( + tree: TeamCollection[], + collID: string, + currentParent?: TeamCollection +): TeamCollection | null => { + for (const coll of tree) { + // If the root is parent, return null + if (coll.id === collID) return currentParent || null + + // Else run it in children + if (coll.children) { + const result = findParentOfColl(coll.children, collID, coll) + if (result) return result + } + } + + return null +} + +const findCollInTree = ( + tree: TeamCollection[], + targetID: string +): TeamCollection | null => { + for (const coll of tree) { + if (coll.id === targetID) return coll + + if (coll.children) { + const result = findCollInTree(coll.children, targetID) + if (result) return result + } + } + + return null +} + +const deleteCollInTree = (tree: TeamCollection[], targetID: string) => { + const parent = findParentOfColl(tree, targetID) + + if (parent && parent.children) { + parent.children = parent.children.filter((coll) => coll.id !== targetID) + } + + const el = findCollInTree(tree, targetID) + if (!el) return + + pull(tree, el) +} + +const updateCollInTree = ( + tree: TeamCollection[], + updateColl: Partial & Pick +) => { + const el = findCollInTree(tree, updateColl.id) + if (!el) return + Object.assign(el, updateColl) +} + +const findReqInTree = ( + tree: TeamCollection[], + reqID: string +): TeamRequest | null => { + for (const coll of tree) { + if (coll.requests) { + const match = coll.requests.find((req) => req.id === reqID) + if (match) return match + } + + if (coll.children) { + const match = findReqInTree(coll.children, reqID) + if (match) return match + } + } + + return null +} + +const findCollWithReqIDInTree = ( + tree: TeamCollection[], + reqID: string +): TeamCollection | null => { + for (const coll of tree) { + if (coll.requests) { + if (coll.requests.find((req) => req.id === reqID)) return coll + } + + if (coll.children) { + const result = findCollWithReqIDInTree(coll.children, reqID) + if (result) return result + } + } + + return null +} + +export class TeamCollectionsService extends Service { + public static readonly ID = "TEAM_COLLECTIONS_SERVICE" + + //collection variables current value and secret value services + private secretEnvironmentService = this.bind(SecretEnvironmentService) + private currentEnvironmentValueService = this.bind(CurrentValueService) + + private teamID: string | null = null + + public collections = ref([]) + public loadingCollections = ref([]) + public pendingTeamCollectionPath = ref(null) + + private entityIDs: Set = new Set() + + private teamCollectionAdded$: Subscription | null = null + private teamCollectionUpdated$: Subscription | null = null + private teamCollectionRemoved$: Subscription | null = null + private teamRequestAdded$: Subscription | null = null + private teamRequestUpdated$: Subscription | null = null + private teamRequestDeleted$: Subscription | null = null + private teamRequestMoved$: Subscription | null = null + private teamCollectionMoved$: Subscription | null = null + private teamRequestOrderUpdated$: Subscription | null = null + private teamCollectionOrderUpdated$: Subscription | null = null + private teamRootCollectionSorted$: Subscription | null = null + private teamChildCollectionSorted$: Subscription | null = null + + private teamCollectionAddedSub: WSubscription | null = null + private teamCollectionUpdatedSub: WSubscription | null = null + private teamCollectionRemovedSub: WSubscription | null = null + private teamRequestAddedSub: WSubscription | null = null + private teamRequestUpdatedSub: WSubscription | null = null + private teamRequestDeletedSub: WSubscription | null = null + private teamRequestMovedSub: WSubscription | null = null + private teamCollectionMovedSub: WSubscription | null = null + private teamRequestOrderUpdatedSub: WSubscription | null = null + private teamCollectionOrderUpdatedSub: WSubscription | null = null + private teamRootCollectionSortedSub: WSubscription | null = null + private teamChildCollectionSortedSub: WSubscription | null = null + + override onServiceInit() { + this.collectionLoadingWatcher() + } + + /** + * Look up a `TeamCollection` subtree by backend `id` in the current tree. + * Useful before a delete mutation so callers can capture the subtree for + * recursive cleanup (e.g. flushing nested secret-store entries) without + * racing the delete subscription. + */ + public findCollectionByID(id: string): TeamCollection | null { + return findCollInTree(this.collections.value, id) + } + + /** + * Watches for loading collections and updates inherited properties once loading is done + */ + private collectionLoadingWatcher() { + watch( + () => this.loadingCollections.value.length, + (loadingCount) => { + if ( + loadingCount === 0 && + this.pendingTeamCollectionPath.value && + this.collections.value.length > 0 + ) { + updateInheritedPropertiesForAffectedRequests( + this.pendingTeamCollectionPath.value, + "rest" + ) + this.pendingTeamCollectionPath.value = null + } + } + ) + } + + /** + * Change the current team ID and resets the collections + * @param newTeamID The new team ID to switch to + */ + public changeTeamID(newTeamID: string | null) { + this.teamID = newTeamID + this.collections.value = [] + this.entityIDs.clear() + + this.loadingCollections.value = [] + + this.unsubscribeSubscriptions() + + if (this.teamID) this.initialize() + } + + /** + * Clears all collections and resets the service state + */ + public clearCollections() { + this.collections.value = [] + this.entityIDs.clear() + this.loadingCollections.value = [] + this.unsubscribeSubscriptions() + this.teamID = null + } + + /** + * Unsubscribes from the subscriptions + * NOTE: Once this is called, no new updates to the tree will be detected + */ + unsubscribeSubscriptions() { + this.teamCollectionAdded$?.unsubscribe() + this.teamCollectionUpdated$?.unsubscribe() + this.teamCollectionRemoved$?.unsubscribe() + this.teamRequestAdded$?.unsubscribe() + this.teamRequestDeleted$?.unsubscribe() + this.teamRequestUpdated$?.unsubscribe() + this.teamRequestMoved$?.unsubscribe() + this.teamCollectionMoved$?.unsubscribe() + this.teamRequestOrderUpdated$?.unsubscribe() + this.teamCollectionOrderUpdated$?.unsubscribe() + this.teamRootCollectionSorted$?.unsubscribe() + this.teamChildCollectionSorted$?.unsubscribe() + + this.teamCollectionAddedSub?.unsubscribe() + this.teamCollectionUpdatedSub?.unsubscribe() + this.teamCollectionRemovedSub?.unsubscribe() + this.teamRequestAddedSub?.unsubscribe() + this.teamRequestDeletedSub?.unsubscribe() + this.teamRequestUpdatedSub?.unsubscribe() + this.teamRequestMovedSub?.unsubscribe() + this.teamCollectionMovedSub?.unsubscribe() + this.teamRequestOrderUpdatedSub?.unsubscribe() + this.teamCollectionOrderUpdatedSub?.unsubscribe() + this.teamRootCollectionSortedSub?.unsubscribe() + this.teamChildCollectionSortedSub?.unsubscribe() + } + + private async initialize() { + await this.loadRootCollections() + this.registerSubscriptions() + } + + /** + * Performs addition of a collection to the tree + * + * @param {TeamCollection} collection - The collection to add to the tree + * @param {string | null} parentCollectionID - The parent of the new collection, pass null if this collection is in root + */ + private addCollection( + collection: TeamCollection, + parentCollectionID: string | null + ) { + // Check if we have it already in the entity tree, if so, we don't need it again + if (this.entityIDs.has(`collection-${collection.id}`)) return + + const tree = this.collections.value + + if (!parentCollectionID) { + tree.push(collection) + } else { + const parentCollection = findCollInTree(tree, parentCollectionID) + + if (!parentCollection) return + + // Prevent adding child collections to a collection that has not been expanded yet incoming from GQL subscription, during import, etc + // Hence, add entries to the pre-existing list without setting 'children' if it is `null' + if (parentCollection.children !== null) { + parentCollection.children.push(collection) + } + } + + // Migrate device-local secret entries seeded by `importToTeamsWorkspace` + // under the importer-stamped `_ref_id` to the backend-assigned `id`. + // No-op on devices that didn't seed (migration helpers skip when + // nothing exists under the old key). + this.migrateImportedSecretEntries(collection) + + // Add to entity ids set + this.entityIDs.add(`collection-${collection.id}`) + + this.collections.value = [...tree] + } + + // Relies on the backend preserving `data._ref_id` at every level — + // each nested folder's `teamCollectionAdded` event must carry its own + // `data._ref_id` for migration to fire. A backend that drops nested + // `data` would leave per-folder entries orphaned under `_ref_id`. + private migrateImportedSecretEntries(collection: TeamCollection) { + if (!collection.data) return + try { + const parsed = JSON.parse(collection.data) as { _ref_id?: unknown } + if (typeof parsed._ref_id !== "string" || !parsed._ref_id) return + this.secretEnvironmentService.updateSecretEnvironmentID( + parsed._ref_id, + collection.id + ) + this.currentEnvironmentValueService.updateEnvironmentID( + parsed._ref_id, + collection.id + ) + } catch { + // Malformed `data` — skip migration; the secret service stays + // keyed by `_ref_id` and the imported secrets aren't accessible + // via the team `id`. Rare; only happens on a malformed backend. + } + } + + /** + * Loads the root collections of the current team + * @param replace Whether to replace the existing collections or append to them + * We might want to replace when we are reloading the collections like when sorting the whole root collections + */ + private async loadRootCollections(replace = false) { + if (this.teamID === null) throw new Error("Team ID is null") + + this.loadingCollections.value.push("root") + + const totalCollections: TeamCollection[] = [] + + while (true) { + const result = await runGQLQuery({ + query: RootCollectionsOfTeamDocument, + variables: { + teamID: this.teamID, + cursor: + totalCollections.length > 0 + ? totalCollections[totalCollections.length - 1].id + : undefined, + }, + }) + + if (E.isLeft(result)) { + this.loadingCollections.value = this.loadingCollections.value.filter( + (x) => x !== "root" + ) + + throw new Error( + `Error fetching root collections: ${result.left?.error}` + ) + } + + if (replace) { + this.collections.value = [] + this.entityIDs.clear() + + totalCollections.push( + ...result.right.rootCollectionsOfTeam.map( + (x: any) => + { + ...x, + children: null, + requests: null, + } + ) + ) + } else { + totalCollections.push( + ...result.right.rootCollectionsOfTeam.map( + (x: any) => + { + ...x, + children: null, + requests: null, + } + ) + ) + } + + if (result.right.rootCollectionsOfTeam.length !== TEAMS_BACKEND_PAGE_SIZE) + break + } + + this.loadingCollections.value = this.loadingCollections.value.filter( + (x) => x !== "root" + ) + + // Add all the collections to the entity ids list + totalCollections.forEach((coll) => + this.entityIDs.add(`collection-${coll.id}`) + ) + + this.collections.value.push(...totalCollections) + } + + /** + * Updates an existing collection in tree + * + * @param {Partial & Pick} collectionUpdate - Object defining the fields that need to be updated (ID is required to find the target) + */ + private updateCollection( + collectionUpdate: Partial & Pick + ) { + const tree = this.collections.value + + updateCollInTree(tree, collectionUpdate) + + this.collections.value = [...tree] + } + + /** + * Removes a collection from the tree + * + * @param {string} collectionID - ID of the collection to remove + */ + private removeCollection(collectionID: string) { + const tree = this.collections.value + + deleteCollInTree(tree, collectionID) + + this.entityIDs.delete(`collection-${collectionID}`) + + this.collections.value = [...tree] + } + + /** + * Adds a request to the tree + * + * @param {TeamRequest} request - The request to add to the tree + */ + private addRequest(request: TeamRequest) { + // Check if we have it already in the entity tree, if so, we don't need it again + if (this.entityIDs.has(`request-${request.id}`)) return + + const tree = this.collections.value + + // Check if we have the collection (if not, then not loaded?) + const coll = findCollInTree(tree, request.collectionID) + if (!coll) return // Ignore add request + + // Collection is not expanded + if (!coll.requests) return + + // Collection is expanded hence append request + coll.requests.push(request) + + // Update the Entity IDs list + this.entityIDs.add(`request-${request.id}`) + + this.collections.value = [...tree] + } + + /** + * Updates the request in tree + * + * @param {Partial & Pick} requestUpdate - Object defining all the fields to update in request (ID of the request is required) + */ + private updateRequest( + requestUpdate: Partial & Pick + ) { + const tree = this.collections.value + + // Find request, if not present, don't update + const req = findReqInTree(tree, requestUpdate.id) + if (!req) return + + Object.assign(req, requestUpdate) + + this.collections.value = [...tree] + } + + /** + * Removes a request from the tree + * + * @param {string} requestID - ID of the request to remove + */ + private removeRequest(requestID: string) { + const tree = this.collections.value + + // Find request in tree, don't attempt if no collection or no requests (expansion?) + const coll = findCollWithReqIDInTree(tree, requestID) + if (!coll || !coll.requests) return + + // Remove the collection + remove(coll.requests, (req: any) => req.id === requestID) + + // Remove from entityIDs set + this.entityIDs.delete(`request-${requestID}`) + + // Publish new tree + this.collections.value = [...tree] + } + + /** + * Moves a request from one collection to another + * + * @param {string} request - The request to move + */ + private async moveRequest(request: TeamRequest) { + const tree = this.collections.value + + // Remove the request from the current collection + this.removeRequest(request.id) + + const currentRequest = request.request + + if (currentRequest === null || currentRequest === undefined) return + + // Find request in tree, don't attempt if no collection or no requests is found + const collection = findCollInTree(tree, request.collectionID) + if (!collection) return // Ignore add request + + // Collection is not expanded + if (!collection.requests) return + + this.addRequest({ + id: request.id, + collectionID: request.collectionID, + request: translateToNewRequest(request.request), + title: request.title, + }) + } + + /** + * Moves a collection from one collection to another or to root + * + * @param {string} collectionID - The ID of the collection to move + */ + private async moveCollection( + collectionID: string, + parentID: string | null, + title: string, + data?: string | null + ) { + // Remove the collection from the current position + this.removeCollection(collectionID) + + if (collectionID === null || parentID === undefined) return + + // Expand the parent collection if it is not expanded + // so that the old children is also visible when expanding + if (parentID) this.expandCollection(parentID) + + this.addCollection( + { + id: collectionID, + children: null, + requests: null, + title: title, + data, + }, + parentID ?? null + ) + } + + private reorderItems = (array: unknown[], from: number, to: number) => { + const item = array.splice(from, 1)[0] + if (from < to) { + array.splice(to - 1, 0, item) + } else { + array.splice(to, 0, item) + } + } + + public updateRequestOrder( + dragedRequestID: string, + destinationRequestID: string | null, + destinationCollectionID: string + ) { + const tree = this.collections.value + + // If the destination request is null, then it is the last request in the collection + if (destinationRequestID === null) { + const collection = findCollInTree(tree, destinationCollectionID) + + if (!collection) return // Ignore order update + + // Collection is not expanded + if (!collection.requests) return + + const requestIndex = collection.requests.findIndex( + (req) => req.id === dragedRequestID + ) + + // If the collection index is not found, don't update + if (requestIndex === -1) return + + // Move the request to the end of the requests + collection.requests.push(collection.requests.splice(requestIndex, 1)[0]) + } else { + // Find collection in tree, don't attempt if no collection is found + const collection = findCollInTree(tree, destinationCollectionID) + if (!collection) return // Ignore order update + + // Collection is not expanded + if (!collection.requests) return + + const requestIndex = collection.requests.findIndex( + (req) => req.id === dragedRequestID + ) + const destinationIndex = collection.requests.findIndex( + (req) => req.id === destinationRequestID + ) + + if (requestIndex === -1) return + + this.reorderItems(collection.requests, requestIndex, destinationIndex) + } + + this.collections.value = [...tree] + } + + public updateCollectionOrder = ( + collectionID: string, + destinationCollectionID: string | null + ) => { + const tree = this.collections.value + + // If the destination collection is null, then it is the last collection in the tree + if (destinationCollectionID === null) { + const collLast = findParentOfColl(tree, collectionID) + if (collLast && collLast.children) { + const collectionIndex = collLast.children.findIndex( + (coll) => coll.id === collectionID + ) + + // reorder the collection to the end of the collections + collLast.children.push(collLast.children.splice(collectionIndex, 1)[0]) + } else { + const collectionIndex = tree.findIndex( + (coll) => coll.id === collectionID + ) + + // If the collection index is not found, don't update + if (collectionIndex === -1) return + + // reorder the collection to the end of the collections in the root + tree.push(tree.splice(collectionIndex, 1)[0]) + } + } else { + // Find collection in tree + const coll = findParentOfColl(tree, destinationCollectionID) + + // If the collection has a parent collection and check if it has children + if (coll && coll.children) { + const collectionIndex = coll.children.findIndex( + (coll) => coll.id === collectionID + ) + + const destinationIndex = coll.children.findIndex( + (coll) => coll.id === destinationCollectionID + ) + + // If the collection index is not found, don't update + if (collectionIndex === -1) return + + this.reorderItems(coll.children, collectionIndex, destinationIndex) + } else { + // If the collection has no parent collection, it is a root collection + const collectionIndex = tree.findIndex( + (coll) => coll.id === collectionID + ) + + const destinationIndex = tree.findIndex( + (coll) => coll.id === destinationCollectionID + ) + + // If the collection index is not found, don't update + if (collectionIndex === -1) return + + this.reorderItems(tree, collectionIndex, destinationIndex) + } + } + + this.collections.value = [...tree] + } + + private registerSubscriptions() { + if (!this.teamID) return + + const [teamCollAdded$, teamCollAddedSub] = runGQLSubscription({ + query: TeamCollectionAddedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamCollectionAddedSub = teamCollAddedSub + + this.teamCollectionAdded$ = teamCollAdded$.subscribe((result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Collection Added Error: ${JSON.stringify(result.left)}` + ) + + this.addCollection( + { + id: result.right.teamCollectionAdded.id, + children: null, + requests: null, + title: result.right.teamCollectionAdded.title, + data: result.right.teamCollectionAdded.data ?? null, + }, + result.right.teamCollectionAdded.parent?.id ?? null + ) + }) + + const [teamCollUpdated$, teamCollUpdatedSub] = runGQLSubscription({ + query: TeamCollectionUpdatedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamCollectionUpdatedSub = teamCollUpdatedSub + this.teamCollectionUpdated$ = teamCollUpdated$.subscribe((result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Collection Updated Error: ${JSON.stringify(result.left)}` + ) + + this.updateCollection({ + id: result.right.teamCollectionUpdated.id, + title: result.right.teamCollectionUpdated.title, + data: result.right.teamCollectionUpdated.data, + }) + + this.loadingCollections.value = this.loadingCollections.value.filter( + (x) => x !== result.right.teamCollectionUpdated.id + ) + }) + + const [teamCollRemoved$, teamCollRemovedSub] = runGQLSubscription({ + query: TeamCollectionRemovedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamCollectionRemovedSub = teamCollRemovedSub + this.teamCollectionRemoved$ = teamCollRemoved$.subscribe((result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Collection Removed Error: ${JSON.stringify(result.left)}` + ) + + this.removeCollection(result.right.teamCollectionRemoved) + }) + + const [teamReqAdded$, teamReqAddedSub] = runGQLSubscription({ + query: TeamRequestAddedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRequestAddedSub = teamReqAddedSub + this.teamRequestAdded$ = teamReqAdded$.subscribe((result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Request Added Error: ${JSON.stringify(result.left)}` + ) + + this.addRequest({ + id: result.right.teamRequestAdded.id, + collectionID: result.right.teamRequestAdded.collectionID, + request: translateToNewRequest( + JSON.parse(result.right.teamRequestAdded.request) + ), + title: result.right.teamRequestAdded.title, + }) + }) + + const [teamReqUpdated$, teamReqUpdatedSub] = runGQLSubscription({ + query: TeamRequestUpdatedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRequestUpdatedSub = teamReqUpdatedSub + this.teamRequestUpdated$ = teamReqUpdated$.subscribe((result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Request Updated Error: ${JSON.stringify(result.left)}` + ) + + this.updateRequest({ + id: result.right.teamRequestUpdated.id, + collectionID: result.right.teamRequestUpdated.collectionID, + request: JSON.parse(result.right.teamRequestUpdated.request), + title: result.right.teamRequestUpdated.title, + }) + }) + + const [teamReqDeleted$, teamReqDeletedSub] = runGQLSubscription({ + query: TeamRequestDeletedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRequestDeletedSub = teamReqDeletedSub + this.teamRequestDeleted$ = teamReqDeleted$.subscribe((result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Request Deleted Error ${JSON.stringify(result.left)}` + ) + + this.removeRequest(result.right.teamRequestDeleted) + }) + + const [teamRequestMoved$, teamRequestMovedSub] = runGQLSubscription({ + query: TeamRequestMovedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRequestMovedSub = teamRequestMovedSub + this.teamRequestMoved$ = teamRequestMoved$.subscribe((result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Request Move Error ${JSON.stringify(result.left)}` + ) + + const { requestMoved } = result.right + + const request = { + id: requestMoved.id, + collectionID: requestMoved.collectionID, + title: requestMoved.title, + request: JSON.parse(requestMoved.request), + } + + this.moveRequest(request) + }) + + const [teamCollectionMoved$, teamCollectionMovedSub] = runGQLSubscription({ + query: TeamCollectionMovedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamCollectionMovedSub = teamCollectionMovedSub + this.teamCollectionMoved$ = teamCollectionMoved$.subscribe( + (result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Collection Move Error ${JSON.stringify(result.left)}` + ) + + const { teamCollectionMoved } = result.right + const { id, parent, title, data } = teamCollectionMoved + + const parentID = parent?.id ?? null + + this.moveCollection(id, parentID, title, data) + } + ) + + const [teamRequestOrderUpdated$, teamRequestOrderUpdatedSub] = + runGQLSubscription({ + query: TeamRequestOrderUpdatedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRequestOrderUpdatedSub = teamRequestOrderUpdatedSub + this.teamRequestOrderUpdated$ = teamRequestOrderUpdated$.subscribe( + (result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Request Order Update Error ${JSON.stringify(result.left)}` + ) + + const { requestOrderUpdated } = result.right + const { request } = requestOrderUpdated + const { nextRequest } = requestOrderUpdated + + this.updateRequestOrder( + request.id, + nextRequest ? nextRequest.id : null, + nextRequest ? nextRequest.collectionID : request.collectionID + ) + } + ) + + const [teamCollectionOrderUpdated$, teamCollectionOrderUpdatedSub] = + runGQLSubscription({ + query: TeamCollectionOrderUpdatedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamCollectionOrderUpdatedSub = teamCollectionOrderUpdatedSub + this.teamCollectionOrderUpdated$ = teamCollectionOrderUpdated$.subscribe( + (result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Collection Order Update Error ${JSON.stringify(result.left)}` + ) + + const { collectionOrderUpdated } = result.right + const { collection } = collectionOrderUpdated + const { nextCollection } = collectionOrderUpdated + + this.updateCollectionOrder( + collection.id, + nextCollection ? nextCollection.id : null + ) + } + ) + + const [teamRootCollectionSorted$, teamRootCollectionSortedSub] = + runGQLSubscription({ + query: TeamRootCollectionsSortedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRootCollectionSortedSub = teamRootCollectionSortedSub + this.teamRootCollectionSorted$ = teamRootCollectionSorted$.subscribe( + (result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Root Collection Sorted Error ${JSON.stringify(result.left)}` + ) + + this.loadRootCollections(true) + } + ) + + const [teamChildCollectionSorted$, teamChildCollectionSortedSub] = + runGQLSubscription({ + query: TeamChildCollectionSortedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamChildCollectionSortedSub = teamChildCollectionSortedSub + this.teamChildCollectionSorted$ = teamChildCollectionSorted$.subscribe( + (result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Child Collection Sorted Error ${JSON.stringify(result.left)}` + ) + + const { teamChildCollectionsSorted } = result.right + + if (teamChildCollectionsSorted) { + this.expandCollection(teamChildCollectionsSorted, true) + } + } + ) + } + + private async getCollectionChildren( + collection: TeamCollection + ): Promise { + const collections: TeamCollection[] = [] + + while (true) { + const data = await runGQLQuery({ + query: GetCollectionChildrenDocument, + variables: { + collectionID: collection.id, + cursor: + collections.length > 0 + ? collections[collections.length - 1].id + : undefined, + }, + }) + + if (E.isLeft(data)) { + throw new Error( + `Child Collection Fetch Error for ${collection.id}: ${data.left}` + ) + } + + collections.push( + ...data.right.collection!.children.map( + (el: any) => + { + id: el.id, + title: el.title, + data: el.data, + children: null, + requests: null, + } + ) + ) + + if (data.right.collection!.children.length !== TEAMS_BACKEND_PAGE_SIZE) + break + } + + return collections + } + + private async getCollectionRequests( + collection: TeamCollection + ): Promise { + const requests: TeamRequest[] = [] + + while (true) { + const data = await runGQLQuery({ + query: GetCollectionRequestsDocument, + variables: { + collectionID: collection.id, + cursor: + requests.length > 0 ? requests[requests.length - 1].id : undefined, + }, + }) + + if (E.isLeft(data)) { + throw new Error(`Child Request Fetch Error for ${data}: ${data.left}`) + } + + requests.push( + ...data.right.requestsInCollection.map((el: any) => { + return { + id: el.id, + collectionID: collection.id, + title: el.title, + request: translateToNewRequest(JSON.parse(el.request)), + } + }) + ) + + if (data.right.requestsInCollection.length !== TEAMS_BACKEND_PAGE_SIZE) + break + } + + return requests + } + + /** + * Expands a collection on the tree + * + * When a collection is loaded initially in the adapter, children and requests are not loaded (they will be set to null) + * Upon expansion those two fields will be populated + * + * @param {string} collectionID - The ID of the collection to expand + * @param {boolean} reFetch - Whether to re-fetch the children and requests even if they are already loaded (used in sorting scenarios where order might have changed) + */ + async expandCollection(collectionID: string, reFetch = false): Promise { + if (this.loadingCollections.value.includes(collectionID)) return + + const tree = this.collections.value + + const collection = findCollInTree(tree, collectionID) + + if (!collection) return + + if (collection.children !== null && !reFetch) return + + this.loadingCollections.value.push(collectionID) + + try { + const [collections, requests] = await Promise.all([ + this.getCollectionChildren(collection), + this.getCollectionRequests(collection), + ]) + + collection.children = collections + collection.requests = requests + + // Add to the entity ids set + collections.forEach((coll) => this.entityIDs.add(`collection-${coll.id}`)) + requests.forEach((req) => this.entityIDs.add(`request-${req.id}`)) + + this.collections.value = [...tree] + } catch (error) { + console.error(`Error expanding collection ${collectionID}:`, error) + + // Set empty arrays instead of leaving as null to prevent future expansion attempts + // This prevents the infinite loop by ensuring the collection is marked as expanded + collection.children = [] + collection.requests = [] + + this.collections.value = [...tree] + } finally { + this.loadingCollections.value = this.loadingCollections.value.filter( + (x) => x !== collectionID + ) + } + } + + private getCurrentValue = ( + env: HoppCollectionVariable, + varIndex: number, + collectionID: string + ) => { + if (env && env.secret) { + return this.secretEnvironmentService.getSecretEnvironmentVariable( + collectionID, + varIndex + )?.value + } + return this.currentEnvironmentValueService.getEnvironmentVariable( + collectionID, + varIndex + )?.currentValue + } + + /** + * This function populates the values of the variables with the current values or secrets. + * @param variables Variables to populate + * @returns Populated variables with current values or secrets + */ + private populateValues( + variables: HoppCollectionVariable[], + parentID: string + ) { + return variables.map((v, index) => ({ + ...v, + currentValue: this.getCurrentValue(v, index, parentID) ?? v.currentValue, + })) + } + + /** + * Used to obtain the inherited auth and headers for a given folder path, used for both REST and GraphQL team collections + * @param folderPath the path of the folder to cascade the auth from + * @returns the inherited auth and headers for the given folder path + */ + public cascadeParentCollectionForProperties(folderPath: string) { + let auth: HoppInheritedProperty["auth"] = { + parentID: folderPath ?? "", + parentName: "", + inheritedAuth: { + authType: "none", + authActive: true, + }, + } + const headers: HoppInheritedProperty["headers"] = [] + + const variables: HoppInheritedProperty["variables"] = [] + + const scripts: HoppInheritedProperty["scripts"] = [] + + if (!folderPath) return { auth, headers, variables, scripts } + + const path = folderPath.split("/") + + // Check if the path is empty or invalid + if (!path || path.length === 0) { + console.error("Invalid path:", folderPath) + return { auth, headers, variables, scripts } + } + + // Loop through the path and get the last parent folder with authType other than 'inherit' + for (let i = 0; i < path.length; i++) { + const parentFolder = findCollInTree(this.collections.value, path[i]) + + // Check if parentFolder is undefined or null + if (!parentFolder) { + console.error("Parent folder not found for path:", path) + return { auth, headers, variables, scripts } + } + + const data: Partial = parentFolder.data + ? JSON.parse(parentFolder.data) + : {} + + if (!data.auth) { + data.auth = { + authType: "inherit", + authActive: true, + } + auth.parentID = path.slice(0, i + 1).join("/") + auth.parentName = parentFolder.title + } + + if (!data.headers) data.headers = [] + + if (!data.variables) data.variables = [] + + const parentFolderAuth = data.auth + const parentFolderHeaders = data.headers + const parentFolderVariables = data.variables + + if ( + parentFolderAuth?.authType === "inherit" && + path.slice(0, i + 1).length === 1 + ) { + auth = { + parentID: path.slice(0, i + 1).join("/"), + parentName: parentFolder.title, + inheritedAuth: auth.inheritedAuth, + } + } + + if (parentFolderAuth?.authType !== "inherit") { + auth = { + parentID: path.slice(0, i + 1).join("/"), + parentName: parentFolder.title, + inheritedAuth: parentFolderAuth, + } + } + + // Update headers, overwriting duplicates by key + if (parentFolderHeaders) { + const activeHeaders = parentFolderHeaders.filter((h) => h.active) + activeHeaders.forEach((header) => { + const index = headers.findIndex( + (h) => h.inheritedHeader?.key === header.key + ) + const currentPath = path.slice(0, i + 1).join("/") + if (index !== -1) { + // Replace the existing header with the same key + headers[index] = { + parentID: currentPath, + parentName: parentFolder.title, + inheritedHeader: header, + } + } else { + headers.push({ + parentID: currentPath, + parentName: parentFolder.title, + inheritedHeader: header, + }) + } + }) + } + + // Update variables, overwriting duplicates by key + if (parentFolderVariables) { + const currentPath = [...path.slice(0, i + 1)].join("/") + + variables.push({ + parentPath: path.slice(0, i + 1).join("/"), + parentID: parentFolder.id ?? currentPath, + parentName: parentFolder.title, + inheritedVariables: this.populateValues( + parentFolderVariables, + parentFolder.id ?? currentPath + ), + }) + } + + // Collect scripts from the collection hierarchy (root to child order) + const parentPreRequestScript = data.preRequestScript ?? "" + const parentTestScript = data.testScript ?? "" + + if ( + hasActualScript(parentPreRequestScript) || + hasActualScript(parentTestScript) + ) { + const currentPath = path.slice(0, i + 1).join("/") + + scripts.push({ + parentID: parentFolder.id ?? currentPath, + parentName: parentFolder.title, + preRequestScript: parentPreRequestScript, + testScript: parentTestScript, + }) + } + } + + return { auth, headers, variables, scripts } + } + + private async waitForCollectionLoading(collectionID: string) { + while (this.loadingCollections.value.includes(collectionID)) { + await new Promise((resolve) => setTimeout(resolve, 50)) + } + } + + /** + * Used to obtain the inherited auth and headers for a given folder path + * This function is async and will expand the collections if they are not expanded yet + * @param folderPath the path of the folder to cascade the auth from + * @returns the inherited auth and headers for the given folder path + */ + public async cascadeParentCollectionForPropertiesAsync(folderPath: string) { + if (!folderPath) + return { + auth: { + parentID: "", + parentName: "", + inheritedAuth: { + authType: "none", + authActive: true, + }, + }, + headers: [], + variables: [], + scripts: [], + } + + const path = folderPath.split("/") + + // Check if the path is empty or invalid + if (!path || path.length === 0) { + console.error("Invalid path:", folderPath) + return { + auth: { + parentID: "", + parentName: "", + inheritedAuth: { + authType: "none", + authActive: true, + }, + }, + headers: [], + variables: [], + scripts: [], + } + } + + // Loop through the path and expand the collections if they are not expanded + for (let i = 0; i < path.length; i++) { + const parentFolder = findCollInTree(this.collections.value, path[i]) + + if (parentFolder) { + if (parentFolder.children === null) { + if (this.loadingCollections.value.includes(parentFolder.id)) { + await this.waitForCollectionLoading(parentFolder.id) + } else { + await this.expandCollection(parentFolder.id) + } + } + } + } + + return this.cascadeParentCollectionForProperties(folderPath) + } +} diff --git a/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts b/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts new file mode 100644 index 0000000..d2e8f60 --- /dev/null +++ b/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts @@ -0,0 +1,437 @@ +import { + HoppCollection, + HoppCollectionVariable, + HoppRESTHeaders, + HoppRESTRequest, +} from "@hoppscotch/data" +import { Service } from "dioc" +import { hasActualScript } from "@hoppscotch/js-sandbox/scripting" +import * as E from "fp-ts/Either" +import { cloneDeep } from "lodash-es" +import { nextTick, Ref } from "vue" +import { + captureInitialEnvironmentState, + runTestRunnerRequest, +} from "~/helpers/RequestRunner" +import { + HoppTestRunnerDocument, + TestRunnerConfig, +} from "~/helpers/rest/document" +import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" +import { HoppTestData, HoppTestResult } from "~/helpers/types/HoppTestResult" +import { HoppTab } from "../tab" +import { populateValuesInInheritedCollectionVars } from "~/helpers/utils/inheritedCollectionVarTransformer" + +export type TestRunnerOptions = { + stopRef: Ref +} & TestRunnerConfig + +export type TestRunnerRequest = HoppRESTRequest & { + type: "test-response" + response?: HoppRESTResponse | null + testResults?: HoppTestResult | null + isLoading?: boolean + error?: string + renderResults?: boolean + passedTests: number + failedTests: number +} + +function delay(timeMS: number) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, timeMS) + return () => { + clearTimeout(timeout) + reject(new Error("Operation cancelled")) + } + }) +} + +export class TestRunnerService extends Service { + public static readonly ID = "TEST_RUNNER_SERVICE" + + public runTests( + tab: Ref>, + collection: HoppCollection, + options: TestRunnerOptions, + ancestorPreRequestScripts: string[] = [], + ancestorTestScripts: string[] = [] + ) { + // Reset the result collection + tab.value.document.status = "running" + tab.value.document.resultCollection = { + v: collection.v, + id: collection.id, + name: collection.name, + auth: collection.auth, + headers: collection.headers, + folders: [], + requests: [], + variables: [], + description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", + } + + this.runTestCollection( + tab, + collection, + options, + [], + undefined, + undefined, + [], + undefined, + ancestorPreRequestScripts, + ancestorTestScripts + ) + .then(() => { + tab.value.document.status = "stopped" + }) + .catch((error) => { + if ( + error instanceof Error && + error.message === "Test execution stopped" + ) { + tab.value.document.status = "stopped" + } else { + tab.value.document.status = "error" + console.error("Test runner failed:", error) + } + }) + .finally(() => { + tab.value.document.status = "stopped" + }) + } + + private async runTestCollection( + tab: Ref>, + collection: HoppCollection, + options: TestRunnerOptions, + parentPath: number[] = [], + parentHeaders?: HoppRESTHeaders, + parentAuth?: HoppRESTRequest["auth"], + parentVariables: HoppCollection["variables"] = [], + parentID?: string, + parentPreRequestScripts: string[] = [], + parentTestScripts: string[] = [] + ) { + try { + // Compute inherited auth and headers for this collection + const inheritedAuth = + collection.auth?.authType === "inherit" && collection.auth.authActive + ? parentAuth || { authType: "none", authActive: false } + : collection.auth || { authType: "none", authActive: false } + + const inheritedHeaders: HoppRESTHeaders = [ + ...(parentHeaders || []), + ...collection.headers, + ] + + const inheritedVariables = [ + ...(populateValuesInInheritedCollectionVars( + parentVariables, + parentID || collection._ref_id || collection.id + ) || []), + ...(populateValuesInInheritedCollectionVars( + collection.variables, + collection._ref_id || collection.id + ) || []), + ] + + const inheritedPreRequestScripts = [ + ...parentPreRequestScripts, + ...(hasActualScript(collection.preRequestScript) + ? [collection.preRequestScript] + : []), + ] + const inheritedTestScripts = [ + ...parentTestScripts, + ...(hasActualScript(collection.testScript) + ? [collection.testScript] + : []), + ] + + // Process folders progressively + for (let i = 0; i < collection.folders.length; i++) { + if (options.stopRef?.value) { + tab.value.document.status = "stopped" + throw new Error("Test execution stopped") + } + + const folder = collection.folders[i] + const currentPath = [...parentPath, i] + + // Add folder to the result collection + this.addFolderToPath( + tab.value.document.resultCollection!, + currentPath, + { + ...cloneDeep(folder), + folders: [], + requests: [], + } + ) + + await this.runTestCollection( + tab, + folder, + options, + currentPath, + inheritedHeaders, + inheritedAuth, + inheritedVariables, + collection._ref_id || collection.id, + inheritedPreRequestScripts, + inheritedTestScripts + ) + } + + // Process requests progressively + for (let i = 0; i < collection.requests.length; i++) { + if (options.stopRef?.value) { + tab.value.document.status = "stopped" + throw new Error("Test execution stopped") + } + + const request = collection.requests[i] as TestRunnerRequest + const currentPath = [...parentPath, i] + + // Add request to the result collection before execution + this.addRequestToPath( + tab.value.document.resultCollection!, + currentPath, + cloneDeep(request) + ) + + // Update the request with inherited headers and auth before execution + const finalRequest = { + ...request, + auth: + request.auth.authType === "inherit" && request.auth.authActive + ? inheritedAuth + : request.auth, + headers: [...inheritedHeaders, ...request.headers], + } + + await this.runTestRequest( + tab, + finalRequest, + collection, + options, + currentPath, + inheritedVariables, + inheritedPreRequestScripts, + inheritedTestScripts + ) + + if (options.delay && options.delay > 0) { + try { + await delay(options.delay) + } catch (_error) { + if (options.stopRef?.value) { + tab.value.document.status = "stopped" + throw new Error("Test execution stopped") + } + } + } + } + } catch (error) { + if ( + error instanceof Error && + error.message === "Test execution stopped" + ) { + throw error + } + tab.value.document.status = "error" + console.error("Collection execution failed:", error) + throw error + } + } + + private addFolderToPath( + collection: HoppCollection, + path: number[], + folder: HoppCollection + ) { + let current = collection + + // Navigate to the parent folder + for (let i = 0; i < path.length - 1; i++) { + current = current.folders[path[i]] + } + + // Add the folder at the specified index + if (path.length > 0) { + current.folders[path[path.length - 1]] = folder + } + } + + private addRequestToPath( + collection: HoppCollection, + path: number[], + request: TestRunnerRequest + ) { + let current = collection + + // Navigate to the parent folder + for (let i = 0; i < path.length - 1; i++) { + current = current.folders[path[i]] + } + + // Add the request at the specified index + if (path.length > 0) { + current.requests[path[path.length - 1]] = request + } + } + + private updateRequestAtPath( + collection: HoppCollection, + path: number[], + updates: Partial + ) { + let current = collection + + // Navigate to the parent folder + for (let i = 0; i < path.length - 1; i++) { + current = current.folders[path[i]] + } + + // Update the request at the specified index + if (path.length > 0) { + const index = path[path.length - 1] + current.requests[index] = { + ...current.requests[index], + ...updates, + } as TestRunnerRequest + } + } + + private async runTestRequest( + tab: Ref>, + request: TestRunnerRequest, + collection: HoppCollection, + options: TestRunnerOptions, + path: number[], + inheritedVariables: HoppCollectionVariable[] = [], + inheritedPreRequestScripts: string[] = [], + inheritedTestScripts: string[] = [] + ) { + if (options.stopRef?.value) { + throw new Error("Test execution stopped") + } + + try { + // Update request status in the result collection + this.updateRequestAtPath(tab.value.document.resultCollection!, path, { + isLoading: true, + error: undefined, + }) + + // Force Vue to flush DOM updates before starting async work. + // This ensures components consuming the isLoading state (such as those rendering the Send/Cancel button) update immediately. + // Performance impact: nextTick() waits for microtask queue drain (actual latency varies based on pending microtasks) + // but is necessary to prevent UI flicker and ensure loading indicators appear before long-running network requests. + await nextTick() + + // Capture the initial environment state for a test run so that it remains consistent and unchanged when current environment changes + const initialEnvironmentState = captureInitialEnvironmentState() + + const results = await runTestRunnerRequest( + request, + options.keepVariableValues, + inheritedVariables, + initialEnvironmentState, + inheritedPreRequestScripts, + inheritedTestScripts + ) + + if (options.stopRef?.value) { + throw new Error("Test execution stopped") + } + + if (results && E.isRight(results)) { + const { response, testResult, updatedRequest } = results.right + const { passed, failed } = this.getTestResultInfo(testResult) + + tab.value.document.testRunnerMeta.totalTests += passed + failed + tab.value.document.testRunnerMeta.passedTests += passed + tab.value.document.testRunnerMeta.failedTests += failed + + // Update request with results and propagate pre-request script changes in the result collection + this.updateRequestAtPath(tab.value.document.resultCollection!, path, { + ...updatedRequest, + testResults: testResult, + response: options.persistResponses ? response : null, + isLoading: false, + }) + + if (response.type === "success" || response.type === "fail") { + tab.value.document.testRunnerMeta.totalTime += + response.meta.responseDuration + tab.value.document.testRunnerMeta.completedRequests += 1 + } + } else { + const errorMsg = "Request execution failed" + + // Update request with error in the result collection + this.updateRequestAtPath(tab.value.document.resultCollection!, path, { + error: errorMsg, + isLoading: false, + response: { + type: "network_fail", + error: "Unknown", + req: request, + }, + }) + + if (options.stopOnError) { + tab.value.document.status = "stopped" + throw new Error("Test execution stopped due to error") + } + } + } catch (error) { + if ( + error instanceof Error && + error.message === "Test execution stopped" + ) { + throw error + } + + const errorMsg = + error instanceof Error ? error.message : "Unknown error occurred" + + // Update request with error in the result collection + this.updateRequestAtPath(tab.value.document.resultCollection!, path, { + error: errorMsg, + isLoading: false, + }) + + if (options.stopOnError) { + tab.value.document.status = "stopped" + throw new Error("Test execution stopped due to error") + } + } + } + + private getTestResultInfo(testResult: HoppTestData) { + let passed = 0 + let failed = 0 + + for (const result of testResult.expectResults) { + if (result.status === "pass") { + passed++ + } else if (result.status === "fail") { + failed++ + } + } + + for (const nestedTest of testResult.tests) { + const nestedResult = this.getTestResultInfo(nestedTest) + passed += nestedResult.passed + failed += nestedResult.failed + } + + return { passed, failed } + } +} diff --git a/packages/hoppscotch-common/src/services/ui-extension.service.ts b/packages/hoppscotch-common/src/services/ui-extension.service.ts new file mode 100644 index 0000000..6da61ab --- /dev/null +++ b/packages/hoppscotch-common/src/services/ui-extension.service.ts @@ -0,0 +1,25 @@ +import { Service } from "dioc" +import { Component, shallowRef } from "vue" + +/** + * A registrar that allows other services to register + * additional stuff into the UI + */ +export class UIExtensionService extends Service { + public static readonly ID = "UI_EXTENSION_SERVICE" + + /** + * Defines the Root UI Extensions that are registered. + * These components are rendered in `layouts/default.vue`. + * NOTE: This is supposed to be readonly, to register + */ + public rootUIExtensionComponents = shallowRef([]) + + /** + * Registers a root UI extension component that will be rendered + * in the root of the UI + */ + public addRootUIExtension(component: Component) { + this.rootUIExtensionComponents.value.push(component) + } +} diff --git a/packages/hoppscotch-common/src/services/workspace.service.ts b/packages/hoppscotch-common/src/services/workspace.service.ts new file mode 100644 index 0000000..6287bf3 --- /dev/null +++ b/packages/hoppscotch-common/src/services/workspace.service.ts @@ -0,0 +1,228 @@ +import { tryOnScopeDispose, useIntervalFn } from "@vueuse/core" +import { Service } from "dioc" +import { computed, reactive, ref, watch, readonly } from "vue" +import { useStreamStatic } from "~/composables/stream" +import TeamListAdapter from "~/helpers/teams/TeamListAdapter" +import { platform } from "~/platform" +import { min } from "lodash-es" +import { TeamAccessRole } from "~/helpers/backend/graphql" +import { TeamCollectionsService } from "./team-collection.service" +import { DocumentationService } from "./documentation.service" + +/** + * Defines a workspace and its information + */ + +export type PersonalWorkspace = { + type: "personal" +} + +export type TeamWorkspace = { + type: "team" + teamID: string + teamName: string + role: TeamAccessRole | null | undefined +} + +export type Workspace = PersonalWorkspace | TeamWorkspace + +export type WorkspaceServiceEvent = { + type: "managed-team-list-adapter-polled" +} + +/** + * This services manages workspace related data and actions in Hoppscotch. + */ +export class WorkspaceService extends Service { + public static readonly ID = "WORKSPACE_SERVICE" + + private _currentWorkspace = ref({ type: "personal" }) + + /** + * A readonly reference to the currently selected workspace + */ + public currentWorkspace = readonly(this._currentWorkspace) + + private teamListAdapterLocks = reactive(new Map()) + private teamListAdapterLockTicker = 0 // Used to generate unique lock IDs + private managedTeamListAdapter = new TeamListAdapter(true, false) + + private teamCollectionService = this.bind(TeamCollectionsService) + private documentationService = this.bind(DocumentationService) + + private currentUser = useStreamStatic( + platform.auth.getCurrentUserStream(), + platform.auth.getCurrentUser(), + () => { + /* noop */ + } + )[0] + + private readonly pollingTime = computed( + () => + min(Array.from(this.teamListAdapterLocks.values()).filter((x) => !!x)) ?? + -1 + ) + + override onServiceInit() { + // Dispose the managed team list adapter when the user logs out + // and initialize it when the user logs in + watch( + this.currentUser, + (user) => { + if (!user && this.managedTeamListAdapter.isInitialized) { + this.managedTeamListAdapter.dispose() + } + + if (user && !this.managedTeamListAdapter.isInitialized) { + this.managedTeamListAdapter.initialize() + } + }, + { immediate: true } + ) + + // Poll the managed team list adapter if the polling time is defined + const { pause: pauseListPoll, resume: resumeListPoll } = useIntervalFn( + () => { + if (this.managedTeamListAdapter.isInitialized) { + this.managedTeamListAdapter.fetchList() + + this.emit({ type: "managed-team-list-adapter-polled" }) + } + }, + this.pollingTime, + { immediate: true } + ) + + // Pause and resume the polling when the polling time changes + watch( + this.pollingTime, + (pollingTime) => { + if (pollingTime === -1) { + pauseListPoll() + } else { + resumeListPoll() + } + }, + { immediate: true } + ) + + // Watch for workspace changes and update team collection service and documentation service accordingly + this.setupWorkspaceSync() + } + + /** + * Sets up synchronization between team collection service and documentation service. + * Ensures that team collections and published docs stay updated whenever + * the workspace or user changes. + * + * Fixes a bug where the initial fetch failed on cloud instances because + * authorization was null during user login. Now we wait for authentication + * to be ready before fetching team collections and published docs. + */ + private setupWorkspaceSync() { + watch( + [this._currentWorkspace, this.currentUser], + async ([newWorkspace, user], [oldWorkspace, oldUser]) => { + // Skip if workspace and user haven't changed + if ( + this.areWorkspacesEqual(newWorkspace, oldWorkspace) && + user?.uid === oldUser?.uid + ) { + return + } + + try { + // Ensure authentication is ready before fetching docs + if (user) { + await platform.auth.waitProbableLoginToConfirm() + } + + if (newWorkspace?.type === "team" && newWorkspace.teamID) { + this.teamCollectionService.changeTeamID(newWorkspace.teamID) + + if (user) { + await this.documentationService.fetchTeamPublishedDocs( + newWorkspace.teamID + ) + } + } else { + this.teamCollectionService.clearCollections() + + if (user) { + await this.documentationService.fetchUserPublishedDocs() + } + } + } catch (error) { + console.error("Failed to sync workspace data:", error) + } + }, + { immediate: true } + ) + } + + /** + * Checks if two workspaces are effectively equal to avoid unnecessary updates + * + * Note: Vue's watch API provides `undefined` as `oldValue` on the first callback + * invocation when using `{ immediate: true }`, since there is no previous value yet. + * This is why `oldWorkspace` has an optional type, while `newWorkspace` is always defined. + */ + private areWorkspacesEqual( + newWorkspace: Workspace, + oldWorkspace?: Workspace + ): boolean { + if (!newWorkspace || !oldWorkspace) return false + + // Both are personal workspaces + if (newWorkspace.type === "personal" && oldWorkspace.type === "personal") + return true + + // Team workspaces are equal only if they share the same team ID + return ( + newWorkspace.type === "team" && + oldWorkspace.type === "team" && + newWorkspace.teamID === oldWorkspace.teamID + ) + } + + // TODO: Update this function, its existence is pretty weird + /** + * Updates the name of the current workspace if it is a team workspace. + * @param newTeamName The new name of the team + */ + public updateWorkspaceTeamName(newTeamName: string) { + if (this._currentWorkspace.value.type === "team") { + this._currentWorkspace.value = { + ...this._currentWorkspace.value, + teamName: newTeamName, + } + } + } + + /** + * Changes the current workspace to the given workspace. + * @param workspace The new workspace + */ + public changeWorkspace(workspace: Workspace) { + this._currentWorkspace.value = workspace + } + + /** + * Acquires a team list adapter that is managed by the workspace service. + * The team list adapter is associated with a Vue Scope and will be disposed + * when the scope is disposed. + * @param pollDuration The duration between polls in milliseconds. If null, the team list adapter will not poll. + */ + public acquireTeamListAdapter(pollDuration: number | null) { + const lockID = this.teamListAdapterLockTicker++ + + this.teamListAdapterLocks.set(lockID, pollDuration) + + tryOnScopeDispose(() => { + this.teamListAdapterLocks.delete(lockID) + }) + + return this.managedTeamListAdapter + } +} diff --git a/packages/hoppscotch-common/src/setupTests.ts b/packages/hoppscotch-common/src/setupTests.ts new file mode 100644 index 0000000..9f99acf --- /dev/null +++ b/packages/hoppscotch-common/src/setupTests.ts @@ -0,0 +1,79 @@ +import * as E from "fp-ts/Either" +import { expect } from "vitest" + +globalThis.Worker = class { + constructor() {} + postMessage = () => {} + terminate = () => {} + onmessage = null + onerror = null +} as any + +expect.extend({ + toBeLeft(received, expected) { + const { isNot } = this + + return { + pass: + E.isLeft(received) && + this.equals(received.left, expected, undefined, false), + message: () => + `Expected received value ${isNot ? "not " : ""}to be a left`, + } + }, + toBeRight(received) { + const { isNot } = this + + return { + pass: E.isRight(received), + message: () => + `Expected received value ${isNot ? "not " : ""}to be a right`, + } + }, + toEqualLeft(received, expected) { + const { isNot } = this + + const isLeft = E.isLeft(received) + const leftEquals = E.isLeft(received) + ? this.equals(received.left, expected) + : false + + return { + pass: isLeft && leftEquals, + message: () => { + if (!isLeft) { + return `Expected received value ${isNot ? "not " : ""}to be a left` + } else if (!leftEquals) { + return `Expected received left value ${ + isNot ? "not" : "" + } to equal expected value` + } + + throw new Error("Invalid state on `toEqualLeft` matcher") + }, + } + }, + toSubsetEqualRight(received, expected) { + const { isNot } = this + + const isRight = E.isRight(received) + const rightSubsetEquals = E.isRight(received) + ? !!this.utils.subsetEquality(received.right, expected) + : false + + return { + pass: isRight && rightSubsetEquals, + message: () => { + if (!isRight) { + return `Expected received value ${isNot ? "not " : ""}to be a left` + } else if (!rightSubsetEquals) { + return `Expected received left value to ${ + isNot ? "not " : "" + }equal expected value` + } + + throw new Error("Invalid state on `toSubsetEqualRight` matcher") + }, + } + }, +}) diff --git a/packages/hoppscotch-common/src/shims.d.ts b/packages/hoppscotch-common/src/shims.d.ts new file mode 100644 index 0000000..3248a85 --- /dev/null +++ b/packages/hoppscotch-common/src/shims.d.ts @@ -0,0 +1,26 @@ +/// +/// +/// + +// // Hoppscotch Browser Extension +interface PWExtensionHook { + getVersion: () => { major: number; minor: number } + sendRequest: ( + req: AxiosRequestConfig & { wantsBinary: boolean } + ) => Promise + cancelRequest: () => void +} + +type HoppExtensionStatusHook = { + status: ExtensionStatus + _subscribers: { + status?: ((...args: any[]) => any)[] | undefined + } + subscribe(prop: "status", func: (...args: any[]) => any): void +} +export declare global { + interface Window { + __POSTWOMAN_EXTENSION_HOOK__: PWExtensionHook | undefined + __HOPP_EXTENSION_STATUS_PROXY__: HoppExtensionStatusHook | undefined + } +} diff --git a/packages/hoppscotch-common/src/types/kernel.d.ts b/packages/hoppscotch-common/src/types/kernel.d.ts new file mode 100644 index 0000000..a4c43a3 --- /dev/null +++ b/packages/hoppscotch-common/src/types/kernel.d.ts @@ -0,0 +1,9 @@ +import type { KernelAPI } from "@hoppscotch/kernel" + +declare global { + interface Window { + __KERNEL__?: KernelAPI + } +} + +export {} diff --git a/packages/hoppscotch-common/src/types/pm-coll-exts.d.ts b/packages/hoppscotch-common/src/types/pm-coll-exts.d.ts new file mode 100644 index 0000000..1bb973a --- /dev/null +++ b/packages/hoppscotch-common/src/types/pm-coll-exts.d.ts @@ -0,0 +1,25 @@ +import "postman-collection" + +/* + There are some small mismatches in some types that the 'postman-collection' + package provides (come on guys ;) ). These type definitions append extra + information which is present in the runtime values of the types +*/ + +type PMRawLanguage = "text" | "javascript" | "json" | "html" | "xml" + +declare module "postman-collection" { + interface RequestBody { + // Options is not well-defined by the schema, so we are treating everything as optional for runtime safety. + // See: https://schema.postman.com/collection/json/v2.1.0/draft-04/collection.json + options?: { + raw?: { + language?: PMRawLanguage + } + } + } + + interface FormParam { + type: "file" | "text" + } +} diff --git a/packages/hoppscotch-common/src/types/post-request.d.ts b/packages/hoppscotch-common/src/types/post-request.d.ts new file mode 100644 index 0000000..0660094 --- /dev/null +++ b/packages/hoppscotch-common/src/types/post-request.d.ts @@ -0,0 +1,1294 @@ +type HoppRESTContentType = + | null + | "multipart/form-data" + | "application/json" + | "application/ld+json" + | "application/hal+json" + | "application/vnd.api+json" + | "application/xml" + | "text/xml" + | "application/x-www-form-urlencoded" + | "binary" + | "text/html" + | "text/plain" + | "application/octet-stream" + +interface HoppRESTHeader { + key: string + value: string + active: boolean + description: string +} + +interface HoppRESTResponseHeader { + key: string + value: string +} + +interface HoppRESTParam { + key: string + value: string + active: boolean + description: string +} + +// Form data key-value pair for multipart/form-data +interface FormDataKeyValue { + key: string + active: boolean + isFile: boolean + value: string | Blob[] | null +} + +interface HoppRESTReqBody { + contentType: HoppRESTContentType + body: string | null | File | FormDataKeyValue[] +} + +interface Cookie { + name: string + value: string + domain: string + path: string + expires?: string + maxAge?: number + secure: boolean + httpOnly: boolean + sameSite: "None" | "Lax" | "Strict" +} + +type AuthLocation = "HEADERS" | "QUERY_PARAMS" +type DigestAlgorithm = "MD5" | "MD5-sess" +type DigestQOP = "auth" | "auth-int" +type JWTAlgorithm = + | "HS256" + | "HS384" + | "HS512" + | "RS256" + | "RS384" + | "RS512" + | "PS256" + | "PS384" + | "PS512" + | "ES256" + | "ES384" + | "ES512" +type HAWKAlgorithm = "sha256" | "sha1" + +interface HoppRESTAuthNone { + authType: "none" + authActive: boolean +} + +interface HoppRESTAuthInherit { + authType: "inherit" + authActive: boolean +} + +interface HoppRESTAuthBasic { + authType: "basic" + authActive: boolean + username: string + password: string +} + +interface HoppRESTAuthBearer { + authType: "bearer" + authActive: boolean + token: string +} + +interface HoppRESTAuthAPIKey { + authType: "api-key" + authActive: boolean + key: string + value: string + addTo: AuthLocation +} + +interface HoppRESTAuthOAuth2 { + authType: "oauth-2" + authActive: boolean + grantTypeInfo: OAuth2GrantTypeInfo + addTo: AuthLocation +} + +type OAuth2GrantTypeInfo = + | { + grantType: "AUTHORIZATION_CODE" + authEndpoint: string + tokenEndpoint: string + clientID: string + clientSecret?: string + scopes?: string + isPKCE?: boolean + codeVerifierMethod?: "plain" | "S256" + token?: string + } + | { + grantType: "CLIENT_CREDENTIALS" + tokenEndpoint: string + clientID: string + clientSecret?: string + scopes?: string + clientAuthentication?: "AS_BASIC_AUTH_HEADERS" | "IN_BODY" + } + | { + grantType: "PASSWORD" + tokenEndpoint: string + clientID: string + clientSecret?: string + username: string + password: string + scopes?: string + } + | { + grantType: "IMPLICIT" + authEndpoint: string + clientID: string + scopes?: string + } +interface HoppRESTAuthAWSSignature { + authType: "aws-signature" + authActive: boolean + accessKey: string + secretKey: string + region: string + serviceName: string + serviceToken?: string + addTo: AuthLocation +} + +interface HoppRESTAuthDigest { + authType: "digest" + authActive: boolean + username: string + password: string + realm?: string + nonce?: string + algorithm: DigestAlgorithm + qop: DigestQOP + nc?: string + cnonce?: string + opaque?: string + disableRetry: boolean +} + +interface HoppRESTAuthHAWK { + authType: "hawk" + authActive: boolean + authId: string + authKey: string + algorithm: HAWKAlgorithm + includePayloadHash: boolean + user?: string + nonce?: string + ext?: string + app?: string + dlg?: string + timestamp?: string +} + +interface HoppRESTAuthAkamaiEdgeGrid { + authType: "akamai-eg" + authActive: boolean + accessToken: string + clientToken: string + clientSecret: string + nonce?: string + timestamp?: string + host?: string + headersToSign?: string + maxBodySize?: string +} + +interface HoppRESTAuthJWT { + authType: "jwt" + authActive: boolean + secret: string + privateKey?: string + algorithm: JWTAlgorithm + payload: string + addTo: AuthLocation + isSecretBase64Encoded: boolean + headerPrefix: string + paramName: string + jwtHeaders: string +} + +// Discriminated union for all auth types +type HoppRESTAuth = + | HoppRESTAuthNone + | HoppRESTAuthInherit + | HoppRESTAuthBasic + | HoppRESTAuthBearer + | HoppRESTAuthAPIKey + | HoppRESTAuthOAuth2 + | HoppRESTAuthAWSSignature + | HoppRESTAuthDigest + | HoppRESTAuthHAWK + | HoppRESTAuthAkamaiEdgeGrid + | HoppRESTAuthJWT + +// Length property that can be used both as property and callable with comparison methods +// Matches actual runtime implementation in post-request.js lines 371-414 +interface ChaiLengthAssertion { + // Primary comparison methods (actually implemented in runtime) + above(n: number): ChaiExpectation + below(n: number): ChaiExpectation + within(min: number, max: number): ChaiExpectation + least(n: number): ChaiExpectation + most(n: number): ChaiExpectation + gte(n: number): ChaiExpectation + lte(n: number): ChaiExpectation + + // Aliases implemented in runtime (for compatibility) + greaterThan(n: number): ChaiExpectation // Alias for above() + lessThan(n: number): ChaiExpectation // Alias for below() + + // Postman-style .at.least() and .at.most() support + at: Readonly<{ + least(n: number): ChaiExpectation + most(n: number): ChaiExpectation + }> +} + +// Chai-powered assertion interface +interface ChaiExpectation { + // Negation + not: ChaiExpectation + + // Language chains (improve readability without affecting assertions) + to: ChaiExpectation + be: ChaiExpectation + been: ChaiExpectation + is: ChaiExpectation + that: ChaiExpectation + which: ChaiExpectation + and: ChaiExpectation + has: ChaiExpectation + have: ChaiExpectation + with: ChaiExpectation + at: ChaiExpectation + of: ChaiExpectation + same: ChaiExpectation + but: ChaiExpectation + does: ChaiExpectation + still: ChaiExpectation + also: ChaiExpectation + + // Modifiers (can be used as both properties and methods) + deep: ChaiExpectation + nested: ChaiExpectation + own: ChaiExpectation + ordered: ChaiExpectation + any: ChaiExpectation + all: ChaiExpectation + + // Include/contain can be used as both properties AND methods + include: ChaiExpectation & ((value: any) => ChaiExpectation) + contain: ChaiExpectation & ((value: any) => ChaiExpectation) + includes: ChaiExpectation & ((value: any) => ChaiExpectation) + contains: ChaiExpectation & ((value: any) => ChaiExpectation) + + // Type assertions - can be used as both property and method + a: ChaiExpectation & ((type: string) => ChaiExpectation) + an: ChaiExpectation & ((type: string) => ChaiExpectation) + + // Equality assertions + equal(value: any): ChaiExpectation + equals(value: any): ChaiExpectation + eq(value: any): ChaiExpectation + eql(value: any): ChaiExpectation + + // Truthiness assertions + true: ChaiExpectation + false: ChaiExpectation + ok: ChaiExpectation + null: ChaiExpectation + undefined: ChaiExpectation + NaN: ChaiExpectation + exist: ChaiExpectation + empty: ChaiExpectation + arguments: ChaiExpectation + + // Numerical comparison assertions + above(n: number): ChaiExpectation + gt(n: number): ChaiExpectation + greaterThan(n: number): ChaiExpectation + below(n: number): ChaiExpectation + lt(n: number): ChaiExpectation + lessThan(n: number): ChaiExpectation + least(n: number): ChaiExpectation + gte(n: number): ChaiExpectation + greaterThanOrEqual(n: number): ChaiExpectation + most(n: number): ChaiExpectation + lte(n: number): ChaiExpectation + lessThanOrEqual(n: number): ChaiExpectation + within(start: number, finish: number): ChaiExpectation + closeTo(expected: number, delta: number): ChaiExpectation + approximately(expected: number, delta: number): ChaiExpectation + + // Property assertions + property(name: string, value?: any): ChaiExpectation + ownProperty(name: string, value?: any): ChaiExpectation + haveOwnProperty(name: string, value?: any): ChaiExpectation + ownPropertyDescriptor( + name: string, + descriptor?: PropertyDescriptor + ): ChaiExpectation + + // Length assertions - SPECIAL: Can be used as property or called with comparison methods + // Allow .length to be called as function or used as property with comparison methods + length: ChaiLengthAssertion & number & ((n: number) => ChaiExpectation) + lengthOf: ((n: number) => ChaiExpectation) & ChaiLengthAssertion + + // String/Array inclusion assertions + string(str: string): ChaiExpectation + match(regex: RegExp): ChaiExpectation + matches(regex: RegExp): ChaiExpectation + members(set: any[]): ChaiExpectation + oneOf(list: any[]): ChaiExpectation + + // Key assertions + keys(...keys: string[] | [string[]]): ChaiExpectation + key(key: string | string[]): ChaiExpectation + + // Function/Error assertions + throw( + errorLike?: any, + errMsgMatcher?: string | RegExp, + message?: string + ): ChaiExpectation + throws( + errorLike?: any, + errMsgMatcher?: string | RegExp, + message?: string + ): ChaiExpectation + Throw( + errorLike?: any, + errMsgMatcher?: string | RegExp, + message?: string + ): ChaiExpectation + respondTo(method: string): ChaiExpectation + respondsTo(method: string): ChaiExpectation + itself: ChaiExpectation + satisfy(matcher: (value: any) => boolean): ChaiExpectation + satisfies(matcher: (value: any) => boolean): ChaiExpectation + + // Object state assertions + sealed: ChaiExpectation + frozen: ChaiExpectation + extensible: ChaiExpectation + finite: ChaiExpectation + + // instanceof assertion + instanceof(constructor: any): ChaiExpectation + instanceOf(constructor: any): ChaiExpectation + + // Side-effect assertions + // Side effect assertions - support both getter function and object+property patterns + change( + getter: () => any + ): ChaiExpectation & { by(delta: number): ChaiExpectation } + change( + obj: any, + prop: string + ): ChaiExpectation & { by(delta: number): ChaiExpectation } + increase( + getter: () => any + ): ChaiExpectation & { by(delta: number): ChaiExpectation } + increase( + obj: any, + prop: string + ): ChaiExpectation & { by(delta: number): ChaiExpectation } + decrease( + getter: () => any + ): ChaiExpectation & { by(delta: number): ChaiExpectation } + decrease( + obj: any, + prop: string + ): ChaiExpectation & { by(delta: number): ChaiExpectation } + + // Postman custom Chai assertions (available via pm.expect()) + /** + * Assert that value matches JSON Schema + * @param schema - JSON Schema object + */ + jsonSchema(schema: { + type?: string + required?: string[] + properties?: Record + items?: any + enum?: any[] + minimum?: number + maximum?: number + minLength?: number + maxLength?: number + pattern?: string + minItems?: number + maxItems?: number + }): ChaiExpectation + + /** + * Assert that string value contains specific charset/encoding + * @param expectedCharset - Expected charset (e.g., 'utf-8', 'iso-8859-1') + */ + charset(expectedCharset: string): ChaiExpectation + + /** + * Assert that cookie exists and optionally has specific value + * @param cookieName - Name of the cookie + * @param cookieValue - Optional expected value + */ + cookie(cookieName: string, cookieValue?: string): ChaiExpectation + + /** + * Assert that JSON path exists and optionally has specific value + * @param path - JSONPath expression (e.g., '$.users[0].name') + * @param expectedValue - Optional expected value at path + */ + jsonPath(path: string, expectedValue?: any): ChaiExpectation + + // Legacy methods (kept for backward compatibility) + toBe(value: any): void + toBeLevel2xx(): void + toBeLevel3xx(): void + toBeLevel4xx(): void + toBeLevel5xx(): void + toBeType(type: string): void + toHaveLength(length: number): void + toInclude(value: any): void +} + +// Legacy expectation interface for pw namespace (backward compatibility only) +interface LegacyExpectation extends LegacyExpectationMethods { + not: LegacyBaseExpectation +} + +interface LegacyBaseExpectation extends LegacyExpectationMethods {} + +interface LegacyExpectationMethods { + toBe(value: any): void + toBeLevel2xx(): void + toBeLevel3xx(): void + toBeLevel4xx(): void + toBeLevel5xx(): void + toBeType(type: string): void + toHaveLength(length: number): void + toInclude(value: any): void +} + +// Backward compatibility types for hopp and pm namespaces +interface Expectation extends ChaiExpectation {} +interface BaseExpectation extends ChaiExpectation {} +interface ExpectationMethods extends ChaiExpectation {} + +declare namespace pw { + function test(name: string, func: () => void): void + function expect(value: any): LegacyExpectation + const response: Readonly<{ + status: number + body: any + headers: HoppRESTResponseHeader[] + }> + namespace env { + function set(key: string, value: string): void + function unset(key: string): void + function get(key: string): string + function getResolve(key: string): string + function resolve(value: string): string + } +} + +declare namespace hopp { + const env: Readonly<{ + get(key: string): string | null + getRaw(key: string): string | null + getInitialRaw(key: string): string | null + set(key: string, value: string): void + delete(key: string): void + reset(key: string): void + setInitial(key: string, value: string): void + active: Readonly<{ + get(key: string): string | null + getRaw(key: string): string | null + getInitialRaw(key: string): string | null + set(key: string, value: string): void + delete(key: string): void + reset(key: string): void + setInitial(key: string, value: string): void + }> + global: Readonly<{ + get(key: string): string | null + getRaw(key: string): string | null + getInitialRaw(key: string): string | null + set(key: string, value: string): void + delete(key: string): void + reset(key: string): void + setInitial(key: string, value: string): void + }> + }> + + const request: Readonly<{ + readonly url: string + readonly method: string + readonly params: HoppRESTParam[] + readonly headers: HoppRESTHeader[] + readonly body: HoppRESTReqBody + readonly auth: HoppRESTAuth + variables: Readonly<{ + get(key: string): string | null + }> + }> + + const response: Readonly<{ + readonly statusCode: number + readonly statusText: string + readonly headers: HoppRESTResponseHeader[] + readonly responseTime: number + body: Readonly<{ + asText(): string + asJSON(): any + bytes(): Uint8Array + }> + /** + * Get response body as text string + * @returns Response body as string + */ + text(): string + /** + * Get response body as parsed JSON + * @returns Parsed JSON object + */ + json(): any + /** + * Get HTTP reason phrase (status text) + * @returns HTTP reason phrase (e.g., "OK", "Not Found") + */ + reason(): string + /** + * Convert response to data URI format + * @returns Data URI string with base64 encoded content + */ + dataURI(): string + /** + * Parse JSONP response + * @param callbackName - Optional callback function name (default: "callback") + * @returns Parsed JSON object from JSONP wrapper + */ + jsonp(callbackName?: string): any + }> + + const cookies: Readonly<{ + get(domain: string, name: string): Cookie | null + set(domain: string, cookie: Cookie): void + has(domain: string, name: string): boolean + getAll(domain: string): Cookie[] + delete(domain: string, name: string): void + clear(domain: string): void + }> + + function test(name: string, testFunction: () => void): void + + interface HoppExpectFunction { + (value: any): ChaiExpectation + /** + * Fail the test with a custom message + * @param message - Optional message to display on failure + */ + fail(message?: string): never + } + + const expect: HoppExpectFunction + + const info: Readonly<{ + readonly eventName: "post-request" + readonly requestName: string + readonly requestId: string + readonly iteration: never + readonly iterationCount: never + }> + + /** + * Fetch API - Makes HTTP requests respecting interceptor settings + * @param input - URL string or Request object + * @param init - Optional request options + * @returns Promise that resolves to Response object + */ + function fetch( + input: RequestInfo | URL, + init?: RequestInit + ): Promise +} + +/** + * Global fetch function - alias to hopp.fetch() + * Makes HTTP requests respecting interceptor settings + * @param input - URL string or Request object + * @param init - Optional request options + * @returns Promise that resolves to Response object + */ +declare function fetch( + input: RequestInfo | URL, + init?: RequestInit +): Promise + +declare namespace pm { + const environment: Readonly<{ + get(key: string): string | null + set(key: string, value: string): void + unset(key: string): void + has(key: string): boolean + clear(): void + toObject(): Record + }> + + const globals: Readonly<{ + get(key: string): string | null + set(key: string, value: string): void + unset(key: string): void + has(key: string): boolean + clear(): void + toObject(): Record + }> + + const collectionVariables: Readonly<{ + get(key: string): string | null + set(key: string, value: string): void + unset(key: string): void + has(key: string): boolean + clear(): void + toObject(): Record + }> + + const variables: Readonly<{ + get(key: string): string | null + set(key: string, value: string): void + unset(key: string): void + has(key: string): boolean + toObject(): Record + }> + + const iterationData: Readonly<{ + get(key: string): any + toObject(): Record + }> + + const request: Readonly<{ + url: Readonly<{ + toString(): string + protocol: string | null + port: string | null + path: string[] + host: string[] + query: Readonly<{ + has(key: string): boolean + get(key: string): string | null + toObject(): Record + }> + variables: Readonly<{ + has(key: string): boolean + get(key: string): string | null + toObject(): Record + }> + hash: string | null + update(url: string): void + addQueryParams(params: string | Array<{ key: string; value: string }>): void + removeQueryParams(params: string | string[]): void + }> + headers: Readonly<{ + has(key: string): boolean + get(key: string): string | null + toObject(): Record + add(header: { key: string; value: string }): void + remove(key: string): void + upsert(header: { key: string; value: string }): void + }> + method: string + body: Readonly<{ + mode: string + raw: string | null + urlencoded: Array<{ key: string; value: string }> | null + formdata: Array<{ key: string; value: string }> | null + file: any | null + graphql: any | null + toObject(): any + update(body: any): void + }> + auth: any + certificate: any + proxy: any + }> + + const response: Readonly<{ + code: number + status: string + headers: Readonly<{ + has(key: string): boolean + get(key: string): string | null + toObject(): Record + }> + cookies: Readonly<{ + has(name: string): boolean + get(name: string): Cookie | null + toObject(): Record + }> + body: string + json(): any + text(): string + reason(): string + responseTime: number + responseSize: number + dataURI(): string + }> + + const cookies: Readonly<{ + has(name: string): boolean + get(name: string): Cookie | null + set(name: string, value: string, options?: any): void + jar(): any + }> + + function test(name: string, fn: () => void): void + + interface PmExpectFunction { + (value: any, message?: string): ChaiExpectation + fail?: (...args: any[]) => never + } + + const expect: PmExpectFunction + + const info: Readonly<{ + eventName: string + iteration: number + iterationCount: number + requestName: string + requestId: string + }> + + interface SendRequestCallback { + (error: Error | null, response: { + code: number + status: string + headers: { + has(key: string): boolean + get(key: string): string | null + } + body: string + responseTime: number + responseSize: number + text(): string + json(): any + cookies: { + has(name: string): boolean + get(name: string): any | null + } + } | null): void + } + + function sendRequest( + urlOrRequest: string | { + url: string + method?: string + header?: Record | Array<{ key: string; value: string }> + body?: { + mode: 'raw' | 'urlencoded' | 'formdata' + raw?: string + urlencoded?: Array<{ key: string; value: string }> + formdata?: Array<{ key: string; value: string }> + } + }, + callback: SendRequestCallback + ): void + + const vault: Readonly<{ + get(key: string): string | null + set(key: string, value: string): void + unset(key: string): void + }> + + const visualizer: Readonly<{ + set(template: string, data?: any): void + clear(): void + }> +} + const environment: Readonly<{ + readonly name: string + get(key: string): any + set(key: string, value: any): void + unset(key: string): void + has(key: string): boolean + clear(): void + toObject(): Record + replaceIn(template: string): string + }> + + const globals: Readonly<{ + get(key: string): any + /** + * Set a global variable + * @param key - Variable key + * @param value - Variable value (undefined is preserved, other types are coerced to strings) + */ + set(key: string, value: any): void + unset(key: string): void + has(key: string): boolean + clear(): void + toObject(): Record + replaceIn(template: string): string + }> + + const variables: Readonly<{ + get(key: string): any + /** + * Set a variable in the active environment scope + * @param key - Variable key + * @param value - Variable value (undefined is preserved, other types are coerced to strings) + */ + set(key: string, value: any): void + has(key: string): boolean + replaceIn(template: string): string + toObject(): Record + }> + + const request: Readonly<{ + readonly id: string + readonly name: string + readonly url: Readonly<{ + toString(): string + readonly protocol: string + readonly host: string[] + readonly port: string + readonly path: string[] + readonly hash: string + + // URL Helper Methods (Postman-compatible) + /** + * Get the hostname as a string (e.g., "api.example.com") + * @returns The hostname portion of the URL + */ + getHost(): string + /** + * Get the path with leading slash (e.g., "/v1/users") + * @param unresolved - If true, returns unresolved path with variables (currently ignored) + * @returns The path portion of the URL + */ + getPath(unresolved?: boolean): string + /** + * Get the path with query string (e.g., "/v1/users?page=1") + * @returns Path and query string combined + */ + getPathWithQuery(): string + /** + * Get the query string without leading ? (e.g., "page=1&limit=20") + * @param options - Optional configuration (currently ignored) + * @returns Query string without the leading question mark + */ + getQueryString(options?: Record): string + /** + * Get the hostname with port (e.g., "api.example.com:8080") + * @param forcePort - If true, includes standard ports (80/443) + * @returns Hostname with port if non-standard or forced + */ + getRemote(forcePort?: boolean): string + + readonly query: Readonly<{ + /** + * Get the value of a query parameter by key + * @param key - Parameter key to retrieve + * @returns Parameter value or null if not found + */ + get(key: string): string | null + /** + * Check if a query parameter exists + * @param key - Parameter key to check + * @returns true if parameter exists, false otherwise + */ + has(key: string): boolean + /** + * Get all query parameters as a key-value object + * @returns Object with all query parameters + */ + all(): Record + /** + * Convert query parameters to object (alias for all()) + * @returns Object with all query parameters + */ + toObject(): Record + /** + * Get the number of query parameters + * @returns Count of parameters + */ + count(): number + /** + * Get a parameter by index + * @param index - Zero-based index + * @returns Parameter object or null if out of bounds + */ + idx(index: number): { key: string; value: string } | null + + /** + * Iterate over all query parameters + * @param callback - Function to call for each parameter + */ + each(callback: (param: { key: string; value: string }) => void): void + /** + * Map query parameters to a new array + * @param callback - Transform function + * @returns Array of transformed values + */ + map(callback: (param: { key: string; value: string }) => T): T[] + /** + * Filter query parameters + * @param callback - Predicate function + * @returns Array of parameters matching the predicate + */ + filter( + callback: (param: { key: string; value: string }) => boolean + ): Array<{ key: string; value: string }> + /** + * Find a query parameter by string key or function predicate + * @param rule - String key or predicate function + * @param context - Optional context to bind the predicate function + * @returns Matching parameter or null if not found + */ + find( + rule: string | ((param: { key: string; value: string }) => boolean), + context?: any + ): { key: string; value: string } | null + /** + * Get the index of a query parameter + * @param item - String key or parameter object to find + * @returns Index of parameter, or -1 if not found + */ + indexOf(item: string | { key: string; value: string }): number + }> + }> + + /** + * Client certificate used for mutual TLS authentication + * In Postman, certificates are configured at the app/collection level, not programmatically in scripts + * Returns undefined in Hoppscotch as certificate configuration is handled at the application level + * @see https://learning.postman.com/docs/sending-requests/certificates/ + */ + readonly certificate: + | { + readonly name: string + readonly matches: string[] + readonly key: { readonly src: string } + readonly cert: { readonly src: string } + readonly passphrase?: string + } + | undefined + + /** + * Proxy configuration for the request + * In Postman, proxy is configured at the app level, not programmatically in scripts + * Returns undefined in Hoppscotch as proxy configuration is handled at the application level + * @see https://learning.postman.com/docs/sending-requests/capturing-request-data/proxy/ + */ + readonly proxy: + | { + readonly host: string + readonly port: number + readonly tunnel: boolean + readonly disabled: boolean + } + | undefined + + readonly method: string + readonly headers: Readonly<{ + /** + * Get the value of a header by name (case-insensitive) + * @param name - Header name to retrieve + * @returns Header value or null if not found + */ + get(name: string): string | null + /** + * Check if a header exists (case-insensitive) + * @param name - Header name to check + * @returns true if header exists, false otherwise + */ + has(name: string): boolean + /** + * Get all headers as a key-value object + * @returns Object with all headers (keys in lowercase) + */ + all(): Record + /** + * Convert headers to object (alias for all()) + * @returns Object with all headers + */ + toObject(): Record + /** + * Get the number of headers + * @returns Count of headers + */ + count(): number + /** + * Get a header by index + * @param index - Zero-based index + * @returns Header object or null if out of bounds + */ + idx(index: number): { key: string; value: string } | null + + /** + * Iterate over all headers + * @param callback - Function to call for each header + */ + each(callback: (header: { key: string; value: string }) => void): void + /** + * Map headers to a new array + * @param callback - Transform function + * @returns Array of transformed values + */ + map(callback: (header: { key: string; value: string }) => T): T[] + /** + * Filter headers + * @param callback - Predicate function + * @returns Array of headers matching the predicate + */ + filter( + callback: (header: { key: string; value: string }) => boolean + ): Array<{ key: string; value: string }> + /** + * Find a header by string name or function predicate (case-insensitive) + * @param rule - String name or predicate function + * @param context - Optional context to bind the predicate function + * @returns Matching header or null if not found + */ + find( + rule: string | ((header: { key: string; value: string }) => boolean), + context?: any + ): { key: string; value: string } | null + /** + * Get the index of a header (case-insensitive) + * @param item - String name or header object to find + * @returns Index of header, or -1 if not found + */ + indexOf(item: string | { key: string; value: string }): number + }> + readonly body: HoppRESTReqBody + readonly auth: HoppRESTAuth + }> + + const response: Readonly<{ + readonly code: number + readonly status: string + readonly responseTime: number + readonly responseSize: number + text(): string + json(): any + stream: Uint8Array + /** + * Get HTTP reason phrase (status text) + * @returns HTTP reason phrase (e.g., "OK", "Not Found") + */ + reason(): string + /** + * Convert response to data URI format + * @returns Data URI string with base64 encoded content + */ + dataURI(): string + /** + * Parse JSONP response + * @param callbackName - Optional callback function name (default: "callback") + * @returns Parsed JSON object from JSONP wrapper + */ + jsonp(callbackName?: string): any + headers: Readonly<{ + get(name: string): string | null + has(name: string): boolean + all(): HoppRESTResponseHeader[] + }> + cookies: Readonly<{ + get(name: string): string | null + has(name: string): boolean + toObject(): Record + }> + to: Readonly<{ + have: Readonly<{ + status(expectedCode: number): void + header(headerName: string, headerValue?: string): void + body(expectedBody: string): void + jsonBody(): void + jsonBody(key: string): void + jsonBody(key: string, expectedValue: any): void + jsonBody(schema: object): void + responseTime: Readonly<{ + below(ms: number): void + above(ms: number): void + }> + jsonSchema(schema: { + type?: string + required?: string[] + properties?: Record + items?: any + enum?: any[] + minimum?: number + maximum?: number + minLength?: number + maxLength?: number + pattern?: string + minItems?: number + maxItems?: number + }): void + charset(expectedCharset: string): void + cookie(cookieName: string, cookieValue?: string): void + jsonPath(path: string, expectedValue?: any): void + }> + be: Readonly<{ + ok(): void + success(): void + accepted(): void + badRequest(): void + unauthorized(): void + forbidden(): void + notFound(): void + rateLimited(): void + serverError(): void + clientError(): void + json(): void + html(): void + xml(): void + text(): void + }> + }> + }> + + const cookies: Readonly<{ + get(name: string): any + set(name: string, value: string, options?: any): any + jar(): never + }> + + function test(name: string, testFunction: () => void): void + + interface ExpectFunction { + (value: any): ChaiExpectation + /** + * Fail the test with a custom message + * @param message - Optional message to display on failure + */ + fail(message?: string): never + } + + const expect: ExpectFunction + + const info: Readonly<{ + readonly eventName: "post-request" + readonly requestName: string + readonly requestId: string + readonly iteration: never + readonly iterationCount: never + }> + + /** + * Send an HTTP request (unsupported) + * @throws Error - sendRequest is not supported in Hoppscotch + */ + function sendRequest( + request: string | { url: string; method?: string; [key: string]: any }, + callback?: (err: any, response: any) => void + ): never + + /** + * Visualizer API (unsupported) + * The Postman Visualizer allows you to present response data as HTML templates with styling. + * This feature is not supported in Hoppscotch as it requires a browser-based visualization UI. + * @see https://learning.postman.com/docs/sending-requests/response-data/visualizer/ + */ + const visualizer: Readonly<{ + /** + * Set a Handlebars template to visualize response data (unsupported) + * @param layout - HTML template string with Handlebars syntax + * @param data - Data object to pass to the template + * @param options - Optional configuration object + * @throws Error - Visualizer is not supported in Hoppscotch + */ + set( + layout: string, + data?: Record, + options?: Record + ): never + + /** + * Clear the current visualization (unsupported) + * @throws Error - Visualizer is not supported in Hoppscotch + */ + clear(): never + }> + + /** + * Collection variables (unsupported - Workspace feature) + * Collection variables are not supported in Hoppscotch as they are a Postman Workspace feature + */ + const collectionVariables: Readonly<{ + get(key: string): never + set(key: string, value: string): never + unset(key: string): never + has(key: string): never + clear(): never + toObject(): never + replaceIn(template: string): never + }> + + /** + * Postman Vault (unsupported) + * Vault is not supported in Hoppscotch as it is a Postman-specific feature + */ + const vault: Readonly<{ + get(key: string): never + set(key: string, value: string): never + unset(key: string): never + }> + + /** + * Iteration data (unsupported - Collection Runner feature) + * Iteration data is not supported in Hoppscotch as it requires Collection Runner + */ + const iterationData: Readonly<{ + get(key: string): never + set(key: string, value: string): never + unset(key: string): never + has(key: string): never + toObject(): never + toJSON(): never + }> + + /** + * Execution control + */ + const execution: Readonly<{ + /** + * Execution location identifier + * Always returns ["Hoppscotch"] with current = "Hoppscotch" + */ + readonly location: readonly string[] & { + readonly current: string + } + /** + * Set next request to execute (unsupported - Collection Runner feature) + * @param requestNameOrId - Name or ID of the next request + */ + setNextRequest(requestNameOrId: string | null): never + /** + * Skip current request execution (unsupported - Collection Runner feature) + */ + skipRequest(): never + /** + * Run a request (unsupported - Collection Runner feature) + * @param requestNameOrId - Name or ID of the request to run + */ + runRequest(requestNameOrId: string): never + }> + + /** + * Import packages from Package Library (unsupported) + * @param packageName - Name of the package to import (e.g., '@team-domain/package-name' or 'npm:package-name@version') + * @returns The imported package module + * @throws Error - Package imports are not supported in Hoppscotch + */ + function require(packageName: string): never +} diff --git a/packages/hoppscotch-common/src/types/pre-request.d.ts b/packages/hoppscotch-common/src/types/pre-request.d.ts new file mode 100644 index 0000000..224bb15 --- /dev/null +++ b/packages/hoppscotch-common/src/types/pre-request.d.ts @@ -0,0 +1,949 @@ +type HoppRESTContentType = + | null + | "multipart/form-data" + | "application/json" + | "application/ld+json" + | "application/hal+json" + | "application/vnd.api+json" + | "application/xml" + | "text/xml" + | "application/x-www-form-urlencoded" + | "binary" + | "text/html" + | "text/plain" + | "application/octet-stream" + +interface HoppRESTHeader { + key: string + value: string + active: boolean + description: string +} + +interface HoppRESTParam { + key: string + value: string + active: boolean + description: string +} + +// Form data key-value pair for multipart/form-data +interface FormDataKeyValue { + key: string + active: boolean + isFile: boolean + value: string | Blob[] | null +} + +interface HoppRESTReqBody { + contentType: HoppRESTContentType + body: string | null | File | FormDataKeyValue[] +} + +interface Cookie { + name: string + value: string + domain: string + path: string + expires?: string + maxAge?: number + secure: boolean + httpOnly: boolean + sameSite: "None" | "Lax" | "Strict" +} + +type AuthLocation = "HEADERS" | "QUERY_PARAMS" +type DigestAlgorithm = "MD5" | "MD5-sess" +type DigestQOP = "auth" | "auth-int" +type JWTAlgorithm = + | "HS256" + | "HS384" + | "HS512" + | "RS256" + | "RS384" + | "RS512" + | "PS256" + | "PS384" + | "PS512" + | "ES256" + | "ES384" + | "ES512" +type HAWKAlgorithm = "sha256" | "sha1" + +interface HoppRESTAuthNone { + authType: "none" + authActive: boolean +} + +interface HoppRESTAuthInherit { + authType: "inherit" + authActive: boolean +} + +interface HoppRESTAuthBasic { + authType: "basic" + authActive: boolean + username: string + password: string +} + +interface HoppRESTAuthBearer { + authType: "bearer" + authActive: boolean + token: string +} + +interface HoppRESTAuthAPIKey { + authType: "api-key" + authActive: boolean + key: string + value: string + addTo: AuthLocation +} + +type OAuth2GrantTypeInfo = + | { + grantType: "AUTHORIZATION_CODE" + authEndpoint: string + tokenEndpoint: string + clientID: string + clientSecret?: string + scopes?: string + isPKCE?: boolean + codeVerifierMethod?: "plain" | "S256" + token?: string + } + | { + grantType: "CLIENT_CREDENTIALS" + tokenEndpoint: string + clientID: string + clientSecret?: string + scopes?: string + clientAuthentication?: "AS_BASIC_AUTH_HEADERS" | "IN_BODY" + } + | { + grantType: "PASSWORD" + tokenEndpoint: string + clientID: string + clientSecret?: string + username: string + password: string + scopes?: string + } + | { + grantType: "IMPLICIT" + authEndpoint: string + clientID: string + scopes?: string + } + +interface HoppRESTAuthOAuth2 { + authType: "oauth-2" + authActive: boolean + grantTypeInfo: OAuth2GrantTypeInfo + addTo: AuthLocation +} + +interface HoppRESTAuthAWSSignature { + authType: "aws-signature" + authActive: boolean + accessKey: string + secretKey: string + region: string + serviceName: string + serviceToken?: string + addTo: AuthLocation +} + +interface HoppRESTAuthDigest { + authType: "digest" + authActive: boolean + username: string + password: string + realm?: string + nonce?: string + algorithm: DigestAlgorithm + qop: DigestQOP + nc?: string + cnonce?: string + opaque?: string + disableRetry: boolean +} + +interface HoppRESTAuthHAWK { + authType: "hawk" + authActive: boolean + authId: string + authKey: string + algorithm: HAWKAlgorithm + includePayloadHash: boolean + user?: string + nonce?: string + ext?: string + app?: string + dlg?: string + timestamp?: string +} + +interface HoppRESTAuthAkamaiEdgeGrid { + authType: "akamai-eg" + authActive: boolean + accessToken: string + clientToken: string + clientSecret: string + nonce?: string + timestamp?: string + host?: string + headersToSign?: string + maxBodySize?: string +} + +interface HoppRESTAuthJWT { + authType: "jwt" + authActive: boolean + secret: string + privateKey?: string + algorithm: JWTAlgorithm + payload: string + addTo: AuthLocation + isSecretBase64Encoded: boolean + headerPrefix: string + paramName: string + jwtHeaders: string +} + +// Discriminated union for all auth types +type HoppRESTAuth = + | HoppRESTAuthNone + | HoppRESTAuthInherit + | HoppRESTAuthBasic + | HoppRESTAuthBearer + | HoppRESTAuthAPIKey + | HoppRESTAuthOAuth2 + | HoppRESTAuthAWSSignature + | HoppRESTAuthDigest + | HoppRESTAuthHAWK + | HoppRESTAuthAkamaiEdgeGrid + | HoppRESTAuthJWT + +declare namespace pw { + namespace env { + function get(key: string): string + function getResolve(key: string): string + function set(key: string, value: string): void + function unset(key: string): void + function resolve(key: string): string + } +} + +declare namespace hopp { + const env: Readonly<{ + get(key: string): string | null + getRaw(key: string): string | null + set(key: string, value: string): void + delete(key: string): void + reset(key: string): void + getInitialRaw(key: string): string | null + setInitial(key: string, value: string): void + active: Readonly<{ + get(key: string): string | null + getRaw(key: string): string | null + set(key: string, value: string): void + delete(key: string): void + reset(key: string): void + getInitialRaw(key: string): string | null + setInitial(key: string, value: string): void + }> + global: Readonly<{ + get(key: string): string | null + getRaw(key: string): string | null + set(key: string, value: string): void + delete(key: string): void + reset(key: string): void + getInitialRaw(key: string): string | null + setInitial(key: string, value: string): void + }> + }> + + const request: Readonly<{ + readonly url: string + readonly method: string + readonly params: HoppRESTParam[] + readonly headers: HoppRESTHeader[] + readonly body: HoppRESTReqBody + readonly auth: HoppRESTAuth + setUrl(url: string): void + setMethod(method: string): void + setHeader(name: string, value: string): void + setHeaders(headers: Array>): void + removeHeader(key: string): void + setParam(name: string, value: string): void + setParams(params: Array>): void + removeParam(key: string): void + /** + * Set or update request body with automatic merging + * + * This method supports both partial updates and complete replacement: + * - Partial updates: Merges provided fields with existing body configuration + * - Complete replacement: When all fields are provided, replaces entire body + * + * @param body - Partial or complete HoppRESTReqBody object + *` + * @example + * // Partial update - just change content type + * hopp.request.setBody({ contentType: "application/xml" }) + * + * // Partial update - just change body content + * hopp.request.setBody({ body: JSON.stringify({ updated: true }) }) + * + * // Complete replacement + * hopp.request.setBody({ + * contentType: "application/json", + * body: JSON.stringify({ name: "test", value: 123 }) + * }) + */ + setBody(body: Partial): void + /** + * Set or update authentication configuration with automatic merging + * + * This method supports both partial updates and complete replacement: + * - Partial updates: Merges provided fields with existing auth configuration + * - Complete replacement: When switching auth types, resets type-specific fields + * + * @param auth - Partial or complete HoppRESTAuth object + * + * @example + * // Partial update - just change the token (merges with existing) + * hopp.request.setAuth({ bearerToken: "new-token" }) + * + * // Complete replacement - switch auth types + * hopp.request.setAuth({ + * authType: "basic", + * authActive: true, + * username: "user", + * password: "pass" + * }) + * + * // Update multiple fields while preserving others + * hopp.request.setAuth({ + * accessToken: "updated-token" + * }) + */ + setAuth(auth: Partial): void + variables: Readonly<{ + get(key: string): string | null + set(key: string, value: string): void + }> + }> + + const cookies: Readonly<{ + get(domain: string, name: string): Cookie | null + set(domain: string, cookie: Cookie): void + has(domain: string, name: string): boolean + getAll(domain: string): Cookie[] + delete(domain: string, name: string): void + clear(domain: string): void + }> + + /** + * Fetch API - Makes HTTP requests respecting interceptor settings + * @param input - URL string or Request object + * @param init - Optional request options + * @returns Promise that resolves to Response object + */ + function fetch( + input: RequestInfo | URL, + init?: RequestInit + ): Promise +} + +/** + * Global fetch function - alias to hopp.fetch() + * Makes HTTP requests respecting interceptor settings + * @param input - URL string or Request object + * @param init - Optional request options + * @returns Promise that resolves to Response object + */ +declare function fetch( + input: RequestInfo | URL, + init?: RequestInit +): Promise + +declare namespace pm { + const environment: Readonly<{ + /** + * Get an environment variable value + * @param key - Variable key + * @returns Variable value or undefined if not found + */ + get(key: string): any + /** + * Set an environment variable + * @param key - Variable key + * @param value - Variable value + */ + set(key: string, value: any): void + /** + * Remove an environment variable + * @param key - Variable key to remove + */ + unset(key: string): void + /** + * Check if an environment variable exists + * @param key - Variable key to check + * @returns true if variable exists, false otherwise + */ + has(key: string): boolean + /** + * Clear all environment variables in the active environment + */ + clear(): void + /** + * Get all environment variables as an object + * @returns Object with all environment variables as key-value pairs + */ + toObject(): Record + }> + + const globals: Readonly<{ + /** + * Get a global variable value + * @param key - Variable key + * @returns Variable value or undefined if not found + */ + get(key: string): any + /** + * Set a global variable + * @param key - Variable key + * @param value - Variable value (undefined is preserved, other types are coerced to strings) + */ + set(key: string, value: any): void + /** + * Remove a global variable + * @param key - Variable key to remove + */ + unset(key: string): void + /** + * Check if a global variable exists + * @param key - Variable key to check + * @returns true if variable exists, false otherwise + */ + has(key: string): boolean + /** + * Clear all global variables + */ + clear(): void + /** + * Get all global variables as an object + * @returns Object with all global variables as key-value pairs + */ + toObject(): Record + }> + + const variables: Readonly<{ + /** + * Get a variable value from either environment or global scope + * Environment variables take precedence over global variables + * @param key - Variable key + * @returns Variable value or undefined if not found + */ + get(key: string): any + /** + * Set a variable in the active environment scope + * @param key - Variable key + * @param value - Variable value (undefined is preserved, other types are coerced to strings) + */ + set(key: string, value: any): void + /** + * Check if a variable exists in either environment or global scope + * @param key - Variable key to check + * @returns true if variable exists, false otherwise + */ + has(key: string): boolean + /** + * Replace variables in a template string + * @param template - Template string with {{variable}} placeholders + * @returns String with variables replaced with their values + */ + replaceIn(template: string): string + }> + + /** + * Request object with full Postman compatibility + * All properties are mutable in pre-request scripts to match Postman behavior + */ + let request: { + // ID and name (read-only) + readonly id: string + readonly name: string + + /** + * Client certificate used for mutual TLS authentication + * In Postman, certificates are configured at the app/collection level, not programmatically in scripts + * Returns undefined in Hoppscotch as certificate configuration is handled at the application level + * @see https://learning.postman.com/docs/sending-requests/certificates/ + */ + readonly certificate: + | { + readonly name: string + readonly matches: string[] + readonly key: { readonly src: string } + readonly cert: { readonly src: string } + readonly passphrase?: string + } + | undefined + + /** + * Proxy configuration for the request + * In Postman, proxy is configured at the app level, not programmatically in scripts + * Returns undefined in Hoppscotch as proxy configuration is handled at the application level + * @see https://learning.postman.com/docs/sending-requests/capturing-request-data/proxy/ + */ + readonly proxy: + | { + readonly host: string + readonly port: number + readonly tunnel: boolean + readonly disabled: boolean + } + | undefined + + // URL - Fully mutable with Postman URL object structure + // Intersection type allows both string assignment AND object access + url: string & { + toString(): string + protocol: string + host: string[] + port: string + path: string[] + hash: string + + // URL Helper Methods (Postman-compatible) + /** + * Get the hostname as a string (e.g., "api.example.com") + * @returns The hostname portion of the URL + */ + getHost(): string + /** + * Get the path with leading slash (e.g., "/v1/users") + * @param unresolved - If true, returns unresolved path with variables (currently ignored) + * @returns The path portion of the URL + */ + getPath(unresolved?: boolean): string + /** + * Get the path with query string (e.g., "/v1/users?page=1") + * @returns Path and query string combined + */ + getPathWithQuery(): string + /** + * Get the query string without leading ? (e.g., "page=1&limit=20") + * @param options - Optional configuration (currently ignored) + * @returns Query string without the leading question mark + */ + getQueryString(options?: Record): string + /** + * Get the hostname with port (e.g., "api.example.com:8080") + * @param forcePort - If true, includes standard ports (80/443) + * @returns Hostname with port if non-standard or forced + */ + getRemote(forcePort?: boolean): string + /** + * Update the entire URL from a string or object with toString() + * @param url - New URL string or object with toString() method + */ + update(url: string | { toString(): string }): void + /** + * Add multiple query parameters to the URL + * @param params - Array of parameter objects with key/value pairs + */ + addQueryParams(params: Array<{ key: string; value?: string }>): void + /** + * Remove query parameters by name + * @param params - Single parameter name or array of names to remove + */ + removeQueryParams(params: string | string[]): void + + query: { + // Read methods + /** + * Get the value of a query parameter by key + * @param key - Parameter key to retrieve + * @returns Parameter value or null if not found + */ + get(key: string): string | null + /** + * Check if a query parameter exists + * @param key - Parameter key to check + * @returns true if parameter exists, false otherwise + */ + has(key: string): boolean + /** + * Get all query parameters as a key-value object + * @returns Object with all query parameters + */ + all(): Record + /** + * Convert query parameters to object (alias for all()) + * @returns Object with all query parameters + */ + toObject(): Record + /** + * Get the number of query parameters + * @returns Count of parameters + */ + count(): number + /** + * Get a parameter by index + * @param index - Zero-based index + * @returns Parameter object or null if out of bounds + */ + idx(index: number): { key: string; value: string } | null + + // Iteration methods + /** + * Iterate over all query parameters + * @param callback - Function to call for each parameter + */ + each(callback: (param: { key: string; value: string }) => void): void + /** + * Map query parameters to a new array + * @param callback - Transform function + * @returns Array of transformed values + */ + map(callback: (param: { key: string; value: string }) => T): T[] + /** + * Filter query parameters + * @param callback - Predicate function + * @returns Array of parameters matching the predicate + */ + filter( + callback: (param: { key: string; value: string }) => boolean + ): Array<{ key: string; value: string }> + /** + * Find a query parameter by string key or function predicate + * @param rule - String key or predicate function + * @param context - Optional context to bind the predicate function + * @returns Matching parameter or null if not found + */ + find( + rule: string | ((param: { key: string; value: string }) => boolean), + context?: any + ): { key: string; value: string } | null + /** + * Get the index of a query parameter + * @param item - String key or parameter object to find + * @returns Index of parameter, or -1 if not found + */ + indexOf(item: string | { key: string; value: string }): number + + // Mutation methods + /** + * Add a new query parameter + * @param param - Parameter to add + */ + add(param: { key: string; value: string }): void + /** + * Remove a query parameter by key + * @param key - Parameter key to remove + */ + remove(key: string): void + /** + * Update an existing parameter or add a new one + * @param param - Parameter to upsert + */ + upsert(param: { key: string; value: string }): void + /** + * Remove all query parameters + */ + clear(): void + /** + * Insert a query parameter before another parameter + * @param item - Parameter to insert + * @param before - String key or parameter object to insert before + */ + insert( + item: { key: string; value: string }, + before: string | { key: string; value: string } + ): void + /** + * Move a parameter to the end or append a new one + * @param item - Parameter to append + */ + append(item: { key: string; value: string }): void + /** + * Merge parameters from an array or object + * @param source - Array of parameters or key-value object + * @param prune - If true, remove parameters not in source + */ + assimilate( + source: + | Array<{ key: string; value: string }> + | Record, + prune?: boolean + ): void + } + } + + // Method - Mutable + method: string + + // Headers - With Postman mutation methods + headers: { + // Read methods + /** + * Get the value of a header by name (case-insensitive) + * @param name - Header name to retrieve + * @returns Header value or null if not found + */ + get(name: string): string | null + /** + * Check if a header exists (case-insensitive) + * @param name - Header name to check + * @returns true if header exists, false otherwise + */ + has(name: string): boolean + /** + * Get all headers as a key-value object + * @returns Object with all headers (keys in lowercase) + */ + all(): Record + /** + * Convert headers to object (alias for all()) + * @returns Object with all headers (keys in lowercase) + */ + toObject(): Record + /** + * Get the number of headers + * @returns Count of headers + */ + count(): number + /** + * Get a header by index + * @param index - Zero-based index + * @returns Header object or null if out of bounds + */ + idx(index: number): { key: string; value: string } | null + + // Iteration methods + /** + * Iterate over all headers + * @param callback - Function to call for each header + */ + each(callback: (header: { key: string; value: string }) => void): void + /** + * Map headers to a new array + * @param callback - Transform function + * @returns Array of transformed values + */ + map(callback: (header: { key: string; value: string }) => T): T[] + /** + * Filter headers + * @param callback - Predicate function + * @returns Array of headers matching the predicate + */ + filter( + callback: (header: { key: string; value: string }) => boolean + ): Array<{ key: string; value: string }> + /** + * Find a header by string key or function predicate (case-insensitive) + * @param rule - String key or predicate function + * @param context - Optional context to bind the predicate function + * @returns Matching header or null if not found + */ + find( + rule: string | ((header: { key: string; value: string }) => boolean), + context?: any + ): { key: string; value: string } | null + /** + * Get the index of a header (case-insensitive) + * @param item - String key or header object to find + * @returns Index of header, or -1 if not found + */ + indexOf(item: string | { key: string; value: string }): number + + // Mutation methods + /** + * Add a new header + * @param header - Header to add + */ + add(header: { key: string; value: string }): void + /** + * Remove a header by name (case-insensitive) + * @param headerName - Header name to remove + */ + remove(headerName: string): void + /** + * Update an existing header or add a new one + * @param header - Header to upsert + */ + upsert(header: { key: string; value: string }): void + /** + * Remove all headers + */ + clear(): void + /** + * Insert a header before another header + * @param item - Header to insert + * @param before - String key or header object to insert before + */ + insert( + item: { key: string; value: string }, + before: string | { key: string; value: string } + ): void + /** + * Move a header to the end or append a new one + * @param item - Header to append + */ + append(item: { key: string; value: string }): void + /** + * Merge headers from an array or object (case-insensitive) + * @param source - Array of headers or key-value object + * @param prune - If true, remove headers not in source + */ + assimilate( + source: Array<{ key: string; value: string }> | Record, + prune?: boolean + ): void + } + + // Body - With Postman update() method + // Uses HoppRESTReqBody for type safety with Postman's update() extension + body: HoppRESTReqBody & { + update( + body: + | string + | { + mode?: "raw" | "urlencoded" | "formdata" | "file" + raw?: string + urlencoded?: Array<{ key: string; value: string }> + formdata?: Array<{ key: string; value: string | File }> + file?: File + options?: { + raw?: { + language?: "json" | "text" | "html" | "xml" + } + } + } + ): void + } + + // Auth - Mutable with proper type safety + auth: HoppRESTAuth + } + + const info: Readonly<{ + readonly eventName: "pre-request" + readonly requestName: string + readonly requestId: string + readonly iteration: never + readonly iterationCount: never + }> + + /** + * Send an HTTP request (unsupported) + * @throws Error - sendRequest is not supported in Hoppscotch + */ + function sendRequest( + request: string | { url: string; method?: string; [key: string]: any }, + callback?: (err: any, response: any) => void + ): never + + /** + * Collection variables (unsupported - Workspace feature) + * Collection variables are not supported in Hoppscotch as they are a Postman Workspace feature + */ + const collectionVariables: Readonly<{ + get(key: string): never + set(key: string, value: string): never + unset(key: string): never + has(key: string): never + clear(): never + toObject(): never + replaceIn(template: string): never + }> + + /** + * Postman Vault (unsupported) + * Vault is not supported in Hoppscotch as it is a Postman-specific feature + */ + const vault: Readonly<{ + get(key: string): never + set(key: string, value: string): never + unset(key: string): never + }> + + /** + * Iteration data (unsupported - Collection Runner feature) + * Iteration data is not supported in Hoppscotch as it requires Collection Runner + */ + const iterationData: Readonly<{ + get(key: string): never + set(key: string, value: string): never + unset(key: string): never + has(key: string): never + toObject(): never + toJSON(): never + }> + + /** + * Visualizer API (unsupported) + * The Postman Visualizer allows you to present response data as HTML templates with styling. + * This feature is not supported in Hoppscotch as it requires a browser-based visualization UI. + * @see https://learning.postman.com/docs/sending-requests/response-data/visualizer/ + */ + const visualizer: Readonly<{ + /** + * Set a Handlebars template to visualize response data (unsupported) + * @param layout - HTML template string with Handlebars syntax + * @param data - Data object to pass to the template + * @param options - Optional configuration object + * @throws Error - Visualizer is not supported in Hoppscotch + */ + set( + layout: string, + data?: Record, + options?: Record + ): never + + /** + * Clear the current visualization (unsupported) + * @throws Error - Visualizer is not supported in Hoppscotch + */ + clear(): never + }> + + /** + * Execution control + */ + const execution: Readonly<{ + /** + * Execution location identifier + * Always returns ["Hoppscotch"] with current = "Hoppscotch" + */ + readonly location: readonly string[] & { + readonly current: string + } + /** + * Set next request to execute (unsupported - Collection Runner feature) + * @param requestNameOrId - Name or ID of the next request + */ + setNextRequest(requestNameOrId: string | null): never + /** + * Skip current request execution (unsupported - Collection Runner feature) + */ + skipRequest(): never + /** + * Run a request (unsupported - Collection Runner feature) + * @param requestNameOrId - Name or ID of the request to run + */ + runRequest(requestNameOrId: string): never + }> + + /** + * Import packages from Package Library (unsupported) + * @param packageName - Name of the package to import (e.g., '@team-domain/package-name' or 'npm:package-name@version') + * @returns The imported package module + * @throws Error - Package imports are not supported in Hoppscotch + */ + function require(packageName: string): never +} diff --git a/packages/hoppscotch-common/src/types/ts-utils.ts b/packages/hoppscotch-common/src/types/ts-utils.ts new file mode 100644 index 0000000..d19b2d9 --- /dev/null +++ b/packages/hoppscotch-common/src/types/ts-utils.ts @@ -0,0 +1,3 @@ +export type KeysMatching = { + [K in keyof T]-?: T[K] extends V ? K : never +}[keyof T] diff --git a/packages/hoppscotch-common/src/vite-envs.d.ts b/packages/hoppscotch-common/src/vite-envs.d.ts new file mode 100644 index 0000000..2c097b7 --- /dev/null +++ b/packages/hoppscotch-common/src/vite-envs.d.ts @@ -0,0 +1,34 @@ +/// + +// Environment Variables Intellisense +interface ImportMetaEnv { + readonly VITE_GA_ID: string + + readonly VITE_GTM_ID: string + + readonly VITE_API_KEY: string + readonly VITE_AUTH_DOMAIN: string + readonly VITE_DATABASE_URL: string + readonly VITE_PROJECT_ID: string + readonly VITE_STORAGE_BUCKET: string + readonly VITE_MESSAGING_SENDER_ID: string + readonly VITE_APP_ID: string + readonly VITE_MEASUREMENT_ID: string + + readonly VITE_BASE_URL: string + readonly VITE_SHORTCODE_BASE_URL: string + + readonly VITE_BACKEND_GQL_URL: string + readonly VITE_BACKEND_WS_URL: string + readonly VITE_BACKEND_API_URL: string + + readonly VITE_SENTRY_DSN?: string + readonly VITE_SENTRY_ENVIRONMENT?: string + readonly VITE_SENTRY_RELEASE_TAG?: string + + readonly VITE_PROXYSCOTCH_ACCESS_TOKEN?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/packages/hoppscotch-common/src/vitest.d.ts b/packages/hoppscotch-common/src/vitest.d.ts new file mode 100644 index 0000000..07d4836 --- /dev/null +++ b/packages/hoppscotch-common/src/vitest.d.ts @@ -0,0 +1,13 @@ +import type { Assertion, AsymmetricMatchersContaining } from "vitest" + +interface CustomMatchers { + toBeLeft(expected: unknown): R + toBeRight(): R + toEqualLeft(expected: unknown): R + toSubsetEqualRight(expected: unknown): R +} + +declare module 'vitest' { + interface Assertion extends CustomMatchers {} + interface AsymmetricMatchersContaining extends CustomMatchers {} +} diff --git a/packages/hoppscotch-common/src/workers/regex.js b/packages/hoppscotch-common/src/workers/regex.js new file mode 100644 index 0000000..4b3bdcc --- /dev/null +++ b/packages/hoppscotch-common/src/workers/regex.js @@ -0,0 +1,32 @@ +function generateREForProtocol(protocol) { + return [ + new RegExp( + `${protocol}(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]).){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(:[0-9]+)?(\\/[^?#]*)?(\\?[^#]*)?(#.*)?$` + ), + new RegExp( + `${protocol}(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]).)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9/])(:[0-9]+)?(\\/[^?#]*)?(\\?[^#]*)?(#.*)?$` + ), + ] +} + +const ws = generateREForProtocol("^(wss?:\\/\\/)?") +const sse = generateREForProtocol("^(https?:\\/\\/)?") +const socketio = generateREForProtocol("^((wss?:\\/\\/)|(https?:\\/\\/))?") +const regex = { ws, sse, socketio } + +// type = ws/sse/socketio +async function validator(type, url) { + console.time(`validator ${url}`) + const [res1, res2] = await Promise.all([ + regex[type][0].test(url), + regex[type][1].test(url), + ]) + console.timeEnd(`validator ${url}`) + return res1 || res2 +} + +onmessage = async ({ data }) => { + const { type, url } = data + const result = await validator(type, url) + postMessage({ type, url, result }) +} diff --git a/packages/hoppscotch-common/tsconfig.json b/packages/hoppscotch-common/tsconfig.json new file mode 100644 index 0000000..d8f3afc --- /dev/null +++ b/packages/hoppscotch-common/tsconfig.json @@ -0,0 +1,49 @@ +{ + "compilerOptions": { + "target": "ESNext", + "allowJs": true, + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "jsx": "preserve", + "sourceMap": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ESNext", "DOM"], + "skipLibCheck": true, + "noUnusedLocals": true, + "paths": { + "~/*": ["./src/*"], + "@composables/*": ["./src/composables/*"], + "@components/*": ["./src/components/*"], + "@helpers/*": ["./src/helpers/*"], + "@modules/*": ["./src/modules/*"], + "@workers/*": ["./src/workers/*"], + "@functional/*": ["./src/helpers/functional/*"] + }, + "types": [ + "vite/client", + "unplugin-icons/types/vue", + "unplugin-fonts/client", + "vite-plugin-pages/client", + "vite-plugin-vue-layouts/client", + "vite-plugin-pwa/client", + "./src/types/kernel.d.ts" + ] + }, + "include": [ + "meta.ts", + "src/**/*.js", + "src/*.ts", + "src/**/*.ts", + "src/**/*.d.ts", + "src/**/*.tsx", + "src/**/*.vue" + ], + "vueCompilerOptions": { + "jsxTemplates": true, + "experimentalRfc436": true + } +} diff --git a/packages/hoppscotch-common/type-check.mjs b/packages/hoppscotch-common/type-check.mjs new file mode 100644 index 0000000..a9f9035 --- /dev/null +++ b/packages/hoppscotch-common/type-check.mjs @@ -0,0 +1,95 @@ +import fs from "fs" +import { glob } from "glob" +import path from "path" +import ts from "typescript" +import vueTsc from "vue-tsc" + +import { fileURLToPath } from "url" + +/** + * Helper function to find files to perform type check on + */ +const findFilesToPerformTypeCheck = (directoryPaths, filePatterns) => { + const files = [] + + directoryPaths.forEach((directoryPath) => { + if (!fs.existsSync(directoryPath)) { + console.error(`Directory not found: ${directoryPath}`) + process.exit(1) + } + + files.push( + ...glob.sync(filePatterns, { + cwd: directoryPath, + ignore: ["**/__tests__/**", "**/*.d.ts"], + absolute: true, + }) + ) + }) + return files +} + +// Derive the current file's directory path `__dirname` from the URL of this module `__filename` +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Define the directory paths and file patterns to perform type checks on +const directoryPaths = [ + path.resolve(__dirname, "src", "services"), + path.resolve(__dirname, "src", "helpers", "auth"), +] +const filePatterns = ["**/*.ts"] + +const tsConfigFileName = path.resolve(__dirname, "tsconfig.json") +const tsConfig = ts.readConfigFile(tsConfigFileName, ts.sys.readFile) +const { options } = ts.parseJsonConfigFileContent( + tsConfig.config, + ts.sys, + __dirname +) + +const files = findFilesToPerformTypeCheck(directoryPaths, filePatterns) + +const host = ts.createCompilerHost(options) +const program = vueTsc.createProgram({ + rootNames: files, + options: { ...options, noEmit: true }, + host, +}) + +// Perform type checking +const diagnostics = ts + .getPreEmitDiagnostics(program) + // Filter diagnostics to include only errors from files in the specified directory + .filter(({ file }) => { + if (!file) { + return false + } + return directoryPaths.some((directoryPath) => + path.resolve(file.fileName).includes(directoryPath) + ) + }) + +if (!diagnostics.length) { + console.log("Type checking passed.") + + // Success + process.exit(0) +} + +console.log("TypeScript diagnostics:") + +const formatHost = { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: host.getCurrentDirectory, + getNewLine: () => ts.sys.newLine, +} + +const formattedDiagnostics = ts.formatDiagnosticsWithColorAndContext( + diagnostics, + formatHost +) +console.error(formattedDiagnostics) + +// Failure +process.exit(1) diff --git a/packages/hoppscotch-common/vitest.config.mts b/packages/hoppscotch-common/vitest.config.mts new file mode 100644 index 0000000..e9bf56b --- /dev/null +++ b/packages/hoppscotch-common/vitest.config.mts @@ -0,0 +1,45 @@ +import { defineConfig } from "vitest/config" +import * as path from "path" +import Icons from "unplugin-icons/vite" +import { FileSystemIconLoader } from "unplugin-icons/loaders" +import Vue from "@vitejs/plugin-vue" + +export default defineConfig({ + test: { + environment: "jsdom", + setupFiles: "./src/setupTests.ts", + }, + resolve: { + alias: { + "~": path.resolve(__dirname, "../hoppscotch-common/src"), + "@composables": path.resolve( + __dirname, + "../hoppscotch-common/src/composables" + ), + "@components": path.resolve( + __dirname, + "../hoppscotch-common/src/components" + ), + "@helpers": path.resolve(__dirname, "../hoppscotch-common/src/helpers"), + "@modules": path.resolve(__dirname, "../hoppscotch-common/src/modules"), + "@workers": path.resolve(__dirname, "../hoppscotch-common/src/workers"), + "@functional": path.resolve( + __dirname, + "../hoppscotch-common/src/helpers/functional" + ), + }, + }, + plugins: [ + Vue(), + Icons({ + compiler: "vue3", + customCollections: { + hopp: FileSystemIconLoader("../hoppscotch-common/assets/icons"), + auth: FileSystemIconLoader("../hoppscotch-common/assets/icons/auth"), + brands: FileSystemIconLoader( + "../hoppscotch-common/assets/icons/brands" + ), + }, + }) as any, + ], +}) diff --git a/packages/hoppscotch-data/package.json b/packages/hoppscotch-data/package.json new file mode 100644 index 0000000..81425cd --- /dev/null +++ b/packages/hoppscotch-data/package.json @@ -0,0 +1,52 @@ +{ + "name": "@hoppscotch/data", + "version": "0.4.4", + "description": "Data Types, Validations and Migrations for Hoppscotch Public Data Structures", + "type": "module", + "main": "dist/hoppscotch-data.cjs", + "module": "dist/hoppscotch-data.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/*" + ], + "scripts": { + "dev": "vite build --watch", + "build:code": "vite build", + "build:decl": "tsc --project tsconfig.decl.json", + "build": "pnpm run build:code && pnpm run build:decl", + "prepare": "pnpm run build:code && pnpm run build:decl", + "do-typecheck": "pnpm exec tsc --noEmit" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/hoppscotch-data.js", + "require": "./dist/hoppscotch-data.cjs" + } + }, + "repository": { + "type": "git", + "url": "git+https://github.com/hoppscotch/hoppscotch.git" + }, + "author": "Hoppscotch (support@hoppscotch.io)", + "license": "MIT", + "bugs": { + "url": "https://github.com/hoppscotch/hoppscotch/issues" + }, + "homepage": "https://github.com/hoppscotch/hoppscotch#readme", + "devDependencies": { + "@types/lodash": "4.17.24", + "typescript": "5.9.3", + "vite": "7.3.2" + }, + "dependencies": { + "fp-ts": "2.16.11", + "io-ts": "2.2.22", + "jose": "6.2.3", + "lodash": "4.18.1", + "parser-ts": "0.7.0", + "uuid": "13.0.0", + "verzod": "0.4.0", + "zod": "3.25.32" + } +} diff --git a/packages/hoppscotch-data/src/collection/index.ts b/packages/hoppscotch-data/src/collection/index.ts new file mode 100644 index 0000000..9ec5013 --- /dev/null +++ b/packages/hoppscotch-data/src/collection/index.ts @@ -0,0 +1,154 @@ +import { InferredEntity, createVersionedEntity } from "verzod" + +import V1_VERSION from "./v/1" +import V2_VERSION from "./v/2" +import V3_VERSION from "./v/3" +import V4_VERSION from "./v/4" +import V5_VERSION from "./v/5" +import V6_VERSION from "./v/6" +import V7_VERSION from "./v/7" +import V8_VERSION from "./v/8" +import V9_VERSION from "./v/9" +import V10_VERSION from "./v/10" +import V11_VERSION from "./v/11" +import V12_VERSION from "./v/12" + +export { CollectionVariable } from "./v/10" + +import { z } from "zod" +import { translateToNewRequest } from "../rest" +import { translateToGQLRequest } from "../graphql" +import { generateUniqueRefId } from "../utils/collection" + +const versionedObject = z.object({ + v: z.number(), +}) + +export const HoppCollection = createVersionedEntity({ + latestVersion: 12, + versionMap: { + 1: V1_VERSION, + 2: V2_VERSION, + 3: V3_VERSION, + 4: V4_VERSION, + 5: V5_VERSION, + 6: V6_VERSION, + 7: V7_VERSION, + 8: V8_VERSION, + 9: V9_VERSION, + 10: V10_VERSION, + 11: V11_VERSION, + 12: V12_VERSION, + }, + getVersion(data) { + const versionCheck = versionedObject.safeParse(data) + + if (versionCheck.success) return versionCheck.data.v + + // For V1 we have to check the schema + const result = V1_VERSION.schema.safeParse(data) + + return result.success ? 1 : null + }, +}) + +export type HoppCollection = InferredEntity + +export type HoppCollectionVariable = InferredEntity< + typeof HoppCollection +>["variables"][number] + +export const CollectionSchemaVersion = 12 + +/** + * Generates a Collection object. This ignores the version number object + * @param x The Collection Data + * @returns The final collection + */ +export function makeCollection(x: Omit): HoppCollection { + return { + v: CollectionSchemaVersion, + ...x, + _ref_id: x._ref_id ? x._ref_id : generateUniqueRefId("coll"), + } +} + +/** + * Translates an old collection to a new collection + * @param x The collection object to load + * @returns The proper new collection format + */ +export function translateToNewRESTCollection(x: any): HoppCollection { + // Legacy + const name = x.name ?? "Untitled" + const folders = (x.folders ?? []).map(translateToNewRESTCollection) + const requests = (x.requests ?? []).map(translateToNewRequest) + + const auth = x.auth ?? { authType: "inherit", authActive: true } + const headers = x.headers ?? [] + const variables = x.variables ?? [] + + const description = x.description ?? null + + const preRequestScript = x.preRequestScript ?? "" + const testScript = x.testScript ?? "" + + const obj = makeCollection({ + name, + folders, + requests, + auth, + headers, + variables, + description, + preRequestScript, + testScript, + }) + + if (x.id) obj.id = x.id + if (x._ref_id) { + obj._ref_id = x._ref_id + } + + return obj +} + +/** + * Translates an old collection to a new collection + * @param x The collection object to load + * @returns The proper new collection format + */ +export function translateToNewGQLCollection(x: any): HoppCollection { + // Legacy + const name = x.name ?? "Untitled" + const folders = (x.folders ?? []).map(translateToNewGQLCollection) + const requests = (x.requests ?? []).map(translateToGQLRequest) + + const auth = x.auth ?? { authType: "inherit", authActive: true } + const headers = x.headers ?? [] + const variables = x.variables ?? [] + + const description = x.description ?? null + + const preRequestScript = x.preRequestScript ?? "" + const testScript = x.testScript ?? "" + + const obj = makeCollection({ + name, + folders, + requests, + auth, + headers, + variables, + description, + preRequestScript, + testScript, + }) + + if (x.id) obj.id = x.id + if (x._ref_id) { + obj._ref_id = x._ref_id + } + + return obj +} diff --git a/packages/hoppscotch-data/src/collection/v/1.ts b/packages/hoppscotch-data/src/collection/v/1.ts new file mode 100644 index 0000000..f2a923b --- /dev/null +++ b/packages/hoppscotch-data/src/collection/v/1.ts @@ -0,0 +1,37 @@ +import { defineVersion, entityReference, entityRefUptoVersion } from "verzod" +import { z } from "zod" +import { HoppRESTRequest } from "../../rest" +import { HoppGQLRequest } from "../../graphql" +import { HoppCollection } from ".." + +const baseCollectionSchema = z.object({ + v: z.literal(1), + id: z.optional(z.string()), // For Firestore ID data + + name: z.string(), + requests: z.array( + z.lazy(() => + z.union([ + entityReference(HoppRESTRequest), + entityReference(HoppGQLRequest), + ]) + ) + ), +}) + +type Input = z.input & { + folders: Input[] +} + +type Output = z.output & { + folders: Output[] +} + +export const V1_SCHEMA = baseCollectionSchema.extend({ + folders: z.lazy(() => z.array(entityRefUptoVersion(HoppCollection, 1))), +}) as z.ZodType + +export default defineVersion({ + initial: true, + schema: V1_SCHEMA, +}) diff --git a/packages/hoppscotch-data/src/collection/v/10.ts b/packages/hoppscotch-data/src/collection/v/10.ts new file mode 100644 index 0000000..21759c5 --- /dev/null +++ b/packages/hoppscotch-data/src/collection/v/10.ts @@ -0,0 +1,54 @@ +import { defineVersion, entityRefUptoVersion } from "verzod" +import { z } from "zod" + +import { HoppCollection } from ".." +import { v9_baseCollectionSchema, V9_SCHEMA } from "./9" + +export const CollectionVariable = z.object({ + key: z.string(), + initialValue: z.string(), + currentValue: z.string(), + secret: z.boolean(), +}) + +export type CollectionVariable = z.infer + +export const v10_baseCollectionSchema = v9_baseCollectionSchema.extend({ + v: z.literal(10), + variables: z.array(CollectionVariable), +}) + +type Input = z.input & { + folders: Input[] +} + +type Output = z.output & { + folders: Output[] +} + +export const V10_SCHEMA = v10_baseCollectionSchema.extend({ + folders: z.lazy(() => z.array(entityRefUptoVersion(HoppCollection, 10))), +}) as z.ZodType + +export default defineVersion({ + initial: false, + schema: V10_SCHEMA, + up(old: z.infer) { + const result: z.infer = { + ...old, + v: 10 as const, + variables: [], + folders: old.folders.map((folder) => { + const result = HoppCollection.safeParseUpToVersion(folder, 10) + + if (result.type !== "ok") { + throw new Error("Failed to migrate child collections") + } + + return result.value + }), + } + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/collection/v/11.ts b/packages/hoppscotch-data/src/collection/v/11.ts new file mode 100644 index 0000000..dcc77a7 --- /dev/null +++ b/packages/hoppscotch-data/src/collection/v/11.ts @@ -0,0 +1,45 @@ +import { defineVersion, entityRefUptoVersion } from "verzod" +import { z } from "zod" + +import { HoppCollection } from ".." +import { v10_baseCollectionSchema, V10_SCHEMA } from "./10" + +export const v11_baseCollectionSchema = v10_baseCollectionSchema.extend({ + v: z.literal(11), + description: z.string().nullable().catch(null), +}) + +type Input = z.input & { + folders: Input[] +} + +type Output = z.output & { + folders: Output[] +} + +export const V11_SCHEMA = v11_baseCollectionSchema.extend({ + folders: z.lazy(() => z.array(entityRefUptoVersion(HoppCollection, 11))), +}) as z.ZodType + +export default defineVersion({ + initial: false, + schema: V11_SCHEMA, + up(old: z.infer) { + const result: z.infer = { + ...old, + v: 11 as const, + description: null, + folders: old.folders.map((folder) => { + const result = HoppCollection.safeParseUpToVersion(folder, 11) + + if (result.type !== "ok") { + throw new Error("Failed to migrate child collections") + } + + return result.value + }), + } + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/collection/v/12.ts b/packages/hoppscotch-data/src/collection/v/12.ts new file mode 100644 index 0000000..985d77a --- /dev/null +++ b/packages/hoppscotch-data/src/collection/v/12.ts @@ -0,0 +1,47 @@ +import { defineVersion, entityRefUptoVersion } from "verzod" +import { z } from "zod" + +import { HoppCollection } from ".." +import { v11_baseCollectionSchema, V11_SCHEMA } from "./11" + +export const v12_baseCollectionSchema = v11_baseCollectionSchema.extend({ + v: z.literal(12), + preRequestScript: z.string().catch(""), + testScript: z.string().catch(""), +}) + +type Input = z.input & { + folders: Input[] +} + +type Output = z.output & { + folders: Output[] +} + +export const V12_SCHEMA = v12_baseCollectionSchema.extend({ + folders: z.lazy(() => z.array(entityRefUptoVersion(HoppCollection, 12))), +}) as z.ZodType + +export default defineVersion({ + initial: false, + schema: V12_SCHEMA, + up(old: z.infer) { + const result: z.infer = { + ...old, + v: 12 as const, + preRequestScript: "", + testScript: "", + folders: old.folders.map((folder) => { + const result = HoppCollection.safeParseUpToVersion(folder, 12) + + if (result.type !== "ok") { + throw new Error("Failed to migrate child collections") + } + + return result.value + }), + } + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/collection/v/2.ts b/packages/hoppscotch-data/src/collection/v/2.ts new file mode 100644 index 0000000..cef5a72 --- /dev/null +++ b/packages/hoppscotch-data/src/collection/v/2.ts @@ -0,0 +1,70 @@ +import { defineVersion, entityReference, entityRefUptoVersion } from "verzod" +import { z } from "zod" + +import { HoppGQLRequest } from "../../graphql" +import { GQLHeader } from "../../graphql/v/1" +import { HoppGQLAuth } from "../../graphql/v/5" +import { HoppRESTRequest } from "../../rest" +import { HoppRESTHeaders } from "../../rest/v/1" +import { HoppRESTAuth } from "../../rest/v/5" +import { V1_SCHEMA } from "./1" +import { HoppCollection } from ".." + +export const v2_baseCollectionSchema = z.object({ + v: z.literal(2), + id: z.optional(z.string()), // For Firestore ID data + + name: z.string(), + requests: z.array( + z.lazy(() => + z.union([ + entityReference(HoppRESTRequest), + entityReference(HoppGQLRequest), + ]) + ) + ), + + auth: z.union([HoppRESTAuth, HoppGQLAuth]), + headers: z.union([HoppRESTHeaders, z.array(GQLHeader)]), +}) + +type Input = z.input & { + folders: Input[] +} + +type Output = z.output & { + folders: Output[] +} + +export const V2_SCHEMA = v2_baseCollectionSchema.extend({ + folders: z.lazy(() => z.array(entityRefUptoVersion(HoppCollection, 2))), +}) as z.ZodType + +export default defineVersion({ + initial: false, + schema: V2_SCHEMA, + up(old: z.infer) { + const result: z.infer = { + ...old, + v: 2, + auth: { + authActive: true, + authType: "inherit", + }, + headers: [], + folders: old.folders.map((folder) => { + const result = HoppCollection.safeParseUpToVersion(folder, 2) + + if (result.type !== "ok") { + throw new Error("Failed to migrate child collections") + } + + return result.value + }), + } + + if (old.id) result.id = old.id + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/collection/v/3.ts b/packages/hoppscotch-data/src/collection/v/3.ts new file mode 100644 index 0000000..15e0c70 --- /dev/null +++ b/packages/hoppscotch-data/src/collection/v/3.ts @@ -0,0 +1,66 @@ +import { defineVersion, entityRefUptoVersion } from "verzod" +import { z } from "zod" + +import { GQLHeader as V1_GQLHeader } from "../../graphql/v/1" +import { HoppRESTHeaders as V1_HoppRESTHeaders } from "../../rest/v/1" + +import { HoppGQLAuth, GQLHeader as V2_GQLHeader } from "../../graphql/v/6" +import { + HoppRESTAuth, + HoppRESTHeaders as V2_HoppRESTHeaders, +} from "../../rest/v/7" + +import { v2_baseCollectionSchema, V2_SCHEMA } from "./2" +import { HoppCollection } from ".." + +export const v3_baseCollectionSchema = v2_baseCollectionSchema.extend({ + v: z.literal(3), + + // AWS Signature Authorization type addition + auth: z.union([HoppRESTAuth, HoppGQLAuth]), + + // `description` field addition under `headers` + headers: z.union([V2_HoppRESTHeaders, z.array(V2_GQLHeader)]), +}) + +type Input = z.input & { + folders: Input[] +} + +type Output = z.output & { + folders: Output[] +} + +export const V3_SCHEMA = v3_baseCollectionSchema.extend({ + folders: z.lazy(() => z.array(entityRefUptoVersion(HoppCollection, 3))), +}) as z.ZodType + +export default defineVersion({ + initial: false, + schema: V3_SCHEMA, + up(old: z.infer) { + const headers = (old.headers as V1_HoppRESTHeaders | V1_GQLHeader[]).map( + (header) => ({ + ...header, + description: "", + }) + ) + + const result: z.infer = { + ...old, + v: 3, + headers, + folders: old.folders.map((folder) => { + const result = HoppCollection.safeParseUpToVersion(folder, 3) + + if (result.type !== "ok") { + throw new Error("Failed to migrate child collections") + } + + return result.value + }), + } + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/collection/v/4.ts b/packages/hoppscotch-data/src/collection/v/4.ts new file mode 100644 index 0000000..7fa2bb0 --- /dev/null +++ b/packages/hoppscotch-data/src/collection/v/4.ts @@ -0,0 +1,47 @@ +import { defineVersion, entityRefUptoVersion } from "verzod" +import { z } from "zod" + +import { HoppGQLAuth } from "../../graphql/v/7" +import { HoppRESTAuth } from "../../rest/v/8/auth" + +import { V3_SCHEMA, v3_baseCollectionSchema } from "./3" +import { HoppCollection } from ".." + +export const v4_baseCollectionSchema = v3_baseCollectionSchema.extend({ + v: z.literal(4), + auth: z.union([HoppRESTAuth, HoppGQLAuth]), +}) + +type Input = z.input & { + folders: Input[] +} + +type Output = z.output & { + folders: Output[] +} + +export const V4_SCHEMA = v4_baseCollectionSchema.extend({ + folders: z.lazy(() => z.array(entityRefUptoVersion(HoppCollection, 4))), +}) as z.ZodType + +export default defineVersion({ + initial: false, + schema: V4_SCHEMA, + up(old: z.infer) { + const result: z.infer = { + ...old, + v: 4 as const, + folders: old.folders.map((folder) => { + const result = HoppCollection.safeParseUpToVersion(folder, 4) + + if (result.type !== "ok") { + throw new Error("Failed to migrate child collections") + } + + return result.value + }), + } + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/collection/v/5.ts b/packages/hoppscotch-data/src/collection/v/5.ts new file mode 100644 index 0000000..8f37263 --- /dev/null +++ b/packages/hoppscotch-data/src/collection/v/5.ts @@ -0,0 +1,46 @@ +import { defineVersion, entityRefUptoVersion } from "verzod" +import { z } from "zod" + +import { V4_SCHEMA, v4_baseCollectionSchema } from "./4" +import { generateUniqueRefId } from "../../utils/collection" +import { HoppCollection } from ".." + +export const v5_baseCollectionSchema = v4_baseCollectionSchema.extend({ + v: z.literal(5), + _ref_id: z.string().optional(), +}) + +type Input = z.input & { + folders: Input[] +} + +type Output = z.output & { + folders: Output[] +} + +export const V5_SCHEMA = v5_baseCollectionSchema.extend({ + folders: z.lazy(() => z.array(entityRefUptoVersion(HoppCollection, 5))), +}) as z.ZodType + +export default defineVersion({ + initial: false, + schema: V5_SCHEMA, + up(old: z.infer) { + const result: z.infer = { + ...old, + v: 5 as const, + _ref_id: generateUniqueRefId("coll"), + folders: old.folders.map((folder) => { + const result = HoppCollection.safeParseUpToVersion(folder, 5) + + if (result.type !== "ok") { + throw new Error("Failed to migrate child collections") + } + + return result.value + }), + } + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/collection/v/6.ts b/packages/hoppscotch-data/src/collection/v/6.ts new file mode 100644 index 0000000..437091d --- /dev/null +++ b/packages/hoppscotch-data/src/collection/v/6.ts @@ -0,0 +1,64 @@ +import { defineVersion, entityRefUptoVersion } from "verzod" +import { z } from "zod" + +import { HoppGQLAuth } from "../../graphql/v/8" +import { HoppRESTAuth } from "../../rest/v/11/auth" + +import { V5_SCHEMA, v5_baseCollectionSchema } from "./5" +import { HoppCollection } from ".." + +export const v6_baseCollectionSchema = v5_baseCollectionSchema.extend({ + v: z.literal(6), + auth: z.union([HoppRESTAuth, HoppGQLAuth]), +}) + +type Input = z.input & { + folders: Input[] +} + +type Output = z.output & { + folders: Output[] +} + +export const V6_SCHEMA = v6_baseCollectionSchema.extend({ + folders: z.lazy(() => z.array(entityRefUptoVersion(HoppCollection, 6))), +}) as z.ZodType + +export default defineVersion({ + initial: false, + schema: V6_SCHEMA, + up(old: z.infer) { + const auth = old.auth + + const migratedAuth: HoppRESTAuth = + auth.authType === "oauth-2" + ? { + ...auth, + grantTypeInfo: + auth.grantTypeInfo.grantType === "CLIENT_CREDENTIALS" + ? { + ...auth.grantTypeInfo, + clientAuthentication: "IN_BODY", + } + : auth.grantTypeInfo, + } + : auth + + const result: z.infer = { + ...old, + auth: migratedAuth, + v: 6 as const, + folders: old.folders.map((folder) => { + const result = HoppCollection.safeParseUpToVersion(folder, 6) + + if (result.type !== "ok") { + throw new Error("Failed to migrate child collections") + } + + return result.value + }), + } + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/collection/v/7.ts b/packages/hoppscotch-data/src/collection/v/7.ts new file mode 100644 index 0000000..8b06982 --- /dev/null +++ b/packages/hoppscotch-data/src/collection/v/7.ts @@ -0,0 +1,47 @@ +import { defineVersion, entityRefUptoVersion } from "verzod" +import { z } from "zod" + +import { HoppGQLAuth } from "../../graphql/v/8" +import { HoppRESTAuth } from "../../rest/v/12/auth" + +import { V6_SCHEMA, v6_baseCollectionSchema } from "./6" +import { HoppCollection } from ".." + +export const v7_baseCollectionSchema = v6_baseCollectionSchema.extend({ + v: z.literal(7), + auth: z.union([HoppRESTAuth, HoppGQLAuth]), +}) + +type Input = z.input & { + folders: Input[] +} + +type Output = z.output & { + folders: Output[] +} + +export const V7_SCHEMA = v7_baseCollectionSchema.extend({ + folders: z.lazy(() => z.array(entityRefUptoVersion(HoppCollection, 7))), +}) as z.ZodType + +export default defineVersion({ + initial: false, + schema: V7_SCHEMA, + up(old: z.infer) { + const result: z.infer = { + ...old, + v: 7 as const, + folders: old.folders.map((folder) => { + const result = HoppCollection.safeParseUpToVersion(folder, 7) + + if (result.type !== "ok") { + throw new Error("Failed to migrate child collections") + } + + return result.value + }), + } + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/collection/v/8.ts b/packages/hoppscotch-data/src/collection/v/8.ts new file mode 100644 index 0000000..1426a62 --- /dev/null +++ b/packages/hoppscotch-data/src/collection/v/8.ts @@ -0,0 +1,47 @@ +import { defineVersion, entityRefUptoVersion } from "verzod" +import { z } from "zod" + +import { HoppGQLAuth } from "../../graphql/v/8" +import { HoppRESTAuth } from "../../rest/v/13/auth" + +import { HoppCollection } from ".." +import { v7_baseCollectionSchema, V7_SCHEMA } from "./7" + +export const v8_baseCollectionSchema = v7_baseCollectionSchema.extend({ + v: z.literal(8), + auth: z.union([HoppRESTAuth, HoppGQLAuth]), +}) + +type Input = z.input & { + folders: Input[] +} + +type Output = z.output & { + folders: Output[] +} + +export const V8_SCHEMA = v8_baseCollectionSchema.extend({ + folders: z.lazy(() => z.array(entityRefUptoVersion(HoppCollection, 8))), +}) as z.ZodType + +export default defineVersion({ + initial: false, + schema: V8_SCHEMA, + up(old: z.infer) { + const result: z.infer = { + ...old, + v: 8 as const, + folders: old.folders.map((folder) => { + const result = HoppCollection.safeParseUpToVersion(folder, 8) + + if (result.type !== "ok") { + throw new Error("Failed to migrate child collections") + } + + return result.value + }), + } + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/collection/v/9.ts b/packages/hoppscotch-data/src/collection/v/9.ts new file mode 100644 index 0000000..7fea6af --- /dev/null +++ b/packages/hoppscotch-data/src/collection/v/9.ts @@ -0,0 +1,92 @@ +import { defineVersion, entityRefUptoVersion } from "verzod" +import { z } from "zod" + +import { HoppGQLAuth } from "../../graphql/v/9" +import { HoppRESTAuth } from "../../rest/v/15/auth" + +import { HoppCollection } from ".." +import { v8_baseCollectionSchema, V8_SCHEMA } from "./8" + +export const v9_baseCollectionSchema = v8_baseCollectionSchema.extend({ + v: z.literal(9), + auth: z.union([HoppRESTAuth, HoppGQLAuth]), +}) + +type Input = z.input & { + folders: Input[] +} + +type Output = z.output & { + folders: Output[] +} + +export const V9_SCHEMA = v9_baseCollectionSchema.extend({ + folders: z.lazy(() => z.array(entityRefUptoVersion(HoppCollection, 9))), +}) as z.ZodType + +export default defineVersion({ + initial: false, + schema: V9_SCHEMA, + up(old: z.infer) { + // Migrate auth field if it's OAuth2 to include new advanced parameters + let newAuth: z.infer["auth"] + if (old.auth.authType === "oauth-2") { + const oldGrantTypeInfo = old.auth.grantTypeInfo + let newGrantTypeInfo + + // Add the advanced parameters to the appropriate grant type + if (oldGrantTypeInfo.grantType === "AUTHORIZATION_CODE") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + authRequestParams: [], + tokenRequestParams: [], + refreshRequestParams: [], + } + } else if (oldGrantTypeInfo.grantType === "CLIENT_CREDENTIALS") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + tokenRequestParams: [], + refreshRequestParams: [], + } + } else if (oldGrantTypeInfo.grantType === "PASSWORD") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + tokenRequestParams: [], + refreshRequestParams: [], + } + } else if (oldGrantTypeInfo.grantType === "IMPLICIT") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + authRequestParams: [], + refreshRequestParams: [], + } + } else { + newGrantTypeInfo = oldGrantTypeInfo + } + + newAuth = { + ...old.auth, + grantTypeInfo: newGrantTypeInfo, + } as z.infer["auth"] + } else { + newAuth = old.auth + } + + const result: z.infer = { + ...old, + v: 9 as const, + auth: newAuth, + folders: old.folders.map((folder) => { + const result = HoppCollection.safeParseUpToVersion(folder, 9) + + if (result.type !== "ok") { + throw new Error("Failed to migrate child collections") + } + + return result.value + }), + } + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/cookies.ts b/packages/hoppscotch-data/src/cookies.ts new file mode 100644 index 0000000..d6a2cb7 --- /dev/null +++ b/packages/hoppscotch-data/src/cookies.ts @@ -0,0 +1,16 @@ +import { field } from "fp-ts" +import { z } from "zod" + +export const CookieSchema = z.object({ + name: z.string(), // Cookie name + value: z.string(), // Cookie value + domain: z.string(), // Domain the cookie belongs to + path: z.string(), // Path scope of the cookie (default: "/") + expires: z.string().optional(), // Expiration date in ISO format, null for session cookies + maxAge: z.number().optional(), // Maximum age in seconds, null if not set + httpOnly: z.boolean(), // Whether cookie is HTTP-only (not accessible via JavaScript) + secure: z.boolean(), // Whether cookie should only be sent over HTTPS + sameSite: z.enum(["None", "Lax", "Strict"]), // SameSite attribute for CSRF protection +}) + +export type Cookie = z.infer diff --git a/packages/hoppscotch-data/src/environment/index.ts b/packages/hoppscotch-data/src/environment/index.ts new file mode 100644 index 0000000..a00e4cf --- /dev/null +++ b/packages/hoppscotch-data/src/environment/index.ts @@ -0,0 +1,235 @@ +import * as E from "fp-ts/Either" +import { pipe } from "fp-ts/function" +import { InferredEntity, createVersionedEntity } from "verzod" + +import { z } from "zod" + +import V0_VERSION from "./v/0" +import V1_VERSION, { uniqueID } from "./v/1" +import V2_VERSION from "./v/2" +import { HOPP_SUPPORTED_PREDEFINED_VARIABLES } from "../predefinedVariables" + +const versionedObject = z.object({ + v: z.number(), +}) + +export const Environment = createVersionedEntity({ + latestVersion: 2, + versionMap: { + 0: V0_VERSION, + 1: V1_VERSION, + 2: V2_VERSION, + }, + getVersion(data) { + const versionCheck = versionedObject.safeParse(data) + + if (versionCheck.success) return versionCheck.data.v + + // For V0 we have to check the schema + const result = V0_VERSION.schema.safeParse(data) + return result.success ? 0 : null + }, +}) + +export type Environment = InferredEntity + +export type EnvironmentVariable = InferredEntity< + typeof Environment +>["variables"][number] + +const REGEX_ENV_VAR = /<<([^>]*)>>/g // "<>" + +/** + * How much times can we expand environment variables + */ +const ENV_MAX_EXPAND_LIMIT = 10 + +/** + * Error state when there is a suspected loop while + * recursively expanding variables + */ +const ENV_EXPAND_LOOP = "ENV_EXPAND_LOOP" as const + +export const EnvironmentSchemaVersion = 2 + +export function parseBodyEnvVariablesE( + body: string, + env: Environment["variables"] +) { + let result = body + let depth = 0 + + while (result.match(REGEX_ENV_VAR) != null && depth <= ENV_MAX_EXPAND_LIMIT) { + result = result.replace(REGEX_ENV_VAR, (key) => { + const variableName = key.replace(/[<>]/g, "") + + // Prioritise predefined variable values over normal environment variables processing. + const foundPredefinedVar = HOPP_SUPPORTED_PREDEFINED_VARIABLES.find( + (preVar) => preVar.key === variableName + ) + + if (foundPredefinedVar) { + return foundPredefinedVar.getValue() + } + + const foundEnv = env.find((envVar) => envVar.key === variableName) + + if (foundEnv && "currentValue" in foundEnv) { + return foundEnv.currentValue + } + return key + }) + + depth++ + } + + return depth > ENV_MAX_EXPAND_LIMIT + ? E.left(ENV_EXPAND_LOOP) + : E.right(result) +} + +/** + * @deprecated Use `parseBodyEnvVariablesE` instead. + */ +export const parseBodyEnvVariables = ( + body: string, + env: Environment["variables"] +) => + pipe( + parseBodyEnvVariablesE(body, env), + E.getOrElse(() => body) + ) + +export function parseTemplateStringE( + str: string, + variables: Environment["variables"], + maskValue = false, + showKeyIfSecret = false, + showKeyIfNotFound = false +) { + if (!variables || !str) { + return E.right(str) + } + + let result = str + let depth = 0 + let isSecret = false + + while ( + result.match(REGEX_ENV_VAR) != null && + depth <= ENV_MAX_EXPAND_LIMIT && + !isSecret + ) { + const currentResult = decodeURI(encodeURI(result)).replace( + REGEX_ENV_VAR, + (_, p1) => { + // Prioritise predefined variable values over normal environment variables processing. + const foundPredefinedVar = HOPP_SUPPORTED_PREDEFINED_VARIABLES.find( + (preVar) => preVar.key === p1 + ) + + if (foundPredefinedVar) { + return foundPredefinedVar.getValue() + } + + const variable = variables.find((x) => x && x.key === p1) + + if (variable && "currentValue" in variable) { + // Show the key if it is a secret and explicitly specified + if (variable.secret && showKeyIfSecret) { + isSecret = true + return `<<${p1}>>` + } + // Mask the value if it is a secret and explicitly specified + if (variable.secret && maskValue) { + return "*".repeat( + ( + variable as { + secret: true + initialValue: string + currentValue: string + key: string + } + ).currentValue.length + ) + } + return variable.currentValue + } + + if (showKeyIfNotFound) { + return `<<${p1}>>` + } + + return "" + } + ) + + if (currentResult === result) { + break + } + + result = currentResult + depth++ + } + + return depth > ENV_MAX_EXPAND_LIMIT + ? E.left(ENV_EXPAND_LOOP) + : E.right(result) +} + +export type NonSecretEnvironmentVariable = Extract< + EnvironmentVariable, + { secret: false } +> + +export type NonSecretEnvironment = Omit & { + variables: NonSecretEnvironmentVariable[] +} + +/** + * @deprecated Use `parseTemplateStringE` instead + */ +export const parseTemplateString = ( + str: string, + variables: Environment["variables"], + maskValue = false, + showKeyIfSecret = false, + showKeyIfNotFound = false +) => + pipe( + parseTemplateStringE( + str, + variables, + maskValue, + showKeyIfSecret, + showKeyIfNotFound + ), + E.getOrElse(() => str) + ) + +export const translateToNewEnvironmentVariables = ( + x: any +): Environment["variables"][number] => { + return { + key: x.key, + initialValue: x.initialValue ?? x.value ?? "", + currentValue: x.currentValue ?? x.value ?? "", + secret: x.secret ?? false, + } +} + +export const translateToNewEnvironment = (x: any): Environment => { + if (x.v && x.v === EnvironmentSchemaVersion) return x + + // Legacy + const id = x.id || uniqueID() + const name = x.name ?? "Untitled" + const variables = (x.variables ?? []).map(translateToNewEnvironmentVariables) + + return { + v: EnvironmentSchemaVersion, + id, + name, + variables, + } +} diff --git a/packages/hoppscotch-data/src/environment/v/0.ts b/packages/hoppscotch-data/src/environment/v/0.ts new file mode 100644 index 0000000..74b1898 --- /dev/null +++ b/packages/hoppscotch-data/src/environment/v/0.ts @@ -0,0 +1,18 @@ +import { z } from "zod" +import { defineVersion } from "verzod" + +export const V0_SCHEMA = z.object({ + id: z.optional(z.string()), + name: z.string(), + variables: z.array( + z.object({ + key: z.string(), + value: z.string(), + }) + ), +}) + +export default defineVersion({ + initial: true, + schema: V0_SCHEMA, +}) diff --git a/packages/hoppscotch-data/src/environment/v/1.ts b/packages/hoppscotch-data/src/environment/v/1.ts new file mode 100644 index 0000000..4a428ac --- /dev/null +++ b/packages/hoppscotch-data/src/environment/v/1.ts @@ -0,0 +1,44 @@ +import { z } from "zod" +import { defineVersion } from "verzod" +import { V0_SCHEMA } from "./0" + +export const uniqueID = () => Math.random().toString(36).substring(2, 16) + +export const V1_SCHEMA = z.object({ + v: z.literal(1), + id: z.string(), + name: z.string(), + variables: z.array( + z.union([ + z.object({ + key: z.string(), + secret: z.literal(true), + }), + z.object({ + key: z.string(), + value: z.string(), + secret: z.literal(false).catch(false), + }), + ]) + ), +}) + +export default defineVersion({ + initial: false, + schema: V1_SCHEMA, + up(old: z.infer) { + const result: z.infer = { + ...old, + v: 1, + id: old.id || uniqueID(), + variables: old.variables.map((variable) => { + return { + ...variable, + secret: false, + } + }), + } + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/environment/v/2.ts b/packages/hoppscotch-data/src/environment/v/2.ts new file mode 100644 index 0000000..b9b2599 --- /dev/null +++ b/packages/hoppscotch-data/src/environment/v/2.ts @@ -0,0 +1,41 @@ +import { z } from "zod" +import { defineVersion } from "verzod" +import { V1_SCHEMA } from "./1" + +export const V2_SCHEMA = V1_SCHEMA.extend({ + v: z.literal(2), + variables: z.array( + z.object({ + key: z.string(), + initialValue: z.string(), + currentValue: z.string(), + secret: z.boolean(), + }) + ), +}) + +export default defineVersion({ + initial: false, + schema: V2_SCHEMA, + up(old: z.infer) { + const result: z.infer = { + ...old, + v: 2, + variables: old.variables.map((variable) => { + const { key, secret } = variable + + // if the variable is secret, set initialValue and currentValue to empty string + // else set initialValue and currentValue to value + // and delete value + return { + key, + secret, + initialValue: secret ? "" : variable.value, + currentValue: secret ? "" : variable.value, + } + }), + } + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/global-environment/index.ts b/packages/hoppscotch-data/src/global-environment/index.ts new file mode 100644 index 0000000..4fd1dc6 --- /dev/null +++ b/packages/hoppscotch-data/src/global-environment/index.ts @@ -0,0 +1,39 @@ +import { InferredEntity, createVersionedEntity } from "verzod" + +import { z } from "zod" + +import V0_VERSION from "./v/0" +import V1_VERSION from "./v/1" +import V2_VERSION from "./v/2" + +const versionedObject = z.object({ + v: z.number(), +}) + +export const GLOBAL_ENV_LATEST_VERSION = 2 as const + +export const GlobalEnvironment = createVersionedEntity({ + latestVersion: GLOBAL_ENV_LATEST_VERSION, + versionMap: { + 0: V0_VERSION, + 1: V1_VERSION, + 2: V2_VERSION, + }, + getVersion(data) { + const versionCheck = versionedObject.safeParse(data) + + if (versionCheck.success) return versionCheck.data.v + + // For V0 we have to check the schema + const result = V0_VERSION.schema.safeParse(data) + return result.success ? 0 : null + }, +}) + +export type GlobalEnvironment = InferredEntity + +export type GlobalEnvironmentVariable = InferredEntity< + typeof GlobalEnvironment +>["variables"][number] + +export const GlobalEnvironmentSchemaVersion = 2 diff --git a/packages/hoppscotch-data/src/global-environment/v/0.ts b/packages/hoppscotch-data/src/global-environment/v/0.ts new file mode 100644 index 0000000..3b8d3f8 --- /dev/null +++ b/packages/hoppscotch-data/src/global-environment/v/0.ts @@ -0,0 +1,25 @@ +import { z } from "zod" +import { defineVersion } from "verzod" + +export const V0_SCHEMA = z.array( + z.union([ + z.object({ + key: z.string(), + value: z.string(), + secret: z.literal(false), + }), + z.object({ + key: z.string(), + secret: z.literal(true), + }), + z.object({ + key: z.string(), + value: z.string(), + }), + ]) +) + +export default defineVersion({ + initial: true, + schema: V0_SCHEMA, +}) diff --git a/packages/hoppscotch-data/src/global-environment/v/1.ts b/packages/hoppscotch-data/src/global-environment/v/1.ts new file mode 100644 index 0000000..19c0ed9 --- /dev/null +++ b/packages/hoppscotch-data/src/global-environment/v/1.ts @@ -0,0 +1,46 @@ +import { z } from "zod" +import { defineVersion } from "verzod" +import { V0_SCHEMA } from "./0" + +export const V1_SCHEMA = z.object({ + v: z.literal(1), + variables: z.array( + z.union([ + z.object({ + key: z.string(), + secret: z.literal(true), + }), + z.object({ + key: z.string(), + value: z.string(), + secret: z.literal(false), + }), + ]) + ), +}) + +export default defineVersion({ + initial: false, + schema: V1_SCHEMA, + up(old: z.infer) { + const variables = old.map((variable) => { + if ("value" in variable) { + return { + key: variable.key, + value: variable.value, + secret: false, + } + } + + return { + key: variable.key, + secret: true, + } + }) + + return >{ + v: 1, + variables, + } + }, +}) diff --git a/packages/hoppscotch-data/src/global-environment/v/2.ts b/packages/hoppscotch-data/src/global-environment/v/2.ts new file mode 100644 index 0000000..e205d51 --- /dev/null +++ b/packages/hoppscotch-data/src/global-environment/v/2.ts @@ -0,0 +1,40 @@ +import { z } from "zod" +import { defineVersion } from "verzod" +import { V1_SCHEMA } from "./1" + +export const V2_SCHEMA = V1_SCHEMA.extend({ + v: z.literal(2), + variables: z.array( + z.object({ + key: z.string(), + initialValue: z.string(), + currentValue: z.string(), + secret: z.boolean(), + }) + ), +}) + +export default defineVersion({ + initial: false, + schema: V2_SCHEMA, + up(old: z.infer) { + const result: z.infer = { + ...old, + v: 2, + variables: old.variables.map((variable) => { + const { key, secret } = variable + // if the variable is secret, set initialValue and currentValue to empty string + // else set initialValue and currentValue to value + // and delete value + return { + key, + secret: secret ?? false, + initialValue: variable.secret ? "" : variable.value, + currentValue: variable.secret ? "" : variable.value, + } + }), + } + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/graphql/index.ts b/packages/hoppscotch-data/src/graphql/index.ts new file mode 100644 index 0000000..e27b191 --- /dev/null +++ b/packages/hoppscotch-data/src/graphql/index.ts @@ -0,0 +1,94 @@ +import { InferredEntity, createVersionedEntity } from "verzod" +import { z } from "zod" +import V1_VERSION from "./v/1" +import V2_VERSION from "./v/2" +import V3_VERSION from "./v/3" +import V4_VERSION from "./v/4" +import V5_VERSION from "./v/5" +import V6_VERSION from "./v/6" +import V7_VERSION from "./v/7" +import V8_VERSION from "./v/8" +import V9_VERSION from "./v/9" + +export { + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthInherit, + HoppGQLAuthNone, +} from "./v/2" + +export { HoppGQLAuthAPIKey } from "./v/4" + +export { GQLHeader, HoppGQLAuthAWSSignature } from "./v/6" +export { HoppGQLAuth, HoppGQLAuthOAuth2 } from "./v/9" + +export const GQL_REQ_SCHEMA_VERSION = 9 + +const versionedObject = z.object({ + v: z.number(), +}) + +export const HoppGQLRequest = createVersionedEntity({ + latestVersion: 9, + versionMap: { + 1: V1_VERSION, + 2: V2_VERSION, + 3: V3_VERSION, + 4: V4_VERSION, + 5: V5_VERSION, + 6: V6_VERSION, + 7: V7_VERSION, + 8: V8_VERSION, + 9: V9_VERSION, + }, + getVersion(x) { + const result = versionedObject.safeParse(x) + + return result.success ? result.data.v : null + }, +}) + +export type HoppGQLRequest = InferredEntity + +const DEFAULT_QUERY = ` +query Request { + method + url + headers { + key + value + } +}`.trim() + +export function getDefaultGQLRequest(): HoppGQLRequest { + return { + v: GQL_REQ_SCHEMA_VERSION, + name: "Untitled", + url: "https://echo.hoppscotch.io/graphql", + headers: [], + variables: ` +{ + "id": "1" +}`.trim(), + query: DEFAULT_QUERY, + auth: { + authType: "inherit", + authActive: true, + }, + } +} + +/** + * @deprecated This function is deprecated. Use `HoppGQLRequest` instead. + */ +export function translateToGQLRequest(x: unknown): HoppGQLRequest { + const result = HoppGQLRequest.safeParse(x) + return result.type === "ok" ? result.value : getDefaultGQLRequest() +} + +export function makeGQLRequest(x: Omit): HoppGQLRequest { + return { + v: GQL_REQ_SCHEMA_VERSION, + ...x, + } +} diff --git a/packages/hoppscotch-data/src/graphql/v/1.ts b/packages/hoppscotch-data/src/graphql/v/1.ts new file mode 100644 index 0000000..7c86d77 --- /dev/null +++ b/packages/hoppscotch-data/src/graphql/v/1.ts @@ -0,0 +1,24 @@ +import { z } from "zod" +import { defineVersion } from "verzod" + +export const GQLHeader = z.object({ + key: z.string().catch(""), + value: z.string().catch(""), + active: z.boolean().catch(true) +}) + +export type GQLHeader = z.infer + +export const V1_SCHEMA = z.object({ + v: z.literal(1), + name: z.string(), + url: z.string(), + headers: z.array(GQLHeader).catch([]), + query: z.string(), + variables: z.string(), +}) + +export default defineVersion({ + initial: true, + schema: V1_SCHEMA +}) diff --git a/packages/hoppscotch-data/src/graphql/v/2.ts b/packages/hoppscotch-data/src/graphql/v/2.ts new file mode 100644 index 0000000..e4392a3 --- /dev/null +++ b/packages/hoppscotch-data/src/graphql/v/2.ts @@ -0,0 +1,100 @@ +import { z } from "zod" +import { defineVersion } from "verzod" +import { GQLHeader, V1_SCHEMA } from "./1" + +export const HoppGQLAuthNone = z.object({ + authType: z.literal("none"), +}) + +export type HoppGQLAuthNone = z.infer + +export const HoppGQLAuthBasic = z.object({ + authType: z.literal("basic"), + + username: z.string().catch(""), + password: z.string().catch(""), +}) + +export type HoppGQLAuthBasic = z.infer + +export const HoppGQLAuthBearer = z.object({ + authType: z.literal("bearer"), + + token: z.string().catch(""), +}) + +export type HoppGQLAuthBearer = z.infer + +export const HoppGQLAuthOAuth2 = z.object({ + authType: z.literal("oauth-2"), + + token: z.string().catch(""), + oidcDiscoveryURL: z.string().catch(""), + authURL: z.string().catch(""), + accessTokenURL: z.string().catch(""), + clientID: z.string().catch(""), + scope: z.string().catch(""), +}) + +export type HoppGQLAuthOAuth2 = z.infer + +export const HoppGQLAuthAPIKey = z.object({ + authType: z.literal("api-key"), + + key: z.string().catch(""), + value: z.string().catch(""), + addTo: z.string().catch("Headers"), +}) + +export type HoppGQLAuthAPIKey = z.infer + +export const HoppGQLAuthInherit = z.object({ + authType: z.literal("inherit"), +}) + +export type HoppGQLAuthInherit = z.infer + +export const HoppGQLAuth = z + .discriminatedUnion("authType", [ + HoppGQLAuthNone, + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthOAuth2, + HoppGQLAuthAPIKey, + HoppGQLAuthInherit, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppGQLAuth = z.infer + +export const V2_SCHEMA = z.object({ + id: z.optional(z.string()), + v: z.literal(2), + + name: z.string(), + url: z.string(), + headers: z.array(GQLHeader).catch([]), + query: z.string(), + variables: z.string(), + + auth: HoppGQLAuth, +}) + +export default defineVersion({ + initial: false, + schema: V2_SCHEMA, + up(old: z.infer) { + return >{ + ...old, + v: 2, + auth: { + authActive: true, + authType: "none", + }, + } + }, +}) diff --git a/packages/hoppscotch-data/src/graphql/v/3.ts b/packages/hoppscotch-data/src/graphql/v/3.ts new file mode 100644 index 0000000..d1f9b02 --- /dev/null +++ b/packages/hoppscotch-data/src/graphql/v/3.ts @@ -0,0 +1,77 @@ +import { z } from "zod" + +import { defineVersion } from "verzod" + +import { HoppRESTAuthOAuth2 } from "../../rest/v/3" +import { + HoppGQLAuthAPIKey, + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthInherit, + HoppGQLAuthNone, + V2_SCHEMA, +} from "./2" + +export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest/v/3" + +export type HoppGqlAuthOAuth2 = z.infer + +export const HoppGQLAuth = z + .discriminatedUnion("authType", [ + HoppGQLAuthNone, + HoppGQLAuthInherit, + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthAPIKey, + HoppRESTAuthOAuth2, // both rest and gql have the same auth type for oauth2 + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppGQLAuth = z.infer + +export const V3_SCHEMA = V2_SCHEMA.extend({ + v: z.literal(3), + auth: HoppGQLAuth, +}) + +export default defineVersion({ + initial: false, + schema: V3_SCHEMA, + up(old: z.infer) { + if (old.auth.authType === "oauth-2") { + const { token, accessTokenURL, scope, clientID, authURL } = old.auth + + return { + ...old, + v: 3 as const, + auth: { + ...old.auth, + authType: "oauth-2" as const, + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE" as const, + authEndpoint: authURL, + tokenEndpoint: accessTokenURL, + clientID: clientID, + clientSecret: "", + scopes: scope, + isPKCE: false, + token, + }, + addTo: "HEADERS" as const, + }, + } + } + + return { + ...old, + v: 3 as const, + auth: { + ...old.auth, + }, + } + }, +}) diff --git a/packages/hoppscotch-data/src/graphql/v/4.ts b/packages/hoppscotch-data/src/graphql/v/4.ts new file mode 100644 index 0000000..ca62b01 --- /dev/null +++ b/packages/hoppscotch-data/src/graphql/v/4.ts @@ -0,0 +1,71 @@ +import { z } from "zod" + +import { defineVersion } from "verzod" + +import { HoppRESTAuthOAuth2 } from "../../rest/v/5" +import { + HoppGQLAuthAPIKey as HoppGQLAuthAPIKeyOld, + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthInherit, + HoppGQLAuthNone, +} from "./2" +import { V3_SCHEMA } from "./3" + +export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest/v/5" + +export const HoppGQLAuthAPIKey = HoppGQLAuthAPIKeyOld.extend({ + addTo: z.enum(["HEADERS", "QUERY_PARAMS"]).catch("HEADERS"), +}) + +export type HoppGqlAuthOAuth2 = z.infer + +export const HoppGQLAuth = z + .discriminatedUnion("authType", [ + HoppGQLAuthNone, + HoppGQLAuthInherit, + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthAPIKey, + HoppRESTAuthOAuth2, // both rest and gql have the same auth type for oauth2 + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppGQLAuth = z.infer + +export const V4_SCHEMA = V3_SCHEMA.extend({ + v: z.literal(4), + auth: HoppGQLAuth, +}) + +export default defineVersion({ + initial: false, + schema: V4_SCHEMA, + up(old: z.infer) { + if (old.auth.authType === "api-key") { + return { + ...old, + v: 4 as const, + auth: { + ...old.auth, + addTo: + old.auth.addTo === "Query params" + ? ("QUERY_PARAMS" as const) + : ("HEADERS" as const), + }, + } + } + + return { + ...old, + v: 4 as const, + auth: { + ...old.auth, + }, + } + }, +}) diff --git a/packages/hoppscotch-data/src/graphql/v/5.ts b/packages/hoppscotch-data/src/graphql/v/5.ts new file mode 100644 index 0000000..3b390a0 --- /dev/null +++ b/packages/hoppscotch-data/src/graphql/v/5.ts @@ -0,0 +1,47 @@ +import { z } from "zod" + +import { defineVersion } from "verzod" + +import { HoppRESTAuthOAuth2 } from "../../rest/v/5" +import { + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthInherit, + HoppGQLAuthNone, +} from "./2" +import { HoppGQLAuthAPIKey, V4_SCHEMA } from "./4" + +export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest/v/5" + +export const HoppGQLAuth = z + .discriminatedUnion("authType", [ + HoppGQLAuthNone, + HoppGQLAuthInherit, + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthAPIKey, + HoppRESTAuthOAuth2, // both rest and gql have the same auth type for oauth2 + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppGQLAuth = z.infer + +export const V5_SCHEMA = V4_SCHEMA.extend({ + v: z.literal(5), + auth: HoppGQLAuth, +}) + +export default defineVersion({ + initial: false, + schema: V5_SCHEMA, + up(old: z.infer) { + return { + ...old, + v: 5 as const, + } + }, +}) diff --git a/packages/hoppscotch-data/src/graphql/v/6.ts b/packages/hoppscotch-data/src/graphql/v/6.ts new file mode 100644 index 0000000..9dfde90 --- /dev/null +++ b/packages/hoppscotch-data/src/graphql/v/6.ts @@ -0,0 +1,70 @@ +import { defineVersion } from "verzod" +import { z } from "zod" +import { HoppRESTAuthAWSSignature } from "./../../rest/v/7" +import { + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthInherit, + HoppGQLAuthNone, +} from "./2" +import { HoppGQLAuthOAuth2, V5_SCHEMA } from "./5" +import { HoppGQLAuthAPIKey } from "./4" + +export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest/v/7" + +// Both REST & GQL have the same schema definition for AWS Signature Authorization type +export const HoppGQLAuthAWSSignature = HoppRESTAuthAWSSignature + +export type HoppGQLAuthAWSSignature = z.infer + +export const GQLHeader = z.object({ + key: z.string().catch(""), + value: z.string().catch(""), + active: z.boolean().catch(true), + description: z.string().catch(""), +}) + +export type GQLHeader = z.infer + +export const HoppGQLAuth = z + .discriminatedUnion("authType", [ + HoppGQLAuthNone, + HoppGQLAuthInherit, + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthOAuth2, + HoppGQLAuthAPIKey, + HoppGQLAuthAWSSignature, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppGQLAuth = z.infer + +export const V6_SCHEMA = V5_SCHEMA.extend({ + v: z.literal(6), + auth: HoppGQLAuth, + headers: z.array(GQLHeader).catch([]), +}) + +export default defineVersion({ + schema: V6_SCHEMA, + initial: false, + up(old: z.infer) { + const headers = old.headers.map((header) => { + return { + ...header, + description: "", + } + }) + + return { + ...old, + v: 6 as const, + headers, + } + }, +}) diff --git a/packages/hoppscotch-data/src/graphql/v/7.ts b/packages/hoppscotch-data/src/graphql/v/7.ts new file mode 100644 index 0000000..2d72008 --- /dev/null +++ b/packages/hoppscotch-data/src/graphql/v/7.ts @@ -0,0 +1,49 @@ +import { defineVersion } from "verzod" +import { z } from "zod" + +import { + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthInherit, + HoppGQLAuthNone, +} from "./2" +import { HoppGQLAuthAPIKey } from "./4" +import { HoppGQLAuthAWSSignature, V6_SCHEMA } from "./6" +import { HoppRESTAuthOAuth2 } from "./../../rest/v/7" + +export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest/v/7" + +export const HoppGQLAuth = z + .discriminatedUnion("authType", [ + HoppGQLAuthNone, + HoppGQLAuthInherit, + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppRESTAuthOAuth2, + HoppGQLAuthAPIKey, + HoppGQLAuthAWSSignature, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppGQLAuth = z.infer + +export const V7_SCHEMA = V6_SCHEMA.extend({ + v: z.literal(7), + auth: HoppGQLAuth, +}) + +export default defineVersion({ + schema: V7_SCHEMA, + initial: false, + up(old: z.infer) { + return { + ...old, + v: 7 as const, + // no need to update anything for HoppGQLAuth, because we loosened the previous schema by making `clientSecret` optional + } + }, +}) diff --git a/packages/hoppscotch-data/src/graphql/v/8.ts b/packages/hoppscotch-data/src/graphql/v/8.ts new file mode 100644 index 0000000..d7e8f8e --- /dev/null +++ b/packages/hoppscotch-data/src/graphql/v/8.ts @@ -0,0 +1,64 @@ +import { defineVersion } from "verzod" +import { z } from "zod" + +import { + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthInherit, + HoppGQLAuthNone, +} from "./2" +import { HoppGQLAuthAPIKey } from "./4" +import { HoppGQLAuthAWSSignature } from "./6" +import { HoppRESTAuthOAuth2 } from "../../rest/v/11/auth" +import { V7_SCHEMA } from "./7" + +export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest/v/11/auth" + +export const HoppGQLAuth = z + .discriminatedUnion("authType", [ + HoppGQLAuthNone, + HoppGQLAuthInherit, + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppRESTAuthOAuth2, + HoppGQLAuthAPIKey, + HoppGQLAuthAWSSignature, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppGQLAuth = z.infer + +export const V8_SCHEMA = V7_SCHEMA.extend({ + v: z.literal(8), + auth: HoppGQLAuth, +}) + +export default defineVersion({ + schema: V8_SCHEMA, + initial: false, + up(old: z.infer) { + const auth = old.auth + + return { + ...old, + v: 8 as const, + auth: + auth.authType === "oauth-2" + ? { + ...auth, + grantTypeInfo: + auth.grantTypeInfo.grantType === "CLIENT_CREDENTIALS" + ? { + ...auth.grantTypeInfo, + clientAuthentication: "IN_BODY" as const, + } + : auth.grantTypeInfo, + } + : auth, + } + }, +}) diff --git a/packages/hoppscotch-data/src/graphql/v/9.ts b/packages/hoppscotch-data/src/graphql/v/9.ts new file mode 100644 index 0000000..136d364 --- /dev/null +++ b/packages/hoppscotch-data/src/graphql/v/9.ts @@ -0,0 +1,94 @@ +import { defineVersion } from "verzod" +import { z } from "zod" + +import { + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthInherit, + HoppGQLAuthNone, +} from "./2" +import { HoppGQLAuthAPIKey } from "./4" +import { HoppGQLAuthAWSSignature } from "./6" +import { HoppRESTAuthOAuth2 } from "../../rest/v/15/auth" +import { V8_SCHEMA } from "./8" + +export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest/v/15/auth" + +export const HoppGQLAuth = z + .discriminatedUnion("authType", [ + HoppGQLAuthNone, + HoppGQLAuthInherit, + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppRESTAuthOAuth2, + HoppGQLAuthAPIKey, + HoppGQLAuthAWSSignature, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppGQLAuth = z.infer + +export const V9_SCHEMA = V8_SCHEMA.extend({ + v: z.literal(9), + auth: HoppGQLAuth, +}) + +export default defineVersion({ + schema: V9_SCHEMA, + initial: false, + up(old: z.infer) { + // If the auth is OAuth2, migrate it to include the new advanced parameters + let newAuth: z.infer + if (old.auth.authType === "oauth-2") { + const oldGrantTypeInfo = old.auth.grantTypeInfo + let newGrantTypeInfo + + // Add the advanced parameters to the appropriate grant type + if (oldGrantTypeInfo.grantType === "AUTHORIZATION_CODE") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + authRequestParams: [], + tokenRequestParams: [], + refreshRequestParams: [], + } + } else if (oldGrantTypeInfo.grantType === "CLIENT_CREDENTIALS") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + tokenRequestParams: [], + refreshRequestParams: [], + } + } else if (oldGrantTypeInfo.grantType === "PASSWORD") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + tokenRequestParams: [], + refreshRequestParams: [], + } + } else if (oldGrantTypeInfo.grantType === "IMPLICIT") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + authRequestParams: [], + refreshRequestParams: [], + } + } else { + newGrantTypeInfo = oldGrantTypeInfo + } + + newAuth = { + ...old.auth, + grantTypeInfo: newGrantTypeInfo, + } as z.infer + } else { + newAuth = old.auth + } + + return { + ...old, + v: 9 as const, + auth: newAuth, + } + }, +}) diff --git a/packages/hoppscotch-data/src/index.ts b/packages/hoppscotch-data/src/index.ts new file mode 100644 index 0000000..ecbfc8c --- /dev/null +++ b/packages/hoppscotch-data/src/index.ts @@ -0,0 +1,13 @@ +export * from "./rest" +export * from "./graphql" +export * from "./collection" +export * from "./rawKeyValue" +export * from "./environment" +export * from "./global-environment" +export * from "./predefinedVariables" +export * from "./utils/collection" +export * from "./utils/hawk" +export * from "./utils/akamai-eg" +export * from "./utils/jwt" +export * from "./rest-request-response" +export * from "./cookies" diff --git a/packages/hoppscotch-data/src/predefinedVariables.ts b/packages/hoppscotch-data/src/predefinedVariables.ts new file mode 100644 index 0000000..9fbab46 --- /dev/null +++ b/packages/hoppscotch-data/src/predefinedVariables.ts @@ -0,0 +1,384 @@ +export type PredefinedVariable = { + key: `$${string}` + description: string + getValue: () => string +} + +const generateV4Uuid = () => { + const characters = "0123456789abcdef" + let uuid = "" + for (let i = 0; i < 36; i++) { + if (i === 8 || i === 13 || i === 18 || i === 23) { + uuid += "-" + } else if (i === 14) { + uuid += "4" + } else if (i === 19) { + uuid += characters.charAt(8 + Math.floor(Math.random() * 4)) + } else { + uuid += characters.charAt(Math.floor(Math.random() * characters.length)) + } + } + return uuid +} + +export const HOPP_SUPPORTED_PREDEFINED_VARIABLES: PredefinedVariable[] = [ + // Common + { + key: "$guid", + description: "A v4 style GUID.", + getValue: generateV4Uuid, + }, + { + key: "$timestamp", + description: "The current UNIX timestamp in seconds.", + getValue: () => Math.floor(Date.now() / 1000).toString(), + }, + + { + key: "$isoTimestamp", + description: "The current ISO timestamp at zero UTC.", + getValue: () => new Date().toISOString(), + }, + { + key: "$randomUUID", + description: "A random 36-character UUID.", + getValue: generateV4Uuid, + }, + + // Text, numbers, and colors + { + key: "$randomAlphaNumeric", + description: "A random alphanumeric character.", + getValue: () => { + const characters = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + return characters.charAt(Math.floor(Math.random() * characters.length)) + }, + }, + + { + key: "$randomBoolean", + description: "A random boolean value.", + getValue: () => (Math.random() < 0.5 ? "true" : "false"), + }, + + { + key: "$randomInt", + description: "A random integer between 0 and 1000.", + getValue: () => Math.floor(Math.random() * 1000).toString(), + }, + + { + key: "$randomColor", + description: "A random color.", + getValue: () => { + const colors = ["red", "green", "blue", "yellow", "purple", "orange"] + return colors[Math.floor(Math.random() * colors.length)] + }, + }, + + { + key: "$randomHexColor", + description: "A random hex value.", + getValue: () => { + const characters = "0123456789abcdef" + let color = "#" + for (let i = 0; i < 6; i++) { + color += characters.charAt( + Math.floor(Math.random() * characters.length) + ) + } + return color + }, + }, + + { + key: "$randomAbbreviation", + description: "A random abbreviation.", + getValue: () => { + const abbreviations = [ + "SQL", + "PCI", + "JSON", + "HTML", + "CSS", + "JS", + "TS", + "API", + ] + return abbreviations[Math.floor(Math.random() * abbreviations.length)] + }, + }, + + // Internet and IP addresses + { + key: "$randomIP", + description: "A random IPv4 address.", + getValue: () => { + const ip = Array.from({ length: 4 }, () => + Math.floor(Math.random() * 256) + ) + return ip.join(".") + }, + }, + + { + key: "$randomIPV6", + description: "A random IPv6 address.", + getValue: () => { + const ip = Array.from({ length: 8 }, () => + Math.floor(Math.random() * 65536).toString(16).padStart(4, "0") + ) + return ip.join(":") + }, + }, + + { + key: "$randomMACAddress", + description: "A random MAC address.", + getValue: () => { + const mac = Array.from({ length: 6 }, () => + Math.floor(Math.random() * 256).toString(16).padStart(2, "0") + ) + return mac.join(":") + }, + }, + + { + key: "$randomPassword", + description: "A random 15-character alphanumeric password.", + getValue: () => { + const characters = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + let password = "" + for (let i = 0; i < 15; i++) { + password += characters.charAt( + Math.floor(Math.random() * characters.length) + ) + } + return password + }, + }, + + { + key: "$randomLocale", + description: "A random two-letter language code (ISO 639-1).", + getValue: () => { + const locales = ["ny", "sr", "si"] + return locales[Math.floor(Math.random() * locales.length)] + }, + }, + + { + key: "$randomUserAgent", + description: "A random user agent.", + getValue: () => { + const userAgents = [ + "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.9.8; rv:15.6) Gecko/20100101 Firefox/15.6.6", + "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:15.6) Gecko/20100101 Firefox/15.6.6", + "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:15.6) Gecko/20100101 Firefox/15.6.6", + ] + return userAgents[Math.floor(Math.random() * userAgents.length)] + }, + }, + { + key: "$randomProtocol", + description: "A random internet protocol.", + getValue: () => { + const protocols = ["http", "https"] + return protocols[Math.floor(Math.random() * protocols.length)] + }, + }, + + { + key: "$randomSemver", + description: "A random semantic version number.", + getValue: () => { + const semver = Array.from({ length: 3 }, () => + Math.floor(Math.random() * 10) + ) + return semver.join(".") + }, + }, + + // Names + { + key: "$randomFirstName", + description: "A random first name.", + getValue: () => { + const firstNames = [ + "Ethan", + "Chandler", + "Megane", + "John", + "Jane", + "Alice", + "Bob", + ] + return firstNames[Math.floor(Math.random() * firstNames.length)] + }, + }, + { + key: "$randomLastName", + description: "A random last name.", + getValue: () => { + const lastNames = [ + "Schaden", + "Schneider", + "Willms", + "Doe", + "Smith", + "Johnson", + ] + return lastNames[Math.floor(Math.random() * lastNames.length)] + }, + }, + { + key: "$randomFullName", + description: "A random first and last name.", + getValue: () => { + const firstNames = [ + "Ethan", + "Chandler", + "Megane", + "John", + "Jane", + "Alice", + "Bob", + ] + const lastNames = [ + "Schaden", + "Schneider", + "Willms", + "Doe", + "Smith", + "Johnson", + ] + return `${firstNames[Math.floor(Math.random() * firstNames.length)]} ${ + lastNames[Math.floor(Math.random() * lastNames.length)] + }` + }, + }, + { + key: "$randomNamePrefix", + description: "A random name prefix.", + getValue: () => { + const prefixes = ["Dr.", "Ms.", "Mr.", "Mrs.", "Miss", "Prof."] + return prefixes[Math.floor(Math.random() * prefixes.length)] + }, + }, + { + key: "$randomNameSuffix", + description: "A random name suffix.", + getValue: () => { + const suffixes = ["I", "MD", "DDS", "PhD", "Esq.", "Jr."] + return suffixes[Math.floor(Math.random() * suffixes.length)] + }, + }, + + // Company names + { + key: "$randomCompanyName", + description: "A random company name.", + getValue: () => { + const companyNames = [ + "Nexora", + "CodeLoom", + "BitHaven", + "SynapseWorks", + "VoltEdge", + "Elevoria", + "Bridgent", + "Verniq", + "Corevia", + "Stratigen", + "Lunova Studio", + "Pixelora", + "MuseMind", + "BrightNest", + "Auralis", + "VerdaFlow", + "BloomShift", + "PureTrek", + "EcoVerse", + "ReLeaf Labs", + ] + + return companyNames[Math.floor(Math.random() * companyNames.length)] + }, + }, + + // Addresses + { + key: "$randomCity", + description: "A random city name.", + getValue: () => { + const cities = [ + "New York", + "Los Angeles", + "Chicago", + "Houston", + "Phoenix", + "Philadelphia", + ] + return cities[Math.floor(Math.random() * cities.length)] + }, + }, + + // profession + { + key: "$randomJobArea", + description: "A random job area.", + getValue: () => { + const jobAreas = [ + "Mobility", + "Intranet", + "Configuration", + "Development", + "Design", + "Testing", + ] + return jobAreas[Math.floor(Math.random() * jobAreas.length)] + }, + }, + { + key: "$randomJobDescriptor", + description: "A random job descriptor.", + getValue: () => { + const jobDescriptors = [ + "Forward", + "Corporate", + "Senior", + "Junior", + "Lead", + "Principal", + ] + return jobDescriptors[Math.floor(Math.random() * jobDescriptors.length)] + }, + }, + { + key: "$randomJobTitle", + description: "A random job title.", + getValue: () => { + const jobTitles = [ + "International Creative Liaison", + "Global Branding Officer", + "Dynamic Data Specialist", + "Internal Communications Consultant", + "Productivity Analyst", + "Regional Applications Developer", + ] + return jobTitles[Math.floor(Math.random() * jobTitles.length)] + }, + }, + { + key: "$randomJobType", + description: "A random job type.", + getValue: () => { + const jobTypes = ["Supervisor", "Manager", "Coordinator", "Director"] + return jobTypes[Math.floor(Math.random() * jobTypes.length)] + }, + }, + + // TODO: Support various other predefined variables +] diff --git a/packages/hoppscotch-data/src/rawKeyValue.ts b/packages/hoppscotch-data/src/rawKeyValue.ts new file mode 100644 index 0000000..353f5b7 --- /dev/null +++ b/packages/hoppscotch-data/src/rawKeyValue.ts @@ -0,0 +1,246 @@ +import { not } from "fp-ts/Predicate" +import { pipe, flow } from "fp-ts/function" +import * as Str from "fp-ts/string" +import * as RA from "fp-ts/ReadonlyArray" +import * as A from "fp-ts/Array" +import * as O from "fp-ts/Option" +import * as E from "fp-ts/Either" +import * as P from "parser-ts/Parser" +import * as S from "parser-ts/string" +import * as C from "parser-ts/char" +import { recordUpdate } from "./utils/record" + +/** + * Special characters in the Raw Key Value Grammar + */ +const SPECIAL_CHARS = ["#", ":"] as const + +export type RawKeyValueEntry = { + key: string + value: string + active: boolean +} + +/* Beginning of Parser Definitions */ + +const wsSurround = P.surroundedBy(S.spaces) + +const stringArrayJoin = (sep: string) => (input: string[]) => input.join(sep) + +const stringTakeUntilChars = (chars: C.Char[]) => pipe( + P.takeUntil((c: C.Char) => chars.includes(c)), + P.map(stringArrayJoin("")), +) + +const stringTakeUntilCharsInclusive = flow( + stringTakeUntilChars, + P.chainFirst(() => P.sat(() => true)), +) + +const quotedString = pipe( + S.doubleQuotedString, + P.map((x) => JSON.parse(`"${x}"`)) +) + +const key = pipe( + wsSurround(quotedString), + + P.alt(() => + pipe( + stringTakeUntilChars([":", "\n"]), + P.map(Str.trim) + ) + ) +) + +const value = pipe( + wsSurround(quotedString), + + P.alt(() => + pipe( + stringTakeUntilChars(["\n"]), + P.map(Str.trim) + ) + ) +) + +const commented = pipe( + S.maybe(S.string("#")), + P.map(not(Str.isEmpty)) +) + +const line = pipe( + wsSurround(commented), + P.bindTo("commented"), + + P.bind("key", () => wsSurround(key)), + + P.chainFirst(() => C.char(":")), + + P.bind("value", () => value), +) + +const lineWithNoColon = pipe( + wsSurround(commented), + P.bindTo("commented"), + P.bind("key", () => P.either( + stringTakeUntilCharsInclusive(["\n"]), + () => pipe( + P.manyTill(P.sat((_: string) => true), P.eof()), + P.map(flow( + RA.toArray, + stringArrayJoin("") + )) + ) + )), + P.map(flow( + O.fromPredicate(({ key }) => !Str.isEmpty(key)) + )) +) + +const file = pipe( + P.manyTill(wsSurround(line), P.eof()), +) + +/** + * This Raw Key Value parser ignores the key value pair (no colon) issues + */ +const tolerantFile = pipe( + P.manyTill( + P.either( + pipe(line, P.map(O.some)), + () => pipe( + lineWithNoColon, + P.map(flow( + O.map((a) => ({ ...a, value: "" })) + )) + ) + ), + P.eof() + ), + P.map(flow( + RA.filterMap(flow( + O.fromPredicate(O.isSome), + O.map((a) => a.value) + )) + )) +) + +/* End of Parser Definitions */ + +/** + * Detect whether the string needs to have escape characters in raw key value strings + * @param input The string to check against + */ +const stringNeedsEscapingForRawKVString = (input: string) => { + // If there are any of our special characters, it needs to be escaped definitely + if (SPECIAL_CHARS.some((x) => input.includes(x))) + return true + + // The theory behind this impl is that if we apply JSON.stringify on a string + // it does escaping and then return a JSON string representation. + // We remove the quotes of the JSON and see if it can be matched against the input string + const stringified = JSON.stringify(input) + + const y = stringified + .substring(1, stringified.length - 1) + .trim() + + return y !== input +} + +/** + * Applies Raw Key Value escaping (via quotes + escape chars) if needed + * @param input The input to apply escape on + * @returns If needed, the escaped string, else the input string itself + */ +const applyEscapeIfNeeded = (input: string) => + stringNeedsEscapingForRawKVString(input) + ? JSON.stringify(input) + : input + +/** + * Converts Raw Key Value Entries to the file string format + * @param entries The entries array + * @returns The entries in string format + */ +export const rawKeyValueEntriesToString = (entries: RawKeyValueEntry[]) => + pipe( + entries, + A.map( + flow( + recordUpdate("key", applyEscapeIfNeeded), + recordUpdate("value", applyEscapeIfNeeded), + ({ key, value, active }) => + active ? `${(key)}: ${value}` : `# ${key}: ${value}` + ) + ), + stringArrayJoin("\n") + ) + +/** + * Parses raw key value entries string to array + * @param s The file string to parse from + * @returns Either the parser fail result or the raw key value entries + */ +export const parseRawKeyValueEntriesE = (s: string) => + pipe( + tolerantFile, + S.run(s), + E.mapLeft((err) => ({ + message: `Expected ${err.expected.map((x) => `'${x}'`).join(", ")}`, + expected: err.expected, + pos: err.input.cursor, + })), + E.map( + ({ value }) => pipe( + value, + RA.map(({ key, value, commented }) => + { + active: !commented, + key, + value + } + ) + ) + ) + ) + +/** + * Less error tolerating version of `parseRawKeyValueEntriesE` + * @param s The file string to parse from + * @returns Either the parser fail result or the raw key value entries + */ +export const strictParseRawKeyValueEntriesE = (s: string) => + pipe( + file, + S.run(s), + E.mapLeft((err) => ({ + message: `Expected ${err.expected.map((x) => `'${x}'`).join(", ")}`, + expected: err.expected, + pos: err.input.cursor, + })), + E.map( + ({ value }) => pipe( + value, + RA.map(({ key, value, commented }) => + { + active: !commented, + key, + value + } + ) + ) + ) + ) + +/** + * Kept for legacy code compatibility, parses raw key value entries. + * If failed, it returns an empty array + * @deprecated Use `parseRawKeyValueEntriesE` instead + */ +export const parseRawKeyValueEntries = flow( + parseRawKeyValueEntriesE, + E.map(RA.toArray), + E.getOrElse(() => [] as RawKeyValueEntry[]) +) diff --git a/packages/hoppscotch-data/src/rest-request-response/index.ts b/packages/hoppscotch-data/src/rest-request-response/index.ts new file mode 100644 index 0000000..5eb2656 --- /dev/null +++ b/packages/hoppscotch-data/src/rest-request-response/index.ts @@ -0,0 +1,50 @@ +import { InferredEntity, createVersionedEntity, entityReference } from "verzod" +import { z } from "zod" +import V0_VERSION from "./v/0" +import { + HoppRESTResOriginalReqSchemaVersion, + HoppRESTResponseOriginalRequest, +} from "./original-request" + +export { HoppRESTResponseOriginalRequest } from "./original-request" + +const versionedObject = z.object({ + v: z.number(), +}) + +export const HoppRESTRequestResponse = createVersionedEntity({ + latestVersion: 0, + versionMap: { + 0: V0_VERSION, + }, + getVersion(data) { + const versionCheck = versionedObject.safeParse(data) + + if (versionCheck.success) return versionCheck.data.v + + // Schema starts from version 0, so if the version is not present, + // we assume it's version 0 + const result = V0_VERSION.schema.safeParse(data) + return result.success ? 0 : null + }, +}) + +export type HoppRESTRequestResponse = InferredEntity< + typeof HoppRESTRequestResponse +> + +export const HoppRESTRequestResponses = z.record( + z.string(), + entityReference(HoppRESTRequestResponse) +) + +export type HoppRESTRequestResponses = z.infer + +export function makeHoppRESTResponseOriginalRequest( + x: Omit +): HoppRESTResponseOriginalRequest { + return { + v: HoppRESTResOriginalReqSchemaVersion, + ...x, + } +} diff --git a/packages/hoppscotch-data/src/rest-request-response/original-request/index.ts b/packages/hoppscotch-data/src/rest-request-response/original-request/index.ts new file mode 100644 index 0000000..45a280e --- /dev/null +++ b/packages/hoppscotch-data/src/rest-request-response/original-request/index.ts @@ -0,0 +1,41 @@ +import { InferredEntity, createVersionedEntity } from "verzod" +import { z } from "zod" +import V1_VERSION from "./v/1" +import V2_VERSION from "./v/2" +import V3_VERSION from "./v/3" +import V4_VERSION from "./v/4" +import V5_VERSION from "./v/5" +import V6_VERSION from "./v/6" + +const versionedObject = z.object({ + // v is a stringified number + v: z.string().regex(/^\d+$/).transform(Number), +}) + +export const HoppRESTResponseOriginalRequest = createVersionedEntity({ + latestVersion: 6, + versionMap: { + 1: V1_VERSION, + 2: V2_VERSION, + 3: V3_VERSION, + 4: V4_VERSION, + 5: V5_VERSION, + 6: V6_VERSION, + }, + getVersion(data) { + const versionCheck = versionedObject.safeParse(data) + + if (versionCheck.success) return versionCheck.data.v + + // For V1 we have to check the schema + const result = V1_VERSION.schema.safeParse(data) + + return result.success ? 1 : null + }, +}) + +export const HoppRESTResOriginalReqSchemaVersion = "6" + +export type HoppRESTResponseOriginalRequest = InferredEntity< + typeof HoppRESTResponseOriginalRequest +> diff --git a/packages/hoppscotch-data/src/rest-request-response/original-request/v/1.ts b/packages/hoppscotch-data/src/rest-request-response/original-request/v/1.ts new file mode 100644 index 0000000..a4307bd --- /dev/null +++ b/packages/hoppscotch-data/src/rest-request-response/original-request/v/1.ts @@ -0,0 +1,24 @@ +import { defineVersion } from "verzod" +import { z } from "zod" +import { HoppRESTHeaders, HoppRESTParams } from "../../../rest/v/7" +import { HoppRESTReqBody } from "../../../rest/v/6" +import { HoppRESTRequestVariables } from "../../../rest/v/2" + +import { HoppRESTAuth } from "../../../rest/v/8/auth" + +export const V1_SCHEMA = z.object({ + v: z.literal("1"), + name: z.string(), + method: z.string(), + endpoint: z.string(), + headers: HoppRESTHeaders, + params: HoppRESTParams, + body: HoppRESTReqBody, + auth: HoppRESTAuth, + requestVariables: HoppRESTRequestVariables, +}) + +export default defineVersion({ + initial: true, + schema: V1_SCHEMA, +}) diff --git a/packages/hoppscotch-data/src/rest-request-response/original-request/v/2.ts b/packages/hoppscotch-data/src/rest-request-response/original-request/v/2.ts new file mode 100644 index 0000000..cf6231a --- /dev/null +++ b/packages/hoppscotch-data/src/rest-request-response/original-request/v/2.ts @@ -0,0 +1,20 @@ +import { defineVersion } from "verzod" +import { z } from "zod" +import { V1_SCHEMA } from "./1" +import { HoppRESTReqBody } from "../../../rest/v/9/body" + +export const V2_SCHEMA = V1_SCHEMA.extend({ + v: z.literal("2"), + body: HoppRESTReqBody, +}) + +export default defineVersion({ + initial: false, + schema: V2_SCHEMA, + up(old: z.infer) { + return { + ...old, + v: "2" as const, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest-request-response/original-request/v/3.ts b/packages/hoppscotch-data/src/rest-request-response/original-request/v/3.ts new file mode 100644 index 0000000..57a03f2 --- /dev/null +++ b/packages/hoppscotch-data/src/rest-request-response/original-request/v/3.ts @@ -0,0 +1,38 @@ +import { defineVersion } from "verzod" +import { z } from "zod" + +import { V2_SCHEMA } from "./2" +import { HoppRESTAuth } from "../../../rest/v/11/auth" +import { HoppRESTReqBody } from "../../../rest/v/10/body" + +export const V3_SCHEMA = V2_SCHEMA.extend({ + v: z.literal("3"), + auth: HoppRESTAuth, + body: HoppRESTReqBody, +}) + +export default defineVersion({ + initial: false, + schema: V3_SCHEMA, + up(old: z.infer) { + const auth = old.auth + + return { + ...old, + v: "3" as const, + auth: + auth.authType === "oauth-2" + ? { + ...auth, + grantTypeInfo: + auth.grantTypeInfo.grantType === "CLIENT_CREDENTIALS" + ? { + ...auth.grantTypeInfo, + clientAuthentication: "IN_BODY" as const, + } + : auth.grantTypeInfo, + } + : auth, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest-request-response/original-request/v/4.ts b/packages/hoppscotch-data/src/rest-request-response/original-request/v/4.ts new file mode 100644 index 0000000..a97b954 --- /dev/null +++ b/packages/hoppscotch-data/src/rest-request-response/original-request/v/4.ts @@ -0,0 +1,20 @@ +import { defineVersion } from "verzod" +import { z } from "zod" +import { V3_SCHEMA } from "./3" +import { HoppRESTAuth } from "../../../rest/v/12/auth" + +export const V4_SCHEMA = V3_SCHEMA.extend({ + v: z.literal("4"), + auth: HoppRESTAuth, +}) + +export default defineVersion({ + initial: false, + schema: V4_SCHEMA, + up(old: z.infer) { + return { + ...old, + v: "4" as const, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest-request-response/original-request/v/5.ts b/packages/hoppscotch-data/src/rest-request-response/original-request/v/5.ts new file mode 100644 index 0000000..2fa26a7 --- /dev/null +++ b/packages/hoppscotch-data/src/rest-request-response/original-request/v/5.ts @@ -0,0 +1,20 @@ +import { defineVersion } from "verzod" +import { z } from "zod" +import { V4_SCHEMA } from "./4" +import { HoppRESTAuth } from "../../../rest/v/13/auth" + +export const V5_SCHEMA = V4_SCHEMA.extend({ + v: z.literal("5"), + auth: HoppRESTAuth, +}) + +export default defineVersion({ + initial: false, + schema: V5_SCHEMA, + up(old: z.infer) { + return { + ...old, + v: "5" as const, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest-request-response/original-request/v/6.ts b/packages/hoppscotch-data/src/rest-request-response/original-request/v/6.ts new file mode 100644 index 0000000..8eb0258 --- /dev/null +++ b/packages/hoppscotch-data/src/rest-request-response/original-request/v/6.ts @@ -0,0 +1,65 @@ +import { defineVersion } from "verzod" +import { z } from "zod" +import { V5_SCHEMA } from "./5" +import { HoppRESTAuth } from "../../../rest/v/15/auth" + +export const V6_SCHEMA = V5_SCHEMA.extend({ + v: z.literal("6"), + auth: HoppRESTAuth, +}) + +export default defineVersion({ + initial: false, + schema: V6_SCHEMA, + up(old: z.infer) { + // If the auth is OAuth2, migrate it to include the new advanced parameters + let newAuth: z.infer + if (old.auth.authType === "oauth-2") { + const oldGrantTypeInfo = old.auth.grantTypeInfo + let newGrantTypeInfo + + // Add the advanced parameters to the appropriate grant type + if (oldGrantTypeInfo.grantType === "AUTHORIZATION_CODE") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + authRequestParams: [], + tokenRequestParams: [], + refreshRequestParams: [], + } + } else if (oldGrantTypeInfo.grantType === "CLIENT_CREDENTIALS") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + tokenRequestParams: [], + refreshRequestParams: [], + } + } else if (oldGrantTypeInfo.grantType === "PASSWORD") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + tokenRequestParams: [], + refreshRequestParams: [], + } + } else if (oldGrantTypeInfo.grantType === "IMPLICIT") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + authRequestParams: [], + refreshRequestParams: [], + } + } else { + newGrantTypeInfo = oldGrantTypeInfo + } + + newAuth = { + ...old.auth, + grantTypeInfo: newGrantTypeInfo, + } as z.infer + } else { + newAuth = old.auth + } + + return { + ...old, + v: "6" as const, + auth: newAuth, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest-request-response/v/0.ts b/packages/hoppscotch-data/src/rest-request-response/v/0.ts new file mode 100644 index 0000000..9e4bb9e --- /dev/null +++ b/packages/hoppscotch-data/src/rest-request-response/v/0.ts @@ -0,0 +1,35 @@ +import { defineVersion, entityReference } from "verzod" +import { z } from "zod" +import { HoppRESTResponseOriginalRequest } from "../original-request" +import { StatusCodes } from "../../utils/statusCodes" + +export const ValidCodes = z.union( + Object.keys(StatusCodes).map((code) => z.literal(parseInt(code))) as [ + z.ZodLiteral, + z.ZodLiteral, + ...z.ZodLiteral[] + ] +) + +export const HoppRESTResponseHeaders = z.array( + z.object({ + key: z.string(), + value: z.string(), + }) +) + +export type HoppRESTResponseHeader = z.infer + +export const V0_SCHEMA = z.object({ + name: z.string(), + originalRequest: entityReference(HoppRESTResponseOriginalRequest), + status: z.string(), + code: z.optional(ValidCodes).nullable().catch(null), + headers: HoppRESTResponseHeaders, + body: z.string(), +}) + +export default defineVersion({ + initial: true, + schema: V0_SCHEMA, +}) diff --git a/packages/hoppscotch-data/src/rest/content-types.ts b/packages/hoppscotch-data/src/rest/content-types.ts new file mode 100644 index 0000000..e1e51b7 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/content-types.ts @@ -0,0 +1,19 @@ +export const knownContentTypes = { + "application/json": "json", + "application/ld+json": "json", + "application/hal+json": "json", + "application/vnd.api+json": "json", + "application/xml": "xml", + "text/xml": "xml", + "application/x-www-form-urlencoded": "multipart", + "multipart/form-data": "multipart", + "application/octet-stream": "binary", + "text/html": "html", + "text/plain": "plain", +} + +export type ValidContentTypes = keyof typeof knownContentTypes + +export const ValidContentTypesList = Object.keys( + knownContentTypes +) as ValidContentTypes[] diff --git a/packages/hoppscotch-data/src/rest/index.ts b/packages/hoppscotch-data/src/rest/index.ts new file mode 100644 index 0000000..4aa4683 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/index.ts @@ -0,0 +1,299 @@ +import * as Eq from "fp-ts/Eq" +import * as S from "fp-ts/string" +import cloneDeep from "lodash/cloneDeep" +import { createVersionedEntity, InferredEntity } from "verzod" +import { z } from "zod" + +import { lodashIsEqualEq, mapThenEq, undefinedEq } from "../utils/eq" + +import V0_VERSION from "./v/0" +import V1_VERSION from "./v/1" +import V2_VERSION, { HoppRESTRequestVariables } from "./v/2" +import V3_VERSION from "./v/3" +import V4_VERSION from "./v/4" +import V5_VERSION from "./v/5" +import V6_VERSION from "./v/6" +import V7_VERSION, { HoppRESTHeaders, HoppRESTParams } from "./v/7" +import V8_VERSION from "./v/8" +import V9_VERSION from "./v/9" +import V10_VERSION from "./v/10" +import { HoppRESTReqBody } from "./v/10/body" +import V11_VERSION from "./v/11" +import V12_VERSION from "./v/12" +import V13_VERSION from "./v/13" +import { HoppRESTAuth } from "./v/15/auth" +import V14_VERSION from "./v/14" +import V15_VERSION from "./v/15/index" +import V16_VERSION from "./v/16" +import { HoppRESTRequestResponses } from "../rest-request-response" +import { generateUniqueRefId } from "../utils/collection" +import V17_VERSION from "./v/17" + +export * from "./content-types" + +export { + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthInherit, + HoppRESTAuthNone, +} from "./v/1" + +export { HoppRESTRequestVariables } from "./v/2" + +export { HoppRESTAuthAPIKey } from "./v/4" + +export { + HoppRESTAuthAWSSignature, + HoppRESTHeaders, + HoppRESTParams, +} from "./v/7" + +export { HoppRESTAuthDigest } from "./v/8/auth" + +export { FormDataKeyValue } from "./v/9/body" + +export { HoppRESTReqBody } from "./v/10/body" + +export { HoppRESTAuthHAWK, HoppRESTAuthAkamaiEdgeGrid } from "./v/12/auth" + +export { HoppRESTAuth, HoppRESTAuthJWT } from "./v/15/auth" +export { AuthCodeGrantTypeParams } from "./v/15/auth" +export { PasswordGrantTypeParams } from "./v/15/auth" +export { ImplicitOauthFlowParams } from "./v/15/auth" +export { + HoppRESTAuthOAuth2, + ClientCredentialsGrantTypeParams, + OAuth2AdvancedParam, + OAuth2AuthRequestParam, +} from "./v/15/auth" + +export { + HoppRESTRequestResponse, + HoppRESTRequestResponses, +} from "../rest-request-response" + +const versionedObject = z.object({ + // v is a stringified number + v: z.string().regex(/^\d+$/).transform(Number), +}) + +export const HoppRESTRequest = createVersionedEntity({ + latestVersion: 17, + versionMap: { + 0: V0_VERSION, + 1: V1_VERSION, + 2: V2_VERSION, + 3: V3_VERSION, + 4: V4_VERSION, + 5: V5_VERSION, + 6: V6_VERSION, + 7: V7_VERSION, + 8: V8_VERSION, + 9: V9_VERSION, + 10: V10_VERSION, + 11: V11_VERSION, + 12: V12_VERSION, + 13: V13_VERSION, + 14: V14_VERSION, + 15: V15_VERSION, + 16: V16_VERSION, + 17: V17_VERSION, + }, + getVersion(data) { + // For V1 onwards we have the v string storing the number + const versionCheck = versionedObject.safeParse(data) + + if (versionCheck.success) return versionCheck.data.v + + // For V0 we have to check the schema + const result = V0_VERSION.schema.safeParse(data) + + return result.success ? 0 : null + }, +}) + +export type HoppRESTRequest = InferredEntity + +// TODO: Handle the issue with the preRequestScript and testScript type check failures on pre-commit +const HoppRESTRequestEq = Eq.struct({ + id: undefinedEq(S.Eq), + v: S.Eq, + auth: lodashIsEqualEq, + body: lodashIsEqualEq, + endpoint: S.Eq, + headers: mapThenEq( + (arr) => arr.filter((h: any) => h.key !== "" && h.value !== ""), + lodashIsEqualEq + ), + params: mapThenEq( + (arr) => arr.filter((p: any) => p.key !== "" && p.value !== ""), + lodashIsEqualEq + ), + method: S.Eq, + name: S.Eq, + preRequestScript: S.Eq, + testScript: S.Eq, + requestVariables: mapThenEq( + (arr) => arr.filter((v: any) => v.key !== "" && v.value !== ""), + lodashIsEqualEq + ), + responses: lodashIsEqualEq, + _ref_id: undefinedEq(S.Eq), + description: lodashIsEqualEq, +}) + +export const RESTReqSchemaVersion = "17" + +export type HoppRESTParam = HoppRESTRequest["params"][number] +export type HoppRESTHeader = HoppRESTRequest["headers"][number] +export type HoppRESTRequestVariable = + HoppRESTRequest["requestVariables"][number] + +export const isEqualHoppRESTRequest = HoppRESTRequestEq.equals + +/** + * Safely tries to extract REST Request data from an unknown value. + * If we fail to detect certain bits, we just resolve it to the default value + * @param x The value to extract REST Request data from + * @param defaultReq The default REST Request to source from + * + * @deprecated Usage of this function is no longer recommended and is only here + * for legacy reasons and will be removed + */ +export function safelyExtractRESTRequest( + x: unknown, + defaultReq: HoppRESTRequest +): HoppRESTRequest { + const req = cloneDeep(defaultReq) + + if (!!x && typeof x === "object") { + if ("id" in x && typeof x.id === "string") req.id = x.id + + if ("_ref_id" in x && typeof x._ref_id === "string") req._ref_id = x._ref_id + + if ("name" in x && typeof x.name === "string") req.name = x.name + + if ("method" in x && typeof x.method === "string") req.method = x.method + + if ("endpoint" in x && typeof x.endpoint === "string") + req.endpoint = x.endpoint + + if ("preRequestScript" in x && typeof x.preRequestScript === "string") + req.preRequestScript = x.preRequestScript + + if ("testScript" in x && typeof x.testScript === "string") + req.testScript = x.testScript + + if ("body" in x) { + const result = HoppRESTReqBody.safeParse(x.body) + + if (result.success) { + req.body = result.data + } + } + + if ("auth" in x) { + const result = HoppRESTAuth.safeParse(x.auth) + + if (result.success) { + req.auth = result.data + } + } + + if ("params" in x) { + const result = HoppRESTParams.safeParse(x.params) + + if (result.success) { + req.params = result.data + } + } + + if ("headers" in x) { + const result = HoppRESTHeaders.safeParse(x.headers) + + if (result.success) { + req.headers = result.data + } + } + + if ("requestVariables" in x) { + const result = HoppRESTRequestVariables.safeParse(x.requestVariables) + + if (result.success) { + req.requestVariables = result.data + } + } + + if ("responses" in x) { + const result = HoppRESTRequestResponses.safeParse(x.responses) + if (result.success) { + req.responses = result.data + } + } + + if ("description" in x && typeof x.description === "string") { + req.description = x.description + } + } + + return req +} + +export function makeRESTRequest( + x: Omit +): HoppRESTRequest { + return { + v: RESTReqSchemaVersion, + _ref_id: x._ref_id ?? generateUniqueRefId("req"), + ...x, + } +} + +export function getDefaultRESTRequest(): HoppRESTRequest { + const ref_id = generateUniqueRefId("req") + return { + v: RESTReqSchemaVersion, + endpoint: "https://echo.hoppscotch.io", + name: "Untitled", + params: [], + headers: [], + method: "GET", + auth: { + authType: "inherit", + authActive: true, + }, + preRequestScript: "", + testScript: "", + body: { + contentType: null, + body: null, + }, + requestVariables: [], + responses: {}, + _ref_id: ref_id, + description: null, + } +} + +/** + * Checks if the given value is a HoppRESTRequest + * @param x The value to check + * + * @deprecated This function is no longer recommended and is only here for legacy reasons + * Use `HoppRESTRequest.is`/`HoppRESTRequest.isLatest` instead. + */ +export function isHoppRESTRequest(x: unknown): x is HoppRESTRequest { + return HoppRESTRequest.isLatest(x) +} + +/** + * Safely parses a value into a HoppRESTRequest. + * @param x The value to check + * + * @deprecated This function is no longer recommended and is only here for + * legacy reasons. Use `HoppRESTRequest.safeParse` instead. + */ +export function translateToNewRequest(x: unknown): HoppRESTRequest { + const result = HoppRESTRequest.safeParse(x) + return result.type === "ok" ? result.value : getDefaultRESTRequest() +} diff --git a/packages/hoppscotch-data/src/rest/v/0.ts b/packages/hoppscotch-data/src/rest/v/0.ts new file mode 100644 index 0000000..77a0752 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/0.ts @@ -0,0 +1,39 @@ +import { defineVersion } from "verzod" +import { z } from "zod" + +export const V0_SCHEMA = z.object({ + id: z.optional(z.string()), // Firebase Firestore ID + + url: z.string(), + path: z.string(), + headers: z.array( + z.object({ + key: z.string(), + value: z.string(), + active: z.boolean() + }) + ), + params: z.array( + z.object({ + key: z.string(), + value: z.string(), + active: z.boolean() + }) + ), + name: z.string(), + method: z.string(), + preRequestScript: z.string(), + testScript: z.string(), + contentType: z.string(), + body: z.string(), + rawParams: z.optional(z.string()), + auth: z.optional(z.string()), + httpUser: z.optional(z.string()), + httpPassword: z.optional(z.string()), + bearerToken: z.optional(z.string()), +}) + +export default defineVersion({ + initial: true, + schema: V0_SCHEMA +}) diff --git a/packages/hoppscotch-data/src/rest/v/1.ts b/packages/hoppscotch-data/src/rest/v/1.ts new file mode 100644 index 0000000..66a13da --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/1.ts @@ -0,0 +1,233 @@ +import { defineVersion } from "verzod" +import { z } from "zod" + +import { V0_SCHEMA } from "./0" + +export const FormDataKeyValue = z + .object({ + key: z.string(), + active: z.boolean(), + }) + .and( + z.union([ + z.object({ + isFile: z.literal(true), + value: z.array(z.instanceof(Blob).nullable()), + }), + z.object({ + isFile: z.literal(false), + value: z.string(), + }), + ]) + ) + +export type FormDataKeyValue = z.infer + +export const HoppRESTReqBodyFormData = z.object({ + contentType: z.literal("multipart/form-data"), + body: z.array(FormDataKeyValue), +}) + +export type HoppRESTReqBodyFormData = z.infer + +export const HoppRESTReqBody = z.union([ + z.object({ + contentType: z.literal(null), + body: z.literal(null).catch(null), + }), + z.object({ + contentType: z.literal("multipart/form-data"), + body: z.array(FormDataKeyValue).catch([]), + }), + z.object({ + contentType: z.union([ + z.literal("application/json"), + z.literal("application/ld+json"), + z.literal("application/hal+json"), + z.literal("application/vnd.api+json"), + z.literal("application/xml"), + z.literal("application/x-www-form-urlencoded"), + z.literal("text/html"), + z.literal("text/plain"), + ]), + body: z.string().catch(""), + }), +]) + +export type HoppRESTReqBody = z.infer + +export const HoppRESTAuthNone = z.object({ + authType: z.literal("none"), +}) + +export type HoppRESTAuthNone = z.infer + +export const HoppRESTAuthBasic = z.object({ + authType: z.literal("basic"), + username: z.string().catch(""), + password: z.string().catch(""), +}) + +export type HoppRESTAuthBasic = z.infer + +export const HoppRESTAuthBearer = z.object({ + authType: z.literal("bearer"), + token: z.string().catch(""), +}) + +export type HoppRESTAuthBearer = z.infer + +export const HoppRESTAuthOAuth2 = z.object({ + authType: z.literal("oauth-2"), + token: z.string().catch(""), + oidcDiscoveryURL: z.string().catch(""), + authURL: z.string().catch(""), + accessTokenURL: z.string().catch(""), + clientID: z.string().catch(""), + scope: z.string().catch(""), +}) + +export type HoppRESTAuthOAuth2 = z.infer + +export const HoppRESTAuthAPIKey = z.object({ + authType: z.literal("api-key"), + key: z.string().catch(""), + value: z.string().catch(""), + addTo: z.string().catch("Headers"), +}) + +export type HoppRESTAuthAPIKey = z.infer + +export const HoppRESTAuthInherit = z.object({ + authType: z.literal("inherit"), +}) + +export type HoppRESTAuthInherit = z.infer + +export const HoppRESTAuth = z + .discriminatedUnion("authType", [ + HoppRESTAuthNone, + HoppRESTAuthInherit, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthOAuth2, + HoppRESTAuthAPIKey, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppRESTAuth = z.infer + +export const HoppRESTParams = z.array( + z.object({ + key: z.string().catch(""), + value: z.string().catch(""), + active: z.boolean().catch(true), + }) +) + +export type HoppRESTParams = z.infer + +export const HoppRESTHeaders = z.array( + z.object({ + key: z.string().catch(""), + value: z.string().catch(""), + active: z.boolean().catch(true), + }) +) + +export type HoppRESTHeaders = z.infer + +export const V1_SCHEMA = z.object({ + v: z.literal("1"), + id: z.optional(z.string()), // Firebase Firestore ID + + name: z.string(), + method: z.string(), + endpoint: z.string(), + params: HoppRESTParams, + headers: HoppRESTHeaders, + preRequestScript: z.string().catch(""), + testScript: z.string().catch(""), + + auth: HoppRESTAuth, + + body: HoppRESTReqBody, +}) + +export function parseRequestBody( + x: z.infer +): z.infer["body"] { + return { + contentType: "application/json", + body: x.contentType === "application/json" ? x.rawParams ?? "" : "", + } +} + +export function parseOldAuth( + x: z.infer +): z.infer["auth"] { + if (!x.auth || x.auth === "None") + return { + authType: "none", + authActive: true, + } + + if (x.auth === "Basic Auth") + return { + authType: "basic", + authActive: true, + username: x.httpUser ?? "", + password: x.httpPassword ?? "", + } + + if (x.auth === "Bearer Token") + return { + authType: "bearer", + authActive: true, + token: x.bearerToken ?? "", + } + + return { authType: "none", authActive: true } +} + +export default defineVersion({ + initial: false, + schema: V1_SCHEMA, + up(old: z.infer) { + const { + url, + path, + headers, + params, + name, + method, + preRequestScript, + testScript, + } = old + + const endpoint = `${url}${path}` + const body = parseRequestBody(old) + const auth = parseOldAuth(old) + + const result: z.infer = { + v: "1", + endpoint, + headers, + params, + name, + method, + preRequestScript, + testScript, + body, + auth, + } + + if (old.id) result.id = old.id + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/rest/v/10/body.ts b/packages/hoppscotch-data/src/rest/v/10/body.ts new file mode 100644 index 0000000..af22ca9 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/10/body.ts @@ -0,0 +1,40 @@ +import { z } from "zod" +import { FormDataKeyValue } from "../9/body" + +export const HoppRESTReqBody = z.union([ + z.object({ + contentType: z.literal(null), + body: z.literal(null).catch(null), + }), + z.object({ + contentType: z.literal("multipart/form-data"), + body: z.array(FormDataKeyValue).catch([]), + showIndividualContentType: z.boolean().optional().catch(false), + isBulkEditing: z.boolean().optional().catch(false), + }), + z.object({ + contentType: z.literal("application/octet-stream"), + body: z.instanceof(File).nullable().catch(null), + }), + z.object({ + contentType: z.literal("application/x-www-form-urlencoded"), + body: z.string().catch(""), + isBulkEditing: z.boolean().optional().catch(false), + }), + z.object({ + contentType: z.union([ + z.literal("application/json"), + z.literal("application/ld+json"), + z.literal("application/hal+json"), + z.literal("application/vnd.api+json"), + z.literal("application/xml"), + z.literal("text/xml"), + z.literal("binary"), + z.literal("text/html"), + z.literal("text/plain"), + ]), + body: z.string().catch(""), + }), +]) + +export type HoppRESTReqBody = z.infer diff --git a/packages/hoppscotch-data/src/rest/v/10/index.ts b/packages/hoppscotch-data/src/rest/v/10/index.ts new file mode 100644 index 0000000..95367fb --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/10/index.ts @@ -0,0 +1,20 @@ +import { z } from "zod" +import { V9_SCHEMA } from "../9" +import { HoppRESTReqBody } from "./body" +import { defineVersion } from "verzod" + +export const V10_SCHEMA = V9_SCHEMA.extend({ + v: z.literal("10"), + body: HoppRESTReqBody, +}) + +export default defineVersion({ + schema: V10_SCHEMA, + initial: false, + up(old: z.infer) { + return { + ...old, + v: "10" as const, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest/v/11/auth.ts b/packages/hoppscotch-data/src/rest/v/11/auth.ts new file mode 100644 index 0000000..81be6cc --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/11/auth.ts @@ -0,0 +1,52 @@ +import { + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthInherit, + HoppRESTAuthNone, +} from "../1" +import { HoppRESTAuthAPIKey } from "../4" +import { AuthCodeGrantTypeParams, HoppRESTAuthAWSSignature } from "../7" +import { + ClientCredentialsGrantTypeParams as ClientCredentialsGrantTypeParamsOld, + HoppRESTAuthDigest, + PasswordGrantTypeParams, +} from "../8/auth" +import { ImplicitOauthFlowParams } from "../3" +import { z } from "zod" + +export const ClientCredentialsGrantTypeParams = + ClientCredentialsGrantTypeParamsOld.extend({ + clientAuthentication: z.enum(["AS_BASIC_AUTH_HEADERS", "IN_BODY"]), + }) + +export const HoppRESTAuthOAuth2 = z.object({ + authType: z.literal("oauth-2"), + grantTypeInfo: z.discriminatedUnion("grantType", [ + AuthCodeGrantTypeParams, + ClientCredentialsGrantTypeParams, + PasswordGrantTypeParams, + ImplicitOauthFlowParams, + ]), + addTo: z.enum(["HEADERS", "QUERY_PARAMS"]).catch("HEADERS"), +}) + +export type HoppRESTAuthOAuth2 = z.infer + +export const HoppRESTAuth = z + .discriminatedUnion("authType", [ + HoppRESTAuthNone, + HoppRESTAuthInherit, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthOAuth2, + HoppRESTAuthAPIKey, + HoppRESTAuthAWSSignature, + HoppRESTAuthDigest, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppRESTAuth = z.infer diff --git a/packages/hoppscotch-data/src/rest/v/11/index.ts b/packages/hoppscotch-data/src/rest/v/11/index.ts new file mode 100644 index 0000000..4fb0d97 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/11/index.ts @@ -0,0 +1,37 @@ +import { z } from "zod" + +import { V10_SCHEMA } from "../10" +import { defineVersion } from "verzod" + +import { HoppRESTAuth } from "./auth" + +export const V11_SCHEMA = V10_SCHEMA.extend({ + v: z.literal("11"), + auth: HoppRESTAuth, +}) + +export default defineVersion({ + schema: V11_SCHEMA, + initial: false, + up(old: z.infer) { + const auth = old.auth + + return { + ...old, + v: "11" as const, + auth: + auth.authType === "oauth-2" + ? { + ...auth, + grantTypeInfo: + auth.grantTypeInfo.grantType === "CLIENT_CREDENTIALS" + ? { + ...auth.grantTypeInfo, + clientAuthentication: "IN_BODY" as const, + } + : auth.grantTypeInfo, + } + : auth, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest/v/12/auth.ts b/packages/hoppscotch-data/src/rest/v/12/auth.ts new file mode 100644 index 0000000..4eb3af7 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/12/auth.ts @@ -0,0 +1,67 @@ +import { z } from "zod" +import { + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthInherit, + HoppRESTAuthNone, +} from "../1" +import { HoppRESTAuthAPIKey } from "../4" +import { HoppRESTAuthAWSSignature } from "../7" +import { HoppRESTAuthDigest } from "../8/auth" +import { HoppRESTAuthOAuth2 } from "../11/auth" + +export const HoppRESTAuthHAWK = z.object({ + authType: z.literal("hawk"), + authId: z.string().catch(""), + authKey: z.string().catch(""), + algorithm: z.enum(["sha256", "sha1"]).catch("sha256"), + includePayloadHash: z.boolean().catch(false), + + // Optional fields + user: z.string().optional(), + nonce: z.string().optional(), + ext: z.string().optional(), + app: z.string().optional(), + dlg: z.string().optional(), + timestamp: z.string().optional(), +}) + +export const HoppRESTAuthAkamaiEdgeGrid = z.object({ + authType: z.literal("akamai-eg"), + accessToken: z.string().catch(""), + clientToken: z.string().catch(""), + clientSecret: z.string().catch(""), + + // Optional fields + nonce: z.string().optional(), + timestamp: z.string().optional(), + host: z.string().optional(), + headersToSign: z.string().optional(), + maxBodySize: z.string().optional(), +}) + +export type HoppRESTAuthHAWK = z.infer +export type HoppRESTAuthAkamaiEdgeGrid = z.infer< + typeof HoppRESTAuthAkamaiEdgeGrid +> + +export const HoppRESTAuth = z + .discriminatedUnion("authType", [ + HoppRESTAuthNone, + HoppRESTAuthInherit, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthOAuth2, + HoppRESTAuthAPIKey, + HoppRESTAuthAWSSignature, + HoppRESTAuthDigest, + HoppRESTAuthHAWK, + HoppRESTAuthAkamaiEdgeGrid, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppRESTAuth = z.infer diff --git a/packages/hoppscotch-data/src/rest/v/12/index.ts b/packages/hoppscotch-data/src/rest/v/12/index.ts new file mode 100644 index 0000000..4946010 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/12/index.ts @@ -0,0 +1,21 @@ +import { z } from "zod" +import { defineVersion } from "verzod" +import { V11_SCHEMA } from "../11" + +import { HoppRESTAuth } from "./auth" + +export const V12_SCHEMA = V11_SCHEMA.extend({ + v: z.literal("12"), + auth: HoppRESTAuth, +}) + +export default defineVersion({ + schema: V12_SCHEMA, + initial: false, + up(old: z.infer) { + return { + ...old, + v: "12" as const, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest/v/13/auth.ts b/packages/hoppscotch-data/src/rest/v/13/auth.ts new file mode 100644 index 0000000..01d2031 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/13/auth.ts @@ -0,0 +1,64 @@ +import { z } from "zod" +import { + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthInherit, + HoppRESTAuthNone, +} from "../1" +import { HoppRESTAuthAPIKey } from "../4" +import { HoppRESTAuthAWSSignature } from "../7" +import { HoppRESTAuthDigest } from "../8/auth" +import { HoppRESTAuthOAuth2 } from "../11/auth" +import { HoppRESTAuthAkamaiEdgeGrid, HoppRESTAuthHAWK } from "../12/auth" + +export const HoppRESTAuthJWT = z.object({ + authType: z.literal("jwt"), + secret: z.string().catch(""), + privateKey: z.string().catch(""), // For RSA/ECDSA algorithms + algorithm: z + .enum([ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "PS256", + "PS384", + "PS512", + "ES256", + "ES384", + "ES512", + ]) + .catch("HS256"), + payload: z.string().catch("{}"), + addTo: z.enum(["HEADERS", "QUERY_PARAMS"]).catch("HEADERS"), + isSecretBase64Encoded: z.boolean().catch(false), + headerPrefix: z.string().catch("Bearer "), + paramName: z.string().catch("token"), + jwtHeaders: z.string().catch("{}"), +}) + +export type HoppRESTAuthJWT = z.infer + +export const HoppRESTAuth = z + .discriminatedUnion("authType", [ + HoppRESTAuthNone, + HoppRESTAuthInherit, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthOAuth2, + HoppRESTAuthAPIKey, + HoppRESTAuthAWSSignature, + HoppRESTAuthDigest, + HoppRESTAuthHAWK, + HoppRESTAuthAkamaiEdgeGrid, + HoppRESTAuthJWT, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppRESTAuth = z.infer diff --git a/packages/hoppscotch-data/src/rest/v/13/index.ts b/packages/hoppscotch-data/src/rest/v/13/index.ts new file mode 100644 index 0000000..ff4ca82 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/13/index.ts @@ -0,0 +1,22 @@ +import { V12_SCHEMA } from "../12" + +import { z } from "zod" +import { defineVersion } from "verzod" + +import { HoppRESTAuth } from "./auth" + +export const V13_SCHEMA = V12_SCHEMA.extend({ + v: z.literal("13"), + auth: HoppRESTAuth, +}) + +export default defineVersion({ + schema: V13_SCHEMA, + initial: false, + up(old: z.infer) { + return { + ...old, + v: "13" as const, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest/v/14.ts b/packages/hoppscotch-data/src/rest/v/14.ts new file mode 100644 index 0000000..f49167b --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/14.ts @@ -0,0 +1,19 @@ +import { z } from "zod" +import { defineVersion } from "verzod" +import { V13_SCHEMA } from "./13" + +// Update the HoppRESTRequestResponses while migrating HoppRESTRequest +export const V14_SCHEMA = V13_SCHEMA.extend({ + v: z.literal("14"), +}) + +export default defineVersion({ + schema: V14_SCHEMA, + initial: false, + up(old: z.infer) { + return { + ...old, + v: "14" as const, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest/v/15/auth.ts b/packages/hoppscotch-data/src/rest/v/15/auth.ts new file mode 100644 index 0000000..08b8c0e --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/15/auth.ts @@ -0,0 +1,96 @@ +import { z } from "zod" +import { + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthInherit, + HoppRESTAuthNone, +} from "../1" +import { HoppRESTAuthAPIKey } from "../4" +import { + AuthCodeGrantTypeParams as AuthCodeGrantTypeParamsOld, + HoppRESTAuthAWSSignature, +} from "../7" +import { + HoppRESTAuthDigest, + PasswordGrantTypeParams as PasswordGrantTypeParamsOld, +} from "../8/auth" +import { HoppRESTAuthAkamaiEdgeGrid, HoppRESTAuthHAWK } from "../12/auth" +import { HoppRESTAuthJWT } from "../13/auth" +import { ClientCredentialsGrantTypeParams as ClientCredentialsGrantTypeParamsOld } from "../11/auth" +import { ImplicitOauthFlowParams as ImplicitOauthFlowParamsOld } from "../3" + +export { HoppRESTAuthJWT } from "../13/auth" + +// Define the OAuth2 advanced parameter structure +export const OAuth2AdvancedParam = z.object({ + id: z.number(), + key: z.string(), + value: z.string(), + active: z.boolean(), + sendIn: z.enum(["headers", "url", "body"]).catch("headers"), +}) + +// omit sendIn from OAuth2AuthRequestParam +export const OAuth2AuthRequestParam = OAuth2AdvancedParam.omit({ sendIn: true }) + +export const AuthCodeGrantTypeParams = AuthCodeGrantTypeParamsOld.extend({ + authRequestParams: z.array(OAuth2AuthRequestParam).optional().default([]), + tokenRequestParams: z.array(OAuth2AdvancedParam).optional().default([]), + refreshRequestParams: z.array(OAuth2AdvancedParam).optional().default([]), + tokenType: z.enum(["access_token", "id_token"]).optional().catch("access_token"), +}) + +export const ClientCredentialsGrantTypeParams = + ClientCredentialsGrantTypeParamsOld.extend({ + tokenRequestParams: z.array(OAuth2AdvancedParam).optional().default([]), + refreshRequestParams: z.array(OAuth2AdvancedParam).optional().default([]), + tokenType: z.enum(["access_token", "id_token"]).optional().catch("access_token"), + }) + +export const PasswordGrantTypeParams = PasswordGrantTypeParamsOld.extend({ + tokenRequestParams: z.array(OAuth2AdvancedParam).optional().default([]), + refreshRequestParams: z.array(OAuth2AdvancedParam).optional().default([]), + tokenType: z.enum(["access_token", "id_token"]).optional().catch("access_token"), +}) + +export const ImplicitOauthFlowParams = ImplicitOauthFlowParamsOld.extend({ + authRequestParams: z.array(OAuth2AuthRequestParam).optional().default([]), + refreshRequestParams: z.array(OAuth2AdvancedParam).optional().default([]), + tokenType: z.enum(["access_token", "id_token"]).optional().catch("access_token"), +}) + +// Extend OAuth2 with advanced parameters +export const HoppRESTAuthOAuth2 = z.object({ + authType: z.literal("oauth-2"), + grantTypeInfo: z.discriminatedUnion("grantType", [ + AuthCodeGrantTypeParams, + ClientCredentialsGrantTypeParams, + PasswordGrantTypeParams, + ImplicitOauthFlowParams, + ]), + addTo: z.enum(["HEADERS", "QUERY_PARAMS"]).catch("HEADERS"), +}) + +export type HoppRESTAuthOAuth2 = z.infer + +export const HoppRESTAuth = z + .discriminatedUnion("authType", [ + HoppRESTAuthNone, + HoppRESTAuthInherit, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthOAuth2, + HoppRESTAuthAPIKey, + HoppRESTAuthAWSSignature, + HoppRESTAuthDigest, + HoppRESTAuthHAWK, + HoppRESTAuthAkamaiEdgeGrid, + HoppRESTAuthJWT, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppRESTAuth = z.infer diff --git a/packages/hoppscotch-data/src/rest/v/15/index.ts b/packages/hoppscotch-data/src/rest/v/15/index.ts new file mode 100644 index 0000000..d7ddced --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/15/index.ts @@ -0,0 +1,69 @@ +import { V14_SCHEMA } from "../14" + +import { z } from "zod" +import { defineVersion } from "verzod" + +import { HoppRESTAuth } from "./auth" + +export const V15_SCHEMA = V14_SCHEMA.extend({ + v: z.literal("15"), + auth: HoppRESTAuth, +}) + +const V15_VERSION = defineVersion({ + schema: V15_SCHEMA, + initial: false, + up(old: z.infer) { + // If the auth is OAuth2, migrate it to include the new advanced parameters + let newAuth: z.infer + if (old.auth.authType === "oauth-2") { + const oldGrantTypeInfo = old.auth.grantTypeInfo + let newGrantTypeInfo + + // Add the advanced parameters to the appropriate grant type + if (oldGrantTypeInfo.grantType === "AUTHORIZATION_CODE") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + authRequestParams: [], + tokenRequestParams: [], + refreshRequestParams: [], + } + } else if (oldGrantTypeInfo.grantType === "CLIENT_CREDENTIALS") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + tokenRequestParams: [], + refreshRequestParams: [], + } + } else if (oldGrantTypeInfo.grantType === "PASSWORD") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + tokenRequestParams: [], + refreshRequestParams: [], + } + } else if (oldGrantTypeInfo.grantType === "IMPLICIT") { + newGrantTypeInfo = { + ...oldGrantTypeInfo, + authRequestParams: [], + refreshRequestParams: [], + } + } else { + newGrantTypeInfo = oldGrantTypeInfo + } + + newAuth = { + ...old.auth, + grantTypeInfo: newGrantTypeInfo, + } as z.infer + } else { + newAuth = old.auth + } + + return { + ...old, + v: "15" as const, + auth: newAuth, + } + }, +}) + +export default V15_VERSION diff --git a/packages/hoppscotch-data/src/rest/v/16.ts b/packages/hoppscotch-data/src/rest/v/16.ts new file mode 100644 index 0000000..aaa40d7 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/16.ts @@ -0,0 +1,23 @@ +import { V15_SCHEMA } from "./15" +import { z } from "zod" +import { defineVersion } from "verzod" +import { generateUniqueRefId } from "../../utils/collection" + +export const V16_SCHEMA = V15_SCHEMA.extend({ + v: z.literal("16"), + _ref_id: z.string().optional(), +}) + +const V16_VERSION = defineVersion({ + schema: V16_SCHEMA, + initial: false, + up(old: z.infer) { + return { + ...old, + v: "16" as const, + _ref_id: generateUniqueRefId("req"), + } + }, +}) + +export default V16_VERSION diff --git a/packages/hoppscotch-data/src/rest/v/17.ts b/packages/hoppscotch-data/src/rest/v/17.ts new file mode 100644 index 0000000..f7aac36 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/17.ts @@ -0,0 +1,22 @@ +import { z } from "zod" +import { defineVersion } from "verzod" +import { V16_SCHEMA } from "./16" + +export const V17_SCHEMA = V16_SCHEMA.extend({ + v: z.literal("17"), + description: z.string().nullable().catch(null), +}) + +const V17_VERSION = defineVersion({ + schema: V17_SCHEMA, + initial: false, + up(old: z.infer) { + return { + ...old, + v: "17" as const, + description: null, + } + }, +}) + +export default V17_VERSION diff --git a/packages/hoppscotch-data/src/rest/v/2.ts b/packages/hoppscotch-data/src/rest/v/2.ts new file mode 100644 index 0000000..bb793c6 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/2.ts @@ -0,0 +1,30 @@ +import { defineVersion } from "verzod" +import { z } from "zod" +import { V1_SCHEMA } from "./1" + +export const HoppRESTRequestVariables = z.array( + z.object({ + key: z.string().catch(""), + value: z.string().catch(""), + active: z.boolean().catch(true), + }) +) + +export type HoppRESTRequestVariables = z.infer + +export const V2_SCHEMA = V1_SCHEMA.extend({ + v: z.literal("2"), + requestVariables: HoppRESTRequestVariables, +}) + +export default defineVersion({ + initial: false, + schema: V2_SCHEMA, + up(old: z.infer) { + return { + ...old, + v: "2", + requestVariables: [], + } as z.infer + }, +}) diff --git a/packages/hoppscotch-data/src/rest/v/3.ts b/packages/hoppscotch-data/src/rest/v/3.ts new file mode 100644 index 0000000..2a2a7bb --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/3.ts @@ -0,0 +1,127 @@ +import { z } from "zod" +import { + HoppRESTAuthAPIKey, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthInherit, + HoppRESTAuthNone, +} from "./1" +import { V2_SCHEMA } from "./2" + +import { defineVersion } from "verzod" + +export const AuthCodeGrantTypeParams = z.object({ + grantType: z.literal("AUTHORIZATION_CODE"), + authEndpoint: z.string().trim(), + tokenEndpoint: z.string().trim(), + clientID: z.string().trim(), + clientSecret: z.string().trim(), + scopes: z.string().trim().optional(), + token: z.string().catch(""), + isPKCE: z.boolean(), + codeVerifierMethod: z + .union([z.literal("plain"), z.literal("S256")]) + .optional(), +}) + +export const ClientCredentialsGrantTypeParams = z.object({ + grantType: z.literal("CLIENT_CREDENTIALS"), + authEndpoint: z.string().trim(), + clientID: z.string().trim(), + clientSecret: z.string().trim(), + scopes: z.string().trim().optional(), + token: z.string().catch(""), +}) + +export const PasswordGrantTypeParams = z.object({ + grantType: z.literal("PASSWORD"), + authEndpoint: z.string().trim(), + clientID: z.string().trim(), + clientSecret: z.string().trim(), + scopes: z.string().trim().optional(), + username: z.string().trim(), + password: z.string().trim(), + token: z.string().catch(""), +}) + +export const ImplicitOauthFlowParams = z.object({ + grantType: z.literal("IMPLICIT"), + authEndpoint: z.string().trim(), + clientID: z.string().trim(), + scopes: z.string().trim().optional(), + token: z.string().catch(""), +}) + +export const HoppRESTAuthOAuth2 = z.object({ + authType: z.literal("oauth-2"), + grantTypeInfo: z.discriminatedUnion("grantType", [ + AuthCodeGrantTypeParams, + ClientCredentialsGrantTypeParams, + PasswordGrantTypeParams, + ImplicitOauthFlowParams, + ]), + addTo: z.enum(["HEADERS", "QUERY_PARAMS"]).catch("HEADERS"), +}) + +export type HoppRESTAuthOAuth2 = z.infer + +export const HoppRESTAuth = z + .discriminatedUnion("authType", [ + HoppRESTAuthNone, + HoppRESTAuthInherit, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthOAuth2, + HoppRESTAuthAPIKey, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppRESTAuth = z.infer + +// V2_SCHEMA has one change in HoppRESTAuthOAuth2, we'll add the grant_type field +export const V3_SCHEMA = V2_SCHEMA.extend({ + v: z.literal("3"), + auth: HoppRESTAuth, +}) + +export default defineVersion({ + initial: false, + schema: V3_SCHEMA, + up(old: z.infer) { + if (old.auth.authType === "oauth-2") { + const { token, accessTokenURL, scope, clientID, authURL } = old.auth + + return { + ...old, + v: "3" as const, + auth: { + ...old.auth, + authType: "oauth-2" as const, + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE" as const, + authEndpoint: authURL, + tokenEndpoint: accessTokenURL, + clientID: clientID, + clientSecret: "", + scopes: scope, + isPKCE: false, + token, + }, + addTo: "HEADERS" as const, + }, + } + } + + return { + ...old, + v: "3" as const, + auth: { + ...old.auth, + }, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest/v/4.ts b/packages/hoppscotch-data/src/rest/v/4.ts new file mode 100644 index 0000000..c8da860 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/4.ts @@ -0,0 +1,69 @@ +import { z } from "zod" +import { defineVersion } from "verzod" +import { HoppRESTAuthOAuth2, V3_SCHEMA } from "./3" +import { + HoppRESTAuthAPIKey as HoppRESTAuthAPIKeyOld, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthInherit, + HoppRESTAuthNone, +} from "./1" + +// in this new version, we update the old 'Headers' and 'Query params' to be more consistent with OAuth addTo values +// also in the previous version addTo was a string, which prevented some bugs from being caught by the type system +// this version uses an enum, so we get the values as literals in the type system +export const HoppRESTAuthAPIKey = HoppRESTAuthAPIKeyOld.extend({ + addTo: z.enum(["HEADERS", "QUERY_PARAMS"]).catch("HEADERS"), +}) + +export type HoppRESTAuthAPIKey = z.infer + +export const HoppRESTAuth = z + .discriminatedUnion("authType", [ + HoppRESTAuthNone, + HoppRESTAuthInherit, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthOAuth2, + HoppRESTAuthAPIKey, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppRESTAuth = z.infer + +export const V4_SCHEMA = V3_SCHEMA.extend({ + v: z.literal("4"), + auth: HoppRESTAuth, +}) + +export default defineVersion({ + schema: V4_SCHEMA, + initial: false, + up(old: z.infer) { + if (old.auth.authType === "api-key") { + return { + ...old, + v: "4" as const, + auth: { + ...old.auth, + addTo: + old.auth.addTo === "Query params" + ? ("QUERY_PARAMS" as const) + : ("HEADERS" as const), + }, + } + } + + return { + ...old, + auth: { + ...old.auth, + }, + v: "4" as const, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest/v/5.ts b/packages/hoppscotch-data/src/rest/v/5.ts new file mode 100644 index 0000000..f1c5680 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/5.ts @@ -0,0 +1,66 @@ +import { z } from "zod" +import { defineVersion } from "verzod" +import { + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthInherit, + HoppRESTAuthNone, +} from "./1" +import { HoppRESTAuthAPIKey, V4_SCHEMA } from "./4" +import { + AuthCodeGrantTypeParams as AuthCodeGrantTypeParamsOld, + ClientCredentialsGrantTypeParams, + HoppRESTAuthOAuth2 as HoppRESTAuthOAuth2Old, + ImplicitOauthFlowParams, + PasswordGrantTypeParams, +} from "./3" + +export const AuthCodeGrantTypeParams = AuthCodeGrantTypeParamsOld.extend({ + clientSecret: z.string().optional(), +}) + +export const HoppRESTAuthOAuth2 = HoppRESTAuthOAuth2Old.extend({ + grantTypeInfo: z.discriminatedUnion("grantType", [ + AuthCodeGrantTypeParams, + ClientCredentialsGrantTypeParams, + PasswordGrantTypeParams, + ImplicitOauthFlowParams, + ]), +}) + +export type HoppRESTAuthOAuth2 = z.infer + +export const HoppRESTAuth = z + .discriminatedUnion("authType", [ + HoppRESTAuthNone, + HoppRESTAuthInherit, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthOAuth2, + HoppRESTAuthAPIKey, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppRESTAuth = z.infer + +export const V5_SCHEMA = V4_SCHEMA.extend({ + v: z.literal("5"), + auth: HoppRESTAuth, +}) + +export default defineVersion({ + schema: V5_SCHEMA, + initial: false, + up(old: z.infer) { + // v5 is not a breaking change in terms of migrations + // we're just making clientSecret in authcode + pkce flow optional + return { + ...old, + v: "5" as const, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest/v/6.ts b/packages/hoppscotch-data/src/rest/v/6.ts new file mode 100644 index 0000000..7c53491 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/6.ts @@ -0,0 +1,49 @@ +import { defineVersion } from "verzod" +import { z } from "zod" + +import { FormDataKeyValue } from "./1" +import { V5_SCHEMA } from "./5" + +export const HoppRESTReqBody = z.union([ + z.object({ + contentType: z.literal(null), + body: z.literal(null).catch(null), + }), + z.object({ + contentType: z.literal("multipart/form-data"), + body: z.array(FormDataKeyValue).catch([]), + }), + z.object({ + contentType: z.union([ + z.literal("application/json"), + z.literal("application/ld+json"), + z.literal("application/hal+json"), + z.literal("application/vnd.api+json"), + z.literal("application/xml"), + z.literal("text/xml"), + z.literal("application/x-www-form-urlencoded"), + z.literal("text/html"), + z.literal("text/plain"), + ]), + body: z.string().catch(""), + }), +]) + +export type HoppRESTReqBody = z.infer + +export const V6_SCHEMA = V5_SCHEMA.extend({ + v: z.literal("6"), + body: HoppRESTReqBody, +}) + +export default defineVersion({ + schema: V6_SCHEMA, + initial: false, + up(old: z.infer) { + // No migration, `text/xml` is added to the list of supported content types + return { + ...old, + v: "6" as const, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest/v/7.ts b/packages/hoppscotch-data/src/rest/v/7.ts new file mode 100644 index 0000000..9b291e3 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/7.ts @@ -0,0 +1,128 @@ +import { z } from "zod" +import { defineVersion } from "verzod" +import { + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthInherit, + HoppRESTAuthNone, +} from "./1" +import { HoppRESTAuthAPIKey } from "./4" +import { V6_SCHEMA } from "./6" + +import { AuthCodeGrantTypeParams as AuthCodeGrantTypeParamsOld } from "./5" + +import { + ClientCredentialsGrantTypeParams, + ImplicitOauthFlowParams, + PasswordGrantTypeParams, +} from "./3" + +// Add refreshToken to all grant types except Implicit +export const AuthCodeGrantTypeParams = AuthCodeGrantTypeParamsOld.extend({ + refreshToken: z.string().optional(), +}) + +export const HoppRESTAuthOAuth2 = z.object({ + authType: z.literal("oauth-2"), + grantTypeInfo: z.discriminatedUnion("grantType", [ + AuthCodeGrantTypeParams, + ClientCredentialsGrantTypeParams, + PasswordGrantTypeParams, + ImplicitOauthFlowParams, + ]), + addTo: z.enum(["HEADERS", "QUERY_PARAMS"]).catch("HEADERS"), +}) + +export type HoppRESTAuthOAuth2 = z.infer + +export const HoppRESTParams = z.array( + z.object({ + key: z.string().catch(""), + value: z.string().catch(""), + active: z.boolean().catch(true), + description: z.string().catch(""), + }) +) + +export type HoppRESTParams = z.infer + +export const HoppRESTHeaders = z.array( + z.object({ + key: z.string().catch(""), + value: z.string().catch(""), + active: z.boolean().catch(true), + description: z.string().catch(""), + }) +) + +export type HoppRESTHeaders = z.infer + +// in this new version, we add a new auth type for AWS Signature +// this auth type is used for AWS Signature V5 authentication +// it requires the user to provide the access key id, secret access key, region, service name, and service token + +export const HoppRESTAuthAWSSignature = z.object({ + authType: z.literal("aws-signature"), + accessKey: z.string().catch(""), + secretKey: z.string().catch(""), + region: z.string().catch(""), + serviceName: z.string().catch(""), + serviceToken: z.string().optional(), + signature: z.object({}).optional(), + addTo: z.enum(["HEADERS", "QUERY_PARAMS"]).catch("HEADERS"), +}) + +export type HoppRESTAuthAWSSignature = z.infer + +export const HoppRESTAuth = z + .discriminatedUnion("authType", [ + HoppRESTAuthNone, + HoppRESTAuthInherit, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthOAuth2, + HoppRESTAuthAPIKey, + HoppRESTAuthAWSSignature, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppRESTAuth = z.infer + +export const V7_SCHEMA = V6_SCHEMA.extend({ + v: z.literal("7"), + params: HoppRESTParams, + headers: HoppRESTHeaders, + auth: HoppRESTAuth, +}) + +export default defineVersion({ + schema: V7_SCHEMA, + initial: false, + up(old: z.infer) { + const params = old.params.map((param) => { + return { + ...param, + description: "", + } + }) + + const headers = old.headers.map((header) => { + return { + ...header, + description: "", + } + }) + + return { + ...old, + v: "7" as const, + params, + headers, + // no need to update anything for HoppRESTAuth, because the newly added refreshToken is optional + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest/v/8/auth.ts b/packages/hoppscotch-data/src/rest/v/8/auth.ts new file mode 100644 index 0000000..1ff66e1 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/8/auth.ts @@ -0,0 +1,75 @@ +import { + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthInherit, + HoppRESTAuthNone, +} from "../1" + +import { HoppRESTAuthAPIKey } from "../4" + +import { + ClientCredentialsGrantTypeParams as ClientCredentialsGrantTypeParamsOld, + ImplicitOauthFlowParams, + PasswordGrantTypeParams as PasswordGrantTypeParamsOld, +} from "../3" + +import { AuthCodeGrantTypeParams, HoppRESTAuthAWSSignature } from "../7" +import { z } from "zod" + +export const ClientCredentialsGrantTypeParams = + ClientCredentialsGrantTypeParamsOld.extend({ + clientSecret: z.string().optional(), + }) + +export const PasswordGrantTypeParams = PasswordGrantTypeParamsOld.extend({ + clientSecret: z.string().optional(), +}) + +export const HoppRESTAuthOAuth2 = z.object({ + authType: z.literal("oauth-2"), + grantTypeInfo: z.discriminatedUnion("grantType", [ + AuthCodeGrantTypeParams, + ClientCredentialsGrantTypeParams, + PasswordGrantTypeParams, + ImplicitOauthFlowParams, + ]), + addTo: z.enum(["HEADERS", "QUERY_PARAMS"]).catch("HEADERS"), +}) + +export type HoppRESTAuthOAuth2 = z.infer + +// in this new version, we add a new auth type for Digest authentication +export const HoppRESTAuthDigest = z.object({ + authType: z.literal("digest"), + username: z.string().catch(""), + password: z.string().catch(""), + realm: z.string().catch(""), + nonce: z.string().catch(""), + algorithm: z.enum(["MD5", "MD5-sess"]).catch("MD5"), + qop: z.enum(["auth", "auth-int"]).catch("auth"), + nc: z.string().catch(""), + cnonce: z.string().catch(""), + opaque: z.string().catch(""), + disableRetry: z.boolean().catch(false), +}) + +export type HoppRESTAuthDigest = z.infer + +export const HoppRESTAuth = z + .discriminatedUnion("authType", [ + HoppRESTAuthNone, + HoppRESTAuthInherit, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthOAuth2, + HoppRESTAuthAPIKey, + HoppRESTAuthAWSSignature, + HoppRESTAuthDigest, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppRESTAuth = z.infer diff --git a/packages/hoppscotch-data/src/rest/v/8/index.ts b/packages/hoppscotch-data/src/rest/v/8/index.ts new file mode 100644 index 0000000..c1b818f --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/8/index.ts @@ -0,0 +1,27 @@ +import { defineVersion } from "verzod" +import { z } from "zod" + +import { V7_SCHEMA } from "../7" + +import { HoppRESTRequestResponses } from "../../../rest-request-response" + +import { HoppRESTAuth } from "./auth" + +export const V8_SCHEMA = V7_SCHEMA.extend({ + v: z.literal("8"), + auth: HoppRESTAuth, + responses: HoppRESTRequestResponses, +}) + +export default defineVersion({ + schema: V8_SCHEMA, + initial: false, + up(old: z.infer) { + return { + ...old, + v: "8" as const, + // no need to update anything for HoppRESTAuth, because we loosened the previous schema by making `clientSecret` optional + responses: {}, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest/v/9/body.ts b/packages/hoppscotch-data/src/rest/v/9/body.ts new file mode 100644 index 0000000..1ad19ae --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/9/body.ts @@ -0,0 +1,68 @@ +import { z } from "zod" + +export const FormDataKeyValue = z + .object({ + key: z.string(), + active: z.boolean(), + contentType: z.string().optional().catch(undefined), + }) + .and( + z.union([ + z.object({ + isFile: z.literal(true), + value: z.array(z.instanceof(Blob).nullable()).catch([]), + }), + z.object({ + isFile: z.literal(false), + value: z.string(), + }), + ]) + ) + .transform((data) => { + // Sample use case about restoring the `value` field in an empty state during page reload + // for files chosen in the previous attempt + if (data.isFile && Array.isArray(data.value) && data.value.length === 0) { + return { + ...data, + isFile: false, + value: "", + } + } + + return data + }) + +export type FormDataKeyValue = z.infer + +export const HoppRESTReqBody = z.union([ + z.object({ + contentType: z.literal(null), + body: z.literal(null).catch(null), + }), + z.object({ + contentType: z.literal("multipart/form-data"), + body: z.array(FormDataKeyValue).catch([]), + showIndividualContentType: z.boolean().optional().catch(false), + }), + z.object({ + contentType: z.literal("application/octet-stream"), + body: z.instanceof(File).nullable().catch(null), + }), + z.object({ + contentType: z.union([ + z.literal("application/json"), + z.literal("application/ld+json"), + z.literal("application/hal+json"), + z.literal("application/vnd.api+json"), + z.literal("application/xml"), + z.literal("text/xml"), + z.literal("application/x-www-form-urlencoded"), + z.literal("binary"), + z.literal("text/html"), + z.literal("text/plain"), + ]), + body: z.string().catch(""), + }), +]) + +export type HoppRESTReqBody = z.infer diff --git a/packages/hoppscotch-data/src/rest/v/9/index.ts b/packages/hoppscotch-data/src/rest/v/9/index.ts new file mode 100644 index 0000000..e38a64d --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/9/index.ts @@ -0,0 +1,22 @@ +import { defineVersion } from "verzod" +import { z } from "zod" + +import { V8_SCHEMA } from "../8" +import { HoppRESTReqBody } from "./body" + +export const V9_SCHEMA = V8_SCHEMA.extend({ + v: z.literal("9"), + body: HoppRESTReqBody, +}) + +export default defineVersion({ + schema: V9_SCHEMA, + initial: false, + up(old: z.infer) { + // No migration for body, the new contentType added to each formdata field is optional + return { + ...old, + v: "9" as const, + } + }, +}) diff --git a/packages/hoppscotch-data/src/type-utils.d.ts b/packages/hoppscotch-data/src/type-utils.d.ts new file mode 100644 index 0000000..91440dd --- /dev/null +++ b/packages/hoppscotch-data/src/type-utils.d.ts @@ -0,0 +1,3 @@ +interface Object { + hasOwnProperty(key: K): this is Record; +} diff --git a/packages/hoppscotch-data/src/utils/akamai-eg.ts b/packages/hoppscotch-data/src/utils/akamai-eg.ts new file mode 100644 index 0000000..e0363ff --- /dev/null +++ b/packages/hoppscotch-data/src/utils/akamai-eg.ts @@ -0,0 +1,65 @@ +export async function calculateAkamaiEdgeGridHeader(params: { + accessToken: string + clientToken: string + clientSecret: string + url: string + method: string + body?: string // Add body parameter + nonce?: string + timestamp?: string + host?: string + headersToSign?: string + maxBodySize?: string +}) { + const encoder = new TextEncoder() + const decoder = new TextDecoder() + + const timestamp = params.timestamp || Math.floor(Date.now() / 1000).toString() + const nonce = params.nonce || crypto.randomUUID() + const host = params.host || new URL(params.url).host + + // 1. Create signing key using clientSecret + const keyMaterial = await crypto.subtle.importKey( + "raw", + encoder.encode(params.clientSecret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ) + + const signingKey = await crypto.subtle.sign( + "HMAC", + keyMaterial, + encoder.encode(timestamp) + ) + + // 2. Calculate content hash if request has body + let contentHash = "" + if (params.body) { + const hashBuffer = await crypto.subtle.digest( + "SHA-256", + encoder.encode(params.body) + ) + contentHash = Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + } + + // 3. Create string to sign + const data = `${params.method} ${params.url} ${host} ${timestamp} ${nonce} ${contentHash}` + + // 4. Sign the string using the signing key + const signatureBuffer = await crypto.subtle.sign( + "HMAC", + keyMaterial, + encoder.encode(data) + ) + const signature = btoa( + String.fromCharCode(...new Uint8Array(signatureBuffer)) + ) + + // 5. Format the final authorization header + const authorizationHeader = `EG1-HMAC-SHA256 client_token=${params.clientToken};access_token=${params.accessToken};timestamp=${timestamp};nonce=${nonce};signature=${signature}` + + return authorizationHeader +} diff --git a/packages/hoppscotch-data/src/utils/collection.ts b/packages/hoppscotch-data/src/utils/collection.ts new file mode 100644 index 0000000..78d75c4 --- /dev/null +++ b/packages/hoppscotch-data/src/utils/collection.ts @@ -0,0 +1,13 @@ +import { v4 as uuidV4 } from "uuid" + +/** + * Generate a unique reference ID + * @param prefix Prefix to add to the generated ID + * @returns The generated reference ID + */ +export const generateUniqueRefId = (prefix = "") => { + const timestamp = Date.now().toString(36) + const randomPart = uuidV4() + + return `${prefix}_${timestamp}_${randomPart}` +} diff --git a/packages/hoppscotch-data/src/utils/eq.ts b/packages/hoppscotch-data/src/utils/eq.ts new file mode 100644 index 0000000..d90eac9 --- /dev/null +++ b/packages/hoppscotch-data/src/utils/eq.ts @@ -0,0 +1,50 @@ +import * as Eq from "fp-ts/Eq" +import * as S from "fp-ts/string" +import isEqual from "lodash/isEqual" + +/* + * Eq-s are fp-ts an interface (type class) that defines how the equality + * of 2 values of a certain type are matched as equal + */ + + +/** + * Create an Eq from a non-undefinable value and makes it accept undefined + * @param eq The non nullable Eq to add to + * @returns The updated Eq which accepts undefined + */ +export const undefinedEq = (eq: Eq.Eq): Eq.Eq => ({ + equals(x: T | undefined, y: T | undefined) { + if (x !== undefined && y !== undefined) { + return eq.equals(x, y) + } + + return x === undefined && y === undefined + } +}) + +/** + * An Eq which compares by transforming based on a mapping function and then applying the Eq to it + * @param map The mapping function to map values to + * @param eq The Eq which takes the value which the map returns + * @returns An Eq which takes the input of the mapping function + */ +export const mapThenEq = (map: (x: A) => B, eq: Eq.Eq): Eq.Eq
=> ({ + equals(x: A, y: A) { + return eq.equals(map(x), map(y)) + } +}) + +/** + * An Eq which checks equality of 2 string in a case insensitive way + */ +export const stringCaseInsensitiveEq: Eq.Eq = mapThenEq(S.toLowerCase, S.Eq) + +/** + * An Eq that does equality check with Lodash's isEqual function + */ +export const lodashIsEqualEq: Eq.Eq = { + equals(x: any, y: any) { + return isEqual(x, y) + } +} diff --git a/packages/hoppscotch-data/src/utils/hawk.ts b/packages/hoppscotch-data/src/utils/hawk.ts new file mode 100644 index 0000000..2eebc1e --- /dev/null +++ b/packages/hoppscotch-data/src/utils/hawk.ts @@ -0,0 +1,224 @@ +import { HoppRESTRequest } from "../rest" + +interface HawkOptions { + id: string + key: string + algorithm: "sha256" | "sha1" + method: string + url: string + + // Optional parameters + user?: string + nonce?: string + ext?: string + app?: string + dlg?: string + timestamp?: number + + // Payload options + includePayloadHash: boolean + payload?: string | FormData | File | null | Blob + contentType?: HoppRESTRequest["body"]["contentType"] +} + +async function generateNonce(length: number = 6): Promise { + const array = new Uint8Array(length) + crypto.getRandomValues(array) + return Array.from(array) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + .substring(0, length) +} + +function sha256Hash(data: string): Promise { + const encoder = new TextEncoder() + const dataBuffer = encoder.encode(data) + return crypto.subtle.digest("SHA-256", dataBuffer) +} + +function sha1Hash(data: string): Promise { + const encoder = new TextEncoder() + const dataBuffer = encoder.encode(data) + return crypto.subtle.digest("SHA-1", dataBuffer) +} + +async function hashData( + data: string, + algorithm: "sha256" | "sha1" +): Promise { + return algorithm === "sha256" ? sha256Hash(data) : sha1Hash(data) +} + +async function hmacSign( + key: string, + message: string, + algorithm: "sha256" | "sha1" +): Promise { + const encoder = new TextEncoder() + const keyData = encoder.encode(key) + const messageData = encoder.encode(message) + + const cryptoAlgo = algorithm === "sha256" ? "SHA-256" : "SHA-1" + + const cryptoKey = await crypto.subtle.importKey( + "raw", + keyData, + { + name: "HMAC", + hash: { name: cryptoAlgo }, + }, + false, + ["sign"] + ) + + const signature = await crypto.subtle.sign("HMAC", cryptoKey, messageData) + + // Convert to base64 string + return btoa(String.fromCharCode.apply(null, [...new Uint8Array(signature)])) +} + +/** + * Normalize line endings to '\n' to ensure consistent hash generation + */ +function normalizeLineEndings(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") +} + +/** + * Get the content of a payload for hash calculation + * This function needs to exactly match the server's payload hash calculation + */ +async function getPayloadContent( + payload: string | FormData | File | Blob | null, + contentType: string +): Promise { + if (!payload) return "" + + // For form data + if (payload instanceof FormData) { + if (contentType === "multipart/form-data") { + // For multipart form data, we need to extract the parts + const parts: string[] = [] + payload.forEach((value, key) => { + if (typeof value === "string") { + parts.push(`${key}=${value}`) + } else { + // For file parts, use the file name + parts.push(`${key}=${value instanceof File ? value.name : "blob"}`) + } + }) + return normalizeLineEndings(parts.join("&")) + } else { + // For url-encoded form data + const pairs: string[] = [] + payload.forEach((value, key) => { + if (typeof value === "string") { + pairs.push(`${key}=${encodeURIComponent(value)}`) + } + }) + return normalizeLineEndings(pairs.join("&")) + } + } + + // For blob/file types + if (payload instanceof Blob) { + try { + const text = await payload.text() + return normalizeLineEndings(text) + } catch (e) { + console.error("Failed to read blob content", e) + return "" + } + } + + // Handle JSON specifically + if (contentType.includes("application/json") && typeof payload === "string") { + try { + // Parse and re-stringify to ensure consistent formatting + const jsonObj = JSON.parse(payload) + return normalizeLineEndings(JSON.stringify(jsonObj)) + } catch (e) { + // If not valid JSON, use as-is + return normalizeLineEndings(payload.toString()) + } + } + + // Default: convert to string and normalize line endings + return normalizeLineEndings(payload.toString()) +} + +export async function calculateHawkHeader( + options: HawkOptions +): Promise { + const timestamp = + options.timestamp !== undefined && options.timestamp !== null + ? options.timestamp + : Math.floor(Date.now() / 1000) + + // Use provided nonce or generate a new one + const nonce = + options.nonce && options.nonce !== "" + ? options.nonce + : await generateNonce() + + // Parse URL + const urlObj = new URL(options.url) + const host = urlObj.hostname + const port = urlObj.port || (urlObj.protocol === "https:" ? "443" : "80") + const path = urlObj.pathname + urlObj.search + + // Create the normalized string + const artifacts = { + ts: timestamp, + nonce: nonce, + method: options.method.toUpperCase(), + resource: path, + host: host, + port: port, + hash: "", + ext: options.ext || "", + } + + // Calculate payload hash if needed + if (options.includePayloadHash && options.payload) { + try { + const contentType = options.contentType || "text/plain" + const content = await getPayloadContent(options.payload, contentType) + + // Create the normalized payload string as per HAWK spec + const normalizedPayload = `hawk.1.payload\n${contentType}\n${content}\n` + + // Hash the normalized payload + const contentHash = await hashData(normalizedPayload, options.algorithm) + + // Convert hash to base64 + artifacts.hash = btoa( + String.fromCharCode.apply(null, [...new Uint8Array(contentHash)]) + ) + } catch (error) { + console.error("Error calculating payload hash:", error) + } + } + + // Construct the string to sign according to Hawk spec + const macBaseString = `hawk.1.header\n${artifacts.ts}\n${artifacts.nonce}\n${artifacts.method}\n${artifacts.resource}\n${artifacts.host}\n${artifacts.port}\n${artifacts.hash}\n${artifacts.ext}\n` + + // Calculate MAC + const mac = await hmacSign(options.key, macBaseString, options.algorithm) + + // Construct the Hawk header + const header = [ + `Hawk id="${options.id}"`, + `ts="${artifacts.ts}"`, + `nonce="${artifacts.nonce}"`, + `mac="${mac}"`, + ] + + // Add optional parameters if present + if (options.ext && options.ext !== "") header.push(`ext="${options.ext}"`) + if (options.app && options.app !== "") header.push(`app="${options.app}"`) + if (options.dlg && options.dlg !== "") header.push(`dlg="${options.dlg}"`) + if (artifacts.hash !== "") header.push(`hash="${artifacts.hash}"`) + + return header.join(", ") +} diff --git a/packages/hoppscotch-data/src/utils/jwt.ts b/packages/hoppscotch-data/src/utils/jwt.ts new file mode 100644 index 0000000..18e1846 --- /dev/null +++ b/packages/hoppscotch-data/src/utils/jwt.ts @@ -0,0 +1,99 @@ +import * as jose from "jose" + +export interface JWTTokenParams { + algorithm: string + secret: string + privateKey: string + payload: string + jwtHeaders: string + isSecretBase64Encoded: boolean +} + +/** + * Generates a JWT token using the provided parameters + * @param params JWT token generation parameters with pre-parsed values + * @returns Promise - The generated JWT token or null if generation fails + */ +export async function generateJWTToken( + params: JWTTokenParams +): Promise { + const { + algorithm, + secret, + privateKey, + payload, + jwtHeaders, + isSecretBase64Encoded, + } = params + + // Parse the payload and headers from JSON strings + let parsedPayload = {} + let parsedHeaders = {} + + // Safely parse payload JSON + try { + const payloadString = payload?.trim() || "{}" + if (payloadString === "") { + parsedPayload = {} + } else { + parsedPayload = JSON.parse(payloadString) + } + } catch (e) { + console.error("Failed to parse JWT payload JSON:", e) + console.error("Payload value:", payload) + return null + } + + // Safely parse headers JSON + try { + const headersString = jwtHeaders?.trim() || "{}" + if (headersString === "") { + parsedHeaders = {} + } else { + parsedHeaders = JSON.parse(headersString) + } + } catch (e) { + console.error("Failed to parse JWT headers JSON:", e) + console.error("Headers value:", jwtHeaders) + return null + } + + try { + let cryptoKey: Uint8Array + + // Use private key for RSA/ECDSA algorithms, secret for HMAC algorithms + if ( + algorithm.startsWith("RS") || + algorithm.startsWith("ES") || + algorithm.startsWith("PS") + ) { + // RSA or ECDSA algorithms - use private key + if (!privateKey) { + console.error("Private key is required for RSA/ECDSA algorithms") + return null + } + cryptoKey = new TextEncoder().encode(privateKey) + } else { + // HMAC algorithms - use secret + if (!secret) { + console.error("Secret is required for HMAC algorithms") + return null + } + cryptoKey = isSecretBase64Encoded + ? Uint8Array.from(Buffer.from(secret, "base64")) + : new TextEncoder().encode(secret) + } + + const token = await new jose.SignJWT(parsedPayload) + .setProtectedHeader({ + alg: algorithm, + ...parsedHeaders, + }) + .sign(cryptoKey) + + return token + } catch (e) { + console.error("Error generating JWT token:", e) + return null + } +} diff --git a/packages/hoppscotch-data/src/utils/record.ts b/packages/hoppscotch-data/src/utils/record.ts new file mode 100644 index 0000000..6db6a1c --- /dev/null +++ b/packages/hoppscotch-data/src/utils/record.ts @@ -0,0 +1,17 @@ + +/** + * Modify a record value with a mapping function + * @param name The key to update + * @param func The value to update + * @returns The updated record + */ +export const recordUpdate = + < + X extends {}, + K extends keyof X, + R + >(name: K, func: (input: X[K]) => R) => + (x: X): Omit & { [x in K]: R } => ({ + ...x, + [name]: func(x[name]) + }) diff --git a/packages/hoppscotch-data/src/utils/statusCodes.ts b/packages/hoppscotch-data/src/utils/statusCodes.ts new file mode 100644 index 0000000..8838f1c --- /dev/null +++ b/packages/hoppscotch-data/src/utils/statusCodes.ts @@ -0,0 +1,101 @@ +export const StatusCodes: { + [key: number]: string +} = { + // 1xx Informational + // Request received, continuing process.[2] + // This class of status code indicates a provisional response, consisting only of the Status-Line and optional headers, and is terminated by an empty line. Since HTTP/1.0 did not define any 1xx status codes, servers must not send a 1xx response to an HTTP/1.0 client except under experimental conditions. + 100: "Continue", // This means that the server has received the request headers, and that the client should proceed to send the request body (in the case of a request for which a body needs to be sent; for example, a POST request). If the request body is large, sending it to a server when a request has already been rejected based upon inappropriate headers is inefficient. To have a server check if the request could be accepted based on the request's headers alone, a client must send Expect: 100-continue as a header in its initial request[2] and check if a 100 Continue status code is received in response before continuing (or receive 417 Expectation Failed and not continue).[2] + 101: "Switching Protocols", // This means the requester has asked the server to switch protocols and the server is acknowledging that it will do so.[2] + 102: "Processing", // (WebDAV; RFC 2518) As a WebDAV request may contain many sub-requests involving file operations, it may take a long time to complete the request. This code indicates that the server has received and is processing the request, but no response is available yet.[3] This prevents the client from timing out and assuming the request was lost. + // 2xx Success + // This class of status codes indicates the action requested by the client was received, understood, accepted and processed successfully. + 200: "OK", // Standard response for successful HTTP requests. The actual response will depend on the request method used. In a GET request, the response will contain an entity corresponding to the requested resource. In a POST request the response will contain an entity describing or containing the result of the action.[2] + 201: "Created", // The request has been fulfilled and resulted in a new resource being created.[2] + 202: "Accepted", // The request has been accepted for processing, but the processing has not been completed. The request might or might not eventually be acted upon, as it might be disallowed when processing actually takes place.[2] + 203: "Non-Authoritative Information", // (since HTTP/1.1) The server successfully processed the request, but is returning information that may be from another source.[2] + 204: "No Content", // The server successfully processed the request, but is not returning any content.[2] + 205: "Reset Content", // The server successfully processed the request, but is not returning any content. Unlike a 204 response, this response requires that the requester reset the document view.[2] + 206: "Partial Content", // The server is delivering only part of the resource due to a range header sent by the client. The range header is used by tools like wget to enable resuming of interrupted downloads, or split a download into multiple simultaneous streams.[2] + 207: "Multi-Status", // (WebDAV; RFC 4918) The message body that follows is an XML message and can contain a number of separate response codes, depending on how many sub-requests were made.[4] + 208: "Already Reported", // (WebDAV; RFC 5842) The members of a DAV binding have already been enumerated in a previous reply to this request, and are not being included again. + 226: "IM Used", // (RFC 3229) The server has fulfilled a GET request for the resource, and the response is a representation of the result of one or more instance-manipulations applied to the current instance. [5] + // 3xx Redirection + // The client must take additional action to complete the request.[2] + // This class of status code indicates that further action needs to be taken by the user agent in order to fulfil the request. The action required may be carried out by the user agent without interaction with the user if and only if the method used in the second request is GET or HEAD. A user agent should not automatically redirect a request more than five times, since such redirections usually indicate an infinite loop. + 300: "Multiple Choices", // Indicates multiple options for the resource that the client may follow. It, for instance, could be used to present different format options for video, list files with different extensions, or word sense disambiguation.[2] + 301: "Moved Permanently", // This and all future requests should be directed to the given URI.[2] + 302: "Found", // This is an example of industry practice contradicting the standard.[2] The HTTP/1.0 specification (RFC 1945) required the client to perform a temporary redirect (the original describing phrase was "Moved Temporarily"),[6] but popular browsers implemented 302 with the functionality of a 303 See Other. Therefore, HTTP/1.1 added status codes 303 and 307 to distinguish between the two behaviours.[7] However, some Web applications and frameworks use the 302 status code as if it were the 303.[citation needed] + 303: "See Other", // (since HTTP/1.1) The response to the request can be found under another URI using a GET method. When received in response to a POST (or PUT/DELETE), it should be assumed that the server has received the data and the redirect should be issued with a separate GET message.[2] + 304: "Not Modified", // Indicates the resource has not been modified since last requested.[2] Typically, the HTTP client provides a header like the If-Modified-Since header to provide a time against which to compare. Using this saves bandwidth and reprocessing on both the server and client, as only the header data must be sent and received in comparison to the entirety of the page being re-processed by the server, then sent again using more bandwidth of the server and client. + 305: "Use Proxy", // (since HTTP/1.1) Many HTTP clients (such as Mozilla[8] and Internet Explorer) do not correctly handle responses with this status code, primarily for security reasons.[2] + 306: "Switch Proxy", // No longer used.[2] Originally meant "Subsequent requests should use the specified proxy."[9] + 307: "Temporary Redirect", // (since HTTP/1.1) In this case, the request should be repeated with another URI; however, future requests can still use the original URI.[2] In contrast to 302, the request method should not be changed when reissuing the original request. For instance, a POST request must be repeated using another POST request. + 308: "Permanent Redirect", // (experimental Internet-Draft)[10] The request, and all future requests should be repeated using another URI. 307 and 308 (as proposed) parallel the behaviours of 302 and 301, but do not require the HTTP method to change. So, for example, submitting a form to a permanently redirected resource may continue smoothly. + // 4xx Client Error + // The 4xx class of status code is intended for cases in which the client seems to have erred. Except when responding to a HEAD request, the server should include an entity containing an explanation of the error situation, and whether it is a temporary or permanent condition. These status codes are applicable to any request method. User agents should display any included entity to the user. + 400: "Bad Request", // The request cannot be fulfilled due to bad syntax.[2] + 401: "Unauthorized", // Similar to 403 Forbidden, but specifically for use when authentication is possible but has failed or not yet been provided.[2] The response must include a WWW-Authenticate header field containing a challenge applicable to the requested resource. See Basic access authentication and Digest access authentication. + 402: "Payment Required", // Reserved for future use.[2] The original intention was that this code might be used as part of some form of digital cash or micropayment scheme, but that has not happened, and this code is not usually used. As an example of its use, however, Apple's MobileMe service generates a 402 error ("httpStatusCode:402" in the Mac OS X Console log) if the MobileMe account is delinquent.[citation needed] + 403: "Forbidden", // The request was a legal request, but the server is refusing to respond to it.[2] Unlike a 401 Unauthorized response, authenticating will make no difference.[2] + 404: "Not Found", // The requested resource could not be found but may be available again in the future.[2] Subsequent requests by the client are permissible. + 405: "Method Not Allowed", // A request was made of a resource using a request method not supported by that resource;[2] for example, using GET on a form which requires data to be presented via POST, or using PUT on a read-only resource. + 406: "Not Acceptable", // The requested resource is only capable of generating content not acceptable according to the Accept headers sent in the request.[2] + 407: "Proxy Authentication Required", // The client must first authenticate itself with the proxy.[2] + 408: "Request Timeout", // The server timed out waiting for the request.[2] According to W3 HTTP specifications: "The client did not produce a request within the time that the server was prepared to wait. The client MAY repeat the request without modifications at any later time." + 409: "Conflict", // Indicates that the request could not be processed because of conflict in the request, such as an edit conflict.[2] + 410: "Gone", // Indicates that the resource requested is no longer available and will not be available again.[2] This should be used when a resource has been intentionally removed and the resource should be purged. Upon receiving a 410 status code, the client should not request the resource again in the future. Clients such as search engines should remove the resource from their indices. Most use cases do not require clients and search engines to purge the resource, and a "404 Not Found" may be used instead. + 411: "Length Required", // The request did not specify the length of its content, which is required by the requested resource.[2] + 412: "Precondition Failed", // The server does not meet one of the preconditions that the requester put on the request.[2] + 413: "Request Entity Too Large", // The request is larger than the server is willing or able to process.[2] + 414: "Request-URI Too Long", // The URI provided was too long for the server to process.[2] + 415: "Unsupported Media Type", // The request entity has a media type which the server or resource does not support.[2] For example, the client uploads an image as image/svg+xml, but the server requires that images use a different format. + 416: "Requested Range Not Satisfiable", // The client has asked for a portion of the file, but the server cannot supply that portion.[2] For example, if the client asked for a part of the file that lies beyond the end of the file.[2] + 417: "Expectation Failed", // The server cannot meet the requirements of the Expect request-header field.[2] + 418: "I'm a teapot", // (RFC 2324) This code was defined in 1998 as one of the traditional IETF April Fools' jokes, in RFC 2324, Hyper Text Coffee Pot Control Protocol, and is not expected to be implemented by actual HTTP servers. However, known implementations do exist.[11] + 420: "Enhance Your Calm", // (Twitter) Returned by the Twitter Search and Trends API when the client is being rate limited.[12] Likely a reference to this number's association with marijuana. Other services may wish to implement the 429 Too Many Requests response code instead. The phrase "Enhance Your Calm" is a reference to Demolition Man (film). In the film, Sylvester Stallone's character John Spartan is a hot-head in a generally more subdued future, and is regularly told to "Enhance your calm" rather than a more common phrase like "calm down". + 422: "Unprocessable Entity", // (WebDAV; RFC 4918) The request was well-formed but was unable to be followed due to semantic errors.[4] + 423: "Locked", // (WebDAV; RFC 4918) The resource that is being accessed is locked.[4] + 424: "Failed Dependency", // (WebDAV; RFC 4918) The request failed due to failure of a previous request (e.g. a PROPPATCH).[4] + 425: "Unordered Collection", // (Internet draft) Defined in drafts of "WebDAV Advanced Collections Protocol",[14] but not present in "Web Distributed Authoring and Versioning (WebDAV) Ordered Collections Protocol".[15] + 426: "Upgrade Required", // (RFC 2817) The client should switch to a different protocol such as TLS/1.0.[16] + 428: "Precondition Required", // (RFC 6585) The origin server requires the request to be conditional. Intended to prevent "the 'lost update' problem, where a client GETs a resource's state, modifies it, and PUTs it back to the server, when meanwhile a third party has modified the state on the server, leading to a conflict."[17] + 429: "Too Many Requests", // (RFC 6585) The user has sent too many requests in a given amount of time. Intended for use with rate limiting schemes.[17] + 431: "Request Header Fields Too Large", // (RFC 6585) The server is unwilling to process the request because either an individual header field, or all the header fields collectively, are too large.[17] + 444: "No Response", // (Nginx) Used in Nginx logs to indicate that the server has returned no information to the client and closed the connection (useful as a deterrent for malware). + 449: "Retry With", // (Microsoft) A Microsoft extension. The request should be retried after performing the appropriate action.[18] Often search-engines or custom applications will ignore required parameters. Where no default action is appropriate, the Aviongoo website sends a "HTTP/1.1 449 Retry with valid parameters: param1, param2, . . ." response. The applications may choose to learn, or not. + 450: "Blocked by Windows Parental Controls", // (Microsoft) A Microsoft extension. This error is given when Windows Parental Controls are turned on and are blocking access to the given webpage.[19] + 451: "Unavailable For Legal Reasons", // (Internet draft) Defined in the internet draft "A New HTTP Status Code for Legally-restricted Resources",[20]. Intended to be used when resource access is denied for legal reasons, e.g. censorship or government-mandated blocked access. Likely a reference to the 1953 dystopian novel Fahrenheit 451, where books are outlawed. + 499: "Client Closed Request", // (Nginx) Used in Nginx logs to indicate when the connection has been closed by client while the server is still processing its request, making server unable to send a status code back.[21] + // 5xx Server Error + // The server failed to fulfill an apparently valid request.[2] + // Response status codes beginning with the digit "5" indicate cases in which the server is aware that it has encountered an error or is otherwise incapable of performing the request. Except when responding to a HEAD request, the server should include an entity containing an explanation of the error situation, and indicate whether it is a temporary or permanent condition. Likewise, user agents should display any included entity to the user. These response codes are applicable to any request method. + 500: "Internal Server Error", // A generic error message, given when no more specific message is suitable.[2] + 501: "Not Implemented", // The server either does not recognise the request method, or it lacks the ability to fulfill the request.[2] + 502: "Bad Gateway", // The server was acting as a gateway or proxy and received an invalid response from the upstream server.[2] + 503: "Service Unavailable", // The server is currently unavailable (because it is overloaded or down for maintenance).[2] Generally, this is a temporary state. + 504: "Gateway Timeout", // The server was acting as a gateway or proxy and did not receive a timely response from the upstream server.[2] + 505: "HTTP Version Not Supported", // The server does not support the HTTP protocol version used in the request.[2] + 506: "Variant Also Negotiates", // (RFC 2295) Transparent content negotiation for the request results in a circular reference.[22] + 507: "Insufficient Storage", // (WebDAV; RFC 4918) The server is unable to store the representation needed to complete the request.[4] + 508: "Loop Detected", // (WebDAV; RFC 5842) The server detected an infinite loop while processing the request (sent in lieu of 208). + 509: "Bandwidth Limit Exceeded", // (Apache bw/limited extension) This status code, while used by many servers, is not specified in any RFCs. + 510: "Not Extended", // (RFC 2774) Further extensions to the request are required for the server to fulfill it.[23] + 511: "Network Authentication Required", // (RFC 6585) The client needs to authenticate to gain network access. Intended for use by intercepting proxies used to control access to the network (e.g. "captive portals" used to require agreement to Terms of Service before granting full Internet access via a Wi-Fi hotspot).[17] + 598: "Network read timeout error", // (Unknown) This status code is not specified in any RFCs, but is used by Microsoft Corp. HTTP proxies to signal a network read timeout behind the proxy to a client in front of the proxy. + 599: "Network connect timeout error", // (Unknown) This status code is not specified in any RFCs, but is used by Microsoft Corp. HTTP proxies to signal a network connect timeout behind the proxy to a client in front of the proxy. +} as const + +export function getStatusCodeReasonPhrase( + code: number, + statusText?: string +): string { + // Return statusText if non-empty after trimming and add ellipsis if greater than 35 characters + const trimmedStatusText = statusText?.trim() + if (trimmedStatusText) { + return trimmedStatusText.length > 35 + ? `${trimmedStatusText.substring(0, 35)}...` + : trimmedStatusText + } + + return StatusCodes[code] ?? "Unknown" +} diff --git a/packages/hoppscotch-data/tsconfig.decl.json b/packages/hoppscotch-data/tsconfig.decl.json new file mode 100644 index 0000000..26e4dc7 --- /dev/null +++ b/packages/hoppscotch-data/tsconfig.decl.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "esnext", + "lib": ["esnext", "DOM"], + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "strictNullChecks": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "emitDeclarationOnly": true, + "declarationDir": "./dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/hoppscotch-data/tsconfig.json b/packages/hoppscotch-data/tsconfig.json new file mode 100644 index 0000000..a87ab59 --- /dev/null +++ b/packages/hoppscotch-data/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "esnext", + "lib": ["esnext", "DOM"], + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "strictNullChecks": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/hoppscotch-data/vite.config.ts b/packages/hoppscotch-data/vite.config.ts new file mode 100644 index 0000000..f416483 --- /dev/null +++ b/packages/hoppscotch-data/vite.config.ts @@ -0,0 +1,15 @@ +import { tuple } from "io-ts" +import { resolve } from "path" +import { defineConfig } from "vite" + +export default defineConfig({ + build: { + outDir: "./dist", + emptyOutDir: true, + lib: { + entry: resolve(__dirname, "src/index.ts"), + fileName: "hoppscotch-data", + formats: ["es", "cjs"], + }, + }, +}) diff --git a/packages/hoppscotch-desktop/.gitignore b/packages/hoppscotch-desktop/.gitignore new file mode 100644 index 0000000..f5e62d3 --- /dev/null +++ b/packages/hoppscotch-desktop/.gitignore @@ -0,0 +1,41 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +bundle.zip +manifest.json + +src-tauri/hopp_bundle.zip +src-tauri/hopp_manifest.json +src-tauri/hoppscotch-desktop-data +target + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml diff --git a/packages/hoppscotch-desktop/README.md b/packages/hoppscotch-desktop/README.md new file mode 100644 index 0000000..a6f5fcc --- /dev/null +++ b/packages/hoppscotch-desktop/README.md @@ -0,0 +1,166 @@ +# Hoppscotch Desktop App ALPHA + + + +
+ +#### Hoppscotch Desktop App is a cross-platform [Hoppscotch](https://hoppscotch.io) app built with [Tauri V2](https://v2.tauri.app/) + +![Hoppscotch Desktop App](desktop-app.png) + +#### Now with the ability to connect to Self-Hosted instances + +![Hoppscotch Desktop App](connection-to-self-hosted-instance.png) + +## Installation + +1. [Download the latest version of Hoppscotch Desktop App](https://hoppscotch.com/download) +2. Open the downloaded file. +3. Follow the on-screen instructions to install Hoppscotch Desktop App. +4. Open Hoppscotch Desktop App. + +##### Using Homebrew + +You can use Homebrew (on macOS or Linux) to install Hoppscotch + +```bash +brew install --cask hoppscotch +``` + +## Access Options + +### Hoppscotch Cloud Edition for Individuals + +1. Open Hoppscotch Desktop App. +2. Click the Hoppscotch logo in the top-left corner. +3. Click "**HOPPSCOTCH CLOUD**". +4. Sign in with your Hoppscotch Cloud account to access your workspaces and collections. + +### Self-Hosted Edition (Community and Enterprise) + +> [!Note] +> To enable desktop app support for your self-hosted Hoppscotch instance, make sure to update the `WHITELISTED_ORIGINS` environment variable in your `.env` file with your deployment URL. +> +> e.g. to allow connection to `https://hoppscotch.mydomain.com` you need to add `app://hoppscotch_mydomain_com` (MacOS, Linux) and `http://app.hoppscotch_mydomain_com` (Windows) to the `WHITELISTED_ORIGINS` environment variable. +> ```bash +> WHITELISTED_ORIGINS=...existing_origins,app://hoppscotch_mydomain_com,http://app.hoppscotch_mydomain_com +> ``` + +To connect to your self-hosted (community or enterprise) instance: + +1. Open Hoppscotch Desktop App. +2. Click the Hoppscotch logo in the top-left corner. +3. Click "**Add an instance**". +4. Enter the URL of your self-hosted Hoppscotch instance. +5. Click "**Connect**". + +> [!Note] +> For docker setup, the desktop app uses a server at port `3200`, and it is part of the frontend container: +> +> ``` +> ❯ docker run -p 3000:3000 -p 3200:3200 hoppscotch/hoppscotch-frontend +> ``` +> +> Once the container is live, you can enter `[your-ip]:3200` or simply the base address of the instance if you are using [subpath access](https://docs.hoppscotch.io/guides/articles/self-host-hoppscotch-on-your-own-servers#4-subpath-access). + +## Building and Self-Hosting Hoppscotch Desktop + +You can also build Hoppscotch Desktop locally to self-host with on-prem infra: + +1. Install and generate the selfhost web app: + ```bash + cd ../hoppscotch-selfhost-web + pnpm install + pnpm generate + ``` +2. Build the `webapp-bundler`: + ```bash + cd crates/webapp-bundler + cargo build --release + ``` +3. Bundle the web app: + ```bash + cd target/release + ./webapp-bundler --input [path-to-dist-directory] --output [path-to-hoppscotch-desktop]/bundle.zip --manifest [path-to-hoppscotch-desktop]/manifest.json + ``` +4. Run the development server: + ```bash + cd hoppscotch-desktop + pnpm tauri dev + ``` + or the following for production build: + ```bash + cd src-tauri + pnpm tauri dev + ``` + +> [!Note] +> `[path-to-dist-directory]` should point to the `dist` directory created by the `pnpm generate` command in step 1. + +## Minimum System Requirements + +### Windows +- **OS Version**: Windows 10 1803+ or Windows 11 +- **Architecture**: x64 + +### macOS +- **OS Version**: macOS 10.15 (Catalina) or later +- **Architecture**: Intel x64 or Apple Silicon (ARM64) + +### Linux +- **Architecture**: x64 +- **Recommended OS**: Ubuntu 24.04 or newer (or similar flavor of distros) +- **Minimum**: Systems with GLIBC 2.38+ + +#### Why Ubuntu 24.04-like flavors or newer? + +Ubuntu 24.04-like flavors ships with the exact WebKit2GTK version (2.44.0-2) that is stable enough to correctly handle interaction between WebKit, UI libraries, Mesa drivers, and Wayland displays.[^1][^2][^3] + +> [!IMPORTANT] +> There may be some display oddities on Wayland systems caused by the interaction between WebKit and the underlying graphics drivers.[^4][^5][^6] +> +> **Workaround**: +> ```bash +> WEBKIT_DISABLE_COMPOSITING_MODE=1 hoppscotch +> # or +> WEBKIT_DISABLE_DMABUF_RENDERER=1 hoppscotch +> # or both together +> ``` + +### Misc. + +- **Older distributions**: The AppImage requires GLIBC 2.38+ [^1][^7] + - Users on older systems will see GLIBC version errors like "GLIBC_2.32' not found"[^8] +- **Tauri v2 dependency**: The desktop app requires libwebkit2gtk-4.1, which is only available by default in Ubuntu 22.04+ repositories[^9] +- **Build from source**: GitHub workflow for building from source[^10] + +--- + +### Sources + +[^1]: [WebKit version pinning and GLIBC explanation](https://github.com/hoppscotch/hoppscotch/issues/3543#issuecomment-2869628299) - Detailed explanation of why specific webkit2gtk 2.44.0-2 is used and GLIBC 2.38+ requirement + +[^2]: [WebKit 2.44.0-2 selection rationale](https://github.com/hoppscotch/hoppscotch/issues/4880#issuecomment-2014063000) - Why this specific version provides the best balance for Wayland support + +[^3]: [Ubuntu webkit2gtk package versions](https://packages.ubuntu.com/search?keywords=webkit2gtk&searchon=names) - Official Ubuntu package repositories showing version availability + +[^4]: [EGL/Mesa/Wayland bug report](https://bugs.launchpad.net/ubuntu/+source/webkit2gtk/+bug/1966418) - Comprehensive bug report about webkit apps showing blank screens on Wayland + +[^5]: [WebKit GTK Wayland compositing issue](https://bugs.webkit.org/show_bug.cgi?id=165246) - Upstream WebKit bug about compositing mode failures + +[^6]: [Mesa issue with webkit2gtk](https://gitlab.freedesktop.org/mesa/mesa/-/issues/6236) - Mesa driver interaction with webkit2gtk causing EGL initialization failures + +[^7]: [GLIBC compatibility matrix](https://github.com/hoppscotch/hoppscotch/issues/3543#issuecomment-1077225935) - User reports of specific GLIBC version errors + +[^8]: [Specific GLIBC error examples](https://github.com/hoppscotch/hoppscotch/issues/3543#issuecomment-1329816314) - User reports showing exact GLIBC version error messages + +[^9]: [Tauri v2 webkit requirements](https://github.com/tauri-apps/tauri/issues/8535#issuecomment-2162723242) - Tauri v2's dependency on libwebkit2gtk-4.1 + +[^10]: [Hoppscotch Desktop build workflow](https://github.com/hoppscotch/hoppscotch/blob/main/.github/workflows/build-hoppscotch-desktop.yml) - Official build workflow and instructions diff --git a/packages/hoppscotch-desktop/connection-to-self-hosted-instance.png b/packages/hoppscotch-desktop/connection-to-self-hosted-instance.png new file mode 100644 index 0000000..508610c Binary files /dev/null and b/packages/hoppscotch-desktop/connection-to-self-hosted-instance.png differ diff --git a/packages/hoppscotch-desktop/crates/webapp-bundler/.gitignore b/packages/hoppscotch-desktop/crates/webapp-bundler/.gitignore new file mode 100644 index 0000000..6cf4d68 --- /dev/null +++ b/packages/hoppscotch-desktop/crates/webapp-bundler/.gitignore @@ -0,0 +1,21 @@ +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml + +/target/ + +/gen/schemas + +.env + +bundles + +trust/ + +site/ diff --git a/packages/hoppscotch-desktop/crates/webapp-bundler/Cargo.lock b/packages/hoppscotch-desktop/crates/webapp-bundler/Cargo.lock new file mode 100644 index 0000000..ccfe9de --- /dev/null +++ b/packages/hoppscotch-desktop/crates/webapp-bundler/Cargo.lock @@ -0,0 +1,1169 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys", +] + +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "blake3" +version = "1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8ee0c1824c4dea5b5f81736aff91bae041d2c07ee1192bec91054e10e3e601e" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.11+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "cc" +version = "1.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7777341816418c02e033934a09f20dc0ccaf65a5201ef8a450ae0105a573fda" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.5.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e77c3243bd94243c03672cb5154667347c457ca271254724f9f393aee1c05ff" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "flate2" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.169" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" + +[[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + +[[package]] +name = "log" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924" +dependencies = [ + "adler2", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rustversion" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" + +[[package]] +name = "ryu" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.217" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.217" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.138" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webapp-bundler" +version = "0.1.0" +dependencies = [ + "base64", + "blake3", + "chrono", + "clap", + "mime_guess", + "rayon", + "serde", + "serde_json", + "thiserror", + "walkdir", + "zip", + "zstd", +] + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae9c1ea7b3a5e1f4b922ff856a129881167511563dc219869afe3787fc0c1a45" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "hmac", + "indexmap", + "lzma-rs", + "memchr", + "pbkdf2", + "rand", + "sha1", + "thiserror", + "time", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a3ab4db68cea366acc5c897c7b4d4d1b8994a9cd6e6f841f8964566a419059" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.13+zstd.1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38ff0f21cfee8f97d94cef41359e0c89aa6113028ab0291aa8ca0038995a95aa" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/packages/hoppscotch-desktop/crates/webapp-bundler/Cargo.toml b/packages/hoppscotch-desktop/crates/webapp-bundler/Cargo.toml new file mode 100644 index 0000000..5bd7685 --- /dev/null +++ b/packages/hoppscotch-desktop/crates/webapp-bundler/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "webapp-bundler" +version = "0.1.0" +authors = ["CuriousCorrelation"] +edition = "2024" +publish = false + +[dependencies] +base64 = "0.22.1" +blake3 = { version = "1.5.4", features = ["serde"] } +chrono = { version = "0.4.39", features = ["serde"] } +clap = { version = "4.5.28", features = ["derive"] } +mime_guess = "2.0.5" +rayon = "1.10.0" +serde = { version = "1.0.215", features = ["derive"] } +serde_json = "1.0.133" +thiserror = "2.0.3" +walkdir = "2.5.0" +zip = "2.2.0" +zstd = "0.13.2" diff --git a/packages/hoppscotch-desktop/crates/webapp-bundler/src/main.rs b/packages/hoppscotch-desktop/crates/webapp-bundler/src/main.rs new file mode 100644 index 0000000..05204fd --- /dev/null +++ b/packages/hoppscotch-desktop/crates/webapp-bundler/src/main.rs @@ -0,0 +1,221 @@ +/// This is just `webapp-server`'s bundler part as a CLI +use std::fs::File; +use std::io::{Cursor, Write}; +use std::path::PathBuf; + +use clap::Parser; +use rayon::prelude::*; +use thiserror::Error; +use walkdir::WalkDir; +use zip::{ZipWriter, write::SimpleFileOptions}; + +#[derive(Error, Debug)] +pub enum BundlerError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("ZIP error: {0}")] + Zip(#[from] zip::result::ZipError), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Invalid path: {0}")] + InvalidPath(String), + + #[error("Configuration error: {0}")] + Config(String), +} + +type Result = std::result::Result; + +#[derive(Parser, Debug)] +#[command(author, version, about = "Creates a bundle from a directory")] +struct Args { + /// Path to the directory to bundle + #[arg(short, long)] + input: PathBuf, + + /// Output path for the bundle + #[arg(short, long)] + output: PathBuf, + + /// Path to save the manifest file (optional) + #[arg(short, long)] + manifest: Option, + + /// Custom version for the bundle (defaults to CLI tool version) + #[arg(short, long)] + version: Option, +} + +#[derive(serde::Serialize)] +struct FileEntry { + path: String, + size: u64, + #[serde(with = "hash_serde")] + hash: blake3::Hash, + mime_type: Option, +} + +#[derive(serde::Serialize)] +struct Manifest { + files: Vec, + version: String, + created_at: chrono::DateTime, +} + +mod hash_serde { + use base64::{Engine, engine::general_purpose::STANDARD}; + use serde::Serializer; + + pub fn serialize(hash: &blake3::Hash, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&STANDARD.encode(hash.as_bytes())) + } +} + +struct BundleBuilder { + writer: ZipWriter>>, + files: Vec, +} + +impl BundleBuilder { + fn new>(input_path: P) -> Result { + let input_path = input_path.as_ref(); + + if !input_path.exists() { + return Err(BundlerError::Config(format!( + "Input path {} does not exist", + input_path.display() + ))); + } + + struct FileInfo { + relative_path: String, + content: Vec, + hash: blake3::Hash, + size: u64, + mime_type: Option, + } + + let file_infos: Vec = WalkDir::new(input_path) + .into_iter() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_type().is_file()) + .par_bridge() + .map(|entry| { + let path = entry.path(); + let relative_path = path + .strip_prefix(input_path) + .unwrap() + .components() + .map(|comp| comp.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + + let content = std::fs::read(path).map_err(|e| { + BundlerError::Config(format!("Failed to read file {}: {}", path.display(), e)) + })?; + + let hash = blake3::hash(&content); + let size = content.len() as u64; + + let mime_type = mime_guess::from_path(path) + .first() + .map(|mime| mime.to_string()); + + Ok(FileInfo { + relative_path, + content, + hash, + size, + mime_type, + }) + }) + .collect::>>()?; + + let mut builder = Self { + writer: ZipWriter::new(Cursor::new(Vec::new())), + files: Vec::with_capacity(file_infos.len()), + }; + + for file_info in file_infos { + let options = SimpleFileOptions::default().unix_permissions(0o644); + + builder + .writer + .start_file(&file_info.relative_path, options) + .map_err(|e| BundlerError::Config(format!("Failed to start file in zip: {}", e)))?; + + builder + .writer + .write_all(&file_info.content) + .map_err(|e| BundlerError::Config(format!("Failed to write file to zip: {}", e)))?; + + builder.files.push(FileEntry { + path: file_info.relative_path, + size: file_info.size, + hash: file_info.hash, + mime_type: file_info.mime_type, + }); + } + + Ok(builder) + } + + fn finish(self) -> Result<(Vec, Vec)> { + let writer = self + .writer + .finish() + .map_err(|e| BundlerError::Config(format!("Failed to finish zip archive: {}", e)))?; + + Ok((writer.into_inner(), self.files)) + } +} + +fn main() -> Result<()> { + let args = Args::parse(); + + if !args.input.exists() { + return Err(BundlerError::InvalidPath(format!( + "Input path does not exist: {}", + args.input.display() + ))); + } + + println!("Creating bundle from directory: {}", args.input.display()); + + let builder = BundleBuilder::new(&args.input)?; + let (content, files) = builder.finish()?; + + let version = args.version + .or_else(|| std::env::var("WEBAPP_BUNDLE_VERSION").ok()) + .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()); + println!("Using bundle version: {}", version); + + let mut output_file = File::create(&args.output)?; + output_file.write_all(&content)?; + println!("Bundle written to: {}", args.output.display()); + + let manifest = Manifest { + files, + version, + created_at: chrono::Utc::now(), + }; + + if let Some(manifest_path) = args.manifest { + let manifest_json = serde_json::to_string_pretty(&manifest)?; + std::fs::write(manifest_path.clone(), manifest_json)?; + println!("Manifest written to: {}", manifest_path.display()); + } + + println!( + "Bundle created successfully with {} files", + manifest.files.len() + ); + + Ok(()) +} diff --git a/packages/hoppscotch-desktop/desktop-app.png b/packages/hoppscotch-desktop/desktop-app.png new file mode 100644 index 0000000..38ab8e4 Binary files /dev/null and b/packages/hoppscotch-desktop/desktop-app.png differ diff --git a/packages/hoppscotch-desktop/eslint.config.mjs b/packages/hoppscotch-desktop/eslint.config.mjs new file mode 100644 index 0000000..8cd9a49 --- /dev/null +++ b/packages/hoppscotch-desktop/eslint.config.mjs @@ -0,0 +1,79 @@ +import pluginVue from "eslint-plugin-vue" +import { + defineConfigWithVueTs, + vueTsConfigs, +} from "@vue/eslint-config-typescript" +import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended" +import globals from "globals" + +export default defineConfigWithVueTs( + { + ignores: [ + "static/**", + "src/helpers/backend/graphql.ts", + "**/*.d.ts", + "types/**", + "dist/**", + "node_modules/**", + ], + }, + pluginVue.configs["flat/recommended"], + vueTsConfigs.recommended, + eslintPluginPrettierRecommended, + { + files: ["**/*.ts", "**/*.js", "**/*.vue"], + linterOptions: { + reportUnusedDisableDirectives: false, + }, + languageOptions: { + sourceType: "module", + ecmaVersion: "latest", + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + requireConfigFile: false, + ecmaFeatures: { + jsx: false, + }, + }, + }, + rules: { + semi: [2, "never"], + "import/named": "off", + "no-console": "off", + "no-debugger": process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn", + "prettier/prettier": + process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn", + "vue/multi-word-component-names": "off", + "vue/no-side-effects-in-computed-properties": "off", + "import/no-named-as-default": "off", + "import/no-named-as-default-member": "off", + "@typescript-eslint/no-unused-vars": + process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn", + "@typescript-eslint/no-unused-expressions": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-function-type": "off", + "import/default": "off", + "no-undef": "off", + "no-restricted-globals": [ + "error", + { + name: "localStorage", + message: + "Do not use 'localStorage' directly. Please use the PersistenceService", + }, + ], + "no-restricted-syntax": [ + "error", + { + selector: "CallExpression[callee.object.property.name='localStorage']", + message: + "Do not use 'localStorage' directly. Please use the PersistenceService", + }, + ], + }, + } +) diff --git a/packages/hoppscotch-desktop/index.html b/packages/hoppscotch-desktop/index.html new file mode 100644 index 0000000..6baef12 --- /dev/null +++ b/packages/hoppscotch-desktop/index.html @@ -0,0 +1,14 @@ + + + + + + + Hoppscotch Desktop App + + + +
+ + + diff --git a/packages/hoppscotch-desktop/package.json b/packages/hoppscotch-desktop/package.json new file mode 100644 index 0000000..0e98281 --- /dev/null +++ b/packages/hoppscotch-desktop/package.json @@ -0,0 +1,69 @@ +{ + "name": "hoppscotch-desktop", + "private": true, + "version": "26.6.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "tauri": "tauri", + "lint": "eslint src", + "lint:ts": "vue-tsc --noEmit", + "lintfix": "eslint --fix src", + "prod-lint": "cross-env HOPP_LINT_FOR_PROD=true pnpm run lint", + "do-lint": "pnpm run prod-lint", + "do-typecheck": "pnpm run lint:ts", + "do-lintfix": "pnpm run lintfix", + "prepare-web": "(cd ../hoppscotch-selfhost-web && pnpm install && pnpm generate) && (cd crates/webapp-bundler && cargo build --release && cd target/release && ./webapp-bundler --input ../../../../../hoppscotch-selfhost-web/dist --output ../../../../bundle.zip --manifest ../../../../manifest.json)", + "dev:full": "pnpm tauri dev", + "build:full": "pnpm tauri build", + "dev:portable": "pnpm tauri dev -- --no-default-features --features portable", + "build:portable": "pnpm tauri build -- --no-default-features --features portable" + }, + "dependencies": { + "@fontsource-variable/inter": "5.2.8", + "@fontsource-variable/material-symbols-rounded": "5.2.45", + "@fontsource-variable/roboto-mono": "5.2.9", + "@hoppscotch/common": "workspace:^", + "@hoppscotch/kernel": "workspace:^", + "@hoppscotch/plugin-appload": "github:CuriousCorrelation/tauri-plugin-appload#9d4528be4f385bccbe46859631d31aa2ee1ec0b6", + "@hoppscotch/ui": "0.2.6", + "@tauri-apps/api": "2.1.1", + "@tauri-apps/plugin-fs": "2.0.2", + "@tauri-apps/plugin-process": "2.2.0", + "@tauri-apps/plugin-shell": "2.3.3", + "@tauri-apps/plugin-store": "2.4.1", + "@tauri-apps/plugin-updater": "2.9.0", + "fp-ts": "2.16.11", + "rxjs": "7.8.2", + "vue": "3.5.38", + "vue-router": "4.6.4", + "vue-tippy": "6.7.1", + "zod": "3.25.32" + }, + "devDependencies": { + "@eslint/eslintrc": "3.3.5", + "@eslint/js": "9.39.2", + "@iconify-json/lucide": "1.2.114", + "@rushstack/eslint-patch": "1.16.1", + "@tauri-apps/cli": "2.9.3", + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@vitejs/plugin-vue": "6.0.7", + "@vue/eslint-config-typescript": "14.8.0", + "autoprefixer": "10.5.0", + "eslint": "9.39.2", + "eslint-plugin-prettier": "5.5.6", + "eslint-plugin-vue": "10.9.2", + "globals": "16.5.0", + "postcss": "8.5.15", + "sass": "1.101.0", + "tailwindcss": "3.4.16", + "typescript": "5.9.3", + "unplugin-icons": "22.5.0", + "unplugin-vue-components": "30.0.0", + "vite": "7.3.2", + "vue-tsc": "2.2.0" + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/.envrc b/packages/hoppscotch-desktop/plugin-workspace/relay/.envrc new file mode 100644 index 0000000..894571b --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/.envrc @@ -0,0 +1,3 @@ +source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k=" + +use devenv diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/.gitignore b/packages/hoppscotch-desktop/plugin-workspace/relay/.gitignore new file mode 100644 index 0000000..e6f42ef --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/.gitignore @@ -0,0 +1,19 @@ +/target +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/Cargo.lock b/packages/hoppscotch-desktop/plugin-workspace/relay/Cargo.lock new file mode 100644 index 0000000..32eaf08 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/Cargo.lock @@ -0,0 +1,1232 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +dependencies = [ + "anstyle", + "windows-sys 0.59.0", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f34d93e62b03caf570cccc334cbc6c2fceca82f39211051345108adcba3eebdc" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "curl" +version = "0.4.47" +source = "git+https://github.com/CuriousCorrelation/curl-rust.git#1ec8079cf527b9cf47cc7a48c68b458affdae273" +dependencies = [ + "curl-sys", + "libc", + "openssl-probe", + "openssl-sys", + "socket2", +] + +[[package]] +name = "curl-sys" +version = "0.4.77+curl-8.10.1" +source = "git+https://github.com/CuriousCorrelation/curl-rust.git#1ec8079cf527b9cf47cc7a48c68b458affdae273" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "windows-sys 0.52.0", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-serde" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd" +dependencies = [ + "http", + "serde", +] + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "infer" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847" +dependencies = [ + "cfb", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.167" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d6582e104315a817dff97f75133544b2e094ee22447d2acf4a74e189ba06fc" + +[[package]] +name = "libz-sys" +version = "1.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d16453e800a8cf6dd2fc3eb4bc99b786a9b90c663b8559a5b1a041bf89e472" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "object" +version = "0.36.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "openssl" +version = "0.10.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-src" +version = "300.4.1+3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faa4eac4138c62414b5622d1b31c5c304f34b406b013c079c2bbc652fdd6678c" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" + +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "relay" +version = "0.1.1" +dependencies = [ + "bytes", + "cookie", + "curl", + "dashmap", + "env_logger", + "http", + "http-serde", + "infer", + "lazy_static", + "log", + "mime", + "openssl", + "openssl-sys", + "serde", + "serde_json", + "strum", + "thiserror", + "time", + "tokio-util", + "tracing", + "url", + "url-escape", + "urlencoding", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustversion" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "socket2" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" +dependencies = [ + "backtrace", + "pin-project-lite", +] + +[[package]] +name = "tokio-util" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "url-escape" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44e0ce4d1246d075ca5abec4b41d33e87a6054d08e2366b63205665e950db218" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/Cargo.toml b/packages/hoppscotch-desktop/plugin-workspace/relay/Cargo.toml new file mode 100644 index 0000000..a274b61 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "relay" +version = "0.1.1" +description = "A HTTP request-response relay used by Hoppscotch Desktop and Hoppscotch Agent for more advanced request handling including custom headers, certificates, proxies, and local system integration." +authors = ["CuriousCorrelation"] +edition = "2021" + +[dependencies] +curl = { git = "https://github.com/CuriousCorrelation/curl-rust.git", features = ["ntlm"] } +cookie = "0.18" +tokio-util = "0.7.12" +lazy_static = "1.5.0" +time = { version = "0.3.37", features = ["serde"] } +openssl = { version = "0.10.66", features = ["vendored"] } +# NOTE: This crate follows `openssl-sys` from https://github.com/CuriousCorrelation/curl-rust.git +# to avoid issues from version mismatch when compiling from source. +openssl-sys = { version = "0.9.64", features = ["vendored"] } +log = "0.4.22" +env_logger = "0.11.5" +thiserror = "1.0.64" +http = "1.1.0" +http-serde = "2.1.1" +url-escape = "0.1.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +urlencoding = "2.1.3" +dashmap = "6.1.0" +tracing = "0.1.41" +infer = "0.16.0" +strum = { version = "0.26.3", features = ["derive"] } +bytes = { version = "1.9.0", features = ["serde"] } +mime = "0.3.17" +url = "2.5.4" diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/LICENSE.md b/packages/hoppscotch-desktop/plugin-workspace/relay/LICENSE.md new file mode 100644 index 0000000..24bfc7f --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 - CuriousCorrelation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/README.md b/packages/hoppscotch-desktop/plugin-workspace/relay/README.md new file mode 100644 index 0000000..5035fae --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/README.md @@ -0,0 +1,101 @@ +# Relay + +A HTTP request-response relay used by Hoppscotch Desktop and Hoppscotch Agent for more advanced request handling including custom headers, certificates, proxies, and local system integration. + +> [!IMPORTANT] +> This crate is only available via GitHub and not published on crates.io right now. + +
+ +![GitHub License MIT](https://img.shields.io/github/license/CuriousCorrelation/relay) +[![Rust](https://img.shields.io/badge/Rust-1.77.2+-orange)](https://www.rust-lang.org) + +
+ +## Installation + +Add to your `Cargo.toml`: + +```toml +[dependencies] +relay = { git = "https://github.com/CuriousCorrelation/relay.git" } +``` + +## Features + +- 🦀 Blazingly fast! +- HTTP client built on libcurl +- HTTP/1.1, HTTP/2.0, HTTP/3.0 support +- Security with SSL/TLS certificate management +- Proxy support with authentication +- Multiple authentication methods (Basic, Bearer, Digest) +- Content handling (JSON, Form Data, Binary) +- Custom security configurations +- Async request execution with cancellation support + +## Usage + +```rust +use relay::{Request, Response, execute}; + +let request = Request { + id: 1, + url: "https://api.example.com".to_string(), + method: Method::Get, + version: Version::Http2, + // ... configure other options +}; + +let response = execute(request).await?; +``` + +> [!NOTE] +> All requests are executed asynchronously and can be cancelled using the `cancel(request_id)` function. + +## Security Features + +> [!TIP] +> You can configure certificate validation, host verification, and custom certificates: + +```rust +let security_config = SecurityConfig { + validate_certificates: Some(true), + verify_host: Some(true), + certificates: Some(CertificateConfig { + client: Some(CertificateType::Pem { + cert: cert_data, + key: key_data + }), + ca: Some(vec![ca_cert_data]) + }) +}; +``` + +## Error Handling + +The crate uses a custom error type `RelayError` that provides information about failures: + +```rust +#[derive(Error)] +pub enum RelayError { + Network { message: String, cause: Option }, + Certificate { message: String, cause: Option }, + Parse { message: String, cause: Option }, + // ... other variants +} +``` + +## Requirements + +- Rust 1.77.2 or later +- OpenSSL development libraries +- libcurl with SSL and HTTP/2.0 support + +> [!WARNING] +> This crate uses custom forks of some dependencies for NTLM support and consistent OpenSSL backend across platforms. + +## License + +Code: (c) 2024 - CuriousCorrelation + +MIT or MIT/Apache 2.0 where applicable. diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/devenv.lock b/packages/hoppscotch-desktop/plugin-workspace/relay/devenv.lock new file mode 100644 index 0000000..b51b260 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/devenv.lock @@ -0,0 +1,84 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1776802132, + "narHash": "sha256-2yO2SGA7zVFYKe0qyJjdg7WHuMOKNwTQmigL7ydD8hI=", + "owner": "cachix", + "repo": "devenv", + "rev": "91affc7a7b6646852a0079678eadf12ac5029d9d", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1776845169, + "narHash": "sha256-Ya6Ba5oC0+PK1TSU4Rkjpoca73mUp6FoHQV5QGnqbx0=", + "owner": "nix-community", + "repo": "fenix", + "rev": "f0b5be1fa2891221ba8b48784f8fded5ef15301f", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1776329215, + "narHash": "sha256-a8BYi3mzoJ/AcJP8UldOx8emoPRLeWqALZWu4ZvjPXw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b86751bc4085f48661017fa226dee99fab6c651b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "fenix": "fenix", + "nixpkgs": "nixpkgs" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1776800521, + "narHash": "sha256-f8YJfwAOsLFpIoqZuX3yF69UvMLrkx7iVzMH1pJU7cM=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "8954b66d43225e62c92e8bbcc8500191b5cceb1e", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} \ No newline at end of file diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/devenv.nix b/packages/hoppscotch-desktop/plugin-workspace/relay/devenv.nix new file mode 100644 index 0000000..3917672 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/devenv.nix @@ -0,0 +1,72 @@ +{ pkgs, lib, config, inputs, ... }: + +let + rosettaPkgs = + if pkgs.stdenv.isDarwin && pkgs.stdenv.isAarch64 + then pkgs.pkgsx86_64Darwin + else pkgs; + + darwinPackages = with pkgs; [ + darwin.apple_sdk.frameworks.Security + darwin.apple_sdk.frameworks.CoreServices + darwin.apple_sdk.frameworks.CoreFoundation + darwin.apple_sdk.frameworks.Foundation + ]; + + linuxPackages = with pkgs; [ + ]; + +in { + packages = with pkgs; [ + git + cargo-edit + ] ++ lib.optionals pkgs.stdenv.isDarwin darwinPackages + ++ lib.optionals pkgs.stdenv.isLinux linuxPackages; + + env = { + APP_GREET = "Relay"; + } // lib.optionalAttrs pkgs.stdenv.isLinux { + LD_LIBRARY_PATH = lib.makeLibraryPath [ + ]; + } // lib.optionalAttrs pkgs.stdenv.isDarwin { + # Place to put macOS-specific environment variables + }; + + scripts = { + hello.exec = "echo hello from $APP_GREET"; + e.exec = "emacs"; + }; + + enterShell = '' + git --version + ${lib.optionalString pkgs.stdenv.isDarwin '' + # Place to put macOS-specific shell initialization + ''} + ${lib.optionalString pkgs.stdenv.isLinux '' + # Place to put Linux-specific shell initialization + ''} + ''; + + enterTest = '' + echo "Running tests" + ''; + + dotenv.enable = true; + + languages = { + rust = { + enable = true; + channel = "nightly"; + components = [ + "rustc" + "cargo" + "clippy" + "rustfmt" + "rust-analyzer" + "llvm-tools-preview" + "rust-src" + "rustc-codegen-cranelift-preview" + ]; + }; + }; +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/devenv.yaml b/packages/hoppscotch-desktop/plugin-workspace/relay/devenv.yaml new file mode 100644 index 0000000..9ee9ba3 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/devenv.yaml @@ -0,0 +1,23 @@ +# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json +inputs: + # For NodeJS-22 and above + nixpkgs: + url: github:NixOS/nixpkgs/nixpkgs-unstable + # nixpkgs: + # url: github:cachix/devenv-nixpkgs/rolling + fenix: + url: github:nix-community/fenix + inputs: + nixpkgs: + follows: nixpkgs + +# If you're using non-OSS software, you can set allowUnfree to true. +allowUnfree: true + +# If you're willing to use a package that's vulnerable +# permittedInsecurePackages: +# - "openssl-1.1.1w" + +# If you have more than one devenv you can merge them +#imports: +# - ./backend diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/src/auth.rs b/packages/hoppscotch-desktop/plugin-workspace/relay/src/auth.rs new file mode 100644 index 0000000..147fb10 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/src/auth.rs @@ -0,0 +1,326 @@ +use curl::easy::Easy; +use std::collections::HashMap; + +use crate::{ + error::{RelayError, Result}, + interop::{ApiKeyLocation, AuthType, GrantType, TokenResponse}, +}; + +pub(crate) struct AuthHandler<'a> { + handle: &'a mut Easy, + headers: &'a mut HashMap, +} + +impl<'a> AuthHandler<'a> { + pub(crate) fn new(handle: &'a mut Easy, headers: &'a mut HashMap) -> Self { + Self { handle, headers } + } + + #[tracing::instrument(skip(self), level = "debug")] + pub(crate) fn set_auth(&mut self, auth: &AuthType) -> Result<()> { + match auth { + AuthType::Basic { username, password } => { + tracing::info!(username = %username, "Setting basic auth"); + self.set_basic_auth(username, password) + } + AuthType::Bearer { token } => { + tracing::info!("Setting bearer auth"); + self.set_bearer_auth(token) + } + AuthType::Digest { + username, password, .. + } => { + tracing::info!(username = %username, "Setting digest auth"); + self.set_digest_auth(username, password) + } + AuthType::ApiKey { + key, + value, + location, + } => { + tracing::info!(key = %key, "Setting API key auth"); + self.set_apikey_auth(key, value, location) + } + AuthType::Aws { + access_key, + secret_key, + region, + service, + session_token, + location, + } => { + tracing::debug!("AWS SigV4 auth is handled at application level"); + self.set_aws_auth( + access_key, + secret_key, + region, + service, + session_token.as_deref(), + location, + ) + } + AuthType::OAuth2 { + grant_type, + access_token, + refresh_token, + } => { + if let Some(token) = access_token { + tracing::info!("Using existing OAuth2 access token"); + self.set_bearer_auth(token) + } else if let Some(refresh_token) = refresh_token { + tracing::info!("Refreshing OAuth2 token"); + self.refresh_oauth2_token(grant_type, refresh_token) + } else { + tracing::info!("Initiating OAuth2 flow"); + self.handle_oauth2_flow(grant_type) + } + } + AuthType::None => { + tracing::info!("No authentication required"); + Ok(()) + } + } + } + + fn set_basic_auth(&mut self, username: &str, password: &str) -> Result<()> { + tracing::debug!(username = %username, "Setting basic auth credentials"); + + self.handle.username(username).map_err(|e| { + tracing::error!(error = %e, "Failed to set username"); + RelayError::Network { + message: "Failed to set username".into(), + cause: Some(e.to_string()), + } + })?; + + self.handle.password(password).map_err(|e| { + tracing::error!(error = %e, "Failed to set password"); + RelayError::Network { + message: "Failed to set password".into(), + cause: Some(e.to_string()), + } + })?; + + tracing::debug!("Basic auth credentials set successfully"); + Ok(()) + } + + fn set_bearer_auth(&mut self, token: &str) -> Result<()> { + self.headers + .insert("Authorization".to_string(), format!("Bearer {}", token)); + Ok(()) + } + + fn set_apikey_auth(&mut self, key: &str, value: &str, location: &ApiKeyLocation) -> Result<()> { + tracing::debug!(key = %key, location = ?location, "Setting API key auth"); + + match location { + ApiKeyLocation::Header => { + tracing::debug!("Adding API key as header: {}", key); + self.headers.insert(key.to_string(), value.to_string()); + } + ApiKeyLocation::Query => { + // For query parameters, we don't need to do anything here + // This is handled in the request.rs file before setting the URL + tracing::debug!("API key will be added to query parameters in URL"); + } + } + + tracing::debug!("API key auth configured successfully"); + Ok(()) + } + + fn set_aws_auth( + &mut self, + _access_key: &str, + _secret_key: &str, + _region: &str, + _service: &str, + _session_token: Option<&str>, + _location: &ApiKeyLocation, + ) -> Result<()> { + tracing::debug!("AWS SigV4 auth is handled at application level"); + Ok(()) + } + + fn set_digest_auth(&mut self, username: &str, password: &str) -> Result<()> { + tracing::debug!("Setting up digest authentication"); + self.set_basic_auth(username, password)?; + + let mut auth = curl::easy::Auth::new(); + auth.digest(true); + + tracing::info!("Configuring digest auth mode"); + self.handle.http_auth(&auth).map_err(|e| { + tracing::error!(error = %e, "Failed to set digest authentication"); + RelayError::Network { + message: "Failed to set digest authentication".into(), + cause: Some(e.to_string()), + } + })?; + + tracing::debug!("Digest auth configured successfully"); + Ok(()) + } + + fn handle_oauth2_flow(&mut self, grant_type: &GrantType) -> Result<()> { + match grant_type { + GrantType::ClientCredentials { + token_endpoint, + client_id, + client_secret, + } => { + tracing::info!("Initiating client credentials flow"); + self.client_credentials_flow(token_endpoint, client_id, client_secret.as_deref()) + } + GrantType::Password { + token_endpoint, + username, + password, + } => { + tracing::info!("Initiating password flow"); + self.password_flow(token_endpoint, username, password) + } + GrantType::AuthorizationCode { .. } => { + tracing::warn!("Authorization Code flow not supported"); + Err(RelayError::UnsupportedFeature { + feature: "Authorization Code Grant".into(), + message: "Authorization Code flow requires browser interaction".into(), + relay: "curl".into(), + }) + } + GrantType::Implicit { .. } => { + tracing::warn!("Implicit flow not supported"); + Err(RelayError::UnsupportedFeature { + feature: "Implicit Grant".into(), + message: "Implicit flow requires browser interaction".into(), + relay: "curl".into(), + }) + } + } + } + + fn refresh_oauth2_token(&mut self, grant_type: &GrantType, refresh_token: &str) -> Result<()> { + let token_endpoint = match grant_type { + GrantType::ClientCredentials { token_endpoint, .. } + | GrantType::Password { token_endpoint, .. } + | GrantType::AuthorizationCode { token_endpoint, .. } => token_endpoint, + GrantType::Implicit { .. } => { + tracing::error!("Attempted to refresh token with implicit grant"); + return Err(RelayError::UnsupportedFeature { + feature: "Token Refresh".into(), + message: "Implicit grant does not support refresh tokens".into(), + relay: "curl".into(), + }); + } + }; + + let params = vec![ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ]; + + self.request_token(token_endpoint, ¶ms) + } + + fn client_credentials_flow( + &mut self, + token_endpoint: &str, + client_id: &str, + client_secret: Option<&str>, + ) -> Result<()> { + tracing::info!("Performing client credentials flow"); + + let mut params = vec![ + ("grant_type", "client_credentials"), + ("client_id", client_id), + ]; + + if let Some(secret) = client_secret { + params.push(("client_secret", secret)); + } + + self.request_token(token_endpoint, ¶ms) + } + + fn password_flow( + &mut self, + token_endpoint: &str, + username: &str, + password: &str, + ) -> Result<()> { + tracing::info!("Performing password flow"); + + let params = vec![ + ("grant_type", "password"), + ("username", username), + ("password", password), + ]; + + self.request_token(token_endpoint, ¶ms) + } + + fn request_token(&mut self, token_endpoint: &str, params: &[(&str, &str)]) -> Result<()> { + let mut handle = Easy::new(); + tracing::debug!(endpoint = %token_endpoint, "Requesting OAuth2 token"); + + handle.url(token_endpoint).map_err(|e| { + tracing::error!(error = %e, "Failed to set token endpoint URL"); + RelayError::Network { + message: "Failed to set token endpoint URL".into(), + cause: Some(e.to_string()), + } + })?; + + let form_data: String = params + .iter() + .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) + .collect::>() + .join("&"); + + handle.post_fields_copy(form_data.as_bytes()).map_err(|e| { + tracing::error!(error = %e, "Failed to set form data"); + RelayError::Network { + message: "Failed to set form data".into(), + cause: Some(e.to_string()), + } + })?; + + let mut response = Vec::new(); + { + let mut transfer = handle.transfer(); + transfer + .write_function(|data| { + response.extend_from_slice(data); + Ok(data.len()) + }) + .map_err(|e| { + tracing::error!(error = %e, "Failed to set write callback"); + RelayError::Network { + message: "Failed to set write callback".into(), + cause: Some(e.to_string()), + } + })?; + + tracing::debug!("Performing token request"); + transfer.perform().map_err(|e| { + tracing::error!(error = %e, "Failed to perform token request"); + RelayError::Network { + message: "Failed to perform token request".into(), + cause: Some(e.to_string()), + } + })?; + } + + let token_response: TokenResponse = serde_json::from_slice(&response).map_err(|e| { + tracing::error!(error = %e, "Failed to parse token response"); + RelayError::Parse { + message: "Failed to parse token response".into(), + cause: Some(e.to_string()), + } + })?; + + tracing::info!("Successfully obtained OAuth2 token"); + self.set_bearer_auth(&token_response.access_token) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/src/content.rs b/packages/hoppscotch-desktop/plugin-workspace/relay/src/content.rs new file mode 100644 index 0000000..1517bab --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/src/content.rs @@ -0,0 +1,306 @@ +use curl::easy::Easy; +use http::HeaderName; +use std::{collections::HashMap, path::Path}; + +use crate::{ + error::{RelayError, Result}, + interop::{ContentType, FormValue, MediaType}, +}; + +pub(crate) struct ContentHandler<'a> { + handle: &'a mut Easy, + headers: &'a mut HashMap, +} + +impl<'a> ContentHandler<'a> { + pub(crate) fn new(handle: &'a mut Easy, headers: &'a mut HashMap) -> Self { + tracing::debug!("Creating new ContentHandler with headers: {:?}", headers); + Self { handle, headers } + } + + fn merge_headers(&mut self, new_headers: HashMap) { + tracing::info!( + original_headers = ?self.headers, + new_headers = ?new_headers, + "Merging headers" + ); + + for (key, value) in new_headers { + let key_lower = key.to_lowercase(); + + if !self + .headers + .iter() + .any(|(k, _)| k.to_lowercase() == key_lower) + { + let canonical_key = HeaderName::from_bytes(key.as_bytes()) + .map(|name| name.to_string()) + .unwrap_or_else(|_| key); + + tracing::debug!(key = %canonical_key, value = %value, "Adding header"); + self.headers.insert(canonical_key, value); + } else { + tracing::debug!(key = %key, "Skipping duplicate header (case-insensitive match exists)"); + } + } + + tracing::trace!(merged_headers = ?self.headers, "Headers after merge"); + } + + #[tracing::instrument(skip(self), level = "debug")] + pub(crate) fn set_content(&mut self, content: &ContentType) -> Result<()> { + match content { + ContentType::Text { + content, + media_type, + } => { + tracing::info!(content_length = content.len(), "Setting text content"); + self.set_text_content(content, media_type) + } + ContentType::Json { + content, + media_type, + } => { + tracing::info!("Setting JSON content"); + self.set_json_content(content, media_type) + } + ContentType::Form { + content, + media_type, + } => { + tracing::info!(field_count = content.len(), "Setting form content"); + self.set_form_content(content, media_type) + } + ContentType::Binary { + content, + media_type, + filename, + } => { + tracing::info!( + content_length = content.len(), + filename = ?filename, + "Setting binary content" + ); + self.set_binary_content(content, media_type, filename.as_deref()) + } + ContentType::Multipart { + content, + media_type, + } => { + tracing::info!(field_count = content.len(), "Setting multipart content"); + self.set_multipart_content(content, media_type) + } + ContentType::Xml { + content, + media_type, + } => { + tracing::info!("Setting XML content"); + self.set_text_content(content, media_type) + } + ContentType::Urlencoded { + content, + media_type, + } => { + tracing::info!(field_count = content.len(), "Setting URL-encoded content"); + self.set_urlencoded_content(content, media_type) + } + } + } + + fn set_text_content(&mut self, content: &str, media_type: &MediaType) -> Result<()> { + /* TODO: Look into reintroducing this when auth handling is done by kernel */ + // let mut headers = HashMap::new(); + // headers.insert("content-type".to_string(), media_type.to_string()); + // self.merge_headers(headers); + + self.handle + .post_fields_copy(content.as_bytes()) + .map_err(|e| { + tracing::error!(error = %e, "Failed to set text content"); + RelayError::Network { + message: "Failed to set text content".into(), + cause: Some(e.to_string()), + } + })?; + + tracing::debug!("Text content set successfully"); + Ok(()) + } + + fn set_json_content( + &mut self, + content: &serde_json::Value, + media_type: &MediaType, + ) -> Result<()> { + let json_str = serde_json::to_string(content).map_err(|e| { + tracing::error!(error = %e, "Failed to serialize JSON"); + RelayError::Parse { + message: "Failed to serialize JSON".into(), + cause: Some(e.to_string()), + } + })?; + + /* TODO: Look into reintroducing this when auth handling is done by kernel */ + // let mut headers = HashMap::new(); + // headers.insert("content-type".to_string(), media_type.to_string()); + // self.merge_headers(headers); + + self.handle + .post_fields_copy(json_str.as_bytes()) + .map_err(|e| { + tracing::error!(error = %e, "Failed to set JSON content"); + RelayError::Network { + message: "Failed to set JSON content".into(), + cause: Some(e.to_string()), + } + })?; + + tracing::debug!("JSON content set successfully"); + Ok(()) + } + + fn set_binary_content( + &mut self, + content: &[u8], + media_type: &MediaType, + filename: Option<&str>, + ) -> Result<()> { + let mut headers = HashMap::new(); + + if let Some(name) = filename { + let safe_name = Path::new(name) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(name); + + /* TODO: Look into reintroducing this when auth handling is done by kernel */ + // headers.insert("content-type".to_string(), media_type.to_string()); + headers.insert( + "Content-Disposition".to_string(), + format!("attachment; filename=\"{}\"", safe_name), + ); + } else { + /* TODO: Look into reintroducing this when auth handling is done by kernel */ + // headers.insert("content-type".to_string(), media_type.to_string()); + } + + // self.merge_headers(headers); + + self.handle.post_fields_copy(content).map_err(|e| { + tracing::error!(error = %e, "Failed to set binary content"); + RelayError::Network { + message: "Failed to set binary content".into(), + cause: Some(e.to_string()), + } + })?; + + tracing::debug!("Binary content set successfully"); + Ok(()) + } + + fn set_form_content( + &mut self, + content: &Vec<(String, Vec)>, + media_type: &MediaType, + ) -> Result<()> { + /* TODO: Look into reintroducing this when auth handling is done by kernel */ + // let mut headers = HashMap::new(); + // headers.insert("content-type".to_string(), media_type.to_string()); + // self.merge_headers(headers); + + let mut form = curl::easy::Form::new(); + + for (key, values) in content { + for value in values { + match value { + FormValue::Text { value: text } => { + tracing::debug!(key = %key, text_length = text.len(), "Adding form text field"); + form.part(key) + .contents(text.as_bytes()) + .add() + .map_err(|e| { + tracing::error!(error = %e, key = %key, "Failed to add form text field"); + RelayError::Network { + message: format!("Failed to add form text field: {}", key), + cause: Some(e.to_string()), + } + })?; + } + FormValue::File { + filename, + content_type, + data, + } => { + tracing::debug!( + key = %key, + filename = %filename, + content_type = ?content_type, + data_length = data.len(), + "Adding form file field" + ); + form.part(key) + .buffer(&filename, data.to_vec()) + .content_type(&content_type.to_string()) + .add() + .map_err(|e| { + tracing::error!( + error = %e, + key = %key, + filename = %filename, + "Failed to add form file field" + ); + RelayError::Network { + message: format!( + "Failed to add form file field: {} ({})", + key, filename + ), + cause: Some(e.to_string()), + } + })?; + } + } + } + } + + self.handle.httppost(form).map_err(|e| { + tracing::error!(error = %e, "Failed to set form data"); + RelayError::Network { + message: "Failed to set form data".into(), + cause: Some(e.to_string()), + } + })?; + + tracing::debug!("Form content set successfully"); + Ok(()) + } + + fn set_multipart_content( + &mut self, + content: &Vec<(String, Vec)>, + media_type: &MediaType, + ) -> Result<()> { + self.set_form_content(content, media_type) + } + + fn set_urlencoded_content(&mut self, content: &String, media_type: &MediaType) -> Result<()> { + /* TODO: Look into reintroducing this when auth handling is done by kernel */ + // let mut headers = HashMap::new(); + // headers.insert("content-type".to_string(), media_type.to_string()); + // self.merge_headers(headers); + + tracing::debug!(content_length = content.len(), "URL-encoded form data"); + + self.handle + .post_fields_copy(content.as_bytes()) + .map_err(|e| { + tracing::error!(error = %e, "Failed to set urlencoded content"); + RelayError::Network { + message: "Failed to set urlencoded content".into(), + cause: Some(e.to_string()), + } + })?; + + tracing::debug!("URL-encoded content set successfully"); + Ok(()) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/src/error.rs b/packages/hoppscotch-desktop/plugin-workspace/relay/src/error.rs new file mode 100644 index 0000000..959b74a --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/src/error.rs @@ -0,0 +1,70 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RelayError { + #[error("Unsupported feature '{feature}' in relay '{relay}': {message}")] + UnsupportedFeature { + feature: String, + message: String, + relay: String, + }, + + #[error("Network error: {message}")] + Network { + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + cause: Option, + }, + + #[error("Request timed out during {}", .phase.as_ref().map_or("execution", TimeoutPhase::as_str))] + Timeout { + message: String, + phase: Option, + }, + + #[error("Certificate error: {message}")] + Certificate { + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + cause: Option, + }, + + #[error("Failed to parse response: {message}")] + Parse { + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + cause: Option, + }, + + #[error("Request aborted: {message}")] + Abort { message: String }, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum TimeoutPhase { + Connect, + Tls, + Response, +} + +impl TimeoutPhase { + fn as_str(&self) -> &'static str { + match self { + TimeoutPhase::Connect => "connection establishment", + TimeoutPhase::Tls => "TLS handshake", + TimeoutPhase::Response => "response waiting", + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RequestResult { + Success { response: T }, + Error { error: RelayError }, +} + +pub type Result = std::result::Result; diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/src/header.rs b/packages/hoppscotch-desktop/plugin-workspace/relay/src/header.rs new file mode 100644 index 0000000..b9a354d --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/src/header.rs @@ -0,0 +1,69 @@ +use std::collections::HashMap; +use std::str::FromStr; + +use curl::easy::{Easy, List}; +use http::{HeaderMap, HeaderName, HeaderValue}; + +use crate::error::{RelayError, Result}; + +pub(crate) struct HeadersBuilder<'a> { + handle: &'a mut Easy, +} + +impl<'a> HeadersBuilder<'a> { + pub(crate) fn new(handle: &'a mut Easy) -> Self { + Self { handle } + } + + #[tracing::instrument(skip(self), level = "debug")] + pub(crate) fn add_headers(&mut self, headers: Option<&HashMap>) -> Result<()> { + let Some(headers) = headers else { + tracing::debug!("No headers provided"); + return Ok(()); + }; + + let mut header_map = HeaderMap::new(); + for (key, value) in headers { + if let (Ok(name), Ok(val)) = (HeaderName::from_str(key), HeaderValue::from_str(value)) { + header_map.insert(name, val); + } + } + + let header_count = header_map.len(); + tracing::info!(header_count, "Building header list"); + + let list = header_map + .iter() + .map(|(key, value)| { + let key_str = key.as_str(); + let value_str = value.to_str().unwrap_or(""); + tracing::debug!( + key = ?key_str, + value_count = value_str.len(), + value = ?value_str, + "Processing headers" + ); + let header = format!("{}: {}", key_str, value_str); + tracing::debug!(%header, "Adding header"); + header + }) + .try_fold(List::new(), |mut list, header| { + list.append(&header).map_err(|e| { + tracing::error!(%e, "Failed to append header: {header}"); + RelayError::Network { + message: format!("Failed to append header: {header}"), + cause: Some(e.to_string()), + } + })?; + Ok(list) + })?; + + self.handle.http_headers(list).map_err(|e| { + tracing::error!(%e, "Failed to set headers"); + RelayError::Network { + message: "Failed to set headers".into(), + cause: Some(e.to_string()), + } + }) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/src/interop.rs b/packages/hoppscotch-desktop/plugin-workspace/relay/src/interop.rs new file mode 100644 index 0000000..352216e --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/src/interop.rs @@ -0,0 +1,422 @@ +use std::collections::HashMap; + +use bytes::Bytes; +use http::{Method, StatusCode, Version}; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; +use time::OffsetDateTime; + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Display, EnumString)] +pub enum MediaType { + // Text + #[serde(rename = "text/plain")] + #[strum(to_string = "text/plain")] + TextPlain, + #[serde(rename = "text/html")] + #[strum(to_string = "text/html")] + TextHtml, + #[serde(rename = "text/css")] + #[strum(to_string = "text/css")] + TextCss, + #[serde(rename = "text/csv")] + #[strum(to_string = "text/csv")] + TextCsv, + #[serde(rename = "text/xml")] + #[strum(to_string = "text/xml")] + TextXml, + + // Application + #[serde(rename = "application/json")] + #[strum(to_string = "application/json")] + Json, + #[serde(rename = "application/ld+json")] + #[strum(to_string = "application/ld+json")] + JsonLd, + #[serde(rename = "application/xml")] + #[strum(to_string = "application/xml")] + Xml, + #[serde(rename = "application/x-www-form-urlencoded")] + #[strum(to_string = "application/x-www-form-urlencoded")] + FormUrlEncoded, + #[serde(rename = "multipart/form-data")] + #[strum(to_string = "multipart/form-data")] + MultipartFormData, + #[serde(rename = "application/octet-stream")] + #[strum(to_string = "application/octet-stream")] + OctetStream, + #[serde(rename = "application/pdf")] + #[strum(to_string = "application/pdf")] + ApplicationPdf, + #[serde(rename = "application/zip")] + #[strum(to_string = "application/zip")] + ApplicationZip, + #[serde(rename = "application/javascript")] + #[strum(to_string = "application/javascript")] + ApplicationJavascript, + + // Audio + #[serde(rename = "audio/mpeg")] + #[strum(to_string = "audio/mpeg")] + AudioMpeg, + #[serde(rename = "audio/mp4")] + #[strum(to_string = "audio/mp4")] + AudioMp4, + #[serde(rename = "audio/x-m4a")] + #[strum(to_string = "audio/x-m4a")] + AudioXM4a, + #[serde(rename = "audio/wav")] + #[strum(to_string = "audio/wav")] + AudioWav, + #[serde(rename = "audio/ogg")] + #[strum(to_string = "audio/ogg")] + AudioOgg, + #[serde(rename = "audio/aac")] + #[strum(to_string = "audio/aac")] + AudioAac, + #[serde(rename = "audio/flac")] + #[strum(to_string = "audio/flac")] + AudioFlac, + + // Video + #[serde(rename = "video/mp4")] + #[strum(to_string = "video/mp4")] + VideoMp4, + #[serde(rename = "video/avi")] + #[strum(to_string = "video/avi")] + VideoAvi, + #[serde(rename = "video/quicktime")] + #[strum(to_string = "video/quicktime")] + VideoQuicktime, + #[serde(rename = "video/x-msvideo")] + #[strum(to_string = "video/x-msvideo")] + VideoXMsvideo, + #[serde(rename = "video/webm")] + #[strum(to_string = "video/webm")] + VideoWebm, + #[serde(rename = "video/x-flv")] + #[strum(to_string = "video/x-flv")] + VideoXFlv, + + // Image + #[serde(rename = "image/png")] + #[strum(to_string = "image/png")] + ImagePng, + #[serde(rename = "image/jpeg")] + #[strum(to_string = "image/jpeg")] + ImageJpeg, + #[serde(rename = "image/gif")] + #[strum(to_string = "image/gif")] + ImageGif, + #[serde(rename = "image/svg+xml")] + #[strum(to_string = "image/svg+xml")] + ImageSvgXml, + #[serde(rename = "image/webp")] + #[strum(to_string = "image/webp")] + ImageWebp, + #[serde(rename = "image/bmp")] + #[strum(to_string = "image/bmp")] + ImageBmp, + #[serde(rename = "image/x-icon")] + #[strum(to_string = "image/x-icon")] + ImageXIcon, + + // Fallback for unknown + #[serde(other)] + Other, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum FormValue { + #[serde(rename_all = "camelCase")] + Text { value: String }, + #[serde(rename_all = "camelCase")] + File { + filename: String, + content_type: MediaType, + data: Bytes, + }, +} + +pub type FormData = Vec<(String, Vec)>; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum ContentType { + #[serde(rename_all = "camelCase")] + Text { + content: String, + media_type: MediaType, + }, + #[serde(rename_all = "camelCase")] + Json { + content: serde_json::Value, + media_type: MediaType, + }, + #[serde(rename_all = "camelCase")] + Xml { + content: String, + media_type: MediaType, + }, + #[serde(rename_all = "camelCase")] + Form { + content: FormData, + media_type: MediaType, + }, + #[serde(rename_all = "camelCase")] + Binary { + content: Bytes, + media_type: MediaType, + filename: Option, + }, + #[serde(rename_all = "camelCase")] + Multipart { + content: FormData, + media_type: MediaType, + }, + #[serde(rename_all = "camelCase")] + Urlencoded { + content: String, + media_type: MediaType, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: Option, + pub refresh_token: Option, + pub scope: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum GrantType { + #[serde(rename_all = "camelCase")] + AuthorizationCode { + auth_endpoint: String, + token_endpoint: String, + client_id: String, + client_secret: Option, + }, + #[serde(rename_all = "camelCase")] + ClientCredentials { + token_endpoint: String, + client_id: String, + client_secret: Option, + }, + #[serde(rename_all = "camelCase")] + Password { + token_endpoint: String, + username: String, + password: String, + }, + #[serde(rename_all = "camelCase")] + Implicit { + auth_endpoint: String, + client_id: String, + }, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ApiKeyLocation { + Header, + Query, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum AuthType { + None, + #[serde(rename_all = "camelCase")] + Basic { + username: String, + password: String, + }, + #[serde(rename_all = "camelCase")] + Bearer { + token: String, + }, + #[serde(rename_all = "camelCase")] + Digest { + username: String, + password: String, + realm: Option, + nonce: Option, + opaque: Option, + algorithm: Option, + qop: Option, + nc: Option, + cnonce: Option, + }, + #[serde(rename_all = "camelCase")] + ApiKey { + key: String, + value: String, + #[serde(rename = "in")] + location: ApiKeyLocation, + }, + #[serde(rename_all = "camelCase")] + OAuth2 { + grant_type: GrantType, + access_token: Option, + refresh_token: Option, + }, + #[serde(rename_all = "camelCase")] + Aws { + access_key: String, + secret_key: String, + region: String, + service: String, + session_token: Option, + #[serde(rename = "in")] + location: ApiKeyLocation, + }, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum DigestAlgorithm { + Md5, + Sha256, + Sha512, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum DigestQop { + Auth, + AuthInt, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum CertificateType { + Pem { cert: Bytes, key: Bytes }, + Pfx { data: Bytes, password: String }, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SecurityConfig { + pub certificates: Option, + #[serde(rename = "verifyHost")] + pub verify_host: Option, + #[serde(rename = "verifyPeer")] + pub verify_peer: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CertificateConfig { + pub client: Option, + pub ca: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RequestMeta { + pub options: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RequestOptions { + pub timeout: Option, + pub follow_redirects: Option, + pub max_redirects: Option, + pub decompress: Option, + pub cookies: Option, + pub keep_alive: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Request { + pub id: i64, + pub url: String, + #[serde(with = "http_serde::method")] + pub method: Method, + #[serde(with = "http_serde::version")] + pub version: Version, + pub headers: Option>, + pub params: Option>, + pub content: Option, + pub auth: Option, + pub security: Option, + pub proxy: Option, + pub meta: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ResponseBody { + pub body: Bytes, + pub media_type: MediaType, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Response { + pub id: i64, + #[serde(with = "http_serde::status_code")] + pub status: StatusCode, + #[serde(rename = "statusText")] + pub status_text: String, + #[serde(with = "http_serde::version")] + pub version: Version, + pub headers: HashMap, + pub cookies: Option>, + pub body: ResponseBody, + pub meta: ResponseMeta, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ProxyConfig { + pub url: String, + pub auth: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ProxyAuth { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Cookie { + pub name: String, + pub value: String, + pub domain: Option, + pub path: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub expires: Option, + pub secure: Option, + #[serde(rename = "httpOnly")] + pub http_only: Option, + #[serde(rename = "sameSite")] + pub same_site: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub enum SameSite { + Strict, + Lax, + None, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ResponseMeta { + pub timing: TimingInfo, + pub size: SizeInfo, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TimingInfo { + pub start: u64, + pub end: u64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SizeInfo { + pub headers: u64, + pub body: u64, + pub total: u64, +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/src/lib.rs b/packages/hoppscotch-desktop/plugin-workspace/relay/src/lib.rs new file mode 100644 index 0000000..9127005 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/src/lib.rs @@ -0,0 +1,14 @@ +mod auth; +mod content; +pub mod error; +mod header; +mod interop; +mod relay; +mod request; +mod response; +mod security; +mod transfer; +mod util; + +pub use interop::{Request, Response}; +pub use relay::{cancel, execute}; diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/src/relay.rs b/packages/hoppscotch-desktop/plugin-workspace/relay/src/relay.rs new file mode 100644 index 0000000..424a670 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/src/relay.rs @@ -0,0 +1,175 @@ +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::SystemTime, +}; + +use curl::easy::Easy; +use dashmap::DashMap; +use http::StatusCode; +use tokio_util::sync::CancellationToken; + +use crate::{ + error::{RelayError, Result}, + interop::{Request, Response}, + request::CurlRequest, + response::ResponseHandler, + transfer::TransferHandler, +}; + +lazy_static::lazy_static! { + static ref ACTIVE_REQUESTS: DashMap> = DashMap::new(); +} + +#[tracing::instrument(skip(request), fields(request_id = request.id), level = "debug")] +fn execute_request(request: &Request, cancel_token: &CancellationToken) -> Result { + tracing::info!( + method = %request.method, + url = %request.url, + "Executing request" + ); + + let id = request.id; + let mut handle = Easy::new(); + let start_time = SystemTime::now(); + + let mut curl_request = CurlRequest::new(&mut handle, request); + curl_request.prepare()?; + + tracing::debug!(request = ?request, "Full request details before sending"); + + handle.verbose(true).map_err(|e| RelayError::Network { + message: "Failed to set verbose mode".into(), + cause: Some(e.to_string()), + })?; + + handle + .debug_function(|info_type, data| { + if let Ok(s) = std::str::from_utf8(data) { + tracing::debug!(info_type = ?info_type, s = ?s, "cURL debug fn"); + } + }) + .map_err(|e| RelayError::Network { + message: "Failed to set debug function".into(), + cause: Some(e.to_string()), + })?; + + let mut transfer_handler = TransferHandler::new(); + transfer_handler.handle_transfer(&mut handle, cancel_token)?; + + let status = handle.response_code().map_err(|e| { + tracing::error!(error = %e, "Failed to get response code"); + RelayError::Network { + message: "Failed to get response code".into(), + cause: Some(e.to_string()), + } + })? as u16; + + let header_size = handle.header_size().map_err(|e| { + tracing::error!(error = %e, "Failed to get header size"); + RelayError::Network { + message: "Failed to get header size".into(), + cause: Some(e.to_string()), + } + })?; + + let (body, headers) = transfer_handler.into_parts(); + + tracing::info!( + status = status, + body_size = body.len(), + header_size = header_size, + "Request completed" + ); + + // NOTE: If this fails, something has gone very wrong. + let status_code = StatusCode::from_u16(status).unwrap(); + + ResponseHandler::new( + id, + headers, + body, + status_code, + header_size, + start_time, + SystemTime::now(), + request.version.clone(), + ) + .build() +} + +#[tracing::instrument(skip(request), fields(request_id = request.id), level = "debug")] +pub async fn execute(request: Request) -> Result { + let request_id = request.id; + let cancelled = Arc::new(AtomicBool::new(false)); + + tracing::info!( + method = %request.method, + url = %request.url, + "Starting request execution" + ); + + ACTIVE_REQUESTS.insert(request_id, Arc::clone(&cancelled)); + + let cancel_token = CancellationToken::new(); + let cancel_token_clone = cancel_token.clone(); + let cancelled_clone = Arc::clone(&cancelled); + + let handle = std::thread::spawn(move || { + let result = execute_request(&request, &cancel_token); + if cancel_token_clone.is_cancelled() { + cancelled_clone.store(true, Ordering::SeqCst); + } + result + }); + + let result = match handle.join() { + Ok(result) => { + if cancelled.load(Ordering::SeqCst) { + tracing::info!("Request was cancelled by user"); + Err(RelayError::Abort { + message: "Request cancelled by user".into(), + }) + } else { + tracing::debug!("Request completed normally"); + result + } + } + Err(_) => { + tracing::error!("Request thread panicked"); + Err(RelayError::Network { + message: "Request thread panicked".into(), + cause: None, + }) + } + }; + + ACTIVE_REQUESTS.remove(&request_id); + tracing::debug!("Request execution completed"); + + tracing::debug!("Result {:#?}", result); + + result +} + +#[tracing::instrument(level = "debug")] +pub async fn cancel(request_id: i64) -> Result<()> { + tracing::debug!(request_id = request_id, "Attempting to cancel request"); + + if let Some(cancelled) = ACTIVE_REQUESTS.get(&request_id) { + cancelled.store(true, Ordering::SeqCst); + tracing::info!(request_id = request_id, "Request cancelled successfully"); + Ok(()) + } else { + tracing::warn!( + request_id = request_id, + "Request not found for cancellation" + ); + Err(RelayError::Network { + message: "Request not found".into(), + cause: None, + }) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/src/request.rs b/packages/hoppscotch-desktop/plugin-workspace/relay/src/request.rs new file mode 100644 index 0000000..e9b5570 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/src/request.rs @@ -0,0 +1,273 @@ +use curl::easy::Easy; +use std::{collections::HashMap, ops::Not}; + +use crate::{ + auth::AuthHandler, + content::ContentHandler, + error::{RelayError, Result}, + header::HeadersBuilder, + interop::{ApiKeyLocation, AuthType, Request}, + security::SecurityHandler, + util::ToCurlVersion, +}; + +pub(crate) struct CurlRequest<'a> { + handle: &'a mut Easy, + request: &'a Request, +} + +impl<'a> CurlRequest<'a> { + pub(crate) fn new(handle: &'a mut Easy, request: &'a Request) -> Self { + tracing::debug!( + request_id = request.id, + url = %request.url, + method = %request.method, + "Creating new curl request" + ); + Self { handle, request } + } + + #[tracing::instrument(skip(self), fields(request_id = self.request.id), level = "debug")] + fn setup_basics(&mut self) -> Result<()> { + tracing::debug!("Setting up basic request parameters"); + + self.handle + .custom_request(&self.request.method.to_string()) + .map_err(|e| { + tracing::error!(error = %e, "Failed to set request method"); + RelayError::Network { + message: "Failed to set request method".into(), + cause: Some(e.to_string()), + } + })?; + + self.handle.url(&self.request.url).map_err(|e| { + tracing::error!(error = %e, "Failed to set URL"); + RelayError::Network { + message: "Failed to set URL".into(), + cause: Some(e.to_string()), + } + })?; + + /* NOTE: Once auth handling is correctly migrated over, this is how query param should be handled + if let Some(AuthType::ApiKey { key, value, location }) = &self.request.auth { + if let ApiKeyLocation::Query = location { + tracing::debug!(key = %key, "Adding API key to query parameters"); + + let mut url = url::Url::parse(&self.request.url).map_err(|e| { + tracing::error!(error = %e, "Failed to parse URL for API key addition"); + RelayError::Parse { + message: "Failed to parse URL for API key addition".into(), + cause: Some(e.to_string()), + } + })?; + + url.query_pairs_mut().append_pair(key, value); + let updated_url = url.to_string(); + tracing::debug!(url = %updated_url, "Updated URL with API key in query parameters"); + + self.handle.url(&updated_url).map_err(|e| { + tracing::error!(error = %e, "Failed to set URL with API key"); + RelayError::Network { + message: "Failed to set URL with API key".into(), + cause: Some(e.to_string()), + } + })?; + } else { + self.handle.url(&self.request.url).map_err(|e| { + tracing::error!(error = %e, "Failed to set URL"); + RelayError::Network { + message: "Failed to set URL".into(), + cause: Some(e.to_string()), + } + })?; + } + } else { + self.handle.url(&self.request.url).map_err(|e| { + tracing::error!(error = %e, "Failed to set URL"); + RelayError::Network { + message: "Failed to set URL".into(), + cause: Some(e.to_string()), + } + })?; + } + */ + + self.handle + .http_version(self.request.version.to_curl_version()) + .map_err(|e| { + tracing::error!(error = %e, "Failed to set HTTP version"); + RelayError::Network { + message: "Failed to set HTTP version".into(), + cause: Some(e.to_string()), + } + })?; + + // NOTE: `""` corresponds to accept all, + // see: https://curl.se/libcurl/c/CURLOPT_ACCEPT_ENCODING.html + self.handle.accept_encoding("").map_err(|e| { + tracing::error!(error = %e, "Failed to set accept-encoding"); + RelayError::Network { + message: "Failed to set accept-encoding".into(), + cause: Some(e.to_string()), + } + })?; + + let Some(ref meta) = self.request.meta else { + tracing::debug!("No meta configuration provided"); + return Ok(()); + }; + + let Some(ref options) = meta.options else { + tracing::debug!("No options in meta configuration"); + return Ok(()); + }; + + if let Some(follow) = options.follow_redirects { + tracing::debug!(follow_redirects = follow, "Setting redirect behavior"); + self.handle.follow_location(follow).map_err(|e| { + tracing::error!(error = %e, "Failed to set follow_location"); + RelayError::Network { + message: "Failed to set redirect behavior".into(), + cause: Some(e.to_string()), + } + })?; + } + + if let Some(max) = options.max_redirects { + tracing::debug!(max_redirects = max, "Setting maximum redirects"); + self.handle.max_redirections(max).map_err(|e| { + tracing::error!(error = %e, "Failed to set max_redirections"); + RelayError::Network { + message: "Failed to set maximum redirects".into(), + cause: Some(e.to_string()), + } + })?; + } + + if let Some(timeout_ms) = options.timeout { + tracing::debug!(timeout_ms = timeout_ms, "Setting request timeout"); + self.handle + .timeout(std::time::Duration::from_millis(timeout_ms)) + .map_err(|e| { + tracing::error!(error = %e, "Failed to set timeout"); + RelayError::Network { + message: "Failed to set timeout".into(), + cause: Some(e.to_string()), + } + })?; + } + + if let Some(decompress) = options.decompress { + if !decompress { + tracing::debug!("Disabling automatic decompression"); + self.handle.accept_encoding("identity").map_err(|e| { + tracing::error!(error = %e, "Failed to disable decompression"); + RelayError::Network { + message: "Failed to disable decompression".into(), + cause: Some(e.to_string()), + } + })?; + } + } + + if let Some(enable_cookies) = options.cookies { + tracing::debug!(enable_cookies = enable_cookies, "Setting cookie handling"); + if enable_cookies { + self.handle.cookie_file("").map_err(|e| { + tracing::error!(error = %e, "Failed to enable cookies"); + RelayError::Network { + message: "Failed to enable cookie handling".into(), + cause: Some(e.to_string()), + } + })?; + } + } + + if let Some(keep_alive) = options.keep_alive { + tracing::debug!(keep_alive = keep_alive, "Setting keep-alive"); + self.handle.tcp_keepalive(keep_alive).map_err(|e| { + tracing::error!(error = %e, "Failed to set keep-alive"); + RelayError::Network { + message: "Failed to set keep-alive".into(), + cause: Some(e.to_string()), + } + })?; + } + + tracing::debug!("Basic request parameters set successfully"); + Ok(()) + } + + #[tracing::instrument(skip(self), fields(request_id = self.request.id), level = "debug")] + pub(crate) fn prepare(&mut self) -> Result<()> { + tracing::debug!("Preparing request"); + self.setup_basics()?; + + let mut headers = HashMap::new(); + + if let Some(ref content) = self.request.content { + tracing::trace!(content_type = ?content, "Setting request content"); + ContentHandler::new(self.handle, &mut headers).set_content(content)?; + } + + if let Some(ref auth) = self.request.auth { + tracing::trace!(auth_type = ?auth, "Configuring authentication"); + AuthHandler::new(self.handle, &mut headers).set_auth(auth)?; + } + + if let Some(ref security) = self.request.security { + tracing::trace!( + verify_peer = ?security.verify_peer, + verify_host = ?security.verify_host, + "Configuring security settings" + ); + SecurityHandler::new(self.handle).configure(security)?; + } + + if let Some(ref proxy) = self.request.proxy { + tracing::trace!(proxy_url = %proxy.url, "Setting up proxy"); + + self.handle + .proxy(&proxy.url) + .map_err(|e| RelayError::Network { + message: "Failed to set proxy".into(), + cause: Some(e.to_string()), + })?; + + self.handle + .proxy_auth(curl::easy::Auth::new().auto(true)) + .map_err(|e| RelayError::Network { + message: "Failed to set proxy authentication to auto".into(), + cause: Some(e.to_string()), + })?; + + if let Some(ref auth) = proxy.auth { + if (auth.username.trim().is_empty() || auth.password.trim().is_empty()).not() { + self.handle.proxy_username(&auth.username).map_err(|e| { + RelayError::Network { + message: "Failed to set proxy username".into(), + cause: Some(e.to_string()), + } + })?; + + self.handle.proxy_password(&auth.password).map_err(|e| { + RelayError::Network { + message: "Failed to set proxy password".into(), + cause: Some(e.to_string()), + } + })?; + } + } + } + + if let Some(ref request_headers) = self.request.headers { + headers.extend(request_headers.clone()); + HeadersBuilder::new(self.handle).add_headers(Some(&headers))?; + } else if !headers.is_empty() { + HeadersBuilder::new(self.handle).add_headers(Some(&headers))?; + } + + Ok(()) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/src/response.rs b/packages/hoppscotch-desktop/plugin-workspace/relay/src/response.rs new file mode 100644 index 0000000..6923185 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/src/response.rs @@ -0,0 +1,227 @@ +use std::{collections::HashMap, str::FromStr, time::SystemTime}; + +use bytes::Bytes; +use http::{StatusCode, Version}; +use mime::Mime; +use time::OffsetDateTime; + +use crate::{ + error::{RelayError, Result}, + interop::{ + Cookie, MediaType, Response, ResponseBody, ResponseMeta, SameSite, SizeInfo, TimingInfo, + }, +}; + +pub(crate) struct ResponseHandler { + id: i64, + headers: HashMap, + body: Bytes, + status: StatusCode, + header_size: u64, + start_time: SystemTime, + end_time: SystemTime, + version: Version, +} + +impl ResponseHandler { + pub(crate) fn new( + id: i64, + headers: HashMap, + body: Bytes, + status: StatusCode, + header_size: u64, + start_time: SystemTime, + end_time: SystemTime, + version: Version, + ) -> Self { + Self { + id, + headers, + body, + status, + header_size, + start_time, + end_time, + version, + } + } + + #[tracing::instrument(skip(self), fields(request_id = self.id), level = "debug")] + pub(crate) fn build(self) -> Result { + tracing::debug!(status = %self.status, "Building response"); + let media_type = self.determine_media_type(); + let timing = self.calculate_timing()?; + let size = SizeInfo { + headers: self.header_size, + body: self.body.len() as u64, + total: self.header_size + self.body.len() as u64, + }; + + tracing::debug!( + status = ?self.status, + media_type = ?media_type, + body_size = size.body, + total_size = size.total, + version = ?self.version, + "Response built successfully" + ); + + let cookies = self.parse_cookies(); + + let body = ResponseBody { + body: self.body, + media_type, + }; + + Ok(Response { + id: self.id, + status: self.status, + status_text: self.status.to_string(), + version: self.version, + cookies, + headers: self.headers, + meta: ResponseMeta { timing, size }, + body, + }) + } + + /// Parses the response's `Set-Cookie` header(s) into structured cookies. + /// + /// The relay used to return `cookies: None` because nothing downstream + /// consumed structured cookies, and `transfer.rs` joins multiple + /// `Set-Cookie` headers into one value with `\n`. This splits on that + /// same delimiter and parses each line with the `cookie` crate so the + /// jar service gets a real contract instead of re-parsing the joined + /// header string. The joined header is left in `headers` untouched so + /// the existing header path does not regress. + fn parse_cookies(&self) -> Option> { + let raw = self + .headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie")) + .map(|(_, v)| v.as_str())?; + + let cookies: Vec = raw + .split('\n') + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { + return None; + } + match cookie::Cookie::parse(line) { + Ok(parsed) => Some(Self::to_interop_cookie(&parsed)), + Err(e) => { + tracing::warn!(error = %e, raw = %line, "Skipping unparseable Set-Cookie"); + None + } + } + }) + .collect(); + + (!cookies.is_empty()).then_some(cookies) + } + + fn to_interop_cookie(c: &cookie::Cookie<'_>) -> Cookie { + // RFC 6265 5.2.2, Max-Age takes precedence over Expires when both + // are present. `checked_add` so an absurd Max-Age cannot panic the + // relay, it just drops the expiry and the cookie reads as session. + let expires = c + .max_age() + .and_then(|age| OffsetDateTime::now_utc().checked_add(age)) + .or_else(|| c.expires_datetime()); + + let same_site = c.same_site().map(|s| match s { + cookie::SameSite::Strict => SameSite::Strict, + cookie::SameSite::Lax => SameSite::Lax, + cookie::SameSite::None => SameSite::None, + }); + + Cookie { + name: c.name().to_owned(), + value: c.value().to_owned(), + domain: c.domain().map(str::to_owned), + path: c.path().map(str::to_owned), + expires, + secure: c.secure(), + http_only: c.http_only(), + same_site, + } + } + + fn determine_media_type(&self) -> MediaType { + tracing::trace!("Determining response content type"); + + self.headers + .iter() + .find_map(|(k, v)| { + if k.to_lowercase() == "content-type" { + v.parse::() + .ok() + .and_then(|mime| match (mime.type_(), mime.subtype()) { + (mime::APPLICATION, mime::JSON) => Some(MediaType::Json), + (mime::APPLICATION, mime::XML) => Some(MediaType::Xml), + (mime::APPLICATION, mime::OCTET_STREAM) => Some(MediaType::OctetStream), + (mime::TEXT, mime::PLAIN) => Some(MediaType::TextPlain), + (mime::TEXT, mime::HTML) => Some(MediaType::TextHtml), + (mime::TEXT, mime::CSS) => Some(MediaType::TextCss), + (mime::TEXT, mime::CSV) => Some(MediaType::TextCsv), + (mime::TEXT, mime::XML) => Some(MediaType::TextXml), + (mime::APPLICATION, mime::WWW_FORM_URLENCODED) => { + Some(MediaType::FormUrlEncoded) + } + (mime::APPLICATION, name) if name == "ld+json" => { + Some(MediaType::JsonLd) + } + (mime::MULTIPART, name) if name == "form-data" => { + Some(MediaType::MultipartFormData) + } + _ => None, + }) + } else { + None + } + }) + .or(infer::get(&self.body) + .map(|kind| MediaType::from_str(kind.mime_type()).ok()) + .flatten()) + .unwrap_or(MediaType::TextPlain) + } + + fn calculate_timing(&self) -> Result { + let start_ms = self + .start_time + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|e| { + tracing::error!(error = %e, "Failed to get start time"); + RelayError::Parse { + message: "Failed to get start time".into(), + cause: Some(e.to_string()), + } + })? + .as_millis() as u64; + + let end_ms = self + .end_time + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|e| { + tracing::error!(error = %e, "Failed to get end time"); + RelayError::Parse { + message: "Failed to get end time".into(), + cause: Some(e.to_string()), + } + })? + .as_millis() as u64; + + tracing::trace!( + start_ms = start_ms, + end_ms = end_ms, + duration_ms = end_ms - start_ms, + "Calculated request timing" + ); + + Ok(TimingInfo { + start: start_ms, + end: end_ms, + }) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/src/security.rs b/packages/hoppscotch-desktop/plugin-workspace/relay/src/security.rs new file mode 100644 index 0000000..6a2e4de --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/src/security.rs @@ -0,0 +1,173 @@ +use bytes::Bytes; +use curl::easy::Easy; + +use openssl::pkcs12::Pkcs12; + +use crate::{ + error::{RelayError, Result}, + interop::{CertificateConfig, CertificateType, SecurityConfig}, +}; + +pub(crate) struct SecurityHandler<'a> { + handle: &'a mut Easy, +} + +impl<'a> SecurityHandler<'a> { + pub(crate) fn new(handle: &'a mut Easy) -> Self { + Self { handle } + } + + #[tracing::instrument(skip(self), level = "debug")] + pub(crate) fn configure(&mut self, security: &SecurityConfig) -> Result<()> { + tracing::info!("Configuring security settings"); + + if let Some(verify) = security.verify_peer { + tracing::debug!(verify = verify, "Setting SSL verify peer"); + self.handle.ssl_verify_peer(verify).map_err(|e| { + tracing::error!(error = %e, "Failed to set SSL verify peer"); + RelayError::Certificate { + message: "Failed to set SSL verify peer".into(), + cause: Some(e.to_string()), + } + })?; + } + + if let Some(verify) = security.verify_host { + tracing::debug!(verify = verify, "Setting SSL verify host"); + self.handle.ssl_verify_host(verify).map_err(|e| { + tracing::error!(error = %e, "Failed to set SSL verify host"); + RelayError::Certificate { + message: "Failed to set SSL verify host".into(), + cause: Some(e.to_string()), + } + })?; + } + + if let Some(ref certs) = security.certificates { + self.configure_certificates(certs)?; + } + + tracing::debug!("Security configuration complete"); + Ok(()) + } + + #[tracing::instrument(skip(self), level = "debug")] + fn configure_certificates(&mut self, certs: &CertificateConfig) -> Result<()> { + if let Some(ref client_cert) = certs.client { + match client_cert { + CertificateType::Pem { cert, key } => { + tracing::info!("Configuring PEM certificate"); + self.configure_pem_certificate(cert, key)?; + } + CertificateType::Pfx { data, password } => { + tracing::info!("Configuring PKCS#12 certificate"); + self.configure_pfx_certificate(data, password)?; + } + } + } + + if let Some(ref ca_certs) = certs.ca { + self.configure_ca_certificates(ca_certs)?; + } + + Ok(()) + } + + fn configure_pem_certificate(&mut self, cert: &[u8], key: &[u8]) -> Result<()> { + tracing::debug!("Setting PEM certificate type"); + self.handle.ssl_cert_type("PEM").map_err(|e| { + tracing::error!(error = %e, "Failed to set certificate type"); + RelayError::Certificate { + message: "Failed to set certificate type".into(), + cause: Some(e.to_string()), + } + })?; + + tracing::debug!("Setting PEM certificate data"); + self.handle.ssl_cert_blob(cert).map_err(|e| { + tracing::error!(error = %e, "Failed to set client certificate"); + RelayError::Certificate { + message: "Failed to set client certificate".into(), + cause: Some(e.to_string()), + } + })?; + + tracing::debug!("Setting PEM key type"); + self.handle.ssl_key_type("PEM").map_err(|e| { + tracing::error!(error = %e, "Failed to set key type"); + RelayError::Certificate { + message: "Failed to set key type".into(), + cause: Some(e.to_string()), + } + })?; + + tracing::debug!("Setting PEM key data"); + self.handle.ssl_key_blob(key).map_err(|e| { + tracing::error!(error = %e, "Failed to set client key"); + RelayError::Certificate { + message: "Failed to set client key".into(), + cause: Some(e.to_string()), + } + })?; + + Ok(()) + } + + fn configure_pfx_certificate(&mut self, data: &[u8], password: &str) -> Result<()> { + let pkcs12 = Pkcs12::from_der(data).map_err(|e| { + tracing::error!(error = %e, "Failed to parse PKCS#12 data"); + RelayError::Certificate { + message: "Failed to parse PKCS#12 data".into(), + cause: Some(e.to_string()), + } + })?; + + let parsed = pkcs12.parse2(password).map_err(|e| { + tracing::error!(error = %e, "Failed to parse PKCS#12 password"); + RelayError::Certificate { + message: "Failed to parse PKCS#12 password".into(), + cause: Some(e.to_string()), + } + })?; + + if let (Some(cert), Some(key)) = (parsed.cert, parsed.pkey) { + let cert_pem = cert.to_pem().map_err(|e| { + tracing::error!(error = %e, "Failed to convert certificate to PEM"); + RelayError::Certificate { + message: "Failed to convert certificate to PEM".into(), + cause: Some(e.to_string()), + } + })?; + + let key_pem = key.private_key_to_pem_pkcs8().map_err(|e| { + tracing::error!(error = %e, "Failed to convert private key to PEM"); + RelayError::Certificate { + message: "Failed to convert private key to PEM".into(), + cause: Some(e.to_string()), + } + })?; + + self.configure_pem_certificate(&cert_pem, &key_pem) + } else { + tracing::error!("PKCS#12 file missing certificate or private key"); + Err(RelayError::Certificate { + message: "PKCS#12 file missing certificate or private key".into(), + cause: None, + }) + } + } + + fn configure_ca_certificates(&mut self, ca_certs: &[Bytes]) -> Result<()> { + for (index, cert) in ca_certs.iter().enumerate() { + tracing::debug!(cert_index = index, "Setting CA certificate"); + self.handle.ssl_cainfo_blob(cert).map_err(|e| { + tracing::error!(error = %e, cert_index = index, "Failed to set CA certificate"); + RelayError::Certificate { + message: format!("Failed to set CA certificate at index {}", index), + cause: Some(e.to_string()), + } + })?; + } + Ok(()) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/src/transfer.rs b/packages/hoppscotch-desktop/plugin-workspace/relay/src/transfer.rs new file mode 100644 index 0000000..dde38d9 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/src/transfer.rs @@ -0,0 +1,116 @@ +use std::collections::HashMap; + +use bytes::{Bytes, BytesMut}; +use curl::easy::Easy; +use tokio_util::sync::CancellationToken; + +use crate::error::{RelayError, Result}; + +pub(crate) struct TransferHandler { + body: BytesMut, + headers: HashMap, +} + +impl TransferHandler { + pub(crate) fn new() -> Self { + Self { + body: BytesMut::new(), + headers: HashMap::new(), + } + } + + #[tracing::instrument(skip(self, handle), level = "debug")] + pub(crate) fn handle_transfer( + &mut self, + handle: &mut Easy, + cancel_token: &CancellationToken, + ) -> Result<()> { + tracing::debug!("Setting up transfer handlers"); + let mut transfer = handle.transfer(); + + let body = &mut self.body; + let headers = &mut self.headers; + + transfer + .write_function(move |data| { + body.extend_from_slice(data); + tracing::trace!(bytes = data.len(), "Received response data chunk"); + Ok(data.len()) + }) + .map_err(|e| { + tracing::error!(error = %e, "Failed to set write callback"); + RelayError::Network { + message: "Failed to set write callback".into(), + cause: Some(e.to_string()), + } + })?; + + transfer + .header_function(move |header| { + if let Ok(header_str) = String::from_utf8(header.to_vec()) { + if let Some(idx) = header_str.find(':') { + let (key, value) = header_str.split_at(idx); + let key = key.trim().to_string(); + let value = value[1..].trim().to_string(); + + if key.to_lowercase() == "set-cookie" { + // NOTE: Special handling workaround. + // Concatenate multiple `Set-Cookie` headers. + match headers.entry(key) { + std::collections::hash_map::Entry::Occupied(mut e) => { + let existing = e.get_mut(); + existing.push_str("\n"); + existing.push_str(&value); + } + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(value); + } + } + } else { + headers.entry(key).or_insert(value); + } + } + } + true + }) + .map_err(|e| { + tracing::error!(error = %e, "Failed to set header callback"); + RelayError::Network { + message: "Failed to set header callback".into(), + cause: Some(e.to_string()), + } + })?; + + transfer + .progress_function(|_, _, _, _| { + let cancelled = cancel_token.is_cancelled(); + if cancelled { + tracing::warn!("Request cancelled by user"); + } + !cancelled + }) + .map_err(|e| { + tracing::error!(error = %e, "Failed to set progress callback"); + RelayError::Network { + message: "Failed to set progress callback".into(), + cause: Some(e.to_string()), + } + })?; + + tracing::debug!("Starting transfer"); + transfer.perform().map_err(|e| { + tracing::error!(error = %e, "Failed to perform request"); + RelayError::Network { + message: "Failed to perform request".into(), + cause: Some(e.to_string()), + } + })?; + + tracing::debug!("Transfer completed successfully"); + Ok(()) + } + + pub(crate) fn into_parts(self) -> (Bytes, HashMap) { + (self.body.into(), self.headers) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/src/util.rs b/packages/hoppscotch-desktop/plugin-workspace/relay/src/util.rs new file mode 100644 index 0000000..6e1c5ec --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/src/util.rs @@ -0,0 +1,15 @@ +pub trait ToCurlVersion { + fn to_curl_version(self) -> curl::easy::HttpVersion; +} + +impl ToCurlVersion for http::Version { + fn to_curl_version(self) -> curl::easy::HttpVersion { + match self { + http::Version::HTTP_10 => curl::easy::HttpVersion::V10, + http::Version::HTTP_11 => curl::easy::HttpVersion::V11, + http::Version::HTTP_2 => curl::easy::HttpVersion::V2, + http::Version::HTTP_3 => curl::easy::HttpVersion::V3, + _ => panic!("Unsupported"), + } + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/.envrc b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/.envrc new file mode 100644 index 0000000..894571b --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/.envrc @@ -0,0 +1,3 @@ +source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k=" + +use devenv diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/.gitignore b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/.gitignore new file mode 100644 index 0000000..c387dc9 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/.gitignore @@ -0,0 +1,22 @@ +/.vs +.DS_Store +.Thumbs.db +*.sublime* +.idea/ +debug.log +package-lock.json +.vscode/settings.json +yarn.lock + +/.tauri +/target +node_modules/ +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/Cargo.lock b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/Cargo.lock new file mode 100644 index 0000000..84f403a --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/Cargo.lock @@ -0,0 +1,5812 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +dependencies = [ + "serde", +] + +[[package]] +name = "blake3" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "serde", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +dependencies = [ + "objc2 0.6.1", +] + +[[package]] +name = "bon" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced38439e7a86a4761f7f7d5ded5ff009135939ecb464a24452eaa4c1696af7d" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce61d2d3844c6b8d31b2353d9f66cf5e632b3e9549583fe3cac2f4f6136725e" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.101", +] + +[[package]] +name = "brotli" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "bytemuck" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +dependencies = [ + "serde", +] + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.9.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.12", +] + +[[package]] +name = "cargo_toml" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02260d489095346e5cafd04dea8e8cb54d1d74fcd759022a9b72986ebe9a1257" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "cc" +version = "1.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32db95edf998450acc7881c932f94cd9b05c87b4b2599e8bab064753da4acfd1" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cocoa" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" +dependencies = [ + "bitflags 2.9.0", + "block", + "cocoa-foundation", + "core-foundation 0.10.0", + "core-graphics", + "foreign-types 0.5.0", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" +dependencies = [ + "bitflags 2.9.0", + "block", + "core-foundation 0.10.0", + "core-graphics-types", + "libc", + "objc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.9.0", + "core-foundation 0.10.0", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.9.0", + "core-foundation 0.10.0", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa 0.4.8", + "matches", + "phf 0.8.0", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.101", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.101", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.101", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.101", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.1", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "dlopen2" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1297103d2bbaea85724fcee6294c2d50b1081f9ad47d0f6f6f61eda65315a6" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b99bf03862d7f545ebc28ddd33a665b50865f4dfd84031a393823879bd4c54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "serde", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embed-resource" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbc6e0d8e0c03a655b53ca813f0463d2c956bc4db8138dbc89f120b066551e3" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +dependencies = [ + "serde", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "flate2" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.9.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.0", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "h2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.9.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex_color" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37f101bf4c633f7ca2e4b5e136050314503dd198e78e325ea602c327c484ef0" +dependencies = [ + "rand 0.8.5", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "html5ever" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa 1.0.15", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "humantime" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "itoa 1.0.15", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +dependencies = [ + "futures-util", + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.61.0", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2549ca8c7241c82f59c80ba2a6f415d931c5b58d24fb8412caa1a1f02c49139a" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8197e866e47b68f8f7d95249e172903bec06004b18b2937f1095d40a0c57de04" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown 0.15.3", + "serde", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.9.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e4755b7b995046f510a7520c42b2fed58b77bd94d5a87a8eb43d2fd126da8" +dependencies = [ + "cssparser", + "html5ever", + "indexmap 1.9.3", + "matches", + "selectors", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.9.0", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.3", +] + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" +dependencies = [ + "log", + "phf 0.10.1", + "phf_codegen 0.10.0", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime-infer" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91caed19dd472bc88bcd063571df18153529d49301a1918f4cf37f42332bee2e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "muda" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de14a9b5d569ca68d7c891d613b390cf5ab4f851c77aaa2f9e435555d3d9492" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2 0.6.1", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "once_cell", + "png", + "serde", + "thiserror 2.0.12", + "windows-sys 0.59.0", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.9.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +dependencies = [ + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +dependencies = [ + "bitflags 2.9.0", + "block2 0.6.1", + "libc", + "objc2 0.6.1", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-foundation 0.3.1", + "objc2-quartz-core 0.3.1", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.1", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.1", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags 2.9.0", + "dispatch2", + "objc2 0.6.1", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" +dependencies = [ + "bitflags 2.9.0", + "dispatch2", + "objc2 0.6.1", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e" +dependencies = [ + "objc2 0.6.1", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.9.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +dependencies = [ + "bitflags 2.9.0", + "block2 0.6.1", + "libc", + "objc2 0.6.1", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.1", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.9.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.9.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.1", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.1", + "objc2-core-foundation", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad" +dependencies = [ + "bitflags 2.9.0", + "block2 0.6.1", + "objc2 0.6.1", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" +dependencies = [ + "bitflags 2.9.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_macros 0.8.0", + "phf_shared 0.8.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.1", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d" +dependencies = [ + "base64 0.22.1", + "indexmap 2.9.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "664ec5419c51e34154eec046ebcba56312d5a2fc3b09a06da188e1ad21afadf6" +dependencies = [ + "proc-macro2", + "syn 2.0.101", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" +dependencies = [ + "toml_edit 0.20.7", +] + +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit 0.22.26", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" +dependencies = [ + "bitflags 2.9.0", +] + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.12", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "reqwest" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "windows-registry", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +dependencies = [ + "bitflags 2.9.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.101", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "matches", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", + "thin-slice", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299d9c19d7d466db4ab10addd5703e4c615dec2a5a16dbbafe191045e87ee66e" +dependencies = [ + "erased-serde", + "serde", + "typeid", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa 1.0.15", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa 1.0.15", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.9.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9823f2d3b6a81d98228151fdeaf848206a7855a7a042bbf9bf870449a66cafb" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74064874e9f6a15f04c1f3cb627902d0e6b410abbf36668afa873c61889f1763" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "servo_arc" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" + +[[package]] +name = "socket2" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "softbuffer" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +dependencies = [ + "bytemuck", + "cfg_aliases", + "core-graphics", + "foreign-types 0.5.0", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", + "raw-window-handle", + "redox_syscall", + "wasm-bindgen", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "sysinfo" +version = "0.34.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4b93974b3d3aeaa036504b8eefd4c039dced109171c1ae973f1dc63b2c7e4b2" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "windows 0.57.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.9.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e59c1f38e657351a2e822eadf40d6a2ad4627b9c25557bc1180ec1b3295ef82" +dependencies = [ + "bitflags 2.9.0", + "core-foundation 0.10.0", + "core-graphics", + "crossbeam-channel", + "dispatch", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "lazy_static", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2 0.6.1", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "once_cell", + "parking_lot", + "raw-window-handle", + "scopeguard", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.1", + "windows-core 0.61.0", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7b0bc1aec81bda6bc455ea98fcaed26b3c98c1648c627ad6ff1c704e8bf8cbc" +dependencies = [ + "anyhow", + "bytes", + "dirs", + "dunce", + "embed_plist", + "futures-util", + "getrandom 0.2.16", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2 0.6.1", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "objc2-ui-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.12", + "tokio", + "tray-icon", + "url", + "urlpattern", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.61.1", +] + +[[package]] +name = "tauri-build" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a0350f0df1db385ca5c02888a83e0e66655c245b7443db8b78a70da7d7f8fc" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93f035551bf7b11b3f51ad9bc231ebbe5e085565527991c16cf326aa38cdf47" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.101", + "tauri-utils", + "thiserror 2.0.12", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8db4df25e2d9d45de0c4c910da61cd5500190da14ae4830749fee3466dddd112" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.101", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37a5ebe6a610d1b78a94650896e6f7c9796323f408800cef436e0fa0539de601" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars", + "serde", + "serde_json", + "tauri-utils", + "toml", + "walkdir", +] + +[[package]] +name = "tauri-plugin-appload" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "blake3", + "bon", + "chrono", + "cocoa", + "dashmap", + "dunce", + "ed25519-dalek", + "flate2", + "futures", + "hex", + "hex_color", + "humantime-serde", + "lru", + "mime-infer", + "mime_guess", + "objc", + "rand 0.8.5", + "rayon", + "regex", + "reqwest", + "serde", + "serde_json", + "sysinfo", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", + "tokio", + "tracing", + "url", + "windows 0.58.0", + "winver", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f004905d549854069e6774533d742b03cacfd6f03deb08940a8677586cbe39" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2 0.6.1", + "objc2-ui-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.12", + "url", + "windows 0.61.1", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f85d056f4d4b014fe874814034f3416d57114b617a493a4fe552580851a3f3a2" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2 0.6.1", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.1", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2900399c239a471bcff7f15c4399eb1a8c4fe511ba2853e07c996d771a5e0a4" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.12", + "toml", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d321dbc6f998d825ab3f0d62673e810c861aac2d0de2cc2c395328f1d113b4" +dependencies = [ + "embed-resource", + "indexmap 2.9.0", + "toml", +] + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thin-slice" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa 1.0.15", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.22.26", +] + +[[package]] +name = "toml_datetime" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.9.0", + "toml_datetime", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" +dependencies = [ + "indexmap 2.9.0", + "toml_datetime", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" +dependencies = [ + "indexmap 2.9.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow 0.7.10", +] + +[[package]] +name = "toml_write" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7eee98ec5c90daf179d55c20a49d8c0d043054ce7c26336c09a24d31f14fa0" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2 0.6.1", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.1", + "once_cell", + "png", + "serde", + "thiserror 2.0.12", + "windows-sys 0.59.0", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +dependencies = [ + "getrandom 0.3.3", + "serde", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.101", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b542b5cfbd9618c46c2784e4d41ba218c336ac70d44c55e47b251033e7d85601" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.1", + "windows-core 0.61.0", + "windows-implement 0.60.0", + "windows-interface 0.59.1", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "webview2-com-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae2d11c4a686e4409659d7891791254cf9286d3cfe0eef54df1523533d22295" +dependencies = [ + "thiserror 2.0.12", + "windows 0.61.1", + "windows-core 0.61.0", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2 0.6.1", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" +dependencies = [ + "windows-collections", + "windows-core 0.61.0", + "windows-future", + "windows-link", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.0", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +dependencies = [ + "windows-implement 0.60.0", + "windows-interface 0.59.1", + "windows-link", + "windows-result 0.3.2", + "windows-strings 0.4.0", +] + +[[package]] +name = "windows-future" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32" +dependencies = [ + "windows-core 0.61.0", + "windows-link", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.0", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +dependencies = [ + "windows-result 0.3.2", + "windows-strings 0.3.1", + "windows-targets 0.53.0", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows-version" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04a5c6627e310a23ad2358483286c7df260c964eb2d003d8efd6d0f4e79265c" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "winver" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e0e7162b9e282fd75a0a832cce93994bdb21208d848a418cd05a5fdd9b9ab33" +dependencies = [ + "windows 0.48.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.0", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "wry" +version = "0.51.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c886a0a9d2a94fd90cfa1d929629b79cfefb1546e2c7430c63a47f0664c0e4e2" +dependencies = [ + "base64 0.22.1", + "block2 0.6.1", + "cookie", + "crossbeam-channel", + "dpi", + "dunce", + "gdkx11", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "ndk", + "objc2 0.6.1", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.12", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.1", + "windows-core 0.61.0", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "zip" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "flate2", + "getrandom 0.3.3", + "hmac", + "indexmap 2.9.0", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zopfli" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.15+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/Cargo.toml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/Cargo.toml new file mode 100644 index 0000000..89dd7a6 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "tauri-plugin-appload" +version = "0.1.0" +authors = [ "CuriousCorrelation" ] +description = "A Tauri plugin to download and load web app into WebView" +edition = "2021" +rust-version = "1.77.2" +exclude = ["/examples", "/webview-dist", "/webview-src", "/node_modules"] +links = "tauri-plugin-appload" + +[dependencies] +tauri = { version = "2.0.6" } +serde = "1.0" +serde_json = { version = "1", features = [] } +thiserror = { version = "2.0.3", features = [] } +url = { version = "2.5.3", features = ["serde"] } +reqwest = { version = "0.12.9", features = ["json"] } +zip = { version = "2.2.0", features = [] } +tokio = { version = "1.41.1", features = [] } +mime-infer = { version = "3.0.0", features = [] } +regex = { version = "1.11.1", features = [] } +tracing = "0.1.41" +dashmap = { version = "6.1.0" } +flate2 = { version = "1.0.35" } +chrono = { version = "0.4.38", features = ["serde"] } +base64 = "0.22.1" +blake3 = { version = "1.5.4", features = ["serde"] } +ed25519-dalek = { version = "2.1.1", features = ["rand_core", "serde"] } +hex = "0.4.3" +lru = "0.12.5" +sysinfo = "0.34.2" +humantime-serde = "1.1.1" +futures = "0.3.31" +mime_guess = "2.0.5" +rayon = "1.10.0" +hex_color = "3.0.0" +dunce = "1.0.5" +bon = "3.6.3" + +[build-dependencies] +tauri-plugin = { version = "2.0.1", features = ["build"] } + +[target.'cfg(target_os = "macos")'.dependencies] +cocoa = "0.26.0" +objc = "0.2.7" +rand = "0.8.5" + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.58.0", features = [ + "Win32_Graphics_Dwm", + "Win32_Foundation", + "Win32_UI_Controls", +] } +winver = "1.0.0" diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/LICENSE.md b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/LICENSE.md new file mode 100644 index 0000000..2b0b5d5 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 - CuriousCorrelation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/README.md b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/README.md new file mode 100644 index 0000000..11fb364 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/README.md @@ -0,0 +1,105 @@ +# Tauri Plugin: AppLoad + +> A Tauri plugin for downloading and loading web app bundles into WebView. + +
+ +![GitHub License MIT](https://img.shields.io/github/license/CuriousCorrelation/tauri-plugin-appload) +![Tauri 2.0](https://img.shields.io/badge/Tauri-2.0-blue) +[![Rust](https://img.shields.io/badge/Rust-1.77.2+-orange)](https://www.rust-lang.org) + +
+ +## Features + +- 🦀 Blazingly fast! +- Download and load web app bundles from remote servers +- Secure verification using `ed25519` + `blake3` +- Caching with hot/cold storage strategy +- Custom URI scheme for isolated app loading + +## Installation + +> [!IMPORTANT] +> This plugin requires Tauri 2.0 or later. + +Add the plugin to your project by installing directly from GitHub: + +```toml +[dependencies] +tauri-plugin-appload = { git = "https://github.com/CuriousCorrelation/tauri-plugin-appload" } +``` + +``` json +"dependencies": { + "@CuriousCorrelation/plugin-appload": "github:CuriousCorrelation/tauri-plugin-appload" +} +``` + +## Quick Start + +### Rust + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_appload::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +### JavaScript/TypeScript + +```typescript +import { download, load } from '@CuriousCorrelation/plugin-appload' + +// Download a bundle +const { bundleName } = await download({ + serverUrl: "https://example.com" +}) + +// Load the bundle in a new window +await load({ + bundleName, + window: { + title: "My App", + width: 800, + height: 600 + } +}) +``` + +## Configuration + +> [!NOTE] +> The plugin uses sensible defaults but can be customized via configuration. + +| Option | Description | Default | +|--------|-------------|---------| +| `api.serverUrl` | Bundle server URL | `http://localhost:3200` | +| `cache.maxSize` | Maximum cache size | `100MB` | +| `cache.filesTtl` | File time-to-live | `1 hour` | +| `storage.maxBundleSize` | Maximum bundle size | `50MB` | + +## Permissions + +The plugin defines the following permissions: + +- `allow-download`: Enable bundle downloads +- `allow-load`: Enable bundle loading +- `deny-download`: Disable bundle downloads +- `deny-load`: Disable bundle loading + +## Development + +Requirements: +- Rust 1.77.2 or later +- Node.js 18 or later +- pnpm + +## License + +Code: (c) 2025 - CuriousCorrelation + +MIT or MIT/Apache 2.0 where applicable. diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/build.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/build.rs new file mode 100644 index 0000000..3412832 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/build.rs @@ -0,0 +1,8 @@ +const COMMANDS: &[&str] = &["load", "download", "clear", "close", "remove"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .android_path("android") + .ios_path("ios") + .build(); +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/devenv.lock b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/devenv.lock new file mode 100644 index 0000000..9108f14 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/devenv.lock @@ -0,0 +1,105 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1778705971, + "narHash": "sha256-n0LjnKBAjJ5/mgNzOCeVvAeHUrNMUZ3fBQx/UDCkHtQ=", + "owner": "cachix", + "repo": "devenv", + "rev": "64d4353a3628c4138c84d8ba10987da2ba27fddd", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1778748763, + "narHash": "sha256-x4dC4C/GtyOGlgIAPml8SfXeHwLkG/66r7zk7ueL0OM=", + "owner": "nix-community", + "repo": "fenix", + "rev": "67a701a5cb6507815a33d8f7b3466c78a7d724e7", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1778672786, + "narHash": "sha256-Blg88K1jwG+P0Mr27+rKMFCufdrWkV3wWh9AdYtz0FQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "eef00dfd8a712b34af845f9350bac681b1228bd1", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "fenix": "fenix", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1778703600, + "narHash": "sha256-EpgXYEuDyqWKcyhO4EP/nRY+7ZnQwNDBYMVb/k06ME4=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "77db6109f0952f64ffc94d2e4215aa20b3d703bf", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1778728594, + "narHash": "sha256-+gIOsOzqWNfn+ThCXBQGcLHVEnaGQW59XjghE9JUIYk=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "83a17ebffcfacb17b49e1b5e9dc15eed07936648", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} \ No newline at end of file diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/devenv.nix b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/devenv.nix new file mode 100644 index 0000000..a266648 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/devenv.nix @@ -0,0 +1,90 @@ +{ pkgs, lib, config, inputs, ... }: + +let + rosettaPkgs = + if pkgs.stdenv.isDarwin && pkgs.stdenv.isAarch64 + then pkgs.pkgsx86_64Darwin + else pkgs; + + darwinPackages = with pkgs; [ + # Unified apple-sdk replaces the per-framework + # darwin.apple_sdk.frameworks.* entries that were removed during the + # nixpkgs Darwin SDK migration. The default unversioned SDK bundles + # Security, CoreServices, CoreFoundation, Foundation, AppKit, and + # WebKit, which is what this shell consumed before. + apple-sdk + ]; + + linuxPackages = with pkgs; [ + libsoup_3 + webkitgtk_4_1 + librsvg + libappindicator + libayatana-appindicator + ]; + +in { + packages = with pkgs; [ + git + nodejs_22 + typescript-language-server + vue-language-server + cargo-edit + ] ++ lib.optionals pkgs.stdenv.isDarwin darwinPackages + ++ lib.optionals pkgs.stdenv.isLinux linuxPackages; + + env = { + APP_GREET = "Hi!"; + } // lib.optionalAttrs pkgs.stdenv.isLinux { + LD_LIBRARY_PATH = lib.makeLibraryPath [ + pkgs.libappindicator + pkgs.libayatana-appindicator + ]; + } // lib.optionalAttrs pkgs.stdenv.isDarwin { + # Place to put macOS-specific environment variables + }; + + scripts = { + hello.exec = "echo hello from $APP_GREET"; + e.exec = "emacs"; + }; + + enterShell = '' + git --version + ${lib.optionalString pkgs.stdenv.isDarwin '' + # Place to put macOS-specific shell initialization + ''} + ${lib.optionalString pkgs.stdenv.isLinux '' + # Place to put Linux-specific shell initialization + ''} + ''; + + enterTest = '' + echo "Running tests" + ''; + + dotenv.enable = true; + + languages = { + typescript.enable = true; + javascript = { + enable = true; + pnpm.enable = true; + npm.enable = true; + }; + rust = { + enable = true; + channel = "nightly"; + components = [ + "rustc" + "cargo" + "clippy" + "rustfmt" + "rust-analyzer" + "llvm-tools-preview" + "rust-src" + "rustc-codegen-cranelift-preview" + ]; + }; + }; +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/devenv.yaml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/devenv.yaml new file mode 100644 index 0000000..d016920 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/devenv.yaml @@ -0,0 +1,14 @@ +inputs: + fenix: + url: github:nix-community/fenix + inputs: + nixpkgs: + follows: nixpkgs + nixpkgs: + url: github:NixOS/nixpkgs/nixpkgs-unstable + rust-overlay: + url: github:oxalica/rust-overlay + inputs: + nixpkgs: + follows: nixpkgs +allowUnfree: true diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/dist-js/index.cjs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/dist-js/index.cjs new file mode 100644 index 0000000..aa575c4 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/dist-js/index.cjs @@ -0,0 +1,25 @@ +'use strict'; + +var core = require('@tauri-apps/api/core'); + +async function download(options) { + return await core.invoke('plugin:appload|download', { options }); +} +async function load(options) { + return await core.invoke('plugin:appload|load', { options }); +} +async function close(options) { + return await core.invoke('plugin:appload|close', { options }); +} +async function remove(options) { + return await core.invoke('plugin:appload|remove', { options }); +} +async function clear() { + return await core.invoke('plugin:appload|clear'); +} + +exports.clear = clear; +exports.close = close; +exports.download = download; +exports.load = load; +exports.remove = remove; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/dist-js/index.d.ts b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/dist-js/index.d.ts new file mode 100644 index 0000000..34a23ed --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/dist-js/index.d.ts @@ -0,0 +1,72 @@ +export interface DownloadOptions { + serverUrl: string; +} +export interface DownloadResponse { + success: boolean; + bundleName: string; + serverUrl: string; + version: string; +} +export interface WindowOptions { + title?: string; + width?: number; + height?: number; + resizable?: boolean; + /** + * Initial WebView zoom factor applied between window creation and first + * paint. When omitted, the WebView opens at its native default of 1.0. + * Pass a persisted user preference here to avoid the 100% flash that a + * post-mount setZoom from the bundle would produce. + */ + zoomLevel?: number; +} +export interface LoadOptions { + bundleName: string; + /** + * Optional host override for the webview URL. On web, org context comes from + * window.location.hostname (acme.hoppscotch.io). On desktop, the webview URL is + * normally app://{bundleName}/ which always returns the same hostname. Passing a host + * creates the webview at app://{host}/ instead, so the JS can read + * window.location.hostname and get org context the same way. + * + * When provided, the webview will be loaded with `app://{host}/` instead of + * `app://{bundleName}/`. This enables cloud-for-orgs support where the same + * bundle serves multiple organization subdomains. + * + * The host will be sanitized (dots become underscores) for URL compatibility. + * The JavaScript bundle can read `window.location.hostname` to determine + * the organization context. + * + * @example + * // Load Hoppscotch bundle as acme.hoppscotch.io + * load({ bundleName: "Hoppscotch", host: "acme.hoppscotch.io" }) + * // Results in: window.location.hostname === "acme_hoppscotch_io" + */ + host?: string; + inline?: boolean; + window?: WindowOptions; +} +export interface LoadResponse { + success: boolean; + windowLabel: string; +} +export interface CloseOptions { + windowLabel: string; +} +export interface CloseResponse { + success: boolean; +} +export interface RemoveOptions { + bundleName: string; + serverUrl: string; +} +export interface RemoveResponse { + success: boolean; + bundleName: string; +} +export declare function download(options: DownloadOptions): Promise; +export declare function load(options: LoadOptions): Promise; +export declare function close(options: CloseOptions): Promise; +export declare function remove(options: RemoveOptions): Promise; +export declare function clear(): Promise; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/dist-js/index.d.ts.map b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/dist-js/index.d.ts.map new file mode 100644 index 0000000..5862ab3 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/dist-js/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../guest-js/index.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,WAAW;IAC1B,UAAU,EAAE,MAAM,CAAA;IAClB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,OAAO,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,wBAAsB,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAElF;AAED,wBAAsB,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC,CAEtE;AAED,wBAAsB,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC,CAEzE;AAED,wBAAsB,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,cAAc,CAAC,CAE5E;AAED,wBAAsB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAE3C"} \ No newline at end of file diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/dist-js/index.js b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/dist-js/index.js new file mode 100644 index 0000000..5dbd5b7 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/dist-js/index.js @@ -0,0 +1,19 @@ +import { invoke } from '@tauri-apps/api/core'; + +async function download(options) { + return await invoke('plugin:appload|download', { options }); +} +async function load(options) { + return await invoke('plugin:appload|load', { options }); +} +async function close(options) { + return await invoke('plugin:appload|close', { options }); +} +async function remove(options) { + return await invoke('plugin:appload|remove', { options }); +} +async function clear() { + return await invoke('plugin:appload|clear'); +} + +export { clear, close, download, load, remove }; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/.gitignore b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/.vscode/extensions.json b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/.vscode/extensions.json new file mode 100644 index 0000000..61343e9 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "svelte.svelte-vscode", + "tauri-apps.tauri-vscode", + "rust-lang.rust-analyzer" + ] +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/README.md b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/README.md new file mode 100644 index 0000000..72726a1 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/README.md @@ -0,0 +1,8 @@ +# Svelte + Vite + +This template should help get you started developing with Tauri and Svelte in Vite. + +## Recommended IDE Setup + +[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer). + diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/index.html b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/index.html new file mode 100644 index 0000000..fad1c5d --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/index.html @@ -0,0 +1,14 @@ + + + + + + + Tauri + Svelte + + + +
+ + + diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/jsconfig.json b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/jsconfig.json new file mode 100644 index 0000000..ee5e92f --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/jsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "moduleResolution": "Node", + "target": "ESNext", + "module": "ESNext", + /** + * svelte-preprocess cannot figure out whether you have + * a value or a type, so tell TypeScript to enforce using + * `import type` instead of `import` for Types. + */ + "importsNotUsedAsValues": "error", + "isolatedModules": true, + "resolveJsonModule": true, + /** + * To have warnings / errors of the Svelte compiler at the + * correct position, enable source maps by default. + */ + "sourceMap": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable this if you'd like to use dynamic types. + */ + "checkJs": true + }, + /** + * Use global.d.ts instead of compilerOptions.types + * to avoid limiting type declarations. + */ + "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"] +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/package.json b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/package.json new file mode 100644 index 0000000..f4d0dad --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/package.json @@ -0,0 +1,22 @@ +{ + "name": "tauri-app", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "2.1.1", + "tauri-plugin-appload-api": "file:../../" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^1.0.1", + "svelte": "^3.49.0", + "vite": "^3.0.2", + "@tauri-apps/cli": "^2.0.0-alpha.17" + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/public/svelte.svg b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/public/svelte.svg new file mode 100644 index 0000000..c5e0848 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/public/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/public/tauri.svg b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/public/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/public/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/public/vite.svg b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/.gitignore b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/.gitignore new file mode 100644 index 0000000..f4dfb82 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/.gitignore @@ -0,0 +1,4 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/Cargo.toml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/Cargo.toml new file mode 100644 index 0000000..5672cb5 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "tauri-app" +version = "0.1.0" +description = "A Tauri App" +authors = ["you"] +license = "" +repository = "" +edition = "2021" +rust-version = "1.77.2" + +[lib] +name = "tauri_app_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[build-dependencies] +tauri-build = { version = "2.0.1", default-features = false } + +[dependencies] +tauri = { version = "2.0.6" } +tauri-plugin-appload = { path = "../../../" } + diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/build.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/build.rs new file mode 100644 index 0000000..795b9b7 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/capabilities/default.json b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/capabilities/default.json new file mode 100644 index 0000000..3076a24 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/capabilities/default.json @@ -0,0 +1,12 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "enables the default permissions", + "windows": [ + "main" + ], + "permissions": [ + "core:default", + "appload:default" + ] +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/128x128.png b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/128x128.png new file mode 100644 index 0000000..77e7d23 Binary files /dev/null and b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/128x128.png differ diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/128x128@2x.png b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..0f7976f Binary files /dev/null and b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/128x128@2x.png differ diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/32x32.png b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/32x32.png new file mode 100644 index 0000000..98fda06 Binary files /dev/null and b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/32x32.png differ diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/icon.icns b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/icon.icns new file mode 100644 index 0000000..29d6685 Binary files /dev/null and b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/icon.icns differ diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/icon.ico b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/icon.ico new file mode 100644 index 0000000..06c23c8 Binary files /dev/null and b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/icon.ico differ diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/icon.png b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/icon.png new file mode 100644 index 0000000..d1756ce Binary files /dev/null and b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/icons/icon.png differ diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/src/lib.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/src/lib.rs new file mode 100644 index 0000000..2650a62 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/src/lib.rs @@ -0,0 +1,14 @@ +// Learn more about Tauri commands at https://v2.tauri.app/develop/calling-rust/#commands +#[tauri::command] +fn greet(name: &str) -> String { + format!("Hello, {}! You've been greeted from Rust!", name) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![greet]) + .plugin(tauri_plugin_appload::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/src/main.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/src/main.rs new file mode 100644 index 0000000..455963e --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + tauri_app_lib::run(); +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/tauri.conf.json b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/tauri.conf.json new file mode 100644 index 0000000..72ebf40 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src-tauri/tauri.conf.json @@ -0,0 +1,37 @@ +{ + "productName": "tauri-app", + "version": "0.1.0", + "identifier": "com.tauri.dev", + "build": { + "beforeDevCommand": "pnpm dev", + "beforeBuildCommand": "pnpm build", + "devUrl": "http://localhost:1420", + "frontendDist": "../dist" + }, + "app": { + "withGlobalTauri": false, + "security": { + "csp": null + }, + "windows": [ + { + "fullscreen": false, + "height": 600, + "resizable": true, + "title": "tauri-app", + "width": 800 + } + ] + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/App.svelte b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/App.svelte new file mode 100644 index 0000000..7e97590 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/App.svelte @@ -0,0 +1,54 @@ + + +
+

Welcome to Tauri!

+ + + +

+ Click on the Tauri, Vite, and Svelte logos to learn more. +

+ +
+ +
+ +
+ +
{@html response}
+
+ +
+ + diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/lib/Greet.svelte b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/lib/Greet.svelte new file mode 100644 index 0000000..41e901b --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/lib/Greet.svelte @@ -0,0 +1,22 @@ + + +
+
+ + +
+

{greetMsg}

+
+ diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/main.js b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/main.js new file mode 100644 index 0000000..6b4e1a9 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/main.js @@ -0,0 +1,8 @@ +import "./style.css"; +import App from "./App.svelte"; + +const app = new App({ + target: document.getElementById("app"), +}); + +export default app; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/style.css b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/style.css new file mode 100644 index 0000000..c0f9e3b --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/style.css @@ -0,0 +1,102 @@ +:root { + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 400; + + color: #0f0f0f; + background-color: #f6f6f6; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +.container { + margin: 0; + padding-top: 10vh; + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: 0.75s; +} + +.logo.tauri:hover { + filter: drop-shadow(0 0 2em #24c8db); +} + +.row { + display: flex; + justify-content: center; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} + +a:hover { + color: #535bf2; +} + +h1 { + text-align: center; +} + +input, +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + color: #0f0f0f; + background-color: #ffffff; + transition: border-color 0.25s; + box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); +} + +button { + cursor: pointer; +} + +button:hover { + border-color: #396cd8; +} + +input, +button { + outline: none; +} + +#greet-input { + margin-right: 5px; +} + +@media (prefers-color-scheme: dark) { + :root { + color: #f6f6f6; + background-color: #2f2f2f; + } + + a:hover { + color: #24c8db; + } + + input, + button { + color: #ffffff; + background-color: #0f0f0f98; + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/vite-env.d.ts b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/vite-env.d.ts new file mode 100644 index 0000000..4078e74 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/vite.config.js b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/vite.config.js new file mode 100644 index 0000000..3b85afb --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/vite.config.js @@ -0,0 +1,24 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; + +const host = process.env.TAURI_DEV_HOST; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [svelte()], + + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // prevent vite from obscuring rust errors + clearScreen: false, + // tauri expects a fixed port, fail if that port is not available + server: { + host: host || false, + port: 1420, + strictPort: true, + hmr: host ? { + protocol: 'ws', + host, + port: 1421 + } : undefined, + }, +}) diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/guest-js/index.ts b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/guest-js/index.ts new file mode 100644 index 0000000..3caf436 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/guest-js/index.ts @@ -0,0 +1,96 @@ +import { invoke } from '@tauri-apps/api/core' + +export interface DownloadOptions { + serverUrl: string +} + +export interface DownloadResponse { + success: boolean + bundleName: string + serverUrl: string + version: string +} + +export interface WindowOptions { + title?: string + width?: number + height?: number + resizable?: boolean + /** + * Initial WebView zoom factor applied between window creation and first + * paint. When omitted, the WebView opens at its native default of 1.0. + * Pass a persisted user preference here to avoid the 100% flash that a + * post-mount setZoom from the bundle would produce. + */ + zoomLevel?: number +} + +export interface LoadOptions { + bundleName: string + /** + * Optional host override for the webview URL. On web, org context comes from + * window.location.hostname (acme.hoppscotch.io). On desktop, the webview URL is + * normally app://{bundleName}/ which always returns the same hostname. Passing a host + * creates the webview at app://{host}/ instead, so the JS can read + * window.location.hostname and get org context the same way. + * + * When provided, the webview will be loaded with `app://{host}/` instead of + * `app://{bundleName}/`. This enables cloud-for-orgs support where the same + * bundle serves multiple organization subdomains. + * + * The host will be sanitized (dots become underscores) for URL compatibility. + * The JavaScript bundle can read `window.location.hostname` to determine + * the organization context. + * + * @example + * // Load Hoppscotch bundle as acme.hoppscotch.io + * load({ bundleName: "Hoppscotch", host: "acme.hoppscotch.io" }) + * // Results in: window.location.hostname === "acme_hoppscotch_io" + */ + host?: string; + inline?: boolean; + window?: WindowOptions; +} + +export interface LoadResponse { + success: boolean + windowLabel: string +} + +export interface CloseOptions { + windowLabel: string +} + +export interface CloseResponse { + success: boolean +} + +export interface RemoveOptions { + bundleName: string + serverUrl: string +} + +export interface RemoveResponse { + success: boolean + bundleName: string +} + +export async function download(options: DownloadOptions): Promise { + return await invoke('plugin:appload|download', { options }) +} + +export async function load(options: LoadOptions): Promise { + return await invoke('plugin:appload|load', { options }) +} + +export async function close(options: CloseOptions): Promise { + return await invoke('plugin:appload|close', { options }) +} + +export async function remove(options: RemoveOptions): Promise { + return await invoke('plugin:appload|remove', { options }) +} + +export async function clear(): Promise { + return await invoke('plugin:appload|clear') +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/package.json b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/package.json new file mode 100644 index 0000000..44e167a --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/package.json @@ -0,0 +1,33 @@ +{ + "name": "@CuriousCorrelation/plugin-appload", + "version": "0.1.0", + "author": "CuriousCorrelation", + "description": "A Tauri plugin for downloading and loading web app bundles into WebView.", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "files": [ + "dist-js", + "README.md" + ], + "scripts": { + "build": "tsc && rollup -c", + "prepublishOnly": "pnpm build", + "pretest": "pnpm build" + }, + "dependencies": { + "@tauri-apps/api": "2.9.1" + }, + "devDependencies": { + "@rollup/plugin-typescript": "^12.3.0", + "rollup": "^4.59.0", + "tslib": "^2.8.1", + "typescript": "5.9.3" + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/clear.toml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/clear.toml new file mode 100644 index 0000000..83de181 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/clear.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-clear" +description = "Enables the clear command without any pre-configured scope." +commands.allow = ["clear"] + +[[permission]] +identifier = "deny-clear" +description = "Denies the clear command without any pre-configured scope." +commands.deny = ["clear"] diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/close.toml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/close.toml new file mode 100644 index 0000000..fad12d1 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/close.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-close" +description = "Enables the close command without any pre-configured scope." +commands.allow = ["close"] + +[[permission]] +identifier = "deny-close" +description = "Denies the close command without any pre-configured scope." +commands.deny = ["close"] diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/download.toml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/download.toml new file mode 100644 index 0000000..896b30c --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/download.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-download" +description = "Enables the download command without any pre-configured scope." +commands.allow = ["download"] + +[[permission]] +identifier = "deny-download" +description = "Denies the download command without any pre-configured scope." +commands.deny = ["download"] diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/load.toml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/load.toml new file mode 100644 index 0000000..f6e47ad --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/load.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-load" +description = "Enables the load command without any pre-configured scope." +commands.allow = ["load"] + +[[permission]] +identifier = "deny-load" +description = "Denies the load command without any pre-configured scope." +commands.deny = ["load"] diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/ping.toml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/ping.toml new file mode 100644 index 0000000..1d13588 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/ping.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-ping" +description = "Enables the ping command without any pre-configured scope." +commands.allow = ["ping"] + +[[permission]] +identifier = "deny-ping" +description = "Denies the ping command without any pre-configured scope." +commands.deny = ["ping"] diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/remove.toml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/remove.toml new file mode 100644 index 0000000..9c9791e --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/commands/remove.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-remove" +description = "Enables the remove command without any pre-configured scope." +commands.allow = ["remove"] + +[[permission]] +identifier = "deny-remove" +description = "Denies the remove command without any pre-configured scope." +commands.deny = ["remove"] diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/reference.md b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/reference.md new file mode 100644 index 0000000..a62dce5 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/autogenerated/reference.md @@ -0,0 +1,177 @@ +## Default Permission + +Default permissions for AppLoad plugin + +#### This default permission set includes the following: + +- `allow-load` +- `allow-download` +- `allow-clear` +- `allow-close` +- `allow-remove` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`appload:allow-clear` + + + +Enables the clear command without any pre-configured scope. + +
+ +`appload:deny-clear` + + + +Denies the clear command without any pre-configured scope. + +
+ +`appload:allow-close` + + + +Enables the close command without any pre-configured scope. + +
+ +`appload:deny-close` + + + +Denies the close command without any pre-configured scope. + +
+ +`appload:allow-download` + + + +Enables the download command without any pre-configured scope. + +
+ +`appload:deny-download` + + + +Denies the download command without any pre-configured scope. + +
+ +`appload:allow-load` + + + +Enables the load command without any pre-configured scope. + +
+ +`appload:deny-load` + + + +Denies the load command without any pre-configured scope. + +
+ +`appload:allow-ping` + + + +Enables the ping command without any pre-configured scope. + +
+ +`appload:deny-ping` + + + +Denies the ping command without any pre-configured scope. + +
+ +`appload:allow-remove` + + + +Enables the remove command without any pre-configured scope. + +
+ +`appload:deny-remove` + + + +Denies the remove command without any pre-configured scope. + +
diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/default.toml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/default.toml new file mode 100644 index 0000000..2bb1ae2 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/default.toml @@ -0,0 +1,3 @@ +[default] +description = "Default permissions for AppLoad plugin" +permissions = ["allow-load", "allow-download", "allow-clear", "allow-close", "allow-remove"] diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/schemas/schema.json b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/schemas/schema.json new file mode 100644 index 0000000..4381a8e --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/schemas/schema.json @@ -0,0 +1,378 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the clear command without any pre-configured scope.", + "type": "string", + "const": "allow-clear", + "markdownDescription": "Enables the clear command without any pre-configured scope." + }, + { + "description": "Denies the clear command without any pre-configured scope.", + "type": "string", + "const": "deny-clear", + "markdownDescription": "Denies the clear command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Enables the download command without any pre-configured scope.", + "type": "string", + "const": "allow-download", + "markdownDescription": "Enables the download command without any pre-configured scope." + }, + { + "description": "Denies the download command without any pre-configured scope.", + "type": "string", + "const": "deny-download", + "markdownDescription": "Denies the download command without any pre-configured scope." + }, + { + "description": "Enables the load command without any pre-configured scope.", + "type": "string", + "const": "allow-load", + "markdownDescription": "Enables the load command without any pre-configured scope." + }, + { + "description": "Denies the load command without any pre-configured scope.", + "type": "string", + "const": "deny-load", + "markdownDescription": "Denies the load command without any pre-configured scope." + }, + { + "description": "Enables the ping command without any pre-configured scope.", + "type": "string", + "const": "allow-ping", + "markdownDescription": "Enables the ping command without any pre-configured scope." + }, + { + "description": "Denies the ping command without any pre-configured scope.", + "type": "string", + "const": "deny-ping", + "markdownDescription": "Denies the ping command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Default permissions for AppLoad plugin\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-download`\n- `allow-clear`\n- `allow-close`\n- `allow-remove`", + "type": "string", + "const": "default", + "markdownDescription": "Default permissions for AppLoad plugin\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-download`\n- `allow-clear`\n- `allow-close`\n- `allow-remove`" + } + ] + } + } +} \ No newline at end of file diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/pnpm-lock.yaml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/pnpm-lock.yaml new file mode 100644 index 0000000..5cf8848 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/pnpm-lock.yaml @@ -0,0 +1,362 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tauri-apps/api': + specifier: 2.9.1 + version: 2.9.1 + devDependencies: + '@rollup/plugin-typescript': + specifier: ^12.3.0 + version: 12.3.0(rollup@4.54.0)(tslib@2.8.1)(typescript@5.9.3) + rollup: + specifier: ^4.54.0 + version: 4.54.0 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + +packages: + + '@rollup/plugin-typescript@12.3.0': + resolution: {integrity: sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.14.0||^3.0.0||^4.0.0 + tslib: '*' + typescript: '>=3.7.0' + peerDependenciesMeta: + rollup: + optional: true + tslib: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.54.0': + resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.54.0': + resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.54.0': + resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.54.0': + resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.54.0': + resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.54.0': + resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.54.0': + resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.54.0': + resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.54.0': + resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.54.0': + resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.54.0': + resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.54.0': + resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.54.0': + resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.54.0': + resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.54.0': + resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.54.0': + resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.54.0': + resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.54.0': + resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.54.0': + resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.54.0': + resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.54.0': + resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.54.0': + resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==} + cpu: [x64] + os: [win32] + + '@tauri-apps/api@2.9.1': + resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + rollup@4.54.0: + resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + +snapshots: + + '@rollup/plugin-typescript@12.3.0(rollup@4.54.0)(tslib@2.8.1)(typescript@5.9.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.54.0) + resolve: 1.22.11 + typescript: 5.9.3 + optionalDependencies: + rollup: 4.54.0 + tslib: 2.8.1 + + '@rollup/pluginutils@5.3.0(rollup@4.54.0)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.54.0 + + '@rollup/rollup-android-arm-eabi@4.54.0': + optional: true + + '@rollup/rollup-android-arm64@4.54.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.54.0': + optional: true + + '@rollup/rollup-darwin-x64@4.54.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.54.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.54.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.54.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.54.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.54.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.54.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.54.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.54.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.54.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.54.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.54.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.54.0': + optional: true + + '@tauri-apps/api@2.9.1': {} + + '@types/estree@1.0.8': {} + + estree-walker@2.0.2: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + path-parse@1.0.7: {} + + picomatch@4.0.3: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rollup@4.54.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.54.0 + '@rollup/rollup-android-arm64': 4.54.0 + '@rollup/rollup-darwin-arm64': 4.54.0 + '@rollup/rollup-darwin-x64': 4.54.0 + '@rollup/rollup-freebsd-arm64': 4.54.0 + '@rollup/rollup-freebsd-x64': 4.54.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.54.0 + '@rollup/rollup-linux-arm-musleabihf': 4.54.0 + '@rollup/rollup-linux-arm64-gnu': 4.54.0 + '@rollup/rollup-linux-arm64-musl': 4.54.0 + '@rollup/rollup-linux-loong64-gnu': 4.54.0 + '@rollup/rollup-linux-ppc64-gnu': 4.54.0 + '@rollup/rollup-linux-riscv64-gnu': 4.54.0 + '@rollup/rollup-linux-riscv64-musl': 4.54.0 + '@rollup/rollup-linux-s390x-gnu': 4.54.0 + '@rollup/rollup-linux-x64-gnu': 4.54.0 + '@rollup/rollup-linux-x64-musl': 4.54.0 + '@rollup/rollup-openharmony-arm64': 4.54.0 + '@rollup/rollup-win32-arm64-msvc': 4.54.0 + '@rollup/rollup-win32-ia32-msvc': 4.54.0 + '@rollup/rollup-win32-x64-gnu': 4.54.0 + '@rollup/rollup-win32-x64-msvc': 4.54.0 + fsevents: 2.3.3 + + supports-preserve-symlinks-flag@1.0.0: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/rollup.config.js b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/rollup.config.js new file mode 100644 index 0000000..f420bfd --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/rollup.config.js @@ -0,0 +1,32 @@ +import { readFileSync } from 'fs' +import { join } from 'path' +import { cwd } from 'process' +import typescript from '@rollup/plugin-typescript' + +const pkg = JSON.parse(readFileSync(join(cwd(), 'package.json'), 'utf8')) + +export default { + input: 'guest-js/index.ts', + output: [ + { + file: pkg.exports.import, + format: 'esm' + }, + { + file: pkg.exports.require, + format: 'cjs' + } + ], + plugins: [ + typescript({ + tsconfig: './tsconfig.json', + declaration: true, + declarationDir: './dist-js' + }) + ], + external: [ + /^@tauri-apps\/api/, + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}) + ] +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/api/client.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/api/client.rs new file mode 100644 index 0000000..f95ec3b --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/api/client.rs @@ -0,0 +1,136 @@ +use std::time::Duration; + +use reqwest::{Client as HttpClient, StatusCode, Url}; +use serde::de::DeserializeOwned; + +use crate::{BundleMetadata, PublicKeyInfo}; + +use super::{ + error::{ApiError, Result}, + model::ApiResponse, + API_VERSION, +}; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone)] +pub struct ApiClient { + client: HttpClient, + base_url: Url, +} + +impl ApiClient { + pub fn new(base_url: impl AsRef) -> Result { + tracing::info!( + "Initializing ApiClient with base URL: {}", + base_url.as_ref() + ); + + let client = HttpClient::builder() + .timeout(DEFAULT_TIMEOUT) + .user_agent(format!( + "{}/{}", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION") + )) + .build() + .map_err(ApiError::RequestFailed)?; + + Ok(Self { + client, + base_url: base_url.as_ref().parse().map_err(ApiError::InvalidUrl)?, + }) + } + + pub async fn list_key(&self) -> Result { + self.get(&format!("/api/{API_VERSION}/key")).await + } + + // NOTE: Right now this is fetching whatever is listed, + // but if there are more than one bundle per SH instance, + // this is where the changes should be made. + pub async fn fetch_bundle_metadata(&self, name: &str) -> Result { + tracing::debug!(bundle_name = name, "Fetching metadata"); + self.get(&format!("/api/{API_VERSION}/manifest")).await + } + + pub async fn download_bundle(&self, name: &str) -> Result> { + tracing::debug!(bundle_name = name, "Downloading bundle"); + let url = self.build_url(&format!("/api/{API_VERSION}/bundle"))?; + + let download_client = HttpClient::builder() + .timeout(10 * DEFAULT_TIMEOUT) + .user_agent(format!( + "{}/{}", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION") + )) + .build() + .map_err(ApiError::RequestFailed)?; + + let response = download_client.get(url).send().await.map_err(|e| { + tracing::error!(bundle_name = name, error = %e, "Download request failed"); + ApiError::RequestFailed(e) + })?; + + match response.status() { + StatusCode::OK => { + tracing::debug!(bundle_name = name, "Download successful"); + Ok(response.bytes().await?.to_vec()) + } + StatusCode::NOT_FOUND => { + tracing::warn!(bundle_name = name, "Bundle not found"); + Err(ApiError::BundleNotFound(name.to_string())) + } + status => { + let error_text = response.text().await.unwrap_or_default(); + tracing::error!(bundle_name = name, status = %status, error = %error_text, "Download failed"); + Err(ApiError::from_status(status.as_u16(), error_text)) + } + } + } + + async fn get(&self, path: &str) -> Result { + tracing::debug!(path, "Sending GET request"); + let url = self.build_url(path)?; + + let response = self.client.get(url).send().await.map_err(|e| { + tracing::error!(path, error = %e, "Request failed"); + ApiError::RequestFailed(e) + })?; + + match response.status() { + StatusCode::OK => { + let api_response: ApiResponse = response.json().await?; + if api_response.success { + Ok(api_response.data) + } else { + Err(ApiError::ServerError { + status: 200, + message: api_response.error.unwrap_or_else(|| "Unknown error".into()), + }) + } + } + StatusCode::NOT_FOUND => Err(ApiError::BundleNotFound(path.to_string())), + status => { + let error_text = response.text().await.unwrap_or_default(); + tracing::error!(path, status = %status, error = %error_text, "Request failed"); + Err(ApiError::from_status(status.as_u16(), error_text)) + } + } + } + + fn build_url(&self, path: &str) -> Result { + let path_to_join = path.trim_start_matches('/'); + + let mut base = self.base_url.clone(); + if !base.path().ends_with('/') { + base.set_path(&format!("{}/", base.path())); + } + + base.join(path_to_join).map_err(|e| { + tracing::error!(path, error = %e, "Invalid URL"); + ApiError::InvalidUrl(e) + }) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/api/error.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/api/error.rs new file mode 100644 index 0000000..cc53889 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/api/error.rs @@ -0,0 +1,50 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ApiError { + #[error("HTTP request failed: {0}")] + RequestFailed(#[from] reqwest::Error), + + #[error("Invalid server response: {0}")] + InvalidResponse(String), + + #[error("Bundle not found: {0}")] + BundleNotFound(String), + + #[error("File not found: {0}")] + FileNotFound(String), + + #[error("Bundle verification failed: {0}")] + VerificationFailed(String), + + #[error("API error {status}: {message}")] + ServerError { status: u16, message: String }, + + #[error("Failed to parse response: {0}")] + ParseError(#[from] serde_json::Error), + + #[error("Server connection failed: {0}")] + ConnectionFailed(String), + + #[error("Invalid URL: {0}")] + InvalidUrl(#[from] url::ParseError), +} + +impl ApiError { + pub fn is_not_found(&self) -> bool { + matches!(self, Self::BundleNotFound(_)) + } + + pub fn is_verification_error(&self) -> bool { + matches!(self, Self::VerificationFailed(_)) + } + + pub fn from_status(status: u16, message: impl Into) -> Self { + Self::ServerError { + status, + message: message.into(), + } + } +} + +pub type Result = std::result::Result; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/api/mod.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/api/mod.rs new file mode 100644 index 0000000..ae83b93 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/api/mod.rs @@ -0,0 +1,8 @@ +mod client; +mod error; +mod model; + +pub use client::ApiClient; +pub use error::ApiError; + +pub const API_VERSION: &str = "v1"; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/api/model.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/api/model.rs new file mode 100644 index 0000000..e24cbec --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/api/model.rs @@ -0,0 +1,9 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct ApiResponse { + pub success: bool, + #[serde(default)] + pub error: Option, + pub data: T, +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/bundle/error.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/bundle/error.rs new file mode 100644 index 0000000..e374e30 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/bundle/error.rs @@ -0,0 +1,27 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum BundleError { + #[error("File not found in bundle: {0}")] + FileNotFound(String), + + #[error("Invalid bundle format: {0}")] + InvalidFormat(String), + + #[error("Zip error: {0}")] + Zip(#[from] zip::result::ZipError), + + #[error("Storage error: {0}")] + Storage(#[from] crate::storage::StorageError), + + #[error("API error: {0}")] + Api(#[from] crate::api::ApiError), + + #[error("Cache error: {0}")] + Cache(#[from] crate::cache::CacheError), + + #[error("Verification error: {0}")] + Verification(#[from] crate::verification::VerificationError), +} + +pub type Result = std::result::Result; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/bundle/loader.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/bundle/loader.rs new file mode 100644 index 0000000..30b4c25 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/bundle/loader.rs @@ -0,0 +1,184 @@ +use std::sync::Arc; + +use super::{BundleError, Result, VerifiedBundle}; +use crate::{ + api::ApiClient, + cache::CacheManager, + storage::{StorageError, StorageManager}, + verification::{BundleVerifier, KeyManager}, +}; + +#[derive(Debug)] +struct BundleInfo { + name: String, + content: Vec, +} + +pub struct BundleLoader { + cache: Arc, + storage: Arc, +} + +impl BundleLoader { + pub fn new(cache: Arc, storage: Arc) -> Self { + Self { cache, storage } + } + + pub async fn load_bundle(&self, server_url: &str) -> Result<()> { + tracing::info!(%server_url, "Starting bundle loading process"); + + let api_client = self.create_api_client(server_url)?; + let verifier = self.init_bundle_verifier(&api_client).await?; + let metadata = self.fetch_metadata(&api_client, server_url).await?; + + let bundle_info = match self.get_bundle_info(server_url, &metadata).await? { + Some(info) => info, + None => { + self.download_and_verify_bundle(server_url, &api_client, &verifier, &metadata) + .await? + } + }; + + self.store_verified_bundle(server_url, bundle_info, &metadata) + .await?; + + tracing::info!(%server_url, "Bundle loading completed successfully"); + Ok(()) + } + + async fn get_bundle_info( + &self, + server_url: &str, + metadata: &crate::BundleMetadata, + ) -> Result> { + match self.storage.get_bundle_entry(server_url).await? { + Some(entry) if entry.version == metadata.version => { + tracing::info!(%server_url, "Bundle version matches, checking cached version"); + match self.load_existing_bundle(&entry.bundle_name).await { + Ok(bundle) => { + tracing::info!(%server_url, "Successfully loaded cached bundle"); + Ok(Some(bundle)) + } + Err(BundleError::Storage(StorageError::BundleNotFound(_))) => { + tracing::info!(%server_url, "Cached bundle not found, will download fresh copy"); + Ok(None) + } + Err(e) => Err(e), + } + } + Some(entry) => { + tracing::info!( + %server_url, + local_version = %entry.version, + remote_version = %metadata.version, + "Version mismatch, downloading new bundle" + ); + self.storage + .delete_bundle(&entry.bundle_name, server_url) + .await?; + Ok(None) + } + None => { + tracing::info!(%server_url, "No existing bundle found, downloading"); + Ok(None) + } + } + } + + async fn store_verified_bundle( + &self, + server_url: &str, + bundle_info: BundleInfo, + metadata: &crate::BundleMetadata, + ) -> Result<()> { + let verified = VerifiedBundle::new(bundle_info.content, metadata.clone())?; + + tracing::info!(%server_url, "Caching verified bundle"); + Ok(self + .cache + .cache_bundle(&bundle_info.name, &verified) + .await?) + } + + fn create_api_client(&self, server_url: &str) -> Result { + Ok(ApiClient::new(server_url).map_err(|e| { + tracing::error!(%server_url, %e, "Failed to create API client"); + e + })?) + } + + async fn init_bundle_verifier(&self, api_client: &ApiClient) -> Result { + let key_manager = KeyManager::new(api_client.clone()).await.map_err(|e| { + tracing::error!(%e, "Failed to initialize key manager"); + e + })?; + + Ok(BundleVerifier::new(key_manager)) + } + + async fn fetch_metadata( + &self, + api_client: &ApiClient, + server_url: &str, + ) -> Result { + Ok(api_client + .fetch_bundle_metadata("latest") + .await + .map_err(|e| { + tracing::error!(%server_url, %e, "Failed to fetch bundle metadata"); + e + })?) + } + + async fn load_existing_bundle(&self, bundle_name: &str) -> Result { + tracing::info!(%bundle_name, "Loading bundle from storage"); + let content = self.storage.load_bundle(bundle_name).await?; + Ok(BundleInfo { + name: bundle_name.to_string(), + content, + }) + } + + async fn download_and_verify_bundle( + &self, + server_url: &str, + api_client: &ApiClient, + verifier: &BundleVerifier, + metadata: &crate::BundleMetadata, + ) -> Result { + tracing::info!(%server_url, "Downloading bundle"); + let content = api_client.download_bundle("latest").await.map_err(|e| { + tracing::error!(%server_url, %e, "Failed to download bundle"); + e + })?; + + tracing::info!(%server_url, "Verifying bundle integrity"); + verifier.verify_bundle(&content, metadata).await?; + + let verified = VerifiedBundle::new(content.clone(), metadata.clone())?; + let name = self.generate_bundle_name(server_url); + + tracing::info!(%server_url, "Storing verified bundle"); + self.storage + .store_bundle(&name, server_url, &metadata.version, &verified) + .await?; + + Ok(BundleInfo { name, content }) + } + + fn generate_bundle_name(&self, server_url: &str) -> String { + server_url + // NOTE: This is to fix capitalized URL not being served properly on Windows + .to_lowercase() + .split("://") + .nth(1) + .unwrap_or("unknown") + // Extract only the hostname part by splitting at the first '/' and taking the first part + .split('/') + .next() + .unwrap_or("unknown") + .replace(['/', '.', ':', '-'], "_") + .trim_end_matches('_') + .to_string() + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/bundle/mod.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/bundle/mod.rs new file mode 100644 index 0000000..4e9ac15 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/bundle/mod.rs @@ -0,0 +1,7 @@ +mod error; +mod loader; +mod verified; + +pub use error::{BundleError, Result}; +pub use loader::BundleLoader; +pub use verified::VerifiedBundle; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/bundle/verified.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/bundle/verified.rs new file mode 100644 index 0000000..1452c78 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/bundle/verified.rs @@ -0,0 +1,175 @@ +use std::collections::HashMap; +use std::io::Cursor; + +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use zip::ZipArchive; + +use super::Result; +use crate::bundle::BundleError; +use crate::verification::FileVerifier; +use crate::{BundleMetadata, Manifest}; + +#[derive(Debug)] +pub struct VerifiedFile { + pub path: String, + pub content: Vec, +} + +#[derive(Debug)] +pub struct VerifiedBundle { + pub content: Vec, + pub files: HashMap, + pub total_size: usize, +} + +impl VerifiedBundle { + pub fn new(content: Vec, metadata: BundleMetadata) -> super::Result { + tracing::info!( + "Creating new verified bundle with {} files", + metadata.manifest.files.len() + ); + + let mut archive = ZipArchive::new(Cursor::new(&content)).map_err(|e| { + tracing::error!(error = %e, "Failed to read ZIP archive"); + super::BundleError::InvalidFormat(format!("Failed to read ZIP archive: {}", e)) + })?; + + tracing::debug!("Extracting all files from archive"); + let file_contents: HashMap> = metadata + .manifest + .files + .iter() + .map(|file_entry| { + let path = file_entry.path.clone(); + tracing::debug!(path = %path, "Extracting file from archive"); + let mut content = Vec::new(); + match archive.by_name(&path) { + Ok(mut file) => { + if let Err(e) = std::io::Read::read_to_end(&mut file, &mut content) { + tracing::error!(path = %path, error = %e, "Failed to read file"); + Err(super::BundleError::InvalidFormat(format!( + "Failed to read {}: {}", + path, e + ))) + } else { + Ok((path, content)) + } + } + Err(e) => { + tracing::error!(path = %path, error = %e, "File not found in archive"); + Err(super::BundleError::InvalidFormat(format!( + "File not found in archive: {}", + path + ))) + } + } + }) + .collect::>()?; + + tracing::debug!("Verifying files in parallel"); + let verified_files: Result> = metadata + .manifest + .files + .par_iter() + .map(|file_entry| { + let content = file_contents.get(&file_entry.path).ok_or_else(|| { + super::BundleError::InvalidFormat(format!("Missing content for {}", file_entry.path)) + })?; + + tracing::debug!(path = %file_entry.path, "Verifying file"); + FileVerifier::verify(content, &file_entry.hash).map_err(|e| { + tracing::error!(path = %file_entry.path, error = %e, "File verification failed"); + super::BundleError::Verification(e) + })?; + + Ok(( + file_entry.path.clone(), + VerifiedFile { + path: file_entry.path.clone(), + content: content.clone(), + }, + )) + }) + .collect(); + + let files: HashMap<_, _> = verified_files?.into_iter().collect(); + let total_size = files.values().map(|f| f.content.len()).sum(); + + tracing::info!( + "Successfully verified bundle with {} files, total size {}", + files.len(), + total_size + ); + + Ok(Self { + content, + files, + total_size, + }) + } + + pub fn trust(content: Vec, manifest: Manifest) -> Result { + tracing::info!( + "Creating trusted bundle with {} files", + manifest.files.len() + ); + + let mut archive = ZipArchive::new(Cursor::new(&content)).map_err(|e| { + tracing::error!(error = %e, "Failed to read ZIP archive"); + BundleError::InvalidFormat(format!("Failed to read ZIP archive: {}", e)) + })?; + + let mut files = HashMap::new(); + let mut total_size = 0; + + for file_entry in manifest.files.iter() { + let path = &file_entry.path; + tracing::debug!(path = %path, "Extracting file from archive"); + + match archive.by_name(path) { + Ok(mut file) => { + let mut content = Vec::new(); + if let Err(e) = std::io::Read::read_to_end(&mut file, &mut content) { + tracing::error!(path = %path, error = %e, "Failed to read file"); + return Err(BundleError::InvalidFormat(format!( + "Failed to read {}: {}", + path, e + ))); + } + + total_size += content.len(); + files.insert( + path.to_string(), + VerifiedFile { + path: path.to_string(), + content, + }, + ); + } + Err(e) => { + tracing::error!(path = %path, error = %e, "File not found in archive"); + return Err(BundleError::InvalidFormat(format!( + "File not found in archive: {}", + path + ))); + } + } + } + + tracing::info!( + "Successfully created trusted bundle with {} files, total size {}", + files.len(), + total_size + ); + + Ok(Self { + content, + files, + total_size, + }) + } + + pub fn iter_files(&self) -> impl Iterator { + self.files.values() + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/cache/error.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/cache/error.rs new file mode 100644 index 0000000..8be22ae --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/cache/error.rs @@ -0,0 +1,27 @@ +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum CacheError { + #[error("Cache entry not found: {0}")] + NotFound(String), + + #[error("Cache entry expired: {0}")] + Expired(String), + + #[error("Cache full")] + CacheFull, + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Verification error: {0}")] + Verification(#[from] crate::verification::VerificationError), + + #[error("Zip error: {0}")] + Zip(#[from] zip::result::ZipError), + + #[error("Invalid cache entry: {0}")] + InvalidEntry(String), +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/cache/manager.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/cache/manager.rs new file mode 100644 index 0000000..37e773d --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/cache/manager.rs @@ -0,0 +1,80 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use rayon::prelude::*; +use tauri::Config; + +use super::{CacheError, CachePolicy, FileStore, Result}; +use crate::bundle::VerifiedBundle; + +const BATCH_SIZE: usize = 10; + +pub struct CacheManager { + store: Arc, + policy: CachePolicy, +} + +impl CacheManager { + pub fn new(cache_dir: PathBuf, policy: CachePolicy, config: Config) -> Self { + tracing::info!( + ?cache_dir, + max_size = policy.max_size, + "Initializing CacheManager" + ); + + let store = Arc::new(FileStore::new(cache_dir, policy.max_size, config)); + Self { store, policy } + } + + pub async fn get_file(&self, bundle_name: &str, file_path: &str) -> Result> { + let cache_key = format!("{}:{}", bundle_name, file_path); + tracing::info!(%bundle_name, %file_path, %cache_key, "Retrieving file"); + + tracing::debug!( + %bundle_name, + %file_path, + %cache_key, + thread_id = ?std::thread::current().id(), + "Cache lookup attempt" + ); + + self.store.get(&cache_key).await + } + + pub async fn cache_bundle(&self, name: &str, verified: &VerifiedBundle) -> Result<()> { + tracing::info!(%name, "Starting bundle caching process"); + + if verified.total_size > self.policy.max_size { + tracing::error!(%name, total_size = verified.total_size, max_size = self.policy.max_size, "Cache size limit exceeded"); + return Err(CacheError::CacheFull); + } + + tracing::info!(%name, "Clearing memory cache before caching bundle"); + self.clear_memory_cache().await; + + let store = self.store.clone(); + + verified + .iter_files() + .collect::>() + .par_chunks(BATCH_SIZE) + .try_for_each(|batch| { + batch.iter().try_for_each(|file| { + let cache_key = format!("{}:{}", name, file.path); + futures::executor::block_on(store.store(&cache_key, file.content.clone())) + .map_err(|e| { + tracing::error!(%name, path = %file.path, %e, "Cache storage failed"); + e + }) + }) + })?; + + tracing::info!(%name, "Bundle caching completed successfully"); + Ok(()) + } + + pub async fn clear_memory_cache(&self) { + tracing::info!("Forwarding clear cache request to FileStore"); + self.store.clear().await; + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/cache/mod.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/cache/mod.rs new file mode 100644 index 0000000..1fbbf52 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/cache/mod.rs @@ -0,0 +1,12 @@ +mod error; +mod manager; +mod policy; +mod store; + +pub use error::{CacheError, Result}; +pub use manager::CacheManager; +pub use policy::CachePolicy; +pub use store::FileStore; + +pub const DEFAULT_CACHE_SIZE: usize = 1000 * 1024 * 1024; // 1000MB +pub const DEFAULT_FILE_TTL: u64 = 3600; // 1 hour diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/cache/policy.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/cache/policy.rs new file mode 100644 index 0000000..447f5e6 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/cache/policy.rs @@ -0,0 +1,36 @@ +use std::time::Duration; + +#[derive(Debug, Clone)] +pub struct CachePolicy { + pub max_size: usize, + pub file_ttl: Duration, + pub hot_ratio: f32, +} + +impl CachePolicy { + pub fn new(max_size: usize, file_ttl: Duration, hot_ratio: f32) -> Self { + Self { + max_size, + file_ttl, + hot_ratio: hot_ratio.clamp(0.0, 1.0), + } + } + + pub fn hot_cache_size(&self) -> usize { + (self.max_size as f32 * self.hot_ratio) as usize + } + + pub fn disk_cache_size(&self) -> usize { + self.max_size - self.hot_cache_size() + } +} + +impl Default for CachePolicy { + fn default() -> Self { + Self { + max_size: super::DEFAULT_CACHE_SIZE, + file_ttl: Duration::from_secs(super::DEFAULT_FILE_TTL), + hot_ratio: 0.9, + } + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/cache/store.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/cache/store.rs new file mode 100644 index 0000000..87f3939 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/cache/store.rs @@ -0,0 +1,180 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; + +use dashmap::DashMap; +use lru::LruCache; +use tauri::Config; +use tokio::sync::Mutex; + +use super::{CacheError, Result}; + +pub struct FileStore { + hot_cache: Arc>>, + disk_cache: Arc>, + cache_dir: PathBuf, + max_memory: usize, + config: Config, +} + +struct CacheEntry { + content: Vec, + last_accessed: Instant, + size: usize, +} + +impl FileStore { + pub fn new(cache_dir: PathBuf, max_memory: usize, config: Config) -> Self { + tracing::info!( + cache_dir = ?cache_dir, + max_memory, + "Initializing FileStore with cache directory and memory limit." + ); + Self { + hot_cache: Arc::new(Mutex::new(LruCache::new( + std::num::NonZeroUsize::new(1000).unwrap(), + ))), + disk_cache: Arc::new(DashMap::new()), + cache_dir, + max_memory, + config, + } + } + + pub async fn store(&self, key: &str, content: Vec) -> Result<()> { + let size = content.len(); + tracing::info!(key, size, "Storing file in cache."); + + let mut cache = self.hot_cache.lock().await; + let current_size = self.current_size(&cache); + + if current_size + size <= self.max_memory { + tracing::debug!(key, size, "File fits in memory; adding to hot cache."); + cache.put( + key.to_string(), + CacheEntry { + content, + last_accessed: Instant::now(), + size, + }, + ); + return Ok(()); + } + + if size > self.max_memory { + tracing::warn!( + key, + size, + "File size exceeds maximum memory. Writing directly to disk." + ); + let path = self.cache_dir.join(key); + tokio::fs::write(&path, &content).await.map_err(|e| { + tracing::error!(key, error = %e, "Failed to write file to disk."); + e + })?; + self.disk_cache.insert(key.to_string(), path); + return Ok(()); + } + + tracing::debug!( + key, + size, + current_size, + "Hot cache full; evicting files to make room." + ); + while cache.len() > 0 && self.current_size(&cache) + size > self.max_memory { + if let Some((evicted_key, entry)) = cache.pop_lru() { + let path = self.cache_dir.join(&evicted_key); + tracing::debug!(evicted_key, "Evicting file to disk."); + std::fs::write(&path, &entry.content).map_err(|e| { + tracing::error!( + evicted_key, + error = %e, + "Failed to write evicted file to disk." + ); + e + })?; + self.disk_cache.insert(evicted_key, path); + } + } + + tracing::debug!(key, "Adding file to hot cache after eviction."); + cache.put( + key.to_string(), + CacheEntry { + content, + last_accessed: Instant::now(), + size, + }, + ); + Ok(()) + } + + pub async fn get(&self, key: &str) -> Result> { + tracing::info!(key, "Retrieving file from cache."); + tracing::debug!( + key, + thread_id = ?std::thread::current().id(), + hot_cache_len = self.hot_cache.lock().await.len(), + disk_cache_len = self.disk_cache.len(), + "Cache access attempt details" + ); + + let mut guard = self.hot_cache.lock().await; + if let Some(entry) = guard.get(key) { + tracing::debug!(key, "File found in hot cache."); + return Ok(entry.content.clone()); + } + drop(guard); + + tracing::debug!(key, "File not found in hot cache. Checking disk cache."); + if let Some(path) = self.disk_cache.get(key) { + tracing::debug!(key, path = ?path, "File found in disk cache. Reading from disk."); + let content = tokio::fs::read(path.value()).await.map_err(|e| { + tracing::error!(key, error = %e, "Failed to read file from disk."); + e + })?; + self.store(key, content.clone()).await?; + return Ok(content); + } + + tracing::warn!(key, "File not found in cache."); + Err(CacheError::NotFound(key.to_string())) + } + + pub async fn clear_except_prefix(&self, prefix: &str) { + let mut cache = self.hot_cache.lock().await; + + let keys_to_keep: Vec<_> = cache + .iter() + .filter(|(key, _)| key.starts_with(prefix)) + .map(|(key, _)| key.clone()) + .collect(); + + let mut new_cache = LruCache::new(std::num::NonZeroUsize::new(1000).unwrap()); + for key in keys_to_keep { + if let Some(entry) = cache.pop(&key) { + new_cache.put(key, entry); + } + } + + *cache = new_cache; + } + + pub async fn clear(&self) { + tracing::info!("Clearing in-memory cache"); + let config = self.config.clone(); + let name = config + .product_name + .unwrap_or("unknown".to_string()) + .to_lowercase(); + self.clear_except_prefix(&name).await; + tracing::info!("In-memory cache cleared successfully"); + } + + fn current_size(&self, cache: &LruCache) -> usize { + let size = cache.iter().map(|(_, entry)| entry.size).sum(); + tracing::debug!(current_size = size, "Calculating current hot cache size."); + size + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/commands.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/commands.rs new file mode 100644 index 0000000..ed75ba2 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/commands.rs @@ -0,0 +1,429 @@ +use std::sync::Arc; + +#[cfg(target_os = "macos")] +use tauri::TitleBarStyle; +use tauri::{ + command, AppHandle, LogicalPosition, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, +}; + +use crate::{ + bundle::BundleLoader, + cache::CacheManager, + mapping::HostMapper, + models::{ + CloseOptions, CloseResponse, DownloadOptions, DownloadResponse, LoadOptions, LoadResponse, + }, + storage::{StorageError, StorageManager}, + ui, RemoveOptions, RemoveResponse, Result, +}; + +/// Writes a line to appload.diag.log for debugging window lifecycle events. +/// This runs at the Rust level so it captures events even when JS logging +/// fails (e.g. webview destroyed before JS can write). Best-effort: silently +/// ignores any IO errors. +/// +/// The log directory comes from `Config::log_dir`, set by the host app during +/// plugin initialization. If no log_dir was configured, this is a no-op. +fn diag_log(msg: &str) { + let Some(dir) = crate::DIAG_LOG_DIR.get() else { + return; + }; + let _ = std::fs::create_dir_all(dir); + let path = dir.join("appload.diag.log"); + let _ = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .and_then(|mut f| { + use std::io::Write; + writeln!(f, "[{}] [RUST] {}", chrono::Utc::now().to_rfc3339(), msg) + }); +} + +/// Maximum length for window labels/hosts +const MAX_HOST_LENGTH: usize = 255; + +fn sanitize_window_label(input: &str) -> Result { + if input.is_empty() { + return Err(crate::Error::Config("Host cannot be empty".into())); + } + if input.len() > MAX_HOST_LENGTH { + return Err(crate::Error::Config(format!( + "Host exceeds maximum length of {} characters", + MAX_HOST_LENGTH + ))); + } + + Ok(input + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '_' }) + .collect()) +} + +#[command] +pub async fn download( + app: AppHandle, + options: DownloadOptions, +) -> Result { + tracing::info!(?options, "Starting download process"); + let bundle_loader = app.state::>(); + tracing::debug!("Retrieved BundleLoader state"); + + tracing::info!(url = %options.server_url, "Attempting to load bundle"); + bundle_loader + .load_bundle(options.server_url.as_str()) + .await + .map_err(|e| { + tracing::error!(?e, url = %options.server_url, "Failed to load bundle"); + e + })?; + + let storage = app.state::>(); + tracing::debug!("Retrieved StorageManager state"); + + tracing::info!(url = %options.server_url, "Fetching bundle entry from storage"); + let entry = storage + .get_bundle_entry(options.server_url.as_str()) + .await + .map_err(|e| { + tracing::error!(?e, url = %options.server_url, "Failed to fetch bundle entry"); + e + })? + .ok_or_else(|| { + tracing::error!(url = %options.server_url, "Bundle not found after download"); + crate::Error::Config("Bundle not found after download".into()) + })?; + + let response = DownloadResponse { + success: true, + bundle_name: entry.bundle_name, + server_url: options.server_url, + version: entry.version, + }; + + tracing::info!(?response, "Download completed successfully"); + Ok(response) +} + +#[command] +pub async fn load(app: AppHandle, options: LoadOptions) -> Result { + let base_label = sanitize_window_label(&options.window.title)?; + let current_label = format!("{}-curr", base_label); + let alternate_label = format!("{}-next", base_label); + + let has_curr = app.get_webview_window(¤t_label).is_some(); + let has_next = app.get_webview_window(&alternate_label).is_some(); + let label = if has_curr { + alternate_label.clone() + } else { + current_label.clone() + }; + + // All webviews use the bundle name as the URL host so they share the same + // origin (app://{bundle_name}/). This is critical because Tauri v2's IPC + // validates the webview origin at runtime and rejects origins it doesn't + // recognize. Using different hosts per org (e.g. app://test_org_hoppscotch_io) + // would break all IPC communication in the org webview. + // + // For cloud-for-orgs, the org host is passed as a query parameter instead. + // The JS side reads window.location.search to get the org context, and the + // kernel store uses the query param to maintain per-org file isolation. + let sanitized_bundle = sanitize_window_label(&options.bundle_name)?; + + let url = match &options.host { + Some(host) => { + // pass the original host value as-is in the query param so the JS + // side can extract the org domain without reversing sanitization. + // URL query values don't need the same restrictions as hostnames + format!( + "app://{}/?org={}", + sanitized_bundle.to_lowercase(), + host.to_lowercase() + ) + } + None => format!("app://{}/", sanitized_bundle.to_lowercase()), + }; + + // list all existing webview windows so the diag log shows the full picture + let existing_windows: Vec = app + .webview_windows() + .keys() + .cloned() + .collect(); + + diag_log(&format!( + "LOAD called: bundle={}, host={:?}, title={}, url={}, label={}, has_curr={}, has_next={}, existing_windows={:?}", + options.bundle_name, + options.host, + options.window.title, + url, + label, + has_curr, + has_next, + existing_windows + )); + + tracing::info!( + ?options, + bundle = %options.bundle_name, + %url, + window_label = %label, + "Loading bundle" + ); + tracing::debug!(%url, "Generated app URL"); + + let host_mapper = app.state::>(); + host_mapper.register( + &sanitized_bundle.to_lowercase(), + &options.bundle_name.to_lowercase(), + ); + tracing::debug!( + host = %sanitized_bundle.to_lowercase(), + bundle = %options.bundle_name.to_lowercase(), + "Registered host mapping" + ); + + let sanitized_title = sanitize_window_label(&options.window.title)?; + + // Build the webview with the kernel init script. Org context is carried + // via the ?org= query param in the URL (set above) and preserved across + // Vue Router navigations by a beforeEach guard in modules/router.ts. + let builder = + WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.parse().unwrap())) + .initialization_script(crate::KERNEL_JS) + .title(sanitized_title) + .inner_size(options.window.width, options.window.height) + .resizable(options.window.resizable) + .disable_drag_drop_handler(); + + let window = match builder.build() + { + Ok(window) => window, + Err(e) => { + tracing::error!( + ?e, + ?label, + "Failed to create window, cleaning up host mapping" + ); + host_mapper.unregister(&sanitized_bundle.to_lowercase()); + return Err(e.into()); + } + }; + + // Apply the caller-supplied zoom before the first paint. The WebView is + // attached but no content has rendered yet, so a 150% zoom lands without + // the user seeing a 100% flash. A post-mount setZoom from the bundle's + // own JS would otherwise race the first paint and produce a visible + // jump. Failures here are logged and ignored, since the bundle's own + // watcher (in `hoppscotch-common`'s `useDesktopZoomEffect`) re-applies + // the value after mount and degrades the failure to today's behavior. + if let Some(zoom) = options.window.zoom_level { + if let Err(e) = window.set_zoom(zoom) { + tracing::warn!(?e, zoom, ?label, "set_zoom on new webview failed"); + } + } + + #[cfg(target_os = "macos")] + { + let window_clone = window.clone(); + window.run_on_main_thread(move || { + ui::macos::posit::setup_window(window_clone); + })?; + } + + #[cfg(target_os = "windows")] + { + let window_clone = window.clone(); + window.run_on_main_thread(move || { + ui::windows::posit::setup_window(window_clone); + })?; + } + + let is_visible = window.is_visible().unwrap_or(false); + let response = LoadResponse { + success: is_visible, + window_label: label.clone(), + }; + + diag_log(&format!( + "LOAD complete: label={}, visible={}, success={}", + label, is_visible, response.success + )); + + tracing::info!(?response, "Bundle loaded successfully"); + Ok(response) +} + +#[command] +pub async fn close(app: AppHandle, options: CloseOptions) -> Result { + tracing::info!(?options, "Starting window close process"); + + let existing_windows: Vec = app + .webview_windows() + .keys() + .cloned() + .collect(); + + diag_log(&format!( + "CLOSE called: window_label={}, existing_windows={:?}", + options.window_label, + existing_windows + )); + + let Some(window) = app.get_webview_window(&options.window_label) else { + diag_log(&format!( + "CLOSE: window {} not found or already closed", + options.window_label + )); + tracing::info!(window_label = %options.window_label, "Window not found or already closed"); + return Ok(CloseResponse { success: true }); + }; + + window.close().map_err(|e| { + diag_log(&format!( + "CLOSE: failed to close window {}: {:?}", + options.window_label, e + )); + tracing::error!(?e, window_label = %options.window_label, "Failed to close window"); + e + })?; + + let remaining_windows: Vec = app + .webview_windows() + .keys() + .cloned() + .collect(); + + diag_log(&format!( + "CLOSE complete: closed={}, remaining_windows={:?}", + options.window_label, + remaining_windows + )); + + let response = CloseResponse { success: true }; + + tracing::info!(?response, "Window close process completed"); + Ok(response) +} + +#[command] +pub async fn remove( + app: AppHandle, + options: RemoveOptions, +) -> Result { + tracing::info!(?options, "Starting instance removal process"); + let storage = app.state::>(); + let cache = app.state::>(); + let host_mapper = app.state::>(); + + tracing::debug!("Retrieved StorageManager, CacheManager, and HostMapper states"); + + storage + .delete_bundle(&options.bundle_name, &options.server_url) + .await + .map_err(|e| { + tracing::error!(?e, "Failed to delete bundle from storage"); + e + })?; + + cache.clear_memory_cache().await; + + // Clean up mappings that pointed to this bundle, otherwise they'd resolve to files + // that no longer exist. + host_mapper.remove_mappings_for_bundle(&options.bundle_name.to_lowercase()); + + let response = RemoveResponse { + success: true, + bundle_name: options.bundle_name, + }; + + tracing::info!(?response, "Instance removed successfully"); + Ok(response) +} + +#[command] +pub async fn clear(app: AppHandle) -> Result<()> { + tracing::info!("Starting bundle cleanup process"); + let storage = app.state::>(); + let host_mapper = app.state::>(); + + host_mapper.clear(); + tracing::debug!("Cleared host mappings"); + + let layout = storage.layout(); + + if layout.bundles_dir().exists() { + tracing::debug!("Clearing bundles directory"); + tokio::fs::remove_dir_all(layout.bundles_dir()) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to clear bundles directory"); + crate::Error::Storage(StorageError::Io(e)) + })?; + tokio::fs::create_dir(layout.bundles_dir()) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to recreate bundles directory"); + crate::Error::Storage(StorageError::Io(e)) + })?; + } + + if layout.cache_dir().exists() { + tracing::debug!("Clearing cache directory"); + tokio::fs::remove_dir_all(layout.cache_dir()) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to clear cache directory"); + crate::Error::Storage(StorageError::Io(e)) + })?; + tokio::fs::create_dir(layout.cache_dir()) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to recreate cache directory"); + crate::Error::Storage(StorageError::Io(e)) + })?; + } + + if layout.key_dir().exists() { + tracing::debug!("Clearing key directory"); + tokio::fs::remove_dir_all(layout.key_dir()) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to clear key directory"); + crate::Error::Storage(StorageError::Io(e)) + })?; + tokio::fs::create_dir(layout.key_dir()).await.map_err(|e| { + tracing::error!(error = %e, "Failed to recreate key directory"); + crate::Error::Storage(StorageError::Io(e)) + })?; + } + + if layout.temp_dir().exists() { + tracing::debug!("Clearing temp directory"); + tokio::fs::remove_dir_all(layout.temp_dir()) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to clear temp directory"); + crate::Error::Storage(StorageError::Io(e)) + })?; + tokio::fs::create_dir(layout.temp_dir()) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to recreate temp directory"); + crate::Error::Storage(StorageError::Io(e)) + })?; + } + + if layout.registry_path().exists() { + tracing::debug!("Removing registry.json"); + tokio::fs::remove_file(layout.registry_path()) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to remove registry.json"); + crate::Error::Storage(StorageError::Io(e)) + })?; + } + + tracing::info!("Bundle cleanup completed successfully"); + Ok(()) +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/config/mod.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/config/mod.rs new file mode 100644 index 0000000..44859c3 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/config/mod.rs @@ -0,0 +1,5 @@ +mod model; + +pub use model::{ApiConfig, CacheConfig, Config, StorageConfig}; + +pub const DEFAULT_CONFIG_PATH: &str = "config.json"; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/config/model.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/config/model.rs new file mode 100644 index 0000000..eca8dac --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/config/model.rs @@ -0,0 +1,75 @@ +use bon::Builder; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::time::Duration; + +use crate::cache::DEFAULT_CACHE_SIZE; +use crate::vendor::VendorConfig; + +#[derive(Debug, Clone, Builder, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub api: ApiConfig, + #[serde(default)] + pub cache: CacheConfig, + #[serde(default)] + pub storage: StorageConfig, + #[serde(skip)] + pub vendor: VendorConfig, + // optional log directory for diagnostic logging from the plugin layer. + // when set, the plugin writes best-effort diag lines (window lifecycle + // events, etc.) to `appload.diag.log` inside this directory. the host + // app is responsible for passing its own log directory here so the + // plugin doesn't need to know about app-specific path conventions + #[serde(skip)] + pub log_dir: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiConfig { + pub server_url: String, + #[serde(with = "humantime_serde")] + pub timeout: Duration, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheConfig { + pub max_size: usize, + #[serde(with = "humantime_serde")] + pub file_ttl: Duration, + pub hot_ratio: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageConfig { + pub root_dir: PathBuf, + pub max_bundle_size: usize, +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + server_url: "http://localhost:3200".to_string(), + timeout: Duration::from_secs(30), + } + } +} + +impl Default for CacheConfig { + fn default() -> Self { + Self { + max_size: DEFAULT_CACHE_SIZE, + file_ttl: Duration::from_secs(3600), + hot_ratio: 0.9, + } + } +} + +impl Default for StorageConfig { + fn default() -> Self { + Self { + root_dir: PathBuf::from("data"), + max_bundle_size: 50 * 1024 * 1024, // 50MB + } + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/desktop.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/desktop.rs new file mode 100644 index 0000000..a9da830 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/desktop.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use serde::de::DeserializeOwned; +use tauri::{plugin::PluginApi, AppHandle, Runtime}; + +use crate::{ + bundle::BundleLoader, + models::{DownloadOptions, DownloadResponse, LoadOptions, LoadResponse}, + Result, +}; + +pub fn init( + app: &AppHandle, + api: PluginApi, + bundle_loader: Arc, +) -> Result> { + Ok(Appload { + app: app.clone(), + bundle_loader, + }) +} + +pub struct Appload { + app: AppHandle, + bundle_loader: Arc, +} + +impl Appload { + pub async fn download(&self, options: DownloadOptions) -> Result { + super::commands::download(self.app.clone(), options).await + } + + pub async fn load(&self, options: LoadOptions) -> Result { + super::commands::load(self.app.clone(), options).await + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/envvar.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/envvar.rs new file mode 100644 index 0000000..2f8437d --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/envvar.rs @@ -0,0 +1,8 @@ +use std::{collections::HashMap, env}; + +/// Collect environment variables that should be exposed to the web app +pub(super) fn collect_env_vars() -> HashMap { + env::vars() + .filter(|(k, _)| k.starts_with("VITE_")) + .collect() +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/error.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/error.rs new file mode 100644 index 0000000..b2227be --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/error.rs @@ -0,0 +1,40 @@ +use serde::Serialize; +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + Api(#[from] crate::api::ApiError), + + #[error(transparent)] + Cache(#[from] crate::cache::CacheError), + + #[error(transparent)] + Storage(#[from] crate::storage::StorageError), + + #[error(transparent)] + Bundle(#[from] crate::bundle::BundleError), + + #[error(transparent)] + Verification(#[from] crate::verification::VerificationError), + + #[error(transparent)] + Tauri(#[from] tauri::Error), + + #[error("Window not found")] + WindowNotFound, + + #[error("Configuration error: {0}")] + Config(String), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/global.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/global.rs new file mode 100644 index 0000000..b4910dd --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/global.rs @@ -0,0 +1,4 @@ +use std::time::Duration; + +pub(crate) const BUNDLE_CLEANUP_INTERVAL: Duration = Duration::from_secs(3600); // 1 hr +pub(crate) const BUNDLE_MAX_AGE: Duration = Duration::from_secs(86400); // 24 hrs diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/kernel.js b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/kernel.js new file mode 100644 index 0000000..747f223 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/kernel.js @@ -0,0 +1,40 @@ +;(() => { + console.log("Setting desktop kernel mode") + window.__KERNEL_MODE__ = "desktop" + + // write bundle identity to the log file on disk so we can trace which + // webview is which across webkit relaunches (console logs get wiped). + // runs before any JS modules load, so we use the raw Tauri IPC channel + // instead of @tauri-apps/api. + // + // log webview identity to disk so we can trace which webview is which + // across webkit relaunches (console logs get wiped) + Promise.resolve().then(function () { + var params = new URLSearchParams(window.location.search) + var orgParam = params.get("org") + var tag = orgParam ? "org(" + orgParam + ")" : "vendored" + + var line = [ + "", + "========================================================================", + "WEBVIEW INIT " + new Date().toISOString(), + " tag : " + tag, + " ?org= : " + (orgParam || "(not set)"), + " href : " + window.location.href, + " hostname : " + window.location.hostname, + " origin : " + window.location.origin, + "========================================================================", + "", + ].join("\n") + + // __TAURI_INTERNALS__ is always present before initialization_scripts run + if (window.__TAURI_INTERNALS__) { + window.__TAURI_INTERNALS__.invoke("append_log", { + filename: "appload.diag.log", + content: line, + }).catch(function (err) { + console.warn("[kernel.js] Failed to write init log:", err) + }) + } + }) +})() diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/lib.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/lib.rs new file mode 100644 index 0000000..20a333e --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/lib.rs @@ -0,0 +1,178 @@ +// Copyright 2025 CuriousCorrelation +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! app bundle loading plugin for Tauri. +//! This plugin handles downloading and loading external web app bundles in Tauri webviews. + +// TODO: Fix these. There are icons in the asset directory for the web app. +#![doc( + html_logo_url = "https://github.com///raw/main/packages/app/public/favicon.ico", + html_favicon_url = "https://github.com///raw/main/packages/app/public/favicon.ico" +)] + +use std::path::PathBuf; +use std::sync::{Arc, OnceLock}; +use tauri::{ + plugin::{Builder, Plugin, TauriPlugin}, + Manager, Runtime, +}; + +// log directory for the plugin-level diagnostic logger (commands::diag_log). +// set once during plugin init from Config::log_dir. a OnceLock because +// diag_log is a free function called from command handlers that don't +// carry the config around +static DIAG_LOG_DIR: OnceLock = OnceLock::new(); + +pub use config::Config; +pub use config::{ApiConfig, CacheConfig, StorageConfig}; +pub use models::*; + +#[cfg(desktop)] +mod desktop; +#[cfg(mobile)] +mod mobile; + +mod api; +mod bundle; +mod cache; +mod commands; +mod config; +mod envvar; +mod error; +mod global; +mod mapping; +mod models; +mod storage; +mod ui; +mod uri; +mod vendor; +mod verification; + +pub use error::{Error, Result}; +pub use mapping::HostMapper; +pub use vendor::VendorConfig; + +#[cfg(mobile)] +use mobile::Appload; + +const KERNEL_JS: &str = include_str!("kernel.js"); + +pub fn init(config: Config) -> TauriPlugin { + Builder::new("appload") + .setup(move |app, api| { + tracing::info!("Initializing appload plugin"); + + if let Some(log_dir) = &config.log_dir { + let _ = DIAG_LOG_DIR.set(log_dir.clone()); + } + + tracing::debug!("Using provided configuration settings."); + let storage_root = config.storage.root_dir.clone(); + + tracing::info!( + path = ?storage_root, + "Using configured storage root directory." + ); + + let storage = tauri::async_runtime::block_on(async { + let storage = storage::StorageManager::new(storage_root) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to initialize storage manager"); + Error::Config(e.to_string()) + })?; + + storage.init().await.map_err(|e| { + tracing::error!(error = %e, "Failed to initialize storage directories"); + Error::Config(e.to_string()) + })?; + + Ok::<_, Error>(Arc::new(storage)) + })?; + + tracing::debug!("Setting up uri handler."); + let tauri_config = app.config().clone(); + + tracing::debug!("Initializing cache manager."); + let cache = Arc::new(cache::CacheManager::new( + storage.layout().cache_dir(), + cache::CachePolicy::new( + config.cache.max_size, + config.cache.file_ttl, + config.cache.hot_ratio, + ), + tauri_config.clone(), + )); + + tracing::debug!("Setting up bundle loader."); + let bundle_loader = Arc::new(bundle::BundleLoader::new(cache.clone(), storage.clone())); + + tracing::debug!("Initializing host mapper."); + let host_mapper = Arc::new(mapping::HostMapper::new()); + + tracing::debug!("Initializing uri handler."); + let uri_handler = Arc::new(uri::UriHandler::new( + cache.clone(), + tauri_config.clone(), + host_mapper.clone(), + )); + + { + let tauri_config = tauri_config.clone(); + let cache = cache.clone(); + let storage = storage.clone(); + tauri::async_runtime::block_on(async move { + if let Err(e) = config.vendor.initialize(tauri_config, cache, storage).await { + tracing::error!(error = %e, "Failed to initialize vendor."); + } + }); + } + + #[cfg(desktop)] + tracing::debug!("Initializing desktop-specific components."); + #[cfg(desktop)] + let view = desktop::init(app, api, bundle_loader.clone())?; + + #[cfg(mobile)] + tracing::debug!("Initializing mobile-specific components."); + #[cfg(mobile)] + let view = mobile::init(app, api, bundle_loader.clone())?; + + app.manage(bundle_loader); + app.manage(cache); + app.manage(storage); + app.manage(host_mapper); + app.manage(uri_handler); + app.manage(view); + + tracing::info!("appload plugin setup complete."); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + commands::download, + commands::load, + commands::close, + commands::remove, + commands::clear + ]) + .register_uri_scheme_protocol("app", move |ctx, req| { + tracing::info!("Incoming URI request"); + + let app = ctx.app_handle(); + let uri = req.uri(); + + tracing::debug!( + url = %uri, + thread_id = ?std::thread::current().id(), + "Handling app URI scheme request." + ); + + let uri_handler = app.state::>(); + + tracing::info!("Got URI handler"); + + tauri::async_runtime::block_on(uri_handler.handle(uri)).unwrap() + }) + .build() +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/mapping.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/mapping.rs new file mode 100644 index 0000000..129085d --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/mapping.rs @@ -0,0 +1,246 @@ +use dashmap::DashMap; +use std::sync::Arc; + +/// Maps virtual hosts to bundle names. +/// +/// This enables a single bundle to be served under multiple virtual hosts, +/// which is essential for cloud-for-orgs support where the same Hoppscotch +/// bundle needs to appear as different organization subdomains. +/// +/// Example: +/// - `load({ bundleName: "Hoppscotch", host: "acme.hoppscotch.io" })` +/// - Registers mapping: "acme_hoppscotch_io" -> "hoppscotch" +/// - Webview URL becomes: `app://acme_hoppscotch_io/` +/// - UriHandler resolves "acme_hoppscotch_io" -> "hoppscotch" for file lookups +/// +/// On web, org context comes from window.location.hostname (acme.hoppscotch.io), but on +/// desktop the webview URL is app://{bundle_name}/ which always returns the same hostname +/// regardless of which org the user is in. This mapper lets the same bundle files serve +/// under different virtual hostnames, so the JS can read window.location.hostname and get +/// the org context just like it does on web. +/// +/// The inner Arc is necessary because HostMapper derives Clone. When the struct +/// gets cloned (happens when passed across thread boundaries), both instances need to share +/// the same underlying map. Without the Arc, each clone gets its own DashMap and mappings +/// registered in one wouldn't be visible in the other. +#[derive(Debug, Clone, Default)] +pub struct HostMapper { + mappings: Arc>, +} + +impl HostMapper { + pub fn new() -> Self { + Self { + mappings: Arc::new(DashMap::new()), + } + } + + /// Register a host-to-bundle mapping. + /// + /// # Arguments + /// * `host` - The virtual host (e.g., "acme_hoppscotch_io") + /// * `bundle_name` - The actual bundle name (e.g., "hoppscotch") + pub fn register(&self, host: &str, bundle_name: &str) { + tracing::debug!(host = %host, bundle = %bundle_name, "Registering host to bundle mapping"); + self.mappings + .insert(host.to_string(), bundle_name.to_string()); + } + + /// Resolve a host to its bundle name. + /// + /// If no mapping exists, returns the host unchanged (passthrough). This is how backward + /// compatibility works: when loading without a host parameter, the bundle name itself + /// becomes the host, and since there's no explicit mapping for it, resolve just returns + /// it as-is. No special-casing needed. + /// + /// # Arguments + /// * `host` - The virtual host to resolve + /// + /// # Returns + /// The bundle name if a mapping exists, otherwise the host unchanged. + pub fn resolve(&self, host: &str) -> String { + let resolved = self + .mappings + .get(host) + .map(|v| v.clone()) + .unwrap_or_else(|| host.to_string()); + + tracing::debug!(host = %host, resolved = %resolved, "Resolved host mapping"); + resolved + } + + /// Remove a host mapping. + /// + /// # Arguments + /// * `host` - The virtual host to unregister + pub fn unregister(&self, host: &str) { + tracing::debug!(host = %host, "Unregistering host mapping"); + self.mappings.remove(host); + } + + pub fn has_mapping(&self, host: &str) -> bool { + self.mappings.contains_key(host) + } + + pub fn clear(&self) { + tracing::debug!("Clearing all host mappings"); + self.mappings.clear(); + } + + // Removes all mappings that point to a specific bundle. This gets called when a bundle + // is removed via the `remove` command. Without this cleanup, you'd end up with orphaned + // mappings where the virtual host still resolves to a bundle that no longer exists, + // causing confusing 404s on the next load attempt. + // + // Iterates all mappings which is O(n), but n is small in practice (number of orgs the + // user has loaded). Could add a reverse index if this ever becomes a problem. + pub fn remove_mappings_for_bundle(&self, bundle_name: &str) { + let hosts_to_remove: Vec = self + .mappings + .iter() + .filter(|entry| entry.value() == bundle_name) + .map(|entry| entry.key().clone()) + .collect(); + + for host in &hosts_to_remove { + tracing::debug!(host = %host, bundle = %bundle_name, "Removing orphaned host mapping"); + self.mappings.remove(host); + } + + if !hosts_to_remove.is_empty() { + tracing::info!( + count = hosts_to_remove.len(), + bundle = %bundle_name, + "Cleaned up host mappings for removed bundle" + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_register_and_resolve() { + let mapper = HostMapper::new(); + mapper.register("acme_hoppscotch_io", "hoppscotch"); + + assert_eq!(mapper.resolve("acme_hoppscotch_io"), "hoppscotch"); + } + + #[test] + fn test_resolve_passthrough() { + let mapper = HostMapper::new(); + + assert_eq!(mapper.resolve("hoppscotch"), "hoppscotch"); + } + + #[test] + fn test_unregister() { + let mapper = HostMapper::new(); + mapper.register("acme_hoppscotch_io", "hoppscotch"); + mapper.unregister("acme_hoppscotch_io"); + + assert_eq!(mapper.resolve("acme_hoppscotch_io"), "acme_hoppscotch_io"); + } + + #[test] + fn test_has_mapping() { + let mapper = HostMapper::new(); + assert!(!mapper.has_mapping("acme_hoppscotch_io")); + + mapper.register("acme_hoppscotch_io", "hoppscotch"); + assert!(mapper.has_mapping("acme_hoppscotch_io")); + } + + #[test] + fn test_empty_host_passthrough() { + let mapper = HostMapper::new(); + assert_eq!(mapper.resolve(""), ""); + } + + #[test] + fn test_case_sensitivity() { + let mapper = HostMapper::new(); + mapper.register("acme_hoppscotch_io", "hoppscotch"); + + assert_eq!(mapper.resolve("ACME_HOPPSCOTCH_IO"), "ACME_HOPPSCOTCH_IO"); + assert_eq!(mapper.resolve("acme_hoppscotch_io"), "hoppscotch"); + } + + #[test] + fn test_overwrite_mapping() { + let mapper = HostMapper::new(); + mapper.register("acme_hoppscotch_io", "hoppscotch"); + mapper.register("acme_hoppscotch_io", "different_bundle"); + + assert_eq!(mapper.resolve("acme_hoppscotch_io"), "different_bundle"); + } + + #[test] + fn test_clear() { + let mapper = HostMapper::new(); + mapper.register("acme_hoppscotch_io", "hoppscotch"); + mapper.register("beta_hoppscotch_io", "hoppscotch"); + mapper.clear(); + + assert!(!mapper.has_mapping("acme_hoppscotch_io")); + assert!(!mapper.has_mapping("beta_hoppscotch_io")); + assert_eq!(mapper.resolve("acme_hoppscotch_io"), "acme_hoppscotch_io"); + } + + #[test] + fn test_multiple_hosts_same_bundle() { + let mapper = HostMapper::new(); + mapper.register("acme_hoppscotch_io", "hoppscotch"); + mapper.register("beta_hoppscotch_io", "hoppscotch"); + mapper.register("gamma_hoppscotch_io", "hoppscotch"); + + assert_eq!(mapper.resolve("acme_hoppscotch_io"), "hoppscotch"); + assert_eq!(mapper.resolve("beta_hoppscotch_io"), "hoppscotch"); + assert_eq!(mapper.resolve("gamma_hoppscotch_io"), "hoppscotch"); + } + + #[test] + fn test_long_host_name() { + let mapper = HostMapper::new(); + let long_host = "a".repeat(253); // DNS max length + mapper.register(&long_host, "hoppscotch"); + + assert_eq!(mapper.resolve(&long_host), "hoppscotch"); + } + + #[test] + fn test_remove_mappings_for_bundle() { + let mapper = HostMapper::new(); + mapper.register("acme_hoppscotch_io", "hoppscotch"); + mapper.register("beta_hoppscotch_io", "hoppscotch"); + mapper.register("gamma_other_io", "other_bundle"); + + mapper.remove_mappings_for_bundle("hoppscotch"); + + // Both hoppscotch mappings should be gone, falling back to passthrough + assert!(!mapper.has_mapping("acme_hoppscotch_io")); + assert!(!mapper.has_mapping("beta_hoppscotch_io")); + assert_eq!(mapper.resolve("acme_hoppscotch_io"), "acme_hoppscotch_io"); + assert_eq!(mapper.resolve("beta_hoppscotch_io"), "beta_hoppscotch_io"); + + // other_bundle mapping should be untouched + assert!(mapper.has_mapping("gamma_other_io")); + assert_eq!(mapper.resolve("gamma_other_io"), "other_bundle"); + } + + #[test] + fn test_remove_mappings_for_nonexistent_bundle() { + let mapper = HostMapper::new(); + mapper.register("acme_hoppscotch_io", "hoppscotch"); + + // Should be a no-op, not panic or error + mapper.remove_mappings_for_bundle("nonexistent"); + + // Original mapping should still be there + assert!(mapper.has_mapping("acme_hoppscotch_io")); + assert_eq!(mapper.resolve("acme_hoppscotch_io"), "hoppscotch"); + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/mobile.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/mobile.rs new file mode 100644 index 0000000..3caec21 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/mobile.rs @@ -0,0 +1,44 @@ +use serde::de::DeserializeOwned; +use tauri::{ + plugin::{PluginApi, PluginHandle}, + AppHandle, Runtime, +}; + +use crate::{DownloadOptions, DownloadResponse, LoadOptions, LoadResponse}; + +#[cfg(target_os = "ios")] +tauri::ios_plugin_binding!(init_plugin_appload); + +#[cfg(target_os = "android")] +const PLUGIN_IDENTIFIER: &str = "app.tauri.appload"; + +/// Initialize plugin for mobile platforms +pub fn init( + _app: &AppHandle, + api: PluginApi, +) -> crate::Result> { + #[cfg(target_os = "android")] + let handle = api.register_android_plugin(PLUGIN_IDENTIFIER, "Appload")?; + #[cfg(target_os = "ios")] + let handle = api.register_ios_plugin(init_plugin_appload)?; + Ok(Appload(handle)) +} + +/// Mobile implementation of the app loading plugin +pub struct Appload(PluginHandle); + +impl Appload { + /// Download an app bundle + pub async fn download(&self, options: DownloadOptions) -> crate::Result { + self.0 + .run_mobile_plugin("download", options) + .map_err(Into::into) + } + + /// Load an app in a new window + pub async fn load(&self, options: LoadOptions) -> crate::Result { + self.0 + .run_mobile_plugin("load", options) + .map_err(Into::into) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/models.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/models.rs new file mode 100644 index 0000000..6440598 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/models.rs @@ -0,0 +1,248 @@ +use std::collections::HashMap; + +use blake3::Hash; +use chrono::{DateTime, Utc}; +use ed25519_dalek::Signature; +use serde::{Deserialize, Serialize}; +use url::Url; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BundleMetadata { + pub version: String, + pub created_at: DateTime, + #[serde(with = "signature_serde")] + pub signature: Signature, + pub manifest: Manifest, + #[serde(default)] + pub properties: HashMap, +} + +mod signature_serde { + use super::*; + use base64::{engine::general_purpose::STANDARD, Engine}; + use serde::{de::Error, Deserializer, Serializer}; + + pub fn serialize(sig: &Signature, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&STANDARD.encode(sig.to_bytes())) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let bytes = STANDARD.decode(&s).map_err(D::Error::custom)?; + let bytes: [u8; 64] = bytes + .try_into() + .map_err(|_| D::Error::custom("invalid signature length"))?; + Ok(Signature::from_bytes(&bytes)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileEntry { + pub path: String, + pub size: u64, + #[serde(with = "hash_serde")] + pub hash: Hash, + pub mime_type: Option, +} + +mod hash_serde { + use super::*; + use base64::{engine::general_purpose::STANDARD, Engine}; + use blake3::Hash; + + pub fn serialize(sig: &Hash, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&STANDARD.encode(sig.as_bytes())) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + let s = String::deserialize(deserializer)?; + let bytes = STANDARD.decode(&s).map_err(D::Error::custom)?; + let bytes: [u8; 32] = bytes + .try_into() + .map_err(|_| D::Error::custom("invalid signature length"))?; + Ok(Hash::from_bytes(bytes)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Manifest { + pub files: Vec, + #[serde(default)] + pub version: Option, +} + +impl Manifest { + pub fn total_size(&self) -> u64 { + self.files.iter().map(|f| f.size).sum() + } + + pub fn get_file(&self, path: &str) -> Option<&FileEntry> { + self.files.iter().find(|f| f.path == path) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ApiResponse { + pub success: bool, + #[serde(default)] + pub error: Option, + pub data: T, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct BundleList { + pub bundles: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct BundleSummary { + pub name: String, + pub version: String, + pub created_at: DateTime, + pub file_count: usize, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct VerificationResponse { + pub status: String, + pub created_at: DateTime, + pub version: String, + pub file_count: usize, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PublicKeyInfo { + pub key: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadOptions { + pub server_url: Url, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadResponse { + pub success: bool, + pub bundle_name: String, + pub server_url: Url, + pub version: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoadOptions { + pub bundle_name: String, + /// Optional host override for the webview URL. + /// + /// When provided, the webview will be loaded with `app://{host}/` instead of + /// `app://{bundle_name}/`. This enables cloud-for-orgs support where the same + /// bundle serves multiple organization subdomains. + /// + /// Example: `host: "acme.hoppscotch.io"` will: + /// - Sanitize to "acme_hoppscotch_io" + /// - Create webview at `app://acme_hoppscotch_io/` + /// - Register mapping so file requests resolve to the correct bundle + #[serde(default)] + pub host: Option, + #[serde(default)] + pub inline: bool, + #[serde(default)] + pub window: WindowOptions, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LoadResponse { + pub success: bool, + pub window_label: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloseOptions { + pub window_label: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CloseResponse { + pub success: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveOptions { + pub bundle_name: String, + pub server_url: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveResponse { + pub success: bool, + pub bundle_name: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowOptions { + #[serde(default = "default_window_title")] + pub title: String, + #[serde(default = "default_window_width")] + pub width: f64, + #[serde(default = "default_window_height")] + pub height: f64, + #[serde(default = "default_resizable")] + pub resizable: bool, + /// Initial WebView zoom factor applied between window creation and first + /// paint. When `None`, the WebView opens at its native default of `1.0`. + /// Callers that persist a user-chosen zoom (the Hoppscotch desktop shell + /// reads `DesktopSettings.zoomLevel`) pass it here so the bundled app + /// renders at the right scale on first paint, avoiding the 100% flash + /// that a post-mount JS-side `setZoom` would otherwise produce. + #[serde(default)] + pub zoom_level: Option, +} + +fn default_window_title() -> String { + "Appload".into() +} + +fn default_window_width() -> f64 { + 800.0 +} + +fn default_window_height() -> f64 { + 600.0 +} + +fn default_resizable() -> bool { + true +} + +impl Default for WindowOptions { + fn default() -> Self { + Self { + title: default_window_title(), + width: default_window_width(), + height: default_window_height(), + resizable: default_resizable(), + zoom_level: None, + } + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/storage/error.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/storage/error.rs new file mode 100644 index 0000000..b2f98b1 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/storage/error.rs @@ -0,0 +1,39 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum StorageError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Bundle not found: {0}")] + BundleNotFound(String), + + #[error("Invalid path: {0}")] + InvalidPath(String), + + #[error("Disk not found: fatal error, unable to resolve user config storage disk")] + DiskNotFound, + + #[error("Storage full: required {required} bytes, available {available} bytes")] + StorageFull { required: u64, available: u64 }, + + #[error("Lock error: {0}")] + LockError(String), + + #[error("Registry error: {0}")] + Registry(String), + + #[error("Version mismatch: local {local}, remote {remote}")] + VersionMismatch { local: String, remote: String }, + + #[error("Invalid URL format: {0}")] + InvalidUrl(#[from] url::ParseError), + + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("Other error: {0}")] + OtherError(String), +} + +pub type Result = std::result::Result; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/storage/layout.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/storage/layout.rs new file mode 100644 index 0000000..a956665 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/storage/layout.rs @@ -0,0 +1,35 @@ +use std::path::PathBuf; + +pub struct StorageLayout { + pub(crate) root: PathBuf, +} + +impl StorageLayout { + pub fn new(root: PathBuf) -> Self { + Self { root } + } + + pub fn bundles_dir(&self) -> PathBuf { + self.root.join("bundles") + } + + pub fn bundle_path(&self, name: &str) -> PathBuf { + self.bundles_dir().join(format!("{}.zip", name)) + } + + pub fn cache_dir(&self) -> PathBuf { + self.root.join("cache") + } + + pub fn temp_dir(&self) -> PathBuf { + self.root.join("temp") + } + + pub fn key_dir(&self) -> PathBuf { + self.root.join("key") + } + + pub fn registry_path(&self) -> PathBuf { + self.root.join("registry.json") + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/storage/manager.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/storage/manager.rs new file mode 100644 index 0000000..e7b9939 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/storage/manager.rs @@ -0,0 +1,207 @@ +use std::path::PathBuf; +use std::sync::Arc; +use sysinfo::Disks; +use tokio::sync::RwLock; +use tracing; + +use super::{Registry, Result, ServerEntry, StorageError, StorageLayout}; +use crate::bundle::VerifiedBundle; + +pub struct StorageManager { + layout: Arc, + registry: Arc>, + locks: Arc>>, +} + +impl StorageManager { + pub async fn new(root: PathBuf) -> Result { + tracing::info!(root = ?root, "Initializing StorageManager with root directory"); + let layout = StorageLayout::new(root); + + let registry = Registry::load(&layout).await?; + + Ok(Self { + layout: Arc::new(layout), + registry: Arc::new(RwLock::new(registry)), + locks: Arc::new(RwLock::new(Vec::new())), + }) + } + + pub async fn init(&self) -> Result<()> { + tracing::info!("Initializing storage directories"); + let dirs = [ + self.layout.bundles_dir(), + self.layout.cache_dir(), + self.layout.temp_dir(), + self.layout.key_dir(), + ]; + + for dir in &dirs { + tracing::debug!(directory = ?dir, "Creating directory"); + tokio::fs::create_dir_all(dir).await?; + } + + tracing::info!("Storage directories initialized successfully"); + Ok(()) + } + + pub async fn store_bundle( + &self, + name: &str, + server_url: &str, + version: &str, + verified: &VerifiedBundle, + ) -> Result<()> { + tracing::info!(bundle_name = name, "Storing bundle"); + + let bundle_path = self.layout.bundle_path(name); + let temp_path = self.layout.temp_dir().join(format!("{}.tmp", name)); + + for path in [&bundle_path, &temp_path] { + if let Some(parent) = path.parent() { + tracing::debug!(path = ?parent, "Ensuring directory exists"); + tokio::fs::create_dir_all(parent).await?; + } + } + + tracing::debug!( + bundle_name = name, + temp_path = ?temp_path, + "Ensuring sufficient space for the bundle" + ); + self.ensure_space(verified.content.len()).await?; + + tracing::debug!( + bundle_name = name, + temp_path = ?temp_path, + "Writing bundle content to temporary file" + ); + tokio::fs::write(&temp_path, &verified.content).await?; + + tracing::debug!( + bundle_name = name, + temp_path = ?temp_path, + bundle_path = ?bundle_path, + "Renaming temporary file to final bundle path" + ); + tokio::fs::rename(temp_path, bundle_path).await?; + + let mut registry = self.registry.write().await; + registry.update_server_entry( + server_url, + ServerEntry { + bundle_name: name.to_string(), + version: version.to_string(), + created_at: chrono::Utc::now(), + last_accessed: chrono::Utc::now(), + }, + )?; + + registry.save(&self.layout).await?; + + tracing::info!(bundle_name = name, "Bundle stored successfully"); + Ok(()) + } + + pub async fn get_bundle_entry(&self, server_url: &str) -> Result> { + let registry = self.registry.read().await; + Ok(registry.get_bundle_for_server(server_url).cloned()) + } + + pub async fn load_bundle(&self, name: &str) -> Result> { + tracing::info!(bundle_name = name, "Loading bundle"); + + let bundle_path = self.layout.bundle_path(name); + + if !bundle_path.exists() { + tracing::warn!(bundle_name = name, "Bundle not found"); + return Err(StorageError::BundleNotFound(name.to_string())); + } + + tracing::debug!(bundle_name = name, bundle_path = ?bundle_path, "Reading bundle content"); + let content = tokio::fs::read(&bundle_path).await?; + + tracing::info!(bundle_name = name, "Bundle loaded successfully"); + Ok(content) + } + + pub async fn delete_bundle(&self, name: &str, server_url: &str) -> Result<()> { + tracing::info!(bundle_name = name, "Deleting bundle"); + + let bundle_path = self.layout.bundle_path(name); + + if bundle_path.exists() { + tracing::debug!(bundle_name = name, bundle_path = ?bundle_path, "Removing bundle file"); + tokio::fs::remove_file(bundle_path).await?; + } + + let mut registry = self.registry.write().await; + registry.remove_server_entry(server_url)?; + registry.save(&self.layout).await?; + + tracing::info!(bundle_name = name, "Bundle deleted successfully"); + Ok(()) + } + + async fn ensure_space(&self, required: usize) -> Result<()> { + tracing::debug!(required_space = required, "Checking available disk space"); + + let disks = Disks::new_with_refreshed_list(); + + let storage_path = self.layout.root.canonicalize().map_err(|e| { + tracing::error!(error = %e, "Failed to resolve storage path"); + StorageError::Io(e) + })?; + + // Convert both paths to the same format for comparison, Windows... + let normalized_storage = dunce::canonicalize(&storage_path).unwrap_or(storage_path.clone()); + + // NOTE: There cannot be more than one user config storage disk, + // although even if there is, defaulting to the first one we found + // is as good of a guess as any. + // Find the disk with the longest matching mount point + let disk = disks + .into_iter() + .filter_map(|disk| { + let normalized_disk = dunce::canonicalize(disk.mount_point()) + .unwrap_or_else(|_| disk.mount_point().to_path_buf()); + + normalized_storage + .starts_with(&normalized_disk) + .then_some((disk, normalized_disk)) + }) + .max_by_key(|(_, normalized_disk)| normalized_disk.as_os_str().len()) + .map(|(disk, _)| disk) + .ok_or_else(|| { + tracing::error!( + storage_path = %storage_path.display(), + "Fatal error, unable to resolve user config storage disk" + ); + StorageError::DiskNotFound + })?; + + let available = disk.available_space(); + + if (required as u64) > available { + tracing::warn!( + required_space = required, + available_space = available, + "Insufficient disk space" + ); + return Err(StorageError::StorageFull { + required: required as u64, + available, + }); + } + + tracing::debug!( + available_space = available, + "Sufficient disk space available" + ); + Ok(()) + } + + pub fn layout(&self) -> &StorageLayout { + &self.layout + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/storage/mod.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/storage/mod.rs new file mode 100644 index 0000000..1787295 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/storage/mod.rs @@ -0,0 +1,9 @@ +mod error; +mod layout; +mod manager; +mod registry; + +pub use error::{Result, StorageError}; +pub use layout::StorageLayout; +pub use manager::StorageManager; +pub use registry::{Registry, ServerEntry}; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/storage/registry.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/storage/registry.rs new file mode 100644 index 0000000..70383f2 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/storage/registry.rs @@ -0,0 +1,72 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use url::Url; + +use super::error::Result; +use super::layout::StorageLayout; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Registry { + pub version: u32, + pub servers: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerEntry { + pub bundle_name: String, + pub version: String, + pub created_at: DateTime, + pub last_accessed: DateTime, +} + +impl Registry { + pub async fn load(layout: &StorageLayout) -> Result { + if !layout.registry_path().exists() { + return Ok(Self { + version: 1, + servers: HashMap::new(), + }); + } + + let content = tokio::fs::read_to_string(layout.registry_path()).await?; + Ok(serde_json::from_str(&content)?) + } + + pub async fn save(&self, layout: &StorageLayout) -> Result<()> { + let content = serde_json::to_string_pretty(self)?; + let path = layout.registry_path(); + let temp_path = path.with_extension("json.tmp"); + + tokio::fs::write(&temp_path, &content).await?; + tokio::fs::rename(&temp_path, &path).await?; + + Ok(()) + } + + pub fn get_bundle_for_server(&self, server_url: &str) -> Option<&ServerEntry> { + let normalized_url = Self::normalize_url(server_url).ok()?; + self.servers.get(&normalized_url) + } + + pub fn update_server_entry(&mut self, server_url: &str, entry: ServerEntry) -> Result<()> { + let normalized_url = Self::normalize_url(server_url)?; + self.servers.insert(normalized_url, entry); + Ok(()) + } + + pub fn remove_server_entry(&mut self, server_url: &str) -> Result> { + let normalized_url = Self::normalize_url(server_url)?; + Ok(self.servers.remove(&normalized_url)) + } + + fn normalize_url(url: &str) -> Result { + let parsed = if !url.starts_with("http://") && !url.starts_with("https://") { + Url::parse(&format!("https://{}", url)) + } else { + Url::parse(url) + }?; + + Ok(parsed.to_string().trim_end_matches('/').to_string()) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/ui/macos/mod.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/ui/macos/mod.rs new file mode 100644 index 0000000..c5cba13 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/ui/macos/mod.rs @@ -0,0 +1 @@ +pub mod posit; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/ui/macos/posit.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/ui/macos/posit.rs new file mode 100644 index 0000000..ee19d7c --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/ui/macos/posit.rs @@ -0,0 +1,429 @@ +use cocoa::{ + appkit::{ + NSAppearance, NSAppearanceNameVibrantDark, NSAppearanceNameVibrantLight, NSView, NSWindow, + NSWindowButton, NSWindowTitleVisibility, + }, + base::{id, nil, BOOL, YES}, + delegate, + foundation::{NSRect, NSUInteger}, +}; +use hex_color::HexColor; +use objc::{ + msg_send, + runtime::{Object, Sel}, + sel, sel_impl, +}; +use rand::distributions::{Alphanumeric, DistString}; +use tauri::{Emitter, Listener, LogicalPosition, Runtime, WebviewWindow}; + +#[derive(Debug)] +pub struct MacosWindow { + window: WebviewWindow, + traffic_lights_inset: LogicalPosition, + ns_window: id, +} + +impl MacosWindow { + pub fn new(window: WebviewWindow, traffic_lights_inset: LogicalPosition) -> Self { + let ns_window = window.ns_window().expect("Failed to get NS window handle") as id; + unsafe { + ns_window.setTitlebarAppearsTransparent_(YES); + ns_window.setTitleVisibility_(NSWindowTitleVisibility::NSWindowTitleHidden); + } + Self { + window, + traffic_lights_inset, + ns_window, + } + } + + pub fn setup(&self) { + self.reposition_controls(); + self.setup_theme_listener(); + unsafe { + let delegate = self.create_window_delegate(); + self.ns_window.setDelegate_(delegate); + } + } + + pub fn update_theme(&self, color: HexColor) { + let brightness = (color.r as u64 + color.g as u64 + color.b as u64) / 3; + unsafe { + let window_handle = self.ns_window; + let appearance = if brightness >= 128 { + NSAppearance(NSAppearanceNameVibrantLight) + } else { + NSAppearance(NSAppearanceNameVibrantDark) + }; + NSWindow::setAppearance(window_handle, appearance); + self.reposition_controls(); + } + } + + fn setup_theme_listener(&self) { + let window = self.window.clone(); + self.window.listen("hopp-bg-changed", move |event| { + let color_str = event.payload(); + + if let Ok(color_str) = serde_json::from_str::<&str>(color_str) { + if let Ok(color) = HexColor::parse_rgb(color_str.trim()) { + let macos_window = + MacosWindow::new(window.clone(), LogicalPosition::new(15.0, 23.0)); + macos_window.update_theme(color); + } + } + }); + } + + fn reposition_controls(&self) { + unsafe { + let buttons = self.window_buttons(); + let title_bar = buttons.title_bar_container(); + + let frame_height = buttons.height() + self.traffic_lights_inset.y; + title_bar.adjust_frame(frame_height, self.window_height()); + + buttons.reposition(self.traffic_lights_inset.x); + } + } + + unsafe fn create_window_delegate(&self) -> id { + let current_delegate = self.ns_window.delegate(); + let state = Box::new(WindowState::new( + self.window.clone(), + self.traffic_lights_inset, + )); + let app_box = Box::into_raw(state) as *mut std::ffi::c_void; + + let delegate_name = format!( + "WindowDelegate_{}_{}", + self.window.label(), + Alphanumeric.sample_string(&mut rand::thread_rng(), 20) + ); + + delegate!(&delegate_name, { + window: id = self.ns_window, + app_box: *mut std::ffi::c_void = app_box, + toolbar: id = nil, + super_delegate: id = current_delegate, + + (windowShouldClose:) => WindowDelegate::window_should_close as extern fn(&Object, Sel, id) -> BOOL, + (windowWillClose:) => WindowDelegate::window_will_close as extern fn(&Object, Sel, id), + (windowDidResize:) => WindowDelegate::window_did_resize:: as extern fn(&Object, Sel, id), + (windowDidMove:) => WindowDelegate::window_did_move as extern fn(&Object, Sel, id), + + (windowDidChangeBackingProperties:) => WindowDelegate::window_did_change_backing_properties as extern fn(&Object, Sel, id), + + (windowDidBecomeKey:) => WindowDelegate::window_did_become_key as extern fn(&Object, Sel, id), + (windowDidResignKey:) => WindowDelegate::window_did_resign_key as extern fn(&Object, Sel, id), + + (draggingEntered:) => WindowDelegate::dragging_entered as extern fn(&Object, Sel, id) -> BOOL, + (prepareForDragOperation:) => WindowDelegate::prepare_for_drag_operation as extern fn(&Object, Sel, id) -> BOOL, + (performDragOperation:) => WindowDelegate::perform_drag_operation as extern fn(&Object, Sel, id) -> BOOL, + (concludeDragOperation:) => WindowDelegate::conclude_drag_operation as extern fn(&Object, Sel, id), + (draggingExited:) => WindowDelegate::dragging_exited as extern fn(&Object, Sel, id), + + (window:willUseFullScreenPresentationOptions:) => WindowDelegate::window_will_use_full_screen_presentation_options as extern fn(&Object, Sel, id, NSUInteger) -> NSUInteger, + (windowDidEnterFullScreen:) => WindowDelegate::window_did_enter_full_screen:: as extern fn(&Object, Sel, id), + (windowWillEnterFullScreen:) => WindowDelegate::window_will_enter_full_screen:: as extern fn(&Object, Sel, id), + (windowDidExitFullScreen:) => WindowDelegate::window_did_exit_full_screen:: as extern fn(&Object, Sel, id), + (windowDidEndLiveResize:) => WindowDelegate::window_did_end_live_resize:: as extern fn(&Object, Sel, id), + (windowWillExitFullScreen:) => WindowDelegate::window_will_exit_full_screen:: as extern fn(&Object, Sel, id), + (windowDidFailToEnterFullScreen:) => WindowDelegate::window_did_fail_to_enter_full_screen as extern fn(&Object, Sel, id), + + (effectiveAppearanceDidChange:) => WindowDelegate::effective_appearance_did_change as extern fn(&Object, Sel, id), + (effectiveAppearanceDidChangedOnMainThread:) => WindowDelegate::effective_appearance_did_change_on_main_thread as extern fn(&Object, Sel, id) + }) + } + + unsafe fn window_height(&self) -> f64 { + NSView::frame(self.ns_window).size.height + } + + unsafe fn window_buttons(&self) -> WindowButtons { + WindowButtons::new(self.ns_window) + } +} + +#[derive(Debug)] +struct WindowButtons { + close: id, + minimize: id, + zoom: id, +} + +impl WindowButtons { + unsafe fn new(window: id) -> Self { + Self { + close: window.standardWindowButton_(NSWindowButton::NSWindowCloseButton), + minimize: window.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton), + zoom: window.standardWindowButton_(NSWindowButton::NSWindowZoomButton), + } + } + + unsafe fn height(&self) -> f64 { + let close_frame: NSRect = msg_send![self.close, frame]; + close_frame.size.height + } + + unsafe fn title_bar_container(&self) -> TitleBarContainer { + TitleBarContainer(self.close.superview().superview()) + } + + unsafe fn button_spacing(&self) -> f64 { + NSView::frame(self.minimize).origin.x - NSView::frame(self.close).origin.x + } + + unsafe fn reposition(&self, inset_x: f64) { + let spacing = self.button_spacing(); + for (i, button) in [self.close, self.minimize, self.zoom].iter().enumerate() { + let mut frame = NSView::frame(*button); + frame.origin.x = inset_x + (i as f64 * spacing); + frame.origin.y = 0.0; + button.setFrameOrigin(frame.origin); + } + } +} + +struct TitleBarContainer(id); + +impl TitleBarContainer { + unsafe fn adjust_frame(&self, height: f64, window_height: f64) { + let mut frame = NSView::frame(self.0); + frame.size.height = height; + frame.origin.y = window_height - height; + frame.origin.x = 0.0; + let _: () = msg_send![self.0, setFrame: frame]; + } +} + +#[derive(Debug)] +struct WindowState { + window: WebviewWindow, + traffic_lights_inset: LogicalPosition, +} + +impl WindowState { + fn new(window: WebviewWindow, traffic_lights_inset: LogicalPosition) -> Self { + Self { + window, + traffic_lights_inset, + } + } +} + +struct WindowDelegate; + +impl WindowDelegate { + extern "C" fn window_should_close(this: &Object, _: Sel, sender: id) -> BOOL { + unsafe { + let super_del: id = *this.get_ivar("super_delegate"); + msg_send![super_del, windowShouldClose: sender] + } + } + + extern "C" fn window_will_close(this: &Object, _: Sel, notification: id) { + unsafe { + let super_del: id = *this.get_ivar("super_delegate"); + let _: () = msg_send![super_del, windowWillClose: notification]; + } + } + + extern "C" fn window_did_resize(this: &Object, _: Sel, notification: id) { + unsafe { + Self::with_window_state::(this, |state| { + let macos_window = + MacosWindow::new(state.window.clone(), state.traffic_lights_inset); + macos_window.reposition_controls(); + }); + + let super_del: id = *this.get_ivar("super_delegate"); + let _: () = msg_send![super_del, windowDidResize: notification]; + } + } + + extern "C" fn window_did_move(this: &Object, _: Sel, notification: id) { + unsafe { + let super_del: id = *this.get_ivar("super_delegate"); + let _: () = msg_send![super_del, windowDidMove: notification]; + } + } + + extern "C" fn window_did_change_backing_properties(this: &Object, _: Sel, notification: id) { + unsafe { + let super_del: id = *this.get_ivar("super_delegate"); + let _: () = msg_send![super_del, windowDidChangeBackingProperties: notification]; + } + } + + extern "C" fn window_did_become_key(this: &Object, _: Sel, notification: id) { + unsafe { + let super_del: id = *this.get_ivar("super_delegate"); + let _: () = msg_send![super_del, windowDidBecomeKey: notification]; + } + } + + extern "C" fn window_did_resign_key(this: &Object, _: Sel, notification: id) { + unsafe { + let super_del: id = *this.get_ivar("super_delegate"); + let _: () = msg_send![super_del, windowDidResignKey: notification]; + } + } + + extern "C" fn dragging_entered(this: &Object, _: Sel, notification: id) -> BOOL { + unsafe { + let super_del: id = *this.get_ivar("super_delegate"); + msg_send![super_del, draggingEntered: notification] + } + } + + extern "C" fn prepare_for_drag_operation(this: &Object, _: Sel, notification: id) -> BOOL { + unsafe { + let super_del: id = *this.get_ivar("super_delegate"); + msg_send![super_del, prepareForDragOperation: notification] + } + } + + extern "C" fn perform_drag_operation(this: &Object, _: Sel, sender: id) -> BOOL { + unsafe { + let super_del: id = *this.get_ivar("super_delegate"); + msg_send![super_del, performDragOperation: sender] + } + } + + extern "C" fn conclude_drag_operation(this: &Object, _: Sel, notification: id) { + unsafe { + let super_del: id = *this.get_ivar("super_delegate"); + let _: () = msg_send![super_del, concludeDragOperation: notification]; + } + } + + extern "C" fn dragging_exited(this: &Object, _: Sel, notification: id) { + unsafe { + let super_del: id = *this.get_ivar("super_delegate"); + let _: () = msg_send![super_del, draggingExited: notification]; + } + } + + extern "C" fn window_will_use_full_screen_presentation_options( + this: &Object, + _: Sel, + window: id, + proposed_options: NSUInteger, + ) -> NSUInteger { + unsafe { + let super_del: id = *this.get_ivar("super_delegate"); + msg_send![super_del, window: window willUseFullScreenPresentationOptions: proposed_options] + } + } + + extern "C" fn window_did_enter_full_screen( + this: &Object, + _: Sel, + notification: id, + ) { + unsafe { + Self::with_window_state::(this, |state| { + let _ = state.window.emit("did-enter-fullscreen", ()); + }); + + let super_del: id = *this.get_ivar("super_delegate"); + let _: () = msg_send![super_del, windowDidEnterFullScreen: notification]; + } + } + + extern "C" fn window_will_enter_full_screen( + this: &Object, + _: Sel, + notification: id, + ) { + unsafe { + Self::with_window_state::(this, |state| { + let _ = state.window.emit("will-enter-fullscreen", ()); + }); + + let super_del: id = *this.get_ivar("super_delegate"); + let _: () = msg_send![super_del, windowWillEnterFullScreen: notification]; + } + } + + extern "C" fn window_did_end_live_resize(this: &Object, _: Sel, _: id) { + unsafe { + Self::with_window_state::(this, |state| { + let macos_window = + MacosWindow::new(state.window.clone(), state.traffic_lights_inset); + macos_window.reposition_controls(); + }); + } + } + + extern "C" fn window_did_exit_full_screen(this: &Object, _: Sel, notification: id) { + unsafe { + Self::with_window_state::(this, |state| { + let _ = state.window.emit("did-exit-fullscreen", ()); + let macos_window = + MacosWindow::new(state.window.clone(), state.traffic_lights_inset); + macos_window.reposition_controls(); + }); + + let super_del: id = *this.get_ivar("super_delegate"); + let _: () = msg_send![super_del, windowDidExitFullScreen: notification]; + } + } + + extern "C" fn window_will_exit_full_screen( + this: &Object, + _: Sel, + notification: id, + ) { + unsafe { + Self::with_window_state::(this, |state| { + let _ = state.window.emit("will-exit-fullscreen", ()); + }); + + let super_del: id = *this.get_ivar("super_delegate"); + let _: () = msg_send![super_del, windowWillExitFullScreen: notification]; + } + } + + extern "C" fn window_did_fail_to_enter_full_screen(this: &Object, _: Sel, window: id) { + unsafe { + let super_del: id = *this.get_ivar("super_delegate"); + let _: () = msg_send![super_del, windowDidFailToEnterFullScreen: window]; + } + } + + extern "C" fn effective_appearance_did_change(this: &Object, _: Sel, notification: id) { + unsafe { + let super_del: id = *this.get_ivar("super_delegate"); + let _: () = msg_send![super_del, effectiveAppearanceDidChange: notification]; + } + } + + extern "C" fn effective_appearance_did_change_on_main_thread( + this: &Object, + _: Sel, + notification: id, + ) { + unsafe { + let super_del: id = *this.get_ivar("super_delegate"); + let _: () = + msg_send![super_del, effectiveAppearanceDidChangedOnMainThread: notification]; + } + } + + unsafe fn with_window_state(this: &Object, f: F) + where + F: FnOnce(&mut WindowState), + { + let ptr: *mut std::ffi::c_void = *this.get_ivar("app_box"); + let state_ptr = ptr as *mut WindowState; + f(&mut *state_ptr); + } +} + +pub fn setup_window(window: WebviewWindow) { + let macos_window = MacosWindow::new(window, LogicalPosition::new(15.0, 16.0)); + macos_window.setup(); + macos_window.update_theme(HexColor::WHITE); +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/ui/mod.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/ui/mod.rs new file mode 100644 index 0000000..0669925 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/ui/mod.rs @@ -0,0 +1,4 @@ +#[cfg(target_os = "macos")] +pub mod macos; +#[cfg(target_os = "windows")] +pub mod windows; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/ui/windows/mod.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/ui/windows/mod.rs new file mode 100644 index 0000000..c5cba13 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/ui/windows/mod.rs @@ -0,0 +1 @@ +pub mod posit; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/ui/windows/posit.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/ui/windows/posit.rs new file mode 100644 index 0000000..2d2ffe8 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/ui/windows/posit.rs @@ -0,0 +1,133 @@ +use std::{ffi::c_void, mem::size_of, mem::transmute, ptr}; + +use hex_color::HexColor; +use tauri::{Listener, Runtime, WebviewWindow}; +use windows::Win32::{ + Foundation::{BOOL, COLORREF, HWND}, + Graphics::Dwm::{DwmSetWindowAttribute, DWMWA_CAPTION_COLOR, DWMWA_USE_IMMERSIVE_DARK_MODE}, + UI::Controls::{ + SetWindowThemeAttribute, WTA_NONCLIENT, WTNCA_NODRAWCAPTION, WTNCA_NODRAWICON, + WTNCA_NOMIRRORHELP, WTNCA_NOSYSMENU, + }, +}; +use winver::WindowsVersion; + +// Windows 11 Build 22000 is the minimum version required for +// DWMWA_USE_IMMERSIVE_DARK_MODE and DWMWA_CAPTION_COLOR. +// +// According to Microsoft documentation: +// https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +// +// "DWMWA_USE_IMMERSIVE_DARK_MODE: Use with DwmSetWindowAttribute. +// [...] This value is supported starting with Windows 11 Build 22000." +// +// "DWMWA_CAPTION_COLOR: Use with DwmSetWindowAttribute. +// [...] This value is supported starting with Windows 11 Build 22000." +const MIN_WIN11_BUILD: u32 = 22000; + +#[derive(Debug)] +pub struct WindowsWindow { + window: WebviewWindow, + hwnd: HWND, +} + +impl WindowsWindow { + pub fn new(window: WebviewWindow) -> Self { + let hwnd = window.hwnd().expect("Failed to get window handle"); + let hwnd = HWND(hwnd.0); + Self { window, hwnd } + } + + pub fn setup(&self) { + self.update_theme(HexColor::rgb(23, 23, 23)); + self.setup_theme_listener(); + } + + pub fn update_theme(&self, color: HexColor) { + self.set_dark_mode(); + self.set_caption_color(color); + self.set_theme_attributes(); + } + + fn setup_theme_listener(&self) { + let window = self.window.clone(); + self.window.listen("hopp-bg-changed", move |event| { + let color_str = event.payload(); + + if let Ok(color_str) = serde_json::from_str::<&str>(color_str) { + if let Ok(color) = HexColor::parse_rgb(color_str.trim()) { + let windows_window = WindowsWindow::new(window.clone()); + windows_window.update_theme(color); + } + } + }); + } + + fn set_dark_mode(&self) { + if let Some(version) = WindowsVersion::detect() { + if version.major >= 10 && version.build >= MIN_WIN11_BUILD { + unsafe { + let use_dark_mode = BOOL::from(true); + let _ = DwmSetWindowAttribute( + self.hwnd, + DWMWA_USE_IMMERSIVE_DARK_MODE, + ptr::addr_of!(use_dark_mode) as *const c_void, + size_of::().try_into().unwrap(), + ); + } + } + } + } + + fn set_caption_color(&self, color: HexColor) { + if let Some(version) = WindowsVersion::detect() { + if version.major >= 10 && version.build >= MIN_WIN11_BUILD { + unsafe { + let color_ref = self.hex_color_to_colorref(color); + let _ = DwmSetWindowAttribute( + self.hwnd, + DWMWA_CAPTION_COLOR, + ptr::addr_of!(color_ref) as *const c_void, + size_of::().try_into().unwrap(), + ); + } + } + } + } + + fn set_theme_attributes(&self) { + unsafe { + let theme_attributes = WinThemeAttribute::new(); + SetWindowThemeAttribute( + self.hwnd, + WTA_NONCLIENT, + ptr::addr_of!(theme_attributes) as *const c_void, + size_of::().try_into().unwrap(), + ) + .expect("Failed to set theme attributes"); + } + } + + fn hex_color_to_colorref(&self, color: HexColor) -> COLORREF { + unsafe { COLORREF(transmute::<[u8; 4], u32>([color.r, color.g, color.b, 0])) } + } +} + +#[derive(Debug)] +struct WinThemeAttribute { + flag: u32, + mask: u32, +} + +impl WinThemeAttribute { + fn new() -> Self { + let flag = WTNCA_NODRAWCAPTION | WTNCA_NODRAWICON; + let mask = WTNCA_NODRAWCAPTION | WTNCA_NODRAWICON | WTNCA_NOSYSMENU | WTNCA_NOMIRRORHELP; + Self { flag, mask } + } +} + +pub fn setup_window(window: WebviewWindow) { + let windows_window = WindowsWindow::new(window); + windows_window.setup(); +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/uri/error.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/uri/error.rs new file mode 100644 index 0000000..c1c44c0 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/uri/error.rs @@ -0,0 +1,12 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum UriError { + #[error("HTTP error: {0}")] + Http(#[from] tauri::http::Error), + + #[error("Cache error: {0}")] + Cache(#[from] crate::cache::CacheError), +} + +pub type Result = std::result::Result; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/uri/handler.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/uri/handler.rs new file mode 100644 index 0000000..65b4c1c --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/uri/handler.rs @@ -0,0 +1,134 @@ +use std::sync::Arc; + +use tauri::{ + http::{Response, Uri}, + Config, +}; + +use super::error::Result; +use crate::cache::CacheManager; +use crate::mapping::HostMapper; + +pub struct UriHandler { + cache: Arc, + mapper: Arc, + config: Config, +} + +impl UriHandler { + pub fn new(cache: Arc, config: Config, mapper: Arc) -> Self { + Self { + cache, + config, + mapper, + } + } + + async fn fetch_content(&self, host: &str, path: &str) -> Result> { + let file_path = if path.is_empty() { "index.html" } else { path }; + let bundle_name = self.mapper.resolve(host); + tracing::debug!( + host = %host, + bundle = %bundle_name, + path = %path, + resolved_path = %file_path, + "Fetching file content." + ); + Ok(self.cache.get_file(&bundle_name, file_path).await?) + } + + fn determine_mime(path: &str) -> &str { + if path.is_empty() || path == "index.html" { + "text/html; charset=utf-8" + } else { + mime_guess::from_path(path) + .first_raw() + .unwrap_or("application/octet-stream") + } + } + + pub async fn handle(&self, uri: &Uri) -> Result>> { + let host = uri.host().unwrap_or_default(); + let path = uri.path().trim_start_matches('/'); + + tracing::debug!(host = %host, path = %path, "Handling request"); + + // Try to fetch the requested path. If it fails and the path looks + // like a SPA route (no file extension), fall back to index.html so + // the client-side router can handle it. + let fetch_result = match self.fetch_content(host, path).await { + Ok(content) => Ok((content, path)), + Err(e) if !path.is_empty() && !path.contains('.') => { + tracing::info!( + host = %host, + path = %path, + "Path not found and has no extension, falling back to index.html for SPA routing" + ); + self.fetch_content(host, "") + .await + .map(|content| (content, "")) + .map_err(|_| e) + } + Err(e) => Err(e), + }; + + match fetch_result { + Ok((content, resolved_path)) => { + tracing::info!(host = %host, path = %path, content_length = %content.len(), "Successfully retrieved file content"); + + let mime_type = Self::determine_mime(resolved_path); + let csp = match self.config.app.security.csp.as_ref() { + Some(csp) => { + tracing::debug!("Using configured CSP"); + csp.to_string() + } + None => { + tracing::debug!("No CSP configured, using null"); + String::from("null") + } + }; + + tracing::info!( + status = 200, + host = %host, + path = %path, + mime = %mime_type, + csp = %csp, + content_length = %content.len(), + "Building response" + ); + + let response = Response::builder() + .status(200) + .header("content-type", mime_type) + .header("content-security-policy", csp) + .header("access-control-allow-credentials", "true") + .header("x-content-type-options", "nosniff") + .body(content); + + tracing::info!("Sending response"); + + response + } + Err(e) => { + tracing::error!( + error = %e, + host = %host, + path = %path, + "Failed to retrieve file content" + ); + + Response::builder().status(404).body(Vec::new()) + } + } + .map_err(|e| { + tracing::error!( + error = %e, + host = %host, + path = %path, + "Failed to build response" + ); + e.into() + }) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/uri/mod.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/uri/mod.rs new file mode 100644 index 0000000..c0ab89c --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/uri/mod.rs @@ -0,0 +1,4 @@ +pub mod error; +pub mod handler; + +pub use handler::UriHandler; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/vendor/config.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/vendor/config.rs new file mode 100644 index 0000000..b839ce7 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/vendor/config.rs @@ -0,0 +1,79 @@ +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; + +use tauri::Config as TauriConfig; + +use crate::{ + bundle::VerifiedBundle, cache::CacheManager, storage::StorageManager, vendor::VendorError, + Manifest, +}; + +use super::Result; + +const VENDOR_SOURCE: &str = "vendor"; + +#[derive(Debug, Clone, Default)] +pub struct VendorConfig { + pub bundle_path: PathBuf, + pub manifest_path: PathBuf, +} + +impl VendorConfig { + pub async fn initialize( + &self, + config: TauriConfig, + cache: Arc, + storage: Arc, + ) -> Result<()> { + let content = fs::read(&self.bundle_path).map_err(|e| VendorError::Io(e))?; + + let manifest_str = + fs::read_to_string(&self.manifest_path).map_err(|e| VendorError::Io(e))?; + + let manifest: Manifest = + serde_json::from_str(&manifest_str).map_err(|e| VendorError::Json(e))?; + + let max_bundle_size = 100 * 1024 * 1024; + if content.len() > max_bundle_size { + return Err(VendorError::InvalidData(format!( + "Bundle too large: {} bytes (max: {} bytes)", + content.len(), + max_bundle_size + ))); + } + + let name = config + .product_name + .unwrap_or("unknown".to_string()) + .to_lowercase(); + let version = manifest + .version + .clone() + .unwrap_or_else(|| config.version.as_deref().unwrap_or("0.0.0").to_string()); + + tracing::info!( + name = %name, + version = %version, + size = content.len(), + "Initializing vendored bundle" + ); + + let verified = VerifiedBundle::trust(content, manifest)?; + + // NOTE: This is temporary, to make sure bundle verifier + // has required file at the location, + // won't be necessary after source refactor. + storage + .store_bundle(&name, VENDOR_SOURCE, &version, &verified) + .await?; + + cache.cache_bundle(&name, &verified).await?; + + tracing::info!( + name = %name, + "Vendored bundle initialized successfully" + ); + Ok(()) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/vendor/error.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/vendor/error.rs new file mode 100644 index 0000000..2c7f8ee --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/vendor/error.rs @@ -0,0 +1,27 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum VendorError { + #[error("Invalid vendor data: {0}")] + InvalidData(String), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Zip error: {0}")] + Zip(#[from] zip::result::ZipError), + + #[error("Bundle error: {0}")] + Bundle(#[from] crate::bundle::BundleError), + + #[error("Storage error: {0}")] + Storage(#[from] crate::storage::StorageError), + + #[error("Cache error: {0}")] + Cache(#[from] crate::cache::CacheError), +} + +pub type Result = std::result::Result; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/vendor/mod.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/vendor/mod.rs new file mode 100644 index 0000000..2e6dfc9 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/vendor/mod.rs @@ -0,0 +1,5 @@ +mod config; +mod error; + +pub use config::VendorConfig; +pub use error::{Result, VendorError}; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/verification/bundle.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/verification/bundle.rs new file mode 100644 index 0000000..53eae09 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/verification/bundle.rs @@ -0,0 +1,32 @@ +use super::error::Result; +use super::key::KeyManager; +use crate::BundleMetadata; + +#[derive(Debug, Clone)] +pub struct BundleVerifier { + key_manager: KeyManager, +} + +impl BundleVerifier { + pub fn new(key_manager: KeyManager) -> Self { + Self { key_manager } + } + + pub async fn verify_bundle(&self, content: &[u8], metadata: &BundleMetadata) -> Result<()> { + tracing::info!( + file_count = metadata.manifest.files.len(), + "Verifying bundle signature" + ); + + self.key_manager + .verify_signature(content, &metadata.signature) + .await + .map_err(|e| { + tracing::error!( + error = %e, + "Signature verification failed" + ); + e + }) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/verification/error.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/verification/error.rs new file mode 100644 index 0000000..60283b8 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/verification/error.rs @@ -0,0 +1,40 @@ +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum VerificationError { + #[error("Invalid signature")] + InvalidSignature, + + #[error("Invalid file hash: expected {expected}, got {actual}")] + InvalidHash { expected: String, actual: String }, + + #[error("Key validation failed: {0}")] + KeyValidation(String), + + #[error("Invalid key format: {0}")] + InvalidKeyFormat(String), + + #[error("Invalid key length: {0}")] + InvalidKeyLength(String), + + #[error(transparent)] + Base64(#[from] base64::DecodeError), + + #[error(transparent)] + Ed25519(#[from] ed25519_dalek::SignatureError), + + #[error(transparent)] + Io(#[from] std::io::Error), +} + +impl VerificationError { + pub fn is_key_error(&self) -> bool { + matches!(self, Self::KeyValidation(_)) + } + + pub fn is_verification_error(&self) -> bool { + matches!(self, Self::InvalidSignature | Self::InvalidHash { .. }) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/verification/file.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/verification/file.rs new file mode 100644 index 0000000..ad452b2 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/verification/file.rs @@ -0,0 +1,52 @@ +use base64::{engine::general_purpose::STANDARD, Engine}; +use blake3::Hash; + +use super::error::{Result, VerificationError}; + +#[derive(Debug)] +pub struct FileVerifier; + +impl FileVerifier { + pub fn verify(data: &[u8], expected_hash: &Hash) -> Result<()> { + tracing::debug!("Starting hash verification of file data."); + let actual_hash = blake3::hash(data); + + if actual_hash != *expected_hash { + let expected_hash_b64 = STANDARD.encode(expected_hash.as_bytes()); + let actual_hash_b64 = STANDARD.encode(actual_hash.as_bytes()); + + tracing::error!( + expected_hash = %expected_hash_b64, + actual_hash = %actual_hash_b64, + "Hash verification failed." + ); + + return Err(VerificationError::InvalidHash { + expected: expected_hash_b64, + actual: actual_hash_b64, + }); + } + + tracing::debug!("Hash verification succeeded."); + Ok(()) + } + + pub fn hash(data: &[u8]) -> Hash { + tracing::debug!("Calculating hash for given data."); + blake3::hash(data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_file_verification() { + let data = b"test data"; + let hash = FileVerifier::hash(data); + + assert!(FileVerifier::verify(data, &hash).is_ok()); + assert!(FileVerifier::verify(b"wrong data", &hash).is_err()); + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/verification/key.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/verification/key.rs new file mode 100644 index 0000000..6fb8980 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/verification/key.rs @@ -0,0 +1,57 @@ +use base64::Engine; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + +use super::error::{Result, VerificationError}; +use crate::api::ApiClient; + +#[derive(Debug, Clone)] +pub struct KeyManager { + verified_key: VerifyingKey, + api_client: ApiClient, +} + +impl KeyManager { + pub async fn new(api_client: ApiClient) -> Result { + tracing::info!("Initializing KeyManager"); + + tracing::debug!("Retrieving verified key"); + let server_key = api_client.list_key().await.map_err(|e| { + tracing::error!(error = %e, "Failed to fetch server keys"); + VerificationError::KeyValidation(e.to_string()) + })?; + + tracing::debug!("Decoding and verifying key bytes"); + let key_bytes = base64::engine::general_purpose::STANDARD + .decode(&server_key.key) + .map_err(|e| { + tracing::error!(error = %e, "Failed to decode key bytes"); + VerificationError::InvalidKeyFormat(e.to_string()) + })?; + + let key_bytes: [u8; 32] = key_bytes.try_into().map_err(|_| { + tracing::error!("Key length is invalid"); + VerificationError::InvalidKeyLength("Expected 32 bytes".to_string()) + })?; + + let verified_key = VerifyingKey::from_bytes(&key_bytes).map_err(|e| { + tracing::error!(error = %e, "Invalid key format"); + VerificationError::InvalidKeyFormat(e.to_string()) + })?; + + tracing::debug!("Key successfully retrieved"); + + Ok(Self { + verified_key, + api_client, + }) + } + + pub async fn verify_signature(&self, data: &[u8], signature: &Signature) -> Result<()> { + tracing::info!("Verifying signature"); + let verifying_key = self.verified_key; + verifying_key.verify(data, signature).map_err(|_| { + tracing::error!("Signature verification failed"); + VerificationError::InvalidSignature + }) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/verification/mod.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/verification/mod.rs new file mode 100644 index 0000000..cc2808b --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/verification/mod.rs @@ -0,0 +1,11 @@ +mod bundle; +mod error; +mod file; +mod key; + +pub use bundle::BundleVerifier; +pub use error::VerificationError; +pub use file::FileVerifier; +pub use key::KeyManager; + +pub const VERIFICATION_VERSION: &str = "1.0.0"; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/tsconfig.json b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/tsconfig.json new file mode 100644 index 0000000..ce47298 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2021", + "module": "esnext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noImplicitAny": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist-js" + }, + "include": ["guest-js/*.ts"], + "exclude": ["dist-js", "node_modules"] +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/.envrc b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/.envrc new file mode 100644 index 0000000..894571b --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/.envrc @@ -0,0 +1,3 @@ +source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k=" + +use devenv diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/.gitignore b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/.gitignore new file mode 100644 index 0000000..e9acab9 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/.gitignore @@ -0,0 +1,23 @@ +/.vs +.DS_Store +.Thumbs.db +*.sublime* +.idea/ +debug.log +package-lock.json +.vscode/settings.json +yarn.lock + +/.tauri +/target +node_modules/ + +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.lock b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.lock new file mode 100644 index 0000000..141ace1 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.lock @@ -0,0 +1,5361 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "curl" +version = "0.4.47" +source = "git+https://github.com/CuriousCorrelation/curl-rust.git#1ec8079cf527b9cf47cc7a48c68b458affdae273" +dependencies = [ + "curl-sys", + "libc", + "openssl-probe", + "openssl-sys", + "socket2 0.5.10", +] + +[[package]] +name = "curl-sys" +version = "0.4.77+curl-8.10.1" +source = "git+https://github.com/CuriousCorrelation/curl-rust.git#1ec8079cf527b9cf47cc7a48c68b458affdae273" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "windows-sys 0.52.0", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.38.0", + "precomputed-hash", + "selectors 0.36.1", + "tendril 0.5.0", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.12+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever 0.14.1", + "match_token", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever 0.38.0", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-serde" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd" +dependencies = [ + "http", + "serde", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847" +dependencies = [ + "cfb", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser 0.29.6", + "html5ever 0.29.1", + "indexmap 2.14.0", + "selectors 0.24.0", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril 0.4.3", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril 0.5.0", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-src" +version = "300.6.0+3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.6", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "relay" +version = "0.1.1" +source = "git+https://github.com/CuriousCorrelation/relay.git#ed2329e4ebb71bb984c4705aa950cb9c3f9ff931" +dependencies = [ + "bytes", + "curl", + "dashmap", + "env_logger", + "http", + "http-serde", + "infer 0.16.0", + "lazy_static", + "log", + "mime", + "openssl", + "openssl-sys", + "serde", + "serde_json", + "strum", + "thiserror 1.0.69", + "time", + "tokio-util", + "tracing", + "url", + "url-escape", + "urlencoding", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser 0.29.6", + "derive_more 0.99.20", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc 0.2.0", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.1", + "cssparser 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc 0.4.3", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" +dependencies = [ + "bitflags 2.11.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-plugin-relay" +version = "0.1.0" +dependencies = [ + "relay", + "serde", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "tauri-runtime" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever 0.29.1", + "http", + "infer 0.19.0", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2 0.6.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.2", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.2", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "url-escape" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44e0ce4d1246d075ca5abec4b41d33e87a6054d08e2366b63205665e950db218" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.toml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.toml new file mode 100644 index 0000000..00f2950 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "tauri-plugin-relay" +version = "0.1.0" +authors = [ "CuriousCorrelation" ] +description = "" +edition = "2021" +rust-version = "1.77.2" +exclude = ["/examples", "/webview-dist", "/webview-src", "/node_modules"] +links = "tauri-plugin-relay" + +[dependencies] +tauri = { version = "2.1.0" } +serde = "1.0" +thiserror = "2" +tracing = "0.1.41" +relay = { git = "https://github.com/CuriousCorrelation/relay.git" } + +[build-dependencies] +tauri-plugin = { version = "2.5.4", features = ["build"] } diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/LICENSE.md b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/LICENSE.md new file mode 100644 index 0000000..24bfc7f --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 - CuriousCorrelation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/README.md b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/README.md new file mode 100644 index 0000000..b0fba6a --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/README.md @@ -0,0 +1,129 @@ +# Tauri Plugin: Relay + +> A HTTP request-response relay plugin for Tauri apps, providing advanced request handling capabilities including custom headers, certificates, proxies, and local system integration. + +
+ +![GitHub License MIT](https://img.shields.io/github/license/CuriousCorrelation/tauri-plugin-relay) +![Tauri 2.0](https://img.shields.io/badge/Tauri-2.0-blue) +[![Rust](https://img.shields.io/badge/Rust-1.77.2+-orange)](https://www.rust-lang.org) + +
+ +## Features + +- 🦀 Blazingly fast! +- HTTP client built on libcurl +- Security with SSL/TLS certificate management +- Proxy support +- Multiple authentication methods (Basic, Bearer, Digest) +- Content handling (JSON, Form Data, Binary) +- Async request execution with cancellation support + +## Installation + +> [!IMPORTANT] +> This plugin requires Tauri 2.0 or later. + +Add the plugin to your project by installing directly from GitHub: + +```toml +[dependencies] +tauri-plugin-relay = { git = "https://github.com/CuriousCorrelation/tauri-plugin-relay" } +``` +``` json +"dependencies": { + "@CuriousCorrelation/plugin-relay": "github:CuriousCorrelation/tauri-plugin-relay" +} +``` + +## Quick Start + +### Rust + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_relay::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +### JavaScript/TypeScript + +```typescript +import { execute, cancel } from '@CuriousCorrelation/plugin-relay' + +// Execute a request +const result = await execute({ + id: 1, + url: "https://api.example.com/data", + method: "POST", + headers: { + "Content-Type": ["application/json"] + }, + content: { + kind: "json", + content: { hello: "world" } + } +}) + +// Cancel a request +await cancel(1) +``` + +## Content Types + +The plugin supports multiple content types for requests: + +| Type | Description | +|------|-------------| +| `text` | Plain text content | +| `json` | JSON data with automatic parsing | +| `form` | Multipart form data with file support | +| `binary` | Raw binary data with optional MIME type | +| `urlencoded` | URL-encoded form data | + +## Authentication + +Built-in support for various authentication methods: + +| Method | Description | +|--------|-------------| +| `basic` | Basic HTTP authentication | +| `bearer` | Bearer token authentication | +| `digest` | Digest authentication (MD5, SHA-256, SHA-512) | + +## Security + +The plugin provides extensive security options: + +- Client certificate support (PEM, PKCS#12) +- Custom CA certificates +- Certificate validation control +- Host verification settings + +## Development + +Requirements: +- Rust 1.77.2 or later +- Node.js 18 or later +- pnpm +- libcurl with SSL support + +## Error Handling + +The plugin provides detailed error information for: + +- Network failures +- Certificate issues +- Timeout scenarios +- Parse errors +- Request cancellations + +## License + +Code: (c) 2024 - CuriousCorrelation + +MIT or MIT/Apache 2.0 where applicable. diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/build.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/build.rs new file mode 100644 index 0000000..339053a --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/build.rs @@ -0,0 +1,8 @@ +const COMMANDS: &[&str] = &["execute", "cancel", "subscribe"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .android_path("android") + .ios_path("ios") + .build(); +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/devenv.lock b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/devenv.lock new file mode 100644 index 0000000..03df3f3 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/devenv.lock @@ -0,0 +1,105 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1776802132, + "narHash": "sha256-2yO2SGA7zVFYKe0qyJjdg7WHuMOKNwTQmigL7ydD8hI=", + "owner": "cachix", + "repo": "devenv", + "rev": "91affc7a7b6646852a0079678eadf12ac5029d9d", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1776845169, + "narHash": "sha256-Ya6Ba5oC0+PK1TSU4Rkjpoca73mUp6FoHQV5QGnqbx0=", + "owner": "nix-community", + "repo": "fenix", + "rev": "f0b5be1fa2891221ba8b48784f8fded5ef15301f", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1776329215, + "narHash": "sha256-a8BYi3mzoJ/AcJP8UldOx8emoPRLeWqALZWu4ZvjPXw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b86751bc4085f48661017fa226dee99fab6c651b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "fenix": "fenix", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1776800521, + "narHash": "sha256-f8YJfwAOsLFpIoqZuX3yF69UvMLrkx7iVzMH1pJU7cM=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "8954b66d43225e62c92e8bbcc8500191b5cceb1e", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1776827647, + "narHash": "sha256-sYixYhp5V8jCajO8TRorE4fzs7IkL4MZdfLTKgkPQBk=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "40e6ccc06e1245a4837cbbd6bdda64e21cc67379", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} \ No newline at end of file diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/devenv.nix b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/devenv.nix new file mode 100644 index 0000000..c6f1f7a --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/devenv.nix @@ -0,0 +1,85 @@ +{ pkgs, lib, config, inputs, ... }: + +let + rosettaPkgs = + if pkgs.stdenv.isDarwin && pkgs.stdenv.isAarch64 + then pkgs.pkgsx86_64Darwin + else pkgs; + + darwinPackages = with pkgs; [ + apple-sdk + ]; + + linuxPackages = with pkgs; [ + libsoup_3 + webkitgtk_4_1 + librsvg + libappindicator + libayatana-appindicator + ]; + +in { + packages = with pkgs; [ + git + nodejs_22 + typescript-language-server + vue-language-server + cargo-edit + ] ++ lib.optionals pkgs.stdenv.isDarwin darwinPackages + ++ lib.optionals pkgs.stdenv.isLinux linuxPackages; + + env = { + APP_GREET = "Hi!"; + } // lib.optionalAttrs pkgs.stdenv.isLinux { + LD_LIBRARY_PATH = lib.makeLibraryPath [ + pkgs.libappindicator + pkgs.libayatana-appindicator + ]; + } // lib.optionalAttrs pkgs.stdenv.isDarwin { + # Place to put macOS-specific environment variables + }; + + scripts = { + hello.exec = "echo hello from $APP_GREET"; + e.exec = "emacs"; + }; + + enterShell = '' + git --version + ${lib.optionalString pkgs.stdenv.isDarwin '' + # Place to put macOS-specific shell initialization + ''} + ${lib.optionalString pkgs.stdenv.isLinux '' + # Place to put Linux-specific shell initialization + ''} + ''; + + enterTest = '' + echo "Running tests" + ''; + + dotenv.enable = true; + + languages = { + typescript.enable = true; + javascript = { + enable = true; + pnpm.enable = true; + npm.enable = true; + }; + rust = { + enable = true; + channel = "nightly"; + components = [ + "rustc" + "cargo" + "clippy" + "rustfmt" + "rust-analyzer" + "llvm-tools-preview" + "rust-src" + "rustc-codegen-cranelift-preview" + ]; + }; + }; +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/devenv.yaml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/devenv.yaml new file mode 100644 index 0000000..d016920 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/devenv.yaml @@ -0,0 +1,14 @@ +inputs: + fenix: + url: github:nix-community/fenix + inputs: + nixpkgs: + follows: nixpkgs + nixpkgs: + url: github:NixOS/nixpkgs/nixpkgs-unstable + rust-overlay: + url: github:oxalica/rust-overlay + inputs: + nixpkgs: + follows: nixpkgs +allowUnfree: true diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/dist-js/index.cjs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/dist-js/index.cjs new file mode 100644 index 0000000..06b05fc --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/dist-js/index.cjs @@ -0,0 +1,27 @@ +'use strict'; + +var core = require('@tauri-apps/api/core'); + +exports.MediaType = void 0; +(function (MediaType) { + MediaType["TEXT_PLAIN"] = "text/plain"; + MediaType["TEXT_HTML"] = "text/html"; + MediaType["TEXT_CSS"] = "text/css"; + MediaType["TEXT_CSV"] = "text/csv"; + MediaType["APPLICATION_JSON"] = "application/json"; + MediaType["APPLICATION_LD_JSON"] = "application/ld+json"; + MediaType["APPLICATION_XML"] = "application/xml"; + MediaType["TEXT_XML"] = "text/xml"; + MediaType["APPLICATION_FORM"] = "application/x-www-form-urlencoded"; + MediaType["APPLICATION_OCTET"] = "application/octet-stream"; + MediaType["MULTIPART_FORM"] = "multipart/form-data"; +})(exports.MediaType || (exports.MediaType = {})); +async function execute(request) { + return await core.invoke('plugin:relay|execute', { request }); +} +async function cancel(requestId) { + return await core.invoke('plugin:relay|cancel', { requestId }); +} + +exports.cancel = cancel; +exports.execute = execute; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/dist-js/index.d.ts b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/dist-js/index.d.ts new file mode 100644 index 0000000..4c3e504 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/dist-js/index.d.ts @@ -0,0 +1,234 @@ +export type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS" | "CONNECT" | "TRACE"; +export type Version = "HTTP/1.0" | "HTTP/1.1" | "HTTP/2.0" | "HTTP/3.0"; +export type StatusCode = 100 | 101 | 102 | 103 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511; +export type FormDataValue = { + kind: "text"; + value: string; +} | { + kind: "file"; + filename: string; + contentType: string; + data: Uint8Array; +}; +export type FormData = [string, FormDataValue[]][]; +export declare enum MediaType { + TEXT_PLAIN = "text/plain", + TEXT_HTML = "text/html", + TEXT_CSS = "text/css", + TEXT_CSV = "text/csv", + APPLICATION_JSON = "application/json", + APPLICATION_LD_JSON = "application/ld+json", + APPLICATION_XML = "application/xml", + TEXT_XML = "text/xml", + APPLICATION_FORM = "application/x-www-form-urlencoded", + APPLICATION_OCTET = "application/octet-stream", + MULTIPART_FORM = "multipart/form-data" +} +export type ContentType = { + kind: "text"; + content: string; + mediaType: MediaType.TEXT_PLAIN | MediaType.TEXT_HTML | MediaType.TEXT_CSS | MediaType.TEXT_CSV; +} | { + kind: "json"; + content: unknown; + mediaType: MediaType.APPLICATION_JSON | MediaType.APPLICATION_LD_JSON; +} | { + kind: "xml"; + content: string; + mediaType: MediaType.APPLICATION_XML | MediaType.TEXT_XML; +} | { + kind: "form"; + content: FormData; + mediaType: MediaType.APPLICATION_FORM; +} | { + kind: "binary"; + content: Uint8Array; + mediaType: MediaType.APPLICATION_OCTET | string; + filename?: string; +} | { + kind: "multipart"; + content: FormData; + mediaType: MediaType.MULTIPART_FORM; +} | { + kind: "urlencoded"; + content: string; + mediaType: MediaType.APPLICATION_FORM; +} | { + kind: "stream"; + content: ReadableStream; + mediaType: string; +}; +export interface ResponseBody { + body: Uint8Array; + mediaType: MediaType | string; +} +export type AuthType = { + kind: "none"; +} | { + kind: "basic"; + username: string; + password: string; +} | { + kind: "bearer"; + token: string; +} | { + kind: "digest"; + username: string; + password: string; + realm?: string; + nonce?: string; + opaque?: string; + algorithm?: "MD5" | "SHA-256" | "SHA-512"; + qop?: "auth" | "auth-int"; + nc?: string; + cnonce?: string; +} | { + kind: "oauth2"; + grantType: { + kind: "authorization_code"; + authEndpoint: string; + tokenEndpoint: string; + clientId: string; + clientSecret?: string; + } | { + kind: "client_credentials"; + tokenEndpoint: string; + clientId: string; + clientSecret?: string; + } | { + kind: "password"; + tokenEndpoint: string; + username: string; + password: string; + } | { + kind: "implicit"; + authEndpoint: string; + clientId: string; + }; + accessToken?: string; + refreshToken?: string; +} | { + kind: "apikey"; + key: string; + value: string; + in: "header" | "query"; +} | { + kind: "aws"; + accessKey: string; + secretKey: string; + region: string; + service: string; + sessionToken?: string; + in: "header" | "query"; +}; +export type CertificateType = { + kind: "pem"; + cert: Uint8Array; + key: Uint8Array; +} | { + kind: "pfx"; + data: Uint8Array; + password: string; +}; +export interface RequestOptions { + timeout?: number; + followRedirects?: boolean; + maxRedirects?: number; + decompress?: boolean; + cookies?: boolean; + keepAlive?: boolean; +} +export interface RequestMeta { + options?: RequestOptions; +} +export interface Request { + id: number; + url: string; + method: Method; + version: Version; + headers?: Record; + params?: Record; + content?: ContentType; + auth?: AuthType; + security?: { + certificates?: { + client?: CertificateType; + ca?: Array; + }; + verifyHost?: boolean; + verifyPeer?: boolean; + }; + proxy?: { + url: string; + auth?: { + username: string; + password: string; + }; + }; + meta?: RequestMeta; +} +export interface Response { + id: number; + status: StatusCode; + statusText: string; + version: Version; + headers: Record; + cookies?: Array<{ + name: string; + value: string; + domain?: string; + path?: string; + expires?: Date; + secure?: boolean; + httpOnly?: boolean; + sameSite?: 'Strict' | 'Lax' | 'None'; + }>; + body: ResponseBody; + meta: { + timing: { + start: number; + end: number; + }; + size: { + headers: number; + body: number; + total: number; + }; + }; +} +export type UnsupportedFeatureError = { + kind: "unsupported_feature"; + feature: string; + message: string; + relay: string; +}; +export type RelayError = UnsupportedFeatureError | { + kind: "network"; + message: string; + cause?: unknown; +} | { + kind: "timeout"; + message: string; + phase?: "connect" | "tls" | "response"; +} | { + kind: "certificate"; + message: string; + cause?: unknown; +} | { + kind: "parse"; + message: string; + cause?: unknown; +} | { + kind: "abort"; + message: string; +}; +export type RequestResult = { + kind: 'success'; + response: Response; +} | { + kind: 'error'; + error: RelayError; +}; +export declare function execute(request: Request): Promise; +export declare function cancel(requestId: number): Promise; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/dist-js/index.d.ts.map b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/dist-js/index.d.ts.map new file mode 100644 index 0000000..d38851a --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/dist-js/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["guest-js/index.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,MAAM,GACd,KAAK,GACL,MAAM,GACN,KAAK,GACL,QAAQ,GACR,OAAO,GACP,MAAM,GACN,SAAS,GACT,SAAS,GACT,OAAO,CAAA;AAEX,MAAM,MAAM,OAAO,GAAG,UAAU,GAAG,UAAU,GAAG,UAAU,GAAG,UAAU,CAAA;AAEvE,MAAM,MAAM,UAAU,GAChB,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,GACH,GAAG,CAAA;AAET,MAAM,MAAM,aAAa,GACnB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,UAAU,CAAA;CAAE,CAAA;AAE/E,MAAM,MAAM,QAAQ,GAAG,CAAC,MAAM,EAAE,aAAa,EAAE,CAAC,EAAE,CAAA;AAElD,oBAAY,SAAS;IACjB,UAAU,eAAe;IACzB,SAAS,cAAc;IACvB,QAAQ,aAAa;IACrB,QAAQ,aAAa;IACrB,gBAAgB,qBAAqB;IACrC,mBAAmB,wBAAwB;IAC3C,eAAe,oBAAoB;IACnC,QAAQ,aAAa;IACrB,gBAAgB,sCAAsC;IACtD,iBAAiB,6BAA6B;IAC9C,cAAc,wBAAwB;CACzC;AAED,MAAM,MAAM,WAAW,GACjB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,SAAS,CAAC,UAAU,GAAG,SAAS,CAAC,SAAS,GAAG,SAAS,CAAC,QAAQ,GAAG,SAAS,CAAC,QAAQ,CAAA;CAAE,GAClI;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,SAAS,CAAC,gBAAgB,GAAG,SAAS,CAAC,mBAAmB,CAAA;CAAE,GACzG;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,SAAS,CAAC,eAAe,GAAG,SAAS,CAAC,QAAQ,CAAA;CAAE,GAC3F;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,SAAS,CAAC,gBAAgB,CAAA;CAAE,GAC1E;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE,UAAU,CAAC;IAAC,SAAS,EAAE,SAAS,CAAC,iBAAiB,GAAG,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC3G;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,OAAO,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,SAAS,CAAC,cAAc,CAAA;CAAE,GAC7E;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,SAAS,CAAC,gBAAgB,CAAA;CAAE,GAC9E;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE,cAAc,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAA;AAEpE,MAAM,WAAW,YAAY;IACzB,IAAI,EAAE,UAAU,CAAA;IAChB,SAAS,EAAE,SAAS,GAAG,MAAM,CAAA;CAChC;AAED,MAAM,MAAM,QAAQ,GACd;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAChB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACrD;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACjC;IACE,IAAI,EAAE,QAAQ,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,KAAK,GAAG,SAAS,GAAG,SAAS,CAAA;IACzC,GAAG,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;IACzB,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,MAAM,CAAC,EAAE,MAAM,CAAA;CAClB,GACC;IACE,IAAI,EAAE,QAAQ,CAAA;IACd,SAAS,EACP;QACE,IAAI,EAAE,oBAAoB,CAAA;QAC1B,YAAY,EAAE,MAAM,CAAA;QACpB,aAAa,EAAE,MAAM,CAAA;QACrB,QAAQ,EAAE,MAAM,CAAA;QAChB,YAAY,CAAC,EAAE,MAAM,CAAA;KACxB,GACC;QACE,IAAI,EAAE,oBAAoB,CAAA;QAC1B,aAAa,EAAE,MAAM,CAAA;QACrB,QAAQ,EAAE,MAAM,CAAA;QAChB,YAAY,CAAC,EAAE,MAAM,CAAA;KACxB,GACC;QACE,IAAI,EAAE,UAAU,CAAA;QAChB,aAAa,EAAE,MAAM,CAAA;QACrB,QAAQ,EAAE,MAAM,CAAA;QAChB,QAAQ,EAAE,MAAM,CAAA;KACnB,GACC;QACE,IAAI,EAAE,UAAU,CAAA;QAChB,YAAY,EAAE,MAAM,CAAA;QACpB,QAAQ,EAAE,MAAM,CAAA;KACnB,CAAA;IACD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,YAAY,CAAC,EAAE,MAAM,CAAA;CACxB,GACC;IACE,IAAI,EAAE,QAAQ,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAA;CACzB,GACC;IACE,IAAI,EAAE,KAAK,CAAA;IACX,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;IACf,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAA;CACzB,CAAA;AAEL,MAAM,MAAM,eAAe,GACvB;IACA,IAAI,EAAE,KAAK,CAAA;IACX,IAAI,EAAE,UAAU,CAAA;IAChB,GAAG,EAAE,UAAU,CAAA;CAChB,GACC;IACA,IAAI,EAAE,KAAK,CAAA;IACX,IAAI,EAAE,UAAU,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAEH,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,EAAE,cAAc,CAAA;CACzB;AAED,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAA;IACV,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,OAAO,CAAA;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC/B,OAAO,CAAC,EAAE,WAAW,CAAA;IACrB,IAAI,CAAC,EAAE,QAAQ,CAAA;IAEf,QAAQ,CAAC,EAAE;QACT,YAAY,CAAC,EAAE;YACb,MAAM,CAAC,EAAE,eAAe,CAAA;YACxB,EAAE,CAAC,EAAE,KAAK,CAAC,UAAU,CAAC,CAAA;SACvB,CAAA;QACD,UAAU,CAAC,EAAE,OAAO,CAAA;QACpB,UAAU,CAAC,EAAE,OAAO,CAAA;KACrB,CAAA;IAED,KAAK,CAAC,EAAE;QACN,GAAG,EAAE,MAAM,CAAA;QACX,IAAI,CAAC,EAAE;YACL,QAAQ,EAAE,MAAM,CAAA;YAChB,QAAQ,EAAE,MAAM,CAAA;SACjB,CAAA;KACF,CAAA;IAED,IAAI,CAAC,EAAE,WAAW,CAAA;CACnB;AAED,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,EAAE,UAAU,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,OAAO,EAAE,OAAO,CAAA;IAChB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC/B,OAAO,CAAC,EAAE,KAAK,CAAC;QACd,IAAI,EAAE,MAAM,CAAA;QACZ,KAAK,EAAE,MAAM,CAAA;QACb,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,OAAO,CAAC,EAAE,IAAI,CAAA;QACd,MAAM,CAAC,EAAE,OAAO,CAAA;QAChB,QAAQ,CAAC,EAAE,OAAO,CAAA;QAClB,QAAQ,CAAC,EAAE,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAA;KACrC,CAAC,CAAA;IACF,IAAI,EAAE,YAAY,CAAA;IAElB,IAAI,EAAE;QACJ,MAAM,EAAE;YACN,KAAK,EAAE,MAAM,CAAA;YACb,GAAG,EAAE,MAAM,CAAA;SACZ,CAAA;QACD,IAAI,EAAE;YACJ,OAAO,EAAE,MAAM,CAAA;YACf,IAAI,EAAE,MAAM,CAAA;YACZ,KAAK,EAAE,MAAM,CAAA;SACd,CAAA;KACF,CAAA;CACF;AAED,MAAM,MAAM,uBAAuB,GAAG;IACpC,IAAI,EAAE,qBAAqB,CAAA;IAC3B,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,UAAU,GAClB,uBAAuB,GACvB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GACrD;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,SAAS,GAAG,KAAK,GAAG,UAAU,CAAA;CAAE,GAC5E;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GACzD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GACnD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAA;AAEtC,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,GACvC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,CAAA;AAExC,wBAAsB,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAEtE;AAED,wBAAsB,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE7D"} \ No newline at end of file diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/dist-js/index.js b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/dist-js/index.js new file mode 100644 index 0000000..c5f0a1a --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/dist-js/index.js @@ -0,0 +1,24 @@ +import { invoke } from '@tauri-apps/api/core'; + +var MediaType; +(function (MediaType) { + MediaType["TEXT_PLAIN"] = "text/plain"; + MediaType["TEXT_HTML"] = "text/html"; + MediaType["TEXT_CSS"] = "text/css"; + MediaType["TEXT_CSV"] = "text/csv"; + MediaType["APPLICATION_JSON"] = "application/json"; + MediaType["APPLICATION_LD_JSON"] = "application/ld+json"; + MediaType["APPLICATION_XML"] = "application/xml"; + MediaType["TEXT_XML"] = "text/xml"; + MediaType["APPLICATION_FORM"] = "application/x-www-form-urlencoded"; + MediaType["APPLICATION_OCTET"] = "application/octet-stream"; + MediaType["MULTIPART_FORM"] = "multipart/form-data"; +})(MediaType || (MediaType = {})); +async function execute(request) { + return await invoke('plugin:relay|execute', { request }); +} +async function cancel(requestId) { + return await invoke('plugin:relay|cancel', { requestId }); +} + +export { MediaType, cancel, execute }; diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/guest-js/index.ts b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/guest-js/index.ts new file mode 100644 index 0000000..1a7500f --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/guest-js/index.ts @@ -0,0 +1,289 @@ +import { invoke } from '@tauri-apps/api/core' + +export type Method = + | "GET" // Retrieve resource + | "POST" // Create resource + | "PUT" // Replace resource + | "DELETE" // Remove resource + | "PATCH" // Modify resource + | "HEAD" // GET without body + | "OPTIONS" // Get allowed methods + | "CONNECT" // Create tunnel + | "TRACE" // Loop-back test + +export type Version = "HTTP/1.0" | "HTTP/1.1" | "HTTP/2.0" | "HTTP/3.0" + +export type StatusCode = + | 100 // Continue + | 101 // Switching Protocols + | 102 // Processing + | 103 // Early Hints + | 200 // OK + | 201 // Created + | 202 // Accepted + | 203 // Non-Authoritative Info + | 204 // No Content + | 205 // Reset Content + | 206 // Partial Content + | 207 // Multi-Status + | 208 // Already Reported + | 226 // IM Used + | 300 // Multiple Choices + | 301 // Moved Permanently + | 302 // Found + | 303 // See Other + | 304 // Not Modified + | 305 // Use Proxy + | 306 // Switch Proxy + | 307 // Temporary Redirect + | 308 // Permanent Redirect + | 400 // Bad Request + | 401 // Unauthorized + | 402 // Payment Required + | 403 // Forbidden + | 404 // Not Found + | 405 // Method Not Allowed + | 406 // Not Acceptable + | 407 // Proxy Auth Required + | 408 // Request Timeout + | 409 // Conflict + | 410 // Gone + | 411 // Length Required + | 412 // Precondition Failed + | 413 // Payload Too Large + | 414 // URI Too Long + | 415 // Unsupported Media + | 416 // Range Not Satisfiable + | 417 // Expectation Failed + | 418 // I'm a teapot + | 421 // Misdirected Request + | 422 // Unprocessable Entity + | 423 // Locked + | 424 // Failed Dependency + | 425 // Too Early + | 426 // Upgrade Required + | 428 // Precondition Required + | 429 // Too Many Requests + | 431 // Headers Too Large + | 451 // Unavailable Legal + | 500 // Server Error + | 501 // Not Implemented + | 502 // Bad Gateway + | 503 // Service Unavailable + | 504 // Gateway Timeout + | 505 // HTTP Ver. Not Supported + | 506 // Variant Negotiates + | 507 // Insufficient Storage + | 508 // Loop Detected + | 510 // Not Extended + | 511 // Network Auth Required + +export type FormDataValue = + | { kind: "text"; value: string } + | { kind: "file"; filename: string; contentType: string; data: Uint8Array } + +export type FormData = [string, FormDataValue[]][] + +export enum MediaType { + TEXT_PLAIN = "text/plain", + TEXT_HTML = "text/html", + TEXT_CSS = "text/css", + TEXT_CSV = "text/csv", + APPLICATION_JSON = "application/json", + APPLICATION_LD_JSON = "application/ld+json", + APPLICATION_XML = "application/xml", + TEXT_XML = "text/xml", + APPLICATION_FORM = "application/x-www-form-urlencoded", + APPLICATION_OCTET = "application/octet-stream", + MULTIPART_FORM = "multipart/form-data" +} + +export type ContentType = + | { kind: "text"; content: string; mediaType: MediaType.TEXT_PLAIN | MediaType.TEXT_HTML | MediaType.TEXT_CSS | MediaType.TEXT_CSV } + | { kind: "json"; content: unknown; mediaType: MediaType.APPLICATION_JSON | MediaType.APPLICATION_LD_JSON } + | { kind: "xml"; content: string; mediaType: MediaType.APPLICATION_XML | MediaType.TEXT_XML } + | { kind: "form"; content: FormData; mediaType: MediaType.APPLICATION_FORM } + | { kind: "binary"; content: Uint8Array; mediaType: MediaType.APPLICATION_OCTET | string; filename?: string } + | { kind: "multipart"; content: FormData; mediaType: MediaType.MULTIPART_FORM } + | { kind: "urlencoded"; content: string; mediaType: MediaType.APPLICATION_FORM } + | { kind: "stream"; content: ReadableStream; mediaType: string } + +export interface ResponseBody { + body: Uint8Array + mediaType: MediaType | string +} + +export type AuthType = + | { kind: "none" } + | { kind: "basic"; username: string; password: string } + | { kind: "bearer"; token: string } + | { + kind: "digest" + username: string + password: string + realm?: string + nonce?: string + opaque?: string + algorithm?: "MD5" | "SHA-256" | "SHA-512" + qop?: "auth" | "auth-int" + nc?: string + cnonce?: string + } + | { + kind: "oauth2" + grantType: + | { + kind: "authorization_code" + authEndpoint: string + tokenEndpoint: string + clientId: string + clientSecret?: string + } + | { + kind: "client_credentials" + tokenEndpoint: string + clientId: string + clientSecret?: string + } + | { + kind: "password" + tokenEndpoint: string + username: string + password: string + } + | { + kind: "implicit" + authEndpoint: string + clientId: string + } + accessToken?: string + refreshToken?: string + } + | { + kind: "apikey" + key: string + value: string + in: "header" | "query" + } + | { + kind: "aws" + accessKey: string + secretKey: string + region: string + service: string + sessionToken?: string + in: "header" | "query" + } + +export type CertificateType = + | { + kind: "pem" + cert: Uint8Array + key: Uint8Array + } + | { + kind: "pfx" + data: Uint8Array + password: string + } + +export interface RequestOptions { + timeout?: number + followRedirects?: boolean + maxRedirects?: number + decompress?: boolean + cookies?: boolean + keepAlive?: boolean +} + +export interface RequestMeta { + options?: RequestOptions +} + +export interface Request { + id: number + url: string + method: Method + version: Version + headers?: Record + params?: Record + content?: ContentType + auth?: AuthType + + security?: { + certificates?: { + client?: CertificateType + ca?: Array + } + verifyHost?: boolean + verifyPeer?: boolean + } + + proxy?: { + url: string + auth?: { + username: string + password: string + } + } + + meta?: RequestMeta +} + +export interface Response { + id: number + status: StatusCode + statusText: string + version: Version + headers: Record + cookies?: Array<{ + name: string + value: string + domain?: string + path?: string + expires?: Date + secure?: boolean + httpOnly?: boolean + sameSite?: 'Strict' | 'Lax' | 'None' + }> + body: ResponseBody + + meta: { + timing: { + start: number + end: number + } + size: { + headers: number + body: number + total: number + } + } +} + +export type UnsupportedFeatureError = { + kind: "unsupported_feature" + feature: string + message: string + relay: string +} + +export type RelayError = + | UnsupportedFeatureError + | { kind: "network"; message: string; cause?: unknown } + | { kind: "timeout"; message: string; phase?: "connect" | "tls" | "response" } + | { kind: "certificate"; message: string; cause?: unknown } + | { kind: "parse"; message: string; cause?: unknown } + | { kind: "abort"; message: string } + +export type RequestResult = + | { kind: 'success'; response: Response } + | { kind: 'error'; error: RelayError } + +export async function execute(request: Request): Promise { + return await invoke('plugin:relay|execute', { request }) +} + +export async function cancel(requestId: number): Promise { + return await invoke('plugin:relay|cancel', { requestId }) +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/package.json b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/package.json new file mode 100644 index 0000000..51432f2 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/package.json @@ -0,0 +1,38 @@ +{ + "name": "@CuriousCorrelation/plugin-relay", + "version": "0.1.0", + "author": "CuriousCorrelation", + "description": "An HTTP request-response relay plugin for Tauri apps.", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "files": [ + "dist-js", + "README.md" + ], + "scripts": { + "build": "tsc && rollup -c", + "prepublishOnly": "pnpm build", + "pretest": "pnpm build" + }, + "dependencies": { + "@tauri-apps/api": "2.1.1" + }, + "devDependencies": { + "@rollup/plugin-typescript": "^11.1.6", + "rollup": "^4.59.0", + "tslib": "^2.6.2", + "typescript": "5.9.2" + }, + "pnpm": { + "overrides": { + "picomatch@<4.0.4": "^4.0.4" + } + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/autogenerated/commands/cancel.toml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/autogenerated/commands/cancel.toml new file mode 100644 index 0000000..91efeaa --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/autogenerated/commands/cancel.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-cancel" +description = "Enables the cancel command without any pre-configured scope." +commands.allow = ["cancel"] + +[[permission]] +identifier = "deny-cancel" +description = "Denies the cancel command without any pre-configured scope." +commands.deny = ["cancel"] diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/autogenerated/commands/execute.toml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/autogenerated/commands/execute.toml new file mode 100644 index 0000000..d98be89 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/autogenerated/commands/execute.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-execute" +description = "Enables the execute command without any pre-configured scope." +commands.allow = ["execute"] + +[[permission]] +identifier = "deny-execute" +description = "Denies the execute command without any pre-configured scope." +commands.deny = ["execute"] diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/autogenerated/commands/run.toml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/autogenerated/commands/run.toml new file mode 100644 index 0000000..3f5afde --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/autogenerated/commands/run.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-run" +description = "Enables the run command without any pre-configured scope." +commands.allow = ["run"] + +[[permission]] +identifier = "deny-run" +description = "Denies the run command without any pre-configured scope." +commands.deny = ["run"] diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/autogenerated/commands/subscribe.toml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/autogenerated/commands/subscribe.toml new file mode 100644 index 0000000..0277f2a --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/autogenerated/commands/subscribe.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-subscribe" +description = "Enables the subscribe command without any pre-configured scope." +commands.allow = ["subscribe"] + +[[permission]] +identifier = "deny-subscribe" +description = "Denies the subscribe command without any pre-configured scope." +commands.deny = ["subscribe"] diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/autogenerated/reference.md b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/autogenerated/reference.md new file mode 100644 index 0000000..9ddaff1 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/autogenerated/reference.md @@ -0,0 +1,122 @@ +## Default Permission + +Default permissions for the plugin + +#### This default permission set includes the following: + +- `allow-execute` +- `allow-cancel` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`relay:allow-cancel` + + + +Enables the cancel command without any pre-configured scope. + +
+ +`relay:deny-cancel` + + + +Denies the cancel command without any pre-configured scope. + +
+ +`relay:allow-execute` + + + +Enables the execute command without any pre-configured scope. + +
+ +`relay:deny-execute` + + + +Denies the execute command without any pre-configured scope. + +
+ +`relay:allow-run` + + + +Enables the run command without any pre-configured scope. + +
+ +`relay:deny-run` + + + +Denies the run command without any pre-configured scope. + +
+ +`relay:allow-subscribe` + + + +Enables the subscribe command without any pre-configured scope. + +
+ +`relay:deny-subscribe` + + + +Denies the subscribe command without any pre-configured scope. + +
diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/default.toml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/default.toml new file mode 100644 index 0000000..0b8243f --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/default.toml @@ -0,0 +1,3 @@ +[default] +description = "Default permissions for the plugin" +permissions = ["allow-execute", "allow-cancel"] diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/schemas/schema.json b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/schemas/schema.json new file mode 100644 index 0000000..d1a0760 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/permissions/schemas/schema.json @@ -0,0 +1,354 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the cancel command without any pre-configured scope.", + "type": "string", + "const": "allow-cancel", + "markdownDescription": "Enables the cancel command without any pre-configured scope." + }, + { + "description": "Denies the cancel command without any pre-configured scope.", + "type": "string", + "const": "deny-cancel", + "markdownDescription": "Denies the cancel command without any pre-configured scope." + }, + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Enables the run command without any pre-configured scope.", + "type": "string", + "const": "allow-run", + "markdownDescription": "Enables the run command without any pre-configured scope." + }, + { + "description": "Denies the run command without any pre-configured scope.", + "type": "string", + "const": "deny-run", + "markdownDescription": "Denies the run command without any pre-configured scope." + }, + { + "description": "Enables the subscribe command without any pre-configured scope.", + "type": "string", + "const": "allow-subscribe", + "markdownDescription": "Enables the subscribe command without any pre-configured scope." + }, + { + "description": "Denies the subscribe command without any pre-configured scope.", + "type": "string", + "const": "deny-subscribe", + "markdownDescription": "Denies the subscribe command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-execute`\n- `allow-cancel`", + "type": "string", + "const": "default", + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-execute`\n- `allow-cancel`" + } + ] + } + } +} \ No newline at end of file diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/pnpm-lock.yaml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/pnpm-lock.yaml new file mode 100644 index 0000000..fcf7640 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/pnpm-lock.yaml @@ -0,0 +1,409 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + picomatch@<4.0.4: ^4.0.4 + +importers: + + .: + dependencies: + '@tauri-apps/api': + specifier: 2.1.1 + version: 2.1.1 + devDependencies: + '@rollup/plugin-typescript': + specifier: ^11.1.6 + version: 11.1.6(rollup@4.60.2)(tslib@2.8.1)(typescript@5.9.2) + rollup: + specifier: ^4.59.0 + version: 4.60.2 + tslib: + specifier: ^2.6.2 + version: 2.8.1 + typescript: + specifier: 5.9.2 + version: 5.9.2 + +packages: + + '@rollup/plugin-typescript@11.1.6': + resolution: {integrity: sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.14.0||^3.0.0||^4.0.0 + tslib: '*' + typescript: '>=3.7.0' + peerDependenciesMeta: + rollup: + optional: true + tslib: + optional: true + + '@rollup/pluginutils@5.1.3': + resolution: {integrity: sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.2': + resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.2': + resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.2': + resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.2': + resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.2': + resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.2': + resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.2': + resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.2': + resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.2': + resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.2': + resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.2': + resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.2': + resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.2': + resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.2': + resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} + cpu: [x64] + os: [win32] + + '@tauri-apps/api@2.1.1': + resolution: {integrity: sha512-fzUfFFKo4lknXGJq8qrCidkUcKcH2UHhfaaCNt4GzgzGaW2iS26uFOg4tS3H4P8D6ZEeUxtiD5z0nwFF0UN30A==} + + '@types/estree@1.0.6': + resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + is-core-module@2.15.1: + resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} + engines: {node: '>= 0.4'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + + rollup@4.60.2: + resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + engines: {node: '>=14.17'} + hasBin: true + +snapshots: + + '@rollup/plugin-typescript@11.1.6(rollup@4.60.2)(tslib@2.8.1)(typescript@5.9.2)': + dependencies: + '@rollup/pluginutils': 5.1.3(rollup@4.60.2) + resolve: 1.22.8 + typescript: 5.9.2 + optionalDependencies: + rollup: 4.60.2 + tslib: 2.8.1 + + '@rollup/pluginutils@5.1.3(rollup@4.60.2)': + dependencies: + '@types/estree': 1.0.6 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.2 + + '@rollup/rollup-android-arm-eabi@4.60.2': + optional: true + + '@rollup/rollup-android-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-x64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.2': + optional: true + + '@tauri-apps/api@2.1.1': {} + + '@types/estree@1.0.6': {} + + '@types/estree@1.0.8': {} + + estree-walker@2.0.2: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + is-core-module@2.15.1: + dependencies: + hasown: 2.0.2 + + path-parse@1.0.7: {} + + picomatch@4.0.4: {} + + resolve@1.22.8: + dependencies: + is-core-module: 2.15.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rollup@4.60.2: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.2 + '@rollup/rollup-android-arm64': 4.60.2 + '@rollup/rollup-darwin-arm64': 4.60.2 + '@rollup/rollup-darwin-x64': 4.60.2 + '@rollup/rollup-freebsd-arm64': 4.60.2 + '@rollup/rollup-freebsd-x64': 4.60.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 + '@rollup/rollup-linux-arm-musleabihf': 4.60.2 + '@rollup/rollup-linux-arm64-gnu': 4.60.2 + '@rollup/rollup-linux-arm64-musl': 4.60.2 + '@rollup/rollup-linux-loong64-gnu': 4.60.2 + '@rollup/rollup-linux-loong64-musl': 4.60.2 + '@rollup/rollup-linux-ppc64-gnu': 4.60.2 + '@rollup/rollup-linux-ppc64-musl': 4.60.2 + '@rollup/rollup-linux-riscv64-gnu': 4.60.2 + '@rollup/rollup-linux-riscv64-musl': 4.60.2 + '@rollup/rollup-linux-s390x-gnu': 4.60.2 + '@rollup/rollup-linux-x64-gnu': 4.60.2 + '@rollup/rollup-linux-x64-musl': 4.60.2 + '@rollup/rollup-openbsd-x64': 4.60.2 + '@rollup/rollup-openharmony-arm64': 4.60.2 + '@rollup/rollup-win32-arm64-msvc': 4.60.2 + '@rollup/rollup-win32-ia32-msvc': 4.60.2 + '@rollup/rollup-win32-x64-gnu': 4.60.2 + '@rollup/rollup-win32-x64-msvc': 4.60.2 + fsevents: 2.3.3 + + supports-preserve-symlinks-flag@1.0.0: {} + + tslib@2.8.1: {} + + typescript@5.9.2: {} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/rollup.config.js b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/rollup.config.js new file mode 100644 index 0000000..d834363 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/rollup.config.js @@ -0,0 +1,31 @@ +import { readFileSync } from 'fs' +import { join } from 'path' +import { cwd } from 'process' +import typescript from '@rollup/plugin-typescript' + +const pkg = JSON.parse(readFileSync(join(cwd(), 'package.json'), 'utf8')) + +export default { + input: 'guest-js/index.ts', + output: [ + { + file: pkg.exports.import, + format: 'esm' + }, + { + file: pkg.exports.require, + format: 'cjs' + } + ], + plugins: [ + typescript({ + declaration: true, + declarationDir: `./${pkg.exports.import.split('/')[0]}` + }) + ], + external: [ + /^@tauri-apps\/api/, + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}) + ] +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/commands.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/commands.rs new file mode 100644 index 0000000..73da002 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/commands.rs @@ -0,0 +1,38 @@ +use crate::{models::*, RelayExt, Result}; +use tauri::{command, AppHandle, Runtime}; + +#[command] +pub(crate) async fn execute( + app: AppHandle, + request: RunRequest, +) -> Result { + tracing::debug!(?request, "Received execute command"); + let response = app.relay().execute(request).await; + + match &response { + Ok(_) => { + tracing::info!("Execute command completed successfully"); + } + Err(e) => { + tracing::error!(?e, "Execute command failed"); + } + } + + response +} + +#[command] +pub(crate) async fn cancel( + app: AppHandle, + request_id: CancelRequest, +) -> Result { + tracing::debug!(?request_id, "Received cancel command"); + let response = app.relay().cancel(request_id).await; + + match &response { + Ok(_) => tracing::info!("Cancel command completed successfully"), + Err(e) => tracing::error!(?e, "Cancel command failed"), + } + + response +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/desktop.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/desktop.rs new file mode 100644 index 0000000..25c597b --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/desktop.rs @@ -0,0 +1,42 @@ +use crate::{models::*, Result}; +use serde::de::DeserializeOwned; +use tauri::{plugin::PluginApi, AppHandle, Runtime}; + +pub fn init( + app: &AppHandle, + _api: PluginApi, +) -> Result> { + tracing::debug!("Initializing Relay for desktop platform"); + Ok(Relay(app.clone())) +} + +pub struct Relay(AppHandle); + +impl Relay { + pub async fn execute(&self, request: RunRequest) -> Result { + tracing::debug!(?request, "Executing request"); + + match relay::execute(request).await { + Ok(response) => { + tracing::debug!("Request executed successfully"); + Ok(ExecuteResponse::Success { response }) + } + Err(error) => { + tracing::error!(?error, "Request execution failed"); + Ok(ExecuteResponse::Error { error }) + } + } + } + + pub async fn cancel(&self, request_id: CancelRequest) -> Result { + tracing::debug!(?request_id, "Cancelling request"); + + if let Err(e) = relay::cancel(request_id).await { + tracing::error!(?e, "Request cancellation failed"); + return Err(e.into()); + } + + tracing::debug!("Request cancelled successfully"); + Ok(()) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/error.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/error.rs new file mode 100644 index 0000000..00cccbb --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/error.rs @@ -0,0 +1,24 @@ +use serde::{ser::Serializer, Serialize}; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Relay(#[from] relay::error::RelayError), + #[cfg(mobile)] + #[error(transparent)] + PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + tracing::error!(?self, "Serializing error"); + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/lib.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/lib.rs new file mode 100644 index 0000000..9b88b83 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/lib.rs @@ -0,0 +1,66 @@ +use tauri::{ + plugin::{Builder, TauriPlugin}, + Manager, Runtime, +}; + +pub use models::*; + +#[cfg(desktop)] +mod desktop; +#[cfg(mobile)] +mod mobile; + +mod commands; +mod error; +mod models; + +pub use error::{Error, Result}; + +#[cfg(desktop)] +use desktop::Relay; +#[cfg(mobile)] +use mobile::Relay; + +pub trait RelayExt { + fn relay(&self) -> &Relay; +} + +impl> crate::RelayExt for T { + fn relay(&self) -> &Relay { + tracing::trace!("Accessing Relay state"); + self.state::>().inner() + } +} + +pub fn init() -> TauriPlugin { + tracing::info!("Beginning relay plugin initialization"); + + Builder::new("relay") + .invoke_handler(tauri::generate_handler![ + commands::execute, + commands::cancel + ]) + .setup(|app, api| { + tracing::info!("Setting up relay plugin"); + + #[cfg(mobile)] + { + tracing::debug!("Initializing mobile-specific components"); + let relay = mobile::init(app, api)?; + tracing::debug!("Mobile components initialized successfully"); + app.manage(relay); + } + + #[cfg(desktop)] + { + tracing::debug!("Initializing desktop-specific components"); + let relay = desktop::init(app, api)?; + tracing::debug!("Desktop components initialized successfully"); + app.manage(relay); + } + + tracing::info!("relay plugin setup complete"); + Ok(()) + }) + .build() +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/mobile.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/mobile.rs new file mode 100644 index 0000000..07e3850 --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/mobile.rs @@ -0,0 +1,48 @@ +use serde::de::DeserializeOwned; +use tauri::{ + plugin::{PluginApi, PluginHandle}, + AppHandle, Runtime, +}; + +use crate::models::*; + +#[cfg(target_os = "ios")] +tauri::ios_plugin_binding!(init_plugin_relay); + +pub fn init( + _app: &AppHandle, + api: PluginApi, +) -> crate::Result> { + tracing::debug!("Initializing Relay for mobile platform"); + + #[cfg(target_os = "android")] + let handle = { + tracing::debug!("Registering Android plugin"); + api.register_android_plugin("", "ExamplePlugin")? + }; + + #[cfg(target_os = "ios")] + let handle = { + tracing::debug!("Registering iOS plugin"); + api.register_ios_plugin(init_plugin_relay)? + }; + + tracing::info!("Mobile plugin initialization complete"); + Ok(Relay(handle)) +} + +pub struct Relay(PluginHandle); + +impl Relay { + pub fn run(&self, request: RunRequest) -> crate::Result { + tracing::debug!(?request, "Running mobile plugin"); + let result = self.0.run_mobile_plugin("run", request); + + match &result { + Ok(_) => tracing::debug!("Mobile plugin execution successful"), + Err(e) => tracing::error!(?e, "Mobile plugin execution failed"), + } + + result.map_err(Into::into) + } +} diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/models.rs b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/models.rs new file mode 100644 index 0000000..6e104af --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/src/models.rs @@ -0,0 +1,17 @@ +use relay::{error::RelayError, Request as RelayRequest, Response as RelayResponse}; +use serde::{Deserialize, Serialize}; + +pub type RunRequest = RelayRequest; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum ExecuteResponse { + #[serde(rename = "success")] + Success { response: RelayResponse }, + #[serde(rename = "error")] + Error { error: RelayError }, +} + +pub type CancelRequest = i64; + +pub type CancelResponse = (); diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/tsconfig.json b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/tsconfig.json new file mode 100644 index 0000000..bee11ea --- /dev/null +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2021", + "module": "esnext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noImplicitAny": true, + "noEmit": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist-js" + }, + "include": ["guest-js/*.ts"], + "exclude": ["dist-js", "node_modules"] +} diff --git a/packages/hoppscotch-desktop/postcss.config.js b/packages/hoppscotch-desktop/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/packages/hoppscotch-desktop/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/packages/hoppscotch-desktop/public/favicon.ico b/packages/hoppscotch-desktop/public/favicon.ico new file mode 100644 index 0000000..7411836 Binary files /dev/null and b/packages/hoppscotch-desktop/public/favicon.ico differ diff --git a/packages/hoppscotch-desktop/public/images/add_group.svg b/packages/hoppscotch-desktop/public/images/add_group.svg new file mode 100644 index 0000000..4350ea1 --- /dev/null +++ b/packages/hoppscotch-desktop/public/images/add_group.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-desktop/public/images/pack.svg b/packages/hoppscotch-desktop/public/images/pack.svg new file mode 100644 index 0000000..56a669b --- /dev/null +++ b/packages/hoppscotch-desktop/public/images/pack.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-desktop/public/images/youre_lost.svg b/packages/hoppscotch-desktop/public/images/youre_lost.svg new file mode 100644 index 0000000..ea33353 --- /dev/null +++ b/packages/hoppscotch-desktop/public/images/youre_lost.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-desktop/public/logo.svg b/packages/hoppscotch-desktop/public/logo.svg new file mode 100644 index 0000000..d78a86b --- /dev/null +++ b/packages/hoppscotch-desktop/public/logo.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-desktop/public/tauri.svg b/packages/hoppscotch-desktop/public/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/packages/hoppscotch-desktop/public/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/hoppscotch-desktop/public/vite.svg b/packages/hoppscotch-desktop/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/packages/hoppscotch-desktop/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/hoppscotch-desktop/src-tauri/.gitignore b/packages/hoppscotch-desktop/src-tauri/.gitignore new file mode 100644 index 0000000..b21bd68 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/.gitignore @@ -0,0 +1,7 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas diff --git a/packages/hoppscotch-desktop/src-tauri/Cargo.lock b/packages/hoppscotch-desktop/src-tauri/Cargo.lock new file mode 100644 index 0000000..7127ee3 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/Cargo.lock @@ -0,0 +1,8046 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +dependencies = [ + "anstyle", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" + +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "ashpd" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d43c03d9e36dd40cab48435be0b09646da362c278223ca535493877b2c1dee9" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.8.5", + "raw-window-handle 0.6.2", + "serde", + "serde_repr", + "tokio", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus 4.0.1", +] + +[[package]] +name = "async-broadcast" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cd0e2e25ea8e5f7e9df04578dc6cf5c83577fd09b1a46aaf5c85e1c33f2a7e" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddb939d66e4ae03cee6091612804ba446b12878410cfa17f785f4dd67d4014e8" +dependencies = [ + "flate2", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-executor" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb812ffb58524bdd10860d7d974e2f01cc0950c2438a74ee5ec2e2280c6c4ffa" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f7e37c0ed80b2a977691c47dae8625cfb21e205827106c64f7c588766b2e50" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" +dependencies = [ + "async-lock", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 0.38.42", + "slab", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 0.38.42", + "tracing", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "async-signal" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 0.38.42", + "signal-hook-registry", + "slab", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "axum" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +dependencies = [ + "serde", +] + +[[package]] +name = "blake3" +version = "1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8ee0c1824c4dea5b5f81736aff91bae041d2c07ee1192bec91054e10e3e601e" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "serde", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +dependencies = [ + "objc2 0.6.2", +] + +[[package]] +name = "blocking" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bon" +version = "3.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537c317ddf588aab15c695bf92cf55dec159b93221c074180ca3e0e5a94da415" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5abbf2d4a4c6896197c9de13d6d7cb7eff438c63dacde1dde980569cb00248" +dependencies = [ + "darling 0.21.2", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.90", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytemuck" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b37c88a63ffd85d15b406896cc343916d7cf57838a847b3a6f2ca5d39a5695a" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +dependencies = [ + "serde", +] + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.11+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.6.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.12", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.7", +] + +[[package]] +name = "cc" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9157bbaa6b165880c27a4293a474c91cdcf265cc68cc829bf10be0964a391caf" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets 0.52.6", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation 0.1.2", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "foreign-types 0.5.0", + "libc", + "objc", +] + +[[package]] +name = "cocoa" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" +dependencies = [ + "bitflags 2.6.0", + "block", + "cocoa-foundation 0.2.0", + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "foreign-types 0.5.0", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" +dependencies = [ + "bitflags 2.6.0", + "block", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", + "libc", + "objc", +] + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.15", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.6.0", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.6.0", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.6.0", + "core-foundation 0.10.0", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.90", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.90", +] + +[[package]] +name = "curl" +version = "0.4.47" +source = "git+https://github.com/CuriousCorrelation/curl-rust.git#1ec8079cf527b9cf47cc7a48c68b458affdae273" +dependencies = [ + "curl-sys", + "libc", + "openssl-probe", + "openssl-sys", + "socket2 0.5.8", +] + +[[package]] +name = "curl-sys" +version = "0.4.77+curl-8.10.1" +source = "git+https://github.com/CuriousCorrelation/curl-rust.git#1ec8079cf527b9cf47cc7a48c68b458affdae273" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "windows-sys 0.52.0", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core 0.20.10", + "darling_macro 0.20.10", +] + +[[package]] +name = "darling" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08440b3dd222c3d0433e63e097463969485f112baff337dfdaca043a0d760570" +dependencies = [ + "darling_core 0.21.2", + "darling_macro 0.21.2", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.90", +] + +[[package]] +name = "darling_core" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25b7912bc28a04ab1b7715a68ea03aaa15662b43a1a4b2c480531fd19f8bf7e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.90", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core 0.20.10", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "darling_macro" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce154b9bea7fb0c8e8326e62d00354000c36e79770ff21b8c84e3aa267d9d531" +dependencies = [ + "darling_core 0.21.2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-url" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" + +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + +[[package]] +name = "der" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "derive_more" +version = "0.99.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.90", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.0", + "windows-sys 0.60.2", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.6.0", + "block2 0.6.1", + "libc", + "objc2 0.6.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading 0.8.6", +] + +[[package]] +name = "dlopen2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b54f373ccf864bf587a89e880fb7610f8d73f3045f13580948ccbcaff26febff" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b99bf03862d7f545ebc28ddd33a665b50865f4dfd84031a393823879bd4c54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "document-features" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f25c0e292a7ca6d6498557ff1df68f32c99850012b6ea401cf8daf771f22ff53" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "serde", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "embed-resource" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.7", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] +name = "enumflags2" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d232db7f5956f3f14313dc2f87985c58bd2c695ce124c8cdd984e08e15ac133d" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0d48a183585823424a4ce1aa132d174a6a81bd540895822eb4c8373a8e49e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "erased-serde" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24e2389d65ab4fab27dc2a5de7b191e1f6617d1f1c8855c0dc569c94a4cbb18d" +dependencies = [ + "serde", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "file-rotate" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8e2fa049328a1f3295991407a88585805d126dfaadf74b9fe8c194c730aafc" +dependencies = [ + "chrono", + "flate2", +] + +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + +[[package]] +name = "flate2" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f81ec6369c545a7d40e4589b5597581fa1c441fe1cce96dd1de43159910a36a2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cef40d21ae2c515b51041df9ed313ed21e572df340ea58a922a0aefe7e8891a1" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.6.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.0", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "h2" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccae279728d634d083c00f6099cb58f01cc99c145b84b8be2f6c74618d79922e" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.11.4", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex_color" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37f101bf4c633f7ca2e4b5e136050314503dd198e78e325ea602c327c484ef0" +dependencies = [ + "rand 0.8.5", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "hoppscotch-desktop" +version = "26.6.0" +dependencies = [ + "axum", + "dirs 6.0.0", + "file-rotate", + "native-dialog", + "random-port", + "semver", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-appload", + "tauri-plugin-deep-link", + "tauri-plugin-dialog", + "tauri-plugin-fs", + "tauri-plugin-http", + "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-relay", + "tauri-plugin-shell", + "tauri-plugin-store", + "tauri-plugin-updater", + "tauri-plugin-window-state", + "tempfile", + "thiserror 2.0.12", + "tokio", + "tower-http", + "tracing", + "tracing-appender", + "tracing-subscriber", + "winreg 0.52.0", +] + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-serde" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd" +dependencies = [ + "http", + "serde", +] + +[[package]] +name = "httparse" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" +dependencies = [ + "futures-util", + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core 0.52.0", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +dependencies = [ + "equivalent", + "hashbrown 0.15.2", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847" +dependencies = [ + "cfb", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.6.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser", + "html5ever", + "indexmap 2.11.4", + "selectors", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +dependencies = [ + "cfg-if", + "windows-targets 0.48.5", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.6.0", + "libc", + "redox_syscall", +] + +[[package]] +name = "libz-sys" +version = "1.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d16453e800a8cf6dd2fc3eb4bc99b786a9b90c663b8559a5b1a041bf89e472" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + +[[package]] +name = "litrs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.2", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.2", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime-infer" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91caed19dd472bc88bcd063571df18153529d49301a1918f4cf37f42332bee2e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "minisign-verify" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6367d84fb54d4242af283086402907277715b8fe46976963af5ebf173f8efba3" + +[[package]] +name = "miniz_oxide" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2ef2593ffb6958c941575cee70c8e257438749971869c4ae5acf6f91a168a61" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2 0.6.2", + "objc2-app-kit 0.3.1", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "once_cell", + "png", + "serde", + "thiserror 2.0.12", + "windows-sys 0.60.2", +] + +[[package]] +name = "native-dialog" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e7038885d2aeab236bd60da9e159a5967b47cde3292da3b15ff1bec27c039f" +dependencies = [ + "ascii", + "block", + "cocoa 0.25.0", + "core-foundation 0.9.4", + "dirs-next", + "objc", + "objc-foundation", + "objc_id", + "once_cell", + "raw-window-handle 0.5.2", + "thiserror 1.0.69", + "versions", + "wfd", + "which", + "winapi", +] + +[[package]] +name = "native-tls" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.6.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle 0.6.2", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "network-interface" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a43439bf756eed340bdf8feba761e2d50c7d47175d87545cd5cbe4a137c4d1" +dependencies = [ + "cc", + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +dependencies = [ + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561f357ba7f3a2a61563a186a163d0a3a5247e1089524a3981d49adb775078bc" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.6.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +dependencies = [ + "bitflags 2.6.0", + "block2 0.6.1", + "objc2 0.6.2", + "objc2-core-foundation", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.6.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags 2.6.0", + "dispatch2", + "objc2 0.6.2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" +dependencies = [ + "bitflags 2.6.0", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.6.0", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +dependencies = [ + "bitflags 2.6.0", + "block2 0.6.1", + "objc2 0.6.2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.6.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.6.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" +dependencies = [ + "bitflags 2.6.0", + "objc2 0.6.2", + "objc2-core-foundation", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad" +dependencies = [ + "bitflags 2.6.0", + "block2 0.6.1", + "objc2 0.6.2", + "objc2-app-kit 0.3.1", + "objc2-core-foundation", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "object" +version = "0.36.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "open" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ecd52f0b8d15c40ce4820aa251ed5de032e5d91fab27f7db2f40d42a8bdf69c" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl" +version = "0.10.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-src" +version = "300.4.1+3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faa4eac4138c62414b5622d1b31c5c304f34b406b013c079c2bbc652fdd6678c" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ffd2b0a5634335b135d5728d84c5e0fd726954b87111f7506a61c502280d982" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros 0.11.2", + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared 0.11.2", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + +[[package]] +name = "plist" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016" +dependencies = [ + "base64 0.22.1", + "indexmap 2.11.4", + "quick-xml 0.32.0", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67582bd5b65bdff614270e2ea89a1cf15bef71245cc1e5f7ea126977144211d" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 0.38.42", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" +dependencies = [ + "proc-macro2", + "syn 2.0.90", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" +dependencies = [ + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit 0.22.27", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "quick-xml" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e96808277ec6f97351a2380e6c25114bc9e67037775464979f3037c92d05ef" +dependencies = [ + "bytes", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.5.8", + "thiserror 2.0.12", + "tokio", + "tracing", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.12", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c40286217b4ba3a71d644d752e6a0b71f13f1b6a2c5311acfcbe0c2418ed904" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.8", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.3", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "random-port" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52b7d0e298a1b2f2f46c8d5da944c80ed1e5e6b032521cc44ee2b1dcbe2b94a" +dependencies = [ + "network-interface", + "rand 0.8.5", + "thiserror 1.0.69", +] + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +dependencies = [ + "bitflags 2.6.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.12", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "relay" +version = "0.1.1" +source = "git+https://github.com/CuriousCorrelation/relay.git#1c4f1e16106ed48983926ea6ddb114c1dbb2f688" +dependencies = [ + "bytes", + "cookie", + "curl", + "dashmap", + "env_logger", + "http", + "http-serde", + "infer 0.16.0", + "lazy_static", + "log", + "mime", + "openssl", + "openssl-sys", + "serde", + "serde_json", + "strum", + "thiserror 1.0.69", + "time", + "tokio-util", + "tracing", + "url", + "url-escape", + "urlencoding", +] + +[[package]] +name = "reqwest" +version = "0.12.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" +dependencies = [ + "async-compression", + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", + "windows-registry 0.2.0", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8af382a047821a08aa6bfc09ab0d80ff48d45d8726f7cd8e44891f7cb4a4278e" +dependencies = [ + "ashpd", + "block2 0.5.1", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "raw-window-handle 0.6.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.48.0", +] + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.15", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rust-ini" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f" +dependencies = [ + "cfg-if", + "ordered-multimap", + "trim-in-place", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc-hash" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" +dependencies = [ + "bitflags 2.6.0", + "errno", + "libc", + "linux-raw-sys 0.4.14", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags 2.6.0", + "errno", + "libc", + "linux-raw-sys 0.9.4", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustls" +version = "0.23.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5065c3f250cbd332cd894be57c40fa52387247659b14a2d6041d121547903b1b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2bf47e6ff922db3825eb750c4e2ff784c6ff8fb9e13046ef6a1d1c5401b0b37" +dependencies = [ + "web-time", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "schemars" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars_derive" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.90", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.6.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa39c7303dc58b5543c94d22c1766b0d31f2ee58306363ea622b10bbc075eaa2" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.226" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2676ba99bd82f75cae5cbd2c8eda6fa0b8760f18978ea840e980dd5567b5c5b6" +dependencies = [ + "erased-serde", + "serde", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.226" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.226" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "serde_json" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.11.4", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d" +dependencies = [ + "darling 0.20.10", + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared_child" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09fa9338aed9a1df411814a5b2252f7cd206c55ae9bf2fa763f8de84603aa60c" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "socket2" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +dependencies = [ + "bytemuck", + "cfg_aliases", + "core-graphics 0.24.0", + "foreign-types 0.5.0", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core", + "raw-window-handle 0.6.2", + "redox_syscall", + "wasm-bindgen", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" +dependencies = [ + "new_debug_unreachable", + "once_cell", + "parking_lot", + "phf_shared 0.10.0", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.90", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "sysinfo" +version = "0.34.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4b93974b3d3aeaa036504b8eefd4c039dced109171c1ae973f1dc63b2c7e4b2" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "windows 0.57.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.6.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" +dependencies = [ + "bitflags 2.6.0", + "block2 0.6.1", + "core-foundation 0.10.0", + "core-graphics 0.25.0", + "crossbeam-channel", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2 0.6.2", + "objc2-app-kit 0.3.1", + "objc2-foundation 0.3.1", + "once_cell", + "parking_lot", + "raw-window-handle 0.6.2", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "tar" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c65998313f8e17d0d553d28f91a0df93e4dbbbf770279c7bc21ca0f09ea1a1f6" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs 6.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.3", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2 0.6.2", + "objc2-app-kit 0.3.1", + "objc2-foundation 0.3.1", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle 0.6.2", + "reqwest 0.13.2", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.12", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.61.3", +] + +[[package]] +name = "tauri-build" +version = "2.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 6.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.7", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.90", + "tauri-utils", + "thiserror 2.0.12", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.90", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.7", + "walkdir", +] + +[[package]] +name = "tauri-plugin-appload" +version = "0.1.0" +source = "git+https://github.com/CuriousCorrelation/tauri-plugin-appload?rev=9d4528be4f385bccbe46859631d31aa2ee1ec0b6#9d4528be4f385bccbe46859631d31aa2ee1ec0b6" +dependencies = [ + "base64 0.22.1", + "blake3", + "bon", + "chrono", + "cocoa 0.26.0", + "dashmap", + "dunce", + "ed25519-dalek", + "flate2", + "futures", + "hex", + "hex_color", + "humantime-serde", + "lru", + "mime-infer", + "mime_guess", + "objc", + "rand 0.8.5", + "rayon", + "regex", + "reqwest 0.12.9", + "serde", + "serde_json", + "sysinfo", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", + "tokio", + "tracing", + "url", + "windows 0.58.0", + "winver", + "zip", +] + +[[package]] +name = "tauri-plugin-deep-link" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35d51ffd286073414d26353bcfc9e83e3cd63f96fa7f7a912f92f2118e5de5a6" +dependencies = [ + "dunce", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.12", + "tracing", + "url", + "windows-registry 0.3.0", + "windows-result 0.2.0", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b59fd750551b1066744ab956a1cd6b1ea3e1b3763b0b9153ac27a044d596426" +dependencies = [ + "log", + "raw-window-handle 0.6.2", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.12", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1edf18000f02903a7c2e5997fb89aca455ecbc0acc15c6535afbb883be223" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.12", + "toml 0.8.2", + "url", + "uuid", +] + +[[package]] +name = "tauri-plugin-http" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "696ef548befeee6c6c17b80ef73e7c41205b6c2204e87ef78ccc231212389a5c" +dependencies = [ + "data-url", + "http", + "regex", + "reqwest 0.12.9", + "schemars", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.12", + "tokio", + "url", + "urlpattern", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635ed7c580dc3cdc61c94097d38ef517d749ffc0141c806d904e68e4b0cf1c2a" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "open", + "schemars", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", + "url", + "windows 0.58.0", + "zbus 5.4.0", +] + +[[package]] +name = "tauri-plugin-process" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40cc553ab29581c8c43dfa5fb0c9d5aee8ba962ad3b42908eea26c79610441b7" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-relay" +version = "0.1.0" +source = "git+https://github.com/CuriousCorrelation/tauri-plugin-relay?rev=273488c8f50a22ee707af6b50ccd5570851f8bc9#273488c8f50a22ee707af6b50ccd5570851f8bc9" +dependencies = [ + "relay", + "serde", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", + "tracing", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", + "tokio", +] + +[[package]] +name = "tauri-plugin-store" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c0c08fae6995909f5e9a0da6038273b750221319f2c0f3b526d6de1cde21505" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", + "tokio", + "tracing", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2d39224390c41ba544f02b4f1721f42256320b3fb8c371e9425cbddeb4a68c" +dependencies = [ + "base64 0.22.1", + "dirs 5.0.1", + "flate2", + "futures-util", + "http", + "infer 0.16.0", + "minisign-verify", + "percent-encoding", + "reqwest 0.12.9", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.12", + "time", + "tokio", + "url", + "windows-sys 0.59.0", + "zip", +] + +[[package]] +name = "tauri-plugin-window-state" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e344b512b0d99d9d06225f235d87d6c66d89496a3bf323d9b578d940596e6c" +dependencies = [ + "bitflags 2.6.0", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", +] + +[[package]] +name = "tauri-runtime" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2 0.6.2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle 0.6.2", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.12", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2 0.6.2", + "objc2-app-kit 0.3.1", + "once_cell", + "percent-encoding", + "raw-window-handle 0.6.2", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever", + "http", + "infer 0.19.0", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.2", + "proc-macro2", + "quote", + "regex", + "schemars", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.12", + "toml 0.9.7", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd21509dd1fa9bd355dc29894a6ff10635880732396aa38c0066c1e6c1ab8074" +dependencies = [ + "embed-resource", + "toml 0.9.7", +] + +[[package]] +name = "tempfile" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix 1.0.8", + "windows-sys 0.60.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "time" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022db8904dfa342efe721985167e9fcd16c29b226db4397ed752a761cfce81e8" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.5.8", + "tokio-macros", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.8", + "toml_datetime 0.6.11", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" +dependencies = [ + "indexmap 2.11.4", + "serde_core", + "serde_spanned 1.0.2", + "toml_datetime 0.7.2", + "toml_parser", + "toml_writer", + "winnow 0.7.13", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.11.4", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.11.4", + "serde", + "serde_spanned 0.6.8", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.11.4", + "toml_datetime 0.6.11", + "winnow 0.7.13", +] + +[[package]] +name = "toml_parser" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +dependencies = [ + "winnow 0.7.13", +] + +[[package]] +name = "toml_writer" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.6.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror 1.0.69", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tray-icon" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2 0.6.2", + "objc2-app-kit 0.3.1", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.1", + "once_cell", + "png", + "serde", + "thiserror 2.0.12", + "windows-sys 0.59.0", +] + +[[package]] +name = "trim-in-place" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e13db2e0ccd5e14a544e8a246ba2312cd25223f616442d7f2cb0e3db614236e" + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df" + +[[package]] +name = "unicode-ident" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "url-escape" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44e0ce4d1246d075ca5abec4b41d33e87a6054d08e2366b63205665e950db218" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +dependencies = [ + "getrandom 0.2.15", + "serde", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "versions" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c73a36bc44e3039f51fbee93e39f41225f6b17b380eb70cc2aab942df06b34dd" +dependencies = [ + "itertools", + "nom", +] + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b17ae1f6c8a2b28506cd96d412eebf83b4a0ff2cbefeeb952f2f9dfa44ba18" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.90", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "056535ced7a150d45159d3a8dc30f91a2e2d588ca0b23f70e56033622b8016f6" +dependencies = [ + "cc", + "downcast-rs", + "rustix 0.38.42", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66249d3fc69f76fd74c82cc319300faa554e9d865dab1f7cd66cc20db10b280" +dependencies = [ + "bitflags 2.6.0", + "rustix 0.38.42", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd0ade57c4e6e9a8952741325c30bf82f4246885dca8bf561898b86d0c1f58e" +dependencies = [ + "bitflags 2.6.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597f2001b2e5fc1121e3d5b9791d3e78f05ba6bfa4641053846248e3a13661c3" +dependencies = [ + "proc-macro2", + "quick-xml 0.36.2", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa8ac0d8e8ed3e3b5c9fc92c7881406a268e11555abe36493efabe649a29e09" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "0.26.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement 0.60.0", + "windows-interface 0.59.1", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" +dependencies = [ + "thiserror 2.0.12", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "wfd" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e713040b67aae5bf1a0ae3e1ebba8cc29ab2b90da9aa1bff6e09031a8a41d7a8" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.42", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2 0.6.2", + "objc2-app-kit 0.3.1", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "raw-window-handle 0.6.2", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.0", + "windows-interface 0.59.1", + "windows-link", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +dependencies = [ + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-registry" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bafa604f2104cf5ae2cc2db1dee84b7e6a5d11b05f737b60def0ffdc398cbc0a" +dependencies = [ + "windows-result 0.2.0", + "windows-strings 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978d65aedf914c664c510d9de43c8fd85ca745eaff1ed53edf409b479e441663" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-version" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998aa457c9ba8ff2fb9f13e9d2a930dabcea28f1d0ab94d687d8b3654844515" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winver" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e0e7162b9e282fd75a0a832cce93994bdb21208d848a418cd05a5fdd9b9ab33" +dependencies = [ + "windows 0.48.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.6.0", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "wry" +version = "0.54.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb26159b420aa77684589a744ae9a9461a95395b848764ad12290a14d960a11a" +dependencies = [ + "base64 0.22.1", + "block2 0.6.1", + "cookie", + "crossbeam-channel", + "dirs 6.0.0", + "dpi", + "dunce", + "gdkx11", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "ndk", + "objc2 0.6.2", + "objc2-app-kit 0.3.1", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle 0.6.2", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.12", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e105d177a3871454f754b33bb0ee637ecaaac997446375fd3e5d43a2ed00c909" +dependencies = [ + "libc", + "linux-raw-sys 0.4.14", + "rustix 0.38.42", +] + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b8e3d6ae3342792a6cc2340e4394334c7402f3d793b390d2c5494a4032b3030" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "derivative", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.27.1", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tokio", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.0.1", + "zbus_names 3.0.0", + "zvariant 4.0.0", +] + +[[package]] +name = "zbus" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbddd8b6cb25d5d8ec1b23277b45299a98bfb220f1761ca11e186d5c702507f8" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "serde", + "serde_repr", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.59.0", + "winnow 0.7.13", + "xdg-home", + "zbus_macros 5.4.0", + "zbus_names 4.2.0", + "zvariant 5.6.0", +] + +[[package]] +name = "zbus_macros" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a3e850ff1e7217a3b7a07eba90d37fe9bb9e89a310f718afcde5885ca9b6d7" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "zvariant_utils 1.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac404d48b4e9cf193c8b49589f3280ceca5ff63519e7e64f55b4cf9c47ce146" +dependencies = [ + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "syn 2.0.90", + "zbus_names 4.2.0", + "zvariant 5.6.0", + "zvariant_utils 3.2.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.0.0", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow 0.7.13", + "zvariant 5.6.0", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "zip" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae9c1ea7b3a5e1f4b922ff856a129881167511563dc219869afe3787fc0c1a45" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "hmac", + "indexmap 2.11.4", + "lzma-rs", + "memchr", + "pbkdf2", + "rand 0.8.5", + "sha1", + "thiserror 2.0.12", + "time", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a3ab4db68cea366acc5c897c7b4d4d1b8994a9cd6e6f841f8964566a419059" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.13+zstd.1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38ff0f21cfee8f97d94cef41359e0c89aa6113028ab0291aa8ca0038995a95aa" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zvariant" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e09e8be97d44eeab994d752f341e67b3b0d80512a8b315a0671d47232ef1b65" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "url", + "zvariant_derive 4.0.0", +] + +[[package]] +name = "zvariant" +version = "5.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91b3680bb339216abd84714172b5138a4edac677e641ef17e1d8cb1b3ca6e6f" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.13", + "zvariant_derive 5.6.0", + "zvariant_utils 3.2.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a5857e2856435331636a9fbb415b09243df4521a267c5bedcd5289b4d5799e" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", + "zvariant_utils 1.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8c68501be459a8dbfffbe5d792acdd23b4959940fc87785fb013b32edbc208" +dependencies = [ + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "syn 2.0.90", + "zvariant_utils 3.2.0", +] + +[[package]] +name = "zvariant_utils" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bedb16a193cc12451873fee2a1bc6550225acece0e36f333e68326c73c8172" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "zvariant_utils" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16edfee43e5d7b553b77872d99bc36afdda75c223ca7ad5e3fbecd82ca5fc34" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "static_assertions", + "syn 2.0.90", + "winnow 0.7.13", +] diff --git a/packages/hoppscotch-desktop/src-tauri/Cargo.toml b/packages/hoppscotch-desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..7e11448 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "hoppscotch-desktop" +version = "26.6.0" +description = "Desktop App for hoppscotch.io" +authors = ["CuriousCorrelation"] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +# The `_lib` suffix may seem redundant but it is necessary +# to make the lib name unique and wouldn't conflict with the bin name. +# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 +name = "hoppscotch_desktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["devtools"] } +tauri-plugin-shell = { version = "2.3.3", features = [] } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = [] } +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +tracing-appender = { version = "0.2.3" } +tauri-plugin-store = "2.2.0" +tauri-plugin-dialog = "2.2.0" +tauri-plugin-fs = "2.2.0" +tauri-plugin-deep-link = "2.2.0" +tauri-plugin-appload = { git = "https://github.com/CuriousCorrelation/tauri-plugin-appload", rev = "9d4528be4f385bccbe46859631d31aa2ee1ec0b6" } +tauri-plugin-relay = { git = "https://github.com/CuriousCorrelation/tauri-plugin-relay", rev = "273488c8f50a22ee707af6b50ccd5570851f8bc9" } +axum = "0.8.1" +tower-http = { version = "0.6.2", features = ["cors"] } +random-port = "0.1.1" +tokio = "1.43.0" +tauri-plugin-process = "2.2.0" +file-rotate = "0.8.0" +dirs = "6.0.0" +thiserror = "2.0.12" +native-dialog = { version = "0.7.0" } +tauri-plugin-http = { version = "2.0.1", features = ["gzip"] } +tauri-plugin-opener = "2" +semver = "1.0" + +[dev-dependencies] +tempfile = "3.20.0" + +[target.'cfg(windows)'.dependencies] +tempfile = { version = "3.13.0" } +winreg = { version = "0.52.0" } + +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri-plugin-updater = "2.3.1" +tauri-plugin-window-state = "2.2.1" + +[features] +default = [] +portable = [] diff --git a/packages/hoppscotch-desktop/src-tauri/build.rs b/packages/hoppscotch-desktop/src-tauri/build.rs new file mode 100644 index 0000000..0d2e590 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/build.rs @@ -0,0 +1,20 @@ +fn main() { + #[cfg(all(feature = "portable", target_os = "windows"))] + { + println!("cargo:rerun-if-changed=tauri.portable.windows.conf.json"); + println!("cargo:rustc-env=TAURI_CONFIG_FILE=tauri.portable.windows.conf.json"); + } + + #[cfg(all(feature = "portable", target_os = "macos"))] + { + println!("cargo:rerun-if-changed=tauri.portable.macos.conf.json"); + println!("cargo:rustc-env=TAURI_CONFIG_FILE=tauri.portable.macos.conf.json"); + } + + #[cfg(not(feature = "portable"))] + { + println!("cargo:rerun-if-changed=tauri.conf.json"); + } + + tauri_build::build() +} diff --git a/packages/hoppscotch-desktop/src-tauri/capabilities/README.md b/packages/hoppscotch-desktop/src-tauri/capabilities/README.md new file mode 100644 index 0000000..aae2632 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/capabilities/README.md @@ -0,0 +1,44 @@ +# Capabilities Configuration + +## Why wildcards are used in default.json + +The `default.json` capability configuration uses wildcards for windows, webviews, and remote URLs: + +```json +{ + "windows": ["*"], + "webviews": ["*"], + "remote": { + "urls": ["app://*"] + } +} +``` + +### Rationale + +**Cloud for Orgs Support**: The desktop app supports multi-tenancy where organizations get their own isolated contexts via dynamic hostnames. When a user switches to organization "acme", a new webview is created with URL `app://acme_hoppscotch_io/`. Since organization names are dynamic and user-defined, we cannot enumerate all possible window/webview labels or `app://` origins at build time. + +**Security Considerations**: + +1. **`app://` protocol is sandboxed**: The `app://` protocol is entirely handled by the tauri-plugin-appload plugin. External websites cannot inject content into this namespace. Only content served from the local bundle cache is accessible via `app://` URLs. + +2. **No cross-origin access**: Each `app://` origin is isolated. A webview at `app://acme_hoppscotch_io/` cannot access content from `app://beta_hoppscotch_io/`. + +3. **IPC commands are validated**: Tauri commands validate their inputs. The wildcard permission allows IPC calls from any `app://` origin, but the commands themselves enforce authorization. + +**Alternatives Considered**: + +- **Explicit patterns like `Hoppscotch-*`**: Tauri's capability system doesn't support glob patterns for window names in all contexts. +- **Pattern matching like `app://*_hoppscotch_io`**: Would require maintaining a list of allowed suffixes and wouldn't handle custom deployments. + +### Previous Configuration + +Before cloud-for-orgs support, the configuration used explicit window names: + +```json +{ + "windows": ["main", "Hoppscotch-curr", "Hoppscotch-next"] +} +``` + +This was more restrictive but incompatible with dynamic organization subdomains. diff --git a/packages/hoppscotch-desktop/src-tauri/capabilities/default.json b/packages/hoppscotch-desktop/src-tauri/capabilities/default.json new file mode 100644 index 0000000..0e74597 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/capabilities/default.json @@ -0,0 +1,43 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window and all app:// origins", + "windows": ["*"], + "webviews": ["*"], + "remote": { + "urls": ["app://*"] + }, + "permissions": [ + "core:default", + "core:window:default", + "core:window:allow-start-dragging", + "core:event:default", + "core:path:default", + "core:webview:default", + "core:webview:allow-set-webview-zoom", + "shell:allow-open", + "store:default", + "dialog:default", + "process:default", + "updater:default", + "fs:allow-copy-file", + "fs:allow-remove", + "fs:allow-exists", + "fs:allow-read-file", + "fs:allow-read-dir", + "fs:allow-write-file", + "fs:allow-write-text-file", + { + "identifier": "fs:scope", + "allow": [ + { "path": "$APPCONFIG" }, + { "path": "$APPCONFIG/**" }, + { "path": "$APPDATA" }, + { "path": "$APPDATA/**" } + ] + }, + "deep-link:default", + "appload:default", + "relay:default" + ] +} diff --git a/packages/hoppscotch-desktop/src-tauri/capabilities/desktop.json b/packages/hoppscotch-desktop/src-tauri/capabilities/desktop.json new file mode 100644 index 0000000..71ca4a1 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/capabilities/desktop.json @@ -0,0 +1,12 @@ +{ + "identifier": "desktop-capability", + "platforms": [ + "macOS", + "windows", + "linux" + ], + "permissions": [ + "updater:default", + "window-state:default" + ] +} \ No newline at end of file diff --git a/packages/hoppscotch-desktop/src-tauri/icons/128x128.png b/packages/hoppscotch-desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000..823d9e7 Binary files /dev/null and b/packages/hoppscotch-desktop/src-tauri/icons/128x128.png differ diff --git a/packages/hoppscotch-desktop/src-tauri/icons/128x128@2x.png b/packages/hoppscotch-desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..4a4eaed Binary files /dev/null and b/packages/hoppscotch-desktop/src-tauri/icons/128x128@2x.png differ diff --git a/packages/hoppscotch-desktop/src-tauri/icons/32x32.png b/packages/hoppscotch-desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000..219f660 Binary files /dev/null and b/packages/hoppscotch-desktop/src-tauri/icons/32x32.png differ diff --git a/packages/hoppscotch-desktop/src-tauri/icons/Square107x107Logo.png b/packages/hoppscotch-desktop/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..8184c70 Binary files /dev/null and b/packages/hoppscotch-desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/packages/hoppscotch-desktop/src-tauri/icons/Square142x142Logo.png b/packages/hoppscotch-desktop/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..ff62875 Binary files /dev/null and b/packages/hoppscotch-desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/packages/hoppscotch-desktop/src-tauri/icons/Square150x150Logo.png b/packages/hoppscotch-desktop/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..ee3f87f Binary files /dev/null and b/packages/hoppscotch-desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/packages/hoppscotch-desktop/src-tauri/icons/Square284x284Logo.png b/packages/hoppscotch-desktop/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..94f6eeb Binary files /dev/null and b/packages/hoppscotch-desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/packages/hoppscotch-desktop/src-tauri/icons/Square30x30Logo.png b/packages/hoppscotch-desktop/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..d6a892a Binary files /dev/null and b/packages/hoppscotch-desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/packages/hoppscotch-desktop/src-tauri/icons/Square310x310Logo.png b/packages/hoppscotch-desktop/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..cebe63f Binary files /dev/null and b/packages/hoppscotch-desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/packages/hoppscotch-desktop/src-tauri/icons/Square44x44Logo.png b/packages/hoppscotch-desktop/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..9f7a5c5 Binary files /dev/null and b/packages/hoppscotch-desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/packages/hoppscotch-desktop/src-tauri/icons/Square71x71Logo.png b/packages/hoppscotch-desktop/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..56f0613 Binary files /dev/null and b/packages/hoppscotch-desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/packages/hoppscotch-desktop/src-tauri/icons/Square89x89Logo.png b/packages/hoppscotch-desktop/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..11ebad0 Binary files /dev/null and b/packages/hoppscotch-desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/packages/hoppscotch-desktop/src-tauri/icons/StoreLogo.png b/packages/hoppscotch-desktop/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..2b9ce6e Binary files /dev/null and b/packages/hoppscotch-desktop/src-tauri/icons/StoreLogo.png differ diff --git a/packages/hoppscotch-desktop/src-tauri/icons/icon.icns b/packages/hoppscotch-desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000..11df6ab Binary files /dev/null and b/packages/hoppscotch-desktop/src-tauri/icons/icon.icns differ diff --git a/packages/hoppscotch-desktop/src-tauri/icons/icon.ico b/packages/hoppscotch-desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000..cc9a3cf Binary files /dev/null and b/packages/hoppscotch-desktop/src-tauri/icons/icon.ico differ diff --git a/packages/hoppscotch-desktop/src-tauri/icons/icon.png b/packages/hoppscotch-desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000..7209bbf Binary files /dev/null and b/packages/hoppscotch-desktop/src-tauri/icons/icon.png differ diff --git a/packages/hoppscotch-desktop/src-tauri/src/backup.rs b/packages/hoppscotch-desktop/src-tauri/src/backup.rs new file mode 100644 index 0000000..eff75b0 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/src/backup.rs @@ -0,0 +1,425 @@ +use std::fs; +use std::path::PathBuf; + +use semver::Version; +use tauri::{AppHandle, Runtime}; + +use crate::{error::HoppError, path}; + +const MAX_BACKUP_COUNT: usize = 3; + +#[tauri::command] +pub async fn check_and_backup_on_version_change( + app: AppHandle, +) -> Result<(), String> { + let handle = app.clone(); + match tauri::async_runtime::spawn_blocking(move || perform_version_check_and_backup(handle)) + .await + { + Ok(Ok(_)) => Ok(()), + Ok(Err(e)) => { + tracing::error!(error = %e, "Backup operation failed"); + Err(e.to_string()) + } + Err(e) => { + tracing::error!(error = %e, "Backup task panicked"); + Err("Backup operation panicked".to_string()) + } + } +} + +pub fn perform_version_check_and_backup(app: AppHandle) -> Result<(), HoppError> { + let current_version = get_current_app_version(&app)?; + + tracing::info!( + current_version = %current_version, + "Version check initiated" + ); + + if !backup_exists_for_version(¤t_version)? { + tracing::info!( + version = %current_version, + "No backup found for current version, creating backup" + ); + + backup_current_data(¤t_version)?; + cleanup_old_backups()?; + + tracing::info!("Backup operation completed successfully"); + } else { + tracing::debug!( + version = %current_version, + "Backup already exists for current version, skipping" + ); + } + + Ok(()) +} + +fn get_current_app_version(app: &AppHandle) -> Result { + let version_str = app.package_info().version.to_string(); + Version::parse(&version_str).map_err(|e| { + tracing::error!( + version_string = %version_str, + error = %e, + "Failed to parse current app version" + ); + HoppError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid version format: {}", version_str), + )) + }) +} + +fn backup_exists_for_version(version: &Version) -> Result { + let backup_dir = path::backup_dir()?; + let version_backup_dir = backup_dir.join(format!("backup-by-v{}", version)); + + Ok(version_backup_dir.exists()) +} + +fn backup_current_data(current_version: &Version) -> Result<(), HoppError> { + let config_dir = path::config_dir()?; + let backup_dir = path::backup_dir()?; + let version_backup_dir = backup_dir.join(format!("backup-by-v{}", current_version)); + + if !config_dir.exists() { + tracing::warn!("Config directory doesn't exist, skipping backup"); + return Ok(()); + } + + tracing::info!( + source = %config_dir.display(), + target = %version_backup_dir.display(), + "Starting full config directory backup" + ); + + fs::create_dir_all(&version_backup_dir)?; + + // NOTE: This copies all contents of `config_dir` to `version_backup_dir`, + // but excludes the `backup` and `latest` directories to avoid infinite recursion + // and prevent backing up the current working data that might be in flux. + copy_directory_contents_excluding_special_dirs(&config_dir, &version_backup_dir, &backup_dir)?; + + tracing::info!( + target = %version_backup_dir.display(), + "Full config backup completed successfully" + ); + + Ok(()) +} + +fn copy_directory_contents_excluding_special_dirs( + src: &PathBuf, + dst: &PathBuf, + backup_dir: &PathBuf, +) -> Result<(), HoppError> { + if !src.exists() { + return Ok(()); + } + + let latest_dir = match path::latest_dir() { + Ok(dir) => Some(dir), + Err(e) => { + tracing::warn!(error = %e, "Failed to get latest directory path"); + None + } + }; + + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if src_path == *backup_dir { + tracing::debug!( + path = %src_path.display(), + "Skipping backup directory to avoid recursion" + ); + continue; + } + + if let Some(ref latest_dir) = latest_dir { + if src_path == *latest_dir { + tracing::debug!( + path = %src_path.display(), + "Skipping latest directory to avoid backing up current working data" + ); + continue; + } + } + + if src_path.is_dir() { + copy_directory_recursive(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path)?; + } + } + + Ok(()) +} + +fn copy_directory_recursive(src: &PathBuf, dst: &PathBuf) -> Result<(), HoppError> { + if !src.exists() { + return Ok(()); + } + + fs::create_dir_all(dst)?; + + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if src_path.is_dir() { + copy_directory_recursive(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path)?; + } + } + + Ok(()) +} + +fn parse_version_from_backup_dirname(dirname: &str) -> Option { + // Parse "backup-by-v1.2.3" format + if let Some(version_part) = dirname.strip_prefix("backup-by-v") { + Version::parse(version_part).ok() + } else { + None + } +} + +fn cleanup_old_backups() -> Result<(), HoppError> { + let backup_dir = path::backup_dir()?; + + if !backup_dir.exists() { + return Ok(()); + } + + let entries = fs::read_dir(&backup_dir)?; + let mut version_paths = Vec::new(); + + for entry in entries { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + if let Some(dirname) = path.file_name().and_then(|n| n.to_str()) { + if let Some(version) = parse_version_from_backup_dirname(dirname) { + version_paths.push((version, path)); + } + } + } + } + + if version_paths.len() <= MAX_BACKUP_COUNT { + return Ok(()); + } + + version_paths.sort_by(|a, b| a.0.cmp(&b.0)); + + let to_remove_count = version_paths.len() - MAX_BACKUP_COUNT; + let to_remove = &version_paths[..to_remove_count]; + + for (version, path) in to_remove { + tracing::info!( + version = %version, + path = %path.display(), + "Removing old backup" + ); + + match fs::remove_dir_all(path) { + Ok(_) => { + tracing::debug!( + version = %version, + "Successfully removed old backup" + ); + } + Err(e) => { + tracing::warn!( + version = %version, + error = %e, + "Failed to remove old backup, continuing" + ); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_parse_version_from_backup_dirname() { + // Valid cases + assert_eq!( + parse_version_from_backup_dirname("backup-by-v1.2.3"), + Some(Version::new(1, 2, 3)) + ); + assert_eq!( + parse_version_from_backup_dirname("backup-by-v10.0.0"), + Some(Version::new(10, 0, 0)) + ); + assert_eq!( + parse_version_from_backup_dirname("backup-by-v2.1.0-beta.1"), + Version::parse("2.1.0-beta.1").ok() + ); + + // Invalid cases + assert_eq!(parse_version_from_backup_dirname("backup-v1.2.3"), None); + assert_eq!(parse_version_from_backup_dirname("v1.2.3"), None); + assert_eq!(parse_version_from_backup_dirname("backup-by-v"), None); + assert_eq!( + parse_version_from_backup_dirname("backup-by-vinvalid"), + None + ); + assert_eq!(parse_version_from_backup_dirname(""), None); + assert_eq!(parse_version_from_backup_dirname("random-dir"), None); + } + + #[test] + fn test_max_backup_count_constant() { + assert_eq!(MAX_BACKUP_COUNT, 3); + } + + #[test] + fn test_copy_directory_recursive_empty_dir() { + let temp_dir = TempDir::new().unwrap(); + let src = temp_dir.path().join("src"); + let dst = temp_dir.path().join("dst"); + + fs::create_dir_all(&src).unwrap(); + + let result = copy_directory_recursive(&src, &dst); + assert!(result.is_ok()); + assert!(dst.exists()); + assert!(dst.is_dir()); + } + + #[test] + fn test_copy_directory_recursive_with_files() { + let temp_dir = TempDir::new().unwrap(); + let src = temp_dir.path().join("src"); + let dst = temp_dir.path().join("dst"); + + fs::create_dir_all(&src).unwrap(); + fs::write(src.join("test.txt"), "test content").unwrap(); + fs::create_dir_all(src.join("subdir")).unwrap(); + fs::write(src.join("subdir").join("nested.txt"), "nested content").unwrap(); + + let result = copy_directory_recursive(&src, &dst); + assert!(result.is_ok()); + + assert!(dst.exists()); + assert!(dst.join("test.txt").exists()); + assert!(dst.join("subdir").exists()); + assert!(dst.join("subdir").join("nested.txt").exists()); + + let content = fs::read_to_string(dst.join("test.txt")).unwrap(); + assert_eq!(content, "test content"); + + let nested_content = fs::read_to_string(dst.join("subdir").join("nested.txt")).unwrap(); + assert_eq!(nested_content, "nested content"); + } + + #[test] + fn test_copy_directory_recursive_nonexistent_src() { + let temp_dir = TempDir::new().unwrap(); + let src = temp_dir.path().join("nonexistent"); + let dst = temp_dir.path().join("dst"); + + let result = copy_directory_recursive(&src, &dst); + assert!(result.is_ok()); // Should return Ok for nonexistent source + assert!(!dst.exists()); // Destination should not be created + } + + #[test] + fn test_version_sorting_in_cleanup() { + let versions = vec![ + Version::new(1, 0, 0), + Version::new(2, 1, 0), + Version::new(1, 5, 0), + Version::new(2, 0, 0), + ]; + + let mut version_paths: Vec<(Version, PathBuf)> = versions + .into_iter() + .map(|v| { + ( + v.clone(), + PathBuf::from(format!("backup-by-v{}", v.clone())), + ) + }) + .collect(); + + version_paths.sort_by(|a, b| a.0.cmp(&b.0)); + + // Should be sorted: 1.0.0, 1.5.0, 2.0.0, 2.1.0 + assert_eq!(version_paths[0].0, Version::new(1, 0, 0)); + assert_eq!(version_paths[1].0, Version::new(1, 5, 0)); + assert_eq!(version_paths[2].0, Version::new(2, 0, 0)); + assert_eq!(version_paths[3].0, Version::new(2, 1, 0)); + } + + #[test] + fn test_cleanup_old_backups_integration() { + let temp_dir = TempDir::new().unwrap(); + let backup_root = temp_dir.path(); + + let backup_dirs = vec![ + "backup-by-v1.0.0", + "backup-by-v1.1.0", + "backup-by-v1.2.0", + "backup-by-v2.0.0", + "backup-by-v2.1.0", // This should be kept (newest 3) + "backup-by-v2.2.0", // This should be kept + "backup-by-v3.0.0", // This should be kept + "not-a-backup-dir", // This should be ignored + ]; + + for dir in &backup_dirs { + fs::create_dir_all(backup_root.join(dir)).unwrap(); + } + + let entries = fs::read_dir(backup_root).unwrap(); + let mut version_paths = Vec::new(); + + for entry in entries { + let entry = entry.unwrap(); + let path = entry.path(); + + if path.is_dir() { + if let Some(dirname) = path.file_name().and_then(|n| n.to_str()) { + if let Some(version) = parse_version_from_backup_dirname(dirname) { + version_paths.push((version, path)); + } + } + } + } + + version_paths.sort_by(|a, b| a.0.cmp(&b.0)); + + // Should have 7 valid backup directories (excluding "not-a-backup-dir") + assert_eq!(version_paths.len(), 7); + + // If MAX_BACKUP_COUNT is 3, we should remove 4 directories + let should_remove = version_paths.len() > MAX_BACKUP_COUNT; + assert!(should_remove); + + if should_remove { + let to_remove_count = version_paths.len() - MAX_BACKUP_COUNT; + assert_eq!(to_remove_count, 4); + + // The oldest versions should be marked for removal + assert_eq!(version_paths[0].0, Version::new(1, 0, 0)); + assert_eq!(version_paths[1].0, Version::new(1, 1, 0)); + assert_eq!(version_paths[2].0, Version::new(1, 2, 0)); + assert_eq!(version_paths[3].0, Version::new(2, 0, 0)); + } + } +} diff --git a/packages/hoppscotch-desktop/src-tauri/src/config.rs b/packages/hoppscotch-desktop/src-tauri/src/config.rs new file mode 100644 index 0000000..c3b7aa4 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/src/config.rs @@ -0,0 +1,239 @@ +use std::{fs, path::PathBuf, sync::Mutex, time::Duration}; + +use serde::Deserialize; +use tauri_plugin_appload::{ApiConfig, CacheConfig, Config, StorageConfig, VendorConfig}; + +use crate::{error::HoppError, path}; + +// Appload plugin configuration. These constants are baked into the plugin +// config at startup via `HoppApploadConfig::build()`, before the webview +// exists, so they cannot be overridden by runtime user settings. A future +// user-facing connection timeout override will need a separate mechanism, +// either a startup-time store file read or a deferred appload init. +const API_SERVER_URL: &str = "http://localhost:3200"; +const API_TIMEOUT_SECS: u64 = 30; +const CACHE_MAX_SIZE_MB: usize = 1000; +const CACHE_FILE_TTL_SECS: u64 = 3600; +const CACHE_HOT_RATIO: f32 = 0.9; +const MAX_BUNDLE_SIZE_MB: usize = 50; + +pub struct HoppApploadConfig { + bundle_path: PathBuf, + manifest_path: PathBuf, + config_dir: PathBuf, +} + +impl HoppApploadConfig { + pub fn new() -> Result { + let config_dir = path::config_dir().unwrap_or_else(|e| { + tracing::error!(error = %e, "Failed to create config directory, using temp dir"); + std::env::temp_dir().join(path::APP_ID) + }); + + let bundle_path = path::bundle_path(); + let manifest_path = path::manifest_path(); + + Ok(Self { + bundle_path, + manifest_path, + config_dir, + }) + } + + pub fn write_vendored(&self) -> Result<(), HoppError> { + fs::write(&self.bundle_path, include_bytes!("../../bundle.zip"))?; + fs::write(&self.manifest_path, include_bytes!("../../manifest.json"))?; + Ok(()) + } + + pub fn build(&self) -> Config { + Config::builder() + .api(ApiConfig { + server_url: API_SERVER_URL.to_string(), + timeout: Duration::from_secs(API_TIMEOUT_SECS), + }) + .cache(CacheConfig { + max_size: CACHE_MAX_SIZE_MB * 1024 * 1024, + file_ttl: Duration::from_secs(CACHE_FILE_TTL_SECS), + hot_ratio: CACHE_HOT_RATIO, + }) + .storage(StorageConfig { + root_dir: self.config_dir.clone(), + max_bundle_size: MAX_BUNDLE_SIZE_MB * 1024 * 1024, + }) + .vendor(VendorConfig { + bundle_path: self.bundle_path.clone(), + manifest_path: self.manifest_path.clone(), + }) + .log_dir( + path::logs_dir().unwrap_or_else(|_| std::env::temp_dir()), + ) + .build() + } +} + +// Webview-pushed runtime settings bridge. +// +// The webview persists user settings (timeout, zoom, auto-reconnect, and so +// on) via `tauri-plugin-store`. The Tauri shell needs live access to some +// of those values, for example `connectionTimeoutMs` for the appload HTTP +// client. Rather than having Rust read the store file directly, which would +// couple this code to the plugin's on-disk format, the webview pushes the +// current settings to Rust via `set_desktop_config` at init and on change. +// +// The IPC plumbing is wired end-to-end but no Rust code reads +// `DESKTOP_CONFIG` yet. Consumers such as the appload connection timeout +// are future scope. +// +// The struct deliberately only deserializes fields Rust actually consumes. +// TS sends the full `DESKTOP_SETTINGS_SCHEMA` payload and serde drops the +// rest. Adding a new Rust consumer means adding a field here, not changing +// the IPC contract. + +/// Subset of the webview-side `DesktopSettings` that Rust services consume. +/// +/// Field names are snake_case with `rename_all = "camelCase"` so they line +/// up with what the TS store produces from `DESKTOP_SETTINGS_SCHEMA`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopConfig { + /// Timeout (ms) for outbound HTTP requests in the appload client and + /// related connection paths. Mirrors `API_TIMEOUT_SECS` when the value + /// is 30_000. + pub connection_timeout_ms: u64, +} + +/// Live copy of the most recent settings pushed from the webview. +/// +/// `None` means the webview has not called `set_desktop_config` yet, which +/// is the case during the early Tauri startup path before the window loads +/// and for the whole of the pre-webview `PortableHome` and `StandardHome` +/// flow. Consumers must treat `None` as "no override, use the compile-time +/// default". +static DESKTOP_CONFIG: Mutex> = Mutex::new(None); + +/// Returns a clone of the most recent settings pushed from the webview, or +/// `None` if nothing has been pushed yet. +/// +/// Cloning keeps the lock scope short, which is cheap because +/// `DesktopConfig` is a small POD struct. +#[allow(dead_code)] // no Rust consumers yet, see module doc above. +pub fn current_desktop_config() -> Option { + DESKTOP_CONFIG + .lock() + .ok() + .and_then(|guard| guard.clone()) +} + +/// Tauri command invoked by the webview on init and whenever settings +/// change. Overwrites any previously-pushed config and is idempotent on +/// identical input. +#[tauri::command] +pub fn set_desktop_config(config: DesktopConfig) -> Result<(), String> { + tracing::debug!(?config, "Received desktop config from webview"); + let mut guard = DESKTOP_CONFIG + .lock() + .map_err(|e| format!("DESKTOP_CONFIG mutex poisoned: {}", e))?; + *guard = Some(config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_constants() { + // NOTE: These are rather pointless tests, but are here just in case + // there are rebase/merge conflicts that rewrites the values + // (since there's been quite a lot of experimentation on that front) + // so this created on a new branch shall remain consistent. + assert_eq!(API_SERVER_URL, "http://localhost:3200"); + assert_eq!(API_TIMEOUT_SECS, 30); + assert_eq!(CACHE_MAX_SIZE_MB, 1000); + assert_eq!(CACHE_FILE_TTL_SECS, 3600); + assert_eq!(CACHE_HOT_RATIO, 0.9); + assert_eq!(MAX_BUNDLE_SIZE_MB, 50); + } + + #[test] + fn test_hopp_appload_config_new() { + let config = HoppApploadConfig::new(); + assert!(config.is_ok()); + + let config = config.unwrap(); + assert!(config + .bundle_path + .to_string_lossy() + .contains("hopp_bundle.zip")); + assert!(config + .manifest_path + .to_string_lossy() + .contains("hopp_manifest.json")); + assert!(!config.config_dir.as_os_str().is_empty()); + } + + #[test] + fn test_config_paths() { + let config = HoppApploadConfig::new().unwrap(); + + assert_eq!( + config.bundle_path.file_name().unwrap().to_str().unwrap(), + "hopp_bundle.zip" + ); + + assert_eq!( + config.manifest_path.file_name().unwrap().to_str().unwrap(), + "hopp_manifest.json" + ); + + assert!(!config.config_dir.as_os_str().is_empty()); + } + + // The roundtrip and overwrite assertions stay in one test because + // `DESKTOP_CONFIG` is process-wide shared state and cargo runs tests + // in parallel by default. Splitting them into two `#[test]` functions + // would race for the global mutex and produce flaky assertions + // depending on schedule. The other tests in this module exercise + // `DesktopConfig` deserialization in isolation and never touch + // `DESKTOP_CONFIG`, so they are safe to run alongside this one. + #[test] + fn set_desktop_config_roundtrip_and_overwrite() { + let result = set_desktop_config(DesktopConfig { + connection_timeout_ms: 45_000, + }); + assert!(result.is_ok()); + assert_eq!( + current_desktop_config().unwrap().connection_timeout_ms, + 45_000 + ); + + set_desktop_config(DesktopConfig { + connection_timeout_ms: 90_000, + }) + .unwrap(); + assert_eq!( + current_desktop_config().unwrap().connection_timeout_ms, + 90_000 + ); + } + + #[test] + fn desktop_config_deserializes_from_camel_case() { + let json = r#"{"connectionTimeoutMs": 60000}"#; + let cfg: DesktopConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.connection_timeout_ms, 60_000); + } + + #[test] + fn desktop_config_deserialize_ignores_extra_fields() { + // TS pushes the full `DESKTOP_SETTINGS_SCHEMA` so extras must drop. + let json = r#"{ + "connectionTimeoutMs": 30000, + "disableUpdateNotifications": true, + "zoomLevel": 1.25 + }"#; + let cfg: DesktopConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.connection_timeout_ms, 30_000); + } +} diff --git a/packages/hoppscotch-desktop/src-tauri/src/dialog.rs b/packages/hoppscotch-desktop/src-tauri/src/dialog.rs new file mode 100644 index 0000000..e4967b4 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/src/dialog.rs @@ -0,0 +1,58 @@ +use native_dialog::{MessageDialog, MessageType}; + +pub fn panic(msg: &str) { + const FATAL_ERROR: &str = "Fatal error"; + + MessageDialog::new() + .set_type(MessageType::Error) + .set_title(FATAL_ERROR) + .set_text(msg) + .show_alert() + .unwrap_or_default(); + + tracing::error!("{}: {}", FATAL_ERROR, msg); + + panic!("{}: {}", FATAL_ERROR, msg); +} + +pub fn info(msg: &str) { + tracing::info!("{}", msg); + + MessageDialog::new() + .set_type(MessageType::Info) + .set_title("Info") + .set_text(msg) + .show_alert() + .unwrap_or_default(); +} + +pub fn warn(msg: &str) { + tracing::warn!("{}", msg); + + MessageDialog::new() + .set_type(MessageType::Warning) + .set_title("Warning") + .set_text(msg) + .show_alert() + .unwrap_or_default(); +} + +pub fn error(msg: &str) { + tracing::error!("{}", msg); + + MessageDialog::new() + .set_type(MessageType::Error) + .set_title("Error") + .set_text(msg) + .show_alert() + .unwrap_or_default(); +} + +pub fn confirm(title: &str, msg: &str, icon: MessageType) -> bool { + MessageDialog::new() + .set_type(icon) + .set_title(title) + .set_text(msg) + .show_confirm() + .unwrap_or_default() +} diff --git a/packages/hoppscotch-desktop/src-tauri/src/error.rs b/packages/hoppscotch-desktop/src-tauri/src/error.rs new file mode 100644 index 0000000..02474e0 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/src/error.rs @@ -0,0 +1,18 @@ +use std::io; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum HoppError { + #[error("IO error: {0}")] + Io(#[from] io::Error), + + #[error("Failed to initialize server port")] + ServerPortInitialization, + + #[error("Failed to emit event: {0}")] + EventEmission(String), + + #[error("Tauri error: {0}")] + Tauri(#[from] tauri::Error), +} diff --git a/packages/hoppscotch-desktop/src-tauri/src/lib.rs b/packages/hoppscotch-desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..db89d88 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/src/lib.rs @@ -0,0 +1,273 @@ +pub mod backup; +pub mod config; +pub mod dialog; +pub mod error; +pub mod logger; +pub mod path; +pub mod server; +pub mod updater; +pub mod util; +pub mod webview; + +use std::sync::OnceLock; + +use tauri::Emitter; +use tauri_plugin_deep_link::DeepLinkExt; +use tauri_plugin_window_state::StateFlags; + +use random_port::{PortPicker, Protocol}; + +use config::HoppApploadConfig; +use error::HoppError; + +static SERVER_PORT: OnceLock = OnceLock::new(); + +#[tauri::command] +fn is_portable() -> bool { + cfg!(feature = "portable") +} + +#[tauri::command] +fn hopp_auth_port() -> u16 { + SERVER_PORT + .get() + .copied() + .expect("Server port not initialized") +} + +fn setup_deep_link_handler(app: &tauri::App) -> Result<(), HoppError> { + let handle = app.handle().clone(); + + app.deep_link().on_open_url(move |event| { + let urls = event.urls(); + tracing::info!( + urls = ?urls, + count = urls.len(), + "Processing deep link request" + ); + + if let Err(e) = handle.emit("scheme-request-received", urls) { + tracing::error!( + error.message = %e, + error.type = %std::any::type_name_of_val(&e), + "Deep link event emission failed" + ); + } + }); + + tracing::info!(app_name = %app.package_info().name, "Configured deep link handler"); + + Ok(()) +} + +fn setup_server(app: &tauri::App) -> Result { + let server_port: u16 = PortPicker::new() + .protocol(Protocol::Tcp) + .port_range(15000..=25000) + .pick() + .map_err(|_| HoppError::ServerPortInitialization)?; + + tracing::info!("Selected server port: {}", server_port); + SERVER_PORT + .set(server_port) + .map_err(|_| HoppError::ServerPortInitialization)?; + + let port = *SERVER_PORT + .get() + .ok_or(HoppError::ServerPortInitialization)?; + tracing::info!(port = port, "Initializing server with pre-selected port"); + + let port = server::init(port, app.handle().clone()); + tracing::info!(port = port, "Server initialization complete"); + + Ok(port) +} + +async fn setup_version_backup(app: &tauri::App) -> Result<(), HoppError> { + tracing::info!("Checking for version changes and performing backup if needed"); + + let handle = app.handle().clone(); + match tauri::async_runtime::spawn_blocking(move || { + backup::perform_version_check_and_backup(handle) + }) + .await + { + Ok(Ok(_)) => { + tracing::info!("Version backup check completed successfully"); + Ok(()) + } + Ok(Err(e)) => { + tracing::error!( + error = %e, + "Version backup check failed, but continuing with startup" + ); + Ok(()) + } + Err(e) => { + tracing::error!( + error = %e, + "Version backup task panicked, but continuing with startup" + ); + Ok(()) + } + } +} + +/// Gracefully quit the Hoppscotch Desktop +/// +/// This command is invoked from the frontend when the user triggers +/// the quit action (typically via Cmd+Q/Ctrl+Q keyboard shortcut). +/// +/// It performs a clean shutdown by logging the quit request +/// for debugging and then calling `app.exit(0)` which triggers +/// v2's graceful shutdown. +/// +/// It basically trigger `RunEvent::ExitRequested` +/// followed by `RunEvent::Exit` events. +/// See https://docs.rs/tauri/latest/tauri/struct.AppHandle.html#method.exit +#[tauri::command] +fn quit_app(app: tauri::AppHandle) -> Result<(), String> { + tracing::info!("Quit command received, shutting down application"); + app.exit(0); + Ok(()) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + let mode = if cfg!(feature = "portable") { + "portable" + } else { + "standard" + }; + tracing::info!(mode = mode, "Hoppscotch Desktop running in {} mode", mode); + + #[cfg(all(feature = "portable", windows))] + { + tracing::debug!("Checking WebView initialization for portable Windows variant"); + webview::init_webview(); + } + + let appload_config = match HoppApploadConfig::new() { + Ok(config) => config, + Err(e) => { + tracing::error!(error = %e, "Failed to initialize application configuration"); + return; + } + }; + + if let Err(e) = appload_config.write_vendored() { + tracing::error!(error = %e, "Failed to write bundled files"); + return; + } + + let appload_config = appload_config.build(); + + let app = tauri::Builder::default() + .setup(|app| { + // Set up native Edit menu to enable standard clipboard shortcuts (copy, paste, etc.) + // Required on Linux where webkit2gtk does not handle these without menu items + #[cfg(target_os = "linux")] + { + use tauri::menu::{Menu, PredefinedMenuItem, Submenu}; + + let result = (|| -> Result<(), Box> { + let handle = app.handle(); + let edit_menu = Submenu::with_items( + handle, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(handle, None)?, + &PredefinedMenuItem::redo(handle, None)?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::cut(handle, None)?, + &PredefinedMenuItem::copy(handle, None)?, + &PredefinedMenuItem::paste(handle, None)?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::select_all(handle, None)?, + ], + )?; + // NOTE: This menu bar will be visible on Linux. Removing it or hiding it + // also removes the accelerator registrations and breaks clipboard shortcuts + // (webkit2gtk requires native menu items to recognise Ctrl+C/V/X etc.). + // See https://github.com/tauri-apps/tauri/issues/2397 + let menu = Menu::with_items(handle, &[&edit_menu])?; + app.set_menu(menu)?; + Ok(()) + })(); + + if let Err(e) = result { + tracing::warn!(error = %e, "Failed to set up native Edit menu; clipboard shortcuts may not work"); + } + } + + tauri::async_runtime::block_on(async { + if let Err(e) = setup_version_backup(app).await { + tracing::error!(error = %e, "Failed to setup version backup"); + return Err(e.into()); + } + + if let Err(e) = setup_server(app) { + tracing::error!(error = %e, "Failed to setup server"); + return Err(e.into()); + } + + if let Err(e) = setup_deep_link_handler(app) { + tracing::error!(error = %e, "Failed to setup deep link handler"); + return Err(e.into()); + } + + tracing::info!("Starting Hoppscotch Desktop v{}", env!("CARGO_PKG_VERSION")); + Ok(()) + }) + }) + .plugin( + tauri_plugin_window_state::Builder::new() + .with_state_flags( + StateFlags::SIZE + | StateFlags::POSITION + | StateFlags::MAXIMIZED + | StateFlags::FULLSCREEN, + ) + .with_denylist(&["main"]) + .build(), + ) + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_appload::init(appload_config)) + .plugin(tauri_plugin_relay::init()) + .invoke_handler(tauri::generate_handler![ + is_portable, + hopp_auth_port, + quit_app, + backup::check_and_backup_on_version_change, + config::set_desktop_config, + updater::check_for_updates, + updater::download_and_install_update, + updater::restart_application, + updater::cancel_update, + updater::get_download_progress, + updater::is_portable_mode, + path::get_config_dir, + path::get_latest_dir, + path::get_instance_dir, + path::get_store_dir, + path::get_backup_dir, + path::get_logs_dir, + logger::append_log, + path::read_log, + path::get_appload_registry, + ]) + .run(tauri::generate_context!()); + + if let Err(e) = app { + tracing::error!(error = %e, "Error while running Hoppscotch Desktop"); + } +} diff --git a/packages/hoppscotch-desktop/src-tauri/src/logger.rs b/packages/hoppscotch-desktop/src-tauri/src/logger.rs new file mode 100644 index 0000000..60f6238 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/src/logger.rs @@ -0,0 +1,77 @@ +use std::io::Write; +use std::path::PathBuf; + +use file_rotate::{compression::Compression, suffix::AppendCount, ContentLimit, FileRotate}; +use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +use crate::path; + +pub struct LogGuard(pub tracing_appender::non_blocking::WorkerGuard); + +pub fn setup(log_dir: &PathBuf) -> Result> { + std::fs::create_dir_all(log_dir)?; + + let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| "debug".into()); + + let log_file_path = path::log_file_path(); + tracing::info!(log_file_path =? &log_file_path); + + let file = FileRotate::new( + &log_file_path, + AppendCount::new(5), + ContentLimit::Bytes(10 * 1024 * 1024), + Compression::None, + None, + ); + + let (non_blocking, guard) = tracing_appender::non_blocking(file); + + let console_layer = fmt::layer() + .with_writer(std::io::stdout) + .with_thread_ids(true) + .with_thread_names(true) + .with_ansi(!cfg!(target_os = "windows")); + + let file_layer = fmt::layer() + .with_writer(non_blocking) + .with_ansi(false) + .with_thread_ids(true) + .with_thread_names(true); + + tracing_subscriber::registry() + .with(env_filter) + .with(file_layer) + .with(console_layer) + .init(); + + tracing::info!( + log_file = %log_file_path.display(), + "Logging initialized with rotating file" + ); + + Ok(LogGuard(guard)) +} + +// appends content to a log file inside `logs_dir()`. bypasses the Tauri +// fs plugin so the write isn't subject to the scope in capabilities.json. +// the filename is caller-controlled but confined to `logs_dir()` to +// prevent arbitrary file writes +#[tauri::command] +pub fn append_log(filename: String, content: String) -> Result<(), String> { + let dir = path::logs_dir().map_err(|e| e.to_string())?; + let path = dir.join(&filename); + + // safety: reject any path traversal attempts + if path.parent() != Some(&dir) { + return Err("invalid log filename".to_string()); + } + + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|e| e.to_string())?; + + file.write_all(content.as_bytes()) + .map_err(|e| e.to_string()) +} diff --git a/packages/hoppscotch-desktop/src-tauri/src/main.rs b/packages/hoppscotch-desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..641b846 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/src/main.rs @@ -0,0 +1,65 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use hoppscotch_desktop_lib::{ + logger::{self, LogGuard}, + path, +}; + +fn main() { + #[cfg(feature = "portable")] + println!("Starting Hoppscotch Desktop in PORTABLE mode"); + + #[cfg(not(feature = "portable"))] + println!("Starting Hoppscotch Desktop in STANDARD mode"); + + let log_dir = match path::logs_dir() { + Ok(dir) => { + println!("Log directory: {}", dir.display()); + dir + } + Err(e) => { + eprintln!("Failed to setup logging directory: {}", e); + println!("Starting Hoppscotch Desktop without logging..."); + return hoppscotch_desktop_lib::run(); + } + }; + + let Ok(LogGuard(guard)) = logger::setup(&log_dir) else { + eprintln!("Failed to setup logging!"); + println!("Starting Hoppscotch Desktop without logging..."); + return hoppscotch_desktop_lib::run(); + }; + + // This keeps the guard alive, this is scoped to `main` + // so it can only drop when the entire app exits, + // so safe to have it like this. + let _guard = guard; + + #[cfg(feature = "portable")] + { + tracing::info!( + "Hoppscotch Desktop v{} starting in PORTABLE mode", + env!("CARGO_PKG_VERSION") + ); + if let Ok(config_dir) = path::config_dir() { + tracing::info!("Config directory (portable): {}", config_dir.display()); + } + if let Ok(current_dir) = std::env::current_dir() { + tracing::info!("Current working directory: {}", current_dir.display()); + } + } + + #[cfg(not(feature = "portable"))] + { + tracing::info!( + "Hoppscotch Desktop v{} starting in STANDARD mode", + env!("CARGO_PKG_VERSION") + ); + if let Ok(config_dir) = path::config_dir() { + tracing::info!("Config directory (standard): {}", config_dir.display()); + } + } + + hoppscotch_desktop_lib::run() +} diff --git a/packages/hoppscotch-desktop/src-tauri/src/path.rs b/packages/hoppscotch-desktop/src-tauri/src/path.rs new file mode 100644 index 0000000..2a37df8 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/src/path.rs @@ -0,0 +1,350 @@ +use std::io; +use std::path::PathBuf; + +/// App identifier (identical to `tauri.conf.json`) +/// used for various directories and configurations +pub const APP_ID: &str = "io.hoppscotch.desktop"; + +pub fn config_dir() -> io::Result { + let path = platform_config_dir(); + std::fs::create_dir_all(&path)?; + Ok(path) +} + +pub fn latest_dir() -> io::Result { + let path = config_dir()?.join("latest"); + std::fs::create_dir_all(&path)?; + Ok(path) +} + +pub fn instance_dir() -> io::Result { + let path = latest_dir()?.join("instance"); + std::fs::create_dir_all(&path)?; + Ok(path) +} + +pub fn store_dir() -> io::Result { + let path = latest_dir()?.join("store"); + std::fs::create_dir_all(&path)?; + Ok(path) +} + +pub fn get_versioned_backup_dir(version: &str) -> io::Result { + let backup_root = backup_dir()?; + let versioned_path = backup_root.join(format!("v{}", version)); + std::fs::create_dir_all(&versioned_path)?; + Ok(versioned_path) +} + +pub fn backup_dir() -> io::Result { + let path = config_dir()?.join("backup"); + std::fs::create_dir_all(&path)?; + Ok(path) +} + +pub fn logs_dir() -> io::Result { + let path = platform_logs_dir(); + std::fs::create_dir_all(&path)?; + Ok(path) +} + +#[tauri::command] +pub fn get_config_dir() -> Result { + config_dir() + .map(|path| path.to_string_lossy().to_string()) + .map_err(|err| err.to_string()) +} + +#[tauri::command] +pub fn get_latest_dir() -> Result { + latest_dir() + .map(|path| path.to_string_lossy().to_string()) + .map_err(|err| err.to_string()) +} + +#[tauri::command] +pub fn get_instance_dir() -> Result { + instance_dir() + .map(|path| path.to_string_lossy().to_string()) + .map_err(|err| err.to_string()) +} + +#[tauri::command] +pub fn get_store_dir() -> Result { + store_dir() + .map(|path| path.to_string_lossy().to_string()) + .map_err(|err| err.to_string()) +} + +#[tauri::command] +pub fn get_backup_dir() -> Result { + backup_dir() + .map(|path| path.to_string_lossy().to_string()) + .map_err(|err| err.to_string()) +} + +#[tauri::command] +pub fn get_logs_dir() -> Result { + logs_dir() + .map(|path| path.to_string_lossy().to_string()) + .map_err(|err| err.to_string()) +} + +// exposes the appload storage registry so the JS side can match the current +// webview's hostname back to the original server URL. this is needed because +// the app:// URL encoding is lossy (generate_bundle_name in appload replaces +// both dots and hyphens with underscores, so "test-org" and "test_org" would +// produce the same bundle name). returns an empty registry on fresh installs. +// +// FE-1140: the lossy encoding means two distinct org domains that differ only +// by hyphens vs underscores would collide at the bundle name level. DNS rules +// make this unlikely in practice but the encoding should be hardened later +#[tauri::command] +pub fn get_appload_registry() -> Result { + let registry_path = config_dir() + .map_err(|err| err.to_string())? + .join("registry.json"); + + if !registry_path.exists() { + return Ok(r#"{"version":1,"servers":{}}"#.to_string()); + } + + std::fs::read_to_string(®istry_path).map_err(|err| err.to_string()) +} + +// reads the contents of a log file inside `logs_dir()`. same scope +// bypass as `append_log` in logger.rs +#[tauri::command] +pub fn read_log(filename: String) -> Result { + let dir = logs_dir().map_err(|e| e.to_string())?; + let path = dir.join(&filename); + + if path.parent() != Some(&dir) { + return Err("invalid log filename".to_string()); + } + + std::fs::read_to_string(&path).map_err(|e| e.to_string()) +} + +pub fn log_file_path() -> PathBuf { + platform_logs_dir().join(format!("{}.log", APP_ID)) +} + +pub fn bundle_path() -> PathBuf { + if cfg!(feature = "portable") { + std::env::current_dir() + .unwrap_or_else(|_| std::env::temp_dir()) + .join("hopp_bundle.zip") + } else { + std::env::temp_dir().join("hopp_bundle.zip") + } +} + +pub fn manifest_path() -> PathBuf { + if cfg!(feature = "portable") { + std::env::current_dir() + .unwrap_or_else(|_| std::env::temp_dir()) + .join("hopp_manifest.json") + } else { + std::env::temp_dir().join("hopp_manifest.json") + } +} + +// Follows how `tauri` does this +// see: https://github.com/tauri-apps/tauri/blob/dev/crates/tauri/src/path/desktop.rs +fn platform_config_dir() -> PathBuf { + if cfg!(feature = "portable") { + return std::env::current_dir() + .unwrap_or_else(|_| std::env::temp_dir()) + .join("hoppscotch-desktop-data"); + } + + #[cfg(target_os = "macos")] + { + dirs::home_dir() + .map(|dir| dir.join("Library/Application Support").join(APP_ID)) + .unwrap_or_else(|| std::env::temp_dir().join(APP_ID)) + } + + #[cfg(target_os = "windows")] + { + dirs::config_dir() + .map(|dir| dir.join(APP_ID)) + .unwrap_or_else(|| std::env::temp_dir().join(APP_ID)) + } + + #[cfg(target_os = "linux")] + { + dirs::config_dir() + .map(|dir| dir.join(APP_ID)) + .unwrap_or_else(|| std::env::temp_dir().join(APP_ID)) + } + + // Fallback for others + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + std::env::temp_dir().join(APP_ID) + } +} + +// Follows how `tauri` does this +// see: https://github.com/tauri-apps/tauri/blob/dev/crates/tauri/src/path/desktop.rs +fn platform_logs_dir() -> PathBuf { + if cfg!(feature = "portable") { + return std::env::current_dir() + .unwrap_or_else(|_| std::env::temp_dir()) + .join("logs"); + } + + #[cfg(target_os = "macos")] + { + dirs::home_dir() + .map(|dir| dir.join("Library/Logs").join(APP_ID)) + .unwrap_or_else(|| std::env::temp_dir().join(APP_ID).join("logs")) + } + + // Also fallback for others + #[cfg(not(target_os = "macos"))] + { + dirs::data_local_dir() + .map(|dir| dir.join(APP_ID).join("logs")) + .unwrap_or_else(|| std::env::temp_dir().join(APP_ID).join("logs")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_app_id_constant() { + assert_eq!(APP_ID, "io.hoppscotch.desktop"); + } + + #[test] + fn test_log_file_path() { + let path = log_file_path(); + assert!(path.to_string_lossy().contains("io.hoppscotch.desktop.log")); + } + + #[test] + fn test_manifest_path_portable() { + let path = manifest_path(); + assert!(path.to_string_lossy().ends_with("hopp_manifest.json")); + } + + #[test] + fn test_platform_config_dir_structure() { + let config_dir = platform_config_dir(); + + #[cfg(feature = "portable")] + { + assert!(config_dir + .to_string_lossy() + .contains("hoppscotch-desktop-data")); + } + + #[cfg(not(feature = "portable"))] + { + assert!(config_dir.to_string_lossy().contains(APP_ID)); + } + } + + #[test] + fn test_platform_logs_dir_structure() { + let logs_dir = platform_logs_dir(); + + #[cfg(feature = "portable")] + { + assert!(logs_dir.to_string_lossy().ends_with("logs")); + } + + #[cfg(not(feature = "portable"))] + { + assert!(logs_dir.to_string_lossy().contains(APP_ID)); + } + } + + #[test] + fn test_get_config_dir_command() { + let result = get_config_dir(); + assert!(result.is_ok()); + let path_str = result.unwrap(); + assert!(!path_str.is_empty()); + } + + #[test] + fn test_get_logs_dir_command() { + let result = get_logs_dir(); + assert!(result.is_ok()); + let path_str = result.unwrap(); + assert!(!path_str.is_empty()); + } +} + +#[cfg(test)] +mod portable_tests { + // NOTE: These tests should only run when + // the portable feature flag is enabled + #[cfg(feature = "portable")] + #[test] + fn test_portable_bundle_path() { + let path = bundle_path(); + assert!(path.file_name().unwrap() == "hopp_bundle.zip"); + } + + #[cfg(feature = "portable")] + #[test] + fn test_portable_manifest_path() { + let path = manifest_path(); + assert!(path.file_name().unwrap() == "hopp_manifest.json"); + } + + #[cfg(feature = "portable")] + #[test] + fn test_portable_config_dir_structure() { + let config_dir = platform_config_dir(); + assert!(config_dir + .to_string_lossy() + .contains("hoppscotch-desktop-data")); + } +} + +#[cfg(test)] +mod platform_tests { + use super::*; + + #[cfg(target_os = "macos")] + #[test] + fn test_macos_paths() { + let config_dir = platform_config_dir(); + if !cfg!(feature = "portable") { + assert!(config_dir + .to_string_lossy() + .contains("Library/Application Support")); + } + + let logs_dir = platform_logs_dir(); + if !cfg!(feature = "portable") { + assert!(logs_dir.to_string_lossy().contains("Library/Logs")); + } + } + + #[cfg(target_os = "windows")] + #[test] + fn test_windows_paths() { + let config_dir = platform_config_dir(); + if !cfg!(feature = "portable") { + assert!(config_dir.to_string_lossy().contains(APP_ID)); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn test_linux_paths() { + let config_dir = platform_config_dir(); + if !cfg!(feature = "portable") { + assert!(config_dir.to_string_lossy().contains(APP_ID)); + } + } +} diff --git a/packages/hoppscotch-desktop/src-tauri/src/server.rs b/packages/hoppscotch-desktop/src-tauri/src/server.rs new file mode 100644 index 0000000..65362ba --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/src/server.rs @@ -0,0 +1,155 @@ +use std::net::SocketAddr; + +use axum::{ + extract::Query, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use serde::{Deserialize, Serialize}; +use tauri::{Emitter, Runtime}; +use tower_http::cors::CorsLayer; + +#[derive(Debug, Serialize, Deserialize)] +struct AuthTokensQuery { + access_token: String, + refresh_token: Option, +} + +async fn device_token( + Query(token_query): Query, + handle: tauri::AppHandle, +) -> Response { + tracing::info!("Processing device token request"); + + let serialized = match serde_json::to_value(token_query) { + Ok(val) => val, + Err(e) => { + tracing::error!("Failed to serialize tokens: {}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + match handle.emit("hopp_auth://token", serialized) { + Ok(_) => tracing::info!("Device token event emitted successfully"), + Err(e) => tracing::error!( + error.message = %e, + error.type = %std::any::type_name_of_val(&e), + "Failed to emit device token event" + ), + } + StatusCode::OK.into_response() +} + +pub(crate) fn init(port: u16, handle: tauri::AppHandle) -> u16 { + tracing::info!("Beginning server initialization"); + + let app = Router::new() + .route( + "/device-token", + get(move |query| device_token(query, handle.clone())), + ) + .layer(CorsLayer::very_permissive()); + + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + + tauri::async_runtime::spawn(async move { + tracing::info!( + address = %addr, + port = port, + "Starting HTTP server" + ); + + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => { + tracing::info!( + local_addr = %l.local_addr().unwrap_or(addr), + "Server bound successfully" + ); + l + } + Err(e) => { + tracing::error!( + error.message = %e, + error.kind = ?e.kind(), + attempted_address = %addr, + "Server bind failed" + ); + panic!("Could not bind to the unused port"); + } + }; + + tracing::info!("Server starting"); + if let Err(e) = axum::serve(listener, app).await { + tracing::error!( + error.message = %e, + error.type = %std::any::type_name_of_val(&e), + "Server crashed" + ); + } + }); + + port +} + +// `AuthTokensQuery` gets deserialized from query params on the `/device-token` +// endpoint. the desktop app receives auth tokens this way after the user does +// browser-based login and gets redirected back. `refresh_token` is optional +// because some auth flows (like the device code flow) don't always return one, +// and we don't wanna blow up if it's missing. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_auth_tokens_query_deserialize_both_tokens() { + let json = r#"{"access_token":"abc123","refresh_token":"def456"}"#; + let query: AuthTokensQuery = serde_json::from_str(json).unwrap(); + + assert_eq!(query.access_token, "abc123"); + assert_eq!(query.refresh_token, Some("def456".to_string())); + } + + #[test] + fn test_auth_tokens_query_deserialize_without_refresh() { + let json = r#"{"access_token":"abc123"}"#; + let query: AuthTokensQuery = serde_json::from_str(json).unwrap(); + + assert_eq!(query.access_token, "abc123"); + assert_eq!(query.refresh_token, None); + } + + #[test] + fn test_auth_tokens_query_deserialize_fails_without_access_token() { + let json = r#"{"refresh_token":"def456"}"#; + let result: Result = serde_json::from_str(json); + + assert!(result.is_err()); + } + + #[test] + fn test_auth_tokens_query_roundtrip() { + let original = AuthTokensQuery { + access_token: "token_value".to_string(), + refresh_token: Some("refresh_value".to_string()), + }; + + let serialized = serde_json::to_value(&original).unwrap(); + assert_eq!(serialized["access_token"], "token_value"); + assert_eq!(serialized["refresh_token"], "refresh_value"); + + let deserialized: AuthTokensQuery = serde_json::from_value(serialized).unwrap(); + assert_eq!(deserialized.access_token, original.access_token); + assert_eq!(deserialized.refresh_token, original.refresh_token); + } + + #[test] + fn test_auth_tokens_query_null_refresh_token() { + let json = r#"{"access_token":"abc123","refresh_token":null}"#; + let query: AuthTokensQuery = serde_json::from_str(json).unwrap(); + + assert_eq!(query.access_token, "abc123"); + assert_eq!(query.refresh_token, None); + } +} diff --git a/packages/hoppscotch-desktop/src-tauri/src/updater.rs b/packages/hoppscotch-desktop/src-tauri/src/updater.rs new file mode 100644 index 0000000..2a57f58 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/src/updater.rs @@ -0,0 +1,308 @@ +use crate::{dialog, util}; +use native_dialog::MessageType; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tauri::{AppHandle, Emitter}; +use tauri_plugin_updater::{Update, UpdaterExt}; +use tokio::sync::Mutex; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateInfo { + pub available: bool, + pub current_version: String, + pub latest_version: Option, + pub release_notes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadProgress { + pub downloaded: u64, + pub total: Option, + pub percentage: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum UpdateEvent { + CheckStarted, + CheckCompleted { + info: UpdateInfo, + }, + CheckFailed { + error: String, + }, + DownloadStarted { + #[serde(rename = "totalBytes")] + total_bytes: Option, + }, + DownloadProgress { + progress: DownloadProgress, + }, + DownloadCompleted, + InstallStarted, + InstallCompleted, + RestartRequired, + UpdateCancelled, + Error { + message: String, + }, +} + +// Global state for tracking update progress +// TODO: See if it's possible to let Tauri handle this state management +static UPDATE_STATE: std::sync::LazyLock>>> = + std::sync::LazyLock::new(|| Arc::new(Mutex::new(None))); + +static DOWNLOAD_STATE: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| { + Arc::new(Mutex::new(DownloadProgress { + downloaded: 0, + total: None, + percentage: 0.0, + })) + }); + +/// Check for updates and returns basic information +/// For portable mode: Shows native dialog if update found +/// For standard mode: Just returns information, UI handles the rest +#[tauri::command] +pub async fn check_for_updates( + app: AppHandle, + show_native_dialog: bool, +) -> Result { + tracing::info!(portable_dialog = show_native_dialog, "Checking for updates"); + + let _ = app.emit("updater-event", UpdateEvent::CheckStarted); + + let updater = app.updater().map_err(|e| { + let error_msg = format!("Failed to initialize updater: {}", e); + tracing::error!("{}", error_msg); + let _ = app.emit( + "updater-event", + UpdateEvent::CheckFailed { + error: error_msg.clone(), + }, + ); + error_msg + })?; + + match updater.check().await { + Ok(Some(update)) => { + let current_version = app.package_info().version.to_string(); + let latest_version = update.version.to_string(); + let release_notes = update.body.clone(); + + tracing::info!(current_version, latest_version, "Update available"); + + { + let mut state = UPDATE_STATE.lock().await; + *state = Some(update); + } + + let update_info = UpdateInfo { + available: true, + current_version, + latest_version: Some(latest_version.clone()), + release_notes, + }; + + if show_native_dialog { + handle_portable_update_dialog(&app, &latest_version).await?; + } + + let _ = app.emit( + "updater-event", + UpdateEvent::CheckCompleted { + info: update_info.clone(), + }, + ); + + Ok(update_info) + } + Ok(None) => { + tracing::info!("No updates available"); + let update_info = UpdateInfo { + available: false, + current_version: app.package_info().version.to_string(), + latest_version: None, + release_notes: None, + }; + + let _ = app.emit( + "updater-event", + UpdateEvent::CheckCompleted { + info: update_info.clone(), + }, + ); + + Ok(update_info) + } + Err(e) => { + let error_msg = format!("Failed to check for updates: {}", e); + tracing::error!("{}", error_msg); + let _ = app.emit( + "updater-event", + UpdateEvent::CheckFailed { + error: error_msg.clone(), + }, + ); + Err(error_msg) + } + } +} + +/// Download and install update (for standard mode) +/// Emits progress events that the frontend can listen to +#[tauri::command] +pub async fn download_and_install_update(app: AppHandle) -> Result<(), String> { + tracing::info!("Starting update download and installation"); + + let update = { + let mut state = UPDATE_STATE.lock().await; + state.take().ok_or("No update available to install")? + }; + + let _ = app.emit( + "updater-event", + UpdateEvent::DownloadStarted { total_bytes: None }, + ); + + { + let mut download_state = DOWNLOAD_STATE.lock().await; + download_state.downloaded = 0; + download_state.total = None; + download_state.percentage = 0.0; + } + + let app_progress = app.clone(); + let app_callback = app.clone(); + + update + .download_and_install( + move |chunk_length, content_length| { + let app = app_progress.clone(); + tauri::async_runtime::spawn(async move { + let mut download_state = DOWNLOAD_STATE.lock().await; + + if let Some(total) = content_length { + if download_state.total.is_none() { + download_state.total = Some(total); + let _ = app.emit( + "updater-event", + UpdateEvent::DownloadStarted { + total_bytes: Some(total), + }, + ); + } + } + + download_state.downloaded += chunk_length as u64; + + if let Some(total) = download_state.total { + download_state.percentage = + (download_state.downloaded as f64 / total as f64) * 100.0; + } + + let progress = download_state.clone(); + // Release the lock before emitting + // TODO: See if it's possible to not have a lock at all + drop(download_state); + + let _ = app.emit("updater-event", UpdateEvent::DownloadProgress { progress }); + }); + }, + move || { + let app = app_callback.clone(); + tauri::async_runtime::spawn(async move { + let _ = app.emit("updater-event", UpdateEvent::DownloadCompleted); + let _ = app.emit("updater-event", UpdateEvent::InstallStarted); + let _ = app.emit("updater-event", UpdateEvent::RestartRequired); + }); + }, + ) + .await + .map_err(|e| { + let error_msg = format!("Failed to download and install update: {}", e); + let _ = app.emit( + "updater-event", + UpdateEvent::Error { + message: error_msg.clone(), + }, + ); + error_msg + })?; + + tracing::info!("Update download and installation completed successfully"); + Ok(()) +} + +/// Restart the application (for standard mode) +#[tauri::command] +pub async fn restart_application() -> Result<(), String> { + tracing::info!("Restarting application"); + tauri::process::restart(&tauri::Env::default()); + // This function never returns because the app restarts, + // so it's safe to allow `unreachable_code` here. + #[allow(unreachable_code)] + Ok(()) +} + +/// Cancel any ongoing update process +#[tauri::command] +pub async fn cancel_update(app: AppHandle) -> Result<(), String> { + tracing::info!("Cancelling update"); + + { + let mut state = UPDATE_STATE.lock().await; + *state = None; + } + + { + let mut download_state = DOWNLOAD_STATE.lock().await; + download_state.downloaded = 0; + download_state.total = None; + download_state.percentage = 0.0; + } + + let _ = app.emit("updater-event", UpdateEvent::UpdateCancelled); + Ok(()) +} + +/// Get current download progress (for polling if needed) +#[tauri::command] +pub async fn get_download_progress() -> Result { + let download_state = DOWNLOAD_STATE.lock().await; + Ok(download_state.clone()) +} + +/// Check if we're running in portable mode (for frontend convenience) +#[tauri::command] +pub fn is_portable_mode() -> bool { + cfg!(feature = "portable") +} + +/// Helper function to handle portable mode update dialog +async fn handle_portable_update_dialog( + _app: &AppHandle, + latest_version: &str, +) -> Result<(), String> { + let download_url = "https://hoppscotch.com/download"; + let message = format!( + "An update (version {}) is available for Hoppscotch Desktop (Portable).\n\nWould you like to download it now?\n\n• Yes = Download now\n• No = Remind me later", + latest_version + ); + + if dialog::confirm("Download Update", &message, MessageType::Info) { + if util::open_link(download_url).is_none() { + dialog::error(&format!( + "Failed to open download page. Please visit {}", + download_url + )); + return Err("Failed to open download URL".to_string()); + } + } + + Ok(()) +} diff --git a/packages/hoppscotch-desktop/src-tauri/src/util.rs b/packages/hoppscotch-desktop/src-tauri/src/util.rs new file mode 100644 index 0000000..42abe78 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/src/util.rs @@ -0,0 +1,107 @@ +use std::process::{Command, Stdio}; + +pub fn open_link(link: &str) -> Option<()> { + let null = Stdio::null(); + + #[cfg(target_os = "windows")] + { + Command::new("rundll32") + .args(["url.dll,FileProtocolHandler", link]) + .stdout(null) + .spawn() + .ok() + .map(|_| ()) + } + + #[cfg(target_os = "macos")] + { + Command::new("open") + .arg(link) + .stdout(null) + .spawn() + .ok() + .map(|_| ()) + } + + #[cfg(target_os = "linux")] + { + Command::new("xdg-open") + .arg(link) + .stdout(null) + .spawn() + .ok() + .map(|_| ()) + } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // NOTE: These tests won't actually open URLs in testing environments + // but these will test the command construction logic + #[test] + fn test_open_link_with_valid_url() { + let test_url = "https://example.com"; + + // The function should not panic and should return `Some(())` or `None` + // depending on whether the command can be spawned + let result = open_link(test_url); + + // This should return `Some(())` if the command exists, + // on unsupported platforms, this should return None + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + { + assert!(result.is_some() || result.is_none()); + } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + assert_eq!(result, None); + } + } + + #[test] + fn test_open_link_with_empty_string() { + let result = open_link(""); + + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + { + assert!(result.is_some() || result.is_none()); + } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + assert_eq!(result, None); + } + } + + #[test] + fn test_open_link_with_special_characters() { + let test_urls = vec![ + "https://example.com/path with spaces", + "https://example.com/path?query=value&other=test", + "https://example.com/path#fragment", + "file:///path/to/local/file.txt", + ]; + + for url in test_urls { + let result = open_link(url); + + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + { + assert!(result.is_some() || result.is_none()); + } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + assert_eq!(result, None); + } + } + } +} diff --git a/packages/hoppscotch-desktop/src-tauri/src/webview/error.rs b/packages/hoppscotch-desktop/src-tauri/src/webview/error.rs new file mode 100644 index 0000000..7bfeff5 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/src/webview/error.rs @@ -0,0 +1,15 @@ +use std::io; + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum WebViewError { + #[error("Failed to open URL: {0}")] + UrlOpen(#[from] io::Error), + #[error("Failed to download WebView2 installer: {0}")] + Download(String), + #[error("WebView2 installation failed: {0}")] + Installation(String), + #[error("Failed during request: {0}")] + Request(#[from] tauri_plugin_http::reqwest::Error), +} diff --git a/packages/hoppscotch-desktop/src-tauri/src/webview/mod.rs b/packages/hoppscotch-desktop/src-tauri/src/webview/mod.rs new file mode 100644 index 0000000..0238154 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/src/webview/mod.rs @@ -0,0 +1,212 @@ +/// The WebView2 Runtime is a critical dependency for Tauri applications on Windows. +/// We need to check for its presence, see [Source: GitHub Issue #59 - Portable windows build](https://github.com/tauri-apps/tauri-action/issues/59#issuecomment-827142638) +/// +/// > "Tauri requires an installer if you define app resources, external binaries or running on environments that do not have Webview2 runtime installed. So I don't think it's a good idea to have a "portable" option since a Tauri binary itself isn't 100% portable." +/// +/// The approach for checking WebView2 installation is based on Microsoft's official documentation, which states: +/// +/// > ###### Detect if a WebView2 Runtime is already installed +/// > +/// > To verify that a WebView2 Runtime is installed, use one of the following approaches: +/// > +/// > * Approach 1: Inspect the `pv (REG_SZ)` regkey for the WebView2 Runtime at both of the following registry locations. The `HKEY_LOCAL_MACHINE` regkey is used for _per-machine_ install. The `HKEY_CURRENT_USER` regkey is used for _per-user_ install. +/// > +/// > For WebView2 applications, at least one of these regkeys must be present and defined with a version greater than 0.0.0.0. If neither regkey exists, or if only one of these regkeys exists but its value is `null`, an empty string, or 0.0.0.0, this means that the WebView2 Runtime isn't installed on the client. Inspect these regkeys to detect whether the WebView2 Runtime is installed, and to get the version of the WebView2 Runtime. Find `pv (REG_SZ)` at the following two locations. +/// > +/// > The two registry locations to inspect on 64-bit Windows: +/// > +/// > ```text +/// > HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5} +/// > +/// > HKEY_CURRENT_USER\Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5} +/// > ``` +/// > +/// > The two registry locations to inspect on 32-bit Windows: +/// > +/// > ```text +/// > HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5} +/// > +/// > HKEY_CURRENT_USER\Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5} +/// > ``` +/// > +/// > * Approach 2: Run [GetAvailableCoreWebView2BrowserVersionString](/microsoft-edge/webview2/reference/win32/webview2-idl#getavailablecorewebview2browserversionstring) and evaluate whether the `versionInfo` is `nullptr`. `nullptr` indicates that the WebView2 Runtime isn't installed. This API returns version information for the WebView2 Runtime or for any installed preview channels of Microsoft Edge (Beta, Dev, or Canary). +/// +/// See: https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution?tabs=dotnetcsharp#detect-if-a-webview2-runtime-is-already-installed +/// +/// Our implementation uses Approach 1, checking both the 32-bit (WOW6432Node) and 64-bit registry locations +/// to make sure we have critical dependency compatibility with different system architectures. +pub mod error; + +use std::{io, ops::Not}; + +use native_dialog::MessageType; + +use crate::{dialog, util}; +use error::WebViewError; + +#[cfg(windows)] +use { + std::io::Cursor, + std::process::Command, + tauri_plugin_http::reqwest, + tempfile::TempDir, + winreg::{ + enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}, + RegKey, + }, +}; + +const TAURI_WEBVIEW_REF: &str = "https://v2.tauri.app/references/webview-versions/"; +const WINDOWS_WEBVIEW_REF: &str = + "https://developer.microsoft.com/microsoft-edge/webview2/#download-section"; + +fn is_available() -> bool { + #[cfg(windows)] + { + const KEY_WOW64: &str = r"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; + const KEY: &str = + r"SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; + + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + + [ + hklm.open_subkey(KEY_WOW64), + hkcu.open_subkey(KEY_WOW64), + hklm.open_subkey(KEY), + hkcu.open_subkey(KEY), + ] + .into_iter() + .any(|result| result.is_ok()) + } + + #[cfg(not(windows))] + { + true + } +} + +fn open_install_website() -> Result<(), WebViewError> { + let url = if cfg!(windows) { + WINDOWS_WEBVIEW_REF + } else { + TAURI_WEBVIEW_REF + }; + + util::open_link(url).map(|_| ()).ok_or_else(|| { + WebViewError::UrlOpen(io::Error::new( + io::ErrorKind::Other, + "Failed to open browser to WebView download section", + )) + }) +} + +#[cfg(windows)] +async fn install() -> Result<(), WebViewError> { + const WEBVIEW2_BOOTSTRAPPER_URL: &str = "https://go.microsoft.com/fwlink/p/?LinkId=2124703"; + const DEFAULT_FILENAME: &str = "MicrosoftEdgeWebview2Setup.exe"; + + let client = reqwest::Client::builder() + .user_agent("Hoppscotch Agent") + .gzip(true) + .build()?; + + let response = client.get(WEBVIEW2_BOOTSTRAPPER_URL).send().await?; + + if !response.status().is_success() { + return Err(WebViewError::Download(format!( + "Failed to download WebView2 bootstrapper. Status: {}", + response.status() + ))); + } + + let filename = + get_filename_from_response(&response).unwrap_or_else(|| DEFAULT_FILENAME.to_owned()); + + let tmp_dir = TempDir::with_prefix("WebView-setup-")?; + let installer_path = tmp_dir.path().join(filename); + + let content = response.bytes().await?; + { + let mut file = std::fs::File::create(&installer_path)?; + io::copy(&mut Cursor::new(content), &mut file)?; + } + + let status = Command::new(&installer_path).args(["/install"]).status()?; + + if !status.success() { + return Err(WebViewError::Installation(format!( + "Installer exited with code `{}`.", + status.code().unwrap_or(-1) + ))); + } + + Ok(()) +} + +#[cfg(windows)] +fn get_filename_from_response(response: &reqwest::Response) -> Option { + response + .headers() + .get("content-disposition") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split("filename=").last()) + .map(|name| name.trim().replace('\"', "")) + .or_else(|| { + response + .url() + .path_segments() + .and_then(|segments| segments.last()) + .map(|name| name.to_string()) + }) + .filter(|name| !name.is_empty()) +} + +#[cfg(not(windows))] +async fn install() -> Result<(), WebViewError> { + Err(WebViewError::Installation( + "Unable to auto-install WebView. Please refer to https://v2.tauri.app/references/webview-versions/".to_string(), + )) +} + +pub fn init_webview() { + if is_available() { + return; + } + + if dialog::confirm( + "WebView Error", + "WebView is required for this application to work.\n\n\ + Do you want to install it?", + MessageType::Error, + ) + .not() + { + tracing::warn!("Declined to setup WebView."); + + std::process::exit(1); + } + + if let Err(e) = tauri::async_runtime::block_on(install()) { + dialog::error(&format!( + "Failed to install WebView: {}\n\n\ + Please install it manually from webpage that should open when you click 'Ok'.\n\n\ + If that doesn't work, please visit Microsoft Edge Webview2 download section.", + e + )); + + if let Err(e) = open_install_website() { + tracing::warn!("Failed to launch WebView website:\n{}", e); + } + + std::process::exit(1); + } + + if is_available().not() { + dialog::panic( + "Unable to setup WebView:\n\n\ + Please install it manually and relaunch the application.\n\ + https://developer.microsoft.com/microsoft-edge/webview2/#download-section", + ); + } +} diff --git a/packages/hoppscotch-desktop/src-tauri/tauri.conf.json b/packages/hoppscotch-desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..d1614ea --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/tauri.conf.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Hoppscotch", + "version": "26.6.0", + "identifier": "io.hoppscotch.desktop", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://127.0.0.1:1420", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "main", + "width": 500, + "height": 700, + "decorations": false, + "alwaysOnTop": true, + "resizable": false + } + ], + "security": { + "csp": { + "default-src": "blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' customprotocol: asset:", + "script-src": "* 'self' 'unsafe-eval' 'wasm-unsafe-eval' 'unsafe-inline'", + "connect-src": "ipc: http://ipc.localhost https://api.hoppscotch.io data: *", + "font-src": "https://fonts.gstatic.com data: 'self' *", + "img-src": "'self' asset: http://asset.localhost blob: data: customprotocol: *", + "style-src": "'unsafe-inline' 'self' https://fonts.googleapis.com data: asset: *", + "worker-src": "* 'self' data: 'unsafe-eval' blob:" + } + } + }, + "bundle": { + "active": true, + "targets": "all", + "createUpdaterArtifacts": true, + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["io.hoppscotch.desktop"] + } + }, + "updater": { + "active": true, + "endpoints": [ + "https://releases.hoppscotch.com/hoppscotch-selfhost-desktop.json" + ], + "dialog": true, + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDYwOURFNEY4RDRGMDQxNTgKUldSWVFmRFUrT1NkWUc1RDM0Z2ZjTHE2dG52Q3ZlYzg3ZXVpZU9KaENPWTBMd3MwY0hWa1lreDcK" + } + } +} diff --git a/packages/hoppscotch-desktop/src-tauri/tauri.portable.macos.conf.json b/packages/hoppscotch-desktop/src-tauri/tauri.portable.macos.conf.json new file mode 100644 index 0000000..5cf9a7a --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/tauri.portable.macos.conf.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Hoppscotch", + "version": "26.6.0", + "identifier": "io.hoppscotch.desktop", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://127.0.0.1:1420", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "main", + "width": 500, + "height": 700, + "decorations": false, + "alwaysOnTop": true, + "resizable": false + } + ], + "security": { + "csp": { + "default-src": "blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' customprotocol: asset:", + "script-src": "* 'self' 'unsafe-eval' 'wasm-unsafe-eval' 'unsafe-inline'", + "connect-src": "ipc: http://ipc.localhost https://api.hoppscotch.io data: *", + "font-src": "https://fonts.gstatic.com data: 'self' *", + "img-src": "'self' asset: http://asset.localhost blob: data: customprotocol: *", + "style-src": "'unsafe-inline' 'self' https://fonts.googleapis.com data: asset: *", + "worker-src": "* 'self' data: 'unsafe-eval' blob:" + } + } + }, + "bundle": { + "active": true, + "targets": "all", + "createUpdaterArtifacts": false, + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["io.hoppscotch.desktop"] + } + }, + "updater": { + "active": true, + "endpoints": [ + "https://releases.hoppscotch.com/hoppscotch-selfhost-desktop.json" + ], + "dialog": false, + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDYwOURFNEY4RDRGMDQxNTgKUldSWVFmRFUrT1NkWUc1RDM0Z2ZjTHE2dG52Q3ZlYzg3ZXVpZU9KaENPWTBMd3MwY0hWa1lreDcK" + } + } +} diff --git a/packages/hoppscotch-desktop/src-tauri/tauri.portable.windows.conf.json b/packages/hoppscotch-desktop/src-tauri/tauri.portable.windows.conf.json new file mode 100644 index 0000000..15ff044 --- /dev/null +++ b/packages/hoppscotch-desktop/src-tauri/tauri.portable.windows.conf.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Hoppscotch", + "version": "26.6.0", + "identifier": "io.hoppscotch.desktop", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://127.0.0.1:1420", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "main", + "width": 500, + "height": 700, + "decorations": false, + "alwaysOnTop": true, + "resizable": false + } + ], + "security": { + "csp": { + "default-src": "blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' customprotocol: asset:", + "script-src": "* 'self' 'unsafe-eval' 'wasm-unsafe-eval' 'unsafe-inline'", + "connect-src": "ipc: http://ipc.localhost https://api.hoppscotch.io data: *", + "font-src": "https://fonts.gstatic.com data: 'self' *", + "img-src": "'self' asset: http://asset.localhost blob: data: customprotocol: *", + "style-src": "'unsafe-inline' 'self' https://fonts.googleapis.com data: asset: *", + "worker-src": "* 'self' data: 'unsafe-eval' blob:" + } + } + }, + "bundle": { + "active": false, + "targets": "all", + "windows": { + "webviewInstallMode": { + "type": "skip" + } + }, + "createUpdaterArtifacts": false + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["io.hoppscotch.desktop"] + } + }, + "updater": { + "active": true, + "endpoints": [ + "https://releases.hoppscotch.com/hoppscotch-selfhost-desktop.json" + ], + "dialog": false, + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDYwOURFNEY4RDRGMDQxNTgKUldSWVFmRFUrT1NkWUc1RDM0Z2ZjTHE2dG52Q3ZlYzg3ZXVpZU9KaENPWTBMd3MwY0hWa1lreDcK" + } + } +} diff --git a/packages/hoppscotch-desktop/src/App.vue b/packages/hoppscotch-desktop/src/App.vue new file mode 100644 index 0000000..9a0fe28 --- /dev/null +++ b/packages/hoppscotch-desktop/src/App.vue @@ -0,0 +1,18 @@ + + + diff --git a/packages/hoppscotch-desktop/src/assets/scss/styles.scss b/packages/hoppscotch-desktop/src/assets/scss/styles.scss new file mode 100644 index 0000000..24f5dbb --- /dev/null +++ b/packages/hoppscotch-desktop/src/assets/scss/styles.scss @@ -0,0 +1,103 @@ +@mixin base-theme { + --font-sans: 'Inter Variable', sans-serif; + --font-mono: 'Roboto Mono Variable', monospace; + --font-size-body: 0.75rem; + --font-size-tiny: 0.625rem; + --line-height-body: 1rem; +} + +@mixin dark-theme { + --primary-color: #181818; + --primary-light-color: #1c1c1e; + --primary-dark-color: #262626; + --primary-contrast-color: #171717; + + --secondary-color: #a3a3a3; + --secondary-light-color: #737373; + --secondary-dark-color: #fafafa; + + --divider-color: #262626; + --divider-light-color: #1f1f1f; + --divider-dark-color: #2d2d2d; + + --error-color: #292524; + --tooltip-color: #f5f5f5; + --popover-color: #1b1b1b; +} + +@mixin green-theme { + --accent-color: #10b981; + --accent-light-color: #34d399; + --accent-dark-color: #059669; + --accent-contrast-color: #fff; + --gradient-from-color: #a7f3d0; + --gradient-via-color: #34d399; + --gradient-to-color: #059669; +} + +:root { + @include base-theme; + @include dark-theme; + @include green-theme; +} + +:root.dark { + @include dark-theme; + color-scheme: dark; +} + +:root[data-accent='green'] { + @include green-theme; +} + +* { + backface-visibility: hidden; + -moz-backface-visibility: hidden; + -webkit-backface-visibility: hidden; + + &::before { + backface-visibility: hidden; + -moz-backface-visibility: hidden; + -webkit-backface-visibility: hidden; + } + + &::after { + backface-visibility: hidden; + -moz-backface-visibility: hidden; + -webkit-backface-visibility: hidden; + } + + @apply selection:bg-accentDark; + @apply selection:text-accentContrast; +} + +:root { + accent-color: var(--accent-color); + font-variant-ligatures: common-ligatures; + @apply antialiased; + @apply overscroll-none; +} + +input::placeholder, +textarea::placeholder { + @apply text-secondary; + @apply opacity-50 #{!important}; +} + +input, +textarea { + @apply text-secondaryDark; + @apply font-medium; +} + +body { + @apply bg-primary; + @apply text-body text-secondary; + @apply font-medium; + @apply select-none; + @apply overflow-x-hidden; + @apply leading-body #{!important}; + animation: fade 300ms forwards; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; +} diff --git a/packages/hoppscotch-desktop/src/assets/scss/tailwind.scss b/packages/hoppscotch-desktop/src/assets/scss/tailwind.scss new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/packages/hoppscotch-desktop/src/assets/scss/tailwind.scss @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/packages/hoppscotch-desktop/src/assets/vue.svg b/packages/hoppscotch-desktop/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/packages/hoppscotch-desktop/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/hoppscotch-desktop/src/components.d.ts b/packages/hoppscotch-desktop/src/components.d.ts new file mode 100644 index 0000000..9a351ff --- /dev/null +++ b/packages/hoppscotch-desktop/src/components.d.ts @@ -0,0 +1,21 @@ +/* eslint-disable */ +// @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 + +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary'] + HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary'] + HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'] + LayoutHeader: typeof import('./components/layout/LayoutHeader.vue')['default'] + LayoutSidebar: typeof import('./components/layout/LayoutSidebar.vue')['default'] + Tippy: typeof import('vue-tippy')['Tippy'] + } +} diff --git a/packages/hoppscotch-desktop/src/components/layout/LayoutHeader.vue b/packages/hoppscotch-desktop/src/components/layout/LayoutHeader.vue new file mode 100644 index 0000000..e7e6fbb --- /dev/null +++ b/packages/hoppscotch-desktop/src/components/layout/LayoutHeader.vue @@ -0,0 +1,35 @@ + + + diff --git a/packages/hoppscotch-desktop/src/components/layout/LayoutSidebar.vue b/packages/hoppscotch-desktop/src/components/layout/LayoutSidebar.vue new file mode 100644 index 0000000..44a2691 --- /dev/null +++ b/packages/hoppscotch-desktop/src/components/layout/LayoutSidebar.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/packages/hoppscotch-desktop/src/composables/useAppInitialization.ts b/packages/hoppscotch-desktop/src/composables/useAppInitialization.ts new file mode 100644 index 0000000..7b12554 --- /dev/null +++ b/packages/hoppscotch-desktop/src/composables/useAppInitialization.ts @@ -0,0 +1,466 @@ +import { ref } from "vue" +import * as E from "fp-ts/Either" +import { load, download, close } from "@hoppscotch/plugin-appload" +import { getVersion } from "@tauri-apps/api/app" +import { invoke } from "@tauri-apps/api/core" + +import { DesktopPersistenceService } from "~/services/persistence.service" +import { InstanceStoreMigrationService } from "~/services/instance-store-migration.service" +import type { + Instance, + ConnectionState, +} from "@hoppscotch/common/platform/instance" +import { VENDORED_INSTANCE_CONFIG } from "@hoppscotch/common/platform/instance" +import { useDesktopSettings } from "@hoppscotch/common/composables/desktop-settings" + +// simple diag logger for the main window (runs before kernel log module is available) +function mainDiag(msg: string) { + const line = `[${new Date().toISOString()}] [MAIN] ${msg}\n` + if ((window as any).__TAURI_INTERNALS__) { + ;(window as any).__TAURI_INTERNALS__ + .invoke("append_log", { + filename: "io.hoppscotch.desktop.diag.log", + content: line, + }) + .catch(() => {}) + } +} + +export enum AppState { + LOADING = "loading", + UPDATE_AVAILABLE = "update_available", + UPDATE_IN_PROGRESS = "update_in_progress", + UPDATE_READY = "update_ready", + ERROR = "error", + LOADED = "loaded", +} + +export function useAppInitialization() { + const persistence = DesktopPersistenceService.getInstance() + const migration = InstanceStoreMigrationService.getInstance() + + // Shared with the launcher's own zoom watcher (`useDesktopZoomEffect`). + // Each `load()` call below awaits `desktopSettings.ready()` before + // reading `zoomLevel`, so the appload Rust-side pre-mount apply gets + // the persisted value rather than the schema default on a fast + // cold-start click. Without the gate, a user who clicks Connect + // before the store read resolves would forward 1.0 to appload and + // see the bundled app paint at 100% even though their setting was + // 110, 125, or 150. + const desktopSettings = useDesktopSettings() + + const appState = ref(AppState.LOADING) + const error = ref("") + const statusMessage = ref("Initializing...") + const appVersion = ref("...") + + const saveConnectionState = async (state: ConnectionState) => { + try { + await persistence.connectionState.set(state) + } catch (err) { + console.error("Failed to save connection state:", err) + } + } + + const findMostRecentInstance = ( + instances: Instance[], + targetUrl: string + ): Instance | null => { + return ( + instances.find( + (instance) => + instance.serverUrl === targetUrl || + instance.serverUrl.includes(targetUrl) || + targetUrl.includes(instance.serverUrl) + ) || null + ) + } + + const loadVendoredInstance = async () => { + try { + statusMessage.value = "Loading Hoppscotch Desktop..." + + await saveConnectionState({ + status: "connected", + instance: VENDORED_INSTANCE_CONFIG, + }) + + mainDiag("loadVendoredInstance: calling load(bundleName=Hoppscotch)") + console.log("Loading vendored app...") + + // Wait for the store read before forwarding `zoomLevel`, so the + // appload Rust-side pre-mount apply gets the persisted value + // rather than the schema default on a fast cold-start click. + await desktopSettings.ready() + + const loadResp = await load({ + bundleName: VENDORED_INSTANCE_CONFIG.bundleName!, + window: { + title: "Hoppscotch", + zoomLevel: desktopSettings.settings.zoomLevel, + }, + }) + + mainDiag( + `loadVendoredInstance: load result success=${loadResp.success}, label=${loadResp.windowLabel}` + ) + if (!loadResp.success) { + throw new Error("Failed to load Hoppscotch Vendored") + } + + console.log("Vendored app loaded successfully") + mainDiag("loadVendoredInstance: closing main window") + close({ windowLabel: "main" }) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err) + console.error("Error loading vendored app:", errorMessage) + error.value = errorMessage + + await saveConnectionState({ + status: "error", + target: "Vendored", + message: errorMessage, + }) + + appState.value = AppState.ERROR + } + } + + const loadVendoredIfMatches = async (instance: Instance) => { + mainDiag( + `loadVendoredIfMatches: kind=${instance.kind}, displayName=${instance.displayName}, bundleName=${instance.bundleName}` + ) + + // cloud-org instances share the same bundleName as vendored ("Hoppscotch") + // because they use the same app bundle, just loaded with a different org + // context via the host parameter. we must check kind, not bundleName, to + // distinguish them. without this, restarting the app after connecting to an + // org would incorrectly load vendored (no host param = no org context). + // "cloud" (default cloud, e.g. hoppscotch.io) also uses the vendored bundle + // and doesn't need a download step. + if (instance.kind === "vendored" || instance.kind === "cloud") { + mainDiag( + "loadVendoredIfMatches: matched vendored, calling loadVendoredInstance" + ) + await loadVendoredInstance() + } else if (instance.kind === "cloud-org") { + // cloud-org: uses the vendored bundle but needs the host parameter so the + // webview gets the org context (?org= query param). skip the download + // step since cloud-org shares the vendored bundle which is already + // available locally. + try { + statusMessage.value = `Loading ${instance.displayName}...` + + await saveConnectionState({ + status: "connecting", + target: instance.serverUrl, + }) + + mainDiag( + `loadVendoredIfMatches: loading cloud-org instance, bundle=${instance.bundleName}, host=${instance.serverUrl}` + ) + await desktopSettings.ready() + const loadResp = await load({ + bundleName: instance.bundleName!, + host: instance.serverUrl, + window: { + title: "Hoppscotch", + zoomLevel: desktopSettings.settings.zoomLevel, + }, + }) + + mainDiag( + `loadVendoredIfMatches: load result success=${loadResp.success}, label=${loadResp.windowLabel}` + ) + if (!loadResp.success) { + throw new Error(`Failed to load ${instance.displayName}`) + } + + await saveConnectionState({ + status: "connected", + instance: instance, + }) + + console.log(`Successfully loaded instance: ${instance.displayName}`) + mainDiag("loadVendoredIfMatches: closing main window") + close({ windowLabel: "main" }) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err) + console.error( + `Failed to load cloud-org instance ${instance.displayName}:`, + errorMessage + ) + + await saveConnectionState({ + status: "error", + target: instance.serverUrl, + message: errorMessage, + }) + + mainDiag( + `loadVendoredIfMatches: FAILED to load cloud-org ${instance.displayName}, falling back to vendored. error=${errorMessage}` + ) + console.log("Falling back to vendored instance") + await loadVendoredInstance() + } + } else { + // self-hosted or other non-vendored instances: need to download the + // bundle from the server before loading + try { + statusMessage.value = `Loading ${instance.displayName}...` + + await saveConnectionState({ + status: "connecting", + target: instance.serverUrl, + }) + + const dlResp = await download({ serverUrl: instance.serverUrl }) + const updatedInstance: Instance = { + ...instance, + version: dlResp.version, + bundleName: dlResp.bundleName, + } + const DESKTOP_APP_SERVER_PATH = "/desktop-app-server" + const normUrl = (u: string) => { + let n = u.toLowerCase() + while (n.endsWith("/")) n = n.slice(0, -1) + if (n.endsWith(DESKTOP_APP_SERVER_PATH)) + n = n.slice(0, -DESKTOP_APP_SERVER_PATH.length) + while (n.endsWith("/")) n = n.slice(0, -1) + return n + } + try { + const recentInstances = await persistence.recentInstances.get() + await persistence.recentInstances.set( + recentInstances.map((r) => + normUrl(r.serverUrl) === normUrl(updatedInstance.serverUrl) + ? { + ...r, + version: dlResp.version, + bundleName: dlResp.bundleName, + } + : r + ) + ) + } catch (syncErr) { + console.error("Failed to sync recent instance version:", syncErr) + } + + mainDiag( + `loadVendoredIfMatches: loading non-vendored instance, bundle=${updatedInstance.bundleName}` + ) + await desktopSettings.ready() + const loadResp = await load({ + bundleName: updatedInstance.bundleName!, + window: { + title: "Hoppscotch", + zoomLevel: desktopSettings.settings.zoomLevel, + }, + }) + + mainDiag( + `loadVendoredIfMatches: load result success=${loadResp.success}, label=${loadResp.windowLabel}` + ) + if (!loadResp.success) { + throw new Error(`Failed to load ${instance.displayName}`) + } + + await saveConnectionState({ + status: "connected", + instance: updatedInstance, + }) + + console.log(`Successfully loaded instance: ${instance.displayName}`) + mainDiag("loadVendoredIfMatches: closing main window") + close({ windowLabel: "main" }) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err) + console.error( + `Failed to load instance ${instance.displayName}:`, + errorMessage + ) + + await saveConnectionState({ + status: "error", + target: instance.serverUrl, + message: errorMessage, + }) + + mainDiag( + `loadVendoredIfMatches: FAILED to load ${instance.displayName}, falling back to vendored. error=${errorMessage}` + ) + console.log("Falling back to vendored instance") + await loadVendoredInstance() + } + } + } + + const loadRecent = async () => { + try { + statusMessage.value = "Loading application..." + + // Both the main window and the vendored webview's InstanceService + // share hoppscotch-unified.store for connection state and recent + // instances. The InstanceService's detectCurrentInstanceFromHostname + // persists the detected instance (including cloud-org) to this store, + // so on restart the main window can resume the correct instance. + const connectionState = await persistence.connectionState.get() + const recentInstances = await persistence.recentInstances.get() + + mainDiag(`loadRecent: connectionState=${JSON.stringify(connectionState)}`) + mainDiag( + `loadRecent: connectionState.status=${connectionState?.status ?? "(null)"}, instance.kind=${connectionState?.status === "connected" ? connectionState.instance?.kind : "(n/a)"}, instance.displayName=${connectionState?.status === "connected" ? connectionState.instance?.displayName : "(n/a)"}, recentInstances.length=${recentInstances.length}` + ) + mainDiag(`loadRecent: recentInstances=${JSON.stringify(recentInstances)}`) + console.log("Current connection state:", connectionState) + console.log("Recent instances:", recentInstances) + + if (connectionState) { + switch (connectionState.status) { + case "connected": + if (connectionState.instance) { + mainDiag( + `loadRecent: resuming connected instance: kind=${connectionState.instance.kind}, displayName=${connectionState.instance.displayName}` + ) + statusMessage.value = `Connecting to ${connectionState.instance.displayName}...` + try { + await loadVendoredIfMatches(connectionState.instance) + return + } catch (err) { + console.warn( + "Failed to load previously connected instance:", + err + ) + } + } + break + + case "connecting": + if (connectionState.target) { + statusMessage.value = `Resuming connection to ${connectionState.target}...` + const targetInstance = findMostRecentInstance( + recentInstances, + connectionState.target + ) + if (targetInstance) { + try { + await loadVendoredIfMatches(targetInstance) + return + } catch (err) { + console.warn("Failed to resume connection:", err) + } + } + } + break + + case "error": + console.warn("Previous connection failed:", connectionState.message) + break + + case "idle": + default: + break + } + } + + const mostRecentInstance = recentInstances[0] + + if (mostRecentInstance) { + statusMessage.value = `Connecting to ${mostRecentInstance.displayName}...` + try { + await loadVendoredIfMatches(mostRecentInstance) + return + } catch (err) { + console.warn("Failed to load most recent instance:", err) + } + } + + console.log("No recent instances found, loading vendored as fallback") + await loadVendoredInstance() + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err) + console.error("Error in loadRecent:", errorMessage) + error.value = errorMessage + appState.value = AppState.ERROR + } + } + + const performBasicInitialization = async () => { + try { + appVersion.value = await getVersion() + } catch (error) { + console.error("Failed to get app version:", error) + appVersion.value = "unknown" + } + + statusMessage.value = "Checking for version changes..." + try { + await invoke("check_and_backup_on_version_change") + console.log("Version backup check completed") + } catch (err) { + console.warn("Version backup check failed:", err) + } + + statusMessage.value = "Running data migration..." + await migration.initialize() + + const migrationStatus = migration.getMigrationStatus() + if (migrationStatus.value.status === "failed") { + throw new Error( + `Migration failed: ${migration.getMigrationError().value}` + ) + } + + statusMessage.value = "Initializing stores..." + // `init` returns `Either` so callers can decide + // how to surface a failure. Branching to a thrown Error here lets + // the surrounding `initialize()` try/catch route the failure into + // `error.value` for the UI, the same way every other startup + // failure is reported, instead of letting init silently complete + // and leave the app running on defaults with no Rust sync. + const initResult = await persistence.init() + if (E.isLeft(initResult)) { + throw new Error( + `Persistence init failed: ${initResult.left.kind}: ${initResult.left.message}` + ) + } + } + + const initialize = async (customLogic?: () => Promise) => { + appState.value = AppState.LOADING + error.value = "" + + try { + await performBasicInitialization() + + if (customLogic) { + await customLogic() + } else { + await loadRecent() + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err) + console.error("Initialization error:", errorMessage) + error.value = errorMessage + appState.value = AppState.ERROR + } + } + + return { + appState, + error, + statusMessage, + appVersion, + + persistence, + migration, + + saveConnectionState, + findMostRecentInstance, + loadVendoredInstance, + loadVendoredIfMatches, + loadRecent, + performBasicInitialization, + initialize, + } +} diff --git a/packages/hoppscotch-desktop/src/kernel/index.ts b/packages/hoppscotch-desktop/src/kernel/index.ts new file mode 100644 index 0000000..5d75abb --- /dev/null +++ b/packages/hoppscotch-desktop/src/kernel/index.ts @@ -0,0 +1,13 @@ +import { KernelAPI } from "@hoppscotch/kernel" + +export { Io } from "./io" +export { Relay } from "./relay" +export { Store } from "./store" + +export const getModule = ( + name: K +): NonNullable => { + const kernel = window.__KERNEL__ + if (!kernel?.[name]) throw new Error(`Kernel ${String(name)} not initialized`) + return kernel[name] +} diff --git a/packages/hoppscotch-desktop/src/kernel/io.ts b/packages/hoppscotch-desktop/src/kernel/io.ts new file mode 100644 index 0000000..bf55cf8 --- /dev/null +++ b/packages/hoppscotch-desktop/src/kernel/io.ts @@ -0,0 +1,34 @@ +import type { + SaveFileWithDialogOptions, + OpenExternalLinkOptions, + SaveFileResponse, + OpenExternalLinkResponse, + EventCallback, + UnlistenFn, +} from "@hoppscotch/kernel" +import { getModule } from "." + +export const Io = (() => { + const module = () => getModule("io") + + return { + saveFileWithDialog: ( + opts: SaveFileWithDialogOptions + ): Promise => module().saveFileWithDialog(opts), + + openExternalLink: ( + opts: OpenExternalLinkOptions + ): Promise => module().openExternalLink(opts), + + listen: ( + event: string, + handler: EventCallback + ): Promise => module().listen(event, handler), + + once: (event: string, handler: EventCallback): Promise => + module().once(event, handler), + + emit: (event: string, payload?: unknown): Promise => + module().emit(event, payload), + } as const +})() diff --git a/packages/hoppscotch-desktop/src/kernel/relay.ts b/packages/hoppscotch-desktop/src/kernel/relay.ts new file mode 100644 index 0000000..46a8d4e --- /dev/null +++ b/packages/hoppscotch-desktop/src/kernel/relay.ts @@ -0,0 +1,26 @@ +import type { + RelayRequest, + RelayRequestEvents, + RelayError, + RelayResponse, + RelayEventEmitter, +} from "@hoppscotch/kernel" +import * as E from "fp-ts/Either" +import { getModule } from "." + +export const Relay = (() => { + const module = () => getModule("relay") + + return { + capabilities: () => module().capabilities, + canHandle: (request: RelayRequest): E.Either => + module().canHandle(request), + execute: ( + request: RelayRequest + ): { + cancel: () => Promise + emitter: RelayEventEmitter + response: Promise> + } => module().execute(request), + } as const +})() diff --git a/packages/hoppscotch-desktop/src/kernel/store-resource.ts b/packages/hoppscotch-desktop/src/kernel/store-resource.ts new file mode 100644 index 0000000..4bbcec3 --- /dev/null +++ b/packages/hoppscotch-desktop/src/kernel/store-resource.ts @@ -0,0 +1,120 @@ +import * as E from "fp-ts/Either" +import type { z } from "zod" + +import { Log } from "@hoppscotch/common/kernel/log" + +import { Store } from "~/kernel/store" + +const LOG_TAG = "store-resource" + +/** + * A single schema-validated, namespaced, persistent value in the shared + * store. + * + * The persistence service holds several of these (desktop settings, + * update state, connection state, recent instances) which previously + * existed as bespoke `get*` / `set*` / `watch*` method pairs on the + * service class. Each pair was ~20 lines of near-identical plumbing + * that wrapped `Store.get` with a parse and a default fallback, + * wrapped `Store.set` with validation and a throw on failure, and + * wrapped `Store.watch` with an undefined filter and a parse on every + * incoming value. Extracting the pattern to a factory cuts the + * service down to a thin declarative layer where each resource is + * four lines. + * + * A resource is an ordinary value that can be passed around and composed. + * Compound operations (for example "add an instance to the recent list" + * which reads, transforms, and writes) become free functions over a + * resource rather than methods on a god class, which separates the data + * access concern (this factory) from the business rules (the free + * functions). + */ +export interface StoreResource { + /** + * Reads the current value from the store. Falls back to `defaults()` on + * any read error, missing key, or schema validation failure, so callers + * always receive a valid `T`. + */ + get(): Promise + + /** + * Writes a new value after validating through the schema. Throws on + * validation failure or store write failure. Callers that want silent + * best-effort semantics should wrap the call themselves. + */ + set(value: T): Promise + + /** + * Subscribes to external writes. The handler receives the parsed value + * whenever any writer (this process or another) updates the key. + * Resolves to an unsubscribe function. + */ + watch(handler: (value: T) => void): Promise<() => void> +} + +// Input type is deliberately `any` so this works with schemas whose parse +// input differs from output, most commonly `z.object` schemas that carry +// `.default()` on each field (input has optional fields, output has them +// required). Constraining input to `T` would reject every such schema. +export function createStoreResource( + namespace: string, + key: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + schema: z.ZodType, + defaults: () => T +): StoreResource { + return { + async get(): Promise { + const result = await Store.get(namespace, key) + if (E.isLeft(result) || result.right === undefined) { + return defaults() + } + const parsed = schema.safeParse(result.right) + if (!parsed.success) { + Log.warn( + LOG_TAG, + `${namespace}/${key} failed schema validation, falling back to defaults`, + parsed.error + ) + return defaults() + } + return parsed.data + }, + + async set(value: T): Promise { + const validated = schema.parse(value) + const result = await Store.set(namespace, key, validated) + if (E.isLeft(result)) { + // `StoreError` is a tagged union with `kind` and `message`. + // Interpolating the object directly stringifies to + // `[object Object]`, which is useless in logs and throws, so + // format it explicitly here. + const err = result.left + throw new Error( + `Failed to persist ${namespace}/${key}: ${err.kind}: ${err.message}` + ) + } + }, + + async watch(handler: (value: T) => void): Promise<() => void> { + const emitter = await Store.watch(namespace, key) + return emitter.on("change", ({ value }: { value?: unknown }) => { + if (value === undefined) return + const parsed = schema.safeParse(value) + if (parsed.success) { + handler(parsed.data) + return + } + // Mirrors the parse-failure log in `get()`. Without this, an + // external writer with a schema mismatch (for example a shell + // and webview temporarily out of sync after a migration) would + // stop delivering updates with no observable signal. + Log.warn( + LOG_TAG, + `${namespace}/${key} watch received invalid value, skipping`, + parsed.error + ) + }) + }, + } +} diff --git a/packages/hoppscotch-desktop/src/kernel/store.ts b/packages/hoppscotch-desktop/src/kernel/store.ts new file mode 100644 index 0000000..72168b1 --- /dev/null +++ b/packages/hoppscotch-desktop/src/kernel/store.ts @@ -0,0 +1,114 @@ +import type { + StorageOptions, + StoreError, + StoreEvents, + StoreEventEmitter, +} from "@hoppscotch/kernel" +import * as E from "fp-ts/Either" +import { getModule } from "." +import { invoke } from "@tauri-apps/api/core" +import { join } from "@tauri-apps/api/path" + +const STORE_PATH = "hoppscotch-unified.store" + +export const getConfigDir = async (): Promise => { + return invoke("get_config_dir") +} + +export const getBackupDir = async (): Promise => { + return invoke("get_backup_dir") +} + +export const getLatestDir = async (): Promise => { + return invoke("get_latest_dir") +} + +export const getStoreDir = async (): Promise => { + return invoke("get_store_dir") +} + +export const getInstanceDir = async (): Promise => { + return invoke("get_instance_dir") +} + +const getStorePath = async (): Promise => { + try { + const storeDir = await getStoreDir() + return join(storeDir, STORE_PATH) + } catch (error) { + console.error("Failed to get store directory:", error) + return "hoppscotch-unified.store" + } +} + +export const Store = (() => { + const module = () => getModule("store") + + return { + capabilities: () => module().capabilities, + + init: async () => { + const storePath = await getStorePath() + return module().init(storePath) + }, + + set: async ( + namespace: string, + key: string, + value: unknown, + options?: StorageOptions + ): Promise> => { + const storePath = await getStorePath() + return module().set(storePath, namespace, key, value, options) + }, + + get: async ( + namespace: string, + key: string + ): Promise> => { + const storePath = await getStorePath() + return module().get(storePath, namespace, key) + }, + + remove: async ( + namespace: string, + key: string + ): Promise> => { + const storePath = await getStorePath() + return module().remove(storePath, namespace, key) + }, + + clear: async (namespace?: string): Promise> => { + const storePath = await getStorePath() + return module().clear(storePath, namespace) + }, + + has: async ( + namespace: string, + key: string + ): Promise> => { + const storePath = await getStorePath() + return module().has(storePath, namespace, key) + }, + + listNamespaces: async (): Promise> => { + const storePath = await getStorePath() + return module().listNamespaces(storePath) + }, + + listKeys: async ( + namespace: string + ): Promise> => { + const storePath = await getStorePath() + return module().listKeys(storePath, namespace) + }, + + watch: async ( + namespace: string, + key: string + ): Promise> => { + const storePath = await getStorePath() + return module().watch(storePath, namespace, key) + }, + } as const +})() diff --git a/packages/hoppscotch-desktop/src/main.ts b/packages/hoppscotch-desktop/src/main.ts new file mode 100644 index 0000000..0f9f91f --- /dev/null +++ b/packages/hoppscotch-desktop/src/main.ts @@ -0,0 +1,25 @@ +import { createApp } from "vue" +import App from "./App.vue" +import router from "./router" + +import "@hoppscotch/ui/style.css" +import "./assets/scss/styles.scss" +import "./assets/scss/tailwind.scss" +import "@fontsource-variable/inter" +import "@fontsource-variable/material-symbols-rounded" +import "@fontsource-variable/roboto-mono" + +import { initKernel } from "@hoppscotch/kernel" +import { useDesktopZoomEffect } from "@hoppscotch/common/composables/desktop-zoom" + +const app = createApp(App) +app.use(router) +app.mount("#app") + +initKernel("desktop") + +// Sync the launcher window's zoom with the user's persisted preference. +// The same composable is wired into the bundled selfhost-web entry so the +// post-connect window picks up the same value. Each call owns the +// watcher for its own window, the store is the shared source of truth. +useDesktopZoomEffect() diff --git a/packages/hoppscotch-desktop/src/router.ts b/packages/hoppscotch-desktop/src/router.ts new file mode 100644 index 0000000..6b603f0 --- /dev/null +++ b/packages/hoppscotch-desktop/src/router.ts @@ -0,0 +1,32 @@ +import { createRouter, createWebHistory } from "vue-router" +import { invoke } from "@tauri-apps/api/core" + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: "/", + name: "home", + component: async () => { + try { + const isPortable = await invoke("is_portable_mode") + // Dynamic import because otherwise `updater` + // tends to experience weird race conditions, + // not sure how or why + return isPortable + ? import("./views/PortableHome.vue") + : import("./views/StandardHome.vue") + } catch (error) { + console.error( + "Failed to detect portable mode, defaulting to standard:", + error + ) + + return import("./views/StandardHome.vue") + } + }, + }, + ], +}) + +export default router diff --git a/packages/hoppscotch-desktop/src/services/instance-store-migration.service.ts b/packages/hoppscotch-desktop/src/services/instance-store-migration.service.ts new file mode 100644 index 0000000..ec39bb8 --- /dev/null +++ b/packages/hoppscotch-desktop/src/services/instance-store-migration.service.ts @@ -0,0 +1,431 @@ +import { BehaviorSubject, Observable } from "rxjs" +import { computed } from "vue" +import { LazyStore } from "@tauri-apps/plugin-store" +import { + getInstanceDir, + getConfigDir, + getStoreDir, + getLatestDir, + Store, +} from "~/kernel/store" +import * as E from "fp-ts/Either" +import { join } from "@tauri-apps/api/path" +import { exists, copyFile, remove, readDir, mkdir } from "@tauri-apps/plugin-fs" + +const STORE_NAMESPACE = "hoppscotch-desktop.v1" +const MIGRATION_NAMESPACE = "migration.v1" +const CURRENT_STORE_VERSION = 1 + +type LegacyServerInstance = { + type: "server" + serverUrl: string + displayName: string + version: string + lastUsed: string + bundleName?: string +} + +type LegacyCloudInstance = { + type: "cloud" + displayName: string + version: string +} + +type LegacyInstanceKind = LegacyServerInstance | LegacyCloudInstance + +type LegacyConnectionState = + | { status: "idle" } + | { status: "connecting"; target: string } + | { status: "connected"; instance: LegacyInstanceKind } + | { status: "error"; target: string; message: string } + +type LegacyUpdateState = { + status: string + version?: string + message?: string + progress?: { + downloaded: number + total?: number + } +} + +export type InstanceKind = "on-prem" | "cloud" | "cloud-org" | "vendored" + +export type Instance = { + kind: InstanceKind + serverUrl: string + displayName: string + version: string + lastUsed: string + bundleName?: string +} + +export type ConnectionState = + | { status: "idle" } + | { status: "connecting"; target: string } + | { status: "connected"; instance: Instance } + | { status: "error"; target: string; message: string } + +export type MigrationStatus = + | { status: "pending" } + | { status: "in-progress" } + | { status: "completed" } + | { status: "failed"; error: string } + +export class InstanceStoreMigrationService { + private static instance: InstanceStoreMigrationService + private status$ = new BehaviorSubject({ status: "pending" }) + private migrationLock = false + + private constructor() {} + + public static getInstance(): InstanceStoreMigrationService { + if (!InstanceStoreMigrationService.instance) { + InstanceStoreMigrationService.instance = + new InstanceStoreMigrationService() + } + return InstanceStoreMigrationService.instance + } + + async initialize(): Promise { + if (this.migrationLock) { + console.log("Migration already in progress, skipping...") + return + } + + this.migrationLock = true + + try { + const initResult = await Store.init() + if (E.isLeft(initResult)) { + console.error(`Failed to initialize kernel store: ${initResult.left}`) + // Don't fail the migration, just log and continue, + // since there's a chance no services have started just yet, + // better to continue and fail with legitimate critical reasons, + // rather than failing here and not being able to see the actual cause. + } + + const isMigrated = await this.isMigrationComplete() + if (isMigrated) { + console.log("Migration already completed") + this.status$.next({ status: "completed" }) + return + } + + await this.performMigration() + } catch (error) { + console.error("Migration error:", error) + // Even if migration fails, mark as completed to prevent retry loops, + // so there won't be partial state from the "source". + // There's always a way to restart migration is the source is safe. + await this.markMigrationComplete() + this.status$.next({ status: "completed" }) + } finally { + this.migrationLock = false + } + } + + private async isMigrationComplete(): Promise { + try { + const versionResult = await Store.get( + MIGRATION_NAMESPACE, + "migrationVersion" + ) + const currentVersion = E.isRight(versionResult) + ? versionResult.right || 0 + : 0 + return currentVersion >= CURRENT_STORE_VERSION + } catch { + return false + } + } + + private async markMigrationComplete(): Promise { + try { + await Store.set( + MIGRATION_NAMESPACE, + "migrationVersion", + CURRENT_STORE_VERSION + ) + } catch (error) { + console.error("Failed to mark migration as complete:", error) + } + } + + private async performMigration(): Promise { + this.status$.next({ status: "in-progress" }) + + try { + // Ensure directory structure exists + // (shouldn't fail, but good to check just in case) + await this.ensureDirectoryStructure() + + // Migrate data from old stores. + // NOTE: This can fail if the store is using default path, + // and not the full specificed path. + // Remember to check that first. + await this.migrateDataSafely() + + // Move .hoppscotch.store files + // (shouldn't fail, but good to check just in case) + await this.migrateHoppscotchStoreFiles() + + // Mark migration as complete before cleanup + // (shouldn't fail, but good to check just in case) + await this.markMigrationComplete() + + // Clean up old files + // (best effort, don't fail if this fails) + await this.cleanupOldFilesSafely() + + this.status$.next({ status: "completed" }) + console.log("Migration completed successfully") + } catch (error) { + console.error("Migration failed:", error) + // Mark as completed anyway to prevent retry loops + // (same reasoning as in init) + await this.markMigrationComplete() + this.status$.next({ status: "completed" }) + } + } + + private async ensureDirectoryStructure(): Promise { + try { + const latestDir = await getLatestDir() + const storeDir = await getStoreDir() + const instanceDir = await getInstanceDir() + + // Create directories if they don't exist + for (const dir of [latestDir, storeDir, instanceDir]) { + try { + const dirExists = await exists(dir) + if (!dirExists) { + await mkdir(dir, { recursive: true }) + console.log(`Created directory: ${dir}`) + } + } catch (error) { + // Directory might already exist or parent might not exist + console.log(`Directory ${dir} might already exist:`, error) + } + } + } catch (error) { + console.error("Failed to ensure directory structure:", error) + // Continue anyway, directories might already exist + } + } + + private async migrateDataSafely(): Promise { + const configDir = await getConfigDir() + + const hoppStorePath = await join(configDir, "hopp.store.json") + const desktopStorePath = await join(configDir, "hoppscotch-desktop.store") + + let connectionState: ConnectionState = { status: "idle" } + let recentInstances: Instance[] = [] + let updateState: any = null + + // Read from hopp.store.json if it exists + try { + if (await exists(hoppStorePath)) { + const hoppStore = new LazyStore(hoppStorePath) + await hoppStore.init() + + const hoppState = + await hoppStore.get("connectionState") + if (hoppState && hoppState.status === "connected") { + connectionState = { + status: "connected", + instance: this.convertInstanceToNewFormat(hoppState.instance), + } + } + + const legacyInstances = + await hoppStore.get("recentInstances") + if (legacyInstances) { + recentInstances = legacyInstances.map((inst) => + this.convertInstanceToNewFormat(inst) + ) + } + } + } catch (error) { + console.log("Could not read hopp.store.json:", error) + } + + // Read from hoppscotch-desktop.store if it exists + try { + if (await exists(desktopStorePath)) { + const desktopStore = new LazyStore(desktopStorePath) + await desktopStore.init() + + // Only override connection state if we didn't get one from hopp.store + if (connectionState.status === "idle") { + const desktopState = + await desktopStore.get("connectionState") + if (desktopState && desktopState.status === "connected") { + connectionState = { + status: "connected", + instance: this.convertInstanceToNewFormat(desktopState.instance), + } + } + } + + updateState = await desktopStore.get("updateState") + } + } catch (error) { + console.log("Could not read hoppscotch-desktop.store:", error) + } + + try { + await Store.set(STORE_NAMESPACE, "connectionState", connectionState) + console.log("Migrated connection state") + } catch (error) { + console.error("Failed to save connection state:", error) + } + + try { + await Store.set(STORE_NAMESPACE, "recentInstances", recentInstances) + console.log(`Migrated ${recentInstances.length} recent instances`) + } catch (error) { + console.error("Failed to save recent instances:", error) + } + + if (updateState) { + try { + await Store.set(STORE_NAMESPACE, "updateState", updateState) + console.log("Migrated update state") + } catch (error) { + console.error("Failed to save update state:", error) + } + } + } + + private async migrateHoppscotchStoreFiles(): Promise { + try { + const configDir = await getConfigDir() + const storeDir = await getStoreDir() + + const entries = await readDir(configDir) + const storeFiles = entries + .filter( + (entry: any) => + !entry.isDirectory && entry.name.endsWith(".hoppscotch.store") + ) + .map((entry: any) => entry.name) + + for (const fileName of storeFiles) { + try { + const sourcePath = await join(configDir, fileName) + const targetPath = await join(storeDir, fileName) + + // Check if source still exists and target doesn't + const sourceExists = await exists(sourcePath) + const targetExists = await exists(targetPath) + + if (sourceExists && !targetExists) { + await copyFile(sourcePath, targetPath) + console.log(`Migrated ${fileName} to store directory`) + } else if (sourceExists && targetExists) { + console.log(`${fileName} already exists in target, skipping`) + } + } catch (error) { + console.error(`Failed to migrate ${fileName}:`, error) + // Continue with other files + } + } + } catch (error) { + console.error("Failed to migrate .hoppscotch.store files:", error) + // Non-critical, continue + } + } + + private async cleanupOldFilesSafely(): Promise { + const configDir = await getConfigDir() + + // List of files to potentially remove + const filesToClean = ["hopp.store.json", "hoppscotch-desktop.store"] + + for (const fileName of filesToClean) { + try { + const filePath = await join(configDir, fileName) + if (await exists(filePath)) { + await remove(filePath) + console.log(`Cleaned up old file: ${fileName}`) + } + } catch (error) { + console.log(`Could not remove ${fileName}:`, error) + // Non-critical, continue + } + } + + // Also clean up .hoppscotch.store files that were successfully migrated + try { + const storeDir = await getStoreDir() + const entries = await readDir(configDir) + const storeFiles = entries + .filter( + (entry: any) => + !entry.isDirectory && entry.name.endsWith(".hoppscotch.store") + ) + .map((entry: any) => entry.name) + + for (const fileName of storeFiles) { + try { + const sourcePath = await join(configDir, fileName) + const targetPath = await join(storeDir, fileName) + + // Only remove if successfully copied to target + if ((await exists(targetPath)) && (await exists(sourcePath))) { + await remove(sourcePath) + console.log(`Cleaned up migrated file: ${fileName}`) + } + } catch (error) { + console.log(`Could not remove ${fileName}:`, error) + } + } + } catch (error) { + console.log("Could not clean up .hoppscotch.store files:", error) + } + } + + private convertInstanceToNewFormat( + legacyInstance: LegacyInstanceKind + ): Instance { + if (legacyInstance.type === "cloud") { + return { + kind: "cloud", + serverUrl: "hoppscotch.io", + displayName: legacyInstance.displayName, + version: legacyInstance.version, + lastUsed: new Date().toISOString(), + } + } else { + return { + kind: "on-prem", + serverUrl: legacyInstance.serverUrl, + displayName: legacyInstance.displayName, + version: legacyInstance.version, + lastUsed: legacyInstance.lastUsed, + bundleName: legacyInstance.bundleName, + } + } + } + + public getMigrationStatusStream(): Observable { + return this.status$ + } + + public getMigrationStatus() { + return computed(() => this.status$.value) + } + + public isMigrationCompleted() { + return computed(() => this.status$.value.status === "completed") + } + + public getMigrationError() { + return computed(() => { + const status = this.status$.value + return status.status === "failed" ? status.error : null + }) + } +} diff --git a/packages/hoppscotch-desktop/src/services/persistence.service.ts b/packages/hoppscotch-desktop/src/services/persistence.service.ts new file mode 100644 index 0000000..adbf4c2 --- /dev/null +++ b/packages/hoppscotch-desktop/src/services/persistence.service.ts @@ -0,0 +1,406 @@ +import * as E from "fp-ts/Either" +import { invoke } from "@tauri-apps/api/core" +import { z } from "zod" +import { StoreError } from "@hoppscotch/kernel" + +import { + DESKTOP_SETTINGS_SCHEMA, + DESKTOP_SETTINGS_STORE_KEY, + DESKTOP_SETTINGS_STORE_NAMESPACE, + type DesktopSettings, +} from "@hoppscotch/common/platform/desktop-settings" +import { + UPDATE_STATE_SCHEMA, + UPDATE_STATE_STORE_KEY, +} from "@hoppscotch/common/platform/update-state" +import type { Instance } from "@hoppscotch/common/platform/instance" +import { Log } from "@hoppscotch/common/kernel/log" + +import { Store } from "~/kernel/store" +import { + createStoreResource, + type StoreResource, +} from "~/kernel/store-resource" +import type { UpdateState } from "~/types" + +const LOG_TAG = "persistence" + +// Shared namespace for every desktop-local store resource. Individual keys +// live in `STORE_KEYS` below. Exported for the small handful of callers +// that still touch the store directly. +export const STORE_NAMESPACE = DESKTOP_SETTINGS_STORE_NAMESPACE + +export const STORE_KEYS = { + UPDATE_STATE: UPDATE_STATE_STORE_KEY, + CONNECTION_STATE: "connectionState", + RECENT_INSTANCES: "recentInstances", + SCHEMA_VERSION: "schema_version", + // Legacy key. Written by portable builds in schema v1. Read only by the + // v1 to v2 migration. All other code uses `DESKTOP_SETTINGS`. + PORTABLE_SETTINGS: "portableSettings", + DESKTOP_SETTINGS: DESKTOP_SETTINGS_STORE_KEY, +} as const + +// Runtime validator for `Instance` values read from the store. The type +// annotation pins the Zod output to the canonical `Instance` in common, +// so any drift between the definition stored here and the definition +// consumed by the webview's instance service would fail typecheck +// rather than silently producing a mismatched runtime value. +export const INSTANCE_SCHEMA: z.ZodType = z.object({ + kind: z.enum(["on-prem", "cloud", "cloud-org", "vendored"]), + serverUrl: z.string(), + displayName: z.string(), + version: z.string(), + lastUsed: z.string(), + bundleName: z.string().optional(), +}) + +// Runtime definition of the persisted connection state. NOTE: the +// canonical `ConnectionState` type in +// `@hoppscotch/common/platform/instance.ts` is a discriminated union, +// while the persisted form here is flat with optional fields per +// variant. The two are semi-compatible (the union serializes cleanly +// into this flat form), but the reader loses type narrowing. A later +// migration can switch the persisted form to a discriminated union so +// common's type becomes the single source of truth end-to-end. +export const PERSISTED_CONNECTION_STATE_SCHEMA = z.object({ + status: z.enum(["idle", "connecting", "connected", "error"]), + instance: INSTANCE_SCHEMA.optional(), + target: z.string().optional(), + message: z.string().optional(), +}) + +export type PersistedConnectionState = z.infer< + typeof PERSISTED_CONNECTION_STATE_SCHEMA +> + +// Re-exported for callers that import from this service. The canonical type +// lives in `@hoppscotch/common/platform/desktop-settings`. +export type { DesktopSettings } + +// Legacy `PortableSettings` interface. Kept as a local type (not +// exported, not re-exported from `~/types`) because the v2 migration +// is the only reader and no new code should produce this form. Once +// the migration is retired this type can be dropped entirely. +interface LegacyPortableSettings { + disableUpdateNotifications: boolean + autoSkipWelcome: boolean +} + +interface Migration { + version: number + // Each migration returns its result as an `Either` so the surrounding + // `runMigrations` loop, and in turn `init`, can propagate the + // `StoreError` contract without falling back to throws. + migrate: () => Promise> +} + +const migrations: Migration[] = [ + { + version: 1, + migrate: async () => E.right(undefined), + }, + { + // v1 to v2. Introduces `DesktopSettings` as the single source of truth + // for all desktop builds. Portable users had `portableSettings` with two + // fields, standard-build users had nothing. Both land in `desktopSettings` + // with full defaults for any field not carried over. + // + // Legacy `portableSettings` is intentionally left in place. The key + // is cheap to keep, it preserves a rollback path, and a later + // migration can prune it once the v2 definition has stabilized. + version: 2, + migrate: async () => { + // Decide whether to skip based on the existing `desktopSettings` + // payload. Two paths can re-run this migration after it succeeded + // once. A user downgrades to a pre-v2 build, which resets + // `SCHEMA_VERSION` to "1" because the older code does not + // recognize "2" and rolls it back. A re-upgrade then sees v1 + // again and tries to migrate. The other path is a corrupted + // `SCHEMA_VERSION` value, which the `runMigrations` parse-defense + // coerces to "1" so every migration reruns from scratch. In both + // cases, blindly running the legacy carry-forward would + // overwrite any user-set v2 fields with + // `disableUpdateNotifications` plus schema defaults, undoing the + // user's work. + // + // Three reachable cases here, each handled explicitly. A `Left` + // from `Store.get` means the store is degraded (file I/O + // failure, or not yet open) and there is no way to tell whether + // v2 already ran. Propagating the `Left` aborts the migration + // before `runMigrations` bumps `SCHEMA_VERSION`, so the next + // launch retries on a hopefully-recovered store. A `Right` with + // a present and schema-valid payload is the canonical "v2 + // already happened" signal, since the migration itself is what + // writes a valid payload, so a stored value implies the + // migration ran successfully at least once. A `Right` with + // either no payload (fresh install) or a malformed payload + // (partial object, wrong field types) falls through to the + // legacy carry-forward, which writes a fresh schema-defaults + // `desktopSettings` and self-heals the corruption. + const existingResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.DESKTOP_SETTINGS + ) + if (E.isLeft(existingResult)) { + return existingResult + } + if ( + existingResult.right !== undefined && + DESKTOP_SETTINGS_SCHEMA.safeParse(existingResult.right).success + ) { + return E.right(undefined) + } + + const legacyResult = await Store.get>( + STORE_NAMESPACE, + STORE_KEYS.PORTABLE_SETTINGS + ) + const legacy = + E.isRight(legacyResult) && legacyResult.right + ? legacyResult.right + : undefined + + // Use `safeParse` with a defaults fallback. `Store.get` returns + // the raw JSON value cast to the generic without validation, so + // a corrupted legacy entry (for example a stringified boolean + // left behind by an older build) would throw from `.parse` and + // abort the full persistence init. Falling back to a fresh + // defaults parse keeps the migration progressing on bad data. + const parsed = DESKTOP_SETTINGS_SCHEMA.safeParse({ + disableUpdateNotifications: legacy?.disableUpdateNotifications, + autoSkipWelcome: legacy?.autoSkipWelcome, + }) + const migrated = parsed.success + ? parsed.data + : DESKTOP_SETTINGS_SCHEMA.parse({}) + + const writeResult = await Store.set( + STORE_NAMESPACE, + STORE_KEYS.DESKTOP_SETTINGS, + migrated + ) + if (E.isLeft(writeResult)) { + // Return Left so `runMigrations` aborts before recording the + // new `SCHEMA_VERSION`. Swallowing here would let the loop + // bump the version despite the failed write, and the next + // launch would treat the data as already migrated and never + // retry. + Log.error( + LOG_TAG, + "v2 migration failed to write desktopSettings", + writeResult.left + ) + return writeResult + } + return E.right(undefined) + }, + }, +] + +/** + * Facade over the desktop-local persistent store. + * + * Each persistent value is exposed as a `StoreResource`, which + * carries a uniform `{ get, set, watch }` API validated through a Zod + * schema. The service itself is a thin orchestrator. It runs + * schema-version migrations on init, subscribes once to the + * desktop-settings resource so the Rust mailbox stays in sync with any + * writer, and exposes the resources as readonly members. + * + * Callers move from `persistence.setFoo(value)` to `persistence.foo.set(value)` + * and likewise for `get` / `watch`. Compound operations (e.g. adding to the + * recent-instances list) live as free functions over the resource, below. + */ +export class DesktopPersistenceService { + private static instance: DesktopPersistenceService + + readonly desktopSettings: StoreResource + readonly updateState: StoreResource + readonly connectionState: StoreResource + readonly recentInstances: StoreResource + + private constructor() { + this.desktopSettings = createStoreResource( + STORE_NAMESPACE, + STORE_KEYS.DESKTOP_SETTINGS, + DESKTOP_SETTINGS_SCHEMA, + () => DESKTOP_SETTINGS_SCHEMA.parse({}) + ) + this.updateState = createStoreResource( + STORE_NAMESPACE, + STORE_KEYS.UPDATE_STATE, + UPDATE_STATE_SCHEMA.nullable(), + () => null + ) + this.connectionState = createStoreResource( + STORE_NAMESPACE, + STORE_KEYS.CONNECTION_STATE, + PERSISTED_CONNECTION_STATE_SCHEMA.nullable(), + () => null + ) + this.recentInstances = createStoreResource( + STORE_NAMESPACE, + STORE_KEYS.RECENT_INSTANCES, + z.array(INSTANCE_SCHEMA), + () => [] + ) + } + + public static getInstance(): DesktopPersistenceService { + if (!DesktopPersistenceService.instance) { + DesktopPersistenceService.instance = new DesktopPersistenceService() + } + return DesktopPersistenceService.instance + } + + async init(): Promise> { + const initResult = await Store.init() + if (E.isLeft(initResult)) { + Log.error(LOG_TAG, "Failed to initialize store", initResult.left) + return initResult + } + const migrationResult = await this.runMigrations() + if (E.isLeft(migrationResult)) { + return migrationResult + } + await this.setupRustSync() + return E.right(undefined) + } + + /** + * Keep the Rust-side `DESKTOP_CONFIG` mailbox (see + * `src-tauri/src/config.rs`) in sync with the desktop settings resource. + * + * Two triggers. An initial push so Rust has the current value + * before any consumer reads it. A subscription to the resource's + * `watch` so every subsequent write from any writer (this shell, + * the webview, another process) gets mirrored. The write-side code + * path stays pure persistence, and the sync is a cross-cutting + * concern handled here. + * + * Both paths swallow failures. Rust already has a compile-time default + * for every field Rust cares about, so a failed sync degrades to + * "Rust reads stale value" rather than a user-visible error. + */ + private async setupRustSync(): Promise { + try { + const initial = await this.desktopSettings.get() + await invoke("set_desktop_config", { config: initial }) + } catch (err) { + Log.warn(LOG_TAG, "Initial DesktopSettings sync to Rust failed", err) + } + + try { + await this.desktopSettings.watch((settings) => { + invoke("set_desktop_config", { config: settings }).catch((err) => { + Log.warn(LOG_TAG, "DesktopSettings sync to Rust failed", err) + }) + }) + } catch (err) { + Log.warn( + LOG_TAG, + "Failed to subscribe to DesktopSettings for Rust sync", + err + ) + } + } + + private async runMigrations(): Promise> { + const versionResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.SCHEMA_VERSION + ) + const perhapsVersion = E.isRight(versionResult) ? versionResult.right : "1" + const rawVersion = perhapsVersion ?? "1" + // Coerce a corrupted or non-numeric stored value to the lowest known + // version. Without this, `parseInt("v2")` or `parseInt("")` would + // return NaN, every `migration.version > NaN` check below would + // evaluate to false, the loop would skip every migration, and the + // code would still write `SCHEMA_VERSION = "2"` at the end. That + // would mark migrations complete without ever running them. + // Falling back to "1" instead reruns every migration from scratch, + // which is safe because each migration is idempotent on a fresh + // store and a real fresh install lands here through the same path. + const parsedVersion = parseInt(rawVersion, 10) + const currentVersion = Number.isNaN(parsedVersion) ? "1" : rawVersion + const targetVersion = "2" + + if (currentVersion === targetVersion) { + return E.right(undefined) + } + + for (const migration of migrations) { + if (migration.version > parseInt(currentVersion, 10)) { + const result = await migration.migrate() + if (E.isLeft(result)) { + return result + } + } + } + + // Record the new version only when the write succeeds. A silent + // failure here would leave the stored version stale, and the next + // launch would re-run every migration on already-migrated data. + const versionWrite = await Store.set( + STORE_NAMESPACE, + STORE_KEYS.SCHEMA_VERSION, + targetVersion + ) + if (E.isLeft(versionWrite)) { + Log.error( + LOG_TAG, + "Failed to persist schema version after migrations", + versionWrite.left + ) + return versionWrite + } + return E.right(undefined) + } +} + +/** + * Adds an instance to the recent list, preserving the "most-recent-first, + * max 10, deduplicated by kind+serverUrl" invariants. Kept as a free + * function over the resource rather than a method on the service so the + * data-access concern (the resource) stays separate from the business + * rules (dedupe, sort, trim). + */ +export async function addRecentInstance( + recent: StoreResource, + instance: Instance +): Promise { + const current = await recent.get() + const now = new Date().toISOString() + const existingIndex = current.findIndex( + (i) => i.kind === instance.kind && i.serverUrl === instance.serverUrl + ) + + const merged = + existingIndex >= 0 + ? current.map((existing, index) => + index === existingIndex ? { ...instance, lastUsed: now } : existing + ) + : [{ ...instance, lastUsed: now }, ...current] + + const next = [...merged] + .sort( + (a, b) => new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime() + ) + .slice(0, 10) + + await recent.set(next) +} + +/** + * Removes an instance from the recent list by `serverUrl`. Absent entries + * are silently ignored. + */ +export async function removeRecentInstance( + recent: StoreResource, + serverUrl: string +): Promise { + const current = await recent.get() + const filtered = current.filter((i) => i.serverUrl !== serverUrl) + await recent.set(filtered) +} diff --git a/packages/hoppscotch-desktop/src/services/updater.client.ts b/packages/hoppscotch-desktop/src/services/updater.client.ts new file mode 100644 index 0000000..45a26f9 --- /dev/null +++ b/packages/hoppscotch-desktop/src/services/updater.client.ts @@ -0,0 +1,73 @@ +import { invoke } from "@tauri-apps/api/core" +import { listen, type UnlistenFn } from "@tauri-apps/api/event" + +export interface UpdateInfo { + available: boolean + currentVersion: string + latestVersion?: string + releaseNotes?: string +} + +export interface DownloadProgress { + downloaded: number + total?: number + percentage: number +} + +// TODO: Type safety just like `persistence.serivce.ts`? +export type UpdateEvent = + | { type: "CheckStarted" } + | { type: "CheckCompleted"; info: UpdateInfo } + | { type: "CheckFailed"; error: string } + | { type: "DownloadStarted"; totalBytes?: number } + | { type: "DownloadProgress"; progress: DownloadProgress } + | { type: "DownloadCompleted" } + | { type: "InstallStarted" } + | { type: "InstallCompleted" } + | { type: "RestartRequired" } + | { type: "UpdateCancelled" } + | { type: "Error"; message: string } + +export class UpdaterClient { + private unlistenFn?: UnlistenFn + + async checkForUpdates(showNativeDialog = false): Promise { + return invoke("check_for_updates", { showNativeDialog }) + } + + async downloadAndInstall(): Promise { + return invoke("download_and_install_update") + } + + async restart(): Promise { + return invoke("restart_application") + } + + async cancel(): Promise { + return invoke("cancel_update") + } + + async getDownloadProgress(): Promise { + return invoke("get_download_progress") + } + + async isPortableMode(): Promise { + return invoke("is_portable_mode") + } + + async listenToUpdates( + callback: (event: UpdateEvent) => void + ): Promise { + this.unlistenFn = await listen("updater-event", (event) => { + callback(event.payload as UpdateEvent) + }) + return this.unlistenFn + } + + stopListening(): void { + if (this.unlistenFn) { + this.unlistenFn() + this.unlistenFn = undefined + } + } +} diff --git a/packages/hoppscotch-desktop/src/types/index.ts b/packages/hoppscotch-desktop/src/types/index.ts new file mode 100644 index 0000000..7394544 --- /dev/null +++ b/packages/hoppscotch-desktop/src/types/index.ts @@ -0,0 +1,20 @@ +// Re-exports of types whose canonical definitions live in common. Listed +// here so in-package imports can keep using `~/types` without every caller +// needing to know the precise module path in common. New types that need to +// cross the shell/webview boundary belong in common directly. +export { + UpdateStatus, + type UpdateState, + type DownloadProgress, +} from "@hoppscotch/common/platform/update-state" + +// Not to be confused with `UpdateStatus`. `CheckResult` is the outcome of a +// single call to the updater's `checkForUpdates`, where `UpdateStatus` is +// the full state machine covering checking, downloading, installing, and +// restart. Only `checkForUpdates` returns this. +export enum CheckResult { + AVAILABLE, + NOT_AVAILABLE, + TIMEOUT, + ERROR, +} diff --git a/packages/hoppscotch-desktop/src/types/kernel.d.ts b/packages/hoppscotch-desktop/src/types/kernel.d.ts new file mode 100644 index 0000000..a4c43a3 --- /dev/null +++ b/packages/hoppscotch-desktop/src/types/kernel.d.ts @@ -0,0 +1,9 @@ +import type { KernelAPI } from "@hoppscotch/kernel" + +declare global { + interface Window { + __KERNEL__?: KernelAPI + } +} + +export {} diff --git a/packages/hoppscotch-desktop/src/utils/updater.ts b/packages/hoppscotch-desktop/src/utils/updater.ts new file mode 100644 index 0000000..c98276d --- /dev/null +++ b/packages/hoppscotch-desktop/src/utils/updater.ts @@ -0,0 +1,165 @@ +import { check, type DownloadEvent } from "@tauri-apps/plugin-updater" +import { relaunch } from "@tauri-apps/plugin-process" +import { type LazyStore } from "@tauri-apps/plugin-store" +import { UpdateStatus, CheckResult, UpdateState } from "~/types" + +export class UpdaterService { + private currentProgress: { downloaded: number; total?: number } = { + downloaded: 0, + } + + constructor(private store: LazyStore) {} + + async initialize(): Promise { + await this.saveUpdateState({ + status: UpdateStatus.IDLE, + }) + } + + getCurrentProgress(): { downloaded: number; total?: number } { + return this.currentProgress + } + + async checkForUpdates(timeout = 5000): Promise { + try { + await this.saveUpdateState({ + status: UpdateStatus.CHECKING, + }) + + // This creats a timeout promise that is slightly longer than `check`'s internal timeout, + // this is just to make sure we don't keep checking for updates indefinitely. + // NOTE: `check` tends to hang indefinitely in dev mode, but works in build, + // so this is just in case this ever happens on prod. + const timeoutPromise = new Promise((resolve) => { + // Longer local timeout to make sure it only triggers + // if there's an issue with `check`'s built-in timeout. + const bufferTimeout = timeout + 1000 + setTimeout(() => { + console.log( + "Update check exceeded buffer timeout, likely hanging in check function" + ) + resolve(null) + }, bufferTimeout) + }) + + const updateResult = await Promise.race([ + check({ timeout }), + timeoutPromise, + ]) + + // If we got a timeout (null), we treat it as no update available + // NOTE: We could maybe show more info but for now this works fine + if (!updateResult) { + console.log("Update check timed out or no update available") + await this.saveUpdateState({ + status: UpdateStatus.NOT_AVAILABLE, + }) + return CheckResult.TIMEOUT + } + + const hasUpdates = updateResult.available + + await this.saveUpdateState( + hasUpdates + ? { + status: UpdateStatus.AVAILABLE, + version: updateResult.version, + message: updateResult.body, + } + : { + status: UpdateStatus.NOT_AVAILABLE, + } + ) + + console.log("Update check result:", { + available: updateResult.available, + currentVersion: updateResult.currentVersion, + version: updateResult.version, + }) + + return hasUpdates ? CheckResult.AVAILABLE : CheckResult.NOT_AVAILABLE + } catch (error) { + console.error("Error checking for updates:", error) + await this.saveUpdateState({ + status: UpdateStatus.ERROR, + message: String(error), + }) + return CheckResult.ERROR + } + } + + async downloadAndInstall(): Promise { + try { + const updateResult = await check() + + if (!updateResult) { + throw new Error("No update available to install") + } + + let totalBytes: number | undefined + let downloadedBytes = 0 + + await this.saveUpdateState({ + status: UpdateStatus.DOWNLOADING, + }) + + await updateResult.downloadAndInstall((event: DownloadEvent) => { + try { + if (event.event === "Started") { + totalBytes = event.data.contentLength + downloadedBytes = 0 + console.log(`Download started, total size: ${totalBytes} bytes`) + } else if (event.event === "Progress") { + downloadedBytes += event.data.chunkLength + console.log( + `Download progress: ${downloadedBytes}/${totalBytes} bytes` + ) + + this.currentProgress = { + downloaded: downloadedBytes, + total: totalBytes, + } + } else if (event.event === "Finished") { + console.log("Download finished, starting installation") + this.saveUpdateState({ + status: UpdateStatus.INSTALLING, + }) + } + } catch (error) { + console.warn("Progress tracking error:", error) + } + }) + + // If we reach here, it means the app hasn't restarted automatically + // Mark as ready to restart + await this.saveUpdateState({ + status: UpdateStatus.READY_TO_RESTART, + }) + } catch (error) { + console.error("Error installing updates:", error) + await this.saveUpdateState({ + status: UpdateStatus.ERROR, + message: String(error), + }) + throw error + } + } + + async restartApp(): Promise { + try { + await relaunch() + } catch (error) { + console.error("Failed to restart app:", error) + throw error + } + } + + private async saveUpdateState(state: UpdateState): Promise { + try { + await this.store.set("updateState", state) + await this.store.save() + } catch (error) { + console.error("Failed to save update state:", error) + } + } +} diff --git a/packages/hoppscotch-desktop/src/views/Home.vue b/packages/hoppscotch-desktop/src/views/Home.vue new file mode 100644 index 0000000..16e2a37 --- /dev/null +++ b/packages/hoppscotch-desktop/src/views/Home.vue @@ -0,0 +1,424 @@ + + + diff --git a/packages/hoppscotch-desktop/src/views/PortableHome.vue b/packages/hoppscotch-desktop/src/views/PortableHome.vue new file mode 100644 index 0000000..83aaded --- /dev/null +++ b/packages/hoppscotch-desktop/src/views/PortableHome.vue @@ -0,0 +1,294 @@ + + + diff --git a/packages/hoppscotch-desktop/src/views/StandardHome.vue b/packages/hoppscotch-desktop/src/views/StandardHome.vue new file mode 100644 index 0000000..f43068f --- /dev/null +++ b/packages/hoppscotch-desktop/src/views/StandardHome.vue @@ -0,0 +1,192 @@ + + + diff --git a/packages/hoppscotch-desktop/src/views/shared/AppHeader.vue b/packages/hoppscotch-desktop/src/views/shared/AppHeader.vue new file mode 100644 index 0000000..b6a7a5a --- /dev/null +++ b/packages/hoppscotch-desktop/src/views/shared/AppHeader.vue @@ -0,0 +1,21 @@ + + + diff --git a/packages/hoppscotch-desktop/src/views/shared/ErrorState.vue b/packages/hoppscotch-desktop/src/views/shared/ErrorState.vue new file mode 100644 index 0000000..fb1fdc6 --- /dev/null +++ b/packages/hoppscotch-desktop/src/views/shared/ErrorState.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/hoppscotch-desktop/src/views/shared/LoadingState.vue b/packages/hoppscotch-desktop/src/views/shared/LoadingState.vue new file mode 100644 index 0000000..6fb4e2e --- /dev/null +++ b/packages/hoppscotch-desktop/src/views/shared/LoadingState.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/hoppscotch-desktop/src/views/shared/UpdateFlow.vue b/packages/hoppscotch-desktop/src/views/shared/UpdateFlow.vue new file mode 100644 index 0000000..1359145 --- /dev/null +++ b/packages/hoppscotch-desktop/src/views/shared/UpdateFlow.vue @@ -0,0 +1,112 @@ + + + diff --git a/packages/hoppscotch-desktop/src/views/shared/VersionInfo.vue b/packages/hoppscotch-desktop/src/views/shared/VersionInfo.vue new file mode 100644 index 0000000..0406f85 --- /dev/null +++ b/packages/hoppscotch-desktop/src/views/shared/VersionInfo.vue @@ -0,0 +1,15 @@ + + + diff --git a/packages/hoppscotch-desktop/src/vite-env.d.ts b/packages/hoppscotch-desktop/src/vite-env.d.ts new file mode 100644 index 0000000..fc81239 --- /dev/null +++ b/packages/hoppscotch-desktop/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent<{}, {}, any>; + export default component; +} diff --git a/packages/hoppscotch-desktop/tailwind.config.ts b/packages/hoppscotch-desktop/tailwind.config.ts new file mode 100644 index 0000000..90d7ff2 --- /dev/null +++ b/packages/hoppscotch-desktop/tailwind.config.ts @@ -0,0 +1,7 @@ +import { Config } from "tailwindcss" +import preset from "@hoppscotch/ui/ui-preset" + +export default { + content: ["src/**/*.{vue,html}"], + presets: [preset], +} satisfies Config diff --git a/packages/hoppscotch-desktop/tsconfig.json b/packages/hoppscotch-desktop/tsconfig.json new file mode 100644 index 0000000..1a0e597 --- /dev/null +++ b/packages/hoppscotch-desktop/tsconfig.json @@ -0,0 +1,47 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "~/*": ["./src/*"], + "@hoppscotch/common": ["../hoppscotch-common/src/index.ts"], + "@hoppscotch/common/*": ["../hoppscotch-common/src/*"], + "@composables/*": ["./src/composables/*"], + "@components/*": ["./src/components/*"], + "@helpers/*": ["./src/helpers/*"], + "@kernel/*": ["./src/kernel/*"], + "@modules/*": ["./src/modules/*"], + "@services/*": ["./src/services/*"], + "@workers/*": ["./src/workers/*"], + "@functional/*": ["./src/helpers/functional/*"] + }, + "types": [ + "vite/client", + "unplugin-icons/types/vue", + "./src/types/kernel.d.ts" + ] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }], + "vueCompilerOptions": { + "jsxTemplates": true, + "experimentalRfc436": true + } +} diff --git a/packages/hoppscotch-desktop/tsconfig.node.json b/packages/hoppscotch-desktop/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/packages/hoppscotch-desktop/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/hoppscotch-desktop/vite.config.ts b/packages/hoppscotch-desktop/vite.config.ts new file mode 100644 index 0000000..75d2f1e --- /dev/null +++ b/packages/hoppscotch-desktop/vite.config.ts @@ -0,0 +1,81 @@ +import { defineConfig } from "vite" +import vue from "@vitejs/plugin-vue" +import Icons from "unplugin-icons/vite" +import IconResolver from "unplugin-icons/resolver" +import Components from "unplugin-vue-components/vite" +import path from "path" + +// @ts-expect-error process is a nodejs global +const host = process.env.TAURI_DEV_HOST + +export default defineConfig(async () => ({ + plugins: [ + vue(), + Components({ + dts: "./src/components.d.ts", + resolvers: [ + IconResolver({ + prefix: "icon", + }), + (compName: string) => { + if (compName.startsWith("Hopp")) + return { name: compName, from: "@hoppscotch/ui" } + return undefined + }, + ], + types: [ + { + from: "vue-tippy", + names: ["Tippy"], + }, + ], + include: [/\.vue$/, /\.vue\?vue/], + dirs: ["src/components"], + }), + Icons({ + compiler: "vue3", + }), + ], + + resolve: { + alias: { + "~": path.resolve(__dirname, "src"), + "~/": path.resolve(__dirname, "src/"), + "@hoppscotch/common": "@hoppscotch/common/src", + }, + dedupe: ["vue"], + }, + + optimizeDeps: { + include: ["@hoppscotch/kernel"], + }, + + build: { + rollupOptions: { + output: { + manualChunks: { + kernel: ["@hoppscotch/kernel"], + ui: ["@hoppscotch/ui"], + }, + }, + }, + }, + + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + clearScreen: false, + server: { + port: 1420, + strictPort: true, + host: "127.0.0.1", + hmr: host + ? { + protocol: "ws", + host, + port: 1421, + } + : undefined, + watch: { + ignored: ["**/src-tauri/**"], + }, + }, +})) diff --git a/packages/hoppscotch-js-sandbox/.gitignore b/packages/hoppscotch-js-sandbox/.gitignore new file mode 100644 index 0000000..3ca1e54 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/.gitignore @@ -0,0 +1,5 @@ +node_modules +coverage +.husky/ +dist +lib \ No newline at end of file diff --git a/packages/hoppscotch-js-sandbox/.prettierignore b/packages/hoppscotch-js-sandbox/.prettierignore new file mode 100644 index 0000000..fac841e --- /dev/null +++ b/packages/hoppscotch-js-sandbox/.prettierignore @@ -0,0 +1,8 @@ +.dependabot +.github +.hoppscotch +.vscode +package-lock.json +node_modules +dist +lib \ No newline at end of file diff --git a/packages/hoppscotch-js-sandbox/.prettierrc.cjs b/packages/hoppscotch-js-sandbox/.prettierrc.cjs new file mode 100644 index 0000000..a43ab29 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/.prettierrc.cjs @@ -0,0 +1,3 @@ +module.exports = { + semi: false, +} diff --git a/packages/hoppscotch-js-sandbox/README.md b/packages/hoppscotch-js-sandbox/README.md new file mode 100644 index 0000000..8da89bc --- /dev/null +++ b/packages/hoppscotch-js-sandbox/README.md @@ -0,0 +1,70 @@ + +
+ +# Hoppscotch JavaScript Sandbox ALPHA + +
+ +This package deals with providing a JavaScript sandbox for executing various security sensitive external scripts. + +## How does this work? + +This package makes use of [quickjs-emscripten](https://www.npmjs.com/package/quickjs-emscripten) for building sandboxes for running external code on Hoppscotch. + +Currently implemented sandboxes: +- Hoppscotch Test Scripts +- Hoppscotch Pre Request Scripts + +## Development + +1. Clone the repository + +``` +git clone https://github.com/hoppscotch/hoppscotch +``` + +2. Install the package dependencies + +``` +pnpm install +``` + +3. Navigate to the [package folder](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-js-sandbox) +``` +cd hoppscotch/packages/hoppscotch-js-sandbox +``` + + +4. Build the package + +```bash +pnpm run build +``` + +5. Run the test suite + +```bash +pnpm run test +``` + +## Versioning +This project follows [Semantic Versioning](https://semver.org/) but as the project is still pre-1.0. The code and the public exposed API should not be considered to be fixed and stable. Things can change at any time! + +## License +This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see [`LICENSE`](../../LICENSE) for more details. + +
+
+
+ + ###### built with ❤︎ by the [Hoppscotch Team](https://github.com/hoppscotch) and [contributors](https://github.com/hoppscotch/hoppscotch/graphs/contributors). + +
diff --git a/packages/hoppscotch-js-sandbox/eslint.config.cjs b/packages/hoppscotch-js-sandbox/eslint.config.cjs new file mode 100644 index 0000000..436d0b0 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/eslint.config.cjs @@ -0,0 +1,70 @@ +const { FlatCompat } = require("@eslint/eslintrc") +const js = require("@eslint/js") +const tsParser = require("@typescript-eslint/parser") +const typescriptEslintPlugin = require("@typescript-eslint/eslint-plugin") +const prettierPlugin = require("eslint-plugin-prettier") +const globals = require("globals") + +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}) + +module.exports = [ + { + ignores: [ + "dist/**", + "node_modules/**", + "**/*.d.ts", + "eslint.config.cjs", + ".prettierrc.cjs", + "src/bootstrap-code/**", + ], + }, + ...compat.extends( + "eslint:recommended", + "plugin:prettier/recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended" + ), + { + files: ["**/*.ts", "**/*.js"], + linterOptions: { + reportUnusedDisableDirectives: false, + }, + languageOptions: { + parser: tsParser, + sourceType: "module", + ecmaVersion: 2021, + globals: { + ...globals.node, + ...globals.jest, + ...globals.browser, + }, + }, + plugins: { + "@typescript-eslint": typescriptEslintPlugin, + prettier: prettierPlugin, + }, + rules: { + semi: [2, "never"], + "prettier/prettier": ["warn", { semi: false, trailingComma: "es5" }], + "no-undef": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + args: "all", + argsIgnorePattern: "^_", + caughtErrors: "all", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + varsIgnorePattern: "^_", + ignoreRestSiblings: true, + }, + ], + }, + }, +] diff --git a/packages/hoppscotch-js-sandbox/index.d.ts b/packages/hoppscotch-js-sandbox/index.d.ts new file mode 100644 index 0000000..c7ee163 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/index.d.ts @@ -0,0 +1,2 @@ +export { default } from "./dist/types/index.d.ts" +export * from "./dist/types/index.d.ts" diff --git a/packages/hoppscotch-js-sandbox/node.d.ts b/packages/hoppscotch-js-sandbox/node.d.ts new file mode 100644 index 0000000..32567df --- /dev/null +++ b/packages/hoppscotch-js-sandbox/node.d.ts @@ -0,0 +1,2 @@ +export { default } from "./dist/node/index.d.ts" +export * from "./dist/node/index.d.ts" diff --git a/packages/hoppscotch-js-sandbox/package.json b/packages/hoppscotch-js-sandbox/package.json new file mode 100644 index 0000000..61144cb --- /dev/null +++ b/packages/hoppscotch-js-sandbox/package.json @@ -0,0 +1,96 @@ +{ + "name": "@hoppscotch/js-sandbox", + "version": "2.1.0", + "description": "JavaScript sandboxes for running external scripts used by Hoppscotch clients", + "type": "module", + "files": [ + "dist", + "*.d.ts" + ], + "exports": { + ".": { + "types": "./dist/types/index.d.ts" + }, + "./web": { + "types": "./dist/web.d.ts", + "import": "./dist/web.js", + "require": "./dist/web.cjs" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.js", + "require": "./dist/node.cjs" + }, + "./scripting": { + "types": "./dist/scripting.d.ts", + "import": "./dist/scripting.js", + "require": "./dist/scripting.cjs" + } + }, + "types": "./index.d.ts", + "engines": { + "node": ">=22" + }, + "scripts": { + "lint": "eslint .", + "lintfix": "eslint --fix .", + "test": "vitest run", + "build": "vite build && tsc --emitDeclarationOnly", + "clean": "pnpm tsc --build --clean", + "postinstall": "pnpm run build", + "prepublish": "pnpm run build", + "do-lint": "pnpm run lint", + "do-lintfix": "pnpm run lintfix", + "do-typecheck": "pnpm exec tsc --noEmit", + "do-build-prod": "pnpm run build", + "do-test": "pnpm run test" + }, + "keywords": [ + "hoppscotch", + "sandbox", + "js-sandbox", + "apis", + "test-runner" + ], + "author": "Hoppscotch (support@hoppscotch.io)", + "license": "MIT", + "dependencies": { + "@hoppscotch/data": "workspace:^", + "@types/lodash-es": "4.17.12", + "acorn": "8.17.0", + "chai": "6.2.2", + "faraday-cage": "0.1.0", + "fp-ts": "2.16.11", + "lodash": "4.18.1", + "lodash-es": "4.18.1" + }, + "devDependencies": { + "@digitak/esrun": "3.2.26", + "@eslint/eslintrc": "3.3.5", + "@eslint/js": "9.39.2", + "@relmify/jest-fp-ts": "2.1.1", + "@types/chai": "5.2.3", + "@types/jest": "30.0.0", + "@types/lodash": "4.17.24", + "@types/node": "24.10.1", + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "eslint": "9.39.2", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-prettier": "5.5.6", + "globals": "16.5.0", + "io-ts": "2.2.22", + "prettier": "3.8.4", + "typescript": "5.9.3", + "vite": "7.3.2", + "vitest": "4.1.9" + }, + "peerDependencies": { + "isolated-vm": "6.1.2" + }, + "peerDependenciesMeta": { + "isolated-vm": { + "optional": true + } + } +} diff --git a/packages/hoppscotch-js-sandbox/scripting.d.ts b/packages/hoppscotch-js-sandbox/scripting.d.ts new file mode 100644 index 0000000..876119f --- /dev/null +++ b/packages/hoppscotch-js-sandbox/scripting.d.ts @@ -0,0 +1 @@ +export * from "./dist/scripting" diff --git a/packages/hoppscotch-js-sandbox/setupFiles.ts b/packages/hoppscotch-js-sandbox/setupFiles.ts new file mode 100644 index 0000000..11b1985 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/setupFiles.ts @@ -0,0 +1,15 @@ +// Vitest doesn't work without globals +// Ref: https://github.com/relmify/jest-fp-ts/issues/11 + +import decodeMatchers from "@relmify/jest-fp-ts/dist/decodeMatchers" +import eitherMatchers from "@relmify/jest-fp-ts/dist/eitherMatchers" +import optionMatchers from "@relmify/jest-fp-ts/dist/optionMatchers" +import theseMatchers from "@relmify/jest-fp-ts/dist/theseMatchers" +import eitherOrTheseMatchers from "@relmify/jest-fp-ts/dist/eitherOrTheseMatchers" +import { expect } from "vitest" + +expect.extend(decodeMatchers.matchers) +expect.extend(eitherMatchers.matchers) +expect.extend(optionMatchers.matchers) +expect.extend(theseMatchers.matchers) +expect.extend(eitherOrTheseMatchers.matchers) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/encrypt-decrypt.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/encrypt-decrypt.spec.ts new file mode 100644 index 0000000..345798c --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/encrypt-decrypt.spec.ts @@ -0,0 +1,261 @@ +import { describe, it, expect } from "vitest" +import { FaradayCage } from "faraday-cage" +import { customCryptoModule } from "../../../cage-modules" + +const runCage = async (script: string) => { + const cage = await FaradayCage.create() + return cage.runCode(script, [ + customCryptoModule({ + cryptoImpl: globalThis.crypto, + }), + ]) +} + +describe("crypto.subtle.encrypt/decrypt", () => { + it("encrypts and decrypts with AES-GCM", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ) + + const data = [116, 101, 115, 116, 32, 100, 97, 116, 97] // "test data" + const iv = [1,2,3,4,5,6,7,8,9,10,11,12] + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + data + ) + + if (!Array.isArray(encrypted) || encrypted.length === 0) { + throw new Error('encrypted did not return a byte array') + } + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + encrypted + ) + + if (JSON.stringify(decrypted) !== JSON.stringify(data)) { + throw new Error('decrypted bytes mismatch') + } + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("encrypts and decrypts with AES-CBC", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'AES-CBC', length: 256 }, + true, + ['encrypt', 'decrypt'] + ) + + const data = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100] // "hello world" + const iv = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16] + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-CBC', iv }, + key, + data + ) + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-CBC', iv }, + key, + encrypted + ) + + if (JSON.stringify(decrypted) !== JSON.stringify(data)) { + throw new Error('decrypted bytes mismatch') + } + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("handles empty data", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 128 }, + true, + ['encrypt', 'decrypt'] + ) + + const data = [] + const iv = [1,2,3,4,5,6,7,8,9,10,11,12] + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + data + ) + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + encrypted + ) + + if (!Array.isArray(decrypted) || decrypted.length !== 0) { + throw new Error('expected empty decrypted array') + } + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("rejects decrypt with invalid key usage", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt'] // No decrypt usage + ) + + const data = [116, 101, 115, 116] + const iv = [1,2,3,4,5,6,7,8,9,10,11,12] + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + data + ) + + let rejected = false + try { + await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted) + } catch (_) { + rejected = true + } + if (!rejected) throw new Error('expected decrypt to reject') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("rejects decrypt with wrong IV (AES-GCM)", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ) + + const data = [116, 101, 115, 116] + const iv1 = [1,2,3,4,5,6,7,8,9,10,11,12] + const iv2 = [12,11,10,9,8,7,6,5,4,3,2,1] + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv1 }, + key, + data + ) + + let rejected = false + try { + await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv2 }, key, encrypted) + } catch (_) { + rejected = true + } + if (!rejected) throw new Error('expected decrypt to reject with wrong IV') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("encrypts and decrypts with RSA-OAEP", async () => { + const script = ` + (async () => { + const keyPair = await crypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: [1, 0, 1], + hash: 'SHA-256' + }, + true, + ['encrypt', 'decrypt'] + ) + + const data = [116, 101, 115, 116, 32, 100, 97, 116, 97] // "test data" + + const encrypted = await crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + keyPair.publicKey, + data + ) + + if (!Array.isArray(encrypted) || encrypted.length === 0) { + throw new Error('encrypted did not return a byte array') + } + + const decrypted = await crypto.subtle.decrypt( + { name: 'RSA-OAEP' }, + keyPair.privateKey, + encrypted + ) + + if (JSON.stringify(decrypted) !== JSON.stringify(data)) { + throw new Error('decrypted bytes mismatch') + } + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("encrypts and decrypts with AES-CTR", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'AES-CTR', length: 256 }, + true, + ['encrypt', 'decrypt'] + ) + + const data = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100] // "hello world" + const counter = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16] + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-CTR', counter: counter, length: 64 }, + key, + data + ) + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-CTR', counter: counter, length: 64 }, + key, + encrypted + ) + + if (JSON.stringify(decrypted) !== JSON.stringify(data)) { + throw new Error('decrypted bytes mismatch') + } + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/key-operations.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/key-operations.spec.ts new file mode 100644 index 0000000..dbeeb3d --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/key-operations.spec.ts @@ -0,0 +1,829 @@ +import { describe, expect, test } from "vitest" +import { runTestWithEmptyEnv } from "~/utils/test-helpers" + +/** + * Tests for crypto.subtle key-based operations: + * - generateKey + * - importKey + * - exportKey + * - encrypt/decrypt + * - sign/verify + */ + +describe("crypto.subtle.generateKey()", () => { + test("should generate HMAC key for signing", () => { + const script = ` + const key = await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign", "verify"] + ) + + hopp.test("HMAC key generated successfully", () => { + hopp.expect(key).toBeType("object") + hopp.expect(key.type).toBe("secret") + hopp.expect(key.extractable).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "HMAC key generated successfully", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should generate AES-GCM key for encryption", () => { + const script = ` + const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ) + + hopp.test("AES-GCM key generated successfully", () => { + hopp.expect(key).toBeType("object") + hopp.expect(key.type).toBe("secret") + hopp.expect(key.extractable).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "AES-GCM key generated successfully", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) +}) + +describe("crypto.subtle.importKey() and exportKey()", () => { + test("should import raw HMAC key", () => { + // Create a test key (32 bytes of data) + const rawKeyBytes = Array.from({ length: 32 }, (_, i) => i) + + const script = ` + const rawKeyData = ${JSON.stringify(rawKeyBytes)} + + const key = await crypto.subtle.importKey( + "raw", + rawKeyData, + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign", "verify"] + ) + + hopp.test("Raw HMAC key imported successfully", () => { + hopp.expect(key).toBeType("object") + hopp.expect(key.type).toBe("secret") + hopp.expect(key.extractable).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Raw HMAC key imported successfully", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should export key in raw format", () => { + const rawKeyBytes = Array.from({ length: 32 }, (_, i) => i) + + const script = ` + const rawKeyData = ${JSON.stringify(rawKeyBytes)} + + const key = await crypto.subtle.importKey( + "raw", + rawKeyData, + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign", "verify"] + ) + + const exportedKey = await crypto.subtle.exportKey("raw", key) + const exportedArray = Array.from(exportedKey) + + hopp.test("Key exported successfully in raw format", () => { + hopp.expect(exportedArray.length).toBe(32) + hopp.expect(exportedArray[0]).toBe(0) + hopp.expect(exportedArray[31]).toBe(31) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Key exported successfully in raw format", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should import and export key in JWK format", () => { + const script = ` + // Generate a key first + const generatedKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ) + + // Export as JWK + const jwk = await crypto.subtle.exportKey("jwk", generatedKey) + + hopp.test("Key exported as JWK successfully", () => { + hopp.expect(jwk).toBeType("object") + hopp.expect(jwk.kty).toBe("oct") + hopp.expect(jwk.k).toBeType("string") + hopp.expect(jwk.alg).toBe("A256GCM") + }) + + // Re-import the JWK + const reimportedKey = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "AES-GCM" }, + true, + ["encrypt", "decrypt"] + ) + + hopp.test("JWK reimported successfully", () => { + hopp.expect(reimportedKey).toBeType("object") + hopp.expect(reimportedKey.type).toBe("secret") + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Key exported as JWK successfully", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "JWK reimported successfully", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) +}) + +describe("crypto.subtle.encrypt() and decrypt()", () => { + test("should encrypt and decrypt with AES-GCM", () => { + const plainTextBytes = Array.from(new TextEncoder().encode("Hello, World!")) + const ivBytes = Array.from({ length: 12 }, (_, i) => i) // 12-byte IV for AES-GCM + + const script = ` + const plainText = ${JSON.stringify(plainTextBytes)} + const iv = ${JSON.stringify(ivBytes)} + + // Generate an AES-GCM key + const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ) + + // Encrypt the plaintext + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv }, + key, + plainText + ) + + hopp.test("Encryption produces ciphertext", () => { + hopp.expect(ciphertext.length).toBeType("number") + hopp.expect(ciphertext.length > 0).toBe(true) + }) + + // Decrypt the ciphertext + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: iv }, + key, + ciphertext + ) + + const decryptedText = new TextDecoder().decode(new Uint8Array(decrypted)) + + hopp.test("Decryption recovers original plaintext", () => { + hopp.expect(decryptedText).toBe("Hello, World!") + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Encryption produces ciphertext", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "Decryption recovers original plaintext", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("encrypted data differs from plaintext", () => { + const plainTextBytes = Array.from( + new TextEncoder().encode("Secret message") + ) + const ivBytes = Array.from({ length: 12 }, (_, i) => i + 100) + + const script = ` + const plainText = ${JSON.stringify(plainTextBytes)} + const iv = ${JSON.stringify(ivBytes)} + + const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ) + + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv }, + key, + plainText + ) + + // Compare ciphertext to plaintext - they should be different + const ciphertextArray = Array.from(ciphertext) + const isDifferent = plainText.some((byte, i) => ciphertextArray[i] !== byte) + + hopp.test("Ciphertext differs from plaintext", () => { + hopp.expect(isDifferent).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Ciphertext differs from plaintext", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) +}) + +describe("crypto.subtle.sign() and verify()", () => { + test("should sign and verify with HMAC", () => { + const dataBytes = Array.from(new TextEncoder().encode("Data to sign")) + + const script = ` + const data = ${JSON.stringify(dataBytes)} + + // Generate HMAC key + const key = await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign", "verify"] + ) + + // Sign the data + const signature = await crypto.subtle.sign( + "HMAC", + key, + data + ) + + hopp.test("Signature is produced", () => { + hopp.expect(signature.length).toBe(32) // SHA-256 produces 32-byte signatures + hopp.expect(signature.byteLength).toBe(32) + }) + + // Verify the signature + const isValid = await crypto.subtle.verify( + "HMAC", + key, + signature, + data + ) + + hopp.test("Signature verification succeeds", () => { + hopp.expect(isValid).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Signature is produced", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "Signature verification succeeds", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("verification fails with tampered data", () => { + const dataBytes = Array.from(new TextEncoder().encode("Original data")) + const tamperedDataBytes = Array.from( + new TextEncoder().encode("Tampered data") + ) + + const script = ` + const originalData = ${JSON.stringify(dataBytes)} + const tamperedData = ${JSON.stringify(tamperedDataBytes)} + + const key = await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign", "verify"] + ) + + // Sign the original data + const signature = await crypto.subtle.sign( + "HMAC", + key, + originalData + ) + + // Try to verify with tampered data + const isValid = await crypto.subtle.verify( + "HMAC", + key, + signature, + tamperedData + ) + + hopp.test("Verification fails with tampered data", () => { + hopp.expect(isValid).toBe(false) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Verification fails with tampered data", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("verification fails with wrong key", () => { + const dataBytes = Array.from(new TextEncoder().encode("Protected data")) + + const script = ` + const data = ${JSON.stringify(dataBytes)} + + // Generate two different keys + const key1 = await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign", "verify"] + ) + + const key2 = await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign", "verify"] + ) + + // Sign with key1 + const signature = await crypto.subtle.sign( + "HMAC", + key1, + data + ) + + // Try to verify with key2 + const isValid = await crypto.subtle.verify( + "HMAC", + key2, + signature, + data + ) + + hopp.test("Verification fails with wrong key", () => { + hopp.expect(isValid).toBe(false) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Verification fails with wrong key", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) +}) + +describe("crypto.getRandomValues() validation", () => { + test("should reject arrays exceeding 65536 bytes", () => { + const script = ` + let errorThrown = false + let errorMessage = "" + + try { + const largeArray = new Array(65537).fill(0) + crypto.getRandomValues(largeArray) + } catch (e) { + errorThrown = true + errorMessage = e.message + } + + hopp.test("Throws error for oversized array", () => { + hopp.expect(errorThrown).toBe(true) + hopp.expect(errorMessage).toInclude("65536") + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Throws error for oversized array", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should accept array at exactly 65536 bytes", () => { + const script = ` + const maxArray = new Array(65536).fill(0) + const result = crypto.getRandomValues(maxArray) + + hopp.test("Accepts 65536-byte array", () => { + hopp.expect(result.length).toBe(65536) + // Check that at least some values were filled + const hasNonZero = result.some(v => v !== 0) + hopp.expect(hasNonZero).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Accepts 65536-byte array", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) +}) + +describe("crypto.subtle.deriveBits() and deriveKey()", () => { + test("should derive bits using PBKDF2", () => { + const script = ` + // Import a password as key material + const passwordKey = await crypto.subtle.importKey( + "raw", + [112, 97, 115, 115, 119, 111, 114, 100], // "password" + { name: "PBKDF2" }, + false, + ["deriveBits"] + ) + + // Derive 256 bits + const derivedBits = await crypto.subtle.deriveBits( + { + name: "PBKDF2", + hash: "SHA-256", + salt: new Array(16).fill(1), // 16-byte salt + iterations: 1000 + }, + passwordKey, + 256 + ) + + hopp.test("PBKDF2 deriveBits produces correct length", () => { + hopp.expect(derivedBits.length).toBe(32) // 256 bits = 32 bytes + // Check that we got non-zero data + hopp.expect(derivedBits.some(b => b !== 0)).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "PBKDF2 deriveBits produces correct length", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should derive key using PBKDF2", () => { + const script = ` + // Import password as key material + const passwordKey = await crypto.subtle.importKey( + "raw", + [109, 121, 112, 97, 115, 115], // "mypass" + { name: "PBKDF2" }, + false, + ["deriveKey"] + ) + + // Derive an AES-GCM key + const derivedKey = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + hash: "SHA-256", + salt: new Array(16).fill(2), + iterations: 1000 + }, + passwordKey, + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ) + + hopp.test("PBKDF2 deriveKey produces usable AES key", () => { + hopp.expect(derivedKey).toBeType("object") + hopp.expect(derivedKey.type).toBe("secret") + hopp.expect(derivedKey.extractable).toBe(true) + }) + + // Test that the derived key actually works + const testData = [116, 101, 115, 116] // "test" + const iv = new Array(12).fill(0) + + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv }, + derivedKey, + testData + ) + + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: iv }, + derivedKey, + encrypted + ) + + hopp.test("Derived key can encrypt and decrypt", () => { + hopp.expect(JSON.stringify(decrypted)).toBe(JSON.stringify(testData)) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "PBKDF2 deriveKey produces usable AES key", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "Derived key can encrypt and decrypt", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) +}) + +describe("crypto.subtle.wrapKey() and unwrapKey()", () => { + test("should wrap and unwrap AES key with AES-KW", () => { + const script = ` + // Generate keys + const aesKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ) + + const wrappingKey = await crypto.subtle.generateKey( + { name: "AES-KW", length: 256 }, + true, + ["wrapKey", "unwrapKey"] + ) + + // Wrap the AES key + const wrappedKey = await crypto.subtle.wrapKey( + "raw", + aesKey, + wrappingKey, + "AES-KW" + ) + + hopp.test("Key wrapping produces wrapped data", () => { + hopp.expect(wrappedKey).toBeType("object") + hopp.expect(wrappedKey.length).toBe(40) // AES-256 key (32) + padding (8) + }) + + // Unwrap the key + const unwrappedKey = await crypto.subtle.unwrapKey( + "raw", + wrappedKey, + wrappingKey, + "AES-KW", + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ) + + hopp.test("Unwrapped key is valid", () => { + hopp.expect(unwrappedKey).toBeType("object") + hopp.expect(unwrappedKey.type).toBe("secret") + hopp.expect(unwrappedKey.extractable).toBe(true) + }) + + // Test that unwrapped key works + const testData = [117, 110, 119, 114, 97, 112] // "unwrap" + const iv = new Array(12).fill(3) + + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv }, + unwrappedKey, + testData + ) + + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: iv }, + unwrappedKey, + encrypted + ) + + hopp.test("Unwrapped key functions correctly", () => { + hopp.expect(JSON.stringify(decrypted)).toBe(JSON.stringify(testData)) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Key wrapping produces wrapped data", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "Unwrapped key is valid", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "Unwrapped key functions correctly", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should wrap and unwrap key with JWK format", () => { + const script = ` + // Generate an AES key + const aesKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 128 }, + true, + ["encrypt", "decrypt"] + ) + + // Export to JWK for comparison + const originalJwk = await crypto.subtle.exportKey("jwk", aesKey) + + // Generate wrapping key + const wrappingKey = await crypto.subtle.generateKey( + { name: "AES-KW", length: 128 }, + true, + ["wrapKey", "unwrapKey"] + ) + + // Wrap the key as JWK + const wrappedKey = await crypto.subtle.wrapKey( + "jwk", + aesKey, + wrappingKey, + "AES-KW" + ) + + hopp.test("JWK wrapping produces data", () => { + hopp.expect(wrappedKey).toBeType("object") + hopp.expect(wrappedKey.length > 0).toBe(true) + }) + + // Unwrap the JWK + const unwrappedKey = await crypto.subtle.unwrapKey( + "jwk", + wrappedKey, + wrappingKey, + "AES-KW", + { name: "AES-GCM" }, + true, + ["encrypt", "decrypt"] + ) + + hopp.test("Unwrapped JWK key is valid", () => { + hopp.expect(unwrappedKey).toBeType("object") + hopp.expect(unwrappedKey.type).toBe("secret") + }) + + // Export unwrapped key and compare + const unwrappedJwk = await crypto.subtle.exportKey("jwk", unwrappedKey) + + hopp.test("Unwrapped key matches original", () => { + hopp.expect(unwrappedJwk.kty).toBe(originalJwk.kty) + hopp.expect(unwrappedJwk.alg).toBe(originalJwk.alg) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "JWK wrapping produces data", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "Unwrapped JWK key is valid", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "Unwrapped key matches original", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/random-values.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/random-values.spec.ts new file mode 100644 index 0000000..84028b4 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/random-values.spec.ts @@ -0,0 +1,243 @@ +import { describe, expect, test } from "vitest" +import { FaradayCage } from "faraday-cage" +import { customCryptoModule } from "~/cage-modules" +import { runTestWithEmptyEnv } from "~/utils/test-helpers" + +describe("crypto.getRandomValues()", () => { + test("should generate random values for array", () => { + const script = ` + const array = new Array(10).fill(0) + const result = crypto.getRandomValues(array) + + // Check that values were modified + const hasNonZero = result.some(v => v !== 0) + + hopp.test("Random values generated", () => { + hopp.expect(result.length).toBe(10) + hopp.expect(hasNonZero).toBe(true) + hopp.expect(result).toBe(array) // Should mutate in place + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Random values generated", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should generate different values on multiple calls", () => { + const script = ` + const array1 = new Array(32).fill(0) + const array2 = new Array(32).fill(0) + + crypto.getRandomValues(array1) + crypto.getRandomValues(array2) + + // Arrays should be different + const isDifferent = array1.some((v, i) => v !== array2[i]) + + hopp.test("Random values are different", () => { + hopp.expect(isDifferent).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Random values are different", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + }) + + test("should handle different array sizes", () => { + const script = ` + const sizes = [1, 16, 256] + const results = [] + + for (const size of sizes) { + const array = new Array(size).fill(0) + crypto.getRandomValues(array) + results.push({ + size, + hasRandomValues: array.some(v => v !== 0), + length: array.length + }) + } + + hopp.test("Handles various array sizes", () => { + for (const result of results) { + hopp.expect(result.length).toBe(result.size) + hopp.expect(result.hasRandomValues).toBe(true) + } + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Handles various array sizes", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should return values in valid byte range (0-255)", () => { + const script = ` + const array = new Array(100).fill(0) + crypto.getRandomValues(array) + + const allInRange = array.every(v => v >= 0 && v <= 255) + const hasVariety = new Set(array).size > 1 + + hopp.test("Values are valid bytes", () => { + hopp.expect(allInRange).toBe(true) + hopp.expect(hasVariety).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Values are valid bytes", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) +}) + +describe("crypto.randomUUID()", () => { + test("should generate valid UUID v4 format", () => { + const script = ` + const uuid = crypto.randomUUID() + + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + + hopp.test("UUID format is valid", () => { + hopp.expect(typeof uuid).toBe("string") + hopp.expect(uuid.length).toBe(36) + hopp.expect(uuidPattern.test(uuid)).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "UUID format is valid", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should generate unique UUIDs", () => { + const script = ` + const uuids = [] + for (let i = 0; i < 100; i++) { + uuids.push(crypto.randomUUID()) + } + + const uniqueUuids = new Set(uuids) + + hopp.test("UUIDs are unique", () => { + hopp.expect(uniqueUuids.size).toBe(100) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "UUIDs are unique", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + }) + + test("should generate UUIDs with correct version and variant", () => { + const script = ` + const uuid = crypto.randomUUID() + const parts = uuid.split('-') + + // Version should be 4 (random) + const version = parts[2][0] + + // Variant should be 8, 9, a, or b (RFC 4122) + const variant = parts[3][0].toLowerCase() + + hopp.test("UUID version and variant are correct", () => { + hopp.expect(version).toBe('4') + hopp.expect(['8', '9', 'a', 'b'].includes(variant)).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "UUID version and variant are correct", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should still work when cryptoImpl.randomUUID is missing (polyfill)", async () => { + const cage = await FaradayCage.create() + + const cryptoImplWithoutRandomUUID = { + getRandomValues: globalThis.crypto.getRandomValues.bind( + globalThis.crypto + ), + subtle: globalThis.crypto.subtle, + } as unknown as Crypto + + const script = ` + (async () => { + const uuid = crypto.randomUUID() + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + if (typeof uuid !== 'string' || uuid.length !== 36) throw new Error('uuid shape invalid') + if (!uuidPattern.test(uuid)) throw new Error('uuid format invalid') + })() + ` + + const result = await cage.runCode(script, [ + customCryptoModule({ + cryptoImpl: cryptoImplWithoutRandomUUID, + }), + ]) + + expect(result.type).toBe("ok") + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/sign-verify.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/sign-verify.spec.ts new file mode 100644 index 0000000..594132d --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/sign-verify.spec.ts @@ -0,0 +1,207 @@ +import { describe, it, expect } from "vitest" +import { FaradayCage } from "faraday-cage" +import { customCryptoModule } from "../../../cage-modules" + +const runCage = async (script: string) => { + const cage = await FaradayCage.create() + return cage.runCode(script, [ + customCryptoModule({ + cryptoImpl: globalThis.crypto, + }), + ]) +} + +describe("crypto.subtle.sign/verify", () => { + it("signs and verifies with HMAC", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + true, + ['sign', 'verify'] + ) + + const data = [109, 101, 115, 115, 97, 103, 101] // "message" + + const signature = await crypto.subtle.sign('HMAC', key, data) + if (!Array.isArray(signature) || signature.length === 0) throw new Error('signature shape mismatch') + + const isValid = await crypto.subtle.verify('HMAC', key, signature, data) + if (isValid !== true) throw new Error('expected signature to verify') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("signs and verifies with ECDSA", async () => { + const script = ` + (async () => { + const keyPair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'] + ) + + const data = [116, 101, 115, 116, 32, 109, 101, 115, 115, 97, 103, 101] // "test message" + + const signature = await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + keyPair.privateKey, + data + ) + + const isValid = await crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + keyPair.publicKey, + signature, + data + ) + + if (isValid !== true) throw new Error('expected ECDSA signature to verify') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("signs and verifies with RSA-PSS", async () => { + const script = ` + (async () => { + const keyPair = await crypto.subtle.generateKey( + { name: 'RSA-PSS', modulusLength: 2048, publicExponent: [1, 0, 1], hash: 'SHA-256' }, + true, + ['sign', 'verify'] + ) + + const data = [114, 115, 97, 32, 116, 101, 115, 116] // "rsa test" + + const signature = await crypto.subtle.sign( + { name: 'RSA-PSS', saltLength: 32 }, + keyPair.privateKey, + data + ) + + const isValid = await crypto.subtle.verify( + { name: 'RSA-PSS', saltLength: 32 }, + keyPair.publicKey, + signature, + data + ) + + if (isValid !== true) throw new Error('expected RSA-PSS signature to verify') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("returns false for verification with different data", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + true, + ['sign', 'verify'] + ) + + const data1 = [100, 97, 116, 97, 49] // "data1" + const data2 = [100, 97, 116, 97, 50] // "data2" + + const signature = await crypto.subtle.sign('HMAC', key, data1) + const isValid = await crypto.subtle.verify('HMAC', key, signature, data2) + if (isValid !== false) throw new Error('expected verification to be false with different data') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("returns false for verification with wrong key", async () => { + const script = ` + (async () => { + const key1 = await crypto.subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + true, + ['sign', 'verify'] + ) + + const key2 = await crypto.subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + true, + ['sign', 'verify'] + ) + + const data = [116, 101, 115, 116] + const signature = await crypto.subtle.sign('HMAC', key1, data) + const isValid = await crypto.subtle.verify('HMAC', key2, signature, data) + + if (isValid !== false) throw new Error('expected verification to be false with wrong key') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("handles empty data", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + true, + ['sign', 'verify'] + ) + + const data = [] + const signature = await crypto.subtle.sign('HMAC', key, data) + const isValid = await crypto.subtle.verify('HMAC', key, signature, data) + if (isValid !== true) throw new Error('expected verification to succeed for empty data') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("signs and verifies with RSASSA-PKCS1-v1_5", async () => { + const script = ` + (async () => { + const keyPair = await crypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: [1, 0, 1], + hash: 'SHA-256' + }, + true, + ['sign', 'verify'] + ) + + const data = [114, 115, 97, 32, 116, 101, 115, 116] // "rsa test" + + const signature = await crypto.subtle.sign( + { name: 'RSASSA-PKCS1-v1_5' }, + keyPair.privateKey, + data + ) + + const isValid = await crypto.subtle.verify( + { name: 'RSASSA-PKCS1-v1_5' }, + keyPair.publicKey, + signature, + data + ) + + if (isValid !== true) throw new Error('expected RSASSA-PKCS1-v1_5 signature to verify') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/subtle-digest.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/subtle-digest.spec.ts new file mode 100644 index 0000000..adaa94e --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/subtle-digest.spec.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from "vitest" +import { runTestWithEmptyEnv } from "~/utils/test-helpers" + +// Pre-compute test data outside the sandbox +const helloWorldBytes = Array.from(new TextEncoder().encode("Hello, World!")) +const testBytes = Array.from(new TextEncoder().encode("test")) +const emptyBytes: number[] = [] +const consistentDataBytes = Array.from( + new TextEncoder().encode("consistent data") +) +const data1Bytes = Array.from(new TextEncoder().encode("data1")) +const data2Bytes = Array.from(new TextEncoder().encode("data2")) + +describe("crypto.subtle.digest()", () => { + test("should compute SHA-256 hash of string data", () => { + const script = ` + const dataArray = ${JSON.stringify(helloWorldBytes)} + + const hashBuffer = await crypto.subtle.digest("SHA-256", dataArray) + + // Convert to hex string for verification + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + + // Expected SHA-256 hash of "Hello, World!" + const expectedHash = "dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f" + + hopp.test("SHA-256 digest is correct", () => { + hopp.expect(hashHex).toBe(expectedHash) + hopp.expect(hashBuffer.byteLength).toBe(32) // SHA-256 produces 32 bytes + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "SHA-256 digest is correct", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should compute SHA-1 hash", () => { + const script = ` + const dataArray = ${JSON.stringify(testBytes)} + + const hashBuffer = await crypto.subtle.digest("SHA-1", dataArray) + + // Expected SHA-1 hash of "test" + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + const expectedHash = "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3" + + hopp.test("SHA-1 digest is correct", () => { + hopp.expect(hashHex).toBe(expectedHash) + hopp.expect(hashBuffer.byteLength).toBe(20) // SHA-1 produces 20 bytes + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "SHA-1 digest is correct", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should compute SHA-384 hash", () => { + const script = ` + const dataArray = ${JSON.stringify(testBytes)} + + const hashBuffer = await crypto.subtle.digest("SHA-384", dataArray) + + hopp.test("SHA-384 produces correct byte length", () => { + hopp.expect(hashBuffer.byteLength).toBe(48) // SHA-384 produces 48 bytes + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "SHA-384 produces correct byte length", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + }) + + test("should compute SHA-512 hash", () => { + const script = ` + const dataArray = ${JSON.stringify(testBytes)} + + const hashBuffer = await crypto.subtle.digest("SHA-512", dataArray) + + hopp.test("SHA-512 produces correct byte length", () => { + hopp.expect(hashBuffer.byteLength).toBe(64) // SHA-512 produces 64 bytes + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "SHA-512 produces correct byte length", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + }) + + test("should handle empty string", () => { + const script = ` + const dataArray = ${JSON.stringify(emptyBytes)} + + const hashBuffer = await crypto.subtle.digest("SHA-256", dataArray) + + // SHA-256 of empty string + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + const expectedHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + + hopp.test("Empty string digest is correct", () => { + hopp.expect(hashHex).toBe(expectedHash) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Empty string digest is correct", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + }) + + test("should produce consistent hashes for same input", () => { + const script = ` + const dataArray = ${JSON.stringify(consistentDataBytes)} + + const hash1 = await crypto.subtle.digest("SHA-256", dataArray) + const hash2 = await crypto.subtle.digest("SHA-256", dataArray) + + const hash1Hex = Array.from(new Uint8Array(hash1)).map(b => b.toString(16).padStart(2, '0')).join('') + const hash2Hex = Array.from(new Uint8Array(hash2)).map(b => b.toString(16).padStart(2, '0')).join('') + + hopp.test("Same input produces same hash", () => { + hopp.expect(hash1Hex).toBe(hash2Hex) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Same input produces same hash", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + }) + + test("should produce different hashes for different inputs", () => { + const script = ` + const data1Array = ${JSON.stringify(data1Bytes)} + const data2Array = ${JSON.stringify(data2Bytes)} + + const hash1 = await crypto.subtle.digest("SHA-256", data1Array) + const hash2 = await crypto.subtle.digest("SHA-256", data2Array) + + const hash1Hex = Array.from(new Uint8Array(hash1)).map(b => b.toString(16).padStart(2, '0')).join('') + const hash2Hex = Array.from(new Uint8Array(hash2)).map(b => b.toString(16).padStart(2, '0')).join('') + + hopp.test("Different inputs produce different hashes", () => { + hopp.expect(hash1Hex).not.toBe(hash2Hex) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Different inputs produce different hashes", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/fetch.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/fetch.spec.ts new file mode 100644 index 0000000..a9883e5 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/fetch.spec.ts @@ -0,0 +1,571 @@ +import { describe, test, it, expect } from "vitest" +import { FaradayCage } from "faraday-cage" +import { defaultModules } from "~/cage-modules" +import type { HoppFetchHook } from "~/types" + +const jsonBody = { foo: "bar", answer: 42 } +const jsonText = JSON.stringify(jsonBody) +const jsonBytes = Array.from(new TextEncoder().encode(jsonText)) + +const hookWithHeaders: HoppFetchHook = async () => { + const resp = new Response(jsonText, { + status: 200, + headers: { "x-foo": "bar", "content-type": "application/json" }, + }) + return Object.assign(resp, { + _bodyBytes: jsonBytes, + _headersData: { "x-foo": "bar", "content-type": "application/json" }, + }) as Response +} + +const hookNoHeaders: HoppFetchHook = async () => { + const resp = new Response(jsonText, { + status: 200, + headers: { "x-missing": "not-copied" }, + }) + // Intentionally do NOT provide _headersData here; module should fallback to native Headers + return Object.assign(resp, { _bodyBytes: jsonBytes }) as Response +} + +const runCage = async (script: string, hook: HoppFetchHook) => { + const cage = await FaradayCage.create() + const result = await cage.runCode(script, [ + ...defaultModules({ hoppFetchHook: hook }), + ]) + return result +} + +describe("fetch cage module", () => { + // --------------------------------------------------------------------------- + // Global API availability (parity with faraday-cage conventions) + // --------------------------------------------------------------------------- + it("exposes fetch API globals in sandbox", async () => { + const script = ` + (async () => { + if (typeof fetch !== 'function') throw new Error('fetch not available') + if (typeof Headers !== 'function') throw new Error('Headers not available') + if (typeof Request !== 'function') throw new Error('Request not available') + if (typeof Response !== 'function') throw new Error('Response not available') + if (typeof AbortController !== 'function') throw new Error('AbortController not available') + })() + ` + const result = await runCage(script, hookWithHeaders) + expect(result.type).toBe("ok") + }) + + it("exposes essential properties on Response/Request/Headers", async () => { + const script = ` + (async () => { + const response = new Response() + if (typeof response.status !== 'number') throw new Error('Response.status missing') + if (typeof response.ok !== 'boolean') throw new Error('Response.ok missing') + if (typeof response.json !== 'function') throw new Error('Response.json missing') + if (typeof response.text !== 'function') throw new Error('Response.text missing') + if (typeof response.clone !== 'function') throw new Error('Response.clone missing') + + const request = new Request('https://example.com') + if (typeof request.url !== 'string') throw new Error('Request.url missing') + if (typeof request.method !== 'string') throw new Error('Request.method missing') + if (typeof request.clone !== 'function') throw new Error('Request.clone missing') + + const headers = new Headers() + if (typeof headers.get !== 'function') throw new Error('Headers.get missing') + if (typeof headers.set !== 'function') throw new Error('Headers.set missing') + if (typeof headers.has !== 'function') throw new Error('Headers.has missing') + + const controller = new AbortController() + if (typeof controller.signal !== 'object') throw new Error('AbortController.signal missing') + if (typeof controller.abort !== 'function') throw new Error('AbortController.abort missing') + })() + ` + const result = await runCage(script, hookWithHeaders) + expect(result.type).toBe("ok") + }) + + // --------------------------------------------------------------------------- + // Fetch basics and options + // --------------------------------------------------------------------------- + it("basic fetch works and calls hook", async () => { + let lastArgs: { input: string; init?: RequestInit } | null = null + const capturingHook: HoppFetchHook = async (input, init) => { + lastArgs = { input: String(input), init } + return Object.assign(new Response(jsonText, { status: 200 }), { + _bodyBytes: jsonBytes, + _headersData: { "content-type": "application/json" }, + }) as Response + } + + const script = ` + (async () => { + const res = await fetch('https://example.com/api') + if (!res.ok) throw new Error('fetch not ok') + })() + ` + const result = await runCage(script, capturingHook) + expect(result.type).toBe("ok") + expect(lastArgs?.input).toBe("https://example.com/api") + expect(lastArgs?.init).toBeUndefined() + }) + + it("fetch with options passes through init", async () => { + let lastArgs: { input: string; init?: RequestInit } | null = null + const capturingHook: HoppFetchHook = async (input, init) => { + lastArgs = { input: String(input), init } + return Object.assign(new Response(jsonText, { status: 200 }), { + _bodyBytes: jsonBytes, + _headersData: { "content-type": "application/json" }, + }) as Response + } + + const script = ` + (async () => { + await fetch('https://example.com/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ test: true }) + }) + })() + ` + const result = await runCage(script, capturingHook) + expect(result.type).toBe("ok") + expect(lastArgs?.input).toBe("https://example.com/api") + expect(lastArgs?.init?.method).toBe("POST") + // Headers were converted to plain object inside cage for compatibility + expect((lastArgs?.init as any)?.headers?.["Content-Type"]).toBe( + "application/json" + ) + expect(typeof lastArgs?.init?.body).toBe("string") + }) + + it("converts in-cage Headers instance in init to plain object for hook", async () => { + let lastArgs: { input: string; init?: RequestInit } | null = null + const capturingHook: HoppFetchHook = async (input, init) => { + lastArgs = { input: String(input), init } + return Object.assign(new Response(jsonText, { status: 200 }), { + _bodyBytes: jsonBytes, + _headersData: { "content-type": "application/json" }, + }) as Response + } + + const script = ` + (async () => { + const h = new Headers({ 'X-Token': 'secret' }) + await fetch('https://example.com/with-headers', { headers: h }) + })() + ` + const result = await runCage(script, capturingHook) + expect(result.type).toBe("ok") + const hdrs = (lastArgs?.init as any)?.headers || {} + expect(hdrs["X-Token"] ?? hdrs["x-token"]).toBe("secret") + }) + + test("json() parses and bodyUsed toggles; second consume throws", async () => { + const script = ` + (async () => { + const res = await fetch('https://example.test/json') + if (res.ok !== true) throw new Error('ok not true') + if (res.status !== 200) throw new Error('status mismatch') + const data = await res.json() + if (res.bodyUsed !== true) throw new Error('bodyUsed not true after json()') + if (data.foo !== 'bar') throw new Error('json parse mismatch') + let threw = false + try { await res.text() } catch (_) { threw = true } + if (!threw) throw new Error('second consume did not throw') + })() + ` + const result = await runCage(script, hookWithHeaders) + expect(result.type).toBe("ok") + }) + + test("headers with _headersData: get() and entries() work", async () => { + const script = ` + (async () => { + const res = await fetch('https://example.test/headers') + const v = res.headers.get('x-foo') + if (v !== 'bar') throw new Error('headers.get failed') + const it = res.headers.entries() + let found = false + for (const pair of it) { if (pair[0] === 'x-foo' && pair[1] === 'bar') found = true } + if (!found) throw new Error('headers.entries missing pair') + })() + ` + const result = await runCage(script, hookWithHeaders) + expect(result.type).toBe("ok") + }) + + test("headers fallback without _headersData uses native Headers", async () => { + const script = ` + (async () => { + const res = await fetch('https://example.test/no-headers') + const v = res.headers.get('x-missing') + if (v !== 'not-copied') throw new Error('fallback headers missing') + })() + ` + const result = await runCage(script, hookNoHeaders) + expect(result.type).toBe("ok") + }) + + it("fallback builds _bodyBytes when hook returns native Response", async () => { + const nativeHook: HoppFetchHook = async () => + new Response("Zed", { + status: 200, + headers: { "content-type": "text/plain" }, + }) + const script = ` + (async () => { + const res = await fetch('https://example.test/native-body') + const t = await res.text() + if (t !== 'Zed') throw new Error('text mismatch after fallback _bodyBytes') + })() + ` + const result = await runCage(script, nativeHook) + expect(result.type).toBe("ok") + }) + + test("AbortController abort() flips signal and invokes listener", async () => { + const script = ` + (() => { + const ac = new AbortController() + let called = false + ac.signal.addEventListener('abort', () => { called = true }) + if (ac.signal.aborted !== false) throw new Error('initial aborted not false') + ac.abort() + if (ac.signal.aborted !== true) throw new Error('aborted not true after abort()') + if (called !== true) throw new Error('listener not called') + })() + ` + const result = await runCage(script, hookWithHeaders) + expect(result.type).toBe("ok") + }) + + test("clone(): original and clone can consume independently (flaky)", async () => { + const script = ` + (async () => { + const res = await fetch('https://example.test/clone') + const clone = res.clone() + await res.text() + if (res.bodyUsed !== true) throw new Error('res.bodyUsed not true') + await clone.text() + if (clone.bodyUsed !== true) throw new Error('clone.bodyUsed not true') + })() + ` + const result = await runCage(script, hookWithHeaders) + expect(result.type).toBe("ok") + }) + + // --------------------------------------------------------------------------- + // Headers API surface (constructor-based) + // --------------------------------------------------------------------------- + it("Headers supports set/get/has/delete/append and case-insensitivity", async () => { + const script = ` + (async () => { + const headers = new Headers() + headers.set('Content-Type', 'application/json') + if (headers.get('content-type') !== 'application/json') throw new Error('case-insensitive get failed') + if (!headers.has('Content-Type')) throw new Error('has failed') + headers.append('X-Custom', 'v1') + headers.append('X-Custom', 'v2') + const x = headers.get('x-custom') + if (!(x && x.includes('v1') && x.includes('v2'))) throw new Error('append combine failed') + headers.delete('Content-Type') + if (headers.has('Content-Type')) throw new Error('delete failed') + })() + ` + const result = await runCage(script, hookWithHeaders) + expect(result.type).toBe("ok") + }) + + it("Headers can initialize from object literal", async () => { + const script = ` + (async () => { + const headers = new Headers({ 'Content-Type': 'application/json', 'X-Custom': 'test' }) + if (headers.get('content-type') !== 'application/json') throw new Error('init object failed') + if (headers.get('x-custom') !== 'test') throw new Error('init object custom failed') + })() + ` + const result = await runCage(script, hookWithHeaders) + expect(result.type).toBe("ok") + }) + + // --------------------------------------------------------------------------- + // Request constructor parity (subset) + // --------------------------------------------------------------------------- + it("Request constructs with url/method and options", async () => { + const script = ` + (async () => { + const r1 = new Request('https://example.com/api') + if (r1.url !== 'https://example.com/api') throw new Error('Request.url mismatch') + if (r1.method !== 'GET') throw new Error('Request default method mismatch') + + const r2 = new Request('https://example.com/api', { method: 'POST', headers: { 'Content-Type': 'application/json' } }) + if (r2.method !== 'POST') throw new Error('Request.method mismatch') + // Our Request.headers is a plain object map + if (!r2.headers || r2.headers['content-type'] !== 'application/json') throw new Error('Request.headers map mismatch') + })() + ` + const result = await runCage(script, hookWithHeaders) + expect(result.type).toBe("ok") + }) + + it("Request.clone() retains core properties", async () => { + const script = ` + (async () => { + const original = new Request('https://example.com/api', { method: 'POST', headers: { 'Content-Type': 'application/json' } }) + const clone = original.clone() + if (clone.url !== original.url) throw new Error('clone url mismatch') + if (clone.method !== original.method) throw new Error('clone method mismatch') + })() + ` + const result = await runCage(script, hookWithHeaders) + expect(result.type).toBe("ok") + }) + + // --------------------------------------------------------------------------- + // Response constructor parity (subset) + // --------------------------------------------------------------------------- + it("Response constructs with defaults and custom status", async () => { + const script = ` + (async () => { + const r1 = new Response() + if (r1.status !== 200) throw new Error('Response default status mismatch') + if (r1.ok !== true) throw new Error('Response default ok mismatch') + + const r2 = new Response('Not Found', { status: 404, statusText: 'Not Found', headers: { 'Content-Type': 'text/plain' } }) + if (r2.status !== 404) throw new Error('Response status mismatch') + if (r2.ok !== false) throw new Error('Response ok mismatch') + if (r2.statusText !== 'Not Found') throw new Error('Response statusText mismatch') + // Our Response.headers is a plain map; verify via key + if (!r2.headers || r2.headers['content-type'] !== 'text/plain') throw new Error('Response headers map mismatch') + })() + ` + const result = await runCage(script, hookWithHeaders) + expect(result.type).toBe("ok") + }) + + it("Response init supports type/url/redirected fields", async () => { + const script = ` + (async () => { + const r = new Response('x', { status: 200, type: 'default', url: 'https://e.x', redirected: true }) + if (r.type !== 'default') throw new Error('Response.type mismatch') + if (r.url !== 'https://e.x') throw new Error('Response.url mismatch') + if (r.redirected !== true) throw new Error('Response.redirected mismatch') + })() + ` + const result = await runCage(script, hookWithHeaders) + expect(result.type).toBe("ok") + }) + + // --------------------------------------------------------------------------- + // Body reading variants (adapted to our module semantics) + // --------------------------------------------------------------------------- + it("text(): reads body and sets bodyUsed", async () => { + const textBytes = Array.from(new TextEncoder().encode("Hello World")) + const hook: HoppFetchHook = async () => + Object.assign(new Response("Hello World", { status: 200 }), { + _bodyBytes: textBytes, + _headersData: { "content-type": "text/plain" }, + }) as Response + const script = ` + (async () => { + const res = await fetch('https://example.test/text') + const t = await res.text() + if (t !== 'Hello World') throw new Error('text mismatch') + if (res.bodyUsed !== true) throw new Error('bodyUsed not true after text()') + })() + ` + const result = await runCage(script, hook) + expect(result.type).toBe("ok") + }) + + it("arrayBuffer(): returns bytes array and sets bodyUsed", async () => { + const bytes = [72, 101, 108, 108, 111] + const hook: HoppFetchHook = async () => + Object.assign(new Response("", { status: 200 }), { + _bodyBytes: bytes, + _headersData: {}, + }) as Response + const script = ` + (async () => { + const res = await fetch('https://example.test/binary') + const arr = await res.arrayBuffer() // In our module, this returns an array of numbers + if (!Array.isArray(arr)) throw new Error('arrayBuffer did not return array') + if (arr.length !== 5 || arr[0] !== 72 || arr[1] !== 101 || arr[2] !== 108 || arr[3] !== 108 || arr[4] !== 111) throw new Error('byte content mismatch') + if (res.bodyUsed !== true) throw new Error('bodyUsed not true after arrayBuffer()') + })() + ` + const result = await runCage(script, hook) + expect(result.type).toBe("ok") + }) + + it("blob(): returns minimal blob-like and sets bodyUsed", async () => { + const bytes = [1, 2, 3] + const hook: HoppFetchHook = async () => + Object.assign(new Response("", { status: 200 }), { + _bodyBytes: bytes, + _headersData: {}, + }) as Response + const script = ` + (async () => { + const res = await fetch('https://example.test/blob') + const b = await res.blob() + if (typeof b !== 'object' || typeof b.size !== 'number' || !Array.isArray(b.bytes)) throw new Error('blob shape mismatch') + if (b.size !== 3) throw new Error('blob size mismatch') + if (res.bodyUsed !== true) throw new Error('bodyUsed not true after blob()') + })() + ` + const result = await runCage(script, hook) + expect(result.type).toBe("ok") + }) + + it("formData(): parses simple urlencoded text and sets bodyUsed", async () => { + const text = "name=Test%20User&id=123" + const bytes = Array.from(new TextEncoder().encode(text)) + const hook: HoppFetchHook = async () => + Object.assign(new Response("", { status: 200 }), { + _bodyBytes: bytes, + _headersData: {}, + }) as Response + const script = ` + (async () => { + const res = await fetch('https://example.test/form') + const fd = await res.formData() + if (fd.name !== 'Test User') throw new Error('form name mismatch') + if (fd.id !== '123') throw new Error('form id mismatch') + if (res.bodyUsed !== true) throw new Error('bodyUsed not true after formData()') + })() + ` + const result = await runCage(script, hook) + expect(result.type).toBe("ok") + }) + + it("text() trims at first null byte (cleaning trailing bytes)", async () => { + const bytes = [65, 66, 0, 67] // 'A','B','\0','C' => expect 'AB' + const hook: HoppFetchHook = async () => + Object.assign(new Response("", { status: 200 }), { + _bodyBytes: bytes, + _headersData: { "content-type": "text/plain" }, + }) as Response + const script = ` + (async () => { + const res = await fetch('https://example.test/null-bytes') + const t = await res.text() + if (t !== 'AB') throw new Error('null-byte trimming failed: ' + t) + })() + ` + const result = await runCage(script, hook) + expect(result.type).toBe("ok") + }) + + it("enforces single body consumption across methods", async () => { + const textBytes = Array.from(new TextEncoder().encode("Hello")) + const hook: HoppFetchHook = async () => + Object.assign(new Response("Hello", { status: 200 }), { + _bodyBytes: textBytes, + _headersData: {}, + }) as Response + const script = ` + (async () => { + const res = await fetch('https://example.test/consume-once') + await res.text() + let ok = false + try { await res.json() } catch (e) { if (String(e.message).includes('already been consumed')) ok = true } + if (!ok) throw new Error('second consume should have failed') + })() + ` + const result = await runCage(script, hook) + expect(result.type).toBe("ok") + }) + + // --------------------------------------------------------------------------- + // AbortController integration with fetch (module-adapted) + // --------------------------------------------------------------------------- + it("fetch with aborted signal rejects (message-based)", async () => { + const abortAwareHook: HoppFetchHook = async (_input, init) => { + if ((init as any)?.signal?.aborted) { + throw new Error("The operation was aborted.") + } + return Object.assign(new Response(jsonText, { status: 200 }), { + _bodyBytes: jsonBytes, + _headersData: { "content-type": "application/json" }, + }) as Response + } + const script = ` + (async () => { + const ac = new AbortController() + ac.abort() + let rejected = false + try { + await fetch('https://example.test/abort', { signal: ac.signal }) + } catch (err) { + if (!String(err.message).toLowerCase().includes('aborted')) throw new Error('expected abort message') + rejected = true + } + if (!rejected) throw new Error('fetch should reject when aborted') + })() + ` + const result = await runCage(script, abortAwareHook) + expect(result.type).toBe("ok") + }) + + // --------------------------------------------------------------------------- + // Error handling (module-adapted) + // --------------------------------------------------------------------------- + it("network errors propagate as FetchError with message", async () => { + const failingHook: HoppFetchHook = async () => { + throw new Error("Network failure") + } + const script = ` + (async () => { + let passed = false + try { + await fetch('https://example.test/error') + } catch (e) { + if (!String(e.message).includes('Network failure')) throw new Error('unexpected error message: ' + e.message) + passed = true + } + if (!passed) throw new Error('expected rejection') + })() + ` + const result = await runCage(script, failingHook) + expect(result.type).toBe("ok") + }) + + it("network errors surface as FetchError by name in-cage", async () => { + const failingHook: HoppFetchHook = async () => { + throw new Error("Bad things") + } + const script = ` + (async () => { + let passed = false + try { + await fetch('https://example.test/error2') + } catch (e) { + if (e.name !== 'FetchError') throw new Error('expected FetchError name, got: ' + e.name) + passed = true + } + if (!passed) throw new Error('expected rejection') + })() + ` + const result = await runCage(script, failingHook) + expect(result.type).toBe("ok") + }) + + // --------------------------------------------------------------------------- + // Headers iteration helpers + // --------------------------------------------------------------------------- + it("Headers keys/values/forEach expose entries", async () => { + const script = ` + (async () => { + const h = new Headers({ A: '1', B: '2' }) + const keys = h.keys() + const values = h.values() + let count = 0 + h.forEach((_v, _k) => { count++ }) + if (!Array.isArray(keys) || !Array.isArray(values)) throw new Error('keys/values shape') + if (count < 2) throw new Error('forEach did not iterate') + })() + ` + const result = await runCage(script, hookWithHeaders) + expect(result.type).toBe("ok") + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/async-await-support.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/async-await-support.spec.ts new file mode 100644 index 0000000..eb2980c --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/async-await-support.spec.ts @@ -0,0 +1,179 @@ +/** + * Async/Await Support Tests + * + * Tests that pm.test() and hopp.test() properly support async functions with await, + * which is critical for Postman script imports that use asynchronous patterns. + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +const NAMESPACES = ["pm", "hopp"] as const + +describe.each(NAMESPACES)("%s.test() - Async/Await Support", (namespace) => { + test("should support async function with await", () => { + return expect( + runTest(` + ${namespace}.test("async with await", async function() { + const promise = new Promise((resolve) => { + resolve(42) + }) + const result = await promise + ${namespace}.expect(result).to.equal(42) + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "async with await", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support async arrow function", () => { + return expect( + runTest(` + ${namespace}.test("async arrow", async () => { + const result = await Promise.resolve("success") + ${namespace}.expect(result).to.equal("success") + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "async arrow", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support Promise.all with await", () => { + return expect( + runTest(` + ${namespace}.test("Promise.all", async function() { + const results = await Promise.all([ + Promise.resolve(1), + Promise.resolve(2), + Promise.resolve(3) + ]) + ${namespace}.expect(results).to.eql([1, 2, 3]) + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Promise.all", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support async error handling", () => { + return expect( + runTest(` + ${namespace}.test("async error", async function() { + try { + await Promise.reject(new Error("test error")) + ${namespace}.expect.fail("Should not reach here") + } catch (error) { + ${namespace}.expect(error.message).to.equal("test error") + } + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "async error", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support multiple sequential awaits", () => { + return expect( + runTest(` + ${namespace}.test("sequential awaits", async function() { + const a = await Promise.resolve(10) + const b = await Promise.resolve(20) + const c = await Promise.resolve(30) + ${namespace}.expect(a + b + c).to.equal(60) + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "sequential awaits", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support async IIFE pattern", () => { + return expect( + runTest(` + ${namespace}.test("async IIFE", async function() { + const result = await (async () => { + const data = await Promise.resolve({ value: 100 }) + return data.value * 2 + })() + ${namespace}.expect(result).to.equal(200) + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "async IIFE", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/core-chai-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/core-chai-assertions.spec.ts new file mode 100644 index 0000000..fa49e24 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/core-chai-assertions.spec.ts @@ -0,0 +1,607 @@ +/** + * @see https://github.com/hoppscotch/hoppscotch/discussions/5221 + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +const NAMESPACES = ["pm", "hopp"] as const + +describe.each(NAMESPACES)("%s.expect() - Core Chai Assertions", (namespace) => { + describe("Equality Assertions", () => { + test("should support `.equal()` for strict equality", () => { + return expect( + runTest(` + ${namespace}.test("equality assertions", () => { + ${namespace}.expect(42).to.equal(42) + ${namespace}.expect('test').to.equal('test') + ${namespace}.expect(true).to.equal(true) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "equality assertions", + expectResults: [ + { status: "pass", message: "Expected 42 to equal 42" }, + { status: "pass", message: "Expected 'test' to equal 'test'" }, + { status: "pass", message: "Expected true to equal true" }, + ], + }), + ], + }), + ]) + }) + + test("should support `.eql()` for deep equality", () => { + return expect( + runTest(` + ${namespace}.test("deep equality", () => { + ${namespace}.expect({a: 1}).to.eql({a: 1}) + ${namespace}.expect([1, 2, 3]).to.eql([1, 2, 3]) + ${namespace}.expect({nested: {value: 42}}).to.eql({nested: {value: 42}}) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "deep equality", + expectResults: [ + { status: "pass", message: "Expected {a: 1} to eql {a: 1}" }, + { + status: "pass", + message: "Expected [1, 2, 3] to eql [1, 2, 3]", + }, + { status: "pass", message: expect.stringContaining("to eql") }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Type Assertions", () => { + test("should assert primitive types with `.a()` and `.an()`", () => { + return expect( + runTest(` + ${namespace}.test("type assertions", () => { + ${namespace}.expect('foo').to.be.a('string') + ${namespace}.expect({a: 1}).to.be.an('object') + ${namespace}.expect([1, 2, 3]).to.be.an('array') + ${namespace}.expect(42).to.be.a('number') + ${namespace}.expect(true).to.be.a('boolean') + ${namespace}.expect(null).to.be.a('null') + ${namespace}.expect(undefined).to.be.an('undefined') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "type assertions", + expectResults: [ + { status: "pass", message: "Expected 'foo' to be a string" }, + { status: "pass", message: "Expected {a: 1} to be an object" }, + { + status: "pass", + message: "Expected [1, 2, 3] to be an array", + }, + { status: "pass", message: "Expected 42 to be a number" }, + { status: "pass", message: "Expected true to be a boolean" }, + { status: "pass", message: "Expected null to be a null" }, + { + status: "pass", + message: "Expected undefined to be an undefined", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Truthiness Assertions", () => { + test("should support `.true`, `.false`, `.ok` assertions", () => { + return expect( + runTest(` + ${namespace}.test("truthiness", () => { + ${namespace}.expect(true).to.be.true + ${namespace}.expect(false).to.be.false + ${namespace}.expect(1).to.be.ok + ${namespace}.expect('hello').to.be.ok + ${namespace}.expect(0).to.not.be.ok + ${namespace}.expect('').to.not.be.ok + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "truthiness", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("Numerical Comparisons", () => { + test("should support `.above()` and `.below()` comparisons", () => { + return expect( + runTest(` + ${namespace}.test("numerical comparisons", () => { + ${namespace}.expect(10).to.be.above(5) + ${namespace}.expect(5).to.be.below(10) + ${namespace}.expect(5).to.not.be.above(10) + ${namespace}.expect(10).to.not.be.below(5) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "numerical comparisons", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.within()` for range comparisons", () => { + return expect( + runTest(` + ${namespace}.test("within range", () => { + ${namespace}.expect(5).to.be.within(1, 10) + ${namespace}.expect(1).to.be.within(1, 10) + ${namespace}.expect(10).to.be.within(1, 10) + ${namespace}.expect(0).to.not.be.within(1, 10) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "within range", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.closeTo()` for floating point comparisons", () => { + return expect( + runTest(` + ${namespace}.test("close to", () => { + ${namespace}.expect(1.5).to.be.closeTo(1.0, 0.6) + ${namespace}.expect(10.5).to.be.closeTo(10.0, 0.5) + ${namespace}.expect(5.1).to.not.be.closeTo(5.0, 0.05) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "close to", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("Property Assertions", () => { + test("should support `.property()` for property existence and value", () => { + return expect( + runTest(` + ${namespace}.test("property assertions", () => { + ${namespace}.expect({a: 1}).to.have.property('a') + ${namespace}.expect({a: 1}).to.have.property('a', 1) + ${namespace}.expect({x: {y: 2}}).to.have.property('x') + ${namespace}.expect({}).to.not.have.property('missing') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "property assertions", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.ownProperty()` for own properties", () => { + return expect( + runTest(` + ${namespace}.test("own property", () => { + ${namespace}.expect({a: 1}).to.have.ownProperty('a') + ${namespace}.expect({a: 1}).to.have.ownProperty('a', 1) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "own property", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("Collection Assertions", () => { + test("should support `.include()` for arrays and strings", () => { + return expect( + runTest(` + ${namespace}.test("include assertions", () => { + ${namespace}.expect([1, 2, 3]).to.include(2) + ${namespace}.expect('hoppscotch').to.include('hopp') + ${namespace}.expect({a: 1, b: 2}).to.include({a: 1}) + ${namespace}.expect([1, 2, 3]).to.not.include(5) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "include assertions", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("Negation with `.not`", () => { + test("should support negation for all assertion types", () => { + return expect( + runTest(` + ${namespace}.test("negation works", () => { + ${namespace}.expect(42).to.not.equal(43) + ${namespace}.expect('foo').to.not.be.a('number') + ${namespace}.expect(false).to.not.be.true + ${namespace}.expect(5).to.not.be.above(10) + ${namespace}.expect({a: 1}).to.not.have.property('b') + ${namespace}.expect([1, 2]).to.not.include(3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "negation works", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("Deep Equality Modifiers", () => { + test("should support `.deep.equal()` and `.deep.property()`", () => { + return expect( + runTest(` + ${namespace}.test("deep modifiers", () => { + ${namespace}.expect({a: {b: 1}}).to.deep.equal({a: {b: 1}}) + ${namespace}.expect({a: {b: 1}}).to.have.deep.property('a', {b: 1}) + ${namespace}.expect([{x: 1}]).to.deep.include({x: 1}) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "deep modifiers", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("Language Chains", () => { + test("should support basic language chain properties (`.to`, `.be`, `.that`)", () => { + return expect( + runTest(` + ${namespace}.test("basic language chains", () => { + ${namespace}.expect(2).to.equal(2) + ${namespace}.expect(2).to.be.equal(2) + ${namespace}.expect(2).to.be.a('number').that.equals(2) + ${namespace}.expect([1,2,3]).to.be.an('array').that.has.lengthOf(3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "basic language chains", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should chain type assertion with include using `.and`", () => { + return expect( + runTest(` + ${namespace}.test("and chain - type and include", () => { + ${namespace}.expect('hello world').to.be.a('string').and.include('world') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "and chain - type and include", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should chain type assertion with lengthOf using `.and`", () => { + return expect( + runTest(` + ${namespace}.test("and chain - type and length", () => { + ${namespace}.expect([1, 2, 3]).to.be.an('array').and.have.lengthOf(3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "and chain - type and length", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support complex chaining with multiple `.and` and `.that`", () => { + return expect( + runTest(` + ${namespace}.test("complex and chaining", () => { + ${namespace}.expect({ name: 'John', age: 30 }) + .to.be.an('object') + .and.have.property('name') + .that.is.a('string') + .and.equals('John') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "complex and chaining", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should work with `.and.not` for negation", () => { + return expect( + runTest(` + ${namespace}.test("and with negation", () => { + ${namespace}.expect({ a: 1, b: 2 }) + .to.be.an('object') + .and.not.be.empty + .and.not.have.property('c') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "and with negation", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should chain numeric comparisons with `.and`", () => { + return expect( + runTest(` + ${namespace}.test("and with numbers", () => { + ${namespace}.expect(200) + .to.be.a('number') + .and.be.within(200, 299) + .and.equal(200) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "and with numbers", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) +}) + +describe.each(NAMESPACES)( + "%s.expect() - Basic Function Assertions", + (namespace) => { + test("should support `.throw()` for error throwing", () => { + return expect( + runTest(` + ${namespace}.test("throw assertions", () => { + ${namespace}.expect(() => { throw new Error('oops') }).to.throw() + ${namespace}.expect(() => { throw new Error('oops') }).to.throw(Error) + ${namespace}.expect(() => {}).to.not.throw() + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "throw assertions", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.respondTo()` for method existence", () => { + return expect( + runTest(` + ${namespace}.test("respondTo assertions", () => { + const obj = { method: () => {} } + ${namespace}.expect(obj).to.respondTo('method') + ${namespace}.expect([]).to.respondTo('push') + ${namespace}.expect('').to.respondTo('charAt') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "respondTo assertions", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.satisfy()` for custom predicates", () => { + return expect( + runTest(` + ${namespace}.test("satisfy assertions", () => { + ${namespace}.expect(2).to.satisfy((n) => n > 0) + ${namespace}.expect(10).to.satisfy((n) => n % 2 === 0) + ${namespace}.expect('hello').to.satisfy((s) => s.length > 3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "satisfy assertions", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + } +) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/deep-include-keys-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/deep-include-keys-assertions.spec.ts new file mode 100644 index 0000000..987fe54 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/deep-include-keys-assertions.spec.ts @@ -0,0 +1,364 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +const NAMESPACES = ["pm", "hopp"] as const + +describe.each(NAMESPACES)( + "%s.expect() - deep.include() - Object Property Inclusion", + (namespace) => { + test("should pass when object deeply includes partial match", async () => { + const testScript = ` + ${namespace}.test("deep.include() - object property inclusion", (namespace) => { + ${namespace}.expect({ a: 1, b: 2, c: 3 }).to.deep.include({ a: 1, b: 2 }); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.include() - object property inclusion", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should pass with negation when object does not include properties", async () => { + const testScript = ` + ${namespace}.test("deep.include() - negated", (namespace) => { + ${namespace}.expect({ a: 1, b: 2 }).to.not.deep.include({ a: 1, c: 3 }); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.include() - negated", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should work with nested objects", async () => { + const testScript = ` + ${namespace}.test("deep.include() - nested objects", (namespace) => { + const obj = { a: { b: { c: 1 } }, d: 2 }; + ${namespace}.expect(obj).to.deep.include({ a: { b: { c: 1 } } }); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.include() - nested objects", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + } +) + +describe.each(NAMESPACES)( + "%s.expect() - deep.include() - Array Object Inclusion", + (namespace) => { + test("should pass when array deeply includes object", async () => { + const testScript = ` + ${namespace}.test("deep.include() - array object inclusion", (namespace) => { + ${namespace}.expect([{ id: 1 }, { id: 2 }]).to.deep.include({ id: 1 }); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.include() - array object inclusion", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should pass with negation when array does not include object", async () => { + const testScript = ` + ${namespace}.test("deep.include() - array negated", (namespace) => { + ${namespace}.expect([{ id: 1 }, { id: 2 }]).to.not.deep.include({ id: 3 }); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.include() - array negated", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should work with exact nested objects in arrays", async () => { + const testScript = ` + ${namespace}.test("deep.include() - complex array objects", (namespace) => { + const arr = [ + { name: 'John', age: 30, address: { city: 'NYC' } }, + { name: 'Jane', age: 25, address: { city: 'LA' } } + ]; + // deep.include on arrays requires exact object match + ${namespace}.expect(arr).to.deep.include({ name: 'John', age: 30, address: { city: 'NYC' } }); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.include() - complex array objects", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + } +) + +describe.each(NAMESPACES)( + "%s.expect() - include.deep() - Alternative Syntax", + (namespace) => { + test("should work with include.deep syntax", async () => { + const testScript = ` + ${namespace}.test("include.deep() - alternative syntax", (namespace) => { + ${namespace}.expect({ a: 1, b: 2, c: 3 }).to.include.deep({ a: 1, b: 2 }); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "include.deep() - alternative syntax", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + } +) + +describe.each(NAMESPACES)( + "%s.expect() - include.keys() - Partial Key Matching", + (namespace) => { + test("should pass when object has at least the specified keys", async () => { + const testScript = ` + ${namespace}.test("include.keys() - partial key matching", (namespace) => { + ${namespace}.expect({ a: 1, b: 2, c: 3 }).to.include.keys('a', 'b'); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "include.keys() - partial key matching", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should pass even when object has extra keys", async () => { + const testScript = ` + ${namespace}.test("include.keys() - with extra keys", (namespace) => { + ${namespace}.expect({ a: 1, b: 2, c: 3, d: 4, e: 5 }).to.include.keys('a', 'b'); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "include.keys() - with extra keys", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should pass with negation when object does not have key", async () => { + const testScript = ` + ${namespace}.test("include.keys() - negated", (namespace) => { + ${namespace}.expect({ a: 1, b: 2 }).to.not.include.keys('c'); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "include.keys() - negated", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should work with array of keys", async () => { + const testScript = ` + ${namespace}.test("include.keys() - array syntax", (namespace) => { + ${namespace}.expect({ a: 1, b: 2, c: 3 }).to.include.keys(['a', 'b']); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "include.keys() - array syntax", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should work with single key", async () => { + const testScript = ` + ${namespace}.test("include.keys() - single key", (namespace) => { + ${namespace}.expect({ a: 1, b: 2, c: 3 }).to.include.keys('a'); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "include.keys() - single key", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + } +) + +describe.each(NAMESPACES)("%s.expect() - Combination Patterns", (namespace) => { + test("should work with chained assertions", async () => { + const testScript = ` + ${namespace}.test("chained deep.include", (namespace) => { + const response = { status: 'success', data: { user: { id: 1, name: 'John' } } }; + ${namespace}.expect(response).to.be.an('object') + .and.deep.include({ status: 'success' }); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "chained deep.include", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with response validation patterns", async () => { + const testScript = ` + ${namespace}.test("response validation with deep.include", (namespace) => { + const jsonData = { status: 200, message: 'OK', data: { results: [] } }; + ${namespace}.expect(jsonData).to.deep.include({ status: 200, message: 'OK' }); + ${namespace}.expect(jsonData).to.include.keys('status', 'message', 'data'); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "response validation with deep.include", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/env-fallback-behavior.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/env-fallback-behavior.spec.ts new file mode 100644 index 0000000..bf2ddc7 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/env-fallback-behavior.spec.ts @@ -0,0 +1,863 @@ +/** + * Environment Variable Fallback Behavior Tests + * + * Tests the fallback from currentValue to initialValue when currentValue is empty + * across pm.environment and hopp.env namespaces. + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +// All namespaces share the same environment variable fallback implementation +// Testing: pm.environment (active scope) and hopp.env +// Note: pm.globals uses same implementation as pm.environment, just different scope +const NAMESPACES = [ + { + name: "pm.environment", + get: "pm.environment.get", + set: "pm.environment.set", + }, + { name: "hopp.env", get: "hopp.env.get", set: "hopp.env.set" }, +] as const + +describe("Environment Variable Fallback Behavior - All Namespaces", () => { + describe.each(NAMESPACES)( + "$name - currentValue empty string fallback", + ({ get }) => { + test("should fallback to initialValue when currentValue is empty string", async () => { + const envs = { + global: [], + selected: [ + { + key: "testVar", + currentValue: "", // Empty string + initialValue: "fallback_value", + secret: false, + }, + ], + } + + const result = await runTest( + ` + const value = ${get}("testVar") + pm.test("should get fallback value", () => { + pm.expect(value).to.equal("fallback_value") + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should return empty string when both currentValue and initialValue are empty", async () => { + const envs = { + global: [], + selected: [ + { + key: "testVar", + currentValue: "", + initialValue: "", + secret: false, + }, + ], + } + + const result = await runTest( + ` + const value = ${get}("testVar") + pm.test("should get empty string", () => { + pm.expect(value).to.equal("") + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + } + ) + + describe.each(NAMESPACES)( + "$name - currentValue undefined/null fallback", + ({ get }) => { + // Note: These tests check GET behavior with null/undefined in initial state. + // Setting null via pm.environment.set(key, null) IS supported (via NULL_MARKER). + // These tests validate fallback logic when null/undefined exists in the env state. + + test("should fallback to initialValue when currentValue is undefined", async () => { + const envs = { + global: [], + selected: [ + { + key: "testVar", + currentValue: undefined, + initialValue: "fallback_value", + secret: false, + }, + ], + } + + const result = await runTest( + ` + const value = ${get}("testVar") + pm.test("should get fallback value", () => { + pm.expect(value).to.equal("fallback_value") + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should fallback to initialValue when currentValue is null", async () => { + const envs = { + global: [], + selected: [ + { + key: "testVar", + currentValue: null, + initialValue: "fallback_value", + secret: false, + }, + ], + } + + const result = await runTest( + ` + const value = ${get}("testVar") + pm.test("should get fallback value for null", () => { + pm.expect(value).to.equal("fallback_value") + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + } + ) + + describe.each(NAMESPACES)( + "$name - falsy values should NOT fallback", + ({ get }) => { + test("should use currentValue when it is 0 (not fallback)", async () => { + const envs = { + global: [], + selected: [ + { + key: "testVar", + currentValue: 0, + initialValue: 100, + secret: false, + }, + ], + } + + const result = await runTest( + ` + const value = ${get}("testVar") + pm.test("should get 0, not fallback to 100", () => { + pm.expect(value).to.equal(0) + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should use currentValue when it is false (not fallback)", async () => { + const envs = { + global: [], + selected: [ + { + key: "testVar", + currentValue: false, + initialValue: true, + secret: false, + }, + ], + } + + const result = await runTest( + ` + const value = ${get}("testVar") + pm.test("should get false, not fallback to true", () => { + pm.expect(value).to.equal(false) + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should use currentValue when it is empty array (not fallback)", async () => { + const envs = { + global: [], + selected: [ + { + key: "testVar", + currentValue: [], + initialValue: [1, 2, 3], + secret: false, + }, + ], + } + + const result = await runTest( + ` + const value = ${get}("testVar") + pm.test("should get empty array, not fallback", () => { + pm.expect(value).to.be.an("array") + pm.expect(value.length).to.equal(0) + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should use currentValue when it is empty object (not fallback)", async () => { + const envs = { + global: [], + selected: [ + { + key: "testVar", + currentValue: {}, + initialValue: { key: "value" }, + secret: false, + }, + ], + } + + const result = await runTest( + ` + const value = ${get}("testVar") + pm.test("should get empty object, not fallback", () => { + pm.expect(value).to.be.an("object") + pm.expect(Object.keys(value).length).to.equal(0) + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + } + ) + + // NaN test - only for pm namespace which preserves non-string types + describe("pm.environment - NaN handling", () => { + test("should use currentValue when it is NaN (not fallback)", async () => { + const envs = { + global: [], + selected: [ + { + key: "testVar", + currentValue: NaN, + initialValue: 100, + secret: false, + }, + ], + } + + const result = await runTest( + ` + const value = pm.environment.get("testVar") + pm.test("should get NaN, not fallback to 100", () => { + pm.expect(Number.isNaN(value)).to.be.true + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe.each(NAMESPACES)( + "$name - complex type preservation with fallback", + ({ get }) => { + test("should fallback to initialValue array when currentValue is empty", async () => { + const envs = { + global: [], + selected: [ + { + key: "users", + currentValue: "", + initialValue: [ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, + ], + secret: false, + }, + ], + } + + const result = await runTest( + ` + const value = ${get}("users") + pm.test("should get array from initialValue", () => { + pm.expect(value).to.be.an("array") + pm.expect(value.length).to.equal(2) + pm.expect(value[0].name).to.equal("Alice") + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should fallback to initialValue object when currentValue is null", async () => { + const envs = { + global: [], + selected: [ + { + key: "config", + currentValue: null, + initialValue: { debug: true, maxRetries: 3 }, + secret: false, + }, + ], + } + + const result = await runTest( + ` + const value = ${get}("config") + pm.test("should get object from initialValue", () => { + pm.expect(value).to.be.an("object") + pm.expect(value.debug).to.equal(true) + pm.expect(value.maxRetries).to.equal(3) + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + } + ) + + describe.each(NAMESPACES)( + "$name - global vs selected scope fallback", + ({ name, get }) => { + test("should fallback to initialValue in global scope", async () => { + const envs = { + global: [ + { + key: "globalVar", + currentValue: "", + initialValue: "global_fallback", + secret: false, + }, + ], + selected: [], + } + + // Use hopp.env.global.get for hopp, pm.globals.get for pm + const globalGet = + name === "pm.environment" ? "pm.globals.get" : "hopp.env.global.get" + + const result = await runTest( + ` + const value = ${globalGet}("globalVar") + pm.test("should get global fallback value", () => { + pm.expect(value).to.equal("global_fallback") + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should fallback in selected scope even when global has a value", async () => { + const envs = { + global: [ + { + key: "sharedVar", + currentValue: "global_value", + initialValue: "global_initial", + secret: false, + }, + ], + selected: [ + { + key: "sharedVar", + currentValue: "", // Empty in selected + initialValue: "selected_initial", + secret: false, + }, + ], + } + + const result = await runTest( + ` + const value = ${get}("sharedVar") + pm.test("should get selected scope's initialValue, not global", () => { + pm.expect(value).to.equal("selected_initial") + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + } + ) + + describe.each(NAMESPACES)( + "$name - runtime modification and fallback", + ({ get, set }) => { + test("should use newly set value, not fallback to initialValue", async () => { + const envs = { + global: [], + selected: [ + { + key: "testVar", + currentValue: "", + initialValue: "initial", + secret: false, + }, + ], + } + + const result = await runTest( + ` + // First, verify it falls back to initialValue + const before = ${get}("testVar") + pm.test("before set: should get initial value", () => { + pm.expect(before).to.equal("initial") + }) + + // Now set a new value + ${set}("testVar", "runtime_value") + + // Verify it uses the newly set value + const after = ${get}("testVar") + pm.test("after set: should get runtime value", () => { + pm.expect(after).to.equal("runtime_value") + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should fallback when currentValue is set to empty string at runtime", async () => { + const envs = { + global: [], + selected: [ + { + key: "testVar", + currentValue: "original", + initialValue: "fallback", + secret: false, + }, + ], + } + + const result = await runTest( + ` + // First, verify original value + const before = ${get}("testVar") + pm.test("before: should get original value", () => { + pm.expect(before).to.equal("original") + }) + + // Set to empty string + ${set}("testVar", "") + + // Should now fall back to initialValue + const after = ${get}("testVar") + pm.test("after setting empty: should fallback to initial", () => { + pm.expect(after).to.equal("fallback") + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + } + ) + + // PM namespace returns undefined for non-existent keys + describe("pm.environment - non-existent variable behavior", () => { + test("should return undefined for non-existent variable (not fallback)", async () => { + const envs = { + global: [], + selected: [], + } + + const result = await runTest( + ` + const value = pm.environment.get("nonExistent") + pm.test("should get undefined for non-existent", () => { + pm.expect(value).to.be.undefined + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + // Hopp namespace returns null for non-existent keys + describe("hopp.env - non-existent variable behavior", () => { + test("should return null for non-existent variable (not fallback)", async () => { + const envs = { + global: [], + selected: [], + } + + const result = await runTest( + ` + const value = hopp.env.get("nonExistent") + pm.test("should get null for non-existent", () => { + pm.expect(value).to.be.null + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) +}) + +// Additional tests for pm.variables which searches both scopes +describe("pm.variables - Fallback Behavior Across Scopes", () => { + test("should fallback to initialValue in selected scope first", async () => { + const envs = { + global: [ + { + key: "sharedVar", + currentValue: "global_value", + initialValue: "global_initial", + secret: false, + }, + ], + selected: [ + { + key: "sharedVar", + currentValue: "", + initialValue: "selected_initial", + secret: false, + }, + ], + } + + const result = await runTest( + ` + const value = pm.variables.get("sharedVar") + pm.test("should get selected scope's initialValue", () => { + pm.expect(value).to.equal("selected_initial") + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should search global scope when selected scope variable doesn't exist", async () => { + const envs = { + global: [ + { + key: "globalOnly", + currentValue: "", + initialValue: "global_fallback", + secret: false, + }, + ], + selected: [], + } + + const result = await runTest( + ` + const value = pm.variables.get("globalOnly") + pm.test("should get global scope's initialValue", () => { + pm.expect(value).to.equal("global_fallback") + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should return undefined when variable doesn't exist in either scope", async () => { + const envs = { + global: [], + selected: [], + } + + const result = await runTest( + ` + const value = pm.variables.get("nonExistent") + pm.test("should get undefined", () => { + pm.expect(value).to.be.undefined + }) + `, + envs + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/instanceof-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/instanceof-assertions.spec.ts new file mode 100644 index 0000000..c78f0f5 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/instanceof-assertions.spec.ts @@ -0,0 +1,503 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +const NAMESPACES = ["pm", "hopp"] as const + +describe.each(NAMESPACES)( + "%s.expect() - instanceof assertions - Built-in types", + (namespace) => { + test("should support instanceof Object for plain objects", async () => { + const testScript = ` + ${namespace}.test("instanceof Object", () => { + const obj = { key: "value" }; + ${namespace}.expect(obj).to.be.an.instanceof(Object); + ${namespace}.expect(obj).to.be.instanceOf(Object); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "instanceof Object", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should support instanceof Array", async () => { + const testScript = ` + ${namespace}.test("instanceof Array", () => { + const arr = [1, 2, 3]; + ${namespace}.expect(arr).to.be.an.instanceof(Array); + ${namespace}.expect(arr).to.be.instanceOf(Array); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "instanceof Array", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should support instanceof Date", async () => { + const testScript = ` + ${namespace}.test("instanceof Date", () => { + const date = new Date(); + ${namespace}.expect(date).to.be.an.instanceof(Date); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "instanceof Date", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should support instanceof RegExp", async () => { + const testScript = ` + ${namespace}.test("instanceof RegExp", () => { + const regex = /test/; + ${namespace}.expect(regex).to.be.an.instanceof(RegExp); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "instanceof RegExp", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should support instanceof Error", async () => { + const testScript = ` + ${namespace}.test("instanceof Error", () => { + const err = new Error("test error"); + ${namespace}.expect(err).to.be.an.instanceof(Error); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "instanceof Error", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should support arrays as instanceof Object", async () => { + const testScript = ` + ${namespace}.test("array instanceof Object", () => { + const arr = [1, 2, 3]; + ${namespace}.expect(arr).to.be.an.instanceof(Object); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "array instanceof Object", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + } +) + +describe.each(NAMESPACES)( + "%s.expect() - instanceof assertions - Custom classes", + (namespace) => { + test("should support instanceof with custom class definitions", async () => { + const testScript = ` + ${namespace}.test("custom class instanceof", () => { + class Person { + constructor(name) { + this.name = name; + } + } + + const john = new Person("John"); + ${namespace}.expect(john).to.be.an.instanceof(Person); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "custom class instanceof", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should support instanceof with ES6 class syntax", async () => { + const testScript = ` + ${namespace}.test("ES6 class instanceof", () => { + class Animal { + constructor(type) { + this.type = type; + } + } + + class Dog extends Animal { + constructor(name) { + super("dog"); + this.name = name; + } + } + + const rover = new Dog("Rover"); + ${namespace}.expect(rover).to.be.an.instanceof(Dog); + ${namespace}.expect(rover).to.be.an.instanceof(Animal); + ${namespace}.expect(rover).to.be.an.instanceof(Object); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "ES6 class instanceof", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should support instanceof with constructor functions", async () => { + const testScript = ` + ${namespace}.test("constructor function instanceof", () => { + function Car(make, model) { + this.make = make; + this.model = model; + } + + const tesla = new Car("Tesla", "Model 3"); + ${namespace}.expect(tesla).to.be.an.instanceof(Car); + ${namespace}.expect(tesla).to.be.an.instanceof(Object); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "constructor function instanceof", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with custom classes in response data context", async () => { + const testScript = ` + ${namespace}.test("custom class with response data", () => { + class ResponseData { + constructor(data) { + this.data = data; + } + } + + const responseData = pm.response.json(); + const wrapped = new ResponseData(responseData); + + ${namespace}.expect(wrapped).to.be.an.instanceof(ResponseData); + ${namespace}.expect(wrapped).to.be.an.instanceof(Object); + }); + ` + + const mockResponse = { + status: 200, + statusText: "OK", + responseTime: 100, + headers: [], + body: { test: "data" }, + } + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "custom class with response data", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + } +) + +describe.each(NAMESPACES)( + "%s.expect() - instanceof assertions - Custom classes with other assertions", + (namespace) => { + test("should work with custom classes in property assertions", async () => { + const testScript = ` + ${namespace}.test("custom class with properties", () => { + class User { + constructor(name, age) { + this.name = name; + this.age = age; + } + } + + const user = new User("Alice", 30); + + ${namespace}.expect(user).to.be.an.instanceof(User); + ${namespace}.expect(user).to.have.property("name", "Alice"); + ${namespace}.expect(user).to.have.property("age", 30); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "custom class with properties", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with custom classes in array assertions", async () => { + const testScript = ` + ${namespace}.test("array of custom class instances", () => { + class Item { + constructor(id) { + this.id = id; + } + } + + const items = [new Item(1), new Item(2), new Item(3)]; + + ${namespace}.expect(items).to.be.an.instanceof(Array); + ${namespace}.expect(items).to.have.lengthOf(3); + ${namespace}.expect(items[0]).to.be.an.instanceof(Item); + ${namespace}.expect(items[1]).to.be.an.instanceof(Item); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "array of custom class instances", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with custom classes and deep equality", async () => { + const testScript = ` + ${namespace}.test("custom class deep equality", () => { + class Point { + constructor(x, y) { + this.x = x; + this.y = y; + } + } + + const p1 = new Point(10, 20); + const p2 = new Point(10, 20); + + ${namespace}.expect(p1).to.be.an.instanceof(Point); + ${namespace}.expect(p2).to.be.an.instanceof(Point); + ${namespace}.expect(p1).to.deep.equal(p2); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "custom class deep equality", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + } +) + +describe.each(NAMESPACES)( + "%s.expect() - instanceof assertions - Negation and failure cases", + (namespace) => { + test("should support negation with .not.instanceof", async () => { + const testScript = ` + ${namespace}.test("negated instanceof", () => { + const str = "hello"; + const num = 42; + const arr = [1, 2, 3]; + + ${namespace}.expect(str).to.not.be.an.instanceof(Number); + ${namespace}.expect(num).to.not.be.an.instanceof(String); + ${namespace}.expect(arr).to.not.be.an.instanceof(String); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "negated instanceof", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should fail when instanceof check fails", async () => { + const testScript = ` + ${namespace}.test("instanceof failure", () => { + const str = "hello"; + ${namespace}.expect(str).to.be.an.instanceof(Array); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "instanceof failure", + expectResults: [expect.objectContaining({ status: "fail" })], + }), + ]), + }), + ]) + ) + }) + } +) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/keys-members-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/keys-members-assertions.spec.ts new file mode 100644 index 0000000..8309bdc --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/keys-members-assertions.spec.ts @@ -0,0 +1,443 @@ +/** + * @see https://github.com/hoppscotch/hoppscotch/issues/5489 + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +const NAMESPACES = ["pm", "hopp"] as const + +describe.each(NAMESPACES)("%s.expect() - Keys Assertions", (namespace) => { + describe("keys() method", () => { + test("should accept array syntax: keys(['a', 'b'])", () => { + return expect( + runTest(` + ${namespace}.test('Keys with array syntax', function () { + ${namespace}.expect({a: 1, b: 2}).to.have.keys(['a','b']); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Keys with array syntax", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1, b: 2} to have keys 'a', 'b'", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should accept spread syntax: keys('a', 'b')", () => { + return expect( + runTest(` + ${namespace}.test('Keys with spread syntax', function () { + ${namespace}.expect({a: 1, b: 2}).to.have.keys('a','b'); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Keys with spread syntax", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1, b: 2} to have keys 'a', 'b'", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should support negation with array syntax", () => { + return expect( + runTest(` + ${namespace}.test('Negated keys with array', function () { + ${namespace}.expect({a: 1}).to.not.have.keys(['b','c']); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Negated keys with array", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1} to not have keys 'b', 'c'", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should support negation with spread syntax", () => { + return expect( + runTest(` + ${namespace}.test('Negated keys with spread', function () { + ${namespace}.expect({x:5}).to.not.have.keys('y','z'); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Negated keys with spread", + expectResults: [ + { + status: "pass", + message: "Expected {x: 5} to not have keys 'y', 'z'", + }, + ], + children: [], + }, + ], + }, + ]) + }) + }) + + describe("key() singular method", () => { + test("should accept single string argument", () => { + return expect( + runTest(` + ${namespace}.test('Single key check', function () { + ${namespace}.expect({name: 'test'}).to.have.key('name'); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Single key check", + expectResults: [ + { + status: "pass", + message: "Expected {name: 'test'} to have keys 'name'", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should support negation: not.have.key('z')", () => { + return expect( + runTest(` + ${namespace}.test('Negated key assertion', function () { + ${namespace}.expect({x:5}).to.not.have.key('z'); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Negated key assertion", + expectResults: [ + { + status: "pass", + message: "Expected {x: 5} to not have keys 'z'", + }, + ], + children: [], + }, + ], + }, + ]) + }) + }) +}) + +describe.each(NAMESPACES)("%s.expect() - Members Assertions", (namespace) => { + describe("members() method", () => { + test("should match members in any order", () => { + return expect( + runTest(` + ${namespace}.test('Members matching', function () { + ${namespace}.expect([1,2,3]).to.have.members([3,2,1]); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Members matching", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to have members [3, 2, 1]", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should support negation", () => { + return expect( + runTest(` + ${namespace}.test('Negated members', function () { + ${namespace}.expect([1,2,3]).to.not.have.members([4,5,6]); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Negated members", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to not have members [4, 5, 6]", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should fail when members don't match", () => { + return expect( + runTest(` + ${namespace}.test('Members mismatch', function () { + ${namespace}.expect([1,2]).to.have.members([1,2,3]); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Members mismatch", + expectResults: [ + { + status: "fail", + message: expect.stringContaining("to have members"), + }, + ], + children: [], + }, + ], + }, + ]) + }) + }) + + describe("include.members() method", () => { + test("should match subset of members", () => { + return expect( + runTest(` + ${namespace}.test('Include members', function () { + ${namespace}.expect([1,2,3]).to.include.members([2]); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Include members", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to include members [2]", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should match multiple subset members", () => { + return expect( + runTest(` + ${namespace}.test('Multiple include members', function () { + ${namespace}.expect([1,2,3,4,5]).to.include.members([2,4]); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Multiple include members", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3, 4, 5] to include members [2, 4]", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should support negation: not.include.members([5])", () => { + return expect( + runTest(` + ${namespace}.test('Negated include.members', function () { + ${namespace}.expect([1,2,3]).to.not.include.members([5]); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Negated include.members", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to not include members [5]", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should fail when subset members not present", () => { + return expect( + runTest(` + ${namespace}.test('Missing subset members', function () { + ${namespace}.expect([1,2,3]).to.include.members([4,5]); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Missing subset members", + expectResults: [ + { + status: "fail", + message: expect.stringContaining("to include members"), + }, + ], + children: [], + }, + ], + }, + ]) + }) + }) +}) + +describe.each(NAMESPACES)( + "%s.expect() - Combined Keys and Members", + (namespace) => { + test("should handle all assertions from the reported issue", () => { + return expect( + runTest(` + ${namespace}.test('Contains and includes', function () { + var arr = [1,2,3]; + ${namespace}.expect(arr).to.include(2); + ${namespace}.expect('hoppscotch').to.include('hopp'); + ${namespace}.expect({a: 1, b: 2}).to.have.keys(['a','b']); + ${namespace}.expect({x:5}).to.not.have.key('z'); + }); + + ${namespace}.test('Members matching', function () { + ${namespace}.expect([1,2,3]).to.have.members([3,2,1]); + ${namespace}.expect([1,2,3]).to.include.members([2]); + ${namespace}.expect([1,2,3]).to.not.include.members([5]); + }); + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Contains and includes", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to include 2", + }, + { + status: "pass", + message: "Expected 'hoppscotch' to include 'hopp'", + }, + { + status: "pass", + message: "Expected {a: 1, b: 2} to have keys 'a', 'b'", + }, + { + status: "pass", + message: "Expected {x: 5} to not have keys 'z'", + }, + ], + children: [], + }, + { + descriptor: "Members matching", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to have members [3, 2, 1]", + }, + { + status: "pass", + message: "Expected [1, 2, 3] to include members [2]", + }, + { + status: "pass", + message: "Expected [1, 2, 3] to not include members [5]", + }, + ], + children: [], + }, + ], + }, + ]) + }) + } +) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/length-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/length-assertions.spec.ts new file mode 100644 index 0000000..8713e2b --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/length-assertions.spec.ts @@ -0,0 +1,604 @@ +import { describe, expect, test } from "vitest" +import { TestResponse } from "~/types" +import { runTest } from "~/utils/test-helpers" + +const NAMESPACES = ["pm", "hopp"] as const + +describe.each(NAMESPACES)("%s.expect() - Length Assertions", (namespace) => { + const mockResponse: TestResponse = { + status: 200, + statusText: "OK", + responseTime: 100, + headers: [{ key: "content-type", value: "application/json" }], + body: { + items: ["apple", "banana", "cherry"], + emptyArray: [], + singleItem: ["solo"], + data: { + nested: { + values: [1, 2, 3, 4, 5], + }, + }, + }, + } + + describe(".length getter - Basic comparison methods", () => { + test("should support .length.above() for arrays", async () => { + const testScript = ` + ${namespace}.test("length.above()", () => { + ${namespace}.expect([1, 2, 3, 4]).to.have.length.above(3); + ${namespace}.expect([1, 2]).to.not.have.length.above(5); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "length.above()", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support .length.above() for strings", async () => { + const testScript = ` + ${namespace}.test("string length.above()", () => { + ${namespace}.expect('hello world').to.have.length.above(5); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "string length.above()", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should support .length.below() for arrays", async () => { + const testScript = ` + ${namespace}.test("length.below()", () => { + ${namespace}.expect([1, 2]).to.have.length.below(5); + ${namespace}.expect([1, 2, 3, 4]).to.not.have.length.below(3); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "length.below()", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support .length.within() for range checks", async () => { + const testScript = ` + ${namespace}.test("length.within()", () => { + ${namespace}.expect([1, 2, 3]).to.have.length.within(2, 5); + ${namespace}.expect('test').to.have.length.within(1, 10); + ${namespace}.expect([1]).to.not.have.length.within(5, 10); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "length.within()", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe(".length.at.least() and .length.at.most() - Postman chain syntax", () => { + test("should pass when array length meets minimum (.at.least)", async () => { + const script = ` + ${namespace}.test("Array length at least", () => { + const items = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.items + ${namespace}.expect(items).to.have.length.at.least(1) + ${namespace}.expect(items).to.have.length.at.least(3) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + mockResponse + )() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Array length at least", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should fail when array length below minimum (.at.least)", async () => { + const script = ` + ${namespace}.test("Array too short", () => { + const items = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.items + ${namespace}.expect(items).to.have.length.at.least(10) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + mockResponse + )() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Array too short", + expectResults: [expect.objectContaining({ status: "fail" })], + }), + ]), + }), + ]) + ) + }) + + test("should pass when array length within maximum (.at.most)", async () => { + const script = ` + ${namespace}.test("Array length at most", () => { + const items = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.items + ${namespace}.expect(items).to.have.length.at.most(10) + ${namespace}.expect(items).to.have.length.at.most(3) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + mockResponse + )() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Array length at most", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should fail when array length exceeds maximum (.at.most)", async () => { + const script = ` + ${namespace}.test("Array too long", () => { + const items = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.items + ${namespace}.expect(items).to.have.length.at.most(2) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + mockResponse + )() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Array too long", + expectResults: [expect.objectContaining({ status: "fail" })], + }), + ]), + }), + ]) + ) + }) + }) + + describe(".length.least() and .length.most() - Direct methods without .at", () => { + test("should support .length.least() without .at chain", async () => { + const script = ` + ${namespace}.test("Direct least", () => { + ${namespace}.expect([1, 2, 3]).to.have.length.least(1) + ${namespace}.expect([1, 2, 3]).to.have.length.least(3) + }) + ` + + const result = await runTest(script, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Direct least", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should support mixed syntax with and without .at", async () => { + const script = ` + ${namespace}.test("Mixed syntax", () => { + const items = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.items + ${namespace}.expect(items).to.have.length.least(1) + ${namespace}.expect(items).to.have.length.at.least(1) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + mockResponse + )() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Mixed syntax", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + }) + + describe(".length.gte() and .length.lte() - Aliases", () => { + test("should support .length.gte() as alias for .least()", async () => { + const script = ` + ${namespace}.test("GTE alias", () => { + ${namespace}.expect([1, 2, 3]).to.have.length.gte(3) + ${namespace}.expect([1, 2, 3]).to.have.length.gte(1) + }) + ` + + const result = await runTest(script, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "GTE alias", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should support .length.lte() as alias for .most()", async () => { + const script = ` + ${namespace}.test("LTE alias", () => { + ${namespace}.expect([1, 2, 3]).to.have.length.lte(3) + ${namespace}.expect([1, 2, 3]).to.have.length.lte(10) + }) + ` + + const result = await runTest(script, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "LTE alias", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + }) + + describe(".length(n) - Callable method for exact length", () => { + test("should support .length(n) as method for exact length", async () => { + const testScript = ` + ${namespace}.test("length as method", () => { + ${namespace}.expect([1, 2, 3]).to.have.length(3); + ${namespace}.expect('abc').to.have.length(3); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "length as method", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe(".lengthOf(n) - Method for exact length", () => { + test("should support .lengthOf(n) for exact length", async () => { + const testScript = ` + ${namespace}.test("lengthOf()", () => { + ${namespace}.expect('hello').to.have.lengthOf(5); + ${namespace}.expect([1, 2, 3, 4, 5]).to.have.lengthOf(5); + ${namespace}.expect('').to.have.lengthOf(0); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "lengthOf()", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe(".lengthOf.at.least() and .lengthOf.at.most() - Alternative syntax", () => { + const lengthOfMockResponse: TestResponse = { + status: 200, + statusText: "OK", + responseTime: 100, + headers: [], + body: { + items: ["a", "b", "c", "d"], + }, + } + + test("should support .lengthOf.at.least()", async () => { + const script = ` + ${namespace}.test("lengthOf.at.least", () => { + const items = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.items + ${namespace}.expect(items).to.have.lengthOf.at.least(1) + ${namespace}.expect(items).to.have.lengthOf.at.least(4) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + lengthOfMockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "lengthOf.at.least", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should support .lengthOf.at.most()", async () => { + const script = ` + ${namespace}.test("lengthOf.at.most", () => { + const items = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.items + ${namespace}.expect(items).to.have.lengthOf.at.most(10) + ${namespace}.expect(items).to.have.lengthOf.at.most(4) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + lengthOfMockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "lengthOf.at.most", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + }) + + describe("Edge cases and special scenarios", () => { + test("should work with empty arrays", async () => { + const script = ` + ${namespace}.test("Empty array", () => { + const empty = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.emptyArray + ${namespace}.expect(empty).to.have.length.at.least(0) + ${namespace}.expect(empty).to.have.length.at.most(0) + ${namespace}.expect(empty).to.have.length(0) + ${namespace}.expect(empty).to.have.lengthOf(0) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + mockResponse + )() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Empty array", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with strings", async () => { + const script = ` + ${namespace}.test("String length", () => { + const str = "hello world" + ${namespace}.expect(str).to.have.length.at.least(5) + ${namespace}.expect(str).to.have.length.at.most(20) + ${namespace}.expect(str).to.have.length(11) + ${namespace}.expect(str).to.have.lengthOf(11) + }) + ` + + const result = await runTest(script, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "String length", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with nested arrays", async () => { + const script = ` + ${namespace}.test("Nested array length", () => { + const nested = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.data.nested.values + ${namespace}.expect(nested).to.have.length.at.least(5) + ${namespace}.expect(nested).to.have.lengthOf(5) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + mockResponse + )() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Nested array length", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/null-undefined-value-preservation.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/null-undefined-value-preservation.spec.ts new file mode 100644 index 0000000..418adac --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/null-undefined-value-preservation.spec.ts @@ -0,0 +1,378 @@ +/** + * Null and Undefined Value Preservation Across Namespaces + * + * Tests that null and undefined values are correctly preserved when setting + * and getting environment variables across pm, pw, and hopp namespaces. + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("Null and undefined value preservation across namespaces", () => { + describe("Cross-namespace null value handling", () => { + test("pm.environment.set with null should work across pm, pw, and hopp namespaces", () => { + return expect( + runTest( + ` + pm.environment.set('key', null) + + pm.test("pm.environment.get returns actual null", () => { + pm.expect(pm.environment.get('key')).to.equal(null) + }) + + pm.test("typeof null should be 'object'", () => { + pm.expect(typeof pm.environment.get('key')).to.equal('object') + }) + + pm.test("pw.env.get returns actual null (cross-namespace)", () => { + pm.expect(pw.env.get('key')).to.equal(null) + }) + + pm.test("typeof via pw.env.get should be 'object'", () => { + pm.expect(typeof pw.env.get('key')).to.equal('object') + }) + + pm.test("hopp.env.get returns actual null (cross-namespace)", () => { + pm.expect(hopp.env.get('key')).to.equal(null) + }) + + pm.test("typeof via hopp.env.get should be 'object'", () => { + pm.expect(typeof hopp.env.get('key')).to.equal('object') + }) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "pm.environment.get returns actual null", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "typeof null should be 'object'", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "pw.env.get returns actual null (cross-namespace)", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "typeof via pw.env.get should be 'object'", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: + "hopp.env.get returns actual null (cross-namespace)", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "typeof via hopp.env.get should be 'object'", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("pm.environment.set with undefined should work across pm, pw, and hopp namespaces", () => { + return expect( + runTest( + ` + pm.environment.set('undefKey', undefined) + + pm.test("pm.environment.get returns actual undefined", () => { + pm.expect(pm.environment.get('undefKey')).to.equal(undefined) + }) + + pm.test("typeof undefined should be 'undefined'", () => { + pm.expect(typeof pm.environment.get('undefKey')).to.equal('undefined') + }) + + pm.test("pw.env.get returns actual undefined (cross-namespace)", () => { + pm.expect(pw.env.get('undefKey')).to.equal(undefined) + }) + + pm.test("typeof via pw.env.get should be 'undefined'", () => { + pm.expect(typeof pw.env.get('undefKey')).to.equal('undefined') + }) + + pm.test("hopp.env.get returns actual undefined (cross-namespace)", () => { + pm.expect(hopp.env.get('undefKey')).to.equal(undefined) + }) + + pm.test("typeof via hopp.env.get should be 'undefined'", () => { + pm.expect(typeof hopp.env.get('undefKey')).to.equal('undefined') + }) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "pm.environment.get returns actual undefined", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "typeof undefined should be 'undefined'", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: + "pw.env.get returns actual undefined (cross-namespace)", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "typeof via pw.env.get should be 'undefined'", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: + "hopp.env.get returns actual undefined (cross-namespace)", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "typeof via hopp.env.get should be 'undefined'", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe("Assertion failure messages display actual values", () => { + test("null assertion failures should show actual 'null' value in error messages", () => { + return expect( + runTest( + ` + pm.environment.set('nullKey', null) + + pm.test("pm.environment.get error message should not contain marker", () => { + pm.expect(pm.environment.get('nullKey')).to.equal("this is not null") + }) + + pm.test("pw.env.get error message should not contain marker", () => { + pm.expect(pw.env.get('nullKey')).to.equal("this is not null") + }) + + hopp.test("hopp.env.get error message should not contain marker", () => { + hopp.expect(hopp.env.get('nullKey')).to.equal("this is not null") + }) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: + "pm.environment.get error message should not contain marker", + expectResults: expect.arrayContaining([ + expect.objectContaining({ + status: "fail", + message: expect.not.stringContaining("__HOPPSCOTCH_NULL__"), + }), + ]), + }), + expect.objectContaining({ + descriptor: + "pw.env.get error message should not contain marker", + expectResults: expect.arrayContaining([ + expect.objectContaining({ + status: "fail", + message: expect.not.stringContaining("__HOPPSCOTCH_NULL__"), + }), + ]), + }), + expect.objectContaining({ + descriptor: + "hopp.env.get error message should not contain marker", + expectResults: expect.arrayContaining([ + expect.objectContaining({ + status: "fail", + message: expect.not.stringContaining("__HOPPSCOTCH_NULL__"), + }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("undefined assertion failures should show actual 'undefined' value in error messages", () => { + return expect( + runTest( + ` + pm.environment.set('undefKey', undefined) + + pm.test("pm.environment.get error message should not contain marker", () => { + pm.expect(pm.environment.get('undefKey')).to.equal("this is not undefined") + }) + + pm.test("pw.env.get error message should not contain marker", () => { + pm.expect(pw.env.get('undefKey')).to.equal("this is not undefined") + }) + + hopp.test("hopp.env.get error message should not contain marker", () => { + hopp.expect(hopp.env.get('undefKey')).to.equal("this is not undefined") + }) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: + "pm.environment.get error message should not contain marker", + expectResults: expect.arrayContaining([ + expect.objectContaining({ + status: "fail", + message: expect.not.stringContaining( + "__HOPPSCOTCH_UNDEFINED__" + ), + }), + ]), + }), + expect.objectContaining({ + descriptor: + "pw.env.get error message should not contain marker", + expectResults: expect.arrayContaining([ + expect.objectContaining({ + status: "fail", + message: expect.not.stringContaining( + "__HOPPSCOTCH_UNDEFINED__" + ), + }), + ]), + }), + expect.objectContaining({ + descriptor: + "hopp.env.get error message should not contain marker", + expectResults: expect.arrayContaining([ + expect.objectContaining({ + status: "fail", + message: expect.not.stringContaining( + "__HOPPSCOTCH_UNDEFINED__" + ), + }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe("pm.globals namespace null and undefined handling", () => { + test("pm.globals.set with null should work correctly", () => { + return expect( + runTest( + ` + pm.globals.set('globalNull', null) + + pm.test("pm.globals.get returns actual null", () => { + pm.expect(pm.globals.get('globalNull')).to.equal(null) + }) + + pm.test("typeof null should be 'object'", () => { + pm.expect(typeof pm.globals.get('globalNull')).to.equal('object') + }) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "pm.globals.get returns actual null", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "typeof null should be 'object'", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("pm.globals.set with undefined should work correctly", () => { + return expect( + runTest( + ` + pm.globals.set('globalUndef', undefined) + + pm.test("pm.globals.get returns actual undefined", () => { + pm.expect(pm.globals.get('globalUndef')).to.equal(undefined) + }) + + pm.test("typeof undefined should be 'undefined'", () => { + pm.expect(typeof pm.globals.get('globalUndef')).to.equal('undefined') + }) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "pm.globals.get returns actual undefined", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "typeof undefined should be 'undefined'", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-error-recovery.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-error-recovery.spec.ts new file mode 100644 index 0000000..404aa45 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-error-recovery.spec.ts @@ -0,0 +1,206 @@ +import { afterEach, describe, expect, test } from "vitest" +import { FaradayCage } from "faraday-cage" +import * as E from "fp-ts/Either" +import { runTest, fakeResponse, defaultRequest } from "~/utils/test-helpers" +import { runTestScript } from "~/web" +import { _setCagePromiseForTesting } from "~/utils/cage" + +/** + * Verifies that the test runner properly recovers from script errors without + * stale state persisting across subsequent executions. + */ +describe("script error recovery", () => { + test("runtime error followed by valid script should not show stale error", async () => { + const errorScript = ` +a(); // ReferenceError: a is not defined +hopp.test("Should not run", () => { + hopp.expect(hopp.response.statusCode).toBe(200); +}); +` + + const errorResult = await runTest(errorScript, { + global: [], + selected: [], + })() + + expect(errorResult).toBeLeft() + + const validScript = ` +// a(); - commented out +hopp.test("Status code is 200", () => { + hopp.expect(hopp.response.statusCode).toBe(200); +}); +` + + const validResult = await runTest(validScript, { + global: [], + selected: [], + })() + + expect(validResult).toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Status code is 200", + expectResults: [ + expect.objectContaining({ + status: "pass", + message: expect.stringContaining("Expected '200' to be '200'"), + }), + ], + }), + ], + }), + ]) + }) + + test("multiple consecutive runtime errors should each be fresh", async () => { + const error1 = await runTest(`a();`, { global: [], selected: [] })() + expect(error1).toBeLeft() + + const error2 = await runTest(`b();`, { global: [], selected: [] })() + expect(error2).toBeLeft() + + const valid = await runTest( + `hopp.test("Works", () => { hopp.expect(true).toBe(true); });`, + { global: [], selected: [] } + )() + + expect(valid).toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Works", + expectResults: [ + expect.objectContaining({ + status: "pass", + message: expect.stringContaining( + "Expected 'true' to be 'true'" + ), + }), + ], + }), + ], + }), + ]) + }) + + test("syntax error followed by valid script should work", async () => { + const syntaxError = await runTest(`const x = ;`, { + global: [], + selected: [], + })() + expect(syntaxError).toBeLeft() + + const valid = await runTest( + `hopp.test("Works", () => { hopp.expect(true).toBe(true); });`, + { global: [], selected: [] } + )() + + expect(valid).toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Works", + expectResults: [ + expect.objectContaining({ + status: "pass", + message: expect.stringContaining( + "Expected 'true' to be 'true'" + ), + }), + ], + }), + ], + }), + ]) + }) +}) + +/** + * Exercises the production singleton path where a corrupted cage persists + * across calls. The retry-on-bootstrap-error logic should transparently + * recover so the user never sees the stale failure. + */ +describe("singleton cage retry on bootstrap error", () => { + afterEach(() => { + _setCagePromiseForTesting(null) + }) + + test("bootstrap error triggers retry on fresh cage", async () => { + const corruptedCage = await FaradayCage.create() + const originalRunCode = corruptedCage.runCode.bind(corruptedCage) + + let callCount = 0 + corruptedCage.runCode = ((...args: Parameters) => { + callCount++ + if (callCount === 1) { + // Simulate an infrastructure error on the first call + return Promise.resolve({ + type: "error" as const, + err: new Error("cannot convert to object"), + }) + } + return originalRunCode(...args) + }) as typeof originalRunCode + + _setCagePromiseForTesting(Promise.resolve(corruptedCage)) + + const result = await runTestScript( + `hopp.test("Should work after retry", () => { hopp.expect(true).toBe(true); });`, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: fakeResponse, + cookies: null, + experimentalScriptingSandbox: true, + } + ) + + // The first call failed with an infra error, retry succeeded on a fresh cage + expect(callCount).toBe(1) + expect(E.isRight(result)).toBe(true) + + if (E.isRight(result)) { + expect(result.right.tests).toEqual( + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Should work after retry", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ], + }) + ) + } + }) + + test("user script errors do not trigger retry", async () => { + const cage = await FaradayCage.create() + const originalRunCode = cage.runCode.bind(cage) + + let callCount = 0 + cage.runCode = ((...args: Parameters) => { + callCount++ + return originalRunCode(...args) + }) as typeof originalRunCode + + _setCagePromiseForTesting(Promise.resolve(cage)) + + const result = await runTestScript(`a();`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: fakeResponse, + cookies: null, + experimentalScriptingSandbox: true, + }) + + // User script error should NOT trigger retry — only one call to runCode + expect(E.isLeft(result)).toBe(true) + expect(callCount).toBe(1) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-execution-error-propagation.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-execution-error-propagation.spec.ts new file mode 100644 index 0000000..23c158a --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-execution-error-propagation.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "vitest" +import { runPreRequest, runTest } from "~/utils/test-helpers" + +/** + * The experimental sandbox silently swallowed top-level throws inside the + * generated `await (async function(){...})()` wrapper. The fix routes the + * error through a lexically-captured host reporter installed by bootstrap, + * read back on the host side as the Left branch of the TaskEither pipeline. + * + * These tests exercise the sandbox contract directly by reproducing the + * generated wrapper shape that `combineScriptsWithIIFE` emits from + * `@hoppscotch/cli` and `@hoppscotch/common`. + */ +describe("script execution error propagation", () => { + const envs = { global: [], selected: [] } + + // Mirrors `combineScriptsWithIIFE("experimental")` in the CLI and common + // packages. Kept local to avoid a cross-package import. + const wrapExperimental = (body: string): string => + [ + "const __hoppReporter = globalThis.__hoppReportScriptExecutionError;", + "try {", + `await (async function() {\n${body}\n})();`, + "} catch (__hoppScriptExecutionError) {", + " __hoppReporter(__hoppScriptExecutionError);", + "}", + ].join("\n") + + test("experimental pre-request: synchronous top-level throw returns Left", async () => { + const script = wrapExperimental( + `throw new Error("pre-request top-level throw");` + ) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeLeft() + if (result._tag === "Left") { + expect(result.left).toContain("pre-request top-level throw") + } + }) + + test("experimental pre-request: rejected await inside IIFE returns Left", async () => { + const script = wrapExperimental( + `await Promise.reject(new Error("pre-request rejected await"));` + ) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeLeft() + if (result._tag === "Left") { + expect(result.left).toContain("pre-request rejected await") + } + }) + + test("experimental pre-request: valid script still returns Right", async () => { + const script = wrapExperimental(`pw.env.set("FLAG", "ok");`) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeRight() + }) + + test("experimental test-runner: synchronous top-level throw returns Left", async () => { + const script = wrapExperimental( + `throw new Error("test-runner top-level throw");` + ) + + const result = await runTest(script, envs)() + + expect(result).toBeLeft() + if (result._tag === "Left") { + expect(result.left).toContain("test-runner top-level throw") + } + }) + + test("user script cannot tamper with the reporter to suppress error reporting", async () => { + // User script attempts to delete + overwrite the globalThis reporter + // before throwing; both defenses (immutable property + lexical capture + // in the wrapper) keep the report path intact. + const script = wrapExperimental( + ` + try { + delete globalThis.__hoppReportScriptExecutionError; + globalThis.__hoppReportScriptExecutionError = () => {}; + } catch (_e) { + // strict mode may throw on immutable-property tamper; ignore + } + throw new Error("tamper-attempt throw"); + ` + ) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeLeft() + if (result._tag === "Left") { + expect(result.left).toContain("tamper-attempt throw") + } + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-imports.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-imports.spec.ts new file mode 100644 index 0000000..07eb613 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-imports.spec.ts @@ -0,0 +1,344 @@ +import * as E from "fp-ts/Either" +import { describe, expect, test } from "vitest" +import { combineScriptsWithIIFE } from "~/utils/scripting" +import { runPreRequest, runTestAndGetEnvs } from "~/utils/test-helpers" + +const envs = { global: [], selected: [] } + +describe("script ESM imports — pre-request scripts", () => { + test("named import binding is reachable from the script body", async () => { + const script = combineScriptsWithIIFE([ + `import { value } from "data:text/javascript,export const value = 'esm-ok'";\npw.env.set("IMPORTED_VALUE", value);`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find( + (v) => v.key === "IMPORTED_VALUE" + ) + expect(updated?.currentValue).toBe("esm-ok") + } + }) + + test("default import binding is reachable from the script body", async () => { + const script = combineScriptsWithIIFE([ + `import obj from "data:text/javascript,export default { greet: 'hi' }";\npw.env.set("GREETING", obj.greet);`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find((v) => v.key === "GREETING") + expect(updated?.currentValue).toBe("hi") + } + }) + + test("multiple imports across cascade reach the consuming script body", async () => { + const script = combineScriptsWithIIFE([ + `import { rootVal } from "data:text/javascript,export const rootVal = 1";`, + `import { folderVal } from "data:text/javascript,export const folderVal = 2";`, + `import { reqVal } from "data:text/javascript,export const reqVal = 3";\npw.env.set("SUM", String(rootVal + folderVal + reqVal));`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find((v) => v.key === "SUM") + expect(updated?.currentValue).toBe("6") + } + }) + + test("namespace import binding is reachable from the script body", async () => { + const script = combineScriptsWithIIFE([ + `import * as ns from "data:text/javascript,export const a = 1; export const b = 2";\npw.env.set("NS_SUM", String(ns.a + ns.b));`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find((v) => v.key === "NS_SUM") + expect(updated?.currentValue).toBe("3") + } + }) + + test("mixed default + named imports resolve from one source", async () => { + const script = combineScriptsWithIIFE([ + `import obj, { extra } from "data:text/javascript,export default { v: 7 }; export const extra = 5";\npw.env.set("MIXED", String(obj.v + extra));`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find((v) => v.key === "MIXED") + expect(updated?.currentValue).toBe("12") + } + }) + + test("identical imports across scripts are deduped to a single emit", async () => { + const sharedSource = `data:text/javascript,export default 'shared'` + const script = combineScriptsWithIIFE([ + `import shared from "${sharedSource}";\npw.env.set("FROM_FIRST", shared);`, + `import shared from "${sharedSource}";\npw.env.set("FROM_SECOND", shared);`, + ]) + + const importMatches = script.match(/^import shared from /gm) ?? [] + expect(importMatches).toHaveLength(1) + + const result = await runPreRequest(script, envs)() + expect(result).toBeRight() + if (E.isRight(result)) { + expect( + result.right.selected.find((v) => v.key === "FROM_FIRST")?.currentValue + ).toBe("shared") + expect( + result.right.selected.find((v) => v.key === "FROM_SECOND")?.currentValue + ).toBe("shared") + } + }) + + // Dedup is by literal string match; cosmetic differences (whitespace, quote + // style, alias-renames) from the same source are NOT deduped and surface as + // a duplicate-declaration error from the module evaluator. + test("cosmetically different but semantically identical imports are NOT deduped", async () => { + const script = combineScriptsWithIIFE([ + `import dup from "data:text/javascript,export default 1";`, + `import dup from "data:text/javascript,export default 1";`, + ]) + + const importMatches = script.match(/^import dup\s+from /gm) ?? [] + expect(importMatches).toHaveLength(2) + }) + + // Mixing import shapes for the same local name from the same source + // (e.g. `import * as foo` + `import { foo }`) emits both lines. The friendly + // pre-cage check only fires on cross-source collisions, so this surfaces as + // a duplicate-declaration error from the module evaluator. + test("namespace + named imports for the same local name emit both lines", async () => { + const sharedSource = `data:text/javascript,export const foo = 1` + const script = combineScriptsWithIIFE([ + `import * as foo from "${sharedSource}";`, + `import { foo } from "${sharedSource}";`, + ]) + + const importMatches = script.match(/^import .*foo.* from /gm) ?? [] + expect(importMatches).toHaveLength(2) + }) + + test("same-name imports from different sources surface a SyntaxError", async () => { + const script = combineScriptsWithIIFE([ + `import dup from "data:text/javascript,export default 1";`, + `import dup from "data:text/javascript,export default 2";\npw.env.set("SHOULD_NOT_RUN", "yes");`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeLeft() + if (E.isLeft(result)) { + expect(result.left).toMatch( + /'dup' is imported from different sources across scripts in this request's chain/ + ) + } + }) + + test("parse failure surfaces the original Acorn message, not a misleading wrapper error", async () => { + // Pre-fix: wrapper would re-evaluate the raw script inside an IIFE + // and surface a misleading "import declarations may only appear at + // top level" error instead of the actual syntax error. + const script = combineScriptsWithIIFE([ + `import { foo } from "data:text/javascript,export const foo = 1";\nconst x = ;`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeLeft() + if (E.isLeft(result)) { + expect(result.left).toMatch(/\[Hoppscotch\] Script failed to parse/) + expect(result.left).not.toMatch( + /import declarations may only appear at top level/ + ) + } + }) + + test("import-only cascade emits clean output without an empty try/catch", () => { + // Import-only cascade: no awaited bodies → no try/catch needed. + const script = combineScriptsWithIIFE([ + `import "data:text/javascript,globalThis.__a = 1";`, + `import "data:text/javascript,globalThis.__b = 2";`, + ]) + + expect(script).not.toContain("try {") + expect(script).not.toContain("__hoppReporter") + expect(script).toContain('import "data:text/javascript,globalThis.__a = 1"') + expect(script).toContain('import "data:text/javascript,globalThis.__b = 2"') + }) + + test("import-only cascade with cross-source clash still surfaces the friendly conflict error", async () => { + // The import-only short-circuit must not bypass conflict detection. + const script = combineScriptsWithIIFE([ + `import dup from "data:text/javascript,export default 1";`, + `import dup from "data:text/javascript,export default 2";`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeLeft() + if (E.isLeft(result)) { + expect(result.left).toMatch( + /'dup' is imported from different sources across scripts in this request's chain/ + ) + } + }) + + test("user import binding to a wrapper-reserved name surfaces a friendly error", async () => { + const script = combineScriptsWithIIFE([ + `import __hoppReporter from "data:text/javascript,export default {}";\npw.env.set("SHOULD_NOT_RUN", "yes");`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeLeft() + if (E.isLeft(result)) { + expect(result.left).toMatch( + /'__hoppReporter' is reserved by Hoppscotch's script wrapper/ + ) + } + }) + + test("user import binding 'globalThis' is also reserved", async () => { + // Wrapper reads `globalThis.__hoppReportScriptExecutionError`; a user + // import shadowing `globalThis` would silently break error reporting. + const script = combineScriptsWithIIFE([ + `import globalThis from "data:text/javascript,export default {}";`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeLeft() + if (E.isLeft(result)) { + expect(result.left).toMatch( + /'globalThis' is reserved by Hoppscotch's script wrapper/ + ) + } + }) + + test("named re-export-from declarations are hoisted alongside imports", async () => { + const script = combineScriptsWithIIFE([ + `export { value } from "data:text/javascript,export const value = 're-export-ok'";\nimport { value as v } from "data:text/javascript,export const value = 're-export-ok'";\npw.env.set("RE_EXPORT", v);`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find((v) => v.key === "RE_EXPORT") + expect(updated?.currentValue).toBe("re-export-ok") + } + }) + + test("export-all-from declarations are hoisted alongside imports", async () => { + const script = combineScriptsWithIIFE([ + `export * from "data:text/javascript,export const a = 1";\nimport { a } from "data:text/javascript,export const a = 1";\npw.env.set("EXPORT_ALL", String(a));`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find((v) => v.key === "EXPORT_ALL") + expect(updated?.currentValue).toBe("1") + } + }) +}) + +describe("script ESM imports — test scripts", () => { + test("named import binding resolves in test script", async () => { + const script = combineScriptsWithIIFE([ + `import { value } from "data:text/javascript,export const value = 'test-esm-ok'";\npw.env.set("IMPORTED_VALUE", value);`, + ]) + + const result = await runTestAndGetEnvs(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find( + (v) => v.key === "IMPORTED_VALUE" + ) + expect(updated?.currentValue).toBe("test-esm-ok") + } + }) + + test("default import binding resolves in test script", async () => { + const script = combineScriptsWithIIFE([ + `import obj from "data:text/javascript,export default { greet: 'hello-test' }";\npw.env.set("GREETING", obj.greet);`, + ]) + + const result = await runTestAndGetEnvs(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find((v) => v.key === "GREETING") + expect(updated?.currentValue).toBe("hello-test") + } + }) + + test("malformed test script surfaces a friendly SyntaxError pre-cage", async () => { + const result = await runTestAndGetEnvs("const x = ;", envs)() + + expect(result).toBeLeft() + if (E.isLeft(result)) { + expect(result.left).toMatch(/Script execution failed:.*SyntaxError/) + } + }) +}) + +// Live network coverage against esm.sh — opt-in to keep CI deterministic. +const networkTest = process.env.HOPP_NETWORK_TESTS === "1" ? test : test.skip + +describe("script ESM imports — live esm.sh (opt-in)", () => { + networkTest( + "real-world ESM import shape resolves end-to-end", + async () => { + const script = combineScriptsWithIIFE([ + [ + `import lodash from "https://esm.sh/lodash@4.17.21";`, + `import axios from "https://esm.sh/axios@1.6.0";`, + `import { format } from "https://esm.sh/date-fns@2.30.0";`, + `pw.env.set("PICKED", JSON.stringify(lodash.pick({ a: 1, b: 2 }, ["a"])));`, + `pw.env.set("AXIOS_TYPE", typeof axios);`, + `pw.env.set("FORMATTED", format(new Date(2026, 4, 7), "yyyy-MM-dd"));`, + ].join("\n"), + ]) + + // Soft-pass on esm.sh degradation — the assertions only run when the + // module loader actually delivers a usable result. + let result + try { + result = await runTestAndGetEnvs(script, envs)() + } catch (e) { + console.warn("[skip] esm.sh appears degraded:", e) + return + } + if (E.isLeft(result)) { + console.warn("[skip] esm.sh appears degraded:", result.left) + return + } + + expect( + result.right.selected.find((v) => v.key === "PICKED")?.currentValue + ).toBe(JSON.stringify({ a: 1 })) + expect( + result.right.selected.find((v) => v.key === "AXIOS_TYPE")?.currentValue + ).toMatch(/object|function/) + expect( + result.right.selected.find((v) => v.key === "FORMATTED")?.currentValue + ).toBe("2026-05-07") + }, + 30_000 + ) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/side-effects-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/side-effects-assertions.spec.ts new file mode 100644 index 0000000..56ece7d --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/side-effects-assertions.spec.ts @@ -0,0 +1,426 @@ +/** + * @see https://github.com/hoppscotch/hoppscotch/discussions/5221 + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +const NAMESPACES = ["pm", "hopp"] as const + +describe.each(NAMESPACES)( + "%s.expect() - Side Effect Assertions (Standard Pattern)", + (namespace) => { + describe("`.change()` with object and property", () => { + test("should detect property changes", () => { + return expect( + runTest(` + ${namespace}.test("change assertions work", () => { + const obj = { val: 0 } + ${namespace}.expect(() => { obj.val = 5 }).to.change(obj, 'val') + ${namespace}.expect(() => { obj.val = 5 }).to.not.change(obj, 'val') + ${namespace}.expect(() => {}).to.not.change(obj, 'val') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "change assertions work", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should detect changes by specific delta using `.by()`", () => { + return expect( + runTest(` + ${namespace}.test("change by delta works", () => { + const obj = { val: 0 } + ${namespace}.expect(() => { obj.val += 5 }).to.change(obj, 'val').by(5) + ${namespace}.expect(() => { obj.val -= 3 }).to.change(obj, 'val').by(3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "change by delta works", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support negative delta", () => { + return expect( + runTest(` + ${namespace}.test("change with negative delta", () => { + const obj = { value: 50 } + const decreaseValue = () => { obj.value = 30 } + ${namespace}.expect(decreaseValue).to.change(obj, "value").by(-20) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "change with negative delta", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("`.increase()` with object and property", () => { + test("should detect property increases", () => { + return expect( + runTest(` + ${namespace}.test("increase assertions work", () => { + const obj = { count: 0 } + ${namespace}.expect(() => { obj.count++ }).to.increase(obj, 'count') + ${namespace}.expect(() => { obj.count += 5 }).to.increase(obj, 'count') + ${namespace}.expect(() => { obj.count-- }).to.not.increase(obj, 'count') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "increase assertions work", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should detect increases by specific amount using `.by()`", () => { + return expect( + runTest(` + ${namespace}.test("increase by amount works", () => { + const obj = { count: 0 } + ${namespace}.expect(() => { obj.count += 3 }).to.increase(obj, 'count').by(3) + ${namespace}.expect(() => { obj.count += 7 }).to.increase(obj, 'count').by(7) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "increase by amount works", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("`.decrease()` with object and property", () => { + test("should detect property decreases", () => { + return expect( + runTest(` + ${namespace}.test("decrease assertions work", () => { + const obj = { count: 10 } + ${namespace}.expect(() => { obj.count-- }).to.decrease(obj, 'count') + ${namespace}.expect(() => { obj.count -= 3 }).to.decrease(obj, 'count') + ${namespace}.expect(() => { obj.count++ }).to.not.decrease(obj, 'count') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "decrease assertions work", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should detect decreases by specific amount using `.by()`", () => { + return expect( + runTest(` + ${namespace}.test("decrease by amount works", () => { + const obj = { count: 10 } + ${namespace}.expect(() => { obj.count -= 2 }).to.decrease(obj, 'count').by(2) + ${namespace}.expect(() => { obj.count -= 4 }).to.decrease(obj, 'count').by(4) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "decrease by amount works", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + } +) + +describe.each(NAMESPACES)( + "%s.expect() - Side Effect Assertions (Getter Function Pattern)", + (namespace) => { + describe("`.change()` with getter function", () => { + test("should detect when getter value changes", () => { + return expect( + runTest(` + ${namespace}.test("change with getter function", () => { + let value = 0 + const changeFn = () => { value = 1 } + ${namespace}.expect(changeFn).to.change(() => value) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "change with getter function", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should pass with negation when value does not change", () => { + return expect( + runTest(` + ${namespace}.test("change negated when no change", () => { + let value = 0 + const noChangeFn = () => { value = 0 } + ${namespace}.expect(noChangeFn).to.not.change(() => value) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "change negated when no change", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.by()` chaining with getter", () => { + return expect( + runTest(` + ${namespace}.test("change by with getter function", () => { + let value = 5 + const addFive = () => { value += 5 } + ${namespace}.expect(addFive).to.change(() => value).by(5) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "change by with getter function", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("`.increase()` with getter function", () => { + test("should detect when getter value increases", () => { + return expect( + runTest(` + ${namespace}.test("increase with getter function", () => { + let counter = 0 + const incrementFn = () => { counter++ } + ${namespace}.expect(incrementFn).to.increase(() => counter) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "increase with getter function", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should pass with negation when value does not increase", () => { + return expect( + runTest(` + ${namespace}.test("increase negated when no increase", () => { + let counter = 5 + const noIncreaseFn = () => { counter-- } + ${namespace}.expect(noIncreaseFn).to.not.increase(() => counter) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "increase negated when no increase", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.by()` chaining with getter", () => { + return expect( + runTest(` + ${namespace}.test("increase by with getter function", () => { + let value = 5 + const addFive = () => { value += 5 } + ${namespace}.expect(addFive).to.increase(() => value).by(5) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "increase by with getter function", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("`.decrease()` with getter function", () => { + test("should detect when getter value decreases", () => { + return expect( + runTest(` + ${namespace}.test("decrease with getter function", () => { + let counter = 10 + const decrementFn = () => { counter-- } + ${namespace}.expect(decrementFn).to.decrease(() => counter) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "decrease with getter function", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should pass with negation when value does not decrease", () => { + return expect( + runTest(` + ${namespace}.test("decrease negated when no decrease", () => { + let counter = 5 + const noDecreaseFn = () => { counter++ } + ${namespace}.expect(noDecreaseFn).to.not.decrease(() => counter) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "decrease negated when no decrease", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.by()` chaining with getter", () => { + return expect( + runTest(` + ${namespace}.test("decrease by with getter function", () => { + let value = 10 + const subtractThree = () => { value -= 3 } + ${namespace}.expect(subtractThree).to.decrease(() => value).by(3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "decrease by with getter function", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + } +) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/test-context-preservation.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/test-context-preservation.spec.ts new file mode 100644 index 0000000..19166b2 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/test-context-preservation.spec.ts @@ -0,0 +1,298 @@ +/** + * Regression Test: Test Context Preservation + * + * This test ensures that ALL expectation methods properly preserve the test context + * and record assertions inside the correct test block, not at root level. + * + * Bug History: + * - toBeType() and expectNotToBeType() were incorrectly using createExpectation() directly + * instead of createExpect(), which meant they didn't receive getCurrentTestContext + * - This caused assertions to be recorded at root level instead of inside test blocks + * + * Related Issue: Test structure behavior change in JUnit reports + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +const NAMESPACES = ["pm", "hopp"] as const + +describe("Test Context Preservation - Regression Tests", () => { + describe.each(NAMESPACES)( + "%s namespace - toBeType() assertions", + (namespace) => { + test("toBeType() should record assertions INSIDE test block, not at root", () => { + return expect( + runTest(` + ${namespace}.test("Type checking test", () => { + ${namespace}.expect(42).toBeType('number') + ${namespace}.expect('hello').toBeType('string') + ${namespace}.expect(true).toBeType('boolean') + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + // Root should have NO expectResults + expectResults: [], + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Type checking test", + // All assertions should be INSIDE the test + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("negative toBeType() should record assertions INSIDE test block", () => { + return expect( + runTest(` + ${namespace}.test("Negative type checking", () => { + ${namespace}.expect(42).not.toBeType('string') + ${namespace}.expect('hello').not.toBeType('number') + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + expectResults: [], + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Negative type checking", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("mixed assertion types should all be in correct test context", () => { + return expect( + runTest(` + ${namespace}.test("Mixed assertions", () => { + ${namespace}.expect(1).toBe(1) + ${namespace}.expect(42).toBeType('number') + ${namespace}.expect('test').toBe('test') + ${namespace}.expect('hello').toBeType('string') + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + expectResults: [], + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Mixed assertions", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("multiple tests should each have their own assertions", () => { + return expect( + runTest(` + ${namespace}.test("First test", () => { + ${namespace}.expect(1).toBeType('number') + }) + + ${namespace}.test("Second test", () => { + ${namespace}.expect('test').toBeType('string') + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + expectResults: [], + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "First test", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + expect.objectContaining({ + descriptor: "Second test", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("async tests should preserve context for toBeType", () => { + return expect( + runTest(` + ${namespace}.test("Async type checking", async () => { + const value = await Promise.resolve(42) + ${namespace}.expect(value).toBeType('number') + + const str = await Promise.resolve('hello') + ${namespace}.expect(str).toBeType('string') + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + expectResults: [], + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Async type checking", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("all expectation methods should preserve context", () => { + return expect( + runTest(` + ${namespace}.test("All expectation methods", () => { + ${namespace}.expect(1).toBe(1) + ${namespace}.expect(200).toBeLevel2xx() + ${namespace}.expect(300).toBeLevel3xx() + ${namespace}.expect(400).toBeLevel4xx() + ${namespace}.expect(500).toBeLevel5xx() + ${namespace}.expect(42).toBeType('number') + ${namespace}.expect([1, 2, 3]).toHaveLength(3) + ${namespace}.expect('hello world').toInclude('hello') + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + expectResults: [], + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "All expectation methods", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("negated expectations should preserve context", () => { + return expect( + runTest(` + ${namespace}.test("Negated expectations", () => { + ${namespace}.expect(1).not.toBe(2) + ${namespace}.expect(400).not.toBeLevel2xx() + ${namespace}.expect(42).not.toBeType('string') + ${namespace}.expect([1, 2]).not.toHaveLength(5) + ${namespace}.expect('hello').not.toInclude('goodbye') + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + expectResults: [], + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Negated expectations", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + } + ) + + describe("Root level should never have expectResults", () => { + test("empty root expectResults for pm namespace", () => { + return expect( + runTest(` + pm.test("Test 1", () => { + pm.expect(1).toBe(1) + pm.expect(2).toBeType('number') + }) + + pm.test("Test 2", () => { + pm.expect('test').toBe('test') + pm.expect('str').toBeType('string') + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + // Root must have empty expectResults. + expectResults: [], + children: expect.any(Array), + }), + ]) + ) + }) + + test("empty root expectResults for hopp namespace", () => { + return expect( + runTest(` + hopp.test("Test 1", () => { + hopp.expect(1).toBe(1) + hopp.expect(2).toBeType('number') + }) + + hopp.test("Test 2", () => { + hopp.expect('test').toBe('test') + hopp.expect('str').toBeType('string') + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + expectResults: [], + children: expect.any(Array), + }), + ]) + ) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/test-runner.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/test-runner.spec.ts new file mode 100644 index 0000000..7f9b846 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/test-runner.spec.ts @@ -0,0 +1,277 @@ +import { describe, expect, test } from "vitest" +import { runTest, fakeResponse } from "~/utils/test-helpers" + +/** + * Test runner behavior across all namespaces (pw, hopp, pm) + * + * This test suite validates: + * 1. Syntax error handling - all namespaces throw on invalid syntax + * 2. Async/await support - test functions can be async + * 3. Postman compatibility - pm.test matches Postman behavior + * + * IMPORTANT: Test Result Structure + * Test results follow this hierarchy: + * { + * descriptor: "root", + * expectResults: [], // Empty at root level + * children: [{ // Actual test results in children + * descriptor: "test name", + * expectResults: [...], // Test expectations here + * }] + * } + * + * This structure change ensures proper test descriptor nesting and matches + * the TestDescriptor type: { descriptor, expectResults, children } + */ + +// Test data for namespace-specific syntax +const namespaces = [ + { name: "pw", envArgs: fakeResponse, equalSyntax: "toBe" }, + { name: "hopp", envArgs: fakeResponse, equalSyntax: "toBe" }, + { + name: "pm", + envArgs: { global: [], selected: [] }, + equalSyntax: "to.equal", + }, +] as const + +describe("Test Runner - All Namespaces", () => { + describe.each(namespaces)("$name.test", ({ name, envArgs, equalSyntax }) => { + test("returns a resolved promise for a valid test script with all green", () => { + const script = ` + ${name}.test("Arithmetic operations", () => { + const size = 500 + 500; + ${name}.expect(size).${equalSyntax}(1000); + ${name}.expect(size - 500).${equalSyntax}(500); + ${name}.expect(size * 4).${equalSyntax}(4000); + ${name}.expect(size / 4).${equalSyntax}(250); + }); + ` + + return expect( + runTest(script, envArgs, fakeResponse)() + ).resolves.toBeRight() + }) + + test("resolves for tests with failed expectations", () => { + const script = ` + ${name}.test("Arithmetic operations", () => { + const size = 500 + 500; + ${name}.expect(size).${equalSyntax}(1000); + ${name}.expect(size - 500).not.${equalSyntax}(500); + ${name}.expect(size * 4).${equalSyntax}(4000); + ${name}.expect(size / 4).not.${equalSyntax}(250); + }); + ` + + return expect( + runTest(script, envArgs, fakeResponse)() + ).resolves.toBeRight() + }) + + test("rejects for invalid syntax on tests", () => { + const script = ` + ${name}.test("Arithmetic operations", () => { + const size = 500 + 500; + ${name}.expect(size). + ${name}.expect(size - 500).not.${equalSyntax}(500); + ${name}.expect(size * 4).${equalSyntax}(4000); + ${name}.expect(size / 4).not.${equalSyntax}(250); + }); + ` + + return expect( + runTest(script, envArgs, fakeResponse)() + ).resolves.toBeLeft() + }) + + test("supports async test functions", () => { + const script = ` + ${name}.test("Async test", async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + ${name}.expect(1 + 1).${equalSyntax}(2); + }); + ` + + return expect( + runTest(script, envArgs, fakeResponse)() + ).resolves.toBeRight() + }) + + test("rejects for syntax errors in async tests", () => { + const script = ` + ${name}.test("Async test with error", async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + ${name}.expect(1 + 1). + }); + ` + + return expect( + runTest(script, envArgs, fakeResponse)() + ).resolves.toBeLeft() + }) + + test("rejects for undefined variable in test", () => { + const script = ` + ${name}.test("Test with undefined variable", () => { + ${name}.expect(undefinedVariable).${equalSyntax}(1); + }); + ` + + return expect( + runTest(script, envArgs, fakeResponse)() + ).resolves.toBeLeft() + }) + }) + + /** + * Postman Compatibility Tests + * + * These tests validate that validation assertions like jsonSchema() + * and jsonPath() throw errors when validation fails, causing the script to fail. + * + * This matches the original behavior where validation failures are treated + * the same as other assertion failures. + */ + describe("pm.test - Validation assertions", () => { + test("jsonSchema failures should record failed assertion", () => { + // Postman behavior: jsonSchema validation failures are recorded as failed assertions + // but don't throw errors or fail the script + const response = { + status: 200, + statusText: "OK", + body: JSON.stringify({ name: "John" }), // Missing 'age' property + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Missing required property", function() { + const schema = { + type: "object", + required: ["name", "age"], + properties: { + name: { type: "string" }, + age: { type: "number" } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Missing required property", + expectResults: [ + { + status: "fail", + message: expect.stringContaining( + "Required property 'age' is missing" + ), + }, + ], + }), + ], + }), + ]) + }) + + test("jsonPath failures should record failed assertion", () => { + // Postman behavior: jsonPath validation failures are recorded as failed assertions + // but don't throw errors or fail the script (same as jsonSchema) + const response = { + status: 200, + statusText: "OK", + body: JSON.stringify({ name: "John" }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Path doesn't exist", function() { + pm.response.to.have.jsonPath("$.nonexistent") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Path doesn't exist", + expectResults: [ + { + status: "fail", + message: expect.stringContaining( + "Property 'nonexistent' not found" + ), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Cross-namespace consistency", () => { + test("all namespaces reject syntax errors consistently", () => { + return Promise.all( + namespaces.map(({ name, envArgs }) => { + const script = ` + ${name}.test("Syntax error test", () => { + const value = 42; + ${name}.expect(value). + }); + ` + + return expect( + runTest(script, envArgs, fakeResponse)() + ).resolves.toBeLeft() + }) + ) + }) + + test("all namespaces support async test functions", () => { + return Promise.all( + namespaces.map(({ name, envArgs, equalSyntax }) => { + const script = ` + ${name}.test("Async test", async () => { + await new Promise(resolve => setTimeout(resolve, 5)); + ${name}.expect(2 + 2).${equalSyntax}(4); + }); + ` + + return expect( + runTest(script, envArgs, fakeResponse)() + ).resolves.toBeRight() + }) + ) + }) + + test("all namespaces handle undefined variables consistently", () => { + return Promise.all( + namespaces.map(({ name, envArgs, equalSyntax }) => { + const script = ` + ${name}.test("Undefined variable", () => { + ${name}.expect(nonExistentVar).${equalSyntax}(1); + }); + ` + + return expect( + runTest(script, envArgs, fakeResponse)() + ).resolves.toBeLeft() + }) + ) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/core-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/core-assertions.spec.ts new file mode 100644 index 0000000..079c916 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/core-assertions.spec.ts @@ -0,0 +1,616 @@ +/** + * @see https://github.com/hoppscotch/hoppscotch/discussions/5221 + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("`hopp.expect` - Core Chai Assertions", () => { + describe("Language Chains", () => { + test("should support all language chain properties (`to`, `be`, `that`, `and`, `has`, etc.)", () => { + return expect( + runTest(` + hopp.test("language chains work", () => { + hopp.expect(2).to.equal(2) + hopp.expect(2).to.be.equal(2) + hopp.expect(2).to.be.a('number').that.equals(2) + hopp.expect([1,2,3]).to.be.an('array').that.has.lengthOf(3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "language chains work", + expectResults: [ + { status: "pass", message: "Expected 2 to equal 2" }, + { status: "pass", message: "Expected 2 to be equal 2" }, + { status: "pass", message: "Expected 2 to be a number" }, + { + status: "pass", + message: "Expected 2 to be a number that equals 2", + }, + { + status: "pass", + message: "Expected [1, 2, 3] to be an array", + }, + { + status: "pass", + message: + "Expected [1, 2, 3] to be an array that has lengthOf 3", + }, + ], + }), + ], + }), + ]) + }) + + test("should support multiple modifier combinations with language chains", () => { + return expect( + runTest(` + hopp.test("complex chains work", () => { + hopp.expect([1,2,3]).to.be.an('array') + hopp.expect([1,2,3]).to.have.lengthOf(3) + hopp.expect([1,2,3]).to.include(2) + hopp.expect({a: 1, b: 2}).to.be.an('object') + hopp.expect({a: 1, b: 2}).to.have.property('a') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "complex chains work", + expectResults: expect.arrayContaining([ + { status: "pass", message: expect.stringMatching(/array/) }, + { + status: "pass", + message: expect.stringMatching(/lengthOf 3/), + }, + { status: "pass", message: expect.stringMatching(/include 2/) }, + { status: "pass", message: expect.stringMatching(/object/) }, + { + status: "pass", + message: expect.stringMatching(/property 'a'/), + }, + ]), + }), + ], + }), + ]) + }) + }) + + describe("Type Assertions", () => { + test("should assert primitive types correctly (`.a()`, `.an()`)", () => { + return expect( + runTest(` + hopp.test("type assertions work", () => { + hopp.expect('foo').to.be.a('string') + hopp.expect({a: 1}).to.be.an('object') + hopp.expect([1, 2, 3]).to.be.an('array') + hopp.expect(null).to.be.a('null') + hopp.expect(undefined).to.be.an('undefined') + hopp.expect(42).to.be.a('number') + hopp.expect(true).to.be.a('boolean') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "type assertions work", + expectResults: [ + { status: "pass", message: "Expected 'foo' to be a string" }, + { status: "pass", message: "Expected {a: 1} to be an object" }, + { + status: "pass", + message: "Expected [1, 2, 3] to be an array", + }, + { status: "pass", message: "Expected null to be a null" }, + { + status: "pass", + message: "Expected undefined to be an undefined", + }, + { status: "pass", message: "Expected 42 to be a number" }, + { status: "pass", message: "Expected true to be a boolean" }, + ], + }), + ], + }), + ]) + }) + + test("should assert Symbol and BigInt types", () => { + return expect( + runTest(` + hopp.test("modern type assertions work", () => { + hopp.expect(Symbol('test')).to.be.a('symbol') + hopp.expect(Symbol.for('shared')).to.be.a('symbol') + hopp.expect(BigInt(123)).to.be.a('bigint') + hopp.expect(BigInt('999999999999999999')).to.be.a('bigint') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "modern type assertions work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected Symbol\(test\) to be a symbol/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected Symbol\(shared\) to be a symbol/ + ), + }, + { + status: "pass", + message: "Expected 123n to be a bigint", + }, + { + status: "pass", + message: "Expected 999999999999999999n to be a bigint", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Equality Assertions", () => { + test("should support `.equal()`, `.equals()`, `.eq()` for strict equality", () => { + return expect( + runTest(` + hopp.test("equality works", () => { + hopp.expect(42).to.equal(42) + hopp.expect('test').to.equals('test') + hopp.expect(true).to.eq(true) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "equality works", + expectResults: [ + { status: "pass", message: "Expected 42 to equal 42" }, + { status: "pass", message: "Expected 'test' to equals 'test'" }, + { status: "pass", message: "Expected true to eq true" }, + ], + }), + ], + }), + ]) + }) + + test("should support `.eql()` for deep equality", () => { + return expect( + runTest(` + hopp.test("deep equality works", () => { + hopp.expect({a: 1}).to.eql({a: 1}) + hopp.expect([1, 2, 3]).to.eql([1, 2, 3]) + hopp.expect({a: {b: 2}}).to.eql({a: {b: 2}}) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "deep equality works", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1} to eql {a: 1}", + }, + { + status: "pass", + message: "Expected [1, 2, 3] to eql [1, 2, 3]", + }, + { + status: "pass", + message: "Expected {a: {b: 2}} to eql {a: {b: 2}}", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Truthiness Assertions", () => { + test("should support `.true`, `.false`, `.ok` assertions", () => { + return expect( + runTest(` + hopp.test("truthiness assertions work", () => { + hopp.expect(true).to.be.true + hopp.expect(false).to.be.false + hopp.expect(1).to.be.ok + hopp.expect('hello').to.be.ok + hopp.expect({}).to.be.ok + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "truthiness assertions work", + expectResults: [ + { status: "pass", message: "Expected true to be true" }, + { status: "pass", message: "Expected false to be false" }, + { status: "pass", message: "Expected 1 to be ok" }, + { status: "pass", message: "Expected 'hello' to be ok" }, + { status: "pass", message: "Expected {} to be ok" }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Nullish Assertions", () => { + test("should support `.null`, `.undefined`, `.NaN` assertions", () => { + return expect( + runTest(` + hopp.test("nullish assertions work", () => { + hopp.expect(null).to.be.null + hopp.expect(undefined).to.be.undefined + hopp.expect(NaN).to.be.NaN + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "nullish assertions work", + expectResults: [ + { status: "pass", message: "Expected null to be null" }, + { + status: "pass", + message: "Expected undefined to be undefined", + }, + { status: "pass", message: "Expected NaN to be NaN" }, + ], + }), + ], + }), + ]) + }) + + test("should support `.exist` assertion", () => { + return expect( + runTest(` + hopp.test("exist assertion works", () => { + hopp.expect(0).to.exist + hopp.expect('').to.exist + hopp.expect(false).to.exist + hopp.expect({}).to.exist + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "exist assertion works", + expectResults: [ + { status: "pass", message: "Expected 0 to exist" }, + { status: "pass", message: "Expected '' to exist" }, + { status: "pass", message: "Expected false to exist" }, + { status: "pass", message: "Expected {} to exist" }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Numerical Comparisons", () => { + test("should support `.above()` and `.below()` comparisons", () => { + return expect( + runTest(` + hopp.test("numerical comparisons work", () => { + hopp.expect(10).to.be.above(5) + hopp.expect(5).to.be.below(10) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "numerical comparisons work", + expectResults: [ + { status: "pass", message: "Expected 10 to be above 5" }, + { status: "pass", message: "Expected 5 to be below 10" }, + ], + }), + ], + }), + ]) + }) + + test("should support comparison aliases (`.gt()`, `.gte()`, `.lt()`, `.lte()`)", () => { + return expect( + runTest(` + hopp.test("comparison aliases work", () => { + hopp.expect(10).to.be.gt(5) + hopp.expect(10).to.be.gte(10) + hopp.expect(5).to.be.lt(10) + hopp.expect(5).to.be.lte(5) + hopp.expect(10).to.be.greaterThan(5) + hopp.expect(10).to.be.greaterThanOrEqual(10) + hopp.expect(5).to.be.lessThan(10) + hopp.expect(5).to.be.lessThanOrEqual(5) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "comparison aliases work", + expectResults: expect.arrayContaining([ + { status: "pass", message: expect.stringMatching(/above|gt/) }, + { + status: "pass", + message: expect.stringMatching(/above|gte|at least/), + }, + { status: "pass", message: expect.stringMatching(/below|lt/) }, + { + status: "pass", + message: expect.stringMatching(/below|lte|at most/), + }, + ]), + }), + ], + }), + ]) + }) + + test("should support `.within()` for range comparisons", () => { + return expect( + runTest(` + hopp.test("within range works", () => { + hopp.expect(5).to.be.within(1, 10) + hopp.expect(7).to.be.within(7, 7) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "within range works", + expectResults: [ + { status: "pass", message: "Expected 5 to be within 1, 10" }, + { status: "pass", message: "Expected 7 to be within 7, 7" }, + ], + }), + ], + }), + ]) + }) + + test("should support `.closeTo()` and `.approximately()` for floating point comparisons", () => { + return expect( + runTest(` + hopp.test("close to works", () => { + hopp.expect(1.5).to.be.closeTo(1.0, 0.6) + hopp.expect(1.5).to.be.approximately(1.0, 0.6) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "close to works", + expectResults: [ + { + status: "pass", + message: "Expected 1.5 to be closeTo 1, 0.6", + }, + { + status: "pass", + message: "Expected 1.5 to be approximately 1, 0.6", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Special Value Assertions", () => { + test("should support `.empty` assertion for various types", () => { + return expect( + runTest(` + hopp.test("empty assertion works", () => { + hopp.expect('').to.be.empty + hopp.expect([]).to.be.empty + hopp.expect({}).to.be.empty + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "empty assertion works", + expectResults: [ + { status: "pass", message: "Expected '' to be empty" }, + { status: "pass", message: "Expected [] to be empty" }, + { status: "pass", message: "Expected {} to be empty" }, + ], + }), + ], + }), + ]) + }) + + test("should support `.finite` assertion for numbers", () => { + return expect( + runTest(` + hopp.test("finite assertion works", () => { + hopp.expect(42).to.be.finite + hopp.expect(0).to.be.finite + hopp.expect(-100.5).to.be.finite + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "finite assertion works", + expectResults: [ + { status: "pass", message: "Expected 42 to be finite" }, + { status: "pass", message: "Expected 0 to be finite" }, + { status: "pass", message: "Expected -100.5 to be finite" }, + ], + }), + ], + }), + ]) + }) + + test("should detect Infinity and reject `.finite`", () => { + return expect( + runTest(` + hopp.test("infinity is not finite", () => { + hopp.expect(Infinity).to.not.be.finite + hopp.expect(-Infinity).to.not.be.finite + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "infinity is not finite", + expectResults: [ + { + status: "pass", + message: "Expected Infinity to not be finite", + }, + { + status: "pass", + message: "Expected -Infinity to not be finite", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Negation with `.not`", () => { + test("should support negation for all assertion types", () => { + return expect( + runTest(` + hopp.test("negation works", () => { + hopp.expect(1).to.not.equal(2) + hopp.expect('foo').to.not.be.a('number') + hopp.expect(false).to.not.be.true + hopp.expect('foo').to.not.be.empty + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "negation works", + expectResults: [ + { status: "pass", message: "Expected 1 to not equal 2" }, + { + status: "pass", + message: "Expected 'foo' to not be a number", + }, + { status: "pass", message: "Expected false to not be true" }, + { status: "pass", message: "Expected 'foo' to not be empty" }, + ], + children: [], + }, + ], + }, + ]) + }) + }) + + describe("Boundary Value Testing", () => { + test("should handle boundary values correctly in comparisons", () => { + return expect( + runTest(` + hopp.test("boundary values work", () => { + hopp.expect(Number.MAX_SAFE_INTEGER).to.be.a('number') + hopp.expect(Number.MIN_SAFE_INTEGER).to.be.a('number') + hopp.expect(Number.EPSILON).to.be.above(0) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "boundary values work", + expectResults: expect.arrayContaining([ + { status: "pass", message: expect.stringMatching(/number/) }, + ]), + }), + ], + }), + ]) + }) + }) + + describe("Failure Cases", () => { + test("should produce meaningful error messages on assertion failures", () => { + return expect( + runTest(` + hopp.test("failures have good messages", () => { + hopp.expect(1).to.equal(2) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "failures have good messages", + expectResults: [ + { + status: "fail", + message: expect.stringContaining("Expected 1 to equal 2"), + }, + ], + }), + ], + }), + ]) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/exotic-objects.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/exotic-objects.spec.ts new file mode 100644 index 0000000..f19fe37 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/exotic-objects.spec.ts @@ -0,0 +1,1040 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +// Behavior validation for exotic objects (Proxy, etc.) within sandbox serialization constraints +describe("hopp.expect - Exotic Objects & Error Edge Cases", () => { + describe("Proxy Objects", () => { + test("should handle basic Proxy objects", () => { + return expect( + runTest(` + hopp.test("proxy object assertions work", () => { + const target = { value: 42 } + const proxy = new Proxy(target, {}) + + hopp.expect(proxy).to.be.an("object") + hopp.expect(proxy).to.have.property("value", 42) + hopp.expect(proxy.value).to.equal(42) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "proxy object assertions work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be an object/), + }, + { + status: "pass", + message: expect.stringMatching(/to have property 'value'/), + }, + { + status: "pass", + message: "Expected 42 to equal 42", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle Proxy with custom traps", () => { + return expect( + runTest(` + hopp.test("proxy with custom traps work", () => { + const target = { value: 10 } + const proxy = new Proxy(target, { + get(target, prop) { + if (prop === 'value') return target[prop] * 2 + return target[prop] + } + }) + + hopp.expect(proxy.value).to.equal(20) + hopp.expect(target.value).to.equal(10) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "proxy with custom traps work", + expectResults: [ + { + status: "pass", + message: "Expected 20 to equal 20", + }, + { + status: "pass", + message: "Expected 10 to equal 10", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle revocable Proxy", () => { + return expect( + runTest(` + hopp.test("revocable proxy assertions work", () => { + const target = { value: 42 } + const { proxy, revoke } = Proxy.revocable(target, {}) + + hopp.expect(proxy.value).to.equal(42) + + revoke() + + hopp.expect(() => proxy.value).to.throw(TypeError) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "revocable proxy assertions work", + expectResults: [ + { + status: "pass", + message: "Expected 42 to equal 42", + }, + { + status: "pass", + message: expect.stringMatching(/to throw TypeError/), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("WeakMap & WeakSet", () => { + test("should handle WeakMap behavior", () => { + return expect( + runTest(` + hopp.test("weakmap behavior tests work", () => { + const wm = new WeakMap() + const key1 = {} + const key2 = { id: 1 } + + wm.set(key1, 'value1') + wm.set(key2, 42) + + hopp.expect(wm.has(key1)).to.be.true + hopp.expect(wm.get(key1)).to.equal('value1') + hopp.expect(wm.get(key2)).to.equal(42) + hopp.expect(wm.has({})).to.be.false + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "weakmap behavior tests work", + expectResults: [ + { + status: "pass", + message: "Expected true to be true", + }, + { + status: "pass", + message: "Expected 'value1' to equal 'value1'", + }, + { + status: "pass", + message: "Expected 42 to equal 42", + }, + { + status: "pass", + message: "Expected false to be false", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle WeakSet behavior", () => { + return expect( + runTest(` + hopp.test("weakset behavior tests work", () => { + const ws = new WeakSet() + const obj1 = { id: 1 } + const obj2 = { id: 2 } + + ws.add(obj1) + ws.add(obj2) + + hopp.expect(ws.has(obj1)).to.be.true + hopp.expect(ws.has(obj2)).to.be.true + hopp.expect(ws.has({})).to.be.false + + // WeakSet doesn't have reference equality for new objects + const newObj = { id: 1 } + hopp.expect(ws.has(newObj)).to.be.false + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "weakset behavior tests work", + expectResults: [ + { + status: "pass", + message: "Expected true to be true", + }, + { + status: "pass", + message: "Expected true to be true", + }, + { + status: "pass", + message: "Expected false to be false", + }, + { + status: "pass", + message: "Expected false to be false", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("TypedArrays", () => { + test("should handle Uint8Array", () => { + return expect( + runTest(` + hopp.test("uint8array tests work", () => { + const arr = new Uint8Array([1, 2, 3, 4, 5]) + + hopp.expect(arr).to.be.an('object') + hopp.expect(arr.length).to.equal(5) + hopp.expect(arr[0]).to.equal(1) + hopp.expect(arr[4]).to.equal(5) + hopp.expect(arr[10]).to.equal(undefined) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "uint8array tests work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be an object/), + }, + { + status: "pass", + message: "Expected 5 to equal 5", + }, + { + status: "pass", + message: "Expected 1 to equal 1", + }, + { + status: "pass", + message: "Expected 5 to equal 5", + }, + { + status: "pass", + message: "Expected undefined to equal undefined", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle Int16Array", () => { + return expect( + runTest(` + hopp.test("int16array tests work", () => { + const arr = new Int16Array([-1000, 0, 1000]) + + hopp.expect(arr[0]).to.equal(-1000) + hopp.expect(arr[2]).to.equal(1000) + hopp.expect(arr.byteLength).to.equal(6) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "int16array tests work", + expectResults: [ + { + status: "pass", + message: "Expected -1000 to equal -1000", + }, + { + status: "pass", + message: "Expected 1000 to equal 1000", + }, + { + status: "pass", + message: "Expected 6 to equal 6", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle Float32Array", () => { + return expect( + runTest(` + hopp.test("float32array tests work", () => { + const arr = new Float32Array([3.14, 2.718, 1.414]) + + hopp.expect(arr[0]).to.be.closeTo(3.14, 0.01) + hopp.expect(arr[1]).to.be.closeTo(2.718, 0.001) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "float32array tests work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be closeTo 3\.14/), + }, + { + status: "pass", + message: expect.stringMatching(/to be closeTo 2\.718/), + }, + ], + }), + ], + }), + ]) + }) + + test("should handle multiple TypedArray types", () => { + return expect( + runTest(` + hopp.test("various typedarray types work", () => { + const u32 = new Uint32Array([1, 2, 3]) + const i8 = new Int8Array([-1, 0, 1]) + const f64 = new Float64Array([1.1, 2.2]) + + hopp.expect(u32.byteLength).to.equal(12) + hopp.expect(i8.length).to.equal(3) + hopp.expect(f64.byteLength).to.equal(16) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "various typedarray types work", + expectResults: [ + { + status: "pass", + message: "Expected 12 to equal 12", + }, + { + status: "pass", + message: "Expected 3 to equal 3", + }, + { + status: "pass", + message: "Expected 16 to equal 16", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("ArrayBuffer & DataView", () => { + test("should handle ArrayBuffer", () => { + return expect( + runTest(` + hopp.test("arraybuffer tests work", () => { + const buffer = new ArrayBuffer(16) + const view = new Uint8Array(buffer) + + hopp.expect(buffer.byteLength).to.equal(16) + hopp.expect(view.length).to.equal(16) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "arraybuffer tests work", + expectResults: [ + { + status: "pass", + message: "Expected 16 to equal 16", + }, + { + status: "pass", + message: "Expected 16 to equal 16", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle DataView", () => { + return expect( + runTest(` + hopp.test("dataview tests work", () => { + const buffer = new ArrayBuffer(8) + const view = new DataView(buffer) + + view.setInt32(0, 42) + view.setFloat32(4, 3.14) + + hopp.expect(view.byteLength).to.equal(8) + hopp.expect(view.getInt32(0)).to.equal(42) + hopp.expect(view.getFloat32(4)).to.be.closeTo(3.14, 0.01) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "dataview tests work", + expectResults: [ + { + status: "pass", + message: "Expected 8 to equal 8", + }, + { + status: "pass", + message: "Expected 42 to equal 42", + }, + { + status: "pass", + message: expect.stringMatching(/to be closeTo 3\.14/), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Error Objects & Custom Errors", () => { + test("should handle standard Error types", () => { + return expect( + runTest(` + hopp.test("standard error types work", () => { + const err = new Error('Test error') + const typeErr = new TypeError('Type error') + const rangeErr = new RangeError('Range error') + const refErr = new ReferenceError('Reference error') + + hopp.expect(err).to.be.an.instanceof(Error) + hopp.expect(typeErr).to.be.an.instanceof(TypeError) + hopp.expect(rangeErr).to.be.an.instanceof(RangeError) + hopp.expect(refErr).to.be.an.instanceof(ReferenceError) + hopp.expect(err.message).to.equal('Test error') + hopp.expect(typeErr.message).to.equal('Type error') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "standard error types work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Error/), + }, + { + status: "pass", + message: expect.stringMatching( + /to be an instanceof TypeError/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /to be an instanceof RangeError/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /to be an instanceof ReferenceError/ + ), + }, + { + status: "pass", + message: "Expected 'Test error' to equal 'Test error'", + }, + { + status: "pass", + message: "Expected 'Type error' to equal 'Type error'", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle custom Error classes", () => { + return expect( + runTest(` + hopp.test("custom error classes work", () => { + class CustomError extends Error { + constructor(message, code) { + super(message) + this.name = 'CustomError' + this.code = code + } + } + + const err = new CustomError('Custom error', 500) + + hopp.expect(err).to.be.an.instanceof(Error) + hopp.expect(err.name).to.equal('CustomError') + hopp.expect(err.code).to.equal(500) + hopp.expect(err.message).to.equal('Custom error') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "custom error classes work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Error/), + }, + { + status: "pass", + message: expect.stringMatching( + /to equal 'CustomError'|to equal CustomError/ + ), + }, + { + status: "pass", + message: "Expected 500 to equal 500", + }, + { + status: "pass", + message: "Expected 'Custom error' to equal 'Custom error'", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle Error with cause (ES2022)", () => { + return expect( + runTest(` + hopp.test("error with cause work", () => { + const original = new Error('Original error') + const wrapped = new Error('Wrapped error', { cause: original }) + + hopp.expect(wrapped).to.be.an.instanceof(Error) + hopp.expect(wrapped.message).to.equal('Wrapped error') + hopp.expect(wrapped.cause).to.exist + hopp.expect(wrapped.cause.message).to.equal('Original error') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "error with cause work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Error/), + }, + { + status: "pass", + message: "Expected 'Wrapped error' to equal 'Wrapped error'", + }, + { + status: "pass", + message: expect.stringMatching(/to exist/), + }, + { + status: "pass", + message: + "Expected 'Original error' to equal 'Original error'", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle throw with Error objects", () => { + return expect( + runTest(` + hopp.test("throwing error objects work", () => { + const thrower = () => { throw new TypeError('Invalid type') } + + hopp.expect(thrower).to.throw(TypeError) + hopp.expect(thrower).to.throw(TypeError, 'Invalid type') + hopp.expect(thrower).to.throw('Invalid type') + hopp.expect(thrower).to.throw(/Invalid/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "throwing error objects work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to throw TypeError/), + }, + { + status: "pass", + message: expect.stringMatching(/to throw TypeError/), + }, + { + status: "pass", + message: expect.stringMatching(/to throw 'Invalid type'/), + }, + { + status: "pass", + message: expect.stringMatching(/to throw \/Invalid\//), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Promise Objects", () => { + test("should handle Promise state checking", () => { + return expect( + runTest(` + hopp.test("promise tests work", async () => { + const resolved = Promise.resolve(42) + const rejected = Promise.reject(new Error('Failed')) + + hopp.expect(await resolved).to.equal(42) + + try { + await rejected + } catch (e) { + hopp.expect(e).to.be.an.instanceof(Error) + hopp.expect(e.message).to.equal('Failed') + } + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "promise tests work", + expectResults: [ + { + status: "pass", + message: "Expected 42 to equal 42", + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Error/), + }, + { + status: "pass", + message: "Expected 'Failed' to equal 'Failed'", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle Promise.all", () => { + return expect( + runTest(` + hopp.test("promise.all tests work", async () => { + const p1 = Promise.resolve(1) + const p2 = Promise.resolve(2) + const p3 = Promise.resolve(3) + + const results = await Promise.all([p1, p2, p3]) + + hopp.expect(results).to.have.lengthOf(3) + hopp.expect(results[0]).to.equal(1) + hopp.expect(results[2]).to.equal(3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "promise.all tests work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to have lengthOf 3/), + }, + { + status: "pass", + message: "Expected 1 to equal 1", + }, + { + status: "pass", + message: "Expected 3 to equal 3", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Symbol.iterator & Iterables", () => { + test("should handle custom iterables", () => { + return expect( + runTest(` + hopp.test("custom iterable tests work", () => { + const iterable = { + *[Symbol.iterator]() { + yield 1 + yield 2 + yield 3 + } + } + + hopp.expect(iterable).to.be.an('object') + hopp.expect(typeof iterable[Symbol.iterator]).to.equal('function') + + const values = [...iterable] + hopp.expect(values[0]).to.equal(1) + hopp.expect(values[1]).to.equal(2) + hopp.expect(values[2]).to.equal(3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + expectResults: [], + children: [ + expect.objectContaining({ + descriptor: "custom iterable tests work", + expectResults: [ + { + status: "pass", + message: "Expected {} to be an object", + }, + { + status: "pass", + message: "Expected function to equal function", + }, + { + status: "pass", + message: "Expected 1 to equal 1", + }, + { + status: "pass", + message: "Expected 2 to equal 2", + }, + { + status: "pass", + message: "Expected 3 to equal 3", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle generator functions", () => { + return expect( + runTest(` + hopp.test("generator function tests work", () => { + function* gen() { + yield 'a' + yield 'b' + yield 'c' + } + + hopp.expect(typeof gen).to.equal('function') + + const iterator = gen() + hopp.expect(iterator).to.be.an('object') + hopp.expect(typeof iterator.next).to.equal('function') + + const first = iterator.next() + hopp.expect(first.value).to.equal('a') + hopp.expect(first.done).to.be.false + + iterator.next() + iterator.next() + const last = iterator.next() + hopp.expect(last.done).to.be.true + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + expectResults: [], + children: [ + expect.objectContaining({ + descriptor: "generator function tests work", + expectResults: [ + { + status: "pass", + message: "Expected function to equal function", + }, + { + status: "pass", + message: "Expected {} to be an object", + }, + { + status: "pass", + message: "Expected function to equal function", + }, + { + status: "pass", + message: "Expected 'a' to equal 'a'", + }, + { + status: "pass", + message: "Expected false to be false", + }, + { + status: "pass", + message: "Expected true to be true", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Complex Compositions", () => { + test("should handle nested exotic objects", () => { + return expect( + runTest(` + hopp.test("nested exotic objects work", () => { + const buffer = new ArrayBuffer(3) + const view = new Uint8Array(buffer) + view[0] = 1 + view[1] = 2 + view[2] = 3 + + const mySet = new Set([1, 2, 3]) + const myMap = new Map([['key', 'value']]) + + const composed = { + buffer, + view, + set: mySet, + map: myMap + } + + hopp.expect(composed.buffer.byteLength).to.equal(3) + hopp.expect(composed.view[1]).to.equal(2) + hopp.expect(composed.set).to.be.an.instanceof(Set) + hopp.expect(composed.map).to.be.an.instanceof(Map) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "nested exotic objects work", + expectResults: [ + { + status: "pass", + message: "Expected 3 to equal 3", + }, + { + status: "pass", + message: "Expected 2 to equal 2", + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Set/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Map/), + }, + ], + }), + ], + }), + ]) + }) + + test("should handle Proxy wrapping exotic objects", () => { + return expect( + runTest(` + hopp.test("proxy wrapping exotic objects work", () => { + const arr = new Uint8Array([10, 20, 30]) + const proxy = new Proxy(arr, { + get(target, prop) { + if (typeof prop === 'string' && !isNaN(prop)) { + return target[prop] * 2 + } + return target[prop] + } + }) + + hopp.expect(proxy[0]).to.equal(20) + hopp.expect(proxy[1]).to.equal(40) + hopp.expect(arr[0]).to.equal(10) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "proxy wrapping exotic objects work", + expectResults: [ + { + status: "pass", + message: "Expected 20 to equal 20", + }, + { + status: "pass", + message: "Expected 40 to equal 40", + }, + { + status: "pass", + message: "Expected 10 to equal 10", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Edge Cases", () => { + test("should handle Error stack traces", () => { + return expect( + runTest(` + hopp.test("error stack traces work", () => { + const err = new Error('Test error') + + hopp.expect(err).to.have.property('stack') + hopp.expect(err.stack).to.be.a('string') + hopp.expect(err.stack.length).to.be.above(0) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "error stack traces work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to have property 'stack'/), + }, + { + status: "pass", + message: expect.stringMatching(/to be a string/), + }, + { + status: "pass", + message: expect.stringMatching(/to be above 0/), + }, + ], + }), + ], + }), + ]) + }) + + test("should handle TypedArray negative indexing patterns", () => { + return expect( + runTest(` + hopp.test("typedarray negative patterns work", () => { + const arr = new Int8Array([-128, -1, 0, 1, 127]) + + hopp.expect(arr[0]).to.equal(-128) + hopp.expect(arr[arr.length - 1]).to.equal(127) + hopp.expect(arr.slice(-2)[0]).to.equal(1) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "typedarray negative patterns work", + expectResults: [ + { + status: "pass", + message: "Expected -128 to equal -128", + }, + { + status: "pass", + message: "Expected 127 to equal 127", + }, + { + status: "pass", + message: "Expected 1 to equal 1", + }, + ], + }), + ], + }), + ]) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/functions-errors.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/functions-errors.spec.ts new file mode 100644 index 0000000..6fe82ea --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/functions-errors.spec.ts @@ -0,0 +1,1352 @@ +/** + * @see https://github.com/hoppscotch/hoppscotch/discussions/5221 + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("`hopp.expect` - Functions and Error Assertions", () => { + describe("Throw Assertions", () => { + test("should support `.throw()` without arguments for any error", () => { + return expect( + runTest(` + hopp.test("basic throw works", () => { + hopp.expect(() => { throw new Error('test') }).to.throw() + hopp.expect(() => {}).to.not.throw() + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "basic throw works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/Expected .* to throw$/), + }, + { + status: "pass", + message: expect.stringMatching(/Expected .* to not throw/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.throw()` with error type matching", () => { + return expect( + runTest(` + hopp.test("throw with type works", () => { + hopp.expect(() => { throw new TypeError('type error') }).to.throw(TypeError) + hopp.expect(() => { throw new RangeError('range error') }).to.throw(RangeError) + hopp.expect(() => { throw new ReferenceError('ref error') }).to.throw(ReferenceError) + hopp.expect(() => { throw new Error('generic') }).to.throw(Error) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "throw with type works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw TypeError/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw RangeError/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw ReferenceError/ + ), + }, + { + status: "pass", + message: expect.stringMatching(/Expected .* to throw Error/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.throw()` with string message matching", () => { + return expect( + runTest(` + hopp.test("throw with string message works", () => { + hopp.expect(() => { throw new Error('salmon') }).to.throw('salmon') + hopp.expect(() => { throw new Error('exact message') }).to.throw('exact message') + hopp.expect(() => { throw new Error('contains test word') }).to.throw('test') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "throw with string message works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw 'salmon'/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw 'exact message'/ + ), + }, + { + status: "pass", + message: expect.stringMatching(/Expected .* to throw 'test'/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.throw()` with regex message matching", () => { + return expect( + runTest(` + hopp.test("throw with regex works", () => { + hopp.expect(() => { throw new Error('illegal salmon!') }).to.throw(/salmon/) + hopp.expect(() => { throw new Error('TEST123') }).to.throw(/test\\d+/i) + hopp.expect(() => { throw new Error('error: something went wrong') }).to.throw(/^error:/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "throw with regex works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw \/salmon\// + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw \/test\\d\+\/i/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw \/\^error:\// + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.throw()` with both error type and message", () => { + return expect( + runTest(` + hopp.test("throw with type and message works", () => { + hopp.expect(() => { throw new RangeError('out of range') }).to.throw(RangeError, 'out of range') + hopp.expect(() => { throw new TypeError('wrong type') }).to.throw(TypeError, /wrong/) + hopp.expect(() => { throw new Error('specific error') }).to.throw(Error, 'specific error') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "throw with type and message works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw RangeError, 'out of range'/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw TypeError, \/wrong\// + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw Error, 'specific error'/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should support custom error classes with `.throw()`", () => { + return expect( + runTest(` + hopp.test("custom error classes work", () => { + class CustomError extends Error { + constructor(message) { + super(message) + this.name = 'CustomError' + } + } + + hopp.expect(() => { throw new CustomError('custom message') }).to.throw(CustomError) + hopp.expect(() => { throw new CustomError('specific text') }).to.throw(CustomError, /specific/) + hopp.expect(() => { throw new CustomError('exact') }).to.throw(CustomError, 'exact') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "custom error classes work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw CustomError/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw CustomError, \/specific\// + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw CustomError, 'exact'/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.throws()` and `.Throw()` aliases", () => { + return expect( + runTest(` + hopp.test("throw aliases work", () => { + hopp.expect(() => { throw new Error('test1') }).to.throws() + hopp.expect(() => { throw new Error('test2') }).to.Throw() + hopp.expect(() => { throw new TypeError() }).to.throws(TypeError) + hopp.expect(() => { throw new Error('message') }).to.Throw('message') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "throw aliases work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/Expected .* to throw/), + }, + { + status: "pass", + message: expect.stringMatching(/Expected .* to throw/), + }, + { + status: "pass", + message: expect.stringMatching(/Expected .* to throw/), + }, + { + status: "pass", + message: expect.stringMatching(/Expected .* to throw/), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("instanceof Assertions", () => { + test("should support `.instanceof()` with built-in types", () => { + return expect( + runTest(` + hopp.test("instanceof with built-in types works", () => { + hopp.expect([1, 2]).to.be.an.instanceof(Array) + hopp.expect(new Date()).to.be.an.instanceof(Date) + hopp.expect(new Error()).to.be.an.instanceof(Error) + hopp.expect(new RegExp('test')).to.be.an.instanceof(RegExp) + hopp.expect(new Map()).to.be.an.instanceof(Map) + hopp.expect(new Set()).to.be.an.instanceof(Set) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "instanceof with built-in types works", + expectResults: expect.arrayContaining([ + { + status: "pass", + message: "Expected [1, 2] to be an instanceof Array", + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Date/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Error/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof RegExp/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Map/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Set/), + }, + ]), + }), + ], + }), + ]) + }) + + test("should support `.instanceof()` with error types", () => { + return expect( + runTest(` + hopp.test("instanceof with error types works", () => { + hopp.expect(new Error()).to.be.an.instanceof(Error) + hopp.expect(new TypeError()).to.be.an.instanceof(TypeError) + hopp.expect(new RangeError()).to.be.an.instanceof(RangeError) + hopp.expect(new TypeError()).to.be.an.instanceof(Error) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "instanceof with error types works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be an instanceof/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.instanceOf()` alias", () => { + return expect( + runTest(` + hopp.test("instanceOf alias works", () => { + hopp.expect([]).to.be.an.instanceof(Array) + hopp.expect(new Date()).to.be.an.instanceof(Date) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "instanceOf alias works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be an instanceof/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support negation with `.not.instanceof()`", () => { + return expect( + runTest(` + hopp.test("not instanceof works", () => { + hopp.expect({}).to.not.be.an.instanceof(Array) + hopp.expect([]).to.not.be.an.instanceof(Date) + hopp.expect('string').to.not.be.an.instanceof(Number) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "not instanceof works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to not be an instanceof/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to not be an instanceof/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to not be an instanceof/ + ), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("respondTo Assertions", () => { + test("should support `.respondTo()` for prototype methods", () => { + return expect( + runTest(` + hopp.test("respondTo with prototype methods works", () => { + function Cat() {} + Cat.prototype.meow = function() { return 'meow' } + + const cat = new Cat() + hopp.expect(cat).to.respondTo('meow') + hopp.expect(Cat).to.respondTo('meow') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "respondTo with prototype methods works", + expectResults: [ + { + status: "pass", + message: "Expected {} to respondTo 'meow'", + }, + { + status: "pass", + message: "Expected function Cat() {} to respondTo 'meow'", + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.itself.respondTo()` for static methods", () => { + return expect( + runTest(` + hopp.test("itself respondTo for static methods works", () => { + function Dog() {} + Dog.prototype.bark = function() { return 'bark' } + Dog.woof = function() { return 'woof' } + + hopp.expect(Dog).itself.to.respondTo('woof') + hopp.expect(Dog).to.respondTo('bark') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "itself respondTo for static methods works", + expectResults: [ + { + status: "pass", + message: "Expected function Dog() {} to respondTo 'woof'", + }, + { + status: "pass", + message: "Expected function Dog() {} to respondTo 'bark'", + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.respondTo()` with constructor functions", () => { + return expect( + runTest(` + hopp.test("respondTo with constructor functions works", () => { + function Animal() {} + Animal.prototype.speak = function() { return 'sound' } + Animal.create = function() { return new Animal() } + + const animal = new Animal() + hopp.expect(animal).to.respondTo('speak') + hopp.expect(Animal).to.respondTo('speak') + hopp.expect(Animal).itself.to.respondTo('create') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "respondTo with constructor functions works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to respondTo 'speak'/), + }, + { + status: "pass", + message: expect.stringMatching(/to respondTo 'speak'/), + }, + { + status: "pass", + message: expect.stringMatching(/to respondTo 'create'/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.respondsTo()` alias", () => { + return expect( + runTest(` + hopp.test("respondsTo alias works", () => { + function TestObj() {} + TestObj.prototype.method = function() { return 'test' } + + const obj = new TestObj() + hopp.expect(obj).to.respondsTo('method') + hopp.expect(TestObj).respondsTo('method') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "respondsTo alias works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to respondTo 'method'/), + }, + { + status: "pass", + message: expect.stringMatching(/to respondTo 'method'/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support negation with `.not.respondTo()`", () => { + return expect( + runTest(` + hopp.test("not respondTo works", () => { + function Bird() {} + Bird.prototype.fly = function() { return 'flying' } + + const bird = new Bird() + hopp.expect(bird).to.not.respondTo('swim') + hopp.expect(Bird).to.not.respondTo('nonexistent') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "not respondTo works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to not respondTo 'swim'/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to not respondTo 'nonexistent'/ + ), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("satisfy Assertions", () => { + test("should support `.satisfy()` with simple predicates", () => { + return expect( + runTest(` + hopp.test("satisfy with simple predicates works", () => { + hopp.expect(1).to.satisfy(function(num) { return num > 0 }) + hopp.expect(10).to.satisfy(function(num) { return num % 2 === 0 }) + hopp.expect('hello').to.satisfy(function(str) { return str.length === 5 }) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "satisfy with simple predicates works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected 1 to satisfy function/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected 10 to satisfy function/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected 'hello' to satisfy function/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.satisfy()` with complex object validation", () => { + return expect( + runTest(` + hopp.test("satisfy with complex objects works", () => { + hopp.expect({name: 'Alice', age: 30, active: true}).to.satisfy(function(obj) { + return obj.name.length > 3 && obj.age >= 18 && obj.active === true + }) + + hopp.expect([1, 2, 3, 4, 5]).to.satisfy(function(arr) { + return arr.every(n => n > 0) && arr.length === 5 + }) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "satisfy with complex objects works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected \{name: 'Alice', age: 30, active: true\} to satisfy function/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected \[1, 2, 3, 4, 5\] to satisfy function/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.satisfy()` with regex validation", () => { + return expect( + runTest(` + hopp.test("satisfy with regex works", () => { + hopp.expect('user@example.com').to.satisfy(function(email) { + return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email) + }) + + hopp.expect('abc123').to.satisfy(function(str) { + return /^[a-z]+\\d+$/.test(str) + }) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "satisfy with regex works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected 'user@example\.com' to satisfy function/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected 'abc123' to satisfy function/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.satisfy()` with multi-condition predicates", () => { + return expect( + runTest(` + hopp.test("satisfy with multi-conditions works", () => { + hopp.expect(42).to.satisfy(function(n) { + return n % 2 === 0 && n > 40 && n < 50 + }) + + hopp.expect([1,2,3]).to.satisfy(function(arr) { + return arr.length === 3 && arr[0] === 1 && arr.reduce((a, b) => a + b) === 6 + }) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "satisfy with multi-conditions works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected 42 to satisfy function/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected \[1, 2, 3\] to satisfy function/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.satisfies()` alias", () => { + return expect( + runTest(` + hopp.test("satisfies alias works", () => { + hopp.expect(5).to.satisfies(function(n) { return n > 0 }) + hopp.expect('test').satisfies(function(s) { return s.length > 0 }) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "satisfies alias works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected 5 to satisfy function/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected 'test' to satisfy function/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should support negation with `.not.satisfy()`", () => { + return expect( + runTest(` + hopp.test("not satisfy works", () => { + hopp.expect(1).to.not.satisfy(function(num) { return num < 0 }) + hopp.expect('hello').to.not.satisfy(function(str) { return str.length > 10 }) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "not satisfy works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected 1 to not satisfy function/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected 'hello' to not satisfy function/ + ), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Arguments Object Detection", () => { + test("should support `.arguments` assertion for arguments objects", () => { + return expect( + runTest(` + hopp.test("arguments detection works", () => { + function testFunc() { + hopp.expect(arguments).to.be.arguments + } + testFunc(1, 2, 3) + + hopp.expect([1, 2, 3]).to.not.be.arguments + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "arguments detection works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/Expected .* to be arguments/), + }, + { + status: "pass", + message: "Expected [1, 2, 3] to not be arguments", + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.Arguments` alias", () => { + return expect( + runTest(` + hopp.test("Arguments alias works", () => { + function testFunc() { + hopp.expect(arguments).to.be.Arguments + } + testFunc(1, 2, 3) + + hopp.expect({0: 1, 1: 2, length: 2}).to.not.be.Arguments + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Arguments alias works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be Arguments/), + }, + { + status: "pass", + message: expect.stringMatching(/to not be Arguments/), + }, + ], + }), + ], + }), + ]) + }) + + test("should distinguish arguments from array-like objects", () => { + return expect( + runTest(` + hopp.test("arguments vs array-like objects works", () => { + function testFunc() { + hopp.expect(arguments).to.be.arguments + } + testFunc() + + // Array-like objects should not be considered arguments + hopp.expect([]).to.not.be.arguments + hopp.expect({length: 0}).to.not.be.arguments + hopp.expect({0: 'a', 1: 'b', length: 2}).to.not.be.arguments + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "arguments vs array-like objects works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/Expected .* to be arguments/), + }, + { + status: "pass", + message: "Expected [] to not be arguments", + }, + { + status: "pass", + message: "Expected {length: 0} to not be arguments", + }, + { + status: "pass", + message: + "Expected {0: 'a', 1: 'b', length: 2} to not be arguments", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Object State Assertions", () => { + test("should support `.extensible` assertion for extensible objects", () => { + return expect( + runTest(` + hopp.test("extensible assertion works", () => { + hopp.expect({a: 1}).to.be.extensible + hopp.expect([1, 2, 3]).to.be.extensible + + const obj = {} + hopp.expect(obj).to.be.extensible + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "extensible assertion works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be extensible/), + }, + { + status: "pass", + message: expect.stringMatching(/to be extensible/), + }, + { + status: "pass", + message: expect.stringMatching(/to be extensible/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.sealed` assertion for sealed objects", () => { + return expect( + runTest(` + hopp.test("sealed assertion works", () => { + hopp.expect(Object.seal({})).to.be.sealed + hopp.expect(Object.seal({a: 1})).to.be.sealed + + const obj = {} + hopp.expect(obj).to.not.be.sealed + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "sealed assertion works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be sealed/), + }, + { + status: "pass", + message: expect.stringMatching(/to be sealed/), + }, + { + status: "pass", + message: expect.stringMatching(/to not be sealed/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.frozen` assertion for frozen objects", () => { + return expect( + runTest(` + hopp.test("frozen assertion works", () => { + hopp.expect(Object.freeze({})).to.be.frozen + hopp.expect(Object.freeze({a: 1})).to.be.frozen + + const obj = {} + hopp.expect(obj).to.not.be.frozen + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "frozen assertion works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be frozen/), + }, + { + status: "pass", + message: expect.stringMatching(/to be frozen/), + }, + { + status: "pass", + message: expect.stringMatching(/to not be frozen/), + }, + ], + }), + ], + }), + ]) + }) + + test("should understand that frozen objects are also sealed", () => { + return expect( + runTest(` + hopp.test("frozen implies sealed works", () => { + const frozen = Object.freeze({a: 1}) + hopp.expect(frozen).to.be.frozen + hopp.expect(frozen).to.be.sealed + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "frozen implies sealed works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be frozen/), + }, + { + status: "pass", + message: expect.stringMatching(/to be sealed/), + }, + ], + }), + ], + }), + ]) + }) + + test("should handle primitives as sealed and frozen", () => { + return expect( + runTest(` + hopp.test("primitives are sealed and frozen", () => { + hopp.expect(1).to.be.sealed + hopp.expect(1).to.be.frozen + hopp.expect('string').to.be.sealed + hopp.expect('string').to.be.frozen + hopp.expect(true).to.be.sealed + hopp.expect(true).to.be.frozen + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "primitives are sealed and frozen", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be sealed/), + }, + { + status: "pass", + message: expect.stringMatching(/to be frozen/), + }, + { + status: "pass", + message: expect.stringMatching(/to be sealed/), + }, + { + status: "pass", + message: expect.stringMatching(/to be frozen/), + }, + { + status: "pass", + message: expect.stringMatching(/to be sealed/), + }, + { + status: "pass", + message: expect.stringMatching(/to be frozen/), + }, + ], + }), + ], + }), + ]) + }) + + test("should understand the relationship between extensible, sealed, and frozen", () => { + return expect( + runTest(` + hopp.test("object state relationships work", () => { + const extensible = {a: 1} + const sealed = Object.seal({b: 2}) + const frozen = Object.freeze({c: 3}) + + // Extensible is not sealed or frozen + hopp.expect(extensible).to.be.extensible + hopp.expect(extensible).to.not.be.sealed + hopp.expect(extensible).to.not.be.frozen + + // Sealed is not extensible, not frozen (but can be) + hopp.expect(sealed).to.not.be.extensible + hopp.expect(sealed).to.be.sealed + hopp.expect(sealed).to.not.be.frozen + + // Frozen is not extensible, but is sealed + hopp.expect(frozen).to.not.be.extensible + hopp.expect(frozen).to.be.sealed + hopp.expect(frozen).to.be.frozen + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "object state relationships work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be extensible/), + }, + { + status: "pass", + message: expect.stringMatching(/to not be sealed/), + }, + { + status: "pass", + message: expect.stringMatching(/to not be frozen/), + }, + { + status: "pass", + message: expect.stringMatching(/to not be extensible/), + }, + { + status: "pass", + message: expect.stringMatching(/to be sealed/), + }, + { + status: "pass", + message: expect.stringMatching(/to not be frozen/), + }, + { + status: "pass", + message: expect.stringMatching(/to not be extensible/), + }, + { + status: "pass", + message: expect.stringMatching(/to be sealed/), + }, + { + status: "pass", + message: expect.stringMatching(/to be frozen/), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Edge Cases and Error Scenarios", () => { + test("should handle throw assertion failures gracefully", () => { + return expect( + runTest(` + hopp.test("throw failures work", () => { + hopp.expect(() => {}).to.throw() + hopp.expect(() => { throw new Error() }).to.not.throw() + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "throw failures work", + expectResults: [ + { + status: "fail", + message: expect.stringMatching(/Expected .* to throw/), + }, + { + status: "fail", + message: expect.stringMatching(/Expected .* to not throw/), + }, + ], + }), + ], + }), + ]) + }) + + test("should handle instanceof failures gracefully", () => { + return expect( + runTest(` + hopp.test("instanceof failures work", () => { + hopp.expect([]).to.be.an.instanceof(Date) + hopp.expect(new Date()).to.not.be.an.instanceof(Date) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "instanceof failures work", + expectResults: [ + { + status: "fail", + message: expect.stringMatching( + /Expected .* to be an instanceof/ + ), + }, + { + status: "fail", + message: expect.stringMatching( + /Expected .* to not be an instanceof/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should handle satisfy failures with clear messages", () => { + return expect( + runTest(` + hopp.test("satisfy failures work", () => { + hopp.expect(5).to.satisfy(function(n) { return n < 0 }) + hopp.expect('hello').to.not.satisfy(function(s) { return s.length === 5 }) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "satisfy failures work", + expectResults: [ + { + status: "fail", + message: expect.stringMatching( + /Expected 5 to satisfy function/ + ), + }, + { + status: "fail", + message: expect.stringMatching( + /Expected 'hello' to not satisfy function/ + ), + }, + ], + }), + ], + }), + ]) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/properties-collections.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/properties-collections.spec.ts new file mode 100644 index 0000000..87ec921 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/properties-collections.spec.ts @@ -0,0 +1,1075 @@ +/** + * @see https://github.com/hoppscotch/hoppscotch/discussions/5221 + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("`hopp.expect` - Properties and Collections Assertions", () => { + describe("Property Assertions", () => { + test("should assert basic property existence and values using `.property()`", () => { + return expect( + runTest(` + hopp.test("property assertions work", () => { + hopp.expect({a: 1}).to.have.property('a') + hopp.expect({a: 1}).to.have.property('a', 1) + hopp.expect({x: {a: 1}}).to.have.deep.property('x', {a: 1}) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "property assertions work", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1} to have property 'a'", + }, + { + status: "pass", + message: "Expected {a: 1} to have property 'a', 1", + }, + { + status: "pass", + message: + "Expected {x: {a: 1}} to have deep property 'x', {a: 1}", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert own properties using `.own.property()`", () => { + return expect( + runTest(` + hopp.test("own property assertions work", () => { + hopp.expect({a: 1}).to.have.own.property('a') + hopp.expect({a: 1}).to.have.own.property('a', 1) + hopp.expect({x: {a: 1}}).to.have.deep.own.property('x', {a: 1}) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "own property assertions work", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1} to have own property 'a'", + }, + { + status: "pass", + message: "Expected {a: 1} to have own property 'a', 1", + }, + { + status: "pass", + message: + "Expected {x: {a: 1}} to have deep own property 'x', {a: 1}", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert property descriptors using `.ownPropertyDescriptor()`", () => { + return expect( + runTest(` + hopp.test("ownPropertyDescriptor assertions work", () => { + const obj = {} + Object.defineProperty(obj, 'test', { + value: 42, + writable: false, + enumerable: true, + configurable: false + }) + + hopp.expect(obj).to.have.ownPropertyDescriptor('test') + hopp.expect(obj).to.have.ownPropertyDescriptor('test', { + value: 42, + writable: false, + enumerable: true, + configurable: false + }) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "ownPropertyDescriptor assertions work", + expectResults: [ + { + status: "pass", + message: "Expected {} to have ownPropertyDescriptor 'test'", + }, + { + status: "pass", + message: "Expected {} to have ownPropertyDescriptor 'test'", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Nested Property Assertions", () => { + test("should assert nested properties using `.nested.property()`", () => { + return expect( + runTest(` + hopp.test("nested property assertions work", () => { + hopp.expect({a: {b: ['x', 'y']}}).to.have.nested.property('a.b[1]') + hopp.expect({a: {b: ['x', 'y']}}).to.have.nested.property('a.b[1]', 'y') + hopp.expect({a: {b: ['x', 'y']}}).to.have.nested.property('a.b[0]', 'x') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "nested property assertions work", + expectResults: [ + { + status: "pass", + message: + "Expected {a: {b: ['x', 'y']}} to have nested property 'a.b[1]'", + }, + { + status: "pass", + message: + "Expected {a: {b: ['x', 'y']}} to have nested property 'a.b[1]', 'y'", + }, + { + status: "pass", + message: + "Expected {a: {b: ['x', 'y']}} to have nested property 'a.b[0]', 'x'", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert complex nested properties using `.deep.nested.property()`", () => { + return expect( + runTest(` + hopp.test("deep nested property testing work", () => { + const complexObj = { + user: { + profile: { + settings: { + theme: 'dark', + notifications: { + email: true, + push: false + } + }, + metadata: ['tag1', 'tag2', 'tag3'] + } + }, + array: [ + {nested: {deep: 'value'}}, + {nested: {deeper: {deepest: 42}}} + ] + } + + hopp.expect(complexObj).to.have.deep.nested.property('user.profile.settings', { + theme: 'dark', + notifications: { + email: true, + push: false + } + }) + + hopp.expect(complexObj).to.have.nested.property('user.profile.metadata[1]', 'tag2') + hopp.expect(complexObj).to.have.nested.property('array[0].nested.deep', 'value') + hopp.expect(complexObj).to.have.nested.property('array[1].nested.deeper.deepest', 42) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "deep nested property testing work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to have deep nested property 'user\.profile\.settings'/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to have nested property 'user\.profile\.metadata\[1\]', 'tag2'/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to have nested property 'array\[0\]\.nested\.deep', 'value'/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to have nested property 'array\[1\]\.nested\.deeper\.deepest', 42/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should negate nested property assertions", () => { + return expect( + runTest(` + hopp.test("negation with nested properties work", () => { + hopp.expect({a: 1}).to.not.have.own.property('b') + hopp.expect({a: {b: 1}}).to.not.have.nested.property('a.c') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "negation with nested properties work", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1} to not have own property 'b'", + }, + { + status: "pass", + message: + "Expected {a: {b: 1}} to not have nested property 'a.c'", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Keys Assertions", () => { + test("should assert all keys using `.all.keys()`", () => { + return expect( + runTest(` + hopp.test("all keys assertions work", () => { + hopp.expect({a: 1, b: 2}).to.have.all.keys('a', 'b') + hopp.expect(['x', 'y']).to.have.all.keys(0, 1) + hopp.expect({a: 1, b: 2}).to.have.all.keys(['a', 'b']) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "all keys assertions work", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1, b: 2} to have all keys 'a', 'b'", + }, + { + status: "pass", + message: "Expected ['x', 'y'] to have all keys '0', '1'", + }, + { + status: "pass", + message: "Expected {a: 1, b: 2} to have all keys 'a', 'b'", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert any keys using `.any.keys()`", () => { + return expect( + runTest(` + hopp.test("any keys assertions work", () => { + hopp.expect({a: 1, b: 2}).to.have.any.keys('a', 'c') + hopp.expect({a: 1, b: 2}).to.have.any.keys(['a', 'z']) + hopp.expect({a: 1, b: 2, c: 3, d: 4}).to.have.any.keys('x', 'y', 'z', 'a') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "any keys assertions work", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1, b: 2} to have any keys 'a', 'c'", + }, + { + status: "pass", + message: "Expected {a: 1, b: 2} to have any keys 'a', 'z'", + }, + { + status: "pass", + message: + "Expected {a: 1, b: 2, c: 3, d: 4} to have any keys 'x', 'y', 'z', 'a'", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert included keys using `.include.keys()`", () => { + return expect( + runTest(` + hopp.test("include keys assertions work", () => { + hopp.expect({a: 1, b: 2, c: 3}).to.include.all.keys('a', 'b') + hopp.expect({a: 1, b: 2, c: 3, d: 4}).to.include.any.keys('a', 'z') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "include keys assertions work", + expectResults: [ + { + status: "pass", + message: + "Expected {a: 1, b: 2, c: 3} to include all keys 'a', 'b'", + }, + { + status: "pass", + message: + "Expected {a: 1, b: 2, c: 3, d: 4} to include any keys 'a', 'z'", + }, + ], + }), + ], + }), + ]) + }) + + test("should negate keys assertions", () => { + return expect( + runTest(` + hopp.test("negation with keys work", () => { + hopp.expect({a: 1, b: 2}).to.not.have.any.keys('x', 'y', 'z') + hopp.expect({a: 1, b: 2}).to.not.have.all.keys('a', 'b', 'c') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "negation with keys work", + expectResults: [ + { + status: "pass", + message: + "Expected {a: 1, b: 2} to not have any keys 'x', 'y', 'z'", + }, + { + status: "pass", + message: + "Expected {a: 1, b: 2} to not have all keys 'a', 'b', 'c'", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Members Assertions", () => { + test("should assert array members using `.members()`", () => { + return expect( + runTest(` + hopp.test("member assertions work", () => { + hopp.expect([1, 2, 3]).to.have.members([2, 1, 3]) + hopp.expect([1, 2, 2]).to.have.members([2, 1, 2]) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "member assertions work", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to have members [2, 1, 3]", + }, + { + status: "pass", + message: "Expected [1, 2, 2] to have members [2, 1, 2]", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert included members using `.include.members()`", () => { + return expect( + runTest(` + hopp.test("include members assertions work", () => { + hopp.expect([1, 2, 3]).to.include.members([1, 2]) + hopp.expect([1, 2, 3, 4]).to.include.members([2, 3]) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "include members assertions work", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to include members [1, 2]", + }, + { + status: "pass", + message: "Expected [1, 2, 3, 4] to include members [2, 3]", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert deep members using `.deep.members()`", () => { + return expect( + runTest(` + hopp.test("deep members assertions work", () => { + hopp.expect([{a: 1}]).to.have.deep.members([{a: 1}]) + hopp.expect([{a: 1}, {b: 2}]).to.have.deep.members([{b: 2}, {a: 1}]) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "deep members assertions work", + expectResults: [ + { + status: "pass", + message: "Expected [{a: 1}] to have deep members [{a: 1}]", + }, + { + status: "pass", + message: + "Expected [{a: 1}, {b: 2}] to have deep members [{b: 2}, {a: 1}]", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert ordered members using `.ordered.members()`", () => { + return expect( + runTest(` + hopp.test("ordered members assertions work", () => { + hopp.expect([1, 2, 3]).to.have.ordered.members([1, 2, 3]) + hopp.expect([1, 2]).to.not.have.ordered.members([2, 1]) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "ordered members assertions work", + expectResults: [ + { + status: "pass", + message: + "Expected [1, 2, 3] to have ordered members [1, 2, 3]", + }, + { + status: "pass", + message: "Expected [1, 2] to not have ordered members [2, 1]", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert deep ordered members using `.deep.ordered.members()`", () => { + return expect( + runTest(` + hopp.test("deep ordered members assertions work", () => { + hopp.expect([{a: 1}, {b: 2}]).to.have.deep.ordered.members([{a: 1}, {b: 2}]) + hopp.expect([{a: 1}, {b: 2}]).to.not.have.deep.ordered.members([{b: 2}, {a: 1}]) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "deep ordered members assertions work", + expectResults: [ + { + status: "pass", + message: + "Expected [{a: 1}, {b: 2}] to have deep ordered members [{a: 1}, {b: 2}]", + }, + { + status: "pass", + message: + "Expected [{a: 1}, {b: 2}] to not have deep ordered members [{b: 2}, {a: 1}]", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert include deep ordered members using `.include.deep.ordered.members()`", () => { + return expect( + runTest(` + hopp.test("include deep ordered members assertions work", () => { + hopp.expect([{a: 1}, {b: 2}, {c: 3}]).to.include.deep.ordered.members([{a: 1}, {b: 2}]) + hopp.expect([{a: 1}, {b: 2}, {c: 3}]).to.not.include.deep.ordered.members([{b: 2}, {a: 1}]) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "include deep ordered members assertions work", + expectResults: [ + { + status: "pass", + message: + "Expected [{a: 1}, {b: 2}, {c: 3}] to include deep ordered members [{a: 1}, {b: 2}]", + }, + { + status: "pass", + message: + "Expected [{a: 1}, {b: 2}, {c: 3}] to not include deep ordered members [{b: 2}, {a: 1}]", + }, + ], + }), + ], + }), + ]) + }) + + test("should negate members assertions", () => { + return expect( + runTest(` + hopp.test("negation with members work", () => { + hopp.expect([{a: 1}]).to.not.have.deep.members([{b: 2}]) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "negation with members work", + expectResults: [ + { + status: "pass", + message: + "Expected [{a: 1}] to not have deep members [{b: 2}]", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Length Assertions", () => { + test("should assert length for arrays using `.lengthOf()`", () => { + return expect( + runTest(` + hopp.test("array length assertions work", () => { + hopp.expect([1, 2, 3]).to.have.lengthOf(3) + hopp.expect([1, 2, 3]).to.have.length(3) + hopp.expect([]).to.have.lengthOf(0) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "array length assertions work", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to have lengthOf 3", + }, + { + status: "pass", + message: "Expected [1, 2, 3] to have length 3", + }, + { + status: "pass", + message: "Expected [] to have lengthOf 0", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert length for strings using `.lengthOf()`", () => { + return expect( + runTest(` + hopp.test("string length assertions work", () => { + hopp.expect('foo').to.have.lengthOf(3) + hopp.expect('hello world').to.have.lengthOf(11) + hopp.expect('').to.have.lengthOf(0) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "string length assertions work", + expectResults: [ + { + status: "pass", + message: "Expected 'foo' to have lengthOf 3", + }, + { + status: "pass", + message: "Expected 'hello world' to have lengthOf 11", + }, + { + status: "pass", + message: "Expected '' to have lengthOf 0", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert length for Set using `.lengthOf()`", () => { + return expect( + runTest(` + hopp.test("Set length assertions work", () => { + hopp.expect(new Set([1, 2, 3])).to.have.lengthOf(3) + hopp.expect(new Set()).to.have.lengthOf(0) + hopp.expect(new Set([1, 2, 3])).to.be.an.instanceof(Set) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Set length assertions work", + expectResults: [ + { + status: "pass", + message: "Expected new Set([1, 2, 3]) to have lengthOf 3", + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to have lengthOf 0/ + ), + }, + { + status: "pass", + message: + "Expected new Set([1, 2, 3]) to be an instanceof Set", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert length for Map using `.lengthOf()`", () => { + return expect( + runTest(` + hopp.test("Map length assertions work", () => { + hopp.expect(new Map([['a', 1], ['b', 2], ['c', 3]])).to.have.lengthOf(3) + hopp.expect(new Map()).to.have.lengthOf(0) + hopp.expect(new Map([['a', 1]])).to.be.an.instanceof(Map) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Map length assertions work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to have lengthOf 3/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to have lengthOf 0/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to be an instanceof Map/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should work with length in language chains", () => { + return expect( + runTest(` + hopp.test("language chains with length work", () => { + hopp.expect([1,2,3]).to.be.an('array').that.has.lengthOf(3) + hopp.expect([1,2,3]).to.be.an('array').and.have.lengthOf(3).and.include(2) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "language chains with length work", + expectResults: expect.arrayContaining([ + { status: "pass", message: expect.stringMatching(/array/) }, + { + status: "pass", + message: expect.stringMatching(/lengthOf 3/), + }, + { status: "pass", message: expect.stringMatching(/include 2/) }, + ]), + }), + ], + }), + ]) + }) + }) + + describe("Include/Contain Assertions", () => { + test("should assert inclusion in strings using `.include()`", () => { + return expect( + runTest(` + hopp.test("string inclusion assertions work", () => { + hopp.expect('foobar').to.include('foo') + hopp.expect('foobar').to.contain('bar') + hopp.expect('hello world').to.include('world') + hopp.expect('test123').to.contain('123') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "string inclusion assertions work", + expectResults: [ + { + status: "pass", + message: "Expected 'foobar' to include 'foo'", + }, + { + status: "pass", + message: "Expected 'foobar' to include 'bar'", + }, + { + status: "pass", + message: "Expected 'hello world' to include 'world'", + }, + { + status: "pass", + message: "Expected 'test123' to include '123'", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert inclusion in arrays using `.include()`", () => { + return expect( + runTest(` + hopp.test("array inclusion assertions work", () => { + hopp.expect([1, 2, 3]).to.include(2) + hopp.expect([1, 2, 3]).to.contain(3) + hopp.expect([1, 2, 3]).to.not.include(4) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "array inclusion assertions work", + expectResults: [ + { status: "pass", message: "Expected [1, 2, 3] to include 2" }, + { status: "pass", message: "Expected [1, 2, 3] to include 3" }, + { + status: "pass", + message: "Expected [1, 2, 3] to not include 4", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert inclusion in objects using `.include()`", () => { + return expect( + runTest(` + hopp.test("object inclusion assertions work", () => { + hopp.expect({a: 1, b: 2, c: 3}).to.include({a: 1, b: 2}) + hopp.expect({a: 1, b: 2}).to.not.have.property('c') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "object inclusion assertions work", + expectResults: [ + { + status: "pass", + message: + "Expected {a: 1, b: 2, c: 3} to include {a: 1, b: 2}", + }, + { + status: "pass", + message: "Expected {a: 1, b: 2} to not have property 'c'", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert deep inclusion using `.deep.include()`", () => { + return expect( + runTest(` + hopp.test("deep include assertions work", () => { + hopp.expect([{a: 1}]).to.deep.include({a: 1}) + hopp.expect({x: {a: 1}}).to.deep.include({x: {a: 1}}) + hopp.expect({a: {b: 2}, c: 3}).to.deep.include({a: {b: 2}}) + hopp.expect([{name: 'Alice'}, {name: 'Bob'}]).to.deep.include({name: 'Alice'}) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "deep include assertions work", + expectResults: [ + { + status: "pass", + message: "Expected [{a: 1}] to deep include {a: 1}", + }, + { + status: "pass", + message: "Expected {x: {a: 1}} to deep include {x: {a: 1}}", + }, + { + status: "pass", + message: + "Expected {a: {b: 2}, c: 3} to deep include {a: {b: 2}}", + }, + { + status: "pass", + message: + "Expected [{name: 'Alice'}, {name: 'Bob'}] to deep include {name: 'Alice'}", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert oneOf inclusion using `.contain.oneOf()`", () => { + return expect( + runTest(` + hopp.test("oneOf inclusion assertions work", () => { + hopp.expect(1).to.be.oneOf([1, 2, 3]) + hopp.expect('foo').to.be.oneOf(['foo', 'bar', 'baz']) + hopp.expect('Today is sunny').to.contain.oneOf(['sunny', 'cloudy']) + hopp.expect([1,2,3]).to.contain.oneOf([3,4,5]) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "oneOf inclusion assertions work", + expectResults: [ + { status: "pass", message: "Expected 1 to be oneOf [1, 2, 3]" }, + { + status: "pass", + message: "Expected 'foo' to be oneOf ['foo', 'bar', 'baz']", + }, + { + status: "pass", + message: + "Expected 'Today is sunny' to include oneOf ['sunny', 'cloudy']", + }, + { + status: "pass", + message: "Expected [1, 2, 3] to include oneOf [3, 4, 5]", + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.includes()` and `.contains()` aliases", () => { + return expect( + runTest(` + hopp.test("inclusion aliases work", () => { + hopp.expect([1, 2, 3]).to.includes(2) + hopp.expect('hello').to.contains('ell') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "inclusion aliases work", + expectResults: [ + { status: "pass", message: "Expected [1, 2, 3] to include 2" }, + { + status: "pass", + message: "Expected 'hello' to include 'ell'", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Combined Modifier Scenarios", () => { + test("should combine deep and own modifiers with properties", () => { + return expect( + runTest(` + hopp.test("deep + own combinations work", () => { + hopp.expect({x: {a: 1}}).to.have.deep.own.property('x', {a: 1}) + hopp.expect({x: {a: 1}}).to.not.have.own.property('y') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "deep + own combinations work", + expectResults: [ + { + status: "pass", + message: + "Expected {x: {a: 1}} to have deep own property 'x', {a: 1}", + }, + { + status: "pass", + message: "Expected {x: {a: 1}} to not have own property 'y'", + }, + ], + }), + ], + }), + ]) + }) + + test("should use properties in complex language chains", () => { + return expect( + runTest(` + hopp.test("complex chains with properties work", () => { + hopp.expect([1,2,3]).to.be.an('array').and.have.lengthOf(3).and.include(2) + hopp.expect({a: 1, b: 2}).to.be.an('object').that.has.property('a').which.equals(1) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "complex chains with properties work", + expectResults: expect.arrayContaining([ + { status: "pass", message: expect.stringMatching(/array/) }, + { + status: "pass", + message: expect.stringMatching(/lengthOf 3/), + }, + { status: "pass", message: expect.stringMatching(/include 2/) }, + { status: "pass", message: expect.stringMatching(/object/) }, + { + status: "pass", + message: expect.stringMatching(/property 'a'/), + }, + ]), + }), + ], + }), + ]) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/strings-regex.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/strings-regex.spec.ts new file mode 100644 index 0000000..7367559 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/strings-regex.spec.ts @@ -0,0 +1,333 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("hopp.expect - String and Regex Methods", () => { + describe("String Inclusion (.string())", () => { + test("should support .string() for substring inclusion", () => { + return expect( + runTest(` + hopp.test("string inclusion works", () => { + hopp.expect('hello world').to.have.string('world') + hopp.expect('foobar').to.have.string('foo') + hopp.expect('foobar').to.have.string('bar') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "string inclusion works", + expectResults: [ + { + status: "pass", + message: expect.stringContaining("to have string"), + }, + { + status: "pass", + message: expect.stringContaining("to have string"), + }, + { + status: "pass", + message: expect.stringContaining("to have string"), + }, + ], + }), + ], + }), + ]) + }) + + test("should support .string() negation", () => { + return expect( + runTest(` + hopp.test("string negation works", () => { + hopp.expect('hello').to.not.have.string('goodbye') + hopp.expect('foo').to.not.have.string('bar') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "string negation works", + expectResults: [ + { + status: "pass", + message: expect.stringContaining("to not have string"), + }, + { + status: "pass", + message: expect.stringContaining("to not have string"), + }, + ], + }), + ], + }), + ]) + }) + + test("should fail on missing substring", () => { + return expect( + runTest(` + hopp.test("string assertion fails correctly", () => { + hopp.expect('hello').to.have.string('goodbye') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "string assertion fails correctly", + expectResults: [ + { + status: "fail", + message: expect.stringContaining("to have string"), + }, + ], + }), + ], + }), + ]) + }) + + test("should work with empty strings", () => { + return expect( + runTest(` + hopp.test("empty string edge case", () => { + hopp.expect('hello').to.have.string('') + hopp.expect('').to.have.string('') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "empty string edge case", + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Regex Matching (.match())", () => { + test("should support .match() with regex patterns", () => { + return expect( + runTest(` + hopp.test("regex matching works", () => { + hopp.expect('hello123').to.match(/\\d+/) + hopp.expect('test@example.com').to.match(/^[^@]+@[^@]+\\.[^@]+$/) + hopp.expect('ABC').to.match(/[A-Z]+/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "regex matching works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to match/), + }, + { + status: "pass", + message: expect.stringMatching(/to match/), + }, + { + status: "pass", + message: expect.stringMatching(/to match/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support .match() negation", () => { + return expect( + runTest(` + hopp.test("regex negation works", () => { + hopp.expect('hello').to.not.match(/\\d+/) + hopp.expect('abc').to.not.match(/[A-Z]+/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "regex negation works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to not match/), + }, + { + status: "pass", + message: expect.stringMatching(/to not match/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support .matches() alias", () => { + return expect( + runTest(` + hopp.test("matches alias works", () => { + hopp.expect('abc123').to.matches(/[a-z]+\\d+/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "matches alias works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to match/), + }, + ], + }), + ], + }), + ]) + }) + + test("should fail on non-matching regex", () => { + return expect( + runTest(` + hopp.test("regex assertion fails correctly", () => { + hopp.expect('hello').to.match(/\\d+/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "regex assertion fails correctly", + expectResults: [ + { + status: "fail", + message: expect.stringMatching(/to match/), + }, + ], + }), + ], + }), + ]) + }) + + test("should handle regex with flags", () => { + return expect( + runTest(` + hopp.test("regex flags work", () => { + hopp.expect('HELLO').to.match(/hello/i) + hopp.expect('hello\\nworld').to.match(/hello.world/s) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "regex flags work", + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) + + test("should handle complex regex patterns", () => { + return expect( + runTest(` + hopp.test("complex regex patterns", () => { + hopp.expect('user@example.com').to.match(/^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$/i) + hopp.expect('192.168.1.1').to.match(/^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$/) + hopp.expect('+1-555-123-4567').to.match(/^\\+?\\d{1,3}-?\\d{3}-\\d{3}-\\d{4}$/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "complex regex patterns", + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Combined String and Regex Tests", () => { + test("should work with both string and regex in same test", () => { + return expect( + runTest(` + hopp.test("combined assertions", () => { + const email = 'test@example.com' + hopp.expect(email).to.have.string('@') + hopp.expect(email).to.match(/^[^@]+@[^@]+$/) + hopp.expect(email).to.have.string('example') + hopp.expect(email).to.match(/\\.com$/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "combined assertions", + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) + + test("should chain with other assertions", () => { + return expect( + runTest(` + hopp.test("chained assertions", () => { + hopp.expect('hello world').to.be.a('string').and.have.string('world') + hopp.expect('test123').to.be.a('string').and.match(/\\d+/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "chained assertions", + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/cookies.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/cookies.spec.ts new file mode 100644 index 0000000..51e97bd --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/cookies.spec.ts @@ -0,0 +1,417 @@ +import { getDefaultRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" + +import { Cookie, TestResponse } from "~/types" +import { runPreRequestScript, runTestScript } from "~/web" + +const baseCookies: Cookie[] = [ + { + name: "session_id", + value: "abc123", + domain: "example.com", + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + }, + { + name: "pref", + value: "dark", + domain: "example.com", + path: "/", + httpOnly: false, + secure: false, + sameSite: "Strict", + }, +] + +const defaultRequest = getDefaultRESTRequest() + +describe("hopp.cookies", () => { + test("hopp.cookies.get should return a specific cookie", async () => { + await expect( + runPreRequestScript( + `console.log(hopp.cookies.get("example.com", "session_id"))`, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + cookies: baseCookies, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: [ + { + name: "session_id", + value: "abc123", + domain: "example.com", + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + }, + ], + }), + ], + }) + ) + }) + + test("hopp.cookies.get should return null for missing cookie", async () => { + await expect( + runPreRequestScript( + `console.log(hopp.cookies.get("example.com", "unknown"))`, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + cookies: baseCookies, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [expect.objectContaining({ args: [null] })], + }) + ) + }) + + test("hopp.cookies.has should return true if cookie exists", async () => { + await expect( + runPreRequestScript( + `console.log(hopp.cookies.has("example.com", "session_id"))`, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + cookies: baseCookies, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: [true], + }), + ], + }) + ) + }) + + test("hopp.cookies.has should return false if cookie does not exist", async () => { + await expect( + runPreRequestScript( + `console.log(hopp.cookies.has("example.com", "missing"))`, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + cookies: baseCookies, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: [false], + }), + ], + }) + ) + }) + + test("hopp.cookies.getAll should return all cookies for a domain", () => { + return expect( + runPreRequestScript(`console.log(hopp.cookies.getAll("example.com"))`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + cookies: baseCookies, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: [ + [ + { + name: "session_id", + value: "abc123", + domain: "example.com", + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + }, + { + name: "pref", + value: "dark", + domain: "example.com", + path: "/", + httpOnly: false, + secure: false, + sameSite: "Strict", + }, + ], + ], + }), + ], + }) + ) + }) + + test("hopp.cookies.set should add a new cookie", () => { + return expect( + runPreRequestScript( + ` + const newCookie = { + name: "new_cookie", + value: "new_value", + domain: "example.com", + path: "/", + httpOnly: false, + secure: false, + sameSite: "None", + } + + hopp.cookies.set("example.com", newCookie) + `, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + cookies: baseCookies, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedCookies: expect.arrayContaining([ + expect.objectContaining({ + name: "new_cookie", + value: "new_value", + domain: "example.com", + path: "/", + httpOnly: false, + secure: false, + sameSite: "None", + }), + ]), + }) + ) + }) + + test("hopp.cookies.set should replace existing cookie with same domain+name", () => { + const updated = { ...baseCookies[0], value: "updated123" } + return expect( + runPreRequestScript( + `hopp.cookies.set("example.com", ${JSON.stringify(updated)})`, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + cookies: baseCookies, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedCookies: expect.arrayContaining([ + expect.objectContaining({ value: "updated123" }), + ]), + }) + ) + }) + + test("hopp.cookies.delete should remove a specific cookie", () => { + return expect( + runPreRequestScript(`hopp.cookies.delete("example.com", "pref")`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + cookies: baseCookies, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedCookies: expect.not.arrayContaining([ + expect.objectContaining({ name: "pref" }), + ]), + }) + ) + }) + + test("hopp.cookies.clear should remove all cookies for a domain", () => { + return expect( + runPreRequestScript(`hopp.cookies.clear("example.com")`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + cookies: baseCookies, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedCookies: expect.not.arrayContaining([ + expect.objectContaining({ domain: "example.com" }), + ]), + }) + ) + }) + + test("hopp.cookies methods throw for non-string domain and/or name args", async () => { + const envs = { global: [], selected: [] } + const response: TestResponse = { + status: 200, + body: "OK", + headers: [], + } + + await expect( + runPreRequestScript(`hopp.cookies.get(123, "test")`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + cookies: baseCookies, + }) + ).resolves.toBeLeft() + + await expect( + runPreRequestScript(`hopp.cookies.delete("example.com", 456)`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + cookies: baseCookies, + }) + ).resolves.toBeLeft() + + await expect( + runTestScript(`hopp.cookies.get(123, "test")`, { + envs, + request: defaultRequest, + cookies: undefined, + response, + }) + ).resolves.toBeLeft() + + await expect( + runTestScript(`hopp.cookies.delete("example.com", 456)`, { + envs, + request: defaultRequest, + cookies: undefined, + response, + }) + ).resolves.toBeLeft() + }) + + test("hopp.cookies.set throw if attempting to set cookie not conforming to the expected shape", async () => { + const envs = { global: [], selected: [] } + const response: TestResponse = { + status: 200, + body: "OK", + headers: [], + } + + const script = `hopp.cookies.set("example.com", "test")` + + await expect( + runPreRequestScript(script, { + envs: { global: [], selected: [] }, + request: defaultRequest, + cookies: baseCookies, + }) + ).resolves.toBeLeft() + + await expect( + runTestScript(script, { + envs, + request: defaultRequest, + cookies: undefined, + response, + }) + ).resolves.toBeLeft() + }) + + test("hopp.cookies throws an exception on unsupported platforms", async () => { + const envs = { global: [], selected: [] } + const response: TestResponse = { + status: 200, + body: "OK", + headers: [], + } + + await expect( + runPreRequestScript( + `console.log(hopp.cookies.get("example.com", "session_id"))`, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + // `cookies` specified as `undefined` indicates unsupported platform + cookies: undefined, + } + ) + ).resolves.toBeLeft() + + await expect( + runTestScript( + `console.log(hopp.cookies.get("example.com", "session_id"))`, + { + envs, + request: defaultRequest, + cookies: undefined, + response, + } + ) + ).resolves.toBeLeft() + }) + + test("hopp.cookies API should be available in post-request context", () => { + const envs = { global: [], selected: [] } + const response: TestResponse = { + status: 200, + body: "OK", + headers: [], + } + + return expect( + runTestScript( + ` + hopp.test("Cookies operations work correctly", () => { + hopp.expect(hopp.cookies.has("example.com", "session_id")).toBe(true) + hopp.expect(hopp.cookies.get("example.com", "pref").value).toBe("dark") + + hopp.cookies.set("example.com", { + name: "post_cookie", + value: "post_value", + domain: "example.com", + path: "/", + httpOnly: false, + secure: false, + sameSite: "None", + }) + + hopp.expect(hopp.cookies.has("example.com", "post_cookie")).toBe(true) + hopp.expect(hopp.cookies.getAll("example.com").length).toBe(3) + + hopp.cookies.delete("example.com", "session_id") + + hopp.expect(hopp.cookies.has("example.com", "session_id")).toBe(false) + }) + `, + { + envs, + request: defaultRequest, + cookies: baseCookies, + response, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "Cookies operations work correctly", + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected 'dark' to be 'dark'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected '3' to be '3'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + ], + }), + ], + }), + updatedCookies: expect.arrayContaining([ + expect.objectContaining({ name: "post_cookie", value: "post_value" }), + ]), + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/delete.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/delete.spec.ts new file mode 100644 index 0000000..ca0f4cc --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/delete.spec.ts @@ -0,0 +1,332 @@ +import { describe, expect, test } from "vitest" +import { runTestAndGetEnvs, runTest } from "~/utils/test-helpers" + +describe("hopp.env.delete", () => { + test("removes variable from selected environment", () => + expect( + runTestAndGetEnvs(`hopp.env.delete("baseUrl")`, { + global: [], + selected: [ + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + })() + ).resolves.toEqualRight(expect.objectContaining({ selected: [] }))) + + test("removes variable from global environment", () => + expect( + runTestAndGetEnvs(`hopp.env.delete("baseUrl")`, { + global: [ + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + selected: [], + })() + ).resolves.toEqualRight(expect.objectContaining({ global: [] }))) + + test("removes only from selected if present in both", () => + expect( + runTestAndGetEnvs(`hopp.env.delete("baseUrl")`, { + global: [ + { + key: "baseUrl", + currentValue: "https://httpbin.org", + initialValue: "https://httpbin.org", + secret: false, + }, + ], + selected: [ + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [ + { + key: "baseUrl", + currentValue: "https://httpbin.org", + initialValue: "https://httpbin.org", + secret: false, + }, + ], + selected: [], + }) + )) + + test("removes only first matching entry if duplicates exist in selected", () => + expect( + runTestAndGetEnvs(`hopp.env.delete("baseUrl")`, { + global: [ + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + selected: [ + { + key: "baseUrl", + currentValue: "https://httpbin.org", + initialValue: "https://httpbin.org", + secret: false, + }, + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [ + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + selected: [ + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + }) + )) + + test("removes only first matching entry if duplicates exist in global", () => + expect( + runTestAndGetEnvs(`hopp.env.delete("baseUrl")`, { + global: [ + { + key: "baseUrl", + currentValue: "https://httpbin.org", + initialValue: "https://httpbin.org", + secret: false, + }, + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + selected: [], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [ + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + selected: [], + }) + )) + + test("no change if attempting to delete non-existent key", () => + expect( + runTestAndGetEnvs(`hopp.env.delete("baseUrl")`, { + global: [], + selected: [], + })() + ).resolves.toEqualRight( + expect.objectContaining({ global: [], selected: [] }) + )) + + test("key must be a string", () => + expect( + runTestAndGetEnvs(`hopp.env.delete(5)`, { global: [], selected: [] })() + ).resolves.toBeLeft()) + + test("reflected in script execution", () => + expect( + runTest( + ` + hopp.env.delete("baseUrl") + hopp.expect(hopp.env.get("baseUrl")).toBe(null) + `, + { + global: [], + selected: [ + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + ], + }), + ])) +}) + +describe("hopp.env.active.delete", () => { + test("removes variable from selected environment", () => + expect( + runTestAndGetEnvs(`hopp.env.active.delete("foo")`, { + selected: [ + { + key: "foo", + currentValue: "bar", + initialValue: "bar", + secret: false, + }, + ], + global: [ + { + key: "foo", + currentValue: "baz", + initialValue: "baz", + secret: false, + }, + ], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [], + global: [ + { + key: "foo", + currentValue: "baz", + initialValue: "baz", + secret: false, + }, + ], + }) + )) + + test("no effect if not present in selected", () => + expect( + runTestAndGetEnvs(`hopp.env.active.delete("nope")`, { + selected: [], + global: [ + { + key: "nope", + currentValue: "baz", + initialValue: "baz", + secret: false, + }, + ], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [], + global: [ + { + key: "nope", + currentValue: "baz", + initialValue: "baz", + secret: false, + }, + ], + }) + )) + + test("key must be a string", () => + expect( + runTestAndGetEnvs(`hopp.env.active.delete({})`, { + selected: [], + global: [], + })() + ).resolves.toBeLeft()) +}) + +describe("hopp.env.global.delete", () => { + test("removes variable from global environment", () => + expect( + runTestAndGetEnvs(`hopp.env.global.delete("foo")`, { + selected: [ + { + key: "foo", + currentValue: "bar", + initialValue: "bar", + secret: false, + }, + ], + global: [ + { + key: "foo", + currentValue: "baz", + initialValue: "baz", + secret: false, + }, + ], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "foo", + currentValue: "bar", + initialValue: "bar", + secret: false, + }, + ], + global: [], + }) + )) + + test("no effect if not present in global", () => + expect( + runTestAndGetEnvs(`hopp.env.global.delete("missing")`, { + selected: [ + { + key: "missing", + currentValue: "bar", + initialValue: "bar", + secret: false, + }, + ], + global: [], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "missing", + currentValue: "bar", + initialValue: "bar", + secret: false, + }, + ], + global: [], + }) + )) + + test("key must be a string", () => + expect( + runTestAndGetEnvs(`hopp.env.global.delete([])`, { + selected: [], + global: [], + })() + ).resolves.toBeLeft()) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/get.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/get.spec.ts new file mode 100644 index 0000000..26607ce --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/get.spec.ts @@ -0,0 +1,318 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("hopp.env.get", () => { + test("returns the correct value for an existing selected environment value", () => { + return expect( + runTest( + ` + const data = hopp.env.get("a") + hopp.expect(data).toBe("b") + `, + { + global: [], + selected: [ + { + key: "a", + currentValue: "b", + initialValue: "b", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [{ status: "pass", message: "Expected 'b' to be 'b'" }], + }), + ]) + }) + + test("returns the correct value for an existing global environment value", () => { + return expect( + runTest( + ` + const data = hopp.env.get("a") + hopp.expect(data).toBe("b") + `, + { + global: [ + { + key: "a", + currentValue: "b", + initialValue: "b", + secret: false, + }, + ], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [{ status: "pass", message: "Expected 'b' to be 'b'" }], + }), + ]) + }) + + test("returns null for a key that is not present in both selected or global", () => { + return expect( + runTest( + ` + const data = hopp.env.get("a") + hopp.expect(data).toBe(null) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + ], + }), + ]) + }) + + test("returns the value defined in selected environment if also present in global", () => { + return expect( + runTest( + ` + const data = hopp.env.get("a") + hopp.expect(data).toBe("selected val") + `, + { + global: [ + { + key: "a", + currentValue: "global val", + initialValue: "global val", + secret: false, + }, + ], + selected: [ + { + key: "a", + currentValue: "selected val", + initialValue: "selected val", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'selected val' to be 'selected val'", + }, + ], + }), + ]) + }) + + test("resolves environment values recursively by default", () => { + return expect( + runTest( + ` + const data = hopp.env.get("a") + hopp.expect(data).toBe("hello") + `, + { + global: [], + selected: [ + { + key: "a", + currentValue: "<>", + initialValue: "<>", + secret: false, + }, + { + key: "hello", + currentValue: "hello", + initialValue: "hello", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'hello' to be 'hello'" }, + ], + }), + ]) + }) + + test("errors if the key is not a string", () => { + return expect( + runTest( + ` + const data = hopp.env.get(5) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toBeLeft() + }) +}) + +describe("hopp.env.active.get", () => { + test("returns the value from selected environment if present", () => { + return expect( + runTest( + ` + const data = hopp.env.active.get("a") + hopp.expect(data).toBe("selectedVal") + `, + { + selected: [ + { + key: "a", + currentValue: "selectedVal", + initialValue: "selectedVal", + secret: false, + }, + ], + global: [ + { + key: "a", + currentValue: "globalVal", + initialValue: "globalVal", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'selectedVal' to be 'selectedVal'", + }, + ], + }), + ]) + }) + + test("returns null if key does not exist in selected", () => { + return expect( + runTest( + ` + const data = hopp.env.active.get("absent") + hopp.expect(data).toBe(null) + `, + { + selected: [], + global: [ + { + key: "absent", + currentValue: "globalVal", + initialValue: "globalVal", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + ], + }), + ]) + }) + + test("errors if the key is not a string", () => { + return expect( + runTest( + ` + hopp.env.active.get({}) + `, + { selected: [], global: [] } + )() + ).resolves.toBeLeft() + }) +}) + +describe("hopp.env.global.get", () => { + test("returns the value from global environment if present", () => { + return expect( + runTest( + ` + const data = hopp.env.global.get("foo") + hopp.expect(data).toBe("globalVal") + `, + { + selected: [ + { + key: "foo", + currentValue: "selVal", + initialValue: "selVal", + secret: false, + }, + ], + global: [ + { + key: "foo", + currentValue: "globalVal", + initialValue: "globalVal", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'globalVal' to be 'globalVal'" }, + ], + }), + ]) + }) + + test("returns null if key does not exist in global", () => { + return expect( + runTest( + ` + const data = hopp.env.global.get("not_here") + hopp.expect(data).toBe(null) + `, + { + selected: [ + { + key: "not_here", + currentValue: "selVal", + initialValue: "selVal", + secret: false, + }, + ], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + ], + }), + ]) + }) + + test("errors if the key is not a string", () => { + return expect( + runTest( + ` + hopp.env.global.get([]) + `, + { selected: [], global: [] } + )() + ).resolves.toBeLeft() + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/getInitialRaw.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/getInitialRaw.spec.ts new file mode 100644 index 0000000..0db9055 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/getInitialRaw.spec.ts @@ -0,0 +1,441 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("hopp.env.getInitialRaw", () => { + test("returns initial value for existing selected env variable", () => { + return expect( + runTest( + ` + const val = hopp.env.getInitialRaw("foo") + hopp.expect(val).toBe("bar") + `, + { + selected: [ + { + key: "foo", + currentValue: "baz", + initialValue: "bar", + secret: false, + }, + ], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'bar' to be 'bar'" }, + ], + }), + ]) + }) + + test("returns initial value from global if not in selected", () => { + return expect( + runTest( + ` + const val = hopp.env.getInitialRaw("foo") + hopp.expect(val).toBe("bar") + `, + { + selected: [], + global: [ + { + key: "foo", + currentValue: "baz", + initialValue: "bar", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'bar' to be 'bar'" }, + ], + }), + ]) + }) + + test("selected shadows global when both present", () => { + return expect( + runTest( + ` + const val = hopp.env.getInitialRaw("foo") + hopp.expect(val).toBe("selVal") + `, + { + selected: [ + { + key: "foo", + currentValue: "selCur", + initialValue: "selVal", + secret: false, + }, + ], + global: [ + { + key: "foo", + currentValue: "globCur", + initialValue: "globVal", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'selVal' to be 'selVal'" }, + ], + }), + ]) + }) + + test("returns null for missing key", () => { + return expect( + runTest( + ` + const val = hopp.env.getInitialRaw("notFound") + hopp.expect(val).toBe(null) + `, + { selected: [], global: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + ], + }), + ]) + }) + + test("returns empty string if initial value was empty", () => { + return expect( + runTest( + ` + const val = hopp.env.getInitialRaw("empty") + hopp.expect(val).toBe("") + `, + { + selected: [ + { key: "empty", currentValue: "", initialValue: "", secret: false }, + ], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [{ status: "pass", message: "Expected '' to be ''" }], + }), + ]) + }) + + test("returns literal template syntax, no resolution", () => { + return expect( + runTest( + ` + const val = hopp.env.getInitialRaw("templ") + hopp.expect(val).toBe("<>") + `, + { + selected: [ + { + key: "templ", + currentValue: "baz", + initialValue: "<>", + secret: false, + }, + { + key: "FOO", + currentValue: "bar", + initialValue: "bar", + secret: false, + }, + ], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected '<>' to be '<>'" }, + ], + }), + ]) + }) + + test("errors for non-string key", () => { + return expect( + runTest( + ` + hopp.env.getInitialRaw(5) + `, + { selected: [], global: [] } + )() + ).resolves.toBeLeft() + }) +}) + +describe("hopp.env.active.getInitialRaw", () => { + test("returns initial value if present in selected env", () => { + return expect( + runTest( + ` + const val = hopp.env.active.getInitialRaw("alpha") + hopp.expect(val).toBe("a_value") + `, + { + selected: [ + { + key: "alpha", + currentValue: "changed", + initialValue: "a_value", + secret: false, + }, + ], + global: [ + { + key: "alpha", + currentValue: "global", + initialValue: "g_value", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'a_value' to be 'a_value'" }, + ], + }), + ]) + }) + + test("returns null if not present in selected env", () => { + return expect( + runTest( + ` + const val = hopp.env.active.getInitialRaw("missing") + hopp.expect(val).toBe(null) + `, + { + selected: [], + global: [ + { + key: "missing", + currentValue: "glob", + initialValue: "g", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + ], + }), + ]) + }) + + test("returns '' if initial value was empty string", () => { + return expect( + runTest( + ` + const val = hopp.env.active.getInitialRaw("blank") + hopp.expect(val).toBe("") + `, + { + selected: [ + { key: "blank", currentValue: "", initialValue: "", secret: false }, + ], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [{ status: "pass", message: "Expected '' to be ''" }], + }), + ]) + }) + + test("returns literal template if present", () => { + return expect( + runTest( + ` + const val = hopp.env.active.getInitialRaw("tmpl") + hopp.expect(val).toBe("<>") + `, + { + selected: [ + { + key: "tmpl", + currentValue: "baz", + initialValue: "<>", + secret: false, + }, + { + key: "BAR", + currentValue: "qux", + initialValue: "qux", + secret: false, + }, + ], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected '<>' to be '<>'" }, + ], + }), + ]) + }) + + test("errors for non-string key", () => { + return expect( + runTest( + ` + hopp.env.active.getInitialRaw({}) + `, + { selected: [], global: [] } + )() + ).resolves.toBeLeft() + }) +}) + +describe("hopp.env.global.getInitialRaw", () => { + test("returns initial value if present in global env", () => { + return expect( + runTest( + ` + const val = hopp.env.global.getInitialRaw("gamma") + hopp.expect(val).toBe("g_val") + `, + { + selected: [ + { + key: "gamma", + currentValue: "s_val", + initialValue: "s_val", + secret: false, + }, + ], + global: [ + { + key: "gamma", + currentValue: "current", + initialValue: "g_val", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'g_val' to be 'g_val'" }, + ], + }), + ]) + }) + + test("returns null if not present in global env", () => { + return expect( + runTest( + ` + const val = hopp.env.global.getInitialRaw("none") + hopp.expect(val).toBe(null) + `, + { + selected: [ + { + key: "none", + currentValue: "s", + initialValue: "s", + secret: false, + }, + ], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + ], + }), + ]) + }) + + test("returns '' if initial value was empty string", () => { + return expect( + runTest( + ` + const val = hopp.env.global.getInitialRaw("empty") + hopp.expect(val).toBe("") + `, + { + selected: [], + global: [ + { key: "empty", currentValue: "", initialValue: "", secret: false }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [{ status: "pass", message: "Expected '' to be ''" }], + }), + ]) + }) + + test("returns literal template value if present", () => { + return expect( + runTest( + ` + const val = hopp.env.global.getInitialRaw("tmpl") + hopp.expect(val).toBe("<>") + `, + { + selected: [], + global: [ + { + key: "tmpl", + currentValue: "zed-cur", + initialValue: "<>", + secret: false, + }, + { + key: "ZED", + currentValue: "42", + initialValue: "42", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected '<>' to be '<>'" }, + ], + }), + ]) + }) + + test("errors for non-string key", () => { + return expect( + runTest( + ` + hopp.env.global.getInitialRaw([]) + `, + { selected: [], global: [] } + )() + ).resolves.toBeLeft() + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/getRaw.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/getRaw.spec.ts new file mode 100644 index 0000000..687578b --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/getRaw.spec.ts @@ -0,0 +1,339 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("hopp.env.getRaw", () => { + test("returns the correct value for an existing selected environment value", () => { + return expect( + runTest( + ` + const data = hopp.env.getRaw("a") + hopp.expect(data).toBe("b") + `, + { + global: [], + selected: [ + { key: "a", currentValue: "b", initialValue: "b", secret: false }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [{ status: "pass", message: "Expected 'b' to be 'b'" }], + }), + ]) + }) + + test("returns the correct value for an existing global environment value", () => { + return expect( + runTest( + ` + const data = hopp.env.getRaw("a") + hopp.expect(data).toBe("b") + `, + { + global: [ + { key: "a", currentValue: "b", initialValue: "b", secret: false }, + ], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [{ status: "pass", message: "Expected 'b' to be 'b'" }], + }), + ]) + }) + + test("returns null for a key that is not present in both selected and global", () => { + return expect( + runTest( + ` + const data = hopp.env.getRaw("a") + hopp.expect(data).toBe(null) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + ], + }), + ]) + }) + + test("returns the value defined in selected if also present in global", () => { + return expect( + runTest( + ` + const data = hopp.env.getRaw("a") + hopp.expect(data).toBe("selected val") + `, + { + global: [ + { + key: "a", + currentValue: "global val", + initialValue: "global val", + secret: false, + }, + ], + selected: [ + { + key: "a", + currentValue: "selected val", + initialValue: "selected val", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'selected val' to be 'selected val'", + }, + ], + }), + ]) + }) + + test("does not resolve values recursively", () => { + return expect( + runTest( + ` + const data = hopp.env.getRaw("a") + hopp.expect(data).toBe("<>") + `, + { + global: [], + selected: [ + { + key: "a", + currentValue: "<>", + initialValue: "<>", + secret: false, + }, + { + key: "hello", + currentValue: "there", + initialValue: "there", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected '<>' to be '<>'" }, + ], + }), + ]) + }) + + test("returns the value as is even if there is a potential recursion", () => { + return expect( + runTest( + ` + const data = hopp.env.getRaw("a") + hopp.expect(data).toBe("<>") + `, + { + global: [], + selected: [ + { + key: "a", + currentValue: "<>", + initialValue: "<>", + secret: false, + }, + { + key: "hello", + currentValue: "<>", + initialValue: "<>", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected '<>' to be '<>'" }, + ], + }), + ]) + }) + + test("errors if the key is not a string", () => { + return expect( + runTest( + ` + const data = hopp.env.getRaw(5) + `, + { global: [], selected: [] } + )() + ).resolves.toBeLeft() + }) +}) + +describe("hopp.env.active.getRaw", () => { + test("returns only from selected", () => { + return expect( + runTest( + ` + hopp.expect(hopp.env.active.getRaw("a")).toBe("a-selected") + hopp.expect(hopp.env.active.getRaw("b")).toBe(null) + `, + { + selected: [ + { + key: "a", + currentValue: "a-selected", + initialValue: "AS", + secret: false, + }, + ], + global: [ + { + key: "a", + currentValue: "a-global", + initialValue: "AG", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'a-selected' to be 'a-selected'", + }, + { status: "pass", message: "Expected 'null' to be 'null'" }, + ], + }), + ]) + }) + + test("returns null if key absent in selected", () => { + return expect( + runTest( + ` + hopp.expect(hopp.env.active.getRaw("missing")).toBe(null) + `, + { + selected: [], + global: [ + { + key: "missing", + currentValue: "global", + initialValue: "global", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + ], + }), + ]) + }) + + test("errors if key is not a string", () => { + return expect( + runTest( + ` + hopp.env.active.getRaw({}) + `, + { selected: [], global: [] } + )() + ).resolves.toBeLeft() + }) +}) + +describe("hopp.env.global.getRaw", () => { + test("returns only from global", () => { + return expect( + runTest( + ` + hopp.expect(hopp.env.global.getRaw("b")).toBe("b-global") + hopp.expect(hopp.env.global.getRaw("a")).toBe(null) + `, + { + selected: [ + { + key: "a", + currentValue: "a-selected", + initialValue: "AS", + secret: false, + }, + ], + global: [ + { + key: "b", + currentValue: "b-global", + initialValue: "BG", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'b-global' to be 'b-global'" }, + { status: "pass", message: "Expected 'null' to be 'null'" }, + ], + }), + ]) + }) + + test("returns null if key absent in global", () => { + return expect( + runTest( + ` + hopp.expect(hopp.env.global.getRaw("missing")).toBe(null) + `, + { + selected: [ + { + key: "missing", + currentValue: "sel", + initialValue: "sel", + secret: false, + }, + ], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + ], + }), + ]) + }) + + test("errors if key is not a string", () => { + return expect( + runTest( + ` + hopp.env.global.getRaw([]) + `, + { selected: [], global: [] } + )() + ).resolves.toBeLeft() + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/reset.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/reset.spec.ts new file mode 100644 index 0000000..22cfe6b --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/reset.spec.ts @@ -0,0 +1,499 @@ +import { describe, expect, test } from "vitest" +import { runTestAndGetEnvs, runTest } from "~/utils/test-helpers" + +describe("hopp.env.reset", () => { + test("resets selected variable to its initial value", () => + expect( + runTestAndGetEnvs( + ` + hopp.env.set("foo", "changed") + hopp.env.reset("foo") + `, + { + selected: [ + { + key: "foo", + currentValue: "bar", + initialValue: "bar", + secret: false, + }, + ], + global: [], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "foo", + currentValue: "bar", + initialValue: "bar", + secret: false, + }, + ], + }) + )) + + test("resets global variable to its initial value if not in selected", () => + expect( + runTestAndGetEnvs( + ` + hopp.env.set("bar", "override") + hopp.env.reset("bar") + `, + { + selected: [], + global: [ + { + key: "bar", + currentValue: "baz", + initialValue: "baz", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [ + { + key: "bar", + currentValue: "baz", + initialValue: "baz", + secret: false, + }, + ], + }) + )) + + test("if variable exists in both, only selected variable is reset", () => + expect( + runTestAndGetEnvs( + ` + hopp.env.set("a", "S") + hopp.env.global.set("a", "G") + hopp.env.reset("a") + `, + { + selected: [ + { + key: "a", + currentValue: "sel", + initialValue: "initSel", + secret: false, + }, + ], + global: [ + { + key: "a", + currentValue: "glob", + initialValue: "initGlob", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "a", + currentValue: "initSel", + initialValue: "initSel", + secret: false, + }, + ], + global: [ + { + key: "a", + currentValue: "G", + initialValue: "initGlob", + secret: false, + }, + ], + }) + )) + + test("resets only the first occurrence if duplicates exist in selected", () => + expect( + runTestAndGetEnvs( + ` + hopp.env.set("dup", "X") + hopp.env.reset("dup") + `, + { + selected: [ + { key: "dup", currentValue: "A", initialValue: "A", secret: false }, + { key: "dup", currentValue: "B", initialValue: "B", secret: false }, + ], + global: [], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { key: "dup", currentValue: "A", initialValue: "A", secret: false }, + { key: "dup", currentValue: "B", initialValue: "B", secret: false }, + ], + }) + )) + + test("resets only the first occurrence if duplicates exist in global", () => + expect( + runTestAndGetEnvs( + ` + hopp.env.global.set("gdup", "Y") + hopp.env.reset("gdup") + `, + { + selected: [], + global: [ + { + key: "gdup", + currentValue: "G1", + initialValue: "I1", + secret: false, + }, + { + key: "gdup", + currentValue: "G2", + initialValue: "I2", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [ + { + key: "gdup", + currentValue: "I1", + initialValue: "I1", + secret: false, + }, + { + key: "gdup", + currentValue: "G2", + initialValue: "I2", + secret: false, + }, + ], + }) + )) + + test("no change if attempting to reset a non-existent key", () => + expect( + runTestAndGetEnvs(`hopp.env.reset("none")`, { + selected: [], + global: [], + })() + ).resolves.toEqualRight( + expect.objectContaining({ selected: [], global: [] }) + )) + + test("keys should be a string", () => + expect( + runTestAndGetEnvs(`hopp.env.reset(123)`, { selected: [], global: [] })() + ).resolves.toBeLeft()) + + test("reset reflected in subsequent get in the same script (selected)", () => + expect( + runTest( + ` + hopp.env.set("foo", "override") + hopp.env.reset("foo") + hopp.expect(hopp.env.get("foo")).toBe("bar") + `, + { + selected: [ + { + key: "foo", + currentValue: "bar", + initialValue: "bar", + secret: false, + }, + ], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'bar' to be 'bar'" }, + ], + }), + ])) + + test("reset works for secret variables", () => + expect( + runTestAndGetEnvs( + ` + hopp.env.set("secret", "newVal") + hopp.env.reset("secret") + `, + { + selected: [ + { + key: "secret", + currentValue: "origi", + initialValue: "origi", + secret: true, + }, + ], + global: [], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "secret", + currentValue: "origi", + initialValue: "origi", + secret: true, + }, + ], + }) + )) +}) + +describe("hopp.env.active.reset", () => { + test("resets variable only in selected environment", () => + expect( + runTestAndGetEnvs( + ` + hopp.env.active.set("xxx", "MUT") + hopp.env.active.reset("xxx") + `, + { + selected: [ + { key: "xxx", currentValue: "A", initialValue: "A", secret: false }, + ], + global: [ + { key: "xxx", currentValue: "B", initialValue: "B", secret: false }, + ], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { key: "xxx", currentValue: "A", initialValue: "A", secret: false }, + ], + global: [ + { key: "xxx", currentValue: "B", initialValue: "B", secret: false }, + ], + }) + )) + + test("no effect if key not in selected", () => + expect( + runTestAndGetEnvs( + ` + hopp.env.active.reset("nonexistent") + `, + { + selected: [], + global: [ + { + key: "nonexistent", + currentValue: "G", + initialValue: "G", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [], + global: [ + { + key: "nonexistent", + currentValue: "G", + initialValue: "G", + secret: false, + }, + ], + }) + )) + + test("key must be a string", () => + expect( + runTestAndGetEnvs(`hopp.env.active.reset(123)`, { + selected: [], + global: [], + })() + ).resolves.toBeLeft()) +}) + +describe("hopp.env.global.reset", () => { + test("resets variable only in global environment", () => + expect( + runTestAndGetEnvs( + ` + hopp.env.global.set("yyy", "GGG") + hopp.env.global.reset("yyy") + `, + { + selected: [ + { key: "yyy", currentValue: "S", initialValue: "S", secret: false }, + ], + global: [ + { + key: "yyy", + currentValue: "G", + initialValue: "GI", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { key: "yyy", currentValue: "S", initialValue: "S", secret: false }, + ], + global: [ + { key: "yyy", currentValue: "GI", initialValue: "GI", secret: false }, + ], + }) + )) + + test("no effect if key not in global", () => + expect( + runTestAndGetEnvs( + ` + hopp.env.global.reset("nonexistent") + `, + { + selected: [ + { + key: "nonexistent", + currentValue: "S", + initialValue: "S", + secret: false, + }, + ], + global: [], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "nonexistent", + currentValue: "S", + initialValue: "S", + secret: false, + }, + ], + global: [], + }) + )) + + // Additional regression tests ensuring reset uses the latest state + describe("hopp.env.reset - regression cases", () => { + test("create via setInitial then set, and reset restores to initial (selected)", () => + expect( + runTestAndGetEnvs( + ` + // Variable does not exist initially + hopp.env.setInitial("AUTH_TOKEN", "seeded-v1") + hopp.env.set("AUTH_TOKEN", "overridden-v2") + hopp.env.reset("AUTH_TOKEN") + `, + { + selected: [], + global: [], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "AUTH_TOKEN", + currentValue: "seeded-v1", + initialValue: "seeded-v1", + secret: false, + }, + ], + global: [], + }) + )) + + test("scope flip: remove from global, create in active, reset only affects active and not deleted global", () => + expect( + runTestAndGetEnvs( + ` + // Start by ensuring global is cleared + hopp.env.global.delete("API_KEY") + // Create in active with initial and then override + hopp.env.active.setInitial("API_KEY", "run-initial") + hopp.env.active.set("API_KEY", "run-override") + // Reset should restore to initial in active, global remains absent + hopp.env.active.reset("API_KEY") + `, + { + selected: [], + global: [ + // Simulate global having had a value in the past; we delete within the script + { + key: "API_KEY", + currentValue: "old-glob", + initialValue: "old-glob", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "API_KEY", + currentValue: "run-initial", + initialValue: "run-initial", + secret: false, + }, + ], + // After delete, global should not contain API_KEY + global: [], + }) + )) + + test("delete then reset within same script should be a no-op (selected)", () => + expect( + runTestAndGetEnvs( + ` + hopp.env.active.delete("SESSION_ID") + // Reset after unset should not reintroduce or change anything + hopp.env.active.reset("SESSION_ID") + `, + { + selected: [ + { + key: "SESSION_ID", + currentValue: "s-1", + initialValue: "s-1", + secret: false, + }, + ], + global: [], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [], + global: [], + }) + )) + }) + test("key must be a string", () => + expect( + runTestAndGetEnvs(`hopp.env.global.reset([])`, { + selected: [], + global: [], + })() + ).resolves.toBeLeft()) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/set.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/set.spec.ts new file mode 100644 index 0000000..54f0847 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/set.spec.ts @@ -0,0 +1,388 @@ +import { getDefaultRESTRequest } from "@hoppscotch/data" +import * as TE from "fp-ts/TaskEither" +import { pipe } from "fp-ts/function" +import { describe, expect, test } from "vitest" + +import { runTestScript } from "~/node" +import { TestResponse, TestResult } from "~/types" + +const defaultRequest = getDefaultRESTRequest() +const fakeResponse: TestResponse = { + status: 200, + body: "hoi", + headers: [], +} + +const execEnv = (script: string, envs: TestResult["envs"]) => + pipe( + runTestScript(script, { + envs, + request: defaultRequest, + response: fakeResponse, + }), + TE.map((x) => x.envs) + ) + +const execTest = (script: string, envs: TestResult["envs"]) => + pipe( + runTestScript(script, { + envs, + request: defaultRequest, + response: fakeResponse, + }), + TE.map((x) => x.tests) + ) + +describe("hopp.env.set", () => { + test("updates the selected environment variable correctly", () => { + return expect( + execEnv(`hopp.env.set("a", "c")`, { + global: [], + selected: [ + { key: "a", currentValue: "b", initialValue: "b", secret: false }, + ], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { key: "a", currentValue: "c", initialValue: "b", secret: false }, + ], + }) + ) + }) + + test("updates the global environment variable correctly", () => { + return expect( + execEnv(`hopp.env.set("a", "c")`, { + global: [ + { key: "a", currentValue: "b", initialValue: "b", secret: false }, + ], + selected: [], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [ + { key: "a", currentValue: "c", initialValue: "b", secret: false }, + ], + }) + ) + }) + + test("if env exists in both, updates only selected", () => { + return expect( + execEnv(`hopp.env.set("a", "selC")`, { + global: [ + { + key: "a", + currentValue: "globB", + initialValue: "globB", + secret: false, + }, + ], + selected: [ + { + key: "a", + currentValue: "selD", + initialValue: "selD", + secret: false, + }, + ], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [ + { + key: "a", + currentValue: "globB", + initialValue: "globB", + secret: false, + }, + ], + selected: [ + { + key: "a", + currentValue: "selC", + initialValue: "selD", + secret: false, + }, + ], + }) + ) + }) + + test("creates non-existent key in selected environment", () => { + return expect( + execEnv(`hopp.env.set("a", "created")`, { global: [], selected: [] })() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "a", + currentValue: "created", + initialValue: "created", + secret: false, + }, + ], + global: [], + }) + ) + }) + + test("does not affect secret status for existing variable", () => { + return expect( + execEnv(`hopp.env.set("mysecret", "not-secret-anymore")`, { + selected: [ + { + key: "mysecret", + currentValue: "secretvalue", + initialValue: "secretvalue", + secret: true, + }, + ], + global: [], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "mysecret", + currentValue: "not-secret-anymore", + initialValue: "secretvalue", + secret: true, + }, + ], + }) + ) + }) + + test("rejects non-string key", () => { + return expect( + execEnv(`hopp.env.set(7, "foo")`, { selected: [], global: [] })() + ).resolves.toBeLeft() + }) + + test("rejects non-string value", () => { + return expect( + execEnv(`hopp.env.set("key", 123)`, { selected: [], global: [] })() + ).resolves.toBeLeft() + }) + + test("both key and value must be strings", () => { + return expect( + execEnv(`hopp.env.set(5, 6)`, { selected: [], global: [] })() + ).resolves.toBeLeft() + }) + + test("set environment values are reflected immediately", () => { + return expect( + execTest( + ` + hopp.env.set("foo", "bar") + hopp.expect(hopp.env.get("foo")).toBe("bar") + `, + { selected: [], global: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'bar' to be 'bar'" }, + ], + }), + ]) + }) +}) + +describe("hopp.env.active.set", () => { + test("sets in selected even if key exists in global", () => { + return expect( + execEnv(`hopp.env.active.set("b", "in-selected")`, { + selected: [], + global: [ + { + key: "b", + currentValue: "in-global", + initialValue: "in-global", + secret: false, + }, + ], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "b", + currentValue: "in-selected", + initialValue: "in-selected", + secret: false, + }, + ], + global: [ + { + key: "b", + currentValue: "in-global", + initialValue: "in-global", + secret: false, + }, + ], + }) + ) + }) + + test("updates existing selected variable", () => { + return expect( + execEnv(`hopp.env.active.set("foo", "bar")`, { + selected: [ + { + key: "foo", + currentValue: "baz", + initialValue: "baz", + secret: false, + }, + ], + global: [], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "foo", + currentValue: "bar", + initialValue: "baz", + secret: false, + }, + ], + }) + ) + }) + + test("creates a new key in selected", () => { + return expect( + execEnv(`hopp.env.active.set("make", "new")`, { + selected: [], + global: [], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "make", + currentValue: "new", + initialValue: "new", + secret: false, + }, + ], + global: [], + }) + ) + }) + + test("rejects non-string key", () => { + return expect( + execEnv(`hopp.env.active.set(null, "value")`, { + selected: [], + global: [], + })() + ).resolves.toBeLeft() + }) + + test("rejects non-string value", () => { + return expect( + execEnv(`hopp.env.active.set("key", {})`, { selected: [], global: [] })() + ).resolves.toBeLeft() + }) +}) + +describe("hopp.env.global.set", () => { + test("creates in global, does not affect selected", () => { + return expect( + execEnv(`hopp.env.global.set("c", "in-global")`, { + selected: [ + { + key: "c", + currentValue: "selected", + initialValue: "selected", + secret: false, + }, + ], + global: [], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "c", + currentValue: "selected", + initialValue: "selected", + secret: false, + }, + ], + global: [ + { + key: "c", + currentValue: "in-global", + initialValue: "in-global", + secret: false, + }, + ], + }) + ) + }) + + test("updates existing global variable", () => { + return expect( + execEnv(`hopp.env.global.set("d", "updated")`, { + selected: [], + global: [ + { key: "d", currentValue: "old", initialValue: "old", secret: false }, + ], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [ + { + key: "d", + currentValue: "updated", + initialValue: "old", + secret: false, + }, + ], + }) + ) + }) + + test("creates new variable in global", () => { + return expect( + execEnv(`hopp.env.global.set("e", "new-value")`, { + selected: [], + global: [], + })() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [], + global: [ + { + key: "e", + currentValue: "new-value", + initialValue: "new-value", + secret: false, + }, + ], + }) + ) + }) + + test("rejects non-string key", () => { + return expect( + execEnv(`hopp.env.global.set([], "foo")`, { selected: [], global: [] })() + ).resolves.toBeLeft() + }) + + test("rejects non-string value", () => { + return expect( + execEnv(`hopp.env.global.set("key", true)`, { + selected: [], + global: [], + })() + ).resolves.toBeLeft() + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/setInitial.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/setInitial.spec.ts new file mode 100644 index 0000000..84bbbf8 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/setInitial.spec.ts @@ -0,0 +1,504 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("hopp.env.setInitial", () => { + test("sets initial value in selected env when key doesn't exist", () => { + return expect( + runTest( + ` + hopp.env.setInitial("newKey", "newValue") + const val = hopp.env.getInitialRaw("newKey") + hopp.expect(val).toBe("newValue") + `, + { + selected: [], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'newValue' to be 'newValue'" }, + ], + }), + ]) + }) + + test("updates initial value in selected env when key exists", () => { + return expect( + runTest( + ` + hopp.env.setInitial("existing", "updated") + const val = hopp.env.getInitialRaw("existing") + hopp.expect(val).toBe("updated") + `, + { + selected: [ + { + key: "existing", + currentValue: "current", + initialValue: "original", + secret: false, + }, + ], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'updated' to be 'updated'" }, + ], + }), + ]) + }) + + test("updates selected env when key exists in both selected and global", () => { + return expect( + runTest( + ` + hopp.env.setInitial("shared", "selectedUpdate") + const val = hopp.env.getInitialRaw("shared") + hopp.expect(val).toBe("selectedUpdate") + `, + { + selected: [ + { + key: "shared", + currentValue: "selCur", + initialValue: "selInit", + secret: false, + }, + ], + global: [ + { + key: "shared", + currentValue: "globCur", + initialValue: "globInit", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'selectedUpdate' to be 'selectedUpdate'", + }, + ], + }), + ]) + }) + + test("sets initial value in global env when only exists in global", () => { + return expect( + runTest( + ` + hopp.env.setInitial("globalOnly", "globalUpdate") + const val = hopp.env.getInitialRaw("globalOnly") + hopp.expect(val).toBe("globalUpdate") + `, + { + selected: [], + global: [ + { + key: "globalOnly", + currentValue: "current", + initialValue: "original", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'globalUpdate' to be 'globalUpdate'", + }, + ], + }), + ]) + }) + + test("allows setting empty string as initial value", () => { + return expect( + runTest( + ` + hopp.env.setInitial("empty", "") + const val = hopp.env.getInitialRaw("empty") + hopp.expect(val).toBe("") + `, + { + selected: [], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [{ status: "pass", message: "Expected '' to be ''" }], + }), + ]) + }) + + test("allows setting template syntax as initial value", () => { + return expect( + runTest( + ` + hopp.env.setInitial("template", "<>") + const val = hopp.env.getInitialRaw("template") + hopp.expect(val).toBe("<>") + `, + { + selected: [], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected '<>' to be '<>'" }, + ], + }), + ]) + }) + + test("errors for non-string key", () => { + return expect( + runTest( + ` + hopp.env.setInitial(123, "value") + `, + { selected: [], global: [] } + )() + ).resolves.toBeLeft() + }) + + test("errors for non-string value", () => { + return expect( + runTest( + ` + hopp.env.setInitial("key", 456) + `, + { selected: [], global: [] } + )() + ).resolves.toBeLeft() + }) +}) + +describe("hopp.env.active.setInitial", () => { + test("sets initial value in selected env only", () => { + return expect( + runTest( + ` + hopp.env.active.setInitial("activeKey", "activeValue") + const activeVal = hopp.env.active.getInitialRaw("activeKey") + const globalVal = hopp.env.global.getInitialRaw("activeKey") + hopp.expect(activeVal).toBe("activeValue") + hopp.expect(globalVal).toBe(null) + `, + { + selected: [], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'activeValue' to be 'activeValue'", + }, + { status: "pass", message: "Expected 'null' to be 'null'" }, + ], + }), + ]) + }) + + test("updates existing selected env variable", () => { + return expect( + runTest( + ` + hopp.env.active.setInitial("existing", "updated") + const val = hopp.env.active.getInitialRaw("existing") + hopp.expect(val).toBe("updated") + `, + { + selected: [ + { + key: "existing", + currentValue: "current", + initialValue: "original", + secret: false, + }, + ], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'updated' to be 'updated'" }, + ], + }), + ]) + }) + + test("does not affect global env even if key exists there", () => { + return expect( + runTest( + ` + hopp.env.active.setInitial("shared", "activeUpdate") + const activeVal = hopp.env.active.getInitialRaw("shared") + const globalVal = hopp.env.global.getInitialRaw("shared") + hopp.expect(activeVal).toBe("activeUpdate") + hopp.expect(globalVal).toBe("globalOriginal") + `, + { + selected: [ + { + key: "shared", + currentValue: "selCur", + initialValue: "selInit", + secret: false, + }, + ], + global: [ + { + key: "shared", + currentValue: "globCur", + initialValue: "globalOriginal", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'activeUpdate' to be 'activeUpdate'", + }, + { + status: "pass", + message: "Expected 'globalOriginal' to be 'globalOriginal'", + }, + ], + }), + ]) + }) + + test("allows setting empty string", () => { + return expect( + runTest( + ` + hopp.env.active.setInitial("blank", "") + const val = hopp.env.active.getInitialRaw("blank") + hopp.expect(val).toBe("") + `, + { + selected: [], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [{ status: "pass", message: "Expected '' to be ''" }], + }), + ]) + }) + + test("errors for non-string key", () => { + return expect( + runTest( + ` + hopp.env.active.setInitial(null, "value") + `, + { selected: [], global: [] } + )() + ).resolves.toBeLeft() + }) + + test("errors for non-string value", () => { + return expect( + runTest( + ` + hopp.env.active.setInitial("key", {}) + `, + { selected: [], global: [] } + )() + ).resolves.toBeLeft() + }) +}) + +describe("hopp.env.global.setInitial", () => { + test("sets initial value in global env only", () => { + return expect( + runTest( + ` + hopp.env.global.setInitial("globalKey", "globalValue") + const globalVal = hopp.env.global.getInitialRaw("globalKey") + const activeVal = hopp.env.active.getInitialRaw("globalKey") + hopp.expect(globalVal).toBe("globalValue") + hopp.expect(activeVal).toBe(null) + `, + { + selected: [], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'globalValue' to be 'globalValue'", + }, + { status: "pass", message: "Expected 'null' to be 'null'" }, + ], + }), + ]) + }) + + test("updates existing global env variable", () => { + return expect( + runTest( + ` + hopp.env.global.setInitial("existing", "updated") + const val = hopp.env.global.getInitialRaw("existing") + hopp.expect(val).toBe("updated") + `, + { + selected: [], + global: [ + { + key: "existing", + currentValue: "current", + initialValue: "original", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'updated' to be 'updated'" }, + ], + }), + ]) + }) + + test("does not affect selected env even if key exists there", () => { + return expect( + runTest( + ` + hopp.env.global.setInitial("shared", "globalUpdate") + const globalVal = hopp.env.global.getInitialRaw("shared") + const activeVal = hopp.env.active.getInitialRaw("shared") + hopp.expect(globalVal).toBe("globalUpdate") + hopp.expect(activeVal).toBe("activeOriginal") + `, + { + selected: [ + { + key: "shared", + currentValue: "selCur", + initialValue: "activeOriginal", + secret: false, + }, + ], + global: [ + { + key: "shared", + currentValue: "globCur", + initialValue: "globInit", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'globalUpdate' to be 'globalUpdate'", + }, + { + status: "pass", + message: "Expected 'activeOriginal' to be 'activeOriginal'", + }, + ], + }), + ]) + }) + + test("allows setting empty string", () => { + return expect( + runTest( + ` + hopp.env.global.setInitial("empty", "") + const val = hopp.env.global.getInitialRaw("empty") + hopp.expect(val).toBe("") + `, + { + selected: [], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [{ status: "pass", message: "Expected '' to be ''" }], + }), + ]) + }) + + test("allows setting template syntax", () => { + return expect( + runTest( + ` + hopp.env.global.setInitial("template", "<>") + const val = hopp.env.global.getInitialRaw("template") + hopp.expect(val).toBe("<>") + `, + { + selected: [], + global: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected '<>' to be '<>'" }, + ], + }), + ]) + }) + + test("errors for non-string key", () => { + return expect( + runTest( + ` + hopp.env.global.setInitial([], "value") + `, + { selected: [], global: [] } + )() + ).resolves.toBeLeft() + }) + + test("errors for non-string value", () => { + return expect( + runTest( + ` + hopp.env.global.setInitial("key", true) + `, + { selected: [], global: [] } + )() + ).resolves.toBeLeft() + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/undefined-rejection.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/undefined-rejection.spec.ts new file mode 100644 index 0000000..48ba742 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/undefined-rejection.spec.ts @@ -0,0 +1,120 @@ +import { getDefaultRESTRequest } from "@hoppscotch/data" +import * as TE from "fp-ts/TaskEither" +import { pipe } from "fp-ts/function" +import { describe, expect, test } from "vitest" + +import { runTestScript } from "~/node" +import { TestResponse, TestResult } from "~/types" + +// Hopp namespace enforces strict string typing (unlike PM namespace) + +const defaultRequest = getDefaultRESTRequest() +const fakeResponse: TestResponse = { + status: 200, + body: "test", + headers: [], +} + +const execEnv = (script: string, envs: TestResult["envs"]) => + pipe( + runTestScript(script, { + envs, + request: defaultRequest, + response: fakeResponse, + }), + TE.map((x) => x.envs) + ) + +describe("hopp.env.set undefined rejection", () => { + test("hopp.env.set rejects undefined value", () => { + return expect( + execEnv(`hopp.env.set("key", undefined)`, { selected: [], global: [] })() + ).resolves.toBeLeft() + }) + + test("hopp.env.active.set rejects undefined value", () => { + return expect( + execEnv(`hopp.env.active.set("key", undefined)`, { + selected: [], + global: [], + })() + ).resolves.toBeLeft() + }) + + test("hopp.env.global.set rejects undefined value", () => { + return expect( + execEnv(`hopp.env.global.set("key", undefined)`, { + selected: [], + global: [], + })() + ).resolves.toBeLeft() + }) + + test("hopp.env.setInitial rejects undefined value", () => { + return expect( + execEnv(`hopp.env.setInitial("key", undefined)`, { + selected: [], + global: [], + })() + ).resolves.toBeLeft() + }) + + test("hopp.env.active.setInitial rejects undefined value", () => { + return expect( + execEnv(`hopp.env.active.setInitial("key", undefined)`, { + selected: [], + global: [], + })() + ).resolves.toBeLeft() + }) + + test("hopp.env.global.setInitial rejects undefined value", () => { + return expect( + execEnv(`hopp.env.global.setInitial("key", undefined)`, { + selected: [], + global: [], + })() + ).resolves.toBeLeft() + }) + + test("hopp.env.set rejects null value", () => { + return expect( + execEnv(`hopp.env.set("key", null)`, { selected: [], global: [] })() + ).resolves.toBeLeft() + }) + + test("hopp.env.set rejects boolean value", () => { + return expect( + execEnv(`hopp.env.set("key", true)`, { selected: [], global: [] })() + ).resolves.toBeLeft() + }) + + test("hopp.env.set rejects object value", () => { + return expect( + execEnv(`hopp.env.set("key", {})`, { selected: [], global: [] })() + ).resolves.toBeLeft() + }) + + test("hopp.env.set rejects array value", () => { + return expect( + execEnv(`hopp.env.set("key", [])`, { selected: [], global: [] })() + ).resolves.toBeLeft() + }) + + test("hopp.env.set accepts string value (baseline)", () => { + return expect( + execEnv(`hopp.env.set("key", "value")`, { selected: [], global: [] })() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "key", + currentValue: "value", + initialValue: "value", + secret: false, + }, + ], + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/fetch.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/fetch.spec.ts new file mode 100644 index 0000000..632fd2f --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/fetch.spec.ts @@ -0,0 +1,1748 @@ +import { describe, expect, test, vi } from "vitest" +import { runTest } from "~/utils/test-helpers" +import type { HoppFetchHook } from "~/types" + +/** + * Comprehensive tests for hopp.fetch() and global fetch() API + * + * This test suite covers the complete Fetch API implementation including: + * - Basic fetch functionality (GET, POST, PUT, DELETE, PATCH) + * - Request and Response constructors + * - Body methods (text, json, arrayBuffer, blob, formData) + * - Body consumption tracking (bodyUsed property) + * - Response and Request cloning + * - Headers class operations + * - AbortController functionality + * - Error handling + * - Environment variable integration + * - Edge cases and status codes + * + * The actual network requests are mocked via the hoppFetchHook parameter. + */ + +describe("hopp.fetch() and global fetch()", () => { + describe("Basic functionality", () => { + test("hopp.fetch should be defined and callable", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("OK", { status: 200 }) + }) + + await expect( + runTest( + ` + pw.expect(typeof hopp.fetch).toBe("function") + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'function' to be 'function'", + }, + ], + }), + ]) + }) + + test("hopp.fetch should make GET request with string URL", async () => { + const mockFetch: HoppFetchHook = vi.fn(async (_input, _init) => { + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }) + + await expect( + runTest( + ` + const response = await hopp.fetch("https://api.example.com/data") + pw.expect(response.status).toBe(200) + pw.expect(response.ok).toBe(true) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '200' to be '200'", + }, + { + status: "pass", + message: "Expected 'true' to be 'true'", + }, + ], + }), + ]) + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/data", + undefined + ) + }) + + test("hopp.fetch should make POST request with JSON body", async () => { + const mockFetch: HoppFetchHook = vi.fn(async (_input, _init) => { + return new Response(JSON.stringify({ created: true, id: 42 }), { + status: 201, + headers: { "Content-Type": "application/json" }, + }) + }) + + await expect( + runTest( + ` + const response = await hopp.fetch("https://api.example.com/items", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ name: "test" }) + }) + pw.expect(response.status).toBe(201) + pw.expect(response.ok).toBe(true) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '201' to be '201'", + }, + { + status: "pass", + message: "Expected 'true' to be 'true'", + }, + ], + }), + ]) + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/items", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/json", + }), + body: JSON.stringify({ name: "test" }), + }) + ) + }) + + test("hopp.fetch should handle URL object", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("OK", { status: 200 }) + }) + + await expect( + runTest( + ` + const url = new URL("https://api.example.com/data") + const response = await hopp.fetch(url) + pw.expect(response.status).toBe(200) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '200' to be '200'", + }, + ], + }), + ]) + }) + }) + + describe("Response handling", () => { + test("hopp.fetch should handle text response", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("Plain text response", { + status: 200, + headers: { "Content-Type": "text/plain" }, + }) + }) + + await expect( + runTest( + ` + const response = await hopp.fetch("https://api.example.com/text") + pw.expect(response.status).toBe(200) + pw.expect(response.ok).toBe(true) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '200' to be '200'", + }, + { + status: "pass", + message: "Expected 'true' to be 'true'", + }, + ], + }), + ]) + }) + + test("hopp.fetch should handle response headers", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + const headers = new Headers() + headers.set("X-Custom-Header", "custom-value") + headers.set("Content-Type", "application/json") + + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers, + }) + }) + + await expect( + runTest( + ` + const response = await hopp.fetch("https://api.example.com/data") + pw.expect(response.status).toBe(200) + pw.expect(typeof response.headers).toBe("object") + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '200' to be '200'", + }, + { + status: "pass", + message: "Expected 'object' to be 'object'", + }, + ], + }), + ]) + }) + + test("hopp.fetch should handle status codes", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ error: "Not Found" }), { + status: 404, + statusText: "Not Found", + }) + }) + + await expect( + runTest( + ` + const response = await hopp.fetch("https://api.example.com/missing") + pw.expect(response.status).toBe(404) + pw.expect(response.statusText).toBe("Not Found") + pw.expect(response.ok).toBe(false) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '404' to be '404'", + }, + { + status: "pass", + message: "Expected 'Not Found' to be 'Not Found'", + }, + { + status: "pass", + message: "Expected 'false' to be 'false'", + }, + ], + }), + ]) + }) + }) + + describe("HTTP methods", () => { + test("hopp.fetch should support PUT method", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ updated: true }), { status: 200 }) + }) + + await expect( + runTest( + ` + const response = await hopp.fetch("https://api.example.com/items/1", { + method: "PUT", + body: JSON.stringify({ name: "updated" }) + }) + pw.expect(response.status).toBe(200) + pw.expect(response.ok).toBe(true) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '200' to be '200'", + }, + { + status: "pass", + message: "Expected 'true' to be 'true'", + }, + ], + }), + ]) + }) + + test("hopp.fetch should support DELETE method", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(null, { status: 204 }) + }) + + await expect( + runTest( + ` + const response = await hopp.fetch("https://api.example.com/items/1", { + method: "DELETE" + }) + pw.expect(response.status).toBe(204) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '204' to be '204'", + }, + ], + }), + ]) + }) + + test("hopp.fetch should support PATCH method", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ patched: true }), { status: 200 }) + }) + + await expect( + runTest( + ` + const response = await hopp.fetch("https://api.example.com/items/1", { + method: "PATCH", + body: JSON.stringify({ field: "value" }) + }) + pw.expect(response.status).toBe(200) + pw.expect(response.ok).toBe(true) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '200' to be '200'", + }, + { + status: "pass", + message: "Expected 'true' to be 'true'", + }, + ], + }), + ]) + }) + }) + + describe("Headers", () => { + test("hopp.fetch should send custom headers", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("OK", { status: 200 }) + }) + + await expect( + runTest( + ` + const response = await hopp.fetch("https://api.example.com/data", { + headers: { + "Authorization": "Bearer token123", + "X-API-Key": "key456" + } + }) + pw.expect(response.status).toBe(200) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '200' to be '200'", + }, + ], + }), + ]) + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/data", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer token123", + "X-API-Key": "key456", + }), + }) + ) + }) + }) + + describe("Error handling", () => { + test("hopp.fetch should handle fetch errors", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + throw new Error("Network error") + }) + + await expect( + runTest( + ` + let errorOccurred = false + try { + await hopp.fetch("https://api.example.com/data") + } catch (error) { + errorOccurred = true + pw.expect(error.message).toBe("Network error") + } + pw.expect(errorOccurred).toBe(true) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'Network error' to be 'Network error'", + }, + { + status: "pass", + message: "Expected 'true' to be 'true'", + }, + ], + }), + ]) + }) + }) + + describe("Integration with environment variables", () => { + test("hopp.fetch should work with dynamic URLs from env vars", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ data: "test" }), { status: 200 }) + }) + + await expect( + runTest( + ` + hopp.env.set("API_URL", "https://api.example.com") + const url = hopp.env.get("API_URL") + "/data" + const response = await hopp.fetch(url) + pw.expect(response.status).toBe(200) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '200' to be '200'", + }, + ], + }), + ]) + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/data", + undefined + ) + }) + + test("hopp.fetch should store response data in env vars", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ token: "abc123" }), { + status: 200, + }) + }) + + await expect( + runTest( + ` + const response = await hopp.fetch("https://api.example.com/auth") + pw.expect(response.status).toBe(200) + hopp.env.set("AUTH_TOKEN", "abc123") + pw.expect(hopp.env.get("AUTH_TOKEN")).toBe("abc123") + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '200' to be '200'", + }, + { + status: "pass", + message: "Expected 'abc123' to be 'abc123'", + }, + ], + }), + ]) + }) + }) + + describe("Global fetch() alias", () => { + test("global fetch() should be defined and callable", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("OK", { status: 200 }) + }) + + await expect( + runTest( + ` + pw.expect(typeof fetch).toBe("function") + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'function' to be 'function'", + }, + ], + }), + ]) + }) + + test("global fetch() should work identically to hopp.fetch()", async () => { + const mockFetch: HoppFetchHook = vi.fn(async (_input, _init) => { + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }) + + await expect( + runTest( + ` + const response = await fetch("https://api.example.com/data") + pw.expect(response.status).toBe(200) + pw.expect(response.ok).toBe(true) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '200' to be '200'", + }, + { + status: "pass", + message: "Expected 'true' to be 'true'", + }, + ], + }), + ]) + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/data", + undefined + ) + }) + + test("global fetch() should support POST with body", async () => { + const mockFetch: HoppFetchHook = vi.fn(async (_input, _init) => { + return new Response(JSON.stringify({ created: true }), { + status: 201, + }) + }) + + await expect( + runTest( + ` + const response = await fetch("https://api.example.com/items", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "test" }) + }) + pw.expect(response.status).toBe(201) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '201' to be '201'", + }, + ], + }), + ]) + }) + + test("global fetch() and hopp.fetch() should call the same hook", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("OK", { status: 200 }) + }) + + await expect( + runTest( + ` + await fetch("https://api.example.com/test1") + await hopp.fetch("https://api.example.com/test2") + pw.expect(1).toBe(1) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '1' to be '1'", + }, + ], + }), + ]) + + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + "https://api.example.com/test1", + undefined + ) + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + "https://api.example.com/test2", + undefined + ) + }) + + test("global fetch() should handle response.text() method", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("Hello World", { status: 200 }) + }) + + await expect( + runTest( + ` + const response = await fetch("https://api.example.com/text") + const text = await response.text() + pw.expect(text).toBe("Hello World") + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'Hello World' to be 'Hello World'", + }, + ], + }), + ]) + }) + + test("global fetch() should handle Headers class integration", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("OK", { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }) + + await expect( + runTest( + ` + const headers = new Headers() + headers.set("Authorization", "Bearer token123") + headers.set("Accept", "application/json") + + const response = await fetch("https://api.example.com/data", { + headers: headers + }) + pw.expect(response.status).toBe(200) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '200' to be '200'", + }, + ], + }), + ]) + + // Verify Headers were converted and passed correctly (native Headers lowercases keys) + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/data", + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: "Bearer token123", + accept: "application/json", + }), + }) + ) + }) + + test("global fetch() should work with Request constructor", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("OK", { status: 200 }) + }) + + await expect( + runTest( + ` + const request = new Request("https://api.example.com/data", { + method: "GET", + headers: { "User-Agent": "Test" } + }) + + const response = await fetch(request) + pw.expect(response.status).toBe(200) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '200' to be '200'", + }, + ], + }), + ]) + }) + + test("global fetch() should handle response cloning", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ data: "test" }), { status: 200 }) + }) + + const result = await runTest( + ` + const response = await fetch("https://api.example.com/data") + const cloned = response.clone() + + pw.expect(typeof response.clone).toBe("function") + pw.expect(typeof cloned.json).toBe("function") + pw.expect(response.status).toBe(200) + pw.expect(cloned.status).toBe(200) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + + expect(result).toBeRight() + // For simple GET, init is undefined in our module + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/data", + undefined + ) + }) + + test("global fetch() should handle error responses", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ error: "Not found" }), { + status: 404, + }) + }) + + await expect( + runTest( + ` + const response = await fetch("https://api.example.com/missing") + pw.expect(response.ok).toBe(false) + pw.expect(response.status).toBe(404) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'false' to be 'false'", + }, + { + status: "pass", + message: "Expected '404' to be '404'", + }, + ], + }), + ]) + }) + }) + + describe("Body methods", () => { + test("response.arrayBuffer() returns array of bytes", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("Hello", { status: 200 }) + }) + + await expect( + runTest( + ` + hopp.test("arrayBuffer returns array", async () => { + const response = await hopp.fetch("https://api.example.com/binary") + pw.expect(typeof response.arrayBuffer).toBe("function") + + const buffer = await response.arrayBuffer() + pw.expect(Array.isArray(buffer)).toBe(true) + pw.expect(buffer.length > 0).toBe(true) + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "arrayBuffer returns array", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("response.blob() returns blob object with size and type", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("test data", { status: 200 }) + }) + + await expect( + runTest( + ` + hopp.test("blob returns blob object", async () => { + const response = await hopp.fetch("https://api.example.com/data") + pw.expect(typeof response.blob).toBe("function") + + const blob = await response.blob() + pw.expect(typeof blob).toBe("object") + pw.expect(typeof blob.size).toBe("number") + pw.expect(blob.size > 0).toBe(true) + pw.expect(typeof blob.type).toBe("string") + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "blob returns blob object", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("response.formData() parses form-encoded data", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("name=John&age=30", { status: 200 }) + }) + + await expect( + runTest( + ` + hopp.test("formData parses data", async () => { + const response = await hopp.fetch("https://api.example.com/form") + pw.expect(typeof response.formData).toBe("function") + + const data = await response.formData() + pw.expect(typeof data).toBe("object") + pw.expect(data.name).toBe("John") + pw.expect(data.age).toBe("30") + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "formData parses data", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe("Body consumption tracking", () => { + test("bodyUsed should be false initially and true after consuming", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ data: "test" }), { status: 200 }) + }) + + await expect( + runTest( + ` + hopp.test("bodyUsed tracks consumption", async () => { + const response = await hopp.fetch("https://api.example.com/data") + pw.expect(response.bodyUsed).toBe(false) + + await response.json() + pw.expect(response.bodyUsed).toBe(true) + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "bodyUsed tracks consumption", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("reading body twice should throw error", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("test data", { status: 200 }) + }) + + await expect( + runTest( + ` + hopp.test("cannot read body twice", async () => { + const response = await hopp.fetch("https://api.example.com/data") + + await response.text() + pw.expect(response.bodyUsed).toBe(true) + + try { + await response.text() + pw.expect(true).toBe(false) // Should not reach here + } catch (error) { + pw.expect(error.message).toInclude("Body has already been consumed") + } + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "cannot read body twice", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("different body methods should all consume the body", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("test", { status: 200 }) + }) + + await expect( + runTest( + ` + hopp.test("arrayBuffer consumes body", async () => { + const response = await hopp.fetch("https://api.example.com/data") + await response.arrayBuffer() + pw.expect(response.bodyUsed).toBe(true) + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "arrayBuffer consumes body", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe("Response cloning", () => { + test("response.clone() creates independent copy with separate body consumption", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ value: 42 }), { status: 200 }) + }) + + await expect( + runTest( + ` + hopp.test("clone has independent body", async () => { + try { + const response = await hopp.fetch("https://api.example.com/data") + pw.expect(typeof response.clone).toBe("function") + + const clone = response.clone() + pw.expect(typeof clone).toBe("object") + pw.expect(clone.status).toBe(200) + + // Read original body (use text to avoid JSON parse errors) + const originalText = await response.text() + pw.expect(response.bodyUsed).toBe(true) + pw.expect(clone.bodyUsed).toBe(false) + + // Clone body should still be readable (use text) + const clonedText = await clone.text() + pw.expect(typeof clonedText).toBe("string") + pw.expect(clone.bodyUsed).toBe(true) + } catch (_e) { + // Ensure any exception is recorded as a test failure instead of an execution error + pw.expect(true).toBe(false) + } + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "clone has independent body", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("cloned response should preserve all properties and headers", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ ok: true }), { + status: 201, + statusText: "Created", + headers: { "X-Custom": "value" }, + }) + }) + + await expect( + runTest( + ` + hopp.test("clone preserves properties", async () => { + try { + const response = await hopp.fetch("https://api.example.com/create") + const clone = response.clone() + + pw.expect(clone.status).toBe(201) + pw.expect(clone.statusText).toBe("Created") + pw.expect(clone.ok).toBe(true) + + // Both should have the same body text + const originalText = await response.text() + const clonedText = await clone.text() + pw.expect(originalText).toBe(clonedText) + } catch (_e) { + // Ensure any exception is recorded as a test failure instead of an execution error + pw.expect(true).toBe(false) + } + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "clone preserves properties", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("cloning consumed response should fail", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("test", { status: 200 }) + }) + + await expect( + runTest( + ` + hopp.test("cannot clone consumed response", async () => { + const response = await hopp.fetch("https://api.example.com/data") + + await response.text() + pw.expect(response.bodyUsed).toBe(true) + + const clone = response.clone() + // The clone should have an error marker + pw.expect(clone._error).toBe(true) + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "cannot clone consumed response", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe("Request constructor and cloning", () => { + test("new Request() should create request object with properties", async () => { + await expect( + runTest( + ` + const req = new Request("https://api.example.com/data", { + method: "POST", + headers: { "Content-Type": "application/json" } + }) + + pw.expect(req.url).toBe("https://api.example.com/data") + pw.expect(req.method).toBe("POST") + pw.expect(typeof req.headers).toBe("object") + `, + { global: [], selected: [] }, + undefined, + undefined, + undefined + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: + "Expected 'https://api.example.com/data' to be 'https://api.example.com/data'", + }, + { status: "pass", message: "Expected 'POST' to be 'POST'" }, + { status: "pass", message: "Expected 'object' to be 'object'" }, + ], + }), + ]) + }) + + test("request.clone() should create independent copy", async () => { + await expect( + runTest( + ` + const req1 = new Request("https://api.example.com/data", { method: "POST" }) + const req2 = req1.clone() + + pw.expect(req2.url).toBe(req1.url) + pw.expect(req2.method).toBe(req1.method) + pw.expect(req2.url).toBe("https://api.example.com/data") + `, + { global: [], selected: [] }, + undefined, + undefined, + undefined + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.stringContaining("to be") }, + { status: "pass", message: expect.stringContaining("to be") }, + { + status: "pass", + message: + "Expected 'https://api.example.com/data' to be 'https://api.example.com/data'", + }, + ], + }), + ]) + }) + + test("Request should have bodyUsed property", async () => { + await expect( + runTest( + ` + const req = new Request("https://api.example.com/data") + pw.expect(req.bodyUsed).toBe(false) + `, + { global: [], selected: [] }, + undefined, + undefined, + undefined + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'false' to be 'false'" }, + ], + }), + ]) + }) + }) + + describe("Headers class", () => { + test("new Headers() should create headers object", async () => { + await expect( + runTest( + ` + const headers = new Headers() + headers.set("Content-Type", "application/json") + + pw.expect(headers.get("Content-Type")).toBe("application/json") + pw.expect(headers.has("Content-Type")).toBe(true) + `, + { global: [], selected: [] }, + undefined, + undefined, + undefined + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'application/json' to be 'application/json'", + }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + ]) + }) + + test("Headers.append() should add values", async () => { + await expect( + runTest( + ` + const headers = new Headers() + headers.append("X-Custom", "value1") + headers.append("X-Custom", "value2") + + // Note: Native Headers combines with comma, we just overwrite + pw.expect(headers.has("X-Custom")).toBe(true) + `, + { global: [], selected: [] }, + undefined, + undefined, + undefined + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + ]) + }) + + test("Headers.delete() should remove header", async () => { + await expect( + runTest( + ` + const headers = new Headers({ "X-Custom": "value" }) + pw.expect(headers.has("X-Custom")).toBe(true) + + headers.delete("X-Custom") + pw.expect(headers.has("X-Custom")).toBe(false) + `, + { global: [], selected: [] }, + undefined, + undefined, + undefined + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + ], + }), + ]) + }) + + test("Headers.entries() should return array of [key, value] pairs", async () => { + await expect( + runTest( + ` + const headers = new Headers({ "Content-Type": "application/json", "X-Custom": "test" }) + const entries = Array.from(headers.entries()) + + pw.expect(Array.isArray(entries)).toBe(true) + pw.expect(entries.length).toBe(2) + `, + { global: [], selected: [] }, + undefined, + undefined, + undefined + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected '2' to be '2'" }, + ], + }), + ]) + }) + + test("Headers can be used with fetch()", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("OK", { status: 200 }) + }) + + await expect( + runTest( + ` + const headers = new Headers() + headers.set("Authorization", "Bearer token123") + + const response = await hopp.fetch("https://api.example.com/data", { headers }) + pw.expect(response.status).toBe(200) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected '200' to be '200'" }, + ], + }), + ]) + + // Verify headers were sent (native Headers lowercases keys) + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/data", + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: "Bearer token123", + }), + }) + ) + }) + }) + + describe("AbortController", () => { + test("new AbortController() should create controller with signal", async () => { + await expect( + runTest( + ` + const controller = new AbortController() + + pw.expect(typeof controller).toBe("object") + pw.expect(typeof controller.signal).toBe("object") + pw.expect(controller.signal.aborted).toBe(false) + `, + { global: [], selected: [] }, + undefined, + undefined, + undefined + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'object' to be 'object'" }, + { status: "pass", message: "Expected 'object' to be 'object'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + ], + }), + ]) + }) + + test("controller.abort() should set signal.aborted to true", async () => { + await expect( + runTest( + ` + const controller = new AbortController() + pw.expect(controller.signal.aborted).toBe(false) + + controller.abort() + pw.expect(controller.signal.aborted).toBe(true) + `, + { global: [], selected: [] }, + undefined, + undefined, + undefined + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'false' to be 'false'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + ]) + }) + + test("signal.addEventListener should register abort listener", async () => { + await expect( + runTest( + ` + const controller = new AbortController() + let listenerCalled = false + + controller.signal.addEventListener("abort", () => { + listenerCalled = true + }) + + controller.abort() + pw.expect(listenerCalled).toBe(true) + `, + { global: [], selected: [] }, + undefined, + undefined, + undefined + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + ]) + }) + + test("multiple abort listeners should all be called", async () => { + await expect( + runTest( + ` + const controller = new AbortController() + let count = 0 + + controller.signal.addEventListener("abort", () => { count++ }) + controller.signal.addEventListener("abort", () => { count++ }) + controller.signal.addEventListener("abort", () => { count++ }) + + controller.abort() + pw.expect(count).toBe(3) + `, + { global: [], selected: [] }, + undefined, + undefined, + undefined + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected '3' to be '3'" }, + ], + }), + ]) + }) + }) + + describe("Response constructor", () => { + test("new Response() should create response with properties", async () => { + await expect( + runTest( + ` + const response = new Response("test body", { status: 201, statusText: "Created" }) + + pw.expect(response.status).toBe(201) + pw.expect(response.statusText).toBe("Created") + pw.expect(response.ok).toBe(true) + pw.expect(typeof response.json).toBe("function") + pw.expect(typeof response.text).toBe("function") + `, + { global: [], selected: [] }, + undefined, + undefined, + undefined + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected '201' to be '201'" }, + { status: "pass", message: "Expected 'Created' to be 'Created'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected 'function' to be 'function'" }, + { status: "pass", message: "Expected 'function' to be 'function'" }, + ], + }), + ]) + }) + + test("Response constructor is available globally", async () => { + await expect( + runTest( + ` + pw.expect(typeof Response).toBe("function") + + const resp = new Response("data", { status: 200 }) + pw.expect(resp.status).toBe(200) + `, + { global: [], selected: [] }, + undefined, + undefined, + undefined + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'function' to be 'function'" }, + { status: "pass", message: "Expected '200' to be '200'" }, + ], + }), + ]) + }) + }) + + describe("Edge cases", () => { + test("multiple HTTP status codes should return correct ok status", async () => { + const statuses = [200, 201, 204, 400, 404, 500] + + for (const status of statuses) { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response("data", { status }) + }) + + await expect( + runTest( + ` + const response = await hopp.fetch("https://api.example.com/data") + pw.expect(response.status).toBe(${status}) + pw.expect(response.ok).toBe(${status >= 200 && status < 300}) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toBeRight() + } + }) + + test("empty response body should be handled correctly", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(null, { status: 204 }) + }) + + await expect( + runTest( + ` + hopp.test("empty body handled", async () => { + const response = await hopp.fetch("https://api.example.com/delete") + pw.expect(response.status).toBe(204) + + const text = await response.text() + pw.expect(text).toBe("") + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "empty body handled", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/request.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/request.spec.ts new file mode 100644 index 0000000..0f80053 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/request.spec.ts @@ -0,0 +1,1024 @@ +import { + HoppRESTAuth, + HoppRESTReqBody, + HoppRESTRequest, +} from "@hoppscotch/data" +import { describe, expect, test } from "vitest" + +import { runPreRequestScript, runTestScript } from "~/web" +import { TestResponse } from "~/types" + +const baseRequest: HoppRESTRequest = { + v: "16", + name: "Test Request", + endpoint: "https://example.com/api", + method: "GET", + headers: [{ key: "X-Test", value: "val1", active: true, description: "" }], + params: [{ key: "q", value: "search", active: true, description: "" }], + body: { contentType: null, body: null }, + auth: { authType: "none", authActive: false }, + preRequestScript: "", + testScript: "", + requestVariables: [{ key: "req-var-1", value: "value-1", active: true }], + responses: {}, +} + +const testResponse: TestResponse = { + status: 200, + body: "OK", + headers: [], + statusText: "OK", + responseTime: 200, +} + +describe("hopp.request", () => { + test("hopp.request basic properties are accessible from pre-request script", () => { + const envs = { global: [], selected: [] } + + return expect( + runPreRequestScript( + ` + console.log("URL:", hopp.request.url); + console.log("Method:", hopp.request.method); + console.log("Params:", hopp.request.params); + console.log("Headers:", hopp.request.headers); + console.log("Body:", hopp.request.body); + console.log("Auth:", hopp.request.auth);`, + { + envs, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["URL:", "https://example.com/api"], + }), + expect.objectContaining({ + args: ["Method:", "GET"], + }), + expect.objectContaining({ + args: ["Params:", baseRequest.params], + }), + expect.objectContaining({ + args: ["Headers:", baseRequest.headers], + }), + expect.objectContaining({ + args: ["Body:", baseRequest.body], + }), + expect.objectContaining({ + args: ["Auth:", baseRequest.auth], + }), + ], + }) + ) + }) + + test("hopp.request properties are read-only in both pre-request and test script contexts", () => { + const envs = { global: [], selected: [] } + const response: TestResponse = { + status: 200, + body: "test response", + headers: [], + } + + // Properties that are read-only in both contexts + const basicReadOnlyTests = [ + { property: "url", value: "'https://new-url.com'" }, + { property: "method", value: "'PUT'" }, + { property: "params", value: "[]" }, + { property: "headers", value: "[]" }, + { property: "body", value: "{}" }, + { property: "auth", value: "{}" }, + ] + + // Properties that are read-only only in test script context + const testScriptOnlyReadOnlyTests = [{ property: "variables", value: "{}" }] + + // Test basic properties in pre-request script context + const preRequestTests = basicReadOnlyTests.map(({ property, value }) => + expect( + runPreRequestScript(`hopp.request.${property} = ${value}`, { + envs, + request: baseRequest, + }) + ).resolves.toEqualLeft( + expect.stringContaining( + `Script execution failed: TypeError: hopp.request.${property} is read-only` + ) + ) + ) + + // Test all properties in test script context + const allReadOnlyTests = [ + ...basicReadOnlyTests, + ...testScriptOnlyReadOnlyTests, + ] + const testScriptTests = allReadOnlyTests.map(({ property, value }) => + expect( + runTestScript(`hopp.request.${property} = ${value}`, { + envs, + request: baseRequest, + response, + }) + ).resolves.toEqualLeft( + expect.stringContaining( + `Script execution failed: TypeError: hopp.request.${property} is read-only` + ) + ) + ) + + return Promise.all([...preRequestTests, ...testScriptTests]) + }) + + test("hopp.request.setUrl should update the URL", () => { + return expect( + runPreRequestScript(`hopp.request.setUrl("https://updated.com/api")`, { + envs: { global: [], selected: [] }, + request: baseRequest, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://updated.com/api", + }), + }) + ) + }) + + test("hopp.request.setMethod should update the method (case preserved)", () => { + return expect( + runPreRequestScript(`hopp.request.setMethod("post")`, { + envs: { global: [], selected: [] }, + request: baseRequest, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + method: "post", + }), + }) + ) + }) + + test("hopp.request.setHeader should update existing header case-insensitively", () => { + return expect( + runPreRequestScript(`hopp.request.setHeader("x-test", "updatedVal")`, { + envs: { global: [], selected: [] }, + request: baseRequest, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: [ + expect.objectContaining({ + key: "X-Test", + value: "updatedVal", + }), + ], + }), + }) + ) + }) + + test("hopp.request.setHeader should add new header if not present", () => { + return expect( + runPreRequestScript( + `hopp.request.setHeader("X-New-Header", "newValue")`, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.arrayContaining([ + expect.objectContaining({ key: "X-New-Header", value: "newValue" }), + ]), + }), + }) + ) + }) + + test("hopp.request.removeHeader should remove a header", () => { + return expect( + runPreRequestScript(`hopp.request.removeHeader("X-Test")`, { + envs: { global: [], selected: [] }, + request: baseRequest, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: [], + }), + }) + ) + }) + + test("hopp.request.setParam should update existing param case-insensitively", () => { + return expect( + runPreRequestScript(`hopp.request.setParam("Q", "updatedParam")`, { + envs: { global: [], selected: [] }, + request: baseRequest, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + params: [ + expect.objectContaining({ key: "q", value: "updatedParam" }), + ], + }), + }) + ) + }) + + test("hopp.request.setParam should add new param if absent", () => { + return expect( + runPreRequestScript(`hopp.request.setParam("newParam", "value")`, { + envs: { global: [], selected: [] }, + request: baseRequest, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + params: expect.arrayContaining([ + expect.objectContaining({ key: "newParam", value: "value" }), + ]), + }), + }) + ) + }) + + test("hopp.request.removeParam should remove a param", () => { + return expect( + runPreRequestScript(`hopp.request.removeParam("q")`, { + envs: { global: [], selected: [] }, + request: baseRequest, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + params: [], + }), + }) + ) + }) + + test("hopp.request.setBody should update the body with complete replacement", () => { + const newBody: HoppRESTReqBody = { + contentType: "application/json", + body: JSON.stringify({ changed: true }), + } + return expect( + runPreRequestScript(`hopp.request.setBody(${JSON.stringify(newBody)})`, { + envs: { global: [], selected: [] }, + request: baseRequest, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + body: newBody, + }), + }) + ) + }) + + test("hopp.request.setBody should support partial merge", () => { + // Base request with existing JSON body + const requestWithBody: HoppRESTRequest = { + ...baseRequest, + body: { + contentType: "application/json", + body: JSON.stringify({ existing: "data", keep: true }), + }, + } + + // Script that only updates contentType, preserving body content + const partialUpdate = { contentType: "application/xml" } + + return expect( + runPreRequestScript( + `hopp.request.setBody(${JSON.stringify(partialUpdate)})`, + { + envs: { global: [], selected: [] }, + request: requestWithBody, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + body: { + contentType: "application/xml", + body: JSON.stringify({ existing: "data", keep: true }), + }, + }), + }) + ) + }) + + test("hopp.request.setAuth should update the auth with complete replacement", () => { + const newAuth: HoppRESTAuth = { + authType: "basic", + username: "abc", + password: "123", + authActive: true, + } + + return expect( + runPreRequestScript(`hopp.request.setAuth(${JSON.stringify(newAuth)})`, { + envs: { global: [], selected: [] }, + request: baseRequest, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + auth: newAuth, + }), + }) + ) + }) + + test("hopp.request.setAuth should support partial merge", () => { + // Base request with existing basic auth + const requestWithAuth: HoppRESTRequest = { + ...baseRequest, + auth: { + authType: "basic", + username: "original-user", + password: "original-pass", + authActive: true, + }, + } + + // Script that only updates the username, preserving other fields + const partialUpdate = { username: "updated-user" } + + return expect( + runPreRequestScript( + `hopp.request.setAuth(${JSON.stringify(partialUpdate)})`, + { + envs: { global: [], selected: [] }, + request: requestWithAuth, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + auth: { + authType: "basic", + username: "updated-user", + password: "original-pass", + authActive: true, + }, + }), + }) + ) + }) + + test("hopp.request.setAuth should handle auth type switching", () => { + // Base request with bearer auth + const requestWithBearerAuth: HoppRESTRequest = { + ...baseRequest, + auth: { + authType: "bearer", + token: "old-bearer-token", + authActive: true, + }, + } + + // Switch to basic auth (complete replacement) + const switchToBasic: HoppRESTAuth = { + authType: "basic", + username: "new-user", + password: "new-pass", + authActive: true, + } + + return expect( + runPreRequestScript( + `hopp.request.setAuth(${JSON.stringify(switchToBasic)})`, + { + envs: { global: [], selected: [] }, + request: requestWithBearerAuth, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + auth: switchToBasic, + }), + }) + ) + }) + + test("hopp.request.setHeaders throws error on invalid input", () => { + return expect( + runPreRequestScript(`hopp.request.setHeaders(null)`, { + envs: { global: [], selected: [] }, + request: baseRequest, + }) + ).resolves.toBeLeft() + }) + + test("hopp.request.setParams throws error on invalid input", () => { + return expect( + runPreRequestScript(`hopp.request.setParams(null)`, { + envs: { global: [], selected: [] }, + request: baseRequest, + }) + ).resolves.toBeLeft() + }) + + test("hopp.request.setBody throws error on invalid input", () => { + return expect( + runPreRequestScript(`hopp.request.setBody("invalid_body")`, { + envs: { global: [], selected: [] }, + request: baseRequest, + }) + ).resolves.toBeLeft() + }) + + test("hopp.request.setAuth throws error on invalid input", () => { + return expect( + runPreRequestScript(`hopp.request.setAuth({})`, { + envs: { global: [], selected: [] }, + request: baseRequest, + }) + ).resolves.toBeLeft() + }) + + test("hopp.request.variables.get should return the request variable", () => { + return expect( + runPreRequestScript( + `console.log(hopp.request.variables.get("req-var-1"))`, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [expect.objectContaining({ args: ["value-1"] })], + }) + ) + }) + + test("hopp.request.variables.set should update the request variable", () => { + return expect( + runPreRequestScript( + `hopp.request.variables.set("req-var-1", "new-value-1")`, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + requestVariables: [ + { + key: "req-var-1", + value: "new-value-1", + active: true, + }, + ], + }), + }) + ) + }) + + test("hopp.request.variables.set should add a new request variable if the supplied key does not exist", () => { + return expect( + runPreRequestScript( + `hopp.request.variables.set("req-var-2", "value-2")`, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + requestVariables: [ + { + key: "req-var-1", + value: "value-1", + active: true, + }, + { + key: "req-var-2", + value: "value-2", + active: true, + }, + ], + }), + }) + ) + }) + + test("hopp.request.variables.set should not work in post-request script context", () => { + const envs = { global: [], selected: [] } + + return expect( + runTestScript( + `hopp.request.variables.set("req-var-1", "new-value-from-test")`, + { + envs, + request: baseRequest, + response: testResponse, + } + ) + ).resolves.toEqualLeft( + expect.stringContaining( + `Script execution failed: TypeError: not a function` + ) + ) + }) + + test("hopp.request read-only properties are accessible from post-request script", () => { + const envs = { global: [], selected: [] } + + const testRequest: HoppRESTRequest = { + ...baseRequest, + method: "POST", + endpoint: "https://api.example.com/users", + params: [{ key: "page", value: "1", active: true, description: "" }], + headers: [ + { + key: "Authorization", + value: "Bearer token123", + active: true, + description: "", + }, + { + key: "Content-Type", + value: "application/json", + active: true, + description: "", + }, + ], + body: { + contentType: "application/json", + body: JSON.stringify({ name: "John", age: 30 }), + }, + auth: { + authType: "bearer", + authActive: true, + token: "test-token-123", + }, + } + + return expect( + runTestScript( + ` + hopp.expect(hopp.request.url).toBe("https://api.example.com/users") + hopp.expect(hopp.request.method).toBe("POST") + hopp.expect(hopp.request.params.length).toBe(1) + hopp.expect(hopp.request.params[0].key).toBe("page") + hopp.expect(hopp.request.params[0].value).toBe("1") + hopp.expect(hopp.request.headers.length).toBe(2) + hopp.expect(hopp.request.headers[0].key).toBe("Authorization") + hopp.expect(hopp.request.headers[0].value).toBe("Bearer token123") + hopp.expect(hopp.request.headers[1].key).toBe("Content-Type") + hopp.expect(hopp.request.headers[1].value).toBe("application/json") + hopp.expect(hopp.request.body.contentType).toBe("application/json") + hopp.expect(hopp.request.body.body).toBe('{"name":"John","age":30}') + hopp.expect(hopp.request.auth.authType).toBe("bearer") + hopp.expect(hopp.request.auth.token).toBe("test-token-123") + `, + { + envs, + request: testRequest, + response: testResponse, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: + "Expected 'https://api.example.com/users' to be 'https://api.example.com/users'", + }, + { status: "pass", message: "Expected 'POST' to be 'POST'" }, + { status: "pass", message: "Expected '1' to be '1'" }, + { status: "pass", message: "Expected 'page' to be 'page'" }, + { status: "pass", message: "Expected '1' to be '1'" }, + { status: "pass", message: "Expected '2' to be '2'" }, + { + status: "pass", + message: "Expected 'Authorization' to be 'Authorization'", + }, + { + status: "pass", + message: "Expected 'Bearer token123' to be 'Bearer token123'", + }, + { + status: "pass", + message: "Expected 'Content-Type' to be 'Content-Type'", + }, + { + status: "pass", + message: "Expected 'application/json' to be 'application/json'", + }, + { + status: "pass", + message: "Expected 'application/json' to be 'application/json'", + }, + { + status: "pass", + message: + 'Expected \'{"name":"John","age":30}\' to be \'{"name":"John","age":30}\'', + }, + { status: "pass", message: "Expected 'bearer' to be 'bearer'" }, + { + status: "pass", + message: "Expected 'test-token-123' to be 'test-token-123'", + }, + ], + }), + }) + ) + }) + + test("hopp.request setter methods should not be available in post-request", async () => { + const script = ` + hopp.request.setUrl("http://modified.com") + ` + + await expect( + runTestScript(script, { + envs: { global: [], selected: [] }, + request: baseRequest, + response: testResponse, + }) + ).resolves.toEqualLeft(expect.stringContaining("not a function")) + }) + + test("hopp.request.setHeader should not be available in post-request", async () => { + const script = ` + hopp.request.setHeader("X-Test", "value") + ` + + await expect( + runTestScript(script, { + envs: { global: [], selected: [] }, + request: baseRequest, + response: testResponse, + }) + ).resolves.toEqualLeft(expect.stringContaining("not a function")) + }) + + // Request property immutability tests in post-request context + describe("property immutability in post-request context", () => { + test("hopp.request.url should be read-only", async () => { + const script = ` + hopp.request.url = "http://modified.com" + ` + + await expect( + runTestScript(script, { + envs: { global: [], selected: [] }, + request: baseRequest, + response: testResponse, + }) + ).resolves.toEqualLeft(expect.stringContaining("read-only")) + }) + + test("hopp.request.method should be read-only", async () => { + const script = ` + hopp.request.method = "POST" + ` + + await expect( + runTestScript(script, { + envs: { global: [], selected: [] }, + request: baseRequest, + response: testResponse, + }) + ).resolves.toEqualLeft(expect.stringContaining("read-only")) + }) + + test("hopp.request.headers should be read-only", async () => { + const script = ` + hopp.request.headers = {} + ` + + await expect( + runTestScript(script, { + envs: { global: [], selected: [] }, + request: baseRequest, + response: testResponse, + }) + ).resolves.toEqualLeft(expect.stringContaining("read-only")) + }) + + test("hopp.request.body should be read-only", async () => { + const script = ` + hopp.request.body = "modified" + ` + + await expect( + runTestScript(script, { + envs: { global: [], selected: [] }, + request: baseRequest, + response: testResponse, + }) + ).resolves.toEqualLeft(expect.stringContaining("read-only")) + }) + }) + + describe("setter methods immediately reflect in console.log", () => { + test("setUrl should reflect immediately in hopp.request.url", () => { + return expect( + runPreRequestScript( + ` + console.log("Before:", hopp.request.url) + hopp.request.setUrl("https://updated.com/api") + console.log("After:", hopp.request.url) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before:", "https://example.com/api"], + }), + expect.objectContaining({ + args: ["After:", "https://updated.com/api"], + }), + ], + updatedRequest: expect.objectContaining({ + endpoint: "https://updated.com/api", + }), + }) + ) + }) + + test("setMethod should reflect immediately in hopp.request.method", () => { + return expect( + runPreRequestScript( + ` + console.log("Before:", hopp.request.method) + hopp.request.setMethod("POST") + console.log("After:", hopp.request.method) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before:", "GET"], + }), + expect.objectContaining({ + args: ["After:", "POST"], + }), + ], + updatedRequest: expect.objectContaining({ + method: "POST", + }), + }) + ) + }) + + test("setHeader should reflect immediately in hopp.request.headers", () => { + return expect( + runPreRequestScript( + ` + const before = hopp.request.headers.find(h => h.key === "X-Test") + console.log("Before value:", before.value) + hopp.request.setHeader("X-Test", "modified") + const after = hopp.request.headers.find(h => h.key === "X-Test") + console.log("After value:", after.value) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before value:", "val1"], + }), + expect.objectContaining({ + args: ["After value:", "modified"], + }), + ], + }) + ) + }) + + test("setHeaders should reflect immediately in hopp.request.headers", () => { + return expect( + runPreRequestScript( + ` + console.log("Before length:", hopp.request.headers.length) + hopp.request.setHeaders([ + { key: "X-New-1", value: "val1", active: true, description: "" }, + { key: "X-New-2", value: "val2", active: true, description: "" } + ]) + console.log("After length:", hopp.request.headers.length) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before length:", 1], + }), + expect.objectContaining({ + args: ["After length:", 2], + }), + ], + }) + ) + }) + + test("removeHeader should reflect immediately in hopp.request.headers", () => { + return expect( + runPreRequestScript( + ` + console.log("Before:", hopp.request.headers.map(h => h.key)) + hopp.request.removeHeader("X-Test") + console.log("After:", hopp.request.headers.map(h => h.key)) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before:", ["X-Test"]], + }), + expect.objectContaining({ + args: ["After:", []], + }), + ], + }) + ) + }) + + test("setParam should reflect immediately in hopp.request.params", () => { + return expect( + runPreRequestScript( + ` + const before = hopp.request.params.find(p => p.key === "q") + console.log("Before value:", before.value) + hopp.request.setParam("q", "updated") + const after = hopp.request.params.find(p => p.key === "q") + console.log("After value:", after.value) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before value:", "search"], + }), + expect.objectContaining({ + args: ["After value:", "updated"], + }), + ], + }) + ) + }) + + test("setParams should reflect immediately in hopp.request.params", () => { + return expect( + runPreRequestScript( + ` + console.log("Before length:", hopp.request.params.length) + hopp.request.setParams([ + { key: "page", value: "1", active: true, description: "" }, + { key: "limit", value: "10", active: true, description: "" } + ]) + console.log("After length:", hopp.request.params.length) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before length:", 1], + }), + expect.objectContaining({ + args: ["After length:", 2], + }), + ], + }) + ) + }) + + test("removeParam should reflect immediately in hopp.request.params", () => { + return expect( + runPreRequestScript( + ` + console.log("Before:", hopp.request.params.map(p => p.key)) + hopp.request.removeParam("q") + console.log("After:", hopp.request.params.map(p => p.key)) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before:", ["q"]], + }), + expect.objectContaining({ + args: ["After:", []], + }), + ], + }) + ) + }) + + test("setBody should reflect immediately in hopp.request.body", () => { + return expect( + runPreRequestScript( + ` + console.log("Before:", hopp.request.body.contentType) + hopp.request.setBody({ + contentType: "application/json", + body: '{"test": true}' + }) + console.log("After:", hopp.request.body.contentType) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before:", null], + }), + expect.objectContaining({ + args: ["After:", "application/json"], + }), + ], + }) + ) + }) + + test("setAuth should reflect immediately in hopp.request.auth", () => { + return expect( + runPreRequestScript( + ` + console.log("Before:", hopp.request.auth.authType) + hopp.request.setAuth({ authType: "bearer", token: "test-token" }) + console.log("After:", hopp.request.auth.authType) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before:", "none"], + }), + expect.objectContaining({ + args: ["After:", "bearer"], + }), + ], + }) + ) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/response.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/response.spec.ts new file mode 100644 index 0000000..4afca3f --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/response.spec.ts @@ -0,0 +1,609 @@ +import { getDefaultRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" +import { runTestScript } from "~/web" +import { TestResponse } from "~/types" + +const defaultRequest = getDefaultRESTRequest() + +const sampleHeaders = [ + { key: "content-type", value: "application/json" }, + { key: "x-custom", value: "hello" }, +] + +const sampleJSONResponse: TestResponse = { + status: 201, + body: { ok: true, msg: "success" }, + headers: sampleHeaders, + statusText: "Created", + responseTime: 123, +} + +const sampleTextResponse: TestResponse = { + status: 200, + body: "Plaintext response", + headers: [{ key: "content-type", value: "text/plain" }], + statusText: "OK", + responseTime: 240, +} + +describe("hopp.response", () => { + test("hopp.response.statusCode should return the status", async () => { + await expect( + runTestScript(`hopp.expect(hopp.response.statusCode).toBe(201)`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleJSONResponse, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [], + envs: expect.objectContaining({ + global: [], + selected: [], + }), + tests: expect.objectContaining({ + children: [], + descriptor: "root", + expectResults: [ + { status: "pass", message: "Expected '201' to be '201'" }, + ], + }), + updatedCookies: null, + }) + ) + }) + + test("hopp.response.statusText should return the status text", async () => { + await expect( + runTestScript(`hopp.expect(hopp.response.statusText).toBe("Created")`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleJSONResponse, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'Created' to be 'Created'" }, + ], + }), + }) + ) + }) + + test("hopp.response.responseTime should return the response time", async () => { + await expect( + runTestScript(`hopp.expect(hopp.response.responseTime).toBe(123)`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleJSONResponse, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [], + envs: expect.objectContaining({ + global: [], + selected: [], + }), + tests: expect.objectContaining({ + children: [], + descriptor: "root", + expectResults: [ + { status: "pass", message: "Expected '123' to be '123'" }, + ], + }), + updatedCookies: null, + }) + ) + }) + + test("hopp.response.headers should return all headers", async () => { + await expect( + runTestScript( + ` + hopp.expect(hopp.response.headers.length).toBe(2) + hopp.expect(hopp.response.headers[0].key).toBe("content-type") + hopp.expect(hopp.response.headers[0].value).toBe("application/json") + hopp.expect(hopp.response.headers[1].key).toBe("x-custom") + hopp.expect(hopp.response.headers[1].value).toBe("hello") + `, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleJSONResponse, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [], + envs: expect.objectContaining({ + global: [], + selected: [], + }), + tests: expect.objectContaining({ + children: [], + descriptor: "root", + expectResults: [ + { status: "pass", message: "Expected '2' to be '2'" }, + { + status: "pass", + message: "Expected 'content-type' to be 'content-type'", + }, + { + status: "pass", + message: "Expected 'application/json' to be 'application/json'", + }, + { status: "pass", message: "Expected 'x-custom' to be 'x-custom'" }, + { status: "pass", message: "Expected 'hello' to be 'hello'" }, + ], + }), + updatedCookies: null, + }) + ) + }) + + test("hopp.response.body.asText returns response text", async () => { + await expect( + runTestScript( + `hopp.expect(hopp.response.body.asText()).toBe("Plaintext response")`, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleTextResponse, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: + "Expected 'Plaintext response' to be 'Plaintext response'", + }, + ], + }), + }) + ) + }) + + test("hopp.response.body.asJSON returns parsed object for JSON response", async () => { + await expect( + runTestScript( + ` + const obj = hopp.response.body.asJSON() + hopp.expect(obj.ok).toBe(true) + hopp.expect(obj.msg).toBe("success") + `, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleJSONResponse, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'true' to be 'true'", + }, + { + status: "pass", + message: "Expected 'success' to be 'success'", + }, + ], + }), + }) + ) + }) + + test("hopp.response.body.asJSON throws for invalid JSON", async () => { + await expect( + runTestScript(`hopp.response.body.asJSON()`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleTextResponse, // text, not JSON + }) + ).resolves.toBeLeft() + }) + + test("hopp.response.body.asJSON throws error for invalid JSON body", async () => { + await expect( + runTestScript(`hopp.response.body.asJSON()`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: { + ...sampleJSONResponse, + body: "not a json!", + headers: [{ key: "content-type", value: "application/json" }], + }, + }) + ).resolves.toBeLeft() + }) + + test("hopp.response.body.bytes returns UTF-8 encoded data for JSON body", async () => { + await expect( + runTestScript( + ` + const obj = hopp.response.body.bytes() + hopp.expect(obj["0"]).toBe(123) + hopp.expect(obj["1"]).toBe(34) + hopp.expect(obj["2"]).toBe(111) + hopp.expect(obj["26"]).toBe(125) + `, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleJSONResponse, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '123' to be '123'", + }, + { + status: "pass", + message: "Expected '34' to be '34'", + }, + { + status: "pass", + message: "Expected '111' to be '111'", + }, + { + status: "pass", + message: "Expected '125' to be '125'", + }, + ], + }), + }) + ) + }) + + test("hopp.response.body.bytes throws error for unsupported body type", async () => { + await expect( + runTestScript(`hopp.response.body.bytes()`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: { ...sampleTextResponse, body: 1234 as any }, + }) + ).resolves.toBeLeft() + }) + + test("hopp.response.bytes returns UTF-8 encoded data for plain text", async () => { + await expect( + runTestScript( + ` + const bytes = hopp.response.body.bytes() + hopp.expect(bytes["0"]).toBe(80) + hopp.expect(bytes["1"]).toBe(108) + hopp.expect(bytes["17"]).toBe(101) + `, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleTextResponse, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '80' to be '80'", + }, + { + status: "pass", + message: "Expected '108' to be '108'", + }, + { + status: "pass", + message: "Expected '101' to be '101'", + }, + ], + }), + }) + ) + }) + + test("hopp.response.bytes returns empty array for null/undefined body", async () => { + await expect( + runTestScript( + ` + const bytes = hopp.response.body.bytes() + const bytesArray = Array.from(bytes) + hopp.expect(bytesArray.length).toBe(0) + `, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: { ...sampleTextResponse, body: null }, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '0' to be '0'", + }, + ], + }), + }) + ) + }) + + // Response property immutability tests + describe("property immutability", () => { + const baseResponse: TestResponse = { + status: 200, + body: "OK", + headers: [], + statusText: "OK", + responseTime: 200, + } + + test("hopp.response.statusCode should be read-only", async () => { + const script = ` + hopp.response.statusCode = 500 + ` + + await expect( + runTestScript(script, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: baseResponse, + }) + ).resolves.toEqualLeft(expect.stringContaining("read-only")) + }) + + test("hopp.response.statusText should be read-only", async () => { + const script = ` + hopp.response.statusText = "Modified" + ` + + await expect( + runTestScript(script, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: baseResponse, + }) + ).resolves.toEqualLeft(expect.stringContaining("read-only")) + }) + + test("hopp.response.headers should be read-only", async () => { + const script = ` + hopp.response.headers = {} + ` + + await expect( + runTestScript(script, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: baseResponse, + }) + ).resolves.toEqualLeft(expect.stringContaining("read-only")) + }) + + test("hopp.response.responseTime should be read-only", async () => { + const script = ` + hopp.response.responseTime = 1000 + ` + + await expect( + runTestScript(script, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: baseResponse, + }) + ).resolves.toEqualLeft(expect.stringContaining("read-only")) + }) + + test("hopp.response.body should be read-only", async () => { + const script = ` + hopp.response.body = null + ` + + await expect( + runTestScript(script, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: baseResponse, + }) + ).resolves.toEqualLeft(expect.stringContaining("read-only")) + }) + + test("hopp.response.body.asJSON should be read-only", async () => { + const script = ` + hopp.response.body.asJSON = null + ` + + await expect( + runTestScript(script, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: baseResponse, + }) + ).resolves.toEqualLeft(expect.stringContaining("read-only")) + }) + + test("hopp.response.body.asText should be read-only", async () => { + const script = ` + hopp.response.body.asText = null + ` + + await expect( + runTestScript(script, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: baseResponse, + }) + ).resolves.toEqualLeft(expect.stringContaining("read-only")) + }) + + test("hopp.response.body.bytes should be read-only", async () => { + const script = ` + hopp.response.body.bytes = null + ` + + await expect( + runTestScript(script, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: baseResponse, + }) + ).resolves.toEqualLeft(expect.stringContaining("read-only")) + }) + }) + + describe("hopp.response utility methods", () => { + test("hopp.response.text() should return response as text", async () => { + await expect( + runTestScript( + `hopp.expect(hopp.response.text()).toBe("Plaintext response")`, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleTextResponse, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: + "Expected 'Plaintext response' to be 'Plaintext response'", + }, + ], + }), + }) + ) + }) + + test("hopp.response.json() should parse JSON response", async () => { + await expect( + runTestScript(`hopp.expect(hopp.response.json().ok).toBe(true)`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleJSONResponse, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + }) + ) + }) + + test("hopp.response.reason() should return HTTP reason phrase", async () => { + await expect( + runTestScript(`hopp.expect(hopp.response.reason()).toBe("OK")`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleTextResponse, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'OK' to be 'OK'" }, + ], + }), + }) + ) + }) + + test("hopp.response.dataURI() should convert response to data URI", async () => { + await expect( + runTestScript( + ` + const dataURI = hopp.response.dataURI() + hopp.expect(dataURI).toBeType("string") + hopp.expect(dataURI.startsWith("data:")).toBe(true) + `, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleJSONResponse, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + }) + ) + }) + + test("hopp.response.jsonp() should parse JSONP response", async () => { + const jsonpResponse: TestResponse = { + status: 200, + body: 'callback({"data": "test"})', + headers: [{ key: "Content-Type", value: "application/javascript" }], + statusText: "OK", + responseTime: 100, + } + + await expect( + runTestScript( + `hopp.expect(hopp.response.jsonp("callback").data).toBe("test")`, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: jsonpResponse, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'test' to be 'test'" }, + ], + }), + }) + ) + }) + + test("hopp.response.jsonp() should handle plain JSON without callback", async () => { + const plainJSONResponse: TestResponse = { + status: 200, + body: '{"plain": "json"}', + headers: [{ key: "Content-Type", value: "application/json" }], + statusText: "OK", + responseTime: 100, + } + + await expect( + runTestScript(`hopp.expect(hopp.response.jsonp().plain).toBe("json")`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: plainJSONResponse, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'json' to be 'json'" }, + ], + }), + }) + ) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/advanced-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/advanced-assertions.spec.ts new file mode 100644 index 0000000..d0de2e8 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/advanced-assertions.spec.ts @@ -0,0 +1,896 @@ +import { describe, expect, test } from "vitest" +import { TestResponse } from "~/types" +import { runTest } from "~/utils/test-helpers" + +describe("`pm.response.to.have.jsonSchema` - JSON Schema Validation", () => { + test("should validate simple type schema", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ name: "John", age: 30 }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Response matches schema", function() { + const schema = { + type: "object", + required: ["name", "age"], + properties: { + name: { type: "string" }, + age: { type: "number" } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Response matches schema", + expectResults: [ + { + status: "pass", + message: "Response body matches JSON schema", + }, + ], + }), + ], + }), + ]) + }) + + test("should validate nested object schema", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ + user: { + id: 123, + profile: { + name: "John", + email: "john@example.com", + }, + }, + }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Nested schema validation", function() { + const schema = { + type: "object", + required: ["user"], + properties: { + user: { + type: "object", + required: ["id", "profile"], + properties: { + id: { type: "number" }, + profile: { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" } + } + } + } + } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Nested schema validation", + expectResults: [ + { + status: "pass", + message: "Response body matches JSON schema", + }, + ], + }), + ], + }), + ]) + }) + + test("should validate array schema with items", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify([ + { id: 1, name: "Item 1" }, + { id: 2, name: "Item 2" }, + ]), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Array schema validation", function() { + const schema = { + type: "array", + items: { + type: "object", + required: ["id", "name"], + properties: { + id: { type: "number" }, + name: { type: "string" } + } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Array schema validation", + expectResults: [ + { + status: "pass", + message: "Response body matches JSON schema", + }, + ], + }), + ], + }), + ]) + }) + + test("should validate enum constraints", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ status: "active", role: "admin" }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Enum validation", function() { + const schema = { + type: "object", + properties: { + status: { enum: ["active", "inactive", "pending"] }, + role: { enum: ["admin", "user", "guest"] } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Enum validation", + expectResults: [ + { + status: "pass", + message: "Response body matches JSON schema", + }, + ], + }), + ], + }), + ]) + }) + + test("should validate number constraints (min/max)", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ age: 25, score: 85 }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Number constraints", function() { + const schema = { + type: "object", + properties: { + age: { type: "number", minimum: 0, maximum: 120 }, + score: { type: "number", minimum: 0, maximum: 100 } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Number constraints", + expectResults: [ + { + status: "pass", + message: "Response body matches JSON schema", + }, + ], + }), + ], + }), + ]) + }) + + test("should validate string constraints (length, pattern)", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ + username: "john123", + email: "john@example.com", + }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("String constraints", function() { + const schema = { + type: "object", + properties: { + username: { type: "string", minLength: 3, maxLength: 20 }, + email: { type: "string", pattern: "^[^@]+@[^@]+\\\\.[^@]+$" } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "String constraints", + expectResults: [ + { + status: "pass", + message: "Response body matches JSON schema", + }, + ], + }), + ], + }), + ]) + }) + + test("should validate array length constraints", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ + items: [1, 2, 3], + tags: ["tag1", "tag2"], + }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Array length constraints", function() { + const schema = { + type: "object", + properties: { + items: { type: "array", minItems: 1, maxItems: 10 }, + tags: { type: "array", minItems: 1 } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Array length constraints", + expectResults: [ + { + status: "pass", + message: "Response body matches JSON schema", + }, + ], + }), + ], + }), + ]) + }) + + test("should record failed assertion when required property is missing", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ name: "John" }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Missing required property", function() { + const schema = { + type: "object", + required: ["name", "age"], + properties: { + name: { type: "string" }, + age: { type: "number" } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Missing required property", + expectResults: [ + { + status: "fail", + message: expect.stringContaining( + "Required property 'age' is missing" + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should record failed assertion when type doesn't match", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ age: "thirty" }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Wrong type", function() { + const schema = { + type: "object", + properties: { + age: { type: "number" } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Wrong type", + expectResults: [ + { + status: "fail", + message: expect.stringContaining( + "Expected type number, got string" + ), + }, + ], + }), + ], + }), + ]) + }) +}) + +describe("`pm.response.to.have.charset` - Charset Assertions", () => { + test("should assert UTF-8 charset", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "Hello World", + headers: [{ key: "Content-Type", value: "text/html; charset=utf-8" }], + } + + return expect( + runTest( + ` + pm.test("Response has UTF-8 charset", function() { + pm.response.to.have.charset("utf-8") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Response has UTF-8 charset", + expectResults: [ + { + status: "pass", + message: expect.stringContaining( + "Expected 'utf-8' to equal 'utf-8'" + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should assert ISO-8859-1 charset", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "Hello World", + headers: [ + { key: "Content-Type", value: "text/plain; charset=ISO-8859-1" }, + ], + } + + return expect( + runTest( + ` + pm.test("Response has ISO-8859-1 charset", function() { + pm.response.to.have.charset("iso-8859-1") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Response has ISO-8859-1 charset", + expectResults: [ + { + status: "pass", + message: expect.stringContaining( + "Expected 'iso-8859-1' to equal 'iso-8859-1'" + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should handle charset case-insensitively", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [ + { key: "Content-Type", value: "application/json; charset=UTF-8" }, + ], + } + + return expect( + runTest( + ` + pm.test("Charset is case-insensitive", function() { + pm.response.to.have.charset("utf-8") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Charset is case-insensitive", + expectResults: [ + { + status: "pass", + message: expect.stringContaining( + "Expected 'utf-8' to equal 'utf-8'" + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should fail when charset doesn't match", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "Hello", + headers: [{ key: "Content-Type", value: "text/html; charset=utf-8" }], + } + + return expect( + runTest( + ` + pm.test("Wrong charset fails", function() { + pm.response.to.have.charset("iso-8859-1") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Wrong charset fails", + expectResults: [ + { + status: "fail", + message: expect.stringContaining( + "Expected 'utf-8' to equal 'iso-8859-1'" + ), + }, + ], + }), + ], + }), + ]) + }) +}) + +describe("`pm.response.to.have.jsonPath` - JSONPath Queries", () => { + test("should query simple property", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ name: "John", age: 30 }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Query simple property", function() { + pm.response.to.have.jsonPath("$.name", "John") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Query simple property", + expectResults: [ + { + status: "pass", + message: expect.stringContaining( + "Expected 'John' to deep equal 'John'" + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should query nested property", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ + user: { + profile: { + name: "John Doe", + age: 30, + }, + }, + }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Query nested property", function() { + pm.response.to.have.jsonPath("$.user.profile.name", "John Doe") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Query nested property", + expectResults: [ + { + status: "pass", + message: expect.stringContaining( + "Expected 'John Doe' to deep equal 'John Doe'" + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should query array element by index", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ + items: [ + { id: 1, name: "Item 1" }, + { id: 2, name: "Item 2" }, + ], + }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Query array element", function() { + pm.response.to.have.jsonPath("$.items[0].name", "Item 1") + pm.response.to.have.jsonPath("$.items[1].id", 2) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Query array element", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should query without expected value (existence check)", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ user: { id: 123, name: "John" } }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Check property exists", function() { + pm.response.to.have.jsonPath("$.user.id") + pm.response.to.have.jsonPath("$.user.name") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Check property exists", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should handle root path", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ name: "John", age: 30 }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Query root", function() { + const root = pm.response.json() + pm.response.to.have.jsonPath("$", root) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Query root", + expectResults: [ + { + status: "pass", + message: expect.stringContaining("deep equal"), + }, + ], + }), + ], + }), + ]) + }) + + test("should fail when path doesn't exist", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ name: "John" }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Non-existent path fails", function() { + pm.response.to.have.jsonPath("$.nonexistent") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Non-existent path fails", + expectResults: [ + { + status: "fail", + message: expect.stringContaining( + "Property 'nonexistent' not found" + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should fail when array index is out of bounds", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ items: [1, 2, 3] }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Out of bounds index fails", function() { + pm.response.to.have.jsonPath("$.items[10]") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Out of bounds index fails", + expectResults: [ + { + status: "fail", + message: expect.stringContaining("out of bounds"), + }, + ], + }), + ], + }), + ]) + }) + + test("should fail when value doesn't match", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ name: "John" }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Wrong value fails", function() { + pm.response.to.have.jsonPath("$.name", "Jane") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Wrong value fails", + expectResults: [ + { + status: "fail", + message: expect.stringContaining( + "Expected 'John' to deep equal 'Jane'" + ), + }, + ], + }), + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/chai-advanced-features.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/chai-advanced-features.spec.ts new file mode 100644 index 0000000..37cc036 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/chai-advanced-features.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("pm.expect - Advanced Chai Features", () => { + describe(".nested property assertions", () => { + test("should access nested properties using dot notation", () => { + return expect( + runTest( + ` + pm.test("Nested property access", function() { + const obj = { a: { b: { c: "value" } } } + pm.expect(obj).to.have.nested.property("a.b.c", "value") + }) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Nested property access", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should access nested properties without value check", () => { + return expect( + runTest( + ` + pm.test("Nested property existence", function() { + const obj = { x: { y: { z: 123 } } } + pm.expect(obj).to.have.nested.property("x.y.z") + }) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Nested property existence", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should handle nested array indices", () => { + return expect( + runTest( + ` + pm.test("Nested array access", function() { + const obj = { items: [{ name: "first" }, { name: "second" }] } + pm.expect(obj).to.have.nested.property("items[1].name", "second") + }) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Nested array access", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should work with .not negation", () => { + return expect( + runTest( + ` + pm.test("Negated nested property", function() { + const obj = { a: { b: "value" } } + pm.expect(obj).to.not.have.nested.property("a.c") + }) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Negated nested property", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + // Side-effect assertions with .by() chaining are comprehensively tested in + // change-increase-decrease-getter.spec.ts which includes both getter and object+property patterns, + // positive/negative deltas, and all assertion combinations +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/chai-modifier-combinations.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/chai-modifier-combinations.spec.ts new file mode 100644 index 0000000..3f2c243 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/chai-modifier-combinations.spec.ts @@ -0,0 +1,689 @@ +import { describe, expect, test } from "vitest" +import { TestResponse } from "~/types" +import { runTest } from "~/utils/test-helpers" + +const mockResponse: TestResponse = { + status: 200, + statusText: "OK", + responseTime: 0, + body: "OK", + headers: [], +} + +describe("Chai Edge Cases - .include.members() / .contain.members() Pattern", () => { + test("should support .include.members() for subset matching", async () => { + const testScript = ` + pm.test("include.members subset", () => { + pm.expect([1, 2, 3, 4]).to.include.members([1, 2]); + pm.expect([1, 2, 3, 4]).to.contain.members([3, 4]); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "include.members subset", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support .have.members() for exact matching", async () => { + const testScript = ` + pm.test("exact members", () => { + pm.expect([1, 2, 3]).to.have.members([1, 2, 3]); + pm.expect([1, 2, 3]).to.have.members([3, 2, 1]); // Order doesn't matter + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "exact members", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("hopp namespace should support include.members", async () => { + const testScript = ` + hopp.test("hopp include.members", () => { + hopp.expect([1, 2, 3, 4]).to.include.members([2, 3]); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "hopp include.members", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Chai Edge Cases - .any.keys() / .all.keys() Patterns", () => { + test("should support .any.keys() - at least one key matches", async () => { + const testScript = ` + pm.test("any.keys pattern", () => { + const obj = { a: 1, b: 2, c: 3 }; + pm.expect(obj).to.have.any.keys('a', 'b'); // Has both + pm.expect(obj).to.have.any.keys('a', 'z'); // Has at least one (a) + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "any.keys pattern", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support .all.keys() - must have exactly these keys", async () => { + const testScript = ` + pm.test("all.keys pattern", () => { + const obj = { a: 1, b: 2 }; + pm.expect(obj).to.have.all.keys('a', 'b'); // Exact match + pm.expect(obj).to.have.keys('a', 'b'); // Default is .all + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "all.keys pattern", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should fail when .any.keys() has no matching keys", async () => { + const testScript = ` + pm.test("any.keys failure", () => { + const obj = { a: 1, b: 2 }; + pm.expect(obj).to.have.any.keys('x', 'y', 'z'); // None match + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "any.keys failure", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "fail" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Chai Edge Cases - .ordered.members() Pattern", () => { + test("should support .ordered.members() - order matters", async () => { + const testScript = ` + pm.test("ordered.members", () => { + pm.expect([1, 2, 3]).to.have.ordered.members([1, 2, 3]); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "ordered.members", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should fail .ordered.members() when order is wrong", async () => { + const testScript = ` + pm.test("ordered.members wrong order", () => { + pm.expect([1, 2, 3]).to.have.ordered.members([3, 2, 1]); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "ordered.members wrong order", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "fail" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Chai Edge Cases - .own.include() Pattern", () => { + test("should support .own.include() - own properties only", async () => { + const testScript = ` + pm.test("own.include", () => { + const obj = Object.create({ inherited: 'value' }); + obj.own = 'ownValue'; + + pm.expect(obj).to.have.own.include({ own: 'ownValue' }); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "own.include", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should fail .own.include() for inherited properties", async () => { + const testScript = ` + pm.test("own.include excludes inherited", () => { + const obj = Object.create({ inherited: 'value' }); + obj.own = 'ownValue'; + + // This should fail because 'inherited' is not an own property + pm.expect(obj).to.have.own.include({ inherited: 'value' }); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "own.include excludes inherited", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "fail" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Chai Edge Cases - .nested.include() Pattern", () => { + test("should support .nested.include() - dot notation for nested properties", async () => { + const testScript = ` + pm.test("nested.include", () => { + const obj = { + user: { + name: 'John', + address: { + city: 'NYC' + } + } + }; + + pm.expect(obj).to.nested.include({ 'user.name': 'John' }); + pm.expect(obj).to.nested.include({ 'user.address.city': 'NYC' }); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "nested.include", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support .nested.include() with bracket notation", async () => { + const testScript = ` + pm.test("nested.include bracket notation", () => { + const obj = { + 'user.name': 'Alice', // Literal key with dot + user: { name: 'Bob' } + }; + + // Bracket notation for literal key with dot + pm.expect(obj).to.nested.include({ '["user.name"]': 'Alice' }); + // Dot notation for nested property + pm.expect(obj).to.nested.include({ 'user.name': 'Bob' }); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "nested.include bracket notation", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Chai Edge Cases - Modifier Combinations", () => { + test("should support .deep.own.include() - stacked modifiers", async () => { + const testScript = ` + pm.test("deep.own.include", () => { + const obj = Object.create({ inherited: { a: 1 } }); + obj.own = { b: 2 }; + + pm.expect(obj).to.have.deep.own.include({ own: { b: 2 } }); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.own.include", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support .deep.equal() - deep equality check", async () => { + const testScript = ` + pm.test("deep.equal", () => { + const obj1 = { a: { b: { c: 1 } } }; + const obj2 = { a: { b: { c: 1 } } }; + + pm.expect(obj1).to.deep.equal(obj2); + pm.expect(obj1).to.eql(obj2); // eql is alias for deep.equal + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.equal", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support .deep.nested.include() - multiple modifiers", async () => { + const testScript = ` + pm.test("deep.nested.include", () => { + const obj = { + user: { + profile: { + settings: { theme: 'dark', notifications: true } + } + } + }; + + pm.expect(obj).to.deep.nested.include({ + 'user.profile.settings': { theme: 'dark', notifications: true } + }); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.nested.include", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Chai Edge Cases - .ownPropertyDescriptor() with Chaining", () => { + test("should support chaining after .ownPropertyDescriptor()", async () => { + const testScript = ` + pm.test("ownPropertyDescriptor chaining", () => { + const obj = {}; + Object.defineProperty(obj, 'foo', { + value: 42, + writable: false, + enumerable: true, + configurable: false + }); + + pm.expect(obj).to.have.ownPropertyDescriptor('foo') + .that.has.property('enumerable', true); + + pm.expect(obj).to.have.ownPropertyDescriptor('foo') + .that.has.property('writable', false); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "ownPropertyDescriptor chaining", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Chai Edge Cases - .respondTo() with .itself", () => { + test("should support .itself.respondTo() for function methods", async () => { + const testScript = ` + pm.test("itself.respondTo", () => { + function MyFunc() {} + MyFunc.staticMethod = function() {}; + + // Check that the function itself has the method + pm.expect(MyFunc).itself.to.respondTo('staticMethod'); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "itself.respondTo", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Chai Edge Cases - Real-World Patterns", () => { + test("should handle complex nested assertions from API responses", async () => { + const jsonResponse: TestResponse = { + status: 200, + statusText: "OK", + responseTime: 0, + body: JSON.stringify({ + data: { + users: [ + { id: 1, name: "Alice", roles: ["admin", "user"] }, + { id: 2, name: "Bob", roles: ["user"] }, + ], + meta: { + total: 2, + page: 1, + }, + }, + }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + const testScript = ` + pm.test("complex API response validation", () => { + const response = pm.response.json(); + + // Deep nested property checks + pm.expect(response).to.nested.include({ 'data.meta.total': 2 }); + pm.expect(response).to.nested.include({ 'data.meta.page': 1 }); + + // Array member checks + const userIds = response.data.users.map(u => u.id); + pm.expect(userIds).to.include.members([1, 2]); + + // Deep property on array element + pm.expect(response.data.users[0]).to.deep.include({ + roles: ["admin", "user"] + }); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + jsonResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "complex API response validation", + expectResults: [ + expect.objectContaining({ + status: "pass", + message: expect.stringContaining("nested include"), + }), + expect.objectContaining({ + status: "pass", + message: expect.stringContaining("nested include"), + }), + expect.objectContaining({ + status: "pass", + message: expect.stringContaining("include members"), + }), + expect.objectContaining({ + status: "pass", + message: expect.stringContaining("deep include"), + }), + ], + }), + ]), + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/cross-namespace-undefined.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/cross-namespace-undefined.spec.ts new file mode 100644 index 0000000..2f89159 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/cross-namespace-undefined.spec.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from "vitest" +import { runTest, runTestAndGetEnvs } from "~/utils/test-helpers" +import { runPreRequestScript } from "~/node" +import { getDefaultRESTRequest } from "@hoppscotch/data" + +const DEFAULT_REQUEST = getDefaultRESTRequest() + +// Undefined marker pattern ensures values survive serialization + +describe("Cross-namespace undefined preservation", () => { + test("hopp.env.get can read undefined set by pm.environment.set", () => { + return expect( + runTest( + ` + pm.environment.set("undef_var", undefined) + const value = hopp.env.get("undef_var") + pm.expect(value).toBe(undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) + + test("pw.env.get can read undefined set by pm.environment.set in pre-request", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set("env_undef_pre", undefined) + // Verify pw.env.get can read the undefined value + const value = pw.env.get("env_undef_pre") + // Store the result to verify it was read correctly + pw.env.set("read_result", value === undefined ? "success" : "failed") + `, + { + envs: { + global: [], + selected: [], + }, + request: DEFAULT_REQUEST, + cookies: null, + experimentalScriptingSandbox: true, + } + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "env_undef_pre", + currentValue: "undefined", // Converted from UNDEFINED_MARKER + initialValue: "undefined", + secret: false, + }, + { + key: "read_result", + currentValue: "success", // Confirms pw.env.get returned undefined + initialValue: "success", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("pm.variables.get can read undefined from environment", () => { + return expect( + runTest( + ` + pm.environment.set("env_undef", undefined) + const value = pm.variables.get("env_undef") + pm.expect(value).toBe(undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) + + test("hopp.env.active.get can read undefined set by pm.variables.set", () => { + return expect( + runTest( + ` + pm.variables.set("var_undef", undefined) + const value = hopp.env.active.get("var_undef") + pm.expect(value).toBe(undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) + + test("hopp.env.global.get can read undefined set by pm.globals.set", () => { + return expect( + runTest( + ` + pm.globals.set("global_test", undefined) + const value = hopp.env.global.get("global_test") + pm.expect(value).toBe(undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) + + test("undefined value appears correctly in environment array", () => { + return expect( + runTestAndGetEnvs( + ` + pm.environment.set("stored_undef", undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "stored_undef", + // The value should be stored as the marker internally but exposed as "undefined" string for UI + currentValue: "undefined", + initialValue: "undefined", + secret: false, + }, + ], + }) + ) + }) + + test("undefined is preserved across multiple namespace reads", () => { + return expect( + runTest( + ` + pm.environment.set("multi_read", undefined) + + // Read from PM namespace + const pmValue = pm.environment.get("multi_read") + pm.expect(pmValue).toBe(undefined) + + // Read from hopp namespace + const hoppValue = hopp.env.get("multi_read") + pm.expect(hoppValue).toBe(undefined) + + // Read from pm.variables (which resolves from environment) + const varValue = pm.variables.get("multi_read") + pm.expect(varValue).toBe(undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) + + test("overwriting undefined with string works across namespaces", () => { + return expect( + runTest( + ` + // Set undefined via PM + pm.environment.set("changeable", undefined) + pm.expect(hopp.env.get("changeable")).toBe(undefined) + + // Overwrite with string via PM + pm.environment.set("changeable", "new_value") + pm.expect(hopp.env.get("changeable")).toBe("new_value") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected 'new_value' to be 'new_value'", + }, + ], + }), + ]) + }) + + test("undefined precedence in pm.variables.get (environment over global)", () => { + return expect( + runTest( + ` + // Set undefined in both global and environment + pm.globals.set("precedence_test", undefined) + pm.environment.set("precedence_test", undefined) + + // pm.variables should return environment's undefined + const value = pm.variables.get("precedence_test") + pm.expect(value).toBe(undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/environment.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/environment.spec.ts new file mode 100644 index 0000000..64af9b0 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/environment.spec.ts @@ -0,0 +1,641 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("pm.environment additional coverage", () => { + test("pm.environment.set creates and retrieves environment variable", () => { + return expect( + runTest( + ` + pm.environment.set("test_set", "set_value") + const retrieved = pm.environment.get("test_set") + pm.expect(retrieved).toBe("set_value") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'set_value' to be 'set_value'", + }, + ], + }), + ]) + }) + + test("pm.environment.has correctly identifies existing and non-existing variables", () => { + return expect( + runTest( + ` + const hasExisting = pm.environment.has("existing_var") + const hasNonExisting = pm.environment.has("non_existing_var") + pm.expect(hasExisting.toString()).toBe("true") + pm.expect(hasNonExisting.toString()).toBe("false") + `, + { + global: [], + selected: [ + { + key: "existing_var", + currentValue: "existing_value", + initialValue: "existing_value", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'true' to be 'true'", + }, + { + status: "pass", + message: "Expected 'false' to be 'false'", + }, + ], + }), + ]) + }) + + test("pm.environment.toObject returns all environment variables set via pm.environment.set", () => { + return expect( + runTest( + ` + pm.environment.set("key1", "value1") + pm.environment.set("key2", "value2") + pm.environment.set("key3", "value3") + + const envObj = pm.environment.toObject() + + pm.expect(envObj.key1).toBe("value1") + pm.expect(envObj.key2).toBe("value2") + pm.expect(envObj.key3).toBe("value3") + pm.expect(Object.keys(envObj).length.toString()).toBe("3") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'value1' to be 'value1'", + }, + { + status: "pass", + message: "Expected 'value2' to be 'value2'", + }, + { + status: "pass", + message: "Expected 'value3' to be 'value3'", + }, + { + status: "pass", + message: "Expected '3' to be '3'", + }, + ], + }), + ]) + }) + + test("pm.environment.toObject returns empty object when no variables are set", () => { + return expect( + runTest( + ` + const envObj = pm.environment.toObject() + pm.expect(Object.keys(envObj).length.toString()).toBe("0") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '0' to be '0'", + }, + ], + }), + ]) + }) + + test("pm.environment.clear removes all environment variables set via pm.environment.set", () => { + return expect( + runTest( + ` + pm.environment.set("key1", "value1") + pm.environment.set("key2", "value2") + + // Verify variables are set + pm.expect(pm.environment.get("key1")).toBe("value1") + pm.expect(pm.environment.get("key2")).toBe("value2") + + // Clear all + pm.environment.clear() + + // Verify variables are cleared + pm.expect(pm.environment.get("key1")).toBe(undefined) + pm.expect(pm.environment.get("key2")).toBe(undefined) + + // Verify toObject returns empty + const envObj = pm.environment.toObject() + pm.expect(Object.keys(envObj).length.toString()).toBe("0") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'value1' to be 'value1'", + }, + { + status: "pass", + message: "Expected 'value2' to be 'value2'", + }, + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected '0' to be '0'", + }, + ], + }), + ]) + }) + + test("pm.environment.unset removes key from tracking", () => { + return expect( + runTest( + ` + pm.environment.set("key1", "value1") + pm.environment.set("key2", "value2") + + // Unset one key + pm.environment.unset("key1") + + // Verify key1 is removed but key2 remains + const envObj = pm.environment.toObject() + pm.expect(envObj.key1).toBe(undefined) + pm.expect(envObj.key2).toBe("value2") + pm.expect(Object.keys(envObj).length.toString()).toBe("1") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected 'value2' to be 'value2'", + }, + { + status: "pass", + message: "Expected '1' to be '1'", + }, + ], + }), + ]) + }) +}) + +describe("pm.globals additional coverage", () => { + test("pm.globals.set creates and retrieves global variable", () => { + return expect( + runTest( + ` + pm.globals.set("test_global", "global_value") + const retrieved = pm.globals.get("test_global") + pm.expect(retrieved).toBe("global_value") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'global_value' to be 'global_value'", + }, + ], + }), + ]) + }) + + test("pm.globals.toObject returns all global variables set via pm.globals.set", () => { + return expect( + runTest( + ` + pm.globals.set("globalKey1", "globalValue1") + pm.globals.set("globalKey2", "globalValue2") + pm.globals.set("globalKey3", "globalValue3") + + const globalObj = pm.globals.toObject() + + pm.expect(globalObj.globalKey1).toBe("globalValue1") + pm.expect(globalObj.globalKey2).toBe("globalValue2") + pm.expect(globalObj.globalKey3).toBe("globalValue3") + pm.expect(Object.keys(globalObj).length.toString()).toBe("3") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'globalValue1' to be 'globalValue1'", + }, + { + status: "pass", + message: "Expected 'globalValue2' to be 'globalValue2'", + }, + { + status: "pass", + message: "Expected 'globalValue3' to be 'globalValue3'", + }, + { + status: "pass", + message: "Expected '3' to be '3'", + }, + ], + }), + ]) + }) + + test("pm.globals.toObject returns empty object when no globals are set", () => { + return expect( + runTest( + ` + const globalObj = pm.globals.toObject() + pm.expect(Object.keys(globalObj).length.toString()).toBe("0") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '0' to be '0'", + }, + ], + }), + ]) + }) + + test("pm.globals.clear removes all global variables set via pm.globals.set", () => { + return expect( + runTest( + ` + pm.globals.set("globalKey1", "globalValue1") + pm.globals.set("globalKey2", "globalValue2") + + // Verify variables are set + pm.expect(pm.globals.get("globalKey1")).toBe("globalValue1") + pm.expect(pm.globals.get("globalKey2")).toBe("globalValue2") + + // Clear all + pm.globals.clear() + + // Verify variables are cleared + pm.expect(pm.globals.get("globalKey1")).toBe(undefined) + pm.expect(pm.globals.get("globalKey2")).toBe(undefined) + + // Verify toObject returns empty + const globalObj = pm.globals.toObject() + pm.expect(Object.keys(globalObj).length.toString()).toBe("0") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'globalValue1' to be 'globalValue1'", + }, + { + status: "pass", + message: "Expected 'globalValue2' to be 'globalValue2'", + }, + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected '0' to be '0'", + }, + ], + }), + ]) + }) + + test("pm.globals.clear also removes initial global variables from environment", () => { + return expect( + runTest( + ` + // Verify initial globals exist + pm.expect(pm.globals.get("initial_global1")).toBe("initial_value1") + pm.expect(pm.globals.get("initial_global2")).toBe("initial_value2") + + // Add tracked globals + pm.globals.set("tracked_global", "tracked_value") + pm.expect(pm.globals.get("tracked_global")).toBe("tracked_value") + + // Verify toObject includes both initial and tracked + const before = pm.globals.toObject() + pm.expect(before.initial_global1).toBe("initial_value1") + pm.expect(before.tracked_global).toBe("tracked_value") + + // Clear all (both initial and tracked) + pm.globals.clear() + + // Verify ALL globals are cleared + pm.expect(pm.globals.get("initial_global1")).toBe(undefined) + pm.expect(pm.globals.get("initial_global2")).toBe(undefined) + pm.expect(pm.globals.get("tracked_global")).toBe(undefined) + + // Verify toObject returns empty + const after = pm.globals.toObject() + pm.expect(Object.keys(after).length.toString()).toBe("0") + `, + { + global: [ + { + key: "initial_global1", + currentValue: "initial_value1", + initialValue: "initial_value1", + secret: false, + }, + { + key: "initial_global2", + currentValue: "initial_value2", + initialValue: "initial_value2", + secret: false, + }, + ], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'initial_value1' to be 'initial_value1'", + }, + { + status: "pass", + message: "Expected 'initial_value2' to be 'initial_value2'", + }, + { + status: "pass", + message: "Expected 'tracked_value' to be 'tracked_value'", + }, + { + status: "pass", + message: "Expected 'initial_value1' to be 'initial_value1'", + }, + { + status: "pass", + message: "Expected 'tracked_value' to be 'tracked_value'", + }, + { status: "pass", message: "Expected 'undefined' to be 'undefined'" }, + { status: "pass", message: "Expected 'undefined' to be 'undefined'" }, + { status: "pass", message: "Expected 'undefined' to be 'undefined'" }, + { status: "pass", message: "Expected '0' to be '0'" }, + ], + }), + ]) + }) + + test("pm.globals.unset removes key from tracking", () => { + return expect( + runTest( + ` + pm.globals.set("globalKey1", "globalValue1") + pm.globals.set("globalKey2", "globalValue2") + + // Unset one key + pm.globals.unset("globalKey1") + + // Verify key1 is removed but key2 remains + const globalObj = pm.globals.toObject() + pm.expect(globalObj.globalKey1).toBe(undefined) + pm.expect(globalObj.globalKey2).toBe("globalValue2") + pm.expect(Object.keys(globalObj).length.toString()).toBe("1") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected 'globalValue2' to be 'globalValue2'", + }, + { + status: "pass", + message: "Expected '1' to be '1'", + }, + ], + }), + ]) + }) +}) + +describe("pm.variables additional coverage", () => { + test("pm.variables.set creates and retrieves variable in active environment", () => { + return expect( + runTest( + ` + pm.variables.set("test_var", "test_value") + const retrieved = pm.variables.get("test_var") + pm.expect(retrieved).toBe("test_value") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'test_value' to be 'test_value'", + }, + ], + }), + ]) + }) + + test("pm.variables.has correctly identifies existing and non-existing variables", () => { + return expect( + runTest( + ` + const hasExisting = pm.variables.has("existing_var") + const hasNonExisting = pm.variables.has("non_existing_var") + pm.expect(hasExisting.toString()).toBe("true") + pm.expect(hasNonExisting.toString()).toBe("false") + `, + { + global: [], + selected: [ + { + key: "existing_var", + currentValue: "existing_value", + initialValue: "existing_value", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'true' to be 'true'", + }, + { + status: "pass", + message: "Expected 'false' to be 'false'", + }, + ], + }), + ]) + }) + + test("pm.variables.get returns the correct value from any scope", () => { + return expect( + runTest( + ` + const data = pm.variables.get("scopedVar") + pm.expect(data).toBe("scopedValue") + `, + { + global: [ + { + key: "scopedVar", + currentValue: "scopedValue", + initialValue: "scopedValue", + secret: false, + }, + ], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'scopedValue' to be 'scopedValue'", + }, + ], + }), + ]) + }) + + test("pm.variables.replaceIn handles multiple variables", () => { + return expect( + runTest( + ` + const template = "User {{name}} has {{count}} items in {{location}}" + const result = pm.variables.replaceIn(template) + pm.expect(result).toBe("User Alice has 10 items in Cart") + `, + { + global: [ + { + key: "location", + currentValue: "Cart", + initialValue: "Cart", + secret: false, + }, + ], + selected: [ + { + key: "name", + currentValue: "Alice", + initialValue: "Alice", + secret: false, + }, + { + key: "count", + currentValue: "10", + initialValue: "10", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: + "Expected 'User Alice has 10 items in Cart' to be 'User Alice has 10 items in Cart'", + }, + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/execution.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/execution.spec.ts new file mode 100644 index 0000000..da721d1 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/execution.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "vitest" +import { runTest, runPreRequest } from "~/utils/test-helpers" + +describe("pm.execution namespace", () => { + test("pm.execution.location returns Hoppscotch array in test script", () => { + return expect( + runTest( + ` + pm.test("pm.execution.location is an array", () => { + pm.expect(Array.isArray(pm.execution.location)).to.be.true + }) + + pm.test("pm.execution.location contains Hoppscotch", () => { + pm.expect(pm.execution.location).to.include("Hoppscotch") + }) + + pm.test("pm.execution.location.current is Hoppscotch", () => { + pm.expect(pm.execution.location.current).to.equal("Hoppscotch") + }) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "pm.execution.location is an array", + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + expect.objectContaining({ + descriptor: "pm.execution.location contains Hoppscotch", + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + expect.objectContaining({ + descriptor: "pm.execution.location.current is Hoppscotch", + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("pm.execution.location is accessible in pre-request script", () => { + return expect( + runPreRequest( + ` + // Verify pm.execution.location exists and has expected values + if (!Array.isArray(pm.execution.location)) { + throw new Error("pm.execution.location is not an array") + } + if (!pm.execution.location.includes("Hoppscotch")) { + throw new Error("pm.execution.location does not contain 'Hoppscotch'") + } + if (pm.execution.location.current !== "Hoppscotch") { + throw new Error("pm.execution.location.current is not 'Hoppscotch'") + } + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [], + selected: [], + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/expect-fail.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/expect-fail.spec.ts new file mode 100644 index 0000000..9286a12 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/expect-fail.spec.ts @@ -0,0 +1,310 @@ +import { describe, expect, test } from "vitest" +import { TestResponse } from "~/types" +import { runTest } from "~/utils/test-helpers" + +const mockResponse: TestResponse = { + status: 200, + statusText: "OK", + responseTime: 0, + body: "OK", + headers: [], +} + +describe("pm.expect.fail() - Explicit test failures", () => { + test("pm.expect.fail() with no arguments fails the test", async () => { + const testScript = ` + pm.test("explicit failure", () => { + pm.expect.fail(); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "explicit failure", + expectResults: [ + expect.objectContaining({ + status: "fail", + message: expect.stringContaining("expect.fail()"), + }), + ], + }), + ]), + }), + ]) + ) + }) + + test("pm.expect.fail() with custom message", async () => { + const testScript = ` + pm.test("custom failure message", () => { + pm.expect.fail("This test intentionally fails"); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "custom failure message", + expectResults: [ + expect.objectContaining({ + status: "fail", + message: "This test intentionally fails", + }), + ], + }), + ]), + }), + ]) + ) + }) + + test("pm.expect.fail() with actual and expected values", async () => { + const testScript = ` + pm.test("failure with values", () => { + pm.expect.fail(5, 10); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "failure with values", + expectResults: [ + expect.objectContaining({ + status: "fail", + message: expect.stringMatching(/expected.*5.*equal.*10/i), + }), + ], + }), + ]), + }), + ]) + ) + }) + + test("pm.expect.fail() practical use case - conditional validation", async () => { + const jsonResponse: TestResponse = { + status: 200, + statusText: "OK", + responseTime: 0, + body: JSON.stringify({ id: 1, name: "Test" }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + const testScript = ` + pm.test("validate response", () => { + const data = pm.response.json(); + + if (!data.email) { + pm.expect.fail("Missing required email field"); + } + + pm.expect(data).to.be.an("object"); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + jsonResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "validate response", + expectResults: expect.arrayContaining([ + expect.objectContaining({ + status: "fail", + message: "Missing required email field", + }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("hopp.expect.fail() - Explicit test failures", () => { + test("hopp.expect.fail() with no arguments fails the test", async () => { + const testScript = ` + hopp.test("explicit failure", () => { + hopp.expect.fail(); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "explicit failure", + expectResults: [ + expect.objectContaining({ + status: "fail", + message: expect.stringContaining("expect.fail()"), + }), + ], + }), + ]), + }), + ]) + ) + }) + + test("hopp.expect.fail() with custom message", async () => { + const testScript = ` + hopp.test("custom failure message", () => { + hopp.expect.fail("This test intentionally fails"); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "custom failure message", + expectResults: [ + expect.objectContaining({ + status: "fail", + message: "This test intentionally fails", + }), + ], + }), + ]), + }), + ]) + ) + }) + + test("hopp.expect.fail() with actual and expected values", async () => { + const testScript = ` + hopp.test("failure with values", () => { + hopp.expect.fail("hello", "world"); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "failure with values", + expectResults: [ + expect.objectContaining({ + status: "fail", + message: expect.stringMatching( + /expected.*hello.*equal.*world/i + ), + }), + ], + }), + ]), + }), + ]) + ) + }) +}) + +describe("expect.fail() - Cross-namespace compatibility", () => { + test("both pm and hopp namespaces support fail() with same behavior", async () => { + const testScript = ` + pm.test("pm namespace fail", () => { + pm.expect.fail("PM failure"); + }); + + hopp.test("hopp namespace fail", () => { + hopp.expect.fail("Hopp failure"); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "pm namespace fail", + expectResults: [ + expect.objectContaining({ + status: "fail", + message: "PM failure", + }), + ], + }), + expect.objectContaining({ + descriptor: "hopp namespace fail", + expectResults: [ + expect.objectContaining({ + status: "fail", + message: "Hopp failure", + }), + ], + }), + ]), + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/info.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/info.spec.ts new file mode 100644 index 0000000..768a28b --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/info.spec.ts @@ -0,0 +1,136 @@ +import { getDefaultRESTRequest } from "@hoppscotch/data" +import * as TE from "fp-ts/TaskEither" +import { pipe } from "fp-ts/function" +import { describe, expect, test } from "vitest" +import { runTestScript } from "~/node" +import { runTest, defaultRequest, fakeResponse } from "~/utils/test-helpers" +import { runPreRequestScript } from "~/web" + +describe("pm.info context", () => { + test("pm.info.eventName returns 'pre-request' in pre-request context", () => { + const defaultRequest = getDefaultRESTRequest() + + return expect( + runPreRequestScript( + ` + console.log("Event name: ", pm.info.eventName) + `, + { + envs: { + global: [], + selected: [], + }, + request: defaultRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ args: ["Event name: ", "pre-request"] }), + ], + }) + ) + }) + + test("pm.info.eventName returns 'test' in test context", () => { + return expect( + runTest( + ` + pm.test("Event name is correct", () => { + pm.expect(pm.info.eventName).toBe("test") + }) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "Event name is correct", + expectResults: [ + { + status: "pass", + message: "Expected 'test' to be 'test'", + }, + ], + }), + ], + }), + ]) + }) + + test("pm.info provides requestName and requestId", () => { + const customRequest = { + ...defaultRequest, + name: "default-request", + id: "test-id", + } + + return expect( + runTest( + ` + pm.test("Request info is available", () => { + pm.expect(pm.info.requestName).toBe("default-request") + pm.expect(pm.info.requestId).toBe("test-id") + }) + `, + { + global: [], + selected: [], + }, + fakeResponse, + customRequest + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "Request info is available", + expectResults: [ + { + status: "pass", + message: "Expected 'default-request' to be 'default-request'", + }, + { status: "pass", message: "Expected 'test-id' to be 'test-id'" }, + ], + }), + ], + }), + ]) + }) + + test("pm.info.requestId falls back to requestName when id is undefined", () => { + return expect( + pipe( + runTestScript( + ` + pm.test("Request ID fallback works", () => { + pm.expect(pm.info.requestId).to.exist + pm.expect(pm.info.requestId).toBe("fallback-request-name") + }) + `, + { + envs: { global: [], selected: [] }, + request: { ...defaultRequest, name: "fallback-request-name" }, + response: fakeResponse, + } + ), + TE.map((x) => x.tests) + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "Request ID fallback works", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/map-set-size.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/map-set-size.spec.ts new file mode 100644 index 0000000..d5ba856 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/map-set-size.spec.ts @@ -0,0 +1,204 @@ +// Map/Set serialize as {} across sandbox boundary, so we extract .size before serialization + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("Map.size property assertions", () => { + test("should support .property('size') for Map", async () => { + const testScript = ` + pm.test("Map - size property", () => { + const myMap = new Map([['key1', 'value1'], ['key2', 'value2']]); + pm.expect(myMap).to.have.property('size', 2); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Map - size property", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should support .property('size') with chaining for Map", async () => { + const testScript = ` + pm.test("Map - size with chaining", () => { + const myMap = new Map([['a', 1], ['b', 2], ['c', 3]]); + pm.expect(myMap).to.have.property('size').that.equals(3); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Map - size with chaining", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support negation for Map size", async () => { + const testScript = ` + pm.test("Map - size negation", () => { + const myMap = new Map([['key1', 'value1']]); + pm.expect(myMap).to.not.have.property('size', 5); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Map - size negation", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) +}) + +describe("Set.size property assertions", () => { + test("should support .property('size') for Set", async () => { + const testScript = ` + pm.test("Set - size property", () => { + const mySet = new Set([1, 2, 3]); + pm.expect(mySet).to.have.property('size', 3); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Set - size property", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should support .property('size') with chaining for Set", async () => { + const testScript = ` + pm.test("Set - size with chaining", () => { + const mySet = new Set(['a', 'b', 'c', 'd']); + pm.expect(mySet).to.have.property('size').that.is.above(2); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Set - size with chaining", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support negation for Set size", async () => { + const testScript = ` + pm.test("Set - size negation", () => { + const mySet = new Set([1, 2]); + pm.expect(mySet).to.not.have.property('size', 10); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Set - size negation", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should handle empty Set", async () => { + const testScript = ` + pm.test("Set - empty size", () => { + const mySet = new Set(); + pm.expect(mySet).to.have.property('size', 0); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Set - empty size", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should handle empty Map", async () => { + const testScript = ` + pm.test("Map - empty size", () => { + const myMap = new Map(); + pm.expect(myMap).to.have.property('size', 0); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Map - empty size", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/pre-request-type-preservation.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/pre-request-type-preservation.spec.ts new file mode 100644 index 0000000..6b92337 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/pre-request-type-preservation.spec.ts @@ -0,0 +1,513 @@ +import { describe, expect, test } from "vitest" +import { getDefaultRESTRequest } from "@hoppscotch/data" +import { runPreRequestScript } from "~/node" + +const DEFAULT_REQUEST = getDefaultRESTRequest() + +// Pre-request scripts use markers to preserve null/undefined across serialization + +describe("PM namespace type preservation in pre-request context", () => { + const emptyEnvs = { + envs: { + global: [], + selected: [], + }, + request: DEFAULT_REQUEST, + } + + describe("pm.environment.set() type preservation", () => { + test("preserves arrays (not String() coercion to '1,2,3')", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('testArray', [1, 2, 3]) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "testArray", + currentValue: "[1,2,3]", // JSON stringified for UI display + initialValue: "[1,2,3]", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves objects (not String() coercion to '[object Object]')", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('testObj', { foo: 'bar', num: 42 }) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "testObj", + currentValue: '{"foo":"bar","num":42}', // JSON stringified for UI display + secret: false, + initialValue: '{"foo":"bar","num":42}', + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves null with NULL_MARKER", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('nullValue', null) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "nullValue", + currentValue: "null", // Converted from NULL_MARKER by getUpdatedEnvs + initialValue: "null", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves undefined with UNDEFINED_MARKER", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('undefinedValue', undefined) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "undefinedValue", + currentValue: "undefined", // Converted from UNDEFINED_MARKER by getUpdatedEnvs + initialValue: "undefined", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves nested structures", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('nested', { + users: [ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" } + ], + meta: { count: 2, filters: ["active", "verified"] } + }) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "nested", + currentValue: + '{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}],"meta":{"count":2,"filters":["active","verified"]}}', + initialValue: + '{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}],"meta":{"count":2,"filters":["active","verified"]}}', + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves primitives correctly", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('str', 'hello') + pm.environment.set('num', 42) + pm.environment.set('bool', true) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "str", + currentValue: "hello", + initialValue: "hello", + secret: false, + }, + { + key: "num", + currentValue: "42", // Converted to string for UI compatibility + initialValue: "42", + secret: false, + }, + { + key: "bool", + currentValue: "true", // Converted to string for UI compatibility + initialValue: "true", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + }) + + describe("pm.globals.set() type preservation", () => { + test("preserves arrays in globals", () => { + return expect( + runPreRequestScript( + ` + pm.globals.set('globalArray', [10, 20, 30]) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [ + { + key: "globalArray", + currentValue: "[10,20,30]", + initialValue: "[10,20,30]", + secret: false, + }, + ], + selected: [], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves objects in globals", () => { + return expect( + runPreRequestScript( + ` + pm.globals.set('globalObj', { env: 'prod', port: 8080 }) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [ + { + key: "globalObj", + currentValue: '{"env":"prod","port":8080}', + initialValue: '{"env":"prod","port":8080}', + secret: false, + }, + ], + selected: [], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves null in globals", () => { + return expect( + runPreRequestScript( + ` + pm.globals.set('globalNull', null) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [ + { + key: "globalNull", + currentValue: "null", // Converted from NULL_MARKER + initialValue: "null", + secret: false, + }, + ], + selected: [], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves undefined in globals", () => { + return expect( + runPreRequestScript( + ` + pm.globals.set('globalUndefined', undefined) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [ + { + key: "globalUndefined", + currentValue: "undefined", // Converted from UNDEFINED_MARKER + initialValue: "undefined", + secret: false, + }, + ], + selected: [], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + }) + + describe("pm.variables.set() type preservation", () => { + test("preserves arrays (uses active scope)", () => { + return expect( + runPreRequestScript( + ` + pm.variables.set('varArray', [5, 10, 15]) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "varArray", + currentValue: "[5,10,15]", + initialValue: "[5,10,15]", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves objects", () => { + return expect( + runPreRequestScript( + ` + pm.variables.set('varObj', { status: 'active', count: 100 }) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "varObj", + currentValue: '{"status":"active","count":100}', + initialValue: '{"status":"active","count":100}', + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves null", () => { + return expect( + runPreRequestScript( + ` + pm.variables.set('varNull', null) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "varNull", + currentValue: "null", + initialValue: "null", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + }) + + describe("Regression tests for String() coercion bug", () => { + test("does NOT convert [1,2,3] to '1,2,3' string", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('arr', [1, 2, 3]) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "arr", + currentValue: "[1,2,3]", // JSON stringified for UI display + initialValue: "[1,2,3]", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("does NOT convert object to '[object Object]'", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('obj', { foo: 'bar' }) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "obj", + currentValue: '{"foo":"bar"}', // Object (JSON string for UI), not "[object Object]" string + initialValue: '{"foo":"bar"}', + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + }) + + describe("Complex scenarios", () => { + test("mixed array of primitives, null, undefined, objects", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('mixed', [ + 'string', + 42, + true, + null, + undefined, + [1, 2], + { key: 'value' } + ]) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "mixed", + currentValue: + '["string",42,true,null,null,[1,2],{"key":"value"}]', + initialValue: + '["string",42,true,null,null,[1,2],{"key":"value"}]', // JSON stringified for UI display + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("multiple PM namespace calls in same pre-request", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('arr1', [1, 2]) + pm.globals.set('arr2', [3, 4]) + pm.variables.set('arr3', [5, 6]) + pm.environment.set('obj1', { a: 1 }) + pm.globals.set('obj2', { b: 2 }) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + selected: [ + { + key: "arr1", + currentValue: "[1,2]", + initialValue: "[1,2]", + secret: false, + }, + { + key: "arr3", + currentValue: "[5,6]", + initialValue: "[5,6]", + secret: false, + }, + { + key: "obj1", + currentValue: '{"a":1}', + initialValue: '{"a":1}', + secret: false, + }, + ], + global: [ + { + key: "arr2", + currentValue: "[3,4]", + initialValue: "[3,4]", + secret: false, + }, + { + key: "obj2", + currentValue: '{"b":2}', + initialValue: '{"b":2}', + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/property-chaining.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/property-chaining.spec.ts new file mode 100644 index 0000000..e19d821 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/property-chaining.spec.ts @@ -0,0 +1,216 @@ +// .property('key') returns a NEW expectation wrapping the property value + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("property() with .that chaining", () => { + test("should chain property value with .that.equals()", async () => { + const testScript = ` + pm.test("property chaining with that", () => { + pm.expect({ a: 1, b: 2 }).to.have.property('a').that.equals(1); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "property chaining with that", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should chain nested property with .that.is.an()", async () => { + const testScript = ` + pm.test("nested property chaining", () => { + pm.expect({ nested: { value: 42 } }) + .to.have.property('nested') + .that.is.an('object'); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "nested property chaining", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support .which as alias for .that", async () => { + const testScript = ` + pm.test("property chaining with which", () => { + pm.expect({ x: 1 }).to.have.property('x').which.equals(1); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "property chaining with which", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support complex chaining", async () => { + const testScript = ` + pm.test("complex property chaining", () => { + pm.expect({ name: 'John', age: 30 }) + .to.be.an('object') + .and.have.property('name') + .that.is.a('string') + .and.equals('John'); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "complex property chaining", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should fail when chained value doesn't match", async () => { + const testScript = ` + pm.test("property chaining fails correctly", () => { + pm.expect({ a: 1, b: 2 }).to.have.property('a').that.equals(2); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "property chaining fails correctly", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "fail" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("property() with value parameter", () => { + test("should assert property value directly", async () => { + const testScript = ` + pm.test("property with value", () => { + pm.expect({ a: 1, b: 2 }).to.have.property('a', 1); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "property with value", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should fail when property value doesn't match", async () => { + const testScript = ` + pm.test("property value mismatch", () => { + pm.expect({ a: 1, b: 2 }).to.not.have.property('a', 2); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "property value mismatch", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) +}) + +describe("own.property() assertions", () => { + test("should check own properties vs inherited", async () => { + const testScript = ` + pm.test("own property check", () => { + const obj = Object.create({ inherited: true }); + obj.own = true; + pm.expect(obj).to.have.own.property('own'); + pm.expect(obj).to.not.have.own.property('inherited'); + pm.expect(obj).to.have.property('inherited'); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "own property check", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/basic.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/basic.spec.ts new file mode 100644 index 0000000..bf84315 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/basic.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("pm.request coverage", () => { + test("pm.request object provides access to request data", () => { + return expect( + runTest( + ` + pw.expect(pm.request.url.toString()).toBe("https://echo.hoppscotch.io") + pw.expect(pm.request.method).toBe("GET") + pw.expect(pm.request.headers.get("Content-Type")).toBe(null) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: + "Expected 'https://echo.hoppscotch.io' to be 'https://echo.hoppscotch.io'", + }, + { + status: "pass", + message: "Expected 'GET' to be 'GET'", + }, + { + status: "pass", + message: "Expected 'null' to be 'null'", + }, + ], + }), + ]) + }) + + test("pm.request.url provides correct URL value", () => { + return expect( + runTest( + ` + pw.expect(pm.request.url.toString()).toBe("https://echo.hoppscotch.io") + pw.expect(pm.request.url.toString().length).toBe(26) + pw.expect(pm.request.url.toString().includes("hoppscotch")).toBe(true) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: + "Expected 'https://echo.hoppscotch.io' to be 'https://echo.hoppscotch.io'", + }, + { + status: "pass", + message: "Expected '26' to be '26'", + }, + { + status: "pass", + message: "Expected 'true' to be 'true'", + }, + ], + }), + ]) + }) + + test("pm.request.headers functionality", () => { + return expect( + runTest( + ` + pw.expect(pm.request.headers.get("Content-Type")).toBe(null) + pw.expect(pm.request.headers.has("Content-Type")).toBe(false) + pw.expect(pm.request.headers.has("User-Agent")).toBe(false) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'null' to be 'null'", + }, + { + status: "pass", + message: "Expected 'false' to be 'false'", + }, + { + status: "pass", + message: "Expected 'false' to be 'false'", + }, + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/headers/propertylist-advanced.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/headers/propertylist-advanced.spec.ts new file mode 100644 index 0000000..bd95968 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/headers/propertylist-advanced.spec.ts @@ -0,0 +1,485 @@ +import { HoppRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" + +import { runPreRequestScript } from "~/web" + +const baseRequest: HoppRESTRequest = { + v: "16", + name: "Test Request", + endpoint: "https://api.example.com/users", + method: "POST", + headers: [ + { key: "Content-Type", value: "application/json", active: true }, + { key: "Authorization", value: "Bearer token123", active: true }, + { key: "X-Custom-Header", value: "custom-value", active: true }, + ], + params: [], + body: { contentType: null, body: null }, + auth: { authType: "none", authActive: false }, + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, +} + +const envs = { global: [], selected: [] } + +describe("pm.request.headers.find()", () => { + test("finds header by predicate function", () => { + return expect( + runPreRequestScript( + ` + const result = pm.request.headers.find((header) => header.key === 'Authorization') + + console.log("Found header:", result) + console.log("Header key:", result.key) + console.log("Header value:", result.value) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Found header:", + expect.objectContaining({ + key: "Authorization", + value: "Bearer token123", + }), + ], + }), + expect.objectContaining({ args: ["Header key:", "Authorization"] }), + expect.objectContaining({ + args: ["Header value:", "Bearer token123"], + }), + ]), + }) + ) + }) + + test("finds header by key string (case-insensitive)", () => { + return expect( + runPreRequestScript( + ` + const result = pm.request.headers.find('content-type') + + console.log("Found by string:", result) + console.log("Value:", result.value) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Found by string:", + expect.objectContaining({ + key: "Content-Type", + value: "application/json", + }), + ], + }), + expect.objectContaining({ args: ["Value:", "application/json"] }), + ]), + }) + ) + }) + + test("returns null when header not found", () => { + return expect( + runPreRequestScript( + ` + const result = pm.request.headers.find('Nonexistent-Header') + + console.log("Result:", result) + console.log("Is null:", result === null) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Result:", null] }), + expect.objectContaining({ args: ["Is null:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.indexOf()", () => { + test("returns index of header by key (case-insensitive)", () => { + return expect( + runPreRequestScript( + ` + const idx1 = pm.request.headers.indexOf('content-type') + const idx2 = pm.request.headers.indexOf('AUTHORIZATION') + const idx3 = pm.request.headers.indexOf('X-Custom-Header') + + console.log("Index of content-type:", idx1) + console.log("Index of AUTHORIZATION:", idx2) + console.log("Index of X-Custom-Header:", idx3) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Index of content-type:", 0] }), + expect.objectContaining({ args: ["Index of AUTHORIZATION:", 1] }), + expect.objectContaining({ args: ["Index of X-Custom-Header:", 2] }), + ]), + }) + ) + }) + + test("returns index of header by object (case-insensitive)", () => { + return expect( + runPreRequestScript( + ` + const idx = pm.request.headers.indexOf({ key: 'authorization' }) + + console.log("Index:", idx) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Index:", 1] }), + ]), + }) + ) + }) + + test("returns -1 when header not found", () => { + return expect( + runPreRequestScript( + ` + const idx = pm.request.headers.indexOf('Nonexistent') + + console.log("Index:", idx) + console.log("Is -1:", idx === -1) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Index:", -1] }), + expect.objectContaining({ args: ["Is -1:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.insert()", () => { + test("inserts header before specified key", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.insert({ key: 'X-API-Key', value: 'secret123' }, 'Authorization') + + const allHeaders = pm.request.headers.map((h) => h.key) + console.log("All headers:", allHeaders) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All headers:", + ["Content-Type", "X-API-Key", "Authorization", "X-Custom-Header"], + ], + }), + ]), + }) + ) + }) + + test("appends header if 'before' key not found", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.insert({ key: 'X-New-Header', value: 'new' }, 'NonExistent') + + const allHeaders = pm.request.headers.map((h) => h.key) + console.log("All headers:", allHeaders) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All headers:", + [ + "Content-Type", + "Authorization", + "X-Custom-Header", + "X-New-Header", + ], + ], + }), + ]), + }) + ) + }) + + test("appends header when no 'before' specified", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.insert({ key: 'X-Added-Header', value: 'added' }) + + const allHeaders = pm.request.headers.map((h) => h.key) + console.log("All headers:", allHeaders) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All headers:", + [ + "Content-Type", + "Authorization", + "X-Custom-Header", + "X-Added-Header", + ], + ], + }), + ]), + }) + ) + }) + + test("throws error when item has no key", async () => { + const result = await runPreRequestScript( + `pm.request.headers.insert({ value: 'test' })`, + { + envs, + request: baseRequest, + cookies: null, + experimentalScriptingSandbox: true, + } + ) + + expect(result).toEqualLeft( + expect.stringContaining("Header must have a 'key' property") + ) + }) +}) + +describe("pm.request.headers.append()", () => { + test("moves existing header to end", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.append({ key: 'Content-Type', value: 'application/xml' }) + + const allHeaders = pm.request.headers.map((h) => h.key) + const contentType = pm.request.headers.get('Content-Type') + + console.log("All headers:", allHeaders) + console.log("Content-Type value:", contentType) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All headers:", + ["Authorization", "X-Custom-Header", "Content-Type"], + ], + }), + expect.objectContaining({ + args: ["Content-Type value:", "application/xml"], + }), + ]), + }) + ) + }) + + test("adds new header at end", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.append({ key: 'X-New-Header', value: 'new-value' }) + + const allHeaders = pm.request.headers.map((h) => h.key) + console.log("All headers:", allHeaders) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All headers:", + [ + "Content-Type", + "Authorization", + "X-Custom-Header", + "X-New-Header", + ], + ], + }), + ]), + }) + ) + }) + + test("throws error when item has no key", async () => { + const result = await runPreRequestScript( + `pm.request.headers.append({ value: 'test' })`, + { + envs, + request: baseRequest, + cookies: null, + experimentalScriptingSandbox: true, + } + ) + + expect(result).toEqualLeft( + expect.stringContaining("Header must have a 'key' property") + ) + }) +}) + +describe("pm.request.headers.assimilate()", () => { + test("updates existing headers and adds new ones from array", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.assimilate([ + { key: 'Content-Type', value: 'text/plain' }, + { key: 'X-API-Key', value: 'key123' } + ]) + + const allHeaders = pm.request.headers.all() + console.log("Content-Type:", allHeaders['Content-Type']) + console.log("Authorization:", allHeaders['Authorization']) + console.log("X-API-Key:", allHeaders['X-API-Key']) + console.log("Count:", pm.request.headers.count()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Content-Type:", "text/plain"] }), + expect.objectContaining({ + args: ["Authorization:", "Bearer token123"], + }), + expect.objectContaining({ args: ["X-API-Key:", "key123"] }), + expect.objectContaining({ args: ["Count:", 4] }), + ]), + }) + ) + }) + + test("updates existing headers and adds new ones from object", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.assimilate({ + 'Content-Type': 'application/xml', + 'X-New-Header': 'new-value' + }) + + const allHeaders = pm.request.headers.all() + console.log("All headers:", allHeaders) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All headers:", + expect.objectContaining({ + "Content-Type": "application/xml", + Authorization: "Bearer token123", + "X-Custom-Header": "custom-value", + "X-New-Header": "new-value", + }), + ], + }), + ]), + }) + ) + }) + + test("prunes headers not in source when prune=true", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.assimilate( + { + 'Content-Type': 'application/json', + 'X-API-Key': 'key123' + }, + true + ) + + const allHeaders = pm.request.headers.all() + const count = pm.request.headers.count() + + console.log("All headers:", allHeaders) + console.log("Count:", count) + console.log("Has Authorization:", pm.request.headers.has('Authorization')) + console.log("Has X-Custom-Header:", pm.request.headers.has('X-Custom-Header')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All headers:", + expect.objectContaining({ + "Content-Type": "application/json", + "X-API-Key": "key123", + }), + ], + }), + expect.objectContaining({ args: ["Count:", 2] }), + expect.objectContaining({ args: ["Has Authorization:", false] }), + expect.objectContaining({ + args: ["Has X-Custom-Header:", false], + }), + ]), + }) + ) + }) + + test("throws error for invalid source", async () => { + const result = await runPreRequestScript( + `pm.request.headers.assimilate("invalid")`, + { + envs, + request: baseRequest, + cookies: null, + experimentalScriptingSandbox: true, + } + ) + + expect(result).toEqualLeft( + expect.stringContaining("Source must be an array or object") + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/headers/propertylist.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/headers/propertylist.spec.ts new file mode 100644 index 0000000..67506fc --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/headers/propertylist.spec.ts @@ -0,0 +1,950 @@ +import { HoppRESTRequest, getDefaultRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" + +import { runPreRequestScript } from "~/web" + +const baseRequest: HoppRESTRequest = { + v: "16", + name: "Test Request", + endpoint: "https://api.example.com/users", + method: "GET", + headers: [ + { + key: "Content-Type", + value: "application/json", + active: true, + description: "", + }, + { + key: "Authorization", + value: "Bearer token123", + active: true, + description: "", + }, + { + key: "X-API-Version", + value: "v1", + active: true, + description: "", + }, + ], + params: [], + body: { contentType: null, body: null }, + auth: { authType: "none", authActive: false }, + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, +} + +const envs = { global: [], selected: [] } + +describe("pm.request.headers.each()", () => { + test("iterates over all headers", () => { + return expect( + runPreRequestScript( + ` + const keys = [] + const values = [] + + pm.request.headers.each((header) => { + keys.push(header.key) + values.push(header.value) + }) + + console.log("Keys:", keys) + console.log("Values:", values) + console.log("Count:", keys.length) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Keys:", ["Content-Type", "Authorization", "X-API-Version"]], + }), + expect.objectContaining({ + args: ["Values:", ["application/json", "Bearer token123", "v1"]], + }), + expect.objectContaining({ args: ["Count:", 3] }), + ]), + }) + ) + }) + + test("callback receives complete header object", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.each((header) => { + console.log("Key:", header.key) + console.log("Value:", header.value) + console.log("Has active:", 'active' in header) + return // Check only first header + }) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Key:", "Content-Type"] }), + expect.objectContaining({ args: ["Value:", "application/json"] }), + expect.objectContaining({ args: ["Has active:", true] }), + ]), + }) + ) + }) + + test("updates after headers are modified", () => { + return expect( + runPreRequestScript( + ` + let count1 = 0 + pm.request.headers.each(() => { count1++ }) + console.log("Initial count:", count1) + + pm.request.headers.add({ key: 'X-Custom', value: 'custom-value' }) + + let count2 = 0 + pm.request.headers.each(() => { count2++ }) + console.log("Count after add:", count2) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial count:", 3] }), + expect.objectContaining({ args: ["Count after add:", 4] }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.map()", () => { + test("transforms headers and returns new array", () => { + return expect( + runPreRequestScript( + ` + const mapped = pm.request.headers.map((header) => { + return header.key.toLowerCase() + ': ' + header.value + }) + + console.log("Mapped:", mapped) + console.log("Array length:", mapped.length) + console.log("First item:", mapped[0]) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Mapped:", + [ + "content-type: application/json", + "authorization: Bearer token123", + "x-api-version: v1", + ], + ], + }), + expect.objectContaining({ args: ["Array length:", 3] }), + expect.objectContaining({ + args: ["First item:", "content-type: application/json"], + }), + ]), + }) + ) + }) + + test("does not modify original headers", () => { + return expect( + runPreRequestScript( + ` + const originalCount = pm.request.headers.count() + + const mapped = pm.request.headers.map((header) => { + return { modified: header.key } + }) + + const afterCount = pm.request.headers.count() + + console.log("Original count:", originalCount) + console.log("Mapped length:", mapped.length) + console.log("After count:", afterCount) + console.log("Headers unchanged:", originalCount === afterCount) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Original count:", 3] }), + expect.objectContaining({ args: ["Mapped length:", 3] }), + expect.objectContaining({ args: ["After count:", 3] }), + expect.objectContaining({ args: ["Headers unchanged:", true] }), + ]), + }) + ) + }) + + test("returns array with custom objects", () => { + return expect( + runPreRequestScript( + ` + const customHeaders = pm.request.headers.map((header) => ({ + name: header.key, + val: header.value, + length: header.value.length + })) + + console.log("First custom:", customHeaders[0]) + console.log("Has name:", 'name' in customHeaders[0]) + console.log("Has val:", 'val' in customHeaders[0]) + console.log("Has length:", 'length' in customHeaders[0]) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "First custom:", + { name: "Content-Type", val: "application/json", length: 16 }, + ], + }), + expect.objectContaining({ args: ["Has name:", true] }), + expect.objectContaining({ args: ["Has val:", true] }), + expect.objectContaining({ args: ["Has length:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.filter()", () => { + test("filters headers based on condition", () => { + return expect( + runPreRequestScript( + ` + const filtered = pm.request.headers.filter((header) => { + return header.key.startsWith('X-') + }) + + console.log("Filtered:", filtered) + console.log("Count:", filtered.length) + console.log("Keys:", filtered.map(h => h.key)) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Count:", 1] }), + expect.objectContaining({ args: ["Keys:", ["X-API-Version"]] }), + ]), + }) + ) + }) + + test("filters by value content", () => { + return expect( + runPreRequestScript( + ` + const filtered = pm.request.headers.filter((header) => { + return header.value.includes('Bearer') + }) + + console.log("Filtered count:", filtered.length) + console.log("Header key:", filtered[0].key) + console.log("Header value:", filtered[0].value) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Filtered count:", 1] }), + expect.objectContaining({ args: ["Header key:", "Authorization"] }), + expect.objectContaining({ + args: ["Header value:", "Bearer token123"], + }), + ]), + }) + ) + }) + + test("returns empty array when no matches", () => { + return expect( + runPreRequestScript( + ` + const filtered = pm.request.headers.filter((header) => { + return header.key === 'NonExistent' + }) + + console.log("Filtered:", filtered) + console.log("Is array:", Array.isArray(filtered)) + console.log("Length:", filtered.length) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Filtered:", []] }), + expect.objectContaining({ args: ["Is array:", true] }), + expect.objectContaining({ args: ["Length:", 0] }), + ]), + }) + ) + }) + + test("returns all headers when condition always true", () => { + return expect( + runPreRequestScript( + ` + const filtered = pm.request.headers.filter((header) => { + return true + }) + + console.log("Count:", filtered.length) + console.log("Equals total:", filtered.length === pm.request.headers.count()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Count:", 3] }), + expect.objectContaining({ args: ["Equals total:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.count()", () => { + test("returns correct count of headers", () => { + return expect( + runPreRequestScript( + ` + const count = pm.request.headers.count() + + console.log("Count:", count) + console.log("Type:", typeof count) + + let manualCount = 0 + pm.request.headers.each(() => { manualCount++ }) + console.log("Manual count:", manualCount) + console.log("Counts match:", count === manualCount) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Count:", 3] }), + expect.objectContaining({ args: ["Type:", "number"] }), + expect.objectContaining({ args: ["Manual count:", 3] }), + expect.objectContaining({ args: ["Counts match:", true] }), + ]), + }) + ) + }) + + test("updates after headers are added or removed", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial count:", pm.request.headers.count()) + + pm.request.headers.add({ key: 'X-New-Header', value: 'value' }) + console.log("After add:", pm.request.headers.count()) + + pm.request.headers.remove('Content-Type') + console.log("After remove:", pm.request.headers.count()) + + pm.request.headers.clear() + console.log("After clear:", pm.request.headers.count()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial count:", 3] }), + expect.objectContaining({ args: ["After add:", 4] }), + expect.objectContaining({ args: ["After remove:", 3] }), + expect.objectContaining({ args: ["After clear:", 0] }), + ]), + }) + ) + }) + + test("returns 0 for empty headers", () => { + const requestWithoutHeaders: HoppRESTRequest = { + ...baseRequest, + headers: [], + } + + return expect( + runPreRequestScript( + ` + const count = pm.request.headers.count() + + console.log("Count:", count) + console.log("Is zero:", count === 0) + `, + { envs, request: requestWithoutHeaders } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Count:", 0] }), + expect.objectContaining({ args: ["Is zero:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.idx()", () => { + test("returns header at specific index", () => { + return expect( + runPreRequestScript( + ` + const first = pm.request.headers.idx(0) + const second = pm.request.headers.idx(1) + const third = pm.request.headers.idx(2) + + console.log("First:", first) + console.log("Second:", second) + console.log("Third:", third) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "First:", + { + key: "Content-Type", + value: "application/json", + active: true, + description: "", + }, + ], + }), + expect.objectContaining({ + args: [ + "Second:", + { + key: "Authorization", + value: "Bearer token123", + active: true, + description: "", + }, + ], + }), + expect.objectContaining({ + args: [ + "Third:", + { + key: "X-API-Version", + value: "v1", + active: true, + description: "", + }, + ], + }), + ]), + }) + ) + }) + + test("returns null for out-of-bounds index", () => { + return expect( + runPreRequestScript( + ` + const header = pm.request.headers.idx(999) + + console.log("Out of bounds:", header) + console.log("Is null:", header === null) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Out of bounds:", null] }), + expect.objectContaining({ args: ["Is null:", true] }), + ]), + }) + ) + }) + + test("returns null for negative index", () => { + return expect( + runPreRequestScript( + ` + const header = pm.request.headers.idx(-1) + + console.log("Negative index:", header) + console.log("Is null:", header === null) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Negative index:", null] }), + expect.objectContaining({ args: ["Is null:", true] }), + ]), + }) + ) + }) + + test("can access header properties via idx", () => { + return expect( + runPreRequestScript( + ` + const header = pm.request.headers.idx(0) + + console.log("Key:", header.key) + console.log("Value:", header.value) + console.log("Active:", header.active) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Key:", "Content-Type"] }), + expect.objectContaining({ args: ["Value:", "application/json"] }), + expect.objectContaining({ args: ["Active:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.clear()", () => { + test("removes all headers", () => { + return expect( + runPreRequestScript( + ` + console.log("Count before:", pm.request.headers.count()) + console.log("Headers before:", pm.request.headers.toObject()) + + pm.request.headers.clear() + + console.log("Count after:", pm.request.headers.count()) + console.log("Headers after:", pm.request.headers.toObject()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: [], + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Count before:", 3] }), + expect.objectContaining({ args: ["Count after:", 0] }), + expect.objectContaining({ args: ["Headers after:", {}] }), + ]), + }) + ) + }) + + test("allows adding headers after clearing", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.clear() + console.log("After clear:", pm.request.headers.count()) + + pm.request.headers.add({ key: 'X-New', value: 'value' }) + console.log("After add:", pm.request.headers.count()) + console.log("Headers:", pm.request.headers.toObject()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["After clear:", 0] }), + expect.objectContaining({ args: ["After add:", 1] }), + expect.objectContaining({ args: ["Headers:", { "X-New": "value" }] }), + ]), + }) + ) + }) + + test("clear followed by get returns null", () => { + return expect( + runPreRequestScript( + ` + console.log("Before clear - Content-Type:", pm.request.headers.get('Content-Type')) + + pm.request.headers.clear() + + console.log("After clear - Content-Type:", pm.request.headers.get('Content-Type')) + console.log("After clear - has Content-Type:", pm.request.headers.has('Content-Type')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Before clear - Content-Type:", "application/json"], + }), + expect.objectContaining({ + args: ["After clear - Content-Type:", null], + }), + expect.objectContaining({ + args: ["After clear - has Content-Type:", false], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.toObject()", () => { + test("returns headers as key-value object", () => { + return expect( + runPreRequestScript( + ` + const obj = pm.request.headers.toObject() + + console.log("Headers object:", obj) + console.log("Type:", typeof obj) + console.log("Content-Type:", obj['Content-Type']) + console.log("Authorization:", obj['Authorization']) + console.log("X-API-Version:", obj['X-API-Version']) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Headers object:", + { + "Content-Type": "application/json", + Authorization: "Bearer token123", + "X-API-Version": "v1", + }, + ], + }), + expect.objectContaining({ args: ["Type:", "object"] }), + expect.objectContaining({ + args: ["Content-Type:", "application/json"], + }), + expect.objectContaining({ + args: ["Authorization:", "Bearer token123"], + }), + expect.objectContaining({ args: ["X-API-Version:", "v1"] }), + ]), + }) + ) + }) + + test("matches all() method", () => { + return expect( + runPreRequestScript( + ` + const toObjectResult = pm.request.headers.toObject() + const allResult = pm.request.headers.all() + + console.log("toObject:", toObjectResult) + console.log("all:", allResult) + console.log("Are equal:", JSON.stringify(toObjectResult) === JSON.stringify(allResult)) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Are equal:", true] }), + ]), + }) + ) + }) + + test("returns empty object for empty headers", () => { + const requestWithoutHeaders: HoppRESTRequest = { + ...baseRequest, + headers: [], + } + + return expect( + runPreRequestScript( + ` + const obj = pm.request.headers.toObject() + + console.log("Headers object:", obj) + console.log("Keys count:", Object.keys(obj).length) + console.log("Is empty:", Object.keys(obj).length === 0) + `, + { envs, request: requestWithoutHeaders } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Headers object:", {}] }), + expect.objectContaining({ args: ["Keys count:", 0] }), + expect.objectContaining({ args: ["Is empty:", true] }), + ]), + }) + ) + }) + + test("updates dynamically after mutations", () => { + return expect( + runPreRequestScript( + ` + const before = pm.request.headers.toObject() + console.log("Before:", before) + + pm.request.headers.add({ key: 'X-Custom', value: 'test' }) + + const after = pm.request.headers.toObject() + console.log("After:", after) + console.log("Has X-Custom:", 'X-Custom' in after) + console.log("X-Custom value:", after['X-Custom']) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Has X-Custom:", true] }), + expect.objectContaining({ args: ["X-Custom value:", "test"] }), + ]), + }) + ) + }) +}) + +describe("combined headers PropertyList operations", () => { + test("chaining multiple PropertyList methods", () => { + return expect( + runPreRequestScript( + ` + const filteredAndMapped = pm.request.headers + .filter(h => h.key.startsWith('X-') || h.key === 'Content-Type') + .map(h => ({ name: h.key, length: h.value.length })) + + console.log("Result:", filteredAndMapped) + console.log("Count:", filteredAndMapped.length) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Result:", + [ + { name: "Content-Type", length: 16 }, + { name: "X-API-Version", length: 2 }, + ], + ], + }), + expect.objectContaining({ args: ["Count:", 2] }), + ]), + }) + ) + }) + + test("using each to build custom structure", () => { + return expect( + runPreRequestScript( + ` + const headerMap = new Map() + + pm.request.headers.each((header) => { + headerMap.set(header.key.toLowerCase(), header.value.toUpperCase()) + }) + + console.log("Map size:", headerMap.size) + console.log("content-type:", headerMap.get('content-type')) + console.log("authorization:", headerMap.get('authorization')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Map size:", 3] }), + expect.objectContaining({ + args: ["content-type:", "APPLICATION/JSON"], + }), + expect.objectContaining({ + args: ["authorization:", "BEARER TOKEN123"], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.remove() - case insensitive", () => { + const envs: TestResult["envs"] = { + global: [], + selected: [], + } + + const baseRequest: HoppRESTRequest = { + ...getDefaultRESTRequest(), + name: "Test", + method: "GET", + endpoint: "https://example.com/api", + headers: [ + { key: "Content-Type", value: "application/json", active: true }, + { key: "Authorization", value: "Bearer token123", active: true }, + { key: "X-Custom-Header", value: "custom-value", active: true }, + ], + } + + test("removes header with exact case match", () => { + return expect( + runPreRequestScript( + ` + const hasContentType = pm.request.headers.has("Content-Type") + pm.request.headers.remove("Content-Type") + const afterRemove = pm.request.headers.has("Content-Type") + + console.log("Has before:", hasContentType) + console.log("Has after:", afterRemove) + console.log("Count after:", pm.request.headers.count()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.not.arrayContaining([ + expect.objectContaining({ key: "Content-Type" }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Has before:", true] }), + expect.objectContaining({ args: ["Has after:", false] }), + expect.objectContaining({ args: ["Count after:", 2] }), + ]), + }) + ) + }) + + test("removes header with different case (lowercase)", () => { + return expect( + runPreRequestScript( + ` + const hasAuth = pm.request.headers.has("Authorization") + // Remove with lowercase - should be case-insensitive + pm.request.headers.remove("authorization") + const afterRemove = pm.request.headers.has("Authorization") + + console.log("Has before:", hasAuth) + console.log("Has after:", afterRemove) + console.log("Count after:", pm.request.headers.count()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.not.arrayContaining([ + expect.objectContaining({ + key: expect.stringMatching(/^authorization$/i), + }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Has before:", true] }), + expect.objectContaining({ args: ["Has after:", false] }), + expect.objectContaining({ args: ["Count after:", 2] }), + ]), + }) + ) + }) + + test("removes header with different case (UPPERCASE)", () => { + return expect( + runPreRequestScript( + ` + const hasCustom = pm.request.headers.has("X-Custom-Header") + // Remove with uppercase - should be case-insensitive + pm.request.headers.remove("X-CUSTOM-HEADER") + const afterRemove = pm.request.headers.has("X-Custom-Header") + + console.log("Has before:", hasCustom) + console.log("Has after:", afterRemove) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.not.arrayContaining([ + expect.objectContaining({ + key: expect.stringMatching(/^x-custom-header$/i), + }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Has before:", true] }), + expect.objectContaining({ args: ["Has after:", false] }), + ]), + }) + ) + }) + + test("multiple remove operations with mixed case", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.remove("Content-Type") + pm.request.headers.remove("authorization") // lowercase + + const hasContentType = pm.request.headers.has("Content-Type") + const hasAuth = pm.request.headers.has("Authorization") + const hasCustom = pm.request.headers.has("X-Custom-Header") + + console.log("Final count:", pm.request.headers.count()) + console.log("Has Content-Type:", hasContentType) + console.log("Has Authorization:", hasAuth) + console.log("Has Custom:", hasCustom) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: [ + { key: "X-Custom-Header", value: "custom-value", active: true }, + ], + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Final count:", 1] }), + expect.objectContaining({ args: ["Has Content-Type:", false] }), + expect.objectContaining({ args: ["Has Authorization:", false] }), + expect.objectContaining({ args: ["Has Custom:", true] }), + ]), + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/mutations.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/mutations.spec.ts new file mode 100644 index 0000000..e089514 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/mutations.spec.ts @@ -0,0 +1,1039 @@ +import { HoppRESTAuth, HoppRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" + +import { runPreRequestScript } from "~/web" + +const baseRequest: HoppRESTRequest = { + v: "16", + name: "Test Request", + endpoint: "https://example.com/api/users?filter=active&sort=name", + method: "GET", + headers: [ + { + key: "Content-Type", + value: "application/json", + active: true, + description: "", + }, + { + key: "Authorization", + value: "Bearer token123", + active: true, + description: "", + }, + ], + params: [ + { key: "filter", value: "active", active: true, description: "" }, + { key: "sort", value: "name", active: true, description: "" }, + ], + body: { contentType: null, body: null }, + auth: { authType: "none", authActive: false }, + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, +} + +const envs = { global: [], selected: [] } + +describe("pm.request URL property mutations", () => { + test("pm.request.url.protocol mutation changes protocol from https to http", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial protocol:", pm.request.url.protocol) + console.log("Initial URL:", pm.request.url.toString()) + + pm.request.url.protocol = 'http' + + console.log("Updated protocol:", pm.request.url.protocol) + console.log("Updated URL:", pm.request.url.toString()) + console.log("hopp.request.url:", hopp.request.url) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining("http://example.com"), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial protocol:", "https"] }), + expect.objectContaining({ args: ["Updated protocol:", "http"] }), + expect.objectContaining({ + args: expect.arrayContaining([ + "Updated URL:", + expect.stringContaining("http://"), + ]), + }), + ]), + }) + ) + }) + + test("pm.request.url.host mutation changes hostname", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial host:", pm.request.url.host) + console.log("Initial hostname:", pm.request.url.host.join('.')) + + pm.request.url.host = ['api', 'newdomain', 'com'] + + console.log("Updated host:", pm.request.url.host) + console.log("Updated hostname:", pm.request.url.host.join('.')) + console.log("Updated URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining("api.newdomain.com"), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial host:", ["example", "com"]], + }), + expect.objectContaining({ + args: ["Updated host:", ["api", "newdomain", "com"]], + }), + expect.objectContaining({ + args: ["Updated hostname:", "api.newdomain.com"], + }), + ]), + }) + ) + }) + + test("pm.request.url.path mutation changes URL path", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial path:", pm.request.url.path) + + pm.request.url.path = ['v2', 'posts', '123'] + + console.log("Updated path:", pm.request.url.path) + console.log("Updated URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining("/v2/posts/123"), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial path:", ["api", "users"]], + }), + expect.objectContaining({ + args: ["Updated path:", ["v2", "posts", "123"]], + }), + ]), + }) + ) + }) + + test("pm.request.url.port mutation changes port", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial port:", pm.request.url.port) + + pm.request.url.port = '8080' + + console.log("Updated port:", pm.request.url.port) + console.log("Updated URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining(":8080"), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial port:", "443"] }), + expect.objectContaining({ args: ["Updated port:", "8080"] }), + ]), + }) + ) + }) + + test("multiple URL property mutations are applied sequentially", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial URL:", pm.request.url.toString()) + + pm.request.url.protocol = 'http' + console.log("After protocol:", pm.request.url.toString()) + + pm.request.url.host = ['api', 'test', 'com'] + console.log("After host:", pm.request.url.toString()) + + pm.request.url.path = ['v3', 'data'] + console.log("After path:", pm.request.url.toString()) + + pm.request.url.port = '3000' + console.log("Final URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "http://api.test.com:3000/v3/data?filter=active&sort=name", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: expect.arrayContaining([ + "Final URL:", + "http://api.test.com:3000/v3/data?filter=active&sort=name", + ]), + }), + ]), + }) + ) + }) + + test("pm.request.url.toString() returns dynamically updated URL", () => { + return expect( + runPreRequestScript( + ` + const url1 = pm.request.url.toString() + console.log("URL before mutation:", url1) + + pm.request.url.protocol = 'http' + pm.request.url.path = ['updated'] + + const url2 = pm.request.url.toString() + console.log("URL after mutation:", url2) + + console.log("URLs are different:", url1 !== url2) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "URL before mutation:", + "https://example.com/api/users?filter=active&sort=name", + ], + }), + expect.objectContaining({ + args: [ + "URL after mutation:", + "http://example.com/updated?filter=active&sort=name", + ], + }), + expect.objectContaining({ + args: ["URLs are different:", true], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query methods", () => { + test("pm.request.url.query.add() adds new query parameter", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial query params:", pm.request.url.query.all()) + + pm.request.url.query.add({ key: 'limit', value: '10' }) + + console.log("Updated query params:", pm.request.url.query.all()) + console.log("Updated URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining("limit=10"), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial query params:", { filter: "active", sort: "name" }], + }), + expect.objectContaining({ + args: [ + "Updated query params:", + { filter: "active", sort: "name", limit: "10" }, + ], + }), + ]), + }) + ) + }) + + test("pm.request.url.query.add() with empty value", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.add({ key: 'flag' }) + + console.log("Query params:", pm.request.url.query.all()) + console.log("URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining("flag="), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Query params:", + { filter: "active", sort: "name", flag: "" }, + ], + }), + ]), + }) + ) + }) + + test("pm.request.url.query.remove() removes existing query parameter", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial query params:", pm.request.url.query.all()) + + pm.request.url.query.remove('filter') + + console.log("Updated query params:", pm.request.url.query.all()) + console.log("Updated URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://example.com/api/users?sort=name", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Updated query params:", { sort: "name" }], + }), + ]), + }) + ) + }) + + test("pm.request.url.query.remove() handles non-existent parameter gracefully", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial query params:", pm.request.url.query.all()) + + pm.request.url.query.remove('nonexistent') + + console.log("Updated query params:", pm.request.url.query.all()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: baseRequest.endpoint, + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Updated query params:", { filter: "active", sort: "name" }], + }), + ]), + }) + ) + }) + + test("pm.request.url.query.all() returns all query parameters as object", () => { + return expect( + runPreRequestScript( + ` + const allParams = pm.request.url.query.all() + console.log("All params:", allParams) + console.log("Filter param:", allParams.filter) + console.log("Sort param:", allParams.sort) + console.log("Param count:", Object.keys(allParams).length) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["All params:", { filter: "active", sort: "name" }], + }), + expect.objectContaining({ + args: ["Filter param:", "active"], + }), + expect.objectContaining({ + args: ["Sort param:", "name"], + }), + expect.objectContaining({ + args: ["Param count:", 2], + }), + ]), + }) + ) + }) + + test("multiple query mutations work correctly", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial:", pm.request.url.query.all()) + + pm.request.url.query.add({ key: 'page', value: '1' }) + console.log("After add:", pm.request.url.query.all()) + + pm.request.url.query.add({ key: 'limit', value: '20' }) + console.log("After second add:", pm.request.url.query.all()) + + pm.request.url.query.remove('sort') + console.log("After remove:", pm.request.url.query.all()) + + console.log("Final URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: + "https://example.com/api/users?filter=active&page=1&limit=20", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "After remove:", + { filter: "active", page: "1", limit: "20" }, + ], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.url string assignment", () => { + test("pm.request.url string assignment replaces entire URL", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial URL:", pm.request.url.toString()) + + pm.request.url = "http://newapi.example.com:8080/v2/posts?id=5" + + console.log("Updated URL:", pm.request.url.toString()) + console.log("Updated protocol:", pm.request.url.protocol) + console.log("Updated host:", pm.request.url.host) + console.log("Updated path:", pm.request.url.path) + console.log("Updated query:", pm.request.url.query.all()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "http://newapi.example.com:8080/v2/posts?id=5", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Updated protocol:", "http"] }), + expect.objectContaining({ + args: ["Updated host:", ["newapi", "example", "com"]], + }), + expect.objectContaining({ args: ["Updated path:", ["v2", "posts"]] }), + expect.objectContaining({ args: ["Updated query:", { id: "5" }] }), + ]), + }) + ) + }) + + test("pm.request.url string assignment followed by property mutations", () => { + return expect( + runPreRequestScript( + ` + pm.request.url = "https://api.example.com/users" + console.log("After string assignment:", pm.request.url.toString()) + + pm.request.url.path = ['posts', '42'] + console.log("After path mutation:", pm.request.url.toString()) + + pm.request.url.query.add({ key: 'include', value: 'comments' }) + console.log("Final URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com/posts/42?include=comments", + }), + }) + ) + }) +}) + +describe("pm.request method mutations", () => { + test("pm.request.method mutation changes HTTP method", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial method:", pm.request.method) + + pm.request.method = 'POST' + + console.log("Updated method:", pm.request.method) + console.log("hopp.request.method:", hopp.request.method) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + method: "POST", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial method:", "GET"] }), + expect.objectContaining({ args: ["Updated method:", "POST"] }), + expect.objectContaining({ args: ["hopp.request.method:", "POST"] }), + ]), + }) + ) + }) + + test("pm.request.method preserves case (matches Postman)", () => { + return expect( + runPreRequestScript( + ` + pm.request.method = 'put' + console.log("Method (lowercase input):", pm.request.method) + + pm.request.method = 'DeLeTe' + console.log("Method (mixed case input):", pm.request.method) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + method: "DeLeTe", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Method (lowercase input):", "put"], + }), + expect.objectContaining({ + args: ["Method (mixed case input):", "DeLeTe"], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers mutations", () => { + test("pm.request.headers.add() adds new header", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial X-Custom-Header:", pm.request.headers.get("X-Custom-Header")) + + pm.request.headers.add({ key: 'X-Custom-Header', value: 'custom-value' }) + + console.log("Updated X-Custom-Header:", pm.request.headers.get("X-Custom-Header")) + console.log("Has X-Custom-Header:", pm.request.headers.has("X-Custom-Header")) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.arrayContaining([ + expect.objectContaining({ + key: "X-Custom-Header", + value: "custom-value", + }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial X-Custom-Header:", null] }), + expect.objectContaining({ + args: ["Updated X-Custom-Header:", "custom-value"], + }), + expect.objectContaining({ args: ["Has X-Custom-Header:", true] }), + ]), + }) + ) + }) + + test("pm.request.headers.upsert() updates existing header", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial Content-Type:", pm.request.headers.get("Content-Type")) + + pm.request.headers.upsert({ key: 'Content-Type', value: 'application/xml' }) + + console.log("Updated Content-Type:", pm.request.headers.get("Content-Type")) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.arrayContaining([ + expect.objectContaining({ + key: "Content-Type", + value: "application/xml", + }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial Content-Type:", "application/json"], + }), + expect.objectContaining({ + args: ["Updated Content-Type:", "application/xml"], + }), + ]), + }) + ) + }) + + test("pm.request.headers.upsert() adds header if not exists", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial X-New-Header:", pm.request.headers.get("X-New-Header")) + + pm.request.headers.upsert({ key: 'X-New-Header', value: 'new-value' }) + + console.log("Updated X-New-Header:", pm.request.headers.get("X-New-Header")) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.arrayContaining([ + expect.objectContaining({ + key: "X-New-Header", + value: "new-value", + }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial X-New-Header:", null] }), + expect.objectContaining({ + args: ["Updated X-New-Header:", "new-value"], + }), + ]), + }) + ) + }) + + test("pm.request.headers.remove() removes header", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial Authorization:", pm.request.headers.get("Authorization")) + console.log("Initial has Authorization:", pm.request.headers.has("Authorization")) + + pm.request.headers.remove("Authorization") + + console.log("Updated Authorization:", pm.request.headers.get("Authorization")) + console.log("Updated has Authorization:", pm.request.headers.has("Authorization")) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.not.arrayContaining([ + expect.objectContaining({ key: "Authorization" }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial Authorization:", "Bearer token123"], + }), + expect.objectContaining({ + args: ["Initial has Authorization:", true], + }), + expect.objectContaining({ args: ["Updated Authorization:", null] }), + expect.objectContaining({ + args: ["Updated has Authorization:", false], + }), + ]), + }) + ) + }) + + test("multiple header mutations work correctly", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.add({ key: 'X-Header-1', value: 'value1' }) + pm.request.headers.add({ key: 'X-Header-2', value: 'value2' }) + console.log("After adds - X-Header-1:", pm.request.headers.get("X-Header-1")) + console.log("After adds - X-Header-2:", pm.request.headers.get("X-Header-2")) + + pm.request.headers.upsert({ key: 'X-Header-1', value: 'updated-value1' }) + console.log("After upsert - X-Header-1:", pm.request.headers.get("X-Header-1")) + + pm.request.headers.remove('X-Header-2') + console.log("After remove - X-Header-2:", pm.request.headers.get("X-Header-2")) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.arrayContaining([ + expect.objectContaining({ + key: "X-Header-1", + value: "updated-value1", + }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["After upsert - X-Header-1:", "updated-value1"], + }), + expect.objectContaining({ + args: ["After remove - X-Header-2:", null], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.body mutations", () => { + test("pm.request.body.update() changes request body with Postman format", () => { + const requestWithBody: HoppRESTRequest = { + ...baseRequest, + body: { + contentType: "application/json", + body: '{"name": "John"}', + }, + } + + return expect( + runPreRequestScript( + ` + console.log("Initial body:", pm.request.body) + + pm.request.body.update({ + mode: 'raw', + raw: '{"name": "Jane", "age": 30}', + options: { raw: { language: 'json' } } + }) + + console.log("Updated body:", pm.request.body) + console.log("hopp.request.body:", hopp.request.body) + `, + { envs, request: requestWithBody } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + body: { + contentType: "application/json", + body: '{"name": "Jane", "age": 30}', + }, + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: expect.arrayContaining([ + "Initial body:", + expect.objectContaining({ contentType: "application/json" }), + ]), + }), + expect.objectContaining({ + args: expect.arrayContaining([ + "Updated body:", + expect.objectContaining({ + contentType: "application/json", + body: '{"name": "Jane", "age": 30}', + }), + ]), + }), + ]), + }) + ) + }) + + test("pm.request.body.update() with string directly", () => { + const requestWithBody: HoppRESTRequest = { + ...baseRequest, + body: { + contentType: "application/json", + body: '{"data": "original"}', + }, + } + + return expect( + runPreRequestScript( + ` + console.log("Initial body:", pm.request.body) + + pm.request.body.update('plain text body') + + console.log("Updated body:", pm.request.body) + console.log("Content-Type:", pm.request.body.contentType) + `, + { envs, request: requestWithBody } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + body: { + contentType: "text/plain", + body: "plain text body", + }, + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Content-Type:", "text/plain"] }), + ]), + }) + ) + }) +}) + +describe("pm.request.auth mutations", () => { + test("pm.request.auth setter changes authentication", () => { + const newAuth: HoppRESTAuth = { + authType: "bearer", + token: "new-bearer-token-xyz", + authActive: true, + } + + return expect( + runPreRequestScript( + ` + console.log("Initial auth type:", pm.request.auth.authType) + + pm.request.auth = ${JSON.stringify(newAuth)} + + console.log("Updated auth type:", pm.request.auth.authType) + console.log("Updated auth token:", pm.request.auth.token) + console.log("hopp.request.auth:", hopp.request.auth) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + auth: newAuth, + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial auth type:", "none"] }), + expect.objectContaining({ args: ["Updated auth type:", "bearer"] }), + expect.objectContaining({ + args: ["Updated auth token:", "new-bearer-token-xyz"], + }), + ]), + }) + ) + }) + + test("pm.request.auth setter replaces entire auth object", () => { + const requestWithAuth: HoppRESTRequest = { + ...baseRequest, + auth: { + authType: "bearer", + token: "original-token", + authActive: true, + }, + } + + return expect( + runPreRequestScript( + ` + console.log("Initial auth:", pm.request.auth) + + pm.request.auth = { + authType: 'basic', + username: 'user', + password: 'pass', + authActive: true + } + + console.log("Updated auth:", pm.request.auth) + console.log("Auth type changed:", pm.request.auth.authType) + `, + { envs, request: requestWithAuth } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + auth: expect.objectContaining({ + authType: "basic", + username: "user", + password: "pass", + }), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Auth type changed:", "basic"] }), + ]), + }) + ) + }) + + test("pm.request.auth can be set to null to disable auth", () => { + const requestWithAuth: HoppRESTRequest = { + ...baseRequest, + auth: { + authType: "bearer", + token: "some-token", + authActive: true, + }, + } + + return expect( + runPreRequestScript( + ` + console.log("Initial auth active:", pm.request.auth.authActive) + console.log("Initial auth type:", pm.request.auth.authType) + + pm.request.auth = null + + console.log("Updated auth active:", pm.request.auth.authActive) + console.log("Updated auth type:", pm.request.auth.authType) + `, + { envs, request: requestWithAuth } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + auth: { + authType: "none", + authActive: false, + }, + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial auth active:", true] }), + expect.objectContaining({ args: ["Updated auth active:", false] }), + expect.objectContaining({ args: ["Updated auth type:", "none"] }), + ]), + }) + ) + }) +}) + +describe("pm.request combined mutations", () => { + test("multiple different mutation types work together", () => { + return expect( + runPreRequestScript( + ` + console.log("=== Initial State ===") + console.log("URL:", pm.request.url.toString()) + console.log("Method:", pm.request.method) + console.log("Content-Type:", pm.request.headers.get("Content-Type")) + + // Change URL + pm.request.url = "https://api.example.com/v2/data" + pm.request.url.protocol = 'http' + pm.request.url.query.add({ key: 'page', value: '1' }) + + // Change method + pm.request.method = 'POST' + + // Change headers + pm.request.headers.upsert({ key: 'Content-Type', value: 'application/xml' }) + pm.request.headers.add({ key: 'X-API-Key', value: 'secret123' }) + + console.log("=== Final State ===") + console.log("URL:", pm.request.url.toString()) + console.log("Method:", pm.request.method) + console.log("Content-Type:", pm.request.headers.get("Content-Type")) + console.log("X-API-Key:", pm.request.headers.get("X-API-Key")) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "http://api.example.com/v2/data?page=1", + method: "POST", + headers: expect.arrayContaining([ + expect.objectContaining({ + key: "Content-Type", + value: "application/xml", + }), + expect.objectContaining({ key: "X-API-Key", value: "secret123" }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["URL:", "http://api.example.com/v2/data?page=1"], + }), + expect.objectContaining({ args: ["Method:", "POST"] }), + expect.objectContaining({ + args: ["Content-Type:", "application/xml"], + }), + expect.objectContaining({ args: ["X-API-Key:", "secret123"] }), + ]), + }) + ) + }) + + test("mutations persist across multiple reads", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.protocol = 'http' + const url1 = pm.request.url.toString() + + pm.request.url.path = ['updated', 'path'] + const url2 = pm.request.url.toString() + + pm.request.url.query.add({ key: 'test', value: 'value' }) + const url3 = pm.request.url.toString() + + console.log("URL after 1st mutation:", url1) + console.log("URL after 2nd mutation:", url2) + console.log("URL after 3rd mutation:", url3) + console.log("Current URL:", pm.request.url.toString()) + console.log("hopp.request.url:", hopp.request.url) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: expect.arrayContaining([ + "URL after 1st mutation:", + expect.stringContaining("http://"), + ]), + }), + expect.objectContaining({ + args: expect.arrayContaining([ + "URL after 2nd mutation:", + expect.stringContaining("/updated/path"), + ]), + }), + expect.objectContaining({ + args: expect.arrayContaining([ + "URL after 3rd mutation:", + expect.stringContaining("test=value"), + ]), + }), + ]), + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/query/propertylist.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/query/propertylist.spec.ts new file mode 100644 index 0000000..58e59d5 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/query/propertylist.spec.ts @@ -0,0 +1,1190 @@ +import { HoppRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" + +import { runPreRequestScript } from "~/web" + +const baseRequest: HoppRESTRequest = { + v: "16", + name: "Test Request", + endpoint: "https://api.example.com/users?filter=active&sort=name&page=1", + method: "GET", + headers: [], + params: [ + { key: "filter", value: "active", active: true, description: "" }, + { key: "sort", value: "name", active: true, description: "" }, + { key: "page", value: "1", active: true, description: "" }, + ], + body: { contentType: null, body: null }, + auth: { authType: "none", authActive: false }, + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, +} + +const envs = { global: [], selected: [] } + +describe("pm.request.url.query.get()", () => { + test("returns value for existing parameter", () => { + return expect( + runPreRequestScript( + ` + const filterValue = pm.request.url.query.get('filter') + const sortValue = pm.request.url.query.get('sort') + + console.log("Filter value:", filterValue) + console.log("Sort value:", sortValue) + console.log("Filter type:", typeof filterValue) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Filter value:", "active"] }), + expect.objectContaining({ args: ["Sort value:", "name"] }), + expect.objectContaining({ args: ["Filter type:", "string"] }), + ]), + }) + ) + }) + + test("returns null for non-existent parameter", () => { + return expect( + runPreRequestScript( + ` + const value = pm.request.url.query.get('nonexistent') + + console.log("Non-existent value:", value) + console.log("Is null:", value === null) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Non-existent value:", null] }), + expect.objectContaining({ args: ["Is null:", true] }), + ]), + }) + ) + }) + + test("updates after parameter is added", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial limit:", pm.request.url.query.get('limit')) + + pm.request.url.query.add({ key: 'limit', value: '20' }) + + console.log("Updated limit:", pm.request.url.query.get('limit')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial limit:", null] }), + expect.objectContaining({ args: ["Updated limit:", "20"] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.has()", () => { + test("returns true for existing parameters", () => { + return expect( + runPreRequestScript( + ` + console.log("Has filter:", pm.request.url.query.has('filter')) + console.log("Has sort:", pm.request.url.query.has('sort')) + console.log("Has page:", pm.request.url.query.has('page')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Has filter:", true] }), + expect.objectContaining({ args: ["Has sort:", true] }), + expect.objectContaining({ args: ["Has page:", true] }), + ]), + }) + ) + }) + + test("returns false for non-existent parameters", () => { + return expect( + runPreRequestScript( + ` + console.log("Has limit:", pm.request.url.query.has('limit')) + console.log("Has offset:", pm.request.url.query.has('offset')) + console.log("Has nonexistent:", pm.request.url.query.has('nonexistent')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Has limit:", false] }), + expect.objectContaining({ args: ["Has offset:", false] }), + expect.objectContaining({ args: ["Has nonexistent:", false] }), + ]), + }) + ) + }) + + test("updates after parameters are modified", () => { + return expect( + runPreRequestScript( + ` + console.log("Has status (before):", pm.request.url.query.has('status')) + + pm.request.url.query.add({ key: 'status', value: 'published' }) + + console.log("Has status (after add):", pm.request.url.query.has('status')) + + pm.request.url.query.remove('status') + + console.log("Has status (after remove):", pm.request.url.query.has('status')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Has status (before):", false] }), + expect.objectContaining({ args: ["Has status (after add):", true] }), + expect.objectContaining({ + args: ["Has status (after remove):", false], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.upsert()", () => { + test("adds new parameter when it doesn't exist", () => { + return expect( + runPreRequestScript( + ` + console.log("Has limit (before):", pm.request.url.query.has('limit')) + + pm.request.url.query.upsert({ key: 'limit', value: '50' }) + + console.log("Has limit (after):", pm.request.url.query.has('limit')) + console.log("Limit value:", pm.request.url.query.get('limit')) + console.log("URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining("limit=50"), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Has limit (before):", false] }), + expect.objectContaining({ args: ["Has limit (after):", true] }), + expect.objectContaining({ args: ["Limit value:", "50"] }), + ]), + }) + ) + }) + + test("updates existing parameter", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial filter:", pm.request.url.query.get('filter')) + + pm.request.url.query.upsert({ key: 'filter', value: 'inactive' }) + + console.log("Updated filter:", pm.request.url.query.get('filter')) + console.log("URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining("filter=inactive"), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial filter:", "active"] }), + expect.objectContaining({ args: ["Updated filter:", "inactive"] }), + ]), + }) + ) + }) + + test("handles empty value", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.upsert({ key: 'flag' }) + + console.log("Flag value:", pm.request.url.query.get('flag')) + console.log("Flag exists:", pm.request.url.query.has('flag')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Flag value:", ""] }), + expect.objectContaining({ args: ["Flag exists:", true] }), + ]), + }) + ) + }) + + test("throws error for missing key", async () => { + const result = await runPreRequestScript( + `pm.request.url.query.upsert({ value: 'test' })`, + { + envs, + request: baseRequest, + cookies: null, + experimentalScriptingSandbox: true, + } + ) + expect(result).toEqualLeft( + expect.stringContaining("must have a 'key' property") + ) + }) +}) + +describe("pm.request.url.query.clear()", () => { + test("removes all query parameters", () => { + return expect( + runPreRequestScript( + ` + console.log("Params before:", pm.request.url.query.all()) + console.log("Count before:", pm.request.url.query.count()) + + pm.request.url.query.clear() + + console.log("Params after:", pm.request.url.query.all()) + console.log("Count after:", pm.request.url.query.count()) + console.log("URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com/users", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Params before:", + { filter: "active", sort: "name", page: "1" }, + ], + }), + expect.objectContaining({ args: ["Count before:", 3] }), + expect.objectContaining({ args: ["Params after:", {}] }), + expect.objectContaining({ args: ["Count after:", 0] }), + ]), + }) + ) + }) + + test("allows adding parameters after clearing", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.clear() + console.log("After clear:", pm.request.url.query.all()) + + pm.request.url.query.add({ key: 'new', value: 'param' }) + console.log("After add:", pm.request.url.query.all()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["After clear:", {}] }), + expect.objectContaining({ args: ["After add:", { new: "param" }] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.each()", () => { + test("iterates over all parameters", () => { + return expect( + runPreRequestScript( + ` + const keys = [] + const values = [] + + pm.request.url.query.each((param) => { + keys.push(param.key) + values.push(param.value) + }) + + console.log("Keys:", keys) + console.log("Values:", values) + console.log("Count:", keys.length) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Keys:", ["filter", "sort", "page"]], + }), + expect.objectContaining({ + args: ["Values:", ["active", "name", "1"]], + }), + expect.objectContaining({ args: ["Count:", 3] }), + ]), + }) + ) + }) + + test("callback receives correct parameter object", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.each((param) => { + console.log("Key:", param.key) + console.log("Value:", param.value) + return // Check only first param + }) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Key:", "filter"] }), + expect.objectContaining({ args: ["Value:", "active"] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.map()", () => { + test("transforms parameters and returns new array", () => { + return expect( + runPreRequestScript( + ` + const mapped = pm.request.url.query.map((param) => { + return param.key.toUpperCase() + '=' + param.value + }) + + console.log("Mapped:", mapped) + console.log("Array length:", mapped.length) + console.log("First item:", mapped[0]) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Mapped:", ["FILTER=active", "SORT=name", "PAGE=1"]], + }), + expect.objectContaining({ args: ["Array length:", 3] }), + expect.objectContaining({ args: ["First item:", "FILTER=active"] }), + ]), + }) + ) + }) + + test("does not modify original parameters", () => { + return expect( + runPreRequestScript( + ` + const originalParams = pm.request.url.query.all() + + const mapped = pm.request.url.query.map((param) => { + return { modified: param.key } + }) + + const afterParams = pm.request.url.query.all() + + console.log("Original:", originalParams) + console.log("Mapped:", mapped) + console.log("After:", afterParams) + console.log("Params unchanged:", JSON.stringify(originalParams) === JSON.stringify(afterParams)) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Original:", { filter: "active", sort: "name", page: "1" }], + }), + expect.objectContaining({ + args: ["After:", { filter: "active", sort: "name", page: "1" }], + }), + expect.objectContaining({ args: ["Params unchanged:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.filter()", () => { + test("filters parameters based on condition", () => { + return expect( + runPreRequestScript( + ` + const filtered = pm.request.url.query.filter((param) => { + return param.key.includes('sort') || param.key.includes('page') + }) + + console.log("Filtered:", filtered) + console.log("Count:", filtered.length) + console.log("Keys:", filtered.map(p => p.key)) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Count:", 2] }), + expect.objectContaining({ args: ["Keys:", ["sort", "page"]] }), + ]), + }) + ) + }) + + test("returns empty array when no matches", () => { + return expect( + runPreRequestScript( + ` + const filtered = pm.request.url.query.filter((param) => { + return param.key === 'nonexistent' + }) + + console.log("Filtered:", filtered) + console.log("Is array:", Array.isArray(filtered)) + console.log("Length:", filtered.length) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Filtered:", []] }), + expect.objectContaining({ args: ["Is array:", true] }), + expect.objectContaining({ args: ["Length:", 0] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.count()", () => { + test("returns correct count of parameters", () => { + return expect( + runPreRequestScript( + ` + const count = pm.request.url.query.count() + + console.log("Count:", count) + console.log("Type:", typeof count) + console.log("Matches all():", count === Object.keys(pm.request.url.query.all()).length) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Count:", 3] }), + expect.objectContaining({ args: ["Type:", "number"] }), + expect.objectContaining({ args: ["Matches all():", true] }), + ]), + }) + ) + }) + + test("updates after parameters are added or removed", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial count:", pm.request.url.query.count()) + + pm.request.url.query.add({ key: 'limit', value: '20' }) + console.log("After add:", pm.request.url.query.count()) + + pm.request.url.query.remove('filter') + console.log("After remove:", pm.request.url.query.count()) + + pm.request.url.query.clear() + console.log("After clear:", pm.request.url.query.count()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial count:", 3] }), + expect.objectContaining({ args: ["After add:", 4] }), + expect.objectContaining({ args: ["After remove:", 3] }), + expect.objectContaining({ args: ["After clear:", 0] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.idx()", () => { + test("returns parameter at specific index", () => { + return expect( + runPreRequestScript( + ` + const first = pm.request.url.query.idx(0) + const second = pm.request.url.query.idx(1) + const third = pm.request.url.query.idx(2) + + console.log("First:", first) + console.log("Second:", second) + console.log("Third:", third) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["First:", { key: "filter", value: "active" }], + }), + expect.objectContaining({ + args: ["Second:", { key: "sort", value: "name" }], + }), + expect.objectContaining({ + args: ["Third:", { key: "page", value: "1" }], + }), + ]), + }) + ) + }) + + test("returns null for out-of-bounds index", () => { + return expect( + runPreRequestScript( + ` + const param = pm.request.url.query.idx(999) + + console.log("Out of bounds:", param) + console.log("Is null:", param === null) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Out of bounds:", null] }), + expect.objectContaining({ args: ["Is null:", true] }), + ]), + }) + ) + }) + + test("returns null for negative index", () => { + return expect( + runPreRequestScript( + ` + const param = pm.request.url.query.idx(-1) + + console.log("Negative index:", param) + console.log("Is null:", param === null) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Negative index:", null] }), + expect.objectContaining({ args: ["Is null:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.toObject()", () => { + test("returns parameters as object (alias for all())", () => { + return expect( + runPreRequestScript( + ` + const obj = pm.request.url.query.toObject() + const all = pm.request.url.query.all() + + console.log("toObject:", obj) + console.log("all:", all) + console.log("Are equal:", JSON.stringify(obj) === JSON.stringify(all)) + console.log("Type:", typeof obj) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["toObject:", { filter: "active", sort: "name", page: "1" }], + }), + expect.objectContaining({ + args: ["all:", { filter: "active", sort: "name", page: "1" }], + }), + expect.objectContaining({ args: ["Are equal:", true] }), + expect.objectContaining({ args: ["Type:", "object"] }), + ]), + }) + ) + }) +}) + +describe("duplicate query parameter handling", () => { + test("handles duplicate parameter keys by converting to array", () => { + const requestWithDuplicates: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users?tag=js&tag=ts&tag=go", + params: [ + { key: "tag", value: "js", active: true, description: "" }, + { key: "tag", value: "ts", active: true, description: "" }, + { key: "tag", value: "go", active: true, description: "" }, + ], + } + + return expect( + runPreRequestScript( + ` + const params = pm.request.url.query.all() + + console.log("Params:", params) + console.log("Tag value:", params.tag) + console.log("Is array:", Array.isArray(params.tag)) + console.log("Array length:", params.tag.length) + console.log("All values:", params.tag) + `, + { envs, request: requestWithDuplicates } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Tag value:", ["js", "ts", "go"]] }), + expect.objectContaining({ args: ["Is array:", true] }), + expect.objectContaining({ args: ["Array length:", 3] }), + expect.objectContaining({ + args: ["All values:", ["js", "ts", "go"]], + }), + ]), + }) + ) + }) + + test("handles mixed duplicate and unique parameters", () => { + const requestWithMixed: HoppRESTRequest = { + ...baseRequest, + endpoint: + "https://api.example.com/users?filter=active&tag=js&tag=ts&sort=name", + params: [ + { key: "filter", value: "active", active: true, description: "" }, + { key: "tag", value: "js", active: true, description: "" }, + { key: "tag", value: "ts", active: true, description: "" }, + { key: "sort", value: "name", active: true, description: "" }, + ], + } + + return expect( + runPreRequestScript( + ` + const params = pm.request.url.query.all() + + console.log("All params:", params) + console.log("filter is string:", typeof params.filter === 'string') + console.log("tag is array:", Array.isArray(params.tag)) + console.log("sort is string:", typeof params.sort === 'string') + `, + { envs, request: requestWithMixed } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All params:", + { filter: "active", tag: ["js", "ts"], sort: "name" }, + ], + }), + expect.objectContaining({ args: ["filter is string:", true] }), + expect.objectContaining({ args: ["tag is array:", true] }), + expect.objectContaining({ args: ["sort is string:", true] }), + ]), + }) + ) + }) + + test("get() returns first value for duplicate keys", () => { + const requestWithDuplicates: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users?tag=first&tag=second", + params: [ + { key: "tag", value: "first", active: true, description: "" }, + { key: "tag", value: "second", active: true, description: "" }, + ], + } + + return expect( + runPreRequestScript( + ` + const value = pm.request.url.query.get('tag') + + console.log("get() value:", value) + console.log("Is first value:", value === 'first') + console.log("all() value:", pm.request.url.query.all().tag) + `, + { envs, request: requestWithDuplicates } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["get() value:", "first"] }), + expect.objectContaining({ args: ["Is first value:", true] }), + expect.objectContaining({ + args: ["all() value:", ["first", "second"]], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.find()", () => { + test("finds parameter by predicate function", () => { + return expect( + runPreRequestScript( + ` + const result = pm.request.url.query.find((param) => param.key === 'sort') + + console.log("Found param:", result) + console.log("Param key:", result.key) + console.log("Param value:", result.value) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Found param:", + expect.objectContaining({ key: "sort", value: "name" }), + ], + }), + expect.objectContaining({ args: ["Param key:", "sort"] }), + expect.objectContaining({ args: ["Param value:", "name"] }), + ]), + }) + ) + }) + + test("finds parameter by key string", () => { + return expect( + runPreRequestScript( + ` + const result = pm.request.url.query.find('filter') + + console.log("Found by string:", result) + console.log("Value:", result.value) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Found by string:", + expect.objectContaining({ key: "filter", value: "active" }), + ], + }), + expect.objectContaining({ args: ["Value:", "active"] }), + ]), + }) + ) + }) + + test("returns null when parameter not found", () => { + return expect( + runPreRequestScript( + ` + const result = pm.request.url.query.find('nonexistent') + + console.log("Result:", result) + console.log("Is null:", result === null) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Result:", null] }), + expect.objectContaining({ args: ["Is null:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.indexOf()", () => { + test("returns index of parameter by key", () => { + return expect( + runPreRequestScript( + ` + const idx1 = pm.request.url.query.indexOf('filter') + const idx2 = pm.request.url.query.indexOf('sort') + const idx3 = pm.request.url.query.indexOf('page') + + console.log("Index of filter:", idx1) + console.log("Index of sort:", idx2) + console.log("Index of page:", idx3) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Index of filter:", 0] }), + expect.objectContaining({ args: ["Index of sort:", 1] }), + expect.objectContaining({ args: ["Index of page:", 2] }), + ]), + }) + ) + }) + + test("returns index of parameter by object", () => { + return expect( + runPreRequestScript( + ` + const idx = pm.request.url.query.indexOf({ key: 'sort' }) + + console.log("Index:", idx) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Index:", 1] }), + ]), + }) + ) + }) + + test("returns -1 when parameter not found", () => { + return expect( + runPreRequestScript( + ` + const idx = pm.request.url.query.indexOf('nonexistent') + + console.log("Index:", idx) + console.log("Is -1:", idx === -1) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Index:", -1] }), + expect.objectContaining({ args: ["Is -1:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.insert()", () => { + test("inserts parameter before specified key", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.insert({ key: 'limit', value: '10' }, 'page') + + const allParams = pm.request.url.query.map((p) => p.key) + console.log("All params:", allParams) + console.log("URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["All params:", ["filter", "sort", "limit", "page"]], + }), + expect.objectContaining({ + args: [ + "URL:", + "https://api.example.com/users?filter=active&sort=name&limit=10&page=1", + ], + }), + ]), + }) + ) + }) + + test("appends parameter if 'before' key not found", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.insert({ key: 'limit', value: '10' }, 'nonexistent') + + const allParams = pm.request.url.query.map((p) => p.key) + console.log("All params:", allParams) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["All params:", ["filter", "sort", "page", "limit"]], + }), + ]), + }) + ) + }) + + test("appends parameter when no 'before' specified", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.insert({ key: 'limit', value: '10' }) + + const allParams = pm.request.url.query.map((p) => p.key) + console.log("All params:", allParams) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["All params:", ["filter", "sort", "page", "limit"]], + }), + ]), + }) + ) + }) + + test("throws error when item has no key", async () => { + const result = await runPreRequestScript( + `pm.request.url.query.insert({ value: '10' })`, + { + envs, + request: baseRequest, + cookies: null, + experimentalScriptingSandbox: true, + } + ) + + expect(result).toEqualLeft( + expect.stringContaining("must have a 'key' property") + ) + }) +}) + +describe("pm.request.url.query.append()", () => { + test("moves existing parameter to end", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.append({ key: 'filter', value: 'updated' }) + + const allParams = pm.request.url.query.map((p) => p.key) + const filterValue = pm.request.url.query.get('filter') + + console.log("All params:", allParams) + console.log("Filter value:", filterValue) + console.log("URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["All params:", ["sort", "page", "filter"]], + }), + expect.objectContaining({ args: ["Filter value:", "updated"] }), + expect.objectContaining({ + args: [ + "URL:", + "https://api.example.com/users?sort=name&page=1&filter=updated", + ], + }), + ]), + }) + ) + }) + + test("adds new parameter at end", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.append({ key: 'limit', value: '10' }) + + const allParams = pm.request.url.query.map((p) => p.key) + console.log("All params:", allParams) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["All params:", ["filter", "sort", "page", "limit"]], + }), + ]), + }) + ) + }) + + test("throws error when item has no key", async () => { + const result = await runPreRequestScript( + `pm.request.url.query.append({ value: '10' })`, + { + envs, + request: baseRequest, + cookies: null, + experimentalScriptingSandbox: true, + } + ) + + expect(result).toEqualLeft( + expect.stringContaining("must have a 'key' property") + ) + }) +}) + +describe("pm.request.url.query.assimilate()", () => { + test("updates existing parameters and adds new ones from array", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.assimilate([ + { key: 'filter', value: 'updated' }, + { key: 'limit', value: '20' } + ]) + + const allParams = pm.request.url.query.all() + console.log("Filter:", allParams.filter) + console.log("Sort:", allParams.sort) + console.log("Limit:", allParams.limit) + console.log("Count:", pm.request.url.query.count()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Filter:", "updated"] }), + expect.objectContaining({ args: ["Sort:", "name"] }), + expect.objectContaining({ args: ["Limit:", "20"] }), + expect.objectContaining({ args: ["Count:", 4] }), + ]), + }) + ) + }) + + test("updates existing parameters and adds new ones from object", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.assimilate({ + filter: 'inactive', + limit: '50' + }) + + const allParams = pm.request.url.query.all() + console.log("All params:", allParams) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All params:", + expect.objectContaining({ + filter: "inactive", + sort: "name", + page: "1", + limit: "50", + }), + ], + }), + ]), + }) + ) + }) + + test("prunes parameters not in source when prune=true", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.assimilate( + { filter: 'active', limit: '10' }, + true + ) + + const allParams = pm.request.url.query.all() + const count = pm.request.url.query.count() + + console.log("All params:", allParams) + console.log("Count:", count) + console.log("Has sort:", pm.request.url.query.has('sort')) + console.log("Has page:", pm.request.url.query.has('page')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All params:", + expect.objectContaining({ filter: "active", limit: "10" }), + ], + }), + expect.objectContaining({ args: ["Count:", 2] }), + expect.objectContaining({ args: ["Has sort:", false] }), + expect.objectContaining({ args: ["Has page:", false] }), + ]), + }) + ) + }) + + test("throws error for invalid source", async () => { + const result = await runPreRequestScript( + `pm.request.url.query.assimilate("invalid")`, + { + envs, + request: baseRequest, + cookies: null, + experimentalScriptingSandbox: true, + } + ) + + expect(result).toEqualLeft( + expect.stringContaining("Source must be an array or object") + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/url/helper-methods.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/url/helper-methods.spec.ts new file mode 100644 index 0000000..6696b1b --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/url/helper-methods.spec.ts @@ -0,0 +1,604 @@ +import { HoppRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" + +import { runPreRequestScript } from "~/web" + +const baseRequest: HoppRESTRequest = { + v: "16", + name: "Test Request", + endpoint: + "https://api.example.com:8080/v1/users/profile?filter=active&sort=name", + method: "GET", + headers: [ + { + key: "Content-Type", + value: "application/json", + active: true, + description: "", + }, + ], + params: [ + { key: "filter", value: "active", active: true, description: "" }, + { key: "sort", value: "name", active: true, description: "" }, + ], + body: { contentType: null, body: null }, + auth: { authType: "none", authActive: false }, + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, +} + +const envs = { global: [], selected: [] } + +describe("pm.request.url.getHost()", () => { + test("returns hostname as a string", () => { + return expect( + runPreRequestScript( + ` + const host = pm.request.url.getHost() + console.log("Host:", host) + console.log("Host type:", typeof host) + console.log("Is string:", typeof host === 'string') + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Host:", "api.example.com"] }), + expect.objectContaining({ args: ["Host type:", "string"] }), + expect.objectContaining({ args: ["Is string:", true] }), + ]), + }) + ) + }) + + test("updates after host mutation", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial host:", pm.request.url.getHost()) + + pm.request.url.host = ['newapi', 'test', 'com'] + + console.log("Updated host:", pm.request.url.getHost()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial host:", "api.example.com"], + }), + expect.objectContaining({ + args: ["Updated host:", "newapi.test.com"], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.getPath()", () => { + test("returns path as string with leading slash", () => { + return expect( + runPreRequestScript( + ` + const path = pm.request.url.getPath() + console.log("Path:", path) + console.log("Starts with slash:", path.startsWith('/')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Path:", "/v1/users/profile"] }), + expect.objectContaining({ args: ["Starts with slash:", true] }), + ]), + }) + ) + }) + + test("returns '/' for empty path", () => { + const requestWithoutPath: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com", + } + + return expect( + runPreRequestScript( + ` + const path = pm.request.url.getPath() + console.log("Path:", path) + console.log("Is root:", path === '/') + `, + { envs, request: requestWithoutPath } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Path:", "/"] }), + expect.objectContaining({ args: ["Is root:", true] }), + ]), + }) + ) + }) + + test("updates after path mutation", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial path:", pm.request.url.getPath()) + + pm.request.url.path = ['api', 'v2', 'posts'] + + console.log("Updated path:", pm.request.url.getPath()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial path:", "/v1/users/profile"], + }), + expect.objectContaining({ args: ["Updated path:", "/api/v2/posts"] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.getPathWithQuery()", () => { + test("returns path with query string", () => { + return expect( + runPreRequestScript( + ` + const pathWithQuery = pm.request.url.getPathWithQuery() + console.log("Path with query:", pathWithQuery) + console.log("Includes path:", pathWithQuery.includes('/v1/users/profile')) + console.log("Includes query:", pathWithQuery.includes('filter=active')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Path with query:", + "/v1/users/profile?filter=active&sort=name", + ], + }), + expect.objectContaining({ args: ["Includes path:", true] }), + expect.objectContaining({ args: ["Includes query:", true] }), + ]), + }) + ) + }) + + test("returns only path when no query parameters", () => { + const requestWithoutQuery: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users", + params: [], + } + + return expect( + runPreRequestScript( + ` + const pathWithQuery = pm.request.url.getPathWithQuery() + console.log("Path with query:", pathWithQuery) + console.log("Has question mark:", pathWithQuery.includes('?')) + `, + { envs, request: requestWithoutQuery } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Path with query:", "/users"] }), + expect.objectContaining({ args: ["Has question mark:", false] }), + ]), + }) + ) + }) + + test("updates after query mutation", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial:", pm.request.url.getPathWithQuery()) + + pm.request.url.query.add({ key: 'page', value: '5' }) + + console.log("Updated:", pm.request.url.getPathWithQuery()) + console.log("Includes new param:", pm.request.url.getPathWithQuery().includes('page=5')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial:", "/v1/users/profile?filter=active&sort=name"], + }), + expect.objectContaining({ + args: [ + "Updated:", + "/v1/users/profile?filter=active&sort=name&page=5", + ], + }), + expect.objectContaining({ args: ["Includes new param:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.getQueryString()", () => { + test("returns query string without leading question mark", () => { + return expect( + runPreRequestScript( + ` + const queryString = pm.request.url.getQueryString() + console.log("Query string:", queryString) + console.log("Starts with question mark:", queryString.startsWith('?')) + console.log("Contains filter:", queryString.includes('filter=active')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Query string:", "filter=active&sort=name"], + }), + expect.objectContaining({ + args: ["Starts with question mark:", false], + }), + expect.objectContaining({ args: ["Contains filter:", true] }), + ]), + }) + ) + }) + + test("returns empty string when no query parameters", () => { + const requestWithoutQuery: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users", + params: [], + } + + return expect( + runPreRequestScript( + ` + const queryString = pm.request.url.getQueryString() + console.log("Query string:", queryString) + console.log("Is empty:", queryString === '') + console.log("Length:", queryString.length) + `, + { envs, request: requestWithoutQuery } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Query string:", ""] }), + expect.objectContaining({ args: ["Is empty:", true] }), + expect.objectContaining({ args: ["Length:", 0] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.getRemote()", () => { + test("includes port when not standard (443/80)", () => { + return expect( + runPreRequestScript( + ` + const remote = pm.request.url.getRemote() + console.log("Remote:", remote) + console.log("Includes port:", remote.includes(':8080')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Remote:", "api.example.com:8080"], + }), + expect.objectContaining({ args: ["Includes port:", true] }), + ]), + }) + ) + }) + + test("excludes standard HTTPS port (443) by default", () => { + const requestWithStandardPort: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users", + } + + return expect( + runPreRequestScript( + ` + const remote = pm.request.url.getRemote() + console.log("Remote:", remote) + console.log("Has port in string:", remote.includes(':')) + `, + { envs, request: requestWithStandardPort } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Remote:", "api.example.com"] }), + expect.objectContaining({ args: ["Has port in string:", false] }), + ]), + }) + ) + }) + + test("forces port display when forcePort is true", () => { + const requestWithStandardPort: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users", + } + + return expect( + runPreRequestScript( + ` + const remote = pm.request.url.getRemote(true) + console.log("Remote with forced port:", remote) + console.log("Has port in string:", remote.includes(':443')) + `, + { envs, request: requestWithStandardPort } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Remote with forced port:", "api.example.com:443"], + }), + expect.objectContaining({ args: ["Has port in string:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.update()", () => { + test("updates entire URL from string", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial URL:", pm.request.url.toString()) + + pm.request.url.update('http://newapi.test.com:3000/v2/posts?id=123') + + console.log("Updated URL:", pm.request.url.toString()) + console.log("Protocol:", pm.request.url.protocol) + console.log("Host:", pm.request.url.getHost()) + console.log("Port:", pm.request.url.port) + console.log("Path:", pm.request.url.getPath()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "http://newapi.test.com:3000/v2/posts?id=123", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Protocol:", "http"] }), + expect.objectContaining({ args: ["Host:", "newapi.test.com"] }), + expect.objectContaining({ args: ["Port:", "3000"] }), + expect.objectContaining({ args: ["Path:", "/v2/posts"] }), + ]), + }) + ) + }) + + test("accepts object with toString() method", () => { + return expect( + runPreRequestScript( + ` + const urlObj = { + toString: () => 'https://custom.api.com/endpoint' + } + + pm.request.url.update(urlObj) + + console.log("Updated URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://custom.api.com/endpoint", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Updated URL:", "https://custom.api.com/endpoint"], + }), + ]), + }) + ) + }) + + test("throws error for invalid input", async () => { + const result = await runPreRequestScript(`pm.request.url.update(12345)`, { + envs, + request: baseRequest, + cookies: null, + experimentalScriptingSandbox: true, + }) + + expect(result).toEqualLeft(expect.stringContaining("URL update requires")) + }) +}) + +describe("pm.request.url.addQueryParams()", () => { + test("adds multiple query parameters", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial params:", pm.request.url.query.all()) + + pm.request.url.addQueryParams([ + { key: 'page', value: '1' }, + { key: 'limit', value: '20' } + ]) + + console.log("Updated params:", pm.request.url.query.all()) + console.log("URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining("page=1&limit=20"), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Updated params:", + { filter: "active", sort: "name", page: "1", limit: "20" }, + ], + }), + ]), + }) + ) + }) + + test("handles empty value parameters", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.addQueryParams([ + { key: 'flag' }, + { key: 'emptyValue', value: '' } + ]) + + console.log("Params:", pm.request.url.query.all()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Params:", + { filter: "active", sort: "name", flag: "", emptyValue: "" }, + ], + }), + ]), + }) + ) + }) + + test("throws error for non-array input", async () => { + const result = await runPreRequestScript( + `pm.request.url.addQueryParams({ key: 'test', value: '123' })`, + { + envs, + request: baseRequest, + cookies: null, + experimentalScriptingSandbox: true, + } + ) + + expect(result).toEqualLeft(expect.stringContaining("requires an array")) + }) +}) + +describe("pm.request.url.removeQueryParams()", () => { + test("removes single query parameter by name", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial params:", pm.request.url.query.all()) + + pm.request.url.removeQueryParams('filter') + + console.log("Updated params:", pm.request.url.query.all()) + console.log("URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com:8080/v1/users/profile?sort=name", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Updated params:", { sort: "name" }], + }), + ]), + }) + ) + }) + + test("removes multiple query parameters by array", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial params:", pm.request.url.query.all()) + + pm.request.url.removeQueryParams(['filter', 'sort']) + + console.log("Updated params:", pm.request.url.query.all()) + console.log("Params object is empty:", Object.keys(pm.request.url.query.all()).length === 0) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com:8080/v1/users/profile", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Updated params:", {}], + }), + expect.objectContaining({ + args: ["Params object is empty:", true], + }), + ]), + }) + ) + }) + + test("handles non-existent parameter names gracefully", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial params:", pm.request.url.query.all()) + + pm.request.url.removeQueryParams(['nonexistent', 'alsoNotThere']) + + console.log("Updated params:", pm.request.url.query.all()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial params:", { filter: "active", sort: "name" }], + }), + expect.objectContaining({ + args: ["Updated params:", { filter: "active", sort: "name" }], + }), + ]), + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/url/properties.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/url/properties.spec.ts new file mode 100644 index 0000000..3cde721 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/url/properties.spec.ts @@ -0,0 +1,285 @@ +import { HoppRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" + +import { runPreRequestScript } from "~/web" + +const baseRequest: HoppRESTRequest = { + v: "16", + name: "Test Request", + endpoint: "https://api.example.com/users#section1", + method: "GET", + headers: [], + params: [], + body: { contentType: null, body: null }, + auth: { authType: "none", authActive: false }, + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, +} + +const envs = { global: [], selected: [] } + +describe("pm.request.url.hash property", () => { + test("hash getter returns fragment without leading #", () => { + return expect( + runPreRequestScript( + ` + const hash = pm.request.url.hash + console.log("Hash:", hash) + console.log("Hash type:", typeof hash) + console.log("Starts with #:", hash.startsWith('#')) + console.log("Full URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Hash:", "section1"] }), + expect.objectContaining({ args: ["Hash type:", "string"] }), + expect.objectContaining({ args: ["Starts with #:", false] }), + expect.objectContaining({ + args: expect.arrayContaining([ + "Full URL:", + expect.stringContaining("#section1"), + ]), + }), + ]), + }) + ) + }) + + test("hash getter returns empty string when no fragment", () => { + const requestWithoutHash: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users", + } + + return expect( + runPreRequestScript( + ` + const hash = pm.request.url.hash + console.log("Hash:", hash) + console.log("Hash is empty:", hash === '') + console.log("Hash length:", hash.length) + `, + { envs, request: requestWithoutHash } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Hash:", ""] }), + expect.objectContaining({ args: ["Hash is empty:", true] }), + expect.objectContaining({ args: ["Hash length:", 0] }), + ]), + }) + ) + }) + + test("hash setter adds fragment to URL", () => { + const requestWithoutHash: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users", + } + + return expect( + runPreRequestScript( + ` + console.log("Initial hash:", pm.request.url.hash) + console.log("Initial URL:", pm.request.url.toString()) + + pm.request.url.hash = 'overview' + + console.log("Updated hash:", pm.request.url.hash) + console.log("Updated URL:", pm.request.url.toString()) + console.log("URL includes #:", pm.request.url.toString().includes('#overview')) + `, + { envs, request: requestWithoutHash } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com/users#overview", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial hash:", ""] }), + expect.objectContaining({ args: ["Updated hash:", "overview"] }), + expect.objectContaining({ args: ["URL includes #:", true] }), + ]), + }) + ) + }) + + test("hash setter updates existing fragment", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial hash:", pm.request.url.hash) + console.log("Initial URL:", pm.request.url.toString()) + + pm.request.url.hash = 'newsection' + + console.log("Updated hash:", pm.request.url.hash) + console.log("Updated URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com/users#newsection", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial hash:", "section1"] }), + expect.objectContaining({ args: ["Updated hash:", "newsection"] }), + expect.objectContaining({ + args: expect.arrayContaining([ + "Updated URL:", + expect.stringContaining("#newsection"), + ]), + }), + ]), + }) + ) + }) + + test("hash setter accepts value with leading #", () => { + const requestWithoutHash: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users", + } + + return expect( + runPreRequestScript( + ` + pm.request.url.hash = '#details' + + console.log("Hash:", pm.request.url.hash) + console.log("URL:", pm.request.url.toString()) + console.log("Hash value:", pm.request.url.hash === 'details') + `, + { envs, request: requestWithoutHash } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com/users#details", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Hash:", "details"] }), + expect.objectContaining({ args: ["Hash value:", true] }), + ]), + }) + ) + }) + + test("hash setter removes fragment when set to empty string", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial hash:", pm.request.url.hash) + console.log("Initial URL:", pm.request.url.toString()) + + pm.request.url.hash = '' + + console.log("Updated hash:", pm.request.url.hash) + console.log("Updated URL:", pm.request.url.toString()) + console.log("URL has #:", pm.request.url.toString().includes('#')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com/users", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial hash:", "section1"] }), + expect.objectContaining({ args: ["Updated hash:", ""] }), + expect.objectContaining({ args: ["URL has #:", false] }), + ]), + }) + ) + }) + + test("hash works with query parameters", () => { + const requestWithQueryAndHash: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users?filter=active#top", + params: [ + { key: "filter", value: "active", active: true, description: "" }, + ], + } + + return expect( + runPreRequestScript( + ` + console.log("Initial URL:", pm.request.url.toString()) + console.log("Initial hash:", pm.request.url.hash) + console.log("Query params:", pm.request.url.query.all()) + + pm.request.url.hash = 'bottom' + + console.log("Updated URL:", pm.request.url.toString()) + console.log("Updated hash:", pm.request.url.hash) + console.log("Query params unchanged:", JSON.stringify(pm.request.url.query.all())) + `, + { envs, request: requestWithQueryAndHash } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com/users?filter=active#bottom", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial hash:", "top"] }), + expect.objectContaining({ args: ["Updated hash:", "bottom"] }), + expect.objectContaining({ + args: ["Query params:", { filter: "active" }], + }), + ]), + }) + ) + }) +}) + +describe("combined host and hash mutations", () => { + test("host and hash can be changed independently", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial URL:", pm.request.url.toString()) + console.log("Initial host:", pm.request.url.getHost()) + console.log("Initial hash:", pm.request.url.hash) + + pm.request.url.host = ['newdomain', 'com'] + console.log("After host change:", pm.request.url.toString()) + + pm.request.url.hash = 'newhash' + console.log("After hash change:", pm.request.url.toString()) + + console.log("Final host:", pm.request.url.getHost()) + console.log("Final hash:", pm.request.url.hash) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://newdomain.com/users#newhash", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial host:", "api.example.com"], + }), + expect.objectContaining({ args: ["Initial hash:", "section1"] }), + expect.objectContaining({ + args: ["Final host:", "newdomain.com"], + }), + expect.objectContaining({ args: ["Final hash:", "newhash"] }), + ]), + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/basic.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/basic.spec.ts new file mode 100644 index 0000000..4750f4c --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/basic.spec.ts @@ -0,0 +1,360 @@ +import { describe, expect, test } from "vitest" +import { TestResponse, TestResult } from "~/types" +import { runTest } from "~/utils/test-helpers" + +const customResponse: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ message: "Hello, World!" }), + headers: [ + { key: "Content-Type", value: "application/json" }, + { key: "Authorization", value: "Bearer token123" }, + ], +} + +const func = ( + script: string, + envs: TestResult["envs"], + response: TestResponse = customResponse +) => runTest(script, envs, response) + +describe("pm.response", () => { + test("pm.response.code provides access to status code", () => { + return expect( + func( + ` + const code = pm.response.code + pw.expect(code.toString()).toBe("200") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '200' to be '200'", + }, + ], + }), + ]) + }) + + test("pm.response.status provides access to status text", () => { + return expect( + func( + ` + const status = pm.response.status + pw.expect(status).toBe("OK") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'OK' to be 'OK'", + }, + ], + }), + ]) + }) + + test("pm.response.text() provides response body as text", () => { + return expect( + func( + ` + const text = pm.response.text() + pw.expect(text).toBe('{"message":"Hello, World!"}') + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: + 'Expected \'{"message":"Hello, World!"}\' to be \'{"message":"Hello, World!"}\'', + }, + ], + }), + ]) + }) + + test("pm.response.json() provides parsed JSON response", () => { + return expect( + func( + ` + const json = pm.response.json() + pw.expect(json.message).toBe("Hello, World!") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'Hello, World!' to be 'Hello, World!'", + }, + ], + }), + ]) + }) + + test("pm.response.headers provides access to response headers", () => { + return expect( + func( + ` + const headers = pm.response.headers + pw.expect(headers.get("Content-Type")).toBe("application/json") + pw.expect(headers.get("Authorization")).toBe("Bearer token123") + // Postman returns undefined for non-existent headers, not null + pw.expect(headers.get("nonexistent")).toBe(undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'application/json' to be 'application/json'", + }, + { + status: "pass", + message: "Expected 'Bearer token123' to be 'Bearer token123'", + }, + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) + + test("pm.response object has correct structure and values", () => { + return expect( + func( + ` + pw.expect(pm.response.code).toBe(200) + pw.expect(pm.response.status).toBe("OK") + pw.expect(pm.response.text()).toBe('{"message":"Hello, World!"}') + pw.expect(pm.response.json().message).toBe("Hello, World!") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '200' to be '200'", + }, + { + status: "pass", + message: "Expected 'OK' to be 'OK'", + }, + { + status: "pass", + message: + 'Expected \'{"message":"Hello, World!"}\' to be \'{"message":"Hello, World!"}\'', + }, + { + status: "pass", + message: "Expected 'Hello, World!' to be 'Hello, World!'", + }, + ], + }), + ]) + }) + + test("pm.response.stream provides response body as Uint8Array", () => { + return expect( + func( + ` + const stream = pm.response.stream + // Verify it's a Uint8Array by checking it can be decoded + const decoder = new TextDecoder() + const decoded = decoder.decode(stream) + pw.expect(decoded).toBe('{"message":"Hello, World!"}') + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: + 'Expected \'{"message":"Hello, World!"}\' to be \'{"message":"Hello, World!"}\'', + }, + ], + }), + ]) + }) + + test("pm.response.stream contains correct byte data", () => { + return expect( + func( + ` + const stream = pm.response.stream + const decoder = new TextDecoder() + const text = decoder.decode(stream) + pw.expect(text).toBe('{"message":"Hello, World!"}') + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: + 'Expected \'{"message":"Hello, World!"}\' to be \'{"message":"Hello, World!"}\'', + }, + ], + }), + ]) + }) + + test("pm.response.reason() returns HTTP reason phrase", () => { + return expect( + func( + ` + const reason = pm.response.reason() + pw.expect(reason).toBe("OK") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'OK' to be 'OK'", + }, + ], + }), + ]) + }) + + test("pm.response.dataURI() converts response to data URI", () => { + return expect( + func( + ` + const dataURI = pm.response.dataURI() + pw.expect(dataURI).toBeType("string") + // Check it starts with data: and contains base64 + const startsWithData = dataURI.startsWith("data:") + pw.expect(startsWithData).toBe(true) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: expect.stringContaining("to be type 'string'"), + }, + { + status: "pass", + message: "Expected 'true' to be 'true'", + }, + ], + }), + ]) + }) + + test("pm.response.jsonp() parses JSONP response", () => { + const jsonpResponse: TestResponse = { + status: 200, + statusText: "OK", + body: 'callback({"data": "test value"})', + headers: [{ key: "Content-Type", value: "application/javascript" }], + } + + return expect( + func( + ` + const data = pm.response.jsonp("callback") + pw.expect(data.data).toBe("test value") + `, + { global: [], selected: [] }, + jsonpResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'test value' to be 'test value'", + }, + ], + }), + ]) + }) + + test("pm.response.jsonp() handles response without callback wrapper", () => { + const jsonResponse: TestResponse = { + status: 200, + statusText: "OK", + body: '{"plain": "json"}', + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + func( + ` + const data = pm.response.jsonp() + pw.expect(data.plain).toBe("json") + `, + { global: [], selected: [] }, + jsonResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'json' to be 'json'", + }, + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/bdd-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/bdd-assertions.spec.ts new file mode 100644 index 0000000..fd22d48 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/bdd-assertions.spec.ts @@ -0,0 +1,897 @@ +/** + * @file Tests for Postman BDD-style response assertions (pm.response.to.*) + * + * These tests verify the Postman compatibility layer's BDD-style assertion helpers + * which are commonly used in Postman collections. They provide syntactic sugar over + * the standard Chai assertions for common response validation patterns. + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("`pm.response.to.have.*` - Status Code Assertions", () => { + test("should support `.status()` for exact status code matching", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Status code is 200", function() { + pm.response.to.have.status(200) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Status code is 200", + expectResults: [ + { + status: "pass", + message: "Expected 200 to equal 200", + }, + ], + }), + ], + }), + ]) + }) + + test("should fail `.status()` when status code doesn't match", () => { + const response: TestResponse = { + status: 404, + statusText: "Not Found", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Status code is 200", function() { + pm.response.to.have.status(200) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Status code is 200", + expectResults: [ + { + status: "fail", + message: expect.stringContaining("Expected 404 to equal 200"), + }, + ], + }), + ], + }), + ]) + }) +}) + +describe("`pm.response.to.be.*` - Status Code Convenience Methods", () => { + test("should support `.ok()` for 2xx status codes", () => { + const responses = [ + { status: 200, statusText: "OK" }, + { status: 201, statusText: "Created" }, + { status: 204, statusText: "No Content" }, + ] + + return Promise.all( + responses.map((r) => + expect( + runTest( + ` + pm.test("Response is OK", function() { + pm.response.to.be.ok() + }) + `, + { global: [], selected: [] }, + { ...r, body: "{}", headers: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + ) + ) + }) + + test("should fail `.ok()` for non-2xx status codes", () => { + const response: TestResponse = { + status: 404, + statusText: "Not Found", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Response is OK", function() { + pm.response.to.be.ok() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { + status: "fail", + message: expect.any(String), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.accepted()` for 202 status code", () => { + const response: TestResponse = { + status: 202, + statusText: "Accepted", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Request accepted", function() { + pm.response.to.be.accepted() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.badRequest()` for 400 status code", () => { + const response: TestResponse = { + status: 400, + statusText: "Bad Request", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Bad request error", function() { + pm.response.to.be.badRequest() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.unauthorized()` for 401 status code", () => { + const response: TestResponse = { + status: 401, + statusText: "Unauthorized", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Unauthorized error", function() { + pm.response.to.be.unauthorized() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.forbidden()` for 403 status code", () => { + const response: TestResponse = { + status: 403, + statusText: "Forbidden", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Forbidden error", function() { + pm.response.to.be.forbidden() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.notFound()` for 404 status code", () => { + const response: TestResponse = { + status: 404, + statusText: "Not Found", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Not found error", function() { + pm.response.to.be.notFound() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.rateLimited()` for 429 status code", () => { + const response: TestResponse = { + status: 429, + statusText: "Too Many Requests", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Rate limited", function() { + pm.response.to.be.rateLimited() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.serverError()` for 5xx status codes", () => { + const responses = [ + { status: 500, statusText: "Internal Server Error" }, + { status: 502, statusText: "Bad Gateway" }, + { status: 503, statusText: "Service Unavailable" }, + ] + + return Promise.all( + responses.map((r) => + expect( + runTest( + ` + pm.test("Server error", function() { + pm.response.to.be.serverError() + }) + `, + { global: [], selected: [] }, + { ...r, body: "{}", headers: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + ) + ) + }) +}) + +describe("`pm.response.to.have.header()` - Header Assertions", () => { + test("should check header existence", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [ + { key: "Content-Type", value: "application/json" }, + { key: "X-Custom-Header", value: "custom-value" }, + ], + } + + return expect( + runTest( + ` + pm.test("Headers exist", function() { + pm.response.to.have.header("Content-Type") + pm.response.to.have.header("X-Custom-Header") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) + + test("should check header value when specified", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Header has correct value", function() { + pm.response.to.have.header("Content-Type", "application/json") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should be case-insensitive for header names", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Case insensitive header check", function() { + pm.response.to.have.header("content-type") + pm.response.to.have.header("CONTENT-TYPE") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) +}) + +describe("`pm.response.to.have.body()` - Body Assertions", () => { + test("should match exact body content", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "Hello, World!", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Body matches", function() { + pm.response.to.have.body("Hello, World!") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) +}) + +describe("`pm.response.to.have.jsonBody()` - JSON Body Assertions", () => { + test("should assert response is JSON object when called without arguments", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ message: "Hello", count: 42 }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Response is JSON", function() { + pm.response.to.have.jsonBody() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should check for property existence when key provided", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ message: "Hello", count: 42 }), + headers: [], + } + + return expect( + runTest( + ` + pm.test("JSON has properties", function() { + pm.response.to.have.jsonBody("message") + pm.response.to.have.jsonBody("count") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) + + test("should check property value when key and value provided", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ message: "Hello", count: 42 }), + headers: [], + } + + return expect( + runTest( + ` + pm.test("JSON property values match", function() { + pm.response.to.have.jsonBody("message", "Hello") + pm.response.to.have.jsonBody("count", 42) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) + + test("should support nested property checks", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ user: { name: "Alice", age: 30 } }), + headers: [], + } + + return expect( + runTest( + ` + pm.test("Nested properties", function() { + const data = pm.response.json() + pm.expect(data.user).to.have.property("name") + pm.expect(data.user.name).to.equal("Alice") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) +}) + +describe("`pm.response.to.be.*` - Content Type Convenience Methods", () => { + test("should support `.json()` for JSON content type", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [ + { key: "Content-Type", value: "application/json; charset=utf-8" }, + ], + } + + return expect( + runTest( + ` + pm.test("Response is JSON", function() { + pm.response.to.be.json() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.html()` for HTML content type", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "", + headers: [{ key: "Content-Type", value: "text/html; charset=utf-8" }], + } + + return expect( + runTest( + ` + pm.test("Response is HTML", function() { + pm.response.to.be.html() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.xml()` for XML content types", () => { + const responses = [ + { headers: [{ key: "Content-Type", value: "application/xml" }] }, + { headers: [{ key: "Content-Type", value: "text/xml" }] }, + ] + + return Promise.all( + responses.map((r) => + expect( + runTest( + ` + pm.test("Response is XML", function() { + pm.response.to.be.xml() + }) + `, + { global: [], selected: [] }, + { status: 200, statusText: "OK", body: "", ...r } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + ) + ) + }) + + test("should support `.text()` for plain text content type", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "Plain text response", + headers: [{ key: "Content-Type", value: "text/plain" }], + } + + return expect( + runTest( + ` + pm.test("Response is text", function() { + pm.response.to.be.text() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) +}) + +describe("`pm.response.to.have.responseTime.*` - Response Time Assertions", () => { + test("should support `.below()` for response time upper bound", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [], + responseTime: 250, + } + + return expect( + runTest( + ` + pm.test("Response time is acceptable", function() { + pm.response.to.have.responseTime.below(500) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.above()` for response time lower bound", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [], + responseTime: 250, + } + + return expect( + runTest( + ` + pm.test("Response time is above threshold", function() { + pm.response.to.have.responseTime.above(100) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) +}) + +describe("Real-world Postman script patterns", () => { + test("should handle complex assertion combinations", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ + success: true, + data: { id: 123, name: "Test" }, + timestamp: Date.now(), + }), + headers: [ + { key: "Content-Type", value: "application/json" }, + { key: "X-Request-ID", value: "abc-123" }, + ], + responseTime: 150, + } + + return expect( + runTest( + ` + pm.test("API response validation", function() { + // Status code checks + pm.response.to.have.status(200) + pm.response.to.be.ok() + + // Header checks + pm.response.to.have.header("Content-Type") + pm.response.to.have.header("X-Request-ID", "abc-123") + + // Content type + pm.response.to.be.json() + + // JSON body checks + pm.response.to.have.jsonBody("success", true) + pm.response.to.have.jsonBody("data") + + // Response time + pm.response.to.have.responseTime.below(500) + + // Detailed JSON assertions + const json = pm.response.json() + pm.expect(json.data.id).to.equal(123) + pm.expect(json.data.name).to.equal("Test") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "API response validation", + expectResults: expect.arrayContaining([ + { status: "pass", message: expect.any(String) }, + ]), + }), + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/cookies.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/cookies.spec.ts new file mode 100644 index 0000000..b06de86 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/cookies.spec.ts @@ -0,0 +1,392 @@ +/** + * @file Tests for Postman cookie handling (pm.response.cookies.*, pm.response.to.have.cookie) + * + * These tests verify cookie parsing from Set-Cookie headers and cookie assertions. + * Cookies in responses are extracted from Set-Cookie headers and made available + * through the pm.response.cookies API. + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("`pm.response.cookies` - Cookie Access Methods", () => { + test("should support `.get()` to retrieve a cookie value by name", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [ + { key: "Set-Cookie", value: "session=abc123; Path=/; HttpOnly" }, + ], + } + + return expect( + runTest( + ` + pm.test("Can retrieve cookie value by name", function() { + const cookieValue = pm.response.cookies.get("session") + pm.expect(cookieValue).to.not.be.null + pm.expect(cookieValue).to.equal("abc123") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Can retrieve cookie value by name", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should return null for non-existent cookies", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [{ key: "Set-Cookie", value: "session=abc123; Path=/" }], + } + + return expect( + runTest( + ` + pm.test("Returns null for non-existent cookie", function() { + const cookie = pm.response.cookies.get("nonexistent") + pm.expect(cookie).to.be.null + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Returns null for non-existent cookie", + expectResults: [ + { + status: "pass", + message: expect.stringContaining("Expected null to be null"), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.has()` to check cookie existence", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [ + { key: "Set-Cookie", value: "auth_token=xyz789; Secure" }, + { key: "Set-Cookie", value: "user_id=42; SameSite=Strict" }, + ], + } + + return expect( + runTest( + ` + pm.test("Can check cookie existence", function() { + pm.expect(pm.response.cookies.has("auth_token")).to.be.true + pm.expect(pm.response.cookies.has("user_id")).to.be.true + pm.expect(pm.response.cookies.has("nonexistent")).to.be.false + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Can check cookie existence", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.toObject()` to get all cookies", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [ + { key: "Set-Cookie", value: "cookie1=value1; Path=/" }, + { key: "Set-Cookie", value: "cookie2=value2; Domain=example.com" }, + ], + } + + return expect( + runTest( + ` + pm.test("Can get all cookies as object", function() { + const cookies = pm.response.cookies.toObject() + pm.expect(cookies).to.be.an("object") + pm.expect(cookies.cookie1).to.equal("value1") + pm.expect(cookies.cookie2).to.equal("value2") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Can get all cookies as object", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should return just the cookie value (matching Postman behavior)", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [ + { + key: "Set-Cookie", + value: + "full_cookie=test_value; Domain=.example.com; Path=/api; Max-Age=3600; Secure; HttpOnly; SameSite=Lax", + }, + ], + } + + return expect( + runTest( + ` + pm.test("Returns only cookie value, not attributes", function() { + const cookieValue = pm.response.cookies.get("full_cookie") + pm.expect(cookieValue).to.equal("test_value") + pm.expect(cookieValue).to.be.a("string") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Returns only cookie value, not attributes", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should handle cookies with equals signs in value", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [ + { + key: "Set-Cookie", + value: + "jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=; Path=/", + }, + ], + } + + return expect( + runTest( + ` + pm.test("Handles equals signs in cookie value", function() { + const cookieValue = pm.response.cookies.get("jwt") + pm.expect(cookieValue).to.include("=") + pm.expect(cookieValue).to.equal("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Handles equals signs in cookie value", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) +}) + +describe("`pm.response.to.have.cookie` - Cookie Assertions", () => { + test("should assert cookie exists by name", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [{ key: "Set-Cookie", value: "session=abc123; Path=/" }], + } + + return expect( + runTest( + ` + pm.test("Response has session cookie", function() { + pm.response.to.have.cookie("session") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Response has session cookie", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should assert cookie value matches", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [{ key: "Set-Cookie", value: "user=john_doe; Path=/" }], + } + + return expect( + runTest( + ` + pm.test("Cookie has correct value", function() { + pm.response.to.have.cookie("user", "john_doe") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Cookie has correct value", + expectResults: [ + { + status: "pass", + message: expect.stringContaining( + "Expected 'john_doe' to equal 'john_doe'" + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should fail when cookie doesn't exist", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Missing cookie fails", function() { + pm.response.to.have.cookie("nonexistent") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Missing cookie fails", + expectResults: [ + { + status: "fail", + message: expect.stringContaining("Expected false to be true"), + }, + ], + }), + ], + }), + ]) + }) + + test("should fail when cookie value doesn't match", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [{ key: "Set-Cookie", value: "token=wrong_value; Path=/" }], + } + + return expect( + runTest( + ` + pm.test("Wrong cookie value fails", function() { + pm.response.to.have.cookie("token", "expected_value") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Wrong cookie value fails", + expectResults: [ + { + status: "fail", + message: expect.stringContaining( + "Expected 'wrong_value' to equal 'expected_value'" + ), + }, + ], + }), + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/datauri-comprehensive.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/datauri-comprehensive.spec.ts new file mode 100644 index 0000000..758b30c --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/datauri-comprehensive.spec.ts @@ -0,0 +1,318 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("pm.response.dataURI() comprehensive coverage", () => { + test("should handle Content-Type without charset", async () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ test: "data" }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + const testScript = ` + pm.test("dataURI format without charset", () => { + const dataUri = pm.response.dataURI() + pm.expect(dataUri).to.be.a('string') + pm.expect(dataUri).to.include('data:') + pm.expect(dataUri).to.match(/^data:.+;base64,/) + pm.expect(dataUri).to.include('application/json') + pm.expect(dataUri).to.include('base64,') + }) + ` + + return expect( + runTest(testScript, { global: [], selected: [] }, response)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "dataURI format without charset", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ]) + }) + + test("should handle Content-Type with charset parameter", async () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ test: "data" }), + headers: [ + { key: "Content-Type", value: "application/json; charset=utf-8" }, + ], + } + + const testScript = ` + pm.test("dataURI format with charset", () => { + const dataUri = pm.response.dataURI() + pm.expect(dataUri).to.be.a('string') + pm.expect(dataUri).to.include('data:') + // Updated regex pattern that handles charset parameters + pm.expect(dataUri).to.match(/^data:.+;base64,/) + pm.expect(dataUri).to.include('application/json') + pm.expect(dataUri).to.include('charset=utf-8') + pm.expect(dataUri).to.include('base64,') + }) + ` + + return expect( + runTest(testScript, { global: [], selected: [] }, response)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "dataURI format with charset", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ]) + }) + + test("should handle text/html with charset", async () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "Hello", + headers: [{ key: "Content-Type", value: "text/html; charset=utf-8" }], + } + + const testScript = ` + pm.test("dataURI with text/html and charset", () => { + const dataUri = pm.response.dataURI() + pm.expect(dataUri).to.be.a('string') + pm.expect(dataUri).to.match(/^data:.+;base64,/) + pm.expect(dataUri).to.include('text/html') + pm.expect(dataUri).to.include('charset=utf-8') + }) + ` + + return expect( + runTest(testScript, { global: [], selected: [] }, response)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "dataURI with text/html and charset", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ]) + }) + + test("should handle text/plain with multiple parameters", async () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "Plain text content", + headers: [ + { + key: "Content-Type", + value: "text/plain; charset=utf-8; format=flowed", + }, + ], + } + + const testScript = ` + pm.test("dataURI with multiple parameters", () => { + const dataUri = pm.response.dataURI() + pm.expect(dataUri).to.be.a('string') + // Regex should handle multiple semicolons + pm.expect(dataUri).to.match(/^data:.+;base64,/) + pm.expect(dataUri).to.include('text/plain') + pm.expect(dataUri).to.include('base64,') + }) + ` + + return expect( + runTest(testScript, { global: [], selected: [] }, response)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "dataURI with multiple parameters", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ]) + }) + + test("should handle application/xml with charset", async () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: 'test', + headers: [ + { key: "Content-Type", value: "application/xml; charset=utf-8" }, + ], + } + + const testScript = ` + pm.test("dataURI with XML and charset", () => { + const dataUri = pm.response.dataURI() + pm.expect(dataUri).to.be.a('string') + pm.expect(dataUri).to.match(/^data:.+;base64,/) + pm.expect(dataUri).to.include('application/xml') + }) + ` + + return expect( + runTest(testScript, { global: [], selected: [] }, response)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "dataURI with XML and charset", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ]) + }) + + test("should handle missing Content-Type header", async () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "Some content", + headers: [], + } + + const testScript = ` + pm.test("dataURI without Content-Type header", () => { + const dataUri = pm.response.dataURI() + pm.expect(dataUri).to.be.a('string') + pm.expect(dataUri).to.match(/^data:.+;base64,/) + // Should default to application/octet-stream + pm.expect(dataUri).to.include('application/octet-stream') + }) + ` + + return expect( + runTest(testScript, { global: [], selected: [] }, response)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "dataURI without Content-Type header", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ]) + }) + + test("should properly encode UTF-8 content", async () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ message: "Hello 世界 🌍" }), + headers: [ + { key: "Content-Type", value: "application/json; charset=utf-8" }, + ], + } + + const testScript = ` + pm.test("dataURI with UTF-8 characters", () => { + const dataUri = pm.response.dataURI() + pm.expect(dataUri).to.be.a('string') + pm.expect(dataUri).to.match(/^data:.+;base64,/) + pm.expect(dataUri.length).to.be.above(50) + // Should contain valid base64 characters after "base64," + const base64Part = dataUri.split('base64,')[1] + pm.expect(base64Part).to.be.a('string') + pm.expect(base64Part.length).to.be.above(0) + }) + ` + + return expect( + runTest(testScript, { global: [], selected: [] }, response)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "dataURI with UTF-8 characters", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ]) + }) + + test("should handle empty response body", async () => { + const response: TestResponse = { + status: 204, + statusText: "No Content", + body: "", + headers: [{ key: "Content-Type", value: "text/plain" }], + } + + const testScript = ` + pm.test("dataURI with empty body", () => { + const dataUri = pm.response.dataURI() + pm.expect(dataUri).to.be.a('string') + pm.expect(dataUri).to.match(/^data:.+;base64,/) + pm.expect(dataUri).to.include('text/plain') + }) + ` + + return expect( + runTest(testScript, { global: [], selected: [] }, response)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "dataURI with empty body", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/method-existence.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/method-existence.spec.ts new file mode 100644 index 0000000..4a8ae09 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/method-existence.spec.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest" +import { runTestScript } from "~/node" +import { TestResponse } from "~/types/test-runner" + +describe("pm.response method existence checks", () => { + const mockResponse: TestResponse = { + status: 200, + headers: [{ key: "Content-Type", value: "application/json" }], + body: JSON.stringify({ message: "Hello" }), + } + + it("should recognize pm.response.reason as a function", async () => { + const testScript = ` + pm.test("pm.response.reason is a function", () => { + pm.expect(pm.response.reason).to.be.a('function') + }) + ` + + await expect( + runTestScript(testScript, { response: mockResponse })() + ).resolves.toEqualRight( + expect.objectContaining({ + tests: [ + expect.objectContaining({ + expectResults: [], + children: [ + expect.objectContaining({ + descriptor: "pm.response.reason is a function", + expectResults: [ + expect.objectContaining({ + status: "pass", + }), + ], + }), + ], + }), + ], + }) + ) + }) + + it("should recognize pm.response.dataURI as a function", async () => { + const testScript = ` + pm.test("pm.response.dataURI is a function", () => { + pm.expect(pm.response.dataURI).to.be.a('function') + }) + ` + + await expect( + runTestScript(testScript, { response: mockResponse })() + ).resolves.toEqualRight( + expect.objectContaining({ + tests: [ + expect.objectContaining({ + expectResults: [], + children: [ + expect.objectContaining({ + descriptor: "pm.response.dataURI is a function", + expectResults: [ + expect.objectContaining({ + status: "pass", + }), + ], + }), + ], + }), + ], + }) + ) + }) + + it("should recognize pm.response.jsonp as a function", async () => { + const testScript = ` + pm.test("pm.response.jsonp is a function", () => { + pm.expect(pm.response.jsonp).to.be.a('function') + }) + ` + + await expect( + runTestScript(testScript, { response: mockResponse })() + ).resolves.toEqualRight( + expect.objectContaining({ + tests: [ + expect.objectContaining({ + expectResults: [], + children: [ + expect.objectContaining({ + descriptor: "pm.response.jsonp is a function", + expectResults: [ + expect.objectContaining({ + status: "pass", + }), + ], + }), + ], + }), + ], + }) + ) + }) + + it("should work with typeof checks", async () => { + const testScript = ` + pm.test("typeof pm.response.reason equals function", () => { + pm.expect(typeof pm.response.reason).to.equal('function') + }) + ` + + await expect( + runTestScript(testScript, { response: mockResponse })() + ).resolves.toEqualRight( + expect.objectContaining({ + tests: [ + expect.objectContaining({ + expectResults: [], + children: [ + expect.objectContaining({ + descriptor: "typeof pm.response.reason equals function", + expectResults: [ + expect.objectContaining({ + status: "pass", + }), + ], + }), + ], + }), + ], + }) + ) + }) + + it("should verify all three utility methods exist as functions", async () => { + const testScript = ` + pm.test("all response utility methods exist", () => { + pm.expect(pm.response.reason).to.be.a('function') + pm.expect(pm.response.dataURI).to.be.a('function') + pm.expect(pm.response.jsonp).to.be.a('function') + }) + ` + + await expect( + runTestScript(testScript, { response: mockResponse })() + ).resolves.toEqualRight( + expect.objectContaining({ + tests: [ + expect.objectContaining({ + expectResults: [], + children: [ + expect.objectContaining({ + descriptor: "all response utility methods exist", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ], + }) + ) + }) + + it("should verify methods work correctly when called", async () => { + const testScript = ` + pm.test("response.reason() returns status text", () => { + pm.expect(pm.response.reason()).to.equal('OK') + }) + + pm.test("response.dataURI() returns data URI string", () => { + const uri = pm.response.dataURI() + pm.expect(uri).to.be.a('string') + pm.expect(uri).to.include('data:') + }) + + pm.test("response.jsonp() parses JSON", () => { + const result = pm.response.jsonp() + pm.expect(result).to.deep.equal({ message: "Hello" }) + }) + ` + + await expect( + runTestScript(testScript, { response: mockResponse })() + ).resolves.toEqualRight( + expect.objectContaining({ + tests: [ + expect.objectContaining({ + expectResults: [], + children: [ + expect.objectContaining({ + descriptor: "response.reason() returns status text", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + expect.objectContaining({ + descriptor: "response.dataURI() returns data URI string", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + expect.objectContaining({ + descriptor: "response.jsonp() parses JSON", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ], + }), + ], + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/sendRequest.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/sendRequest.spec.ts new file mode 100644 index 0000000..8853035 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/sendRequest.spec.ts @@ -0,0 +1,682 @@ +import { describe, expect, test, vi } from "vitest" +import { runTest } from "~/utils/test-helpers" +import type { HoppFetchHook } from "~/types" + +/** + * Tests for pm.sendRequest() functionality + * + * NOTE: These unit tests validate API availability but have limited coverage + * due to QuickJS async callback timing issues. Callback assertions don't + * execute reliably in the test context. + * + * For production validation, see the comprehensive E2E tests in: + * packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json + * + * The E2E tests make real HTTP requests and fully validate: + * - String URL format + * - Request object format + * - URL-encoded body + * - Response format validation + * - HTTP error status codes + * - Environment variable integration + * - Store response in environment + */ + +describe("pm.sendRequest()", () => { + describe("Basic functionality", () => { + test("pm.sendRequest should execute callback with response data", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ success: true, data: "test" }), { + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + }) + }) + + await expect( + runTest( + ` + pm.test("sendRequest with callback", () => { + pm.sendRequest("https://api.example.com/data", (error, response) => { + pm.expect(error).toBe(null) + pm.expect(response.code).toBe(200) + pm.expect(response.status).toBe("OK") + pm.expect(response.json().success).toBe(true) + pm.expect(response.json().data).toBe("test") + }) + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "sendRequest with callback", + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + { status: "pass", message: "Expected '200' to be '200'" }, + { status: "pass", message: "Expected 'OK' to be 'OK'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected 'test' to be 'test'" }, + ], + }), + ], + }), + ]) + }) + + test("pm.sendRequest should handle errors in callback", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + throw new Error("Network error") + }) + + await expect( + runTest( + ` + pm.test("sendRequest with error", () => { + pm.sendRequest("https://api.example.com/fail", (error, response) => { + pm.expect(error).not.toBe(null) + pm.expect(response).toBe(null) + pm.expect(error.message).toBe("Network error") + }) + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "sendRequest with error", + expectResults: [ + expect.objectContaining({ status: "pass" }), + { status: "pass", message: "Expected 'null' to be 'null'" }, + { + status: "pass", + message: "Expected 'Network error' to be 'Network error'", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Request object format", () => { + test("pm.sendRequest accepts request object format with POST (array headers)", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ created: true, id: 123 }), { + status: 201, + statusText: "Created", + headers: { "Content-Type": "application/json" }, + }) + }) + + await expect( + runTest( + ` + pm.test("request object format", () => { + pm.sendRequest({ + url: "https://api.example.com/items", + method: "POST", + header: [ + { key: "Content-Type", value: "application/json" } + ], + body: { + mode: "raw", + raw: JSON.stringify({ name: "test" }) + } + }, (error, response) => { + pm.expect(error).toBe(null) + pm.expect(response.code).toBe(201) + pm.expect(response.status).toBe("Created") + pm.expect(response.json().created).toBe(true) + pm.expect(response.json().id).toBe(123) + }) + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "request object format", + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + { status: "pass", message: "Expected '201' to be '201'" }, + { + status: "pass", + message: "Expected 'Created' to be 'Created'", + }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected '123' to be '123'" }, + ], + }), + ], + }), + ]) + }) + + test("pm.sendRequest accepts request object format with object headers (RFC pattern)", async () => { + const mockFetch: HoppFetchHook = vi.fn(async (_url, options) => { + // Verify headers were properly passed as object + expect(options?.headers).toEqual({ + "Content-Type": "application/json", + Authorization: "Bearer test-token", + }) + + return new Response(JSON.stringify({ success: true, userId: 456 }), { + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + }) + }) + + await expect( + runTest( + ` + pm.test("RFC pattern - object headers", () => { + const requestObject = { + url: 'https://api.example.com/users', + method: 'POST', + header: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer test-token' + }, + body: { + mode: 'raw', + raw: JSON.stringify({ name: 'John Doe' }) + } + } + + pm.sendRequest(requestObject, (error, response) => { + pm.expect(error).toBe(null) + pm.expect(response.code).toBe(200) + pm.expect(response.json().success).toBe(true) + pm.expect(response.json().userId).toBe(456) + }) + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "RFC pattern - object headers", + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + { status: "pass", message: "Expected '200' to be '200'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected '456' to be '456'" }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Body modes", () => { + test("pm.sendRequest handles urlencoded body mode", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response( + JSON.stringify({ authenticated: true, token: "abc123" }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + }) + + await expect( + runTest( + ` + pm.test("urlencoded body", () => { + pm.sendRequest({ + url: "https://api.example.com/login", + method: "POST", + body: { + mode: "urlencoded", + urlencoded: [ + { key: "username", value: "john" }, + { key: "password", value: "secret123" } + ] + } + }, (error, response) => { + pm.expect(error).toBe(null) + pm.expect(response.code).toBe(200) + pm.expect(response.json().authenticated).toBe(true) + pm.expect(response.json().token).toBeType("string") + }) + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "urlencoded body", + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + { status: "pass", message: "Expected '200' to be '200'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + { + status: "pass", + message: "Expected 'abc123' to be type 'string'", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Integration with environment variables", () => { + test("pm.sendRequest works with environment variables", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response( + JSON.stringify({ data: "secured_data", user: "john" }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + }) + + await expect( + runTest( + ` + pm.test("environment variables in sendRequest", () => { + // Set environment variables + pm.environment.set("API_URL", "https://api.example.com") + pm.environment.set("AUTH_TOKEN", "Bearer token123") + + // Use variables in request + const url = pm.environment.get("API_URL") + "/data" + const token = pm.environment.get("AUTH_TOKEN") + + pm.sendRequest({ + url: url, + header: [ + { key: "Authorization", value: token } + ] + }, (error, response) => { + pm.expect(error).toBe(null) + pm.expect(response.code).toBe(200) + pm.expect(response.json().data).toBe("secured_data") + pm.expect(response.json().user).toBe("john") + }) + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "environment variables in sendRequest", + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + { status: "pass", message: "Expected '200' to be '200'" }, + { + status: "pass", + message: "Expected 'secured_data' to be 'secured_data'", + }, + { status: "pass", message: "Expected 'john' to be 'john'" }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Multiple requests in same test", () => { + test("pm.sendRequest supports multiple async requests", async () => { + let callCount = 0 + const mockFetch: HoppFetchHook = vi.fn(async () => { + callCount++ + return new Response( + JSON.stringify({ request: callCount, data: `response${callCount}` }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + }) + + await expect( + runTest( + ` + pm.test("multiple sendRequests", () => { + // First request + pm.sendRequest("https://api.example.com/first", (error, response) => { + pm.expect(error).toBe(null) + pm.expect(response.code).toBe(200) + pm.expect(response.json().request).toBe(1) + }) + + // Second request + pm.sendRequest("https://api.example.com/second", (error, response) => { + pm.expect(error).toBe(null) + pm.expect(response.code).toBe(200) + pm.expect(response.json().request).toBe(2) + }) + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "multiple sendRequests", + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + { status: "pass", message: "Expected '200' to be '200'" }, + { status: "pass", message: "Expected '1' to be '1'" }, + { status: "pass", message: "Expected 'null' to be 'null'" }, + { status: "pass", message: "Expected '200' to be '200'" }, + { status: "pass", message: "Expected '2' to be '2'" }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Additional body modes and content types", () => { + test("pm.sendRequest with formdata body mode", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ uploaded: true, files: 1 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }) + + await expect( + runTest( + ` + pm.test("formdata body", () => { + pm.sendRequest({ + url: "https://api.example.com/upload", + method: "POST", + body: { + mode: "formdata", + formdata: [ + { key: "file", value: "example.txt" }, + { key: "description", value: "test upload" } + ] + } + }, (error, response) => { + pm.expect(error).toBe(null) + pm.expect(response.code).toBe(200) + pm.expect(response.json().uploaded).toBe(true) + }) + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "formdata body", + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + { status: "pass", message: "Expected '200' to be '200'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + ], + }), + ]) + }) + }) + + describe("HTTP methods coverage", () => { + test("pm.sendRequest with PUT method", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ updated: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }) + + await expect( + runTest( + ` + pm.test("PUT request", () => { + pm.sendRequest({ + url: "https://api.example.com/resource/123", + method: "PUT", + header: { "Content-Type": "application/json" }, + body: { mode: "raw", raw: JSON.stringify({ name: "updated" }) } + }, (error, response) => { + pm.expect(error).toBe(null) + pm.expect(response.code).toBe(200) + pm.expect(response.json().updated).toBe(true) + }) + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "PUT request", + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + { status: "pass", message: "Expected '200' to be '200'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + ], + }), + ]) + }) + + test("pm.sendRequest with PATCH method", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ patched: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }) + + await expect( + runTest( + ` + pm.test("PATCH request", () => { + pm.sendRequest({ + url: "https://api.example.com/resource/456", + method: "PATCH", + header: { "Content-Type": "application/json" }, + body: { mode: "raw", raw: JSON.stringify({ status: "active" }) } + }, (error, response) => { + pm.expect(error).toBe(null) + pm.expect(response.code).toBe(200) + }) + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "PATCH request", + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + { status: "pass", message: "Expected '200' to be '200'" }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Response header validation", () => { + test("pm.sendRequest response headers access", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ data: "test" }), { + status: 200, + headers: { + "Content-Type": "application/json", + "X-Request-Id": "abc123", + "X-Rate-Limit": "100", + }, + }) + }) + + await expect( + runTest( + ` + pm.test("response headers parsing", () => { + pm.sendRequest("https://api.example.com/data", (error, response) => { + pm.expect(error).toBe(null) + pm.expect(response.headers.has("Content-Type")).toBe(true) + pm.expect(response.headers.get("X-Request-Id")).toBe("abc123") + pm.expect(response.headers.has("X-Rate-Limit")).toBe(true) + }) + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "response headers parsing", + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected 'abc123' to be 'abc123'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Cookie handling", () => { + test("pm.sendRequest should handle empty cookies gracefully", async () => { + const mockFetch: HoppFetchHook = vi.fn(async () => { + return new Response(JSON.stringify({ success: true }), { + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + }) + }) + + await expect( + runTest( + ` + pm.test("sendRequest without cookies", () => { + pm.sendRequest("https://api.example.com/data", (error, response) => { + pm.expect(error).toBe(null) + pm.expect(response.cookies.has("anything")).toBe(false) + pm.expect(response.cookies.get("anything")).toBe(null) + + const cookiesObj = response.cookies.toObject() + pm.expect(Object.keys(cookiesObj).length).toBe(0) + }) + }) + `, + { global: [], selected: [] }, + undefined, + undefined, + mockFetch + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "sendRequest without cookies", + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + { status: "pass", message: "Expected 'null' to be 'null'" }, + { status: "pass", message: "Expected '0' to be '0'" }, + ], + }), + ], + }), + ]) + }) + }) + + describe("E2E test reference", () => { + test("comprehensive validation in E2E tests", () => { + // This is a documentation test - no actual execution needed + // For comprehensive validation including: + // - HTTP methods (GET, POST, PUT, DELETE, PATCH) + // - Body modes (raw, urlencoded, formdata) + // - Response header parsing + // - Multi-request workflows + // - Store response in environment + // + // See E2E tests in: + // packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json + // + // Run with: pnpm --filter @hoppscotch/cli test:e2e + expect(true).toBe(true) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/serialization-edge-cases.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/serialization-edge-cases.spec.ts new file mode 100644 index 0000000..b1a6af8 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/serialization-edge-cases.spec.ts @@ -0,0 +1,1227 @@ +// QuickJS sandbox boundary serialization: compute values BEFORE crossing to preserve type info + +import { describe, expect, test } from "vitest" +import { + runTestWithResponse, + fakeResponse, + runTest, +} from "~/utils/test-helpers" + +describe("Serialization Edge Cases - Object Reference Equality", () => { + describe("`pm.expect.equal()` - Reference Equality", () => { + test("should pass for same object reference", async () => { + const testScript = ` + pm.test("same reference equality", () => { + const obj = { a: 1 }; + pm.expect(obj).to.equal(obj); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "same reference equality", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should fail for different object references with same content", async () => { + const testScript = ` + pm.test("different references", () => { + pm.expect({ a: 1 }).to.not.equal({ a: 1 }); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "different references", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should work with arrays", async () => { + const testScript = ` + pm.test("array reference equality", () => { + const arr = [1, 2, 3]; + pm.expect(arr).to.equal(arr); + pm.expect([1, 2, 3]).to.not.equal([1, 2, 3]); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "array reference equality", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with primitives (always equal by value)", async () => { + const testScript = ` + pm.test("primitive equality", () => { + pm.expect(5).to.equal(5); + pm.expect('hello').to.equal('hello'); + pm.expect(true).to.equal(true); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "primitive equality", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) +}) + +describe("Serialization Edge Cases - Prototype Chain Preservation", () => { + describe("`pm.expect.own.property()` - Own vs Inherited Properties", () => { + test("should pass when object HAS own property", async () => { + const testScript = ` + pm.test("own property - positive case", () => { + const obj = Object.create({ inherited: true }); + obj.own = true; + pm.expect(obj).to.have.own.property('own'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "own property - positive case", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should distinguish own properties from inherited (negation)", async () => { + const testScript = ` + pm.test("own property vs inherited", () => { + const obj = Object.create({ inherited: true }); + obj.own = true; + pm.expect(obj).to.have.own.property('own'); + pm.expect(obj).to.not.have.own.property('inherited'); + pm.expect(obj).to.have.property('inherited'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "own property vs inherited", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with plain objects", async () => { + const testScript = ` + pm.test("plain object own properties", () => { + const obj = { a: 1, b: 2 }; + pm.expect(obj).to.have.own.property('a'); + pm.expect(obj).to.have.own.property('b'); + pm.expect(obj).to.not.have.own.property('toString'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "plain object own properties", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should fail when assertion is incorrect", async () => { + const testScript = ` + pm.test("incorrect assertion", () => { + const obj = { a: 1 }; + pm.expect(obj).to.not.have.own.property('a'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "incorrect assertion", + expectResults: [expect.objectContaining({ status: "fail" })], + }), + ]), + }), + ]) + ) + }) + + test("should work with arrays (inherited from Array.prototype)", async () => { + const testScript = ` + pm.test("array own vs inherited", () => { + const arr = [1, 2, 3]; + pm.expect(arr).to.have.own.property('0'); + pm.expect(arr).to.have.own.property('length'); + pm.expect(arr).to.not.have.own.property('push'); + pm.expect(arr).to.have.property('push'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "array own vs inherited", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe("`pm.expect.itself.respondTo()` - Static vs Instance Methods", () => { + test("should pass when class HAS static method", async () => { + const testScript = ` + pm.test("static method - positive case", () => { + class MyClass { + static staticMethod() {} + instanceMethod() {} + } + pm.expect(MyClass).itself.to.respondTo('staticMethod'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "static method - positive case", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should distinguish static methods from instance methods", async () => { + const testScript = ` + pm.test("static vs instance methods", () => { + class MyClass { + static staticMethod() {} + instanceMethod() {} + } + pm.expect(MyClass).itself.to.respondTo('staticMethod'); + pm.expect(MyClass).to.not.itself.respondTo('instanceMethod'); + pm.expect(MyClass).to.respondTo('instanceMethod'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "static vs instance methods", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with built-in constructors", async () => { + const testScript = ` + pm.test("built-in static methods", () => { + pm.expect(Array).itself.to.respondTo('isArray'); + pm.expect(Array).itself.to.respondTo('from'); + pm.expect(Object).itself.to.respondTo('keys'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "built-in static methods", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should fail when static method doesn't exist", async () => { + const testScript = ` + pm.test("non-existent static method", () => { + class MyClass { + instanceMethod() {} + } + pm.expect(MyClass).itself.to.respondTo('nonExistent'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "non-existent static method", + expectResults: [expect.objectContaining({ status: "fail" })], + }), + ]), + }), + ]) + ) + }) + + test("should work with negation", async () => { + const testScript = ` + pm.test("static method negation", () => { + class MyClass { + static staticMethod() {} + } + pm.expect(MyClass).itself.to.respondTo('staticMethod'); + pm.expect(MyClass).itself.to.not.respondTo('nonExistent'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "static method negation", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with multiple static methods", async () => { + const testScript = ` + pm.test("multiple static methods", () => { + class MyClass { + static method1() {} + static method2() {} + static method3() {} + } + pm.expect(MyClass).itself.to.respondTo('method1'); + pm.expect(MyClass).itself.to.respondTo('method2'); + pm.expect(MyClass).itself.to.respondTo('method3'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "multiple static methods", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) +}) + +describe("Serialization Edge Cases - Special Object Types", () => { + describe("`pm.expect.eql()` - RegExp Comparison", () => { + test("should pass for identical RegExp patterns", async () => { + const testScript = ` + pm.test("identical regex", () => { + pm.expect(/test/i).to.eql(/test/i); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "identical regex", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should fail for different RegExp flags", async () => { + const testScript = ` + pm.test("different flags", () => { + pm.expect(/test/i).to.not.eql(/test/g); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "different flags", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should fail for different RegExp patterns", async () => { + const testScript = ` + pm.test("different patterns", () => { + pm.expect(/test/).to.not.eql(/demo/); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "different patterns", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + }) + + describe("`pm.expect.equal()` - Date Comparison", () => { + test("should pass for same Date reference", async () => { + const testScript = ` + pm.test("same date reference", () => { + const date = new Date('2024-01-01'); + pm.expect(date).to.equal(date); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "same date reference", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should pass when different Date references are not equal (reference inequality)", async () => { + const testScript = ` + pm.test("different date references", () => { + pm.expect(new Date('2024-01-01')).to.not.equal(new Date('2024-01-01')); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "different date references", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should use eql() for value equality on dates", async () => { + const testScript = ` + pm.test("date value equality", () => { + pm.expect(new Date('2024-01-01')).to.eql(new Date('2024-01-01')); + pm.expect(new Date('2024-01-01')).to.not.eql(new Date('2024-01-02')); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "date value equality", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + }) + + describe("`pm.expect.a()` - Array Type Checking", () => { + test("should pass for 'object' type check", async () => { + const testScript = ` + pm.test("array is object", () => { + pm.expect([]).to.be.a('object'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "array is object", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should pass for 'array' type check", async () => { + const testScript = ` + pm.test("array is array", () => { + pm.expect([]).to.be.an('array'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "array is array", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should pass all typeof vs instanceof tests", async () => { + const testScript = ` + pm.test("typeof vs instanceof", () => { + pm.expect([]).to.be.a('object'); + pm.expect([]).to.be.an('array'); + pm.expect([]).to.be.instanceOf(Array); + pm.expect([]).to.be.instanceOf(Object); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "typeof vs instanceof", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) +}) + +describe("Serialization Edge Cases - Assertion Chaining", () => { + describe("`.length().which` - Value Proxy Chaining", () => { + test("should allow chaining after length check", async () => { + const testScript = ` + pm.test("length chaining", () => { + pm.expect([1, 2, 3]).to.have.length(3).which.is.above(2); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "length chaining", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should chain with .that instead of .which", async () => { + const testScript = ` + pm.test("length with that", () => { + pm.expect('test').to.have.length(4).that.is.above(3); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "length with that", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should support complex chaining", async () => { + const testScript = ` + pm.test("complex chaining", () => { + pm.expect([1, 2, 3, 4, 5]).to.have.length(5).which.is.above(4).and.below(6); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "complex chaining", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe("`.not.have.lengthOf()` - Negation Support", () => { + test("should pass when string does NOT have specified length", async () => { + const testScript = ` + pm.test("string lengthOf negation", () => { + pm.expect('test').to.have.lengthOf(4); + pm.expect('test').to.not.have.lengthOf(10); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "string lengthOf negation", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should pass when array does NOT have specified length", async () => { + const testScript = ` + pm.test("array lengthOf negation", () => { + pm.expect([1, 2]).to.have.lengthOf(2); + pm.expect([1, 2]).to.not.have.lengthOf(3); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "array lengthOf negation", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should fail when negation is incorrect", async () => { + const testScript = ` + pm.test("incorrect negation", () => { + pm.expect('test').to.not.have.lengthOf(4); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "incorrect negation", + expectResults: [expect.objectContaining({ status: "fail" })], + }), + ]), + }), + ]) + ) + }) + + test("should support empty string/array", async () => { + const testScript = ` + pm.test("empty values", () => { + pm.expect('').to.have.lengthOf(0); + pm.expect('').to.not.have.lengthOf(1); + pm.expect([]).to.have.lengthOf(0); + pm.expect([]).to.not.have.lengthOf(5); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "empty values", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe("Circular Reference Handling", () => { + test("should handle circular object references in property assertions", () => { + return expect( + runTest(` + pm.test("Circular reference property", function() { + const obj = { a: 1, b: 2 } + obj.self = obj // Circular reference + + pm.expect(obj).to.have.property('self') + pm.expect(obj.self).to.equal(obj) + pm.expect(obj).to.have.property('a', 1) + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Circular reference property", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should handle nested circular references", () => { + return expect( + runTest(` + pm.test("Nested circular references", function() { + const parent = { name: "parent", child: null } + const child = { name: "child", parent: parent } + parent.child = child // Creates circular reference + + pm.expect(parent.child.parent).to.equal(parent) + pm.expect(parent).to.have.property('name', 'parent') + pm.expect(child).to.have.property('name', 'child') + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Nested circular references", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should not throw 'Converting circular structure to JSON' error", () => { + return expect( + runTest(` + pm.test("No JSON.stringify error", function() { + const obj = { data: 'test' } + obj.circular = obj + + // This should not throw even though obj has circular reference + pm.expect(obj).to.have.property('data') + pm.expect(obj.circular).to.equal(obj) + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "No JSON.stringify error", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should fail with stack overflow for circular array references (known limitation)", () => { + return expect( + runTest(` + pm.test("Circular array limitation", function() { + const arr = [1, 2, 3] + arr.push(arr) // Creates circular reference + pm.expect(arr).to.have.lengthOf(4) + }) + `)() + ).resolves.toEqualLeft( + // QuickJS returns a GC error instead of "Maximum call stack size exceeded" + // The exact QuickJS error message may vary between versions and environments + // (e.g., "internal error: out of memory in GC"), so we only check for the + // generic prefix to avoid brittle tests + expect.stringContaining("Script execution failed:") + ) + }) + }) +}) + +describe("Serialization Edge Cases - Symbol Properties", () => { + test("should not enumerate Symbol properties in assertions", async () => { + const testScript = ` + pm.test("Symbol properties are not enumerable", () => { + const sym = Symbol('test'); + const obj = { regular: 'value' }; + obj[sym] = 'symbol value'; + + // Should only see regular properties + pm.expect(obj).to.have.property('regular'); + pm.expect(Object.keys(obj)).to.have.lengthOf(1); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Symbol properties are not enumerable", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should handle objects with Symbol.iterator", async () => { + const testScript = ` + pm.test("Symbol.iterator support", () => { + const customIterable = { + data: [1, 2, 3], + [Symbol.iterator]: function*() { + yield* this.data; + } + }; + + // Should be able to assert on regular properties + pm.expect(customIterable).to.have.property('data'); + pm.expect(customIterable.data).to.have.lengthOf(3); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Symbol.iterator support", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should serialize objects with Symbol properties correctly", async () => { + const testScript = ` + pm.test("Symbol property serialization", () => { + const sym = Symbol('hidden'); + const obj = { + visible: 'data', + nested: { value: 42 } + }; + obj[sym] = 'should not appear in JSON'; + + const jsonStr = JSON.stringify(obj); + const parsed = JSON.parse(jsonStr); + + // After serialization, Symbol properties are lost + pm.expect(parsed).to.have.property('visible', 'data'); + pm.expect(parsed).to.have.property('nested'); + pm.expect(parsed.nested).to.have.property('value', 42); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Symbol property serialization", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Serialization Edge Cases - Special Numbers", () => { + test("should handle NaN assertions correctly", async () => { + const testScript = ` + pm.test("NaN handling", () => { + const result = 0 / 0; + + // NaN is not equal to itself + pm.expect(result).to.be.NaN; + pm.expect(result).to.not.equal(result); + pm.expect(Number.isNaN(result)).to.be.true; + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "NaN handling", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should handle Infinity and -Infinity", async () => { + const testScript = ` + pm.test("Infinity values", () => { + const posInf = 1 / 0; + const negInf = -1 / 0; + + pm.expect(posInf).to.equal(Infinity); + pm.expect(negInf).to.equal(-Infinity); + pm.expect(posInf).to.not.equal(negInf); + pm.expect(Number.isFinite(posInf)).to.be.false; + pm.expect(Number.isFinite(negInf)).to.be.false; + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Infinity values", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should distinguish between +0 and -0", async () => { + const testScript = ` + pm.test("Negative zero handling", () => { + const positiveZero = 0; + const negativeZero = -0; + + // In most contexts, +0 and -0 are equal + pm.expect(positiveZero).to.equal(negativeZero); + pm.expect(positiveZero === negativeZero).to.be.true; + + // But they can be distinguished with Object.is or 1/x + pm.expect(Object.is(positiveZero, negativeZero)).to.be.false; + pm.expect(1 / positiveZero).to.equal(Infinity); + pm.expect(1 / negativeZero).to.equal(-Infinity); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Negative zero handling", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should handle special numbers in JSON serialization", async () => { + const testScript = ` + pm.test("Special numbers in JSON", () => { + const obj = { + nan: NaN, + inf: Infinity, + negInf: -Infinity, + zero: 0, + negZero: -0 + }; + + const jsonStr = JSON.stringify(obj); + const parsed = JSON.parse(jsonStr); + + // NaN, Infinity, -Infinity become null in JSON + pm.expect(parsed.nan).to.be.null; + pm.expect(parsed.inf).to.be.null; + pm.expect(parsed.negInf).to.be.null; + + // Both zeros become 0 + pm.expect(parsed.zero).to.equal(0); + pm.expect(parsed.negZero).to.equal(0); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Special numbers in JSON", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/type-coercion.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/type-coercion.spec.ts new file mode 100644 index 0000000..e68d69e --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/type-coercion.spec.ts @@ -0,0 +1,360 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +// Postman compatibility: all types preserved during runtime, only undefined needs special handling + +describe("PM namespace type preservation (Postman compatibility)", () => { + describe("Array preservation", () => { + test("arrays are preserved as arrays with .length property", () => { + return expect( + runTest( + ` + pm.environment.set("array", [1, 2, 3]) + const value = pm.environment.get("array") + pm.expect(Array.isArray(value)).toBe(true) + pm.expect(value.length).toBe(3) + pm.expect(value[0]).toBe(1) + pm.expect(value[2]).toBe(3) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected '3' to be '3'" }, + { status: "pass", message: "Expected '1' to be '1'" }, + { status: "pass", message: "Expected '3' to be '3'" }, + ], + }), + ]) + }) + + test("single-element arrays remain arrays", () => { + return expect( + runTest( + ` + pm.environment.set("single", [42]) + const value = pm.environment.get("single") + pm.expect(Array.isArray(value)).toBe(true) + pm.expect(value.length).toBe(1) + pm.expect(value[0]).toBe(42) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected '1' to be '1'" }, + { status: "pass", message: "Expected '42' to be '42'" }, + ], + }), + ]) + }) + + test("empty arrays are preserved", () => { + return expect( + runTest( + ` + pm.environment.set("empty", []) + const value = pm.environment.get("empty") + pm.expect(Array.isArray(value)).toBe(true) + pm.expect(value.length).toBe(0) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected '0' to be '0'" }, + ], + }), + ]) + }) + + test("nested arrays are preserved", () => { + return expect( + runTest( + ` + pm.environment.set("nested", [[1, 2], [3, 4]]) + const value = pm.environment.get("nested") + pm.expect(Array.isArray(value)).toBe(true) + pm.expect(value.length).toBe(2) + pm.expect(Array.isArray(value[0])).toBe(true) + pm.expect(value[0][1]).toBe(2) + pm.expect(value[1][0]).toBe(3) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected '2' to be '2'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected '2' to be '2'" }, + { status: "pass", message: "Expected '3' to be '3'" }, + ], + }), + ]) + }) + }) + + describe("Object preservation", () => { + test("objects are preserved with accessible properties", () => { + return expect( + runTest( + ` + pm.environment.set("obj", { key: "value", num: 42 }) + const value = pm.environment.get("obj") + pm.expect(typeof value).toBe("object") + pm.expect(value.key).toBe("value") + pm.expect(value.num).toBe(42) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'object' to be 'object'" }, + { status: "pass", message: "Expected 'value' to be 'value'" }, + { status: "pass", message: "Expected '42' to be '42'" }, + ], + }), + ]) + }) + + test("empty objects are preserved", () => { + return expect( + runTest( + ` + pm.environment.set("empty_obj", {}) + const value = pm.environment.get("empty_obj") + pm.expect(typeof value).toBe("object") + pm.expect(Array.isArray(value)).toBe(false) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'object' to be 'object'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + ], + }), + ]) + }) + + test("nested objects are preserved", () => { + return expect( + runTest( + ` + const original = { key: "value", nested: { prop: 123, deep: { inner: "test" } } } + pm.environment.set("nested_obj", original) + const retrieved = pm.environment.get("nested_obj") + + pm.expect(typeof retrieved).toBe("object") + pm.expect(retrieved.key).toBe("value") + pm.expect(retrieved.nested.prop).toBe(123) + pm.expect(retrieved.nested.deep.inner).toBe("test") + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'object' to be 'object'" }, + { status: "pass", message: "Expected 'value' to be 'value'" }, + { status: "pass", message: "Expected '123' to be '123'" }, + { status: "pass", message: "Expected 'test' to be 'test'" }, + ], + }), + ]) + }) + }) + + describe("Null preservation", () => { + test("null is preserved as actual null value", () => { + return expect( + runTest( + ` + pm.environment.set("nullable", null) + const value = pm.environment.get("nullable") + pm.expect(value).toBe(null) + pm.expect(value === null).toBe(true) + pm.expect(typeof value).toBe("object") + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected 'object' to be 'object'" }, + ], + }), + ]) + }) + }) + + describe("Undefined preservation (special case)", () => { + test("undefined is preserved as actual undefined", () => { + return expect( + runTest( + ` + pm.environment.set("undef", undefined) + const value = pm.environment.get("undef") + pm.expect(value).toBe(undefined) + pm.expect(typeof value).toBe("undefined") + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) + + test("undefined is distinguishable from non-existent keys", () => { + return expect( + runTest( + ` + pm.environment.set("explicit_undef", undefined) + pm.expect(pm.environment.has("explicit_undef")).toBe(true) + pm.expect(pm.environment.has("never_set")).toBe(false) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + ], + }), + ]) + }) + }) + + describe("Primitive type preservation", () => { + test("numbers are preserved as numbers", () => { + return expect( + runTest( + ` + pm.environment.set("num", 123) + const value = pm.environment.get("num") + pm.expect(typeof value).toBe("number") + pm.expect(value).toBe(123) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'number' to be 'number'" }, + { status: "pass", message: "Expected '123' to be '123'" }, + ], + }), + ]) + }) + + test("booleans are preserved as booleans", () => { + return expect( + runTest( + ` + pm.environment.set("bool", true) + const value = pm.environment.get("bool") + pm.expect(typeof value).toBe("boolean") + pm.expect(value).toBe(true) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'boolean' to be 'boolean'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + ]) + }) + + test("strings remain strings", () => { + return expect( + runTest( + ` + pm.environment.set("str", "hello") + const value = pm.environment.get("str") + pm.expect(typeof value).toBe("string") + pm.expect(value).toBe("hello") + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'string' to be 'string'" }, + { status: "pass", message: "Expected 'hello' to be 'hello'" }, + ], + }), + ]) + }) + }) + + describe("Cross-scope type preservation", () => { + test("pm.globals preserves arrays", () => { + return expect( + runTest( + ` + pm.globals.set("global_array", [1, 2, 3]) + const value = pm.globals.get("global_array") + pm.expect(Array.isArray(value)).toBe(true) + pm.expect(value.length).toBe(3) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected '3' to be '3'" }, + ], + }), + ]) + }) + + test("pm.variables preserves objects", () => { + return expect( + runTest( + ` + pm.variables.set("var_obj", { key: "value" }) + const value = pm.variables.get("var_obj") + pm.expect(typeof value).toBe("object") + pm.expect(value.key).toBe("value") + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'object' to be 'object'" }, + { status: "pass", message: "Expected 'value' to be 'value'" }, + ], + }), + ]) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/unsupported.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/unsupported.spec.ts new file mode 100644 index 0000000..cfb26e5 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/unsupported.spec.ts @@ -0,0 +1,249 @@ +import { describe, expect, test } from "vitest" +import { runTest, runPreRequest } from "~/utils/test-helpers" + +// Unified test data for unsupported APIs +const unsupportedApis = [ + { + api: "pm.info.iteration", + script: "const iteration = pm.info.iteration", + errorMessage: + "pm.info.iteration is not supported in Hoppscotch (Collection Runner feature)", + }, + { + api: "pm.info.iterationCount", + script: "const iterationCount = pm.info.iterationCount", + errorMessage: + "pm.info.iterationCount is not supported in Hoppscotch (Collection Runner feature)", + }, + { + api: "pm.collectionVariables.get()", + script: 'pm.collectionVariables.get("test")', + errorMessage: + "pm.collectionVariables.get() is not supported in Hoppscotch (use environment or request variables instead)", + }, + { + api: "pm.collectionVariables.set()", + script: 'pm.collectionVariables.set("key", "value")', + errorMessage: + "pm.collectionVariables.set() is not supported in Hoppscotch (use environment or request variables instead)", + }, + { + api: "pm.collectionVariables.unset()", + script: 'pm.collectionVariables.unset("key")', + errorMessage: + "pm.collectionVariables.unset() is not supported in Hoppscotch (use environment or request variables instead)", + }, + { + api: "pm.collectionVariables.has()", + script: 'pm.collectionVariables.has("key")', + errorMessage: + "pm.collectionVariables.has() is not supported in Hoppscotch (use environment or request variables instead)", + }, + { + api: "pm.collectionVariables.clear()", + script: "pm.collectionVariables.clear()", + errorMessage: + "pm.collectionVariables.clear() is not supported in Hoppscotch (use environment or request variables instead)", + }, + { + api: "pm.collectionVariables.toObject()", + script: "pm.collectionVariables.toObject()", + errorMessage: + "pm.collectionVariables.toObject() is not supported in Hoppscotch (use environment or request variables instead)", + }, + { + api: "pm.collectionVariables.replaceIn()", + script: 'pm.collectionVariables.replaceIn("{{var}}")', + errorMessage: + "pm.collectionVariables.replaceIn() is not supported in Hoppscotch (use environment or request variables instead)", + }, + { + api: "pm.vault.get()", + script: 'pm.vault.get("test")', + errorMessage: + "pm.vault.get() is not supported in Hoppscotch (Postman Vault feature)", + }, + { + api: "pm.vault.set()", + script: 'pm.vault.set("key", "value")', + errorMessage: + "pm.vault.set() is not supported in Hoppscotch (Postman Vault feature)", + }, + { + api: "pm.vault.unset()", + script: 'pm.vault.unset("key")', + errorMessage: + "pm.vault.unset() is not supported in Hoppscotch (Postman Vault feature)", + }, + { + api: "pm.iterationData.get()", + script: 'pm.iterationData.get("test")', + errorMessage: + "pm.iterationData.get() is not supported in Hoppscotch (Collection Runner feature)", + }, + { + api: "pm.iterationData.set()", + script: 'pm.iterationData.set("key", "value")', + errorMessage: + "pm.iterationData.set() is not supported in Hoppscotch (Collection Runner feature)", + }, + { + api: "pm.iterationData.unset()", + script: 'pm.iterationData.unset("key")', + errorMessage: + "pm.iterationData.unset() is not supported in Hoppscotch (Collection Runner feature)", + }, + { + api: "pm.iterationData.has()", + script: 'pm.iterationData.has("key")', + errorMessage: + "pm.iterationData.has() is not supported in Hoppscotch (Collection Runner feature)", + }, + { + api: "pm.iterationData.toObject()", + script: "pm.iterationData.toObject()", + errorMessage: + "pm.iterationData.toObject() is not supported in Hoppscotch (Collection Runner feature)", + }, + { + api: "pm.iterationData.toJSON()", + script: "pm.iterationData.toJSON()", + errorMessage: + "pm.iterationData.toJSON() is not supported in Hoppscotch (Collection Runner feature)", + }, + { + api: "pm.execution.setNextRequest()", + script: 'pm.execution.setNextRequest("next-request")', + errorMessage: + "pm.execution.setNextRequest() is not supported in Hoppscotch (Collection Runner feature)", + }, + { + api: "pm.execution.skipRequest()", + script: "pm.execution.skipRequest()", + errorMessage: + "pm.execution.skipRequest() is not supported in Hoppscotch (Collection Runner feature)", + }, + { + api: "pm.execution.runRequest()", + script: 'pm.execution.runRequest("request-id")', + errorMessage: + "pm.execution.runRequest() is not supported in Hoppscotch (Collection Runner feature)", + }, + { + api: "pm.visualizer.set()", + script: 'pm.visualizer.set("

Test

")', + errorMessage: + "pm.visualizer.set() is not supported in Hoppscotch (Postman Visualizer feature)", + }, + { + api: "pm.visualizer.clear()", + script: "pm.visualizer.clear()", + errorMessage: + "pm.visualizer.clear() is not supported in Hoppscotch (Postman Visualizer feature)", + }, + { + api: "pm.require()", + script: 'pm.require("@team/package")', + errorMessage: + "pm.require('@team/package') is not supported in Hoppscotch (Package Library feature)", + }, +] + +describe("pm namespace - unsupported features", () => { + // Test unsupported APIs in both pre-request and test scripts + test.each(unsupportedApis)( + "$api throws error in pre-request script", + ({ script, errorMessage }) => { + return expect( + runPreRequest(script, { + global: [], + selected: [], + })() + ).resolves.toEqualLeft(`Script execution failed: Error: ${errorMessage}`) + } + ) + + test.each(unsupportedApis)( + "$api throws error in test script", + async ({ script, errorMessage }) => { + const result = await runTest(script, { + global: [], + selected: [], + })() + + // Check that the error message contains the expected error text + // We use toEqualLeft with stringContaining because QuickJS may append GC disposal errors + expect(result).toEqualLeft( + expect.stringContaining( + `Script execution failed: Error: ${errorMessage}` + ) + ) + } + ) + + test("pm.collectionVariables.get() throws error", async () => { + await expect( + runTest(`pm.collectionVariables.get("test")`, { + global: [], + selected: [], + })() + ).resolves.toEqualLeft( + expect.stringContaining("pm.collectionVariables.get() is not supported") + ) + }) + + test("pm.vault.get() throws error", async () => { + await expect( + runTest(`pm.vault.get("test")`, { + global: [], + selected: [], + })() + ).resolves.toEqualLeft( + expect.stringContaining("pm.vault.get() is not supported") + ) + }) + + test("pm.iterationData.get() throws error", async () => { + await expect( + runTest(`pm.iterationData.get("test")`, { + global: [], + selected: [], + })() + ).resolves.toEqualLeft( + expect.stringContaining("pm.iterationData.get() is not supported") + ) + }) + + test("pm.execution.setNextRequest() throws error", async () => { + await expect( + runTest(`pm.execution.setNextRequest("next-request")`, { + global: [], + selected: [], + })() + ).resolves.toEqualLeft( + expect.stringContaining("pm.execution.setNextRequest() is not supported") + ) + }) + + test("pm.visualizer.set() throws error", async () => { + await expect( + runTest(`pm.visualizer.set("

Test

")`, { + global: [], + selected: [], + })() + ).resolves.toEqualLeft( + expect.stringContaining("pm.visualizer.set() is not supported") + ) + }) + + test("pm.visualizer.clear() throws error", async () => { + await expect( + runTest(`pm.visualizer.clear()`, { + global: [], + selected: [], + })() + ).resolves.toEqualLeft( + expect.stringContaining("pm.visualizer.clear() is not supported") + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/variables.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/variables.spec.ts new file mode 100644 index 0000000..36a3270 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/variables.spec.ts @@ -0,0 +1,705 @@ +import { getDefaultRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" +import { runPreRequestScript } from "~/node" +import { runTest } from "~/utils/test-helpers" + +describe("pm.environment", () => { + test("pm.environment.get returns the correct value for an existing active environment value", () => { + return expect( + runTest( + ` + const data = pm.environment.get("a") + pm.expect(data).toBe("b") + `, + { + global: [], + selected: [ + { + key: "a", + currentValue: "b", + initialValue: "b", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [{ status: "pass", message: "Expected 'b' to be 'b'" }], + }), + ]) + }) + + test("pm.environment.set creates and retrieves environment variable", () => { + return expect( + runTest( + ` + pm.environment.set("test_set", "set_value") + const retrieved = pm.environment.get("test_set") + pw.expect(retrieved).toBe("set_value") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'set_value' to be 'set_value'", + }, + ], + }), + ]) + }) + + test("pm.environment.set works correctly", () => { + return expect( + runTest( + ` + pm.environment.set("newVar", "newValue") + const data = pm.environment.get("newVar") + pm.expect(data).toBe("newValue") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'newValue' to be 'newValue'" }, + ], + }), + ]) + }) + + test("pm.environment.has correctly identifies existing and non-existing variables", () => { + return expect( + runTest( + ` + const hasExisting = pm.environment.has("existing_var") + const hasNonExisting = pm.environment.has("non_existing_var") + pw.expect(hasExisting.toString()).toBe("true") + pw.expect(hasNonExisting.toString()).toBe("false") + `, + { + global: [], + selected: [ + { + key: "existing_var", + currentValue: "existing_value", + initialValue: "existing_value", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'true' to be 'true'", + }, + { + status: "pass", + message: "Expected 'false' to be 'false'", + }, + ], + }), + ]) + }) +}) + +describe("pm.globals", () => { + test("pm.globals.get returns the correct value for an existing global environment value", () => { + return expect( + runTest( + ` + const data = pm.globals.get("globalVar") + pm.expect(data).toBe("globalValue") + `, + { + global: [ + { + key: "globalVar", + currentValue: "globalValue", + initialValue: "globalValue", + secret: false, + }, + ], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'globalValue' to be 'globalValue'", + }, + ], + }), + ]) + }) + + test("pm.globals.set creates and retrieves global variable", () => { + return expect( + runTest( + ` + pm.globals.set("test_global", "global_value") + const retrieved = pm.globals.get("test_global") + pw.expect(retrieved).toBe("global_value") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'global_value' to be 'global_value'", + }, + ], + }), + ]) + }) +}) + +describe("pm.variables", () => { + test("pm.variables.get returns the correct value from any scope", () => { + return expect( + runTest( + ` + const data = pm.variables.get("scopedVar") + pm.expect(data).toBe("scopedValue") + `, + { + global: [ + { + key: "scopedVar", + currentValue: "scopedValue", + initialValue: "scopedValue", + secret: false, + }, + ], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'scopedValue' to be 'scopedValue'", + }, + ], + }), + ]) + }) + + test("pm.variables.set creates and retrieves variable in active environment", () => { + return expect( + runTest( + ` + pm.variables.set("test_var", "test_value") + const retrieved = pm.variables.get("test_var") + pw.expect(retrieved).toBe("test_value") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'test_value' to be 'test_value'", + }, + ], + }), + ]) + }) + + test("pm.variables.has correctly identifies existing and non-existing variables", () => { + return expect( + runTest( + ` + const hasExisting = pm.variables.has("existing_var") + const hasNonExisting = pm.variables.has("non_existing_var") + pw.expect(hasExisting.toString()).toBe("true") + pw.expect(hasNonExisting.toString()).toBe("false") + `, + { + global: [], + selected: [ + { + key: "existing_var", + currentValue: "existing_value", + initialValue: "existing_value", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'true' to be 'true'", + }, + { + status: "pass", + message: "Expected 'false' to be 'false'", + }, + ], + }), + ]) + }) + + test("pm.variables.replaceIn works correctly", () => { + return expect( + runTest( + ` + const template = "Hello {{name}}, welcome to {{place}}!" + const result = pm.variables.replaceIn(template) + pm.expect(result).toBe("Hello World, welcome to Hoppscotch!") + `, + { + global: [], + selected: [ + { + key: "name", + currentValue: "World", + initialValue: "World", + secret: false, + }, + { + key: "place", + currentValue: "Hoppscotch", + initialValue: "Hoppscotch", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: + "Expected 'Hello World, welcome to Hoppscotch!' to be 'Hello World, welcome to Hoppscotch!'", + }, + ], + }), + ]) + }) + + test("pm.variables.replaceIn handles multiple variables", () => { + return expect( + runTest( + ` + const template = "User {{name}} has {{count}} items in {{location}}" + const result = pm.variables.replaceIn(template) + pw.expect(result).toBe("User Alice has 10 items in Cart") + `, + { + global: [ + { + key: "location", + currentValue: "Cart", + initialValue: "Cart", + secret: false, + }, + ], + selected: [ + { + key: "name", + currentValue: "Alice", + initialValue: "Alice", + secret: false, + }, + { + key: "count", + currentValue: "10", + initialValue: "10", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: + "Expected 'User Alice has 10 items in Cart' to be 'User Alice has 10 items in Cart'", + }, + ], + }), + ]) + }) +}) + +describe("pm.test", () => { + test("pm.test works as expected", () => { + return expect( + runTest( + ` + pm.test("Simple test", function() { + pm.expect(1 + 1).toBe(2) + }) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "Simple test", + expectResults: [ + { status: "pass", message: "Expected '2' to be '2'" }, + ], + }), + ], + }), + ]) + }) +}) + +describe("pm environment get() null vs undefined behavior", () => { + test("pm.environment.get() returns undefined (not null) for non-existent keys", () => { + return expect( + runTest( + ` + const value = pm.environment.get("non_existent_key") + pw.expect(value).toBe(undefined) + pw.expect(value === null).toBe(false) + pw.expect(value === undefined).toBe(true) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'undefined' to be 'undefined'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + ]) + }) + + test("pm.globals.get() returns undefined (not null) for non-existent keys", () => { + return expect( + runTest( + ` + const value = pm.globals.get("non_existent_global") + pw.expect(value).toBe(undefined) + pw.expect(value === null).toBe(false) + pw.expect(value === undefined).toBe(true) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'undefined' to be 'undefined'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + ]) + }) + + test("pm.variables.get() returns undefined (not null) for non-existent keys", () => { + return expect( + runTest( + ` + const value = pm.variables.get("non_existent_var") + pw.expect(value).toBe(undefined) + pw.expect(value === null).toBe(false) + pw.expect(value === undefined).toBe(true) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'undefined' to be 'undefined'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + ]) + }) +}) + +describe("pm environment clear() and toObject() methods", () => { + test("pm.environment.clear() removes all environment variables", () => { + return expect( + runTest( + ` + // Set some variables + pm.environment.set("var1", "value1") + pm.environment.set("var2", "value2") + pm.environment.set("var3", "value3") + + // Verify they exist + pw.expect(pm.environment.has("var1")).toBe(true) + pw.expect(pm.environment.has("var2")).toBe(true) + pw.expect(pm.environment.has("var3")).toBe(true) + + // Clear all + pm.environment.clear() + + // Verify all are removed + pw.expect(pm.environment.has("var1")).toBe(false) + pw.expect(pm.environment.has("var2")).toBe(false) + pw.expect(pm.environment.has("var3")).toBe(false) + + // Verify toObject returns empty + const allVars = pm.environment.toObject() + pw.expect(Object.keys(allVars).length).toBe(0) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + { status: "pass", message: "Expected '0' to be '0'" }, + ]), + }), + ]) + }) + + test("pm.environment.toObject() returns all environment variables as object", () => { + return expect( + runTest( + ` + const allVars = pm.environment.toObject() + pw.expect(typeof allVars).toBe("object") + pw.expect(allVars.existing_var).toBe("existing_value") + `, + { + global: [], + selected: [ + { + key: "existing_var", + currentValue: "existing_value", + initialValue: "existing_value", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'object' to be 'object'", + }, + { + status: "pass", + message: "Expected 'existing_value' to be 'existing_value'", + }, + ], + }), + ]) + }) + + test("pm.globals.clear() removes all global variables", () => { + return expect( + runTest( + ` + // Set some globals + pm.globals.set("global1", "value1") + pm.globals.set("global2", "value2") + + // Verify they exist + pw.expect(pm.globals.has("global1")).toBe(true) + pw.expect(pm.globals.has("global2")).toBe(true) + + // Clear all + pm.globals.clear() + + // Verify all are removed + pw.expect(pm.globals.has("global1")).toBe(false) + pw.expect(pm.globals.has("global2")).toBe(false) + + // Verify toObject returns empty + const allGlobals = pm.globals.toObject() + pw.expect(Object.keys(allGlobals).length).toBe(0) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + { status: "pass", message: "Expected '0' to be '0'" }, + ]), + }), + ]) + }) + + test("pm.globals.toObject() returns all global variables as object", () => { + return expect( + runTest( + ` + const allGlobals = pm.globals.toObject() + pw.expect(typeof allGlobals).toBe("object") + pw.expect(allGlobals.global_var).toBe("global_value") + `, + { + global: [ + { + key: "global_var", + currentValue: "global_value", + initialValue: "global_value", + secret: false, + }, + ], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'object' to be 'object'", + }, + { + status: "pass", + message: "Expected 'global_value' to be 'global_value'", + }, + ], + }), + ]) + }) +}) + +describe("pm namespace - pre-request scripts", () => { + const DEFAULT_REQUEST = getDefaultRESTRequest() + + test("pm.environment works in pre-request scripts", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set("preReqVar", "preReqValue") + const retrieved = pm.environment.get("preReqVar") + console.log("Retrieved:", retrieved) + `, + { + envs: { + global: [], + selected: [], + }, + request: DEFAULT_REQUEST, + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + updatedEnvs: expect.objectContaining({ + selected: expect.arrayContaining([ + expect.objectContaining({ + key: "preReqVar", + currentValue: "preReqValue", + }), + ]), + }), + }) + ) + }) + + test("pm.globals works in pre-request scripts", () => { + return expect( + runPreRequestScript( + ` + pm.globals.set("globalPreReq", "globalPreValue") + `, + { + envs: { + global: [], + selected: [], + }, + request: DEFAULT_REQUEST, + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + updatedEnvs: expect.objectContaining({ + global: expect.arrayContaining([ + expect.objectContaining({ + key: "globalPreReq", + currentValue: "globalPreValue", + }), + ]), + }), + }) + ) + }) + + test("pm.variables.set works in pre-request scripts", () => { + return expect( + runPreRequestScript( + ` + pm.variables.set("varPreReq", "varPreValue") + `, + { + envs: { + global: [], + selected: [], + }, + request: DEFAULT_REQUEST, + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + updatedEnvs: expect.objectContaining({ + selected: expect.arrayContaining([ + expect.objectContaining({ + key: "varPreReq", + currentValue: "varPreValue", + }), + ]), + }), + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/base64-helper-functions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/base64-helper-functions.spec.ts new file mode 100644 index 0000000..e0003ce --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/base64-helper-functions.spec.ts @@ -0,0 +1,119 @@ +import * as TE from "fp-ts/TaskEither" +import { pipe } from "fp-ts/function" + +import { describe, expect, test } from "vitest" + +import { getDefaultRESTRequest } from "@hoppscotch/data" +import { runPreRequestScript, runTestScript } from "~/node" +import { TestResponse, TestResult } from "~/types" + +const defaultRequest = getDefaultRESTRequest() + +describe("Base64 helper functions", () => { + const scriptExpectations = { + atob: { + script: `pw.env.set("atob", atob("SGVsbG8gV29ybGQ="))`, + environment: { + global: [], + selected: [ + { + key: "atob", + currentValue: "Hello World", + initialValue: "Hello World", + secret: false, + }, + ], + }, + }, + btoa: { + script: `pw.env.set("btoa", btoa("Hello World"))`, + environment: { + global: [], + selected: [ + { + key: "btoa", + currentValue: "SGVsbG8gV29ybGQ=", + initialValue: "SGVsbG8gV29ybGQ=", + secret: false, + }, + ], + }, + }, + } + + describe("Pre-request script", () => { + describe("atob", () => { + test("successfully decodes the input string", () => { + return expect( + runPreRequestScript(scriptExpectations.atob.script, { + envs: { + global: [], + selected: [], + }, + request: defaultRequest, + })() + ).resolves.toEqualRight( + expect.objectContaining({ + updatedEnvs: scriptExpectations.atob.environment, + }) + ) + }) + }) + + describe("btoa", () => { + test("successfully encodes the input string", () => { + return expect( + runPreRequestScript(scriptExpectations.btoa.script, { + envs: { + global: [], + selected: [], + }, + request: defaultRequest, + })() + ).resolves.toEqualRight( + expect.objectContaining({ + updatedEnvs: scriptExpectations.btoa.environment, + }) + ) + }) + }) + }) + + describe("Test script", () => { + const fakeResponse: TestResponse = { + status: 200, + body: "hoi", + headers: [], + } + + const func = (script: string, envs: TestResult["envs"]) => + pipe( + runTestScript(script, { + envs, + request: defaultRequest, + response: fakeResponse, + }), + TE.map((x) => x.envs) + ) + + describe("atob", () => { + test("successfully decodes the input string", () => { + return expect( + func(scriptExpectations.atob.script, { global: [], selected: [] })() + ).resolves.toEqualRight( + expect.objectContaining(scriptExpectations.atob.environment) + ) + }) + }) + + describe("btoa", () => { + test("successfully encodes the input string", () => { + return expect( + func(scriptExpectations.btoa.script, { global: [], selected: [] })() + ).resolves.toEqualRight( + expect.objectContaining(scriptExpectations.btoa.environment) + ) + }) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/get.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/get.spec.ts new file mode 100644 index 0000000..8257b96 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/get.spec.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("pw.env.get", () => { + test("returns the correct value for an existing selected environment value", () => { + return expect( + runTest( + ` + const data = pw.env.get("a") + pw.expect(data).toBe("b") + `, + { + global: [], + selected: [ + { + key: "a", + currentValue: "b", + initialValue: "b", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'b' to be 'b'", + }, + ], + }), + ]) + }) + + test("returns the correct value for an existing global environment value", () => { + return expect( + runTest( + ` + const data = pw.env.get("a") + pw.expect(data).toBe("b") + `, + { + global: [ + { + key: "a", + currentValue: "b", + initialValue: "b", + secret: false, + }, + ], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'b' to be 'b'", + }, + ], + }), + ]) + }) + + test("returns undefined for a key that is not present in both selected or environment", () => { + return expect( + runTest( + ` + const data = pw.env.get("a") + pw.expect(data).toBe(undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) + + test("returns the value defined in selected environment if it is also present in global", () => { + return expect( + runTest( + ` + const data = pw.env.get("a") + pw.expect(data).toBe("selected val") + `, + { + global: [ + { + key: "a", + currentValue: "global val", + initialValue: "global val", + secret: false, + }, + ], + selected: [ + { + key: "a", + currentValue: "selected val", + initialValue: "selected val", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'selected val' to be 'selected val'", + }, + ], + }), + ]) + }) + + test("does not resolve environment values", () => { + return expect( + runTest( + ` + const data = pw.env.get("a") + pw.expect(data).toBe("<>") + `, + { + global: [], + selected: [ + { + key: "a", + currentValue: "<>", + initialValue: "<>", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '<>' to be '<>'", + }, + ], + }), + ]) + }) + + test("errors if the key is not a string", () => { + return expect( + runTest( + ` + const data = pw.env.get(5) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toBeLeft() + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/getResolve.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/getResolve.spec.ts new file mode 100644 index 0000000..d6b9350 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/getResolve.spec.ts @@ -0,0 +1,216 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("pw.env.getResolve", () => { + test("returns the correct value for an existing selected environment value", () => { + return expect( + runTest( + ` + const data = pw.env.getResolve("a") + pw.expect(data).toBe("b") + `, + { + global: [], + selected: [ + { + key: "a", + currentValue: "b", + initialValue: "b", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'b' to be 'b'", + }, + ], + }), + ]) + }) + + test("returns the correct value for an existing global environment value", () => { + return expect( + runTest( + ` + const data = pw.env.getResolve("a") + pw.expect(data).toBe("b") + `, + { + global: [ + { + key: "a", + currentValue: "b", + initialValue: "b", + secret: false, + }, + ], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'b' to be 'b'", + }, + ], + }), + ]) + }) + + test("returns undefined for a key that is not present in both selected or environment", () => { + return expect( + runTest( + ` + const data = pw.env.getResolve("a") + pw.expect(data).toBe(undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) + + test("returns the value defined in selected environment if it is also present in global", () => { + return expect( + runTest( + ` + const data = pw.env.getResolve("a") + pw.expect(data).toBe("selected val") + `, + { + global: [ + { + key: "a", + currentValue: "global val", + initialValue: "global val", + secret: false, + }, + ], + selected: [ + { + key: "a", + currentValue: "selected val", + initialValue: "selected val", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'selected val' to be 'selected val'", + }, + ], + }), + ]) + }) + + test("resolve environment values", () => { + return expect( + runTest( + ` + const data = pw.env.getResolve("a") + pw.expect(data).toBe("there") + `, + { + global: [], + selected: [ + { + key: "a", + currentValue: "<>", + initialValue: "<>", + secret: false, + }, + { + key: "hello", + currentValue: "there", + initialValue: "there", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'there' to be 'there'", + }, + ], + }), + ]) + }) + + test("returns unresolved value on infinite loop in resolution", () => { + return expect( + runTest( + ` + const data = pw.env.getResolve("a") + pw.expect(data).toBe("<>") + `, + { + global: [], + selected: [ + { + key: "a", + currentValue: "<>", + initialValue: "<>", + secret: false, + }, + { + key: "hello", + currentValue: "<
>", + initialValue: "<>", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '<>' to be '<>'", + }, + ], + }), + ]) + }) + + test("errors if the key is not a string", () => { + return expect( + runTest( + ` + const data = pw.env.getResolve(5) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toBeLeft() + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/resolve.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/resolve.spec.ts new file mode 100644 index 0000000..671b785 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/resolve.spec.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("pw.env.resolve", () => { + test("value should be a string", () => { + return expect( + runTest( + ` + pw.env.resolve(5) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toBeLeft() + }) + + test("resolves global variables correctly", () => { + return expect( + runTest( + ` + const data = pw.env.resolve("<>") + pw.expect(data).toBe("there") + `, + { + global: [ + { + key: "hello", + initialValue: "there", + currentValue: "there", + secret: false, + }, + ], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'there' to be 'there'", + }, + ], + }), + ]) + }) + + test("resolves selected env variables correctly", () => { + return expect( + runTest( + ` + const data = pw.env.resolve("<>") + pw.expect(data).toBe("there") + `, + { + global: [], + selected: [ + { + key: "hello", + initialValue: "there", + currentValue: "there", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'there' to be 'there'", + }, + ], + }), + ]) + }) + + test("chooses selected env variable over global variables when both have same variable", () => { + return expect( + runTest( + ` + const data = pw.env.resolve("<>") + pw.expect(data).toBe("there") + `, + { + global: [ + { + key: "hello", + initialValue: "yo", + currentValue: "yo", + secret: false, + }, + ], + selected: [ + { + key: "hello", + initialValue: "there", + currentValue: "there", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'there' to be 'there'", + }, + ], + }), + ]) + }) + + test("if infinite loop in resolution, abandons resolutions altogether", () => { + return expect( + runTest( + ` + const data = pw.env.resolve("<>") + pw.expect(data).toBe("<>") + `, + { + global: [], + selected: [ + { + key: "hello", + currentValue: "<>", + initialValue: "<>", + secret: false, + }, + { + key: "there", + currentValue: "<>", + initialValue: "<>", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '<>' to be '<>'", + }, + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/set.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/set.spec.ts new file mode 100644 index 0000000..fec21bf --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/set.spec.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from "vitest" +import { runTestAndGetEnvs, runTest } from "~/utils/test-helpers" + +describe("pw.env.set", () => { + test("updates the selected environment variable correctly", () => { + return expect( + runTestAndGetEnvs( + ` + pw.env.set("a", "c") + `, + { + global: [], + selected: [ + { + key: "a", + currentValue: "b", + initialValue: "b", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "a", + currentValue: "c", + initialValue: "b", + secret: false, + }, + ], + }) + ) + }) + + test("updates the global environment variable correctly", () => { + return expect( + runTestAndGetEnvs( + ` + pw.env.set("a", "c") + `, + { + global: [ + { + key: "a", + currentValue: "b", + initialValue: "b", + secret: false, + }, + ], + selected: [], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [ + { + key: "a", + currentValue: "c", + initialValue: "b", + secret: false, + }, + ], + }) + ) + }) + + test("updates the selected environment if env present in both", () => { + return expect( + runTestAndGetEnvs( + ` + pw.env.set("a", "c") + `, + { + global: [ + { + key: "a", + currentValue: "b", + initialValue: "b", + secret: false, + }, + ], + selected: [ + { + key: "a", + currentValue: "d", + initialValue: "d", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [ + { + key: "a", + currentValue: "b", + initialValue: "b", + secret: false, + }, + ], + selected: [ + { + key: "a", + currentValue: "c", + initialValue: "d", + secret: false, + }, + ], + }) + ) + }) + + test("non existent keys are created in the selected environment", () => { + return expect( + runTestAndGetEnvs( + ` + pw.env.set("a", "c") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [], + selected: [ + { + key: "a", + currentValue: "c", + initialValue: "c", + secret: false, + }, + ], + }) + ) + }) + + test("keys should be a string", () => { + return expect( + runTestAndGetEnvs( + ` + pw.env.set(5, "c") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toBeLeft() + }) + + test("values should be a string", () => { + return expect( + runTestAndGetEnvs( + ` + pw.env.set("a", 5) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toBeLeft() + }) + + test("both keys and values should be strings", () => { + return expect( + runTestAndGetEnvs( + ` + pw.env.set(5, 5) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toBeLeft() + }) + + test("set environment values are reflected in the script execution", () => { + return expect( + runTest( + ` + pw.env.set("a", "b") + pw.expect(pw.env.get("a")).toBe("b") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'b' to be 'b'", + }, + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/unset.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/unset.spec.ts new file mode 100644 index 0000000..10ba2f2 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/unset.spec.ts @@ -0,0 +1,250 @@ +import { describe, expect, test } from "vitest" +import { runTestAndGetEnvs, runTest } from "~/utils/test-helpers" + +describe("pw.env.unset", () => { + test("removes the variable set in selected environment correctly", () => { + return expect( + runTestAndGetEnvs( + ` + pw.env.unset("baseUrl") + `, + { + global: [], + selected: [ + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [], + }) + ) + }) + + test("removes the variable set in global environment correctly", () => { + return expect( + runTestAndGetEnvs( + ` + pw.env.unset("baseUrl") + `, + { + global: [ + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + selected: [], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [], + }) + ) + }) + + test("removes the variable from selected environment if the entry is present in both selected and global environments", () => { + return expect( + runTestAndGetEnvs( + ` + pw.env.unset("baseUrl") + `, + { + global: [ + { + key: "baseUrl", + currentValue: "https://httpbin.org", + initialValue: "https://httpbin.org", + secret: false, + }, + ], + selected: [ + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [ + { + key: "baseUrl", + currentValue: "https://httpbin.org", + initialValue: "https://httpbin.org", + secret: false, + }, + ], + selected: [], + }) + ) + }) + + test("removes the initial occurrence of an entry if duplicate entries exist in the selected environment", () => { + return expect( + runTestAndGetEnvs( + ` + pw.env.unset("baseUrl") + `, + { + global: [ + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + selected: [ + { + key: "baseUrl", + currentValue: "https://httpbin.org", + initialValue: "https://httpbin.org", + secret: false, + }, + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [ + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + selected: [ + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + }) + ) + }) + + test("removes the initial occurrence of an entry if duplicate entries exist in the global environment", () => { + return expect( + runTestAndGetEnvs( + ` + pw.env.unset("baseUrl") + `, + { + global: [ + { + key: "baseUrl", + currentValue: "https://httpbin.org", + initialValue: "https://httpbin.org", + secret: false, + }, + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + selected: [], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [ + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + selected: [], + }) + ) + }) + + test("no change if attempting to delete non-existent keys", () => { + return expect( + runTestAndGetEnvs( + ` + pw.env.unset("baseUrl") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + global: [], + selected: [], + }) + ) + }) + + test("keys should be a string", () => { + return expect( + runTestAndGetEnvs( + ` + pw.env.unset(5) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toBeLeft() + }) + + test("set environment values are reflected in the script execution", () => { + return expect( + runTest( + ` + pw.env.unset("baseUrl") + pw.expect(pw.env.get("baseUrl")).toBe(undefined) + `, + { + global: [], + selected: [ + { + key: "baseUrl", + currentValue: "https://echo.hoppscotch.io", + initialValue: "https://echo.hoppscotch.io", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBe.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBe.spec.ts new file mode 100644 index 0000000..4a02d21 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBe.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "vitest" +import { runTest, fakeResponse } from "~/utils/test-helpers" + +describe("toBe", () => { + describe("general assertion (no negation)", () => { + test("expect equals expected passes assertion", () => { + return expect( + runTest( + ` + pw.expect(2).toBe(2) + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected '2' to be '2'" }, + ], + }), + ]) + }) + + test("expect not equals expected fails assertion", () => { + return expect( + runTest( + ` + pw.expect(2).toBe(4) + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "fail", message: "Expected '2' to be '4'" }, + ], + }), + ]) + }) + }) + + describe("general assertion (with negation)", () => { + test("expect equals expected fails assertion", () => { + return expect( + runTest( + ` + pw.expect(2).not.toBe(2) + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "fail", + message: "Expected '2' to not be '2'", + }, + ], + }), + ]) + }) + + test("expect not equals expected passes assertion", () => { + return expect( + runTest( + ` + pw.expect(2).not.toBe(4) + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '2' to not be '4'", + }, + ], + }), + ]) + }) + }) +}) + +test("strict checks types", () => { + return expect( + runTest( + ` + pw.expect(2).toBe("2") + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "fail", + message: "Expected '2' to be '2'", + }, + ], + }), + ]) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeLevelxxx.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeLevelxxx.spec.ts new file mode 100644 index 0000000..bedbf96 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeLevelxxx.spec.ts @@ -0,0 +1,412 @@ +import { describe, expect, test } from "vitest" +import { runTest, fakeResponse } from "~/utils/test-helpers" + +describe("toBeLevelxxx", { timeout: 100000 }, () => { + describe("toBeLevel2xx", () => { + test("assertion passes for 200 series with no negation", async () => { + for (let i = 200; i < 300; i++) { + await expect( + runTest(`pw.expect(${i}).toBeLevel2xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: `Expected '${i}' to be 200-level status`, + }, + ], + }), + ]) + } + }) + + test("assertion fails for non 200 series with no negation", async () => { + for (let i = 300; i < 500; i++) { + await expect( + runTest(`pw.expect(${i}).toBeLevel2xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "fail", + message: `Expected '${i}' to be 200-level status`, + }, + ], + }), + ]) + } + }) + + test("give error if the expect value was not a number with no negation", async () => { + await expect( + runTest(`pw.expect("foo").toBeLevel2xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: + "Expected 200-level status but could not parse value 'foo'", + }, + ], + }), + ]) + }) + + test("assertion fails for 200 series with negation", async () => { + for (let i = 200; i < 300; i++) { + await expect( + runTest(`pw.expect(${i}).not.toBeLevel2xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "fail", + message: `Expected '${i}' to not be 200-level status`, + }, + ], + }), + ]) + } + }) + + test("assertion passes for non 200 series with negation", async () => { + for (let i = 300; i < 500; i++) { + await expect( + runTest(`pw.expect(${i}).not.toBeLevel2xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: `Expected '${i}' to not be 200-level status`, + }, + ], + }), + ]) + } + }) + + test("give error if the expect value was not a number with negation", async () => { + await expect( + runTest(`pw.expect("foo").not.toBeLevel2xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: + "Expected 200-level status but could not parse value 'foo'", + }, + ], + }), + ]) + }) + }) + + describe("toBeLevel3xx", () => { + test("assertion passes for 300 series with no negation", async () => { + for (let i = 300; i < 400; i++) { + await expect( + runTest(`pw.expect(${i}).toBeLevel3xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: `Expected '${i}' to be 300-level status`, + }, + ], + }), + ]) + } + }) + + test("assertion fails for non 300 series with no negation", async () => { + for (let i = 400; i < 500; i++) { + await expect( + runTest(`pw.expect(${i}).toBeLevel3xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "fail", + message: `Expected '${i}' to be 300-level status`, + }, + ], + }), + ]) + } + }) + + test("give error if the expect value is not a number without negation", () => { + return expect( + runTest(`pw.expect("foo").toBeLevel3xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: + "Expected 300-level status but could not parse value 'foo'", + }, + ], + }), + ]) + }) + + test("assertion fails for 400 series with negation", async () => { + for (let i = 300; i < 400; i++) { + await expect( + runTest(`pw.expect(${i}).not.toBeLevel3xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "fail", + message: `Expected '${i}' to not be 300-level status`, + }, + ], + }), + ]) + } + }) + + test("assertion passes for non 200 series with negation", async () => { + for (let i = 400; i < 500; i++) { + await expect( + runTest(`pw.expect(${i}).not.toBeLevel3xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: `Expected '${i}' to not be 300-level status`, + }, + ], + }), + ]) + } + }) + + test("give error if the expect value is not a number with negation", () => { + return expect( + runTest(`pw.expect("foo").not.toBeLevel3xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: + "Expected 300-level status but could not parse value 'foo'", + }, + ], + }), + ]) + }) + }) + + describe("toBeLevel4xx", () => { + test("assertion passes for 400 series with no negation", async () => { + for (let i = 400; i < 500; i++) { + await expect( + runTest(`pw.expect(${i}).toBeLevel4xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: `Expected '${i}' to be 400-level status`, + }, + ], + }), + ]) + } + }) + + test("assertion fails for non 400 series with no negation", async () => { + for (let i = 500; i < 600; i++) { + await expect( + runTest(`pw.expect(${i}).toBeLevel4xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "fail", + message: `Expected '${i}' to be 400-level status`, + }, + ], + }), + ]) + } + }) + + test("give error if the expected value is not a number without negation", () => { + return expect( + runTest(`pw.expect("foo").toBeLevel4xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: + "Expected 400-level status but could not parse value 'foo'", + }, + ], + }), + ]) + }) + + test("assertion fails for 400 series with negation", async () => { + for (let i = 400; i < 500; i++) { + await expect( + runTest(`pw.expect(${i}).not.toBeLevel4xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "fail", + message: `Expected '${i}' to not be 400-level status`, + }, + ], + }), + ]) + } + }) + + test("assertion passes for non 400 series with negation", async () => { + for (let i = 500; i < 600; i++) { + await expect( + runTest(`pw.expect(${i}).not.toBeLevel4xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: `Expected '${i}' to not be 400-level status`, + }, + ], + }), + ]) + } + }) + + test("give error if the expected value is not a number with negation", () => { + return expect( + runTest(`pw.expect("foo").not.toBeLevel4xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: + "Expected 400-level status but could not parse value 'foo'", + }, + ], + }), + ]) + }) + }) + + describe("toBeLevel5xx", () => { + test("assertion passes for 500 series with no negation", async () => { + for (let i = 500; i < 600; i++) { + await expect( + runTest(`pw.expect(${i}).toBeLevel5xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: `Expected '${i}' to be 500-level status`, + }, + ], + }), + ]) + } + }) + + test("assertion fails for non 500 series with no negation", async () => { + for (let i = 200; i < 500; i++) { + await expect( + runTest(`pw.expect(${i}).toBeLevel5xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "fail", + message: `Expected '${i}' to be 500-level status`, + }, + ], + }), + ]) + } + }) + + test("give error if the expect value is not a number with no negation", () => { + return expect( + runTest(`pw.expect("foo").toBeLevel5xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: + "Expected 500-level status but could not parse value 'foo'", + }, + ], + }), + ]) + }) + + test("assertion fails for 500 series with negation", async () => { + for (let i = 500; i < 600; i++) { + await expect( + runTest(`pw.expect(${i}).not.toBeLevel5xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "fail", + message: `Expected '${i}' to not be 500-level status`, + }, + ], + }), + ]) + } + }) + + test("assertion passes for non 500 series with negation", async () => { + for (let i = 200; i < 500; i++) { + await expect( + runTest(`pw.expect(${i}).not.toBeLevel5xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: `Expected '${i}' to not be 500-level status`, + }, + ], + }), + ]) + } + }) + + test("give error if the expect value is not a number with negation", () => { + return expect( + runTest(`pw.expect("foo").not.toBeLevel5xx()`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: + "Expected 500-level status but could not parse value 'foo'", + }, + ], + }), + ]) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeType.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeType.spec.ts new file mode 100644 index 0000000..a7fd040 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeType.spec.ts @@ -0,0 +1,255 @@ +import { describe, expect, test } from "vitest" +import { runTest, fakeResponse } from "~/utils/test-helpers" + +describe("toBeType", () => { + test("asserts true for valid type expectations with no negation", () => { + return expect( + runTest( + ` + pw.expect(2).toBeType("number") + pw.expect("2").toBeType("string") + pw.expect(true).toBeType("boolean") + pw.expect({}).toBeType("object") + pw.expect(undefined).toBeType("undefined") + + const utcDate = new Date("2025-05-22T17:42:26Z") + const istOffset = 5.5 * 60 * 60 * 1000 // 5.5 hours in ms + const istDate = new Date(utcDate.getTime() + istOffset) + + pw.expect(istDate).toBeType("object") + pw.expect(istDate.toISOString()).toBeType("string") + pw.expect(JSON.stringify(istDate)).toBeType("string") + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: `Expected '2' to be type 'number'` }, + { status: "pass", message: `Expected '2' to be type 'string'` }, + { status: "pass", message: `Expected 'true' to be type 'boolean'` }, + { + status: "pass", + message: `Expected '[object Object]' to be type 'object'`, + }, + { + status: "pass", + message: `Expected 'undefined' to be type 'undefined'`, + }, + { + message: expect.stringMatching(/to be type 'object'$/), + status: "pass", + }, + { + message: "Expected '2025-05-22T23:12:26.000Z' to be type 'string'", + status: "pass", + }, + { + message: `Expected '"2025-05-22T23:12:26.000Z"' to be type 'string'`, + status: "pass", + }, + ], + }), + ]) + }) + + test("asserts false for invalid type expectations with no negation", () => { + return expect( + runTest( + ` + pw.expect(2).toBeType("string") + pw.expect("2").toBeType("number") + pw.expect(true).toBeType("string") + pw.expect({}).toBeType("number") + pw.expect(undefined).toBeType("number") + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "fail", message: `Expected '2' to be type 'string'` }, + { status: "fail", message: `Expected '2' to be type 'number'` }, + { status: "fail", message: `Expected 'true' to be type 'string'` }, + { + status: "fail", + message: `Expected '[object Object]' to be type 'number'`, + }, + { + status: "fail", + message: `Expected 'undefined' to be type 'number'`, + }, + ], + }), + ]) + }) + + test("asserts false for valid type expectations with negation", () => { + return expect( + runTest( + ` + pw.expect(2).not.toBeType("number") + pw.expect("2").not.toBeType("string") + pw.expect(true).not.toBeType("boolean") + pw.expect({}).not.toBeType("object") + pw.expect(undefined).not.toBeType("undefined") + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "fail", message: `Expected '2' to not be type 'number'` }, + { status: "fail", message: `Expected '2' to not be type 'string'` }, + { + status: "fail", + message: `Expected 'true' to not be type 'boolean'`, + }, + { + status: "fail", + message: `Expected '[object Object]' to not be type 'object'`, + }, + { + status: "fail", + message: `Expected 'undefined' to not be type 'undefined'`, + }, + ], + }), + ]) + }) + + test("asserts true for invalid type expectations with negation", () => { + return expect( + runTest( + ` + pw.expect(2).not.toBeType("string") + pw.expect("2").not.toBeType("number") + pw.expect(true).not.toBeType("string") + pw.expect({}).not.toBeType("number") + pw.expect(undefined).not.toBeType("number") + + const utcDate = new Date("2025-05-22T17:42:26Z") + const istOffset = 5.5 * 60 * 60 * 1000 // 5.5 hours in ms + const istDate = new Date(utcDate.getTime() + istOffset) + + pw.expect(istDate).not.toBeType("string") + pw.expect(istDate.toISOString()).not.toBeType("object") + pw.expect(JSON.stringify(istDate)).not.toBeType("object") + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: `Expected '2' to not be type 'string'` }, + { status: "pass", message: `Expected '2' to not be type 'number'` }, + { + status: "pass", + message: `Expected 'true' to not be type 'string'`, + }, + { + status: "pass", + message: `Expected '[object Object]' to not be type 'number'`, + }, + { + status: "pass", + message: `Expected 'undefined' to not be type 'number'`, + }, + { + message: expect.stringMatching(/to not be type 'string'$/), + status: "pass", + }, + { + message: + "Expected '2025-05-22T23:12:26.000Z' to not be type 'object'", + status: "pass", + }, + { + message: `Expected '"2025-05-22T23:12:26.000Z"' to not be type 'object'`, + status: "pass", + }, + ], + }), + ]) + }) + + test("gives error for invalid type names without negation", () => { + return expect( + runTest( + ` + pw.expect(2).toBeType("foo") + pw.expect("2").toBeType("bar") + pw.expect(true).toBeType("baz") + pw.expect({}).toBeType("qux") + pw.expect(undefined).toBeType("quux") + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"`, + }, + { + status: "error", + message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"`, + }, + { + status: "error", + message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"`, + }, + { + status: "error", + message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"`, + }, + { + status: "error", + message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"`, + }, + ], + }), + ]) + }) + + test("gives error for invalid type names with negation", () => { + return expect( + runTest( + ` + pw.expect(2).not.toBeType("foo") + pw.expect("2").not.toBeType("bar") + pw.expect(true).not.toBeType("baz") + pw.expect({}).not.toBeType("qux") + pw.expect(undefined).not.toBeType("quux") + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"`, + }, + { + status: "error", + message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"`, + }, + { + status: "error", + message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"`, + }, + { + status: "error", + message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"`, + }, + { + status: "error", + message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"`, + }, + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toHaveLength.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toHaveLength.spec.ts new file mode 100644 index 0000000..6449f5f --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toHaveLength.spec.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from "vitest" +import { runTest, fakeResponse } from "~/utils/test-helpers" + +describe("toHaveLength", () => { + test("asserts true for valid lengths with no negation", () => { + return expect( + runTest( + ` + pw.expect([1, 2, 3, 4]).toHaveLength(4) + pw.expect([]).toHaveLength(0) + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected the array to be of length '4'" }, + { status: "pass", message: "Expected the array to be of length '0'" }, + ], + }), + ]) + }) + + test("asserts false for invalid lengths with no negation", () => { + return expect( + runTest( + ` + pw.expect([]).toHaveLength(4) + pw.expect([1, 2, 3, 4]).toHaveLength(0) + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "fail", message: "Expected the array to be of length '4'" }, + { status: "fail", message: "Expected the array to be of length '0'" }, + ], + }), + ]) + }) + + test("asserts false for valid lengths with negation", () => { + return expect( + runTest( + ` + pw.expect([1, 2, 3, 4]).not.toHaveLength(4) + pw.expect([]).not.toHaveLength(0) + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "fail", + message: "Expected the array to not be of length '4'", + }, + { + status: "fail", + message: "Expected the array to not be of length '0'", + }, + ], + }), + ]) + }) + + test("asserts true for invalid lengths with negation", () => { + return expect( + runTest( + ` + pw.expect([]).not.toHaveLength(4) + pw.expect([1, 2, 3, 4]).not.toHaveLength(0) + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected the array to not be of length '4'", + }, + { + status: "pass", + message: "Expected the array to not be of length '0'", + }, + ], + }), + ]) + }) + + test("gives error if not called on an array or a string with no negation", () => { + return expect( + runTest( + ` + pw.expect(5).toHaveLength(0) + pw.expect(true).toHaveLength(0) + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: + "Expected toHaveLength to be called for an array or string", + }, + { + status: "error", + message: + "Expected toHaveLength to be called for an array or string", + }, + ], + }), + ]) + }) + + test("gives error if not called on an array or a string with negation", () => { + return expect( + runTest( + ` + pw.expect(5).not.toHaveLength(0) + pw.expect(true).not.toHaveLength(0) + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: + "Expected toHaveLength to be called for an array or string", + }, + { + status: "error", + message: + "Expected toHaveLength to be called for an array or string", + }, + ], + }), + ]) + }) + + test("gives an error if toHaveLength parameter is not a number without negation", () => { + return expect( + runTest( + ` + pw.expect([1, 2, 3, 4]).toHaveLength("a") + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: "Argument for toHaveLength should be a number", + }, + ], + }), + ]) + }) + + test("gives an error if toHaveLength parameter is not a number with negation", () => { + return expect( + runTest( + ` + pw.expect([1, 2, 3, 4]).not.toHaveLength("a") + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: "Argument for toHaveLength should be a number", + }, + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toInclude.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toInclude.spec.ts new file mode 100644 index 0000000..0959d50 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toInclude.spec.ts @@ -0,0 +1,170 @@ +import { describe, expect, test } from "vitest" +import { runTest, fakeResponse } from "~/utils/test-helpers" + +describe("toInclude", () => { + test("asserts true for collections with matching values", () => { + return expect( + runTest( + ` + pw.expect([1, 2, 3]).toInclude(1) + pw.expect("123").toInclude(1) + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected [1,2,3] to include 1" }, + { status: "pass", message: 'Expected "123" to include 1' }, + ], + }), + ]) + }) + + test("asserts false for collections without matching values", () => { + return expect( + runTest( + ` + pw.expect([1, 2, 3]).toInclude(4) + pw.expect("123").toInclude(4) + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "fail", message: "Expected [1,2,3] to include 4" }, + { status: "fail", message: 'Expected "123" to include 4' }, + ], + }), + ]) + }) + + test("asserts false for empty collections", () => { + return expect( + runTest( + ` + pw.expect([]).not.toInclude(0) + pw.expect("").not.toInclude(0) + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected [] to not include 0", + }, + { + status: "pass", + message: 'Expected "" to not include 0', + }, + ], + }), + ]) + }) + + test("asserts false for [number array].includes(string)", () => { + return expect( + runTest( + ` + pw.expect([1]).not.toInclude("1") + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: 'Expected [1] to not include "1"', + }, + ], + }), + ]) + }) + + test("asserts true for [string].includes(number)", () => { + // This is a Node.js quirk. + // (`"123".includes(123)` returns `True` in Node.js v14.19.1) + // See https://tc39.es/ecma262/multipage/text-processing.html#sec-string.prototype.includes + return expect( + runTest(`pw.expect("123").toInclude(123)`, fakeResponse)() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: 'Expected "123" to include 123', + }, + ], + }), + ]) + }) + + test("gives error if not called on an array or string", () => { + return expect( + runTest( + ` + pw.expect(5).not.toInclude(0) + pw.expect(true).not.toInclude(0) + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: "Expected toInclude to be called for an array or string", + }, + { + status: "error", + message: "Expected toInclude to be called for an array or string", + }, + ], + }), + ]) + }) + + test("gives an error if toInclude parameter is null", () => { + return expect( + runTest( + ` + pw.expect([1, 2, 3, 4]).not.toInclude(null) + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: "Argument for toInclude should not be null", + }, + ], + }), + ]) + }) + + test("gives an error if toInclude parameter is undefined", () => { + return expect( + runTest( + ` + pw.expect([1, 2, 3, 4]).not.toInclude(undefined) + `, + fakeResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "error", + message: "Argument for toInclude should not be undefined", + }, + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/pre-request.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/pre-request.spec.ts new file mode 100644 index 0000000..6a29dad --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/pre-request.spec.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from "vitest" + +import { getDefaultRESTRequest } from "@hoppscotch/data" +import { runPreRequestScript } from "~/node" + +const DEFAULT_REQUEST = getDefaultRESTRequest() + +describe("runPreRequestScript", () => { + test("returns the updated environment properly", () => { + return expect( + runPreRequestScript( + ` + pw.env.set("bob", "newbob") + `, + { + envs: { + global: [], + selected: [ + { + key: "bob", + currentValue: "oldbob", + initialValue: "oldbob", + secret: false, + }, + { + key: "foo", + currentValue: "bar", + initialValue: "bar", + secret: false, + }, + ], + }, + request: DEFAULT_REQUEST, + } + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "bob", + currentValue: "newbob", + initialValue: "oldbob", + secret: false, + }, + { + key: "foo", + currentValue: "bar", + initialValue: "bar", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("fails if the key is not a string", () => { + return expect( + runPreRequestScript( + ` + pw.env.set(10, "newbob") + `, + { + envs: { + global: [], + selected: [ + { + key: "bob", + currentValue: "oldbob", + initialValue: "oldbob", + secret: false, + }, + { + key: "foo", + currentValue: "bar", + initialValue: "bar", + secret: false, + }, + ], + }, + request: DEFAULT_REQUEST, + } + )() + ).resolves.toBeLeft() + }) + + test("fails if the value is not a string", () => { + return expect( + runPreRequestScript( + ` + pw.env.set("bob", 10) + `, + { + envs: { + global: [], + selected: [ + { + key: "bob", + currentValue: "oldbob", + initialValue: "oldbob", + secret: false, + }, + { + key: "foo", + currentValue: "bar", + initialValue: "bar", + secret: false, + }, + ], + }, + request: DEFAULT_REQUEST, + } + )() + ).resolves.toBeLeft() + }) + + test("fails for invalid syntax", () => { + return expect( + runPreRequestScript( + ` + pw.env.set("bob", + `, + { + envs: { + global: [], + selected: [ + { + key: "bob", + currentValue: "oldbob", + initialValue: "oldbob", + secret: false, + }, + { + key: "foo", + currentValue: "bar", + initialValue: "bar", + secret: false, + }, + ], + }, + request: DEFAULT_REQUEST, + } + )() + ).resolves.toBeLeft() + }) + + test("creates new env variable if doesn't exist", () => { + return expect( + runPreRequestScript( + ` + pw.env.set("foo", "bar") + `, + { envs: { global: [], selected: [] }, request: DEFAULT_REQUEST } + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "foo", + currentValue: "bar", + initialValue: "bar", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/utils/cage.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/utils/cage.spec.ts new file mode 100644 index 0000000..6657a86 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/utils/cage.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest" +import { isInfraError } from "~/utils/cage" + +describe("isInfraError", () => { + test("identifies Error instances as infrastructure errors", () => { + expect(isInfraError(new Error("test error"))).toBe(true) + }) + + test("identifies Error subclasses as infrastructure errors", () => { + class QuickJSUnwrapError extends Error { + constructor(message: string) { + super(message) + this.name = "QuickJSUnwrapError" + } + } + + expect( + isInfraError(new QuickJSUnwrapError("cannot convert to object")) + ).toBe(true) + }) + + test("identifies WASM initialization errors", () => { + expect(isInfraError(new Error("wasm init failed"))).toBe(true) + }) + + test("does not classify plain objects from QuickJS dump() as infrastructure", () => { + // QuickJS dump() produces plain objects for user script errors — NOT Error instances + expect( + isInfraError({ + name: "ReferenceError", + message: "a is not defined", + stack: " at (eval.js:1)\n", + }) + ).toBe(false) + + expect( + isInfraError({ + name: "TypeError", + message: "cannot convert to object", + stack: " at keys (native)\n at (eval.js:1)\n", + }) + ).toBe(false) + }) + + test("handles non-object and null errors gracefully", () => { + expect(isInfraError("string error")).toBe(false) + expect(isInfraError(null)).toBe(false) + expect(isInfraError(undefined)).toBe(false) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/utils/pre-request.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/utils/pre-request.spec.ts new file mode 100644 index 0000000..82229de --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/utils/pre-request.spec.ts @@ -0,0 +1,183 @@ +import { + HoppRESTAuth, + HoppRESTReqBody, + HoppRESTRequest, +} from "@hoppscotch/data" +import { describe, expect, test } from "vitest" + +import { getRequestSetterMethods } from "~/utils/pre-request" + +const baseRequest: HoppRESTRequest = { + v: "16", + name: "Test Request", + endpoint: "https://example.com/api", + method: "GET", + headers: [{ key: "X-Test", value: "val1", active: true, description: "" }], + params: [{ key: "q", value: "search", active: true, description: "" }], + body: { contentType: null, body: null }, + auth: { authType: "none", authActive: false }, + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, +} + +describe("getRequestSetterMethods", () => { + test("`setUrl` method", () => { + const { methods, updatedRequest } = getRequestSetterMethods(baseRequest) + methods.setUrl("https://updated.com/api") + + expect(updatedRequest.endpoint).toBe("https://updated.com/api") + }) + + test("`setMethod` should update the method (case preserved)", () => { + const { methods, updatedRequest } = getRequestSetterMethods(baseRequest) + methods.setMethod("post") + + expect(updatedRequest.method).toBe("post") + }) + + test("`setHeader` setter should update existing header case-insensitively", () => { + const { methods, updatedRequest } = getRequestSetterMethods(baseRequest) + methods.setHeader("x-test", "updatedVal") + + expect( + updatedRequest.headers.find((h) => h.key.toLowerCase() === "x-test") + ?.value + ).toBe("updatedVal") + }) + + test("`setHeader` setter should add new header if not present", () => { + const { methods, updatedRequest } = getRequestSetterMethods(baseRequest) + methods.setHeader("X-New-Header", "newValue") + + expect(updatedRequest.headers.some((h) => h.key === "X-New-Header")).toBe( + true + ) + expect( + updatedRequest.headers.find((h) => h.key === "X-New-Header")?.value + ).toBe("newValue") + }) + + test("`removeHeader` setter should remove a header", () => { + const { methods, updatedRequest } = getRequestSetterMethods(baseRequest) + methods.removeHeader("X-Test") + + expect( + updatedRequest.headers.find((h) => h.key === "X-Test") + ).toBeUndefined() + }) + + test("`setParam` setter should update existing param case-insensitively", () => { + const { methods, updatedRequest } = getRequestSetterMethods(baseRequest) + methods.setParam("Q", "updatedParam") + + expect( + updatedRequest.params.find((p) => p.key.toLowerCase() === "q")?.value + ).toBe("updatedParam") + }) + + test("`setParam` setter should add new param if absent", () => { + const { methods, updatedRequest } = getRequestSetterMethods(baseRequest) + methods.setParam("newParam", "value") + + expect(updatedRequest.params.some((p) => p.key === "newParam")).toBe(true) + }) + + test("`removeParam` setter should remove a param", () => { + const { methods, updatedRequest } = getRequestSetterMethods(baseRequest) + methods.removeParam("q") + + expect(updatedRequest.params.find((p) => p.key === "q")).toBeUndefined() + }) + + test("`setBody` setter should update the body with complete replacement", () => { + const { methods, updatedRequest } = getRequestSetterMethods(baseRequest) + const newBody: HoppRESTReqBody = { + contentType: "application/json", + body: JSON.stringify({ changed: true }), + } + + methods.setBody(newBody) + expect(updatedRequest.body).toEqual(newBody) + }) + + test("`setBody` setter should support partial merge", () => { + // Set up a request with existing body + const requestWithBody: HoppRESTRequest = { + ...baseRequest, + body: { + contentType: "application/json", + body: JSON.stringify({ existing: "data" }), + }, + } + + const { methods, updatedRequest } = getRequestSetterMethods(requestWithBody) + + // Only update contentType, preserving existing body + methods.setBody({ contentType: "application/xml" }) + + expect(updatedRequest.body).toEqual({ + contentType: "application/xml", + body: JSON.stringify({ existing: "data" }), + }) + }) + + test("`setAuth` setter should update the authorization properties with complete replacement", () => { + const { methods, updatedRequest } = getRequestSetterMethods(baseRequest) + const newAuth: HoppRESTAuth = { + authType: "basic", + username: "abc", + password: "123", + authActive: true, + } + + methods.setAuth(newAuth) + expect(updatedRequest.auth).toEqual(newAuth) + }) + + test("`setAuth` setter should support partial merge", () => { + // Set up a request with existing auth + const requestWithAuth: HoppRESTRequest = { + ...baseRequest, + auth: { + authType: "basic", + username: "existing-user", + password: "existing-pass", + authActive: true, + }, + } + + const { methods, updatedRequest } = getRequestSetterMethods(requestWithAuth) + + // Only update username, preserving other auth fields + methods.setAuth({ username: "updated-user" } as Partial) + + expect(updatedRequest.auth).toEqual({ + authType: "basic", + username: "updated-user", + password: "existing-pass", + authActive: true, + }) + }) + + test("`setHeaders` setter throws error on invalid input", () => { + const { methods } = getRequestSetterMethods(baseRequest) + expect(() => methods.setHeaders(null)).toThrow() + }) + + test("`setParams` setter throws error on invalid input", () => { + const { methods } = getRequestSetterMethods(baseRequest) + expect(() => methods.setParams(null)).toThrow() + }) + + test("`setBody` setter throws error on invalid input", () => { + const { methods } = getRequestSetterMethods(baseRequest) + expect(() => methods.setBody("invalid_body")).toThrow() + }) + + test("`setAuth` setter throws error on invalid input", () => { + const { methods } = getRequestSetterMethods(baseRequest) + expect(() => methods.setAuth([6])).toThrow() + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/utils/shared.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/utils/shared.spec.ts new file mode 100644 index 0000000..75d2428 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/utils/shared.spec.ts @@ -0,0 +1,510 @@ +import { + getSharedCookieMethods, + getSharedEnvMethods, + getSharedRequestProps, + preventCyclicObjects, +} from "~/utils/shared" + +import { Cookie, HoppRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" +import { TestResult } from "~/types" + +describe("preventCyclicObjects", () => { + test("succeeds with a simple object", () => { + const testObj = { + a: 1, + } + + expect(preventCyclicObjects(testObj)).toBeRight() + }) + + test("fails with a cyclic object", () => { + const testObj = { + a: 1, + b: null as any, + } + + testObj.b = testObj + + expect(preventCyclicObjects(testObj)).toBeLeft() + }) +}) + +describe("getSharedRequestProps", () => { + const baseRequest: HoppRESTRequest = { + v: "16", + name: "Test Request", + endpoint: "https://example.com/api", + method: "GET", + headers: [{ key: "X-Test", value: "val1", active: true, description: "" }], + params: [{ key: "q", value: "search", active: true, description: "" }], + body: { contentType: null, body: null }, + auth: { authType: "none", authActive: false }, + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, + } + + test("`url` getter", () => { + const request = getSharedRequestProps(baseRequest) + expect(request.url).toBe("https://example.com/api") + }) + + test("`method` getter", () => { + const request = getSharedRequestProps(baseRequest) + expect(request.method).toBe("GET") + }) + + test("`headers` getter", () => { + const request = getSharedRequestProps(baseRequest) + expect(request.headers).toEqual(baseRequest.headers) + }) + + test("`params` getter", () => { + const request = getSharedRequestProps(baseRequest) + expect(request.params).toEqual(baseRequest.params) + }) + + test("`body` getter", () => { + const request = getSharedRequestProps(baseRequest) + expect(request.body).toEqual(baseRequest.body) + }) + + test("`auth` getter", () => { + const request = getSharedRequestProps(baseRequest) + expect(request.auth).toEqual(baseRequest.auth) + }) +}) + +describe("getSharedCookieMethods", () => { + const validCookie: Cookie = { + name: "session", + value: "abc123", + domain: "example.com", + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + } + + test("get() should return existing cookie or null", () => { + const { methods } = getSharedCookieMethods([ + validCookie, + { ...validCookie, name: "token", value: "xyz" }, + ]) + + expect(methods.get("example.com", "session")).toEqual(validCookie) + expect(methods.get("example.com", "missing")).toBeNull() + }) + + test("get() should throw for non-string args", () => { + const { methods } = getSharedCookieMethods([validCookie]) + expect(() => methods.get(123, "session")).toThrow( + "Expected domain and cookieName to be strings" + ) + }) + + test("set() should add a new cookie", () => { + const { methods } = getSharedCookieMethods([]) + methods.set("example.com", validCookie) + + expect(methods.get("example.com", "session")).toEqual(validCookie) + }) + + test("set() should replace an existing cookie with same domain+name", () => { + const oldCookie = { ...validCookie, value: "old" } + const { methods } = getSharedCookieMethods([oldCookie]) + + methods.set("example.com", validCookie) + + expect(methods.getAll("example.com")).toHaveLength(1) + expect(methods.get("example.com", "session")?.value).toBe("abc123") + }) + + test("set() should throw for invalid cookie per schema", () => { + const { methods } = getSharedCookieMethods([]) + expect(() => methods.set("example.com", { bad: "cookie" })).toThrow( + "Invalid cookie" + ) + }) + + test("has() should return true if cookie exists", () => { + const { methods } = getSharedCookieMethods([validCookie]) + + expect(methods.has("example.com", "session")).toBe(true) + expect(methods.has("example.com", "missing")).toBe(false) + }) + + test("getAll() should return all cookies for a domain", () => { + const cookies = [ + validCookie, + { ...validCookie, name: "token", value: "x" }, + { ...validCookie, domain: "other.com" }, + ] + const { methods } = getSharedCookieMethods(cookies) + + const result = methods.getAll("example.com") + expect(result).toHaveLength(2) + expect(result.every((c) => c.domain === "example.com")).toBe(true) + }) + + test("delete() should remove the specified cookie", () => { + const { methods } = getSharedCookieMethods([validCookie]) + methods.delete("example.com", "session") + expect(methods.get("example.com", "session")).toBeNull() + }) + + test("clear() should remove all cookies for a domain", () => { + const cookies = [validCookie, { ...validCookie, name: "token", value: "x" }] + const { methods } = getSharedCookieMethods(cookies) + + methods.clear("example.com") + expect(methods.getAll("example.com")).toHaveLength(0) + }) + + test("should throw for non-string args on has/delete/getAll/clear", () => { + const { methods } = getSharedCookieMethods([validCookie]) + expect(() => methods.has(123 as any, "session")).toThrow() + expect(() => methods.delete(123 as any, "session")).toThrow() + expect(() => methods.getAll(123 as any)).toThrow() + expect(() => methods.clear(123 as any)).toThrow() + }) +}) + +describe("getSharedEnvMethods - Experimental Sandbox (isHoppNamespace=true)", () => { + const baseEnvs: TestResult["envs"] = { + global: [ + { + key: "globalKey", + currentValue: "globalVal", + initialValue: "globalVal", + secret: false, + }, + ], + selected: [ + { + key: "selectedKey", + currentValue: "selectedVal", + initialValue: "selectedVal", + secret: false, + }, + ], + } + + test("returns pw and hopp namespace structure", () => { + const { methods } = getSharedEnvMethods(baseEnvs, true) + + expect(methods).toHaveProperty("pw") + expect(methods).toHaveProperty("hopp") + expect(methods.pw).toHaveProperty("get") + expect(methods.hopp).toHaveProperty("set") + }) + + test("pw.get retrieves from selected then global", () => { + const { methods } = getSharedEnvMethods(baseEnvs, true) + + expect(methods.pw.get("selectedKey")).toBe("selectedVal") + expect(methods.pw.get("globalKey")).toBe("globalVal") + expect(methods.pw.get("nonexistent")).toBeUndefined() + }) + + test("pw.set updates selected environment", () => { + const { methods, updatedEnvs } = getSharedEnvMethods(baseEnvs, true) + + methods.pw.set("newKey", "newVal") + + expect(updatedEnvs.selected).toContainEqual({ + key: "newKey", + currentValue: "newVal", + initialValue: "newVal", + secret: false, + }) + }) + + test("pw.set validates string key and value", () => { + const { methods } = getSharedEnvMethods(baseEnvs, true) + + expect(() => methods.pw.set(123 as any, "value")).toThrow( + "Expected key to be a string" + ) + expect(() => methods.pw.set("key", 123 as any)).toThrow( + "Expected value to be a string" + ) + }) + + test("pw.resolve handles template strings", () => { + const { methods } = getSharedEnvMethods( + { + global: [], + selected: [ + { + key: "name", + currentValue: "Alice", + initialValue: "Alice", + secret: false, + }, + { + key: "greeting", + currentValue: "Hello <>", + initialValue: "Hello <>", + secret: false, + }, + ], + }, + true + ) + + const resolved = methods.pw.resolve("<>") + expect(resolved).toBe("Hello Alice") + }) + + test("pw.getResolve combines get and resolve", () => { + const { methods } = getSharedEnvMethods( + { + global: [], + selected: [ + { + key: "baseUrl", + currentValue: "https://api.example.com", + initialValue: "https://api.example.com", + secret: false, + }, + { + key: "endpoint", + currentValue: "<>/users", + initialValue: "<>/users", + secret: false, + }, + ], + }, + true + ) + + const resolved = methods.pw.getResolve("endpoint") + expect(resolved).toBe("https://api.example.com/users") + }) + + test("hopp.set creates new variable in selected scope (default source='all')", () => { + const { methods, updatedEnvs } = getSharedEnvMethods(baseEnvs, true) + + methods.hopp.set("hoppKey", "hoppVal") + + expect(updatedEnvs.selected).toContainEqual({ + key: "hoppKey", + currentValue: "hoppVal", + initialValue: "hoppVal", + secret: false, + }) + }) + + test("hopp.set validates string types", () => { + const { methods } = getSharedEnvMethods(baseEnvs, true) + + expect(() => methods.hopp.set(123 as any, "value")).toThrow( + "Expected key to be a string" + ) + expect(() => methods.hopp.set("key", 123 as any)).toThrow( + "Expected value to be a string" + ) + }) + + test("hopp.delete removes variable from selected scope", () => { + const { methods, updatedEnvs } = getSharedEnvMethods(baseEnvs, true) + + methods.hopp.delete("selectedKey") + + expect(updatedEnvs.selected).not.toContainEqual( + expect.objectContaining({ key: "selectedKey" }) + ) + expect(updatedEnvs.global.length).toBe(1) + }) + + test("hopp.reset resets a variable to its initial value", () => { + const { methods, updatedEnvs } = getSharedEnvMethods( + { + global: [], + selected: [ + { + key: "testKey", + currentValue: "modified", + initialValue: "original", + secret: false, + }, + ], + }, + true + ) + + methods.hopp.reset("testKey") + + const variable = updatedEnvs.selected.find((e) => e.key === "testKey") + expect(variable?.currentValue).toBe("original") + expect(variable?.initialValue).toBe("original") + }) + + test("hopp.getInitialRaw returns initial value", () => { + const { methods } = getSharedEnvMethods( + { + global: [], + selected: [ + { + key: "testKey", + currentValue: "currentVal", + initialValue: "initialVal", + secret: false, + }, + ], + }, + true + ) + + expect(methods.hopp.getInitialRaw("testKey")).toBe("initialVal") + expect(methods.hopp.getInitialRaw("nonexistent")).toBeNull() + }) + + test("hopp.setInitial sets initial value", () => { + const { methods, updatedEnvs } = getSharedEnvMethods(baseEnvs, true) + + methods.hopp.setInitial("initKey", "initVal") + + const created = updatedEnvs.selected.find((e) => e.key === "initKey") + expect(created).toBeDefined() + expect(created?.initialValue).toBe("initVal") + expect(created?.currentValue).toBe("initVal") + }) +}) + +describe("getSharedEnvMethods - Legacy Sandbox (isHoppNamespace=false)", () => { + const baseEnvs: TestResult["envs"] = { + global: [ + { + key: "globalKey", + currentValue: "globalVal", + initialValue: "globalVal", + secret: false, + }, + ], + selected: [ + { + key: "selectedKey", + currentValue: "selectedVal", + initialValue: "selectedVal", + secret: false, + }, + ], + } + + test("returns env object structure (not pw/hopp)", () => { + const { methods } = getSharedEnvMethods(baseEnvs, false) + + expect(methods).toHaveProperty("env") + expect(methods).not.toHaveProperty("pw") + expect(methods).not.toHaveProperty("hopp") + }) + + test("env object has all expected methods", () => { + const { methods } = getSharedEnvMethods(baseEnvs, false) + + expect(typeof methods.env.get).toBe("function") + expect(typeof methods.env.set).toBe("function") + expect(typeof methods.env.resolve).toBe("function") + expect(typeof methods.env.getResolve).toBe("function") + }) + + test("env.get retrieves from selected then global", () => { + const { methods } = getSharedEnvMethods(baseEnvs, false) + + expect(methods.env.get("selectedKey")).toBe("selectedVal") + expect(methods.env.get("globalKey")).toBe("globalVal") + expect(methods.env.get("nonexistent")).toBeUndefined() + }) + + test("env.set updates environment correctly", () => { + const { methods, updatedEnvs } = getSharedEnvMethods(baseEnvs, false) + + methods.env.set("newKey", "newVal") + + expect(updatedEnvs.selected).toContainEqual({ + key: "newKey", + currentValue: "newVal", + initialValue: "newVal", + secret: false, + }) + }) + + test("env.set validates string types (regression test for #5433)", () => { + const { methods } = getSharedEnvMethods(baseEnvs, false) + + // This is the bug that was fixed in #5433 - missing validation + expect(() => methods.env.set(123 as any, "value")).toThrow( + "Expected key to be a string" + ) + expect(() => methods.env.set("key", 123 as any)).toThrow( + "Expected value to be a string" + ) + }) + + test("env.resolve handles template strings", () => { + const { methods } = getSharedEnvMethods( + { + global: [], + selected: [ + { + key: "user", + currentValue: "Bob", + initialValue: "Bob", + secret: false, + }, + { + key: "message", + currentValue: "Hello <>", + initialValue: "Hello <>", + secret: false, + }, + ], + }, + false + ) + + const resolved = methods.env.resolve("<>") + expect(resolved).toBe("Hello Bob") + }) + + test("env.getResolve returns resolved value", () => { + const { methods } = getSharedEnvMethods( + { + global: [], + selected: [ + { + key: "domain", + currentValue: "example.com", + initialValue: "example.com", + secret: false, + }, + { + key: "apiUrl", + currentValue: "https://<>/api", + initialValue: "https://<>/api", + secret: false, + }, + ], + }, + false + ) + + const resolved = methods.env.getResolve("apiUrl") + expect(resolved).toBe("https://example.com/api") + }) + + test("env object structure prevents #5433 regression (pw.env not recognized)", () => { + const { methods } = getSharedEnvMethods(baseEnvs, false) + + // In legacy sandbox, this gets assigned to pw.env + // The bug was that pw.env was undefined because the structure wasn't correct + expect(methods.env).toBeDefined() + expect(typeof methods.env).toBe("object") + expect(methods.env.get).toBeDefined() + expect(methods.env.set).toBeDefined() + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js b/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js new file mode 100644 index 0000000..0486334 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js @@ -0,0 +1,4120 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +;(inputs) => { + // Keep strict mode scoped to this IIFE to avoid leaking strictness to concatenated/bootstrapped code + "use strict" + + // Exposes a host reporter for the generated experimental `try/catch` + // wrapper in combineScriptsWithIIFE. Locked down (non-writable, + // non-configurable) so user scripts cannot delete or overwrite it to + // suppress error reporting. faraday-cage's `runCode` creates a fresh + // QuickJS runtime per call, so re-definition across calls is not a + // concern — the property lives only for the current run's VM. + Object.defineProperty(globalThis, "__hoppReportScriptExecutionError", { + value: (error) => { + const safe = error && typeof error === "object" ? error : {} + inputs.setScriptExecutionError({ + name: typeof safe.name === "string" ? safe.name : "", + message: + typeof safe.message === "string" ? safe.message : String(error), + stack: typeof safe.stack === "string" ? safe.stack : "", + }) + }, + enumerable: false, + configurable: false, + writable: false, + }) + + // Sequential test execution promise chain + // Initialize with a resolved promise to start the chain + // Store on globalThis so pm.sendRequest() and test() can both access and modify it + if (!globalThis.__testExecutionChain) { + globalThis.__testExecutionChain = Promise.resolve() + } + + // Chai proxy builder - creates a Chai-like API using actual Chai SDK + if (!globalThis.__createChaiProxy) { + globalThis.__createChaiProxy = function ( + expectVal, + inputs, + modifiers = " to" + ) { + const proxy = {} + + // Helper to create a new proxy with updated modifiers + const withModifiers = (newModifiers) => { + return globalThis.__createChaiProxy(expectVal, inputs, newModifiers) + } + + // Assertion methods + proxy.equal = (expected) => { + // PRE-CHECK PATTERN: Track object identity for reference equality + // Special handling for Date/RegExp which serialize to primitives + let isSameReference = undefined + let typeInfo = undefined + + if ( + expectVal !== null && + expected !== null && + typeof expectVal === "object" && + typeof expected === "object" + ) { + isSameReference = expectVal === expected + + // Track type for objects that lose identity during serialization + if (expectVal instanceof Date && expected instanceof Date) { + typeInfo = { + type: "Date", + valueTime: expectVal.getTime(), + expectedTime: expected.getTime(), + } + } else if ( + expectVal instanceof RegExp && + expected instanceof RegExp + ) { + typeInfo = { + type: "RegExp", + valueSource: expectVal.source, + valueFlags: expectVal.flags, + expectedSource: expected.source, + expectedFlags: expected.flags, + } + } + } + + inputs.chaiEqual( + expectVal, + expected, + modifiers, + "equal", + isSameReference, + typeInfo + ) + return withModifiers(modifiers) + } + proxy.equals = (expected) => { + // PRE-CHECK PATTERN: Track object identity for reference equality + // Only pass isSameReference when BOTH values are objects + let isSameReference = undefined + if ( + expectVal !== null && + expected !== null && + typeof expectVal === "object" && + typeof expected === "object" + ) { + isSameReference = expectVal === expected + } + inputs.chaiEqual( + expectVal, + expected, + modifiers, + "equals", + isSameReference + ) + return withModifiers(modifiers) + } + proxy.eq = (expected) => { + // PRE-CHECK PATTERN: Track object identity for reference equality + // Only pass isSameReference when BOTH values are objects + let isSameReference = undefined + if ( + expectVal !== null && + expected !== null && + typeof expectVal === "object" && + typeof expected === "object" + ) { + isSameReference = expectVal === expected + } + inputs.chaiEqual(expectVal, expected, modifiers, "eq", isSameReference) + return withModifiers(modifiers) + } + proxy.eql = (expected) => { + // PRE-CHECK PATTERN: Extract metadata from special objects before serialization + let valueMetadata = undefined + let expectedMetadata = undefined + + // Handle RegExp objects - extract pattern and flags + if (expectVal instanceof RegExp && expected instanceof RegExp) { + valueMetadata = { + type: "RegExp", + source: expectVal.source, + flags: expectVal.flags, + } + expectedMetadata = { + type: "RegExp", + source: expected.source, + flags: expected.flags, + } + } + // Handle Date objects - extract timestamp + else if (expectVal instanceof Date && expected instanceof Date) { + valueMetadata = { + type: "Date", + time: expectVal.getTime(), + } + expectedMetadata = { + type: "Date", + time: expected.getTime(), + } + } + + inputs.chaiEql( + expectVal, + expected, + modifiers, + valueMetadata, + expectedMetadata + ) + return withModifiers(modifiers) + } + + // Custom Postman methods - delegates to chai-helpers.ts + proxy.jsonSchema = (schema) => { + if (!inputs.chaiJsonSchema) { + throw new Error("chaiJsonSchema method not found in inputs") + } + inputs.chaiJsonSchema(expectVal, schema, modifiers) + return withModifiers(modifiers) + } + + proxy.charset = (expectedCharset) => { + if (!inputs.chaiCharset) { + throw new Error("chaiCharset method not found in inputs") + } + inputs.chaiCharset(expectVal, expectedCharset, modifiers) + return withModifiers(modifiers) + } + + proxy.cookie = (cookieName, cookieValue) => { + if (!inputs.chaiCookie) { + throw new Error("chaiCookie method not found in inputs") + } + inputs.chaiCookie(expectVal, cookieName, cookieValue, modifiers) + return withModifiers(modifiers) + } + + proxy.jsonPath = (path, expectedValue) => { + if (!inputs.chaiJsonPath) { + throw new Error("chaiJsonPath method not found in inputs") + } + inputs.chaiJsonPath(expectVal, path, expectedValue, modifiers) + return withModifiers(modifiers) + } + + // .a() and .an() can be both type assertion methods and language chains for .instanceof + const aMethod = (type) => { + // PRE-CHECK PATTERN: Check typeof BEFORE serialization + // Special handling for null (typeof null === 'object' in JS) + let actualType = typeof expectVal + if (expectVal === null) { + actualType = "null" + } else if (Array.isArray(expectVal)) { + actualType = "array" + } + // Record the type assertion (valid terminal assertion) + inputs.chaiTypeOf(expectVal, type, modifiers, actualType) + return withModifiers(modifiers + ` a ${type}`) + } + // Add .instanceof as a property of the function + const aInstanceOfMethod = function (constructor) { + // Perform instanceof check in sandbox before serialization. + const actualInstanceCheck = expectVal instanceof constructor + + const objectType = Object.prototype.toString.call(expectVal) + let constructorName = "Unknown" + try { + if (constructor && typeof constructor.name === "string") { + constructorName = constructor.name + } else { + constructorName = String(constructor) + } + } catch (_e) { + constructorName = String(constructor) + } + + // PRE-FORMAT: Create display string for Set/Map before serialization + let displayValue = null + if (expectVal instanceof Set) { + const values = Array.from(expectVal).slice(0, 10) + displayValue = + values.length > 0 ? `new Set([${values.join(", ")}])` : "new Set()" + } else if (expectVal instanceof Map) { + const entries = Array.from(expectVal.entries()).slice(0, 3) + if (entries.length > 0) { + const formatted = entries.map(([k, v]) => `['${k}', ${v}]`) + displayValue = `new Map([${formatted.join(", ")}])` + } else { + displayValue = "new Map()" + } + } + + inputs.chaiInstanceOf( + expectVal, + constructorName, + modifiers, + objectType, + displayValue, + actualInstanceCheck // Pass the pre-checked result! + ) + return withModifiers(modifiers) + } + aMethod.instanceof = aInstanceOfMethod + aMethod.instanceOf = aInstanceOfMethod // Support both lowercase and camelCase + proxy.a = aMethod + + const anMethod = (type) => { + // Record the type assertion (valid terminal assertion) + inputs.chaiTypeOf(expectVal, type, modifiers) + return withModifiers(modifiers + ` an ${type}`) + } + // Add .instanceof as a property of the function + const instanceOfMethod = function (constructor) { + // Perform instanceof check in sandbox before serialization. + const actualInstanceCheck = expectVal instanceof constructor + + const objectType = Object.prototype.toString.call(expectVal) + let constructorName = "Unknown" + try { + if (constructor && typeof constructor.name === "string") { + constructorName = constructor.name + } else { + constructorName = String(constructor) + } + } catch (_e) { + constructorName = String(constructor) + } + + // PRE-FORMAT: Create display string for Set/Map before serialization + let displayValue = null + if (expectVal instanceof Set) { + const values = Array.from(expectVal).slice(0, 10) + displayValue = + values.length > 0 ? `new Set([${values.join(", ")}])` : "new Set()" + } else if (expectVal instanceof Map) { + const entries = Array.from(expectVal.entries()).slice(0, 3) + if (entries.length > 0) { + const formatted = entries.map(([k, v]) => `['${k}', ${v}]`) + displayValue = `new Map([${formatted.join(", ")}])` + } else { + displayValue = "new Map()" + } + } + + return inputs.chaiInstanceOf( + expectVal, + constructorName, + modifiers, + objectType, + displayValue, + actualInstanceCheck // Pass the pre-checked result! + ) + } + anMethod.instanceof = instanceOfMethod + anMethod.instanceOf = instanceOfMethod // Support both lowercase and camelCase + proxy.an = anMethod + + // Include can be both a method and have properties (for .include.members and .include.all.keys) + const includeMethod = (item) => { + inputs.chaiInclude(expectVal, item, modifiers) + return withModifiers(modifiers + ` include ${JSON.stringify(item)}`) + } + includeMethod.members = (members) => { + inputs.chaiIncludeMembers(expectVal, members, modifiers) + return withModifiers(modifiers + ` include members`) + } + // Add .all for .include.all.keys + Object.defineProperty(includeMethod, "all", { + get: () => { + const newModifiers = modifiers + " include all" + const allProxy = {} + allProxy.keys = (...keys) => { + inputs.chaiAllKeys(expectVal, keys, newModifiers) + return withModifiers(newModifiers) + } + return allProxy + }, + }) + // Add .any for .include.any.keys + Object.defineProperty(includeMethod, "any", { + get: () => { + const newModifiers = modifiers + " include any" + const anyProxy = {} + anyProxy.keys = (...keys) => { + inputs.chaiAnyKeys(expectVal, keys, newModifiers) + return withModifiers(newModifiers) + } + return anyProxy + }, + }) + // Add .keys for .include.keys + includeMethod.keys = (...keys) => { + const keysArray = + keys.length === 1 && Array.isArray(keys[0]) ? keys[0] : keys + inputs.chaiIncludeKeys(expectVal, keysArray, modifiers) + return withModifiers(modifiers) + } + // Add .deep for .include.deep.ordered.members and deep.include() + Object.defineProperty(includeMethod, "deep", { + get: () => { + const newModifiers = modifiers + " include deep" + // Create a function that can be called as deep.include(obj) + const deepIncludeFn = (item) => { + inputs.chaiDeepInclude(expectVal, item, newModifiers) + return withModifiers(newModifiers) + } + // Add .ordered for .include.deep.ordered + Object.defineProperty(deepIncludeFn, "ordered", { + get: () => { + const orderedModifiers = newModifiers + " ordered" + const orderedProxy = {} + orderedProxy.members = (members) => { + inputs.chaiIncludeDeepOrderedMembers( + expectVal, + members, + orderedModifiers + ) + return withModifiers(orderedModifiers) + } + return orderedProxy + }, + }) + return deepIncludeFn + }, + }) + proxy.include = includeMethod + + proxy.includes = (item) => { + inputs.chaiInclude(expectVal, item, modifiers) + return withModifiers(modifiers + ` include ${JSON.stringify(item)}`) + } + + // Contain can be both a method and have properties (for .contain.oneOf and .contain.members) + const containMethod = (item) => { + inputs.chaiInclude(expectVal, item, modifiers) + return withModifiers(modifiers + ` include ${JSON.stringify(item)}`) + } + containMethod.oneOf = (list) => { + inputs.chaiOneOf(expectVal, list, modifiers + " include") + return withModifiers(modifiers + " include oneOf") + } + containMethod.members = (members) => { + inputs.chaiIncludeMembers(expectVal, members, modifiers) + return withModifiers(modifiers + ` include members`) + } + proxy.contain = containMethod + + proxy.contains = (item) => { + inputs.chaiInclude(expectVal, item, modifiers) + return withModifiers(modifiers + ` include ${JSON.stringify(item)}`) + } + + // .lengthOf can be used both as a method and as a language chain + // As method: expect(arr).to.have.lengthOf(5) + // As chain: expect(arr).to.have.lengthOf.at.least(5) + Object.defineProperty(proxy, "lengthOf", { + get: () => { + // Extract actual length for comparison operations + let actualLength + let actualSize, typeName, serializedContent + + // PRE-CHECK PATTERN: Extract size from Set/Map before serialization + if (expectVal instanceof Set) { + actualSize = expectVal.size + actualLength = actualSize + typeName = "Set" + serializedContent = Array.from(expectVal) + } else if (expectVal instanceof Map) { + actualSize = expectVal.size + actualLength = actualSize + typeName = "Map" + serializedContent = Array.from(expectVal) + } else { + try { + actualLength = + expectVal && typeof expectVal.length !== "undefined" + ? expectVal.length + : expectVal && typeof expectVal.size !== "undefined" + ? expectVal.size + : undefined + } catch (_e) { + actualLength = undefined + } + } + + // Create a callable function that also has chainable properties + const lengthOfFn = (length) => { + inputs.chaiLengthOf( + serializedContent || expectVal, + length, + modifiers, + "lengthOf", + actualSize, + typeName + ) + return withModifiers(modifiers + ` lengthOf ${length}`) + } + + // Add comparison methods for chaining (like .lengthOf.at.least()) + if (actualLength !== undefined) { + lengthOfFn.above = (n) => { + inputs.chaiAbove(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthOfFn.below = (n) => { + inputs.chaiBelow(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthOfFn.within = (start, end) => { + inputs.chaiWithin(actualLength, start, end, modifiers) + return withModifiers(modifiers) + } + + lengthOfFn.least = (n) => { + inputs.chaiAtLeast(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthOfFn.most = (n) => { + inputs.chaiAtMost(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthOfFn.greaterThan = (n) => { + inputs.chaiAbove(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthOfFn.lessThan = (n) => { + inputs.chaiBelow(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthOfFn.gte = (n) => { + inputs.chaiAtLeast(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthOfFn.lte = (n) => { + inputs.chaiAtMost(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + // Add .at language chain for .lengthOf.at.least() and .lengthOf.at.most() + Object.defineProperty(lengthOfFn, "at", { + get: () => { + const atProxy = withModifiers(modifiers + " at") + atProxy.least = (n) => { + inputs.chaiAtLeast(actualLength, n, modifiers) + return withModifiers(modifiers) + } + atProxy.most = (n) => { + inputs.chaiAtMost(actualLength, n, modifiers) + return withModifiers(modifiers) + } + return atProxy + }, + configurable: true, + enumerable: false, + }) + } + + return lengthOfFn + }, + configurable: true, + enumerable: false, + }) + // .length as getter property for chaining: .length.above(), .length.below(), .length.within() + // Also supports .length(n) as method for exact length + Object.defineProperty(proxy, "length", { + get: () => { + // Extract actual length value + let actualLength + try { + actualLength = expectVal.length + } catch (_e) { + actualLength = undefined + } + + // Create callable function for exact length: .length(n) + const lengthProxy = (length) => { + // PRE-CHECK PATTERN: Extract size from Set/Map before serialization + let actualSize, typeName, serializedContent + if (expectVal instanceof Set) { + actualSize = expectVal.size + typeName = "Set" + serializedContent = Array.from(expectVal) + } else if (expectVal instanceof Map) { + actualSize = expectVal.size + typeName = "Map" + serializedContent = Array.from(expectVal) + } + inputs.chaiLengthOf( + serializedContent || expectVal, + length, + modifiers, + "length", + actualSize, + typeName + ) + // Return proxy wrapping the LENGTH VALUE for chaining (.which, .that, etc.) + return globalThis.__createChaiProxy( + actualLength, + inputs, + modifiers + ` length ${length}` + ) + } + + // Add comparison methods for chaining + lengthProxy.above = (n) => { + inputs.chaiAbove(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthProxy.below = (n) => { + inputs.chaiBelow(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthProxy.within = (start, end) => { + inputs.chaiWithin(actualLength, start, end, modifiers) + return withModifiers(modifiers) + } + + lengthProxy.least = (n) => { + inputs.chaiAtLeast(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthProxy.most = (n) => { + inputs.chaiAtMost(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthProxy.greaterThan = (n) => { + inputs.chaiAbove(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthProxy.lessThan = (n) => { + inputs.chaiBelow(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthProxy.gte = (n) => { + inputs.chaiAtLeast(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthProxy.lte = (n) => { + inputs.chaiAtMost(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + // Add .at language chain for .length.at.least() and .length.at.most() + Object.defineProperty(lengthProxy, "at", { + get: () => { + const atProxy = withModifiers(modifiers + " at") + atProxy.least = (n) => { + inputs.chaiAtLeast(actualLength, n, modifiers) + return withModifiers(modifiers) + } + atProxy.most = (n) => { + inputs.chaiAtMost(actualLength, n, modifiers) + return withModifiers(modifiers) + } + return atProxy + }, + configurable: true, + enumerable: false, + }) + + return lengthProxy + }, + configurable: true, + enumerable: false, + }) + + proxy.property = (prop, val) => { + // PRE-CHECK PATTERN: Special handling for Map/Set size property + // Map and Set serialize as {} losing .size property, so extract it first + if ( + prop === "size" && + (expectVal instanceof Map || expectVal instanceof Set) + ) { + const actualSize = expectVal.size + // Call chaiProperty with actual size value + inputs.chaiProperty( + { size: actualSize }, // Wrap size in object + prop, + val, + modifiers + ) + // For chaining, return proxy wrapping the size value + return globalThis.__createChaiProxy(actualSize, inputs, modifiers) + } + + // PRE-CHECK PATTERN: Check property existence (including inherited) BEFORE serialization + // The 'in' operator checks for both own and inherited properties + let hasProperty = undefined + if (expectVal !== null && typeof expectVal === "object") { + hasProperty = prop in expectVal + } + + // When val is provided, assert the property value directly + inputs.chaiProperty(expectVal, prop, val, modifiers, hasProperty) + + // For chaining (.that, .which), we need to return a proxy wrapping the PROPERTY VALUE + // Extract the property value from the object + let propertyValue + try { + propertyValue = expectVal[prop] + } catch (_e) { + propertyValue = undefined + } + + // Return a new proxy wrapping the property value, not the original object + // This allows: .property('a').that.equals(1) to work + return globalThis.__createChaiProxy(propertyValue, inputs, modifiers) + } + proxy.ownProperty = (prop) => { + // PRE-CHECK PATTERN: Check hasOwnProperty BEFORE serialization + // Prototype chain is lost when objects cross sandbox boundary + // Only pass isOwnProperty when value is an object + let isOwnProperty = undefined + if (expectVal !== null && typeof expectVal === "object") { + isOwnProperty = Object.prototype.hasOwnProperty.call(expectVal, prop) + } + + inputs.chaiOwnProperty(expectVal, prop, modifiers, isOwnProperty) + + // For chaining, return proxy wrapping the property value + let propertyValue + try { + propertyValue = expectVal[prop] + } catch (_e) { + propertyValue = undefined + } + + return globalThis.__createChaiProxy(propertyValue, inputs, modifiers) + } + proxy.ownPropertyDescriptor = (prop, descriptor) => { + // PRE-CHECK PATTERN: Check property descriptor in sandbox before serialization + let hasDescriptor = false + let actualDescriptor = null + let matchesExpected = false + + try { + actualDescriptor = Object.getOwnPropertyDescriptor(expectVal, prop) + hasDescriptor = actualDescriptor !== undefined + + // If descriptor argument provided, check if it matches + if (hasDescriptor && descriptor !== undefined) { + matchesExpected = true + // Compare each property of the descriptor + for (const key in descriptor) { + if (descriptor[key] !== actualDescriptor[key]) { + matchesExpected = false + break + } + } + } + } catch (_e) { + hasDescriptor = false + } + + inputs.chaiOwnPropertyDescriptor( + expectVal, + prop, + descriptor, + modifiers, + hasDescriptor, + matchesExpected + ) + + // Return a proxy that can chain with .that to access the descriptor + const descriptorProxy = withModifiers(modifiers) + // Delete existing .that property if it exists, then redefine it + delete descriptorProxy.that + Object.defineProperty(descriptorProxy, "that", { + configurable: true, + enumerable: false, + get: () => { + // Return a new Chai proxy wrapping the descriptor itself + return globalThis.__createChaiProxy(actualDescriptor, inputs, " to") + }, + }) + return descriptorProxy + } + + proxy.above = (n) => { + inputs.chaiAbove(expectVal, n, modifiers) + return withModifiers(modifiers + ` above ${n}`) + } + proxy.gt = (n) => { + inputs.chaiAbove(expectVal, n, modifiers) + return withModifiers(modifiers + ` above ${n}`) + } + proxy.greaterThan = (n) => { + inputs.chaiAbove(expectVal, n, modifiers) + return withModifiers(modifiers + ` above ${n}`) + } + proxy.greaterThanOrEqual = (n) => { + inputs.chaiAtLeast(expectVal, n, modifiers) + return withModifiers(modifiers + ` at least ${n}`) + } + proxy.gte = (n) => { + inputs.chaiAtLeast(expectVal, n, modifiers) + return withModifiers(modifiers + ` at least ${n}`) + } + + proxy.below = (n) => { + inputs.chaiBelow(expectVal, n, modifiers) + return withModifiers(modifiers + ` below ${n}`) + } + proxy.lt = (n) => { + inputs.chaiBelow(expectVal, n, modifiers) + return withModifiers(modifiers + ` below ${n}`) + } + proxy.lessThan = (n) => { + inputs.chaiBelow(expectVal, n, modifiers) + return withModifiers(modifiers + ` below ${n}`) + } + proxy.lessThanOrEqual = (n) => { + inputs.chaiAtMost(expectVal, n, modifiers) + return withModifiers(modifiers + ` at most ${n}`) + } + proxy.lte = (n) => { + inputs.chaiAtMost(expectVal, n, modifiers) + return withModifiers(modifiers + ` at most ${n}`) + } + + proxy.within = (start, end) => { + inputs.chaiWithin(expectVal, start, end, modifiers) + return withModifiers(modifiers) + } + proxy.closeTo = (expected, delta) => { + inputs.chaiCloseTo(expectVal, expected, delta, modifiers, "closeTo") + return withModifiers(modifiers) + } + proxy.approximately = (expected, delta) => { + inputs.chaiCloseTo( + expectVal, + expected, + delta, + modifiers, + "approximately" + ) + return withModifiers(modifiers) + } + + proxy.keys = (...keys) => { + // Support both keys('a', 'b') and keys(['a', 'b']) + const keysArray = + keys.length === 1 && Array.isArray(keys[0]) ? keys[0] : keys + inputs.chaiKeys(expectVal, keysArray, modifiers) + return withModifiers(modifiers) + } + proxy.key = (key) => { + // Support both key('a') and key(['a']) + const keysArray = Array.isArray(key) ? key : [key] + inputs.chaiKeys(expectVal, keysArray, modifiers) + return withModifiers(modifiers) + } + + proxy.match = (pattern) => { + // PRE-CHECK PATTERN: Extract RegExp source and flags before serialization + let regexSource, regexFlags + if (pattern instanceof RegExp) { + regexSource = pattern.source + regexFlags = pattern.flags + } + return inputs.chaiMatch( + expectVal, + pattern, + modifiers, + regexSource, + regexFlags + ) + } + proxy.matches = (pattern) => { + // PRE-CHECK PATTERN: Extract RegExp source and flags before serialization + let regexSource, regexFlags + if (pattern instanceof RegExp) { + regexSource = pattern.source + regexFlags = pattern.flags + } + return inputs.chaiMatch( + expectVal, + pattern, + modifiers, + regexSource, + regexFlags + ) + } + proxy.string = (substring) => + inputs.chaiString(expectVal, substring, modifiers) + + proxy.members = (members) => + inputs.chaiMembers(expectVal, members, modifiers) + proxy.oneOf = (list) => inputs.chaiOneOf(expectVal, list, modifiers) + + proxy.throw = (errorLike, errMsgMatcher) => { + // PRE-CHECK PATTERN: Execute function in sandbox, pass results + let threwError = false + let errorTypeName = null + let errorMessage = null + + if (typeof expectVal === "function") { + try { + expectVal() + } catch (e) { + threwError = true + errorTypeName = e.constructor?.name || "Error" + errorMessage = e.message || String(e) + } + } + + // Handle parameter interpretation like Chai does: + // .throw() - no params + // .throw(ErrorType) - constructor function + // .throw('message') - string message + // .throw(/pattern/) - regex pattern + // .throw(ErrorType, 'message') - constructor + message + // .throw(ErrorType, /pattern/) - constructor + regex + + let actualErrorLike = errorLike + let actualErrMsgMatcher = errMsgMatcher + + // If first param is string or regex but no second param, + // treat first param as message matcher, not error type + if (errorLike !== undefined && errMsgMatcher === undefined) { + if (typeof errorLike === "string" || errorLike instanceof RegExp) { + actualErrorLike = undefined + actualErrMsgMatcher = errorLike + } + } + + // Get error type name for matching + let expectedTypeName = null + if (actualErrorLike && typeof actualErrorLike === "function") { + expectedTypeName = actualErrorLike.name + } + + // Convert RegExp to serializable form or pass string message + let regexSource, regexFlags + let isRegexMatcher = false + if (actualErrMsgMatcher instanceof RegExp) { + regexSource = actualErrMsgMatcher.source + regexFlags = actualErrMsgMatcher.flags + isRegexMatcher = true + } + + return inputs.chaiThrow( + expectVal, + threwError, + errorTypeName, + errorMessage, + expectedTypeName, + actualErrMsgMatcher, + regexSource, + regexFlags, + isRegexMatcher, + modifiers + ) + } + proxy.throws = (errorLike, errMsgMatcher) => { + // PRE-CHECK PATTERN: Execute function in sandbox, pass results + let threwError = false + let errorTypeName = null + let errorMessage = null + + if (typeof expectVal === "function") { + try { + expectVal() + } catch (e) { + threwError = true + errorTypeName = e.constructor?.name || "Error" + errorMessage = e.message || String(e) + } + } + + // Handle parameter interpretation like Chai does + let actualErrorLike = errorLike + let actualErrMsgMatcher = errMsgMatcher + + // If first param is string or regex but no second param, + // treat first param as message matcher, not error type + if (errorLike !== undefined && errMsgMatcher === undefined) { + if (typeof errorLike === "string" || errorLike instanceof RegExp) { + actualErrorLike = undefined + actualErrMsgMatcher = errorLike + } + } + + // Get error type name for matching + let expectedTypeName = null + if (actualErrorLike && typeof actualErrorLike === "function") { + expectedTypeName = actualErrorLike.name + } + + // Convert RegExp to serializable form or pass string message + let regexSource, regexFlags + let isRegexMatcher = false + if (actualErrMsgMatcher instanceof RegExp) { + regexSource = actualErrMsgMatcher.source + regexFlags = actualErrMsgMatcher.flags + isRegexMatcher = true + } + + return inputs.chaiThrow( + expectVal, + threwError, + errorTypeName, + errorMessage, + expectedTypeName, + actualErrMsgMatcher, + regexSource, + regexFlags, + isRegexMatcher, + modifiers + ) + } + proxy.Throw = (errorLike, errMsgMatcher) => { + // PRE-CHECK PATTERN: Execute function in sandbox, pass results + let threwError = false + let errorTypeName = null + let errorMessage = null + + if (typeof expectVal === "function") { + try { + expectVal() + } catch (e) { + threwError = true + errorTypeName = e.constructor?.name || "Error" + errorMessage = e.message || String(e) + } + } + + // Handle parameter interpretation like Chai does + let actualErrorLike = errorLike + let actualErrMsgMatcher = errMsgMatcher + + // If first param is string or regex but no second param, + // treat first param as message matcher, not error type + if (errorLike !== undefined && errMsgMatcher === undefined) { + if (typeof errorLike === "string" || errorLike instanceof RegExp) { + actualErrorLike = undefined + actualErrMsgMatcher = errorLike + } + } + + // Get error type name for matching + let expectedTypeName = null + if (actualErrorLike && typeof actualErrorLike === "function") { + expectedTypeName = actualErrorLike.name + } + + // Convert RegExp to serializable form or pass string message + let regexSource, regexFlags + let isRegexMatcher = false + if (actualErrMsgMatcher instanceof RegExp) { + regexSource = actualErrMsgMatcher.source + regexFlags = actualErrMsgMatcher.flags + isRegexMatcher = true + } + + return inputs.chaiThrow( + expectVal, + threwError, + errorTypeName, + errorMessage, + expectedTypeName, + actualErrMsgMatcher, + regexSource, + regexFlags, + isRegexMatcher, + modifiers + ) + } + + // PRE-CHECK PATTERN: Check method existence BEFORE serialization + proxy.respondTo = (method) => { + // Check if method exists on value or its prototype/constructor + // When .itself modifier is present, check for static methods on the constructor itself + const hasItselfModifier = String(modifiers).includes("itself") + let hasMethod = false + + if (hasItselfModifier) { + // .itself.respondTo() checks for static methods directly on the constructor/class + hasMethod = typeof expectVal?.[method] === "function" + } else { + // Regular .respondTo() checks instance methods (on value or prototype) + hasMethod = + typeof expectVal?.[method] === "function" || + typeof expectVal?.prototype?.[method] === "function" + } + + return inputs.chaiRespondTo(expectVal, method, modifiers, hasMethod) + } + proxy.respondsTo = (method) => { + // Check if method exists on value or its prototype/constructor + // When .itself modifier is present, check for static methods on the constructor itself + const hasItselfModifier = String(modifiers).includes("itself") + let hasMethod = false + + if (hasItselfModifier) { + // .itself.respondTo() checks for static methods directly on the constructor/class + hasMethod = typeof expectVal?.[method] === "function" + } else { + // Regular .respondTo() checks instance methods (on value or prototype) + hasMethod = + typeof expectVal?.[method] === "function" || + typeof expectVal?.prototype?.[method] === "function" + } + + return inputs.chaiRespondTo(expectVal, method, modifiers, hasMethod) + } + + proxy.satisfy = (matcher) => { + // PRE-CHECK PATTERN: Execute matcher function in sandbox, pass result + let satisfyResult = false + let matcherString = String(matcher) + + if (typeof matcher === "function") { + try { + satisfyResult = Boolean(matcher(expectVal)) + } catch (_e) { + satisfyResult = false + } + } + + return inputs.chaiSatisfy( + expectVal, + satisfyResult, + matcherString, + modifiers + ) + } + proxy.satisfies = (matcher) => { + // PRE-CHECK PATTERN: Execute matcher function in sandbox, pass result + let satisfyResult = false + let matcherString = String(matcher) + + if (typeof matcher === "function") { + try { + satisfyResult = Boolean(matcher(expectVal)) + } catch (_e) { + satisfyResult = false + } + } + + return inputs.chaiSatisfy( + expectVal, + satisfyResult, + matcherString, + modifiers + ) + } + + // PRE-CHECK PATTERN: change/increase/decrease assertions + proxy.change = (obj, prop) => { + // Support both patterns: + // 1. change(getter) - getter function pattern + // 2. change(obj, prop) - object + property pattern + let initialValue + let finalValue + let changed = false + let delta = 0 + let propName = prop + + try { + // Pattern 1: Single argument that is a function (getter) + if (typeof obj === "function" && prop === undefined) { + initialValue = obj() + if (typeof expectVal === "function") { + expectVal() + } + finalValue = obj() + propName = "value" // Use generic name for getter pattern + } + // Pattern 2: Object + property name + else { + initialValue = obj[prop] + if (typeof expectVal === "function") { + expectVal() + } + finalValue = obj[prop] + } + + changed = initialValue !== finalValue + if ( + typeof initialValue === "number" && + typeof finalValue === "number" + ) { + delta = finalValue - initialValue + } + } catch (_e) { + changed = false + } + + // Call the assertion (adds result to testStack) + inputs.chaiChange(propName, modifiers, changed, delta) + + // Return a proxy with .by() method for chaining + return { + by: (expectedDelta) => { + inputs.chaiChangeBy( + propName, + modifiers, + changed, + delta, + expectedDelta + ) + }, + } + } + + proxy.increase = (obj, prop) => { + // Support both patterns: + // 1. increase(getter) - getter function pattern + // 2. increase(obj, prop) - object + property pattern + let initialValue + let finalValue + let increased = false + let delta = 0 + let propName = prop + + try { + // Pattern 1: Single argument that is a function (getter) + if (typeof obj === "function" && prop === undefined) { + initialValue = obj() + if (typeof expectVal === "function") { + expectVal() + } + finalValue = obj() + propName = "value" // Use generic name for getter pattern + } + // Pattern 2: Object + property name + else { + initialValue = obj[prop] + if (typeof expectVal === "function") { + expectVal() + } + finalValue = obj[prop] + } + + if ( + typeof initialValue === "number" && + typeof finalValue === "number" + ) { + delta = finalValue - initialValue + increased = delta > 0 + } + } catch (_e) { + increased = false + } + + // Call the assertion (adds result to testStack) + inputs.chaiIncrease(propName, modifiers, increased, delta) + + // Return a proxy with .by() method for chaining + return { + by: (expectedDelta) => { + inputs.chaiIncreaseBy( + propName, + modifiers, + increased, + delta, + expectedDelta + ) + }, + } + } + + proxy.decrease = (obj, prop) => { + // Support both patterns: + // 1. decrease(getter) - getter function pattern + // 2. decrease(obj, prop) - object + property pattern + let initialValue + let finalValue + let decreased = false + let delta = 0 + let propName = prop + + try { + // Pattern 1: Single argument that is a function (getter) + if (typeof obj === "function" && prop === undefined) { + initialValue = obj() + if (typeof expectVal === "function") { + expectVal() + } + finalValue = obj() + propName = "value" // Use generic name for getter pattern + } + // Pattern 2: Object + property name + else { + initialValue = obj[prop] + if (typeof expectVal === "function") { + expectVal() + } + finalValue = obj[prop] + } + + if ( + typeof initialValue === "number" && + typeof finalValue === "number" + ) { + delta = finalValue - initialValue + decreased = delta < 0 + } + } catch (_e) { + decreased = false + } + + // Call the assertion (adds result to testStack) + inputs.chaiDecrease(propName, modifiers, decreased, delta) + + // Return a proxy with .by() method for chaining + return { + by: (expectedDelta) => { + inputs.chaiDecreaseBy( + propName, + modifiers, + decreased, + delta, + expectedDelta + ) + }, + } + } + + // PRE-CHECK PATTERN: Check instanceof BEFORE serialization (for custom classes) + proxy.instanceof = function (constructor) { + // Debug logging + if (typeof inputs.chaiInstanceOf !== "function") { + throw new Error( + "inputs.chaiInstanceOf is not a function: " + + typeof inputs.chaiInstanceOf + ) + } + + // Perform instanceof check in sandbox before serialization. + // Essential for custom user-defined classes to work correctly. + const actualInstanceCheck = expectVal instanceof constructor + + // Get the actual type using Object.prototype.toString for built-ins + const objectType = Object.prototype.toString.call(expectVal) + // Get constructor name before serialization (constructors don't cross boundary) + let constructorName = "Unknown" + try { + if (constructor && typeof constructor.name === "string") { + constructorName = constructor.name + } else { + constructorName = String(constructor) + } + } catch (_e) { + constructorName = String(constructor) + } + + // PRE-FORMAT: Create display string for Set/Map before serialization + let displayValue = null + if (expectVal instanceof Set) { + const values = Array.from(expectVal).slice(0, 10) + displayValue = + values.length > 0 ? `new Set([${values.join(", ")}])` : "new Set()" + } else if (expectVal instanceof Map) { + const entries = Array.from(expectVal.entries()).slice(0, 3) + if (entries.length > 0) { + const formatted = entries.map(([k, v]) => `['${k}', ${v}]`) + displayValue = `new Map([${formatted.join(", ")}])` + } else { + displayValue = "new Map()" + } + } + + return inputs.chaiInstanceOf( + expectVal, + constructorName, + modifiers, + objectType, + displayValue, + actualInstanceCheck // Pass the pre-checked result! + ) + } + proxy.instanceOf = function (constructor) { + // Perform instanceof check in sandbox before serialization. + const actualInstanceCheck = expectVal instanceof constructor + + // Get the actual type using Object.prototype.toString for built-ins + const objectType = Object.prototype.toString.call(expectVal) + // Get constructor name before serialization (constructors don't cross boundary) + let constructorName = "Unknown" + try { + if (constructor && typeof constructor.name === "string") { + constructorName = constructor.name + } else { + constructorName = String(constructor) + } + } catch (_e) { + constructorName = String(constructor) + } + + // PRE-FORMAT: Create display string for Set/Map before serialization + let displayValue = null + if (expectVal instanceof Set) { + const values = Array.from(expectVal).slice(0, 10) + displayValue = + values.length > 0 ? `new Set([${values.join(", ")}])` : "new Set()" + } else if (expectVal instanceof Map) { + const entries = Array.from(expectVal.entries()).slice(0, 3) + if (entries.length > 0) { + const formatted = entries.map(([k, v]) => `['${k}', ${v}]`) + displayValue = `new Map([${formatted.join(", ")}])` + } else { + displayValue = "new Map()" + } + } + + return inputs.chaiInstanceOf( + expectVal, + constructorName, + modifiers, + objectType, + displayValue, + actualInstanceCheck // Pass the pre-checked result! + ) + } + + // Assertion getters + Object.defineProperty(proxy, "ok", { + get: () => { + inputs.chaiOk(expectVal, modifiers) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "true", { + get: () => { + inputs.chaiTrue(expectVal, modifiers) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "false", { + get: () => { + inputs.chaiFalse(expectVal, modifiers) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "null", { + get: () => { + inputs.chaiNull(expectVal, modifiers) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "undefined", { + get: () => { + inputs.chaiUndefined(expectVal, modifiers) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "NaN", { + get: () => { + inputs.chaiNaN(expectVal, modifiers) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "exist", { + get: () => { + inputs.chaiExist(expectVal, modifiers) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "empty", { + get: () => { + // PRE-CHECK PATTERN: Pass type info for Set/Map before serialization + let typeName = null + let actualSize = null + if (expectVal instanceof Set) { + typeName = "Set" + actualSize = expectVal.size + } else if (expectVal instanceof Map) { + typeName = "Map" + actualSize = expectVal.size + } + inputs.chaiEmpty(expectVal, modifiers, typeName, actualSize) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "finite", { + get: () => { + inputs.chaiFinite(expectVal, modifiers) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "arguments", { + get: () => { + // PRE-CHECK PATTERN: Check if value is arguments object before serialization + const isArguments = + Object.prototype.toString.call(expectVal) === "[object Arguments]" + inputs.chaiArguments(expectVal, modifiers + " arguments", isArguments) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "Arguments", { + get: () => { + // PRE-CHECK PATTERN: Check if value is arguments object before serialization + const isArguments = + Object.prototype.toString.call(expectVal) === "[object Arguments]" + inputs.chaiArguments(expectVal, modifiers + " Arguments", isArguments) + return withModifiers(modifiers) + }, + }) + // PRE-CHECK PATTERN: Check object state in sandbox BEFORE serialization + Object.defineProperty(proxy, "extensible", { + get: () => { + // Check extensibility in sandbox context before value crosses boundary + const isExtensible = Object.isExtensible(expectVal) + // Pass both the value and the pre-checked boolean + inputs.chaiExtensible(expectVal, modifiers, isExtensible) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "sealed", { + get: () => { + // Check sealed state in sandbox context before value crosses boundary + const isSealed = Object.isSealed(expectVal) + // Pass both the value and the pre-checked boolean + inputs.chaiSealed(expectVal, modifiers, isSealed) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "frozen", { + get: () => { + // Check frozen state in sandbox context before value crosses boundary + const isFrozen = Object.isFrozen(expectVal) + // Pass both the value and the pre-checked boolean + inputs.chaiFrozen(expectVal, modifiers, isFrozen) + return withModifiers(modifiers) + }, + }) + + // Language chains - return new proxy + Object.defineProperty(proxy, "to", { + get: () => withModifiers(" to"), + }) + Object.defineProperty(proxy, "be", { + get: () => withModifiers(modifiers + " be"), + }) + Object.defineProperty(proxy, "been", { + get: () => withModifiers(modifiers + " been"), + }) + Object.defineProperty(proxy, "is", { + get: () => withModifiers(modifiers + " is"), + }) + Object.defineProperty(proxy, "that", { + configurable: true, // Allow ownPropertyDescriptor to override this + get: () => withModifiers(modifiers + " that"), + }) + Object.defineProperty(proxy, "which", { + get: () => withModifiers(modifiers + " which"), + }) + Object.defineProperty(proxy, "and", { + get: () => withModifiers(modifiers + " and"), + }) + Object.defineProperty(proxy, "has", { + get: () => withModifiers(modifiers + " has"), + }) + Object.defineProperty(proxy, "have", { + get: () => { + const haveProxy = withModifiers(modifiers + " have") + // Add ownPropertyDescriptor method to have proxy + haveProxy.ownPropertyDescriptor = (prop, descriptor) => { + // PRE-CHECK PATTERN: Check property descriptor in sandbox before serialization + let hasDescriptor = false + let actualDescriptor = null + let matchesExpected = false + + try { + actualDescriptor = Object.getOwnPropertyDescriptor( + expectVal, + prop + ) + hasDescriptor = actualDescriptor !== undefined + + // If descriptor argument provided, check if it matches + if (hasDescriptor && descriptor !== undefined) { + matchesExpected = true + // Compare each property of the descriptor + for (const key in descriptor) { + if (descriptor[key] !== actualDescriptor[key]) { + matchesExpected = false + break + } + } + } + } catch (_e) { + hasDescriptor = false + } + + inputs.chaiOwnPropertyDescriptor( + expectVal, + prop, + descriptor, + modifiers + " have", + hasDescriptor, + matchesExpected + ) + + // Return a proxy that can chain with .that to access the descriptor + const descriptorProxy = withModifiers(modifiers + " have") + // Delete existing .that property if it exists, then redefine it + delete descriptorProxy.that + Object.defineProperty(descriptorProxy, "that", { + configurable: true, + enumerable: false, + get: () => { + // Return a new Chai proxy wrapping the descriptor itself + return globalThis.__createChaiProxy( + actualDescriptor, + inputs, + " to" + ) + }, + }) + return descriptorProxy + } + return haveProxy + }, + }) + Object.defineProperty(proxy, "with", { + get: () => withModifiers(modifiers + " with"), + }) + Object.defineProperty(proxy, "at", { + get: () => { + const atProxy = withModifiers(modifiers + " at") + atProxy.least = (n) => inputs.chaiAtLeast(expectVal, n, modifiers) + atProxy.most = (n) => inputs.chaiAtMost(expectVal, n, modifiers) + return atProxy + }, + }) + Object.defineProperty(proxy, "of", { + get: () => withModifiers(modifiers + " of"), + }) + Object.defineProperty(proxy, "same", { + get: () => withModifiers(modifiers + " same"), + }) + Object.defineProperty(proxy, "but", { + get: () => withModifiers(modifiers + " but"), + }) + Object.defineProperty(proxy, "does", { + get: () => withModifiers(modifiers + " does"), + }) + Object.defineProperty(proxy, "itself", { + get: () => withModifiers(modifiers + " itself"), + }) + + // Modifiers - return new proxy with updated modifiers + Object.defineProperty(proxy, "not", { + get: () => withModifiers(" to not"), + }) + Object.defineProperty(proxy, "deep", { + get: () => { + const newModifiers = modifiers + " deep" + const deepProxy = withModifiers(newModifiers) + deepProxy.property = (prop, val) => { + if (val !== undefined) { + inputs.chaiDeepOwnProperty(expectVal, prop, val, newModifiers) + } else { + inputs.chaiProperty(expectVal, prop, val, newModifiers) + } + } + deepProxy.members = (members) => + inputs.chaiDeepMembers(expectVal, members, newModifiers) + deepProxy.include = (item) => { + inputs.chaiDeepInclude(expectVal, item, newModifiers) + return withModifiers(newModifiers) + } + return deepProxy + }, + }) + Object.defineProperty(proxy, "own", { + get: () => { + const newModifiers = modifiers + " own" + const ownProxy = withModifiers(newModifiers) + ownProxy.property = (prop, val) => { + if (val !== undefined) { + inputs.chaiDeepOwnProperty(expectVal, prop, val, newModifiers) + } else { + // PRE-CHECK PATTERN: Check hasOwnProperty BEFORE serialization + // Only pass isOwnProperty when value is an object + let isOwnProperty = undefined + if (expectVal !== null && typeof expectVal === "object") { + isOwnProperty = Object.prototype.hasOwnProperty.call( + expectVal, + prop + ) + } + inputs.chaiOwnProperty( + expectVal, + prop, + newModifiers, + isOwnProperty + ) + } + } + return ownProxy + }, + }) + Object.defineProperty(proxy, "nested", { + get: () => { + const newModifiers = modifiers + " nested" + const nestedProxy = withModifiers(newModifiers) + nestedProxy.property = (prop, val) => + inputs.chaiNestedProperty(expectVal, prop, val, newModifiers) + // Add .include() for .nested.include() pattern + nestedProxy.include = (obj) => { + inputs.chaiNestedInclude(expectVal, obj, newModifiers) + return withModifiers(newModifiers) + } + return nestedProxy + }, + }) + Object.defineProperty(proxy, "ordered", { + get: () => { + const newModifiers = modifiers + " ordered" + const orderedProxy = withModifiers(newModifiers) + orderedProxy.members = (members) => + inputs.chaiOrderedMembers(expectVal, members, newModifiers) + return orderedProxy + }, + }) + Object.defineProperty(proxy, "all", { + get: () => { + const newModifiers = modifiers + " all" + const allProxy = withModifiers(newModifiers) + allProxy.keys = (...keys) => + inputs.chaiAllKeys(expectVal, keys, newModifiers) + return allProxy + }, + }) + Object.defineProperty(proxy, "any", { + get: () => { + const newModifiers = modifiers + " any" + const anyProxy = withModifiers(newModifiers) + anyProxy.keys = (...keys) => + inputs.chaiAnyKeys(expectVal, keys, newModifiers) + return anyProxy + }, + }) + // .include is defined above as a method with .members property + + // Postman custom Chai assertions (jsonSchema, charset, cookie, jsonPath) + // These are Postman-specific extensions that work with pm.expect() + + proxy.jsonSchema = (schema) => { + // Basic JSON Schema validation (supports common keywords) + const validateSchema = (data, schema) => { + // Type validation + if (schema.type) { + const actualType = Array.isArray(data) + ? "array" + : data === null + ? "null" + : typeof data + if (actualType !== schema.type) { + return `Expected type ${schema.type}, got ${actualType}` + } + } + + // Required properties + if (schema.required && Array.isArray(schema.required)) { + for (const prop of schema.required) { + if (!(prop in data)) { + return `Required property '${prop}' is missing` + } + } + } + + // Properties validation + if (schema.properties) { + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (key in data) { + const error = validateSchema(data[key], propSchema) + if (error) return `Property '${key}': ${error}` + } + } + } + + // Enum validation + if (schema.enum) { + if (!schema.enum.includes(data)) { + return `Value must be one of: ${schema.enum.join(", ")}` + } + } + + // Number validation + if (typeof data === "number") { + if (schema.minimum !== undefined && data < schema.minimum) { + return `Value ${data} is below minimum ${schema.minimum}` + } + if (schema.maximum !== undefined && data > schema.maximum) { + return `Value ${data} exceeds maximum ${schema.maximum}` + } + } + + // String validation + if (typeof data === "string") { + if ( + schema.minLength !== undefined && + data.length < schema.minLength + ) { + return `String length ${data.length} is below minimum ${schema.minLength}` + } + if ( + schema.maxLength !== undefined && + data.length > schema.maxLength + ) { + return `String length ${data.length} exceeds maximum ${schema.maxLength}` + } + if (schema.pattern) { + const regex = new RegExp(schema.pattern) + if (!regex.test(data)) { + return `String does not match pattern ${schema.pattern}` + } + } + } + + // Array validation + if (Array.isArray(data)) { + if ( + schema.minItems !== undefined && + data.length < schema.minItems + ) { + return `Array length ${data.length} is below minimum ${schema.minItems}` + } + if ( + schema.maxItems !== undefined && + data.length > schema.maxItems + ) { + return `Array length ${data.length} exceeds maximum ${schema.maxItems}` + } + if (schema.items) { + for (let i = 0; i < data.length; i++) { + const error = validateSchema(data[i], schema.items) + if (error) return `Item [${i}]: ${error}` + } + } + } + + return null // Validation passed + } + + const error = validateSchema(expectVal, schema) + if (error) { + throw new Error(`JSON Schema validation failed: ${error}`) + } + return withModifiers(modifiers) + } + + proxy.charset = (expectedCharset) => { + // expectVal should be a string (typically Content-Type header) + if (typeof expectVal !== "string") { + throw new Error( + "charset() expects a string value (typically Content-Type header)" + ) + } + + const lowerExpected = expectedCharset.toLowerCase() + const lowerActual = expectVal.toLowerCase() + + if (!lowerActual.includes(lowerExpected)) { + throw new Error( + `Expected charset "${expectedCharset}" not found in "${expectVal}"` + ) + } + return withModifiers(modifiers) + } + + proxy.cookie = (cookieName, cookieValue) => { + // This works when expectVal is pm.response or similar + // For the Chai extension, we need to check if cookies exist + // This is typically used as: pm.expect(pm.response).to.have.cookie('name') + // But since we're on a Chai proxy, we assume expectVal is the response object + + if (!expectVal || typeof expectVal !== "object" || !expectVal.cookies) { + throw new Error( + "cookie() assertion requires a response object with cookies" + ) + } + + const hasCookie = expectVal.cookies.has(cookieName) + if (!hasCookie) { + throw new Error(`Cookie "${cookieName}" not found in response`) + } + + if (cookieValue !== undefined) { + const actualValue = expectVal.cookies.get(cookieName) + if (actualValue !== cookieValue) { + throw new Error( + `Cookie "${cookieName}" has value "${actualValue}", expected "${cookieValue}"` + ) + } + } + return withModifiers(modifiers) + } + + // Legacy pw.expect API compatibility methods + // These provide backward compatibility for tests using the old pw.expect API + // Legacy API compatibility - delegate to existing legacy functions + // These handle errors properly by returning test results instead of throwing + // Check modifiers to determine if negation is needed + const isNegated = modifiers.includes("not") + + proxy.toBe = (expectedVal) => + isNegated + ? inputs.expectNotToBe(expectVal, expectedVal) + : inputs.expectToBe(expectVal, expectedVal) + + proxy.toBeLevel2xx = () => + isNegated + ? inputs.expectNotToBeLevel2xx(expectVal) + : inputs.expectToBeLevel2xx(expectVal) + + proxy.toBeLevel3xx = () => + isNegated + ? inputs.expectNotToBeLevel3xx(expectVal) + : inputs.expectToBeLevel3xx(expectVal) + + proxy.toBeLevel4xx = () => + isNegated + ? inputs.expectNotToBeLevel4xx(expectVal) + : inputs.expectToBeLevel4xx(expectVal) + + proxy.toBeLevel5xx = () => + isNegated + ? inputs.expectNotToBeLevel5xx(expectVal) + : inputs.expectToBeLevel5xx(expectVal) + + proxy.toBeType = (expectedType) => { + const isDateInstance = expectVal instanceof Date + return isNegated + ? inputs.expectNotToBeType(expectVal, expectedType, isDateInstance) + : inputs.expectToBeType(expectVal, expectedType, isDateInstance) + } + + proxy.toHaveLength = (expectedLength) => + isNegated + ? inputs.expectNotToHaveLength(expectVal, expectedLength) + : inputs.expectToHaveLength(expectVal, expectedLength) + + proxy.toInclude = (needle) => + isNegated + ? inputs.expectNotToInclude(expectVal, needle) + : inputs.expectToInclude(expectVal, needle) + + return proxy + } + } + + const toJSON = (input) => { + if (input == null) return null + + if (typeof input === "string") { + try { + return JSON.parse(input) + } catch { + throw new Error("Invalid JSON string") + } + } + + if (typeof input === "object") { + return input + } + + throw new Error( + "Unsupported input type for hopp.response.asJSON(). Expected string or object." + ) + } + + /** + * Convert input into text (stringify objects, pass strings through). + */ + const toText = (input) => { + if (input == null) return "" + + if (typeof input === "string") return input + + if (typeof input === "object") return JSON.stringify(input) + + throw new Error("Unsupported input type for hopp.response.asText()") + } + + /** + * Convert input into bytes (UTF-8 encode strings, stringify objects first). + */ + const toBytes = (input) => { + if (input == null) return new Uint8Array() + + if (typeof input === "string") return new TextEncoder().encode(input) + + if (typeof input === "object") + return new TextEncoder().encode(JSON.stringify(input)) + + throw new Error("Unsupported input type for hopp.response.bytes()") + } + + const { status, statusText, headers, responseTime, body } = + inputs.getResponse() + + const pwResponse = { + status, + body, + headers, + } + + // Create response body object with read-only methods + const responseBody = { + asJSON: () => toJSON(body), + asText: () => toText(body), + bytes: () => toBytes(body), + } + + // Make body methods read-only using a loop + ;["asJSON", "asText", "bytes"].forEach((method) => { + Object.defineProperty(responseBody, method, { + value: responseBody[method], + writable: false, + configurable: false, + }) + }) + Object.freeze(responseBody) + + // Create response object with read-only properties + const hoppResponse = {} + + // Define response properties and their values + const responseProperties = { + statusCode: status, + statusText: statusText, + headers: headers, + responseTime: responseTime, + body: responseBody, + } + + // Apply read-only protection to all response properties + Object.keys(responseProperties).forEach((prop) => { + Object.defineProperty(hoppResponse, prop, { + value: responseProperties[prop], + writable: false, + enumerable: true, + configurable: false, + }) + }) + + // Add utility methods to hoppResponse + Object.defineProperty(hoppResponse, "text", { + value: () => toText(body), + writable: false, + enumerable: true, + configurable: false, + }) + + Object.defineProperty(hoppResponse, "json", { + value: () => toJSON(body), + writable: false, + enumerable: true, + configurable: false, + }) + + Object.defineProperty(hoppResponse, "reason", { + value: () => { + // Return HTTP reason phrase without status code + // statusText might be "200 OK" or just "OK" + const text = statusText || "" + // If it starts with a number (status code), extract just the reason + const match = text.match(/^\d+\s+(.+)$/) + return match ? match[1] : text + }, + writable: false, + enumerable: true, + configurable: false, + }) + + Object.defineProperty(hoppResponse, "dataURI", { + value: () => { + // Convert response to data URI format + try { + const bytes = toBytes(body) + const contentTypeHeader = headers.find( + (h) => h.key.toLowerCase() === "content-type" + ) + const mimeType = contentTypeHeader + ? contentTypeHeader.value.split(";")[0].trim() + : "application/octet-stream" + + // Convert bytes to base64 + let base64 = "" + if (bytes && typeof bytes === "object") { + // Handle both array-like and object representations + const byteArray = Array.isArray(bytes) + ? bytes + : Object.keys(bytes) + .filter((k) => !isNaN(k)) + .map((k) => bytes[k]) + + // Convert to binary string + let binary = "" + for (let i = 0; i < byteArray.length; i++) { + binary += String.fromCharCode(byteArray[i]) + } + + // Convert to base64 using btoa if available + if (typeof btoa !== "undefined") { + base64 = btoa(binary) + } else { + // Fallback: manual base64 encoding + const chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + for (let i = 0; i < binary.length; i += 3) { + const b1 = binary.charCodeAt(i) & 0xff + const b2 = i + 1 < binary.length ? binary.charCodeAt(i + 1) : 0 + const b3 = i + 2 < binary.length ? binary.charCodeAt(i + 2) : 0 + + const enc1 = b1 >> 2 + const enc2 = ((b1 & 3) << 4) | (b2 >> 4) + const enc3 = ((b2 & 15) << 2) | (b3 >> 6) + const enc4 = b3 & 63 + + base64 += chars[enc1] + chars[enc2] + base64 += i + 1 < binary.length ? chars[enc3] : "=" + base64 += i + 2 < binary.length ? chars[enc4] : "=" + } + } + } + + return `data:${mimeType};base64,${base64}` + } catch (_e) { + // Fallback: return text as data URI + try { + const text = toText(body) + return `data:text/plain;charset=utf-8,${encodeURIComponent(text)}` + } catch { + return "data:," + } + } + }, + writable: false, + enumerable: true, + configurable: false, + }) + + Object.defineProperty(hoppResponse, "jsonp", { + value: (callbackName = "callback") => { + // Parse JSONP response by extracting JSON from callback wrapper + try { + const text = toText(body) + + // Match pattern: callbackName({...}) + const regex = new RegExp( + `^\\s*${callbackName}\\s*\\((.*)\\)\\s*;?\\s*$`, + "s" + ) + const match = text.match(regex) + + if (match && match[1]) { + return JSON.parse(match[1]) + } + + // If no callback wrapper, try parsing as JSON directly + return JSON.parse(text) + } catch (e) { + throw new Error(`Failed to parse JSONP response: ${e.message}`) + } + }, + writable: false, + enumerable: true, + configurable: false, + }) + + // Freeze the entire response object + Object.freeze(hoppResponse) + + globalThis.pw = { + env: { + get: (key) => inputs.envGet(key), + getResolve: (key) => inputs.envGetResolve(key), + set: (key, value) => inputs.envSet(key, value), + unset: (key) => inputs.envUnset(key), + resolve: (key) => inputs.envResolve(key), + }, + expect: (expectVal) => { + // Legacy expectation system only (no Chai for backward compatibility) + const isDateInstance = expectVal instanceof Date + + const expectation = { + toBe: (expectedVal) => inputs.expectToBe(expectVal, expectedVal), + toBeLevel2xx: () => inputs.expectToBeLevel2xx(expectVal), + toBeLevel3xx: () => inputs.expectToBeLevel3xx(expectVal), + toBeLevel4xx: () => inputs.expectToBeLevel4xx(expectVal), + toBeLevel5xx: () => inputs.expectToBeLevel5xx(expectVal), + toBeType: (expectedType) => + inputs.expectToBeType(expectVal, expectedType, isDateInstance), + toHaveLength: (expectedLength) => + inputs.expectToHaveLength(expectVal, expectedLength), + toInclude: (needle) => inputs.expectToInclude(expectVal, needle), + } + + Object.defineProperty(expectation, "not", { + get: () => ({ + toBe: (expectedVal) => inputs.expectNotToBe(expectVal, expectedVal), + toBeLevel2xx: () => inputs.expectNotToBeLevel2xx(expectVal), + toBeLevel3xx: () => inputs.expectNotToBeLevel3xx(expectVal), + toBeLevel4xx: () => inputs.expectNotToBeLevel4xx(expectVal), + toBeLevel5xx: () => inputs.expectNotToBeLevel5xx(expectVal), + toBeType: (expectedType) => + inputs.expectNotToBeType(expectVal, expectedType, isDateInstance), + toHaveLength: (expectedLength) => + inputs.expectNotToHaveLength(expectVal, expectedLength), + toInclude: (needle) => inputs.expectNotToInclude(expectVal, needle), + }), + }) + + return expectation + }, + test: (descriptor, testFn) => { + // Register the test immediately (preserves definition order) + inputs.preTest(descriptor) + + // Capture chain state BEFORE executing testFn() to detect pm.sendRequest() usage + const _chainBeforeTest = globalThis.__testExecutionChain + + // Add testFn execution to the chain to ensure correct context + const testPromise = globalThis.__testExecutionChain.then(async () => { + inputs.setCurrentTest(descriptor) + try { + const testResult = testFn() + // If test returns a promise, await it + if (testResult && typeof testResult.then === "function") { + await testResult + } + } catch (error) { + // Record uncaught errors in test functions (e.g., ReferenceError, TypeError) + // This ensures errors like accessing undefined variables are captured + const errorMessage = + error && typeof error === "object" && "message" in error + ? `${error.name || "Error"}: ${error.message}` + : String(error) + inputs.pushExpectResult("error", errorMessage) + } finally { + inputs.clearCurrentTest() + inputs.postTest() + } + }) + + // Update the chain + globalThis.__testExecutionChain = testPromise + + // Notify runner about the test promise so it can be awaited + if (inputs.onTestPromise) { + inputs.onTestPromise(testPromise) + } + }, + response: pwResponse, + } + + // Immutable getters under the `request` namespace + const requestProps = {} + + // Define all properties with unified read-only protection + ;["url", "method", "params", "headers", "body", "auth"].forEach((prop) => { + Object.defineProperty(requestProps, prop, { + enumerable: true, + configurable: false, + get() { + return inputs.getRequestProps()[prop] + }, + set(_value) { + throw new TypeError(`hopp.request.${prop} is read-only`) + }, + }) + }) + + // Special handling for variables property + Object.defineProperty(requestProps, "variables", { + enumerable: true, + configurable: false, + get() { + return Object.freeze({ + get: (key) => inputs.getRequestVariable(key), + }) + }, + set(_value) { + throw new TypeError(`hopp.request.variables is read-only`) + }, + }) + + // Freeze the entire requestProps object for additional protection + Object.freeze(requestProps) + + // Special markers for undefined and null values to preserve them across sandbox boundary + // NOTE: These values MUST match constants/sandbox-markers.ts + // (Cannot import directly as this runs in QuickJS sandbox) + const UNDEFINED_MARKER = "__HOPPSCOTCH_UNDEFINED__" + const NULL_MARKER = "__HOPPSCOTCH_NULL__" + + // Helper function to convert markers back to their original values + const convertMarkerToValue = (value) => { + if (value === UNDEFINED_MARKER) return undefined + if (value === NULL_MARKER) return null + return value + } + + // Helper function for PM namespace setters with marker handling + const pmSetWithMarkers = (key, value, source) => { + if (typeof value === "undefined") { + return inputs.pmEnvSetAny(key, UNDEFINED_MARKER, { source }) + } else if (value === null) { + return inputs.pmEnvSetAny(key, NULL_MARKER, { source }) + } else { + return inputs.pmEnvSetAny(key, value, { source }) + } + } + + // Helper function for PM namespace getters with tracking + const pmGetWithTracking = (getRawFn, trackingSet, key) => { + const value = getRawFn(key) + // Return undefined for missing keys, preserve null for explicit null values + if (value === null && (!trackingSet || !trackingSet.has(key))) { + return undefined + } + return value + } + + globalThis.hopp = { + env: { + get: (key) => { + const value = inputs.envGetResolve(key, { + fallbackToNull: true, + source: "all", + }) + return convertMarkerToValue(value) + }, + getRaw: (key) => { + const value = inputs.envGet(key, { + fallbackToNull: true, + source: "all", + }) + return convertMarkerToValue(value) + }, + set: (key, value) => inputs.envSet(key, value), + delete: (key) => inputs.envUnset(key), + reset: (key) => inputs.envReset(key), + getInitialRaw: (key) => { + const value = inputs.envGetInitialRaw(key) + return convertMarkerToValue(value) + }, + setInitial: (key, value) => inputs.envSetInitial(key, value), + + active: { + get: (key) => { + const value = inputs.envGetResolve(key, { + fallbackToNull: true, + source: "active", + }) + return convertMarkerToValue(value) + }, + getRaw: (key) => { + const value = inputs.envGet(key, { + fallbackToNull: true, + source: "active", + }) + return convertMarkerToValue(value) + }, + set: (key, value) => inputs.envSet(key, value, { source: "active" }), + delete: (key) => inputs.envUnset(key, { source: "active" }), + reset: (key) => inputs.envReset(key, { source: "active" }), + getInitialRaw: (key) => { + const value = inputs.envGetInitialRaw(key, { source: "active" }) + return convertMarkerToValue(value) + }, + setInitial: (key, value) => + inputs.envSetInitial(key, value, { source: "active" }), + }, + + global: { + get: (key) => { + const value = inputs.envGetResolve(key, { + fallbackToNull: true, + source: "global", + }) + return convertMarkerToValue(value) + }, + getRaw: (key) => { + const value = inputs.envGet(key, { + fallbackToNull: true, + source: "global", + }) + return convertMarkerToValue(value) + }, + set: (key, value) => inputs.envSet(key, value, { source: "global" }), + delete: (key) => inputs.envUnset(key, { source: "global" }), + reset: (key) => inputs.envReset(key, { source: "global" }), + getInitialRaw: (key) => { + const value = inputs.envGetInitialRaw(key, { source: "global" }) + return convertMarkerToValue(value) + }, + setInitial: (key, value) => + inputs.envSetInitial(key, value, { source: "global" }), + }, + }, + request: requestProps, + cookies: { + get: (domain, name) => inputs.cookieGet(domain, name), + set: (domain, cookie) => inputs.cookieSet(domain, cookie), + has: (domain, name) => inputs.cookieHas(domain, name), + getAll: (domain) => inputs.cookieGetAll(domain), + delete: (domain, name) => inputs.cookieDelete(domain, name), + clear: (domain) => inputs.cookieClear(domain), + }, + // Expose fetch as hopp.fetch() - save reference before we override global + fetch: typeof fetch !== "undefined" ? fetch : undefined, + expect: Object.assign( + (expectVal) => { + // Use Chai if available + // Note: message parameter is optional and used for custom assertion messages in Postman + // Currently not fully implemented but accepted for API compatibility + if (inputs.chaiEqual) { + return globalThis.__createChaiProxy(expectVal, inputs) + } + + // Fallback to legacy expectation system + const isDateInstance = expectVal instanceof Date + + const expectation = { + toBe: (expectedVal) => inputs.expectToBe(expectVal, expectedVal), + toBeLevel2xx: () => inputs.expectToBeLevel2xx(expectVal), + toBeLevel3xx: () => inputs.expectToBeLevel3xx(expectVal), + toBeLevel4xx: () => inputs.expectToBeLevel4xx(expectVal), + toBeLevel5xx: () => inputs.expectToBeLevel5xx(expectVal), + toBeType: (expectedType) => + inputs.expectToBeType(expectVal, expectedType, isDateInstance), + toHaveLength: (expectedLength) => + inputs.expectToHaveLength(expectVal, expectedLength), + toInclude: (needle) => inputs.expectToInclude(expectVal, needle), + } + + Object.defineProperty(expectation, "not", { + get: () => ({ + toBe: (expectedVal) => inputs.expectNotToBe(expectVal, expectedVal), + toBeLevel2xx: () => inputs.expectNotToBeLevel2xx(expectVal), + toBeLevel3xx: () => inputs.expectNotToBeLevel3xx(expectVal), + toBeLevel4xx: () => inputs.expectNotToBeLevel4xx(expectVal), + toBeLevel5xx: () => inputs.expectNotToBeLevel5xx(expectVal), + toBeType: (expectedType) => + inputs.expectNotToBeType(expectVal, expectedType, isDateInstance), + toHaveLength: (expectedLength) => + inputs.expectNotToHaveLength(expectVal, expectedLength), + toInclude: (needle) => inputs.expectNotToInclude(expectVal, needle), + }), + }) + + return expectation + }, + { + // expect.fail() - Chai compatibility + // Supports multiple signatures: + // expect.fail() + // expect.fail(message) + // expect.fail(actual, expected) + // expect.fail(actual, expected, message) + // expect.fail(actual, expected, message, operator) + fail: (actual, expected, message, operator) => { + if (inputs.chaiFail) { + inputs.chaiFail(actual, expected, message, operator) + } else { + // Fallback: throw an error with the message + const errorMessage = + message || + (actual !== undefined && expected !== undefined + ? `expected ${actual} to ${operator || "equal"} ${expected}` + : "expect.fail()") + throw new Error(errorMessage) + } + }, + } + ), + test: (descriptor, testFn) => { + // Register test immediately in definition order + inputs.preTest(descriptor) + + // Capture chain state BEFORE executing testFn() to detect pm.sendRequest() usage + const _chainBeforeTest = globalThis.__testExecutionChain + + // Add testFn execution to the chain to ensure correct context + const testPromise = globalThis.__testExecutionChain.then(async () => { + inputs.setCurrentTest(descriptor) + try { + const testResult = testFn() + // If test returns a promise, await it + if (testResult && typeof testResult.then === "function") { + await testResult + } + } catch (error) { + // Record uncaught errors in test functions (e.g., ReferenceError, TypeError) + // This ensures errors like accessing undefined variables are captured + const errorMessage = + error && typeof error === "object" && "message" in error + ? `${error.name || "Error"}: ${error.message}` + : String(error) + inputs.pushExpectResult("error", errorMessage) + } finally { + inputs.clearCurrentTest() + inputs.postTest() + } + }) + + // Update the chain + globalThis.__testExecutionChain = testPromise + + // Notify runner about the test promise so it can be awaited + if (inputs.onTestPromise) { + inputs.onTestPromise(testPromise) + } + }, + response: hoppResponse, + } + + // Initialize tracking Sets for PM namespace from incoming envs + // This allows toObject() to work even when variables were set in previous scripts + if (!globalThis.__pmEnvKeys) { + globalThis.__pmEnvKeys = new Set() + } + if (!globalThis.__pmGlobalKeys) { + globalThis.__pmGlobalKeys = new Set() + } + + // Populate tracking Sets from inputs.envs if available + // This allows toObject() to work even when variables were set in previous scripts + if (inputs && inputs.envs) { + // Track all selected/active environment variables + if (inputs.envs.selected && Array.isArray(inputs.envs.selected)) { + inputs.envs.selected.forEach((envVar) => { + if (envVar && envVar.key && envVar.currentValue !== undefined) { + globalThis.__pmEnvKeys.add(envVar.key) + } + }) + } + // Track all global variables + if (inputs.envs.global && Array.isArray(inputs.envs.global)) { + inputs.envs.global.forEach((envVar) => { + if (envVar && envVar.key && envVar.currentValue !== undefined) { + globalThis.__pmGlobalKeys.add(envVar.key) + } + }) + } + } + + // Make global fetch() an alias to hopp.fetch() + // Both fetch() and hopp.fetch() respect interceptor settings + if (typeof fetch !== "undefined") { + // hopp.fetch is already set from inputs, just create the alias + globalThis.fetch = globalThis.hopp.fetch + } else if (typeof globalThis.hopp?.fetch !== "undefined") { + // If fetch wasn't available but hopp.fetch is, create the global alias + globalThis.fetch = globalThis.hopp.fetch + } + + // PM Namespace - Postman Compatibility Layer + globalThis.pm = { + environment: { + name: "active", + get: (key) => { + return pmGetWithTracking( + globalThis.hopp.env.active.getRaw, + globalThis.__pmEnvKeys, + key + ) + }, + set: (key, value) => { + // Track the key for clear() and toObject() + if (!globalThis.__pmEnvKeys) { + globalThis.__pmEnvKeys = new Set() + } + globalThis.__pmEnvKeys.add(key) + return pmSetWithMarkers(key, value, "active") + }, + unset: (key) => { + // Remove from tracking when unset + if (globalThis.__pmEnvKeys) { + globalThis.__pmEnvKeys.delete(key) + } + return globalThis.hopp.env.active.delete(key) + }, + has: (key) => globalThis.hopp.env.active.get(key) !== null, + clear: () => { + // Clear ALL environment variables (not just tracked ones) + // Get all keys from the inputs array + if (typeof inputs !== "undefined" && inputs.getAllSelectedEnvs) { + const selectedEnvs = inputs.getAllSelectedEnvs() + if (Array.isArray(selectedEnvs)) { + selectedEnvs.forEach((envVar) => { + if (envVar && envVar.key) { + globalThis.hopp.env.active.delete(envVar.key) + } + }) + } + } + + // Also clear any tracked keys + if (globalThis.__pmEnvKeys) { + globalThis.__pmEnvKeys.forEach((key) => { + globalThis.hopp.env.active.delete(key) + }) + globalThis.__pmEnvKeys.clear() + } + }, + toObject: () => { + // Return all environment variables as an object + // Read from getAllSelectedEnvs but verify each key still exists in the Map + // (to respect deletions made via unset() or clear()) + const result = {} + + if (typeof inputs !== "undefined" && inputs.getAllSelectedEnvs) { + const selectedEnvs = inputs.getAllSelectedEnvs() + if (Array.isArray(selectedEnvs)) { + selectedEnvs.forEach((envVar) => { + if (envVar && envVar.key) { + // Check if this key still exists in the active environment + // (it might have been deleted via unset() or clear()) + const currentValue = globalThis.hopp.env.active.get(envVar.key) + if (currentValue !== null) { + result[envVar.key] = currentValue + } + } + }) + } + } + + return result + }, + replaceIn: (template) => { + // Replace {{varName}} with actual values + if (typeof template !== "string") return template + return template.replace(/\{\{([^}]+)\}\}/g, (match, varName) => { + const value = globalThis.hopp.env.active.get(varName.trim()) + return value !== null ? value : match + }) + }, + }, + + globals: { + get: (key) => { + return pmGetWithTracking( + globalThis.hopp.env.global.getRaw, + globalThis.__pmGlobalKeys, + key + ) + }, + set: (key, value) => { + // Track the key for clear() and toObject() + if (!globalThis.__pmGlobalKeys) { + globalThis.__pmGlobalKeys = new Set() + } + globalThis.__pmGlobalKeys.add(key) + return pmSetWithMarkers(key, value, "global") + }, + unset: (key) => { + // Remove from tracking when unset + if (globalThis.__pmGlobalKeys) { + globalThis.__pmGlobalKeys.delete(key) + } + return globalThis.hopp.env.global.delete(key) + }, + has: (key) => globalThis.hopp.env.global.get(key) !== null, + clear: () => { + // Clear ALL global variables (not just tracked ones) + // Get all keys from the inputs array + if (typeof inputs !== "undefined" && inputs.getAllGlobalEnvs) { + const globalEnvs = inputs.getAllGlobalEnvs() + if (Array.isArray(globalEnvs)) { + globalEnvs.forEach((envVar) => { + if (envVar && envVar.key) { + globalThis.hopp.env.global.delete(envVar.key) + } + }) + } + } + + // Also clear any tracked keys + if (globalThis.__pmGlobalKeys) { + globalThis.__pmGlobalKeys.forEach((key) => { + globalThis.hopp.env.global.delete(key) + }) + globalThis.__pmGlobalKeys.clear() + } + }, + toObject: () => { + // Return all global variables as an object + // Read from getAllGlobalEnvs but verify each key still exists in the Map + // (to respect deletions made via unset() or clear()) + const result = {} + + if (typeof inputs !== "undefined" && inputs.getAllGlobalEnvs) { + const globalEnvs = inputs.getAllGlobalEnvs() + if (Array.isArray(globalEnvs)) { + globalEnvs.forEach((envVar) => { + if (envVar && envVar.key) { + // Check if this key still exists in the global environment + const currentValue = globalThis.hopp.env.global.get(envVar.key) + if (currentValue !== null) { + result[envVar.key] = currentValue + } + } + }) + } + } + + return result + }, + replaceIn: (template) => { + // Replace {{varName}} with actual values + if (typeof template !== "string") return template + return template.replace(/\{\{([^}]+)\}\}/g, (match, varName) => { + const value = globalThis.hopp.env.global.get(varName.trim()) + return value !== null ? value : match + }) + }, + }, + + variables: { + get: (key) => { + // pm.variables searches both active and global scopes + // Try active first + let value = globalThis.hopp.env.active.getRaw(key) + let isTracked = + globalThis.__pmEnvKeys && globalThis.__pmEnvKeys.has(key) + + // If not found in active, try global + if (value === null && !isTracked) { + value = globalThis.hopp.env.global.getRaw(key) + isTracked = + globalThis.__pmGlobalKeys && globalThis.__pmGlobalKeys.has(key) + } + + // Return undefined for missing keys, preserve null for explicit null values + if (value === null && !isTracked) { + return undefined // Key doesn't exist in either scope + } + return value // Returns null (from NULL_MARKER) or actual value + }, + set: (key, value) => { + return pmSetWithMarkers(key, value, "active") + }, + has: (key) => globalThis.hopp.env.get(key) !== null, + replaceIn: (template) => { + if (typeof template !== "string") return template + return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => { + const value = globalThis.hopp.env.get(key.trim()) + return value !== null ? value : match + }) + }, + toObject: () => { + // Variables scope includes both environment and global with precedence + // Read from arrays but verify keys still exist in Maps (respect deletions) + const result = {} + + // Add global variables first + if (typeof inputs !== "undefined" && inputs.getAllGlobalEnvs) { + const globalEnvs = inputs.getAllGlobalEnvs() + if (Array.isArray(globalEnvs)) { + globalEnvs.forEach((envVar) => { + if (envVar && envVar.key) { + const currentValue = globalThis.hopp.env.global.get(envVar.key) + if (currentValue !== null) { + result[envVar.key] = currentValue + } + } + }) + } + } + + // Then override with environment variables (environment takes precedence) + if (typeof inputs !== "undefined" && inputs.getAllSelectedEnvs) { + const selectedEnvs = inputs.getAllSelectedEnvs() + if (Array.isArray(selectedEnvs)) { + selectedEnvs.forEach((envVar) => { + if (envVar && envVar.key) { + const currentValue = globalThis.hopp.env.active.get(envVar.key) + if (currentValue !== null) { + result[envVar.key] = currentValue + } + } + }) + } + } + + return result + }, + }, + + request: { + // ID and name (read-only, exposed from inputs) + get id() { + return inputs.pmInfoRequestId() + }, + + get name() { + return inputs.pmInfoRequestName() + }, + + // Certificate and proxy (read-only, not accessible in scripts) + // These are configured at app/collection level in Postman, not in scripts + get certificate() { + return withModifiers(modifiers) + }, + + get proxy() { + return withModifiers(modifiers) + }, + + // URL - Read-only with Postman-compatible structure (no setters in post-request) + get url() { + const urlObj = { + // toString reads current URL dynamically + toString: () => globalThis.hopp.request.url, + + _parseUrl: () => { + const urlString = globalThis.hopp.request.url + try { + const parsed = new URL(urlString) + return { + protocol: parsed.protocol.slice(0, -1), + host: parsed.hostname.split("."), + port: + parsed.port || (parsed.protocol === "https:" ? "443" : "80"), + path: parsed.pathname.split("/").filter(Boolean), + queryParams: Array.from(parsed.searchParams.entries()).map( + ([key, value]) => ({ key, value }) + ), + } + } catch { + return { + protocol: "https", + host: [], + port: "443", + path: [], + queryParams: [], + } + } + }, + } + + // Read-only properties (no setters in post-request script) + Object.defineProperty(urlObj, "protocol", { + get: () => urlObj._parseUrl().protocol, + enumerable: true, + }) + + Object.defineProperty(urlObj, "host", { + get: () => urlObj._parseUrl().host, + enumerable: true, + }) + + Object.defineProperty(urlObj, "path", { + get: () => urlObj._parseUrl().path, + enumerable: true, + }) + + // Postman-compatible URL helper methods (read-only in post-request) + urlObj.getHost = () => urlObj._parseUrl().host.join(".") + + urlObj.getPath = (_unresolved = false) => { + const pathArray = urlObj._parseUrl().path + return pathArray.length > 0 ? "/" + pathArray.join("/") : "/" + } + + urlObj.getPathWithQuery = () => { + const parsed = urlObj._parseUrl() + const path = + parsed.path.length > 0 ? "/" + parsed.path.join("/") : "/" + const query = + parsed.queryParams.length > 0 + ? "?" + + parsed.queryParams + .map( + (p) => + `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}` + ) + .join("&") + : "" + return path + query + } + + urlObj.getQueryString = (_options = {}) => { + const params = urlObj._parseUrl().queryParams + if (params.length === 0) return "" + return params + .map( + (p) => + `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}` + ) + .join("&") + } + + urlObj.getRemote = (forcePort = false) => { + const parsed = urlObj._parseUrl() + const host = parsed.host.join(".") + const showPort = + forcePort || (parsed.port !== "443" && parsed.port !== "80") + return showPort ? `${host}:${parsed.port}` : host + } + + // hostname property (string alias for host array) + Object.defineProperty(urlObj, "hostname", { + get: () => urlObj._parseUrl().host.join("."), + enumerable: true, + }) + + // hash property for URL fragments + Object.defineProperty(urlObj, "hash", { + get: () => { + try { + const parsed = new URL(globalThis.hopp.request.url) + return parsed.hash ? parsed.hash.slice(1) : "" + } catch { + return "" + } + }, + enumerable: true, + }) + + Object.defineProperty(urlObj, "query", { + get: () => { + return { + all: () => { + // Parse current URL dynamically + const parsed = urlObj._parseUrl() + const result = {} + + // Handle duplicate keys by converting to arrays + parsed.queryParams.forEach((p) => { + if (Object.prototype.hasOwnProperty.call(result, p.key)) { + if (!Array.isArray(result[p.key])) { + result[p.key] = [result[p.key]] + } + result[p.key].push(p.value) + } else { + result[p.key] = p.value + } + }) + + return result + }, + + // PropertyList methods (read-only in post-request) + get: (key) => { + const params = urlObj._parseUrl().queryParams + const param = params.find((p) => p.key === key) + return param ? param.value : null + }, + + has: (key) => { + const params = urlObj._parseUrl().queryParams + return params.some((p) => p.key === key) + }, + + toObject: () => { + const parsed = urlObj._parseUrl() + const result = {} + + // Handle duplicate keys by converting to arrays + parsed.queryParams.forEach((p) => { + if (Object.prototype.hasOwnProperty.call(result, p.key)) { + if (!Array.isArray(result[p.key])) { + result[p.key] = [result[p.key]] + } + result[p.key].push(p.value) + } else { + result[p.key] = p.value + } + }) + + return result + }, + + each: (callback) => { + const params = urlObj._parseUrl().queryParams + params.forEach(callback) + }, + + map: (callback) => { + const params = urlObj._parseUrl().queryParams + return params.map(callback) + }, + + filter: (callback) => { + const params = urlObj._parseUrl().queryParams + return params.filter(callback) + }, + + count: () => { + return urlObj._parseUrl().queryParams.length + }, + + idx: (index) => { + const params = urlObj._parseUrl().queryParams + return params[index] || null + }, + + // Advanced PropertyList methods (read-only) + find: (rule, context) => { + const params = urlObj._parseUrl().queryParams + if (typeof rule === "function") { + return ( + params.find(context ? rule.bind(context) : rule) || null + ) + } + // String rule: find by key + if (typeof rule === "string") { + return params.find((p) => p.key === rule) || null + } + return null + }, + + indexOf: (item) => { + const params = urlObj._parseUrl().queryParams + if (typeof item === "string") { + // Find by key + return params.findIndex((p) => p.key === item) + } + if (item && typeof item === "object" && item.key) { + // Find by object with key + return params.findIndex((p) => p.key === item.key) + } + return -1 + }, + } + }, + enumerable: true, + }) + + return urlObj + }, + + get method() { + return globalThis.hopp.request.method + }, + + get headers() { + return { + get: (name) => { + const headers = globalThis.hopp.request.headers + const header = headers.find( + (h) => h.key.toLowerCase() === name.toLowerCase() + ) + return header ? header.value : null + }, + has: (name) => { + const headers = globalThis.hopp.request.headers + return headers.some( + (h) => h.key.toLowerCase() === name.toLowerCase() + ) + }, + all: () => { + const result = {} + globalThis.hopp.request.headers.forEach((header) => { + result[header.key] = header.value + }) + return result + }, + + // PropertyList methods (read-only in post-request) + toObject: () => { + const result = {} + globalThis.hopp.request.headers.forEach((header) => { + result[header.key] = header.value + }) + return result + }, + + each: (callback) => { + globalThis.hopp.request.headers.forEach(callback) + }, + + map: (callback) => { + return globalThis.hopp.request.headers.map(callback) + }, + + filter: (callback) => { + return globalThis.hopp.request.headers.filter(callback) + }, + + count: () => { + return globalThis.hopp.request.headers.length + }, + + idx: (index) => { + return globalThis.hopp.request.headers[index] || null + }, + + // Advanced PropertyList methods (read-only) + find: (rule, context) => { + const headers = globalThis.hopp.request.headers + if (typeof rule === "function") { + return headers.find(context ? rule.bind(context) : rule) || null + } + // String rule: find by key (case-insensitive) + if (typeof rule === "string") { + return ( + headers.find( + (h) => h.key.toLowerCase() === rule.toLowerCase() + ) || null + ) + } + return null + }, + + indexOf: (item) => { + const headers = globalThis.hopp.request.headers + if (typeof item === "string") { + // Find by key (case-insensitive) + return headers.findIndex( + (h) => h.key.toLowerCase() === item.toLowerCase() + ) + } + if (item && typeof item === "object" && item.key) { + // Find by object with key (case-insensitive) + return headers.findIndex( + (h) => h.key.toLowerCase() === item.key.toLowerCase() + ) + } + return -1 + }, + } + }, + + get body() { + return globalThis.hopp.request.body + }, + + get auth() { + return globalThis.hopp.request.auth + }, + + // Custom serialization for console.log to match pre-request behavior + // This method is called by faraday-cage's marshalling system + toJSON() { + // Return a plain object with all properties expanded + // This ensures console.log(pm.request) shows the full structure + const urlParsed = this.url._parseUrl() + return { + id: this.id, + name: this.name, + url: { + protocol: urlParsed.protocol, + host: urlParsed.host, + hostname: urlParsed.host.join("."), + port: urlParsed.port, + path: urlParsed.path, + hash: urlParsed.hash || "", + query: this.url.query.all(), + }, + method: this.method, + headers: this.headers.toObject(), + body: this.body, + auth: this.auth, + } + }, + + toString() { + return `Request { id: ${this.id}, name: ${this.name}, method: ${this.method}, url: ${this.url.toString()} }` + }, + + [Symbol.toStringTag]: "Request", + }, + + response: { + get code() { + return globalThis.hopp.response.statusCode + }, + get status() { + // Get status text from response, fallback to standard reason phrase if empty + const statusText = globalThis.hopp.response.statusText + if (statusText && statusText.trim() !== "") { + return statusText + } + + // Map status code to standard HTTP reason phrase (for HTTP/2 or empty responses) + const statusCode = globalThis.hopp.response.statusCode + const standardReasonPhrases = { + 100: "Continue", + 101: "Switching Protocols", + 102: "Processing", + 103: "Early Hints", + 200: "OK", + 201: "Created", + 202: "Accepted", + 203: "Non-Authoritative Information", + 204: "No Content", + 205: "Reset Content", + 206: "Partial Content", + 207: "Multi-Status", + 208: "Already Reported", + 226: "IM Used", + 300: "Multiple Choices", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 305: "Use Proxy", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 407: "Proxy Authentication Required", + 408: "Request Timeout", + 409: "Conflict", + 410: "Gone", + 411: "Length Required", + 412: "Precondition Failed", + 413: "Payload Too Large", + 414: "URI Too Long", + 415: "Unsupported Media Type", + 416: "Range Not Satisfiable", + 417: "Expectation Failed", + 418: "I'm a teapot", + 421: "Misdirected Request", + 422: "Unprocessable Entity", + 423: "Locked", + 424: "Failed Dependency", + 425: "Too Early", + 426: "Upgrade Required", + 428: "Precondition Required", + 429: "Too Many Requests", + 431: "Request Header Fields Too Large", + 451: "Unavailable For Legal Reasons", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", + 505: "HTTP Version Not Supported", + 506: "Variant Also Negotiates", + 507: "Insufficient Storage", + 508: "Loop Detected", + 510: "Not Extended", + 511: "Network Authentication Required", + } + + return standardReasonPhrases[statusCode] || "" + }, + get responseTime() { + return globalThis.hopp.response.responseTime + }, + get responseSize() { + // Calculate response size from body bytes + try { + // Check if response and body exist + if ( + !globalThis.hopp || + !globalThis.hopp.response || + !globalThis.hopp.response.body + ) { + return withModifiers(modifiers) + } + + // Try bytes() first + if (typeof globalThis.hopp.response.body.bytes === "function") { + const bytes = globalThis.hopp.response.body.bytes() + if (bytes && typeof bytes.length === "number") { + return bytes.length + } + } + + // Fallback: calculate from text representation + if (typeof globalThis.hopp.response.body.asText === "function") { + const text = globalThis.hopp.response.body.asText() + if (typeof text === "string") { + // Use TextEncoder if available + if (typeof TextEncoder !== "undefined") { + try { + const encoder = new TextEncoder() + const encoded = encoder.encode(text) + + // Try standard Uint8Array properties first + if ( + encoded && + typeof encoded.length === "number" && + encoded.length > 0 + ) { + return encoded.length + } else if ( + encoded && + typeof encoded.byteLength === "number" && + encoded.byteLength > 0 + ) { + return encoded.byteLength + } else if (encoded && Object.keys(encoded).length > 0) { + // QuickJS represents Uint8Array as object with numeric keys + // Count numeric keys to get byte length + const len = Object.keys(encoded).filter( + (k) => !isNaN(k) + ).length + if (len > 0) { + return len + } + } + } catch (_e) { + // Fall through to manual calculation + } + } + + // Fallback: manual UTF-8 byte length calculation + let byteLength = 0 + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i) + if (code < 0x80) byteLength += 1 + else if (code < 0x800) byteLength += 2 + else if (code < 0x10000) byteLength += 3 + else byteLength += 4 + } + return byteLength + } + } + + return withModifiers(modifiers) + } catch (_e) { + return withModifiers(modifiers) + } + }, + text: () => globalThis.hopp.response.body.asText(), + json: () => globalThis.hopp.response.body.asJSON(), + get stream() { + return globalThis.hopp.response.body.bytes() + }, + reason: inputs.responseReason, + dataURI: inputs.responseDataURI, + jsonp: (callbackName) => inputs.responseJsonp(callbackName), + headers: { + get: (name) => { + const headers = globalThis.hopp.response.headers + const header = headers.find( + (h) => h.key.toLowerCase() === name.toLowerCase() + ) + // NOTE: Postman returns undefined for non-existent headers, not null + return header ? header.value : undefined + }, + has: (name) => { + const headers = globalThis.hopp.response.headers + return headers.some((h) => h.key.toLowerCase() === name.toLowerCase()) + }, + all: () => { + const result = {} + globalThis.hopp.response.headers.forEach((header) => { + result[header.key] = header.value + }) + return result + }, + }, + cookies: { + get: (name) => { + // Parse cookies from Set-Cookie headers + const headers = globalThis.hopp.response.headers + const setCookieHeaders = headers.filter( + (h) => h.key.toLowerCase() === "set-cookie" + ) + + for (const header of setCookieHeaders) { + const cookieStr = header.value + const cookieName = cookieStr.split("=")[0].trim() + if (cookieName === name) { + // Extract cookie value (everything after first =, before first ;) + const parts = cookieStr.split(";") + const [, ...valueRest] = parts[0].split("=") + const value = valueRest.join("=").trim() + + // Return just the value string, matching Postman behavior + return value + } + } + return null + }, + has: (name) => { + const headers = globalThis.hopp.response.headers + const setCookieHeaders = headers.filter( + (h) => h.key.toLowerCase() === "set-cookie" + ) + + for (const header of setCookieHeaders) { + const cookieName = header.value.split("=")[0].trim() + if (cookieName === name) { + return true + } + } + return false + }, + toObject: () => { + const headers = globalThis.hopp.response.headers + const setCookieHeaders = headers.filter( + (h) => h.key.toLowerCase() === "set-cookie" + ) + + const cookies = {} + for (const header of setCookieHeaders) { + const parts = header.value.split(";") + const [nameValue] = parts + const [name, ...valueRest] = nameValue.split("=") + const value = valueRest.join("=").trim() + cookies[name.trim()] = value + } + return cookies + }, + }, + + // Postman BDD-style assertion helpers + to: { + have: { + status: (expectedCode) => { + const actual = globalThis.hopp.response.statusCode + globalThis.hopp.expect(actual).to.equal(expectedCode) + }, + header: (headerName, headerValue) => { + const headers = globalThis.hopp.response.headers + const header = headers.find( + (h) => h.key.toLowerCase() === headerName.toLowerCase() + ) + if (headerValue !== undefined) { + globalThis.hopp + .expect(header ? header.value : null) + .to.equal(headerValue) + } else { + globalThis.hopp.expect(header).to.exist + } + }, + body: (expectedBody) => { + const actualBody = globalThis.hopp.response.body.asText() + globalThis.hopp.expect(actualBody).to.equal(expectedBody) + }, + jsonBody: (keyOrSchema, expectedValue) => { + const jsonData = globalThis.hopp.response.body.asJSON() + if (keyOrSchema === undefined) { + // No arguments: assert response is JSON object + globalThis.hopp.expect(jsonData).to.be.an("object") + } else if (typeof keyOrSchema === "string") { + // Key provided + if (expectedValue !== undefined) { + // Key and value: assert property equals value + globalThis.hopp + .expect(jsonData[keyOrSchema]) + .to.equal(expectedValue) + } else { + // Key only: assert property exists + globalThis.hopp.expect(jsonData).to.have.property(keyOrSchema) + } + } else if (typeof keyOrSchema === "object") { + // Schema validation (basic deep equality check) + globalThis.hopp.expect(jsonData).to.deep.equal(keyOrSchema) + } + }, + responseTime: { + below: (ms) => { + const actual = globalThis.hopp.response.responseTime + globalThis.hopp.expect(actual).to.be.below(ms) + }, + above: (ms) => { + const actual = globalThis.hopp.response.responseTime + globalThis.hopp.expect(actual).to.be.above(ms) + }, + }, + jsonSchema: (schema) => { + // Manual jsonSchema validation with Postman-compatible messages + // Delegates to external AJV-based validator provided via inputs.validateJsonSchema + // This matches Postman's behavior: record assertion but don't throw + const jsonData = globalThis.hopp.response.body.asJSON() + + // Validate schema + if (!inputs.validateJsonSchema) { + throw new Error( + "JSON schema validation is not available in this environment. To use this feature, please enable the experimental scripting sandbox or upgrade to a supported version of Hoppscotch that includes JSON schema validation support." + ) + } + const validation = inputs.validateJsonSchema(jsonData, schema) + + // Record result with Postman-compatible message using helper + if (inputs.pushExpectResult) { + const status = validation.isValid ? "pass" : "fail" + const message = validation.isValid + ? "Response body matches JSON schema" + : validation.errorMessage || "Schema validation failed" + inputs.pushExpectResult(status, message) + } + }, + charset: (expectedCharset) => { + const headers = globalThis.hopp.response.headers + const contentType = headers.find( + (h) => h.key.toLowerCase() === "content-type" + ) + const contentTypeValue = contentType ? contentType.value : "" + const charsetMatch = contentTypeValue.match(/charset=([^;]+)/) + const actualCharset = charsetMatch + ? charsetMatch[1].trim().toLowerCase() + : null + globalThis.hopp + .expect(actualCharset) + .to.equal(expectedCharset.toLowerCase()) + }, + cookie: (cookieName, cookieValue) => { + const headers = globalThis.hopp.response.headers + const setCookieHeaders = headers.filter( + (h) => h.key.toLowerCase() === "set-cookie" + ) + + let found = false + let actualValue = null + for (const header of setCookieHeaders) { + const cookieStr = header.value + const name = cookieStr.split("=")[0].trim() + if (name === cookieName) { + found = true + const [, ...valueRest] = cookieStr.split("=") + actualValue = valueRest.join("=").split(";")[0].trim() + break + } + } + + if (!found) { + // Cookie not found - record expectation failure + globalThis.hopp.expect(found, `Cookie '${cookieName}' not found`) + .to.be.true + } else if (cookieValue !== undefined) { + // Cookie found and value specified - check value + globalThis.hopp.expect(actualValue).to.equal(cookieValue) + } else { + // Cookie found and no value specified - just assert it exists + globalThis.hopp.expect(found).to.be.true + } + }, + jsonPath: (path, expectedValue) => { + // Basic JSONPath implementation (supports simple paths) + const jsonData = globalThis.hopp.response.body.asJSON() + + const evaluatePath = (data, path) => { + // Remove leading $ if present + const cleanPath = path.startsWith("$") ? path.slice(1) : path + if (!cleanPath || cleanPath === ".") { + return { success: true, value: data } + } + + // Split by dots and brackets, handling array indices + const parts = cleanPath + .replace(/\[(\d+)\]/g, ".$1") + .split(".") + .filter((p) => p) + + let current = data + for (const part of parts) { + if (current === null || current === undefined) { + return { + success: false, + error: `Cannot access property '${part}' of ${current}`, + } + } + if (Array.isArray(current)) { + const index = parseInt(part) + if (isNaN(index) || index >= current.length) { + return { + success: false, + error: `Array index '${part}' out of bounds`, + } + } + current = current[index] + } else if (typeof current === "object") { + if (!(part in current)) { + return { + success: false, + error: `Property '${part}' not found`, + } + } + current = current[part] + } else { + return { + success: false, + error: `Cannot access property '${part}' of ${typeof current}`, + } + } + } + return { success: true, value: current } + } + + const result = evaluatePath(jsonData, path) + + // Postman behavior: jsonPath failures record assertions but don't throw + // Match the same pattern as jsonSchema + if (!result.success) { + // Path evaluation failed - record failed assertion + if (inputs.pushExpectResult) { + inputs.pushExpectResult("fail", result.error) + } + return + } + + if (expectedValue !== undefined) { + globalThis.hopp.expect(result.value).to.deep.equal(expectedValue) + } else { + globalThis.hopp.expect(result.value).to.exist + } + }, + }, + be: { + // Status code convenience methods + ok: () => { + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code >= 200 && code < 300).to.be.true + }, + success: () => { + // Alias for ok - validates 2xx status codes + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code >= 200 && code < 300).to.be.true + }, + accepted: () => { + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code).to.equal(202) + }, + badRequest: () => { + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code).to.equal(400) + }, + unauthorized: () => { + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code).to.equal(401) + }, + forbidden: () => { + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code).to.equal(403) + }, + notFound: () => { + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code).to.equal(404) + }, + rateLimited: () => { + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code).to.equal(429) + }, + clientError: () => { + // Validates 4xx status codes + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code >= 400 && code < 500).to.be.true + }, + serverError: () => { + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code >= 500 && code < 600).to.be.true + }, + // Content type checks + json: () => { + const headers = globalThis.hopp.response.headers + const contentType = headers.find( + (h) => h.key.toLowerCase() === "content-type" + ) + globalThis.hopp + .expect(contentType ? contentType.value : "") + .to.include("application/json") + }, + html: () => { + const headers = globalThis.hopp.response.headers + const contentType = headers.find( + (h) => h.key.toLowerCase() === "content-type" + ) + globalThis.hopp + .expect(contentType ? contentType.value : "") + .to.include("text/html") + }, + xml: () => { + const headers = globalThis.hopp.response.headers + const contentType = headers.find( + (h) => h.key.toLowerCase() === "content-type" + ) + const ct = contentType ? contentType.value : "" + globalThis.hopp.expect( + ct.includes("application/xml") || ct.includes("text/xml") + ).to.be.true + }, + text: () => { + const headers = globalThis.hopp.response.headers + const contentType = headers.find( + (h) => h.key.toLowerCase() === "content-type" + ) + globalThis.hopp + .expect(contentType ? contentType.value : "") + .to.include("text/plain") + }, + }, + }, + }, + + cookies: { + get: (_name) => { + throw new Error( + "pm.cookies.get() needs domain information - use hopp.cookies instead" + ) + }, + set: (_name, _value, _options) => { + throw new Error( + "pm.cookies.set() needs domain information - use hopp.cookies instead" + ) + }, + jar: () => { + throw new Error("pm.cookies.jar() not yet implemented") + }, + }, + + test: (name, fn) => globalThis.hopp.test(name, fn), + expect: Object.assign( + (value, message) => globalThis.hopp.expect(value, message), + { + // pm.expect.fail() - Postman compatibility + fail: globalThis.hopp.expect.fail, + } + ), + + // Script context information + info: { + eventName: "test", // Postman uses "test" for test scripts, not "post-request" + get requestName() { + return inputs.pmInfoRequestName() + }, + get requestId() { + return inputs.pmInfoRequestId() + }, + // Unsupported Collection Runner features + get iteration() { + throw new Error( + "pm.info.iteration is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + get iterationCount() { + throw new Error( + "pm.info.iterationCount is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + }, + + // pm.sendRequest() - Postman-compatible fetch wrapper + sendRequest: (urlOrRequest, callback) => { + // Check if fetch is available + if (typeof globalThis.hopp?.fetch === "undefined") { + const error = new Error( + "pm.sendRequest() requires the fetch API to be available in the scripting environment (usually provided by enabling the scripting sandbox)." + ) + callback(error, null) + return + } + + // Parse arguments (Postman supports both string and object) + let url, options + + if (typeof urlOrRequest === "string") { + url = urlOrRequest + options = {} + } else { + // Object format: { url, method, header, body } + url = urlOrRequest.url + + // Parse headers - support both array [{key, value}] and object {key: value} formats + let headers = {} + if (urlOrRequest.header) { + if (Array.isArray(urlOrRequest.header)) { + // Array format: [{ key: 'Content-Type', value: 'application/json' }] + headers = Object.fromEntries( + urlOrRequest.header.map((h) => [h.key, h.value]) + ) + } else if (typeof urlOrRequest.header === "object") { + // Plain object format: { 'Content-Type': 'application/json' } + headers = urlOrRequest.header + } + } + + options = { + method: urlOrRequest.method || "GET", + headers, + } + + // Handle body based on mode + if (urlOrRequest.body) { + if (urlOrRequest.body.mode === "raw") { + options.body = urlOrRequest.body.raw + } else if (urlOrRequest.body.mode === "urlencoded") { + const params = new URLSearchParams() + urlOrRequest.body.urlencoded?.forEach((pair) => { + params.append(pair.key, pair.value) + }) + options.body = params.toString() + options.headers["Content-Type"] = + "application/x-www-form-urlencoded" + } else if (urlOrRequest.body.mode === "formdata") { + // LIMITATION: FormData is not available in QuickJS sandbox (used in both CLI and web). + // Converting to URLSearchParams as fallback, which has limitations: + // - File uploads are NOT supported (only key-value pairs) + // - Changes Content-Type from multipart/form-data to application/x-www-form-urlencoded + // - May cause issues with servers expecting multipart data + // This is a known limitation of the scripting environment. + const params = new URLSearchParams() + urlOrRequest.body.formdata?.forEach((pair) => { + // Note: Only text values are supported in URLSearchParams conversion + // File values will be converted to "[object File]" string which is not useful + params.append(pair.key, pair.value) + }) + options.body = params.toString() + options.headers["Content-Type"] = + "application/x-www-form-urlencoded" + } + } + } + + // Capture response data when fetch completes, then execute callback synchronously + // within the test execution chain before QuickJS scope is disposed. + // This ensures QuickJS handles (pm.expect, etc.) remain valid during callback execution. + + const callbackData = { + callback, + executed: false, + error: null, + response: null, + } + + // Track request start time for responseTime calculation + const startTime = Date.now() + + const fetchPromise = globalThis.hopp + .fetch(url, options) + .then((response) => { + // Capture response metadata + const statusCode = response.status + const statusMessage = response.statusText + + // Handle Set-Cookie headers specially as they can appear multiple times + // The Fetch API's headers.entries() may not properly enumerate multiple Set-Cookie headers + // Use getSetCookie() if available (modern Fetch API), otherwise fall back to entries() + let headerEntries = [] + if ( + response.headers && + typeof response.headers.getSetCookie === "function" + ) { + // Modern Fetch API - getSetCookie() returns array of Set-Cookie values + const setCookies = response.headers.getSetCookie() + const otherHeaders = Array.from(response.headers.entries()) + .filter(([k]) => k.toLowerCase() !== "set-cookie") + .map(([k, v]) => ({ key: k, value: v })) + + // Add each Set-Cookie as a separate header entry + headerEntries = [ + ...otherHeaders, + ...setCookies.map((value) => ({ key: "Set-Cookie", value })), + ] + } else { + // Fallback: use entries() for all headers + headerEntries = Array.from(response.headers.entries()).map( + ([k, v]) => ({ key: k, value: v }) + ) + } + + // Get body text + return response.text().then((bodyText) => { + // Calculate response time and size + const responseTime = Date.now() - startTime + const responseSize = new Blob([bodyText]).size + + // For Postman compatibility and test expectations, expose raw header entries array. + // Attach helper methods (get/has) directly onto the array to mimic Postman SDK convenience APIs + // Store response data - DON'T execute callback yet + const headersArray = headerEntries.slice() + // Augment array with helper methods while preserving Array semantics + try { + Object.defineProperty(headersArray, "has", { + value: (name) => { + const lowerName = String(name).toLowerCase() + return headerEntries.some( + (h) => h.key.toLowerCase() === lowerName + ) + }, + enumerable: false, + configurable: true, + }) + Object.defineProperty(headersArray, "get", { + value: (name) => { + const lowerName = String(name).toLowerCase() + const header = headerEntries.find( + (h) => h.key.toLowerCase() === lowerName + ) + return header ? header.value : null + }, + enumerable: false, + configurable: true, + }) + } catch (_e) { + // Non-fatal; fallback is plain array + } + callbackData.response = { + code: statusCode, + status: statusMessage, + headers: headersArray, // Array with non-enum helpers; Array.isArray() still true + body: bodyText, + responseTime: responseTime, + responseSize: responseSize, + text: () => bodyText, + json: () => { + try { + return JSON.parse(bodyText) + } catch (_err) { + return null + } + }, + // Parse cookies from Set-Cookie headers (matching pm.response.cookies implementation) + cookies: { + get: (name) => { + // Parse cookies from Set-Cookie headers in the response + const setCookieHeaders = headerEntries.filter( + (h) => h.key.toLowerCase() === "set-cookie" + ) + + for (const header of setCookieHeaders) { + const cookieStr = header.value + const cookieName = cookieStr.split("=")[0].trim() + if (cookieName === name) { + // Extract cookie value (everything after first =, before first ;) + const parts = cookieStr.split(";") + const [, ...valueRest] = parts[0].split("=") + const value = valueRest.join("=").trim() + return value + } + } + return null + }, + has: (name) => { + const setCookieHeaders = headerEntries.filter( + (h) => h.key.toLowerCase() === "set-cookie" + ) + + for (const header of setCookieHeaders) { + const cookieName = header.value.split("=")[0].trim() + if (cookieName === name) { + return true + } + } + return false + }, + toObject: () => { + const setCookieHeaders = headerEntries.filter( + (h) => h.key.toLowerCase() === "set-cookie" + ) + + const cookies = {} + for (const header of setCookieHeaders) { + const parts = header.value.split(";") + const [nameValue] = parts + const [name, ...valueRest] = nameValue.split("=") + const value = valueRest.join("=").trim() + cookies[name.trim()] = value + } + return cookies + }, + }, + } + }) + }) + .catch((err) => { + // Store error - DON'T execute callback yet + callbackData.error = err + }) + + // Add to test execution chain with callback execution + // IMPORTANT: Test context must be maintained when callback executes + // so that expect() calls inside the callback record results to the correct test + if (globalThis.__testExecutionChain) { + // Capture current test descriptor NAME when pm.sendRequest is called + // getCurrentTest() now returns the descriptor name (string) directly + const currentTestName = inputs.getCurrentTest + ? inputs.getCurrentTest() + : null + + globalThis.__testExecutionChain = globalThis.__testExecutionChain.then( + () => { + // Wait for fetch to complete + return fetchPromise.then(() => { + // Restore test context before executing callback + // setCurrentTest() expects the descriptor name (string) + if (currentTestName && inputs.setCurrentTest) { + inputs.setCurrentTest(currentTestName) + } + + // Now execute the callback synchronously (QuickJS handles still valid) + if (!callbackData.executed) { + callbackData.executed = true + try { + if (callbackData.error) { + callbackData.callback(callbackData.error, null) + } else { + callbackData.callback(null, callbackData.response) + } + } finally { + // Test context will be cleared by the test() wrapper after all chains complete + // Don't clear it here as multiple pm.sendRequest calls in same test need it + } + } + }) + } + ) + } + }, + + // Postman Vault (unsupported) + vault: { + get: () => { + throw new Error( + "pm.vault.get() is not supported in Hoppscotch (Postman Vault feature)" + ) + }, + set: () => { + throw new Error( + "pm.vault.set() is not supported in Hoppscotch (Postman Vault feature)" + ) + }, + unset: () => { + throw new Error( + "pm.vault.unset() is not supported in Hoppscotch (Postman Vault feature)" + ) + }, + }, + + // Postman Visualizer (unsupported) + visualizer: { + set: () => { + throw new Error( + "pm.visualizer.set() is not supported in Hoppscotch (Postman Visualizer feature)" + ) + }, + clear: () => { + throw new Error( + "pm.visualizer.clear() is not supported in Hoppscotch (Postman Visualizer feature)" + ) + }, + }, + + // Iteration data (unsupported) + iterationData: { + get: () => { + throw new Error( + "pm.iterationData.get() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + set: () => { + throw new Error( + "pm.iterationData.set() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + unset: () => { + throw new Error( + "pm.iterationData.unset() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + has: () => { + throw new Error( + "pm.iterationData.has() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + toObject: () => { + throw new Error( + "pm.iterationData.toObject() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + toJSON: () => { + throw new Error( + "pm.iterationData.toJSON() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + }, + + // Collection variables (unsupported) + collectionVariables: { + get: () => { + throw new Error( + "pm.collectionVariables.get() is not supported in Hoppscotch (use environment or request variables instead)" + ) + }, + set: () => { + throw new Error( + "pm.collectionVariables.set() is not supported in Hoppscotch (use environment or request variables instead)" + ) + }, + unset: () => { + throw new Error( + "pm.collectionVariables.unset() is not supported in Hoppscotch (use environment or request variables instead)" + ) + }, + has: () => { + throw new Error( + "pm.collectionVariables.has() is not supported in Hoppscotch (use environment or request variables instead)" + ) + }, + clear: () => { + throw new Error( + "pm.collectionVariables.clear() is not supported in Hoppscotch (use environment or request variables instead)" + ) + }, + toObject: () => { + throw new Error( + "pm.collectionVariables.toObject() is not supported in Hoppscotch (use environment or request variables instead)" + ) + }, + replaceIn: () => { + throw new Error( + "pm.collectionVariables.replaceIn() is not supported in Hoppscotch (use environment or request variables instead)" + ) + }, + }, + + // Execution control + execution: { + location: (() => { + const location = ["Hoppscotch"] + Object.defineProperty(location, "current", { + value: "Hoppscotch", + writable: false, + enumerable: true, + }) + Object.freeze(location) + return location + })(), + setNextRequest: () => { + throw new Error( + "pm.execution.setNextRequest() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + skipRequest: () => { + throw new Error( + "pm.execution.skipRequest() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + runRequest: () => { + throw new Error( + "pm.execution.runRequest() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + }, + + // Package imports (unsupported) + require: (packageName) => { + throw new Error( + `pm.require('${packageName}') is not supported in Hoppscotch (Package Library feature)` + ) + }, + } + + // Return the test execution chain promise so the host can await it + // This ensures all tests complete before results are captured + return globalThis.__testExecutionChain +} diff --git a/packages/hoppscotch-js-sandbox/src/bootstrap-code/pre-request.js b/packages/hoppscotch-js-sandbox/src/bootstrap-code/pre-request.js new file mode 100644 index 0000000..4f41249 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/bootstrap-code/pre-request.js @@ -0,0 +1,1638 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +;(inputs) => { + // Keep strict mode scoped to this IIFE to avoid leaking strictness to concatenated/bootstrapped code + "use strict" + + // Exposes a host reporter for the generated experimental `try/catch` + // wrapper in combineScriptsWithIIFE. Locked down (non-writable, + // non-configurable) so user scripts cannot delete or overwrite it to + // suppress error reporting. faraday-cage's `runCode` creates a fresh + // QuickJS runtime per call, so re-definition across calls is not a + // concern — the property lives only for the current run's VM. + Object.defineProperty(globalThis, "__hoppReportScriptExecutionError", { + value: (error) => { + const safe = error && typeof error === "object" ? error : {} + inputs.setScriptExecutionError({ + name: typeof safe.name === "string" ? safe.name : "", + message: + typeof safe.message === "string" ? safe.message : String(error), + stack: typeof safe.stack === "string" ? safe.stack : "", + }) + }, + enumerable: false, + configurable: false, + writable: false, + }) + + globalThis.pw = { + env: { + get: (key) => convertMarkerToValue(inputs.envGet(key)), + getResolve: (key) => convertMarkerToValue(inputs.envGetResolve(key)), + set: (key, value) => inputs.envSet(key, value), + unset: (key) => inputs.envUnset(key), + resolve: (key) => inputs.envResolve(key), + }, + } + + const requestProps = { + // Setter methods + setUrl: (url) => inputs.setRequestUrl(url), + setMethod: (method) => inputs.setRequestMethod(method), + setHeader: (name, value) => inputs.setRequestHeader(name, value), + setHeaders: (headers) => inputs.setRequestHeaders(headers), + removeHeader: (key) => inputs.removeRequestHeader(key), + setParam: (name, value) => inputs.setRequestParam(name, value), + setParams: (params) => inputs.setRequestParams(params), + removeParam: (key) => inputs.removeRequestParam(key), + setBody: (body) => inputs.setRequestBody(body), + setAuth: (auth) => inputs.setRequestAuth(auth), + + // Request variables + variables: { + get: (key) => inputs.getRequestVariable(key), + set: (key, value) => inputs.setRequestVariable(key, value), + }, + } + + // Define all properties with unified read-only protection + ;["url", "method", "params", "headers", "body", "auth"].forEach((prop) => { + Object.defineProperty(requestProps, prop, { + enumerable: true, + configurable: false, + get() { + const currentValues = inputs.getRequestProps() + return currentValues[prop] + }, + set(_value) { + throw new TypeError(`hopp.request.${prop} is read-only`) + }, + }) + }) + + // Freeze the entire requestProps object for additional protection + Object.freeze(requestProps) + + // Special markers for undefined and null values to preserve them across sandbox boundary + // NOTE: These values MUST match constants/sandbox-markers.ts + // (Cannot import directly as this runs in QuickJS sandbox) + const UNDEFINED_MARKER = "__HOPPSCOTCH_UNDEFINED__" + const NULL_MARKER = "__HOPPSCOTCH_NULL__" + + // Helper function to convert markers back to their original values + const convertMarkerToValue = (value) => { + if (value === UNDEFINED_MARKER) return undefined + if (value === NULL_MARKER) return null + return value + } + + globalThis.hopp = { + env: { + get: (key) => { + return convertMarkerToValue( + inputs.envGetResolve(key, { + fallbackToNull: true, + source: "all", + }) + ) + }, + getRaw: (key) => { + return convertMarkerToValue( + inputs.envGet(key, { + fallbackToNull: true, + source: "all", + }) + ) + }, + set: (key, value) => inputs.envSet(key, value), + delete: (key) => inputs.envUnset(key), + reset: (key) => inputs.envReset(key), + getInitialRaw: (key) => { + return convertMarkerToValue(inputs.envGetInitialRaw(key)) + }, + setInitial: (key, value) => inputs.envSetInitial(key, value), + + active: { + get: (key) => { + return convertMarkerToValue( + inputs.envGetResolve(key, { + fallbackToNull: true, + source: "active", + }) + ) + }, + getRaw: (key) => { + return convertMarkerToValue( + inputs.envGet(key, { + fallbackToNull: true, + source: "active", + }) + ) + }, + set: (key, value) => inputs.envSet(key, value, { source: "active" }), + delete: (key) => inputs.envUnset(key, { source: "active" }), + reset: (key) => inputs.envReset(key, { source: "active" }), + getInitialRaw: (key) => { + return convertMarkerToValue( + inputs.envGetInitialRaw(key, { source: "active" }) + ) + }, + setInitial: (key, value) => + inputs.envSetInitial(key, value, { source: "active" }), + }, + + global: { + get: (key) => { + return convertMarkerToValue( + inputs.envGetResolve(key, { + fallbackToNull: true, + source: "global", + }) + ) + }, + getRaw: (key) => { + return convertMarkerToValue( + inputs.envGet(key, { + fallbackToNull: true, + source: "global", + }) + ) + }, + set: (key, value) => inputs.envSet(key, value, { source: "global" }), + delete: (key) => inputs.envUnset(key, { source: "global" }), + reset: (key) => inputs.envReset(key, { source: "global" }), + getInitialRaw: (key) => { + return convertMarkerToValue( + inputs.envGetInitialRaw(key, { source: "global" }) + ) + }, + setInitial: (key, value) => + inputs.envSetInitial(key, value, { source: "global" }), + }, + }, + request: requestProps, + cookies: { + get: (domain, name) => inputs.cookieGet(domain, name), + set: (domain, cookie) => inputs.cookieSet(domain, cookie), + has: (domain, name) => inputs.cookieHas(domain, name), + getAll: (domain) => inputs.cookieGetAll(domain), + delete: (domain, name) => inputs.cookieDelete(domain, name), + clear: (domain) => inputs.cookieClear(domain), + }, + // Expose fetch as hopp.fetch() for explicit access + // Note: This exposes the fetch implementation provided by the host environment via hoppFetchHook + // (injected in cage.ts during sandbox initialization), not the native browser fetch. + // This allows requests to respect interceptor settings. + fetch: fetch, + } + + // Make global fetch() an alias to hopp.fetch() + // Both fetch() and hopp.fetch() respect interceptor settings + if (typeof fetch !== "undefined") { + globalThis.fetch = globalThis.hopp.fetch + } + + // PM Namespace - Postman Compatibility Layer + globalThis.pm = { + environment: { + get: (key) => { + const value = globalThis.hopp.env.active.get(key) + // Postman returns undefined for missing keys, not null + return value === null ? undefined : value + }, + set: (key, value) => { + // PM namespace preserves all types - use pmEnvSetAny directly + if (typeof value === "undefined") { + return inputs.pmEnvSetAny(key, UNDEFINED_MARKER, { source: "active" }) + } else if (value === null) { + return inputs.pmEnvSetAny(key, NULL_MARKER, { source: "active" }) + } else { + return inputs.pmEnvSetAny(key, value, { source: "active" }) + } + }, + unset: (key) => globalThis.hopp.env.active.delete(key), + has: (key) => globalThis.hopp.env.active.get(key) !== null, + clear: () => { + // Get all active environment variables and delete them + const envVars = inputs.getAllSelectedEnvs() + envVars.forEach((envVar) => { + globalThis.hopp.env.active.delete(envVar.key) + }) + }, + toObject: () => { + // Get all active environment variables as an object + const envVars = inputs.getAllSelectedEnvs() + const result = {} + envVars.forEach((envVar) => { + const value = globalThis.hopp.env.active.get(envVar.key) + if (value !== null) { + result[envVar.key] = value + } + }) + return result + }, + }, + + globals: { + get: (key) => { + const value = globalThis.hopp.env.global.get(key) + // Postman returns undefined for missing keys, not null + return value === null ? undefined : value + }, + set: (key, value) => { + // PM namespace preserves all types - use pmEnvSetAny directly + if (typeof value === "undefined") { + return inputs.pmEnvSetAny(key, UNDEFINED_MARKER, { source: "global" }) + } else if (value === null) { + return inputs.pmEnvSetAny(key, NULL_MARKER, { source: "global" }) + } else { + return inputs.pmEnvSetAny(key, value, { source: "global" }) + } + }, + unset: (key) => globalThis.hopp.env.global.delete(key), + has: (key) => globalThis.hopp.env.global.get(key) !== null, + clear: () => { + // Get all global environment variables and delete them + const envVars = inputs.getAllGlobalEnvs() + envVars.forEach((envVar) => { + globalThis.hopp.env.global.delete(envVar.key) + }) + }, + toObject: () => { + // Get all global environment variables as an object + const envVars = inputs.getAllGlobalEnvs() + const result = {} + envVars.forEach((envVar) => { + const value = globalThis.hopp.env.global.get(envVar.key) + if (value !== null) { + result[envVar.key] = value + } + }) + return result + }, + }, + + variables: { + get: (key) => { + const value = globalThis.hopp.env.get(key) + // Postman returns undefined for missing keys, not null + return value === null ? undefined : value + }, + set: (key, value) => { + // PM namespace preserves all types - use pmEnvSetAny directly + // variables.set uses active scope + if (typeof value === "undefined") { + return inputs.pmEnvSetAny(key, UNDEFINED_MARKER, { source: "active" }) + } else if (value === null) { + return inputs.pmEnvSetAny(key, NULL_MARKER, { source: "active" }) + } else { + return inputs.pmEnvSetAny(key, value, { source: "active" }) + } + }, + has: (key) => globalThis.hopp.env.get(key) !== null, + replaceIn: (template) => { + if (typeof template !== "string") return template + return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => { + const value = globalThis.hopp.env.get(key.trim()) + return value !== null ? value : match + }) + }, + }, + + request: { + // ID and name (read-only, exposed from inputs) + get id() { + return inputs.pmInfoRequestId() + }, + + get name() { + return inputs.pmInfoRequestName() + }, + + // Certificate and proxy (read-only, not accessible in scripts) + // These are configured at app/collection level in Postman, not in scripts + get certificate() { + return undefined + }, + + get proxy() { + return undefined + }, + + // URL - Mutable with Postman-compatible structure + get url() { + // Return Postman-compatible URL object + const urlObj = { + // toString reads current URL dynamically (not cached) + toString: () => globalThis.hopp.request.url, + + // Helper to parse URL into components + _parseUrl: () => { + const urlString = globalThis.hopp.request.url + try { + const parsed = new URL(urlString) + + // Get query params from URL + const urlParams = Array.from(parsed.searchParams.entries()).map( + ([key, value]) => ({ key, value }) + ) + + // Only merge from hopp.request.params if URL has no query params + // This supports imported collections while allowing mutations to work + let finalParams = urlParams + if (urlParams.length === 0) { + // No params in URL - check hopp.request.params (for imported collections) + const requestParams = globalThis.hopp.request.params || [] + const activeRequestParams = requestParams + .filter((p) => p.active !== false) + .map((p) => ({ key: p.key, value: p.value })) + finalParams = activeRequestParams + } + + return { + protocol: parsed.protocol.slice(0, -1), // Remove trailing : + host: parsed.hostname.split("."), + port: + parsed.port || (parsed.protocol === "https:" ? "443" : "80"), + path: parsed.pathname.split("/").filter(Boolean), + queryParams: finalParams, + hash: parsed.hash ? parsed.hash.slice(1) : "", // Remove leading # + } + } catch { + // Fallback: try to get params from hopp.request.params + const requestParams = globalThis.hopp.request?.params || [] + const activeParams = requestParams + .filter((p) => p.active !== false) + .map((p) => ({ key: p.key, value: p.value })) + + return { + protocol: "https", + host: [], + port: "443", + path: [], + queryParams: activeParams, + } + } + }, + + // Helper to rebuild URL from components + _rebuildUrl: (components) => { + const protocol = components.protocol || "https" + const host = Array.isArray(components.host) + ? components.host.join(".") + : components.host + const port = + components.port && + components.port !== "443" && + components.port !== "80" + ? `:${components.port}` + : "" + const path = Array.isArray(components.path) + ? "/" + components.path.join("/") + : components.path + const query = + components.queryParams && components.queryParams.length > 0 + ? "?" + + components.queryParams + .map( + (p) => + `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}` + ) + .join("&") + : "" + const hash = components.hash ? `#${components.hash}` : "" + return `${protocol}://${host}${port}${path}${query}${hash}` + }, + + // Postman-compatible URL methods + getHost: () => urlObj._parseUrl().host.join("."), + + getPath: (_unresolved = false) => { + const pathArray = urlObj._parseUrl().path + return pathArray.length > 0 ? "/" + pathArray.join("/") : "/" + }, + + getPathWithQuery: () => { + const parsed = urlObj._parseUrl() + const path = + parsed.path.length > 0 ? "/" + parsed.path.join("/") : "/" + const query = + parsed.queryParams.length > 0 + ? "?" + + parsed.queryParams + .map( + (p) => + `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}` + ) + .join("&") + : "" + return path + query + }, + + getQueryString: (_options = {}) => { + const params = urlObj._parseUrl().queryParams + if (params.length === 0) return "" + return params + .map( + (p) => + `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}` + ) + .join("&") + }, + + getRemote: (forcePort = false) => { + const parsed = urlObj._parseUrl() + const host = parsed.host.join(".") + const showPort = + forcePort || (parsed.port !== "443" && parsed.port !== "80") + return showPort ? `${host}:${parsed.port}` : host + }, + + update: (urlString) => { + if (typeof urlString === "string") { + globalThis.hopp.request.setUrl(urlString) + } else if ( + urlString && + typeof urlString === "object" && + typeof urlString.toString === "function" + ) { + globalThis.hopp.request.setUrl(urlString.toString()) + } else { + throw new Error( + "URL update requires a string or object with toString() method" + ) + } + }, + + addQueryParams: (params) => { + if (!Array.isArray(params)) { + throw new Error("addQueryParams requires an array of parameters") + } + const currentParsed = urlObj._parseUrl() + params.forEach((param) => { + if (param && param.key) { + currentParsed.queryParams.push({ + key: param.key, + value: param.value || "", + }) + } + }) + globalThis.hopp.request.setUrl(urlObj._rebuildUrl(currentParsed)) + }, + + removeQueryParams: (params) => { + if (!Array.isArray(params) && typeof params !== "string") { + throw new Error( + "removeQueryParams requires an array of param names or a single param name" + ) + } + const keysToRemove = Array.isArray(params) ? params : [params] + const currentParsed = urlObj._parseUrl() + const updatedParams = currentParsed.queryParams.filter( + (p) => !keysToRemove.includes(p.key) + ) + currentParsed.queryParams = updatedParams + globalThis.hopp.request.setUrl(urlObj._rebuildUrl(currentParsed)) + // Also update the params array to ensure consistency + globalThis.hopp.request.setParams( + updatedParams.map((p) => ({ + key: p.key, + value: p.value, + active: true, + description: "", + })) + ) + }, + } + + // Lazy-loaded mutable properties + Object.defineProperty(urlObj, "protocol", { + get: () => urlObj._parseUrl().protocol, + set: (value) => { + const parsed = urlObj._parseUrl() + parsed.protocol = value + globalThis.hopp.request.setUrl(urlObj._rebuildUrl(parsed)) + }, + enumerable: true, + }) + + Object.defineProperty(urlObj, "host", { + get: () => urlObj._parseUrl().host, + set: (value) => { + const parsed = urlObj._parseUrl() + parsed.host = Array.isArray(value) ? value : value.split(".") + globalThis.hopp.request.setUrl(urlObj._rebuildUrl(parsed)) + }, + enumerable: true, + }) + + // hostname is an alias for host as a string + Object.defineProperty(urlObj, "hostname", { + get: () => urlObj._parseUrl().host.join("."), + set: (value) => { + const parsed = urlObj._parseUrl() + parsed.host = String(value).split(".") + globalThis.hopp.request.setUrl(urlObj._rebuildUrl(parsed)) + }, + enumerable: true, + }) + + Object.defineProperty(urlObj, "port", { + get: () => urlObj._parseUrl().port, + set: (value) => { + const parsed = urlObj._parseUrl() + parsed.port = String(value) + globalThis.hopp.request.setUrl(urlObj._rebuildUrl(parsed)) + }, + enumerable: true, + }) + + Object.defineProperty(urlObj, "path", { + get: () => urlObj._parseUrl().path, + set: (value) => { + const parsed = urlObj._parseUrl() + parsed.path = Array.isArray(value) + ? value + : value.split("/").filter(Boolean) + globalThis.hopp.request.setUrl(urlObj._rebuildUrl(parsed)) + }, + enumerable: true, + }) + + // hash property for URL fragments + Object.defineProperty(urlObj, "hash", { + get: () => { + try { + const parsed = new URL(globalThis.hopp.request.url) + return parsed.hash ? parsed.hash.slice(1) : "" + } catch { + return "" + } + }, + set: (value) => { + const current = globalThis.hopp.request.url + const baseUrl = current.split("#")[0] + const hashValue = value + ? value.startsWith("#") + ? value + : `#${value}` + : "" + globalThis.hopp.request.setUrl(baseUrl + hashValue) + }, + enumerable: true, + }) + + Object.defineProperty(urlObj, "query", { + get: () => { + return { + // Basic manipulation methods + add: (param) => { + if (!param || !param.key) + throw new Error("Query param must have a 'key' property") + const currentParsed = urlObj._parseUrl() + currentParsed.queryParams.push({ + key: param.key, + value: param.value || "", + }) + globalThis.hopp.request.setUrl( + urlObj._rebuildUrl(currentParsed) + ) + }, + + remove: (key) => { + if (typeof key !== "string") + throw new Error("Query param key must be a string") + const currentParsed = urlObj._parseUrl() + currentParsed.queryParams = currentParsed.queryParams.filter( + (p) => p.key !== key + ) + globalThis.hopp.request.setUrl( + urlObj._rebuildUrl(currentParsed) + ) + }, + + upsert: (param) => { + if (!param || !param.key) + throw new Error("Query param must have a 'key' property") + const currentParsed = urlObj._parseUrl() + const idx = currentParsed.queryParams.findIndex( + (p) => p.key === param.key + ) + if (idx >= 0) { + currentParsed.queryParams[idx].value = param.value || "" + } else { + currentParsed.queryParams.push({ + key: param.key, + value: param.value || "", + }) + } + globalThis.hopp.request.setUrl( + urlObj._rebuildUrl(currentParsed) + ) + }, + + clear: () => { + const currentParsed = urlObj._parseUrl() + currentParsed.queryParams = [] + globalThis.hopp.request.setUrl( + urlObj._rebuildUrl(currentParsed) + ) + // Also clear the params array to ensure consistency + globalThis.hopp.request.setParams([]) + }, + + // Read methods + get: (key) => { + const params = urlObj._parseUrl().queryParams + const param = params.find((p) => p.key === key) + return param ? param.value : null + }, + + has: (key) => { + const params = urlObj._parseUrl().queryParams + return params.some((p) => p.key === key) + }, + + all: () => { + const currentParsed = urlObj._parseUrl() + const result = {} + + // Handle duplicate keys by converting to arrays + currentParsed.queryParams.forEach((p) => { + if (Object.prototype.hasOwnProperty.call(result, p.key)) { + if (!Array.isArray(result[p.key])) { + result[p.key] = [result[p.key]] + } + result[p.key].push(p.value) + } else { + result[p.key] = p.value + } + }) + + return result + }, + + toObject: () => { + // Alias for all() for Postman compatibility + return urlObj.query.all() + }, + + // PropertyList iteration methods + each: (callback) => { + const params = urlObj._parseUrl().queryParams + params.forEach(callback) + }, + + map: (callback) => { + const params = urlObj._parseUrl().queryParams + return params.map(callback) + }, + + filter: (callback) => { + const params = urlObj._parseUrl().queryParams + return params.filter(callback) + }, + + count: () => { + return urlObj._parseUrl().queryParams.length + }, + + idx: (index) => { + const params = urlObj._parseUrl().queryParams + return params[index] || null + }, + + // Advanced PropertyList methods + find: (rule, context) => { + const params = urlObj._parseUrl().queryParams + if (typeof rule === "function") { + return ( + params.find(context ? rule.bind(context) : rule) || null + ) + } + // String rule: find by key + if (typeof rule === "string") { + return params.find((p) => p.key === rule) || null + } + return null + }, + + indexOf: (item) => { + const params = urlObj._parseUrl().queryParams + if (typeof item === "string") { + // Find by key + return params.findIndex((p) => p.key === item) + } + if (item && typeof item === "object" && item.key) { + // Find by object with key + return params.findIndex((p) => p.key === item.key) + } + return -1 + }, + + insert: (item, before) => { + if (!item || !item.key) + throw new Error("Query param must have a 'key' property") + const currentParsed = urlObj._parseUrl() + + if (before) { + // Find position to insert before + const beforeIdx = currentParsed.queryParams.findIndex( + (p) => p.key === before + ) + if (beforeIdx >= 0) { + currentParsed.queryParams.splice(beforeIdx, 0, { + key: item.key, + value: item.value || "", + }) + } else { + // If 'before' not found, append to end + currentParsed.queryParams.push({ + key: item.key, + value: item.value || "", + }) + } + } else { + // No 'before' specified, add to end + currentParsed.queryParams.push({ + key: item.key, + value: item.value || "", + }) + } + + globalThis.hopp.request.setUrl( + urlObj._rebuildUrl(currentParsed) + ) + }, + + append: (item) => { + if (!item || !item.key) + throw new Error("Query param must have a 'key' property") + const currentParsed = urlObj._parseUrl() + + // Remove existing instances of this key + currentParsed.queryParams = currentParsed.queryParams.filter( + (p) => p.key !== item.key + ) + + // Add at end + currentParsed.queryParams.push({ + key: item.key, + value: item.value || "", + }) + + globalThis.hopp.request.setUrl( + urlObj._rebuildUrl(currentParsed) + ) + }, + + assimilate: (source, prune) => { + if (!source || typeof source !== "object") { + throw new Error("Source must be an array or object") + } + + const currentParsed = urlObj._parseUrl() + + // Convert source to array format + let sourceArray + if (Array.isArray(source)) { + sourceArray = source + } else { + // Convert object to array of {key, value} + sourceArray = Object.entries(source).map(([key, value]) => ({ + key, + value: String(value), + })) + } + + // Update or add each item from source + sourceArray.forEach((item) => { + if (!item || !item.key) return + const idx = currentParsed.queryParams.findIndex( + (p) => p.key === item.key + ) + if (idx >= 0) { + // Update existing + currentParsed.queryParams[idx].value = item.value || "" + } else { + // Add new + currentParsed.queryParams.push({ + key: item.key, + value: item.value || "", + }) + } + }) + + if (prune) { + // Remove params not in source + const sourceKeys = sourceArray + .filter((i) => i && i.key) + .map((i) => i.key) + currentParsed.queryParams = currentParsed.queryParams.filter( + (p) => sourceKeys.includes(p.key) + ) + } + + globalThis.hopp.request.setUrl( + urlObj._rebuildUrl(currentParsed) + ) + }, + } + }, + enumerable: true, + }) + + return urlObj + }, + + // URL setter (Postman pattern: pm.request.url = "...") + set url(value) { + let urlString + if (typeof value === "string") { + urlString = value + } else if (value && typeof value.toString === "function") { + urlString = value.toString() + } else { + throw new Error("URL must be a string or have a toString() method") + } + + globalThis.hopp.request.setUrl(urlString) + + // Parse query params from the new URL and update params array + try { + const parsed = new URL(urlString) + const urlParams = Array.from(parsed.searchParams.entries()).map( + ([key, value]) => ({ key, value, active: true, description: "" }) + ) + globalThis.hopp.request.setParams(urlParams) + } catch { + // If URL parsing fails, clear params + globalThis.hopp.request.setParams([]) + } + }, + + // Method - Mutable + // NOTE: Postman does NOT normalize method to uppercase, so we preserve the original case + get method() { + return globalThis.hopp.request.method + }, + + set method(value) { + if (typeof value !== "string") { + throw new Error( + "Method must be a string (GET, POST, PUT, DELETE, etc.)" + ) + } + globalThis.hopp.request.setMethod(value) + }, + + // Headers - With Postman mutation methods and PropertyList interface + get headers() { + return { + // Read methods + get: (name) => { + const headers = globalThis.hopp.request.headers + const header = headers.find( + (h) => h.key.toLowerCase() === name.toLowerCase() + ) + return header ? header.value : null + }, + + has: (name) => { + const headers = globalThis.hopp.request.headers + return headers.some( + (h) => h.key.toLowerCase() === name.toLowerCase() + ) + }, + + all: () => { + const result = {} + globalThis.hopp.request.headers.forEach((header) => { + result[header.key] = header.value + }) + return result + }, + + toObject: () => { + // Alias for all() for Postman compatibility + const result = {} + globalThis.hopp.request.headers.forEach((header) => { + result[header.key] = header.value + }) + return result + }, + + // Mutation methods (Postman compatibility) + add: (header) => { + if (!header || typeof header !== "object") { + throw new Error( + "Header must be an object with 'key' and 'value' properties" + ) + } + if (!header.key) { + throw new Error("Header must have a 'key' property") + } + globalThis.hopp.request.setHeader(header.key, header.value || "") + }, + + remove: (headerName) => { + if (typeof headerName !== "string") { + throw new Error("Header name must be a string") + } + globalThis.hopp.request.removeHeader(headerName) + }, + + upsert: (header) => { + if (!header || typeof header !== "object") { + throw new Error( + "Header must be an object with 'key' and 'value' properties" + ) + } + if (!header.key) { + throw new Error("Header must have a 'key' property") + } + // Remove existing (case-insensitive) then add + globalThis.hopp.request.removeHeader(header.key) + globalThis.hopp.request.setHeader(header.key, header.value || "") + }, + + clear: () => { + globalThis.hopp.request.setHeaders([]) + }, + + // PropertyList iteration methods + each: (callback) => { + globalThis.hopp.request.headers.forEach(callback) + }, + + map: (callback) => { + return globalThis.hopp.request.headers.map(callback) + }, + + filter: (callback) => { + return globalThis.hopp.request.headers.filter(callback) + }, + + count: () => { + return globalThis.hopp.request.headers.length + }, + + idx: (index) => { + return globalThis.hopp.request.headers[index] || null + }, + + // Advanced PropertyList methods + find: (rule, context) => { + const headers = globalThis.hopp.request.headers + if (typeof rule === "function") { + return headers.find(context ? rule.bind(context) : rule) || null + } + // String rule: find by key (case-insensitive) + if (typeof rule === "string") { + return ( + headers.find( + (h) => h.key.toLowerCase() === rule.toLowerCase() + ) || null + ) + } + return null + }, + + indexOf: (item) => { + const headers = globalThis.hopp.request.headers + if (typeof item === "string") { + // Find by key (case-insensitive) + return headers.findIndex( + (h) => h.key.toLowerCase() === item.toLowerCase() + ) + } + if (item && typeof item === "object" && item.key) { + // Find by object with key (case-insensitive) + return headers.findIndex( + (h) => h.key.toLowerCase() === item.key.toLowerCase() + ) + } + return -1 + }, + + insert: (item, before) => { + if (!item || !item.key) + throw new Error("Header must have a 'key' property") + + const headers = globalThis.hopp.request.headers + + if (before) { + // Find position to insert before (case-insensitive) + const beforeIdx = headers.findIndex( + (h) => h.key.toLowerCase() === before.toLowerCase() + ) + if (beforeIdx >= 0) { + const newHeaders = [...headers] + newHeaders.splice(beforeIdx, 0, { + key: item.key, + value: item.value || "", + active: true, + }) + globalThis.hopp.request.setHeaders(newHeaders) + } else { + // If 'before' not found, append to end + globalThis.hopp.request.setHeader(item.key, item.value || "") + } + } else { + // No 'before' specified, add to end + globalThis.hopp.request.setHeader(item.key, item.value || "") + } + }, + + append: (item) => { + if (!item || !item.key) + throw new Error("Header must have a 'key' property") + + // Remove existing instances of this key (case-insensitive) + globalThis.hopp.request.removeHeader(item.key) + + // Add at end + globalThis.hopp.request.setHeader(item.key, item.value || "") + }, + + assimilate: (source, prune) => { + if (!source || typeof source !== "object") { + throw new Error("Source must be an array or object") + } + + let sourceArray + + if (Array.isArray(source)) { + sourceArray = source + } else { + // Convert object to array of {key, value} + sourceArray = Object.entries(source).map(([key, value]) => ({ + key, + value: String(value), + active: true, + })) + } + + // Update or add each item from source + sourceArray.forEach((item) => { + if (!item || !item.key) return + // Remove existing (case-insensitive) + globalThis.hopp.request.removeHeader(item.key) + // Add new/updated + globalThis.hopp.request.setHeader(item.key, item.value || "") + }) + + if (prune) { + // Remove headers not in source (case-insensitive) + const sourceKeys = sourceArray + .filter((i) => i && i.key) + .map((i) => i.key.toLowerCase()) + const currentHeaders = globalThis.hopp.request.headers + const filteredHeaders = currentHeaders.filter((h) => + sourceKeys.includes(h.key.toLowerCase()) + ) + globalThis.hopp.request.setHeaders(filteredHeaders) + } + }, + } + }, + + // Body - With Postman update() method + get body() { + const currentBody = globalThis.hopp.request.body + + // Return body with update() method + return { + // Spread current body properties + ...currentBody, + + // Postman-compatible update() method + update: (bodySpec) => { + if (typeof bodySpec === "string") { + // Direct string assignment + globalThis.hopp.request.setBody({ + contentType: "text/plain", + body: bodySpec, + }) + } else if (bodySpec && typeof bodySpec === "object") { + const mode = bodySpec.mode || "raw" + + switch (mode) { + case "raw": + globalThis.hopp.request.setBody({ + contentType: + bodySpec.options?.raw?.language === "json" + ? "application/json" + : "text/plain", + body: bodySpec.raw || "", + }) + break + + case "urlencoded": + globalThis.hopp.request.setBody({ + contentType: "application/x-www-form-urlencoded", + body: bodySpec.urlencoded || [], + }) + break + + case "formdata": + globalThis.hopp.request.setBody({ + contentType: "multipart/form-data", + body: bodySpec.formdata || [], + }) + break + + case "file": + globalThis.hopp.request.setBody({ + contentType: "binary", + body: bodySpec.file || null, + }) + break + + default: + throw new Error( + `Unsupported body mode: ${mode}. Supported modes: raw, urlencoded, formdata, file` + ) + } + } else { + throw new Error( + "Body spec must be a string or object with mode property" + ) + } + }, + } + }, + + // Body setter (legacy pattern for direct assignment) + set body(value) { + if (typeof value === "string") { + globalThis.hopp.request.setBody({ + contentType: "text/plain", + body: value, + }) + } else if (typeof value === "object" && value !== null) { + globalThis.hopp.request.setBody({ + contentType: "application/json", + body: JSON.stringify(value), + }) + } else { + throw new Error("Body must be a string or object") + } + }, + + // Auth - Mutable + get auth() { + return globalThis.hopp.request.auth + }, + + set auth(value) { + if (value === null || value === undefined) { + globalThis.hopp.request.setAuth({ + authType: "none", + authActive: false, + }) + } else if (typeof value === "object") { + globalThis.hopp.request.setAuth(value) + } else { + throw new Error("Auth must be an object or null") + } + }, + + // Custom serialization for console.log to ensure consistent behavior + // This method is called by faraday-cage's marshalling system + toJSON() { + // Return a plain object with all properties expanded + // This ensures console.log(pm.request) shows the full structure consistently + const urlParsed = this.url._parseUrl() + return { + id: this.id, + name: this.name, + url: { + protocol: urlParsed.protocol, + host: urlParsed.host, + hostname: urlParsed.host.join("."), + port: urlParsed.port, + path: urlParsed.path, + hash: urlParsed.hash || "", + query: this.url.query.all(), + }, + method: this.method, + headers: this.headers.toObject(), + body: this.body, + auth: this.auth, + } + }, + + toString() { + return `Request { id: ${this.id}, name: ${this.name}, method: ${this.method}, url: ${this.url.toString()} }` + }, + + [Symbol.toStringTag]: "Request", + }, + + // Script context information + info: { + eventName: "pre-request", + get requestName() { + return inputs.pmInfoRequestName() + }, + get requestId() { + return inputs.pmInfoRequestId() + }, + // Unsupported Collection Runner features + get iteration() { + throw new Error( + "pm.info.iteration is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + get iterationCount() { + throw new Error( + "pm.info.iterationCount is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + }, + + // pm.sendRequest() - Postman-compatible fetch wrapper + sendRequest: (urlOrRequest, callback) => { + // Check if fetch is available + if (typeof fetch === "undefined") { + const error = new Error( + "pm.sendRequest() requires fetch API support. Enable experimental scripting sandbox or ensure fetch is available in your environment." + ) + callback(error, null) + return + } + + // Parse arguments (Postman supports both string and object) + let url, options + + if (typeof urlOrRequest === "string") { + url = urlOrRequest + options = {} + } else { + // Object format: { url, method, header, body } + url = urlOrRequest.url + + // Parse headers - support both array [{key, value, disabled}] and object {key: value} formats + let headers = {} + if (urlOrRequest.header) { + if (Array.isArray(urlOrRequest.header)) { + // Array format: [{ key: 'Content-Type', value: 'application/json', disabled: false }] + // Filter out disabled headers and handle duplicates properly + const activeHeaders = urlOrRequest.header.filter( + (h) => h.disabled !== true + ) + + // Check if there are duplicate keys (e.g., multiple Set-Cookie headers) + const headerKeys = new Set() + const hasDuplicates = activeHeaders.some((h) => { + if (headerKeys.has(h.key.toLowerCase())) { + return true + } + headerKeys.add(h.key.toLowerCase()) + return false + }) + + if (hasDuplicates) { + // Use Headers API to properly handle duplicate headers + const headersInit = new Headers() + activeHeaders.forEach((h) => { + headersInit.append(h.key, h.value) + }) + headers = headersInit + } else { + // No duplicates - simple object is fine + headers = Object.fromEntries( + activeHeaders.map((h) => [h.key, h.value]) + ) + } + } else if (typeof urlOrRequest.header === "object") { + // Plain object format: { 'Content-Type': 'application/json' } + headers = urlOrRequest.header + } + } + + options = { + method: urlOrRequest.method || "GET", + headers, + } + + // Handle body based on mode + if (urlOrRequest.body) { + if (urlOrRequest.body.mode === "raw") { + options.body = urlOrRequest.body.raw + } else if (urlOrRequest.body.mode === "urlencoded") { + const params = new URLSearchParams() + urlOrRequest.body.urlencoded?.forEach((pair) => { + params.append(pair.key, pair.value) + }) + options.body = params.toString() + // Use .set() for Headers instance, bracket notation for plain object + if (options.headers instanceof Headers) { + options.headers.set( + "Content-Type", + "application/x-www-form-urlencoded" + ) + } else { + options.headers["Content-Type"] = + "application/x-www-form-urlencoded" + } + } else if (urlOrRequest.body.mode === "formdata") { + const formData = new FormData() + urlOrRequest.body.formdata?.forEach((pair) => { + formData.append(pair.key, pair.value) + }) + options.body = formData + } + } + } + + // Track request start time for responseTime calculation + const startTime = Date.now() + + // Call hopp.fetch() and adapt response + globalThis.hopp + .fetch(url, options) + .then(async (response) => { + // Convert Response to Postman response format + try { + const body = await response.text() + // Calculate response metrics + const responseTime = Date.now() - startTime + const responseSize = new Blob([body]).size + + // Handle Set-Cookie headers specially as they can appear multiple times + // The Fetch API's headers.entries() may not properly enumerate multiple Set-Cookie headers + // Use getSetCookie() if available (modern Fetch API), otherwise fall back to entries() + let headerEntries = [] + if ( + response.headers && + typeof response.headers.getSetCookie === "function" + ) { + // Modern Fetch API - getSetCookie() returns array of Set-Cookie values + const setCookies = response.headers.getSetCookie() + const otherHeaders = Array.from(response.headers.entries()) + .filter(([k]) => k.toLowerCase() !== "set-cookie") + .map(([k, v]) => ({ key: k, value: v })) + + // Add each Set-Cookie as a separate header entry + headerEntries = [ + ...otherHeaders, + ...setCookies.map((value) => ({ key: "Set-Cookie", value })), + ] + } else { + // Fallback: use entries() for all headers + headerEntries = Array.from(response.headers.entries()).map( + ([k, v]) => ({ key: k, value: v }) + ) + } + + // For Postman compatibility and test expectations, expose raw header entries array. + // Attach helper methods (get/has) directly onto the array to mimic Postman SDK convenience APIs + const headersArray = headerEntries.slice() + try { + Object.defineProperty(headersArray, "has", { + value: (name) => { + const lowerName = String(name).toLowerCase() + return headerEntries.some( + (h) => h.key.toLowerCase() === lowerName + ) + }, + enumerable: false, + configurable: true, + }) + Object.defineProperty(headersArray, "get", { + value: (name) => { + const lowerName = String(name).toLowerCase() + const header = headerEntries.find( + (h) => h.key.toLowerCase() === lowerName + ) + return header ? header.value : null + }, + enumerable: false, + configurable: true, + }) + } catch (_e) { + // Non-fatal; plain array works for E2E expectations + } + + const pmResponse = { + code: response.status, + status: response.statusText, + headers: headersArray, // Array with helper methods + body, + responseTime: responseTime, + responseSize: responseSize, + text: () => body, + json: () => { + try { + return JSON.parse(body) + } catch { + return null + } + }, + // Parse cookies from Set-Cookie headers (matching pm.response.cookies implementation) + cookies: { + get: (name) => { + // Parse cookies from Set-Cookie headers in the response + const setCookieHeaders = headerEntries.filter( + (h) => h.key.toLowerCase() === "set-cookie" + ) + + for (const header of setCookieHeaders) { + const cookieStr = header.value + const cookieName = cookieStr.split("=")[0].trim() + if (cookieName === name) { + // Extract cookie value (everything after first =, before first ;) + const parts = cookieStr.split(";") + const [, ...valueRest] = parts[0].split("=") + const value = valueRest.join("=").trim() + return value + } + } + return null + }, + has: (name) => { + const setCookieHeaders = headerEntries.filter( + (h) => h.key.toLowerCase() === "set-cookie" + ) + + for (const header of setCookieHeaders) { + const cookieName = header.value.split("=")[0].trim() + if (cookieName === name) { + return true + } + } + return false + }, + toObject: () => { + const setCookieHeaders = headerEntries.filter( + (h) => h.key.toLowerCase() === "set-cookie" + ) + + const cookies = {} + for (const header of setCookieHeaders) { + const parts = header.value.split(";") + const [nameValue] = parts + const [name, ...valueRest] = nameValue.split("=") + const value = valueRest.join("=").trim() + cookies[name.trim()] = value + } + return cookies + }, + }, + } + + callback(null, pmResponse) + } catch (textError) { + // Handle response.text() errors + callback(textError, null) + } + }) + .catch((error) => { + callback(error, null) + }) + }, + + // Collection variables (unsupported) + collectionVariables: { + get: () => { + throw new Error( + "pm.collectionVariables.get() is not supported in Hoppscotch (use environment or request variables instead)" + ) + }, + set: () => { + throw new Error( + "pm.collectionVariables.set() is not supported in Hoppscotch (use environment or request variables instead)" + ) + }, + unset: () => { + throw new Error( + "pm.collectionVariables.unset() is not supported in Hoppscotch (use environment or request variables instead)" + ) + }, + has: () => { + throw new Error( + "pm.collectionVariables.has() is not supported in Hoppscotch (use environment or request variables instead)" + ) + }, + clear: () => { + throw new Error( + "pm.collectionVariables.clear() is not supported in Hoppscotch (use environment or request variables instead)" + ) + }, + toObject: () => { + throw new Error( + "pm.collectionVariables.toObject() is not supported in Hoppscotch (use environment or request variables instead)" + ) + }, + replaceIn: () => { + throw new Error( + "pm.collectionVariables.replaceIn() is not supported in Hoppscotch (use environment or request variables instead)" + ) + }, + }, + + // Postman Vault (unsupported) + vault: { + get: () => { + throw new Error( + "pm.vault.get() is not supported in Hoppscotch (Postman Vault feature)" + ) + }, + set: () => { + throw new Error( + "pm.vault.set() is not supported in Hoppscotch (Postman Vault feature)" + ) + }, + unset: () => { + throw new Error( + "pm.vault.unset() is not supported in Hoppscotch (Postman Vault feature)" + ) + }, + }, + + // Postman Visualizer (unsupported) + visualizer: { + set: () => { + throw new Error( + "pm.visualizer.set() is not supported in Hoppscotch (Postman Visualizer feature)" + ) + }, + clear: () => { + throw new Error( + "pm.visualizer.clear() is not supported in Hoppscotch (Postman Visualizer feature)" + ) + }, + }, + + // Iteration data (unsupported) + iterationData: { + get: () => { + throw new Error( + "pm.iterationData.get() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + set: () => { + throw new Error( + "pm.iterationData.set() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + unset: () => { + throw new Error( + "pm.iterationData.unset() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + has: () => { + throw new Error( + "pm.iterationData.has() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + toObject: () => { + throw new Error( + "pm.iterationData.toObject() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + toJSON: () => { + throw new Error( + "pm.iterationData.toJSON() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + }, + + // Execution control (unsupported) + execution: { + location: (() => { + const location = ["Hoppscotch"] + Object.defineProperty(location, "current", { + value: "Hoppscotch", + writable: false, + enumerable: true, + }) + Object.freeze(location) + return location + })(), + setNextRequest: () => { + throw new Error( + "pm.execution.setNextRequest() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + skipRequest: () => { + throw new Error( + "pm.execution.skipRequest() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + runRequest: () => { + throw new Error( + "pm.execution.runRequest() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + }, + + // Package imports (unsupported) + require: (packageName) => { + throw new Error( + `pm.require('${packageName}') is not supported in Hoppscotch (Package Library feature)` + ) + }, + } +} diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/crypto.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/crypto.ts new file mode 100644 index 0000000..86bacd9 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/crypto.ts @@ -0,0 +1,1031 @@ +import { + defineCageModule, + defineSandboxFunctionRaw, +} from "faraday-cage/modules" +import { + CryptoKeyRegistry, + marshalValue, + MAX_GET_RANDOM_VALUES_SIZE, + uint8ArrayToVmArray, + vmArrayToUint8Array, +} from "./utils/vm-marshal" + +export type CustomCryptoModuleConfig = { + cryptoImpl?: Crypto +} + +// Normalize algorithm objects by converting certain array props to Uint8Array. +const normalizeAlgorithm = (algorithm: any): any => { + if (typeof algorithm === "string") { + return algorithm + } + if (typeof algorithm !== "object" || algorithm === null) { + return algorithm + } + + const normalized = { ...algorithm } + + // Convert known array properties to Uint8Array + const arrayProps = [ + "iv", + "counter", + "salt", + "additionalData", + "label", + "info", + "publicExponent", + ] + for (const prop of arrayProps) { + if (normalized[prop] && Array.isArray(normalized[prop])) { + normalized[prop] = new Uint8Array(normalized[prop]) + } + } + + return normalized +} + +export const customCryptoModule = (config: CustomCryptoModuleConfig = {}) => + defineCageModule((ctx) => { + const cryptoImpl = config.cryptoImpl ?? globalThis.crypto + const subtleImpl = cryptoImpl?.subtle + const getRandomValuesImpl = cryptoImpl?.getRandomValues + + const vmCryptoError = (message: string) => + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message, + }) + ) + + const rejectVmPromise = (message: string) => + ctx.scope.manage( + ctx.vm.newPromise((_resolve, reject) => { + reject(vmCryptoError(message)) + }) + ).handle + + const randomUUID = + typeof cryptoImpl?.randomUUID === "function" + ? cryptoImpl.randomUUID.bind(cryptoImpl) + : () => { + if (typeof getRandomValuesImpl !== "function") { + throw new Error( + "crypto.randomUUID is not available (requires WebCrypto)" + ) + } + + const bytes = new Uint8Array(16) + getRandomValuesImpl.call(cryptoImpl, bytes) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (b) => + b.toString(16).padStart(2, "0") + ).join("") + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` + } + + // Keys cannot be serialized across the VM boundary; store host keys in a registry. + let keyRegistry: CryptoKeyRegistry | null = null + const getKeyRegistry = () => (keyRegistry ??= new CryptoKeyRegistry()) + + // Track pending async operations so the runtime isn't disposed early. + // Without this, WebCrypto promises can resolve/reject after the VM is torn down, + // causing QuickJSUseAfterFree errors. + const pendingOperations: Promise[] = [] + let keepAliveRegistered = false + let resolveKeepAlive: (() => void) | null = null + + const registerKeepAlive = () => { + if (keepAliveRegistered) return + keepAliveRegistered = true + + const keepAlivePromise = new Promise((resolve) => { + resolveKeepAlive = resolve + }) + + ctx.keepAlivePromises.push(keepAlivePromise) + + const asyncHook = async () => { + // Poll until all operations are complete with a grace period + let emptyRounds = 0 + const maxEmptyRounds = 5 + + while (emptyRounds < maxEmptyRounds) { + if (pendingOperations.length > 0) { + emptyRounds = 0 + await Promise.allSettled(pendingOperations) + await new Promise((r) => setTimeout(r, 10)) + } else { + emptyRounds++ + await new Promise((r) => setTimeout(r, 10)) + } + } + + // Ensure registry timers don't outlive the cage run. + keyRegistry?.dispose() + resolveKeepAlive?.() + } + + // NOTE: faraday-cage's afterScriptExecutionHooks types are (() => void) but runtime supports async + ctx.afterScriptExecutionHooks.push(asyncHook as () => void) + } + + const trackAsyncOperation = (promise: Promise): Promise => { + registerKeepAlive() + pendingOperations.push(promise) + return promise.finally(() => { + const index = pendingOperations.indexOf(promise) + if (index > -1) { + pendingOperations.splice(index, 1) + } + }) + } + + // ======================================================================== + // crypto.getRandomValues() implementation + // ======================================================================== + const getRandomValuesFn = defineSandboxFunctionRaw( + ctx, + "getRandomValues", + (...args) => { + if (typeof getRandomValuesImpl !== "function") { + throw new Error( + "crypto.getRandomValues is not available (requires WebCrypto)" + ) + } + + const arrayHandle = args[0] + if (!arrayHandle) { + throw new Error("getRandomValues requires an array-like argument") + } + + // Get the length of the array + const lengthHandle = ctx.vm.getProp(arrayHandle, "length") + const length = ctx.vm.getNumber(lengthHandle) + lengthHandle.dispose() + + // Validate size per Web Crypto spec (max 65536 bytes) + // Prefer byteLength if available (TypedArray-like), otherwise fall back to length. + const byteLengthHandle = ctx.vm.getProp(arrayHandle, "byteLength") + const byteLength = + ctx.vm.typeof(byteLengthHandle) === "number" + ? ctx.vm.getNumber(byteLengthHandle) + : length + byteLengthHandle.dispose() + + if (byteLength > MAX_GET_RANDOM_VALUES_SIZE) { + throw new Error( + `Failed to execute 'getRandomValues': The ArrayBuffer/ArrayBufferView's byte length (${byteLength}) exceeds the maximum allowed (${MAX_GET_RANDOM_VALUES_SIZE} bytes).` + ) + } + + if (length < 0) { + throw new Error( + "Failed to execute 'getRandomValues': Invalid array length" + ) + } + + // Create a native Uint8Array and fill it with random values + const nativeArray = new Uint8Array(length) + getRandomValuesImpl.call(cryptoImpl, nativeArray) + + // Update the VM array with the random values + for (let i = 0; i < length; i++) { + const valueHandle = ctx.scope.manage(ctx.vm.newNumber(nativeArray[i])) + ctx.vm.setProp(arrayHandle, i, valueHandle) + } + + // Return the same array (mutated in place) + return arrayHandle + } + ) + + // ======================================================================== + // crypto.randomUUID() implementation + // ======================================================================== + const randomUUIDFn = defineSandboxFunctionRaw(ctx, "randomUUID", () => { + try { + const uuid = randomUUID() + return ctx.scope.manage(ctx.vm.newString(uuid)) + } catch (e) { + throw e instanceof Error ? e : new Error(String(e)) + } + }) + + // ======================================================================== + // crypto.subtle namespace implementation + // ======================================================================== + const subtleObj = ctx.scope.manage(ctx.vm.newObject()) + + /** + * Helper to retrieve a CryptoKey from a VM key handle + * Keys are stored in the registry and referenced by their __keyId property + */ + const getKeyFromHandle = (keyHandle: any): CryptoKey => { + if (!keyRegistry) { + throw new Error("Invalid key: key registry not initialized") + } + + const keyIdHandle = ctx.vm.getProp(keyHandle, "__keyId") + const keyId = ctx.vm.getString(keyIdHandle) + keyIdHandle.dispose() + + const key = keyRegistry.get(keyId) + if (!key) { + throw new Error("Invalid key: key not found in registry") + } + + // If it's a CryptoKeyPair, we need to determine which key to use + // This shouldn't happen in normal use since we store individual keys + if ("privateKey" in key || "publicKey" in key) { + throw new Error("Invalid key: expected CryptoKey, got CryptoKeyPair") + } + + return key as CryptoKey + } + + /** + * Helper to create a VM key handle from a CryptoKey + */ + const createKeyHandle = (key: CryptoKey): any => { + const keyId = getKeyRegistry().store(key) + const keyObj = ctx.scope.manage(ctx.vm.newObject()) + + // Store the key ID for later retrieval + ctx.vm.setProp( + keyObj, + "__keyId", + ctx.scope.manage(ctx.vm.newString(keyId)) + ) + + // Expose basic key properties + ctx.vm.setProp( + keyObj, + "type", + ctx.scope.manage(ctx.vm.newString(key.type)) + ) + ctx.vm.setProp( + keyObj, + "extractable", + key.extractable ? ctx.vm.true : ctx.vm.false + ) + ctx.vm.setProp(keyObj, "algorithm", marshalValue(ctx, key.algorithm)) + + // Marshal usages array + const usagesArray = ctx.scope.manage(ctx.vm.newArray()) + key.usages.forEach((usage, i) => { + ctx.vm.setProp( + usagesArray, + i, + ctx.scope.manage(ctx.vm.newString(usage)) + ) + }) + ctx.vm.setProp(keyObj, "usages", usagesArray) + + return keyObj + } + + // crypto.subtle.digest() implementation + const digestFn = defineSandboxFunctionRaw(ctx, "digest", (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.digest is not available (requires WebCrypto)" + ) + } + + const algorithm = ctx.vm.dump(args[0]) as string | AlgorithmIdentifier + const dataHandle = args[1] + + if (!dataHandle) { + throw new Error("digest requires data argument") + } + + // Convert VM data to Uint8Array + const data = vmArrayToUint8Array(ctx, dataHandle) + + // Create a promise that resolves with the digest + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.digest(algorithm, data as BufferSource) + ) + .then((hashBuffer) => { + // Convert ArrayBuffer to VM array + const hashArray = new Uint8Array(hashBuffer) + resolve(uint8ArrayToVmArray(ctx, hashArray)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "Digest operation failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + }) + + // crypto.subtle.encrypt() implementation + const encryptFn = defineSandboxFunctionRaw(ctx, "encrypt", (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.encrypt is not available (requires WebCrypto)" + ) + } + + const algorithmRaw = ctx.vm.dump(args[0]) + const algorithm = normalizeAlgorithm(algorithmRaw) + const keyHandle = args[1] + const dataHandle = args[2] + + if (!keyHandle || !dataHandle) { + throw new Error("encrypt requires algorithm, key, and data arguments") + } + + // Get the key from registry + const key = getKeyFromHandle(keyHandle) + const data = vmArrayToUint8Array(ctx, dataHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.encrypt( + algorithm as AlgorithmIdentifier, + key, + data as BufferSource + ) + ) + .then((encryptedBuffer) => { + const encryptedArray = new Uint8Array(encryptedBuffer) + resolve(uint8ArrayToVmArray(ctx, encryptedArray)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "Encryption failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + }) + + // crypto.subtle.decrypt() implementation + const decryptFn = defineSandboxFunctionRaw(ctx, "decrypt", (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.decrypt is not available (requires WebCrypto)" + ) + } + + const algorithmRaw = ctx.vm.dump(args[0]) + const algorithm = normalizeAlgorithm(algorithmRaw) + const keyHandle = args[1] + const dataHandle = args[2] + + if (!keyHandle || !dataHandle) { + throw new Error("decrypt requires algorithm, key, and data arguments") + } + + const key = getKeyFromHandle(keyHandle) + const data = vmArrayToUint8Array(ctx, dataHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.decrypt( + algorithm as AlgorithmIdentifier, + key, + data as BufferSource + ) + ) + .then((decryptedBuffer) => { + const decryptedArray = new Uint8Array(decryptedBuffer) + resolve(uint8ArrayToVmArray(ctx, decryptedArray)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "Decryption failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + }) + + // crypto.subtle.sign() implementation + const signFn = defineSandboxFunctionRaw(ctx, "sign", (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.sign is not available (requires WebCrypto)" + ) + } + + const algorithmRaw = ctx.vm.dump(args[0]) + const algorithm = normalizeAlgorithm(algorithmRaw) + const keyHandle = args[1] + const dataHandle = args[2] + + if (!keyHandle || !dataHandle) { + throw new Error("sign requires algorithm, key, and data arguments") + } + + const key = getKeyFromHandle(keyHandle) + const data = vmArrayToUint8Array(ctx, dataHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.sign( + algorithm as AlgorithmIdentifier, + key, + data as BufferSource + ) + ) + .then((signatureBuffer) => { + const signatureArray = new Uint8Array(signatureBuffer) + resolve(uint8ArrayToVmArray(ctx, signatureArray)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error ? error.message : "Sign failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + }) + + // crypto.subtle.verify() implementation + const verifyFn = defineSandboxFunctionRaw(ctx, "verify", (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.verify is not available (requires WebCrypto)" + ) + } + + const algorithmRaw = ctx.vm.dump(args[0]) + const algorithm = normalizeAlgorithm(algorithmRaw) + const keyHandle = args[1] + const signatureHandle = args[2] + const dataHandle = args[3] + + if (!keyHandle || !signatureHandle || !dataHandle) { + throw new Error( + "verify requires algorithm, key, signature, and data arguments" + ) + } + + const key = getKeyFromHandle(keyHandle) + const signature = vmArrayToUint8Array(ctx, signatureHandle) + const data = vmArrayToUint8Array(ctx, dataHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.verify( + algorithm as AlgorithmIdentifier, + key, + signature as BufferSource, + data as BufferSource + ) + ) + .then((verified) => { + resolve(verified ? ctx.vm.true : ctx.vm.false) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error ? error.message : "Verify failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + }) + + // crypto.subtle.generateKey() implementation + const generateKeyFn = defineSandboxFunctionRaw( + ctx, + "generateKey", + (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.generateKey is not available (requires WebCrypto)" + ) + } + + const algorithmRaw = ctx.vm.dump(args[0]) + const algorithm = normalizeAlgorithm(algorithmRaw) + const extractable = ctx.vm.dump(args[1]) as boolean + const keyUsages = ctx.vm.dump(args[2]) as KeyUsage[] + + if (!algorithm || extractable === undefined || !keyUsages) { + throw new Error( + "generateKey requires algorithm, extractable, and keyUsages arguments" + ) + } + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.generateKey( + algorithm as AlgorithmIdentifier, + extractable, + keyUsages + ) + ) + .then((key) => { + // Handle both CryptoKey and CryptoKeyPair + if ("privateKey" in key && "publicKey" in key) { + // It's a key pair - create handles for both + const keyPairObj = ctx.scope.manage(ctx.vm.newObject()) + ctx.vm.setProp( + keyPairObj, + "privateKey", + createKeyHandle(key.privateKey) + ) + ctx.vm.setProp( + keyPairObj, + "publicKey", + createKeyHandle(key.publicKey) + ) + resolve(keyPairObj) + } else { + // It's a single key + resolve(createKeyHandle(key as CryptoKey)) + } + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "Key generation failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + } + ) + + // crypto.subtle.importKey() implementation + const importKeyFn = defineSandboxFunctionRaw( + ctx, + "importKey", + (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.importKey is not available (requires WebCrypto)" + ) + } + + const format = ctx.vm.dump(args[0]) as KeyFormat + const keyDataHandle = args[1] + const algorithmRaw = ctx.vm.dump(args[2]) + const algorithm = normalizeAlgorithm(algorithmRaw) + const extractable = ctx.vm.dump(args[3]) as boolean + const keyUsages = ctx.vm.dump(args[4]) as KeyUsage[] + + if ( + !format || + !keyDataHandle || + !algorithm || + extractable === undefined || + !keyUsages + ) { + throw new Error( + "importKey requires format, keyData, algorithm, extractable, and keyUsages arguments" + ) + } + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + let importPromise: Promise + + // Handle format-specific overloads + if (format === "jwk") { + const jwkData = ctx.vm.dump(keyDataHandle) as JsonWebKey + importPromise = trackAsyncOperation( + subtleImpl.importKey( + "jwk", + jwkData, + algorithm as AlgorithmIdentifier, + extractable, + keyUsages + ) + ) + } else { + const bufferData = vmArrayToUint8Array(ctx, keyDataHandle) + importPromise = trackAsyncOperation( + subtleImpl.importKey( + format as "pkcs8" | "raw" | "spki", + bufferData as BufferSource, + algorithm as AlgorithmIdentifier, + extractable, + keyUsages + ) + ) + } + + importPromise + .then((key) => { + resolve(createKeyHandle(key)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "Key import failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + } + ) + + // crypto.subtle.exportKey() implementation + const exportKeyFn = defineSandboxFunctionRaw( + ctx, + "exportKey", + (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.exportKey is not available (requires WebCrypto)" + ) + } + + const format = ctx.vm.dump(args[0]) as KeyFormat + const keyHandle = args[1] + + if (!format || !keyHandle) { + throw new Error("exportKey requires format and key arguments") + } + + // Get the key from registry + const key = getKeyFromHandle(keyHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation(subtleImpl.exportKey(format, key)) + .then((exportedKey) => { + if (format === "jwk") { + resolve(marshalValue(ctx, exportedKey)) + } else { + const keyArray = new Uint8Array(exportedKey as ArrayBuffer) + resolve(uint8ArrayToVmArray(ctx, keyArray)) + } + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "Key export failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + } + ) + + // crypto.subtle.deriveBits() implementation + const deriveBitsFn = defineSandboxFunctionRaw( + ctx, + "deriveBits", + (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.deriveBits is not available (requires WebCrypto)" + ) + } + + const algorithmRaw = ctx.vm.dump(args[0]) + const algorithm = normalizeAlgorithm(algorithmRaw) + const keyHandle = args[1] + const length = ctx.vm.dump(args[2]) as number + + if (!algorithm || !keyHandle || length === undefined) { + throw new Error( + "deriveBits requires algorithm, key, and length arguments" + ) + } + + const key = getKeyFromHandle(keyHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.deriveBits( + algorithm as AlgorithmIdentifier, + key, + length + ) + ) + .then((bits) => { + const bitsArray = new Uint8Array(bits) + resolve(uint8ArrayToVmArray(ctx, bitsArray)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "deriveBits failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + } + ) + + // crypto.subtle.deriveKey() implementation + const deriveKeyFn = defineSandboxFunctionRaw( + ctx, + "deriveKey", + (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.deriveKey is not available (requires WebCrypto)" + ) + } + + const algorithmRaw = ctx.vm.dump(args[0]) + const algorithm = normalizeAlgorithm(algorithmRaw) + const baseKeyHandle = args[1] + const derivedKeyTypeRaw = ctx.vm.dump(args[2]) + const derivedKeyType = normalizeAlgorithm(derivedKeyTypeRaw) + const extractable = ctx.vm.dump(args[3]) as boolean + const keyUsages = ctx.vm.dump(args[4]) as KeyUsage[] + + if ( + !algorithm || + !baseKeyHandle || + !derivedKeyType || + extractable === undefined || + !keyUsages + ) { + throw new Error( + "deriveKey requires algorithm, baseKey, derivedKeyType, extractable, and keyUsages arguments" + ) + } + + const baseKey = getKeyFromHandle(baseKeyHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.deriveKey( + algorithm as AlgorithmIdentifier, + baseKey, + derivedKeyType as AlgorithmIdentifier, + extractable, + keyUsages + ) + ) + .then((derivedKey) => { + resolve(createKeyHandle(derivedKey)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "deriveKey failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + } + ) + + // crypto.subtle.wrapKey() implementation + const wrapKeyFn = defineSandboxFunctionRaw(ctx, "wrapKey", (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.wrapKey is not available (requires WebCrypto)" + ) + } + + const format = ctx.vm.dump(args[0]) as KeyFormat + const keyHandle = args[1] + const wrappingKeyHandle = args[2] + const wrapAlgorithmRaw = ctx.vm.dump(args[3]) + const wrapAlgorithm = normalizeAlgorithm(wrapAlgorithmRaw) + + if (!format || !keyHandle || !wrappingKeyHandle || !wrapAlgorithm) { + throw new Error( + "wrapKey requires format, key, wrappingKey, and wrapAlgorithm arguments" + ) + } + + const key = getKeyFromHandle(keyHandle) + const wrappingKey = getKeyFromHandle(wrappingKeyHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.wrapKey( + format, + key, + wrappingKey, + wrapAlgorithm as AlgorithmIdentifier + ) + ) + .then((wrappedKey) => { + const wrappedArray = new Uint8Array(wrappedKey) + resolve(uint8ArrayToVmArray(ctx, wrappedArray)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error ? error.message : "wrapKey failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + }) + + // crypto.subtle.unwrapKey() implementation + const unwrapKeyFn = defineSandboxFunctionRaw( + ctx, + "unwrapKey", + (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.unwrapKey is not available (requires WebCrypto)" + ) + } + + const format = ctx.vm.dump(args[0]) as KeyFormat + const wrappedKeyHandle = args[1] + const unwrappingKeyHandle = args[2] + const unwrapAlgorithmRaw = ctx.vm.dump(args[3]) + const unwrapAlgorithm = normalizeAlgorithm(unwrapAlgorithmRaw) + const unwrappedKeyAlgorithmRaw = ctx.vm.dump(args[4]) + const unwrappedKeyAlgorithm = normalizeAlgorithm( + unwrappedKeyAlgorithmRaw + ) + const extractable = ctx.vm.dump(args[5]) as boolean + const keyUsages = ctx.vm.dump(args[6]) as KeyUsage[] + + if ( + !format || + !wrappedKeyHandle || + !unwrappingKeyHandle || + !unwrapAlgorithm || + !unwrappedKeyAlgorithm || + extractable === undefined || + !keyUsages + ) { + throw new Error( + "unwrapKey requires all arguments: format, wrappedKey, unwrappingKey, unwrapAlgorithm, unwrappedKeyAlgorithm, extractable, keyUsages" + ) + } + + const wrappedKey = vmArrayToUint8Array(ctx, wrappedKeyHandle) + const unwrappingKey = getKeyFromHandle(unwrappingKeyHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.unwrapKey( + format, + wrappedKey as BufferSource, + unwrappingKey, + unwrapAlgorithm as AlgorithmIdentifier, + unwrappedKeyAlgorithm as AlgorithmIdentifier, + extractable, + keyUsages + ) + ) + .then((unwrappedKey) => { + resolve(createKeyHandle(unwrappedKey)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "unwrapKey failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + } + ) + + // Add all methods to subtle object + ctx.vm.setProp(subtleObj, "digest", digestFn) + ctx.vm.setProp(subtleObj, "encrypt", encryptFn) + ctx.vm.setProp(subtleObj, "decrypt", decryptFn) + ctx.vm.setProp(subtleObj, "sign", signFn) + ctx.vm.setProp(subtleObj, "verify", verifyFn) + ctx.vm.setProp(subtleObj, "generateKey", generateKeyFn) + ctx.vm.setProp(subtleObj, "importKey", importKeyFn) + ctx.vm.setProp(subtleObj, "exportKey", exportKeyFn) + ctx.vm.setProp(subtleObj, "deriveBits", deriveBitsFn) + ctx.vm.setProp(subtleObj, "deriveKey", deriveKeyFn) + ctx.vm.setProp(subtleObj, "wrapKey", wrapKeyFn) + ctx.vm.setProp(subtleObj, "unwrapKey", unwrapKeyFn) + + // ======================================================================== + // Main crypto object + // ======================================================================== + const cryptoObj = ctx.scope.manage(ctx.vm.newObject()) + ctx.vm.setProp(cryptoObj, "getRandomValues", getRandomValuesFn) + ctx.vm.setProp(cryptoObj, "randomUUID", randomUUIDFn) + ctx.vm.setProp(cryptoObj, "subtle", subtleObj) + + // Set crypto on global scope + ctx.vm.setProp(ctx.vm.global, "crypto", cryptoObj) + }) diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/default.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/default.ts new file mode 100644 index 0000000..eb9437b --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/default.ts @@ -0,0 +1,75 @@ +import { + blobPolyfill, + ConsoleEntry, + console as ConsoleModule, + encoding, + esmModuleLoader, + timers, + urlPolyfill, +} from "faraday-cage/modules" +import type { HoppFetchHook } from "~/types" +import { customCryptoModule } from "./crypto" +import { customFetchModule } from "./fetch" + +type DefaultModulesConfig = { + handleConsoleEntry?: (consoleEntries: ConsoleEntry) => void + hoppFetchHook?: HoppFetchHook +} + +export const defaultModules = (config?: DefaultModulesConfig) => { + return [ + urlPolyfill, + blobPolyfill, + ConsoleModule({ + onLog(level, ...args) { + console[level](...args) + + if (config?.handleConsoleEntry) { + config.handleConsoleEntry({ + type: level, + args, + timestamp: Date.now(), + }) + } + }, + onCount(...args) { + console.count(args[0]) + }, + onTime(...args) { + console.timeEnd(args[0]) + }, + onTimeLog(...args) { + console.timeLog(...args) + }, + onGroup(...args) { + console.group(...args) + }, + onGroupEnd(...args) { + console.groupEnd(...args) + }, + onClear(...args) { + console.clear(...args) + }, + onAssert(...args) { + console.assert(...args) + }, + onDir(...args) { + console.dir(...args) + }, + onTable(...args) { + console.table(...args) + }, + }), + customCryptoModule({ + cryptoImpl: globalThis.crypto, + }), + + esmModuleLoader, + // Use custom fetch module with HoppFetchHook + customFetchModule({ + fetchImpl: config?.hoppFetchHook, + }), + encoding(), + timers(), + ] +} diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/fetch.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/fetch.ts new file mode 100644 index 0000000..828537d --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/fetch.ts @@ -0,0 +1,2128 @@ +import { + defineCageModule, + defineSandboxFunctionRaw, +} from "faraday-cage/modules" +import type { HoppFetchHook } from "~/types" +import { marshalValue as sharedMarshalValue } from "./utils/vm-marshal" + +// Type augmentation for some Headers iterator methods. +interface HeadersWithIterators extends Headers { + entries(): IterableIterator<[string, string]> + keys(): IterableIterator + values(): IterableIterator +} + +// Response shape used for VM serialization. +type SerializableResponse = Response & { + _bodyBytes: number[] + _headersData?: Record +} + +type AsyncScriptExecutionHook = () => Promise + +export type CustomFetchModuleConfig = { + fetchImpl?: HoppFetchHook +} + +export const customFetchModule = (config: CustomFetchModuleConfig = {}) => + defineCageModule((ctx) => { + const fetchImpl = config.fetchImpl || globalThis.fetch + + // Track pending async operations + const pendingOperations: Promise[] = [] + let resolveKeepAlive: (() => void) | null = null + + // Create keepAlive promise BEFORE registering hook + const keepAlivePromise = new Promise((resolve) => { + resolveKeepAlive = resolve + }) + + ctx.keepAlivePromises.push(keepAlivePromise) + + // Register async hook to wait for all fetch operations + // NOTE: faraday-cage's afterScriptExecutionHooks types are (() => void) but runtime supports async + const asyncHook: AsyncScriptExecutionHook = async () => { + // Poll until all operations are complete with grace period + let emptyRounds = 0 + const maxEmptyRounds = 5 + + while (emptyRounds < maxEmptyRounds) { + if (pendingOperations.length > 0) { + emptyRounds = 0 + await Promise.allSettled(pendingOperations) + await new Promise((r) => setTimeout(r, 10)) + } else { + emptyRounds++ + // Grace period: wait for VM to process jobs + await new Promise((r) => setTimeout(r, 10)) + } + } + resolveKeepAlive?.() + } + ctx.afterScriptExecutionHooks.push(asyncHook as () => void) + + // Track async operations + const trackAsyncOperation = (promise: Promise): Promise => { + pendingOperations.push(promise) + return promise.finally(() => { + const index = pendingOperations.indexOf(promise) + if (index > -1) { + pendingOperations.splice(index, 1) + } + }) + } + + // Helper to marshal values to VM (using shared utility) + const marshalValue = (value: any): any => sharedMarshalValue(ctx, value) + + // Define fetch function in the sandbox + const fetchFn = defineSandboxFunctionRaw(ctx, "fetch", (...args) => { + // Check if input is a Request object with native Request data + let input: RequestInfo | URL + const firstArg = args[0] + if ((firstArg as any).__nativeRequestData) { + // Use the native Request object that was created in the Request constructor + // This preserves method, body, and headers that would otherwise be lost + input = (firstArg as any).__nativeRequestData + } else { + input = ctx.vm.dump(firstArg) + } + const init = args.length > 1 ? args[1] : undefined + + // Check if init has headers that need conversion + if (init) { + const headersHandle = ctx.vm.getProp(init, "headers") + if (headersHandle) { + // Check if it's a Headers instance + const isHoppHeaders = ctx.vm.getProp(headersHandle, "__isHoppHeaders") + if (isHoppHeaders && ctx.vm.typeof(isHoppHeaders) === "boolean") { + const isHoppHeadersValue = ctx.vm.dump(isHoppHeaders) + if (isHoppHeadersValue === true) { + // Call toObject() to get plain object + const toObjectFn = ctx.vm.getProp(headersHandle, "toObject") + if (toObjectFn && ctx.vm.typeof(toObjectFn) === "function") { + const result = ctx.vm.callFunction(toObjectFn, headersHandle) + if (!result.error) { + // Replace headers with the plain object + ctx.vm.setProp(init, "headers", result.value) + result.value.dispose() + } else { + result.error.dispose() + } + } + toObjectFn?.dispose() + } + isHoppHeaders.dispose() + } + headersHandle.dispose() + } + } + + // Now dump init after conversion + const dumpedInit = init ? ctx.vm.dump(init) : undefined + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + const fetchPromise = trackAsyncOperation(fetchImpl(input, dumpedInit)) + + fetchPromise + .then(async (response) => { + // If response doesn't have _bodyBytes, read the body and add it + // This handles cases where fetchImpl returns a native Response + let serializableResponse = response as SerializableResponse + if (!serializableResponse._bodyBytes) { + const arrayBuffer = await response.arrayBuffer() + const bodyBytes = Array.from(new Uint8Array(arrayBuffer)) + serializableResponse = Object.assign(response, { + _bodyBytes: bodyBytes, + }) as SerializableResponse + } + + // Create a serializable response object + const responseObj = ctx.scope.manage(ctx.vm.newObject()) + + // Set basic properties + ctx.vm.setProp( + responseObj, + "status", + ctx.scope.manage(ctx.vm.newNumber(serializableResponse.status)) + ) + ctx.vm.setProp( + responseObj, + "statusText", + ctx.scope.manage( + ctx.vm.newString(serializableResponse.statusText) + ) + ) + ctx.vm.setProp( + responseObj, + "ok", + serializableResponse.ok ? ctx.vm.true : ctx.vm.false + ) + + // Create headers object with Headers-like interface + const headersObj = ctx.scope.manage(ctx.vm.newObject()) + // Prefer _headersData for fast-path; otherwise, build from native Headers + const headersMap: Record = + serializableResponse._headersData || + (() => { + const map: Record = {} + try { + const nativeHeaders = (serializableResponse as Response) + .headers as any + if ( + nativeHeaders && + typeof nativeHeaders.forEach === "function" + ) { + ;(nativeHeaders as Headers).forEach((value, key) => { + map[String(key).toLowerCase()] = String(value) + }) + } else if ( + nativeHeaders && + typeof nativeHeaders.entries === "function" + ) { + for (const [key, value] of nativeHeaders.entries()) { + map[String(key).toLowerCase()] = String(value) + } + } + } catch (_) { + // ignore fallback errors; leave map empty + } + return map + })() + + // Set individual header properties + for (const [key, value] of Object.entries(headersMap)) { + ctx.vm.setProp( + headersObj, + key, + ctx.scope.manage(ctx.vm.newString(String(value))) + ) + } + + // Add entries() method for Headers compatibility + // Returns an array of [key, value] pairs + // QuickJS arrays are iterable by default, so for...of will work + const entriesFn = defineSandboxFunctionRaw(ctx, "entries", () => { + const entriesArray = ctx.scope.manage(ctx.vm.newArray()) + let index = 0 + for (const [key, value] of Object.entries(headersMap)) { + const entry = ctx.scope.manage(ctx.vm.newArray()) + ctx.vm.setProp( + entry, + 0, + ctx.scope.manage(ctx.vm.newString(key)) + ) + ctx.vm.setProp( + entry, + 1, + ctx.scope.manage(ctx.vm.newString(String(value))) + ) + ctx.vm.setProp(entriesArray, index++, entry) + } + return entriesArray + }) + ctx.vm.setProp(headersObj, "entries", entriesFn) + + // Add get() method for Headers compatibility + const getFn = defineSandboxFunctionRaw(ctx, "get", (...args) => { + const key = String(ctx.vm.dump(args[0])) + const value = headersMap[key] || headersMap[key.toLowerCase()] + return value + ? ctx.scope.manage(ctx.vm.newString(value)) + : ctx.vm.null + }) + ctx.vm.setProp(headersObj, "get", getFn) + + ctx.vm.setProp(responseObj, "headers", headersObj) + + // Store the body bytes internally + const bodyBytes = serializableResponse._bodyBytes || [] + + // Store body bytes for sync access + const bodyBytesArray = ctx.scope.manage(ctx.vm.newArray()) + for (let i = 0; i < bodyBytes.length; i++) { + ctx.vm.setProp( + bodyBytesArray, + i, + ctx.scope.manage(ctx.vm.newNumber(bodyBytes[i])) + ) + } + ctx.vm.setProp(responseObj, "_bodyBytes", bodyBytesArray) + + // Track body consumption + let fetchBodyConsumed = false + ctx.vm.setProp(responseObj, "bodyUsed", ctx.vm.false) + + const markFetchBodyConsumed = () => { + if (fetchBodyConsumed) return false + fetchBodyConsumed = true + ctx.vm.setProp(responseObj, "bodyUsed", ctx.vm.true) + return true + } + + // Add json() method - returns promise + const jsonFn = defineSandboxFunctionRaw(ctx, "json", () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markFetchBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + // Filter out null bytes (some interceptors add trailing null bytes) + const nullByteIndex = bodyBytes.indexOf(0) + const cleanBytes = + nullByteIndex >= 0 + ? bodyBytes.slice(0, nullByteIndex) + : bodyBytes + + const text = new TextDecoder().decode( + new Uint8Array(cleanBytes) + ) + const parsed = JSON.parse(text) + const marshalledResult = marshalValue(parsed) + resolve(marshalledResult) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "JSONError", + message: + error instanceof Error + ? error.message + : "JSON parse failed", + }) + ) + ) + } + }) + + return ctx.scope.manage(vmPromise).handle + }) + + ctx.vm.setProp(responseObj, "json", jsonFn) + + // Add text() method - returns promise + const textFn = defineSandboxFunctionRaw(ctx, "text", () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markFetchBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + // Filter out null bytes (some interceptors add trailing null bytes) + const nullByteIndex = bodyBytes.indexOf(0) + const cleanBytes = + nullByteIndex >= 0 + ? bodyBytes.slice(0, nullByteIndex) + : bodyBytes + + const text = new TextDecoder().decode( + new Uint8Array(cleanBytes) + ) + const textHandle = ctx.scope.manage( + ctx.vm.newString(String(text)) + ) + resolve(textHandle) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TextError", + message: + error instanceof Error + ? error.message + : "Text decode failed", + }) + ) + ) + } + }) + + return ctx.scope.manage(vmPromise).handle + }) + + ctx.vm.setProp(responseObj, "text", textFn) + + // Add arrayBuffer() method + // Note: QuickJS doesn't support native ArrayBuffer, so we return a plain array + // with byteLength property for compatibility + const arrayBufferFn = defineSandboxFunctionRaw( + ctx, + "arrayBuffer", + () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markFetchBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + const arr = ctx.scope.manage(ctx.vm.newArray()) + bodyBytes.forEach((byte, i) => { + ctx.vm.setProp( + arr, + i, + ctx.scope.manage(ctx.vm.newNumber(byte)) + ) + }) + // Add byteLength property for ArrayBuffer compatibility + ctx.vm.setProp( + arr, + "byteLength", + ctx.scope.manage(ctx.vm.newNumber(bodyBytes.length)) + ) + resolve(arr) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: + error instanceof Error + ? error.message + : "ArrayBuffer conversion failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + } + ) + ctx.vm.setProp(responseObj, "arrayBuffer", arrayBufferFn) + + // Add blob() method + const blobFn = defineSandboxFunctionRaw(ctx, "blob", () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markFetchBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + const blobObj = ctx.scope.manage(ctx.vm.newObject()) + ctx.vm.setProp( + blobObj, + "size", + ctx.scope.manage(ctx.vm.newNumber(bodyBytes.length)) + ) + ctx.vm.setProp( + blobObj, + "type", + ctx.scope.manage( + ctx.vm.newString("application/octet-stream") + ) + ) + const arr = ctx.scope.manage(ctx.vm.newArray()) + bodyBytes.forEach((byte, i) => { + ctx.vm.setProp( + arr, + i, + ctx.scope.manage(ctx.vm.newNumber(byte)) + ) + }) + ctx.vm.setProp(blobObj, "bytes", arr) + resolve(blobObj) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: + error instanceof Error + ? error.message + : "Blob conversion failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + }) + ctx.vm.setProp(responseObj, "blob", blobFn) + + // Add formData() method + const formDataFn = defineSandboxFunctionRaw( + ctx, + "formData", + () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markFetchBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + const text = new TextDecoder().decode( + new Uint8Array(bodyBytes) + ) + const formDataObj = ctx.scope.manage(ctx.vm.newObject()) + const pairs = text.split("&") + for (const pair of pairs) { + const [key, value] = pair + .split("=") + .map(decodeURIComponent) + if (key) { + ctx.vm.setProp( + formDataObj, + key, + ctx.scope.manage(ctx.vm.newString(value || "")) + ) + } + } + resolve(formDataObj) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: + error instanceof Error + ? error.message + : "FormData parsing failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + } + ) + ctx.vm.setProp(responseObj, "formData", formDataFn) + + // Add clone() method for fetch response + const cloneFetchResponseFn = defineSandboxFunctionRaw( + ctx, + "clone", + () => { + // Can only clone if body hasn't been consumed + if (fetchBodyConsumed) { + const errorResponse = ctx.scope.manage(ctx.vm.newObject()) + ctx.vm.setProp(errorResponse, "_error", ctx.vm.true) + return errorResponse + } + + // Create a new response object + const clonedResponseObj = ctx.scope.manage(ctx.vm.newObject()) + + // Copy all basic properties + ctx.vm.setProp( + clonedResponseObj, + "status", + ctx.scope.manage( + ctx.vm.newNumber(serializableResponse.status) + ) + ) + ctx.vm.setProp( + clonedResponseObj, + "statusText", + ctx.scope.manage( + ctx.vm.newString(serializableResponse.statusText) + ) + ) + ctx.vm.setProp( + clonedResponseObj, + "ok", + serializableResponse.ok ? ctx.vm.true : ctx.vm.false + ) + + // Clone headers + const clonedHeadersObj = ctx.scope.manage(ctx.vm.newObject()) + for (const [key, value] of Object.entries(headersMap)) { + ctx.vm.setProp( + clonedHeadersObj, + key, + ctx.scope.manage(ctx.vm.newString(String(value))) + ) + } + + // Add headers methods to cloned object + const clonedEntriesFn = defineSandboxFunctionRaw( + ctx, + "entries", + () => { + const entriesArray = ctx.scope.manage(ctx.vm.newArray()) + let index = 0 + for (const [key, value] of Object.entries(headersMap)) { + const entry = ctx.scope.manage(ctx.vm.newArray()) + ctx.vm.setProp( + entry, + 0, + ctx.scope.manage(ctx.vm.newString(key)) + ) + ctx.vm.setProp( + entry, + 1, + ctx.scope.manage(ctx.vm.newString(String(value))) + ) + ctx.vm.setProp(entriesArray, index++, entry) + } + return entriesArray + } + ) + ctx.vm.setProp(clonedHeadersObj, "entries", clonedEntriesFn) + + const clonedGetFn = defineSandboxFunctionRaw( + ctx, + "get", + (...args) => { + const key = String(ctx.vm.dump(args[0])) + const value = + headersMap[key] || headersMap[key.toLowerCase()] + return value + ? ctx.scope.manage(ctx.vm.newString(value)) + : ctx.vm.null + } + ) + ctx.vm.setProp(clonedHeadersObj, "get", clonedGetFn) + + ctx.vm.setProp(clonedResponseObj, "headers", clonedHeadersObj) + + // Clone body bytes + const clonedBodyBytes = [...bodyBytes] + let clonedBodyConsumed = false + ctx.vm.setProp(clonedResponseObj, "bodyUsed", ctx.vm.false) + + const markClonedBodyConsumed = () => { + if (clonedBodyConsumed) return false + clonedBodyConsumed = true + ctx.vm.setProp(clonedResponseObj, "bodyUsed", ctx.vm.true) + return true + } + + // Add all body methods to cloned response + const clonedJsonFn = defineSandboxFunctionRaw( + ctx, + "json", + () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markClonedBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + const nullByteIndex = clonedBodyBytes.indexOf(0) + const cleanBytes = + nullByteIndex >= 0 + ? clonedBodyBytes.slice(0, nullByteIndex) + : clonedBodyBytes + + const text = new TextDecoder().decode( + new Uint8Array(cleanBytes) + ) + const parsed = JSON.parse(text) + resolve(marshalValue(parsed)) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "JSONError", + message: + error instanceof Error + ? error.message + : "JSON parse failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + } + ) + ctx.vm.setProp(clonedResponseObj, "json", clonedJsonFn) + + const clonedTextFn = defineSandboxFunctionRaw( + ctx, + "text", + () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markClonedBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + const nullByteIndex = clonedBodyBytes.indexOf(0) + const cleanBytes = + nullByteIndex >= 0 + ? clonedBodyBytes.slice(0, nullByteIndex) + : clonedBodyBytes + + const text = new TextDecoder().decode( + new Uint8Array(cleanBytes) + ) + resolve( + ctx.scope.manage(ctx.vm.newString(String(text))) + ) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TextError", + message: + error instanceof Error + ? error.message + : "Text decode failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + } + ) + ctx.vm.setProp(clonedResponseObj, "text", clonedTextFn) + + const clonedArrayBufferFn = defineSandboxFunctionRaw( + ctx, + "arrayBuffer", + () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markClonedBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + const arr = ctx.scope.manage(ctx.vm.newArray()) + clonedBodyBytes.forEach((byte, i) => { + ctx.vm.setProp( + arr, + i, + ctx.scope.manage(ctx.vm.newNumber(byte)) + ) + }) + resolve(arr) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: + error instanceof Error + ? error.message + : "ArrayBuffer conversion failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + } + ) + ctx.vm.setProp( + clonedResponseObj, + "arrayBuffer", + clonedArrayBufferFn + ) + + const clonedBlobFn = defineSandboxFunctionRaw( + ctx, + "blob", + () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markClonedBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + const blobObj = ctx.scope.manage(ctx.vm.newObject()) + ctx.vm.setProp( + blobObj, + "size", + ctx.scope.manage( + ctx.vm.newNumber(clonedBodyBytes.length) + ) + ) + ctx.vm.setProp( + blobObj, + "type", + ctx.scope.manage( + ctx.vm.newString("application/octet-stream") + ) + ) + const arr = ctx.scope.manage(ctx.vm.newArray()) + clonedBodyBytes.forEach((byte, i) => { + ctx.vm.setProp( + arr, + i, + ctx.scope.manage(ctx.vm.newNumber(byte)) + ) + }) + ctx.vm.setProp(blobObj, "bytes", arr) + resolve(blobObj) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: + error instanceof Error + ? error.message + : "Blob conversion failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + } + ) + ctx.vm.setProp(clonedResponseObj, "blob", clonedBlobFn) + + const clonedFormDataFn = defineSandboxFunctionRaw( + ctx, + "formData", + () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markClonedBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + const nullByteIndex = clonedBodyBytes.indexOf(0) + const cleanBytes = + nullByteIndex >= 0 + ? clonedBodyBytes.slice(0, nullByteIndex) + : clonedBodyBytes + + const text = new TextDecoder().decode( + new Uint8Array(cleanBytes) + ) + const formDataObj = ctx.scope.manage( + ctx.vm.newObject() + ) + const pairs = text.split("&") + for (const pair of pairs) { + const [key, value] = pair + .split("=") + .map(decodeURIComponent) + if (key) { + ctx.vm.setProp( + formDataObj, + key, + ctx.scope.manage(ctx.vm.newString(value || "")) + ) + } + } + resolve(formDataObj) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: + error instanceof Error + ? error.message + : "FormData parsing failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + } + ) + ctx.vm.setProp( + clonedResponseObj, + "formData", + clonedFormDataFn + ) + + // Add clone() method to cloned response (recursively) + ctx.vm.setProp( + clonedResponseObj, + "clone", + cloneFetchResponseFn + ) + + return clonedResponseObj + } + ) + ctx.vm.setProp(responseObj, "clone", cloneFetchResponseFn) + + resolve(responseObj) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "FetchError", + message: + error instanceof Error ? error.message : "Fetch failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + }) + + // Add fetch to global scope + ctx.vm.setProp(ctx.vm.global, "fetch", fetchFn) + + // ======================================================================== + // Headers Class Implementation (wraps native Headers) + // ======================================================================== + // Helper function to create a Headers instance (called from sandbox) + const createHeadersInstance = defineSandboxFunctionRaw( + ctx, + "__createHeadersInstance", + (initHandle) => { + const init = initHandle ? ctx.vm.dump(initHandle) : undefined + const nativeHeaders = new globalThis.Headers(init as HeadersInit) + + const headersInstance = ctx.scope.manage(ctx.vm.newObject()) + + // append(name, value) - delegates to native Headers + const appendFn = defineSandboxFunctionRaw( + ctx, + "append", + (...appendArgs) => { + const name = String(ctx.vm.dump(appendArgs[0])) + const value = String(ctx.vm.dump(appendArgs[1])) + nativeHeaders.append(name, value) + return ctx.vm.undefined + } + ) + ctx.vm.setProp(headersInstance, "append", appendFn) + + // delete(name) - delegates to native Headers + const deleteFn = defineSandboxFunctionRaw( + ctx, + "delete", + (...deleteArgs) => { + const name = String(ctx.vm.dump(deleteArgs[0])) + nativeHeaders.delete(name) + return ctx.vm.undefined + } + ) + ctx.vm.setProp(headersInstance, "delete", deleteFn) + + // get(name) - delegates to native Headers + const getFn = defineSandboxFunctionRaw(ctx, "get", (...getArgs) => { + const name = String(ctx.vm.dump(getArgs[0])) + const value = nativeHeaders.get(name) + return value !== null + ? ctx.scope.manage(ctx.vm.newString(value)) + : ctx.vm.null + }) + ctx.vm.setProp(headersInstance, "get", getFn) + + // has(name) - delegates to native Headers + const hasFn = defineSandboxFunctionRaw(ctx, "has", (...hasArgs) => { + const name = String(ctx.vm.dump(hasArgs[0])) + return nativeHeaders.has(name) ? ctx.vm.true : ctx.vm.false + }) + ctx.vm.setProp(headersInstance, "has", hasFn) + + // set(name, value) - delegates to native Headers + const setFn = defineSandboxFunctionRaw(ctx, "set", (...setArgs) => { + const name = String(ctx.vm.dump(setArgs[0])) + const value = String(ctx.vm.dump(setArgs[1])) + nativeHeaders.set(name, value) + return ctx.vm.undefined + }) + ctx.vm.setProp(headersInstance, "set", setFn) + + // forEach(callbackfn) - delegates to native Headers + const forEachFn = defineSandboxFunctionRaw( + ctx, + "forEach", + (...forEachArgs) => { + const callback = forEachArgs[0] + nativeHeaders.forEach((value, key) => { + ctx.vm.callFunction( + callback, + ctx.vm.undefined, + ctx.scope.manage(ctx.vm.newString(value)), + ctx.scope.manage(ctx.vm.newString(key)), + headersInstance + ) + }) + return ctx.vm.undefined + } + ) + ctx.vm.setProp(headersInstance, "forEach", forEachFn) + + // entries() - delegates to native Headers + const entriesFn = defineSandboxFunctionRaw(ctx, "entries", () => { + const entriesArray = ctx.scope.manage(ctx.vm.newArray()) + let index = 0 + for (const [key, value] of ( + nativeHeaders as HeadersWithIterators + ).entries()) { + const entry = ctx.scope.manage(ctx.vm.newArray()) + ctx.vm.setProp(entry, 0, ctx.scope.manage(ctx.vm.newString(key))) + ctx.vm.setProp(entry, 1, ctx.scope.manage(ctx.vm.newString(value))) + ctx.vm.setProp(entriesArray, index++, entry) + } + return entriesArray + }) + ctx.vm.setProp(headersInstance, "entries", entriesFn) + + // keys() - delegates to native Headers + const keysFn = defineSandboxFunctionRaw(ctx, "keys", () => { + const keysArray = ctx.scope.manage(ctx.vm.newArray()) + let index = 0 + for (const key of (nativeHeaders as HeadersWithIterators).keys()) { + ctx.vm.setProp( + keysArray, + index++, + ctx.scope.manage(ctx.vm.newString(key)) + ) + } + return keysArray + }) + ctx.vm.setProp(headersInstance, "keys", keysFn) + + // values() - delegates to native Headers + const valuesFn = defineSandboxFunctionRaw(ctx, "values", () => { + const valuesArray = ctx.scope.manage(ctx.vm.newArray()) + let index = 0 + for (const value of ( + nativeHeaders as HeadersWithIterators + ).values()) { + ctx.vm.setProp( + valuesArray, + index++, + ctx.scope.manage(ctx.vm.newString(value)) + ) + } + return valuesArray + }) + ctx.vm.setProp(headersInstance, "values", valuesFn) + + // Add a special marker and toObject method for fetch compatibility + ctx.vm.setProp(headersInstance, "__isHoppHeaders", ctx.vm.true) + + const toObjectFn = defineSandboxFunctionRaw(ctx, "toObject", () => { + const obj = ctx.scope.manage(ctx.vm.newObject()) + for (const [key, value] of ( + nativeHeaders as HeadersWithIterators + ).entries()) { + ctx.vm.setProp(obj, key, ctx.scope.manage(ctx.vm.newString(value))) + } + return obj + }) + ctx.vm.setProp(headersInstance, "toObject", toObjectFn) + + return headersInstance + } + ) + + // Set the helper on global scope (keep it, don't remove) + ctx.vm.setProp( + ctx.vm.global, + "__createHeadersInstance", + createHeadersInstance + ) + + // Define the Headers constructor as actual JavaScript in the sandbox + // This ensures it's recognized as a proper constructor + const headersCtorResult = ctx.vm.evalCode(` + (function() { + globalThis.Headers = function Headers(init) { + return __createHeadersInstance(init) + } + return true + })() + `) + + if (headersCtorResult.error) { + console.error( + "[FETCH] Failed to define Headers constructor:", + ctx.vm.dump(headersCtorResult.error) + ) + headersCtorResult.error.dispose() + } else { + headersCtorResult.value?.dispose() + } + + // ======================================================================== + // Request Class Implementation (wraps native Request) + // ======================================================================== + const RequestClass = defineSandboxFunctionRaw(ctx, "Request", (...args) => { + const input = ctx.vm.dump(args[0]) + const init = args.length > 1 ? ctx.vm.dump(args[1]) : {} + + // Create native Request instance + const nativeRequest = new globalThis.Request( + input as RequestInfo, + init as RequestInit + ) + + const requestInstance = ctx.scope.manage(ctx.vm.newObject()) + + // url property - strip trailing slash if original didn't have one + let url = nativeRequest.url + if ( + typeof input === "string" && + !input.endsWith("/") && + url.endsWith("/") + ) { + url = url.slice(0, -1) + } + ctx.vm.setProp( + requestInstance, + "url", + ctx.scope.manage(ctx.vm.newString(url)) + ) + + // method property + ctx.vm.setProp( + requestInstance, + "method", + ctx.scope.manage(ctx.vm.newString(nativeRequest.method)) + ) + + // headers property - create simple object (Headers class can be used separately if needed) + const headersObj = ctx.scope.manage(ctx.vm.newObject()) + for (const [key, value] of ( + nativeRequest.headers as HeadersWithIterators + ).entries()) { + ctx.vm.setProp( + headersObj, + key, + ctx.scope.manage(ctx.vm.newString(value)) + ) + } + ctx.vm.setProp(requestInstance, "headers", headersObj) + + // body property (simplified - most use cases don't need body in Request objects) + ctx.vm.setProp(requestInstance, "body", ctx.vm.null) + + // Store reference to native Request for fetch() to access method/body/headers + // This is a hidden property that won't be enumerable but allows fetch() to properly handle Request objects + ctx.vm.setProp( + requestInstance, + "__nativeRequest", + ctx.scope.manage(ctx.vm.newObject()) // Placeholder - will be replaced in fetch() with actual native Request + ) + // Store the actual native request data for fetch to use + ;(requestInstance as any).__nativeRequestData = nativeRequest + + // bodyUsed property - always false since we don't support reading request bodies yet + ctx.vm.setProp(requestInstance, "bodyUsed", ctx.vm.false) + + // mode property + ctx.vm.setProp( + requestInstance, + "mode", + ctx.scope.manage(ctx.vm.newString(nativeRequest.mode)) + ) + + // credentials property + ctx.vm.setProp( + requestInstance, + "credentials", + ctx.scope.manage(ctx.vm.newString(nativeRequest.credentials)) + ) + + // cache property + ctx.vm.setProp( + requestInstance, + "cache", + ctx.scope.manage(ctx.vm.newString(nativeRequest.cache)) + ) + + // redirect property + ctx.vm.setProp( + requestInstance, + "redirect", + ctx.scope.manage(ctx.vm.newString(nativeRequest.redirect)) + ) + + // referrer property + ctx.vm.setProp( + requestInstance, + "referrer", + ctx.scope.manage(ctx.vm.newString(nativeRequest.referrer)) + ) + + // integrity property + ctx.vm.setProp( + requestInstance, + "integrity", + ctx.scope.manage(ctx.vm.newString(nativeRequest.integrity)) + ) + + // clone() method - delegates to native Request + const cloneFn = defineSandboxFunctionRaw(ctx, "clone", () => { + const clonedNativeRequest = nativeRequest.clone() + const clonedRequest = ctx.scope.manage(ctx.vm.newObject()) + + // Copy all properties from cloned native Request + ctx.vm.setProp( + clonedRequest, + "url", + ctx.scope.manage(ctx.vm.newString(clonedNativeRequest.url)) + ) + ctx.vm.setProp( + clonedRequest, + "method", + ctx.scope.manage(ctx.vm.newString(clonedNativeRequest.method)) + ) + ctx.vm.setProp(clonedRequest, "body", ctx.vm.null) + ctx.vm.setProp(clonedRequest, "bodyUsed", ctx.vm.false) + ctx.vm.setProp( + clonedRequest, + "mode", + ctx.scope.manage(ctx.vm.newString(clonedNativeRequest.mode)) + ) + ctx.vm.setProp( + clonedRequest, + "credentials", + ctx.scope.manage(ctx.vm.newString(clonedNativeRequest.credentials)) + ) + ctx.vm.setProp( + clonedRequest, + "cache", + ctx.scope.manage(ctx.vm.newString(clonedNativeRequest.cache)) + ) + ctx.vm.setProp( + clonedRequest, + "redirect", + ctx.scope.manage(ctx.vm.newString(clonedNativeRequest.redirect)) + ) + ctx.vm.setProp( + clonedRequest, + "referrer", + ctx.scope.manage(ctx.vm.newString(clonedNativeRequest.referrer)) + ) + ctx.vm.setProp( + clonedRequest, + "integrity", + ctx.scope.manage(ctx.vm.newString(clonedNativeRequest.integrity)) + ) + + return clonedRequest + }) + ctx.vm.setProp(requestInstance, "clone", cloneFn) + + return requestInstance + }) + + // Set helper on global and define Request constructor in sandbox + ctx.vm.setProp(ctx.vm.global, "__createRequestInstance", RequestClass) + const requestCtorResult = ctx.vm.evalCode(` + (function() { + globalThis.Request = function Request(input, init) { + return __createRequestInstance(input, init) + } + return true + })() + `) + if (requestCtorResult.error) { + console.error( + "[FETCH] Failed to define Request constructor:", + ctx.vm.dump(requestCtorResult.error) + ) + requestCtorResult.error.dispose() + } else { + requestCtorResult.value?.dispose() + } + + // ======================================================================== + // Response Class Implementation + // ======================================================================== + const ResponseClass = defineSandboxFunctionRaw( + ctx, + "Response", + (...args) => { + const body = args.length > 0 ? ctx.vm.dump(args[0]) : null + const init = args.length > 1 ? ctx.vm.dump(args[1]) : {} + + const responseInstance = ctx.scope.manage(ctx.vm.newObject()) + + // Set status property + const status = init.status || 200 + ctx.vm.setProp( + responseInstance, + "status", + ctx.scope.manage(ctx.vm.newNumber(status)) + ) + + // Set statusText property + ctx.vm.setProp( + responseInstance, + "statusText", + ctx.scope.manage(ctx.vm.newString(init.statusText || "")) + ) + + // Set ok property (true for 200-299 status codes) + const ok = status >= 200 && status < 300 + ctx.vm.setProp(responseInstance, "ok", ok ? ctx.vm.true : ctx.vm.false) + + // Set headers property - convert HeadersInit to plain object with get() method + // Handles plain objects, arrays of tuples, and Headers instances + const responseHeadersObj = ctx.scope.manage(ctx.vm.newObject()) + const headersMap: Record = {} + + // Process headers based on type (HeadersInit: Headers | string[][] | Record) + if (init.headers) { + if (Array.isArray(init.headers)) { + // Array of tuples: [["key", "value"], ...] + for (const [key, value] of init.headers) { + headersMap[String(key).toLowerCase()] = String(value) + } + } else if (typeof init.headers === "object") { + // Plain object or Headers instance - iterate with Object.entries + for (const [key, value] of Object.entries(init.headers)) { + headersMap[String(key).toLowerCase()] = String(value) + } + } + } + + // Set header properties + for (const [key, value] of Object.entries(headersMap)) { + ctx.vm.setProp( + responseHeadersObj, + key, + ctx.scope.manage(ctx.vm.newString(String(value))) + ) + } + + // Add get() method for Headers API compatibility + const getHeaderFn = defineSandboxFunctionRaw(ctx, "get", (...args) => { + const key = String(ctx.vm.dump(args[0])).toLowerCase() + const value = headersMap[key] + return value ? ctx.scope.manage(ctx.vm.newString(value)) : ctx.vm.null + }) + ctx.vm.setProp(responseHeadersObj, "get", getHeaderFn) + + // Add has() method + const hasHeaderFn = defineSandboxFunctionRaw(ctx, "has", (...args) => { + const key = String(ctx.vm.dump(args[0])).toLowerCase() + return headersMap[key] !== undefined ? ctx.vm.true : ctx.vm.false + }) + ctx.vm.setProp(responseHeadersObj, "has", hasHeaderFn) + + ctx.vm.setProp(responseInstance, "headers", responseHeadersObj) + + // Set type property + ctx.vm.setProp( + responseInstance, + "type", + ctx.scope.manage(ctx.vm.newString(init.type || "default")) + ) + + // Set url property + ctx.vm.setProp( + responseInstance, + "url", + ctx.scope.manage(ctx.vm.newString(init.url || "")) + ) + + // Set redirected property + ctx.vm.setProp( + responseInstance, + "redirected", + init.redirected ? ctx.vm.true : ctx.vm.false + ) + + // Store body internally (normalizing supported types to byte array) + let bodyBytes: number[] = [] + if (body != null) { + if (typeof body === "string") { + bodyBytes = Array.from(new TextEncoder().encode(body)) + } else if (body instanceof Uint8Array) { + bodyBytes = Array.from(body) + } else if (body instanceof ArrayBuffer) { + bodyBytes = Array.from(new Uint8Array(body)) + } else if (body instanceof URLSearchParams) { + bodyBytes = Array.from(new TextEncoder().encode(body.toString())) + } else if (body instanceof Date) { + bodyBytes = Array.from(new TextEncoder().encode(body.toISOString())) + } else if (body instanceof RegExp) { + bodyBytes = Array.from(new TextEncoder().encode(body.toString())) + } else if (typeof body === "object") { + // Fallback: JSON stringify generic object (FormData and unsupported complex structures will be stringified) + try { + const jsonString = JSON.stringify(body) + bodyBytes = Array.from(new TextEncoder().encode(jsonString)) + } catch (_) { + // If object isn't JSON-serializable, fall back to its string representation + bodyBytes = Array.from(new TextEncoder().encode(String(body))) + } + } + } + + // Track body consumption state + let bodyConsumed = false + + // bodyUsed getter property + ctx.vm.setProp(responseInstance, "bodyUsed", ctx.vm.false) + + // Helper to mark body as consumed + const markBodyConsumed = () => { + if (bodyConsumed) { + return false // Already consumed + } + bodyConsumed = true + ctx.vm.setProp(responseInstance, "bodyUsed", ctx.vm.true) + return true + } + + // json() method + const jsonFn = defineSandboxFunctionRaw(ctx, "json", () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + const text = new TextDecoder().decode(new Uint8Array(bodyBytes)) + const parsed = JSON.parse(text) + resolve(marshalValue(parsed)) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "JSONError", + message: + error instanceof Error + ? error.message + : "JSON parse failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + }) + ctx.vm.setProp(responseInstance, "json", jsonFn) + + // text() method + const textFn = defineSandboxFunctionRaw(ctx, "text", () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + const text = new TextDecoder().decode(new Uint8Array(bodyBytes)) + resolve(ctx.scope.manage(ctx.vm.newString(text))) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TextError", + message: + error instanceof Error + ? error.message + : "Text decode failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + }) + ctx.vm.setProp(responseInstance, "text", textFn) + + // arrayBuffer() method + // Note: QuickJS doesn't support native ArrayBuffer, so we return a plain array + // with byteLength property for compatibility + const arrayBufferFn = defineSandboxFunctionRaw( + ctx, + "arrayBuffer", + () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + // Create a VM array with the byte values + const arr = ctx.scope.manage(ctx.vm.newArray()) + bodyBytes.forEach((byte, i) => { + ctx.vm.setProp( + arr, + i, + ctx.scope.manage(ctx.vm.newNumber(byte)) + ) + }) + // Add byteLength property for ArrayBuffer compatibility + ctx.vm.setProp( + arr, + "byteLength", + ctx.scope.manage(ctx.vm.newNumber(bodyBytes.length)) + ) + resolve(arr) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: + error instanceof Error + ? error.message + : "ArrayBuffer conversion failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + } + ) + ctx.vm.setProp(responseInstance, "arrayBuffer", arrayBufferFn) + + // blob() method + const blobFn = defineSandboxFunctionRaw(ctx, "blob", () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + // Create a simple blob-like object with byte data + const blobObj = ctx.scope.manage(ctx.vm.newObject()) + ctx.vm.setProp( + blobObj, + "size", + ctx.scope.manage(ctx.vm.newNumber(bodyBytes.length)) + ) + ctx.vm.setProp( + blobObj, + "type", + ctx.scope.manage(ctx.vm.newString("application/octet-stream")) + ) + // Store bytes as array + const arr = ctx.scope.manage(ctx.vm.newArray()) + bodyBytes.forEach((byte, i) => { + ctx.vm.setProp(arr, i, ctx.scope.manage(ctx.vm.newNumber(byte))) + }) + ctx.vm.setProp(blobObj, "bytes", arr) + resolve(blobObj) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: + error instanceof Error + ? error.message + : "Blob conversion failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + }) + ctx.vm.setProp(responseInstance, "blob", blobFn) + + // formData() method + const formDataFn = defineSandboxFunctionRaw(ctx, "formData", () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + // Parse as URL-encoded form data or multipart + const text = new TextDecoder().decode(new Uint8Array(bodyBytes)) + const formDataObj = ctx.scope.manage(ctx.vm.newObject()) + + // Simple URL-encoded parsing + const pairs = text.split("&") + for (const pair of pairs) { + const [key, value] = pair.split("=").map(decodeURIComponent) + if (key) { + ctx.vm.setProp( + formDataObj, + key, + ctx.scope.manage(ctx.vm.newString(value || "")) + ) + } + } + + resolve(formDataObj) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: + error instanceof Error + ? error.message + : "FormData parsing failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + }) + ctx.vm.setProp(responseInstance, "formData", formDataFn) + + // clone() method + const cloneFn = defineSandboxFunctionRaw(ctx, "clone", () => { + // Can only clone if body hasn't been consumed + if (bodyConsumed) { + // In QuickJS, we can't throw synchronously from sandbox function + // Return an error response marked as unusable + const errorResponse = ctx.scope.manage(ctx.vm.newObject()) + ctx.vm.setProp(errorResponse, "_error", ctx.vm.true) + return errorResponse + } + + // Create a new response instance manually + const clonedResponse = ctx.scope.manage(ctx.vm.newObject()) + + // Copy all properties + ctx.vm.setProp( + clonedResponse, + "status", + ctx.scope.manage(ctx.vm.newNumber(status)) + ) + ctx.vm.setProp( + clonedResponse, + "statusText", + ctx.scope.manage(ctx.vm.newString(init.statusText || "")) + ) + ctx.vm.setProp(clonedResponse, "ok", ok ? ctx.vm.true : ctx.vm.false) + + // Clone headers - same logic as Response constructor + const clonedResponseHeadersObj = ctx.scope.manage(ctx.vm.newObject()) + const clonedHeadersMap: Record = {} + + if (init.headers) { + if (Array.isArray(init.headers)) { + for (const [key, value] of init.headers) { + clonedHeadersMap[String(key).toLowerCase()] = String(value) + } + } else if (typeof init.headers === "object") { + for (const [key, value] of Object.entries(init.headers)) { + clonedHeadersMap[String(key).toLowerCase()] = String(value) + } + } + } + + for (const [key, value] of Object.entries(clonedHeadersMap)) { + ctx.vm.setProp( + clonedResponseHeadersObj, + key, + ctx.scope.manage(ctx.vm.newString(String(value))) + ) + } + + // Add get() and has() methods + const clonedGetFn = defineSandboxFunctionRaw( + ctx, + "get", + (...args) => { + const key = String(ctx.vm.dump(args[0])).toLowerCase() + const value = clonedHeadersMap[key] + return value + ? ctx.scope.manage(ctx.vm.newString(value)) + : ctx.vm.null + } + ) + ctx.vm.setProp(clonedResponseHeadersObj, "get", clonedGetFn) + + const clonedHasFn = defineSandboxFunctionRaw( + ctx, + "has", + (...args) => { + const key = String(ctx.vm.dump(args[0])).toLowerCase() + return clonedHeadersMap[key] !== undefined + ? ctx.vm.true + : ctx.vm.false + } + ) + ctx.vm.setProp(clonedResponseHeadersObj, "has", clonedHasFn) + + ctx.vm.setProp(clonedResponse, "headers", clonedResponseHeadersObj) + + // Copy other properties + ctx.vm.setProp( + clonedResponse, + "type", + ctx.scope.manage(ctx.vm.newString(init.type || "default")) + ) + ctx.vm.setProp( + clonedResponse, + "url", + ctx.scope.manage(ctx.vm.newString(init.url || "")) + ) + ctx.vm.setProp( + clonedResponse, + "redirected", + init.redirected ? ctx.vm.true : ctx.vm.false + ) + + // Clone body bytes array and consumption state + const clonedBodyBytes = [...bodyBytes] + let clonedBodyConsumed = false + + // bodyUsed property for cloned response + ctx.vm.setProp(clonedResponse, "bodyUsed", ctx.vm.false) + + // Helper for cloned response + const markClonedBodyConsumed = () => { + if (clonedBodyConsumed) return false + clonedBodyConsumed = true + ctx.vm.setProp(clonedResponse, "bodyUsed", ctx.vm.true) + return true + } + + // Add all body methods to cloned response + const clonedJsonFn = defineSandboxFunctionRaw(ctx, "json", () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markClonedBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + const text = new TextDecoder().decode( + new Uint8Array(clonedBodyBytes) + ) + const parsed = JSON.parse(text) + resolve(marshalValue(parsed)) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "JSONError", + message: + error instanceof Error + ? error.message + : "JSON parse failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + }) + ctx.vm.setProp(clonedResponse, "json", clonedJsonFn) + + const clonedTextFn = defineSandboxFunctionRaw(ctx, "text", () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markClonedBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + const text = new TextDecoder().decode( + new Uint8Array(clonedBodyBytes) + ) + resolve(ctx.scope.manage(ctx.vm.newString(text))) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TextError", + message: + error instanceof Error + ? error.message + : "Text decode failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + }) + ctx.vm.setProp(clonedResponse, "text", clonedTextFn) + + const clonedArrayBufferFn = defineSandboxFunctionRaw( + ctx, + "arrayBuffer", + () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markClonedBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + const arr = ctx.scope.manage(ctx.vm.newArray()) + clonedBodyBytes.forEach((byte, i) => { + ctx.vm.setProp( + arr, + i, + ctx.scope.manage(ctx.vm.newNumber(byte)) + ) + }) + resolve(arr) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: + error instanceof Error + ? error.message + : "ArrayBuffer conversion failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + } + ) + ctx.vm.setProp(clonedResponse, "arrayBuffer", clonedArrayBufferFn) + + const clonedBlobFn = defineSandboxFunctionRaw(ctx, "blob", () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markClonedBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + const blobObj = ctx.scope.manage(ctx.vm.newObject()) + ctx.vm.setProp( + blobObj, + "size", + ctx.scope.manage(ctx.vm.newNumber(clonedBodyBytes.length)) + ) + ctx.vm.setProp( + blobObj, + "type", + ctx.scope.manage(ctx.vm.newString("application/octet-stream")) + ) + const arr = ctx.scope.manage(ctx.vm.newArray()) + clonedBodyBytes.forEach((byte, i) => { + ctx.vm.setProp( + arr, + i, + ctx.scope.manage(ctx.vm.newNumber(byte)) + ) + }) + ctx.vm.setProp(blobObj, "bytes", arr) + resolve(blobObj) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: + error instanceof Error + ? error.message + : "Blob conversion failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + }) + ctx.vm.setProp(clonedResponse, "blob", clonedBlobFn) + + const clonedFormDataFn = defineSandboxFunctionRaw( + ctx, + "formData", + () => { + const vmPromise = ctx.vm.newPromise((resolve, reject) => { + if (!markClonedBodyConsumed()) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: "Body has already been consumed", + }) + ) + ) + return + } + try { + const text = new TextDecoder().decode( + new Uint8Array(clonedBodyBytes) + ) + const formDataObj = ctx.scope.manage(ctx.vm.newObject()) + const pairs = text.split("&") + for (const pair of pairs) { + const [key, value] = pair.split("=").map(decodeURIComponent) + if (key) { + ctx.vm.setProp( + formDataObj, + key, + ctx.scope.manage(ctx.vm.newString(value || "")) + ) + } + } + resolve(formDataObj) + } catch (error) { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "TypeError", + message: + error instanceof Error + ? error.message + : "FormData parsing failed", + }) + ) + ) + } + }) + return ctx.scope.manage(vmPromise).handle + } + ) + ctx.vm.setProp(clonedResponse, "formData", clonedFormDataFn) + + // Add clone() method to cloned response + const nestedCloneFn = cloneFn // Reuse the same clone function + ctx.vm.setProp(clonedResponse, "clone", nestedCloneFn) + + return clonedResponse + }) + ctx.vm.setProp(responseInstance, "clone", cloneFn) + + return responseInstance + } + ) + + // Set helper on global and define Response constructor in sandbox + ctx.vm.setProp(ctx.vm.global, "__createResponseInstance", ResponseClass) + const responseCtorResult = ctx.vm.evalCode(` + (function() { + globalThis.Response = function Response(body, init) { + return __createResponseInstance(body, init) + } + return true + })() + `) + if (responseCtorResult.error) { + console.error( + "[FETCH] Failed to define Response constructor:", + ctx.vm.dump(responseCtorResult.error) + ) + responseCtorResult.error.dispose() + } else { + responseCtorResult.value?.dispose() + } + + // ======================================================================== + // AbortController Class Implementation + // ======================================================================== + const AbortControllerClass = defineSandboxFunctionRaw( + ctx, + "AbortController", + () => { + const controllerInstance = ctx.scope.manage(ctx.vm.newObject()) + + // Create AbortSignal + const signalInstance = ctx.scope.manage(ctx.vm.newObject()) + ctx.vm.setProp(signalInstance, "aborted", ctx.vm.false) + + // Store abort listeners - use an array to store handles that we DON'T dispose + // These handles need to stay alive until abort() is called + const abortListeners: Array<{ handle: any; disposed: boolean }> = [] + + // addEventListener method for signal + const addEventListenerFn = defineSandboxFunctionRaw( + ctx, + "addEventListener", + (...listenerArgs) => { + const eventType = ctx.vm.dump(listenerArgs[0]) + if (eventType === "abort") { + // The handle passed to us is managed by the caller's scope + // We need to create our own reference that won't be auto-disposed + const listenerHandle = listenerArgs[1] + const dupedHandle = listenerHandle.dup() + abortListeners.push({ handle: dupedHandle, disposed: false }) + } + return ctx.vm.undefined + } + ) + ctx.vm.setProp(signalInstance, "addEventListener", addEventListenerFn) + + // Set signal property on controller + ctx.vm.setProp(controllerInstance, "signal", signalInstance) + + // abort() method + const abortFn = defineSandboxFunctionRaw(ctx, "abort", () => { + // Mark signal as aborted + ctx.vm.setProp(signalInstance, "aborted", ctx.vm.true) + + // Call all abort listeners + for (let i = 0; i < abortListeners.length; i++) { + const listenerInfo = abortListeners[i] + if (!listenerInfo.disposed) { + const result = ctx.vm.callFunction( + listenerInfo.handle, + ctx.vm.undefined + ) + if (result.error) { + console.error( + "[ABORT] Listener error:", + ctx.vm.dump(result.error) + ) + result.error.dispose() + } else { + result.value.dispose() + } + // Dispose the handle after calling it + listenerInfo.handle.dispose() + listenerInfo.disposed = true + } + } + + return ctx.vm.undefined + }) + ctx.vm.setProp(controllerInstance, "abort", abortFn) + + return controllerInstance + } + ) + + // Set helper on global and define AbortController constructor in sandbox + ctx.vm.setProp( + ctx.vm.global, + "__createAbortControllerInstance", + AbortControllerClass + ) + const abortCtorResult = ctx.vm.evalCode(` + (function() { + globalThis.AbortController = function AbortController() { + return __createAbortControllerInstance() + } + return true + })() + `) + if (abortCtorResult.error) { + console.error( + "[FETCH] Failed to define AbortController constructor:", + ctx.vm.dump(abortCtorResult.error) + ) + abortCtorResult.error.dispose() + } else { + abortCtorResult.value?.dispose() + } + }) diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/index.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/index.ts new file mode 100644 index 0000000..4d06112 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/index.ts @@ -0,0 +1,4 @@ +export { defaultModules } from "./default" +export { postRequestModule, preRequestModule } from "./scripting-modules" +export { customCryptoModule } from "./crypto" +export type { CustomCryptoModuleConfig } from "./crypto" diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/hopp-namespace.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/hopp-namespace.ts new file mode 100644 index 0000000..9686fce --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/hopp-namespace.ts @@ -0,0 +1,77 @@ +import { CageModuleCtx, defineSandboxFn } from "faraday-cage/modules" + +import type { EnvMethods, RequestProps, HoppNamespaceMethods } from "~/types" +import type { EnvAPIOptions } from "~/utils/shared" + +/** + * Creates hopp namespace methods for the sandbox environment + * Includes environment operations with hopp-specific API + */ +export const createHoppNamespaceMethods = ( + ctx: CageModuleCtx, + envMethods: EnvMethods, + requestProps: RequestProps +): HoppNamespaceMethods => { + return { + // `hopp` namespace environment methods + envDelete: defineSandboxFn( + ctx, + "envDelete", + function (key: unknown, options?: unknown) { + return envMethods.hopp.delete(key as string, options as EnvAPIOptions) + } + ), + envReset: defineSandboxFn( + ctx, + "envReset", + function (key: unknown, options?: unknown) { + return envMethods.hopp.reset(key as string, options as EnvAPIOptions) + } + ), + envGetInitialRaw: defineSandboxFn( + ctx, + "envGetInitialRaw", + function (key: unknown, options?: unknown) { + return envMethods.hopp.getInitialRaw( + key as string, + options as EnvAPIOptions + ) + } + ), + envSetInitial: defineSandboxFn( + ctx, + "envSetInitial", + function (key: unknown, value: unknown, options?: unknown) { + return envMethods.hopp.setInitial( + key as string, + value as string, + options as EnvAPIOptions + ) + } + ), + + // Request getter props + getRequestProps: defineSandboxFn(ctx, "getRequestProps", function () { + return { + get url() { + return requestProps.url + }, + get method() { + return requestProps.method + }, + get params() { + return requestProps.params + }, + get headers() { + return requestProps.headers + }, + get body() { + return requestProps.body + }, + get auth() { + return requestProps.auth + }, + } + }), + } +} diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/pm-namespace.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/pm-namespace.ts new file mode 100644 index 0000000..fa308cb --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/pm-namespace.ts @@ -0,0 +1,25 @@ +import { HoppRESTRequest } from "@hoppscotch/data" +import { CageModuleCtx, defineSandboxFn } from "faraday-cage/modules" + +import type { PmNamespaceMethods } from "~/types" + +/** + * Creates pm (Postman compatibility) namespace methods for the sandbox environment + * Provides Postman-compatible APIs for request information + */ +export const createPmNamespaceMethods = ( + ctx: CageModuleCtx, + config: { request: HoppRESTRequest } +): PmNamespaceMethods => { + return { + // `pm` namespace methods for Postman compatibility + pmInfoRequestName: defineSandboxFn(ctx, "pmInfoRequestName", () => { + return config.request.name + }), + pmInfoRequestId: defineSandboxFn(ctx, "pmInfoRequestId", () => { + // Use request.id if available, fallback to request.name + // Postman uses a unique ID, but for compatibility we use name if ID not set + return config.request.id || config.request.name + }), + } +} diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/pw-namespace.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/pw-namespace.ts new file mode 100644 index 0000000..2e3d684 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/pw-namespace.ts @@ -0,0 +1,69 @@ +import { CageModuleCtx, defineSandboxFn } from "faraday-cage/modules" + +import type { + EnvMethods, + RequestProps, + PwNamespaceMethods, + SandboxValue, +} from "~/types" + +/** + * Creates pw namespace methods for the sandbox environment + * Includes environment operations and request variable management + */ +export const createPwNamespaceMethods = ( + ctx: CageModuleCtx, + envMethods: EnvMethods, + requestProps: RequestProps +): PwNamespaceMethods => { + return { + // `pw` namespace environment methods + envGet: defineSandboxFn( + ctx, + "envGet", + function (key: SandboxValue, options: SandboxValue) { + return envMethods.pw.get(key, options) + } + ), + envGetResolve: defineSandboxFn( + ctx, + "envGetResolve", + function (key: SandboxValue, options: SandboxValue) { + return envMethods.pw.getResolve(key, options) + } + ), + envSet: defineSandboxFn( + ctx, + "envSet", + function (key: SandboxValue, value: SandboxValue, options: SandboxValue) { + return envMethods.pw.set(key, value, options) + } + ), + envUnset: defineSandboxFn( + ctx, + "envUnset", + function (key: SandboxValue, options: SandboxValue) { + return envMethods.pw.unset(key, options) + } + ), + envResolve: defineSandboxFn( + ctx, + "envResolve", + function (key: SandboxValue) { + return envMethods.pw.resolve(key) + } + ), + + // Request variable operations + getRequestVariable: defineSandboxFn( + ctx, + "getRequestVariable", + function (key: SandboxValue) { + const reqVarEntry = requestProps.requestVariables.find( + (reqVar: SandboxValue) => reqVar.key === key + ) + return reqVarEntry ? reqVarEntry.value : null + } + ), + } +} diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/scripting-modules.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/scripting-modules.ts new file mode 100644 index 0000000..342b497 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/scripting-modules.ts @@ -0,0 +1,576 @@ +import { Cookie, HoppRESTRequest } from "@hoppscotch/data" +import { + CageModuleCtx, + defineCageModule, + defineSandboxFn, + defineSandboxObject, +} from "faraday-cage/modules" +import { cloneDeep } from "lodash-es" + +import { getStatusReason } from "~/constants/http-status-codes" +import { BaseInputs, TestDescriptor, TestResponse, TestResult } from "~/types" +import postRequestBootstrapCode from "../bootstrap-code/post-request?raw" +import preRequestBootstrapCode from "../bootstrap-code/pre-request?raw" +import { createBaseInputs } from "./utils/base-inputs" +import { createChaiMethods } from "./utils/chai-helpers" +import { createExpectationMethods } from "./utils/expectation-helpers" +import { createRequestSetterMethods } from "./utils/request-setters" + +type PostRequestModuleConfig = { + envs: TestResult["envs"] + testRunStack: TestDescriptor[] + request: HoppRESTRequest + response: TestResponse + cookies: Cookie[] | null + handleSandboxResults: ({ + envs, + testRunStack, + cookies, + }: { + envs: TestResult["envs"] + testRunStack: TestDescriptor[] + cookies: Cookie[] | null + }) => void + onTestPromise?: (promise: Promise) => void +} + +type PreRequestModuleConfig = { + envs: TestResult["envs"] + request: HoppRESTRequest + cookies: Cookie[] | null + handleSandboxResults: ({ + envs, + request, + cookies, + }: { + envs: TestResult["envs"] + request: HoppRESTRequest + cookies: Cookie[] | null + }) => void +} + +type ModuleType = "pre" | "post" +type ModuleConfig = PreRequestModuleConfig | PostRequestModuleConfig + +/** + * Additional results that may be required for hook registration + */ +type HookRegistrationAdditionalResults = { + getUpdatedRequest: () => HoppRESTRequest +} + +/** + * Type for pre-request script inputs (includes BaseInputs + request setters) + */ +type PreRequestInputs = BaseInputs & + ReturnType["methods"] + +/** + * Type for post-request script inputs (includes BaseInputs + test/expectation methods) + */ +type PostRequestInputs = BaseInputs & + ReturnType & + ReturnType & { + preTest: ReturnType + postTest: ReturnType + setCurrentTest: ReturnType + clearCurrentTest: ReturnType + getCurrentTest: ReturnType + pushExpectResult: ReturnType + getResponse: ReturnType + responseReason: ReturnType + responseDataURI: ReturnType + responseJsonp: ReturnType + } + +/** + * Helper function to register after-script execution hooks with proper typing + * Overload for pre-request hooks (requires additionalResults) + */ +function registerAfterScriptExecutionHook( + ctx: CageModuleCtx, + type: "pre", + config: PreRequestModuleConfig, + baseInputs: ReturnType, + additionalResults: HookRegistrationAdditionalResults +): void + +/** + * Overload for post-request hooks (no additionalResults needed) + */ +function registerAfterScriptExecutionHook( + ctx: CageModuleCtx, + type: "post", + config: PostRequestModuleConfig, + baseInputs: ReturnType +): void + +/** + * Registers hook for capturing script results after async operations complete. + * We wait for keepAlivePromises to resolve before capturing results, + * ensuring env mutations from async callbacks (like hopp.fetch().then()) are included. + */ +function registerAfterScriptExecutionHook( + _ctx: CageModuleCtx, + _type: ModuleType, + _config: ModuleConfig, + _baseInputs: ReturnType, + _additionalResults?: HookRegistrationAdditionalResults +) { + // No-op: result capture happens after cage.runCode() completes. +} + +/** + * Creates input object for scripting modules with appropriate methods based on type + * Overloads ensure proper return types for pre vs post request contexts + */ +function createScriptingInputsObj( + ctx: CageModuleCtx, + type: "pre", + config: PreRequestModuleConfig, + captureGetUpdatedRequest?: (fn: () => HoppRESTRequest) => void +): PreRequestInputs +function createScriptingInputsObj( + ctx: CageModuleCtx, + type: "post", + config: PostRequestModuleConfig, + captureGetUpdatedRequest?: (fn: () => HoppRESTRequest) => void +): PostRequestInputs +function createScriptingInputsObj( + ctx: CageModuleCtx, + type: ModuleType, + config: ModuleConfig, + captureGetUpdatedRequest?: (fn: () => HoppRESTRequest) => void +): PreRequestInputs | PostRequestInputs { + if (type === "pre") { + const preConfig = config as PreRequestModuleConfig + + // Create request setter methods FIRST for pre-request scripts + const { methods: requestSetterMethods, getUpdatedRequest } = + createRequestSetterMethods(ctx, preConfig.request) + + // Capture the getUpdatedRequest function so the caller can use it + if (captureGetUpdatedRequest) { + captureGetUpdatedRequest(getUpdatedRequest) + } + + // Create base inputs with access to updated request + const baseInputs = createBaseInputs(ctx, { + envs: config.envs, + request: config.request, + cookies: config.cookies, + getUpdatedRequest, // Pass the updater function for pre-request + }) + + // Register hook with helper function + registerAfterScriptExecutionHook(ctx, "pre", preConfig, baseInputs, { + getUpdatedRequest, + }) + + return { + ...baseInputs, + ...requestSetterMethods, + } as PreRequestInputs + } + + // Create base inputs shared across all namespaces (post-request path) + const baseInputs = createBaseInputs(ctx, { + envs: config.envs, + request: config.request, + cookies: config.cookies, + }) + + if (type === "post") { + const postConfig = config as PostRequestModuleConfig + + // Track current executing test + let currentExecutingTest: TestDescriptor | null = null + + const getCurrentTestContext = (): TestDescriptor | null => { + return currentExecutingTest + } + + // Create expectation methods for post-request scripts + const expectationMethods = createExpectationMethods( + ctx, + postConfig.testRunStack, + getCurrentTestContext // Pass getter for current test context + ) + + // Create Chai methods + const chaiMethods = createChaiMethods( + ctx, + postConfig.testRunStack, + getCurrentTestContext // Pass getter for current test context + ) + + return { + ...baseInputs, + ...expectationMethods, + ...chaiMethods, + + // Test management methods + preTest: defineSandboxFn( + ctx, + "preTest", + function preTest(descriptor: unknown) { + const testDescriptor: TestDescriptor = { + descriptor: descriptor as string, + expectResults: [], + children: [], + } + + // Add to root.children immediately to preserve registration order. + postConfig.testRunStack[0].children.push(testDescriptor) + + // Stack tracking is handled by setCurrentTest() in bootstrap code. + + // Return the test descriptor so it can be set as context + return testDescriptor + } + ), + postTest: defineSandboxFn(ctx, "postTest", function postTest() { + // Test cleanup handled by clearCurrentTest() in bootstrap. + }), + setCurrentTest: defineSandboxFn( + ctx, + "setCurrentTest", + function setCurrentTest(descriptorName: unknown) { + // Find the test descriptor in the testRunStack by descriptor name + // This ensures we use the ACTUAL object, not a serialized copy + const found = postConfig.testRunStack[0].children.find( + (test) => test.descriptor === descriptorName + ) + currentExecutingTest = found || null + } + ), + clearCurrentTest: defineSandboxFn( + ctx, + "clearCurrentTest", + function clearCurrentTest() { + currentExecutingTest = null + } + ), + getCurrentTest: defineSandboxFn( + ctx, + "getCurrentTest", + function getCurrentTest() { + // Return the descriptor NAME (string) instead of the object + // This allows QuickJS code to store and pass it back to setCurrentTest() + return currentExecutingTest ? currentExecutingTest.descriptor : null + } + ), + // Helper to push expectation results directly to the current test + pushExpectResult: defineSandboxFn( + ctx, + "pushExpectResult", + function pushExpectResult(status: unknown, message: unknown) { + if (currentExecutingTest) { + currentExecutingTest.expectResults.push({ + status: status as "pass" | "fail" | "error", + message: message as string, + }) + } + } + ), + // Allow bootstrap code to notify when test promises are created + onTestPromise: postConfig.onTestPromise + ? defineSandboxFn( + ctx, + "onTestPromise", + function onTestPromise(promise: unknown) { + if (postConfig.onTestPromise) { + postConfig.onTestPromise(promise as Promise) + } + } + ) + : undefined, + getResponse: defineSandboxFn(ctx, "getResponse", function getResponse() { + return postConfig.response + }), + // Response utility methods as cage functions + responseReason: defineSandboxFn( + ctx, + "responseReason", + function responseReason() { + return getStatusReason(postConfig.response.status) + } + ), + responseDataURI: defineSandboxFn( + ctx, + "responseDataURI", + function responseDataURI() { + try { + const body = postConfig.response.body + const contentType = + postConfig.response.headers.find( + (h) => h.key.toLowerCase() === "content-type" + )?.value || "application/octet-stream" + + // Convert body to base64 (browser and Node.js compatible) + let base64Body: string + const bodyString = typeof body === "string" ? body : String(body) + + // Check if we're in a browser environment (btoa available) + if (typeof btoa !== "undefined") { + // Browser environment: use btoa + // btoa requires binary string, so we need to handle UTF-8 properly + const utf8Bytes = new TextEncoder().encode(bodyString) + const binaryString = Array.from(utf8Bytes, (byte) => + String.fromCharCode(byte) + ).join("") + base64Body = btoa(binaryString) + } else if (typeof Buffer !== "undefined") { + // Node.js environment: use Buffer + base64Body = Buffer.from(bodyString).toString("base64") + } else { + throw new Error("No base64 encoding method available") + } + + return `data:${contentType};base64,${base64Body}` + } catch (error) { + throw new Error(`Failed to convert response to data URI: ${error}`) + } + } + ), + responseJsonp: defineSandboxFn( + ctx, + "responseJsonp", + function responseJsonp(...args: unknown[]) { + const callbackName = args[0] + const body = postConfig.response.body + const text = typeof body === "string" ? body : String(body) + + if (callbackName && typeof callbackName === "string") { + // Escape special regex characters in callback name + const escapedName = callbackName.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&" + ) + const regex = new RegExp( + `^\\s*${escapedName}\\s*\\(([\\s\\S]*)\\)\\s*;?\\s*$` + ) + const match = text.match(regex) + if (match && match[1]) { + return JSON.parse(match[1]) + } + } + + // Auto-detect callback wrapper + const autoDetect = text.match( + /^\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(([\s\S]*)\)\s*;?\s*$/ + ) + if (autoDetect && autoDetect[2]) { + try { + return JSON.parse(autoDetect[2]) + } catch { + // If parsing fails, fall through to plain JSON + } + } + + // No JSONP wrapper found, parse as plain JSON + return JSON.parse(text) + } + ), + } as PostRequestInputs + } + + // This should never be reached due to the type guards above + throw new Error(`Invalid module type: ${type}`) +} + +/** + * Creates a scripting module for pre or post request execution + */ +const createScriptingModule = ( + type: ModuleType, + bootstrapCode: string, + config: ModuleConfig, + captureHook?: { + capture?: () => void + bootstrapError?: unknown + scriptExecutionError?: { name: string; message: string; stack: string } + } +) => { + return defineCageModule((ctx) => { + // Track test promises for keepAlive (only for post-request scripts) + const testPromises: Promise[] = [] + let resolveKeepAlive: (() => void) | null = null + let rejectKeepAlive: ((error: Error) => void) | null = null + + // Only register keepAlive for post-request tests; pre-request scripts shouldn't block on this + let testPromiseKeepAlive: Promise | null = null + if ((type as ModuleType) === "post") { + testPromiseKeepAlive = new Promise((resolve, reject) => { + resolveKeepAlive = resolve + rejectKeepAlive = reject + }) + ctx.keepAlivePromises.push(testPromiseKeepAlive) + } + + // Wrap onTestPromise to track in testPromises array (post-request only) + const originalOnTestPromise = (config as PostRequestModuleConfig) + .onTestPromise + if (originalOnTestPromise) { + ;(config as PostRequestModuleConfig).onTestPromise = (promise) => { + testPromises.push(promise) + originalOnTestPromise(promise) + } + } + + const funcHandle = ctx.scope.manage(ctx.vm.evalCode(bootstrapCode)).unwrap() + + // Capture getUpdatedRequest via callback for pre-request scripts + let getUpdatedRequest: (() => HoppRESTRequest) | undefined = undefined + // Type assertion needed here because TypeScript can't narrow ModuleType to "pre" | "post" + // in this generic context. The function overloads ensure type safety at call sites. + const inputsObj = createScriptingInputsObj( + ctx, + type as "pre", + config as PreRequestModuleConfig, + (fn) => { + getUpdatedRequest = fn + } + ) as PreRequestInputs | PostRequestInputs + + // Set up capture function to capture results after runCode() completes. + if (captureHook && type === "pre") { + const preConfig = config as PreRequestModuleConfig + const preInputs = inputsObj as PreRequestInputs + + captureHook.capture = () => { + const capturedEnvs = preInputs.getUpdatedEnvs() || { + global: [], + selected: [], + } + // Use the getUpdatedRequest from request setters (via createRequestSetterMethods) + // This returns the mutated request, not the original + const finalRequest = getUpdatedRequest + ? getUpdatedRequest() + : config.request + + preConfig.handleSandboxResults({ + envs: capturedEnvs, + request: finalRequest, + cookies: preInputs.getUpdatedCookies() || null, + }) + } + } else if (captureHook && type === "post") { + const postConfig = config as PostRequestModuleConfig + const postInputs = inputsObj as PostRequestInputs + + captureHook.capture = () => { + // Deep clone testRunStack to prevent UI reactivity to async mutations + // Without this, async test callbacks that complete after capture will mutate + // the same object being displayed in the UI, causing flickering test results + + postConfig.handleSandboxResults({ + envs: postInputs.getUpdatedEnvs() || { + global: [], + selected: [], + }, + testRunStack: cloneDeep(postConfig.testRunStack), + cookies: postInputs.getUpdatedCookies() || null, + }) + } + } + + // Reporter invoked from the generated `catch` block that wraps the + // experimental IIFE chain. This is a synchronous host callback — unlike + // rejected keepAlive promises or async afterScriptExecutionHooks, these + // calls cross the QuickJS boundary before the script returns, so the + // host sees the error without relying on faraday-cage's loop behaviour. + ;(inputsObj as Record).setScriptExecutionError = + defineSandboxFn(ctx, "setScriptExecutionError", (error: unknown) => { + if (!captureHook || captureHook.scriptExecutionError) return + const err = (error ?? {}) as { + name?: unknown + message?: unknown + stack?: unknown + } + captureHook.scriptExecutionError = { + name: typeof err.name === "string" ? err.name : "", + message: + typeof err.message === "string" ? err.message : String(error), + stack: typeof err.stack === "string" ? err.stack : "", + } + }) + + const sandboxInputsObj = defineSandboxObject(ctx, inputsObj) + + const bootstrapResult = ctx.vm.callFunction( + funcHandle, + ctx.vm.undefined, + sandboxInputsObj + ) + + // Track bootstrap state for error detection + let testExecutionChainPromise: any = null + if (bootstrapResult.error) { + const bootstrapError = ctx.vm.dump(bootstrapResult.error) + + if (captureHook) { + captureHook.bootstrapError = bootstrapError + } + + bootstrapResult.error.dispose() + } else if (bootstrapResult.value) { + testExecutionChainPromise = bootstrapResult.value + // Don't dispose the value yet - we need to await it + } + + // Wait for test execution chain before resolving keepAlive. + // Ensures QuickJS context stays active for callbacks accessing handles (pm.expect, etc.). + if ((type as ModuleType) === "post") { + ctx.afterScriptExecutionHooks.push(async () => { + try { + // If we have a test execution chain, await it + if (testExecutionChainPromise) { + const resolvedPromise = ctx.vm.resolvePromise( + testExecutionChainPromise + ) + testExecutionChainPromise.dispose() + + const awaitResult = await resolvedPromise + if (awaitResult.error) { + const errorDump = ctx.vm.dump(awaitResult.error) + awaitResult.error.dispose() + // Propagate test execution errors. + const error = new Error( + typeof errorDump === "string" + ? errorDump + : JSON.stringify(errorDump) + ) + rejectKeepAlive?.(error) + return + } else { + awaitResult.value?.dispose() + } + } + + // Also wait for any old-style test promises (for backwards compatibility) + if (testPromises.length > 0) { + await Promise.allSettled(testPromises) + } + + resolveKeepAlive?.() + } catch (error) { + rejectKeepAlive?.( + error instanceof Error ? error : new Error(String(error)) + ) + } + }) + } + }) +} + +export const preRequestModule = ( + config: PreRequestModuleConfig, + captureHook?: { capture?: () => void; bootstrapError?: unknown } +) => createScriptingModule("pre", preRequestBootstrapCode, config, captureHook) + +export const postRequestModule = ( + config: PostRequestModuleConfig, + captureHook?: { capture?: () => void; bootstrapError?: unknown } +) => + createScriptingModule("post", postRequestBootstrapCode, config, captureHook) diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/utils/base-inputs.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/base-inputs.ts new file mode 100644 index 0000000..9dc0921 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/base-inputs.ts @@ -0,0 +1,178 @@ +import { Cookie, HoppRESTRequest } from "@hoppscotch/data" +import { CageModuleCtx, defineSandboxFn } from "faraday-cage/modules" + +import { TestResult, BaseInputs, SandboxValue } from "~/types" +import { + getSharedCookieMethods, + getSharedEnvMethods, + getSharedRequestProps, +} from "~/utils/shared" +import { UNDEFINED_MARKER, NULL_MARKER } from "~/constants/sandbox-markers" +import { createHoppNamespaceMethods } from "../namespaces/hopp-namespace" +import { createPmNamespaceMethods } from "../namespaces/pm-namespace" +import { createPwNamespaceMethods } from "../namespaces/pw-namespace" + +type BaseInputsConfig = { + /** + * Environment variables typed as TestResult["envs"] for external API compatibility. + * At runtime, this will be mutated to contain SandboxValue types (arrays, objects, etc.) + * during script execution to support PM namespace compatibility. + * See `getSharedEnvMethods()` for detailed explanation of the type flow. + */ + envs: TestResult["envs"] + request: HoppRESTRequest + cookies: Cookie[] | null + getUpdatedRequest?: () => HoppRESTRequest +} + +/** + * Creates the base input object containing all shared methods across namespaces + */ +export const createBaseInputs = ( + ctx: CageModuleCtx, + config: BaseInputsConfig +): BaseInputs => { + // Get environment methods - Applicable to both hopp and pw namespaces + const { + methods: envMethods, + pmSetAny, + updatedEnvs, + } = getSharedEnvMethods(config.envs, true) + + const { methods: cookieMethods, getUpdatedCookies } = getSharedCookieMethods( + config.cookies + ) + + // Get request properties - shared across pre and post request contexts + // For pre-request, use the updater function to read from mutated request + const requestProps = getSharedRequestProps( + config.request, + config.getUpdatedRequest + ) + + // Cookie accessors + const cookieProps = { + cookieGet: defineSandboxFn( + ctx, + "cookieGet", + (domain: SandboxValue, name: SandboxValue) => { + return cookieMethods.get(domain, name) || null + } + ), + cookieSet: defineSandboxFn( + ctx, + "cookieSet", + (domain: SandboxValue, cookie: SandboxValue) => { + return cookieMethods.set(domain, cookie) + } + ), + cookieHas: defineSandboxFn( + ctx, + "cookieHas", + (domain: SandboxValue, name: SandboxValue) => { + return cookieMethods.has(domain, name) + } + ), + cookieGetAll: defineSandboxFn( + ctx, + "cookieGetAll", + (domain: SandboxValue) => { + return cookieMethods.getAll(domain) + } + ), + cookieDelete: defineSandboxFn( + ctx, + "cookieDelete", + (domain: SandboxValue, name: SandboxValue) => { + return cookieMethods.delete(domain, name) + } + ), + cookieClear: defineSandboxFn(ctx, "cookieClear", (domain: SandboxValue) => { + return cookieMethods.clear(domain) + }), + } + + // Environment accessors for toObject() support + const envAccessors = { + getAllSelectedEnvs: defineSandboxFn(ctx, "getAllSelectedEnvs", () => { + return updatedEnvs.selected || [] + }), + getAllGlobalEnvs: defineSandboxFn(ctx, "getAllGlobalEnvs", () => { + return updatedEnvs.global || [] + }), + } + + // Combine all namespace methods + const pwMethods = createPwNamespaceMethods(ctx, envMethods, requestProps) + const hoppMethods = createHoppNamespaceMethods(ctx, envMethods, requestProps) + const pmMethods = createPmNamespaceMethods(ctx, config) + + // PM namespace-specific setter that accepts any type (for type preservation) + const pmEnvSetAny = defineSandboxFn( + ctx, + "pmEnvSetAny", + function (key: SandboxValue, value: SandboxValue, options: SandboxValue) { + return pmSetAny(key, value, options) + } + ) + + return { + ...pwMethods, + ...hoppMethods, + ...pmMethods, + ...cookieProps, + ...envAccessors, + // PM-specific env setter (preserves all types) + pmEnvSetAny, + // Expose the updated state accessors + getUpdatedEnvs: () => { + // Convert markers back to strings for UI display + // (using centralized markers from constants/sandbox-markers.ts) + + // Handle case where envs is not provided + if (!updatedEnvs) { + return { global: [], selected: [] } + } + + const convertMarkersToStrings = (env: SandboxValue) => { + const convertValue = (value: SandboxValue) => { + // Convert markers to string representations + if (value === UNDEFINED_MARKER) return "undefined" + if (value === NULL_MARKER) return "null" + + // Convert complex types (arrays, objects) to JSON strings for UI display + // This prevents Vue UI from calling .match() on non-string values + if (typeof value === "object" && value !== null) { + try { + return JSON.stringify(value) + } catch (_) { + // If JSON.stringify fails (circular refs, etc.), return string representation + return String(value) + } + } + + // Convert all non-string primitives to strings for UI compatibility + // Vue UI calls .match() on values, which only works on strings + if (typeof value !== "string") { + return String(value) + } + + // Return strings as-is + return value + } + + return { + ...env, + currentValue: convertValue(env.currentValue), + initialValue: convertValue(env.initialValue), + } + } + + return { + global: (updatedEnvs.global || []).map(convertMarkersToStrings), + selected: (updatedEnvs.selected || []).map(convertMarkersToStrings), + } + }, + getUpdatedCookies, + } +} diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/utils/chai-helpers.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/chai-helpers.ts new file mode 100644 index 0000000..46a8537 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/chai-helpers.ts @@ -0,0 +1,2297 @@ +import { CageModuleCtx, defineSandboxFn } from "faraday-cage/modules" +import * as chai from "chai" +import { TestDescriptor, SandboxValue } from "~/types" + +/** + * Creates Chai-based assertion methods that can be used across the sandbox boundary + * Each method wraps actual Chai.js assertions and records results to the test context + * + * Tests context instead of stack position. + * Uses getCurrentTestContext() to get the active test descriptor for expectation placement + * This ensures async test expectations go to the correct test, not whatever is on top of stack + */ +export const createChaiMethods: ( + ctx: CageModuleCtx, + testStack: TestDescriptor[], + getCurrentTestContext?: () => TestDescriptor | null +) => Record = (ctx, testStack, getCurrentTestContext) => { + /** + * Helper to get the current test descriptor for expectation placement + * Prefers test context over stack position + */ + const getCurrentTest = (): TestDescriptor | null => { + // Prefer explicit test context, but fallback to stack for top-level expectations + return ( + getCurrentTestContext?.() || + (testStack.length > 0 ? testStack[testStack.length - 1] : null) + ) + } + + /** + * Helper to execute a Chai assertion and record the result + * Uses test context if available, otherwise falls back to stack (for backward compatibility) + */ + const executeChaiAssertion = (assertionFn: () => void, message: string) => { + const targetTest = getCurrentTest() + if (!targetTest) { + return + } + + try { + assertionFn() + // Record success to the correct test descriptor + targetTest.expectResults.push({ + status: "pass", + message, + }) + } catch (_error: any) { + // Record failure but DON'T throw - allow test to continue + targetTest.expectResults.push({ + status: "fail", + message, + }) + // Don't throw - let the test continue to execute all assertions + } + } + + /** + * Helper to format values for display in messages + */ + // Helper to apply modifiers (not, deep, include, etc.) to a Chai assertion + const applyModifiers = (value: SandboxValue, modifiers: string) => { + let assertion: any = chai.expect(value) + const isNot = modifiers.includes("not") + const isDeep = modifiers.includes("deep") + const isInclude = modifiers.includes("include") + + if (isNot) assertion = assertion.to.not + else assertion = assertion.to + + if (isInclude) assertion = assertion.include + if (isDeep) assertion = assertion.deep + + return assertion + } + + const formatValue = (val: unknown): string => { + if (val === null) return "null" + if (val === undefined) return "undefined" + // Handle BigInt + if (typeof val === "bigint") return String(val) + "n" + // Handle Symbol + if (typeof val === "symbol") return String(val) + // Handle functions (including constructors) - return as-is without quotes + if (typeof val === "function") { + // For named constructors, return just the name + if (val.name && /^[A-Z]/.test(val.name)) { + return val.name + } + // For other functions, return the string representation without quotes + return String(val) + } + // Handle strings that look like functions/constructors (serialized from sandbox) + if (typeof val === "string") { + const trimmed = val.trim() + + // Check for pre-formatted special values (Set, Map, RegExp patterns) + if (trimmed.startsWith("new Set(") || trimmed.startsWith("new Map(")) { + return val // Return without quotes - already formatted + } + if (trimmed.match(/^\/.*\/[gimsuvy]*$/)) { + return val // Return regex pattern without quotes - already formatted + } + + // Check for constructor names (capitalized identifiers) + // Only match known built-in constructors to avoid matching regular strings like "Alice" + const knownConstructors = [ + "Array", + "Object", + "String", + "Number", + "Boolean", + "Date", + "RegExp", + "Error", + "TypeError", + "RangeError", + "ReferenceError", + "SyntaxError", + "Set", + "Map", + "WeakSet", + "WeakMap", + "Promise", + "Symbol", + "Function", + ] + // Also allow any identifier ending with "Error" (for custom error types) + const isErrorConstructor = + /^[A-Z][a-zA-Z0-9]*Error$/.test(trimmed) || + knownConstructors.includes(trimmed) + + if (/^[A-Z][a-zA-Z0-9]*$/.test(trimmed) && isErrorConstructor) { + return trimmed // Return constructor name without quotes + } + + // Check if string looks like a function definition + if ( + trimmed.startsWith("function") || + trimmed.match(/^\(.*\)\s*=>/) || + trimmed.match(/^[a-zA-Z_$][\w$]*\s*\(/) + ) { + // Keep the original function structure instead of simplifying to [Function] + // This preserves function signatures like "function Cat() {}" in messages + return trimmed + } + + // Check for native code functions + if (trimmed.includes("[native code]")) { + // Extract function name from "function TypeEr[ror() {\n [native code]\n}" + const nameMatch = trimmed.match(/function\s+([A-Z][a-zA-Z0-9]*)\s*\(/) + if (nameMatch) { + return nameMatch[1] + } + } + + return `'${val}'` + } + if (typeof val === "number") { + if (isNaN(val)) return "NaN" + if (val === Infinity) return "Infinity" + if (val === -Infinity) return "-Infinity" + if (val === Math.PI) return "Math.PI" + if (val === Math.E) return "Math.E" + return String(val) + } + if (typeof val === "boolean") return String(val) + if (Array.isArray(val)) { + if (val.length === 0) return "[]" + const items = val.slice(0, 10).map(formatValue) + return `[${items.join(", ")}]` // Space after comma for readability + } + if (typeof val === "object") { + try { + // Handle special object types + if (val instanceof Map) { + const entries = Array.from(val.entries()).slice(0, 3) + if (entries.length === 0) return "new Map()" + // Fix Map formatting for .map((entry: unknown) => ...) + const formatted = entries.map((entry: unknown) => { + const [key, value] = entry as [unknown, unknown] + return `[${key}, ${value}]` + }) + return `new Map([${formatted.join(", ")}])` + } + if (val instanceof Set) { + const values = Array.from(val).slice(0, 10) + if (values.length === 0) return "new Set()" + return `new Set([${values.map(formatValue).join(", ")}])` + } + if (val instanceof Date) return `new Date(${val.toISOString()})` + if (val instanceof RegExp) return val.toString() + + // Check constructor name for objects that lost their prototype + const constructorName = (val as { constructor?: { name?: string } }) + .constructor?.name + if (constructorName && constructorName !== "Object") { + // Special handling for Set/Map that lost prototype but have size property + const objWithSize = val as { size?: unknown } + if ( + constructorName === "Set" && + typeof objWithSize.size === "number" + ) { + return `new Set()` + } + if ( + constructorName === "Map" && + typeof objWithSize.size === "number" + ) { + return `new Map()` + } + // Special handling for RegExp that lost prototype + const objWithRegex = val as { source?: unknown; flags?: unknown } + if ( + constructorName === "RegExp" && + typeof objWithRegex.source === "string" + ) { + const flags = objWithRegex.flags || "" + return `/${objWithRegex.source}/${flags}` + } + return `new ${constructorName}()` + } + + // Check if it's a RegExp that lost its prototype + const objWithRegex = val as { source?: unknown; flags?: unknown } + if (typeof objWithRegex.source === "string") { + const flags = objWithRegex.flags || "" + return `/${objWithRegex.source}/${flags}` + } + + // Check if it's a Set or Map that lost its prototype + const objWithMethods = val as { + size?: unknown + has?: unknown + forEach?: unknown + } + if ( + typeof objWithMethods.size === "number" && + typeof objWithMethods.has === "function" + ) { + // Likely a Set or Map + return objWithMethods.forEach ? "new Set()" : "new Map()" + } + + const keys = Object.keys(val) + if (keys.length === 0) return "{}" + const pairs = keys + .slice(0, 5) + .map( + (k) => `${k}: ${formatValue((val as Record)[k])}` + ) + return `{${pairs.join(", ")}}` + } catch { + return "[object Object]" + } + } + if (typeof val === "function") { + return val.name || "[Function]" + } + return String(val) + } + + /** + * Build message with modifiers + * Cleans up duplicate words and formats properly + */ + const buildMessage = ( + value: SandboxValue, + modifiers: string, + assertion: string, + args: unknown[] = [] + ): string => { + const valueStr = formatValue(value) + + // Clean up modifiers + let cleanModifiers = modifiers + .replace(/\s+/g, " ") // normalize whitespace + .trim() + + // Remove language chain words that don't add meaning to assertions + // Handle "that has" and "that does" carefully + const hasTypePrefix = cleanModifiers.match( + /\b(array|object|number|string|boolean|function)\s+that\s+has\b/ + ) + + if (!hasTypePrefix) { + // Standalone "that has" -> "have", "that does" -> "" + cleanModifiers = cleanModifiers.replace(/\bthat\s+has\b/g, "have") + cleanModifiers = cleanModifiers.replace(/\bthat\s+does\b/g, "") + // Replace standalone "has" with "have" + cleanModifiers = cleanModifiers.replace(/\bhas\b/g, "have") + } else { + // Keep "that has" when preceded by a type (e.g., "be an array that has lengthOf") + // Just remove "that does" + cleanModifiers = cleanModifiers.replace(/\bthat\s+does\b/g, "") + } + + // Replace "is" with "be" + cleanModifiers = cleanModifiers.replace(/\bis\b/g, "be") + + // Remove remaining pure language chains (but keep "itself" as it's meaningful) + const languageChains = ["which", "does", "but"] + languageChains.forEach((word) => { + cleanModifiers = cleanModifiers.replace( + new RegExp(`\\b${word}\\b`, "g"), + "" + ) + }) + + // Clean up extra spaces after removing words + cleanModifiers = cleanModifiers.replace(/\s+/g, " ").trim() + + // Remove duplicate consecutive words (e.g., "be be" -> "be", "have have" -> "have") + cleanModifiers = cleanModifiers.replace(/\b(\w+)(\s+\1\b)+/g, "$1") + + // Ensure it starts with "to" if not empty + if (cleanModifiers && !cleanModifiers.startsWith("to")) { + cleanModifiers = "to " + cleanModifiers + } else if (!cleanModifiers) { + cleanModifiers = "to" + } + + // Build base message + let message = `Expected ${valueStr} ${cleanModifiers}` + + // Add assertion, checking if it duplicates the last word in modifiers + const modifierWords = cleanModifiers.trim().split(/\s+/) + const lastModWord = modifierWords[modifierWords.length - 1] + const firstAssertWord = assertion.split(/\s+/)[0] + + if (lastModWord === firstAssertWord) { + // Skip the duplicate word in assertion + const assertionRest = assertion.substring(firstAssertWord.length).trim() + if (assertionRest) { + message += ` ${assertionRest}` + } + } else { + message += ` ${assertion}` + } + + if (args.length > 0) { + // Special handling for keys assertions + if (assertion === "keys") { + let keys = args + // If first arg is an array and it's the only arg, flatten it + if (args.length === 1 && Array.isArray(args[0])) { + keys = args[0] + } + // Format keys with quotes (including numbers) + const keyStrs = keys.map((k) => + typeof k === "number" ? `'${k}'` : formatValue(k) + ) + message += ` ${keyStrs.join(", ")}` + } else if (assertion === "members") { + // Format members - if first arg is already an array, don't double-wrap + if (args.length === 1 && Array.isArray(args[0])) { + // Single array argument - format its contents directly + const memberStrs = args[0].map(formatValue) + message += ` [${memberStrs.join(", ")}]` + } else { + // Multiple arguments or non-array - wrap them + const argStrs = args.map(formatValue) + message += ` [${argStrs.join(", ")}]` + } + } else { + const argStrs = args.map(formatValue) + // Add comma separator for property-like assertions, space for others + const separator = assertion.includes("property") ? ", " : " " + message += `${separator}${argStrs.join(", ")}` + } + } + + return message + } + + // Return all Chai methods wrapped with defineSandboxFn + return { + // Equality assertions + chaiEqual: defineSandboxFn(ctx, "chaiEqual", (( + value: SandboxValue, + expected: SandboxValue, + modifiers: SandboxValue, + methodName: SandboxValue, + isSameReference?: boolean, + typeInfo?: SandboxValue + ) => { + const mods = modifiers || " to" + const isDeep = String(mods).includes("deep") + + // PRE-CHECK PATTERN: Use reference equality check from sandbox + // ONLY applies to .equal() WITHOUT .deep modifier + // Special handling: Date/RegExp have typeInfo even after serialization to strings + if ( + !isDeep && + isSameReference !== undefined && + (typeInfo || + (typeof value === "object" && + value !== null && + typeof expected === "object" && + expected !== null)) + ) { + const isNegated = String(mods).includes("not") + const shouldPass = isNegated ? !isSameReference : isSameReference + + // For Date/RegExp, use type info to display proper values + let displayValue = formatValue(value) + let displayExpected = formatValue(expected) + + if (typeInfo) { + const info = typeInfo as { + type?: string + valueTime?: unknown + expectedTime?: unknown + valueSource?: unknown + valueFlags?: unknown + expectedSource?: unknown + expectedFlags?: unknown + } + if (info.type === "Date") { + displayValue = new Date(info.valueTime as number).toISOString() + displayExpected = new Date( + info.expectedTime as number + ).toISOString() + } else if (info.type === "RegExp") { + displayValue = `/${info.valueSource}/${info.valueFlags}` + displayExpected = `/${info.expectedSource}/${info.expectedFlags}` + } + } + + executeChaiAssertion( + () => { + if (!shouldPass) { + throw new Error( + `Expected ${displayValue}${String(mods)} ${String(methodName || "equal")} ${displayExpected}` + ) + } + }, + buildMessage( + displayValue, + String(mods), + String(methodName || "equal"), + [displayExpected] + ) + ) + } else { + // For primitives, .deep.equal(), or when reference info not available, use default Chai behavior + executeChaiAssertion( + () => applyModifiers(value, mods).equal(expected), + buildMessage(value, String(mods), methodName || "equal", [expected]) + ) + } + }) as any), + + chaiEql: defineSandboxFn(ctx, "chaiEql", (( + value: SandboxValue, + expected: SandboxValue, + modifiers?: SandboxValue, + valueMetadata?: SandboxValue, + expectedMetadata?: SandboxValue + ) => { + const mods = modifiers || " to" + + // PRE-CHECK PATTERN: Use metadata for special objects (RegExp, Date) + if (valueMetadata && expectedMetadata) { + const valueMeta = valueMetadata as { + type?: string + source?: unknown + flags?: unknown + time?: unknown + } + const expectedMeta = expectedMetadata as { + type?: string + source?: unknown + flags?: unknown + time?: unknown + } + const isNegated = String(mods).includes("not") + let matches = false + + if (valueMeta.type === "RegExp") { + // Compare RegExp by source and flags + matches = + valueMeta.source === expectedMeta.source && + valueMeta.flags === expectedMeta.flags + } else if (valueMeta.type === "Date") { + // Compare Date by timestamp + matches = valueMeta.time === expectedMeta.time + } + + const shouldPass = isNegated ? !matches : matches + + executeChaiAssertion( + () => { + if (!shouldPass) { + const displayValue = + valueMeta.type === "RegExp" + ? `/${valueMeta.source}/${valueMeta.flags}` + : new Date(valueMeta.time as number).toISOString() + const displayExpected = + expectedMeta.type === "RegExp" + ? `/${expectedMeta.source}/${expectedMeta.flags}` + : new Date(expectedMeta.time as number).toISOString() + + throw new Error( + `Expected ${displayValue}${String(mods)} eql ${displayExpected}` + ) + } + }, + valueMeta.type === "RegExp" + ? buildMessage( + `/${valueMeta.source}/${valueMeta.flags}`, + mods, + "eql", + [`/${expectedMeta.source}/${expectedMeta.flags}`] + ) + : buildMessage(value, mods, "eql", [expected]) + ) + } else { + // Default behavior for non-special objects + executeChaiAssertion( + () => applyModifiers(value, mods).eql(expected), + buildMessage(value, String(mods), "eql", [expected]) + ) + } + }) as any), + + chaiDeepEqual: defineSandboxFn( + ctx, + "chaiDeepEqual", + ( + value: SandboxValue, + expected: SandboxValue, + modifiers?: SandboxValue + ) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => applyModifiers(value, mods).deep.equal(expected), + buildMessage(value, String(mods), "deep equal", [expected]) + ) + } + ), + + // Type assertions + chaiTypeOf: defineSandboxFn( + ctx, + "chaiTypeOf", + ( + value: SandboxValue, + type: SandboxValue, + modifiers?: SandboxValue, + preCheckedType?: unknown + ) => { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + + // Use "an" for vowel sounds, "a" otherwise + const article = /^[aeiou]/i.test(type) ? "an" : "a" + + // PRE-CHECK PATTERN: Use pre-checked typeof result from sandbox + // After serialization, functions may not be recognized as functions by Chai + if (preCheckedType !== undefined) { + let matches = String(preCheckedType) === String(type) + + // Special case: Arrays are both 'array' and 'object' in JavaScript + // typeof [] === 'object', but Chai also recognizes 'array' + if (preCheckedType === "array" && type === "object") { + matches = true // Arrays should pass for both 'array' and 'object' + } + + const shouldPass = isNegated ? !matches : matches + + const targetTest = getCurrentTest() + if (!targetTest) return + targetTest.expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: buildMessage(value, mods, `${article} ${type}`), + }) + } else { + // Fallback to Chai's type checking if no pre-check available + executeChaiAssertion( + () => applyModifiers(value, mods).be.a(type), + buildMessage(value, String(mods), `${article} ${type}`) + ) + } + } + ), + + chaiInstanceOf: defineSandboxFn( + ctx, + "chaiInstanceOf", + function ( + value, + constructorName, + modifiers, + preCheckedType, + displayValue, + actualInstanceCheck + ) { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + + // PRE-CHECK PATTERN: Use Object.prototype.toString result from sandbox + // We receive the constructor NAME as a string, not the constructor itself + // Map of constructor names to their Object.prototype.toString signatures + const builtInTypes: Record = { + Array: "[object Array]", + Date: "[object Date]", + Error: "[object Error]", + RegExp: "[object RegExp]", + Set: "[object Set]", + Map: "[object Map]", + TypeError: "[object Error]", + RangeError: "[object Error]", + ReferenceError: "[object Error]", + SyntaxError: "[object Error]", + } + + // constructorName is already a string from the sandbox + const constructorNameStr = String(constructorName) + + // Check if this is a built-in type we can detect + const expectedType = builtInTypes[constructorNameStr] + const isBuiltIn = expectedType !== undefined + + // Use pre-checked type if available, otherwise fall back to actual check + const actualType = + preCheckedType || Object.prototype.toString.call(value) + + let isInstance: boolean + // Special case: Object constructor + // In JavaScript, all objects (arrays, plain objects, dates, etc.) are instanceof Object + // Check if value is an object type (not null, not undefined, not primitives) + if (constructorNameStr === "Object") { + const valueType = typeof value + const isObjectType = + value !== null && + value !== undefined && + (valueType === "object" || valueType === "function") + isInstance = isObjectType + } else if (isBuiltIn) { + // For built-in types, compare Object.prototype.toString results + isInstance = actualType === expectedType + } else { + // For custom types, use the pre-checked result from sandbox + // The instanceof check was performed before serialization + isInstance = Boolean(actualInstanceCheck) + } + + const shouldPass = isNegated ? !isInstance : isInstance + + // Use displayValue if provided (for Set/Map), otherwise use value + const valueToDisplay = displayValue || value + + executeChaiAssertion( + () => { + if (!shouldPass) { + throw new Error( + `Expected ${formatValue(valueToDisplay)}${String(mods)} be an instanceof ${constructorNameStr}` + ) + } + }, + buildMessage(valueToDisplay, String(mods), `be an instanceof`, [ + constructorNameStr, + ]) + ) + } + ), + + // Truthiness assertions + chaiOk: defineSandboxFn( + ctx, + "chaiOk", + (value: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + void assertion.be.ok + }, + buildMessage(value, String(mods), "ok") + ) + } + ), + + chaiTrue: defineSandboxFn( + ctx, + "chaiTrue", + (value: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + void assertion.be.true + }, + buildMessage(value, String(mods), "true") + ) + } + ), + + chaiFalse: defineSandboxFn( + ctx, + "chaiFalse", + (value: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + void assertion.be.false + }, + buildMessage(value, String(mods), "false") + ) + } + ), + + // Nullish assertions + chaiNull: defineSandboxFn( + ctx, + "chaiNull", + (value: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + void assertion.be.null + }, + buildMessage(value, String(mods), "null") + ) + } + ), + + chaiUndefined: defineSandboxFn( + ctx, + "chaiUndefined", + (value: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + void assertion.be.undefined + }, + buildMessage(value, String(mods), "undefined") + ) + } + ), + + chaiNaN: defineSandboxFn( + ctx, + "chaiNaN", + (value: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + void assertion.be.NaN + }, + buildMessage(value, String(mods), "NaN") + ) + } + ), + + chaiExist: defineSandboxFn( + ctx, + "chaiExist", + (value: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + void assertion.exist + }, + buildMessage(value, String(mods), "exist") + ) + } + ), + + // Emptiness assertions + chaiEmpty: defineSandboxFn( + ctx, + "chaiEmpty", + ( + value: SandboxValue, + modifiers?: SandboxValue, + typeName?: unknown, + actualSize?: unknown + ) => { + const mods = modifiers || " to" + // Special handling for Set/Map + let isEmpty = false + let displayValue = value + + if (typeName === "Set" || typeName === "Map") { + // Use pre-checked size from sandbox + isEmpty = actualSize === 0 + if (actualSize === 0) { + displayValue = "{}" + } else { + // After serialization, Set/Map become {}, but we have the info they weren't empty + displayValue = value + } + } else if (value instanceof Set) { + isEmpty = value.size === 0 + displayValue = + value.size === 0 + ? "new Set()" + : `new Set([${Array.from(value).join(", ")}])` + } else if (value instanceof Map) { + isEmpty = value.size === 0 + displayValue = + value.size === 0 + ? "new Map()" + : `new Map([${Array.from(value.entries()) + .map((entry: unknown) => { + const [key, value] = entry as [unknown, unknown] + return `[${key}, ${value}]` + }) + .join(", ")}])` + } else if (Array.isArray(value)) { + isEmpty = value.length === 0 + } else if (typeof value === "object" && value !== null) { + isEmpty = Object.keys(value).length === 0 + } else if (typeof value === "string") { + isEmpty = value.length === 0 + } + const isNegated = String(mods).includes("not") + const pass = isNegated ? !isEmpty : isEmpty + const targetTest = getCurrentTest() + if (!targetTest) return + targetTest.expectResults.push({ + status: pass ? "pass" : "fail", + message: buildMessage(displayValue, mods, "empty"), + }) + } + ), + + // Inclusion assertions + chaiInclude: defineSandboxFn( + ctx, + "chaiInclude", + (value: SandboxValue, item: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => applyModifiers(value, mods).include(item), + buildMessage(value, String(mods), "include", [item]) + ) + } + ), + + chaiDeepInclude: defineSandboxFn( + ctx, + "chaiDeepInclude", + (value: SandboxValue, item: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => applyModifiers(value, mods).deep.include(item), + buildMessage(value, String(mods), "deep include", [item]) + ) + } + ), + + chaiIncludeKeys: defineSandboxFn( + ctx, + "chaiIncludeKeys", + (value: SandboxValue, keys: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.include.keys(...keys) + }, + buildMessage(value, String(mods), "include keys", keys) + ) + } + ), + + // Length assertions + chaiLengthOf: defineSandboxFn( + ctx, + "chaiLengthOf", + function ( + value: SandboxValue, + length: unknown, + modifiers: SandboxValue, + methodName: SandboxValue, + actualSize?: unknown, + typeName?: unknown + ) { + const mods = (modifiers || " to") as string + const assertion = mods.trim().endsWith("has") + ? methodName || "lengthOf" + : `have ${methodName || "lengthOf"}` + if (actualSize !== undefined && typeName) { + const targetTest = getCurrentTest() + if (!targetTest) return + const matches = Number(actualSize) === Number(length) + const negated = mods.includes("not") + const pass = negated ? !matches : matches + let displayValue = value + if (typeof typeName === "string") { + if (typeName.includes("Set")) { + displayValue = `new Set([${Array.from(value).join(", ")}])` + } else if (typeName.includes("Map")) { + displayValue = `new Map([${Array.from(value.entries()) + .map((entry: unknown) => { + const [key, value] = entry as [unknown, unknown] + return `[${key}, ${value}]` + }) + .join(", ")}])` + } + } + targetTest.expectResults.push({ + status: pass ? "pass" : "fail", + message: buildMessage(displayValue, mods, assertion, [length]), + }) + } else if (value instanceof Set) { + const targetTest = getCurrentTest() + if (!targetTest) return + const matches = value.size === Number(length) + const negated = mods.includes("not") + const pass = negated ? !matches : matches + const displayValue = `new Set([${Array.from(value).join(", ")}])` + targetTest.expectResults.push({ + status: pass ? "pass" : "fail", + message: buildMessage(displayValue, mods, assertion, [length]), + }) + } else if (value instanceof Map) { + const targetTest = getCurrentTest() + if (!targetTest) return + const matches = value.size === Number(length) + const negated = mods.includes("not") + const pass = negated ? !matches : matches + const displayValue = `new Map([${Array.from(value.entries()) + .map((entry: unknown) => { + const [key, value] = entry as [unknown, unknown] + return `[${key}, ${value}]` + }) + .join(", ")}])` + targetTest.expectResults.push({ + status: pass ? "pass" : "fail", + message: buildMessage(displayValue, mods, assertion, [length]), + }) + } else { + // Handle negation for regular arrays/strings + const negated = mods.includes("not") + executeChaiAssertion( + () => { + const expectChain = chai.expect(value) + if (negated) { + expectChain.to.not.have.lengthOf(Number(length)) + } else { + expectChain.to.have.lengthOf(Number(length)) + } + }, + buildMessage(value, String(mods), assertion, [length]) + ) + } + } + ), + + // Property assertions + chaiProperty: defineSandboxFn(ctx, "chaiProperty", (( + value: SandboxValue, + property: SandboxValue, + propertyValue?: SandboxValue, + modifiers?: SandboxValue, + hasProperty?: boolean + ) => { + const mods = modifiers || " to" + const hasValue = propertyValue !== undefined + + // PRE-CHECK PATTERN: Use property existence check from sandbox + // ONLY when checking existence (no value assertion) + if ( + !hasValue && + hasProperty !== undefined && + typeof value === "object" && + value !== null + ) { + const isNegated = String(mods).includes("not") + const shouldPass = isNegated ? !hasProperty : hasProperty + + executeChaiAssertion( + () => { + if (!shouldPass) { + throw new Error( + `Expected ${formatValue(value)}${String(mods)} have property '${property}'` + ) + } + }, + buildMessage(value, String(mods), `property '${property}'`) + ) + } else { + // For primitives, value assertions, or when property existence info not available + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + if (hasValue) { + assertion.have.property(property, propertyValue) + } else { + assertion.have.property(property) + } + }, + hasValue + ? buildMessage(value, mods, `property '${property}'`, [ + propertyValue, + ]) + : buildMessage(value, mods, `property '${property}'`) + ) + } + }) as any), + + chaiOwnProperty: defineSandboxFn(ctx, "chaiOwnProperty", (( + value: SandboxValue, + property: SandboxValue, + modifiers?: SandboxValue, + isOwnProperty?: boolean + ) => { + const mods = modifiers || " to" + + // PRE-CHECK PATTERN: Use hasOwnProperty check from sandbox + // When prototype chain info is available, use it directly + if ( + isOwnProperty !== undefined && + typeof value === "object" && + value !== null + ) { + const isNegated = String(mods).includes("not") + const shouldPass = isNegated ? !isOwnProperty : isOwnProperty + + executeChaiAssertion( + () => { + if (!shouldPass) { + throw new Error( + `Expected ${formatValue(value)}${String(mods)} have own property '${property}'` + ) + } + }, + buildMessage(value, String(mods), `own property '${property}'`) + ) + } else { + // For primitives or when hasOwnProperty info not available, use default Chai behavior + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.have.own.property(property) + }, + buildMessage(value, String(mods), `own property '${property}'`) + ) + } + }) as any), + + chaiDeepOwnProperty: defineSandboxFn( + ctx, + "chaiDeepOwnProperty", + ( + value: SandboxValue, + property: SandboxValue, + propertyValue: SandboxValue, + modifiers?: SandboxValue + ) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => + chai + .expect(value) + .to.have.deep.own.property(property, propertyValue), + buildMessage(value, String(mods), `property '${property}'`, [ + propertyValue, + ]) + ) + } + ), + + chaiNestedProperty: defineSandboxFn( + ctx, + "chaiNestedProperty", + ( + value: SandboxValue, + property: SandboxValue, + propertyValue?: SandboxValue, + modifiers?: SandboxValue + ) => { + const mods = modifiers || " to" + const hasValue = propertyValue !== undefined + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + if (hasValue) { + assertion.have.nested.property(property, propertyValue) + } else { + assertion.have.nested.property(property) + } + }, + hasValue + ? buildMessage(value, mods, `property '${property}'`, [ + propertyValue, + ]) + : buildMessage(value, mods, `property '${property}'`) + ) + } + ), + + chaiNestedInclude: defineSandboxFn( + ctx, + "chaiNestedInclude", + (value: SandboxValue, obj: unknown, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.nested.include(obj) + }, + buildMessage(value, String(mods), "nested include", [obj]) + ) + } + ), + + // Numerical comparisons + chaiAbove: defineSandboxFn( + ctx, + "chaiAbove", + (value: SandboxValue, n: unknown, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => applyModifiers(value, mods).be.above(n), + buildMessage(value, String(mods), "above", [n]) + ) + } + ), + + chaiBelow: defineSandboxFn( + ctx, + "chaiBelow", + (value: SandboxValue, n: unknown, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => applyModifiers(value, mods).be.below(n), + buildMessage(value, String(mods), "below", [n]) + ) + } + ), + + chaiAtLeast: defineSandboxFn( + ctx, + "chaiAtLeast", + (value: SandboxValue, n: unknown, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => applyModifiers(value, mods).be.at.least(n), + buildMessage(value, String(mods), "at least", [n]) + ) + } + ), + + chaiAtMost: defineSandboxFn( + ctx, + "chaiAtMost", + (value: SandboxValue, n: unknown, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => applyModifiers(value, mods).be.at.most(n), + buildMessage(value, String(mods), "at most", [n]) + ) + } + ), + + chaiWithin: defineSandboxFn( + ctx, + "chaiWithin", + ( + value: SandboxValue, + start: unknown, + end: unknown, + modifiers?: SandboxValue + ) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => applyModifiers(value, mods).be.within(start, end), + buildMessage(value, String(mods), "within", [start, end]) + ) + } + ), + + chaiCloseTo: defineSandboxFn( + ctx, + "chaiCloseTo", + ( + value: SandboxValue, + expected: SandboxValue, + delta: unknown, + modifiers?: SandboxValue, + methodName?: unknown + ) => { + const mods = modifiers || " to" + const method = methodName || "closeTo" + executeChaiAssertion( + () => applyModifiers(value, mods).be.closeTo(expected, delta), + buildMessage(value, String(mods), String(method), [expected, delta]) + ) + } + ), + + // Keys assertions + chaiKeys: defineSandboxFn( + ctx, + "chaiKeys", + (value: SandboxValue, keys: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.have.keys(...keys) + }, + buildMessage(value, String(mods), "keys", keys) + ) + } + ), + + chaiAllKeys: defineSandboxFn( + ctx, + "chaiAllKeys", + (value: SandboxValue, keys: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.have.all.keys(...keys) + }, + buildMessage(value, String(mods), "keys", keys) + ) + } + ), + + chaiAnyKeys: defineSandboxFn( + ctx, + "chaiAnyKeys", + (value: SandboxValue, keys: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.have.any.keys(...keys) + }, + buildMessage(value, String(mods), "keys", keys) + ) + } + ), + + // String/Pattern matching + chaiMatch: defineSandboxFn( + ctx, + "chaiMatch", + function ( + value: SandboxValue, + pattern: unknown, + modifiers: SandboxValue, + regexSource?: unknown, + regexFlags?: unknown + ) { + const mods = modifiers || " to" + let actualPattern = pattern + let displayPattern = pattern + if (regexSource !== undefined) { + actualPattern = new RegExp( + String(regexSource), + String(regexFlags || "") + ) + displayPattern = `/${regexSource}/${regexFlags || ""}` + } + const isNegated = String(mods).includes("not") + let matched = false + try { + matched = Boolean( + actualPattern instanceof RegExp + ? actualPattern.test(value) + : String(value).match(String(actualPattern)) + ) + } catch { + matched = false + } + const pass = isNegated ? !matched : matched + const targetTest = getCurrentTest() + if (!targetTest) return + const displayValue = typeof value === "string" ? value : String(value) + const notStr = isNegated ? " not" : "" + targetTest.expectResults.push({ + status: pass ? "pass" : "fail", + message: `Expected '${displayValue}' to${notStr} match ${displayPattern}`, + }) + } + ), + chaiString: defineSandboxFn( + ctx, + "chaiString", + function ( + value: SandboxValue, + substring: unknown, + modifiers?: SandboxValue + ) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + const valueStr = String(value) + const hasSubstring = valueStr.includes(String(substring)) + const shouldPass = isNegated ? !hasSubstring : hasSubstring + + const targetTest = getCurrentTest() + if (!targetTest) return + + targetTest.expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: buildMessage(value, mods, "have string", [`'${substring}'`]), + }) + } + ), + + // Members assertions + chaiMembers: defineSandboxFn( + ctx, + "chaiMembers", + ( + value: SandboxValue, + members: SandboxValue, + modifiers?: SandboxValue + ) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.have.members(members) + }, + buildMessage(value, String(mods), "members", [members]) + ) + } + ), + + chaiIncludeMembers: defineSandboxFn( + ctx, + "chaiIncludeMembers", + ( + value: SandboxValue, + members: SandboxValue, + modifiers?: SandboxValue + ) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.include.members(members) + }, + buildMessage(value, String(mods), "include members", [members]) + ) + } + ), + + chaiOrderedMembers: defineSandboxFn( + ctx, + "chaiOrderedMembers", + ( + value: SandboxValue, + members: SandboxValue, + modifiers?: SandboxValue + ) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.have.ordered.members(members) + }, + buildMessage(value, String(mods), "members", [...members]) + ) + } + ), + + chaiDeepMembers: defineSandboxFn( + ctx, + "chaiDeepMembers", + ( + value: SandboxValue, + members: SandboxValue, + modifiers?: SandboxValue + ) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.have.deep.members(members) + }, + buildMessage(value, String(mods), "members", [members]) + ) + } + ), + + // OneOf assertion + chaiOneOf: defineSandboxFn( + ctx, + "chaiOneOf", + (value: SandboxValue, list: unknown, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + const isInclude = String(mods).includes("include") + const assertion = isInclude ? "include oneOf" : "oneOf" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + if (isInclude) { + assertion.include.oneOf(list) + } else { + assertion.be.oneOf(list) + } + }, + buildMessage(value, String(mods), assertion, [list]) + ) + } + ), + + // Object state assertions + // PRE-CHECKING PATTERN: Check object state BEFORE serialization + // The pre-checking is done in post-request.js (sandbox context) and passed as a third parameter + // This is because after serialization, objects lose their frozen/sealed/extensible state + chaiExtensible: defineSandboxFn( + ctx, + "chaiExtensible", + (value, modifiers, preCheckedResult) => { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + // Use the pre-checked result from sandbox (before serialization) + const isExtensible = + preCheckedResult !== undefined + ? preCheckedResult + : Object.isExtensible(value) + const shouldPass = isNegated ? !isExtensible : isExtensible + + executeChaiAssertion( + () => { + if (!shouldPass) { + throw new Error( + `Expected ${formatValue(value)}${mods} be extensible` + ) + } + }, + // For buildMessage calls for extensible, sealed, frozen, pass "{}" if value is an empty object, otherwise String(value) + buildMessage( + Object.keys(value as object).length === 0 ? "{}" : String(value), + String(mods), + "extensible" + ) + ) + } + ), + + chaiSealed: defineSandboxFn( + ctx, + "chaiSealed", + (value, modifiers, preCheckedResult) => { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + // Use the pre-checked result from sandbox (before serialization) + const isSealed = + preCheckedResult !== undefined + ? preCheckedResult + : Object.isSealed(value) + const shouldPass = isNegated ? !isSealed : isSealed + + executeChaiAssertion( + () => { + if (!shouldPass) { + throw new Error(`Expected ${formatValue(value)}${mods} be sealed`) + } + }, + // For buildMessage calls for extensible, sealed, frozen, pass "{}" if value is an empty object, otherwise String(value) + buildMessage( + Object.keys(value as object).length === 0 ? "{}" : String(value), + String(mods), + "sealed" + ) + ) + } + ), + + chaiFrozen: defineSandboxFn( + ctx, + "chaiFrozen", + (value, modifiers, preCheckedResult) => { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + // Use the pre-checked result from sandbox (before serialization) + const isFrozen = + preCheckedResult !== undefined + ? preCheckedResult + : Object.isFrozen(value) + const shouldPass = isNegated ? !isFrozen : isFrozen + + executeChaiAssertion( + () => { + if (!shouldPass) { + throw new Error(`Expected ${formatValue(value)}${mods} be frozen`) + } + }, + // For buildMessage calls for extensible, sealed, frozen, pass "{}" if value is an empty object, otherwise String(value) + buildMessage( + Object.keys(value as object).length === 0 ? "{}" : String(value), + String(mods), + "frozen" + ) + ) + } + ), + + // Number state assertions + chaiFinite: defineSandboxFn( + ctx, + "chaiFinite", + (value: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + void assertion.be.finite + }, + buildMessage(value, String(mods), "finite") + ) + } + ), + + // Arguments object assertion + chaiArguments: defineSandboxFn( + ctx, + "chaiArguments", + ( + value: SandboxValue, + modifiers?: SandboxValue, + preCheckedResult?: unknown + ) => { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + const isArguments = + preCheckedResult !== undefined + ? preCheckedResult + : Object.prototype.toString.call(value) === "[object Arguments]" + const shouldPass = isNegated ? !isArguments : isArguments + // Extract "arguments" or "Arguments" from modifiers + const assertionName = + mods.match(/\b(arguments|Arguments)\b/)?.[1] || "arguments" + const targetTest = getCurrentTest() + if (!targetTest) return + targetTest.expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: buildMessage(value, mods, assertionName), + }) + } + ), + + // Property descriptor assertion + chaiOwnPropertyDescriptor: defineSandboxFn( + ctx, + "chaiOwnPropertyDescriptor", + function ( + _value, + prop, + descriptor, + modifiers, + hasDescriptor, + matchesExpected + ) { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + + // PRE-CHECK PATTERN: Use pre-checked results from sandbox + // After serialization, property descriptors lose metadata + let shouldPass = false + + if (descriptor !== undefined) { + // When descriptor comparison is provided + if (isNegated) { + shouldPass = !hasDescriptor || !matchesExpected + } else { + shouldPass = Boolean(hasDescriptor && matchesExpected) + } + } else { + // When only checking for existence + if (isNegated) { + shouldPass = !hasDescriptor + } else { + shouldPass = Boolean(hasDescriptor) + } + } + + const targetTest = getCurrentTest() + if (!targetTest) return + targetTest.expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: `Expected {}${mods} ownPropertyDescriptor '${prop}'`, + }) + } + ), + + // Include deep ordered members + chaiIncludeDeepOrderedMembers: defineSandboxFn( + ctx, + "chaiIncludeDeepOrderedMembers", + function ( + value: SandboxValue, + members: SandboxValue, + modifiers?: SandboxValue + ) { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + let pass = false + try { + chai.expect(value).to.include.deep.ordered.members(members) + pass = !isNegated + } catch { + pass = isNegated + } + const targetTest = getCurrentTest() + if (!targetTest) return + targetTest.expectResults.push({ + status: pass ? "pass" : "fail", + message: buildMessage(value, mods, "members", [...members]), + }) + } + ), + + chaiRespondTo: defineSandboxFn( + ctx, + "chaiRespondTo", + function (value, method, modifiers, preCheckedResult) { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + // PRE-CHECK PATTERN: Use pre-checked result from sandbox + // After serialization, functions become strings and method checks fail + const hasMethod = + preCheckedResult !== undefined + ? preCheckedResult + : typeof (value as any)?.[method as string] === "function" || + typeof (value as any)?.prototype?.[method as string] === + "function" + const shouldPass = isNegated ? !hasMethod : hasMethod + executeChaiAssertion( + () => { + if (!shouldPass) { + throw new Error( + `Expected ${formatValue(value)}${String(mods)} respondTo '${method}'` + ) + } + }, + buildMessage(value, String(mods), `respondTo '${method}'`) + ) + } + ), + + // Function throw assertion + chaiThrow: defineSandboxFn( + ctx, + "chaiThrow", + function ( + fn: unknown, + threwError: unknown, + errorTypeName: unknown, + errorMessage: unknown, + expectedTypeName: unknown, + errMsgMatcher: unknown, + regexSource: unknown, + regexFlags: unknown, + isRegexMatcher: unknown, + modifiers: SandboxValue + ) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + + // Determine what we're checking for + const hasErrorType = + expectedTypeName !== null && expectedTypeName !== undefined + const hasErrorMessage = + errMsgMatcher !== undefined && errMsgMatcher !== null + const hasRegexPattern = Boolean(isRegexMatcher) + + // Check if assertion should pass + let shouldPass = false + + if (isNegated) { + // For negated (.not.throw), logic depends on what's being checked + if (hasErrorType) { + // .not.throw(ErrorType) should pass if: + // 1. Didn't throw at all, OR + // 2. Threw a different error type + shouldPass = + !threwError || String(errorTypeName) !== String(expectedTypeName) + } else if (hasErrorMessage || hasRegexPattern) { + // .not.throw('message') should pass if: + // 1. Didn't throw at all, OR + // 2. Threw with a different message + if (!threwError) { + shouldPass = true + } else if (hasRegexPattern) { + const regex = new RegExp( + String(regexSource), + String(regexFlags || "") + ) + shouldPass = !regex.test(String(errorMessage || "")) + } else { + const errMsg = String(errorMessage || "") + const matchMsg = String(errMsgMatcher || "") + shouldPass = errMsg !== matchMsg && !errMsg.includes(matchMsg) + } + } else { + // .not.throw() with no args should pass if did NOT throw + shouldPass = !threwError + } + } else { + // For positive assertion, should pass if threw error + shouldPass = Boolean(threwError) + + // Additional checks if error was thrown + if (threwError && hasErrorType) { + shouldPass = + shouldPass && String(errorTypeName) === String(expectedTypeName) + } + + if (threwError && hasErrorMessage && !hasRegexPattern) { + // String message match + const errMsg = String(errorMessage || "") + const matchMsg = String(errMsgMatcher || "") + shouldPass = + shouldPass && (errMsg === matchMsg || errMsg.includes(matchMsg)) + } + + if (threwError && hasRegexPattern) { + // RegExp message match + const regex = new RegExp( + String(regexSource), + String(regexFlags || "") + ) + shouldPass = shouldPass && regex.test(String(errorMessage || "")) + } + } + + // Build appropriate message + const messageArgs: string[] = [] + if (hasErrorType && hasErrorMessage) { + if (hasRegexPattern) { + messageArgs.push( + String(expectedTypeName), + `/${regexSource}/${regexFlags || ""}` + ) + } else { + messageArgs.push(String(expectedTypeName), String(errMsgMatcher)) + } + } else if (hasErrorType) { + messageArgs.push(String(expectedTypeName)) + } else if (hasErrorMessage) { + if (hasRegexPattern) { + messageArgs.push(`/${regexSource}/${regexFlags || ""}`) + } else { + messageArgs.push(String(errMsgMatcher)) + } + } + + const targetTest = getCurrentTest() + if (!targetTest) return + targetTest.expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: buildMessage(fn, mods, "throw", messageArgs.filter(Boolean)), + }) + } + ), + + // Function satisfy assertion + chaiSatisfy: defineSandboxFn( + ctx, + "chaiSatisfy", + function ( + value: SandboxValue, + satisfyResult: unknown, + matcherString: unknown, + modifiers: SandboxValue + ) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + const passed = Boolean(satisfyResult) + const shouldPass = isNegated ? !passed : passed + + const targetTest = getCurrentTest() + if (!targetTest) return + targetTest.expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: buildMessage(value, mods, "satisfy", [ + String(matcherString), + ]), + }) + } + ), + + // change/increase/decrease assertions (pre-check pattern - function already executed in sandbox) + chaiChange: defineSandboxFn( + ctx, + "chaiChange", + function (prop, modifiers, changed, _delta) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + const shouldPass = isNegated ? !changed : changed + + const targetTest = getCurrentTest() + if (!targetTest) return + + targetTest.expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: `Expected [Function]${mods} change {}.'${prop}'`, + }) + } + ), + + chaiChangeBy: defineSandboxFn( + ctx, + "chaiChangeBy", + function (prop, modifiers, changed, delta, expectedDelta) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + const numDelta = Number(delta) + const actualDelta = Math.abs(numDelta) + const numExpectedDelta = Number(expectedDelta) + const deltaMatches = + Math.abs(actualDelta - Math.abs(numExpectedDelta)) < 0.0001 + const byPasses = changed && deltaMatches + const byShouldPass = isNegated ? !byPasses : byPasses + + const targetTest = getCurrentTest() + if (!targetTest) return + + // Update the last result (from chaiChange) + const lastResult = + targetTest.expectResults[targetTest.expectResults.length - 1] + lastResult.status = byShouldPass ? "pass" : "fail" + lastResult.message = `Expected [Function]${mods} change {}.'${prop}' by ${numExpectedDelta}` + } + ), + + chaiIncrease: defineSandboxFn( + ctx, + "chaiIncrease", + function (prop, modifiers, increased, _delta) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + const shouldPass = isNegated ? !increased : increased + + const targetTest = getCurrentTest() + if (!targetTest) return + + targetTest.expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: `Expected [Function]${mods} increase {}.'${prop}'`, + }) + } + ), + + chaiIncreaseBy: defineSandboxFn( + ctx, + "chaiIncreaseBy", + function (prop, modifiers, increased, delta, expectedDelta) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + const numDelta = Number(delta) + const numExpectedDelta = Number(expectedDelta) + const deltaMatches = Math.abs(numDelta - numExpectedDelta) < 0.0001 + const byPasses = increased && deltaMatches + const byShouldPass = isNegated ? !byPasses : byPasses + + const targetTest = getCurrentTest() + if (!targetTest) return + + // Update the last result (from chaiIncrease) + const lastResult = + targetTest.expectResults[targetTest.expectResults.length - 1] + lastResult.status = byShouldPass ? "pass" : "fail" + lastResult.message = `Expected [Function]${mods} increase {}.'${prop}' by ${numExpectedDelta}` + } + ), + + chaiDecrease: defineSandboxFn( + ctx, + "chaiDecrease", + function (prop, modifiers, decreased, _delta) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + const shouldPass = isNegated ? !decreased : decreased + + const targetTest = getCurrentTest() + if (!targetTest) return + + targetTest.expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: `Expected [Function]${mods} decrease {}.'${prop}'`, + }) + } + ), + + chaiDecreaseBy: defineSandboxFn( + ctx, + "chaiDecreaseBy", + function (prop, modifiers, decreased, delta, expectedDelta) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + const numDelta = Number(delta) + const numExpectedDelta = Number(expectedDelta) + const deltaMatches = + Math.abs(Math.abs(numDelta) - numExpectedDelta) < 0.0001 + const byPasses = decreased && deltaMatches + const byShouldPass = isNegated ? !byPasses : byPasses + + const targetTest = getCurrentTest() + if (!targetTest) return + + // Update the last result (from chaiDecrease) + const lastResult = + targetTest.expectResults[targetTest.expectResults.length - 1] + lastResult.status = byShouldPass ? "pass" : "fail" + lastResult.message = `Expected [Function]${mods} decrease {}.'${prop}' by ${numExpectedDelta}` + } + ), + + // Custom Postman-specific methods + chaiJsonSchema: defineSandboxFn( + ctx, + "chaiJsonSchema", + function ( + value: SandboxValue, + schema: SandboxValue, + modifiers: SandboxValue + ) { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + + // Validation helper + const validateSchema = ( + data: SandboxValue, + schema: SandboxValue + ): boolean => { + // Type validation + if (schema.type !== undefined) { + const actualType = Array.isArray(data) ? "array" : typeof data + if (actualType !== schema.type) return false + } + + // Required properties + if (schema.required && Array.isArray(schema.required)) { + for (const key of schema.required) { + if (!(key in data)) return false + } + } + + // Properties validation + if (schema.properties && typeof data === "object" && data !== null) { + for (const key in schema.properties) { + if (key in data) { + const propSchema = schema.properties[key] + if (!validateSchema(data[key], propSchema)) return false + } + } + } + + return true + } + + const passes = validateSchema(value, schema) + const shouldPass = isNegated ? !passes : passes + + executeChaiAssertion( + () => { + if (!shouldPass) { + let errorMessage = "" + if (schema.required && Array.isArray(schema.required)) { + for (const key of schema.required) { + if (!(key in value)) { + errorMessage = `Required property '${key}' is missing` + break + } + } + } + if (!errorMessage && schema.type !== undefined) { + const actualType = Array.isArray(value) ? "array" : typeof value + if (actualType !== schema.type) { + errorMessage = `Expected type ${schema.type}, got ${actualType}` + } + } + if (!errorMessage) { + errorMessage = "Schema validation failed" + } + throw new Error(errorMessage) + } + }, + buildMessage(value, mods, "jsonSchema", [schema]) + ) + } + ), + + // Helper function for pm.response.to.have.jsonSchema() to validate without Chai infrastructure + validateJsonSchema: defineSandboxFn( + ctx, + "validateJsonSchema", + function (value: SandboxValue, schema: SandboxValue) { + // Validation helper - same logic as chaiJsonSchema + const validateSchema = ( + data: SandboxValue, + schema: SandboxValue + ): boolean => { + // Type validation + if (schema.type !== undefined) { + const actualType = Array.isArray(data) ? "array" : typeof data + if (actualType !== schema.type) return false + } + + // Required properties + if (schema.required && Array.isArray(schema.required)) { + for (const key of schema.required) { + if (!(key in data)) return false + } + } + + // Properties validation + if (schema.properties && typeof data === "object" && data !== null) { + for (const key in schema.properties) { + if (key in data) { + const propSchema = schema.properties[key] + if (!validateSchema(data[key], propSchema)) return false + } + } + } + + return true + } + + const isValid = validateSchema(value, schema) + + // Generate error message if validation failed + let errorMessage = "" + if (!isValid) { + // Check for required property errors + if (schema.required && Array.isArray(schema.required)) { + for (const key of schema.required) { + if (!(key in value)) { + errorMessage = `Required property '${key}' is missing` + break + } + } + } + + // Check for root type errors + if (!errorMessage && schema.type !== undefined) { + const actualType = Array.isArray(value) ? "array" : typeof value + if (actualType !== schema.type) { + errorMessage = `Expected type ${schema.type}, got ${actualType}` + } + } + + // Check for nested property type errors + if ( + !errorMessage && + schema.properties && + typeof value === "object" && + value !== null + ) { + for (const key in schema.properties) { + if (key in value) { + const propSchema = schema.properties[key] + if (propSchema.type !== undefined) { + const actualPropType = Array.isArray(value[key]) + ? "array" + : typeof value[key] + if (actualPropType !== propSchema.type) { + errorMessage = `Expected type ${propSchema.type}, got ${actualPropType}` + break + } + } + } + } + } + + if (!errorMessage) { + errorMessage = "Schema validation failed" + } + } + + return { isValid, errorMessage } + } + ), + + chaiCharset: defineSandboxFn( + ctx, + "chaiCharset", + function ( + value: SandboxValue, + expectedCharset: unknown, + modifiers: SandboxValue + ) { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + + // For response body testing, check Content-Type header charset + const passes = typeof value === "string" // Simplified check + + const shouldPass = isNegated ? !passes : passes + + const targetTest = getCurrentTest() + if (!targetTest) return + + targetTest.expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: buildMessage(value, mods, "charset", [expectedCharset]), + }) + } + ), + + chaiCookie: defineSandboxFn( + ctx, + "chaiCookie", + function ( + value: SandboxValue, + cookieName: SandboxValue, + cookieValue: unknown, + modifiers: SandboxValue + ) { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + + // Simplified: check if cookie-like structure has name + const hasCookie = + typeof value === "object" && value !== null && cookieName in value + const valueMatches = + cookieValue === undefined || value[cookieName] === cookieValue + + const passes = hasCookie && valueMatches + const shouldPass = isNegated ? !passes : passes + + const targetTest = getCurrentTest() + if (!targetTest) return + + const args = + cookieValue !== undefined ? [cookieName, cookieValue] : [cookieName] + targetTest.expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: buildMessage(value, mods, "cookie", args), + }) + } + ), + + chaiJsonPath: defineSandboxFn( + ctx, + "chaiJsonPath", + function ( + value: SandboxValue, + path: SandboxValue, + expectedValue: SandboxValue, + modifiers: SandboxValue + ) { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + + // Enhanced JSONPath evaluation (supports $.path, array indices, wildcards) + const evaluatePath = (data: SandboxValue, path: string): unknown => { + let pathStr = String(path).trim() + + // Remove leading $. if present + if (pathStr.startsWith("$.")) { + pathStr = pathStr.substring(2) + } else if (pathStr.startsWith("$")) { + pathStr = pathStr.substring(1) + } + + // Handle empty path (just "$") + if (!pathStr || pathStr === "") { + return data + } + + let current: unknown = data + const segments = pathStr.split(/\.|\[/).filter(Boolean) + + for (let segment of segments) { + // Remove trailing ] for array indices + segment = segment.replace(/\]$/, "") + + if (segment === "*") { + // Wildcard - return array of all values + if (Array.isArray(current)) { + return current // For array[*], return the array itself + } else if (typeof current === "object" && current !== null) { + return Object.values(current) + } + return undefined + } else if (/^\d+$/.test(segment)) { + // Numeric index + const index = parseInt(segment, 10) + if ( + Array.isArray(current) && + index >= 0 && + index < current.length + ) { + current = current[index] + } else { + return undefined + } + } else { + // Object property + if ( + current && + typeof current === "object" && + segment in current + ) { + current = (current as Record)[segment] + } else { + return undefined + } + } + } + + return current + } + + const actualValue = evaluatePath(value, path) + const passes = + expectedValue === undefined + ? actualValue !== undefined + : actualValue === expectedValue + + const shouldPass = isNegated ? !passes : passes + + const args = + expectedValue !== undefined ? [path, expectedValue] : [path] + + executeChaiAssertion( + () => { + if (!shouldPass) { + let errorMessage = "" + if (actualValue === undefined) { + // Extract property name from path for better error message + const pathStr = String(path).replace(/^\$\.?/, "") + const segments = pathStr.split(/\.|\[/).filter(Boolean) + const lastSegment = segments[segments.length - 1]?.replace( + /\]$/, + "" + ) + + // Check if it's an array index + if (lastSegment && /^\d+$/.test(lastSegment)) { + errorMessage = `Array index '${lastSegment}' out of bounds` + } else { + errorMessage = `Property '${lastSegment || pathStr}' not found` + } + } else if (expectedValue !== undefined) { + errorMessage = `Expected value at path '${path}' to be '${expectedValue}', but got '${actualValue}'` + } else { + errorMessage = `JSONPath assertion failed for '${path}'` + } + throw new Error(errorMessage) + } + }, + buildMessage(value, mods, "jsonPath", args) + ) + } + ), + + // expect.fail() - Force a test failure + // Supports multiple signatures: + // expect.fail() + // expect.fail(message) + // expect.fail(actual, expected) + // expect.fail(actual, expected, message) + // expect.fail(actual, expected, message, operator) + chaiFail: defineSandboxFn(ctx, "chaiFail", (...args: unknown[]) => { + const targetTest = getCurrentTest() + if (!targetTest) return + + const [actual, expected, message, operator] = args + let errorMessage: string + + // Handle different call signatures + if (actual === undefined && expected === undefined) { + // expect.fail() - no arguments + errorMessage = "expect.fail()" + } else if ( + expected === undefined && + message === undefined && + operator === undefined + ) { + // expect.fail(message) - single string argument + errorMessage = typeof actual === "string" ? actual : "expect.fail()" + } else { + // expect.fail(actual, expected) or full signature + errorMessage = + (message as string) || + `expected ${actual !== undefined ? actual : "undefined"} to ${(operator as string) || "equal"} ${expected !== undefined ? expected : "undefined"}` + } + + // Always record as failure + targetTest.expectResults.push({ + status: "fail", + message: errorMessage, + }) + }), + } +} diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/utils/expectation-helpers.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/expectation-helpers.ts new file mode 100644 index 0000000..6f11fd6 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/expectation-helpers.ts @@ -0,0 +1,149 @@ +import { CageModuleCtx, defineSandboxFn } from "faraday-cage/modules" + +import { TestDescriptor, ExpectationMethods, SandboxValue } from "~/types" +import { createExpectation } from "~/utils/shared" + +/** + * Creates expectation methods for test assertions in post-request scripts + */ +export const createExpectationMethods = ( + ctx: CageModuleCtx, + testRunStack: TestDescriptor[], + getCurrentTestContext?: () => TestDescriptor | null +): ExpectationMethods => { + const createExpect = (expectVal: SandboxValue) => + createExpectation(expectVal, false, testRunStack, getCurrentTestContext) + + return { + expectToBe: defineSandboxFn( + ctx, + "expectToBe", + (expectVal: SandboxValue, expectedVal: SandboxValue) => { + return createExpect(expectVal).toBe(expectedVal) + } + ), + expectToBeLevel2xx: defineSandboxFn( + ctx, + "expectToBeLevel2xx", + (expectVal: SandboxValue) => { + return createExpect(expectVal).toBeLevel2xx() + } + ), + expectToBeLevel3xx: defineSandboxFn( + ctx, + "expectToBeLevel3xx", + (expectVal: SandboxValue) => { + return createExpect(expectVal).toBeLevel3xx() + } + ), + expectToBeLevel4xx: defineSandboxFn( + ctx, + "expectToBeLevel4xx", + (expectVal: SandboxValue) => { + return createExpect(expectVal).toBeLevel4xx() + } + ), + expectToBeLevel5xx: defineSandboxFn( + ctx, + "expectToBeLevel5xx", + (expectVal: SandboxValue) => { + return createExpect(expectVal).toBeLevel5xx() + } + ), + expectToBeType: defineSandboxFn( + ctx, + "expectToBeType", + ( + expectVal: SandboxValue, + expectedType: SandboxValue, + isDate: SandboxValue + ) => { + const resolved = + isDate && typeof expectVal === "string" + ? new Date(expectVal) + : expectVal + return createExpect(resolved).toBeType(expectedType) + } + ), + expectToHaveLength: defineSandboxFn( + ctx, + "expectToHaveLength", + (expectVal: SandboxValue, expectedLength: SandboxValue) => { + return createExpect(expectVal).toHaveLength(expectedLength) + } + ), + expectToInclude: defineSandboxFn( + ctx, + "expectToInclude", + (expectVal: SandboxValue, needle: SandboxValue) => { + return createExpect(expectVal).toInclude(needle) + } + ), + + // Negative expectations + expectNotToBe: defineSandboxFn( + ctx, + "expectNotToBe", + (expectVal: SandboxValue, expectedVal: SandboxValue) => { + return createExpect(expectVal).not.toBe(expectedVal) + } + ), + expectNotToBeLevel2xx: defineSandboxFn( + ctx, + "expectNotToBeLevel2xx", + (expectVal: SandboxValue) => { + return createExpect(expectVal).not.toBeLevel2xx() + } + ), + expectNotToBeLevel3xx: defineSandboxFn( + ctx, + "expectNotToBeLevel3xx", + (expectVal: SandboxValue) => { + return createExpect(expectVal).not.toBeLevel3xx() + } + ), + expectNotToBeLevel4xx: defineSandboxFn( + ctx, + "expectNotToBeLevel4xx", + (expectVal: SandboxValue) => { + return createExpect(expectVal).not.toBeLevel4xx() + } + ), + expectNotToBeLevel5xx: defineSandboxFn( + ctx, + "expectNotToBeLevel5xx", + (expectVal: SandboxValue) => { + return createExpect(expectVal).not.toBeLevel5xx() + } + ), + expectNotToBeType: defineSandboxFn( + ctx, + "expectNotToBeType", + ( + expectVal: SandboxValue, + expectedType: SandboxValue, + isDate: SandboxValue + ) => { + const resolved = + isDate && typeof expectVal === "string" + ? new Date(expectVal) + : expectVal + return createExpect(resolved).not.toBeType(expectedType) + } + ), + expectNotToHaveLength: defineSandboxFn( + ctx, + "expectNotToHaveLength", + (expectVal: SandboxValue, expectedLength: SandboxValue) => { + return createExpect(expectVal).not.toHaveLength(expectedLength) + } + ), + expectNotToInclude: defineSandboxFn( + ctx, + "expectNotToInclude", + (expectVal: SandboxValue, needle: SandboxValue) => { + return createExpect(expectVal).not.toInclude(needle) + } + ), + } +} diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/utils/request-setters.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/request-setters.ts new file mode 100644 index 0000000..d7b7379 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/request-setters.ts @@ -0,0 +1,97 @@ +import { + HoppRESTAuth, + HoppRESTHeaders, + HoppRESTParams, + HoppRESTReqBody, + HoppRESTRequest, +} from "@hoppscotch/data" +import { CageModuleCtx, defineSandboxFn } from "faraday-cage/modules" + +import { getRequestSetterMethods } from "~/utils/pre-request" +import { RequestSetterMethodsResult } from "~/types" + +/** + * Creates request setter methods for pre-request scripts + * These methods allow modification of request properties before execution + */ +export const createRequestSetterMethods = ( + ctx: CageModuleCtx, + request: HoppRESTRequest +): RequestSetterMethodsResult => { + const { methods: requestMethods, updatedRequest } = + getRequestSetterMethods(request) + + const setterMethods = { + // Request setter methods + setRequestUrl: defineSandboxFn(ctx, "setRequestUrl", (url: unknown) => { + requestMethods.setUrl(url as string) + }), + setRequestMethod: defineSandboxFn( + ctx, + "setRequestMethod", + (method: unknown) => { + requestMethods.setMethod(method as string) + } + ), + setRequestHeader: defineSandboxFn( + ctx, + "setRequestHeader", + (name: unknown, value: unknown) => { + requestMethods.setHeader(name as string, value as string) + } + ), + setRequestHeaders: defineSandboxFn( + ctx, + "setRequestHeaders", + (headers: unknown) => { + requestMethods.setHeaders(headers as HoppRESTHeaders) + } + ), + removeRequestHeader: defineSandboxFn( + ctx, + "removeRequestHeader", + (key: unknown) => { + requestMethods.removeHeader(key as string) + } + ), + setRequestParam: defineSandboxFn( + ctx, + "setRequestParam", + (name: unknown, value: unknown) => { + requestMethods.setParam(name as string, value as string) + } + ), + setRequestParams: defineSandboxFn( + ctx, + "setRequestParams", + (params: unknown) => { + requestMethods.setParams(params as HoppRESTParams) + } + ), + removeRequestParam: defineSandboxFn( + ctx, + "removeRequestParam", + (key: unknown) => { + requestMethods.removeParam(key as string) + } + ), + setRequestBody: defineSandboxFn(ctx, "setRequestBody", (body: unknown) => { + requestMethods.setBody(body as HoppRESTReqBody) + }), + setRequestAuth: defineSandboxFn(ctx, "setRequestAuth", (auth: unknown) => { + requestMethods.setAuth(auth as HoppRESTAuth) + }), + setRequestVariable: defineSandboxFn( + ctx, + "setRequestVariable", + (key: unknown, value: unknown) => { + requestMethods.setRequestVariable(key as string, value as string) + } + ), + } + + return { + methods: setterMethods, + getUpdatedRequest: () => updatedRequest, + } +} diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/utils/vm-marshal.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/vm-marshal.ts new file mode 100644 index 0000000..5bc196d --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/vm-marshal.ts @@ -0,0 +1,241 @@ +// Internal faraday-cage ctx type (kept as any). +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type CageModuleContext = any + +export const marshalValue = (ctx: CageModuleContext, value: any): any => { + if (value === null) return ctx.vm.null + if (value === undefined) return ctx.vm.undefined + if (value === true) return ctx.vm.true + if (value === false) return ctx.vm.false + if (typeof value === "string") + return ctx.scope.manage(ctx.vm.newString(value)) + if (typeof value === "number") + return ctx.scope.manage(ctx.vm.newNumber(value)) + if (value instanceof Uint8Array || value instanceof ArrayBuffer) { + // Convert typed arrays to plain arrays for QuickJS + // QuickJS doesn't support native TypedArrays/ArrayBuffer + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value) + const arr = ctx.scope.manage(ctx.vm.newArray()) + for (let i = 0; i < bytes.length; i++) { + ctx.vm.setProp(arr, i, ctx.scope.manage(ctx.vm.newNumber(bytes[i]))) + } + // Add byteLength property for ArrayBuffer compatibility + ctx.vm.setProp( + arr, + "byteLength", + ctx.scope.manage(ctx.vm.newNumber(bytes.length)) + ) + return arr + } + if (typeof value === "object") { + if (Array.isArray(value)) { + const arr = ctx.scope.manage(ctx.vm.newArray()) + value.forEach((item, i) => { + ctx.vm.setProp(arr, i, marshalValue(ctx, item)) + }) + return arr + } else { + const obj = ctx.scope.manage(ctx.vm.newObject()) + for (const [k, v] of Object.entries(value)) { + ctx.vm.setProp(obj, k, marshalValue(ctx, v)) + } + return obj + } + } + return ctx.vm.undefined +} + +export const vmArrayToUint8Array = ( + ctx: CageModuleContext, + vmArray: any +): Uint8Array => { + const length = ctx.vm.getProp(vmArray, "length") + const lengthNum = ctx.vm.getNumber(length) + length.dispose() + + const bytes = new Uint8Array(lengthNum) + for (let i = 0; i < lengthNum; i++) { + const item = ctx.vm.getProp(vmArray, i) + bytes[i] = ctx.vm.getNumber(item) + item.dispose() + } + return bytes +} + +export const uint8ArrayToVmArray = ( + ctx: CageModuleContext, + bytes: Uint8Array +): any => { + const vmArray = ctx.scope.manage(ctx.vm.newArray()) + for (let i = 0; i < bytes.length; i++) { + ctx.vm.setProp(vmArray, i, ctx.scope.manage(ctx.vm.newNumber(bytes[i]))) + } + // Add byteLength property for ArrayBuffer compatibility + ctx.vm.setProp( + vmArray, + "byteLength", + ctx.scope.manage(ctx.vm.newNumber(bytes.length)) + ) + return vmArray +} + +interface KeyEntry { + ref: WeakRef | CryptoKey | CryptoKeyPair + strongRef: CryptoKey | CryptoKeyPair + expiresAt: number +} + +const KEY_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes + +export class CryptoKeyRegistry { + private readonly supportsWeakRef = typeof WeakRef === "function" + private readonly supportsFinalizer = + typeof FinalizationRegistry === "function" + private keys = new Map() + private finalizer?: FinalizationRegistry + private cleanupTimer: ReturnType | null = null + + constructor() { + if (this.supportsFinalizer) { + this.finalizer = new FinalizationRegistry((id: string) => { + this.keys.delete(id) + }) + } + this.scheduleCleanup() + } + + store(key: CryptoKey | CryptoKeyPair, ttl: number = KEY_EXPIRY_MS): string { + const id = + typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : (() => { + // Fallback if randomUUID is unavailable - identifier does not need to be cryptographically secure + if (typeof globalThis.crypto?.getRandomValues === "function") { + const bytes = new Uint8Array(16) + globalThis.crypto.getRandomValues(bytes) + // RFC 4122 v4 bits + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (b) => + b.toString(16).padStart(2, "0") + ).join("") + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` + } + + return `${Date.now()}-${Math.random()}-${Math.random()}` + })() + const expiresAt = Date.now() + ttl + + this.keys.set(id, { + ref: this.supportsWeakRef ? new WeakRef(key) : key, + strongRef: key, + expiresAt, + }) + + if (this.finalizer) { + this.finalizer.register(key, id, key) + } + return id + } + + get(id: string): CryptoKey | CryptoKeyPair | undefined { + const entry = this.keys.get(id) + if (!entry) return undefined + + const now = Date.now() + if (entry.expiresAt <= now) { + this.keys.delete(id) + return undefined + } + + const key = + this.supportsWeakRef && "deref" in entry.ref + ? ((entry.ref as WeakRef).deref() ?? + entry.strongRef) + : entry.strongRef + + if (!key) { + this.keys.delete(id) + return undefined + } + + // Reset TTL on access + entry.expiresAt = Date.now() + KEY_EXPIRY_MS + return key + } + + has(id: string): boolean { + return this.get(id) !== undefined + } + + delete(id: string): boolean { + const entry = this.keys.get(id) + if (!entry) return false + + if (this.finalizer) { + const key = entry.strongRef + + if (key) { + this.finalizer.unregister(key) + } + } + + return this.keys.delete(id) + } + + clear(): void { + for (const [_id, entry] of this.keys.entries()) { + if (this.finalizer) { + const key = entry.strongRef + + this.finalizer.unregister(key) + } + } + this.keys.clear() + } + + get size(): number { + this.cleanup() + return this.keys.size + } + + dispose(): void { + if (this.cleanupTimer) { + clearTimeout(this.cleanupTimer) + this.cleanupTimer = null + } + this.clear() + } + + private cleanup(): void { + const now = Date.now() + for (const [id, entry] of this.keys.entries()) { + const key = + this.supportsWeakRef && "deref" in entry.ref + ? ((entry.ref as WeakRef).deref() ?? + entry.strongRef) + : entry.strongRef + + if (!key || entry.expiresAt <= now) { + this.keys.delete(id) + } + } + } + + private scheduleCleanup(): void { + if (this.cleanupTimer) { + clearTimeout(this.cleanupTimer) + } + + this.cleanupTimer = setTimeout(() => { + this.cleanup() + this.scheduleCleanup() + }, KEY_EXPIRY_MS) // Cleanup interval based on KEY_EXPIRY_MS + } +} + +/** + * Maximum byte size for getRandomValues() per Web Crypto spec + * https://www.w3.org/TR/WebCryptoAPI/#Crypto-method-getRandomValues + */ +export const MAX_GET_RANDOM_VALUES_SIZE = 65536 diff --git a/packages/hoppscotch-js-sandbox/src/constants/http-status-codes.ts b/packages/hoppscotch-js-sandbox/src/constants/http-status-codes.ts new file mode 100644 index 0000000..7f88a71 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/constants/http-status-codes.ts @@ -0,0 +1,126 @@ +/** + * HTTP Status Code Reason Phrases + * + * Standard HTTP status codes and their corresponding reason phrases + * as defined in RFC 7231 and related specifications. + * + * @see https://tools.ietf.org/html/rfc7231#section-6 + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status + */ +export const HTTP_STATUS_REASONS: Readonly> = { + // 1xx Informational + 100: "Continue", + 101: "Switching Protocols", + 102: "Processing", + 103: "Early Hints", + + // 2xx Success + 200: "OK", + 201: "Created", + 202: "Accepted", + 203: "Non-Authoritative Information", + 204: "No Content", + 205: "Reset Content", + 206: "Partial Content", + 207: "Multi-Status", + 208: "Already Reported", + 226: "IM Used", + + // 3xx Redirection + 300: "Multiple Choices", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 305: "Use Proxy", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + + // 4xx Client Error + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 407: "Proxy Authentication Required", + 408: "Request Timeout", + 409: "Conflict", + 410: "Gone", + 411: "Length Required", + 412: "Precondition Failed", + 413: "Payload Too Large", + 414: "URI Too Long", + 415: "Unsupported Media Type", + 416: "Range Not Satisfiable", + 417: "Expectation Failed", + 418: "I'm a teapot", + 421: "Misdirected Request", + 422: "Unprocessable Entity", + 423: "Locked", + 424: "Failed Dependency", + 425: "Too Early", + 426: "Upgrade Required", + 428: "Precondition Required", + 429: "Too Many Requests", + 431: "Request Header Fields Too Large", + 451: "Unavailable For Legal Reasons", + + // 5xx Server Error + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", + 505: "HTTP Version Not Supported", + 506: "Variant Also Negotiates", + 507: "Insufficient Storage", + 508: "Loop Detected", + 510: "Not Extended", + 511: "Network Authentication Required", +} as const + +/** + * Get the reason phrase for an HTTP status code + * @param statusCode - The HTTP status code + * @returns The reason phrase, or "Unknown" if not found + */ +export const getStatusReason = (statusCode: number): string => { + return HTTP_STATUS_REASONS[statusCode] || "Unknown" +} + +/** + * Check if a status code is informational (1xx) + */ +export const isInformational = (statusCode: number): boolean => { + return statusCode >= 100 && statusCode < 200 +} + +/** + * Check if a status code is successful (2xx) + */ +export const isSuccess = (statusCode: number): boolean => { + return statusCode >= 200 && statusCode < 300 +} + +/** + * Check if a status code is a redirection (3xx) + */ +export const isRedirection = (statusCode: number): boolean => { + return statusCode >= 300 && statusCode < 400 +} + +/** + * Check if a status code is a client error (4xx) + */ +export const isClientError = (statusCode: number): boolean => { + return statusCode >= 400 && statusCode < 500 +} + +/** + * Check if a status code is a server error (5xx) + */ +export const isServerError = (statusCode: number): boolean => { + return statusCode >= 500 && statusCode < 600 +} diff --git a/packages/hoppscotch-js-sandbox/src/constants/sandbox-markers.ts b/packages/hoppscotch-js-sandbox/src/constants/sandbox-markers.ts new file mode 100644 index 0000000..67756a7 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/constants/sandbox-markers.ts @@ -0,0 +1,31 @@ +/** + * Special marker constants for preserving undefined and null values + * across the sandbox boundary during serialization. + * + * These markers are used because: + * - JSON.stringify converts undefined to null or omits the property + * - We need to distinguish between actual null and serialized undefined + * - The sandbox boundary requires serialization, losing type information + */ +export const UNDEFINED_MARKER = "__HOPPSCOTCH_UNDEFINED__" as const +export const NULL_MARKER = "__HOPPSCOTCH_NULL__" as const + +export type SandboxMarker = typeof UNDEFINED_MARKER | typeof NULL_MARKER + +/** + * Converts marker strings back to their original values + */ +export const convertMarkerToValue = (value: unknown): unknown => { + if (value === UNDEFINED_MARKER) return undefined + if (value === NULL_MARKER) return null + return value +} + +/** + * Converts null/undefined values to marker strings for serialization + */ +export const convertValueToMarker = (value: unknown): unknown => { + if (value === undefined) return UNDEFINED_MARKER + if (value === null) return NULL_MARKER + return value +} diff --git a/packages/hoppscotch-js-sandbox/src/global.d.ts b/packages/hoppscotch-js-sandbox/src/global.d.ts new file mode 100644 index 0000000..e5c8003 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/global.d.ts @@ -0,0 +1 @@ +import "@relmify/jest-fp-ts" diff --git a/packages/hoppscotch-js-sandbox/src/node/index.ts b/packages/hoppscotch-js-sandbox/src/node/index.ts new file mode 100644 index 0000000..a329c04 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/node/index.ts @@ -0,0 +1,2 @@ +export { runPreRequestScript } from "./pre-request" +export { runTestScript } from "./test-runner" diff --git a/packages/hoppscotch-js-sandbox/src/node/pre-request/experimental.ts b/packages/hoppscotch-js-sandbox/src/node/pre-request/experimental.ts new file mode 100644 index 0000000..945302b --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/node/pre-request/experimental.ts @@ -0,0 +1,150 @@ +import { Cookie, HoppRESTRequest } from "@hoppscotch/data" +import * as E from "fp-ts/Either" +import * as TE from "fp-ts/lib/TaskEither" +import { cloneDeep } from "lodash" + +import { defaultModules, preRequestModule } from "~/cage-modules" +import { HoppFetchHook, SandboxPreRequestResult, TestResult } from "~/types" +import { acquireCage, resetCage, isInfraError } from "~/utils/cage" + +/** + * Runs a pre-request script on the given cage instance. + * Returns the result or "retry" if a bootstrap error triggered a cage reset. + */ +const executePreRequestOnCage = async ( + cage: Awaited>, + preRequestScript: string, + envs: TestResult["envs"], + request: HoppRESTRequest, + cookies: Cookie[] | null, + hoppFetchHook?: HoppFetchHook +): Promise | "retry"> => { + let finalEnvs = envs + let finalRequest = request + let finalCookies = cookies + + const captureHook: { + capture?: () => void + bootstrapError?: unknown + scriptExecutionError?: { name: string; message: string; stack: string } + } = {} + + const result = await cage.runCode(preRequestScript, [ + ...defaultModules({ + hoppFetchHook, + }), + + preRequestModule( + { + envs: cloneDeep(envs), + request: cloneDeep(request), + cookies: cookies ? cloneDeep(cookies) : null, + handleSandboxResults: ({ envs, request, cookies }) => { + finalEnvs = envs + finalRequest = request + finalCookies = cookies + }, + }, + captureHook + ), + ]) + + if (result.type === "error") { + const bootstrapFailed = captureHook.bootstrapError !== undefined + const errorToAnalyze = bootstrapFailed + ? captureHook.bootstrapError + : result.err + + if (bootstrapFailed || isInfraError(errorToAnalyze)) { + resetCage() + return "retry" + } + + if ( + result.err !== null && + typeof result.err === "object" && + "message" in result.err + ) { + const name = + "name" in result.err && typeof result.err.name === "string" + ? result.err.name + : "" + const prefix = name ? `${name}: ` : "" + return E.left(`Script execution failed: ${prefix}${result.err.message}`) + } + + return E.left(`Script execution failed: ${String(result.err)}`) + } + + // Check for errors reported via the generated try/catch wrapper. + // faraday-cage's keepAlive loop swallows rejected promises and does not + // await afterScriptExecutionHooks, so async-boundary errors reach us + // only via this synchronous host reporter. + if (captureHook.scriptExecutionError) { + const { name, message } = captureHook.scriptExecutionError + const prefix = name ? `${name}: ` : "" + return E.left(`Script execution failed: ${prefix}${message}`) + } + + if (captureHook.capture) { + captureHook.capture() + } + + return E.right({ + updatedEnvs: finalEnvs, + updatedRequest: finalRequest, + updatedCookies: finalCookies, + }) +} + +export const runPreRequestScriptWithFaradayCage = ( + preRequestScript: string, + envs: TestResult["envs"], + request: HoppRESTRequest, + cookies: Cookie[] | null, + hoppFetchHook?: HoppFetchHook +): TE.TaskEither => { + return () => + (async () => { + try { + const cage = await acquireCage() + + const firstAttempt = await executePreRequestOnCage( + cage, + preRequestScript, + envs, + request, + cookies, + hoppFetchHook + ) + + if (firstAttempt !== "retry") { + return firstAttempt + } + + // Bootstrap error detected and cage was reset — retry once on a fresh cage + const freshCage = await acquireCage() + const retryResult = await executePreRequestOnCage( + freshCage, + preRequestScript, + envs, + request, + cookies, + hoppFetchHook + ) + + if (retryResult === "retry") { + return E.left( + "Script execution failed: sandbox initialization error (persistent)" + ) + } + + return retryResult + } catch (error) { + const name = + error instanceof Error && error.name ? `${error.name}: ` : "" + const message = error instanceof Error ? error.message : String(error) + return E.left(`Script execution failed: ${name}${message}`) + } + })() +} diff --git a/packages/hoppscotch-js-sandbox/src/node/pre-request/index.ts b/packages/hoppscotch-js-sandbox/src/node/pre-request/index.ts new file mode 100644 index 0000000..d6a469c --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/node/pre-request/index.ts @@ -0,0 +1,39 @@ +import * as TE from "fp-ts/lib/TaskEither" +import { pipe } from "fp-ts/function" +import { RunPreRequestScriptOptions, SandboxPreRequestResult } from "~/types" + +import { runPreRequestScriptWithFaradayCage } from "./experimental" + +export const runPreRequestScript = ( + preRequestScript: string, + options: RunPreRequestScriptOptions +): TE.TaskEither => { + const { envs, experimentalScriptingSandbox = true } = options + + if (experimentalScriptingSandbox) { + const { request, cookies, hoppFetchHook } = options as Extract< + RunPreRequestScriptOptions, + { experimentalScriptingSandbox: true } + > + + return runPreRequestScriptWithFaradayCage( + preRequestScript, + envs, + request, + cookies, + hoppFetchHook + ) + } + + // Dynamically import legacy runner to avoid loading isolated-vm unless needed + return pipe( + TE.tryCatch( + async () => { + const { runPreRequestScriptWithIsolatedVm } = await import("./legacy") + return runPreRequestScriptWithIsolatedVm(preRequestScript, envs) + }, + (error) => `Legacy sandbox execution failed: ${error}` + ), + TE.chain((taskEither) => taskEither) + ) +} diff --git a/packages/hoppscotch-js-sandbox/src/node/pre-request/legacy.ts b/packages/hoppscotch-js-sandbox/src/node/pre-request/legacy.ts new file mode 100644 index 0000000..75e13c8 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/node/pre-request/legacy.ts @@ -0,0 +1,82 @@ +import { pipe } from "fp-ts/function" +import * as TE from "fp-ts/lib/TaskEither" +import type ivmT from "isolated-vm" +import { createRequire } from "module" + +import { getPreRequestScriptMethods } from "~/utils/shared" +import { SandboxPreRequestResult, TestResult } from "~/types" +import { getSerializedAPIMethods } from "../utils" + +const nodeRequire = createRequire(import.meta.url) +const ivm = nodeRequire("isolated-vm") + +export const runPreRequestScriptWithIsolatedVm = ( + preRequestScript: string, + envs: TestResult["envs"] +): TE.TaskEither => { + return pipe( + TE.tryCatch( + async () => { + const isolate: ivmT.Isolate = new ivm.Isolate() + const context = await isolate.createContext() + return { isolate, context } + }, + (reason) => `Context initialization failed: ${reason}` + ), + TE.chain(({ isolate, context }) => + pipe( + TE.tryCatch( + async () => { + const jail = context.global + const { pw, updatedEnvs } = getPreRequestScriptMethods(envs) + const serializedAPIMethods = getSerializedAPIMethods(pw) + jail.setSync("serializedAPIMethods", serializedAPIMethods, { + copy: true, + }) + jail.setSync("atob", atob) + jail.setSync("btoa", btoa) + // Methods in the isolate context can't be invoked straightaway + const finalScript = ` + const pw = new Proxy(serializedAPIMethods, { + get: (pwObjTarget, pwObjProp) => { + const topLevelEntry = pwObjTarget[pwObjProp] + // "pw.env" set of API methods + if (topLevelEntry && typeof topLevelEntry === "object") { + return new Proxy(topLevelEntry, { + get: (subTarget, subProp) => { + const subLevelProperty = subTarget[subProp] + if (subLevelProperty && subLevelProperty.typeof === "function") { + return (...args) => subLevelProperty.applySync(null, args) + } + }, + }) + } + } + }) + ${preRequestScript} + ` + // Create a script and compile it + const script = await isolate.compileScript(finalScript) + // Run the pre-request script in the provided context + await script.run(context) + return { updatedEnvs, updatedCookies: null } + }, + (reason) => reason + ), + TE.fold( + (error) => TE.left(`Script execution failed: ${error}`), + (result) => + pipe( + TE.tryCatch( + async () => { + await isolate.dispose() + return result + }, + (disposeError) => `Isolate disposal failed: ${disposeError}` + ) + ) + ) + ) + ) + ) +} diff --git a/packages/hoppscotch-js-sandbox/src/node/test-runner/experimental.ts b/packages/hoppscotch-js-sandbox/src/node/test-runner/experimental.ts new file mode 100644 index 0000000..a5b6568 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/node/test-runner/experimental.ts @@ -0,0 +1,189 @@ +import { HoppRESTRequest } from "@hoppscotch/data" +import * as E from "fp-ts/Either" +import * as TE from "fp-ts/TaskEither" +import { cloneDeep } from "lodash" + +import { defaultModules, postRequestModule } from "~/cage-modules" +import { + HoppFetchHook, + TestDescriptor, + TestResponse, + TestResult, +} from "~/types" +import { acquireCage, resetCage, isInfraError } from "~/utils/cage" + +/** + * Runs a post-request/test script on the given cage instance. + * Returns the result or "retry" if a bootstrap error triggered a cage reset. + */ +const executeTestOnCage = async ( + cage: Awaited>, + testScript: string, + envs: TestResult["envs"], + request: HoppRESTRequest, + response: TestResponse, + hoppFetchHook?: HoppFetchHook +): Promise | "retry"> => { + const testRunStack: TestDescriptor[] = [ + { descriptor: "root", expectResults: [], children: [] }, + ] + + let finalEnvs = envs + let finalTestResults = testRunStack + const testPromises: Promise[] = [] + + const captureHook: { + capture?: () => void + bootstrapError?: unknown + scriptExecutionError?: { name: string; message: string; stack: string } + } = {} + + const result = await cage.runCode(testScript, [ + ...defaultModules({ + hoppFetchHook, + }), + postRequestModule( + { + envs: cloneDeep(envs), + testRunStack: cloneDeep(testRunStack), + request: cloneDeep(request), + response: cloneDeep(response), + // TODO: Post type update, accommodate for cookies although platform support is limited + cookies: null, + handleSandboxResults: ({ envs, testRunStack }) => { + finalEnvs = envs + finalTestResults = testRunStack + }, + onTestPromise: (promise) => { + testPromises.push(promise) + }, + }, + captureHook + ), + ]) + + if (result.type === "error") { + const bootstrapFailed = captureHook.bootstrapError !== undefined + const errorToAnalyze = bootstrapFailed + ? captureHook.bootstrapError + : result.err + + if (bootstrapFailed || isInfraError(errorToAnalyze)) { + resetCage() + return "retry" + } + + if ( + result.err !== null && + typeof result.err === "object" && + "message" in result.err + ) { + const name = + "name" in result.err && typeof result.err.name === "string" + ? result.err.name + : "" + const prefix = name ? `${name}: ` : "" + return E.left(`Script execution failed: ${prefix}${result.err.message}`) + } + + return E.left(`Script execution failed: ${String(result.err)}`) + } + + // Execute tests sequentially to support dependent tests that share variables. + if (testPromises.length > 0) { + for (let i = 0; i < testPromises.length; i++) { + await testPromises[i] + } + } + + // Check for errors reported via the generated try/catch wrapper. + // faraday-cage's keepAlive loop swallows rejected promises and does not + // await afterScriptExecutionHooks, so async-boundary errors reach us + // only via this synchronous host reporter. + if (captureHook.scriptExecutionError) { + const { name, message } = captureHook.scriptExecutionError + const prefix = name ? `${name}: ` : "" + return E.left(`Script execution failed: ${prefix}${message}`) + } + + if (captureHook.capture) { + captureHook.capture() + } + + // Check for uncaught runtime errors (ReferenceError, TypeError, etc.) in test callbacks. + // These should fail the entire test run, NOT be reported as testcases. + const runtimeErrors = finalTestResults + .flatMap((t) => t.children) + .flatMap((child) => child.expectResults || []) + .filter( + (r) => + r.status === "error" && + /^(ReferenceError|TypeError|SyntaxError|RangeError|URIError|EvalError|AggregateError|InternalError|Error):/.test( + r.message + ) + ) + + if (runtimeErrors.length > 0) { + return E.left(`Script execution failed: ${runtimeErrors[0].message}`) + } + + const safeTestResults = cloneDeep(finalTestResults) + const safeEnvs = cloneDeep(finalEnvs) + + return E.right({ + tests: safeTestResults, + envs: safeEnvs, + }) +} + +export const runPostRequestScriptWithFaradayCage = ( + testScript: string, + envs: TestResult["envs"], + request: HoppRESTRequest, + response: TestResponse, + hoppFetchHook?: HoppFetchHook +): TE.TaskEither => { + return () => + (async () => { + try { + const cage = await acquireCage() + + const firstAttempt = await executeTestOnCage( + cage, + testScript, + envs, + request, + response, + hoppFetchHook + ) + + if (firstAttempt !== "retry") { + return firstAttempt + } + + // Bootstrap error detected and cage was reset — retry once on a fresh cage + const freshCage = await acquireCage() + const retryResult = await executeTestOnCage( + freshCage, + testScript, + envs, + request, + response, + hoppFetchHook + ) + + if (retryResult === "retry") { + return E.left( + "Script execution failed: sandbox initialization error (persistent)" + ) + } + + return retryResult + } catch (error) { + const name = + error instanceof Error && error.name ? `${error.name}: ` : "" + const message = error instanceof Error ? error.message : String(error) + return E.left(`Script execution failed: ${name}${message}`) + } + })() +} diff --git a/packages/hoppscotch-js-sandbox/src/node/test-runner/index.ts b/packages/hoppscotch-js-sandbox/src/node/test-runner/index.ts new file mode 100644 index 0000000..213a021 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/node/test-runner/index.ts @@ -0,0 +1,70 @@ +import * as E from "fp-ts/Either" +import * as TE from "fp-ts/TaskEither" +import { pipe } from "fp-ts/function" + +import { RunPostRequestScriptOptions, TestResponse, TestResult } from "~/types" +import { parseScriptForSyntax } from "~/utils/scripting" +import { preventCyclicObjects } from "~/utils/shared" +import { runPostRequestScriptWithFaradayCage } from "./experimental" + +// Future TODO: Update return type to be based on `SandboxTestResult` (unified with the web implementation) +// No involvement of cookies in the CLI context currently +export const runTestScript = ( + testScript: string, + options: RunPostRequestScriptOptions +): TE.TaskEither => { + const responseObjHandle = preventCyclicObjects(options.response) + + if (E.isLeft(responseObjHandle)) { + return TE.left(`Response marshalling failed: ${responseObjHandle.left}`) + } + + const resolvedResponse = responseObjHandle.right + const { envs, experimentalScriptingSandbox = true } = options + + // Pre-parse before sandbox spin-up so syntax errors surface as a friendly + // host-side message. Each target uses the grammar that matches its eventual + // executor: experimental → ESM module (top-level imports + await accepted); + // legacy → script mode (top-level imports + await rejected). + try { + parseScriptForSyntax( + testScript, + experimentalScriptingSandbox ? "experimental" : "legacy" + ) + } catch (e) { + const err = e as Error + const reason = `${"name" in err ? (err as any).name : "SyntaxError"}: ${err.message}` + return TE.left(`Script execution failed: ${reason}`) + } + + if (experimentalScriptingSandbox) { + const { request, hoppFetchHook } = options as Extract< + RunPostRequestScriptOptions, + { experimentalScriptingSandbox: true } + > + + return runPostRequestScriptWithFaradayCage( + testScript, + envs, + request, + resolvedResponse, + hoppFetchHook + ) + } + + // Dynamically import legacy runner to avoid loading isolated-vm unless needed + return pipe( + TE.tryCatch( + async () => { + const { runPostRequestScriptWithIsolatedVm } = await import("./legacy") + return runPostRequestScriptWithIsolatedVm( + testScript, + envs, + resolvedResponse + ) + }, + (error) => `Legacy sandbox execution failed: ${error}` + ), + TE.chain((taskEither) => taskEither) + ) +} diff --git a/packages/hoppscotch-js-sandbox/src/node/test-runner/legacy.ts b/packages/hoppscotch-js-sandbox/src/node/test-runner/legacy.ts new file mode 100644 index 0000000..73494a7 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/node/test-runner/legacy.ts @@ -0,0 +1,218 @@ +import * as E from "fp-ts/Either" +import * as TE from "fp-ts/TaskEither" +import { pipe } from "fp-ts/function" +import type ivmT from "isolated-vm" +import { createRequire } from "module" + +import { TestResponse, TestResult } from "~/types" +import { + getTestRunnerScriptMethods, + preventCyclicObjects, +} from "~/utils/shared" +import { getSerializedAPIMethods } from "../utils" + +const nodeRequire = createRequire(import.meta.url) +const ivm = nodeRequire("isolated-vm") + +const executeScriptInContext = ( + testScript: string, + envs: TestResult["envs"], + response: TestResponse, + isolate: ivmT.Isolate, + context: ivmT.Context +): Promise => { + return new Promise((resolve, reject) => { + // Parse response object + const responseObjHandle = preventCyclicObjects(response) + if (E.isLeft(responseObjHandle)) { + return reject(`Response parsing failed: ${responseObjHandle.left}`) + } + + const jail = context.global + + const { pw, testRunStack, updatedEnvs } = getTestRunnerScriptMethods(envs) + + const serializedAPIMethods = getSerializedAPIMethods({ + ...pw, + response: responseObjHandle.right, + }) + jail.setSync("serializedAPIMethods", serializedAPIMethods, { copy: true }) + + jail.setSync("atob", atob) + jail.setSync("btoa", btoa) + + jail.setSync("ivm", ivm) + + // Methods in the isolate context can't be invoked straightaway + const finalScript = ` + const pw = new Proxy(serializedAPIMethods, { + get: (pwObj, pwObjProp) => { + // pw.expect(), pw.env, etc. + const topLevelEntry = pwObj[pwObjProp] + + // If the entry exists and is a function + // pw.expect(), pw.test(), etc. + if (topLevelEntry && topLevelEntry.typeof === "function") { + // pw.test() just involves invoking the function via "applySync()" + if (pwObjProp === "test") { + return (...args) => topLevelEntry.applySync(null, args) + } + + // pw.expect() returns an object with matcher methods + return (...args) => { + // Invoke "pw.expect()" and get access to the object with matcher methods + const expectFnResult = topLevelEntry.applySync( + null, + args.map((expectVal) => { + if (typeof expectVal === "object") { + if (expectVal === null) { + return null + } + + // Only arrays and objects stringified here should be parsed from the "pw.expect()" method definition + // The usecase is that any JSON string supplied should be preserved + // An extra "isStringifiedWithinIsolate" prop is added to indicate it has to be parsed + + if (Array.isArray(expectVal)) { + return JSON.stringify({ + arr: expectVal, + isStringifiedWithinIsolate: true, + }) + } + + return JSON.stringify({ + ...expectVal, + isStringifiedWithinIsolate: true, + }) + } + + return expectVal + }) + ) + + // Matcher methods that can be chained with "pw.expect()" + // pw.expect().toBe(), etc + if (expectFnResult.typeof === "object") { + // Access the getter that points to the negated matcher methods via "{ accessors: true }" + const matcherMethods = { + not: expectFnResult.getSync("not", { accessors: true }), + } + + // Serialize matcher methods for use in the isolate context + const matcherMethodNames = [ + "toBe", + "toBeLevel2xx", + "toBeLevel3xx", + "toBeLevel4xx", + "toBeLevel5xx", + "toBeType", + "toHaveLength", + "toInclude", + ] + matcherMethodNames.forEach((methodName) => { + matcherMethods[methodName] = expectFnResult.getSync(methodName) + }) + + return new Proxy(matcherMethods, { + get: (matcherMethodTarget, matcherMethodProp) => { + // pw.expect().not.toBe(), etc + const matcherMethodEntry = matcherMethodTarget[matcherMethodProp] + + if (matcherMethodProp === "not") { + return new Proxy(matcherMethodEntry, { + get: (negatedObjTarget, negatedObjprop) => { + // Return the negated matcher method defn that is invoked from the test script + const negatedMatcherMethodDefn = negatedObjTarget.getSync(negatedObjprop) + return negatedMatcherMethodDefn + }, + }) + } + + // Return the matcher method defn that is invoked from the test script + return matcherMethodEntry + }, + }) + } + } + } + + // "pw.env" set of API methods + if (typeof topLevelEntry === "object" && pwObjProp !== "response") { + return new Proxy(topLevelEntry, { + get: (subTarget, subProp) => { + const subLevelProperty = subTarget[subProp] + if ( + subLevelProperty && + subLevelProperty.typeof === "function" + ) { + return (...args) => subLevelProperty.applySync(null, args) + } + }, + }) + } + + return topLevelEntry + }, + }) + + ${testScript} + ` + + // Create a script and compile it + const script = isolate.compileScript(finalScript) + + // Run the test script in the provided context + script + .then((script) => script.run(context)) + .then(() => { + resolve({ + tests: testRunStack, + envs: updatedEnvs, + }) + }) + .catch((error: Error) => { + reject(error) + }) + }) +} + +export const runPostRequestScriptWithIsolatedVm = ( + testScript: string, + envs: TestResult["envs"], + response: TestResponse +): TE.TaskEither => { + return pipe( + TE.tryCatch( + async () => { + const isolate: ivmT.Isolate = new ivm.Isolate() + const context = await isolate.createContext() + return { isolate, context } + }, + (reason) => `Context initialization failed: ${reason}` + ), + TE.chain(({ isolate, context }) => + pipe( + TE.tryCatch( + async () => + executeScriptInContext( + testScript, + envs, + response, + isolate, + context + ), + (reason) => `Script execution failed: ${reason}` + ), + TE.chain((result) => + TE.tryCatch( + async () => { + await isolate.dispose() + return result + }, + (disposeReason) => `Isolate disposal failed: ${disposeReason}` + ) + ) + ) + ) + ) +} diff --git a/packages/hoppscotch-js-sandbox/src/node/utils.ts b/packages/hoppscotch-js-sandbox/src/node/utils.ts new file mode 100644 index 0000000..7e44e4d --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/node/utils.ts @@ -0,0 +1,23 @@ +import { createRequire } from "module" + +const nodeRequire = createRequire(import.meta.url) +const ivm = nodeRequire("isolated-vm") + +// Helper function to recursively wrap methods in `ivm.Reference` +export const getSerializedAPIMethods = ( + namespaceObj: Record +): Record => { + const result: Record = {} + + for (const [key, value] of Object.entries(namespaceObj)) { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + result[key] = getSerializedAPIMethods(value as Record) + } else if (typeof value === "function") { + result[key] = new ivm.Reference(value) + } else { + result[key] = value + } + } + + return result +} diff --git a/packages/hoppscotch-js-sandbox/src/scripting.ts b/packages/hoppscotch-js-sandbox/src/scripting.ts new file mode 100644 index 0000000..d75185a --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/scripting.ts @@ -0,0 +1,11 @@ +// Subpath barrel for string helpers; lets consumers skip the runner-module +// Vite worker imports. Relative path keeps the emitted `.d.ts` portable. +export { + MODULE_PREFIX, + combineScriptsWithIIFE, + filterValidScripts, + hasActualScript, + stripJsonSerializedModulePrefix, + stripModulePrefix, + type CombineScriptsTarget, +} from "./utils/scripting" diff --git a/packages/hoppscotch-js-sandbox/src/types/index.ts b/packages/hoppscotch-js-sandbox/src/types/index.ts new file mode 100644 index 0000000..c0e927d --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/types/index.ts @@ -0,0 +1,372 @@ +import { Cookie, HoppRESTRequest } from "@hoppscotch/data" +import { ConsoleEntry, defineSandboxFn } from "faraday-cage/modules" + +import type { EnvAPIOptions } from "~/utils/shared" + +// Infer the return type of defineSandboxFn from faraday-cage +type SandboxFunction = ReturnType + +/** + * Type alias for values that cross the QuickJS sandbox boundary. + * + * Values passed between the host environment and the QuickJS sandbox lose their + * TypeScript type information during serialization. This type alias serves as + * a documented alternative to raw `any`, making it explicit that these values: + * + * - Come from or go to the sandbox (pre-request/post-request scripts) + * - Have been serialized and may not preserve complex types + * - Require runtime validation when type safety is needed + * + * Use this type for: + * - Function parameters that accept user script values + * - Return values sent back to the sandbox + * - PM namespace compatibility (preserves non-string types like arrays, objects) + * + * Supported types for environment variable values: + * - Primitives: string, number, boolean, null, undefined + * - Objects: plain objects, arrays (recursively containing these types) + * - Unsupported: Functions, Symbols, and other non-serializable types + * + * Note: Typed as `any` because this type is used in multiple contexts: + * 1. Environment variable storage (supports primitives, objects, arrays) + * 2. Function parameters from user scripts (requires runtime validation) + * 3. Internal object properties (may include QuickJS handles) + * + * @example + * ```typescript + * // Function accepting values from user scripts + * const envSetAny = (key: SandboxValue, value: SandboxValue) => { + * // Runtime validation required since type is `any` + * if (typeof key !== "string") throw new Error("Expected string key") + * // ... handle value + * } + * ``` + */ +export type SandboxValue = any + +/** + * The response object structure exposed to the test script + */ +export type TestResponse = { + /** Status Code of the response */ + status: number + + /** Status text of the response (e.g., "OK", "Not Found", "Internal Server Error") */ + statusText: string + + /** Time taken for the request to complete in milliseconds */ + responseTime: number + + /** List of headers returned */ + headers: { key: string; value: string }[] + + /** + * Body of the response, this will be the JSON object if it is a JSON content type, else body string + */ + body: string | object +} + +/** + * The result of an expectation statement + */ +export type ExpectResult = { + status: "pass" | "fail" | "error" + message: string +} // The expectation failed (fail) or errored (error) + +/** + * An object defining the result of the execution of a + * test block + */ +export type TestDescriptor = { + /** + * The name of the test block + */ + descriptor: string + + /** + * Expectation results of the test block + */ + expectResults: ExpectResult[] + + /** + * Children test blocks (test blocks inside the test block) + */ + children: TestDescriptor[] +} + +/** + * Internal representation of environment variables within the sandbox runtime. + * + * Values can be complex types (arrays, objects) while executing scripts. + * This type is exported for internal use within js-sandbox but should NOT + * be used by consuming packages - use `EnvironmentVariable` instead. + * + * @internal + */ +export type SandboxEnvironmentVariable = { + key: string + currentValue: SandboxValue // Can be arrays, objects, primitives + initialValue: SandboxValue + secret: boolean +} + +/** + * Internal representation of the envs structure during sandbox execution. + * This is what's used internally, before serialization to TestResult["envs"]. + * + * At runtime, environment variables are stored with SandboxValue types to support + * PM namespace compatibility (arrays, objects, etc.). The serialization to strings + * happens only when getUpdatedEnvs() is called at the end of script execution. + * + * Note: This type is structurally compatible with TestResult["envs"] at runtime, + * but TypeScript sees them as different types due to the SandboxValue vs string + * difference. Use type assertions when converting between them. + * + * @internal + */ +export type SandboxEnvs = { + global: SandboxEnvironmentVariable[] + selected: SandboxEnvironmentVariable[] +} + +/** + * External representation of environment variables at the API boundary. + * + * All values are serialized to strings when crossing the sandbox boundary + * via getUpdatedEnvs() which calls JSON.stringify() on complex types. + * + * This is what consuming packages (hoppscotch-common, cli, etc.) receive. + */ +export type EnvironmentVariable = { + key: string + currentValue: string // Always string after serialization + initialValue: string // Always string after serialization + secret: boolean +} + +/** + * Defines the result of a test script execution. + * + * Note: envs contain EnvironmentVariable (strings) not SandboxValue, + * because values are serialized when leaving the sandbox. + */ +export type TestResult = { + tests: TestDescriptor[] + envs: { + global: EnvironmentVariable[] + selected: EnvironmentVariable[] + } +} + +export type GlobalEnvItem = TestResult["envs"]["global"][number] +export type SelectedEnvItem = TestResult["envs"]["selected"][number] + +export type SandboxTestResult = { + tests: TestDescriptor + envs: TestResult["envs"] + consoleEntries?: ConsoleEntry[] + updatedCookies: Cookie[] | null +} + +export type SandboxPreRequestResult = { + updatedEnvs: TestResult["envs"] + consoleEntries?: ConsoleEntry[] + updatedRequest?: HoppRESTRequest + updatedCookies: Cookie[] | null +} + +export interface Expectation { + toBe(expectedVal: SandboxValue): void + toBeLevel2xx(): void + toBeLevel3xx(): void + toBeLevel4xx(): void + toBeLevel5xx(): void + toBeType(expectedType: SandboxValue): void + toHaveLength(expectedLength: SandboxValue): void + toInclude(needle: SandboxValue): void + readonly not: Expectation +} + +export type RunPreRequestScriptOptions = + | { + envs: TestResult["envs"] + request: HoppRESTRequest + cookies: Cookie[] | null // Exclusive to the Desktop App + experimentalScriptingSandbox: true + hoppFetchHook?: HoppFetchHook // Optional hook for hopp.fetch() implementation + } + | { + envs: TestResult["envs"] + experimentalScriptingSandbox?: false + } + +export type RunPostRequestScriptOptions = + | { + envs: TestResult["envs"] + request: HoppRESTRequest + hoppFetchHook?: HoppFetchHook // Optional hook for hopp.fetch() implementation + response: TestResponse + cookies: Cookie[] | null // Exclusive to the Desktop App + experimentalScriptingSandbox: true + } + | { + envs: TestResult["envs"] + response: TestResponse + experimentalScriptingSandbox?: false + } + +/** + * Request properties structure exposed to sandbox + */ +export type RequestProps = { + readonly url: string + readonly method: string + readonly params: HoppRESTRequest["params"] + readonly headers: HoppRESTRequest["headers"] + readonly body: HoppRESTRequest["body"] + readonly auth: HoppRESTRequest["auth"] + readonly requestVariables: HoppRESTRequest["requestVariables"] +} + +/** + * Environment methods structure returned by getSharedEnvMethods + */ +export type EnvMethods = { + pw: { + get: (key: string, options?: EnvAPIOptions) => string | null | undefined + getResolve: ( + key: string, + options?: EnvAPIOptions + ) => string | null | undefined + set: (key: string, value: string, options?: EnvAPIOptions) => void + unset: (key: string, options?: EnvAPIOptions) => void + resolve: (key: string) => string + } + hopp: { + set: (key: string, value: string, options?: EnvAPIOptions) => void + delete: (key: string, options?: EnvAPIOptions) => void + reset: (key: string, options?: EnvAPIOptions) => void + getInitialRaw: (key: string, options?: EnvAPIOptions) => string | null + setInitial: (key: string, value: string, options?: EnvAPIOptions) => void + } +} + +/** + * Return type for createHoppNamespaceMethods function + */ +export interface HoppNamespaceMethods { + envDelete: SandboxFunction + envReset: SandboxFunction + envGetInitialRaw: SandboxFunction + envSetInitial: SandboxFunction + getRequestProps: SandboxFunction +} + +/** + * Return type for createPwNamespaceMethods function + */ +export interface PwNamespaceMethods { + envGet: SandboxFunction + envGetResolve: SandboxFunction + envSet: SandboxFunction + envUnset: SandboxFunction + envResolve: SandboxFunction + getRequestVariable: SandboxFunction +} + +/** + * Return type for createPmNamespaceMethods function + */ +export interface PmNamespaceMethods { + pmInfoRequestName: SandboxFunction + pmInfoRequestId: SandboxFunction +} + +/** + * Return type for createExpectationMethods function + */ +export interface ExpectationMethods { + expectToBe: SandboxFunction + expectToBeLevel2xx: SandboxFunction + expectToBeLevel3xx: SandboxFunction + expectToBeLevel4xx: SandboxFunction + expectToBeLevel5xx: SandboxFunction + expectToBeType: SandboxFunction + expectToHaveLength: SandboxFunction + expectToInclude: SandboxFunction + expectNotToBe: SandboxFunction + expectNotToBeLevel2xx: SandboxFunction + expectNotToBeLevel3xx: SandboxFunction + expectNotToBeLevel4xx: SandboxFunction + expectNotToBeLevel5xx: SandboxFunction + expectNotToBeType: SandboxFunction + expectNotToHaveLength: SandboxFunction + expectNotToInclude: SandboxFunction +} + +/** + * Return type for createRequestSetterMethods function + */ +export interface RequestSetterMethodsResult { + methods: { + setRequestUrl: SandboxFunction + setRequestMethod: SandboxFunction + setRequestHeader: SandboxFunction + setRequestHeaders: SandboxFunction + setRequestParam: SandboxFunction + setRequestParams: SandboxFunction + removeRequestHeader: SandboxFunction + removeRequestParam: SandboxFunction + setRequestBody: SandboxFunction + setRequestAuth: SandboxFunction + setRequestVariable: SandboxFunction + } + getUpdatedRequest: () => HoppRESTRequest +} + +/** + * Return type for createBaseInputs function + */ +export interface BaseInputs + extends PwNamespaceMethods, HoppNamespaceMethods, PmNamespaceMethods { + cookieGet: SandboxFunction + cookieSet: SandboxFunction + cookieHas: SandboxFunction + cookieGetAll: SandboxFunction + cookieDelete: SandboxFunction + cookieClear: SandboxFunction + // Returns serialized env vars (SandboxValue -> string conversion happens here) + getUpdatedEnvs: () => TestResult["envs"] + getUpdatedCookies: () => Cookie[] | null + [key: string]: SandboxValue // Index signature for dynamic namespace properties +} + +/** + * Metadata about a fetch() call made during script execution + */ +export type FetchCallMeta = { + url: string + method: string + timestamp: number +} + +/** + * Hook function for implementing hopp.fetch() / pm.sendRequest() + * + * This hook is called when scripts invoke fetch APIs. Implementations + * differ by environment: + * - Web app: Routes through KernelInterceptorService (respects interceptor preference) + * - CLI: Uses axios directly for network requests + * + * Signature matches standard Fetch API to be compatible with faraday-cage's + * fetch module requirements. + * + * @param input - The URL to fetch (string, URL, or Request object) + * @param init - Standard Fetch API options (method, headers, body, etc.) + * @returns Promise - Standard Fetch API Response object + */ +export type HoppFetchHook = ( + input: RequestInfo | URL, + init?: RequestInit +) => Promise diff --git a/packages/hoppscotch-js-sandbox/src/utils/cage.ts b/packages/hoppscotch-js-sandbox/src/utils/cage.ts new file mode 100644 index 0000000..049a3bf --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/utils/cage.ts @@ -0,0 +1,69 @@ +import { FaradayCage } from "faraday-cage" + +let cagePromise: Promise | null = null + +const isTestEnvironment = + typeof process !== "undefined" && process.env.VITEST === "true" + +/** + * Determines if an error indicates an infrastructure failure (not a user script error). + * + * FaradayCage/QuickJS errors arrive in two shapes: + * + * 1. **User script errors** — `cage.runCode()` returns `{ type: "error" }` where + * `result.err` is a plain object from QuickJS `dump()` (NOT `instanceof Error`). + * + * 2. **Infrastructure errors** — Thrown by host-side module setup (e.g. + * `QuickJSUnwrapError`, marshal failures, WASM init). These are real + * `Error` instances. + * + * `instanceof Error` reliably discriminates between the two. + */ +export const isInfraError = (err: unknown): boolean => err instanceof Error + +export const resetCage = (): void => { + cagePromise = null +} + +/** + * Returns a cached FaradayCage singleton (production) or a fresh instance (tests). + * + * In test environments, a fresh cage is created by default. Tests that need to + * exercise the singleton/retry path can override this via `_setCagePromiseForTesting()`. + */ +export const acquireCage = async (): Promise => { + if (isTestEnvironment) { + if (cagePromise) { + return cagePromise.catch((err) => { + cagePromise = null + throw err + }) + } + + return FaradayCage.create() + } + + if (!cagePromise) { + cagePromise = FaradayCage.create().catch((err) => { + cagePromise = null + throw err + }) + } + + return cagePromise +} + +/** + * Injects a cage promise into the singleton slot. Test-only — allows tests to + * exercise the singleton/retry path that is normally skipped in test environments. + */ +export const _setCagePromiseForTesting = ( + promise: Promise | null +): void => { + if (!isTestEnvironment) { + throw new Error( + "_setCagePromiseForTesting is test-only and cannot be used in non-test environments" + ) + } + cagePromise = promise +} diff --git a/packages/hoppscotch-js-sandbox/src/utils/chai-integration.ts b/packages/hoppscotch-js-sandbox/src/utils/chai-integration.ts new file mode 100644 index 0000000..c8e2c43 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/utils/chai-integration.ts @@ -0,0 +1,354 @@ +import * as chai from "chai" +import { TestDescriptor, SandboxValue } from "../types" + +/** + * Creates a Chai expectation that records results to the test stack + * This integrates actual Chai.js with Hoppscotch's test reporting system + * + * Returns a serializable proxy object that can cross the sandbox boundary + */ +export function createChaiExpectation( + value: SandboxValue, + testStack: TestDescriptor[] +) { + // Create the actual Chai assertion + const assertion = chai.expect(value) + + // Create a serializable proxy that can cross the sandbox boundary + return createSerializableProxy(assertion, value, testStack, {}) +} + +/** + * Creates a serializable proxy object that mimics Chai's API + * This can cross the sandbox boundary unlike the actual Chai assertion object + */ +function createSerializableProxy( + assertion: any, // Chai assertion object - dynamic API, must be any + originalValue: SandboxValue, + testStack: TestDescriptor[], + flags: SandboxValue +): any { + // Returns dynamic proxy with Chai-like API + const proxy: any = {} // Dynamic proxy object with Chai-like methods + + // Helper to create assertion methods + const createMethod = (methodName: string) => { + return (...args: SandboxValue[]) => { + try { + // Call the actual Chai method + const result = assertion[methodName](...args) + + // Record success + recordResult( + testStack, + true, + buildMessage(assertion, methodName, args, originalValue, false) + ) + + // If result is a Chai assertion, return a new serializable proxy + if (result && typeof result === "object" && result.__flags) { + return createSerializableProxy( + result, + result._obj, + testStack, + result.__flags + ) + } + + return result + } catch (error: any) { + // Record failure but DON'T throw - allow test to continue + recordResult(testStack, false, extractErrorMessage(error)) + // Return a proxy to allow chaining even after failure + return createSerializableProxy( + assertion, + originalValue, + testStack, + flags + ) + } + } + } + + // Helper to create assertion getter properties (these perform assertions when accessed) + const createAssertionGetter = (propName: string) => { + return () => { + try { + // Access the property which triggers the assertion + void assertion[propName] + + // Record success + recordResult( + testStack, + true, + buildMessage(assertion, propName, [], originalValue, false) + ) + + // Return undefined (assertion getters don't return values) + return undefined + } catch (error: any) { + // Record failure but DON'T throw - allow test to continue + recordResult(testStack, false, extractErrorMessage(error)) + // Return undefined to allow test to continue + return undefined + } + } + } + + // Helper to create language chain getters (these just return new assertions) + const createChainGetter = (propName: string) => { + return () => { + // Access the property on the Chai assertion + const value = assertion[propName] + // Return a new serializable proxy + if (value && typeof value === "object" && value.__flags) { + return createSerializableProxy( + value, + value._obj || originalValue, + testStack, + value.__flags + ) + } + return value + } + } + + // Add all Chai assertion methods (functions) + const methods = [ + "equal", + "equals", + "eq", + "eql", + "include", + "includes", + "contain", + "contains", + "a", + "an", + "instanceof", + "instanceOf", + "property", + "ownProperty", + "ownPropertyDescriptor", + "lengthOf", + "length", + "match", + "matches", + "string", + "keys", + "key", + "throw", + "throws", + "Throw", + "respondTo", + "respondsTo", + "satisfy", + "satisfies", + "closeTo", + "approximately", + "members", + "oneOf", + "change", + "changes", + "increase", + "increases", + "decrease", + "decreases", + "by", + "above", + "gt", + "greaterThan", + "least", + "gte", + "below", + "lt", + "lessThan", + "most", + "lte", + "within", + ] + + // Add all methods to the proxy + methods.forEach((method) => { + proxy[method] = createMethod(method) + }) + + // Add assertion getters (these perform assertions when accessed) + const assertionGetters = [ + "ok", + "true", + "false", + "null", + "undefined", + "NaN", + "exist", + "empty", + "arguments", + "Arguments", + "finite", + "extensible", + "sealed", + "frozen", + ] + + assertionGetters.forEach((getter) => { + Object.defineProperty(proxy, getter, { + get: createAssertionGetter(getter), + enumerable: false, // Don't enumerate to avoid serialization issues + configurable: true, + }) + }) + + // Add language chains as getters (these just return new assertions) + const chains = [ + "to", + "be", + "been", + "is", + "that", + "which", + "and", + "has", + "have", + "with", + "at", + "of", + "same", + "but", + "does", + "not", + "deep", + "nested", + "own", + "ordered", + "any", + "all", + "itself", + ] + + chains.forEach((chain) => { + Object.defineProperty(proxy, chain, { + get: createChainGetter(chain), + enumerable: false, // Don't enumerate to avoid serialization issues + configurable: true, + }) + }) + + return proxy +} + +/** + * Records an assertion result to the test stack + */ +function recordResult( + testStack: TestDescriptor[], + passed: boolean, + message: string +) { + if (testStack.length === 0) return + + const currentTest = testStack[testStack.length - 1] + currentTest.expectResults.push({ + status: passed ? "pass" : "fail", + message, + }) +} + +/** + * Builds a message for an assertion + * Tries to match the format expected by tests + */ +function buildMessage( + assertion: any, // Chai assertion object - dynamic API, must be any + method: string, + args: SandboxValue[], + value: SandboxValue, + _failed: boolean +): string { + const flags = assertion.__flags || {} + const valueStr = formatValue(value) + + let message = `Expected ${valueStr}` + + // Add "to" or "to not" + if (flags.negate) { + message += " to not" + } else { + message += " to" + } + + // Add modifiers + if (flags.deep) message += " deep" + if (flags.own) message += " own" + if (flags.nested) message += " nested" + + // Add the method name + message += ` ${method}` + + // Add arguments + if (args.length > 0) { + const argStrs = args.map(formatValue) + message += ` ${argStrs.join(", ")}` + } + + return message +} + +/** + * Extracts a clean error message from a Chai assertion error + */ +function extractErrorMessage(error: any): string { + if (!error) return "Assertion failed" + + // Chai errors have a message property + let message = error.message || String(error) + + // Remove stack traces and extra info + const lines = message.split("\n") + if (lines.length > 0) { + message = lines[0] + } + + // Clean up Chai's "expected X to Y" format + // Chai uses lowercase "expected", we want "Expected" + if (message.startsWith("expected ")) { + message = "E" + message.substring(1) + } + + return message +} + +/** + * Formats a value for display in messages + */ +function formatValue(val: SandboxValue): string { + if (val === null) return "null" + if (val === undefined) return "undefined" + if (typeof val === "string") return `'${val}'` + if (typeof val === "number") { + if (isNaN(val)) return "NaN" + if (val === Infinity) return "Infinity" + if (val === -Infinity) return "-Infinity" + return String(val) + } + if (typeof val === "boolean") return String(val) + if (Array.isArray(val)) { + if (val.length === 0) return "[]" + const items = val.slice(0, 10).map(formatValue) + return `[${items.join(", ")}]` + } + if (typeof val === "object") { + try { + const keys = Object.keys(val) + if (keys.length === 0) return "{}" + const pairs = keys.slice(0, 5).map((k) => `${k}: ${formatValue(val[k])}`) + return `{${pairs.join(", ")}}` + } catch { + return "[object Object]" + } + } + if (typeof val === "function") { + return val.name || "[Function]" + } + return String(val) +} diff --git a/packages/hoppscotch-js-sandbox/src/utils/pre-request.ts b/packages/hoppscotch-js-sandbox/src/utils/pre-request.ts new file mode 100644 index 0000000..206e1ae --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/utils/pre-request.ts @@ -0,0 +1,160 @@ +import { + HoppRESTAuth, + HoppRESTHeaders, + HoppRESTParams, + HoppRESTReqBody, + HoppRESTRequest, +} from "@hoppscotch/data" +import { cloneDeep } from "lodash" + +export const getRequestSetterMethods = (request: HoppRESTRequest) => { + // Clone to allow safe mutations internally + const updatedRequest = cloneDeep(request) + + // Mutation methods + const setUrl = (url: string) => { + updatedRequest.endpoint = url + } + + const setMethod = (method: string) => { + // NOTE: Postman does NOT normalize method to uppercase, so we preserve the original case + updatedRequest.method = method + } + const setHeader = (name: string, value: string) => { + const headers = [...updatedRequest.headers] + const headerIndex = headers.findIndex( + (h) => h.key.toLowerCase() === name.toLowerCase() + ) + + if (headerIndex >= 0) { + headers[headerIndex].value = value + } else { + headers.push({ key: name, value, active: true, description: "" }) + } + + updatedRequest.headers = headers + } + + const setHeaders = (headers: HoppRESTHeaders) => { + const parseResult = HoppRESTHeaders.safeParse(headers) + + if (!parseResult.success) { + throw new Error("Invalid headers object") + } + + updatedRequest.headers = parseResult.data + } + + const removeHeader = (key: string) => { + updatedRequest.headers = updatedRequest.headers.filter( + (h) => h.key.toLowerCase() !== key.toLowerCase() + ) + } + + const setParam = (name: string, value: string) => { + const params = [...updatedRequest.params] + const paramIndex = params.findIndex( + (p) => p.key.toLowerCase() === name.toLowerCase() + ) + + if (paramIndex >= 0) { + params[paramIndex].value = value + } else { + params.push({ key: name, value, active: true, description: "" }) + } + + updatedRequest.params = params + } + + const setParams = (params: HoppRESTParams) => { + const parseResult = HoppRESTParams.safeParse(params) + + if (!parseResult.success) { + throw new Error("Invalid params object") + } + + updatedRequest.params = parseResult.data + } + + const removeParam = (key: string) => { + updatedRequest.params = updatedRequest.params.filter((h) => h.key !== key) + } + + const setBody = (newBody: Partial) => { + // Runtime validations given the input is user controlled + if ( + typeof newBody !== "object" || + newBody === null || + Array.isArray(newBody) || + Object.keys(newBody).length === 0 + ) { + throw new Error( + "Invalid body object. Expected a non-empty object with valid body properties." + ) + } + + const mergedBody = { ...updatedRequest.body, ...newBody } + + const parseResult = HoppRESTReqBody.safeParse(mergedBody) + + if (!parseResult.success) { + throw new Error( + "Invalid body object. Expected a non-empty object with valid body properties." + ) + } + + updatedRequest.body = { ...parseResult.data } + } + + const setAuth = (newAuth: HoppRESTAuth) => { + // Runtime validations given the input is user controlled + if ( + typeof newAuth !== "object" || + newAuth === null || + Array.isArray(newAuth) || + Object.keys(newAuth).length === 0 + ) { + throw new Error("Invalid auth object") + } + + const mergedAuth = { ...updatedRequest.auth, ...newAuth } + + const parseResult = HoppRESTAuth.safeParse(mergedAuth) + + if (!parseResult.success) { + throw new Error("Invalid auth object") + } + + updatedRequest.auth = { ...parseResult.data } + } + + const setRequestVariable = (key: string, value: string) => { + const reqVarIndex = updatedRequest.requestVariables.findIndex( + (reqVar) => reqVar.key === key + ) + + if (reqVarIndex >= 0) { + updatedRequest.requestVariables[reqVarIndex].value = value + } else { + updatedRequest.requestVariables.push({ key, value, active: true }) + } + } + + // Returns setter methods under the `request` namespace + return { + methods: { + setUrl, + setMethod, + setHeader, + setHeaders, + removeHeader, + setParam, + setParams, + removeParam, + setBody, + setAuth, + setRequestVariable, + }, + updatedRequest, + } +} diff --git a/packages/hoppscotch-js-sandbox/src/utils/scripting.ts b/packages/hoppscotch-js-sandbox/src/utils/scripting.ts new file mode 100644 index 0000000..b692653 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/utils/scripting.ts @@ -0,0 +1,282 @@ +import { + Parser, + type ExportAllDeclaration, + type ExportNamedDeclaration, + type ImportDeclaration, + type Program, +} from "acorn" + +// Monaco prepends this to TS-mode editor buffers so each script parses as a +// module. Strip it before legacy execution and before serializing to JSON. +export const MODULE_PREFIX = "export {};\n" as const + +/** + * Strips `export {};\n` prefix from scripts before legacy sandbox execution + * (non-module context) or when exporting collections. + */ +export const stripModulePrefix = (script: string): string => { + if (script.startsWith(MODULE_PREFIX)) { + return script.slice(MODULE_PREFIX.length) + } + if (script.startsWith("export {};")) { + return script.slice("export {};".length) + } + return script +} + +/** + * Strips the JSON-serialized `export {};` prefix (with optional `\n` literal) + * from the start of any JSON string value during collection export. + * Capture-and-reinsert is used in place of a lookbehind so the regex parses + * on WebKit < 16.4 (Tauri's macOS WKWebView before Ventura 13.3). + */ +export const stripJsonSerializedModulePrefix = (json: string): string => + json.replace(/(:\s*")export \{\};(?:\\n)?/g, "$1") + +export type CombineScriptsTarget = "experimental" | "legacy" + +// Shared parser options. The experimental path admits ESM grammar (top-level +// imports + await) so they reach faraday-cage's module evaluator. The legacy +// path mirrors its executor's script-mode grammar — top-level `await` and +// `import` are rejected pre-cage to match what the legacy evaluator would. +const PARSE_OPTIONS = { + experimental: { + ecmaVersion: "latest", + sourceType: "module", + allowReturnOutsideFunction: true, + }, + legacy: { + ecmaVersion: "latest", + sourceType: "script", + allowReturnOutsideFunction: true, + }, +} as const + +export const parseScriptForSyntax = ( + script: string, + target: CombineScriptsTarget = "experimental" +): void => { + Parser.parse(script, PARSE_OPTIONS[target]) +} + +type ImportBinding = { + name: string + source: string +} + +type ExtractedImports = { + importStatements: string[] + body: string + bindings: ImportBinding[] + // Set when Acorn rejects the script, so the wrapper surfaces the original + // parse error instead of a downstream "import declarations may only appear + // at top level" from re-evaluating the unmodified body inside an IIFE. + parseError?: string +} + +// Wrapper-declared module-scope names + `globalThis` (which the wrapper +// reads for the reporter). User imports binding these would duplicate- +// declare or shadow them post-hoist, so we reject pre-cage. +const RESERVED_WRAPPER_NAMES = new Set(["__hoppReporter", "globalThis"]) + +// Top-level node shapes that resolve a module URL and therefore must reach +// module scope outside the IIFE wrapper: `import` declarations, plus +// re-export-from forms (`export { x } from "y"`, `export * from "y"`, +// `export * as ns from "y"`). Local-only `export const` / `export { x }` +// don't carry a `source`, so they stay in the body. +type ModuleResolvingDeclaration = + | ImportDeclaration + | (ExportNamedDeclaration & { + source: NonNullable + }) + | ExportAllDeclaration + +const isModuleResolvingDeclaration = ( + n: Program["body"][number] +): n is ModuleResolvingDeclaration => + n.type === "ImportDeclaration" || + n.type === "ExportAllDeclaration" || + (n.type === "ExportNamedDeclaration" && n.source !== null) + +// Lifts top-level module-resolving declarations so they can be hoisted to +// module scope; the IIFE wrapper would otherwise reject them as `SyntaxError`. +const extractTopLevelImports = (script: string): ExtractedImports => { + const empty: ExtractedImports = { + importStatements: [], + body: script, + bindings: [], + } + if (!script.trim()) return empty + + let ast: Program + try { + ast = Parser.parse(script, PARSE_OPTIONS.experimental) + } catch (err) { + return { + ...empty, + parseError: err instanceof Error ? err.message : String(err), + } + } + + const moduleNodes = ast.body.filter(isModuleResolvingDeclaration) + if (moduleNodes.length === 0) return empty + + let body = "" + let cursor = 0 + for (const node of moduleNodes) { + body += script.slice(cursor, node.start) + cursor = node.end + } + body += script.slice(cursor) + + // Only `import` declarations introduce local bindings that could collide + // across cascade levels. Re-exports rebind to the consumer, not to a + // local name, so they don't participate in the duplicate-binding check. + const bindings: ImportBinding[] = moduleNodes + .filter((n): n is ImportDeclaration => n.type === "ImportDeclaration") + .flatMap((n) => + n.specifiers.map((s) => ({ + name: s.local.name, + source: String(n.source.value ?? ""), + })) + ) + + return { + importStatements: moduleNodes.map((n) => script.slice(n.start, n.end)), + body, + bindings, + } +} + +const wrapLegacyScript = (script: string): string => { + const stripped = stripModulePrefix(script.trim()) + if (!stripped) return "" + return `function() {\n${stripped}\n}` +} + +/** + * Combines inherited scripts into a sequential chain. Each script runs in + * its own function for scope isolation. + * + * - `experimental`: `await (async function(){...})();` lines, evaluated in + * an async host context so each `await` settles before the next runs. + * Top-level `import` and `export … from` declarations are hoisted out of + * the IIFEs so module resolution (e.g. faraday-cage's esmModuleLoader) + * can see them. Identical import statements across scripts are deduped; + * same-name imports from different sources, parse failures, and bindings + * that collide with wrapper internals all surface a friendly `SyntaxError` + * pre-cage. + * - `legacy`: sync `(function(){...}).call(this);` lines. Top-level `await` + * is rejected at parse time. + * + * Side-effect imports run at module-evaluation time, before any cascade + * body. The body-order guarantee (root → folder → request) does not extend + * to top-level effects in imported modules. Value imports are unaffected. + */ +export const combineScriptsWithIIFE = ( + scripts: string[], + target: CombineScriptsTarget = "experimental" +): string => { + if (target === "legacy") { + const fns = scripts.map(wrapLegacyScript).filter((s) => s) + if (fns.length === 0) return "" + // Leading `;` guards against ASI: a prior `})` on the host line would + // otherwise be read as a call against our IIFE expression. + return fns.map((fn) => `;(${fn}).call(this);`).join("\n") + } + + const extracted = scripts.map((s) => + extractTopLevelImports(stripModulePrefix(s.trim())) + ) + + const fns = extracted + .map(({ body }) => + body.trim() ? `async function() {\n${body.trim()}\n}` : "" + ) + .filter((s) => s) + + // Identical import statements (literal string match across scripts) are + // deduped to a single emitted line. Same name from different sources is a + // real conflict and surfaces a friendly `SyntaxError` pre-cage. + const allImports = [ + ...new Set(extracted.flatMap((e) => e.importStatements)), + ].filter(Boolean) + + const parseError = extracted.find((e) => e.parseError)?.parseError + + const sourcesByName = new Map>() + for (const { name, source } of extracted.flatMap((e) => e.bindings)) { + if (!sourcesByName.has(name)) sourcesByName.set(name, new Set()) + sourcesByName.get(name)!.add(source) + } + const conflictingName = [...sourcesByName.entries()].find( + ([, sources]) => sources.size > 1 + )?.[0] + + const allBindingNames = new Set( + extracted.flatMap((e) => e.bindings.map((b) => b.name)) + ) + const reservedConflict = [...RESERVED_WRAPPER_NAMES].find((n) => + allBindingNames.has(n) + ) + + if (fns.length === 0 && allImports.length === 0 && !parseError) return "" + + // Errors short-circuit before synthesis; reserved-name check sits before + // the import-only return so reserved bindings still surface. + if (parseError !== undefined) { + return synthesizeReporterWrapper( + `throw new SyntaxError(${JSON.stringify(`[Hoppscotch] Script failed to parse: ${parseError}`)});` + ) + } + + if (conflictingName !== undefined) { + return synthesizeReporterWrapper( + `throw new SyntaxError(${JSON.stringify(`[Hoppscotch] '${conflictingName}' is imported from different sources across scripts in this request's chain. Please import it from a single source, or rename one of the imports to resolve the conflict.`)});` + ) + } + + if (reservedConflict !== undefined) { + return synthesizeReporterWrapper( + `throw new SyntaxError(${JSON.stringify(`[Hoppscotch] '${reservedConflict}' is reserved by Hoppscotch's script wrapper and cannot be used as an import binding. Please rename the import.`)});` + ) + } + + // Import-only cascade: skip the try/catch — no awaited bodies to route + // errors from. Module-evaluation errors propagate via faraday-cage. + if (fns.length === 0) return allImports.join("\n") + + // Wrap the awaited chain in try/catch so top-level throws / rejected + // awaits reach the host reporter; faraday-cage otherwise swallows + // async-boundary errors via its keepAlive loop. + const body = fns.map((fn) => `await (${fn})();`).join("\n") + const tryBlock = synthesizeReporterWrapper(body) + + if (allImports.length === 0) return tryBlock + + return [allImports.join("\n"), tryBlock].join("\n") +} + +const synthesizeReporterWrapper = (bodyLines: string): string => + [ + "const __hoppReporter = globalThis.__hoppReportScriptExecutionError;", + "try {", + bodyLines, + "} catch (__hoppScriptExecutionError) {", + " __hoppReporter(__hoppScriptExecutionError);", + "}", + ].join("\n") + +// Monaco prepends "export {};\n" to empty scripts — strip before checking. +export const hasActualScript = (script: string | undefined | null): boolean => { + if (!script) return false + return stripModulePrefix(script.trim()).length > 0 +} + +export const filterValidScripts = ( + scripts: (string | undefined | null)[] +): string[] => + scripts.filter( + (script): script is string => + typeof script === "string" && stripModulePrefix(script).trim().length > 0 + ) diff --git a/packages/hoppscotch-js-sandbox/src/utils/shared.ts b/packages/hoppscotch-js-sandbox/src/utils/shared.ts new file mode 100644 index 0000000..9b41fe3 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/utils/shared.ts @@ -0,0 +1,1009 @@ +import { + Cookie, + CookieSchema, + HoppRESTRequest, + parseTemplateStringE, +} from "@hoppscotch/data" +import * as E from "fp-ts/Either" +import * as O from "fp-ts/Option" +import { pipe } from "fp-ts/lib/function" +import { cloneDeep } from "lodash-es" + +import { + Expectation, + TestDescriptor, + TestResult, + SandboxValue, + SandboxEnvironmentVariable, + SandboxEnvs, +} from "../types" +import { UNDEFINED_MARKER, NULL_MARKER } from "~/constants/sandbox-markers" + +export type EnvSource = "active" | "global" | "all" +export type EnvAPIOptions = { + fallbackToNull?: boolean + source: EnvSource +} + +const getEnv = ( + envName: string, + envs: SandboxEnvs, + options = { source: "all" } +) => { + if (options.source === "active") { + return O.fromNullable( + envs.selected.find((x: SandboxEnvironmentVariable) => x.key === envName) + ) + } + + if (options.source === "global") { + return O.fromNullable( + envs.global.find((x: SandboxEnvironmentVariable) => x.key === envName) + ) + } + + return O.fromNullable( + envs.selected.find((x: SandboxEnvironmentVariable) => x.key === envName) ?? + envs.global.find((x: SandboxEnvironmentVariable) => x.key === envName) + ) +} + +const findEnvIndex = ( + envName: string, + envList: SandboxEnvironmentVariable[] +): number => { + return envList.findIndex( + (envItem: SandboxEnvironmentVariable) => envItem.key === envName + ) +} + +const setEnv = ( + envName: string, + envValue: SandboxValue, + envs: SandboxEnvs, + options: { setInitialValue?: boolean; source: EnvSource } = { + setInitialValue: false, + source: "all", + } +): SandboxEnvs => { + const { global, selected } = envs + + const indexInSelected = findEnvIndex(envName, selected) + const indexInGlobal = findEnvIndex(envName, global) + + if (["all", "active"].includes(options.source) && indexInSelected >= 0) { + const selectedEnv = selected[indexInSelected] + const targetProperty = options.setInitialValue + ? "initialValue" + : "currentValue" + + selectedEnv[targetProperty] = envValue + } else if (["all", "global"].includes(options.source) && indexInGlobal >= 0) { + const globalEnv = global[indexInGlobal] + const targetProperty = options.setInitialValue + ? "initialValue" + : "currentValue" + + globalEnv[targetProperty] = envValue + } else if (["all", "active"].includes(options.source)) { + selected.push({ + key: envName, + currentValue: envValue, + initialValue: envValue, + secret: false, + }) + } else if (["all", "global"].includes(options.source)) { + global.push({ + key: envName, + currentValue: envValue, + initialValue: envValue, + secret: false, + }) + } + + return { + global, + selected, + } +} + +const unsetEnv = ( + envName: string, + envs: SandboxEnvs, + options = { source: "all" } +): SandboxEnvs => { + const { global, selected } = envs + + const indexInSelected = findEnvIndex(envName, selected) + const indexInGlobal = findEnvIndex(envName, global) + + if (["all", "active"].includes(options.source) && indexInSelected >= 0) { + selected.splice(indexInSelected, 1) + } else if (["all", "global"].includes(options.source) && indexInGlobal >= 0) { + global.splice(indexInGlobal, 1) + } + + return { + global, + selected, + } +} + +/** + * Compiles shared scripting API methods (scoped to environments) for use in both pre and post request scripts + * Experimental sandbox version - Returns methods organized by namespace (`pw` and `hopp`) + */ +export function getSharedEnvMethods( + envs: TestResult["envs"], + isHoppNamespace: true +): { + methods: { + pw: { + get: (key: string, options?: EnvAPIOptions) => string | null | undefined + getResolve: ( + key: string, + options?: EnvAPIOptions + ) => string | null | undefined + set: (key: string, value: string, options?: EnvAPIOptions) => void + unset: (key: string, options?: EnvAPIOptions) => void + resolve: (key: string) => string + } + hopp: { + set: (key: string, value: string, options?: EnvAPIOptions) => void + delete: (key: string, options?: EnvAPIOptions) => void + reset: (key: string, options?: EnvAPIOptions) => void + getInitialRaw: (key: string, options?: EnvAPIOptions) => string | null + setInitial: (key: string, value: string, options?: EnvAPIOptions) => void + } + } + pmSetAny: (key: string, value: SandboxValue, options?: EnvAPIOptions) => void + updatedEnvs: SandboxEnvs +} + +/** + * Legacy sandbox version - Methods pre-wrapped in `env` for direct `pw` namespace assignment + * (Experimental sandbox powered by `faraday-cage` handles this wrapping via bootstrap code) + */ +export function getSharedEnvMethods( + envs: TestResult["envs"], + isHoppNamespace?: false +): { + methods: { + env: { + get: (key: string, options?: EnvAPIOptions) => string | null | undefined + getResolve: ( + key: string, + options?: EnvAPIOptions + ) => string | null | undefined + set: (key: string, value: string, options?: EnvAPIOptions) => void + unset: (key: string, options?: EnvAPIOptions) => void + resolve: (key: string) => string + } + } + updatedEnvs: SandboxEnvs +} + +export function getSharedEnvMethods( + envs: TestResult["envs"], + isHoppNamespace = false +): unknown { + /** + * Type assertion explanation: + * + * The `envs` parameter is typed as `TestResult["envs"]` (with string values) for external API + * compatibility, but at runtime it contains `SandboxValue` types during script execution. + * + * Data flow: + * 1. Entry: External caller passes envs with string values + * { global: [{ key: "count", currentValue: "5", initialValue: "0" }], selected: [] } + * + * 2. Execution: Scripts mutate with complex types (PM namespace compatibility) + * pm.environment.set("users", [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]) + * pm.environment.set("config", { debug: true, maxRetries: 3 }) + * // Now: currentValue is an array/object, not a string! + * + * 3. Exit: getUpdatedEnvs() serializes back to strings via JSON.stringify() + * { global: [{ key: "users", currentValue: "[{...}]", initialValue: "[]" }], ... } + * + * The `satisfies` check acknowledges that during execution (steps 1-3), the runtime type is + * SandboxEnvs, even though the declared type is TestResult["envs"] for API boundary compatibility. + */ + let updatedEnvs = envs satisfies SandboxEnvs + + const envGetFn = ( + key: unknown, + options: EnvAPIOptions = { fallbackToNull: false, source: "all" } + ) => { + if (typeof key !== "string") { + throw new Error("Expected key to be a string") + } + + const result = pipe( + getEnv(key, updatedEnvs, options), + O.fold( + () => (options.fallbackToNull ? null : undefined), + (env) => { + // Get the value to use (currentValue or fallback to initialValue) + // Treat undefined, empty string, and null as "empty" and fallback to initialValue + const valueToUse = + env.currentValue !== undefined && + env.currentValue !== "" && + env.currentValue !== null + ? env.currentValue + : env.initialValue + + // Convert markers back to their actual types for script execution + // This ensures null/undefined values are properly represented in scripts + if (valueToUse === UNDEFINED_MARKER) { + return undefined + } + if (valueToUse === NULL_MARKER) { + return null + } + + // Preserve complex types (arrays, objects) for PM namespace compatibility + return valueToUse + } + ) + ) + + return result + } + + const envGetResolveFn = ( + key: unknown, + options: EnvAPIOptions = { fallbackToNull: false, source: "all" } + ) => { + if (typeof key !== "string") { + throw new Error("Expected key to be a string") + } + + const shouldIncludeSelected = ["all", "active"].includes(options.source) + const shouldIncludeGlobal = ["all", "global"].includes(options.source) + + const envVars = [ + ...(shouldIncludeSelected ? updatedEnvs.selected : []), + ...(shouldIncludeGlobal ? updatedEnvs.global : []), + ] + + const result = pipe( + getEnv(key, updatedEnvs, options), + E.fromOption(() => "INVALID_KEY" as const), + + E.map((e) => { + // Get the value to use (currentValue or fallback to initialValue) + // Treat undefined, empty string, and null as "empty" and fallback to initialValue + const valueToUse = + e.currentValue !== undefined && + e.currentValue !== "" && + e.currentValue !== null + ? e.currentValue + : e.initialValue + + // Convert markers back to their actual types + if (valueToUse === UNDEFINED_MARKER) { + return undefined + } + if (valueToUse === NULL_MARKER) { + return null + } + + // Only resolve templates for string values + // Non-string values (arrays, objects, etc.) are returned as-is for PM namespace compatibility + if (typeof valueToUse !== "string") { + return valueToUse + } + + // For string values, resolve templates + return pipe( + parseTemplateStringE(valueToUse, envVars), + // If the recursive resolution failed, return the unresolved value + E.getOrElse(() => valueToUse) + ) + }), + + E.getOrElseW(() => (options.fallbackToNull ? null : undefined)) + ) + + return result + } + + const envSetFn = ( + key: unknown, + value: unknown, + options: EnvAPIOptions = { source: "all" } + ) => { + if (typeof key !== "string") { + throw new Error("Expected key to be a string") + } + + if (typeof value !== "string") { + throw new Error("Expected value to be a string") + } + + updatedEnvs = setEnv(key, value, updatedEnvs, options) + + return undefined + } + + // PM namespace-specific setter that accepts any type (for Postman compatibility) + const envSetAnyFn = ( + key: unknown, + value: SandboxValue, // Intentionally SandboxValue for PM namespace type preservation + options: EnvAPIOptions = { source: "all" } + ) => { + if (typeof key !== "string") { + throw new Error("Expected key to be a string") + } + + // PM namespace preserves ALL types (arrays, objects, primitives, null, undefined) + updatedEnvs = setEnv(key, value, updatedEnvs, options) + + return undefined + } + + const envUnsetFn = ( + key: unknown, + options: EnvAPIOptions = { source: "all" } + ) => { + if (typeof key !== "string") { + throw new Error("Expected key to be a string") + } + + updatedEnvs = unsetEnv(key, updatedEnvs, options) + + return undefined + } + + const envResolveFn = (value: unknown) => { + if (typeof value !== "string") { + throw new Error("Expected value to be a string") + } + + const result = pipe( + parseTemplateStringE(value, [ + ...updatedEnvs.selected, + ...updatedEnvs.global, + ]), + E.getOrElse(() => value) + ) + + return String(result) + } + + // Methods exclusive to the `hopp` namespace + const envResetFn = ( + key: string, + options: EnvAPIOptions = { source: "all" } + ) => { + if (typeof key !== "string") { + throw new Error("Expected key to be a string") + } + + // Always read from the live, mutated state. `updatedEnvs` is reassigned by setters, + // while `envs` may point to an older object (stale snapshot) even if arrays were mutated. + // Using `updatedEnvs` here avoids subtle drift if future changes replace arrays immutably. + const { global, selected } = updatedEnvs + + const indexInSelected = findEnvIndex(key, selected) + const indexInGlobal = findEnvIndex(key, global) + + if (["all", "active"].includes(options.source) && indexInSelected >= 0) { + const selectedEnv = selected[indexInSelected] + + if ("currentValue" in selectedEnv) { + selectedEnv.currentValue = selectedEnv.initialValue + } + } else if ( + ["all", "global"].includes(options.source) && + indexInGlobal >= 0 + ) { + if ("currentValue" in global[indexInGlobal]) { + global[indexInGlobal].currentValue = global[indexInGlobal].initialValue + } + } + } + + const envGetInitialRawFn = ( + key: unknown, + options: EnvAPIOptions = { source: "all" } + ) => { + if (typeof key !== "string") { + throw new Error("Expected key to be a string") + } + + const result = pipe( + getEnv(key, updatedEnvs, options), + O.fold( + () => undefined, + (env) => { + const initialValue = env.initialValue + + // Convert markers back to their actual types + if (initialValue === UNDEFINED_MARKER) { + return undefined + } + if (initialValue === NULL_MARKER) { + return null + } + + return initialValue // Return as-is (PM namespace preserves types) + } + ) + ) + + return result ?? null + } + + const envSetInitialFn = ( + key: string, + value: string, + options: EnvAPIOptions = { source: "all" } + ) => { + if (typeof key !== "string") { + throw new Error("Expected key to be a string") + } + + if (typeof value !== "string") { + throw new Error("Expected value to be a string") + } + + updatedEnvs = setEnv(key, value, updatedEnvs, { + setInitialValue: true, + source: options.source, + }) + + return undefined + } + + // Experimental scripting sandbox (Both `pw` and `hopp` namespaces) + if (isHoppNamespace) { + return { + methods: { + pw: { + get: envGetFn, + getResolve: envGetResolveFn, + set: envSetFn, + unset: envUnsetFn, + resolve: envResolveFn, + }, + hopp: { + set: envSetFn, + delete: envUnsetFn, + reset: envResetFn, + getInitialRaw: envGetInitialRawFn, + setInitial: envSetInitialFn, + }, + }, + // Expose PM-specific setter that accepts any type + pmSetAny: envSetAnyFn, + updatedEnvs, + } + } + + // Legacy scripting sandbox (Only `pw` namespace) + return { + methods: { + env: { + get: envGetFn, + getResolve: envGetResolveFn, + set: envSetFn, + unset: envUnsetFn, + resolve: envResolveFn, + }, + }, + updatedEnvs, + } +} + +export const getSharedCookieMethods = (cookies: Cookie[] | null) => { + // Incoming `cookies` specified as `null` indicates unsupported platform + const cookiesSupported = cookies !== null + let updatedCookies: Cookie[] = cookies ?? [] + + const throwIfCookiesUnsupported = () => { + if (cookies === null) { + throw new Error( + "Cookies are not supported in the current platform and are exclusive to the Desktop App." + ) + } + } + + const cookieGetFn = (domain: unknown, name: unknown): Cookie | null => { + throwIfCookiesUnsupported() + + if (typeof domain !== "string" || typeof name !== "string") { + throw new Error("Expected domain and cookieName to be strings") + } + + return ( + updatedCookies.find((c) => c.domain === domain && c.name === name) ?? null + ) + } + + const cookieSetFn = (domain: string, cookie: Cookie): void => { + throwIfCookiesUnsupported() + + if (typeof domain !== "string") { + throw new Error("Expected domain to be a string") + } + + const result = CookieSchema.safeParse(cookie) + + if (!result.success) { + throw new Error("Invalid cookie") + } + + updatedCookies = updatedCookies.filter( + (c) => !(c.domain === domain && c.name === cookie.name) + ) + updatedCookies.push(cookie) + } + + const cookieHasFn = (domain: string, name: string): boolean => { + throwIfCookiesUnsupported() + + if (typeof domain !== "string" || typeof name !== "string") { + throw new Error("Expected domain and cookieName to be strings") + } + return updatedCookies.some((c) => c.domain === domain && c.name === name) + } + + const cookieGetAllFn = (domain: string): Cookie[] => { + throwIfCookiesUnsupported() + + if (typeof domain !== "string") { + throw new Error("Expected domain to be a string") + } + return updatedCookies.filter((c) => c.domain === domain) + } + + const cookieDeleteFn = (domain: string, name: string): void => { + throwIfCookiesUnsupported() + + if (typeof domain !== "string" || typeof name !== "string") { + throw new Error("Expected domain and cookieName to be strings") + } + updatedCookies = updatedCookies.filter( + (c) => !(c.domain === domain && c.name === name) + ) + } + + const cookieClearFn = (domain: string): void => { + throwIfCookiesUnsupported() + + if (typeof domain !== "string") { + throw new Error("Expected domain to be a string") + } + updatedCookies = updatedCookies.filter((c) => c.domain !== domain) + } + + return { + methods: { + get: cookieGetFn, + set: cookieSetFn, + has: cookieHasFn, + getAll: cookieGetAllFn, + delete: cookieDeleteFn, + clear: cookieClearFn, + }, + // Use a function so we always read the latest `updatedCookies` (not a stale snapshot) + getUpdatedCookies: () => + cookiesSupported ? cloneDeep(updatedCookies) : null, + } +} + +const getResolvedExpectValue = (expectVal: SandboxValue) => { + if (typeof expectVal !== "string") { + return expectVal + } + + try { + const parsedExpectVal = JSON.parse(expectVal) + + // Supplying non-primitive values is not permitted in the `isStringifiedWithinIsolate` property indicates that the object was stringified before executing the script from the isolate context + // This is done to ensure a JSON string supplied as the "expectVal" is not parsed and preserved as is + if (typeof parsedExpectVal === "object") { + if (parsedExpectVal.isStringifiedWithinIsolate !== true) { + return expectVal + } + + // For an array, the contents are stored in the `arr` property + if (Array.isArray(parsedExpectVal.arr)) { + return parsedExpectVal.arr + } + + delete parsedExpectVal.isStringifiedWithinIsolate + return parsedExpectVal + } + + return expectVal + } catch (_) { + return expectVal + } +} + +export function preventCyclicObjects>( + obj: T +): E.Left | E.Right { + let jsonString + + try { + jsonString = JSON.stringify(obj) + } catch (_) { + return E.left("Stringification failed") + } + + try { + const parsedJson = JSON.parse(jsonString) + return E.right(parsedJson) + } catch (_) { + return E.left("Parsing failed") + } +} + +/** + * Creates an Expectation object for use inside the sandbox + * @param expectVal The expecting value of the expectation + * @param negated Whether the expectation is negated (negative) + * @param currTestStack The current state of the test execution stack + * @returns Object with the expectation methods + */ +export const createExpectation = ( + expectVal: SandboxValue, + negated: boolean, + currTestStack: TestDescriptor[], + getCurrentTestContext?: () => TestDescriptor | null +): Expectation => { + // Non-primitive values supplied are stringified in the isolate context + const resolvedExpectVal = getResolvedExpectValue(expectVal) + + // Helper to get current test descriptor (prefers context over stack) + const getCurrentTest = (): TestDescriptor | null => { + // Prefer explicit test context, but fallback to stack for top-level expectations + return ( + getCurrentTestContext?.() || + (currTestStack.length > 0 + ? currTestStack[currTestStack.length - 1] + : null) + ) + } + + const toBeFn = (expectedVal: SandboxValue) => { + let assertion = resolvedExpectVal === expectedVal + + if (negated) { + assertion = !assertion + } + + const status = assertion ? "pass" : "fail" + const message = `Expected '${resolvedExpectVal}' to${ + negated ? " not" : "" + } be '${expectedVal}'` + + const targetTest = getCurrentTest() + if (!targetTest) return undefined + + targetTest.expectResults.push({ + status, + message, + }) + + return undefined + } + + const toBeLevelXxx = ( + level: string, + rangeStart: number, + rangeEnd: number + ) => { + const parsedExpectVal = parseInt(resolvedExpectVal) + + if (!Number.isNaN(parsedExpectVal)) { + let assertion = + parsedExpectVal >= rangeStart && parsedExpectVal <= rangeEnd + + if (negated) { + assertion = !assertion + } + + const status = assertion ? "pass" : "fail" + const message = `Expected '${parsedExpectVal}' to${ + negated ? " not" : "" + } be ${level}-level status` + + const targetTest = getCurrentTest() + if (!targetTest) return undefined + targetTest.expectResults.push({ + status, + message, + }) + } else { + const message = `Expected ${level}-level status but could not parse value '${resolvedExpectVal}'` + const targetTest = getCurrentTest() + if (!targetTest) return undefined + targetTest.expectResults.push({ + status: "error", + message, + }) + } + + return undefined + } + + const toBeLevel2xxFn = () => toBeLevelXxx("200", 200, 299) + const toBeLevel3xxFn = () => toBeLevelXxx("300", 300, 399) + const toBeLevel4xxFn = () => toBeLevelXxx("400", 400, 499) + const toBeLevel5xxFn = () => toBeLevelXxx("500", 500, 599) + + const toBeTypeFn = (expectedType: SandboxValue) => { + if ( + [ + "string", + "boolean", + "number", + "object", + "undefined", + "bigint", + "symbol", + "function", + ].includes(expectedType) + ) { + let assertion = typeof resolvedExpectVal === expectedType + + if (negated) { + assertion = !assertion + } + + const status = assertion ? "pass" : "fail" + const message = `Expected '${resolvedExpectVal}' to${ + negated ? " not" : "" + } be type '${expectedType}'` + + const targetTest = getCurrentTest() + if (!targetTest) return undefined + targetTest.expectResults.push({ + status, + message, + }) + } else { + const message = + 'Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"' + const targetTest = getCurrentTest() + if (!targetTest) return undefined + targetTest.expectResults.push({ + status: "error", + message, + }) + } + + return undefined + } + + const toHaveLengthFn = (expectedLength: SandboxValue) => { + if ( + !( + Array.isArray(resolvedExpectVal) || + typeof resolvedExpectVal === "string" + ) + ) { + const message = + "Expected toHaveLength to be called for an array or string" + const targetTest = getCurrentTest() + if (!targetTest) return undefined + targetTest.expectResults.push({ + status: "error", + message, + }) + + return undefined + } + + if (typeof expectedLength === "number" && !Number.isNaN(expectedLength)) { + let assertion = resolvedExpectVal.length === expectedLength + + if (negated) { + assertion = !assertion + } + + const status = assertion ? "pass" : "fail" + const message = `Expected the array to${ + negated ? " not" : "" + } be of length '${expectedLength}'` + + const targetTest = getCurrentTest() + if (!targetTest) return undefined + targetTest.expectResults.push({ + status, + message, + }) + } else { + const message = "Argument for toHaveLength should be a number" + const targetTest = getCurrentTest() + if (!targetTest) return undefined + targetTest.expectResults.push({ + status: "error", + message, + }) + } + + return undefined + } + + const toIncludeFn = (needle: SandboxValue) => { + if ( + !( + Array.isArray(resolvedExpectVal) || + typeof resolvedExpectVal === "string" + ) + ) { + const message = "Expected toInclude to be called for an array or string" + const targetTest = getCurrentTest() + if (!targetTest) return undefined + targetTest.expectResults.push({ + status: "error", + message, + }) + return undefined + } + + if (needle === null) { + const message = "Argument for toInclude should not be null" + const targetTest = getCurrentTest() + if (!targetTest) return undefined + targetTest.expectResults.push({ + status: "error", + message, + }) + return undefined + } + + if (needle === undefined) { + const message = "Argument for toInclude should not be undefined" + const targetTest = getCurrentTest() + if (!targetTest) return undefined + targetTest.expectResults.push({ + status: "error", + message, + }) + return undefined + } + + let assertion = resolvedExpectVal.includes(needle) + + if (negated) { + assertion = !assertion + } + + const expectValPretty = JSON.stringify(resolvedExpectVal) + const needlePretty = JSON.stringify(needle) + const status = assertion ? "pass" : "fail" + const message = `Expected ${expectValPretty} to${ + negated ? " not" : "" + } include ${needlePretty}` + + const targetTest = getCurrentTest() + if (!targetTest) return undefined + targetTest.expectResults.push({ + status, + message, + }) + return undefined + } + + const result = { + toBe: toBeFn, + toBeLevel2xx: toBeLevel2xxFn, + toBeLevel3xx: toBeLevel3xxFn, + toBeLevel4xx: toBeLevel4xxFn, + toBeLevel5xx: toBeLevel5xxFn, + toBeType: toBeTypeFn, + toHaveLength: toHaveLengthFn, + toInclude: toIncludeFn, + } as Expectation + + Object.defineProperties(result, { + not: { + get: () => + createExpectation( + expectVal, + !negated, + currTestStack, + getCurrentTestContext + ), + }, + }) + + return result +} + +/** + * Compiles methods for use under the `pw` namespace for pre request scripts + * @param envs The current state of the environment variables + * @returns Object with methods in the `pw` namespace + */ +export const getPreRequestScriptMethods = (envs: TestResult["envs"]) => { + const { methods, updatedEnvs } = getSharedEnvMethods(cloneDeep(envs)) + return { pw: methods, updatedEnvs } +} + +/** + * Compiles methods for use under the `pw` namespace for post request scripts + * @param envs The current state of the environment variables + * @returns Object with methods in the `pw` namespace and test run stack + */ +export const getTestRunnerScriptMethods = (envs: TestResult["envs"]) => { + const testRunStack: TestDescriptor[] = [ + { descriptor: "root", expectResults: [], children: [] }, + ] + + const testFn = (descriptor: string, testFunc: () => void) => { + testRunStack.push({ + descriptor, + expectResults: [], + children: [], + }) + + testFunc() + + const child = testRunStack.pop() as TestDescriptor + testRunStack[testRunStack.length - 1].children.push(child) + } + + const expectFn = (expectVal: unknown) => + createExpectation(expectVal, false, testRunStack) + + const { methods, updatedEnvs } = getSharedEnvMethods(cloneDeep(envs)) + + const pw = { + ...methods, + expect: expectFn, + test: testFn, + } + + return { pw, testRunStack, updatedEnvs } +} + +/** + * Compiles shared scripting API properties (scoped to requests) for use in both pre and post request scripts + * Extracts shared properties from a request object + * @param request The request object to extract shared properties from + * @param getUpdatedRequest Optional function to get the updated request (for pre-request mutations) + * @returns An object containing the shared properties of the request + */ +export const getSharedRequestProps = ( + request: HoppRESTRequest, + getUpdatedRequest?: () => HoppRESTRequest +) => { + return { + get url() { + // For pre-request scripts, read from updated request to see mutations + const currentRequest = getUpdatedRequest ? getUpdatedRequest() : request + return currentRequest.endpoint + }, + get method() { + const currentRequest = getUpdatedRequest ? getUpdatedRequest() : request + return currentRequest.method + }, + get params() { + const currentRequest = getUpdatedRequest ? getUpdatedRequest() : request + return currentRequest.params + }, + get headers() { + const currentRequest = getUpdatedRequest ? getUpdatedRequest() : request + return currentRequest.headers + }, + get body() { + const currentRequest = getUpdatedRequest ? getUpdatedRequest() : request + return currentRequest.body + }, + get auth() { + const currentRequest = getUpdatedRequest ? getUpdatedRequest() : request + return currentRequest.auth + }, + get requestVariables() { + const currentRequest = getUpdatedRequest ? getUpdatedRequest() : request + return currentRequest.requestVariables + }, + } +} diff --git a/packages/hoppscotch-js-sandbox/src/utils/test-helpers.ts b/packages/hoppscotch-js-sandbox/src/utils/test-helpers.ts new file mode 100644 index 0000000..84b8ff9 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/utils/test-helpers.ts @@ -0,0 +1,204 @@ +/** + * Consolidated test helpers for all namespace tests + * + * This file provides reusable helper functions to eliminate duplication + * across 45+ test files that previously had inline `func` definitions. + */ + +import { getDefaultRESTRequest } from "@hoppscotch/data" +import * as TE from "fp-ts/TaskEither" +import { pipe } from "fp-ts/function" +import { runTestScript, runPreRequestScript } from "~/node" +import { TestResponse, TestResult, HoppFetchHook } from "~/types" + +// Default fixtures used across test files +export const defaultRequest = getDefaultRESTRequest() +export const fakeResponse: TestResponse = { + status: 200, + statusText: "OK", + responseTime: 0, + body: "hoi", + headers: [], +} + +/** + * Run a test script and return the test results + * + * This is the most common pattern used across all test files. + * Replaces the inline `func` helper pattern. + * + * @param script - The test script to execute + * @param envs - Environment variables (defaults to empty) + * @param response - Response object (defaults to fakeResponse) + * @param request - Request object (defaults to defaultRequest) + * @param hoppFetchHook - Optional hook for hopp.fetch() implementation + * @returns TaskEither containing test results + * + * @example + * ```typescript + * test("pm.expect assertion", () => { + * return expect( + * runTest(`pm.test("test", () => pm.expect(1).to.equal(1))`, { + * global: [], + * selected: [] + * })() + * ).resolves.toEqualRight([...]) + * }) + * ``` + */ +export const runTest = ( + script: string, + envs: TestResult["envs"], + response: TestResponse = fakeResponse, + request: ReturnType = defaultRequest, + hoppFetchHook?: HoppFetchHook +) => + pipe( + runTestScript(script, { + envs, + request, + response, + cookies: null, + experimentalScriptingSandbox: true, + hoppFetchHook, + }), + TE.map((x) => x.tests) + ) + +/** + * Run a pre-request script and return the environment variables + * + * Used for testing pre-request scripts that modify environment variables. + * + * @param script - The pre-request script to execute + * @param envs - Initial environment variables (defaults to empty) + * @param request - Request object (defaults to defaultRequest) + * @param hoppFetchHook - Optional hook for hopp.fetch() implementation + * @returns TaskEither containing environment variables + * + * @example + * ```typescript + * test("pm.environment.set in pre-request", () => { + * return expect( + * runPreRequest( + * `pm.environment.set("key", "value")`, + * { global: [], selected: [] } + * )() + * ).resolves.toEqualRight({ + * global: [], + * selected: [{ key: "key", value: "value", secret: false }] + * }) + * }) + * ``` + */ +export const runPreRequest = ( + script: string, + envs: TestResult["envs"], + request: ReturnType = defaultRequest, + hoppFetchHook?: HoppFetchHook +) => + pipe( + runPreRequestScript(script, { + envs, + request, + cookies: null, + experimentalScriptingSandbox: true, + hoppFetchHook, + }), + TE.map((x) => x.updatedEnvs) + ) + +/** + * Run a test script with custom response + * + * Convenience wrapper when you only need to customize the response. + * + * @param script - The test script to execute + * @param response - Custom response object + * @param envs - Environment variables (defaults to empty) + * @returns TaskEither containing test results + */ +export const runTestWithResponse = ( + script: string, + response: TestResponse, + envs: TestResult["envs"] = { global: [], selected: [] } +) => runTest(script, envs, response) + +/** + * Run a test script with custom request + * + * Convenience wrapper when you only need to customize the request. + * + * @param script - The test script to execute + * @param request - Custom request object + * @param envs - Environment variables (defaults to empty) + * @param response - Response object (defaults to fakeResponse) + * @returns TaskEither containing test results + */ +export const runTestWithRequest = ( + script: string, + request: ReturnType, + envs: TestResult["envs"] = { global: [], selected: [] }, + response: TestResponse = fakeResponse +) => runTest(script, envs, response, request) + +/** + * Run a test script with empty environments + * + * Convenience wrapper for the most common case (no environment variables). + * + * @param script - The test script to execute + * @param response - Response object (defaults to fakeResponse) + * @returns TaskEither containing test results + */ +export const runTestWithEmptyEnv = ( + script: string, + response: TestResponse = fakeResponse +) => runTest(script, { global: [], selected: [] }, response) + +/** + * Run a test script and return the environment variables (not test results) + * + * Used for testing scripts that modify environment variables but you want + * to inspect the final env state rather than test results. + * + * This is different from runPreRequest which uses runPreRequestScript. + * This uses runTestScript but extracts envs instead of tests. + * + * @param script - The test script to execute + * @param envs - Initial environment variables + * @param response - Response object (defaults to fakeResponse) + * @param request - Request object (defaults to defaultRequest) + * @returns TaskEither containing environment variables + * + * @example + * ```typescript + * test("env mutation in test script", () => { + * return expect( + * runTestAndGetEnvs( + * `pw.env.set("key", "value")`, + * { global: [], selected: [] } + * )() + * ).resolves.toEqualRight({ + * global: [], + * selected: [{ key: "key", value: "value", secret: false }] + * }) + * }) + * ``` + */ +export const runTestAndGetEnvs = ( + script: string, + envs: TestResult["envs"], + response: TestResponse = fakeResponse, + request: ReturnType = defaultRequest +) => + pipe( + runTestScript(script, { + envs, + request, + response, + cookies: null, + experimentalScriptingSandbox: true, + }), + TE.map((x: TestResult) => x.envs) + ) diff --git a/packages/hoppscotch-js-sandbox/src/web/index.ts b/packages/hoppscotch-js-sandbox/src/web/index.ts new file mode 100644 index 0000000..a329c04 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/web/index.ts @@ -0,0 +1,2 @@ +export { runPreRequestScript } from "./pre-request" +export { runTestScript } from "./test-runner" diff --git a/packages/hoppscotch-js-sandbox/src/web/pre-request/index.ts b/packages/hoppscotch-js-sandbox/src/web/pre-request/index.ts new file mode 100644 index 0000000..6cf6d61 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/web/pre-request/index.ts @@ -0,0 +1,203 @@ +import { ConsoleEntry } from "faraday-cage/modules" +import * as E from "fp-ts/Either" +import { cloneDeep } from "lodash" +import { + HoppFetchHook, + RunPreRequestScriptOptions, + SandboxPreRequestResult, + TestResult, +} from "~/types" + +import { defaultModules, preRequestModule } from "~/cage-modules" +import { acquireCage, resetCage, isInfraError } from "~/utils/cage" + +import { Cookie, HoppRESTRequest } from "@hoppscotch/data" +import Worker from "./worker?worker&inline" + +const runPreRequestScriptWithWebWorker = ( + preRequestScript: string, + envs: TestResult["envs"] +): Promise> => { + return new Promise((resolve) => { + const worker = new Worker() + + // Listen for the results from the web worker + worker.addEventListener("message", (event: MessageEvent) => { + worker.terminate() + return resolve(event.data.results) + }) + + // Send the script to the web worker + worker.postMessage({ + preRequestScript, + envs, + }) + }) +} + +/** + * Runs a pre-request script on the given cage instance. + * Returns the result (`Either`) or the string literal "retry" + * if a bootstrap error triggered a cage reset (caller should retry). + */ +const executePreRequestOnCage = async ( + cage: Awaited>, + preRequestScript: string, + envs: TestResult["envs"], + request: HoppRESTRequest, + cookies: Cookie[] | null, + hoppFetchHook?: HoppFetchHook +): Promise | "retry"> => { + const consoleEntries: ConsoleEntry[] = [] + let finalEnvs = envs + let finalRequest = request + let finalCookies = cookies + + const captureHook: { + capture?: () => void + bootstrapError?: unknown + scriptExecutionError?: { name: string; message: string; stack: string } + } = {} + + const result = await cage.runCode(preRequestScript, [ + ...defaultModules({ + handleConsoleEntry: (consoleEntry) => consoleEntries.push(consoleEntry), + hoppFetchHook, + }), + + preRequestModule( + { + envs: cloneDeep(envs), + request: cloneDeep(request), + cookies: cookies ? cloneDeep(cookies) : null, + handleSandboxResults: ({ envs, request, cookies }) => { + finalEnvs = envs + finalRequest = request + finalCookies = cookies + }, + }, + captureHook + ), + ]) + + if (result.type === "error") { + const bootstrapFailed = captureHook.bootstrapError !== undefined + const errorToAnalyze = bootstrapFailed + ? captureHook.bootstrapError + : result.err + + if (bootstrapFailed || isInfraError(errorToAnalyze)) { + resetCage() + return "retry" + } + + if ( + result.err !== null && + typeof result.err === "object" && + "message" in result.err + ) { + const name = + "name" in result.err && typeof result.err.name === "string" + ? result.err.name + : "" + const prefix = name ? `${name}: ` : "" + return E.left(`Script execution failed: ${prefix}${result.err.message}`) + } + + return E.left(`Script execution failed: ${String(result.err)}`) + } + + // Check for errors reported via the generated try/catch wrapper. + // faraday-cage's keepAlive loop swallows rejected promises and does not + // await afterScriptExecutionHooks, so async-boundary errors reach us + // only via this synchronous host reporter. + if (captureHook.scriptExecutionError) { + const { name, message } = captureHook.scriptExecutionError + const prefix = name ? `${name}: ` : "" + return E.left(`Script execution failed: ${prefix}${message}`) + } + + if (captureHook.capture) { + captureHook.capture() + } + + return E.right({ + updatedEnvs: finalEnvs, + consoleEntries, + updatedRequest: finalRequest, + updatedCookies: finalCookies, + } satisfies SandboxPreRequestResult) +} + +const runPreRequestScriptWithFaradayCage = async ( + preRequestScript: string, + envs: TestResult["envs"], + request: HoppRESTRequest, + cookies: Cookie[] | null, + hoppFetchHook?: HoppFetchHook +): Promise> => { + try { + const cage = await acquireCage() + + const firstAttempt = await executePreRequestOnCage( + cage, + preRequestScript, + envs, + request, + cookies, + hoppFetchHook + ) + + if (firstAttempt !== "retry") { + return firstAttempt + } + + // Bootstrap error detected and cage was reset — retry once on a fresh cage + const freshCage = await acquireCage() + const retryResult = await executePreRequestOnCage( + freshCage, + preRequestScript, + envs, + request, + cookies, + hoppFetchHook + ) + + if (retryResult === "retry") { + // Two consecutive bootstrap failures — don't loop, report the error + return E.left( + "Script execution failed: sandbox initialization error (persistent)" + ) + } + + return retryResult + } catch (error) { + const name = error instanceof Error && error.name ? `${error.name}: ` : "" + const message = error instanceof Error ? error.message : String(error) + return E.left(`Script execution failed: ${name}${message}`) + } +} + +export const runPreRequestScript = ( + preRequestScript: string, + options: RunPreRequestScriptOptions +): Promise> => { + const { envs, experimentalScriptingSandbox = true } = options + + if (experimentalScriptingSandbox) { + const { request, cookies, hoppFetchHook } = options as Extract< + RunPreRequestScriptOptions, + { experimentalScriptingSandbox: true } + > + + return runPreRequestScriptWithFaradayCage( + preRequestScript, + envs, + request, + cookies, + hoppFetchHook + ) + } + + return runPreRequestScriptWithWebWorker(preRequestScript, envs) +} diff --git a/packages/hoppscotch-js-sandbox/src/web/pre-request/worker.ts b/packages/hoppscotch-js-sandbox/src/web/pre-request/worker.ts new file mode 100644 index 0000000..511bc51 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/web/pre-request/worker.ts @@ -0,0 +1,40 @@ +import * as TE from "fp-ts/TaskEither" + +import { getPreRequestScriptMethods } from "~/utils/shared" +import { SandboxPreRequestResult, TestResult } from "~/types" + +const executeScriptInContext = ( + preRequestScript: string, + envs: TestResult["envs"] +): TE.TaskEither => { + try { + const { pw, updatedEnvs } = getPreRequestScriptMethods(envs) + + // Create a function from the pre request script using the `Function` constructor + const executeScript = new Function("pw", preRequestScript) + + // Execute the script + executeScript(pw) + + return TE.right({ + updatedEnvs, + updatedCookies: null, + }) + } catch (error) { + return TE.left( + `Script execution failed: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } +} + +// Listen for messages from the main thread +self.addEventListener("message", async (event) => { + const { preRequestScript, envs } = event.data + + const results = await executeScriptInContext(preRequestScript, envs)() + + // Post the result back to the main thread + self.postMessage({ results }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts b/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts new file mode 100644 index 0000000..fc7a14d --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts @@ -0,0 +1,255 @@ +import { ConsoleEntry } from "faraday-cage/modules" +import * as E from "fp-ts/Either" +import { cloneDeep } from "lodash-es" + +import { defaultModules, postRequestModule } from "~/cage-modules" +import { + HoppFetchHook, + RunPostRequestScriptOptions, + SandboxTestResult, + TestDescriptor, + TestResponse, + TestResult, +} from "~/types" +import { acquireCage, resetCage, isInfraError } from "~/utils/cage" +import { parseScriptForSyntax } from "~/utils/scripting" +import { preventCyclicObjects } from "~/utils/shared" + +import { Cookie, HoppRESTRequest } from "@hoppscotch/data" +import Worker from "./worker?worker&inline" + +const runPostRequestScriptWithWebWorker = ( + testScript: string, + envs: TestResult["envs"], + response: TestResponse +): Promise> => { + return new Promise((resolve) => { + const worker = new Worker() + + // Listen for the results from the web worker + worker.addEventListener("message", (event: MessageEvent) => + resolve(event.data.results) + ) + + // Send the script to the web worker + worker.postMessage({ + testScript, + envs, + response, + }) + }) +} + +/** + * Runs a post-request/test script on the given cage instance. + * Returns the result or "retry" if a bootstrap error triggered a cage reset. + */ +const executeTestOnCage = async ( + cage: Awaited>, + testScript: string, + envs: TestResult["envs"], + request: HoppRESTRequest, + response: TestResponse, + cookies: Cookie[] | null, + hoppFetchHook?: HoppFetchHook +): Promise | "retry"> => { + const testRunStack: TestDescriptor[] = [ + { descriptor: "root", expectResults: [], children: [] }, + ] + + let finalEnvs = envs + let finalTestResults = testRunStack + const consoleEntries: ConsoleEntry[] = [] + let finalCookies = cookies + const testPromises: Promise[] = [] + + const captureHook: { + capture?: () => void + bootstrapError?: unknown + scriptExecutionError?: { name: string; message: string; stack: string } + } = {} + + const result = await cage.runCode(testScript, [ + ...defaultModules({ + handleConsoleEntry: (consoleEntry) => consoleEntries.push(consoleEntry), + hoppFetchHook, + }), + + postRequestModule( + { + envs: cloneDeep(envs), + testRunStack: cloneDeep(testRunStack), + request: cloneDeep(request), + response: cloneDeep(response), + cookies: cookies ? cloneDeep(cookies) : null, + handleSandboxResults: ({ envs, testRunStack, cookies }) => { + finalEnvs = envs + finalTestResults = testRunStack + finalCookies = cookies + }, + onTestPromise: (promise) => { + testPromises.push(promise) + }, + }, + captureHook + ), + ]) + + if (result.type === "error") { + const bootstrapFailed = captureHook.bootstrapError !== undefined + const errorToAnalyze = bootstrapFailed + ? captureHook.bootstrapError + : result.err + + if (bootstrapFailed || isInfraError(errorToAnalyze)) { + resetCage() + return "retry" + } + + if ( + result.err !== null && + typeof result.err === "object" && + "message" in result.err + ) { + const name = + "name" in result.err && typeof result.err.name === "string" + ? result.err.name + : "" + const prefix = name ? `${name}: ` : "" + return E.left(`Script execution failed: ${prefix}${result.err.message}`) + } + + return E.left(`Script execution failed: ${String(result.err)}`) + } + + // Wait for async test functions before capturing results. + if (testPromises.length > 0) { + await Promise.all(testPromises) + } + + // Check for errors reported via the generated try/catch wrapper. + // faraday-cage's keepAlive loop swallows rejected promises and does not + // await afterScriptExecutionHooks, so async-boundary errors reach us + // only via this synchronous host reporter. + if (captureHook.scriptExecutionError) { + const { name, message } = captureHook.scriptExecutionError + const prefix = name ? `${name}: ` : "" + return E.left(`Script execution failed: ${prefix}${message}`) + } + + if (captureHook.capture) { + captureHook.capture() + } + + const safeTestResults = cloneDeep(finalTestResults[0]) + + const safeEnvs = cloneDeep(finalEnvs) + const safeConsoleEntries = cloneDeep(consoleEntries) + const safeCookies = finalCookies ? cloneDeep(finalCookies) : null + + return E.right({ + tests: safeTestResults, + envs: safeEnvs, + consoleEntries: safeConsoleEntries, + updatedCookies: safeCookies, + } satisfies SandboxTestResult) +} + +const runPostRequestScriptWithFaradayCage = async ( + testScript: string, + envs: TestResult["envs"], + request: HoppRESTRequest, + response: TestResponse, + cookies: Cookie[] | null, + hoppFetchHook?: HoppFetchHook +): Promise> => { + try { + const cage = await acquireCage() + + const firstAttempt = await executeTestOnCage( + cage, + testScript, + envs, + request, + response, + cookies, + hoppFetchHook + ) + + if (firstAttempt !== "retry") { + return firstAttempt + } + + // Bootstrap error detected and cage was reset — retry once on a fresh cage + const freshCage = await acquireCage() + const retryResult = await executeTestOnCage( + freshCage, + testScript, + envs, + request, + response, + cookies, + hoppFetchHook + ) + + if (retryResult === "retry") { + return E.left( + "Script execution failed: sandbox initialization error (persistent)" + ) + } + + return retryResult + } catch (error) { + const name = error instanceof Error && error.name ? `${error.name}: ` : "" + const message = error instanceof Error ? error.message : String(error) + return E.left(`Script execution failed: ${name}${message}`) + } +} + +export const runTestScript = async ( + testScript: string, + options: RunPostRequestScriptOptions +): Promise> => { + const responseObjHandle = preventCyclicObjects(options.response) + + if (E.isLeft(responseObjHandle)) { + return E.left(`Response marshalling failed: ${responseObjHandle.left}`) + } + + const resolvedResponse = responseObjHandle.right + + const { envs, experimentalScriptingSandbox = true } = options + + // Pre-parse before sandbox spin-up so syntax errors surface as a friendly + // host-side message. Each target uses the grammar that matches its eventual + // executor: experimental → ESM module (top-level imports + await accepted); + // legacy → script mode (top-level imports + await rejected). + try { + parseScriptForSyntax( + testScript, + experimentalScriptingSandbox ? "experimental" : "legacy" + ) + } catch (e) { + const err = e as Error + const reason = `${"name" in err ? (err as any).name : "SyntaxError"}: ${err.message}` + return E.left(`Script execution failed: ${reason}`) + } + + if (experimentalScriptingSandbox) { + const { request, cookies, hoppFetchHook } = options as Extract< + RunPostRequestScriptOptions, + { experimentalScriptingSandbox: true } + > + + return runPostRequestScriptWithFaradayCage( + testScript, + envs, + request, + resolvedResponse, + cookies, + hoppFetchHook + ) + } + + return runPostRequestScriptWithWebWorker(testScript, envs, resolvedResponse) +} diff --git a/packages/hoppscotch-js-sandbox/src/web/test-runner/worker.ts b/packages/hoppscotch-js-sandbox/src/web/test-runner/worker.ts new file mode 100644 index 0000000..9b656a5 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/web/test-runner/worker.ts @@ -0,0 +1,51 @@ +import * as E from "fp-ts/Either" +import * as TE from "fp-ts/TaskEither" + +import { SandboxTestResult, TestResponse, TestResult } from "~/types" +import { + getTestRunnerScriptMethods, + preventCyclicObjects, +} from "~/utils/shared" + +const executeScriptInContext = ( + testScript: string, + envs: TestResult["envs"], + response: TestResponse +): TE.TaskEither => { + try { + const responseObjHandle = preventCyclicObjects(response) + if (E.isLeft(responseObjHandle)) { + return TE.left(`Response marshalling failed: ${responseObjHandle.left}`) + } + + const { pw, testRunStack, updatedEnvs } = getTestRunnerScriptMethods(envs) + + // Create a function from the test script using the `Function` constructor + const executeScript = new Function("pw", testScript) + + // Execute the script + executeScript({ ...pw, response: responseObjHandle.right }) + + return TE.right({ + tests: testRunStack[0], + envs: updatedEnvs, + updatedCookies: null, + } satisfies SandboxTestResult) + } catch (error) { + return TE.left( + `Script execution failed: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } +} + +// Listen for messages from the main thread +self.addEventListener("message", async (event) => { + const { testScript, envs, response } = event.data + + const results = await executeScriptInContext(testScript, envs, response)() + + // Post the result back to the main thread + self.postMessage({ results }) +}) diff --git a/packages/hoppscotch-js-sandbox/tsconfig.json b/packages/hoppscotch-js-sandbox/tsconfig.json new file mode 100644 index 0000000..d35bcc1 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Node", + "skipLibCheck": true, + "lib": ["ESNext", "ESNext.AsyncIterable", "DOM"], + "esModuleInterop": true, + "strict": true, + "paths": { + "~/*": ["./src/*"] + }, + "types": ["@types/node", "@types/jest", "@relmify/jest-fp-ts", "vite/client"], + "outDir": "./dist/", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["./src", "./src/global.d.ts"], + "exclude": ["node_modules", "./src/__tests__"] +} diff --git a/packages/hoppscotch-js-sandbox/vite.config.ts b/packages/hoppscotch-js-sandbox/vite.config.ts new file mode 100644 index 0000000..7bb2360 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/vite.config.ts @@ -0,0 +1,30 @@ +import { resolve } from "path" +import { defineConfig } from "vite" + +export default defineConfig({ + build: { + outDir: "./dist", + emptyOutDir: true, + lib: { + entry: { + web: "./src/web/index.ts", + node: "./src/node/index.ts", + scripting: "./src/scripting.ts", + }, + name: "js-sandbox", + formats: ["es", "cjs"], + }, + rollupOptions: { + external: ["module"], + }, + }, + test: { + environment: "node", + setupFiles: ["./setupFiles.ts"], + }, + resolve: { + alias: { + "~": resolve(__dirname, "./src"), + }, + }, +}) diff --git a/packages/hoppscotch-js-sandbox/web.d.ts b/packages/hoppscotch-js-sandbox/web.d.ts new file mode 100644 index 0000000..27b45c8 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/web.d.ts @@ -0,0 +1,2 @@ +export { default } from "./dist/web/index.d.ts" +export * from "./dist/web/index.d.ts" diff --git a/packages/hoppscotch-kernel/README.md b/packages/hoppscotch-kernel/README.md new file mode 100644 index 0000000..eb83c9a --- /dev/null +++ b/packages/hoppscotch-kernel/README.md @@ -0,0 +1,104 @@ +# Hoppscotch Kernel + +Cross-platform abstraction kernel for Hoppscotch, a unified interface between application logic and platform-specific implementations. + +## Architecture + +The kernel acts as a thin abstraction layer, mediating between high-level application logic and low-level platform implementations, similar to how operating system kernels abstract over hardware details. This helps the core Hoppscotch app be platform-agnostic while maintaining near native performance. + +This codebase is minimal by design, providing just the building blocks for constructing features. If possible, always try composition before modifying the kernel directly. + +## Modules + +### IO Module +File system and external resource handling: + +```typescript +interface IoV1 { + saveFileWithDialog(opts: SaveFileWithDialogOptions): Promise + openExternalLink(opts: OpenExternalLinkOptions): Promise + listen(event: string, handler: EventCallback): Promise +} +``` + +### Relay Module +Network operations with platform-specific optimizations: + +```typescript +interface RelayV1 { + readonly capabilities: RelayCapabilities + execute(request: RelayRequest): { + cancel: () => Promise + emitter: RelayEventEmitter + response: Promise> + } +} +``` + +### Store Module +Cross-platform persistence with encryption support: + +```typescript +interface StoreV1 { + readonly capabilities: Set + set(namespace: string, key: string, value: unknown, options?: StorageOptions): Promise> + watch(namespace: string, key: string): Promise> +} +``` + +## Usage + +### Kernel Initialization + +```typescript +import { initKernel } from '@hoppscotch/kernel' + +// Platform-specific initialization +const kernel = initKernel('web' | 'desktop') +``` + +### Network Operations + +```typescript +import { RelayRequest } from '@hoppscotch/kernel' + +const request: RelayRequest = { + id: 1, + url: "https://api.example.com", + method: "GET", + version: "HTTP/1.1", + headers: { + "Content-Type": "application/json" + } +} + +// Execute with capability checks +const { response, cancel } = kernel.relay.execute(request) +``` + +### Storage Operations + +```typescript +// Encrypted storage with compression +await kernel.store.set("collections", "team-a", data, { + encrypt: true, + compress: true +}) + +// Watch for changes +const watcher = await kernel.store.watch("collections", "team-a") +watcher.on("change", + (update) => console.log("Collection updated:", update) +) +``` + +### File Operations + +```typescript +// Platform-agnostic file save +await kernel.io.saveFileWithDialog({ + data: new Uint8Array([...]), + suggestedFilename: "export.json", + contentType: "application/json" +}) +``` diff --git a/packages/hoppscotch-kernel/eslint.config.mjs b/packages/hoppscotch-kernel/eslint.config.mjs new file mode 100644 index 0000000..e7e6f6f --- /dev/null +++ b/packages/hoppscotch-kernel/eslint.config.mjs @@ -0,0 +1,52 @@ +import js from "@eslint/js" +import tsParser from "@typescript-eslint/parser" +import typescriptEslintPlugin from "@typescript-eslint/eslint-plugin" +import prettierPlugin from "eslint-plugin-prettier" +import globals from "globals" + +export default [ + { + ignores: [ + "dist/**", + "node_modules/**", + "**/*.d.ts", + "vite.config.ts", + ], + }, + js.configs.recommended, + { + files: ["src/**/*.ts"], + languageOptions: { + parser: tsParser, + sourceType: "module", + ecmaVersion: 2022, + globals: { + ...globals.browser, + ...globals.node, + }, + }, + plugins: { + "@typescript-eslint": typescriptEslintPlugin, + prettier: prettierPlugin, + }, + rules: { + ...typescriptEslintPlugin.configs.recommended.rules, + "prettier/prettier": "warn", + "no-undef": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + args: "all", + argsIgnorePattern: "^_", + caughtErrors: "all", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + varsIgnorePattern: "^_", + ignoreRestSiblings: true, + }, + ], + }, + }, +] diff --git a/packages/hoppscotch-kernel/package.json b/packages/hoppscotch-kernel/package.json new file mode 100644 index 0000000..c303956 --- /dev/null +++ b/packages/hoppscotch-kernel/package.json @@ -0,0 +1,72 @@ +{ + "name": "@hoppscotch/kernel", + "version": "0.2.0", + "description": "Cross-platform runtime kernel for Hoppscotch platform-ops", + "type": "module", + "main": "dist/hoppscotch-kernel.cjs", + "module": "dist/hoppscotch-kernel.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/*" + ], + "scripts": { + "dev": "vite build --watch", + "build:code": "vite build", + "build:decl": "tsc --project tsconfig.decl.json", + "build": "pnpm run build:code && pnpm run build:decl", + "prepare": "pnpm run build:code && pnpm run build:decl", + "do-typecheck": "pnpm exec tsc --noEmit", + "lint": "eslint src", + "lintfix": "eslint --fix src", + "do-lint": "pnpm run lint", + "do-lintfix": "pnpm run lintfix" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/hoppscotch-kernel.js", + "require": "./dist/hoppscotch-kernel.cjs" + } + }, + "repository": { + "type": "git", + "url": "git+https://github.com/hoppscotch/hoppscotch.git" + }, + "author": "Hoppscotch (support@hoppscotch.io)", + "license": "MIT", + "bugs": { + "url": "https://github.com/hoppscotch/hoppscotch/issues" + }, + "homepage": "https://github.com/hoppscotch/hoppscotch#readme", + "devDependencies": { + "@eslint/js": "9.39.2", + "@types/node": "24.9.1", + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "eslint": "9.39.2", + "eslint-plugin-prettier": "5.5.6", + "globals": "16.5.0", + "typescript": "5.9.3", + "vite": "7.3.2" + }, + "peerDependencies": { + "@tauri-apps/api": "2.1.1" + }, + "peerDependenciesMeta": { + "@tauri-apps/api": { + "optional": true + } + }, + "dependencies": { + "@hoppscotch/plugin-relay": "github:CuriousCorrelation/tauri-plugin-relay#273488c8f50a22ee707af6b50ccd5570851f8bc9", + "@tauri-apps/plugin-dialog": "2.0.1", + "@tauri-apps/plugin-fs": "2.0.2", + "@tauri-apps/plugin-shell": "2.3.3", + "@tauri-apps/plugin-store": "2.4.1", + "aws4fetch": "1.0.20", + "axios": "1.18.0", + "fp-ts": "2.16.11", + "superjson": "2.2.6", + "zod": "3.25.32" + } +} diff --git a/packages/hoppscotch-kernel/src/index.ts b/packages/hoppscotch-kernel/src/index.ts new file mode 100644 index 0000000..23aadc2 --- /dev/null +++ b/packages/hoppscotch-kernel/src/index.ts @@ -0,0 +1,135 @@ +import type { Version } from "./type/versioning" + +import { VERSIONS as IO_VERSIONS } from "./io" +import { IO_IMPLS as WEB_IO_IMPLS } from "./io/impl/web" +import { IO_IMPLS as DESKTOP_IO_IMPLS } from "./io/impl/desktop" + +import { VERSIONS as RELAY_VERSIONS } from "./relay" +import { RELAY_IMPLS as WEB_RELAY_IMPLS } from "./relay/impl/web" +import { RELAY_IMPLS as DESKTOP_RELAY_IMPLS } from "./relay/impl/desktop" + +import { VERSIONS as STORE_VERSIONS } from "./store" +import { STORE_IMPLS as WEB_STORE_IMPLS } from "./store/impl/web" +import { STORE_IMPLS as DESKTOP_STORE_IMPLS } from "./store/impl/desktop" + +import { VERSIONS as LOG_VERSIONS } from "./log" +import { LOG_IMPLS as WEB_LOG_IMPLS } from "./log/impl/web" +import { LOG_IMPLS as DESKTOP_LOG_IMPLS } from "./log/impl/desktop" + +export interface KernelInfo { + name: string + version: Version + capabilities: string[] +} + +export interface KernelAPI { + info: KernelInfo + io: typeof IO_VERSIONS.v1.api + relay: typeof RELAY_VERSIONS.v1.api + store: typeof STORE_VERSIONS.v1.api + log: typeof LOG_VERSIONS.v1.api +} + +export type KernelMode = "web" | "desktop" + +declare global { + interface Window { + __KERNEL__?: KernelAPI + __KERNEL_MODE__?: KernelMode + } +} + +export function getKernelMode(): KernelMode { + return window.__KERNEL_MODE__ || "web" +} + +export function initKernel(mode?: KernelMode): KernelAPI { + if (mode === "desktop") { + const kernel: KernelAPI = { + info: { + name: "desktop-kernel", + version: { major: 1, minor: 0, patch: 0 }, + capabilities: ["basic-io"], + }, + io: DESKTOP_IO_IMPLS.v1.api, + relay: DESKTOP_RELAY_IMPLS.v1.api, + store: DESKTOP_STORE_IMPLS.v1.api, + log: DESKTOP_LOG_IMPLS.v1.api, + } + + window.__KERNEL__ = kernel + return kernel + } else { + const kernel: KernelAPI = { + info: { + name: "web-kernel", + version: { major: 1, minor: 0, patch: 0 }, + capabilities: ["basic-io"], + }, + io: WEB_IO_IMPLS.v1.api, + relay: WEB_RELAY_IMPLS.v1.api, + store: WEB_STORE_IMPLS.v1.api, + log: WEB_LOG_IMPLS.v1.api, + } + + window.__KERNEL__ = kernel + return kernel + } +} + +export type { + SaveFileWithDialogOptions, + SaveFileResponse, + OpenExternalLinkOptions, + OpenExternalLinkResponse, + Event, + EventCallback, + UnlistenFn, + IoV1, +} from "@io/v/1" + +export type { + RelayRequest, + RelayResponse, + PluginRequest, + PluginResponse, + RelayResponseBody, + FormDataValue, + RelayError, + RelayV1, + Method, + Version, + ContentType, + AuthType, + CertificateType, + RelayCapabilities, + RelayEventEmitter, + RelayRequestEvents, + StatusCode, +} from "@relay/v/1" + +export { + content, + body, + MediaType, + relayRequestToNativeAdapter, +} from "@relay/v/1" + +export type { + StoreCapability, + StoreError, + StoreFile, + StorageOptions, + StoreEvents, + StoreMetadataSchema, + StoreMetadata, + StoredDataSchema, + StoredData, + StoreEventEmitter, + StoreV1, + ScopedStore, +} from "@store/v/1" + +export { extend as extendStore } from "@store/v/1" + +export type { LogLevel, LogCapability, LogError, LogV1 } from "@log/v/1" diff --git a/packages/hoppscotch-kernel/src/io/impl/desktop/index.ts b/packages/hoppscotch-kernel/src/io/impl/desktop/index.ts new file mode 100644 index 0000000..0300d69 --- /dev/null +++ b/packages/hoppscotch-kernel/src/io/impl/desktop/index.ts @@ -0,0 +1,5 @@ +import { implementation as ioV1 } from "./v/1" + +export const IO_IMPLS = { + v1: ioV1, +} as const diff --git a/packages/hoppscotch-kernel/src/io/impl/desktop/v/1.ts b/packages/hoppscotch-kernel/src/io/impl/desktop/v/1.ts new file mode 100644 index 0000000..a92ef3c --- /dev/null +++ b/packages/hoppscotch-kernel/src/io/impl/desktop/v/1.ts @@ -0,0 +1,60 @@ +import { VersionedAPI } from "@type/versioning" +import { + IoV1, + SaveFileWithDialogOptions, + OpenExternalLinkOptions, +} from "@io/v/1" + +import { save } from "@tauri-apps/plugin-dialog" +import { writeFile, writeTextFile } from "@tauri-apps/plugin-fs" +import { open } from "@tauri-apps/plugin-shell" +import { + listen as tauriListen, + emit as tauriEmit, + Event, +} from "@tauri-apps/api/event" + +export const implementation: VersionedAPI = { + version: { major: 1, minor: 0, patch: 0 }, + api: { + async saveFileWithDialog(opts: SaveFileWithDialogOptions) { + const path = await save({ + filters: opts.filters, + defaultPath: opts.suggestedFilename, + }) + + if (!path) { + return { type: "cancelled" as const } + } + + if (typeof opts.data === "string") { + await writeTextFile(path, opts.data) + } else { + await writeFile(path, opts.data) + } + + return { type: "saved" as const, path } + }, + + async openExternalLink(opts: OpenExternalLinkOptions) { + await open(opts.url) + return { type: "opened" as const } + }, + + async listen(event: string, handler: (event: Event) => void) { + return await tauriListen(event, handler) + }, + + async once(event: string, handler: (event: Event) => void) { + const unlistenFn = await tauriListen(event, (e) => { + unlistenFn() + handler(e) + }) + return unlistenFn + }, + + async emit(event: string, payload?: unknown) { + await tauriEmit(event, payload) + }, + }, +} diff --git a/packages/hoppscotch-kernel/src/io/impl/web/index.ts b/packages/hoppscotch-kernel/src/io/impl/web/index.ts new file mode 100644 index 0000000..0300d69 --- /dev/null +++ b/packages/hoppscotch-kernel/src/io/impl/web/index.ts @@ -0,0 +1,5 @@ +import { implementation as ioV1 } from "./v/1" + +export const IO_IMPLS = { + v1: ioV1, +} as const diff --git a/packages/hoppscotch-kernel/src/io/impl/web/v/1.ts b/packages/hoppscotch-kernel/src/io/impl/web/v/1.ts new file mode 100644 index 0000000..b4153fb --- /dev/null +++ b/packages/hoppscotch-kernel/src/io/impl/web/v/1.ts @@ -0,0 +1,97 @@ +import { VersionedAPI } from "@type/versioning" +import { + IoV1, + SaveFileWithDialogOptions, + OpenExternalLinkOptions, + Event, +} from "@io/v/1" +import { pipe } from "fp-ts/function" +import * as S from "fp-ts/string" +import * as RNEA from "fp-ts/ReadonlyNonEmptyArray" + +export const implementation: VersionedAPI = { + version: { major: 1, minor: 0, patch: 0 }, + api: { + async saveFileWithDialog(opts: SaveFileWithDialogOptions) { + // TODO: Revisit this because perhaps a better approach is + // ```ts + // const data: BlobPart = typeof opts.data === 'string' + // ? opts.data + // : new Uint8Array(opts.data); + // const file = new Blob([data], { type: opts.contentType }) + // ``` + const file = new Blob([opts.data as BlobPart], { type: opts.contentType }) + const a = document.createElement("a") + const url = URL.createObjectURL(file) + + a.href = url + a.download = + opts.suggestedFilename ?? + pipe( + url, + S.split("/"), + RNEA.last, + S.split("#"), + RNEA.head, + S.split("?"), + RNEA.head + ) + + document.body.appendChild(a) + a.click() + + setTimeout(() => { + document.body.removeChild(a) + URL.revokeObjectURL(url) + }, 1000) + + // Browsers provide no way to know if save was successful + return { type: "unknown" as const } + }, + + async openExternalLink(opts: OpenExternalLinkOptions) { + window.open(opts.url, "_blank") + // Browsers provide no way to know if save was successful + return { type: "unknown" as const } + }, + + async listen(event: string, handler: (event: Event) => void) { + const listener = (_e: HashChangeEvent) => { + const hash = window.location.hash + if (hash && hash.startsWith(`#${event}:`)) { + const payload = hash.slice(event.length + 2) // Remove #event: + handler({ + event, + id: Date.now(), + payload: JSON.parse(payload) as T, + }) + } + } + + window.addEventListener("hashchange", listener) + return () => window.removeEventListener("hashchange", listener) + }, + + async once(event: string, handler: (event: Event) => void) { + const listener = (_e: HashChangeEvent) => { + const hash = window.location.hash + if (hash && hash.startsWith(`#${event}:`)) { + const payload = hash.slice(event.length + 2) // Remove #event: + window.removeEventListener("hashchange", listener) + handler({ + event, + id: Date.now(), + payload: JSON.parse(payload) as T, + }) + } + } + + window.addEventListener("hashchange", listener) + return () => window.removeEventListener("hashchange", listener) + }, + + async emit(event: string, payload?: unknown) { + window.location.hash = `${event}:${JSON.stringify(payload)}` + }, + }, +} diff --git a/packages/hoppscotch-kernel/src/io/index.ts b/packages/hoppscotch-kernel/src/io/index.ts new file mode 100644 index 0000000..c3f64c9 --- /dev/null +++ b/packages/hoppscotch-kernel/src/io/index.ts @@ -0,0 +1,18 @@ +import { v1 } from "./v/1" + +export type { + IoV1, + SaveFileWithDialogOptions, + SaveFileResponse, + OpenExternalLinkOptions, + OpenExternalLinkResponse, + Event, + EventCallback, + UnlistenFn, +} from "./v/1" + +export const VERSIONS = { + v1, +} as const + +export const latest = v1 diff --git a/packages/hoppscotch-kernel/src/io/v/1.ts b/packages/hoppscotch-kernel/src/io/v/1.ts new file mode 100644 index 0000000..91632c0 --- /dev/null +++ b/packages/hoppscotch-kernel/src/io/v/1.ts @@ -0,0 +1,61 @@ +import type { VersionedAPI } from "@type/versioning" + +export interface Event { + event: string + id: number + payload: T +} + +export type EventCallback = (event: Event) => void | Promise +export type UnlistenFn = () => void | Promise + +export type SaveFileWithDialogOptions = { + data: string | Uint8Array + suggestedFilename: string + contentType: string + filters?: Array<{ + name: string + extensions: string[] + }> +} + +export type OpenExternalLinkOptions = { + url: string +} + +export type SaveFileResponse = + | { type: "unknown" } + | { type: "cancelled" } + | { type: "saved"; path: string } + +export type OpenExternalLinkResponse = + | { type: "unknown" } + | { type: "cancelled" } + | { type: "opened" } + +export interface IoV1 { + saveFileWithDialog: ( + opts: SaveFileWithDialogOptions + ) => Promise + + openExternalLink: ( + opts: OpenExternalLinkOptions + ) => Promise + + listen: (event: string, handler: EventCallback) => Promise + + once: (event: string, handler: EventCallback) => Promise + + emit: (event: string, payload?: unknown) => Promise +} + +export const v1: VersionedAPI = { + version: { major: 1, minor: 0, patch: 0 }, + api: { + saveFileWithDialog: async () => ({ type: "unknown" }), + openExternalLink: async () => ({ type: "unknown" }), + listen: async () => () => {}, + once: async () => () => {}, + emit: async () => {}, + }, +} diff --git a/packages/hoppscotch-kernel/src/log/impl/desktop/index.ts b/packages/hoppscotch-kernel/src/log/impl/desktop/index.ts new file mode 100644 index 0000000..43cb5ee --- /dev/null +++ b/packages/hoppscotch-kernel/src/log/impl/desktop/index.ts @@ -0,0 +1,5 @@ +import { implementation as logV1 } from "./v/1" + +export const LOG_IMPLS = { + v1: logV1, +} as const diff --git a/packages/hoppscotch-kernel/src/log/impl/desktop/v/1.ts b/packages/hoppscotch-kernel/src/log/impl/desktop/v/1.ts new file mode 100644 index 0000000..484561b --- /dev/null +++ b/packages/hoppscotch-kernel/src/log/impl/desktop/v/1.ts @@ -0,0 +1,193 @@ +import * as E from "fp-ts/Either" + +import type { VersionedAPI } from "@type/versioning" +import type { LogV1, LogLevel } from "@log/v/1" + +// in-memory buffer backing the "buffer" capability (same as web impl). +// see impl/web/v/1.ts for the full rationale. on desktop the buffer +// supplements the disk log: disk gives persistence, buffer gives +// programmatic retrieval for future kernel APIs (getLogs(), getLogsByTag()) +// +// window assignment is an intentional debugging hatch for DevTools +// inspection. will be internalized once the kernel retrieval API lands +const buffer: string[] = [] + +if (typeof window !== "undefined") { + ;(window as any).__DIAG_LOGS__ = buffer + ;(window as any).__dumpDiagLogs__ = () => buffer.join("\n") +} + +const FLUSH_INTERVAL_MS = 500 + +// lazy-loaded Tauri invoke. loaded once, shared across all instances +let invoke: + | ((cmd: string, args?: Record) => Promise) + | null = null +let invokePromise: Promise | null = null + +const ensureInvoke = async () => { + if (invoke) return + if (!invokePromise) { + invokePromise = import("@tauri-apps/api/core").then((m) => { + invoke = m.invoke + }) + } + await invokePromise +} + +class TauriLogManager { + private static instances: Map = new Map() + + // the filename (not full path) passed to the Rust `append_log` command. + // the Rust side joins this with `logs_dir()` so writes are always + // confined to the correct directory regardless of build type + private filename: string + private initialized = false + private pendingWrites: string[] = [] + private flushTimer: ReturnType | null = null + + private constructor(filename: string) { + this.filename = filename + } + + static new(filename: string): TauriLogManager { + if (TauriLogManager.instances.has(filename)) { + return TauriLogManager.instances.get(filename)! + } + const instance = new TauriLogManager(filename) + TauriLogManager.instances.set(filename, instance) + return instance + } + + async init(): Promise { + if (this.initialized) return + + try { + await ensureInvoke() + + // write a session header so we know which webview this came from + const orgCtx = + new URLSearchParams(window.location.search).get("org") ?? "(none)" + const header = [ + "", + "=".repeat(72), + `LOG SESSION START ${new Date().toISOString()}`, + ` org context : ${orgCtx}`, + ` href : ${window.location.href}`, + ` host : ${window.location.host}`, + ` __KERNEL__ : ${window.__KERNEL__ ? "present" : "MISSING"}`, + "=".repeat(72), + "", + ].join("\n") + + await invoke!("append_log", { + filename: this.filename, + content: header, + }) + + this.initialized = true + + // flush any writes that accumulated before init completed + if (this.pendingWrites.length > 0) { + this.scheduleFlush() + } + } catch (err) { + console.warn("[kernel-log] Failed to initialize file logger:", err) + } + } + + private async flush(): Promise { + if (!invoke || this.pendingWrites.length === 0) return + + const batch = this.pendingWrites.join("\n") + "\n" + const snapshot = this.pendingWrites + this.pendingWrites = [] + + try { + await invoke("append_log", { + filename: this.filename, + content: batch, + }) + } catch (err) { + // re-queue failed entries (prepend before any new writes that + // accumulated during the await) so they're retried on next flush + this.pendingWrites = snapshot.concat(this.pendingWrites) + console.warn("[kernel-log] Failed to flush logs to disk:", err) + this.scheduleFlush() + } + } + + private scheduleFlush(): void { + if (this.flushTimer) return + this.flushTimer = setTimeout(() => { + this.flushTimer = null + this.flush() + }, FLUSH_INTERVAL_MS) + } + + log(level: string, tag: string, message: string, data?: unknown): void { + const ts = new Date().toISOString() + const dataPart = + data !== undefined + ? ` ${ + typeof data === "string" + ? data + : (() => { + try { + return JSON.stringify(data) + } catch { + return String(data) + } + })() + }` + : "" + const line = `[${ts}] [${level.toUpperCase()}] [${tag}] ${message}${dataPart}` + + // 1. console (same as web) + if (level === "debug") console.debug(line) + else if (level === "warn") console.warn(line) + else if (level === "error") console.error(line) + else console.log(line) + + // 2. in-memory buffer + buffer.push(line) + if (buffer.length > 5000) buffer.splice(0, buffer.length - 5000) + + // 3. file (batched) + this.pendingWrites.push(line) + this.scheduleFlush() + } +} + +export const implementation: VersionedAPI = { + version: { major: 1, minor: 0, patch: 0 }, + api: { + id: "tauri-log", + capabilities: new Set(["console", "file", "buffer"]), + + async init(logPath: string) { + try { + const manager = TauriLogManager.new(logPath) + await manager.init() + return E.right(undefined) + } catch (error) { + return E.left({ + kind: "init", + message: error instanceof Error ? error.message : "Unknown error", + cause: error, + }) + } + }, + + async log( + logPath: string, + level: LogLevel, + tag: string, + message: string, + data?: unknown + ) { + const manager = TauriLogManager.new(logPath) + manager.log(level, tag, message, data) + }, + }, +} diff --git a/packages/hoppscotch-kernel/src/log/impl/web/index.ts b/packages/hoppscotch-kernel/src/log/impl/web/index.ts new file mode 100644 index 0000000..43cb5ee --- /dev/null +++ b/packages/hoppscotch-kernel/src/log/impl/web/index.ts @@ -0,0 +1,5 @@ +import { implementation as logV1 } from "./v/1" + +export const LOG_IMPLS = { + v1: logV1, +} as const diff --git a/packages/hoppscotch-kernel/src/log/impl/web/v/1.ts b/packages/hoppscotch-kernel/src/log/impl/web/v/1.ts new file mode 100644 index 0000000..b035d44 --- /dev/null +++ b/packages/hoppscotch-kernel/src/log/impl/web/v/1.ts @@ -0,0 +1,96 @@ +import * as E from "fp-ts/Either" + +import type { VersionedAPI } from "@type/versioning" +import type { LogV1, LogLevel } from "@log/v/1" + +// in-memory buffer backing the "buffer" capability (see LogCapability). +// console.log is fire-and-forget with no retrieval path (no console.getAll()), +// so this buffer exists for log introspection: future kernel log iterations +// will expose retrieval APIs (getLogs(), getLogsByTag()) for in-app +// diagnostics, "send logs to support" flows, and test assertions. +// +// the window assignment below is an intentional debugging hatch so the +// buffer can be inspected from DevTools. it will be internalized once the +// proper kernel retrieval API lands, but the buffer itself stays as a +// declared capability in v1 +const buffer: string[] = [] + +if (typeof window !== "undefined") { + ;(window as any).__DIAG_LOGS__ = buffer + ;(window as any).__dumpDiagLogs__ = () => buffer.join("\n") +} + +class BrowserLogManager { + private static instance: BrowserLogManager + + private constructor() {} + + static new(): BrowserLogManager { + if (!BrowserLogManager.instance) { + BrowserLogManager.instance = new BrowserLogManager() + } + return BrowserLogManager.instance + } + + private format( + level: string, + tag: string, + message: string, + data?: unknown + ): string { + const ts = new Date().toISOString() + const dataPart = + data !== undefined + ? ` ${ + typeof data === "string" + ? data + : (() => { + try { + return JSON.stringify(data) + } catch { + return String(data) + } + })() + }` + : "" + return `[${ts}] [${level.toUpperCase()}] [${tag}] ${message}${dataPart}` + } + + log(level: string, tag: string, message: string, data?: unknown): void { + const line = this.format(level, tag, message, data) + + // write to appropriate console method + if (level === "debug") console.debug(line) + else if (level === "warn") console.warn(line) + else if (level === "error") console.error(line) + else console.log(line) + + // push to in-memory buffer + buffer.push(line) + if (buffer.length > 5000) buffer.splice(0, buffer.length - 5000) + } +} + +export const implementation: VersionedAPI = { + version: { major: 1, minor: 0, patch: 0 }, + api: { + id: "browser-log", + capabilities: new Set(["console", "buffer"]), + + // web doesn't need file init, but the API is consistent + async init(_logPath: string) { + return E.right(undefined) + }, + + async log( + _logPath: string, + level: LogLevel, + tag: string, + message: string, + data?: unknown + ) { + const manager = BrowserLogManager.new() + manager.log(level, tag, message, data) + }, + }, +} diff --git a/packages/hoppscotch-kernel/src/log/index.ts b/packages/hoppscotch-kernel/src/log/index.ts new file mode 100644 index 0000000..8ee3936 --- /dev/null +++ b/packages/hoppscotch-kernel/src/log/index.ts @@ -0,0 +1,9 @@ +import { v1 } from "./v/1" + +export type { LogV1, LogLevel, LogCapability, LogError } from "./v/1" + +export const VERSIONS = { + v1, +} as const + +export const latest = v1 diff --git a/packages/hoppscotch-kernel/src/log/v/1.ts b/packages/hoppscotch-kernel/src/log/v/1.ts new file mode 100644 index 0000000..6d57563 --- /dev/null +++ b/packages/hoppscotch-kernel/src/log/v/1.ts @@ -0,0 +1,46 @@ +import type { VersionedAPI } from "@type/versioning" +import * as E from "fp-ts/Either" + +export type LogLevel = "debug" | "info" | "warn" | "error" + +// "console": writes to the browser console (fire-and-forget, no retrieval) +// "file": writes to disk via Tauri append_log (desktop only) +// "buffer": in-memory circular buffer for log introspection. backs future +// retrieval APIs (getLogs(), getLogsByTag()) for in-app diagnostics, +// "send logs to support" flows, and test assertions. web declares +// ["console", "buffer"], desktop declares all three +export type LogCapability = "console" | "file" | "buffer" + +export type LogError = + | { kind: "init"; message: string; cause?: unknown } + | { kind: "write"; message: string; cause?: unknown } + +export interface LogV1 { + readonly id: string + readonly capabilities: Set + + // on web this is a no-op. on desktop it opens/creates the log file + // at `logPath` for persistent logging + init(logPath: string): Promise> + + // fire-and-forget: logging should never block the caller. + // on web writes to console only. on desktop writes to console and file + log( + logPath: string, + level: LogLevel, + tag: string, + message: string, + data?: unknown + ): Promise +} + +export const v1: VersionedAPI = { + version: { major: 1, minor: 0, patch: 0 }, + api: { + id: "default", + capabilities: new Set(), + + init: async () => E.left({ kind: "init", message: "Not implemented" }), + log: async () => {}, + }, +} diff --git a/packages/hoppscotch-kernel/src/relay/impl/desktop/index.ts b/packages/hoppscotch-kernel/src/relay/impl/desktop/index.ts new file mode 100644 index 0000000..2b92a17 --- /dev/null +++ b/packages/hoppscotch-kernel/src/relay/impl/desktop/index.ts @@ -0,0 +1,5 @@ +import { implementation as relayV1 } from "./v/1" + +export const RELAY_IMPLS = { + v1: relayV1, +} as const diff --git a/packages/hoppscotch-kernel/src/relay/impl/desktop/v/1.ts b/packages/hoppscotch-kernel/src/relay/impl/desktop/v/1.ts new file mode 100644 index 0000000..7b159a7 --- /dev/null +++ b/packages/hoppscotch-kernel/src/relay/impl/desktop/v/1.ts @@ -0,0 +1,202 @@ +import type { VersionedAPI } from "@type/versioning" +import { + type RelayV1, + type RelayRequest, + type RelayRequestEvents, + type RelayEventEmitter, + type RelayResponse, + type RelayError, + body, + relayRequestToNativeAdapter, +} from "@relay/v/1" +import * as E from "fp-ts/Either" + +import { + execute, + cancel, + type Request as _Request, + type RequestResult, +} from "@hoppscotch/plugin-relay" + +export const implementation: VersionedAPI = { + version: { major: 1, minor: 0, patch: 0 }, + api: { + id: "desktop", + capabilities: { + method: new Set([ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS", + ]), + header: new Set(["stringvalue", "arrayvalue", "multivalue"]), + content: new Set([ + "text", + "json", + "xml", + "form", + "binary", + "multipart", + "urlencoded", + "stream", + "compression", + ]), + auth: new Set(["basic", "bearer", "digest", "oauth2", "apikey"]), + security: new Set([ + "clientcertificates", + "cacertificates", + "certificatevalidation", + "hostverification", + "peerverification", + ]), + proxy: new Set(["http", "https", "authentication", "certificates"]), + advanced: new Set([ + "retry", + "redirects", + "timeout", + "cookies", + "keepalive", + "tcpoptions", + "http2", + "http3", + ]), + }, + + canHandle(request: RelayRequest) { + if (!this.capabilities.method.has(request.method)) { + return E.left({ + kind: "unsupported_feature", + feature: "method", + message: `Method ${request.method} is not supported`, + relay: "desktop", + }) + } + + if ( + request.content && + !this.capabilities.content.has(request.content.kind) + ) { + return E.left({ + kind: "unsupported_feature", + feature: "content", + message: `Content type ${request.content.kind} is not supported`, + relay: "desktop", + }) + } + + if (request.auth && !this.capabilities.auth.has(request.auth.kind)) { + return E.left({ + kind: "unsupported_feature", + feature: "authentication", + message: `Authentication type ${request.auth.kind} is not supported`, + relay: "desktop", + }) + } + + if ( + request.security?.certificates && + !this.capabilities.security.has("clientcertificates") + ) { + return E.left({ + kind: "unsupported_feature", + feature: "security", + message: "Client certificates are not supported", + relay: "desktop", + }) + } + + if ( + request.proxy && + !this.capabilities.proxy.has( + request.proxy.url.startsWith("https") ? "https" : "http" + ) + ) { + return E.left({ + kind: "unsupported_feature", + feature: "proxy", + message: `Proxy protocol ${request.proxy.url.split(":")[0]} is not supported`, + relay: "desktop", + }) + } + + return E.right(true) + }, + + execute(request: RelayRequest) { + const emitter: RelayEventEmitter = { + on: () => () => {}, + once: () => () => {}, + off: () => {}, + } + + const responsePromise = relayRequestToNativeAdapter(request) + .then((request) => { + // SAFETY: Type assertion is safe because: + // 1. The capabilities system prevents requests with unsupported methods from reaching this point + // 2. Content types not supported by the plugin are filtered by capabilities + // 3. Authentication methods are validated through capabilities + // 4. The plugin's Request type is a subset of our Request type + const pluginRequest = { + id: request.id, + url: request.url, + method: request.method, + version: request.version, + headers: request.headers, + params: request.params, + content: request.content, + auth: request.auth, + security: request.security, + proxy: request.proxy, + meta: request.meta, + } + + return execute(pluginRequest) + }) + .then((result: RequestResult): E.Either => { + if (result.kind === "success") { + const response: RelayResponse = { + id: result.response.id, + status: result.response.status, + statusText: result.response.statusText, + version: result.response.version, + headers: result.response.headers, + cookies: result.response.cookies, + body: body.body( + result.response.body.body, + result.response.body.mediaType + ), + meta: { + timing: { + start: result.response.meta.timing.start, + end: result.response.meta.timing.end, + }, + size: result.response.meta.size, + }, + } + return E.right(response) + } + return E.left(result.error) + }) + .catch((error: unknown): E.Either => { + const networkError: RelayError = { + kind: "network", + message: + error instanceof Error ? error.message : "Unknown error occurred", + cause: error, + } + return E.left(networkError) + }) + + return { + cancel: async () => { + await cancel(request.id) + }, + emitter, + response: responsePromise, + } + }, + }, +} diff --git a/packages/hoppscotch-kernel/src/relay/impl/web/index.ts b/packages/hoppscotch-kernel/src/relay/impl/web/index.ts new file mode 100644 index 0000000..2b92a17 --- /dev/null +++ b/packages/hoppscotch-kernel/src/relay/impl/web/index.ts @@ -0,0 +1,5 @@ +import { implementation as relayV1 } from "./v/1" + +export const RELAY_IMPLS = { + v1: relayV1, +} as const diff --git a/packages/hoppscotch-kernel/src/relay/impl/web/v/1.ts b/packages/hoppscotch-kernel/src/relay/impl/web/v/1.ts new file mode 100644 index 0000000..f386534 --- /dev/null +++ b/packages/hoppscotch-kernel/src/relay/impl/web/v/1.ts @@ -0,0 +1,290 @@ +import { + type RelayError, + type RelayEventEmitter, + type RelayRequest, + type RelayRequestEvents, + type RelayResponse, + type RelayV1, + type StatusCode, + body, +} from "@relay/v/1" +import type { VersionedAPI } from "@type/versioning" + +import { AwsV4Signer as _AwsV4Signer } from "aws4fetch" +import axios, { AxiosRequestConfig } from "axios" + +import * as E from "fp-ts/Either" +import * as R from "fp-ts/Record" +import { pipe } from "fp-ts/function" + +const isStatusCode = (status: number): status is StatusCode => + status >= 100 && status < 600 + +const normalizeHeaders = ( + headers: Record +): Record => + pipe( + headers, + R.filterWithIndex((_, v) => v !== undefined), + R.map(String) + ) + +export const implementation: VersionedAPI = { + version: { major: 1, minor: 0, patch: 0 }, + api: { + id: "axios", + capabilities: { + method: new Set([ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS", + ]), + header: new Set(["stringvalue", "arrayvalue", "multivalue"]), + content: new Set([ + "text", + "json", + "xml", + "form", + "urlencoded", + "compression", + ]), + auth: new Set(["basic", "bearer", "apikey", "aws"]), + security: new Set([]), + proxy: new Set([]), + advanced: new Set([]), + }, + + canHandle(request: RelayRequest) { + if (!this.capabilities.method.has(request.method)) { + return E.left({ + kind: "unsupported_feature", + feature: "method", + message: `Method ${request.method} is not supported`, + relay: "axios", + }) + } + + if ( + request.content && + !this.capabilities.content.has(request.content.kind) + ) { + return E.left({ + kind: "unsupported_feature", + feature: "content", + message: `Content type ${request.content.kind} is not supported`, + relay: "axios", + }) + } + + if (request.auth && !this.capabilities.auth.has(request.auth.kind)) { + return E.left({ + kind: "unsupported_feature", + feature: "authentication", + message: `Authentication type ${request.auth.kind} is not supported`, + relay: "axios", + }) + } + + if (request.security?.certificates) { + return E.left({ + kind: "unsupported_feature", + feature: "security", + message: "Client certificates are not supported", + relay: "axios", + }) + } + + if (request.proxy) { + return E.left({ + kind: "unsupported_feature", + feature: "proxy", + message: "Proxy is not supported", + relay: "axios", + }) + } + + return E.right(true) + }, + + execute(request: RelayRequest) { + const cancelTokenSource = axios.CancelToken.source() + const emitter: RelayEventEmitter = { + on: () => () => {}, + once: () => () => {}, + off: () => {}, + } + + const response: Promise> = + (async () => { + try { + const startTime = Date.now() + const config: AxiosRequestConfig = { + url: request.url, + method: request.method, + headers: request.headers, + params: request.params, + data: request.content?.content, + maxRedirects: request.meta?.options?.maxRedirects, + timeout: request.meta?.options?.timeout, + decompress: request.meta?.options?.decompress ?? true, + validateStatus: null, + cancelToken: cancelTokenSource.token, + responseType: "arraybuffer", + } + + // The following code is temporarily commented out because the auth has been pre-processed in EffectiveURL.ts and added in header + // and preprocessing here will cause the environment variables not parsed since the auth object only has the raw value + + // if (request.auth) { + // switch (request.auth.kind) { + // case "basic": + // config.auth = { + // username: request.auth.username, + // password: request.auth.password, + // } + // break + // case "bearer": + // config.headers = { + // ...config.headers, + // Authorization: `Bearer ${request.auth.token}`, + // } + // break + // case "apikey": + // if (request.auth.in === "header") { + // config.headers = { + // ...config.headers, + // [request.auth.key]: request.auth.value, + // } + // } else { + // config.params = { + // ...config.params, + // [request.auth.key]: request.auth.value, + // } + // } + // break + // case "aws": { + // const { + // accessKey, + // secretKey, + // region, + // service, + // sessionToken, + // in: location, + // } = request.auth + // const signer = new AwsV4Signer({ + // url: request.url, + // method: request.method, + // accessKeyId: accessKey, + // secretAccessKey: secretKey, + // region, + // service, + // sessionToken, + // datetime: new Date() + // .toISOString() + // .replace(/[:-]|\.\d{3}/g, ""), + // signQuery: false, + // }) + // const signed = await signer.sign() + // if (location === "query") { + // config.url = signed.url.toString() + // } else { + // const headers: Record = {} + // signed.headers.forEach((value, key) => { + // headers[key] = value + // }) + // config.headers = { + // ...config.headers, + // ...headers, + // } + // } + // break + // } + // } + // } + + const axiosResponse = await axios(config) + const endTime = Date.now() + + if (!isStatusCode(axiosResponse.status)) { + return E.left({ + kind: "version", + message: `Invalid status code: ${axiosResponse.status}`, + }) + } + + const normalizedHeaders = normalizeHeaders(axiosResponse.headers) + const contentType = + normalizedHeaders["content-type"] || + normalizedHeaders["Content-Type"] || + normalizedHeaders["CONTENT-TYPE"] + + const rawBody = axiosResponse.data + const bodySize = rawBody.byteLength + + const headerSize = JSON.stringify(axiosResponse.headers).length + + const response: RelayResponse = { + id: request.id, + status: axiosResponse.status, + statusText: axiosResponse.statusText, + version: request.version, + headers: normalizedHeaders, + body: body.body(axiosResponse.data, contentType), + meta: { + timing: { + start: startTime, + end: endTime, + }, + size: { + headers: headerSize, + body: bodySize, + total: headerSize + bodySize, + }, + }, + } + + return E.right(response) + } catch (error) { + if (axios.isCancel(error)) { + return E.left({ kind: "abort", message: "Request cancelled" }) + } + + if (axios.isAxiosError(error)) { + if (error.code === "ECONNABORTED") { + return E.left({ + kind: "timeout", + message: "Request timed out", + phase: "response", + }) + } + return E.left({ + kind: "network", + message: error.message, + }) + } + + return E.left({ + kind: "network", + message: + error instanceof Error + ? error.message + : "Unknown error occurred", + cause: error, + }) + } + })() + + return { + cancel: async () => { + cancelTokenSource.cancel() + }, + emitter, + response, + } + }, + }, +} diff --git a/packages/hoppscotch-kernel/src/relay/index.ts b/packages/hoppscotch-kernel/src/relay/index.ts new file mode 100644 index 0000000..c8864d4 --- /dev/null +++ b/packages/hoppscotch-kernel/src/relay/index.ts @@ -0,0 +1,9 @@ +import { v1 } from "./v/1" + +export type { RelayV1 } from "./v/1" + +export const VERSIONS = { + v1, +} as const + +export const latest = v1 diff --git a/packages/hoppscotch-kernel/src/relay/v/1.ts b/packages/hoppscotch-kernel/src/relay/v/1.ts new file mode 100644 index 0000000..dbdf0a4 --- /dev/null +++ b/packages/hoppscotch-kernel/src/relay/v/1.ts @@ -0,0 +1,828 @@ +import { Request, Response } from "@hoppscotch/plugin-relay" +import type { VersionedAPI } from "@type/versioning" + +export type PluginRequest = Request +export type PluginResponse = Response + +import * as E from "fp-ts/Either" +import * as O from "fp-ts/Option" +import { pipe } from "fp-ts/function" + +export type Method = + | "GET" // Retrieve resource + | "POST" // Create resource + | "PUT" // Replace resource + | "DELETE" // Remove resource + | "PATCH" // Modify resource + | "HEAD" // GET without body + | "OPTIONS" // Get allowed methods + | "CONNECT" // Create tunnel + | "TRACE" // Loop-back test + +export type Version = "HTTP/1.0" | "HTTP/1.1" | "HTTP/2.0" | "HTTP/3.0" + +export type StatusCode = + | 100 // Continue + | 101 // Switching Protocols + | 102 // Processing + | 103 // Early Hints + | 200 // OK + | 201 // Created + | 202 // Accepted + | 203 // Non-Authoritative Info + | 204 // No Content + | 205 // Reset Content + | 206 // Partial Content + | 207 // Multi-Status + | 208 // Already Reported + | 226 // IM Used + | 300 // Multiple Choices + | 301 // Moved Permanently + | 302 // Found + | 303 // See Other + | 304 // Not Modified + | 305 // Use Proxy + | 306 // Switch Proxy + | 307 // Temporary Redirect + | 308 // Permanent Redirect + | 400 // Bad Request + | 401 // Unauthorized + | 402 // Payment Required + | 403 // Forbidden + | 404 // Not Found + | 405 // Method Not Allowed + | 406 // Not Acceptable + | 407 // Proxy Auth Required + | 408 // Request Timeout + | 409 // Conflict + | 410 // Gone + | 411 // Length Required + | 412 // Precondition Failed + | 413 // Payload Too Large + | 414 // URI Too Long + | 415 // Unsupported Media + | 416 // Range Not Satisfiable + | 417 // Expectation Failed + | 418 // I'm a teapot + | 421 // Misdirected Request + | 422 // Unprocessable Entity + | 423 // Locked + | 424 // Failed Dependency + | 425 // Too Early + | 426 // Upgrade Required + | 428 // Precondition Required + | 429 // Too Many Requests + | 431 // Headers Too Large + | 451 // Unavailable Legal + | 500 // Server Error + | 501 // Not Implemented + | 502 // Bad Gateway + | 503 // Service Unavailable + | 504 // Gateway Timeout + | 505 // HTTP Ver. Not Supported + | 506 // Variant Negotiates + | 507 // Insufficient Storage + | 508 // Loop Detected + | 510 // Not Extended + | 511 // Network Auth Required + +export type FormDataValue = + | { kind: "text"; value: string } + | { kind: "file"; filename: string; contentType: string; data: Uint8Array } + +export enum MediaType { + TEXT_PLAIN = "text/plain", + TEXT_HTML = "text/html", + TEXT_CSS = "text/css", + TEXT_CSV = "text/csv", + APPLICATION_JSON = "application/json", + APPLICATION_LD_JSON = "application/ld+json", + APPLICATION_XML = "application/xml", + TEXT_XML = "text/xml", + APPLICATION_FORM = "application/x-www-form-urlencoded", + APPLICATION_OCTET = "application/octet-stream", + MULTIPART_FORM = "multipart/form-data", +} + +export type ContentType = + | { kind: "text"; content: string; mediaType: MediaType | string } + | { kind: "json"; content: unknown; mediaType: MediaType | string } + | { kind: "xml"; content: string; mediaType: MediaType | string } + | { kind: "form"; content: FormData; mediaType: MediaType | string } + | { + kind: "binary" + content: Uint8Array + mediaType: MediaType | string + filename?: string + } + | { kind: "multipart"; content: FormData; mediaType: MediaType | string } + | { kind: "urlencoded"; content: string; mediaType: MediaType | string } + | { kind: "stream"; content: ReadableStream; mediaType: MediaType | string } +// TODO: Considering adding a "raw" kind for explicit pass-through content in the future, +// not required at the moment tho, needs some plumbing on the relay side. +// | { kind: "raw"; content: string; mediaType: MediaType | string } + +export interface RelayResponseBody { + body: Uint8Array + mediaType: MediaType | string +} + +export type AuthType = + | { kind: "none" } + | { kind: "basic"; username: string; password: string } + | { kind: "bearer"; token: string } + | { + kind: "digest" + username: string + password: string + realm?: string + nonce?: string + opaque?: string + algorithm?: "MD5" | "SHA-256" | "SHA-512" + qop?: "auth" | "auth-int" + nc?: string + cnonce?: string + } + | { + kind: "oauth2" + grantType: + | { + kind: "authorization_code" + authEndpoint: string + tokenEndpoint: string + clientId: string + clientSecret?: string + } + | { + kind: "client_credentials" + tokenEndpoint: string + clientId: string + clientSecret?: string + } + | { + kind: "password" + tokenEndpoint: string + username: string + password: string + } + | { + kind: "implicit" + authEndpoint: string + clientId: string + } + accessToken?: string + refreshToken?: string + } + | { + kind: "apikey" + key: string + value: string + in: "header" | "query" + } + | { + kind: "aws" + accessKey: string + secretKey: string + region: string + service: string + sessionToken?: string + in: "header" | "query" + } + +export type CertificateType = + | { + kind: "pem" + cert: Uint8Array + key: Uint8Array + } + | { + kind: "pfx" + data: Uint8Array + password: string + } + +export interface RelayRequestEvents { + progress: { + phase: "upload" | "download" + loaded: number + total?: number + } + stateChange: { + state: + | "preparing" + | "connecting" + | "sending" + | "waiting" + | "receiving" + | "done" + } + authChallenge: { + type: "basic" | "digest" | "oauth2" + params: Record + } + cookieReceived: { + domain: string + name: string + value: string + path?: string + expires?: Date + secure?: boolean + httpOnly?: boolean + sameSite?: "Strict" | "Lax" | "None" + } + error: { + phase: "preparation" | "connection" | "request" | "response" + error: RelayError + } +} + +export type RelayEventEmitter = { + on(event: K, handler: (payload: T[K]) => void): () => void + once( + event: K, + handler: (payload: T[K]) => void + ): () => void + off(event: K, handler: (payload: T[K]) => void): void +} + +// NOTE: RelayCapabilities and their corresponding objects being two separate types +// even with sometimes identical contents is intentional. +export type MethodCapability = + | "GET" + | "POST" + | "PUT" + | "DELETE" + | "PATCH" + | "HEAD" + | "OPTIONS" + | "CONNECT" + | "TRACE" + +export type HeaderCapability = "stringvalue" | "arrayvalue" | "multivalue" + +export type ContentCapability = + | "text" + | "json" + | "xml" + | "form" + | "binary" + | "multipart" + | "urlencoded" + | "stream" + | "compression" + +export type AuthCapability = + | "none" + | "basic" + | "bearer" + | "digest" + | "oauth2" + | "apikey" + | "aws" + | "mtls" + +export type SecurityCapability = + | "clientcertificates" + | "cacertificates" + | "certificatevalidation" + | "hostverification" + | "peerverification" + +export type ProxyCapability = + | "http" + | "https" + | "socks" + | "authentication" + | "certificates" + +export type AdvancedCapability = + | "retry" + | "redirects" + | "timeout" + | "cookies" + | "keepalive" + | "tcpoptions" + | "ipv6" + | "http2" + | "http3" + | "localaccess" + +export interface RelayCapabilities { + method: Set + header: Set + content: Set + auth: Set + security: Set + proxy: Set + advanced: Set +} + +export type UnsupportedFeatureError = { + kind: "unsupported_feature" + feature: string + message: string + relay: string + alternatives?: Array<{ + relay: string + description: string + }> +} + +export type RelayError = + | UnsupportedFeatureError + | { kind: "network"; message: string; cause?: unknown } + | { kind: "timeout"; message: string; phase?: "connect" | "tls" | "response" } + | { kind: "certificate"; message: string; cause?: unknown } + | { kind: "auth"; message: string; cause?: unknown } + | { kind: "proxy"; message: string; cause?: unknown } + | { kind: "parse"; message: string; cause?: unknown } + | { kind: "version"; message: string; cause?: unknown } + | { kind: "abort"; message: string } + +export interface RelayRequest { + id: number + url: string + method: Method + version: Version + headers?: Record + params?: Record + content?: ContentType + auth?: AuthType + + security?: { + certificates?: { + client?: CertificateType + ca?: Array + } + verifyHost?: boolean + verifyPeer?: boolean + } + + proxy?: { + url: string + auth?: { + username: string + password: string + } + certificates?: { + ca?: Uint8Array[] + client?: CertificateType + } + } + + meta?: { + timing?: { + connect?: number + request?: number + tls?: number + } + retry?: { + count: number + delay: number + } + options?: { + timeout?: number + followRedirects?: boolean + maxRedirects?: number + decompress?: boolean + cookies?: boolean + keepAlive?: boolean + tcpNoDelay?: boolean + ipVersion?: "v4" | "v6" | "any" + } + http2?: { + settings: Record + pushPromise: boolean + } + http3?: { + quicConfig: Record + } + } +} + +export interface RelayResponse { + id: number + status: StatusCode + statusText: string + version: Version + headers: Record + cookies?: Array<{ + name: string + value: string + domain?: string + path?: string + expires?: Date + secure?: boolean + httpOnly?: boolean + sameSite?: "Strict" | "Lax" | "None" + }> + body: RelayResponseBody + + meta: { + timing: { + start: number + end: number + phases?: { + dns?: number + connect?: number + tls?: number + send?: number + wait?: number + receive?: number + } + } + size: { + headers: number + body: number + total: number + } + tls?: { + version: string + cipher: string + certificates?: Array<{ + subject: string + issuer: string + validFrom: string + validTo: string + }> + } + http2?: { + settings: Record + pushPromise: boolean + } + http3?: { + quicConfig: Record + } + redirects?: Array<{ + url: string + status: StatusCode + version: Version + headers: Record + }> + } +} + +export interface RelayV1 { + readonly id: string + readonly capabilities: RelayCapabilities + + canHandle(request: RelayRequest): E.Either + + execute(request: RelayRequest): { + cancel: () => Promise + emitter: RelayEventEmitter + response: Promise> + } +} + +export const hasCapability = ( + capabilities: Set, + capability: T +): boolean => capabilities.has(capability) + +export function findSuitableRelay( + request: RelayRequest, + relays: RelayV1[] +): E.Either { + for (const relay of relays) { + const result = relay.canHandle(request) + if (E.isRight(result)) { + return E.right(relay) + } + } + + const errors = relays + .map((i) => i.canHandle(request)) + .filter(E.isLeft) + .map((e) => e.left) + + return E.left(errors[0]) +} + +export const body = { + body: ( + body: ArrayBuffer | Uint8Array, + contentType?: MediaType | string + ): RelayResponseBody => ({ + body: new Uint8Array(body), + mediaType: + typeof contentType === "string" + ? (Object.values(MediaType).find((t) => contentType.includes(t)) ?? + MediaType.APPLICATION_OCTET) + : (contentType ?? MediaType.APPLICATION_OCTET), + }), +} + +export const transform = { + text: (content: string): string => content, + json: (content: T): T => content, + xml: (content: string): string => content, + form: (content: FormData): FormData => content, + binary: (content: Uint8Array): Uint8Array => content, + multipart: (content: FormData): FormData => content, + stream: (content: ReadableStream): ReadableStream => content, + urlencoded: (arg: string | Record): string => + pipe( + arg, + (input) => (typeof input === "string" ? O.some(input) : O.none), + O.getOrElse(() => { + const params = new URLSearchParams() + const obj = arg as Record + + Object.entries(obj) + .filter(([_, value]) => value !== undefined && value !== null) + .forEach(([key, value]) => params.append(key, value.toString())) + + return params.toString() + }) + ), +} + +/** + * Content factory functions for creating standardized HTTP request content. + * + * The `kind` field determines how content is processed, basically + * - Web (Axios + JS): `kind` is ignored, relying on Axios' auto-detect + * - Desktop (`relay` + Rust): `kind` routes to processing `fn` in `relay` + * + * NOTE: `mediaType` field sets the HTTP `Content-Type` header in both. + * + * There are a bunch of reasons for separating routing and `Content-Type`, + * see `relay` code for more info. + * Essentially this allows for flexible scenarios like: + * - Sending pre-stringified JSON as text to avoid double-encoding + * - Using custom vendor media types with standard processing + * - Future processing evolution without breaking HTTP contracts + */ +export const content = { + /** + * Creates text content. Useful for: + * - Plain text + * - Pre-stringified JSON (to avoid encoding escapes) + * - Custom text formats + */ + text: (content: string, mediaType?: MediaType | string): ContentType => ({ + kind: "text", + content: transform.text(content), + mediaType: mediaType ?? MediaType.TEXT_PLAIN, + }), + + /** + * Creates JSON content with automatic serialization. + * Note: If you already have a JSON string, consider using `text()` + * with `APPLICATION_JSON` mediaType to avoid double-encoding. + */ + json: (content: T, mediaType?: MediaType | string): ContentType => ({ + kind: "json", + content: transform.json(content), + mediaType: mediaType ?? MediaType.APPLICATION_JSON, + }), + + /** + * Creates XML content. Currently processed as text. + */ + xml: (content: string, mediaType?: MediaType | string): ContentType => ({ + kind: "xml", + content: transform.xml(content), + mediaType: mediaType ?? MediaType.APPLICATION_XML, + }), + + /** + * Creates form-encoded content from FormData. + */ + form: (content: FormData, mediaType?: MediaType | string): ContentType => ({ + kind: "form", + content: transform.form(content), + mediaType: mediaType ?? MediaType.APPLICATION_FORM, + }), + + /** + * Creates binary content. Supports any binary format. + */ + binary: ( + content: Uint8Array, + mediaType: MediaType | string = MediaType.APPLICATION_OCTET, + filename?: string + ): ContentType => ({ + kind: "binary", + content: transform.binary(content), + mediaType, + filename, + }), + + /** + * Creates multipart form content with file upload support. + */ + multipart: ( + content: FormData, + mediaType?: MediaType | string + ): ContentType => ({ + kind: "multipart", + content: transform.multipart(content), + mediaType: mediaType ?? MediaType.MULTIPART_FORM, + }), + + /** + * Creates URL-encoded content from string or object. + */ + urlencoded: ( + content: string | Record, + mediaType?: MediaType | string + ): ContentType => ({ + kind: "urlencoded", + content: transform.urlencoded(content), + mediaType: mediaType ?? MediaType.APPLICATION_FORM, + }), + + /** + * Creates streaming content for large payloads. + */ + stream: ( + content: ReadableStream, + mediaType: MediaType | string + ): ContentType => ({ + kind: "stream", + content: transform.stream(content), + mediaType, + }), + + // TODO: Raw content type for pass-through scenarios: + // raw: (content: string, mediaType: MediaType | string): ContentType => ({ + // kind: "raw", + // content, + // mediaType + // }) +} + +/** + * Executable usage examples for content factory functions + * usage / patterns / executable guarantees. + * + * These examples show API usage patterns but also act as compile-time + * guarantees that the API works as documented. If the content factory functions + * change in breaking ways, these examples will fail to type-check, + * these aren't exactly docs nor tests but a mix, + * and these also prevent documentations drift. + * + * Pattern borrowed from Rust's documentation tests where executable code examples are + * embedded alongside API definitions. + * See: https://doc.rust-lang.org/rustdoc/write-documentation/documentation-tests.html + * + * Since TypeScript lacks built-in doc-tests, this provides somewhat similar + * guarantees, essentially serving as + * - discoverable docs that devs can copy-paste or import, and also + * - type-checked contracts so these cannot become outdated since they're actual + * executable code validated at compile time. + */ +export const examples = { + // Avoid double-encoding of pre-stringified JSON + preStringifiedJson: content.text( + '{"message": "Hello \\"world\\"", "path": "C:\\\\Users\\\\file.txt"}', + MediaType.APPLICATION_JSON + ), + + // Vendor-specific JSON formats + jsonApi: content.json( + { data: { type: "users", id: "1" } }, + "application/vnd.api+json" + ), + + // Custom XML schema + soapXml: content.xml( + "...", + "application/soap+xml" + ), + + // Custom binary format + customBinary: content.binary( + new Uint8Array([0x89, 0x50, 0x4e, 0x47]), + "image/png" + ), + + // Backwards compatible - uses defaults + standardJson: content.json({ name: "John" }), + standardText: content.text("Hello world"), +} + +/** + * Helper function to convert standard `FormData` to array of arrays `[string, FormDataValue[]][]` + * + * This implementation uses a Map to maintain insertion order of form fields, + * required for certain multipart/form-data requests where field order matters. + * + * JavaScript Maps maintain insertion order (ECMAScript 2015+) unlike plain objects + * before ES2015 ("own properties") where property enumeration order was not guaranteed, + * See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map#description + * > Although the keys of an ordinary Object are ordered now, + * > this was not always the case, and the order is complex. + * > As a result, it's best not to rely on property order. + * + * This preserves the original field order as per RFC 7578 section 5.2. + * See: https://datatracker.ietf.org/doc/html/rfc7578#section-5.2 + * > Form processors given forms with a well-defined ordering SHOULD send back results in order. + */ +const makeFormDataSerializable = async ( + formData: FormData +): Promise<[string, FormDataValue[]][]> => { + const m = new Map() + + // @ts-expect-error: `formData.entries` does exist but isn't visible, + // see `"lib": ["ESNext", "DOM"],` in `tsconfig.json` + for (const [key, value] of formData.entries()) { + if (value instanceof File || value instanceof Blob) { + const buffer = await value.arrayBuffer() + const fileEntry: FormDataValue = { + kind: "file", + filename: value instanceof File ? value.name : "unknown", + contentType: value.type || "application/octet-stream", + data: new Uint8Array(buffer), + } + + if (m.has(key)) { + m.get(key)!.push(fileEntry) + } else { + m.set(key, [fileEntry]) + } + } else { + const textEntry: FormDataValue = { + kind: "text", + value: value.toString(), + } + + if (m.has(key)) { + m.get(key)!.push(textEntry) + } else { + m.set(key, [textEntry]) + } + } + } + + return Array.from(m.entries()) +} + +// Helper function to adapt a relay request to work with the plugin +export const relayRequestToNativeAdapter = async ( + request: RelayRequest +): Promise => { + const adaptedRequest = { ...request } + + if ( + adaptedRequest.content?.kind === "multipart" && + adaptedRequest.content.content instanceof FormData + ) { + const serializableFormData = await makeFormDataSerializable( + adaptedRequest.content.content + ) + + adaptedRequest.content = { + ...adaptedRequest.content, + // Replace with the converted form data + // SAFETY: Type assertion is necessary here because the plugin system expects + // types similar to Map instead of FormData. + // Then convert the `Map` to simpler nested `Array` of `Array` structure for better compatibility + // `Maps` it seems like are serialized differently across platforms and serialization libraries, + // while `Array` of `Array` tend to maintain more consistent behavior by the sheer ubiquity of it. + // @ts-expect-error: This is intentional to work around SuperJSON serialization + content: serializableFormData, + } + } + + return adaptedRequest as Request +} + +export const v1: VersionedAPI = { + version: { major: 1, minor: 0, patch: 0 }, + api: { + id: "default", + capabilities: { + method: new Set(), + header: new Set(), + content: new Set(), + auth: new Set(), + security: new Set(), + proxy: new Set(), + advanced: new Set(), + }, + canHandle: () => + E.left({ + kind: "unsupported_feature", + feature: "execution", + message: "Default relay cannot handle requests", + relay: "default", + }), + execute: () => ({ + cancel: async () => {}, + emitter: { + on: () => () => {}, + once: () => () => {}, + off: () => {}, + }, + response: Promise.resolve( + E.left({ + kind: "version", + message: "Not implemented", + }) + ), + }), + }, +} diff --git a/packages/hoppscotch-kernel/src/store/impl/desktop/index.ts b/packages/hoppscotch-kernel/src/store/impl/desktop/index.ts new file mode 100644 index 0000000..e3e28dc --- /dev/null +++ b/packages/hoppscotch-kernel/src/store/impl/desktop/index.ts @@ -0,0 +1,5 @@ +import { implementation as storeV1 } from "./v/1" + +export const STORE_IMPLS = { + v1: storeV1, +} as const diff --git a/packages/hoppscotch-kernel/src/store/impl/desktop/v/1.ts b/packages/hoppscotch-kernel/src/store/impl/desktop/v/1.ts new file mode 100644 index 0000000..bc6c61b --- /dev/null +++ b/packages/hoppscotch-kernel/src/store/impl/desktop/v/1.ts @@ -0,0 +1,388 @@ +import * as E from "fp-ts/Either" + +import { Store } from "@tauri-apps/plugin-store" + +import { VersionedAPI } from "@type/versioning" +import { + StoreV1, + StoreError, + StoreEvents, + StorageOptions, + StoredData, + StoredDataSchema, + StoreEventEmitter, +} from "@store/v/1" + +type NamespacedData = Record> + +class TauriStoreManager { + private static instances: Map = new Map() + private store: Store | null = null + private listeners = new Map< + string, + Set<(payload: StoreEvents["change"]) => void> + >() + private data: NamespacedData = {} + private storePath: string + + private constructor(storePath: string) { + this.storePath = storePath + } + + static new(storePath: string): TauriStoreManager { + if (TauriStoreManager.instances.has(storePath)) { + return TauriStoreManager.instances.get(storePath)! + } + + const instance = new TauriStoreManager(storePath) + TauriStoreManager.instances.set(storePath, instance) + return instance + } + + static async closeAll(): Promise { + const closePromises = Array.from(TauriStoreManager.instances.values()).map( + (instance) => instance.close() + ) + await Promise.all(closePromises) + TauriStoreManager.instances.clear() + } + + static async closeStore(storePath: string): Promise { + const instance = TauriStoreManager.instances.get(storePath) + if (instance) { + await instance.close() + TauriStoreManager.instances.delete(storePath) + } + } + + async init(): Promise { + if (!this.store) { + this.store = await Store.load(this.storePath) + const loadedData = await this.store.get("data") + this.data = loadedData ?? {} + + this.store.onChange((_, value: NamespacedData | undefined) => { + if (value) { + this.data = value + this.notifyListeners() + } + }) + } + } + + private notifyListeners(): void { + for (const [key, listeners] of this.listeners.entries()) { + const [namespace, dataKey] = key.split(":") + const value = this.data[namespace]?.[dataKey] + listeners.forEach((listener) => + listener({ + namespace, + key: dataKey, + value: value?.data, + }) + ) + } + } + + async set(namespace: string, key: string, value: StoredData): Promise { + if (!this.store) throw new Error("Store not initialized") + + const validated = StoredDataSchema.parse(value) + this.data[namespace] = this.data[namespace] || {} + this.data[namespace][key] = validated + await this.store.set("data", this.data) + await this.store.save() + } + + async getRaw( + namespace: string, + key: string + ): Promise { + const rawValue = this.data[namespace]?.[key] + if (!rawValue) return undefined + + const validated = StoredDataSchema.parse(rawValue) + return validated + } + + async get(namespace: string, key: string): Promise { + const storedData = await this.getRaw(namespace, key) + return storedData?.data as T | undefined + } + + async has(namespace: string, key: string): Promise { + return !!this.data[namespace]?.[key] + } + + async delete(namespace: string, key: string): Promise { + if (!this.store) throw new Error("Store not initialized") + + if (this.data[namespace]?.[key]) { + delete this.data[namespace][key] + if (Object.keys(this.data[namespace]).length === 0) { + delete this.data[namespace] + } + await this.store.set("data", this.data) + await this.store.save() + return true + } + return false + } + + async clear(namespace?: string): Promise { + if (!this.store) throw new Error("Store not initialized") + + if (namespace) { + delete this.data[namespace] + } else { + this.data = {} + } + await this.store.set("data", this.data) + await this.store.save() + } + + async listNamespaces(): Promise { + return Object.keys(this.data) + } + + async listKeys(namespace: string): Promise { + return Object.keys(this.data[namespace] || {}) + } + + async watch( + namespace: string, + key: string + ): Promise> { + const watchKey = `${namespace}:${key}` + return { + on: ( + event: K, + handler: (payload: StoreEvents[K]) => void + ) => { + if (event !== "change") return () => {} + + if (!this.listeners.has(watchKey)) { + this.listeners.set(watchKey, new Set()) + } + this.listeners + .get(watchKey)! + .add(handler as (payload: StoreEvents["change"]) => void) + return () => + this.listeners + .get(watchKey) + ?.delete(handler as (payload: StoreEvents["change"]) => void) + }, + once: ( + event: K, + handler: (payload: StoreEvents[K]) => void + ) => { + if (event !== "change") return () => {} + + const wrapper = (value: StoreEvents["change"]) => { + handler(value as StoreEvents[K]) + this.listeners.get(watchKey)?.delete(wrapper) + } + + if (!this.listeners.has(watchKey)) { + this.listeners.set(watchKey, new Set()) + } + this.listeners.get(watchKey)!.add(wrapper) + return () => this.listeners.get(watchKey)?.delete(wrapper) + }, + off: ( + event: K, + handler: (payload: StoreEvents[K]) => void + ) => { + if (event === "change") { + this.listeners + .get(watchKey) + ?.delete(handler as (payload: StoreEvents["change"]) => void) + } + }, + } + } + + async close(): Promise { + if (this.store) { + await this.store.close() + this.store = null + this.data = {} + this.listeners.clear() + TauriStoreManager.instances.delete(this.storePath) + } + } +} + +export const implementation: VersionedAPI = { + version: { major: 1, minor: 0, patch: 0 }, + api: { + id: "tauri-store", + capabilities: new Set([ + "permanent", + "structured", + "watch", + "namespace", + "secure", + ]), + + async init(storePath: string) { + try { + const manager = TauriStoreManager.new(storePath) + await manager.init() + return E.right(undefined) + } catch (error) { + return E.left({ + kind: "storage", + message: error instanceof Error ? error.message : "Unknown error", + cause: error, + }) + } + }, + + async set( + storePath: string, + namespace: string, + key: string, + value: unknown, + options?: StorageOptions + ): Promise> { + try { + const manager = TauriStoreManager.new(storePath) + const existingData = await manager.getRaw(namespace, key) + const createdAt = + existingData?.metadata.createdAt || new Date().toISOString() + const updatedAt = new Date().toISOString() + + const storedData: StoredData = { + schemaVersion: 1, + metadata: { + createdAt, + updatedAt, + namespace, + encrypted: options?.encrypt, + compressed: options?.compress, + ttl: options?.ttl, + }, + data: value, + } + + await manager.set(namespace, key, storedData) + return E.right(undefined) + } catch (error) { + return E.left({ + kind: "storage", + message: error instanceof Error ? error.message : "Unknown error", + cause: error, + }) + } + }, + + async get( + storePath: string, + namespace: string, + key: string + ): Promise> { + try { + const manager = TauriStoreManager.new(storePath) + return E.right(await manager.get(namespace, key)) + } catch (error) { + return E.left({ + kind: "storage", + message: error instanceof Error ? error.message : "Unknown error", + cause: error, + }) + } + }, + + async has( + storePath: string, + namespace: string, + key: string + ): Promise> { + try { + const manager = TauriStoreManager.new(storePath) + return E.right(await manager.has(namespace, key)) + } catch (error) { + return E.left({ + kind: "storage", + message: error instanceof Error ? error.message : "Unknown error", + cause: error, + }) + } + }, + + async remove( + storePath: string, + namespace: string, + key: string + ): Promise> { + try { + const manager = TauriStoreManager.new(storePath) + return E.right(await manager.delete(namespace, key)) + } catch (error) { + return E.left({ + kind: "storage", + message: error instanceof Error ? error.message : "Unknown error", + cause: error, + }) + } + }, + + async clear( + storePath: string, + namespace?: string + ): Promise> { + try { + const manager = TauriStoreManager.new(storePath) + await manager.clear(namespace) + return E.right(undefined) + } catch (error) { + return E.left({ + kind: "storage", + message: error instanceof Error ? error.message : "Unknown error", + cause: error, + }) + } + }, + + async listNamespaces( + storePath: string + ): Promise> { + try { + const manager = TauriStoreManager.new(storePath) + return E.right(await manager.listNamespaces()) + } catch (error) { + return E.left({ + kind: "storage", + message: error instanceof Error ? error.message : "Unknown error", + cause: error, + }) + } + }, + + async listKeys( + storePath: string, + namespace: string + ): Promise> { + try { + const manager = TauriStoreManager.new(storePath) + return E.right(await manager.listKeys(namespace)) + } catch (error) { + return E.left({ + kind: "storage", + message: error instanceof Error ? error.message : "Unknown error", + cause: error, + }) + } + }, + + async watch( + storePath: string, + namespace: string, + key: string + ): Promise> { + const manager = TauriStoreManager.new(storePath) + return manager.watch(namespace, key) + }, + }, +} diff --git a/packages/hoppscotch-kernel/src/store/impl/web/index.ts b/packages/hoppscotch-kernel/src/store/impl/web/index.ts new file mode 100644 index 0000000..e3e28dc --- /dev/null +++ b/packages/hoppscotch-kernel/src/store/impl/web/index.ts @@ -0,0 +1,5 @@ +import { implementation as storeV1 } from "./v/1" + +export const STORE_IMPLS = { + v1: storeV1, +} as const diff --git a/packages/hoppscotch-kernel/src/store/impl/web/v/1.ts b/packages/hoppscotch-kernel/src/store/impl/web/v/1.ts new file mode 100644 index 0000000..08e3431 --- /dev/null +++ b/packages/hoppscotch-kernel/src/store/impl/web/v/1.ts @@ -0,0 +1,284 @@ +import * as E from "fp-ts/Either" +import superjson from "superjson" + +import type { VersionedAPI } from "@type/versioning" +import { + StoreV1, + StoredData, + StoredDataSchema, + StoreEvents, + StoreEventEmitter, +} from "@store/v/1" + +class BrowserStoreManager { + private static instance: BrowserStoreManager + private listeners = new Map< + string, + Set<(payload: StoreEvents["change"]) => void> + >() + + private constructor() {} + + static new(): BrowserStoreManager { + if (!BrowserStoreManager.instance) { + BrowserStoreManager.instance = new BrowserStoreManager() + } + return BrowserStoreManager.instance + } + + private getFullKey(namespace: string, key: string): string { + return `${namespace}:${key}` + } + + private notifyListeners(namespace: string, key: string, value?: unknown) { + const fullKey = this.getFullKey(namespace, key) + const listeners = this.listeners.get(fullKey) || new Set() + listeners.forEach((listener) => listener({ namespace, key, value })) + } + + async set(namespace: string, key: string, value: StoredData): Promise { + const validated = StoredDataSchema.parse(value) + localStorage.setItem( + this.getFullKey(namespace, key), + superjson.stringify(validated) + ) + this.notifyListeners(namespace, key, validated.data) + } + + async getRaw( + namespace: string, + key: string + ): Promise { + const rawValue = localStorage.getItem(this.getFullKey(namespace, key)) + if (!rawValue) return undefined + + const parsed = superjson.parse(rawValue) + const validated = StoredDataSchema.parse(parsed) + return validated + } + + async get(namespace: string, key: string): Promise { + const storedData = await this.getRaw(namespace, key) + return storedData?.data as T + } + + async has(namespace: string, key: string): Promise { + return localStorage.getItem(this.getFullKey(namespace, key)) !== null + } + + async delete(namespace: string, key: string): Promise { + const exists = await this.has(namespace, key) + if (exists) { + localStorage.removeItem(this.getFullKey(namespace, key)) + this.notifyListeners(namespace, key, undefined) + } + return exists + } + + async clear(namespace?: string): Promise { + if (namespace) { + const keysToRemove = Object.keys(localStorage).filter((key) => + key.startsWith(`${namespace}:`) + ) + keysToRemove.forEach((key) => localStorage.removeItem(key)) + } else { + localStorage.clear() + } + this.listeners.clear() + } + + async listNamespaces(): Promise { + const namespaces = new Set() + Object.keys(localStorage).forEach((key) => { + const [namespace] = key.split(":") + namespaces.add(namespace) + }) + return Array.from(namespaces) + } + + async listKeys(namespace: string): Promise { + return Object.keys(localStorage) + .filter((key) => key.startsWith(`${namespace}:`)) + .map((key) => key.replace(`${namespace}:`, "")) + } + + async watch( + namespace: string, + key: string + ): Promise> { + const fullKey = this.getFullKey(namespace, key) + return { + on: (event, handler) => { + if (event !== "change") return () => {} + if (!this.listeners.has(fullKey)) { + this.listeners.set(fullKey, new Set()) + } + this.listeners + .get(fullKey)! + .add(handler as (payload: StoreEvents["change"]) => void) + return () => + this.listeners + .get(fullKey) + ?.delete(handler as (payload: StoreEvents["change"]) => void) + }, + once: (event, handler) => { + if (event !== "change") return () => {} + const wrapper = (payload: StoreEvents["change"]) => { + handler(payload) + this.listeners.get(fullKey)?.delete(wrapper) + } + if (!this.listeners.has(fullKey)) { + this.listeners.set(fullKey, new Set()) + } + this.listeners.get(fullKey)!.add(wrapper) + return () => this.listeners.get(fullKey)?.delete(wrapper) + }, + off: (event, handler) => { + if (event === "change") { + this.listeners + .get(fullKey) + ?.delete(handler as (payload: StoreEvents["change"]) => void) + } + }, + } + } +} + +export const implementation: VersionedAPI = { + version: { major: 1, minor: 0, patch: 0 }, + api: { + id: "browser-store", + capabilities: new Set(["permanent", "structured", "watch", "namespace"]), + + // `init` and other methods in `web` don't `storePath` + // but having a consistent API where first param of every method + // is the path that filteres to the "realm" makes it easier to reason around + async init(_storePath) { + try { + return E.right(undefined) + } catch (e) { + return E.left({ + kind: "storage", + message: e instanceof Error ? e.message : "Unknown error", + cause: e, + }) + } + }, + + async set(_storePath, namespace, key, value, options) { + try { + const manager = BrowserStoreManager.new() + const existingData = await manager.getRaw(namespace, key) + const createdAt = + existingData?.metadata.createdAt || new Date().toISOString() + const updatedAt = new Date().toISOString() + + const storedData: StoredData = { + schemaVersion: 1, + metadata: { + createdAt, + updatedAt, + namespace, + encrypted: options?.encrypt, + compressed: options?.compress, + ttl: options?.ttl, + }, + data: value, + } + + await manager.set(namespace, key, storedData) + return E.right(undefined) + } catch (e) { + return E.left({ + kind: "storage", + message: e instanceof Error ? e.message : "Unknown error", + cause: e, + }) + } + }, + + async get(_storePath, namespace, key) { + try { + const manager = BrowserStoreManager.new() + return E.right(await manager.get(namespace, key)) + } catch (e) { + return E.left({ + kind: "storage", + message: e instanceof Error ? e.message : "Unknown error", + cause: e, + }) + } + }, + + async has(_storePath, namespace, key) { + try { + const manager = BrowserStoreManager.new() + return E.right(await manager.has(namespace, key)) + } catch (e) { + return E.left({ + kind: "storage", + message: e instanceof Error ? e.message : "Unknown error", + cause: e, + }) + } + }, + + async remove(_storePath, namespace, key) { + try { + const manager = BrowserStoreManager.new() + return E.right(await manager.delete(namespace, key)) + } catch (e) { + return E.left({ + kind: "storage", + message: e instanceof Error ? e.message : "Unknown error", + cause: e, + }) + } + }, + + async clear(_storePath, namespace) { + try { + const manager = BrowserStoreManager.new() + await manager.clear(namespace) + return E.right(undefined) + } catch (e) { + return E.left({ + kind: "storage", + message: e instanceof Error ? e.message : "Unknown error", + cause: e, + }) + } + }, + + async listNamespaces(_storePath) { + try { + const manager = BrowserStoreManager.new() + return E.right(await manager.listNamespaces()) + } catch (e) { + return E.left({ + kind: "storage", + message: e instanceof Error ? e.message : "Unknown error", + cause: e, + }) + } + }, + + async listKeys(_storePath, namespace) { + try { + const manager = BrowserStoreManager.new() + return E.right(await manager.listKeys(namespace)) + } catch (e) { + return E.left({ + kind: "storage", + message: e instanceof Error ? e.message : "Unknown error", + cause: e, + }) + } + }, + + async watch(_storePath, namespace, key) { + const manager = BrowserStoreManager.new() + return manager.watch(namespace, key) + }, + }, +} diff --git a/packages/hoppscotch-kernel/src/store/index.ts b/packages/hoppscotch-kernel/src/store/index.ts new file mode 100644 index 0000000..6ef36c3 --- /dev/null +++ b/packages/hoppscotch-kernel/src/store/index.ts @@ -0,0 +1,9 @@ +import { v1 } from "./v/1" + +export type { StoreV1 } from "./v/1" + +export const VERSIONS = { + v1, +} as const + +export const latest = v1 diff --git a/packages/hoppscotch-kernel/src/store/v/1.ts b/packages/hoppscotch-kernel/src/store/v/1.ts new file mode 100644 index 0000000..c5e40f8 --- /dev/null +++ b/packages/hoppscotch-kernel/src/store/v/1.ts @@ -0,0 +1,192 @@ +import type { VersionedAPI } from "@type/versioning" +import * as E from "fp-ts/Either" +import { z } from "zod" + +export type StoreCapability = + | "permanent" + | "temporary" + | "structured" + | "sync" + | "watch" + | "secure" + | "namespace" + +export type StoreError = + | { kind: "not_found"; message: string } + | { kind: "permission"; message: string } + | { kind: "quota"; message: string } + | { kind: "version"; message: string } + | { kind: "parse"; message: string; cause?: unknown } + | { kind: "storage"; message: string; cause?: unknown } + | { kind: "encrypt"; message: string; cause?: unknown } + +export interface StoreFile { + include?: boolean + + name: string + size: number + + lastModified: string + content: Uint8Array +} + +export interface StorageOptions { + encrypt?: boolean + temporary?: boolean + compress?: boolean + ttl?: number +} + +export interface StoreEvents { + change: { + namespace: string + key: string + value?: unknown + } +} + +export const StoreMetadataSchema = z.object({ + version: z.number(), + lastUpdated: z.string().datetime(), + namespaces: z.record( + z.object({ + name: z.string(), + version: z.number(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + keys: z.array(z.string()), + }) + ), +}) + +export type StoreMetadata = z.infer + +export const StoredDataSchema = z.object({ + schemaVersion: z.number(), + metadata: z.object({ + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + namespace: z.string(), + encrypted: z.boolean().optional(), + compressed: z.boolean().optional(), + ttl: z.number().optional(), + }), + data: z.unknown(), +}) + +export type StoredData = z.infer + +export interface StoreEventEmitter { + on(event: K, handler: (payload: T[K]) => void): () => void + once( + event: K, + handler: (payload: T[K]) => void + ): () => void + off(event: K, handler: (payload: T[K]) => void): void +} + +export interface StoreV1 { + readonly id: string + readonly capabilities: Set + + init(storePath: string): Promise> + set( + storePath: string, + namespace: string, + key: string, + value: unknown, + options?: StorageOptions + ): Promise> + get( + storePath: string, + namespace: string, + key: string + ): Promise> + remove( + storePath: string, + namespace: string, + key: string + ): Promise> + clear( + storePath: string, + namespace?: string + ): Promise> + has( + storePath: string, + namespace: string, + key: string + ): Promise> + listNamespaces(storePath: string): Promise> + listKeys( + storePath: string, + namespace: string + ): Promise> + watch( + storePath: string, + namespace: string, + key: string + ): Promise> +} + +export interface ScopedStore { + isAvailable(): Promise + set(key: string, value: unknown): Promise + get(key: string): Promise + remove(key: string): Promise +} + +export function extend( + store: StoreV1, + storePath: string, + namespace: string +): ScopedStore { + return { + async isAvailable(): Promise { + try { + return E.isRight(await store.init(storePath)) + } catch { + return false + } + }, + + async set(key: string, value: unknown): Promise { + const result = await store.set(storePath, namespace, key, value) + if (E.isLeft(result)) throw new Error(result.left.message) + }, + + async get(key: string): Promise { + const result = await store.get(storePath, namespace, key) + if (E.isLeft(result)) return null + return result.right ?? null + }, + + async remove(key: string): Promise { + const result = await store.remove(storePath, namespace, key) + if (E.isLeft(result)) throw new Error(result.left.message) + }, + } +} + +export const v1: VersionedAPI = { + version: { major: 1, minor: 0, patch: 0 }, + api: { + id: "default", + capabilities: new Set(), + + init: async () => E.left({ kind: "version", message: "Not implemented" }), + set: async () => E.left({ kind: "version", message: "Not implemented" }), + get: async () => E.left({ kind: "version", message: "Not implemented" }), + remove: async () => E.left({ kind: "version", message: "Not implemented" }), + clear: async () => E.left({ kind: "version", message: "Not implemented" }), + has: async () => E.left({ kind: "version", message: "Not implemented" }), + listNamespaces: async () => + E.left({ kind: "version", message: "Not implemented" }), + listKeys: async () => + E.left({ kind: "version", message: "Not implemented" }), + watch: async () => ({ + on: () => () => {}, + once: () => () => {}, + off: () => {}, + }), + }, +} diff --git a/packages/hoppscotch-kernel/src/type/versioning.ts b/packages/hoppscotch-kernel/src/type/versioning.ts new file mode 100644 index 0000000..df886bf --- /dev/null +++ b/packages/hoppscotch-kernel/src/type/versioning.ts @@ -0,0 +1,10 @@ +export type Version = { + major: number + minor: number + patch: number +} + +export type VersionedAPI = { + version: Version + api: T +} diff --git a/packages/hoppscotch-kernel/src/util/capability.ts b/packages/hoppscotch-kernel/src/util/capability.ts new file mode 100644 index 0000000..7585f1c --- /dev/null +++ b/packages/hoppscotch-kernel/src/util/capability.ts @@ -0,0 +1,10 @@ +import type { Version } from "@type/versioning" + +export function checkCapability( + required: Version, + available: Version +): boolean { + if (available.major !== required.major) return false + if (available.minor < required.minor) return false + return true +} diff --git a/packages/hoppscotch-kernel/tsconfig.base.json b/packages/hoppscotch-kernel/tsconfig.base.json new file mode 100644 index 0000000..eeeedd9 --- /dev/null +++ b/packages/hoppscotch-kernel/tsconfig.base.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "esnext", + "lib": ["esnext", "DOM"], + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "strictNullChecks": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "~/*": ["src/*"], + "@io/*": ["src/io/*"], + "@relay/*": ["src/relay/*"], + "@store/*": ["src/store/*"], + "@log/*": ["src/log/*"], + "@type/*": ["src/type/*"], + "@util/*": ["src/util/*"] + } + } +} diff --git a/packages/hoppscotch-kernel/tsconfig.decl.json b/packages/hoppscotch-kernel/tsconfig.decl.json new file mode 100644 index 0000000..4036008 --- /dev/null +++ b/packages/hoppscotch-kernel/tsconfig.decl.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "declarationDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.spec.ts"] +} diff --git a/packages/hoppscotch-kernel/tsconfig.json b/packages/hoppscotch-kernel/tsconfig.json new file mode 100644 index 0000000..540d207 --- /dev/null +++ b/packages/hoppscotch-kernel/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/hoppscotch-kernel/vite.config.d.ts b/packages/hoppscotch-kernel/vite.config.d.ts new file mode 100644 index 0000000..340562a --- /dev/null +++ b/packages/hoppscotch-kernel/vite.config.d.ts @@ -0,0 +1,2 @@ +declare const _default: import("vite").UserConfig; +export default _default; diff --git a/packages/hoppscotch-kernel/vite.config.ts b/packages/hoppscotch-kernel/vite.config.ts new file mode 100644 index 0000000..9721d33 --- /dev/null +++ b/packages/hoppscotch-kernel/vite.config.ts @@ -0,0 +1,37 @@ +import { resolve } from "path" +import { defineConfig } from "vite" + +export default defineConfig({ + build: { + outDir: "./dist", + emptyOutDir: true, + lib: { + entry: { + 'hoppscotch-kernel': resolve(__dirname, 'src/index.ts'), + }, + formats: ['es', 'cjs'] + }, + rollupOptions: { + external: [ + '@tauri-apps/api', + '@tauri-apps/plugin-dialog', + '@tauri-apps/plugin-fs', + '@tauri-apps/plugin-shell', + 'tauri-plugin-relay-api', + 'axios', + 'fp-ts' + ] + } + }, + resolve: { + alias: { + '~': resolve(__dirname, './src'), + '@io': resolve(__dirname, './src/io'), + '@relay': resolve(__dirname, './src/relay'), + '@store': resolve(__dirname, './src/store'), + '@log': resolve(__dirname, './src/log'), + '@type': resolve(__dirname, './src/type'), + '@util': resolve(__dirname, './src/util') + } + } +}) diff --git a/packages/hoppscotch-relay/.gitignore b/packages/hoppscotch-relay/.gitignore new file mode 100644 index 0000000..54bb18f --- /dev/null +++ b/packages/hoppscotch-relay/.gitignore @@ -0,0 +1,10 @@ +/target +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml diff --git a/packages/hoppscotch-relay/Cargo.lock b/packages/hoppscotch-relay/Cargo.lock new file mode 100644 index 0000000..99a6301 --- /dev/null +++ b/packages/hoppscotch-relay/Cargo.lock @@ -0,0 +1,644 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" + +[[package]] +name = "anstyle-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +dependencies = [ + "anstyle", + "windows-sys", +] + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "bytes" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" + +[[package]] +name = "cc" +version = "1.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2e7962b54006dcfcc61cb72735f4d89bb97061dd6a7ed882ec6b8ee53714c6f" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "colorchoice" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" + +[[package]] +name = "curl" +version = "0.4.47" +source = "git+https://github.com/CuriousCorrelation/curl-rust.git#1ec8079cf527b9cf47cc7a48c68b458affdae273" +dependencies = [ + "curl-sys", + "libc", + "openssl-probe", + "openssl-sys", + "socket2", +] + +[[package]] +name = "curl-sys" +version = "0.4.77+curl-8.10.1" +source = "git+https://github.com/CuriousCorrelation/curl-rust.git#1ec8079cf527b9cf47cc7a48c68b458affdae273" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "windows-sys", +] + +[[package]] +name = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "hoppscotch-relay" +version = "0.1.1" +dependencies = [ + "curl", + "env_logger", + "http", + "log", + "openssl", + "openssl-sys", + "serde", + "serde_json", + "thiserror", + "tokio-util", + "url-escape", +] + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "libc" +version = "0.2.161" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" + +[[package]] +name = "libz-sys" +version = "1.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d16453e800a8cf6dd2fc3eb4bc99b786a9b90c663b8559a5b1a041bf89e472" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + +[[package]] +name = "object" +version = "0.36.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "openssl" +version = "0.10.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-src" +version = "300.4.0+3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a709e02f2b4aca747929cca5ed248880847c650233cf8b8cdc48f40aaf4898a6" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + +[[package]] +name = "proc-macro2" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "serde" +version = "1.0.213" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.213" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.132" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83540f837a8afc019423a8edb95b52a8effe46957ee402287f4292fae35be021" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb" +dependencies = [ + "backtrace", + "pin-project-lite", +] + +[[package]] +name = "tokio-util" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "url-escape" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44e0ce4d1246d075ca5abec4b41d33e87a6054d08e2366b63205665e950db218" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/packages/hoppscotch-relay/Cargo.toml b/packages/hoppscotch-relay/Cargo.toml new file mode 100644 index 0000000..4ac0ce9 --- /dev/null +++ b/packages/hoppscotch-relay/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "hoppscotch-relay" +version = "0.1.1" +description = "A HTTP request-response relay used by Hoppscotch Desktop and Hoppscotch Agent for advanced request handling including custom headers, certificates, proxies, and local system integration." +authors = ["CuriousCorrelation"] +edition = "2021" + +[dependencies] +curl = { git = "https://github.com/CuriousCorrelation/curl-rust.git", features = ["ntlm"] } +tokio-util = "0.7.12" +openssl = { version = "0.10.66", features = ["vendored"] } +# NOTE: This crate follows `openssl-sys` from https://github.com/CuriousCorrelation/curl-rust.git +# to avoid issues from version mismatch when compiling from source. +openssl-sys = { version = "0.9.64", features = ["vendored"] } +log = "0.4.22" +env_logger = "0.11.5" +thiserror = "1.0.64" +http = "1.1.0" +url-escape = "0.1.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/packages/hoppscotch-relay/LICENSE.md b/packages/hoppscotch-relay/LICENSE.md new file mode 100644 index 0000000..d91bdaf --- /dev/null +++ b/packages/hoppscotch-relay/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 CuriousCorrelation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/hoppscotch-relay/README.md b/packages/hoppscotch-relay/README.md new file mode 100644 index 0000000..1fc53de --- /dev/null +++ b/packages/hoppscotch-relay/README.md @@ -0,0 +1,201 @@ +# Hoppscotch Relay + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +A high-performance HTTP request-response relay used by Hoppscotch Desktop and Hoppscotch Agent for advanced request handling including CORS override, custom headers, certificates, proxies, and local system integration. It uses a custom fork of curl-rust with static OpenSSL builds for consistent SSL/TLS behavior across different platforms. + +## Features + +- 🚀 **Full HTTP Support**: Handle GET, POST, PUT, DELETE, and other HTTP methods +- 📦 **Multiple Body Types**: + - Raw text/JSON + - URL-encoded forms + - Multipart form data + - File uploads +- 🔒 **Security**: + - Client certificate authentication (PEM & PFX/PKCS#12) + - Custom root certificate bundles + - Certificate validation control +- 🌐 **Proxy Support**: + - HTTP/HTTPS proxy configuration + - Authentication support + - NTLM support +- ⚡ **Performance**: + - Async design + - Request cancellation support + - Progress logs +- 📊 **Detailed Metrics**: + - Response timing + - Status tracking + - Header parsing + +## Installation + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +hoppscotch-relay = "0.1.1" +``` + +## Usage + +### Basic Request + +```rust +use hoppscotch_relay::{RequestWithMetadata, KeyValuePair}; +use tokio_util::sync::CancellationToken; + +// Create a basic GET request +let request = RequestWithMetadata::new( + 1, // Request ID + "GET".to_string(), // Method + "https://api.example.com/data".to_string(), // Endpoint + vec![ // Headers + KeyValuePair { + key: "Accept".to_string(), + value: "application/json".to_string(), + } + ], + None, // Body + true, // Validate certificates + vec![], // Root certificate bundles + None, // Client certificate + None, // Proxy configuration +); + +// Execute the request with cancellation support +let cancel_token = CancellationToken::new(); +let response = hoppscotch_relay::run_request_task(&request, cancel_token)?; + +println!("Status: {} {}", response.status, response.status_text); +println!("Response time: {}ms", response.time_end_ms - response.time_start_ms); +``` + +### POST Request with JSON Body + +```rust +let mut request = RequestWithMetadata::new( + 2, + "POST".to_string(), + "https://api.example.com/users".to_string(), + vec![ + KeyValuePair { + key: "Content-Type".to_string(), + value: "application/json".to_string(), + } + ], + Some(BodyDef::Text(r#"{"name": "John Doe"}"#.to_string())), + true, + vec![], + None, + None, +); + +let response = hoppscotch_relay::run_request_task(&request, CancellationToken::new())?; +``` + +### File Upload with Form Data + +```rust +let form_data = vec![ + FormDataEntry { + key: "file".to_string(), + value: FormDataValue::File { + filename: "document.pdf".to_string(), + data: std::fs::read("document.pdf")?, + mime: "application/pdf".to_string(), + }, + }, + FormDataEntry { + key: "description".to_string(), + value: FormDataValue::Text("Important document".to_string()), + }, +]; + +let mut request = RequestWithMetadata::new( + 3, + "POST".to_string(), + "https://api.example.com/upload".to_string(), + vec![], + Some(BodyDef::FormData(form_data)), + true, + vec![], + None, + None, +); +``` + +### Client Certificate Authentication + +```rust +let client_cert = ClientCertDef::PEMCert { + certificate_pem: std::fs::read("client.crt")?, + key_pem: std::fs::read("client.key")?, +}; + +let mut request = RequestWithMetadata::new( + 4, + "GET".to_string(), + "https://secure-api.example.com".to_string(), + vec![], + None, + true, + vec![], + Some(client_cert), + None, +); +``` + +### Proxy Configuration + +```rust +let proxy_config = ProxyConfig { + url: "http://proxy.example.com:8080".to_string(), +}; + +let mut request = RequestWithMetadata::new( + 5, + "GET".to_string(), + "https://api.example.com".to_string(), + vec![], + None, + true, + vec![], + None, + Some(proxy_config), +); +``` + +## Request Cancellation + +The library supports request cancellation through Tokio's `CancellationToken`: + +```rust +use tokio_util::sync::CancellationToken; + +let cancel_token = CancellationToken::new(); +let cancel_token_clone = cancel_token.clone(); + +// Spawn the request in a separate task +let request_handle = tokio::spawn(async move { + hoppscotch_relay::run_request_task(&request, cancel_token_clone) +}); + +// Cancel the request after 5 seconds +tokio::time::sleep(Duration::from_secs(5)).await; +cancel_token.cancel(); +``` + +## Building from Source + +1. Clone the repository: +```bash +git clone https://github.com/hoppscotch/hoppscotch-relay +cd hoppscotch-relay +``` + +2. Build the project: +```bash +cargo build --release +``` diff --git a/packages/hoppscotch-relay/src/error.rs b/packages/hoppscotch-relay/src/error.rs new file mode 100644 index 0000000..10b0c9a --- /dev/null +++ b/packages/hoppscotch-relay/src/error.rs @@ -0,0 +1,16 @@ +use serde::Serialize; +use thiserror::Error; + +#[derive(Debug, Error, Serialize)] +pub enum RelayError { + #[error("Invalid method")] + InvalidMethod, + #[error("Invalid URL")] + InvalidUrl, + #[error("Invalid headers")] + InvalidHeaders, + #[error("Request run error: {0}")] + RequestRunError(String), +} + +pub type RelayResult = std::result::Result; diff --git a/packages/hoppscotch-relay/src/interop.rs b/packages/hoppscotch-relay/src/interop.rs new file mode 100644 index 0000000..f893dcb --- /dev/null +++ b/packages/hoppscotch-relay/src/interop.rs @@ -0,0 +1,96 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct KeyValuePair { + pub key: String, + pub value: String, +} + +#[derive(Debug, Deserialize)] +pub enum FormDataValue { + Text(String), + File { + filename: String, + data: Vec, + mime: String, + }, +} + +#[derive(Debug, Deserialize)] +pub struct FormDataEntry { + pub key: String, + pub value: FormDataValue, +} + +#[derive(Debug, Deserialize)] +pub enum BodyDef { + Text(String), + URLEncoded(Vec), + FormData(Vec), +} + +#[derive(Debug, Deserialize)] +pub struct RequestWithMetadata { + pub req_id: usize, + pub method: String, + pub endpoint: String, + pub headers: Vec, + pub body: Option, + pub validate_certs: bool, + pub root_cert_bundle_files: Vec>, + pub client_cert: Option, + pub proxy: Option, +} + +impl RequestWithMetadata { + pub fn new( + req_id: usize, + method: String, + endpoint: String, + headers: Vec, + body: Option, + validate_certs: bool, + root_cert_bundle_files: Vec>, + client_cert: Option, + proxy: Option, + ) -> Self { + Self { + req_id, + method, + endpoint, + headers, + body, + validate_certs, + root_cert_bundle_files, + client_cert, + proxy, + } + } +} + +#[derive(Debug, Deserialize)] +pub struct ProxyConfig { + pub url: String, +} + +#[derive(Debug, Deserialize)] +pub enum ClientCertDef { + PEMCert { + certificate_pem: Vec, + key_pem: Vec, + }, + PFXCert { + certificate_pfx: Vec, + password: String, + }, +} + +#[derive(Debug, Serialize)] +pub struct ResponseWithMetadata { + pub status: u16, + pub status_text: String, + pub headers: Vec, + pub data: Vec, + pub time_start_ms: u128, + pub time_end_ms: u128, +} diff --git a/packages/hoppscotch-relay/src/lib.rs b/packages/hoppscotch-relay/src/lib.rs new file mode 100644 index 0000000..621f1af --- /dev/null +++ b/packages/hoppscotch-relay/src/lib.rs @@ -0,0 +1,23 @@ +pub(crate) mod error; +pub(crate) mod interop; +pub(crate) mod relay; +pub(crate) mod util; + +pub use error::{RelayError, RelayResult}; +pub use interop::{RequestWithMetadata, ResponseWithMetadata}; +pub use relay::run_request_task; + +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} diff --git a/packages/hoppscotch-relay/src/relay.rs b/packages/hoppscotch-relay/src/relay.rs new file mode 100644 index 0000000..b1bfdb5 --- /dev/null +++ b/packages/hoppscotch-relay/src/relay.rs @@ -0,0 +1,595 @@ +use curl::easy::{Easy, List}; +use openssl::{pkcs12::Pkcs12, ssl::SslContextBuilder, x509::X509}; +use openssl_sys::SSL_CTX; +use std::time::SystemTime; +use tokio_util::sync::CancellationToken; + +use crate::{ + error::RelayError, + interop::{ + BodyDef, ClientCertDef, FormDataValue, KeyValuePair, RequestWithMetadata, + ResponseWithMetadata, + }, + util::get_status_text, +}; + +pub fn run_request_task( + req: &RequestWithMetadata, + cancel_token: CancellationToken, +) -> Result { + log::info!( + "Starting request task: [Method: {}] [URL: {}] [Validate Certs: {}] [Has Body: {}] [Proxy Enabled: {}]", + req.method, + req.endpoint, + req.validate_certs, + req.body.is_some(), + req.proxy.is_some() + ); + + let mut curl_handle = Easy::new(); + log::debug!("Initialized new curl handle with default settings"); + + match curl_handle.progress(true) { + Ok(_) => log::debug!("Progress tracking enabled for request monitoring"), + Err(err) => { + log::error!( + "Critical failure enabling progress tracking: {}\nError details: {:?}", + err, + err + ); + return Err(RelayError::RequestRunError(err.description().to_string())); + } + } + + match curl_handle.custom_request(&req.method) { + Ok(_) => log::debug!("HTTP method set: {}", req.method), + Err(err) => { + log::error!("Failed to set HTTP method '{}'. Error: {}", req.method, err); + return Err(RelayError::InvalidMethod); + } + } + + match curl_handle.url(&req.endpoint) { + Ok(_) => log::debug!("Target URL configured: {}", req.endpoint), + Err(err) => { + log::error!( + "URL configuration failed for '{}'\nError: {}", + req.endpoint, + err + ); + return Err(RelayError::InvalidUrl); + } + } + + let headers = match get_headers_list(&req) { + Ok(headers) => { + log::debug!("Generated headers list"); + headers + } + Err(err) => { + log::error!("Header generation failed:\nError: {:?}", err); + return Err(err); + } + }; + + match curl_handle.http_headers(headers) { + Ok(_) => log::debug!("Successfully configured request headers"), + Err(err) => { + log::error!("Failed to set HTTP headers: {}", err); + return Err(RelayError::InvalidHeaders); + } + } + + if let Err(err) = apply_body_to_curl_handle(&mut curl_handle, &req) { + log::error!( + "Request body application failed:\nError: {:?}\nContent-Type: {:?}", + err, + req.headers + .iter() + .find(|h| h.key.to_lowercase() == "content-type") + .map(|h| &h.value) + ); + return Err(err); + } + log::debug!("Request body configured successfully"); + + match curl_handle.ssl_verify_peer(req.validate_certs) { + Ok(_) => log::debug!( + "SSL peer verification setting applied: {}", + req.validate_certs + ), + Err(err) => { + log::error!( + "SSL peer verification configuration failed: {}\nRequested setting: {}", + err, + req.validate_certs + ); + return Err(RelayError::RequestRunError(err.description().to_string())); + } + } + + match curl_handle.ssl_verify_host(req.validate_certs) { + Ok(_) => log::debug!( + "SSL host verification setting applied: {}", + req.validate_certs + ), + Err(err) => { + log::error!( + "SSL host verification configuration failed: {}\nRequested setting: {}", + err, + req.validate_certs + ); + return Err(RelayError::RequestRunError(err.description().to_string())); + } + } + + if let Err(err) = apply_client_cert_to_curl_handle(&mut curl_handle, &req) { + log::error!( + "Client certificate configuration failed:\nError: {:?}\nCert Info: {:#?}", + err, + req.client_cert.as_ref() + ); + return Err(err); + } + log::debug!("Client certificate configuration successful"); + + if let Err(err) = apply_proxy_config_to_curl_handle(&mut curl_handle, &req) { + log::error!( + "Proxy configuration failed:\nError: {:?}\nProxy Info: {:?}", + err, + req.proxy.as_ref() + ); + return Err(err); + } + log::debug!("Proxy configuration applied successfully"); + + let mut response_body = Vec::new(); + let mut response_headers = Vec::new(); + let (start_time_ms, end_time_ms) = { + let mut transfer = curl_handle.transfer(); + log::debug!("Created curl transfer object for request execution"); + + match transfer.ssl_ctx_function(|ssl_ctx_ptr| { + let cert_list = match get_x509_certs_from_root_cert_bundle_safe(&req) { + Ok(certs) => { + log::debug!("Found {} certificates in root bundle", certs.len()); + certs + } + Err(e) => { + log::error!("Failed to load certificates from bundle: {:?}", e); + return Ok(()); + } + }; + + if !cert_list.is_empty() { + let mut ssl_ctx_builder = + unsafe { SslContextBuilder::from_ptr(ssl_ctx_ptr as *mut SSL_CTX) }; + + let cert_store = ssl_ctx_builder.cert_store_mut(); + + for (index, cert) in cert_list.iter().enumerate() { + log::debug!( + "Processing certificate {}: Subject: {:?}, Not Before: {:?}, Not After: {:?}", + index, + cert.subject_name(), + cert.not_before(), + cert.not_after() + ); + + if let Err(e) = cert_store.add_cert(cert.clone()) { + log::warn!( + "Failed to add certificate {} to store\nError: {}\nCert details: {:?}", + index, + e, + cert.subject_name() + ); + } else { + log::debug!( + "Successfully added certificate {} to store\nSubject: {:?}", + index, + cert.subject_name() + ); + } + } + + // SAFETY: We need to prevent Rust from dropping the `SslContextBuilder` because + // the underlying `SSL_CTX` pointer is owned and managed by curl, not us. + // From curl docs: "libcurl does not guarantee the lifetime of the passed in + // object once this callback function has returned" + // and `SslContextBuilder` is just a safe wrapper around curl's `SSL_CTX` from + // `openssl_sys::SSL_CTX`. + // If dropped, Rust would try to free the `SSL_CTX` which curl still needs. + // + // This intentional "leak" is safe because: + // - We're only leaking the thin Rust wrapper + // - Curl manages the actual `SSL_CTX` memory + // - Curl will free the `SSL_CTX` during connection cleanup + // + // See: https://curl.se/libcurl/c/CURLOPT_SSL_CTX_FUNCTION.html + std::mem::forget(ssl_ctx_builder); + } + + Ok(()) + }) { + Ok(_) => log::debug!("SSL context function configured successfully"), + Err(err) => { + log::error!("SSL context function setup failed: {}", err); + return Err(RelayError::RequestRunError(err.description().to_string())); + } + } + + match transfer.progress_function(|dltotal, dlnow, ultotal, ulnow| { + let cancelled = cancel_token.is_cancelled(); + if cancelled { + log::warn!( + "Request cancelled by user\nDownload: {}/{} bytes\nUpload: {}/{} bytes", + dlnow, + dltotal, + ulnow, + ultotal + ); + } else { + log::debug!( + "Progress - Download: {}/{} bytes, Upload: {}/{} bytes", + dlnow, + dltotal, + ulnow, + ultotal + ); + } + !cancelled + }) { + Ok(_) => log::debug!("Progress monitoring function configured"), + Err(err) => { + log::error!("Progress function setup failed: {}", err); + return Err(RelayError::RequestRunError(err.description().to_string())); + } + } + + match transfer.header_function(|header| { + let header = String::from_utf8_lossy(header).into_owned(); + if let Some((key, value)) = header.split_once(':') { + log::debug!("Received header: [{}] = [{}]", key.trim(), value.trim()); + response_headers.push(KeyValuePair { + key: key.trim().to_string(), + value: value.trim().to_string(), + }); + } else { + log::debug!("Received header line (no key-value): {}", header.trim()); + } + true + }) { + Ok(_) => log::debug!("Header processing function configured"), + Err(err) => { + log::error!("Header function setup failed: {}", err); + return Err(RelayError::RequestRunError(err.description().to_string())); + } + } + + match transfer.write_function(|data| { + let chunk_size = data.len(); + response_body.extend_from_slice(data); + log::debug!( + "Received response chunk: {} bytes (Total size so far: {} bytes)", + chunk_size, + response_body.len() + ); + Ok(chunk_size) + }) { + Ok(_) => log::debug!("Response body processing function configured"), + Err(err) => { + log::error!("Write function setup failed: {}", err); + return Err(RelayError::RequestRunError(err.description().to_string())); + } + } + + let start_time_ms = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + log::info!( + "Initiating request transfer at timestamp: {}", + start_time_ms + ); + + if let Err(err) = transfer.perform() { + log::error!( + "Request transfer failed:\nError: {}\nTime elapsed: {}ms", + err, + SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + - start_time_ms, + ); + return Err(RelayError::RequestRunError(err.description().to_string())); + } + + let end_time_ms = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + + log::info!( + "Request transfer completed:\nDuration: {}ms", + end_time_ms - start_time_ms, + ); + + (start_time_ms, end_time_ms) + }; + + let response_status = match curl_handle.response_code() { + Ok(status) => { + let status = status as u16; + log::info!( + "Response status code: {} ({})", + status, + get_status_text(status) + ); + status + } + Err(err) => { + log::error!("Failed to retrieve response code: {}", err); + return Err(RelayError::RequestRunError(err.description().to_string())); + } + }; + + let response_status_text = get_status_text(response_status).to_string(); + log::info!( + "Request completed successfully:\nStatus: {} ({})\nDuration: {}ms\n\ + Response size: {} bytes\nHeaders: {} received\nEndpoint: {}", + response_status, + response_status_text, + end_time_ms - start_time_ms, + response_body.len(), + response_headers.len(), + req.endpoint + ); + + Ok(ResponseWithMetadata { + status: response_status, + status_text: response_status_text, + headers: response_headers, + data: response_body, + time_start_ms: start_time_ms, + time_end_ms: end_time_ms, + }) +} + +fn get_headers_list(req: &RequestWithMetadata) -> Result { + let mut result = List::new(); + + for KeyValuePair { key, value } in &req.headers { + result + .append(&format!("{}: {}", key, value)) + .map_err(|err| RelayError::RequestRunError(err.description().to_string()))?; + } + + Ok(result) +} + +fn apply_body_to_curl_handle( + curl_handle: &mut Easy, + req: &RequestWithMetadata, +) -> Result<(), RelayError> { + match &req.body { + Some(BodyDef::Text(text)) => { + curl_handle + .post_fields_copy(text.as_bytes()) + .map_err(|err| { + RelayError::RequestRunError(format!( + "Error while setting body: {}", + err.description() + )) + })?; + } + Some(BodyDef::FormData(entries)) => { + let mut form = curl::easy::Form::new(); + + for entry in entries { + let mut part = form.part(&entry.key); + + match &entry.value { + FormDataValue::Text(data) => { + part.contents(data.as_bytes()); + } + FormDataValue::File { + filename, + data, + mime, + } => { + part.buffer(filename, data.clone()).content_type(mime); + } + }; + + part.add().map_err(|err| { + RelayError::RequestRunError(format!( + "Error while setting body: {}", + err.description() + )) + })?; + } + + curl_handle.httppost(form).map_err(|err| { + RelayError::RequestRunError(format!( + "Error while setting body: {}", + err.description() + )) + })?; + } + Some(BodyDef::URLEncoded(entries)) => { + let data = entries + .iter() + .map(|KeyValuePair { key, value }| { + format!( + "{}={}", + &url_escape::encode_www_form_urlencoded(key), + url_escape::encode_www_form_urlencoded(value) + ) + }) + .collect::>() + .join("&"); + + curl_handle + .post_fields_copy(data.as_bytes()) + .map_err(|err| { + RelayError::RequestRunError(format!( + "Error while setting body: {}", + err.description() + )) + })?; + } + None => {} + }; + + Ok(()) +} + +fn apply_client_cert_to_curl_handle( + handle: &mut Easy, + req: &RequestWithMetadata, +) -> Result<(), RelayError> { + match &req.client_cert { + Some(ClientCertDef::PEMCert { + certificate_pem, + key_pem, + }) => { + handle.ssl_cert_type("PEM").map_err(|err| { + RelayError::RequestRunError(format!( + "Failed setting PEM Cert Type: {}", + err.description() + )) + })?; + + handle.ssl_cert_blob(certificate_pem).map_err(|err| { + RelayError::RequestRunError(format!( + "Failed setting PEM Cert Blob: {}", + err.description() + )) + })?; + + handle.ssl_key_type("PEM").map_err(|err| { + RelayError::RequestRunError(format!( + "Failed setting PEM key type: {}", + err.description() + )) + })?; + + handle.ssl_key_blob(key_pem).map_err(|err| { + RelayError::RequestRunError(format!( + "Failed setting PEM Cert blob: {}", + err.description() + )) + })?; + } + Some(ClientCertDef::PFXCert { + certificate_pfx, + password, + }) => { + let pkcs12 = Pkcs12::from_der(&certificate_pfx).map_err(|err| { + RelayError::RequestRunError(format!( + "Failed to parse PFX certificate from DER: {}", + err + )) + })?; + + let parsed = pkcs12.parse2(password).map_err(|err| { + RelayError::RequestRunError(format!( + "Failed to parse PFX certificate with provided password: {}", + err + )) + })?; + + if let (Some(cert), Some(key)) = (parsed.cert, parsed.pkey) { + let certificate_pem = cert.to_pem().map_err(|err| { + RelayError::RequestRunError(format!( + "Failed to convert PFX certificate to PEM format: {}", + err + )) + })?; + + let key_pem = key.private_key_to_pem_pkcs8().map_err(|err| { + RelayError::RequestRunError(format!( + "Failed to convert PFX private key to PEM format: {}", + err + )) + })?; + + handle.ssl_cert_type("PEM").map_err(|err| { + RelayError::RequestRunError(format!( + "Failed setting PEM Cert Type for converted PFX: {}", + err.description() + )) + })?; + + handle.ssl_cert_blob(&certificate_pem).map_err(|err| { + RelayError::RequestRunError(format!( + "Failed setting PEM Cert Blob for converted PFX: {}", + err.description() + )) + })?; + + handle.ssl_key_type("PEM").map_err(|err| { + RelayError::RequestRunError(format!( + "Failed setting PEM key type for converted PFX: {}", + err.description() + )) + })?; + + handle.ssl_key_blob(&key_pem).map_err(|err| { + RelayError::RequestRunError(format!( + "Failed setting PEM key blob for converted PFX: {}", + err.description() + )) + })?; + } else { + return Err(RelayError::RequestRunError( + "PFX certificate parsing succeeded, but either cert or private key is missing" + .to_string(), + )); + } + } + None => {} + }; + + Ok(()) +} + +fn get_x509_certs_from_root_cert_bundle_safe( + req: &RequestWithMetadata, +) -> Result, openssl::error::ErrorStack> { + let mut certs = Vec::new(); + + for pem_bundle in &req.root_cert_bundle_files { + match openssl::x509::X509::stack_from_pem(pem_bundle) { + Ok(mut bundle_certs) => certs.append(&mut bundle_certs), + Err(e) => { + log::warn!("Failed to parse certificate bundle: {:?}", e); + } + } + } + + Ok(certs) +} + +fn apply_proxy_config_to_curl_handle( + handle: &mut Easy, + req: &RequestWithMetadata, +) -> Result<(), RelayError> { + if let Some(proxy_config) = &req.proxy { + handle + .proxy_auth(curl::easy::Auth::new().auto(true)) + .map_err(|err| { + RelayError::RequestRunError(format!( + "Failed to set proxy Auth Mode: {}", + err.description() + )) + })?; + + handle.proxy(&proxy_config.url).map_err(|err| { + RelayError::RequestRunError(format!("Failed to set proxy URL: {}", err.description())) + })?; + } + + Ok(()) +} diff --git a/packages/hoppscotch-relay/src/util.rs b/packages/hoppscotch-relay/src/util.rs new file mode 100644 index 0000000..877732c --- /dev/null +++ b/packages/hoppscotch-relay/src/util.rs @@ -0,0 +1,6 @@ +pub fn get_status_text(status: u16) -> &'static str { + http::StatusCode::from_u16(status) + .map(|status| status.canonical_reason()) + .unwrap_or(Some("Unknown Status")) + .unwrap_or("Unknown Status") +} diff --git a/packages/hoppscotch-selfhost-web/.gitignore b/packages/hoppscotch-selfhost-web/.gitignore new file mode 100644 index 0000000..4f088bd --- /dev/null +++ b/packages/hoppscotch-selfhost-web/.gitignore @@ -0,0 +1,34 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Sitemap Generation Artifacts (see vite.config.ts) +.sitemap-gen + +# Backend Code generation +src/api/generated + +# webapp-server +webapp-server/webapp-server +webapp-server/.webapp-server diff --git a/packages/hoppscotch-selfhost-web/Caddyfile b/packages/hoppscotch-selfhost-web/Caddyfile new file mode 100644 index 0000000..cb292cb --- /dev/null +++ b/packages/hoppscotch-selfhost-web/Caddyfile @@ -0,0 +1,10 @@ +{ + admin off + persist_config off +} + +:8080 { + try_files {path} / + root * /site + file_server +} diff --git a/packages/hoppscotch-selfhost-web/Dockerfile b/packages/hoppscotch-selfhost-web/Dockerfile new file mode 100644 index 0000000..b43ae33 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/Dockerfile @@ -0,0 +1,21 @@ +# Initial stage, just build the app +FROM node:lts as builder + +WORKDIR /usr/src/app + +RUN npm i -g pnpm + +COPY . . +RUN pnpm install --force --frozen-lockfile + +WORKDIR /usr/src/app/packages/hoppscotch-selfhost-web/ +RUN pnpm run build + + +# Final stage, take the build artifacts and package it into a static Caddy server +FROM caddy:2-alpine +WORKDIR /site +COPY packages/hoppscotch-selfhost-web/Caddyfile /etc/caddy/Caddyfile +COPY --from=builder /usr/src/app/packages/hoppscotch-selfhost-web/dist/ . + +EXPOSE 8080 diff --git a/packages/hoppscotch-selfhost-web/eslint.config.mjs b/packages/hoppscotch-selfhost-web/eslint.config.mjs new file mode 100644 index 0000000..70df56b --- /dev/null +++ b/packages/hoppscotch-selfhost-web/eslint.config.mjs @@ -0,0 +1,85 @@ +import pluginVue from "eslint-plugin-vue" +import { + defineConfigWithVueTs, + vueTsConfigs, +} from "@vue/eslint-config-typescript" +import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended" +import globals from "globals" + +export default defineConfigWithVueTs( + { + ignores: [ + "static/**", + "src/api/generated/**", + "**/*.d.ts", + "types/**", + "dist/**", + "node_modules/**", + ], + }, + pluginVue.configs["flat/recommended"], + vueTsConfigs.recommended, + eslintPluginPrettierRecommended, + { + files: ["**/*.ts", "**/*.js", "**/*.vue"], + linterOptions: { + reportUnusedDisableDirectives: false, + }, + languageOptions: { + sourceType: "module", + ecmaVersion: "latest", + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + requireConfigFile: false, + ecmaFeatures: { + jsx: false, + }, + }, + }, + rules: { + semi: [2, "never"], + "import/named": "off", + "no-console": "off", + "no-debugger": process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn", + "prettier/prettier": + process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn", + "vue/multi-word-component-names": "off", + "vue/no-side-effects-in-computed-properties": "off", + "import/no-named-as-default": "off", + "import/no-named-as-default-member": "off", + "@typescript-eslint/no-unused-vars": [ + process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/no-unused-expressions": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-function-type": "off", + "import/default": "off", + "no-undef": "off", + "no-restricted-globals": [ + "error", + { + name: "localStorage", + message: + "Do not use 'localStorage' directly. Please use the PersistenceService", + }, + ], + "no-restricted-syntax": [ + "error", + { + selector: "CallExpression[callee.object.property.name='localStorage']", + message: + "Do not use 'localStorage' directly. Please use the PersistenceService", + }, + ], + }, + } +) diff --git a/packages/hoppscotch-selfhost-web/gql-codegen.yml b/packages/hoppscotch-selfhost-web/gql-codegen.yml new file mode 100644 index 0000000..0237c33 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/gql-codegen.yml @@ -0,0 +1,18 @@ +overwrite: true +schema: "../../gql-gen/*.gql" +generates: + src/api/generated/graphql.ts: + documents: "src/**/*.graphql" + plugins: + - add: + content: > + /* eslint-disable */ + // Auto-generated file (DO NOT EDIT!!!), refer gql-codegen.yml + - typescript + - typescript-operations + - typed-document-node + - typescript-urql-graphcache + + src/api/generated/backend-schema.json: + plugins: + - urql-introspection diff --git a/packages/hoppscotch-selfhost-web/index.html b/packages/hoppscotch-selfhost-web/index.html new file mode 100644 index 0000000..6d4ee07 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/index.html @@ -0,0 +1,29 @@ + + + + + Hoppscotch • Open source API development ecosystem + + + + + + +
+ + + + + diff --git a/packages/hoppscotch-selfhost-web/meta.ts b/packages/hoppscotch-selfhost-web/meta.ts new file mode 100644 index 0000000..f7c58dd --- /dev/null +++ b/packages/hoppscotch-selfhost-web/meta.ts @@ -0,0 +1,130 @@ +import { IHTMLTag } from "vite-plugin-html-config" + +export const APP_INFO = { + name: "Hoppscotch", + shortDescription: "Open source API development ecosystem", + description: + "Helps you create requests faster, saving precious time on development.", + keywords: + "hoppscotch, hopp scotch, hoppscotch online, hoppscotch app, postwoman, postwoman chrome, postwoman online, postwoman for mac, postwoman app, postwoman for windows, postwoman google chrome, postwoman chrome app, get postwoman, postwoman web, postwoman android, postwoman app for chrome, postwoman mobile app, postwoman web app, api, request, testing, tool, rest, websocket, sse, graphql, socketio", + app: { + background: "#181818", + lightThemeColor: "#ffffff", + darkThemeColor: "#181818", + }, + social: { + twitter: "@hoppscotch_io", + }, +} as const + +export const META_TAGS = (env: Record): IHTMLTag[] => [ + { + name: "keywords", + content: APP_INFO.keywords, + }, + { + name: "X-UA-Compatible", + content: "IE=edge, chrome=1", + }, + { + name: "name", + content: `${APP_INFO.name} • ${APP_INFO.shortDescription}`, + }, + { + name: "description", + content: APP_INFO.description, + }, + { + name: "image", + content: `${env.VITE_BASE_URL}/banner.png`, + }, + // Open Graph tags + { + name: "og:title", + content: `${APP_INFO.name} • ${APP_INFO.shortDescription}`, + }, + { + name: "og:description", + content: APP_INFO.description, + }, + { + name: "og:image", + content: `${env.VITE_BASE_URL}/banner.png`, + }, + // Twitter tags + { + name: "twitter:card", + content: "summary_large_image", + }, + { + name: "twitter:site", + content: APP_INFO.social.twitter, + }, + { + name: "twitter:creator", + content: APP_INFO.social.twitter, + }, + { + name: "twitter:title", + content: `${APP_INFO.name} • ${APP_INFO.shortDescription}`, + }, + { + name: "twitter:description", + content: APP_INFO.description, + }, + { + name: "twitter:image", + content: `${env.VITE_BASE_URL}/banner.png`, + }, + // Add to homescreen for Chrome on Android. Fallback for PWA (handled by nuxt) + { + name: "application-name", + content: APP_INFO.name, + }, + // Windows phone tile icon + { + name: "msapplication-TileImage", + content: `${env.VITE_BASE_URL}/icon.png`, + }, + { + name: "msapplication-TileColor", + content: APP_INFO.app.background, + }, + { + name: "msapplication-tap-highlight", + content: "no", + }, + // iOS Safari + { + name: "apple-mobile-web-app-title", + content: APP_INFO.name, + }, + { + name: "apple-mobile-web-app-capable", + content: "yes", + }, + { + name: "apple-mobile-web-app-status-bar-style", + content: "black-translucent", + }, + // PWA + { + name: "theme-color", + content: APP_INFO.app.darkThemeColor, + media: "(prefers-color-scheme: dark)", + }, + { + name: "theme-color", + content: APP_INFO.app.lightThemeColor, + media: "(prefers-color-scheme: light)", + }, + { + name: "supported-color-schemes", + content: "light dark", + }, + { + name: "mask-icon", + content: "/icon.png", + color: APP_INFO.app.background, + }, +] diff --git a/packages/hoppscotch-selfhost-web/package.json b/packages/hoppscotch-selfhost-web/package.json new file mode 100644 index 0000000..3cdcec3 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/package.json @@ -0,0 +1,98 @@ +{ + "name": "@hoppscotch/selfhost-web", + "private": true, + "version": "2026.6.0", + "type": "module", + "scripts": { + "dev:vite": "vite", + "dev:gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml dotenv_config_path=\"../../.env\" --watch", + "dev": "pnpm exec npm-run-all -p -l dev:*", + "build": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build", + "preview": "vite preview", + "lint": "eslint src", + "lint:ts": "vue-tsc --noEmit", + "lintfix": "eslint --fix src", + "prod-lint": "cross-env HOPP_LINT_FOR_PROD=true pnpm run lint", + "generate": "pnpm run build", + "do-dev": "pnpm run dev", + "do-build-prod": "pnpm run build", + "do-lint": "pnpm run prod-lint", + "do-typecheck": "pnpm run lint", + "do-lintfix": "pnpm run lintfix", + "gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml dotenv_config_path=\"../../.env\"", + "postinstall": "pnpm run gql-codegen" + }, + "dependencies": { + "@fontsource-variable/inter": "5.2.8", + "@fontsource-variable/material-symbols-rounded": "5.2.45", + "@fontsource-variable/roboto-mono": "5.2.9", + "@hoppscotch/common": "workspace:^", + "@hoppscotch/data": "workspace:^", + "@hoppscotch/kernel": "workspace:^", + "@hoppscotch/plugin-appload": "github:CuriousCorrelation/tauri-plugin-appload#9d4528be4f385bccbe46859631d31aa2ee1ec0b6", + "@hoppscotch/ui": "0.2.6", + "@import-meta-env/unplugin": "0.6.3", + "@tauri-apps/api": "2.1.1", + "@tauri-apps/plugin-dialog": "2.0.1", + "@tauri-apps/plugin-fs": "2.0.2", + "@tauri-apps/plugin-shell": "2.3.3", + "@vueuse/core": "14.3.0", + "axios": "1.18.0", + "buffer": "6.0.3", + "dioc": "3.0.2", + "fp-ts": "2.16.11", + "process": "0.11.10", + "rxjs": "7.8.2", + "stream-browserify": "3.0.0", + "util": "0.12.5", + "verzod": "0.4.0", + "vue": "3.5.38", + "workbox-window": "7.4.1", + "zod": "3.25.32" + }, + "devDependencies": { + "@eslint/eslintrc": "3.3.5", + "@eslint/js": "9.39.2", + "@graphql-codegen/add": "6.0.1", + "@graphql-codegen/cli": "6.3.1", + "@graphql-codegen/typed-document-node": "6.1.8", + "@graphql-codegen/typescript": "5.0.10", + "@graphql-codegen/typescript-operations": "5.1.0", + "@graphql-codegen/typescript-urql-graphcache": "3.1.1", + "@graphql-codegen/urql-introspection": "3.0.1", + "@graphql-typed-document-node/core": "3.2.0", + "@iconify-json/lucide": "1.2.114", + "@intlify/unplugin-vue-i18n": "11.2.4", + "@rushstack/eslint-patch": "1.16.1", + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@vitejs/plugin-legacy": "7.2.1", + "@vitejs/plugin-vue": "6.0.7", + "@vue/eslint-config-typescript": "14.8.0", + "autoprefixer": "10.5.0", + "cross-env": "10.1.0", + "dotenv": "17.4.2", + "eslint": "9.39.2", + "eslint-plugin-prettier": "5.5.6", + "eslint-plugin-vue": "10.9.2", + "globals": "16.5.0", + "npm-run-all": "4.1.5", + "postcss": "8.5.15", + "prettier-plugin-tailwindcss": "0.7.2", + "tailwindcss": "3.4.16", + "typescript": "5.9.3", + "unplugin-fonts": "1.4.0", + "unplugin-icons": "22.5.0", + "unplugin-vue-components": "30.0.0", + "vite": "7.3.2", + "vite-plugin-fonts": "0.7.0", + "vite-plugin-html-config": "2.0.2", + "vite-plugin-inspect": "11.4.1", + "vite-plugin-pages": "0.33.3", + "vite-plugin-pages-sitemap": "1.7.1", + "vite-plugin-pwa": "1.2.0", + "vite-plugin-static-copy": "3.3.0", + "vite-plugin-vue-layouts": "0.11.0", + "vue-tsc": "2.1.6" + } +} diff --git a/packages/hoppscotch-selfhost-web/postcss.config.cjs b/packages/hoppscotch-selfhost-web/postcss.config.cjs new file mode 100644 index 0000000..e305dd9 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/postcss.config.cjs @@ -0,0 +1,8 @@ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +module.exports = config; diff --git a/packages/hoppscotch-selfhost-web/prod_run.mjs b/packages/hoppscotch-selfhost-web/prod_run.mjs new file mode 100755 index 0000000..c50f545 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/prod_run.mjs @@ -0,0 +1,18 @@ +#!/usr/local/bin/node +import { execSync } from "child_process" +import fs from "fs" + +const envFileContent = Object.entries(process.env) + .filter(([env]) => env.startsWith("VITE_")) + .sort(([envA], [envB]) => envA.localeCompare(envB)) + .map( + ([env, val]) => + `${env}=${val.startsWith('"') && val.endsWith('"') ? val : `"${val}"`}` + ) + .join("\n") + +fs.writeFileSync("build.env", envFileContent) + +execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`) + +fs.rmSync("build.env") diff --git a/packages/hoppscotch-selfhost-web/selfhost-web.Caddyfile b/packages/hoppscotch-selfhost-web/selfhost-web.Caddyfile new file mode 100644 index 0000000..59e76d8 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/selfhost-web.Caddyfile @@ -0,0 +1,10 @@ +{ + admin off + persist_config off +} + +:80 :3000 { + try_files {path} / + root * /site/selfhost-web + file_server +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/ClearGlobalEnvironments.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/ClearGlobalEnvironments.graphql new file mode 100644 index 0000000..5a184fc --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/ClearGlobalEnvironments.graphql @@ -0,0 +1,5 @@ +mutation ClearGlobalEnvironments($id: ID!) { + clearGlobalEnvironments(id: $id) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLChildUserCollection.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLChildUserCollection.graphql new file mode 100644 index 0000000..736781a --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLChildUserCollection.graphql @@ -0,0 +1,14 @@ +mutation CreateGQLChildUserCollection( + $title: String! + $parentUserCollectionID: ID! + $data: String +) { + createGQLChildUserCollection( + title: $title + parentUserCollectionID: $parentUserCollectionID + data: $data + ) { + id + data + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLRootUserCollection.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLRootUserCollection.graphql new file mode 100644 index 0000000..bc30e96 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLRootUserCollection.graphql @@ -0,0 +1,6 @@ +mutation CreateGQLRootUserCollection($title: String!, $data: String) { + createGQLRootUserCollection(title: $title, data: $data) { + id + data + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLUserRequest.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLUserRequest.graphql new file mode 100644 index 0000000..02423e8 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLUserRequest.graphql @@ -0,0 +1,13 @@ +mutation CreateGQLUserRequest( + $title: String! + $request: String! + $collectionID: ID! +) { + createGQLUserRequest( + title: $title + request: $request + collectionID: $collectionID + ) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTChildUserCollection.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTChildUserCollection.graphql new file mode 100644 index 0000000..eea3cdf --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTChildUserCollection.graphql @@ -0,0 +1,14 @@ +mutation CreateRESTChildUserCollection( + $title: String! + $parentUserCollectionID: ID! + $data: String +) { + createRESTChildUserCollection( + title: $title + parentUserCollectionID: $parentUserCollectionID + data: $data + ) { + id + data + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTRootUserCollection.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTRootUserCollection.graphql new file mode 100644 index 0000000..d0136c4 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTRootUserCollection.graphql @@ -0,0 +1,6 @@ +mutation CreateRESTRootUserCollection($title: String!, $data: String) { + createRESTRootUserCollection(title: $title, data: $data) { + id + data + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTUserRequest.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTUserRequest.graphql new file mode 100644 index 0000000..01d51b0 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTUserRequest.graphql @@ -0,0 +1,13 @@ +mutation CreateRESTUserRequest( + $collectionID: ID! + $title: String! + $request: String! +) { + createRESTUserRequest( + collectionID: $collectionID + title: $title + request: $request + ) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateUserEnvironment.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateUserEnvironment.graphql new file mode 100644 index 0000000..1865106 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateUserEnvironment.graphql @@ -0,0 +1,9 @@ +mutation CreateUserEnvironment($name: String!, $variables: String!) { + createUserEnvironment(name: $name, variables: $variables) { + id + userUid + name + variables + isGlobal + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateUserGlobalEnvironment.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateUserGlobalEnvironment.graphql new file mode 100644 index 0000000..8e7553f --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateUserGlobalEnvironment.graphql @@ -0,0 +1,5 @@ +mutation CreateUserGlobalEnvironment($variables: String!) { + createUserGlobalEnvironment(variables: $variables) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateUserHistory.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateUserHistory.graphql new file mode 100644 index 0000000..ef9da5c --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateUserHistory.graphql @@ -0,0 +1,13 @@ +mutation CreateUserHistory( + $reqData: String! + $resMetadata: String! + $reqType: ReqType! +) { + createUserHistory( + reqData: $reqData + resMetadata: $resMetadata + reqType: $reqType + ) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateUserSettings.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateUserSettings.graphql new file mode 100644 index 0000000..2ada8a1 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateUserSettings.graphql @@ -0,0 +1,5 @@ +mutation CreateUserSettings($properties: String!) { + createUserSettings(properties: $properties) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteAllUserHistory.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteAllUserHistory.graphql new file mode 100644 index 0000000..3e50858 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteAllUserHistory.graphql @@ -0,0 +1,6 @@ +mutation DeleteAllUserHistory($reqType: ReqType!) { + deleteAllUserHistory(reqType: $reqType) { + count + reqType + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteUserCollection.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteUserCollection.graphql new file mode 100644 index 0000000..cafa7b0 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteUserCollection.graphql @@ -0,0 +1,3 @@ +mutation DeleteUserCollection($userCollectionID: ID!) { + deleteUserCollection(userCollectionID: $userCollectionID) +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteUserEnvironments.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteUserEnvironments.graphql new file mode 100644 index 0000000..1489719 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteUserEnvironments.graphql @@ -0,0 +1,3 @@ +mutation DeleteUserEnvironment($id: ID!) { + deleteUserEnvironment(id: $id) +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteUserRequest.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteUserRequest.graphql new file mode 100644 index 0000000..c4855e8 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteUserRequest.graphql @@ -0,0 +1,3 @@ +mutation DeleteUserRequest($requestID: ID!) { + deleteUserRequest(id: $requestID) +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/ImportUserCollectionsFromJSON.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/ImportUserCollectionsFromJSON.graphql new file mode 100644 index 0000000..beddb87 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/ImportUserCollectionsFromJSON.graphql @@ -0,0 +1,14 @@ +mutation ImportUserCollectionsFromJSON( + $jsonString: String! + $reqType: ReqType! + $parentCollectionID: ID +) { + importUserCollectionsFromJSON( + jsonString: $jsonString + reqType: $reqType + parentCollectionID: $parentCollectionID + ) { + exportedCollection + collectionType + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/MoveUserCollection.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/MoveUserCollection.graphql new file mode 100644 index 0000000..ac28012 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/MoveUserCollection.graphql @@ -0,0 +1,8 @@ +mutation MoveUserCollection($destCollectionID: ID, $userCollectionID: ID!) { + moveUserCollection( + destCollectionID: $destCollectionID + userCollectionID: $userCollectionID + ) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/MoveUserRequest.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/MoveUserRequest.graphql new file mode 100644 index 0000000..38f8e6c --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/MoveUserRequest.graphql @@ -0,0 +1,15 @@ +mutation MoveUserRequest( + $sourceCollectionID: ID! + $requestID: ID! + $destinationCollectionID: ID! + $nextRequestID: ID +) { + moveUserRequest( + sourceCollectionID: $sourceCollectionID + requestID: $requestID + destinationCollectionID: $destinationCollectionID + nextRequestID: $nextRequestID + ) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/RemoveRequestFromHistory.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/RemoveRequestFromHistory.graphql new file mode 100644 index 0000000..a0dd3e9 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/RemoveRequestFromHistory.graphql @@ -0,0 +1,5 @@ +mutation RemoveRequestFromHistory($id: ID!) { + removeRequestFromHistory(id: $id) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/RenameUserCollection.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/RenameUserCollection.graphql new file mode 100644 index 0000000..35603ce --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/RenameUserCollection.graphql @@ -0,0 +1,8 @@ +mutation RenameUserCollection($userCollectionID: ID!, $newTitle: String!) { + renameUserCollection( + userCollectionID: $userCollectionID + newTitle: $newTitle + ) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/SortUserCollections.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/SortUserCollections.graphql new file mode 100644 index 0000000..6417fd9 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/SortUserCollections.graphql @@ -0,0 +1,9 @@ +mutation SortUserCollections( + $parentCollectionID: ID + $sortOption: SortOptions! +) { + sortUserCollections( + parentCollectionID: $parentCollectionID + sortOption: $sortOption + ) +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/ToggleHistoryStarStatus.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/ToggleHistoryStarStatus.graphql new file mode 100644 index 0000000..33bf9c1 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/ToggleHistoryStarStatus.graphql @@ -0,0 +1,5 @@ +mutation ToggleHistoryStarStatus($id: ID!) { + toggleHistoryStarStatus(id: $id) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateGQLUserRequest.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateGQLUserRequest.graphql new file mode 100644 index 0000000..253fed7 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateGQLUserRequest.graphql @@ -0,0 +1,5 @@ +mutation UpdateGQLUserRequest($id: ID!, $request: String!, $title: String) { + updateGQLUserRequest(id: $id, request: $request, title: $title) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateRESTUserRequest.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateRESTUserRequest.graphql new file mode 100644 index 0000000..b7899df --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateRESTUserRequest.graphql @@ -0,0 +1,7 @@ +mutation UpdateRESTUserRequest($id: ID!, $title: String!, $request: String!) { + updateRESTUserRequest(id: $id, title: $title, request: $request) { + id + collectionID + request + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserCollection.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserCollection.graphql new file mode 100644 index 0000000..c5e89d2 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserCollection.graphql @@ -0,0 +1,15 @@ +mutation UpdateUserCollection( + $userCollectionID: ID! + $newTitle: String + $data: String +) { + updateUserCollection( + userCollectionID: $userCollectionID + newTitle: $newTitle + data: $data + ) { + id + title + data + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserCollectionOrder.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserCollectionOrder.graphql new file mode 100644 index 0000000..c9e665b --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserCollectionOrder.graphql @@ -0,0 +1,6 @@ +mutation UpdateUserCollectionOrder($collectionID: ID!, $nextCollectionID: ID) { + updateUserCollectionOrder( + collectionID: $collectionID + nextCollectionID: $nextCollectionID + ) +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserDisplayName.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserDisplayName.graphql new file mode 100644 index 0000000..90a9eed --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserDisplayName.graphql @@ -0,0 +1,5 @@ +mutation UpdateUserDisplayName($updatedDisplayName: String!) { + updateDisplayName(updatedDisplayName: $updatedDisplayName) { + displayName + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserEnvironment.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserEnvironment.graphql new file mode 100644 index 0000000..c849a15 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserEnvironment.graphql @@ -0,0 +1,9 @@ +mutation UpdateUserEnvironment($id: ID!, $name: String!, $variables: String!) { + updateUserEnvironment(id: $id, name: $name, variables: $variables) { + id + userUid + name + variables + isGlobal + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserSettings.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserSettings.graphql new file mode 100644 index 0000000..85bb4b0 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserSettings.graphql @@ -0,0 +1,5 @@ +mutation UpdateUserSettings($properties: String!) { + updateUserSettings(properties: $properties) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/queries/CreateUserEnvironment.graphql b/packages/hoppscotch-selfhost-web/src/api/queries/CreateUserEnvironment.graphql new file mode 100644 index 0000000..1865106 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/queries/CreateUserEnvironment.graphql @@ -0,0 +1,9 @@ +mutation CreateUserEnvironment($name: String!, $variables: String!) { + createUserEnvironment(name: $name, variables: $variables) { + id + userUid + name + variables + isGlobal + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/queries/ExportUserCollectionsToJSON.graphql b/packages/hoppscotch-selfhost-web/src/api/queries/ExportUserCollectionsToJSON.graphql new file mode 100644 index 0000000..09f54ad --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/queries/ExportUserCollectionsToJSON.graphql @@ -0,0 +1,12 @@ +query ExportUserCollectionsToJSON( + $collectionID: ID + $collectionType: ReqType! +) { + exportUserCollectionsToJSON( + collectionID: $collectionID + collectionType: $collectionType + ) { + collectionType + exportedCollection + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/queries/GetGlobalEnvironments.graphql b/packages/hoppscotch-selfhost-web/src/api/queries/GetGlobalEnvironments.graphql new file mode 100644 index 0000000..08d3c21 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/queries/GetGlobalEnvironments.graphql @@ -0,0 +1,11 @@ +query GetGlobalEnvironments { + me { + globalEnvironments { + id + isGlobal + name + userUid + variables + } + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/queries/GetProxyAppUrl.graphql b/packages/hoppscotch-selfhost-web/src/api/queries/GetProxyAppUrl.graphql new file mode 100644 index 0000000..3257adc --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/queries/GetProxyAppUrl.graphql @@ -0,0 +1,6 @@ +query GetProxyAppUrl { + proxyAppUrl { + value + name + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/queries/GetRestUserHistory.graphql b/packages/hoppscotch-selfhost-web/src/api/queries/GetRestUserHistory.graphql new file mode 100644 index 0000000..1448615 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/queries/GetRestUserHistory.graphql @@ -0,0 +1,23 @@ +query GetRESTUserHistory { + me { + RESTHistory { + id + userUid + reqType + request + responseMetadata + isStarred + executedOn + } + + GQLHistory { + id + userUid + reqType + request + responseMetadata + isStarred + executedOn + } + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/queries/GetRootGQLUserCollections.graphql b/packages/hoppscotch-selfhost-web/src/api/queries/GetRootGQLUserCollections.graphql new file mode 100644 index 0000000..2eaa1cf --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/queries/GetRootGQLUserCollections.graphql @@ -0,0 +1,15 @@ +query GetGQLRootUserCollections { + # the frontend doesnt paginate right now, so giving take a big enough value to get all collections at once + rootGQLUserCollections(take: 99999) { + id + title + type + data + childrenGQL { + id + title + type + data + } + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/queries/GetSMTPStatus.graphql b/packages/hoppscotch-selfhost-web/src/api/queries/GetSMTPStatus.graphql new file mode 100644 index 0000000..bb8458d --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/queries/GetSMTPStatus.graphql @@ -0,0 +1,3 @@ +query GetSMTPStatus { + isSMTPEnabled +} diff --git a/packages/hoppscotch-selfhost-web/src/api/queries/GetUserEnvironments.graphql b/packages/hoppscotch-selfhost-web/src/api/queries/GetUserEnvironments.graphql new file mode 100644 index 0000000..0bbeddb --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/queries/GetUserEnvironments.graphql @@ -0,0 +1,11 @@ +query GetUserEnvironments { + me { + environments { + id + isGlobal + name + userUid + variables + } + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/queries/GetUserRootCollections.graphql b/packages/hoppscotch-selfhost-web/src/api/queries/GetUserRootCollections.graphql new file mode 100644 index 0000000..9d72c1f --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/queries/GetUserRootCollections.graphql @@ -0,0 +1,15 @@ +query GetUserRootCollections { + # the frontend doesnt paginate right now, so giving take a big enough value to get all collections at once + rootRESTUserCollections(take: 99999) { + id + title + type + data + childrenREST { + id + title + type + data + } + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/queries/GetUserSettings.graphql b/packages/hoppscotch-selfhost-web/src/api/queries/GetUserSettings.graphql new file mode 100644 index 0000000..1ac9455 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/queries/GetUserSettings.graphql @@ -0,0 +1,8 @@ +query GetUserSettings { + me { + settings { + id + properties + } + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/queries/IsUserHistoryEnabled.graphql b/packages/hoppscotch-selfhost-web/src/api/queries/IsUserHistoryEnabled.graphql new file mode 100644 index 0000000..77bd965 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/queries/IsUserHistoryEnabled.graphql @@ -0,0 +1,6 @@ +query IsUserHistoryEnabled { + isUserHistoryEnabled { + name + value + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryStoreStatusChanged.graphql b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryStoreStatusChanged.graphql new file mode 100644 index 0000000..bde1df5 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserHistoryStoreStatusChanged.graphql @@ -0,0 +1,3 @@ +subscription UserHistoryStoreStatusChanged { + infraConfigUpdate(configName: USER_HISTORY_STORE_ENABLED) +} diff --git a/packages/hoppscotch-selfhost-web/src/components/Login.vue b/packages/hoppscotch-selfhost-web/src/components/Login.vue new file mode 100644 index 0000000..6268382 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/components/Login.vue @@ -0,0 +1,112 @@ + + + diff --git a/packages/hoppscotch-selfhost-web/src/kernel/index.ts b/packages/hoppscotch-selfhost-web/src/kernel/index.ts new file mode 100644 index 0000000..add8a56 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/kernel/index.ts @@ -0,0 +1,13 @@ +import { KernelAPI } from "@hoppscotch/kernel" + +export { Io } from "./io" +export { Relay } from "./relay" +export { Store } from "./store" + +export const getModule = ( + name: K +): NonNullable => { + const kernel = window.__KERNEL__ + if (!kernel?.[name]) throw new Error(`Kernel ${name} not initialized`) + return kernel[name] +} diff --git a/packages/hoppscotch-selfhost-web/src/kernel/io.ts b/packages/hoppscotch-selfhost-web/src/kernel/io.ts new file mode 100644 index 0000000..bf55cf8 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/kernel/io.ts @@ -0,0 +1,34 @@ +import type { + SaveFileWithDialogOptions, + OpenExternalLinkOptions, + SaveFileResponse, + OpenExternalLinkResponse, + EventCallback, + UnlistenFn, +} from "@hoppscotch/kernel" +import { getModule } from "." + +export const Io = (() => { + const module = () => getModule("io") + + return { + saveFileWithDialog: ( + opts: SaveFileWithDialogOptions + ): Promise => module().saveFileWithDialog(opts), + + openExternalLink: ( + opts: OpenExternalLinkOptions + ): Promise => module().openExternalLink(opts), + + listen: ( + event: string, + handler: EventCallback + ): Promise => module().listen(event, handler), + + once: (event: string, handler: EventCallback): Promise => + module().once(event, handler), + + emit: (event: string, payload?: unknown): Promise => + module().emit(event, payload), + } as const +})() diff --git a/packages/hoppscotch-selfhost-web/src/kernel/relay.ts b/packages/hoppscotch-selfhost-web/src/kernel/relay.ts new file mode 100644 index 0000000..46a8d4e --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/kernel/relay.ts @@ -0,0 +1,26 @@ +import type { + RelayRequest, + RelayRequestEvents, + RelayError, + RelayResponse, + RelayEventEmitter, +} from "@hoppscotch/kernel" +import * as E from "fp-ts/Either" +import { getModule } from "." + +export const Relay = (() => { + const module = () => getModule("relay") + + return { + capabilities: () => module().capabilities, + canHandle: (request: RelayRequest): E.Either => + module().canHandle(request), + execute: ( + request: RelayRequest + ): { + cancel: () => Promise + emitter: RelayEventEmitter + response: Promise> + } => module().execute(request), + } as const +})() diff --git a/packages/hoppscotch-selfhost-web/src/kernel/store.ts b/packages/hoppscotch-selfhost-web/src/kernel/store.ts new file mode 100644 index 0000000..6c11b88 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/kernel/store.ts @@ -0,0 +1,164 @@ +import type { + StorageOptions, + StoreError, + StoreEvents, + StoreEventEmitter, +} from "@hoppscotch/kernel" +import * as E from "fp-ts/Either" +import { getModule } from "." +import { getKernelMode } from "@hoppscotch/kernel" + +const STORE_PATH = "hoppscotch-unified.store" + +let cachedStorePath: string | undefined + +// These are only defined functions if in desktop mode. +// For more context, take a look at how `hoppscotch-kernel/.../store/v1/` works +// and how the `web` mode store kernel ignores the first file directory input. +let invoke: + | ((cmd: string, args?: Record) => Promise) + | undefined +let join: ((...paths: string[]) => Promise) | undefined + +// Single init promise to avoid multiple imports and race conditions +let initPromise: Promise | undefined + +const isInitd = async () => { + if (getKernelMode() !== "desktop") return + + if (!initPromise) { + initPromise = Promise.all([ + import("@tauri-apps/api/core").then((module) => { + invoke = module.invoke + }), + import("@tauri-apps/api/path").then((module) => { + join = module.join + }), + ]).then(() => {}) + } + + await initPromise +} + +export const getConfigDir = async (): Promise => { + await isInitd() + if (!invoke) throw new Error("getConfigDir is only available in desktop mode") + return await invoke("get_config_dir") +} + +export const getBackupDir = async (): Promise => { + await isInitd() + if (!invoke) throw new Error("getBackupDir is only available in desktop mode") + return await invoke("get_backup_dir") +} + +export const getLatestDir = async (): Promise => { + await isInitd() + if (!invoke) throw new Error("getLatestDir is only available in desktop mode") + return await invoke("get_latest_dir") +} + +export const getStoreDir = async (): Promise => { + await isInitd() + if (!invoke) throw new Error("getStoreDir is only available in desktop mode") + return await invoke("get_store_dir") +} + +export const getInstanceDir = async (): Promise => { + await isInitd() + if (!invoke) + throw new Error("getInstanceDir is only available in desktop mode") + return await invoke("get_instance_dir") +} + +const getStorePath = async (): Promise => { + if (cachedStorePath) return cachedStorePath + + if (getKernelMode() === "desktop") { + await isInitd() + if (join) { + try { + const storeDir = await getStoreDir() + cachedStorePath = await join(storeDir, STORE_PATH) + return cachedStorePath + } catch (error) { + console.error("Failed to get store directory:", error) + } + } + } + + cachedStorePath = STORE_PATH + return cachedStorePath +} + +export const Store = (() => { + const module = () => getModule("store") + + return { + capabilities: () => module().capabilities, + + init: async () => { + const storePath = await getStorePath() + return module().init(storePath) + }, + + set: async ( + namespace: string, + key: string, + value: unknown, + options?: StorageOptions + ): Promise> => { + const storePath = await getStorePath() + return module().set(storePath, namespace, key, value, options) + }, + + get: async ( + namespace: string, + key: string + ): Promise> => { + const storePath = await getStorePath() + return module().get(storePath, namespace, key) + }, + + remove: async ( + namespace: string, + key: string + ): Promise> => { + const storePath = await getStorePath() + return module().remove(storePath, namespace, key) + }, + + clear: async (namespace?: string): Promise> => { + const storePath = await getStorePath() + return module().clear(storePath, namespace) + }, + + has: async ( + namespace: string, + key: string + ): Promise> => { + const storePath = await getStorePath() + return module().has(storePath, namespace, key) + }, + + listNamespaces: async (): Promise> => { + const storePath = await getStorePath() + return module().listNamespaces(storePath) + }, + + listKeys: async ( + namespace: string + ): Promise> => { + const storePath = await getStorePath() + return module().listKeys(storePath, namespace) + }, + + watch: async ( + namespace: string, + key: string + ): Promise> => { + const storePath = await getStorePath() + return module().watch(storePath, namespace, key) + }, + } as const +})() diff --git a/packages/hoppscotch-selfhost-web/src/main.ts b/packages/hoppscotch-selfhost-web/src/main.ts new file mode 100644 index 0000000..27f3b43 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/main.ts @@ -0,0 +1,369 @@ +import { nextTick, ref, watch } from "vue" +import { emit, listen } from "@tauri-apps/api/event" +import { createHoppApp } from "@hoppscotch/common" +import { useSettingStatic } from "@hoppscotch/common/composables/settings" +import { useDesktopSettings } from "@hoppscotch/common/composables/desktop-settings" +import { resolvePressedKey } from "@hoppscotch/common/helpers/keybindings" +import { getKeyboardLayoutStrategy } from "@hoppscotch/common/helpers/keyboard-strategy" +import { getKernelMode } from "@hoppscotch/kernel" + +import { def as stdBackendDef } from "@hoppscotch/common/platform/std/backend" +// Platform imports +import { def as webAuth } from "@app/platform/auth/web" + +import { def as desktopAuth } from "@app/platform/auth/desktop" + +// Std platform +import { def as webInstance } from "@app/platform/instance/web" +import { def as desktopInstance } from "@app/platform/instance/desktop" +import { stdFooterItems } from "@hoppscotch/common/platform/std/ui/footerItem" +import { stdSupportOptionItems } from "@hoppscotch/common/platform/std/ui/supportOptionsItem" +import { InfraPlatform } from "@app/platform/infra/infra.platform" +import { historySyncDef } from "@app/platform/history/sync" +import { kernelIO } from "@hoppscotch/common/platform/std/kernel-io" +import { HeaderDownloadableLinksService } from "@app/services/headerDownloadableLinks.service" + +import DesktopSettingsSection from "@hoppscotch/common/components/settings/Desktop.vue" +import { useDesktopZoomEffect } from "@hoppscotch/common/composables/desktop-zoom" + +// Std interceptors +import { NativeKernelInterceptorService } from "@hoppscotch/common/platform/std/kernel-interceptors/native" +import { AgentKernelInterceptorService } from "@hoppscotch/common/platform/std/kernel-interceptors/agent" +import { ProxyKernelInterceptorService } from "@hoppscotch/common/platform/std/kernel-interceptors/proxy" +import { ExtensionKernelInterceptorService } from "@hoppscotch/common/platform/std/kernel-interceptors/extension" +import { BrowserKernelInterceptorService } from "@hoppscotch/common/platform/std/kernel-interceptors/browser" + +const PLATFORM_CONFIG = { + web: { + auth: webAuth, + instance: webInstance, + interceptors: [ + BrowserKernelInterceptorService, + ProxyKernelInterceptorService, + AgentKernelInterceptorService, + ExtensionKernelInterceptorService, + ], + defaultInterceptor: "browser", + menuItems: stdFooterItems, + supportItems: stdSupportOptionItems, + cookiesEnabled: false, + }, + + desktop: { + auth: desktopAuth, + instance: desktopInstance, + interceptors: [ + NativeKernelInterceptorService, + ProxyKernelInterceptorService, + ], + defaultInterceptor: "native", + menuItems: stdFooterItems, + supportItems: stdSupportOptionItems, + cookiesEnabled: true, + }, +} + +const headerPaddingLeft = ref("0px") +const headerPaddingTop = ref("0px") + +function setupDesktopUI() { + // Hydrate the keyboard-layout-strategy holder at desktop bootstrap. + // The composable's first call triggers loadInitial, which reads the + // persisted strategy from tauri-plugin-store and writes it into the + // shared holder. Without this call the holder stays at its module- + // level default ("hybrid") until Desktop.vue mounts, so a persisted + // "key" or "code" choice would be dormant on every restart until the + // user opened the settings page. + useDesktopSettings() + + headerPaddingTop.value = "0px" + headerPaddingLeft.value = "80px" + + listen("will-enter-fullscreen", () => { + headerPaddingTop.value = "0px" + headerPaddingLeft.value = "0px" + }) + + listen("will-exit-fullscreen", () => { + headerPaddingTop.value = "0px" + headerPaddingLeft.value = "80px" + }) + + window.addEventListener( + "keydown", + (e) => { + if (e.key === "Backspace" && !isTextInput(e.target)) { + e.preventDefault() + } + }, + true + ) + + watch( + useSettingStatic("BG_COLOR")[0], + async () => { + await nextTick() + await emit("hopp-bg-changed") + }, + { immediate: true } + ) +} + +function isTextInput(target: EventTarget | null): boolean { + if (target instanceof HTMLInputElement) { + const textTypes = [ + "text", + "email", + "password", + "number", + "search", + "tel", + "url", + "textarea", + ] + return textTypes.includes(target.type) + } + + return ( + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable) + ) +} + +async function initApp() { + const platform = getKernelMode() + const config = PLATFORM_CONFIG[platform] + + if (platform === "desktop") { + setupDesktopUI() + // Apply the persisted zoom to this window and keep it in sync with + // the store. The launcher window runs its own copy of this watcher + // from `hoppscotch-desktop/src/main.ts`, and the store is the shared + // source of truth between the two. + useDesktopZoomEffect() + } + + await createHoppApp("#app", { + ui: { + additionalFooterMenuItems: config.menuItems, + additionalSupportOptionsMenuItems: config.supportItems, + // Desktop-only. Renders the "Desktop" block in the shared settings + // page. The component lives in common so every shell that builds a + // Tauri desktop target can register it the same way. Web builds pass + // `undefined` here and the settings page renders without the block. + additionalSettingsSections: + platform === "desktop" ? [DesktopSettingsSection] : undefined, + appHeader: { + paddingLeft: headerPaddingLeft, + paddingTop: headerPaddingTop, + }, + }, + + auth: config.auth, + kernelIO, + instance: config.instance, + + // The history-enabled toggle is a self-host-only infra-config; inject the + // documents the shared sync layer feature-detects rather than baking them + // into common. + sync: { + history: historySyncDef, + }, + + kernelInterceptors: { + default: config.defaultInterceptor, + interceptors: config.interceptors.map((service) => ({ + type: "service" as const, + service, + })), + }, + + platformFeatureFlags: { + exportAsGIST: false, + hasTelemetry: false, + cookiesEnabled: config.cookiesEnabled, + promptAsUsingCookies: false, + hasCookieBasedAuth: platform === "web", + }, + limits: { + collectionImportSizeLimit: 50, + }, + + infra: InfraPlatform, + backend: stdBackendDef, + additionalLinks: [HeaderDownloadableLinksService], + addedServices: [], + }) + + if (platform === "desktop") { + const ALLOWED_DROP_SELECTORS = [ + '[draggable="true"]', + ".draggable-content", + ".draggable-handle", + ".sortable-ghost", + ".sortable-drag", + ".sortable-chosen", + ".vue-draggable", + + 'input[type="file"]', + 'label[for*="attachment"]', + ".file-chips-container", + ".file-chips-wrapper", + + ".cm-editor", + ".cm-content", + ".cm-scroller", + ".ace_editor", + + "[data-allow-drop]", + ".drop-zone", + + "[ondrop]", + "[data-drop-handler]", + ].join(", ") + + const isAllowedDropTarget = (target: EventTarget | null): boolean => { + if (!target || !(target instanceof HTMLElement)) { + return false + } + + if (target.closest(ALLOWED_DROP_SELECTORS)) { + return true + } + + const element = target as any + if (element._vei?.onDrop || element.__vueListeners__?.drop) { + return true + } + + return false + } + + document.addEventListener( + "drop", + (e) => { + if (!isAllowedDropTarget(e.target)) { + e.preventDefault() + e.stopPropagation() + } + }, + false + ) + + document.addEventListener( + "dragover", + (e) => { + e.preventDefault() + }, + false + ) + + document.addEventListener( + "dragstart", + (e) => { + if (!e.target || !(e.target instanceof HTMLElement)) { + return + } + + const target = e.target as HTMLElement + if ( + !target.draggable && + !target.closest('[draggable="true"], .draggable-content') + ) { + e.preventDefault() + } + }, + false + ) + + window.addEventListener( + "keydown", + function (e) { + // Prevent backspace navigation + // NOTE: Only for "non-text" inputs + if (e.key === "Backspace" && !isTextInput(e.target)) { + e.preventDefault() + return + } + + // Skip during IME composition (CJK input). Modern browsers report + // `isComposing`. Older ones use the sentinel `keyCode === 229`. + if (e.isComposing || e.keyCode === 229) return + + // Skip when AltGr is the modifier. Browsers report AltGr as + // Ctrl+Alt on Windows, so QWERTZ users typing `[` via AltGr+8 + // would otherwise match Ctrl+Alt+[ and get hijacked into the + // MRU tab shortcut. `getModifierState("AltGraph")` is true only + // for AltGr, not for genuine Ctrl+Alt. + if (e.getModifierState("AltGraph")) return + + const isCtrlOrCmd = e.ctrlKey || e.metaKey + if (!isCtrlOrCmd) return + + // Resolve the pressed key through the active layout strategy so + // AZERTY's "A" keycap (physical KeyQ position) fires Ctrl+A, + // not Ctrl+Q. The in-page handler uses the same resolver, so + // routing the capture phase through it keeps both paths + // consistent and lets the user's strategy choice take effect + // everywhere, including for the desktop-shell shortcuts the + // capture phase pre-empts. + const key = resolvePressedKey(e, getKeyboardLayoutStrategy()) + if (!key) return + + let shortcutEvent: string | null = null + + if (!e.shiftKey && !e.altKey && key === "q") { + // Ctrl/Cmd + Q - Quit Application + shortcutEvent = "ctrl-q" + } else if (!e.shiftKey && !e.altKey && key === "t") { + // Ctrl/Cmd + T - New Tab + shortcutEvent = "ctrl-t" + } else if (!e.shiftKey && !e.altKey && key === "w") { + // Ctrl/Cmd + W - Close Tab + shortcutEvent = "ctrl-w" + } else if (e.shiftKey && !e.altKey && key === "t") { + // Ctrl/Cmd + Shift + T - Reopen Tab + shortcutEvent = "ctrl-shift-t" + } else if (!e.shiftKey && e.altKey && key === "right") { + // Ctrl/Cmd + Alt + Right - Next Tab + shortcutEvent = "ctrl-alt-right" + } else if (!e.shiftKey && e.altKey && key === "left") { + // Ctrl/Cmd + Alt + Left - Previous Tab + shortcutEvent = "ctrl-alt-left" + } else if (!e.shiftKey && e.altKey && key === "9") { + // Ctrl/Cmd + Alt + 9 - First Tab + shortcutEvent = "ctrl-alt-9" + } else if (!e.shiftKey && e.altKey && key === "0") { + // Ctrl/Cmd + Alt + 0 - Last Tab + shortcutEvent = "ctrl-alt-0" + } else if (!e.shiftKey && e.altKey && key === "u") { + // Ctrl/Cmd + Alt + U - Focus URL Bar + shortcutEvent = "ctrl-alt-u" + } else if (!e.shiftKey && e.altKey && key === "]") { + // Ctrl/Cmd + Alt + ] - MRU Tab Switch + shortcutEvent = "ctrl-alt-]" + } else if (!e.shiftKey && e.altKey && key === "[") { + // Ctrl/Cmd + Alt + [ - MRU Tab Switch (Reverse) + shortcutEvent = "ctrl-alt-[" + } + + if (shortcutEvent) { + e.preventDefault() + e.stopPropagation() + e.stopImmediatePropagation() + + setTimeout(() => { + emit("hoppscotch_desktop_shortcut", shortcutEvent).catch( + (error) => { + console.error("Failed to emit shortcut event:", error) + } + ) + }, 0) + } + }, + true + ) + } +} + +initApp().catch(console.error) diff --git a/packages/hoppscotch-selfhost-web/src/pages/device-login.vue b/packages/hoppscotch-selfhost-web/src/pages/device-login.vue new file mode 100644 index 0000000..a9af845 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/pages/device-login.vue @@ -0,0 +1,159 @@ + + + + + +meta: + layout: empty + diff --git a/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/api.ts b/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/api.ts new file mode 100644 index 0000000..1291d2a --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/api.ts @@ -0,0 +1,45 @@ +import { runMutation } from "@helpers/backend/GQLClient" +import axios from "axios" +import * as E from "fp-ts/Either" +import { z } from "zod" +import { + UpdateUserDisplayNameDocument, + UpdateUserDisplayNameMutation, + UpdateUserDisplayNameMutationVariables, +} from "@app/api/generated/graphql" + +const expectedAllowedProvidersSchema = z.object({ + // currently supported values are "GOOGLE", "GITHUB", "EMAIL", "MICROSOFT", "SAML" + // keeping it as string to avoid backend accidentally breaking frontend when adding new providers + providers: z.array(z.string()), +}) + +export const getAllowedAuthProviders = async () => { + try { + const res = await axios.get( + `${import.meta.env.VITE_BACKEND_API_URL}/auth/providers`, + { + withCredentials: true, + } + ) + + const parseResult = expectedAllowedProvidersSchema.safeParse(res.data) + + if (!parseResult.success) { + return E.left("SOMETHING_WENT_WRONG") + } + + return E.right(parseResult.data.providers) + } catch (_) { + return E.left("SOMETHING_WENT_WRONG") + } +} + +export const updateUserDisplayName = (updatedDisplayName: string) => + runMutation< + UpdateUserDisplayNameMutation, + UpdateUserDisplayNameMutationVariables, + "" + >(UpdateUserDisplayNameDocument, { + updatedDisplayName, + })() diff --git a/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/index.ts b/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/index.ts new file mode 100644 index 0000000..c590efb --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/index.ts @@ -0,0 +1,558 @@ +import * as E from "fp-ts/Either" + +import { BehaviorSubject, Subject } from "rxjs" +import { Ref, ref, watch } from "vue" + +import { content } from "@hoppscotch/kernel" + +import { Io } from "@hoppscotch/common/kernel/io" +import { listen } from "@tauri-apps/api/event" +import { getService } from "@hoppscotch/common/modules/dioc" +import { parseBodyAsJSON } from "@hoppscotch/common/helpers/functional/json" +import { AuthEvent, AuthPlatformDef } from "@hoppscotch/common/platform/auth" +import { PersistenceService } from "@hoppscotch/common/services/persistence" +import { KernelInterceptorService } from "@hoppscotch/common/services/kernel-interceptor.service" + +import Login from "@app/components/Login.vue" +import { getAllowedAuthProviders, updateUserDisplayName } from "./api" + +export type HoppUserWithAuthDetail = { + uid: string + displayName: string | null + email: string | null + photoURL: string | null + provider?: string + accessToken?: string + refreshToken?: string + emailVerified: boolean +} + +interface GQLResponse { + data?: { + me: HoppUserWithAuthDetail + } + errors?: Array<{ message: string }> +} + +export const authEvents$ = new Subject() +export const currentUser$ = new BehaviorSubject( + null +) +export const probableUser$ = new BehaviorSubject( + null +) + +const isGettingInitialUser: Ref = ref(null) + +const persistenceService = getService(PersistenceService) +const interceptorService = getService(KernelInterceptorService) + +async function logout() { + const { response } = interceptorService.execute({ + id: Date.now(), + url: `${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`, + version: "HTTP/1.1", + method: "GET", + }) + + await response + await persistenceService.removeLocalConfig("refresh_token") + await persistenceService.removeLocalConfig("access_token") +} + +async function signInUserWithGithubFB() { + await Io.openExternalLink({ + url: `${ + import.meta.env.VITE_BACKEND_API_URL + }/auth/github?redirect_uri=desktop`, + }) +} + +async function signInUserWithGoogleFB() { + await Io.openExternalLink({ + url: `${ + import.meta.env.VITE_BACKEND_API_URL + }/auth/google?redirect_uri=desktop`, + }) +} + +async function signInUserWithMicrosoftFB() { + await Io.openExternalLink({ + url: `${ + import.meta.env.VITE_BACKEND_API_URL + }/auth/microsoft?redirect_uri=desktop`, + }) +} + +async function getInitialUserDetails(): Promise< + GQLResponse | { error: string } +> { + try { + const accessToken = await persistenceService.getLocalConfig("access_token") + const refreshToken = + await persistenceService.getLocalConfig("refresh_token") + + if (!accessToken || !refreshToken) { + return { error: "auth/cookies_not_found" } + } + + const { response } = interceptorService.execute({ + id: Date.now(), + url: `${import.meta.env.VITE_BACKEND_GQL_URL}`, + method: "POST", + version: "HTTP/1.1", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + content: content.json({ + query: `query Me { + me { + uid + displayName + email + photoURL + isAdmin + createdOn + } + }`, + }), + }) + + const responseBytes = await response + + if (E.isLeft(responseBytes)) { + return { error: "auth/cookies_not_found" } + } + + const res = parseBodyAsJSON(responseBytes.right.body) + if (res._tag == "Some" && res.value.data?.me) { + return { + data: { + me: { + ...res.value.data.me, + refreshToken, + accessToken, + emailVerified: true, + }, + }, + } + } + return { error: "auth/cookies_not_found" } + } catch (_error) { + return { error: "auth/cookies_not_found" } + } +} + +async function setUser(user: HoppUserWithAuthDetail | null) { + const accessToken = await persistenceService.getLocalConfig("access_token") + const refreshToken = await persistenceService.getLocalConfig("refresh_token") + + if (!accessToken || !refreshToken) return null + + const userWithToken = + user && accessToken && refreshToken + ? { + ...user, + accessToken, + refreshToken, + } + : null + + currentUser$.next(userWithToken) + probableUser$.next(userWithToken) + await persistenceService.setLocalConfig( + "login_state", + JSON.stringify(userWithToken) + ) +} + +export async function setInitialUser() { + isGettingInitialUser.value = true + const res = await getInitialUserDetails() + + if ("error" in res) { + await setUser(null) + isGettingInitialUser.value = false + return + } + + if (res.errors?.[0]?.message === "Unauthorized") { + const isRefreshSuccess = await refreshToken() + if (isRefreshSuccess) { + await setInitialUser() + } else { + await setUser(null) + isGettingInitialUser.value = false + } + return + } + + if (res.data?.me) { + const hoppBackendUser = res.data.me + const accessToken = await persistenceService.getLocalConfig("access_token") + if (!accessToken) return null + + if (!accessToken) { + await setUser(null) + isGettingInitialUser.value = false + return + } + + const HoppUserWithAuthDetail: HoppUserWithAuthDetail = { + uid: hoppBackendUser.uid, + displayName: hoppBackendUser.displayName, + email: hoppBackendUser.email, + photoURL: hoppBackendUser.photoURL, + emailVerified: true, + accessToken, + } + + await setUser(HoppUserWithAuthDetail) + isGettingInitialUser.value = false + + authEvents$.next({ + event: "login", + user: HoppUserWithAuthDetail, + }) + } +} + +async function refreshToken() { + try { + const refreshToken = + await persistenceService.getLocalConfig("refresh_token") + if (!refreshToken) return false + + const { response } = interceptorService.execute({ + id: Date.now(), + url: `${import.meta.env.VITE_BACKEND_API_URL}/auth/refresh`, + method: "GET", + version: "HTTP/1.1", + headers: { + Authorization: `Bearer ${refreshToken}`, + }, + }) + + const res = await response + if (E.isLeft(res)) return false + + await setAuthCookies(res.right.headers) + const isSuccessful = res.right.status === 200 + + if (isSuccessful && currentUser$.value) { + authEvents$.next({ + event: "login", + user: { + uid: currentUser$.value.uid, + displayName: currentUser$.value.displayName, + email: currentUser$.value.email, + photoURL: currentUser$.value.photoURL, + emailVerified: currentUser$.value.emailVerified, + }, + }) + } + + return isSuccessful + } catch (_err) { + return false + } +} + +async function sendMagicLink(email: string) { + const { response } = interceptorService.execute({ + id: Date.now(), + url: `${import.meta.env.VITE_BACKEND_API_URL}/auth/signin?origin=desktop`, + version: "HTTP/1.1", + method: "POST", + headers: { + "Content-Type": "application/json", + }, + content: content.json({ email }), + }) + + const res = await response + if (E.isLeft(res)) throw new Error("Failed to send magic link") + + if (res.right.data && res.right.data.deviceIdentifier) { + await persistenceService.setLocalConfig( + "deviceIdentifier", + res.right.data.deviceIdentifier + ) + } else { + throw new Error("Does not get device identifier") + } + + return res.right.data +} + +async function setAuthCookies(headers: Headers) { + const cookieHeader = headers.get("set-cookie") + if (!cookieHeader) return + + const accessTMatch = cookieHeader.match(/access_token=([^;,\s]+)/) + const refreshTMatch = cookieHeader.match(/refresh_token=([^;,\s]+)/) + + if (accessTMatch) { + await persistenceService.setLocalConfig("access_token", accessTMatch[1]) + } + if (refreshTMatch) { + await persistenceService.setLocalConfig("refresh_token", refreshTMatch[1]) + } +} + +export const def: AuthPlatformDef = { + customLoginSelectorUI: Login, + getCurrentUserStream: () => currentUser$, + getAuthEventsStream: () => authEvents$, + getProbableUserStream: () => probableUser$, + getCurrentUser: () => currentUser$.value, + getProbableUser: () => probableUser$.value, + + async getAllowedAuthProviders() { + return await getAllowedAuthProviders() + }, + + getBackendHeaders() { + const accessToken = currentUser$.value?.accessToken + return accessToken + ? { + Authorization: `Bearer ${accessToken}`, + } + : ({} as Record) + }, + + getGQLClientOptions() { + const accessToken = currentUser$.value?.accessToken + return { + // For GraphQL subscriptions via WebSocket + connectionParams: accessToken + ? { + Authorization: `Bearer ${accessToken}`, + } + : undefined, + // For regular HTTP queries + fetchOptions: { + headers: accessToken + ? { Authorization: `Bearer ${accessToken}` } + : undefined, + }, + } + }, + + axiosPlatformConfig() { + const accessToken = currentUser$.value?.accessToken + return { + headers: accessToken + ? { + Authorization: `Bearer ${accessToken}`, + } + : {}, + } + }, + + willBackendHaveAuthError() { + return !currentUser$.value + }, + + onBackendGQLClientShouldReconnect(func) { + authEvents$.subscribe((event) => { + if (event.event === "login" || event.event === "logout") { + func() + } + }) + }, + + isSignInWithEmailLink(url: string) { + const urlObject = new URL(url) + const searchParams = new URLSearchParams(urlObject.search) + return searchParams.has("token") + }, + + async processMagicLink(): Promise { + return Promise.resolve() + }, + + getDevOptsBackendIDToken() { + return null + }, + + async performAuthInit() { + const loginState = await persistenceService.getLocalConfig("login_state") + const probableUser = JSON.parse(loginState ?? "null") + probableUser$.next(probableUser) + await setInitialUser() + + await listen( + "scheme-request-received", + async (event: { payload: string }) => { + const deepLink = event.payload + const params = new URLSearchParams(deepLink.split("?")[1]) + + const accessToken = params.get("access_token") + const refreshToken = params.get("refresh_token") + const token = params.get("token") + + if (accessToken && refreshToken) { + await persistenceService.setLocalConfig("access_token", accessToken) + await persistenceService.setLocalConfig("refresh_token", refreshToken) + return + } + + if (token) { + await persistenceService.setLocalConfig("verifyToken", token) + await this.signInWithEmailLink("", "") + await setInitialUser() + } + } + ) + }, + + waitProbableLoginToConfirm() { + return new Promise((resolve, reject) => { + if (this.getCurrentUser()) { + resolve() + } + + if (!probableUser$.value) reject(new Error("no_probable_user")) + + const unwatch = watch(isGettingInitialUser, (val) => { + if (val === true || val === false) { + resolve() + unwatch() + } + }) + }) + }, + + async signInWithEmail(email: string) { + await sendMagicLink(email) + }, + + async verifyEmailAddress() { + return + }, + + async signInUserWithGoogle() { + await signInUserWithGoogleFB() + }, + + async signInUserWithGithub() { + await signInUserWithGithubFB() + return undefined + }, + + async signInUserWithMicrosoft() { + await signInUserWithMicrosoftFB() + }, + + async signInWithEmailLink(_email: string, url: string) { + const deviceIdentifier = + await persistenceService.getLocalConfig("deviceIdentifier") + + if (!deviceIdentifier) { + throw new Error( + "Device Identifier not found, you can only signin from the browser you generated the magic link" + ) + } + + const urlObject = new URL(url) + const searchParams = new URLSearchParams(urlObject.search) + const token = searchParams.get("token") + const verifyToken = + token || (await persistenceService.getLocalConfig("verifyToken")) + + const { response } = interceptorService.execute({ + id: Date.now(), + url: `${import.meta.env.VITE_BACKEND_API_URL}/auth/verify`, + version: "HTTP/1.1", + method: "POST", + headers: { + "Content-Type": "application/json", + }, + content: content.json({ + token: verifyToken, + deviceIdentifier, + }), + }) + + const res = await response + if (E.isLeft(res)) throw new Error("Failed to verify email link") + + await setAuthCookies(res.right.headers) + + await persistenceService.removeLocalConfig("deviceIdentifier") + await persistenceService.removeLocalConfig("verifyToken") + }, + + // Removed parameter from here because we do not use it + async setEmailAddress() { + return + }, + + async setDisplayName(name: string) { + if (!name) return E.left("USER_NAME_CANNOT_BE_EMPTY") + if (!currentUser$.value) return E.left("NO_USER_LOGGED_IN") + + const res = await updateUserDisplayName(name) + + if (E.isRight(res)) { + const user = currentUser$.value + if (user) { + await setUser({ + ...user, + displayName: res.right.updateDisplayName.displayName ?? null, + }) + } + return E.right(undefined) + } + return E.left(res.left) + }, + + async signOutUser() { + await logout() + + probableUser$.next(null) + currentUser$.next(null) + await persistenceService.removeLocalConfig("login_state") + + authEvents$.next({ + event: "logout", + }) + }, + + async refreshAuthToken() { + const refreshed = await refreshToken() + return refreshed + }, + + /** + * Verifies if the current user's authentication tokens are valid + * @returns True if tokens are valid, false otherwise + */ + async verifyAuthTokens() { + const BACKEND_API_URL = import.meta.env.VITE_BACKEND_API_URL + + const { response } = interceptorService.execute({ + id: Date.now(), + url: `${BACKEND_API_URL}/auth/verify-token`, + method: "GET", + version: "HTTP/1.1", + headers: { + "Content-Type": "application/json", + ...this.getBackendHeaders(), + }, + }) + + const res = await response + if (E.isLeft(res)) return false + + const parsed = parseBodyAsJSON<{ isValid: boolean }>(res.right.body) + if (parsed._tag === "Some" && parsed.value.isValid) { + return true + } + + const refreshed = await refreshToken() + return refreshed + }, +} diff --git a/packages/hoppscotch-selfhost-web/src/platform/auth/web/api.ts b/packages/hoppscotch-selfhost-web/src/platform/auth/web/api.ts new file mode 100644 index 0000000..1291d2a --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/platform/auth/web/api.ts @@ -0,0 +1,45 @@ +import { runMutation } from "@helpers/backend/GQLClient" +import axios from "axios" +import * as E from "fp-ts/Either" +import { z } from "zod" +import { + UpdateUserDisplayNameDocument, + UpdateUserDisplayNameMutation, + UpdateUserDisplayNameMutationVariables, +} from "@app/api/generated/graphql" + +const expectedAllowedProvidersSchema = z.object({ + // currently supported values are "GOOGLE", "GITHUB", "EMAIL", "MICROSOFT", "SAML" + // keeping it as string to avoid backend accidentally breaking frontend when adding new providers + providers: z.array(z.string()), +}) + +export const getAllowedAuthProviders = async () => { + try { + const res = await axios.get( + `${import.meta.env.VITE_BACKEND_API_URL}/auth/providers`, + { + withCredentials: true, + } + ) + + const parseResult = expectedAllowedProvidersSchema.safeParse(res.data) + + if (!parseResult.success) { + return E.left("SOMETHING_WENT_WRONG") + } + + return E.right(parseResult.data.providers) + } catch (_) { + return E.left("SOMETHING_WENT_WRONG") + } +} + +export const updateUserDisplayName = (updatedDisplayName: string) => + runMutation< + UpdateUserDisplayNameMutation, + UpdateUserDisplayNameMutationVariables, + "" + >(UpdateUserDisplayNameDocument, { + updatedDisplayName, + })() diff --git a/packages/hoppscotch-selfhost-web/src/platform/auth/web/index.ts b/packages/hoppscotch-selfhost-web/src/platform/auth/web/index.ts new file mode 100644 index 0000000..a65d75d --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/platform/auth/web/index.ts @@ -0,0 +1,399 @@ +import * as E from "fp-ts/Either" +import axios from "axios" +import { BehaviorSubject, Subject } from "rxjs" +import { Ref, ref, watch } from "vue" + +import { getService } from "@hoppscotch/common/modules/dioc" +import { + AuthEvent, + AuthPlatformDef, + HoppUser, +} from "@hoppscotch/common/platform/auth" +import { PersistenceService } from "@hoppscotch/common/services/persistence" + +import { getAllowedAuthProviders, updateUserDisplayName } from "./api" + +export const authEvents$ = new Subject() +const currentUser$ = new BehaviorSubject(null) +export const probableUser$ = new BehaviorSubject(null) + +const persistenceService = getService(PersistenceService) + +async function logout() { + await axios.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`, { + withCredentials: true, + }) +} + +async function signInUserWithGithubFB() { + window.location.href = `${import.meta.env.VITE_BACKEND_API_URL}/auth/github` +} + +async function signInUserWithGoogleFB() { + window.location.href = `${import.meta.env.VITE_BACKEND_API_URL}/auth/google` +} + +async function signInUserWithMicrosoftFB() { + window.location.href = `${ + import.meta.env.VITE_BACKEND_API_URL + }/auth/microsoft` +} + +async function getInitialUserDetails() { + const res = await axios.post<{ + data?: { + me?: { + uid: string + displayName: string + email: string + photoURL: string + isAdmin: boolean + createdOn: string + // emailVerified: boolean + } + } + errors?: Array<{ + message: string + }> + }>( + `${import.meta.env.VITE_BACKEND_GQL_URL}`, + { + query: `query Me { + me { + uid + displayName + email + photoURL + isAdmin + createdOn + } + }`, + }, + { + headers: { + "Content-Type": "application/json", + }, + withCredentials: true, + } + ) + + return res.data +} + +const isGettingInitialUser: Ref = ref(null) + +async function setUser(user: HoppUser | null) { + currentUser$.next(user) + probableUser$.next(user) + + await persistenceService.setLocalConfig("login_state", JSON.stringify(user)) +} + +async function setInitialUser() { + isGettingInitialUser.value = true + const res = await getInitialUserDetails() + + const error = res.errors && res.errors[0] + + // no cookies sent. so the user is not logged in + if (error && error.message === "auth/cookies_not_found") { + await setUser(null) + isGettingInitialUser.value = false + return + } + + if (error && error.message === "user/not_found") { + await setUser(null) + isGettingInitialUser.value = false + return + } + + // cookies sent, but it is expired, we need to refresh the token + if (error && error.message === "Unauthorized") { + const isRefreshSuccess = await refreshToken() + + if (isRefreshSuccess) { + setInitialUser() + } else { + await setUser(null) + isGettingInitialUser.value = false + await logout() + } + + return + } + + // no errors, we have a valid user + if (res.data && res.data.me) { + const hoppBackendUser = res.data.me + + const hoppUser: HoppUser = { + uid: hoppBackendUser.uid, + displayName: hoppBackendUser.displayName, + email: hoppBackendUser.email, + photoURL: hoppBackendUser.photoURL, + // all our signin methods currently guarantees the email is verified + emailVerified: true, + } + + await setUser(hoppUser) + + isGettingInitialUser.value = false + + authEvents$.next({ + event: "login", + user: hoppUser, + }) + + return + } +} + +async function refreshToken() { + try { + const res = await axios.get( + `${import.meta.env.VITE_BACKEND_API_URL}/auth/refresh`, + { + withCredentials: true, + } + ) + + const isSuccessful = res.status === 200 + + if (isSuccessful) { + authEvents$.next({ + event: "token_refresh", + }) + } + + return isSuccessful + } catch (_error) { + return false + } +} + +async function sendMagicLink(email: string) { + const res = await axios.post( + `${import.meta.env.VITE_BACKEND_API_URL}/auth/signin`, + { + email, + }, + { + withCredentials: true, + } + ) + + if (res.data && res.data.deviceIdentifier) { + await persistenceService.setLocalConfig( + "deviceIdentifier", + res.data.deviceIdentifier + ) + } else { + throw new Error("test: does not get device identifier") + } + + return res.data +} + +export const def: AuthPlatformDef = { + getCurrentUserStream: () => currentUser$, + getAuthEventsStream: () => authEvents$, + getProbableUserStream: () => probableUser$, + + getCurrentUser: () => currentUser$.value, + getProbableUser: () => probableUser$.value, + + getBackendHeaders() { + return {} + }, + getGQLClientOptions() { + return { + fetchOptions: { + credentials: "include", + }, + } + }, + + axiosPlatformConfig() { + return { + // for including cookies in the request + withCredentials: true, + } + }, + + /** + * it is not possible for us to know if the current cookie is expired because we cannot access http-only cookies from js + * hence just returning if the currentUser$ has a value associated with it + */ + willBackendHaveAuthError() { + return !currentUser$.value + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onBackendGQLClientShouldReconnect(func) { + authEvents$.subscribe((event) => { + if ( + event.event == "login" || + event.event == "logout" || + event.event == "token_refresh" + ) { + func() + } + }) + }, + + /** + * we cannot access our auth cookies from javascript, so leaving this as null + */ + getDevOptsBackendIDToken() { + return null + }, + async performAuthInit() { + const probableUser = JSON.parse( + (await persistenceService.getLocalConfig("login_state")) ?? "null" + ) + probableUser$.next(probableUser) + await setInitialUser() + }, + + waitProbableLoginToConfirm() { + return new Promise((resolve, reject) => { + if (this.getCurrentUser()) { + resolve() + } + + if (!probableUser$.value) reject(new Error("no_probable_user")) + + const unwatch = watch(isGettingInitialUser, (val) => { + if (val === true || val === false) { + resolve() + unwatch() + } + }) + }) + }, + + async signInWithEmail(email: string) { + await sendMagicLink(email) + }, + + isSignInWithEmailLink(url: string) { + const urlObject = new URL(url) + const searchParams = new URLSearchParams(urlObject.search) + + return !!searchParams.get("token") + }, + + async verifyEmailAddress() { + return + }, + async signInUserWithGoogle() { + await signInUserWithGoogleFB() + }, + async signInUserWithGithub() { + await signInUserWithGithubFB() + return undefined + }, + async signInUserWithMicrosoft() { + await signInUserWithMicrosoftFB() + }, + async signInWithEmailLink(email: string, url: string) { + const urlObject = new URL(url) + const searchParams = new URLSearchParams(urlObject.search) + + const token = searchParams.get("token") + + const deviceIdentifier = + await persistenceService.getLocalConfig("deviceIdentifier") + + await axios.post( + `${import.meta.env.VITE_BACKEND_API_URL}/auth/verify`, + { + token: token, + deviceIdentifier, + }, + { + withCredentials: true, + } + ) + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async setEmailAddress(_email: string) { + return + }, + + async setDisplayName(name: string) { + if (!name) return E.left("USER_NAME_CANNOT_BE_EMPTY") + if (!currentUser$.value) return E.left("NO_USER_LOGGED_IN") + + const res = await updateUserDisplayName(name) + + if (E.isRight(res)) { + await setUser({ + ...currentUser$.value, + displayName: res.right.updateDisplayName.displayName ?? null, + }) + + return E.right(undefined) + } + return E.left(res.left) + }, + + async signOutUser() { + await logout() + + probableUser$.next(null) + currentUser$.next(null) + + await persistenceService.removeLocalConfig("login_state") + + authEvents$.next({ + event: "logout", + }) + }, + + async refreshAuthToken() { + return refreshToken() + }, + + async processMagicLink() { + if (this.isSignInWithEmailLink(window.location.href)) { + const deviceIdentifier = + await persistenceService.getLocalConfig("deviceIdentifier") + + if (!deviceIdentifier) { + throw new Error( + "Device Identifier not found, you can only signin from the browser you generated the magic link" + ) + } + + await this.signInWithEmailLink(deviceIdentifier, window.location.href) + + await persistenceService.removeLocalConfig("deviceIdentifier") + window.location.href = "/" + } + }, + getAllowedAuthProviders, + + /** + * Verifies if the current user's authentication tokens are valid + * @returns True if tokens are valid, false otherwise + */ + async verifyAuthTokens() { + try { + const BACKEND_API_URL = import.meta.env.VITE_BACKEND_API_URL + + const response = await axios.get(`${BACKEND_API_URL}/auth/verify-token`, { + headers: { + "Content-Type": "application/json", + ...this.getBackendHeaders(), + }, + withCredentials: true, + }) + + // axios automatically throws on error status codes, so if we reach here, it was successful + return !!response.data.isValid + } catch (_error) { + return false + } + }, +} diff --git a/packages/hoppscotch-selfhost-web/src/platform/history/sync.ts b/packages/hoppscotch-selfhost-web/src/platform/history/sync.ts new file mode 100644 index 0000000..b19e467 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/platform/history/sync.ts @@ -0,0 +1,56 @@ +import { + runGQLQuery, + runGQLSubscription, +} from "@hoppscotch/common/helpers/backend/GQLClient" +import { SyncPlatformDef } from "@hoppscotch/common/platform/sync" +import * as E from "fp-ts/Either" + +import { + IsUserHistoryEnabledDocument, + IsUserHistoryEnabledQuery, + IsUserHistoryEnabledQueryVariables, + ServiceStatus, + UserHistoryStoreStatusChangedDocument, +} from "@app/api/generated/graphql" + +const getUserHistoryStore = () => + runGQLQuery< + IsUserHistoryEnabledQuery, + IsUserHistoryEnabledQueryVariables, + "" + >({ + query: IsUserHistoryEnabledDocument, + variables: {}, + }) + +/** + * Self-host history sync overrides. The `isUserHistoryEnabled` infra-config + * (and its change subscription) only exist on the self-hosted backend, so the + * shared sync layer in `~/lib/sync/history` feature-detects these hooks rather + * than depending on the self-host-only documents directly. + */ +export const historySyncDef: NonNullable = { + async getHistoryStoreStatus() { + const res = await getUserHistoryStore() + + if (E.isLeft(res)) { + throw res.left + } + + return res.right.isUserHistoryEnabled.value === ServiceStatus.Enable + }, + subscribeToHistoryStoreStatus(onStatusChange) { + const [statusChanged$, sub] = runGQLSubscription({ + query: UserHistoryStoreStatusChangedDocument, + variables: {}, + }) + + statusChanged$.subscribe((res) => { + if (E.isRight(res)) { + onStatusChange(res.right.infraConfigUpdate === ServiceStatus.Enable) + } + }) + + return sub + }, +} diff --git a/packages/hoppscotch-selfhost-web/src/platform/infra/infra.platform.ts b/packages/hoppscotch-selfhost-web/src/platform/infra/infra.platform.ts new file mode 100644 index 0000000..fdc45b7 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/platform/infra/infra.platform.ts @@ -0,0 +1,42 @@ +import { runGQLQuery } from "@hoppscotch/common/helpers/backend/GQLClient" +import { InfraPlatformDef } from "@hoppscotch/common/platform/infra" +import { + GetProxyAppUrlDocument, + GetSmtpStatusDocument, +} from "@app/api/generated/graphql" +import * as E from "fp-ts/Either" + +const getSMTPStatus = () => { + return runGQLQuery({ + query: GetSmtpStatusDocument, + variables: {}, + }) +} + +const getProxyAppUrl = () => { + return runGQLQuery({ + query: GetProxyAppUrlDocument, + variables: {}, + }) +} + +export const InfraPlatform: InfraPlatformDef = { + getIsSMTPEnabled: async () => { + const res = await getSMTPStatus() + + if (E.isRight(res)) { + return E.right(res.right.isSMTPEnabled) + } + + return E.left("SMTP_STATUS_FETCH_FAILED") + }, + getProxyAppUrl: async () => { + const res = await getProxyAppUrl() + + if (E.isRight(res)) { + return E.right(res.right.proxyAppUrl) + } + + return E.left("PROXY_APP_URL_FETCH_FAILED") + }, +} diff --git a/packages/hoppscotch-selfhost-web/src/platform/instance/desktop/index.ts b/packages/hoppscotch-selfhost-web/src/platform/instance/desktop/index.ts new file mode 100644 index 0000000..5054a7f --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/platform/instance/desktop/index.ts @@ -0,0 +1,1321 @@ +import { pipe } from "fp-ts/function" +import * as E from "fp-ts/Either" +import * as A from "fp-ts/Array" +import * as Ord from "fp-ts/Ord" +import * as N from "fp-ts/number" +import * as TE from "fp-ts/TaskEither" +import * as O from "fp-ts/Option" +import { map } from "rxjs/operators" + +import { + download, + load, + close, + clear, + remove, + type DownloadResponse, + type LoadOptions, + type LoadResponse, + type CloseResponse, + type RemoveResponse, +} from "@hoppscotch/plugin-appload" + +import { Service } from "dioc" +import { BehaviorSubject, Observable } from "rxjs" +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow" + +import { + Instance, + ConnectionState, + OperationResult, + InstanceKind, + InstancePlatformDef, + VENDORED_INSTANCE_CONFIG, +} from "@hoppscotch/common/platform/instance" + +import { Store } from "@app/kernel/store" +import { getKernelMode } from "@hoppscotch/kernel" +import { getService } from "@hoppscotch/common/modules/dioc" + +const STORE_NAMESPACE = "hoppscotch-desktop.v1" + +type RecentInstances = Instance[] + +const sortByLastUsedDesc = A.sort( + pipe( + N.Ord, + Ord.reverse, + Ord.contramap((instance: Instance) => new Date(instance.lastUsed).getTime()) + ) +) + +export class DesktopInstanceService + extends Service + implements InstancePlatformDef +{ + public static readonly ID = "DESKTOP_INSTANCE_SERVICE" + + private connectionState$ = new BehaviorSubject( + this.getDefaultConnectionState() + ) + private recentInstances$ = new BehaviorSubject( + this.getDefaultRecentInstances() + ) + + /** + * Enable instance switching for this platform, adherence to platform def + */ + public readonly instanceSwitchingEnabled: boolean = + getKernelMode() === "desktop" + + /** + * Configuration options for instance management + */ + public readonly config: InstancePlatformDef["config"] = { + maxRecentInstances: 10, + defaultWindowOptions: { + title: "Hoppscotch", + width: 1200, + height: 800, + resizable: true, + }, + autoReconnect: false, + connectionTimeout: 30000, + confirmDestructiveOperations: true, + } + + /** + * Gets observable stream of connection state + */ + public getConnectionStateStream(): Observable { + return this.connectionState$.asObservable() + } + + /** + * Gets observable stream of recent instances + */ + public getRecentInstancesStream(): Observable { + return this.recentInstances$.asObservable() + } + + /** + * Gets observable stream of the current active instance + */ + public getCurrentInstanceStream(): Observable { + return this.getConnectionStateStream().pipe( + map((state) => (state.status === "connected" ? state.instance : null)) + ) + } + + /** + * Gets current connection state synchronously + */ + public getCurrentConnectionState(): ConnectionState { + return this.connectionState$.value + } + + /** + * Gets recent instances synchronously + */ + public getRecentInstances(): RecentInstances { + return this.recentInstances$.value + } + + /** + * Gets the current active instance synchronously + */ + public getCurrentInstance(): Instance | null { + const state = this.getCurrentConnectionState() + return state.status === "connected" ? state.instance : null + } + + /** + * Connects to an instance + */ + public async connectToInstance( + serverUrl: string, + instanceKind: InstanceKind, + displayName?: string, + options?: Partial + ): Promise { + console.log(`[InstanceService] Connecting to: ${serverUrl}`) + + if (this.beforeConnect) { + const shouldContinue = await this.beforeConnect( + serverUrl, + instanceKind, + displayName + ) + if (!shouldContinue) { + console.log( + `[InstanceService] Connection cancelled by beforeConnect hook` + ) + return { + success: false, + message: "Connection cancelled by beforeConnect hook", + } + } + } + + const result = await this.connectToInstanceTE( + serverUrl, + instanceKind, + displayName, + options + )() + + const operationResult = pipe( + result, + E.fold( + (error): OperationResult => { + console.log(`[InstanceService] Connection failed: ${error}`) + if (this.onConnectionError) { + this.onConnectionError(error, serverUrl) + } + return { success: false, message: error } + }, + (operationResult) => { + console.log(`[InstanceService] Connection successful`) + return operationResult + } + ) + ) + + if (operationResult.success && this.afterConnect) { + const currentInstance = this.getCurrentInstance() + if (currentInstance) { + await this.afterConnect(currentInstance) + } + } + + return operationResult + } + + /** + * Downloads an instance without connecting + */ + public async downloadInstance( + serverUrl: string, + instanceKind: InstanceKind, + displayName?: string + ): Promise { + console.log(`[InstanceService] Downloading instance: ${serverUrl}`) + + const result = await this.downloadInstanceTE( + serverUrl, + instanceKind, + displayName + )() + + return pipe( + result, + E.fold( + (error): OperationResult => { + console.log(`[InstanceService] Download failed: ${error}`) + if (this.onDownloadError) { + this.onDownloadError(error, serverUrl) + } + return { success: false, message: error } + }, + (instance): OperationResult => { + console.log( + `[InstanceService] Download successful: ${instance.displayName}` + ) + return { + success: true, + message: `Successfully downloaded ${instance.displayName}`, + data: instance, + } + } + ) + ) + } + + /** + * Loads a previously downloaded instance + */ + public async loadInstance( + instance: Instance, + options?: Partial + ): Promise { + console.log(`[InstanceService] Loading instance: ${instance.displayName}`) + + const result = await this.loadInstanceTE(instance, options)() + + return pipe( + result, + E.fold( + (error): OperationResult => { + console.log(`[InstanceService] Load failed: ${error}`) + if (this.onLoadError) { + this.onLoadError(error, instance) + } + return { success: false, message: error } + }, + (loadResponse): OperationResult => { + console.log( + `[InstanceService] Load successful: ${instance.displayName}` + ) + return { + success: true, + message: `Successfully loaded ${instance.displayName}`, + data: loadResponse, + } + } + ) + ) + } + + /** + * Removes an instance and its data (except vendored instance) + */ + public async removeInstance(instance: Instance): Promise { + if (instance.kind === "vendored") { + console.log( + `[InstanceService] Attempted to remove vendored instance: ${instance.displayName}` + ) + return { + success: false, + message: "Built-in instances cannot be removed", + } + } + + console.log(`[InstanceService] Removing instance: ${instance.displayName}`) + + if (this.beforeRemove) { + const shouldContinue = await this.beforeRemove(instance) + if (!shouldContinue) { + console.log(`[InstanceService] Removal cancelled by beforeRemove hook`) + return { + success: false, + message: "Removal cancelled by beforeRemove hook", + } + } + } + + const result = await this.removeInstanceTE(instance)() + + const operationResult = pipe( + result, + E.fold( + (error): OperationResult => { + console.log(`[InstanceService] Removal failed: ${error}`) + if (this.onRemoveError) { + this.onRemoveError(error, instance) + } + return { success: false, message: error } + }, + (operationResult) => { + console.log(`[InstanceService] Removal successful`) + return operationResult + } + ) + ) + + if (operationResult.success && this.afterRemove) { + await this.afterRemove(instance) + } + + return operationResult + } + + /** + * Disconnects from current instance + */ + public async disconnect(): Promise { + console.log(`[InstanceService] Disconnecting`) + + const currentInstance = this.getCurrentInstance() + + if (currentInstance && this.beforeDisconnect) { + const shouldContinue = await this.beforeDisconnect(currentInstance) + if (!shouldContinue) { + console.log( + `[InstanceService] Disconnection cancelled by beforeDisconnect hook` + ) + return { + success: false, + message: "Disconnection cancelled by beforeDisconnect hook", + } + } + } + + const result = await this.disconnectTE()() + + const operationResult = pipe( + result, + E.fold( + (error): OperationResult => { + console.log(`[InstanceService] Disconnection failed: ${error}`) + return { success: false, message: error } + }, + (operationResult) => { + console.log(`[InstanceService] Disconnection successful`) + return operationResult + } + ) + ) + + if (operationResult.success && this.afterDisconnect) { + await this.afterDisconnect() + } + + return operationResult + } + + /** + * Clears all cached instances + */ + public async clearCache(): Promise { + console.log(`[InstanceService] Clearing cache`) + + const result = await this.clearCacheTE()() + + return pipe( + result, + E.fold( + (error): OperationResult => { + console.log(`[InstanceService] Cache clear failed: ${error}`) + return { success: false, message: error } + }, + (operationResult) => { + console.log(`[InstanceService] Cache cleared successfully`) + return operationResult + } + ) + ) + } + + /** + * Closes a window + */ + public async closeWindow(windowLabel?: string): Promise { + console.log(`[InstanceService] Closing window: ${windowLabel || "current"}`) + + const result = await pipe( + this.performCloseTE(windowLabel), + TE.chain((response) => + this.validateCloseResponseTE(response, windowLabel) + ) + )() + + return pipe( + result, + E.fold( + (error): OperationResult => { + console.log(`[InstanceService] Window close failed: ${error}`) + return { success: false, message: error } + }, + (closeResponse): OperationResult => { + console.log(`[InstanceService] Window close successful`) + return { + success: true, + message: `Successfully closed window ${windowLabel || "current"}`, + data: closeResponse, + } + } + ) + ) + } + + /** + * Normalizes a URL + */ + public normalizeUrl(url: string): string | null { + return pipe( + this.normalizeUrlE(url), + E.fold( + () => null, + (normalized) => normalized + ) + ) + } + + /** + * Validates if a server URL is reachable and compatible + */ + public async validateServerUrl(url: string): Promise<{ + valid: boolean + version?: string + instanceKind?: InstanceKind + error?: string + }> { + console.log(`[InstanceService] Validating server URL: ${url}`) + + const normalizedUrl = this.normalizeUrl(url) + if (!normalizedUrl) { + return { + valid: false, + error: "Invalid URL format", + } + } + + try { + const result = await this.performDownloadTE(normalizedUrl)() + + return pipe( + result, + E.fold( + ( + error + ): { + valid: boolean + version?: string + instanceKind?: InstanceKind + error?: string + } => ({ + valid: false, + error, + }), + ( + response + ): { + valid: boolean + version?: string + instanceKind?: InstanceKind + error?: string + } => { + if (response.success) { + console.log(`[InstanceService] URL validation successful`) + return { + valid: true, + version: response.version, + instanceKind: "on-prem" as InstanceKind, + } + } else { + return { + valid: false, + error: "Server validation failed", + } + } + } + ) + ) + } catch (error) { + console.log(`[InstanceService] URL validation failed: ${error}`) + return { + valid: false, + error: `Validation failed: ${error}`, + } + } + } + + public beforeConnect?: ( + serverUrl: string, + instanceKind: InstanceKind, + displayName?: string + ) => Promise + + public afterConnect?: (instance: Instance) => Promise + + public beforeDisconnect?: (instance: Instance) => Promise + + public afterDisconnect?: () => Promise + + public beforeRemove?: (instance: Instance) => Promise + + public afterRemove?: (instance: Instance) => Promise + + public onConnectionError?: (error: string, target: string) => void + + public onDownloadError?: (error: string, serverUrl: string) => void + + public onLoadError?: (error: string, instance: Instance) => void + + public onRemoveError?: (error: string, instance: Instance) => void + + private getDefaultRecentInstances(): RecentInstances { + return [] + } + + private getDefaultConnectionState(): ConnectionState { + return { status: "idle" } + } + + private loadRecentInstancesTE(): TE.TaskEither { + console.log("[DEBUG] loadRecentInstancesTE called") + + return pipe( + TE.tryCatch( + async () => { + console.log("[DEBUG] About to call Store.get for recentInstances") + const result = await Store.get( + STORE_NAMESPACE, + "recentInstances" + ) + console.log("[DEBUG] Store.get result:", result) + return result + }, + (error) => { + console.error("[DEBUG] Store.get failed:", error) + return `Failed to load recent instances: ${error}` + } + ), + TE.map( + E.fold( + (storeError) => { + console.log("[DEBUG] Store.get returned Left (error):", storeError) + const defaultInstances = this.getDefaultRecentInstances() + console.log("[DEBUG] Using default instances:", defaultInstances) + return defaultInstances + }, + (instances) => { + console.log( + "[DEBUG] Store.get returned Right (success):", + instances + ) + + return pipe( + O.fromNullable(instances), + O.fold( + () => { + console.log( + "[DEBUG] Instances is null/undefined, using default" + ) + const defaultInstances = this.getDefaultRecentInstances() + console.log("[DEBUG] Default instances:", defaultInstances) + return defaultInstances + }, + (instances) => { + console.log("[DEBUG] Processing loaded instances:", instances) + + const processed = pipe( + instances, + sortByLastUsedDesc, + this.config?.maxRecentInstances + ? A.takeLeft(this.config.maxRecentInstances) + : (x) => x + ) + + console.log("[DEBUG] After sorting and limiting:", processed) + return processed + } + ) + ) + } + ) + ) + ) + } + + private loadConnectionStateTE(): TE.TaskEither { + return pipe( + TE.tryCatch( + () => Store.get(STORE_NAMESPACE, "connectionState"), + (error) => `Failed to load connection state: ${error}` + ), + TE.map( + E.fold( + () => this.getDefaultConnectionState(), + (state) => + pipe( + O.fromNullable(state), + O.getOrElse(() => this.getDefaultConnectionState()) + ) + ) + ) + ) + } + + private initializeServiceTE(): TE.TaskEither< + string, + { + instances: RecentInstances + state: ConnectionState + } + > { + return pipe( + pipe( + TE.tryCatch( + () => Store.init(), + (error) => `Store initialization failed: ${error}` + ), + TE.chainEitherK(E.mapLeft(() => "Failed to initialize store")) + ), + TE.chain(() => + pipe( + TE.Do, + TE.bind("instances", () => this.loadRecentInstancesTE()), + TE.bind("state", () => this.loadConnectionStateTE()) + ) + ) + ) + } + + private updateConnectionState(state: ConnectionState): void { + this.connectionState$.next(state) + this.emit(state) + } + + private updateRecentInstances(instances: RecentInstances): void { + this.recentInstances$.next(instances) + } + + override async onServiceInit(): Promise { + console.log(`[InstanceService] Initializing service`) + + await pipe( + this.initializeServiceTE(), + TE.chain(({ instances, state }) => { + console.log("[DEBUG] initializeServiceTE returned:", { + instances, + state, + }) + + this.updateRecentInstances(instances) + this.updateConnectionState(state) + + return pipe( + TE.of({ instances, state }), + TE.chainFirst(() => this.initializeVendoredInstanceTE()), + TE.map(({ instances, state }) => { + console.log( + "[DEBUG] Before updating state - loaded instances:", + instances + ) + console.log( + "[DEBUG] Before updating state - connection state:", + state + ) + console.log( + "[DEBUG] Current BehaviorSubject instances:", + this.recentInstances$.value + ) + + console.log( + `[InstanceService] Service initialized with ${this.recentInstances$.value.length} recent instances` + ) + + this.updateConnectionState(state) + + console.log( + "[DEBUG] After updateConnectionState, BehaviorSubject value:", + this.connectionState$.value + ) + }) + ) + }), + TE.fold( + (error) => async () => { + console.error("Service initialization failed:", error) + this.updateConnectionState({ + status: "error", + target: "store", + message: error, + }) + }, + () => async () => { + console.log(`[InstanceService] Service initialization complete`) + console.log( + "[DEBUG] Final recentInstances$ value:", + this.recentInstances$.value + ) + console.log( + "[DEBUG] Final connectionState$ value:", + this.connectionState$.value + ) + } + ) + )() + } + + private initializeVendoredInstanceTE(): TE.TaskEither { + console.log(`[InstanceService] Checking for vendored instance`) + + return pipe( + TE.of(this.recentInstances$.value), + TE.chain((currentInstances) => { + const hasVendored = currentInstances.some((i) => i.kind === "vendored") + + if (hasVendored) { + console.log( + `[InstanceService] Vendored instance already exists, skipping` + ) + return TE.of(undefined) + } else { + console.log( + `[InstanceService] Vendored instance missing, adding to recent instances` + ) + return pipe( + TE.of(VENDORED_INSTANCE_CONFIG), + TE.chainFirst((instance) => this.addToRecentInstancesTE(instance)), + TE.map(() => undefined) + ) + } + }) + ) + } + + private setConnectionState( + newState: ConnectionState + ): TE.TaskEither { + return pipe( + TE.tryCatch( + () => Store.set(STORE_NAMESPACE, "connectionState", newState), + (error) => `Failed to save connection state: ${error}` + ), + TE.map((): ConnectionState => { + this.updateConnectionState(newState) + return newState + }) + ) + } + + private setRecentInstances( + newInstances: RecentInstances + ): TE.TaskEither { + console.log("[DEBUG] setRecentInstances called with:", newInstances) + + const limitedInstances = this.config?.maxRecentInstances + ? pipe(newInstances, A.takeLeft(this.config.maxRecentInstances)) + : newInstances + + console.log( + "[DEBUG] After applying maxRecentInstances limit:", + limitedInstances + ) + console.log( + "[DEBUG] maxRecentInstances config:", + this.config?.maxRecentInstances + ) + + return pipe( + TE.tryCatch( + async () => { + console.log("[DEBUG] About to call Store.set with:", limitedInstances) + const result = await Store.set( + STORE_NAMESPACE, + "recentInstances", + limitedInstances + ) + console.log("[DEBUG] Store.set result:", result) + return result + }, + (error) => { + console.error("[DEBUG] Store.set failed with error:", error) + return `Failed to save recent instances: ${error}` + } + ), + TE.map((_storeResult): RecentInstances => { + console.log("[DEBUG] Store.set succeeded, updating BehaviorSubject") + console.log( + "[DEBUG] Before updateRecentInstances, current value:", + this.recentInstances$.value + ) + this.updateRecentInstances(limitedInstances) + console.log( + "[DEBUG] After updateRecentInstances, new value:", + this.recentInstances$.value + ) + return limitedInstances + }) + ) + } + + private normalizeUrlE(url: string): E.Either { + return pipe( + E.tryCatch( + () => { + const parsed = new URL(url.includes("://") ? url : `http://${url}`) + return `${parsed.protocol}//${parsed.host}/desktop-app-server` + }, + (error) => `Failed to normalize URL: ${error}` + ) + ) + } + + private setConnectingStateTE( + target: string + ): TE.TaskEither { + console.log(`[InstanceService] Setting connecting state for: ${target}`) + return this.setConnectionState({ status: "connecting", target }) + } + + private performDownloadTE( + serverUrl: string + ): TE.TaskEither { + console.log(`[InstanceService] Downloading from: ${serverUrl}`) + + const timeout = this.config?.connectionTimeout || 30000 + + return TE.tryCatch( + () => + Promise.race([ + download({ serverUrl }), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Connection timeout after ${timeout}ms`)), + timeout + ) + ), + ]), + (error) => `Failed to download instance: ${error}` + ) + } + + private validateDownloadResponseTE( + response: DownloadResponse, + serverUrl: string + ): TE.TaskEither { + return response.success + ? TE.right(response) + : TE.left(`Download failed for ${serverUrl}`) + } + + private createInstanceFromDownload( + response: DownloadResponse, + instanceKind: InstanceKind, + displayName?: string + ): Instance { + const finalDisplayName = pipe( + O.fromNullable(displayName), + O.getOrElse(() => response.serverUrl), + (rawName) => + pipe( + E.tryCatch( + () => + new URL(rawName.includes("://") ? rawName : `http://${rawName}`), + () => rawName + ), + E.fold( + (fallback) => fallback, + (urlObj) => { + const hostnameParts = urlObj.hostname.split(".") + const mainDomain = hostnameParts.slice(-2).join(".") + + return mainDomain === "hoppscotch" + ? "Hoppscotch" + : mainDomain || urlObj.hostname.replace(/^www\./, "") + } + ) + ) + ) + + return { + kind: instanceKind, + serverUrl: response.serverUrl, + displayName: finalDisplayName, + version: response.version, + lastUsed: new Date().toISOString(), + bundleName: response.bundleName, + } + } + + private downloadInstanceTE( + serverUrl: string, + instanceKind: InstanceKind, + displayName?: string + ): TE.TaskEither { + return pipe( + this.normalizeUrlE(serverUrl), + TE.fromEither, + TE.chain((normalizedUrl) => + pipe( + this.setConnectingStateTE(normalizedUrl), + TE.chain(() => this.performDownloadTE(normalizedUrl)), + TE.chain((response) => + this.validateDownloadResponseTE(response, normalizedUrl) + ), + TE.map((response) => + this.createInstanceFromDownload(response, instanceKind, displayName) + ) + ) + ) + ) + } + + private getBundleNameTE(instance: Instance): TE.TaskEither { + return pipe( + O.fromNullable(instance.bundleName), + TE.fromOption(() => `Instance ${instance.displayName} has no bundle name`) + ) + } + + private buildLoadOptions( + instance: Instance, + options?: Partial + ): LoadOptions { + const defaultWindowOptions = this.config?.defaultWindowOptions || { + title: "Hoppscotch", + width: 1200, + height: 800, + resizable: true, + } + + return { + bundleName: instance.bundleName!, + inline: options?.inline ?? true, + window: options?.window ?? defaultWindowOptions, + } + } + + private performLoadTE( + loadOptions: LoadOptions + ): TE.TaskEither { + console.log( + `[InstanceService] Loading with bundle: ${loadOptions.bundleName}` + ) + + return TE.tryCatch( + () => load(loadOptions), + (error) => `Failed to load instance: ${error}` + ) + } + + private validateLoadResponseTE( + response: LoadResponse, + displayName: string + ): TE.TaskEither { + return response.success + ? TE.right(response) + : TE.left(`Failed to load instance ${displayName}`) + } + + private updateInstanceStateTE( + instance: Instance + ): TE.TaskEither { + console.log(`[InstanceService] Updating instance state to connected`) + return this.setConnectionState({ status: "connected", instance }) + } + + private performCloseTE( + windowLabel?: string + ): TE.TaskEither { + console.log(`[InstanceService] Closing window: ${windowLabel || "current"}`) + + return TE.tryCatch( + async () => { + const currentWindow = getCurrentWebviewWindow() + const labelToClose = windowLabel || currentWindow.label + + if (labelToClose === "main") { + throw new Error("Cannot close main window") + } + + const closeResponse = await close({ windowLabel: labelToClose }) + return closeResponse + }, + (error) => `Failed to close window: ${error}` + ) + } + + private validateCloseResponseTE( + response: CloseResponse, + windowLabel?: string + ): TE.TaskEither { + return response.success + ? TE.right(response) + : TE.left(`Failed to close window ${windowLabel || "current"}`) + } + + private loadInstanceTE( + instance: Instance, + options?: Partial + ): TE.TaskEither { + let loadedInstance: Instance = instance + return pipe( + instance.kind === "vendored" + ? TE.of(undefined) + : TE.tryCatch( + async () => { + console.log( + `[InstanceService] Ensuring bundle is available for: ${instance.displayName}` + ) + const dlResp = await download({ serverUrl: instance.serverUrl }) + loadedInstance = { + ...instance, + version: dlResp.version, + bundleName: dlResp.bundleName, + } + }, + (error) => `Failed to ensure bundle is available: ${error}` + ), + TE.chain(() => this.getBundleNameTE(loadedInstance)), + TE.map(() => this.buildLoadOptions(loadedInstance, options)), + TE.chain((loadOptions) => this.performLoadTE(loadOptions)), + TE.chain((response) => + this.validateLoadResponseTE(response, loadedInstance.displayName) + ), + TE.chainFirst(() => this.updateInstanceStateTE(loadedInstance)), + TE.chainFirst(() => this.updateInstanceLastUsed(loadedInstance)), + TE.chainFirst((_loadResponse) => + TE.fromTask(async () => { + try { + const closeResult = await this.performCloseTE()() + if (E.isRight(closeResult)) { + const validateResult = await this.validateCloseResponseTE( + closeResult.right + )() + if (E.isRight(validateResult)) { + console.log(`Window closed successfully after load`) + } else { + console.warn( + `Window close validation failed but load succeeded: ${validateResult.left}` + ) + } + } else { + console.warn( + `Window close failed but load succeeded: ${closeResult.left}` + ) + } + } catch (error) { + console.warn(`Window close error but load succeeded: ${error}`) + } + return undefined + }) + ) + ) + } + + private performRemoveTE( + bundleName: string, + serverUrl: string + ): TE.TaskEither { + console.log(`[InstanceService] Removing bundle: ${bundleName}`) + + return TE.tryCatch( + () => remove({ bundleName, serverUrl }), + (error) => `Failed to remove instance: ${error}` + ) + } + + private validateRemoveResponseTE( + response: RemoveResponse, + displayName: string + ): TE.TaskEither { + return response.success + ? TE.right(response) + : TE.left(`Failed to remove instance ${displayName}`) + } + + private removeFromRecentInstancesTE( + serverUrl: string + ): TE.TaskEither { + return pipe( + this.recentInstances$.value, + A.filter((i) => { + // NOTE: Never remove vendored instances from recent instances list. + if (i.kind === "vendored") { + console.log( + `[InstanceService] Preserving vendored instance: ${i.displayName}` + ) + return true + } + return i.serverUrl !== serverUrl + }), + (filtered) => this.setRecentInstances(filtered) + ) + } + + private removeInstanceTE( + instance: Instance + ): TE.TaskEither { + return pipe( + O.fromNullable(instance.bundleName), + O.fold( + () => + TE.left(`Instance ${instance.displayName} has no bundle to remove`), + (bundleName) => + pipe( + this.performRemoveTE(bundleName, instance.serverUrl), + TE.chain((response) => + this.validateRemoveResponseTE(response, instance.displayName) + ), + TE.chainFirst(() => + this.removeFromRecentInstancesTE(instance.serverUrl) + ), + TE.map( + (): OperationResult => ({ + success: true, + message: `Successfully removed ${instance.displayName}`, + }) + ) + ) + ) + ) + } + + private clearCacheTE(): TE.TaskEither { + return pipe( + TE.tryCatch( + () => clear(), + (error) => `Failed to clear cache: ${error}` + ), + TE.chainFirst(() => { + const vendoredInstance = this.recentInstances$.value.find( + (i) => i.kind === "vendored" + ) + const instancesToKeep = vendoredInstance ? [vendoredInstance] : [] + return this.setRecentInstances(instancesToKeep) + }), + TE.map( + (): OperationResult => ({ + success: true, + message: "Cache cleared successfully", + }) + ) + ) + } + + private createOrGetInstanceTE( + serverUrl: string, + instanceKind: InstanceKind, + displayName?: string + ): TE.TaskEither { + return pipe( + this.findInstanceByUrl(serverUrl), + O.fold( + () => { + console.log( + `[InstanceService] Instance not found, downloading new: ${serverUrl}` + ) + return pipe( + this.downloadInstanceTE(serverUrl, instanceKind, displayName), + TE.chainFirst((newInstance) => + this.addToRecentInstancesTE(newInstance) + ) + ) + }, + (existing) => { + console.log( + `[InstanceService] Using existing instance: ${existing.displayName}` + ) + return TE.right(existing) + } + ) + ) + } + + private addToRecentInstancesTE( + instance: Instance + ): TE.TaskEither { + console.log( + `[InstanceService] Adding to recent instances: ${instance.displayName}` + ) + + console.log( + "[DEBUG] Current recentInstances$ before adding:", + this.recentInstances$.value + ) + + return pipe( + this.recentInstances$.value, + A.prepend(instance), + (withNew) => { + console.log("[DEBUG] After prepending:", withNew) + return withNew + }, + sortByLastUsedDesc, + (afterSort) => { + console.log("[DEBUG] After sorting:", afterSort) + return afterSort + }, + (updated) => { + console.log("[DEBUG] About to save to store:", updated) + return this.setRecentInstances(updated) + } + ) + } + + private connectToInstanceTE( + serverUrl: string, + instanceKind: InstanceKind, + displayName?: string, + options?: Partial + ): TE.TaskEither { + return pipe( + this.normalizeUrlE(serverUrl), + TE.fromEither, + TE.chain((normalizedUrl) => + pipe( + this.createOrGetInstanceTE(normalizedUrl, instanceKind, displayName), + TE.chain((instance) => this.loadInstanceTE(instance, options)), + TE.map( + (): OperationResult => ({ + success: true, + message: `Successfully connected to ${displayName || serverUrl}`, + }) + ) + ) + ) + ) + } + + private findInstanceByUrl(serverUrl: string): O.Option { + const extractHost = (url: string): string => + url.startsWith("app://") + ? url.split("/")[2] + : pipe( + E.tryCatch( + () => new URL(url).host, + () => url + ), + E.getOrElse((fallback) => fallback) + ) + + const targetHost = extractHost(serverUrl) + + console.log( + `[InstanceService] Looking up ${targetHost} in ${JSON.stringify(this.recentInstances$.value, null, 2)}` + ) + + return pipe( + this.recentInstances$.value, + A.findFirst((instance) => extractHost(instance.serverUrl) === targetHost) + ) + } + + private updateInstanceLastUsed( + instance: Instance + ): TE.TaskEither { + console.log( + `[InstanceService] Updating last used for: ${instance.displayName}` + ) + + return pipe( + this.recentInstances$.value, + A.map((i) => + i.serverUrl === instance.serverUrl + ? { + ...i, + version: instance.version, + bundleName: instance.bundleName, + lastUsed: new Date().toISOString(), + } + : i + ), + sortByLastUsedDesc, + (updated) => this.setRecentInstances(updated), + TE.map(() => undefined) + ) + } + + private disconnectTE(): TE.TaskEither { + return pipe( + this.performCloseTE(), + TE.chain((response) => this.validateCloseResponseTE(response)), + TE.map( + (): OperationResult => ({ + success: true, + message: "Disconnected successfully", + }) + ), + TE.orElse((error) => { + if (error.includes("Cannot close main window")) { + console.log( + `[InstanceService] Disconnected (main window remains open)` + ) + return TE.right({ + success: true, + message: "Disconnected (main window remains open)", + }) + } + return TE.left(error) + }) + ) + } +} + +const desktopInstanceService = getService(DesktopInstanceService) + +export const def: InstancePlatformDef = desktopInstanceService diff --git a/packages/hoppscotch-selfhost-web/src/platform/instance/web/index.ts b/packages/hoppscotch-selfhost-web/src/platform/instance/web/index.ts new file mode 100644 index 0000000..6f984cd --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/platform/instance/web/index.ts @@ -0,0 +1,120 @@ +import { Service } from "dioc" +import { Observable, of } from "rxjs" + +import { + Instance, + ConnectionState, + OperationResult, + InstanceKind, + InstancePlatformDef, +} from "@hoppscotch/common/src/platform/instance" +import { getService } from "@hoppscotch/common/modules/dioc" + +export class WebInstanceService + extends Service + implements InstancePlatformDef +{ + public static readonly ID = "WEB_INSTANCE_SERVICE" + + /** + * Disable instance switching for web platform + */ + public readonly instanceSwitchingEnabled: boolean = false + + /** + * Web platform doesn't need most of these methods, but we implement them + * to satisfy the interface contract + */ + + public getConnectionStateStream?(): Observable { + return of({ status: "idle" }) + } + + public getRecentInstancesStream?(): Observable { + return of([]) + } + + public getCurrentInstanceStream?(): Observable { + return of(null) + } + + public getCurrentConnectionState?(): ConnectionState { + return { status: "idle" } + } + + public getRecentInstances?(): Instance[] { + return [] + } + + public getCurrentInstance?(): Instance | null { + return null + } + + public async connectToInstance?( + _serverUrl: string, + _instanceKind: InstanceKind, + _displayName?: string + ): Promise { + return { + success: false, + message: "Instance switching not supported on web platform", + } + } + + public async downloadInstance?( + _serverUrl: string, + _instanceKind: InstanceKind, + _displayName?: string + ): Promise { + return { + success: false, + message: "Instance downloading not supported on web platform", + } + } + + public async loadInstance?(): Promise { + return { + success: false, + message: "Instance loading not supported on web platform", + } + } + + public async removeInstance?(): Promise { + return { + success: false, + message: "Instance removal not supported on web platform", + } + } + + public async disconnect?(): Promise { + return { + success: false, + message: "Disconnect not supported on web platform", + } + } + + public async clearCache?(): Promise { + return { + success: false, + message: "Cache clearing not supported on web platform", + } + } + + public normalizeUrl?(_url: string): string | null { + return null + } + + public async validateServerUrl?(): Promise<{ + valid: boolean + error?: string + }> { + return { + valid: false, + error: "Server validation not supported on web platform", + } + } +} + +const webInstanceService = getService(WebInstanceService) + +export const def: InstancePlatformDef = webInstanceService diff --git a/packages/hoppscotch-selfhost-web/src/services/headerDownloadableLinks.service.ts b/packages/hoppscotch-selfhost-web/src/services/headerDownloadableLinks.service.ts new file mode 100644 index 0000000..b9dd9fe --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/services/headerDownloadableLinks.service.ts @@ -0,0 +1,101 @@ +import { Service } from "dioc" +import { computed, markRaw, Ref, ref } from "vue" +import { installPWA, pwaDefferedPrompt } from "@hoppscotch/common/modules/pwa" +import IconGlobe from "~icons/lucide/globe" +import IconCLI from "~icons/lucide/square-terminal" +import IconApple from "~icons/hopp/apple" +import IconWindows from "~icons/hopp/windows" +import IconLinux from "~icons/hopp/linux" + +import { + AdditionalLinkSet, + AdditionalLinksService, + Link, +} from "@hoppscotch/common/services/additionalLinks.service" + +const macOS: Link = { + id: "whats-new", + text: (t) => t("app.additional_links.macOS"), + icon: markRaw(IconApple), + action: { + type: "link", + href: "https://hoppscotch.com/download?platform=macOS", + }, +} + +const windows: Link = { + id: "windows", + text: (t) => t("app.additional_links.windows"), + icon: markRaw(IconWindows), + action: { + type: "link", + href: "https://hoppscotch.com/download?platform=windows", + }, +} + +const linux: Link = { + id: "linux", + text: (t) => t("app.additional_links.linux"), + icon: markRaw(IconLinux), + action: { + type: "link", + href: "https://hoppscotch.com/download?platform=linux", + }, +} + +const pwa: Link = { + id: "pwa", + text: (t) => t("app.additional_links.web_app"), + icon: IconGlobe, + action: { + type: "custom", + do: () => { + installPWA() + }, + }, + show: computed(() => !!pwaDefferedPrompt.value), +} + +const cli: Link = { + id: "cli", + text: (t) => t("app.additional_links.cli"), + icon: IconCLI, + action: { + type: "link", + href: "https://docs.hoppscotch.io/documentation/clients/cli/overview", + }, +} + +/** + * Service to manage the downloadable links in the app header. + */ +export class HeaderDownloadableLinksService + extends Service + implements AdditionalLinkSet +{ + public static readonly ID = "HEADER_DOWNLOADABLE_LINKS_SERVICE" + public readonly linkSetID = "HEADER_DOWNLOADABLE_LINKS" + + private readonly additionalLinkSet = this.bind(AdditionalLinksService) + + /** + * List of downloadable links to be shown in the header + * This includes showing the link to the desktop app, PWA, CLI. + */ + private headerDownloadableLinks = ref([ + macOS, + windows, + linux, + pwa, + cli, + ]) + + override onServiceInit() { + this.additionalLinkSet.registerAdditionalSet(this) + } + + getLinks(): Ref { + // @ts-expect-error show type not recognizing ComputedRef + return this.headerDownloadableLinks + } +} diff --git a/packages/hoppscotch-selfhost-web/src/vite-env.d.ts b/packages/hoppscotch-selfhost-web/src/vite-env.d.ts new file mode 100644 index 0000000..3de2bbf --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module "*.vue" { + import type { DefineComponent } from "vue" + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/packages/hoppscotch-selfhost-web/tailwind.config.ts b/packages/hoppscotch-selfhost-web/tailwind.config.ts new file mode 100644 index 0000000..ea80726 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/tailwind.config.ts @@ -0,0 +1,31 @@ +import { Config } from "tailwindcss" +import preset from "@hoppscotch/ui/ui-preset" + +export default { + content: ["../hoppscotch-common/src/**/*.{vue,html}"], + presets: [preset], + theme: { + extend: { + inset: { + upperPrimaryStickyFold: "var(--upper-primary-sticky-fold)", + upperSecondaryStickyFold: "var(--upper-secondary-sticky-fold)", + upperTertiaryStickyFold: "var(--upper-tertiary-sticky-fold)", + upperFourthStickyFold: "var(--upper-fourth-sticky-fold)", + upperRunnerStickyFold: "var(--upper-runner-sticky-fold)", + upperMobilePrimaryStickyFold: "var(--upper-mobile-primary-sticky-fold)", + upperMobileSecondaryStickyFold: + "var(--upper-mobile-secondary-sticky-fold)", + upperMobileStickyFold: "var(--upper-mobile-sticky-fold)", + upperMobileTertiaryStickyFold: + "var(--upper-mobile-tertiary-sticky-fold)", + lowerPrimaryStickyFold: "var(--lower-primary-sticky-fold)", + lowerSecondaryStickyFold: "var(--lower-secondary-sticky-fold)", + lowerTertiaryStickyFold: "var(--lower-tertiary-sticky-fold)", + lowerFourthStickyFold: "var(--lower-fourth-sticky-fold)", + sidebarPrimaryStickyFold: "var(--sidebar-primary-sticky-fold)", + sidebarSecondaryStickyFold: "var(--line-height-body)", + propertiesPrimaryStickyFold: "var(--properties-primary-sticky-fold)", + }, + }, + }, +} satisfies Config diff --git a/packages/hoppscotch-selfhost-web/tsconfig.json b/packages/hoppscotch-selfhost-web/tsconfig.json new file mode 100644 index 0000000..75c49f7 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Node", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ESNext", "DOM"], + "skipLibCheck": true, + "noEmit": true, + "paths": { + "@hoppscotch/common": ["../hoppscotch-common/src/index.ts"], + "@hoppscotch/common/*": ["../hoppscotch-common/src/*"], + "@app/platform/*": ["./src/platform/*"], + "@app/services/*": ["./src/services/*"], + "@app/components/*": ["./src/components/*"], + "@app/composables/*": ["./src/composables/*"], + "@app/helpers/*": ["./src/helpers/*"], + "@app/api/*": ["./src/api/*"], + "@app/kernel/*": ["./src/kernel/*"] + }, + "types": ["vite/client", "unplugin-icons/types/vue"] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/packages/hoppscotch-selfhost-web/tsconfig.node.json b/packages/hoppscotch-selfhost-web/tsconfig.node.json new file mode 100644 index 0000000..28e5a5e --- /dev/null +++ b/packages/hoppscotch-selfhost-web/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts", "meta.ts"] +} diff --git a/packages/hoppscotch-selfhost-web/vite.config.ts b/packages/hoppscotch-selfhost-web/vite.config.ts new file mode 100644 index 0000000..21df330 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/vite.config.ts @@ -0,0 +1,277 @@ +import { defineConfig, loadEnv, normalizePath } from "vite" +import { APP_INFO, META_TAGS } from "./meta" +import { viteStaticCopy as StaticCopy } from "vite-plugin-static-copy" +import generateSitemap from "vite-plugin-pages-sitemap" +import HtmlConfig from "vite-plugin-html-config" +import Vue from "@vitejs/plugin-vue" +import VueI18n from "@intlify/unplugin-vue-i18n/vite" +import Components from "unplugin-vue-components/vite" +import Icons from "unplugin-icons/vite" +import Inspect from "vite-plugin-inspect" +import { VitePWA } from "vite-plugin-pwa" +import Pages from "vite-plugin-pages" +import Layouts from "vite-plugin-vue-layouts" +import IconResolver from "unplugin-icons/resolver" +import { FileSystemIconLoader } from "unplugin-icons/loaders" +import * as path from "path" +import Unfonts from "unplugin-fonts/vite" +import legacy from "@vitejs/plugin-legacy" +import ImportMetaEnv from "@import-meta-env/unplugin" + +const ENV = loadEnv("development", path.resolve(__dirname, "../../"), ["VITE_"]) + +export default defineConfig({ + envPrefix: process.env.HOPP_ALLOW_RUNTIME_ENV ? "VITE_BUILDTIME_" : "VITE_", + envDir: path.resolve(__dirname, "../../"), + // TODO: Migrate @hoppscotch/data to full ESM + define: { + // For 'util' polyfill required by dep of '@apidevtools/swagger-parser' + "process.env": {}, + "process.platform": '"browser"', + }, + server: { + port: 3000, + }, + preview: { + port: 3000, + }, + publicDir: path.resolve(__dirname, "../hoppscotch-common/public"), + build: { + sourcemap: true, + emptyOutDir: true, + rollupOptions: { + maxParallelFileOps: 2, + }, + }, + worker: { + format: "es", + }, + resolve: { + alias: { + // Config files + "tailwind.config.cjs": path.resolve( + __dirname, + "../hoppscotch-common/tailwind.config.cjs" + ), + "postcss.config.cjs": path.resolve( + __dirname, + "../hoppscotch-common/postcss.config.cjs" + ), + + // TODO: Maybe leave ~ only for individual apps and not use on common + // Common package aliases + "~": path.resolve(__dirname, "../hoppscotch-common/src"), + "@hoppscotch/common": "@hoppscotch/common/src", + + // Common (shared) modules (legacy - TODO: migrate these to @common/*) + "@composables": path.resolve( + __dirname, + "../hoppscotch-common/src/composables" + ), + "@modules": path.resolve(__dirname, "../hoppscotch-common/src/modules"), + "@services": path.resolve(__dirname, "../hoppscotch-common/src/services"), + "@components": path.resolve( + __dirname, + "../hoppscotch-common/src/components" + ), + "@helpers": path.resolve(__dirname, "../hoppscotch-common/src/helpers"), + "@platform": path.resolve(__dirname, "../hoppscotch-common/src/platform"), + "@functional": path.resolve( + __dirname, + "../hoppscotch-common/src/helpers/functional" + ), + "@workers": path.resolve(__dirname, "../hoppscotch-common/src/workers"), + + // Application layer + "@app/platform": path.resolve(__dirname, "./src/platform"), + "@app/services": path.resolve(__dirname, "./src/services"), + "@app/components": path.resolve(__dirname, "./src/components"), + "@app/composables": path.resolve(__dirname, "./src/composables"), + "@app/helpers": path.resolve(__dirname, "./src/helpers"), + "@app/api": path.resolve(__dirname, "./src/api"), + "@app/kernel": path.resolve(__dirname, "./src/kernel"), + + // Node.js polyfills + stream: "stream-browserify", + util: "util", + querystring: "qs", + }, + dedupe: ["vue"], + }, + plugins: [ + Inspect(), // go to url -> /__inspect + HtmlConfig({ + metas: META_TAGS(ENV), + }), + Vue(), + Pages({ + routeStyle: "nuxt", + dirs: ["../hoppscotch-common/src/pages", "./src/pages"], + importMode: "async", + onRoutesGenerated(routes) { + generateSitemap({ + routes, + nuxtStyle: true, + allowRobots: true, + dest: ".sitemap-gen", + hostname: ENV.VITE_BASE_URL, + }) + }, + }), + StaticCopy({ + targets: [ + { + src: normalizePath(path.resolve(__dirname, "./.sitemap-gen/*")), + dest: normalizePath(path.resolve(__dirname, "./dist")), + }, + ], + }), + Layouts({ + layoutsDirs: "../hoppscotch-common/src/layouts", + defaultLayout: "default", + }), + VueI18n({ + runtimeOnly: false, + compositionOnly: true, + include: [path.resolve(__dirname, "locales")], + }), + Components({ + dts: "../hoppscotch-common/src/components.d.ts", + dirs: ["../hoppscotch-common/src/components"], + directoryAsNamespace: true, + resolvers: [ + IconResolver({ + prefix: "icon", + customCollections: ["hopp", "auth", "brands"], + }), + (compName: string) => { + if (compName.startsWith("Hopp")) + return { name: compName, from: "@hoppscotch/ui" } + else return undefined + }, + ], + types: [ + { + from: "vue-tippy", + names: ["Tippy"], + }, + ], + }), + Icons({ + compiler: "vue3", + customCollections: { + hopp: FileSystemIconLoader("../hoppscotch-common/assets/icons"), + auth: FileSystemIconLoader("../hoppscotch-common/assets/icons/auth"), + brands: FileSystemIconLoader( + "../hoppscotch-common/assets/icons/brands" + ), + }, + }), + VitePWA({ + useCredentials: true, + manifest: { + name: APP_INFO.name, + short_name: APP_INFO.name, + description: APP_INFO.shortDescription, + start_url: "/?source=pwa", + id: "/?source=pwa", + protocol_handlers: [ + { + protocol: "web+hoppscotch", + url: "/%s", + }, + { + protocol: "web+hopp", + url: "/%s", + }, + ], + background_color: APP_INFO.app.background, + theme_color: APP_INFO.app.background, + icons: [ + { + src: "/icons/pwa-16x16.png", + sizes: "16x16", + type: "image/png", + }, + { + src: "/icons/pwa-32x32.png", + sizes: "32x32", + type: "image/png", + }, + { + src: "/icons/pwa-128x128.png", + sizes: "128x128", + type: "image/png", + }, + { + src: "/icons/pwa-192x192.png", + sizes: "192x192", + type: "image/png", + }, + { + src: "/icons/pwa-256x256.png", + sizes: "256x256", + type: "image/png", + }, + { + src: "/icons/pwa-512x512.png", + sizes: "512x512", + type: "image/png", + }, + { + src: "/icons/pwa-1024x1024.png", + sizes: "1024x1024", + type: "image/png", + }, + ], + }, + registerType: "prompt", + workbox: { + cleanupOutdatedCaches: true, + maximumFileSizeToCacheInBytes: 15728640, // 15 MB + navigateFallbackDenylist: [ + /robots.txt/, + /sitemap.xml/, + /discord/, + /telegram/, + /beta/, + /careers/, + /newsletter/, + /twitter/, + /github/, + /announcements/, + /admin/, + /backend/, + ], + }, + }), + Unfonts({ + fontsource: { + families: [ + { + name: "Inter Variable", + variables: ["variable-full"], + }, + { + name: "Material Symbols Rounded Variable", + variables: ["variable-full"], + }, + { + name: "Roboto Mono Variable", + variables: ["variable-full"], + }, + ], + }, + }), + legacy({ + modernPolyfills: ["es.string.replace-all"], + renderLegacyChunks: false, + }), + process.env.HOPP_ALLOW_RUNTIME_ENV + ? ImportMetaEnv.vite({ + example: "../../.env.example", + env: "../../.env", + }) + : [], + ], +}) diff --git a/packages/hoppscotch-selfhost-web/webapp-server/.gitignore b/packages/hoppscotch-selfhost-web/webapp-server/.gitignore new file mode 100644 index 0000000..9a18c98 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/webapp-server/.gitignore @@ -0,0 +1,5 @@ +# compiled binary +webapp-server + +# dev mode key directory +.webapp-server/ diff --git a/packages/hoppscotch-selfhost-web/webapp-server/README.md b/packages/hoppscotch-selfhost-web/webapp-server/README.md new file mode 100644 index 0000000..224d00c --- /dev/null +++ b/packages/hoppscotch-selfhost-web/webapp-server/README.md @@ -0,0 +1,108 @@ +# Hoppscotch Webapp Server (Go) + +Static web server for Hoppscotch Webapp with content bundling (zstd + zip) and verification (blake3 + ed25519). + +## Quick Start + +```bash +go build -o webapp-server . +GO_ENV=development ./webapp-server +``` + +or with Docker +```bash +docker build -t hoppscotch-webapp-server . +``` + +## Configuration + +| Variable | Description | Default | +|----------------------------------|------------------------------------------------------------|------------------------------------------------| +| `WEBAPP_SERVER_PORT` | Server port | `3200` | +| `WEBAPP_SERVER_READ_TIMEOUT` | HTTP read timeout (Go duration, e.g. `30s`; `0` disables) | `15s` | +| `WEBAPP_SERVER_WRITE_TIMEOUT` | HTTP write timeout (Go duration, e.g. `30s`; `0` disables) | `15s` | +| `WEBAPP_SERVER_IDLE_TIMEOUT` | HTTP idle timeout (Go duration, e.g. `2m`; `0` disables) | `60s` | +| `FRONTEND_PATH` | Path to frontend assets | `/site/selfhost-web` (prod) or `../dist` (dev) | +| `WEBAPP_SERVER_SIGNING_SECRET` | Secret string for key derivation | None | +| `WEBAPP_SERVER_SIGNING_SEED` | Base64 encoded 32-byte seed | None | +| `WEBAPP_SERVER_SIGNING_KEY` | Base64 encoded 64-byte private key | None | +| `WEBAPP_SERVER_SIGNING_KEY_FILE` | Custom path for key file | `/data/webapp-server/signing.key` | +| `GO_ENV` | Set to `development` for dev mode | None | + +## Signing Key Persistence + +The server needs a stable signing key. Without one, users get "Invalid signature" errors when they have cached bundles from a previous server instance. Keys are resolved in order: + +1. Environment variable: `WEBAPP_SERVER_SIGNING_KEY`, `WEBAPP_SERVER_SIGNING_SEED`, or `WEBAPP_SERVER_SIGNING_SECRET` +2. Key file on disk at `/data/webapp-server/signing.key` +3. Auto-generate and persist to disk +4. Ephemeral fallback (logs the key for manual config) + +For Kubernetes, either mount a persistent volume at `/data/webapp-server` or set `WEBAPP_SERVER_SIGNING_SECRET` to the same value across replicas. + +If the server can't persist to disk, it logs the generated key: + +``` +======================================== +SIGNING KEY PERSISTENCE FAILED +======================================== +Could not save signing key to: /data/webapp-server/signing.key + +To persist this key, set this environment variable: + + WEBAPP_SERVER_SIGNING_KEY= + +Or mount a persistent volume at: + /data/webapp-server +======================================== +``` + +Copy the logged key value and set it as an environment variable before the next restart. + +## API Endpoints + +| Endpoint | Description | +|------------------------|------------------------------------------------| +| `GET /health` | Health check | +| `GET /api/v1/manifest` | Bundle metadata with file hashes and signature | +| `GET /api/v1/bundle` | Download signed ZIP bundle | +| `GET /api/v1/key` | Public verification key | + +All endpoints also available under `/desktop-app-server/` prefix for desktop app compatibility. + +## Architecture + +``` +Frontend files → zstd ZIP → BLAKE3 per file → ED25519 sign → HTTP serve + ↓ + Manifest JSON + (paths, sizes, hashes, MIME types) +``` + +## Bundle Format + +| Component | Method | +|----------------|--------------------------| +| Compression | zstd (ZIP method 93) | +| File hashing | BLAKE3 (base64) | +| Bundle signing | ED25519 over ZIP content | + +## Troubleshooting + +"Invalid signature" after restart: Server generated a new key because persistence wasn't configured. Mount a volume at `/data/webapp-server` or set `WEBAPP_SERVER_SIGNING_SECRET`. + +"Invalid signature" with multiple replicas: Each replica has a different key. Use env var config with the same secret across all replicas. + +Key file permission errors: Container can't write to `/data/webapp-server`. Make it writable or use env var config. + +## Development + +```bash +GO_ENV=development go run . +go test ./... +CGO_ENABLED=0 GOOS=linux go build -o webapp-server . +``` + +## Migration from Rust Version + +Full API and bundle format compatibility with the Rust version. Same ZIP structure, same BLAKE3 hashing, same ED25519 signatures, identical API responses. New feature is automatic signing key persistence. diff --git a/packages/hoppscotch-selfhost-web/webapp-server/go.mod b/packages/hoppscotch-selfhost-web/webapp-server/go.mod new file mode 100644 index 0000000..25e5143 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/webapp-server/go.mod @@ -0,0 +1,15 @@ +module hoppscotch-selfhost-web/webapp-server + +go 1.24.0 + +toolchain go1.24.1 + +require ( + github.com/klauspost/compress v1.18.0 + github.com/zeebo/blake3 v0.2.4 +) + +require ( + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + golang.org/x/sys v0.36.0 // indirect +) diff --git a/packages/hoppscotch-selfhost-web/webapp-server/go.sum b/packages/hoppscotch-selfhost-web/webapp-server/go.sum new file mode 100644 index 0000000..1835ff1 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/webapp-server/go.sum @@ -0,0 +1,12 @@ +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/packages/hoppscotch-selfhost-web/webapp-server/internal/bundle/builder.go b/packages/hoppscotch-selfhost-web/webapp-server/internal/bundle/builder.go new file mode 100644 index 0000000..dd1a16e --- /dev/null +++ b/packages/hoppscotch-selfhost-web/webapp-server/internal/bundle/builder.go @@ -0,0 +1,189 @@ +// Package bundle handles creating and managing frontend bundles. +// +// Bundles are zstd-compressed ZIP archives with blake3 hashes per file +// and an ed25519 signature over the whole thing. + +package bundle + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "fmt" + "io" + "log" + "mime" + "os" + "path/filepath" + "strings" + + "github.com/klauspost/compress/zstd" + "github.com/zeebo/blake3" +) + +// Builder walks frontend files and packs them into a signed bundle +type Builder struct{} + +func NewBuilder() (*Builder, error) { + return &Builder{}, nil +} + +func init() { + // zstd is ZIP method 93 + // see: https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT + zip.RegisterCompressor(ZipMethodZstd, func(w io.Writer) (io.WriteCloser, error) { + return zstd.NewWriter(w) + }) + + // register decompressor for ZIP validation in manager.go + zip.RegisterDecompressor(ZipMethodZstd, func(r io.Reader) io.ReadCloser { + decoder, err := zstd.NewReader(r) + if err != nil { + // return a reader that errors on read + return errReadCloser{err} + } + return decoder.IOReadCloser() + }) +} + +// errReadCloser is a ReadCloser that always returns an error on Read. +type errReadCloser struct { + err error +} + +func (e errReadCloser) Read(p []byte) (int, error) { + return 0, e.err +} + +func (e errReadCloser) Close() error { + return nil +} + +// Build walks frontendPath and creates a zstd-compressed ZIP. +// Returns the raw bytes, file metadata, and any error. +// +// NOTE: compression happens at the ZIP level (each file is zstd'd individually), +// matching the Rust implementation's approach. This plays nice with partial +// downloads if we ever want to support range requests. +func (b *Builder) Build(frontendPath string) ([]byte, []FileEntry, error) { + if _, err := os.Stat(frontendPath); os.IsNotExist(err) { + return nil, nil, fmt.Errorf("frontend path does not exist: %s", frontendPath) + } + + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + + var files []FileEntry + var fileCount int + + err := filepath.Walk(frontendPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("error accessing %s: %w", path, err) + } + if info.IsDir() { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", path, err) + } + + relPath, err := filepath.Rel(frontendPath, path) + if err != nil { + return fmt.Errorf("failed to compute relative path for %s: %w", path, err) + } + + // normalize to forward slashes for cross-platform compat + normalizedPath := filepath.ToSlash(relPath) + + header := &zip.FileHeader{ + Name: normalizedPath, + Method: ZipMethodZstd, + } + header.SetMode(0644) + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return fmt.Errorf("failed to create ZIP entry for %s: %w", relPath, err) + } + + if _, err := writer.Write(content); err != nil { + return fmt.Errorf("failed to write file %s to ZIP: %w", relPath, err) + } + + // blake3 for file integrity checks + hasher := blake3.New() + hasher.Write(content) + hash := hasher.Sum(nil) + + mimeType := detectMimeType(path) + + files = append(files, FileEntry{ + Path: normalizedPath, + Size: info.Size(), + Hash: base64.StdEncoding.EncodeToString(hash), + MimeType: mimeType, + }) + + fileCount++ + return nil + }) + + if err != nil { + return nil, nil, err + } + + if err := zipWriter.Close(); err != nil { + return nil, nil, fmt.Errorf("failed to finalize ZIP archive: %w", err) + } + + log.Printf("Built bundle with %d files (%d bytes)", fileCount, buf.Len()) + return buf.Bytes(), files, nil +} + +// detectMimeType guesses MIME type from extension. +// Returns nil if unknown (matches Rust's Option behavior). +func detectMimeType(path string) *string { + ext := filepath.Ext(path) + if ext == "" { + return nil + } + + // try Go's builtin mime registry first + mimeType := mime.TypeByExtension(ext) + if mimeType != "" { + // strip params like "; charset=utf-8" + if idx := strings.Index(mimeType, ";"); idx != -1 { + mimeType = strings.TrimSpace(mimeType[:idx]) + } + return &mimeType + } + + // handle web-specific types Go doesn't know about + switch strings.ToLower(ext) { + case ".wasm": + m := "application/wasm" + return &m + case ".mjs": + m := "application/javascript" + return &m + case ".tsx", ".ts": + m := "application/typescript" + return &m + case ".vue": + m := "application/vue" + return &m + case ".svelte": + m := "application/svelte" + return &m + case ".json5": + m := "application/json5" + return &m + case ".webmanifest": + m := "application/manifest+json" + return &m + } + + return nil +} diff --git a/packages/hoppscotch-selfhost-web/webapp-server/internal/bundle/manager.go b/packages/hoppscotch-selfhost-web/webapp-server/internal/bundle/manager.go new file mode 100644 index 0000000..11c39fd --- /dev/null +++ b/packages/hoppscotch-selfhost-web/webapp-server/internal/bundle/manager.go @@ -0,0 +1,73 @@ +package bundle + +import ( + "archive/zip" + "bytes" + "crypto/ed25519" + "encoding/base64" + "fmt" + "log" + "sync" + "time" +) + +// Manager holds the bundle in memory and handles signing. +// Thread-safe for concurrent reads (writes only happen at startup). +type Manager struct { + mu sync.RWMutex + bundle *Bundle + maxSize int + signingKey ed25519.PrivateKey + verifyingKey ed25519.PublicKey +} + +// NewManager creates a manager with a pre-built bundle. +// Signs the bundle content immediately so it's ready to serve. +func NewManager( + content []byte, + files []FileEntry, + signingKey ed25519.PrivateKey, + verifyingKey ed25519.PublicKey, + maxSize int, +) (*Manager, error) { + if len(content) > maxSize { + return nil, fmt.Errorf("bundle too large: %d bytes (max: %d)", len(content), maxSize) + } + + // sanity check that we actually have a valid zip + if _, err := zip.NewReader(bytes.NewReader(content), int64(len(content))); err != nil { + return nil, fmt.Errorf("invalid zip archive: %w", err) + } + + // sign the raw bytes, clients will verify against this + signature := ed25519.Sign(signingKey, content) + + bundle := &Bundle{ + Metadata: Metadata{ + Version: Version, + CreatedAt: time.Now().UTC(), + Signature: base64.StdEncoding.EncodeToString(signature), + Manifest: Manifest{Files: files}, + }, + Content: content, + } + + log.Println("Bundle signed and stored successfully") + + return &Manager{ + bundle: bundle, + maxSize: maxSize, + signingKey: signingKey, + verifyingKey: verifyingKey, + }, nil +} + +func (m *Manager) GetBundle() *Bundle { + m.mu.RLock() + defer m.mu.RUnlock() + return m.bundle +} + +func (m *Manager) GetVerifyingKey() ed25519.PublicKey { + return m.verifyingKey +} diff --git a/packages/hoppscotch-selfhost-web/webapp-server/internal/bundle/types.go b/packages/hoppscotch-selfhost-web/webapp-server/internal/bundle/types.go new file mode 100644 index 0000000..c7aef8a --- /dev/null +++ b/packages/hoppscotch-selfhost-web/webapp-server/internal/bundle/types.go @@ -0,0 +1,36 @@ +package bundle + +import "time" + +const ( + Version = "2026.6.0" + + DefaultMaxSize = 50 * 1024 * 1024 + + // zstd compression method for ZIP + // see: https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT + ZipMethodZstd = 93 +) + +type FileEntry struct { + Path string `json:"path"` + Size int64 `json:"size"` + Hash string `json:"hash"` // blake3, base64 encoded + MimeType *string `json:"mime_type"` // nil if unknown +} + +type Manifest struct { + Files []FileEntry `json:"files"` +} + +type Metadata struct { + Version string `json:"version"` + CreatedAt time.Time `json:"created_at"` + Signature string `json:"signature"` // ed25519 over Content, base64 encoded + Manifest Manifest `json:"manifest"` +} + +type Bundle struct { + Metadata Metadata + Content []byte // raw ZIP bytes +} diff --git a/packages/hoppscotch-selfhost-web/webapp-server/internal/config/config.go b/packages/hoppscotch-selfhost-web/webapp-server/internal/config/config.go new file mode 100644 index 0000000..e5f48fa --- /dev/null +++ b/packages/hoppscotch-selfhost-web/webapp-server/internal/config/config.go @@ -0,0 +1,80 @@ +package config + +import ( + "log" + "os" + "strconv" + "time" +) + +const ( + DefaultPort = 3200 + DefaultFrontendPath = "/site/selfhost-web" + DevFrontendPath = "../dist" + + DefaultReadTimeout = 15 * time.Second + DefaultWriteTimeout = 15 * time.Second + DefaultIdleTimeout = 60 * time.Second +) + +type Config struct { + Port int + FrontendPath string + ReadTimeout time.Duration + WriteTimeout time.Duration + IdleTimeout time.Duration +} + +// parseDuration reads a Go duration string from an env var (e.g. "30s", "2m", "0"). +// A value of "0" is valid and disables the timeout (net/http semantics). +// If the variable is unset or invalid, it logs a warning and returns the fallback. +func parseDuration(envKey string, fallback time.Duration) time.Duration { + if s := os.Getenv(envKey); s != "" { + if d, err := time.ParseDuration(s); err == nil && d >= 0 { + if d == 0 { + log.Printf("Using %s from environment: %s (timeout disabled)", envKey, s) + } else { + log.Printf("Using %s from environment: %s", envKey, s) + } + return d + } + log.Printf("Warning: Invalid %s value '%s', using default %v", envKey, s, fallback) + } + return fallback +} + +// Load reads config from env vars with sensible defaults +func Load() *Config { + cfg := &Config{ + Port: DefaultPort, + } + + if portStr := os.Getenv("WEBAPP_SERVER_PORT"); portStr != "" { + if port, err := strconv.Atoi(portStr); err == nil { + cfg.Port = port + log.Printf("Using WEBAPP_SERVER_PORT from environment: %d", port) + } else { + log.Printf("Warning: Invalid WEBAPP_SERVER_PORT value '%s', using default %d", portStr, DefaultPort) + } + } else { + log.Printf("Using default port: %d", DefaultPort) + } + + // NOTE: env var takes priority, then we check GO_ENV for dev mode + if frontendPath := os.Getenv("FRONTEND_PATH"); frontendPath != "" { + cfg.FrontendPath = frontendPath + log.Printf("Using FRONTEND_PATH from environment: %s", frontendPath) + } else if os.Getenv("GO_ENV") == "development" { + cfg.FrontendPath = DevFrontendPath + log.Println("Running in development mode, using frontend path: ../dist") + } else { + cfg.FrontendPath = DefaultFrontendPath + log.Println("Running in production mode, using frontend path: /site/selfhost-web") + } + + cfg.ReadTimeout = parseDuration("WEBAPP_SERVER_READ_TIMEOUT", DefaultReadTimeout) + cfg.WriteTimeout = parseDuration("WEBAPP_SERVER_WRITE_TIMEOUT", DefaultWriteTimeout) + cfg.IdleTimeout = parseDuration("WEBAPP_SERVER_IDLE_TIMEOUT", DefaultIdleTimeout) + + return cfg +} diff --git a/packages/hoppscotch-selfhost-web/webapp-server/internal/crypto/keys.go b/packages/hoppscotch-selfhost-web/webapp-server/internal/crypto/keys.go new file mode 100644 index 0000000..f696ec5 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/webapp-server/internal/crypto/keys.go @@ -0,0 +1,227 @@ +// Package crypto handles ed25519 key generation and persistence. +// +// Key sources (in priority order): +// 1. WEBAPP_SERVER_SIGNING_KEY: full 64-byte private key, base64 +// 2. WEBAPP_SERVER_SIGNING_SEED: 32-byte seed, base64 +// 3. WEBAPP_SERVER_SIGNING_SECRET: any string (SHA-256 derived) +// 4. Key file on disk +// 5. Generate new and try to persist +// +// For production, either mount a volume at /data/webapp-server +// or set one of the WEBAPP_SERVER_SIGNING_* env vars. + +package crypto + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "log" + "os" + "path/filepath" +) + +const ( + DefaultKeyFileName = "signing.key" + DefaultKeyDir = "/data/webapp-server" + DevKeyDir = ".webapp-server" +) + +type KeyPair struct { + SigningKey ed25519.PrivateKey + VerifyingKey ed25519.PublicKey +} + +// GenerateKeyPair gets or creates an ed25519 key pair. +// Tries env vars first, then disk, then generates new. +func GenerateKeyPair() (*KeyPair, error) { + // try env vars first (explicit config always wins) + if keyB64 := os.Getenv("WEBAPP_SERVER_SIGNING_KEY"); keyB64 != "" { + return loadFromBase64Key(keyB64) + } + + if seedB64 := os.Getenv("WEBAPP_SERVER_SIGNING_SEED"); seedB64 != "" { + return loadFromBase64Seed(seedB64) + } + + if secret := os.Getenv("WEBAPP_SERVER_SIGNING_SECRET"); secret != "" { + return deriveFromSecret(secret) + } + + // try loading from disk + keyPath := getKeyFilePath() + if kp, err := loadFromFile(keyPath); err == nil { + return kp, nil + } + + // nothing found, generate fresh and try to persist + return generateAndPersist(keyPath) +} + +func getKeyFilePath() string { + if path := os.Getenv("WEBAPP_SERVER_SIGNING_KEY_FILE"); path != "" { + return path + } + + var keyDir string + if isDevMode() { + keyDir = DevKeyDir + } else { + keyDir = DefaultKeyDir + } + + return filepath.Join(keyDir, DefaultKeyFileName) +} + +func loadFromFile(path string) (*KeyPair, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + keyBytes, err := base64.StdEncoding.DecodeString(string(data)) + if err != nil { + return nil, fmt.Errorf("invalid key file format: %w", err) + } + + if len(keyBytes) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("invalid key length in file: expected %d, got %d", ed25519.PrivateKeySize, len(keyBytes)) + } + + priv := ed25519.PrivateKey(keyBytes) + pub := priv.Public().(ed25519.PublicKey) + + log.Printf("Loaded signing key from file: %s", path) + log.Printf("Verifying key: %s", base64.StdEncoding.EncodeToString(pub)) + + return &KeyPair{ + SigningKey: priv, + VerifyingKey: pub, + }, nil +} + +func saveToFile(path string, priv ed25519.PrivateKey) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("failed to create key directory: %w", err) + } + + encoded := base64.StdEncoding.EncodeToString(priv) + if err := os.WriteFile(path, []byte(encoded), 0600); err != nil { + return fmt.Errorf("failed to write key file: %w", err) + } + + return nil +} + +// generateAndPersist creates a new key and tries to save it. +// If we can't persist, we log the key so operators can set it manually. +func generateAndPersist(keyPath string) (*KeyPair, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to generate key pair: %w", err) + } + + kp := &KeyPair{ + SigningKey: priv, + VerifyingKey: pub, + } + + if err := saveToFile(keyPath, priv); err == nil { + log.Printf("Generated and saved signing key to: %s", keyPath) + log.Printf("Verifying key: %s", base64.StdEncoding.EncodeToString(pub)) + return kp, nil + } + + // couldn't persist, log the key so it can be set via env var + // this is annoying but better than silent failures + keyB64 := base64.StdEncoding.EncodeToString(priv) + + log.Println("========================================") + log.Println("SIGNING KEY PERSISTENCE FAILED") + log.Println("========================================") + log.Printf("Could not save signing key to: %s", keyPath) + log.Println("") + log.Println("This key will be lost on restart, causing") + log.Println("'Invalid signature' errors for users with") + log.Println("cached bundles.") + log.Println("") + log.Println("To persist this key, set this environment variable:") + log.Println("") + log.Printf(" WEBAPP_SERVER_SIGNING_KEY=%s", keyB64) + log.Println("") + log.Println("Or mount a persistent volume at:") + log.Printf(" %s", filepath.Dir(keyPath)) + log.Println("========================================") + + log.Printf("Verifying key: %s", base64.StdEncoding.EncodeToString(pub)) + + return kp, nil +} + +func loadFromBase64Key(keyB64 string) (*KeyPair, error) { + keyBytes, err := base64.StdEncoding.DecodeString(keyB64) + if err != nil { + return nil, fmt.Errorf("invalid WEBAPP_SERVER_SIGNING_KEY: %w", err) + } + + if len(keyBytes) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("WEBAPP_SERVER_SIGNING_KEY must be %d bytes, got %d", ed25519.PrivateKeySize, len(keyBytes)) + } + + priv := ed25519.PrivateKey(keyBytes) + pub := priv.Public().(ed25519.PublicKey) + + log.Printf("Loaded signing key from WEBAPP_SERVER_SIGNING_KEY") + log.Printf("Verifying key: %s", base64.StdEncoding.EncodeToString(pub)) + + return &KeyPair{ + SigningKey: priv, + VerifyingKey: pub, + }, nil +} + +func loadFromBase64Seed(seedB64 string) (*KeyPair, error) { + seedBytes, err := base64.StdEncoding.DecodeString(seedB64) + if err != nil { + return nil, fmt.Errorf("invalid WEBAPP_SERVER_SIGNING_SEED: %w", err) + } + + if len(seedBytes) != ed25519.SeedSize { + return nil, fmt.Errorf("WEBAPP_SERVER_SIGNING_SEED must be %d bytes, got %d", ed25519.SeedSize, len(seedBytes)) + } + + priv := ed25519.NewKeyFromSeed(seedBytes) + pub := priv.Public().(ed25519.PublicKey) + + log.Printf("Derived signing key from WEBAPP_SERVER_SIGNING_SEED") + log.Printf("Verifying key: %s", base64.StdEncoding.EncodeToString(pub)) + + return &KeyPair{ + SigningKey: priv, + VerifyingKey: pub, + }, nil +} + +// deriveFromSecret hashes an arbitrary string to get a seed. +// Simple but works for shared secrets across replicas. +func deriveFromSecret(secret string) (*KeyPair, error) { + hash := sha256.Sum256([]byte(secret)) + priv := ed25519.NewKeyFromSeed(hash[:]) + pub := priv.Public().(ed25519.PublicKey) + + log.Printf("Derived signing key from WEBAPP_SERVER_SIGNING_SECRET") + log.Printf("Verifying key: %s", base64.StdEncoding.EncodeToString(pub)) + + return &KeyPair{ + SigningKey: priv, + VerifyingKey: pub, + }, nil +} + +// isDevMode returns true if GO_ENV=development. +func isDevMode() bool { + return os.Getenv("GO_ENV") == "development" +} diff --git a/packages/hoppscotch-selfhost-web/webapp-server/internal/server/server.go b/packages/hoppscotch-selfhost-web/webapp-server/internal/server/server.go new file mode 100644 index 0000000..ae0fa24 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/webapp-server/internal/server/server.go @@ -0,0 +1,146 @@ +// Package server handles HTTP endpoints for bundle distribution. +// +// Endpoints: +// GET /health - health check +// GET /api/v1/manifest - bundle metadata (files, hashes, signature) +// GET /api/v1/bundle - download the actual ZIP +// GET /api/v1/key - public key for signature verification +// +// All endpoints also available under /desktop-app-server/ for backwards compat. + +package server + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "net/http" + + "hoppscotch-selfhost-web/webapp-server/internal/bundle" +) + +type Response struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` + Code int `json:"code"` +} + +type Server struct { + bundleManager *bundle.Manager +} + +func New(bundleManager *bundle.Manager) *Server { + return &Server{ + bundleManager: bundleManager, + } +} + +func (s *Server) HandleHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + w.WriteHeader(http.StatusOK) +} + +func (s *Server) HandleManifest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + s.writeErrorResponse(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + log.Println("Fetching bundle manifest") + + b := s.bundleManager.GetBundle() + + response := Response{ + Success: true, + Data: b.Metadata, + Code: http.StatusOK, + } + + s.writeJSONResponse(w, response, http.StatusOK) +} + +func (s *Server) HandleDownloadBundle(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + log.Println("Starting bundle download") + + b := s.bundleManager.GetBundle() + + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(b.Content))) + w.Header().Set("Content-Disposition", "attachment; filename=\"bundle.zip\"") + + if _, err := w.Write(b.Content); err != nil { + log.Printf("Error writing bundle response: %v", err) + return + } + + log.Printf("Successfully sent bundle for download (size: %d bytes)", len(b.Content)) +} + +func (s *Server) HandleKey(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + s.writeErrorResponse(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + log.Println("Listing public key") + + keyInfo := map[string]string{ + "key": base64.StdEncoding.EncodeToString(s.bundleManager.GetVerifyingKey()), + } + + response := Response{ + Success: true, + Data: keyInfo, + Code: http.StatusOK, + } + + s.writeJSONResponse(w, response, http.StatusOK) +} + +// writeJSONResponse buffers the response first to avoid partial writes on error +func (s *Server) writeJSONResponse(w http.ResponseWriter, response Response, statusCode int) { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + log.Printf("Error encoding JSON response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if _, err := w.Write(buf.Bytes()); err != nil { + log.Printf("Error writing response: %v", err) + } +} + +func (s *Server) writeErrorResponse(w http.ResponseWriter, message string, statusCode int) { + response := Response{ + Success: false, + Error: message, + Code: statusCode, + } + s.writeJSONResponse(w, response, statusCode) +} + +func (s *Server) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/health", s.HandleHealth) + mux.HandleFunc("/api/v1/manifest", s.HandleManifest) + mux.HandleFunc("/api/v1/bundle", s.HandleDownloadBundle) + mux.HandleFunc("/api/v1/key", s.HandleKey) + + // desktop app backwards compat + mux.HandleFunc("/desktop-app-server/api/v1/manifest", s.HandleManifest) + mux.HandleFunc("/desktop-app-server/api/v1/bundle", s.HandleDownloadBundle) + mux.HandleFunc("/desktop-app-server/api/v1/key", s.HandleKey) +} diff --git a/packages/hoppscotch-selfhost-web/webapp-server/main.go b/packages/hoppscotch-selfhost-web/webapp-server/main.go new file mode 100644 index 0000000..a1febec --- /dev/null +++ b/packages/hoppscotch-selfhost-web/webapp-server/main.go @@ -0,0 +1,99 @@ +// Hoppscotch Webapp Server +// +// Builds a signed bundle from frontend assets and serves it over HTTP. +// The bundle is zstd-compressed and signed with ed25519 so clients can verify integrity. + +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "hoppscotch-selfhost-web/webapp-server/internal/bundle" + "hoppscotch-selfhost-web/webapp-server/internal/config" + "hoppscotch-selfhost-web/webapp-server/internal/crypto" + "hoppscotch-selfhost-web/webapp-server/internal/server" +) + +func main() { + log.Println("Initializing Hoppscotch Web Static Server") + + cfg := config.Load() + + // NOTE: key generation handles persistence internally + // it'll try env vars first, then disk, then generate new + keyPair, err := crypto.GenerateKeyPair() + if err != nil { + log.Fatalf("Failed to generate key pair: %v", err) + } + + builder, err := bundle.NewBuilder() + if err != nil { + log.Fatalf("Failed to create bundle builder: %v", err) + } + + // this walks the frontend dir and creates a zstd-compressed zip + content, files, err := builder.Build(cfg.FrontendPath) + if err != nil { + log.Fatalf("Failed to build bundle: %v", err) + } + + // manager holds the bundle in memory and handles signing + bundleManager, err := bundle.NewManager( + content, + files, + keyPair.SigningKey, + keyPair.VerifyingKey, + bundle.DefaultMaxSize, + ) + if err != nil { + log.Fatalf("Failed to create bundle manager: %v", err) + } + + srv := server.New(bundleManager) + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + + addr := fmt.Sprintf(":%d", cfg.Port) + + // NOTE: timeouts default to conservative values but can be overridden + // via WEBAPP_SERVER_READ_TIMEOUT, WEBAPP_SERVER_WRITE_TIMEOUT, WEBAPP_SERVER_IDLE_TIMEOUT + // (Go duration strings, e.g. "30s", "2m", "0" to disable) + httpServer := &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: cfg.ReadTimeout, + WriteTimeout: cfg.WriteTimeout, + IdleTimeout: cfg.IdleTimeout, + } + + go func() { + log.Printf("Server starting on %s", addr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server failed: %v", err) + } + }() + + // wait for shutdown signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Server shutting down...") + + // give in-flight requests 30s to finish + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := httpServer.Shutdown(ctx); err != nil { + log.Printf("Server forced to shutdown: %v", err) + } + + log.Println("Server stopped") +} diff --git a/packages/hoppscotch-sh-admin/.dockerignore b/packages/hoppscotch-sh-admin/.dockerignore new file mode 100644 index 0000000..4b90444 --- /dev/null +++ b/packages/hoppscotch-sh-admin/.dockerignore @@ -0,0 +1 @@ +./node_modules diff --git a/packages/hoppscotch-sh-admin/.gitignore b/packages/hoppscotch-sh-admin/.gitignore new file mode 100644 index 0000000..ed50c06 --- /dev/null +++ b/packages/hoppscotch-sh-admin/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +pnpm-lock.yaml + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# GQL generated files +src/helpers/backend/graphql.ts +src/helpers/backend/graphql.schema.json + +# Vite Generated Files +vite.config.ts.timestamp-*.js diff --git a/packages/hoppscotch-sh-admin/.prettierignore b/packages/hoppscotch-sh-admin/.prettierignore new file mode 100644 index 0000000..ef46361 --- /dev/null +++ b/packages/hoppscotch-sh-admin/.prettierignore @@ -0,0 +1,10 @@ +.dependabot +.github +.hoppscotch +.vscode +package-lock.json +node_modules +dist +static +components.d.ts +src/types diff --git a/packages/hoppscotch-sh-admin/.prettierrc.cjs b/packages/hoppscotch-sh-admin/.prettierrc.cjs new file mode 100644 index 0000000..3093462 --- /dev/null +++ b/packages/hoppscotch-sh-admin/.prettierrc.cjs @@ -0,0 +1,3 @@ +module.exports = { + semi: false, +}; diff --git a/packages/hoppscotch-sh-admin/Caddyfile b/packages/hoppscotch-sh-admin/Caddyfile new file mode 100644 index 0000000..2096c51 --- /dev/null +++ b/packages/hoppscotch-sh-admin/Caddyfile @@ -0,0 +1,10 @@ +{ + admin off + persist_config off +} + +:8080 { + try_files {path} / + root * /site + file_server +} diff --git a/packages/hoppscotch-sh-admin/Dockerfile b/packages/hoppscotch-sh-admin/Dockerfile new file mode 100644 index 0000000..9a1f98f --- /dev/null +++ b/packages/hoppscotch-sh-admin/Dockerfile @@ -0,0 +1,21 @@ +# Initial stage, just build the app +FROM node:lts as builder + +WORKDIR /usr/src/app + +RUN npm i -g pnpm + +COPY . . +RUN pnpm install --force --frozen-lockfile + +WORKDIR /usr/src/app/packages/hoppscotch-sh-admin/ +RUN pnpm run build + + +# Final stage, take the build artifacts and package it into a static Caddy server +FROM caddy:2-alpine +WORKDIR /site +COPY packages/hoppscotch-sh-admin/Caddyfile /etc/caddy/Caddyfile +COPY --from=builder /usr/src/app/packages/hoppscotch-sh-admin/dist/ . + +EXPOSE 8080 diff --git a/packages/hoppscotch-sh-admin/README.md b/packages/hoppscotch-sh-admin/README.md new file mode 100644 index 0000000..3ef36b7 --- /dev/null +++ b/packages/hoppscotch-sh-admin/README.md @@ -0,0 +1,45 @@ +
+ + Hoppscotch Logo + +
+

+

+ + Hoppscotch Self Hosted Admin Dashboard + +

+

+ +#### **Support** + +[![Chat on Discord](https://img.shields.io/badge/chat-Discord-7289DA?logo=discord)](https://hoppscotch.io/discord) [![Chat on Telegram](https://img.shields.io/badge/chat-Telegram-2CA5E0?logo=telegram)](https://hoppscotch.io/telegram) [![Discuss on GitHub](https://img.shields.io/badge/discussions-GitHub-333333?logo=github)](https://github.com/hoppscotch/hoppscotch/discussions) + +
+ +## **Built with** + +- [HTML](https://developer.mozilla.org/en-US/docs/Web/HTML) +- [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS), [SCSS](https://sass-lang.com), [Tailwind CSS](https://tailwindcss.com) +- [JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript) +- [TypeScript](https://www.typescriptlang.org) +- [Vue](https://vuejs.org) +- [Vite](https://vitejs.dev) + +## **Developing** + +0. Update [`.env.example`](https://github.com/hoppscotch/hoppscotch/blob/main/.env.example) file found in the root of repository with your own keys and rename it to `.env`. + +### Local development environment + +1. [Clone this repo](https://help.github.com/en/articles/cloning-a-repository) with git. +2. Update `.env.example` file found in the root of `hoppscotch-sh-admin` directory with your own keys and rename it to `.env`. +3. Install pnpm using npm by running `npm install -g pnpm`. +4. Install dependencies by running `pnpm install` within the `hoppscotch-sh-admin` directory +5. It is assumed that the backend is running. Refer the Hoppscotch Backend [`README`](https://github.com/hoppscotch/self-hosted/blob/main/packages/hoppscotch-backend/README.md) to get the backend setup and running. +6. Start the development server with `pnpm run dev`. +7. Open the development site by going to [`http://localhost:3100`](http://localhost:3100) in your browser. diff --git a/packages/hoppscotch-sh-admin/assets/icons/auth/email.svg b/packages/hoppscotch-sh-admin/assets/icons/auth/email.svg new file mode 100644 index 0000000..ac21b3d --- /dev/null +++ b/packages/hoppscotch-sh-admin/assets/icons/auth/email.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/packages/hoppscotch-sh-admin/assets/icons/auth/github.svg b/packages/hoppscotch-sh-admin/assets/icons/auth/github.svg new file mode 100644 index 0000000..a4a47b0 --- /dev/null +++ b/packages/hoppscotch-sh-admin/assets/icons/auth/github.svg @@ -0,0 +1,12 @@ + + + + diff --git a/packages/hoppscotch-sh-admin/assets/icons/auth/google.svg b/packages/hoppscotch-sh-admin/assets/icons/auth/google.svg new file mode 100644 index 0000000..5cdb447 --- /dev/null +++ b/packages/hoppscotch-sh-admin/assets/icons/auth/google.svg @@ -0,0 +1,24 @@ + + + + + + + diff --git a/packages/hoppscotch-sh-admin/assets/icons/auth/microsoft.svg b/packages/hoppscotch-sh-admin/assets/icons/auth/microsoft.svg new file mode 100644 index 0000000..dcc6cd2 --- /dev/null +++ b/packages/hoppscotch-sh-admin/assets/icons/auth/microsoft.svg @@ -0,0 +1,12 @@ + + + + + + + diff --git a/packages/hoppscotch-sh-admin/assets/icons/logo.svg b/packages/hoppscotch-sh-admin/assets/icons/logo.svg new file mode 100644 index 0000000..00fdee0 --- /dev/null +++ b/packages/hoppscotch-sh-admin/assets/icons/logo.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-sh-admin/assets/images/hoppscotch-title.svg b/packages/hoppscotch-sh-admin/assets/images/hoppscotch-title.svg new file mode 100644 index 0000000..b33ed65 --- /dev/null +++ b/packages/hoppscotch-sh-admin/assets/images/hoppscotch-title.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-sh-admin/assets/scss/styles.scss b/packages/hoppscotch-sh-admin/assets/scss/styles.scss new file mode 100644 index 0000000..87d32c8 --- /dev/null +++ b/packages/hoppscotch-sh-admin/assets/scss/styles.scss @@ -0,0 +1,661 @@ +/* +* Write hoppscotch-sh-admin related custom styles in this file. +* If styles are sharable across all package then write into hoppscotch-ui/assets/scss/styles.scss file. +*/ + +@mixin base-theme { + --font-sans: 'Inter Variable', sans-serif; + --font-mono: 'Roboto Mono Variable', monospace; + --font-size-body: 0.75rem; + --font-size-tiny: 0.625rem; + --line-height-body: 1rem; + --upper-primary-sticky-fold: 4rem; + --upper-secondary-sticky-fold: 6.188rem; + --upper-tertiary-sticky-fold: 8.25rem; + --upper-fourth-sticky-fold: 10.2rem; + --upper-mobile-primary-sticky-fold: 6.75rem; + --upper-mobile-secondary-sticky-fold: 8.813rem; + --upper-mobile-sticky-fold: 10.875rem; + --upper-mobile-tertiary-sticky-fold: 8.25rem; + --lower-primary-sticky-fold: 3rem; + --lower-secondary-sticky-fold: 5.063rem; + --lower-tertiary-sticky-fold: 7.125rem; + --lower-fourth-sticky-fold: 9.188rem; + --sidebar-primary-sticky-fold: 2rem; +} + +@mixin dark-theme { + --primary-color: #181818; + --primary-light-color: #1c1c1e; + --primary-dark-color: #262626; + --primary-contrast-color: #171717; + + --secondary-color: #a3a3a3; + --secondary-light-color: #737373; + --secondary-dark-color: #fafafa; + + --divider-color: #262626; + --divider-light-color: #1f1f1f; + --divider-dark-color: #2d2d2d; + + --error-color: #292524; + --tooltip-color: #f5f5f5; + --popover-color: #1b1b1b; +} + +@mixin green-theme { + --accent-color: #10b981; + --accent-light-color: #34d399; + --accent-dark-color: #059669; + --accent-contrast-color: #fff; + --gradient-from-color: #a7f3d0; + --gradient-via-color: #34d399; + --gradient-to-color: #059669; +} + +:root { + @include base-theme; + @include dark-theme; + @include green-theme; +} + +:root.dark { + @include dark-theme; + color-scheme: dark; +} + +:root[data-accent='green'] { + @include green-theme; +} + +* { + backface-visibility: hidden; + -moz-backface-visibility: hidden; + -webkit-backface-visibility: hidden; + + &::before { + backface-visibility: hidden; + -moz-backface-visibility: hidden; + -webkit-backface-visibility: hidden; + } + + &::after { + backface-visibility: hidden; + -moz-backface-visibility: hidden; + -webkit-backface-visibility: hidden; + } + + @apply selection:bg-accentDark; + @apply selection:text-accentContrast; +} + +:root { + accent-color: var(--accent-color); + font-variant-ligatures: common-ligatures; + @apply antialiased; + @apply overscroll-none; +} + +::-webkit-scrollbar-track { + @apply bg-transparent; + @apply border-b-0 border-l border-r-0 border-t-0 border-solid border-dividerLight; +} + +::-webkit-scrollbar-thumb { + @apply bg-divider bg-clip-content; + @apply rounded-full; + @apply border-4 border-solid border-transparent; + @apply hover:bg-dividerDark; + @apply hover:bg-clip-content; +} + +::-webkit-scrollbar { + @apply w-4; + @apply h-0; +} + +.no-scrollbar { + scrollbar-width: none; +} + +input::placeholder, +textarea::placeholder, +.cm-placeholder { + @apply text-secondary; + @apply opacity-50 #{!important}; +} + +input, +textarea { + @apply text-secondaryDark; + @apply font-medium; +} + +html { + scroll-behavior: smooth; +} + +body { + @apply bg-primary; + @apply text-body text-secondary; + @apply font-medium; + @apply select-none; + @apply overflow-x-hidden; + @apply leading-body #{!important}; + animation: fade 300ms forwards; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; +} + +@keyframes fade { + 0% { + @apply opacity-0; + } + + 100% { + @apply opacity-100; + } +} + +.fade-enter-active, +.fade-leave-active { + @apply transition-opacity; +} + +.fade-enter-from, +.fade-leave-to { + @apply opacity-0; +} + +.slide-enter-active, +.slide-leave-active { + @apply transition; + @apply duration-300; +} + +.slide-enter-from, +.slide-leave-to { + @apply transform; + @apply translate-x-full; +} + +.bounce-enter-active, +.bounce-leave-active { + @apply transition; +} + +.bounce-enter-from, +.bounce-leave-to { + @apply transform; + @apply scale-95; +} + +.svg-icons { + @apply flex-shrink-0; + @apply overflow-hidden; + height: var(--line-height-body); + width: var(--line-height-body); +} + +a { + @apply inline-flex; + @apply text-current; + @apply no-underline; + @apply transition; + @apply leading-body; + @apply focus:outline-none; + + &.link { + @apply items-center; + @apply px-1 py-0.5; + @apply -mx-1 -my-0.5; + @apply text-accent; + @apply rounded; + @apply hover:text-accentDark; + @apply focus-visible:ring; + @apply focus-visible:ring-accent; + @apply focus-visible:text-accentDark; + } +} + +.cm-tooltip { + .tippy-box { + @apply shadow-none #{!important}; + @apply fixed; + @apply inline-flex; + @apply -mt-7; + } +} + +.tippy-box[data-theme~='tooltip'] { + @apply bg-tooltip; + @apply border-solid border-tooltip; + @apply rounded; + @apply shadow; + + .tippy-content { + @apply flex; + @apply text-tiny text-primary; + @apply font-semibold; + @apply px-2 py-1; + @apply truncate; + @apply leading-body; + @apply items-center; + + kbd { + @apply hidden; + @apply font-sans; + background-color: rgba(107, 114, 128, 0.45); + @apply text-primaryLight; + @apply rounded-sm; + @apply px-1; + @apply my-0 ml-1; + @apply truncate; + @apply sm:inline-flex; + } + + .env-icon { + @apply transition; + @apply inline-flex; + @apply items-center; + } + } + + .tippy-svg-arrow { + svg:first-child { + @apply fill-tooltip; + } + + svg:last-child { + @apply fill-tooltip; + } + } +} + +.tippy-box[data-theme~='popover'] { + @apply bg-popover; + @apply border-solid border-dividerDark; + @apply rounded; + @apply shadow-lg; + @apply max-w-[45vw] #{!important}; + + .tippy-content { + @apply flex flex-col; + @apply max-h-[45vh]; + @apply items-stretch; + @apply overflow-y-auto; + @apply text-body text-secondary; + @apply p-2; + @apply leading-body; + @apply focus:outline-none; + scroll-behavior: smooth; + + & > span { + @apply block #{!important}; + } + } + + .tippy-svg-arrow { + svg:first-child { + @apply fill-dividerDark; + } + + svg:last-child { + @apply fill-popover; + } + } +} + +[data-v-tippy] { + @apply flex flex-1; + @apply truncate; +} + +[interactive] > div { + @apply flex flex-1; + @apply h-full; +} + +hr { + @apply border-b border-dividerLight; + @apply my-2 #{!important}; +} + +.heading { + @apply font-bold; + @apply text-lg text-secondaryDark; + @apply tracking-tight; +} + +.input, +.select, +.textarea { + @apply flex; + @apply w-full; + @apply px-4 py-2; + @apply bg-transparent; + @apply rounded; + @apply text-secondaryDark; + @apply border border-divider; + @apply focus-visible:border-dividerDark; +} + +input, +select, +textarea, +button { + @apply truncate; + @apply transition; + @apply text-body; + @apply leading-body; + @apply focus:outline-none; + @apply disabled:cursor-not-allowed; +} + +.input[type='file'], +.input[type='radio'], +#installPWA { + @apply hidden; +} + +.floating-input ~ label { + @apply absolute; + @apply px-2 py-0.5; + @apply m-2; + @apply rounded; + @apply transition; + @apply origin-top-left; +} + +.floating-input:focus-within ~ label, +.floating-input:not(:placeholder-shown) ~ label { + @apply bg-primary; + @apply transform; + @apply origin-top-left; + @apply scale-75; + @apply -translate-y-4 translate-x-1; +} + +.floating-input:focus-within ~ label { + @apply text-secondaryDark; +} + +.floating-input ~ .end-actions { + @apply absolute; + @apply right-[.05rem]; + @apply inset-y-0; + @apply flex; + @apply items-center; +} + +.floating-input:has(~ .end-actions) { + @apply pr-12; +} + +pre.ace_editor { + @apply font-mono; + @apply resize-none; + @apply z-0; +} + +.select { + @apply appearance-none; + @apply cursor-pointer; + + &::-ms-expand { + @apply hidden; + } +} + +.info-response { + color: var(--status-info-color); +} + +.success-response { + color: var(--status-success-color); +} + +.redirect-response { + color: var(--status-redirect-color); +} + +.critical-error-response { + color: var(--status-critical-error-color); +} + +.server-error-response { + color: var(--status-server-error-color); +} + +.missing-data-response { + color: var(--status-missing-data-color); +} + +.toasted-container { + @apply max-w-md; + @apply z-[10000]; + + .toasted { + &.toasted-primary { + @apply px-4 py-2; + @apply bg-tooltip; + @apply border-secondaryDark; + @apply text-body text-primary; + @apply justify-between; + @apply shadow-lg; + @apply font-semibold; + @apply transition; + @apply leading-body; + @apply sm:rounded; + @apply sm:border; + + .action { + @apply relative; + @apply flex flex-shrink-0; + @apply text-body; + @apply px-4; + @apply my-1; + @apply ml-auto; + @apply normal-case; + @apply font-semibold; + @apply leading-body; + @apply tracking-normal; + @apply rounded; + @apply last:ml-4; + @apply sm:ml-8; + @apply before:absolute; + @apply before:bg-current; + @apply before:opacity-10; + @apply before:inset-0; + @apply before:transition; + @apply before:content-['']; + @apply hover:no-underline; + @apply hover:before:opacity-20; + } + } + + &.info { + @apply bg-accent; + @apply text-accentContrast; + @apply border-accentDark; + } + + &.error { + @apply bg-red-200; + @apply text-red-800; + @apply border-red-400; + } + + &.success { + @apply bg-green-200; + @apply text-green-800; + @apply border-green-400; + } + } +} + +.smart-splitter .splitpanes__splitter { + @apply relative; + @apply before:absolute; + @apply before:inset-0; + @apply before:bg-accentLight; + @apply before:opacity-0; + @apply before:z-20; + @apply before:transition; + @apply before:content-['']; + @apply hover:before:opacity-100; +} + +.no-splitter .splitpanes__splitter { + @apply relative; +} + +.smart-splitter.splitpanes--vertical > .splitpanes__splitter { + @apply w-0; + @apply before:-left-0.5; + @apply before:-right-0.5; + @apply before:h-full; + @apply bg-divider; +} + +.smart-splitter.splitpanes--horizontal > .splitpanes__splitter { + @apply h-0; + @apply before:-top-0.5; + @apply before:-bottom-0.5; + @apply before:w-full; + @apply bg-divider; +} + +.no-splitter.splitpanes--vertical > .splitpanes__splitter { + @apply w-0; + @apply pointer-events-none; + @apply bg-dividerLight; +} + +.no-splitter.splitpanes--horizontal > .splitpanes__splitter { + @apply h-0; + @apply pointer-events-none; + @apply bg-dividerLight; +} + +.splitpanes--horizontal .splitpanes__pane { + @apply transition-none; +} + +.splitpanes--vertical .splitpanes__pane { + @apply transition-none; +} + +.cm-focused { + @apply select-auto; + @apply outline-none #{!important}; + + .cm-activeLine { + @apply bg-primaryLight; + } + + .cm-activeLineGutter { + @apply bg-primaryDark; + } +} + +.cm-scroller { + @apply overscroll-y-auto; +} + +.cm-editor { + .cm-line::selection { + @apply bg-accentDark #{!important}; + @apply text-accentContrast #{!important}; + } + + .cm-line ::selection { + @apply bg-accentDark #{!important}; + @apply text-accentContrast #{!important}; + } +} + +.shortcut-key { + @apply inline-flex; + @apply font-sans; + @apply text-tiny; + @apply bg-dividerLight; + @apply rounded; + @apply ml-2; + @apply px-0.5; + @apply min-w-[1rem]; + @apply min-h-[1rem]; + @apply leading-none; + @apply items-center; + @apply justify-center; + @apply border border-dividerDark; + @apply shadow-sm; + @apply + + + + + + + Hoppscotch Admin + + +
+ + + diff --git a/packages/hoppscotch-sh-admin/languages.json b/packages/hoppscotch-sh-admin/languages.json new file mode 100644 index 0000000..fd2c468 --- /dev/null +++ b/packages/hoppscotch-sh-admin/languages.json @@ -0,0 +1,201 @@ +[ + { + "code": "af", + "file": "af.json", + "iso": "af-AF", + "name": "Afrikaans" + }, + { + "code": "ar", + "dir": "rtl", + "file": "ar.json", + "iso": "ar-AR", + "name": "عربى" + }, + { + "code": "ca", + "file": "ca.json", + "iso": "ca-CA", + "name": "Català" + }, + { + "code": "cn", + "file": "cn.json", + "iso": "zh-CN", + "name": "简体中文" + }, + { + "code": "cs", + "file": "cs.json", + "iso": "cs-CS", + "name": "Čeština" + }, + { + "code": "da", + "file": "da.json", + "iso": "da-DA", + "name": "Dansk" + }, + { + "code": "de", + "file": "de.json", + "iso": "de-DE", + "name": "Deutsch" + }, + { + "code": "el", + "file": "el.json", + "iso": "el-EL", + "name": "Ελληνικά" + }, + { + "code": "en", + "file": "en.json", + "iso": "en-US", + "name": "English" + }, + { + "code": "es", + "file": "es.json", + "iso": "es-ES", + "name": "Español" + }, + { + "code": "fi", + "file": "fi.json", + "iso": "fi-FI", + "name": "Suomalainen" + }, + { + "code": "fr", + "file": "fr.json", + "iso": "fr-FR", + "name": "Français" + }, + { + "code": "he", + "file": "he.json", + "iso": "he-HE", + "name": "עִברִית" + }, + { + "code": "hu", + "file": "hu.json", + "iso": "hu-HU", + "name": "Magyar" + }, + { + "code": "id", + "file": "id.json", + "iso": "id", + "name": "Indonesian" + }, + { + "code": "it", + "file": "it.json", + "iso": "it", + "name": "Italiano" + }, + { + "code": "ja", + "file": "ja.json", + "iso": "ja-JA", + "name": "日本語" + }, + { + "code": "ko", + "file": "ko.json", + "iso": "ko-KO", + "name": "한국어" + }, + { + "code": "mn", + "file": "mn.json", + "iso": "mn-MN", + "name": "Монгол" + }, + { + "code": "nl", + "file": "nl.json", + "iso": "nl-NL", + "name": "Nederlands" + }, + { + "code": "no", + "file": "no.json", + "iso": "no-NO", + "name": "Norsk" + }, + { + "code": "pl", + "file": "pl.json", + "iso": "pl-PL", + "name": "Polskie" + }, + { + "code": "pt-br", + "file": "pt-br.json", + "iso": "pt-BR", + "name": "Português Brasileiro" + }, + { + "code": "pt", + "file": "pt.json", + "iso": "pt-PT", + "name": "Português" + }, + { + "code": "ro", + "file": "ro.json", + "iso": "ro-RO", + "name": "Română" + }, + { + "code": "ru", + "file": "ru.json", + "iso": "ru-RU", + "name": "Pусский" + }, + { + "code": "sr", + "file": "sr.json", + "iso": "sr-SR", + "name": "Српски" + }, + { + "code": "sv", + "file": "sv.json", + "iso": "sv-SV", + "name": "Svenska" + }, + { + "code": "th", + "file": "th.json", + "iso": "th-TH", + "name": "ไทย" + }, + { + "code": "tr", + "file": "tr.json", + "iso": "tr-TR", + "name": "Türkçe" + }, + { + "code": "tw", + "file": "tw.json", + "iso": "zh-TW", + "name": "繁體中文" + }, + { + "code": "uk", + "file": "uk.json", + "iso": "uk-UK", + "name": "Українська" + }, + { + "code": "vi", + "file": "vi.json", + "iso": "vi-VI", + "name": "Tiếng Việt" + } +] diff --git a/packages/hoppscotch-sh-admin/locales/af.json b/packages/hoppscotch-sh-admin/locales/af.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/af.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/ar.json b/packages/hoppscotch-sh-admin/locales/ar.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/ar.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/ca.json b/packages/hoppscotch-sh-admin/locales/ca.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/ca.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/cn.json b/packages/hoppscotch-sh-admin/locales/cn.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/cn.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/cs.json b/packages/hoppscotch-sh-admin/locales/cs.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/cs.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/da.json b/packages/hoppscotch-sh-admin/locales/da.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/da.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/de.json b/packages/hoppscotch-sh-admin/locales/de.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/de.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/el.json b/packages/hoppscotch-sh-admin/locales/el.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/el.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/en.json b/packages/hoppscotch-sh-admin/locales/en.json new file mode 100644 index 0000000..37a775d --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/en.json @@ -0,0 +1,514 @@ +{ + "action": { + "cancel": "Cancel", + "confirm": "Confirm", + "close": "Close", + "delete": "Delete", + "edit": "Edit", + "label": "Label", + "save": "Save" + }, + "app": { + "back": "Back", + "collapse_sidebar": "Collapse Sidebar", + "continue_to_dashboard": "Continue to Dashboard", + "expand_sidebar": "Expand Sidebar", + "name": "HOPPSCOTCH", + "no_name": "No name", + "open_navigation": "Open Navigation", + "read_documentation": "Read Documentation" + }, + "auth": { + "email_auth": "Email Authentication", + "email_auth_description": "Enable or disable email-based authentication for your users.", + "email_auth_enabled": "Email authentication is enabled and ready to use.", + "email_auth_smtp_note": "To use email-based authentication, you need to enable and configure SMTP in the SMTP tab first.", + "enable_email_auth": "Enable Email Authentication", + "smtp_required": "SMTP must be enabled and configured to use email based authentication." + }, + "configs": { + "auth_providers": { + "auth_provider_config": "Authentication Configurations", + "auth_provider_description": "Set up authentication methods for your Hoppscotch instance using OAuth or Email.", + "callback_url": "CALLBACK URL", + "client_id": "CLIENT ID", + "client_secret": "CLIENT SECRET", + "description": "Configure authentication providers for your server", + "email": "Email", + "github_enterprise": "Enable Github Enterprise", + "oauth": "OAuth", + "oauth_providers": "OAuth Providers", + "provider_not_specified": "Please enable at least one authentication provider", + "scope": "SCOPE", + "sso": "SSO", + "tenant": "TENANT", + "title": "OAuth Providers", + "token": { + "description": "Configure token for your server", + "title": "Token", + "jwt_secret": " JWT Secret", + "token_salt_complexity": "Token Salt Complexity", + "magic_link_token_validity": "Magic Link Token Validity (in hour)", + "refresh_token_validity": "Refresh Token Validity (in milliseconds)", + "access_token_validity": "Access Token Validity (in milliseconds)", + "session_secret": "Session Secret", + "session_cookie_name": "Session Cookie Name (optional)", + "session_cookie_name_help": "Only letters, numbers, underscore, and hyphen. Leave empty to use default 'connect.sid'.", + "session_cookie_name_invalid": "Invalid cookie name. Only letters, numbers, underscore, and hyphen allowed.", + "update_failure": "Failed to update token configurations!!" + }, + "update_failure": "Failed to update authentication provider configurations!!" + }, + "confirm_changes": "Hoppscotch server must restart to reflect the new changes. Confirm changes made to the server configurations?", + "invalid_number": "Please enter a valid number for the field", + "input_empty": "Please fill all the fields before updating the configurations", + "input_validation_error": "Some fields have invalid values. Please correct them before updating the configurations", + "data_sharing": { + "title": "Data Sharing", + "description": "Help improve Hoppscotch by sharing anonymous data", + "enable": "Enable Data Sharing", + "secondary_title": "Data Sharing Configurations", + "see_shared": "See what is shared", + "toggle_description": "Share anonymous data", + "update_failure": "Failed to update data sharing configurations!!" + }, + "load_error": "Unable to load server configurations", + "mail_configs": { + "address_from": "Mailer From Address", + "custom_smtp_configs": "Use Custom SMTP Configurations", + "description": " Configure the smtp configurations", + "enable_email_auth": "Enable Email based authentication", + "enable_smtp": "Enable SMTP", + "host": "SMTP Host", + "input_validation": "SMTP URL should start with smtp(s)://", + "password": "SMTP Password", + "port": "SMTP Port", + "secure": "SMTP Secure", + "smtp_url": "SMTP URL", + "ignore_tls": "SMTP Ignore TLS", + "tls_reject_unauthorized": "TLS Reject Unauthorized", + "title": "SMTP Configurations", + "smtp_auth_incomplete": "SMTP username and password must both be provided or both left empty", + "auth_switch_description": "Switching will clear the credentials entered in the current authentication type. Any unsaved data will be lost and cannot be undone.", + "auth_type": "Authentication Type", + "auth_type_login": "Basic Auth (Login)", + "auth_type_oauth2": "OAuth 2.0", + "oauth2_user": "OAuth 2.0 User", + "oauth2_client_id": "OAuth 2.0 Client ID", + "oauth2_client_secret": "OAuth 2.0 Client Secret", + "oauth2_refresh_token": "OAuth 2.0 Refresh Token", + "oauth2_access_url": "OAuth 2.0 Access Token URL", + "toggle_failure": "Failed to toggle smtp!!", + "update_failure": "Failed to update smtp configurations!!", + "user": "SMTP User" + }, + "history_configs": { + "description": "Disable or enable tracking history of sent requests from the Hoppscotch app", + "title": "History Configurations", + "enable_history": "Enable History", + "clear_history": "Clear existing history", + "clear_failure": "Failed to clear history!!", + "clear_success": "History cleared successfully.", + "toggle_failure": "Failed to toggle history!!", + "clear_confirm": "Are you sure you want to clear all history?" + }, + "rate_limit": { + "description": "Configure rate limiting for your Hoppscotch instance", + "rate_limit_max": "Maximum Requests", + "rate_limit_ttl": "Time to Leave (in milliseconds)", + "title": "Rate Limit Configurations", + "update_failure": "Failed to update rate limit configurations!!", + "input_validation_error": "Please enter valid values for rate limit configurations" + }, + "proxy_url_configs": { + "description": "Configure proxy URL for the app", + "input_validation": "Proxy URL must start with http(s):// and contain no spaces", + "title": "Proxy URL Configurations", + "update_failure": "Failed to update proxy URL configurations!!", + "url_placeholder": "Enter the Proxy URL" + }, + "reset": { + "confirm_reset": "Hoppscotch server must restart to reflect the new changes. Confirm the reset of server configurations?", + "description": "Default configurations will be loaded as specified in the environment file", + "failure": "Failed to reset configurations!!", + "title": "Reset Configurations", + "info": "Reset server configurations" + }, + "restart": { + "description": "{duration} seconds remaining before this page reloads automatically", + "initiate": "Initiating server restart...", + "title": "Server is restarting" + }, + "save_changes": "Save Changes", + "tabs": { + "auth": "Authentication", + "activity": "Activity", + "infra_tokens": "Infra Tokens", + "proxy": "Proxy", + "rate_limit": "Rate Limit", + "smtp": "SMTP", + "miscellaneous": "Miscellaneous", + "reset": "Reset Configurations" + }, + "title": "Configurations", + "mock_server": { + "title": "Mock Server", + "description": "Configure mock server settings used to host example responses.", + "wildcard_domain": "Wildcard Domain", + "wildcard_domain_description": "This field requires a full wildcard domain format. The input must start with an asterisk (*) followed by a dot (.) and then the domain name.", + "wildcard_domain_example": "Example: *.mock.domain.com", + "subpath_content_type_notice_prefix": "If you are not using a wildcard domain for the mock server (i.e., using a subpath instead), responses with the following content types are automatically served as", + "subpath_content_type_text_plain": "text/plain", + "subpath_content_type_notice_suffix": "to help prevent XSS attacks:" + }, + "update_failure": "Failed to update server configurations" + }, + "data_sharing": { + "description": "Share anonymous data usage to improve Hoppscotch", + "enable": "Enable Data Sharing", + "see_shared": "See what is shared", + "toggle_description": "Share data and make Hoppscotch better", + "title": "Make Hoppscotch Better", + "welcome": "Welcome to" + }, + "infra_tokens": { + "copy_token_warning": "Make sure to copy your infra token now. You won't be able to see it again!", + "deletion_success": "The infra token {label} has been deleted", + "empty": "Infra tokens are empty", + "expired": "Expired", + "expiration_label": "Expiration", + "expires_on": "Expires on", + "generate_modal_title": "New Infra Token", + "generate_new_token": "Generate new token", + "generate_token": "Generate Token", + "invalid_label": "Please provide a label for the token", + "last_used_on": "Last used on", + "no_expiration": "No expiration", + "no_expiration_verbose": "This token will never expire!", + "section_description": "Manage your Hoppscotch users through APIs with Infra tokens.", + "section_title": "Infra Tokens", + "tab_title": "Infra Tokens", + "token_expires_on": "This token will expire on", + "token_purpose": "Enter a label to identify this token" + }, + "metrics": { + "dashboard": "Dashboard", + "no_metrics": "No metrics found", + "total_collections": "Total Collections", + "total_requests": "Total Requests", + "total_teams": "Total Workspaces", + "total_users": "Total Users" + }, + "newsletter": { + "description": "Get updates about our latest news", + "subscribe": "Subscribe", + "title": "Stay in Touch", + "toggle_description": "Get updates about the latest at Hoppscotch", + "unsubscribe": "Unsubscribe" + }, + "onboarding": { + "add_atleast_one_auth_provider": "Please add at least one authentication provider to continue.", + "add_configurations": "Add Configurations", + "add_configurations_description": "Please add the configurations for the selected authentication methods.", + "add_oauth_config": "Add Auth Configurations", + "auth_setup": "Authentication Setup", + "auth_successfully_configured": "authentication has been successfully configured.", + "complete": "Complete", + "complete_description": "You have completed the onboarding process. You can now start using Hoppscotch.", + "complete_title": "Onboarding Complete", + "configurations": "Configurations", + "configurations_added_successfully": "Onboarding Configurations added successfully.", + "configurations_adding_failed": "Failed to add onboarding configs, please try again.", + "configurations_description": "Configure your Hoppscotch instance settings.", + "configuration_error": "Failed to add configurations", + "configurations_title": "Server Configurations", + "configuration_summary": "Configuration Summary", + "oauth": { + "description": "Set up OAuth providers like Google, GitHub, Microsoft, etc.", + "description_accordian": "Select the OAuth providers you want to enable and provide the necessary configurations.", + "title": "OAuth" + }, + "onboarding_incomplete": { + "description": "You have not completed the onboarding process. Please set up at least one authentication provider to continue.", + "title": "Onboarding Incomplete" + }, + "onboarding_fail_help": "If you are facing issues with the onboarding process, please contact support or check", + "please_fill_configurations": "Please fill the required field {fieldName}", + "save_auth_config": "Save Auth Config", + "setup_complete": { + "title": "Setup Complete", + "description": "You have successfully completed the onboarding process for your Hoppscotch instance.", + "description_sub": "The page will automatically redirect to the dashboard in a few seconds or you can reload once the server is restarted." + }, + "server_restarting": "Server is restarting in {time} seconds...", + "smtp": { + "description": "Set up SMTP for email authentication.", + "description_accordian": "Configure the SMTP settings for sending emails.", + "title": "SMTP" + }, + "smtp_advanced_config_enable": "Enable to configure your own SMTP credentials", + "select_auth_methods": "Please select authentications", + "select_auth_provider": "You can select one or more authentication providers to continue.", + "select_atleast_one": "Please select at least one authentication provider to continue.", + "start_onboarding": "Start Onboarding", + "welcome": "Welcome", + "welcome_screen_description": "Welcome to Hoppscotch! Let's get started with the setup.", + "welcome_screen_sub_description": "Please set up either SMTP or OAuth for authentication." + }, + "settings": { + "settings": "Settings" + }, + "shared_requests": { + "action": "Action", + "clear_filter": "Clear Filter", + "confirm_request_deletion": "Confirm deletion of the selected shared request?", + "copy": "Copy", + "created_on": "Created On", + "delete": "Delete", + "email": "Email", + "filter": "Filter", + "filter_by_email": "Filter by email", + "id": "ID", + "load_list_error": "Unable to load shared requests list", + "no_requests": "No shared requests found", + "open_request": "Open Request", + "properties": "Properties", + "request": "Request", + "show_more": "Show more", + "title": "Shared Requests", + "url": "URL" + }, + "state": { + "add_user_failure": "Failed to add user to the workspace!!", + "add_user_success": "User is now a member of the workspace!!", + "admin_failure": "Failed to make user an admin!!", + "admin_success": "User is now an admin!!", + "and": "and", + "clear_selection": "Clear Selection", + "configure_auth": "Check out the documentation to configure auth providers.", + "confirm_admin_to_user": "Do you want to remove admin status from this user?", + "confirm_admins_to_users": "Do you want to remove admin status from selected users?", + "confirm_delete_infra_token": "Are you sure you want to delete the infra token {tokenLabel}?", + "confirm_delete_invite": "Do you want to revoke the selected invite?", + "confirm_delete_invites": "Do you want to revoke selected invites?", + "confirm_user_deletion": "Confirm user deletion?", + "confirm_users_deletion": "Do you want to delete selected users?", + "confirm_user_to_admin": "Do you want to make this user into an admin?", + "confirm_users_to_admin": "Do you want to make selected users into admins?", + "confirm_logout": "Confirm Logout", + "created_on": "Created On", + "continue_email": "Continue with Email", + "continue_github": "Continue with Github", + "continue_google": "Continue with Google", + "continue_microsoft": "Continue with Microsoft", + "copied_to_clipboard": "Copied to clipboard", + "create_team_failure": "Failed to create workspace!!", + "create_team_success": "Workspace created successfully!!", + "data_sharing_failure": "Failed to update data sharing settings", + "delete_infra_token_failure": "Something went wrong while deleting the infra token", + "delete_invite_failure": "Failed to delete invite!!", + "delete_invites_failure": "Failed to delete selected invites!!", + "delete_invite_success": "Invite deleted successfully!!", + "delete_invites_success": "Selected invites deleted successfully!!", + "delete_request_failure": "Shared Request deletion failed!!", + "delete_request_success": "Shared Request deleted successfully!!", + "delete_team_failure": "Workspace deletion failed!!", + "delete_team_success": "Workspace deleted successfully!!", + "delete_some_users_failure": "Number of Users Not Deleted: {count}", + "delete_some_users_success": "Number of Users Deleted: {count}", + "delete_user_failed_only_one_admin": "Failed to delete user. There should be atleast one admin!!", + "delete_user_failure": "User deletion failed!!", + "delete_users_failure": "Failed to delete selected users!!", + "delete_user_success": "User deleted successfully!!", + "delete_users_success": "Selected users deleted successfully!!", + "email": "Email", + "email_failure": "Failed to send invitation", + "email_signin_failure": "Failed to login with Email", + "email_success": "Email invitation sent successfully", + "emails_cannot_be_same": "You cannot invite yourself, please choose a different email address!!", + "enter_team_email": "Please enter email of workspace owner!!", + "error": "Something went wrong", + "error_auth_providers": "Unable to load auth providers", + "generate_infra_token_failure": "Something went wrong while generating the infra token", + "github_signin_failure": "Failed to login with Github", + "google_signin_failure": "Failed to login with Google", + "infra_token_label_short": "Infra Token Label character length is too short!!", + "invalid_email": "Please enter a valid email address", + "link_copied_to_clipboard": "Link copied to clipboard", + "logged_out": "Logged out", + "login_as_admin": "and login with an admin account.", + "login_using_email": "Please ask the user to check their email or share the link below", + "login_using_link": "Please ask the user to login using the link below", + "logout": "Logout", + "magic_link_sign_in": "Click on the link to sign in.", + "magic_link_success": "We sent a magic link to", + "microsoft_signin_failure": "Failed to login with Microsoft", + "newsletter_failure": "Unable to update newsletter settings", + "non_admin_logged_in": "Logged in as non admin user.", + "non_admin_login": "You are logged in. But you're not an admin", + "owner_not_present": "Atleast one owner should be present in the team!!", + "privacy_policy": "Privacy Policy", + "reenter_email": "Re-enter email", + "remove_admin_failure": "Failed to remove admin status!!", + "remove_admin_failure_only_one_admin": "Failed to remove admin status. There should be at least one admin!!", + "remove_admin_success": "Admin status removed!!", + "remove_admin_from_users_failure": "Failed to remove admin status from selected users!!", + "remove_admin_from_users_success": "Admin status removed from selected users!!", + "remove_admin_to_delete_user": "Remove admin privilege to delete the user!!", + "remove_owner_to_delete_user": "Remove team ownership status to delete the user!!", + "remove_owner_failure_only_one_owner": "Failed to remove member. There should be atleast one owner in a team!!", + "remove_admin_for_deletion": "Remove admin status before attempting deletion!!", + "remove_owner_for_deletion": "One or more users are team owners. Update ownership before deletion!!", + "remove_invitee_failure": "Removal of invitee failed!!", + "remove_invitee_success": "Removal of invitee is successful!!", + "remove_member_failure": "Member couldn't be removed!!", + "remove_member_success": "Member removed successfully!!", + "rename_team_failure": "Failed to rename workspace!!", + "rename_team_success": "Workspace renamed successfully!", + "rename_user_failure": "Failed to rename user!!", + "rename_user_success": "User renamed successfully!!", + "require_auth_provider": "You need to set atleast one authentication provider to log in.", + "role_update_failed": "Roles updation has failed!!", + "role_update_success": "Roles updated successfully!!", + "selected": "{count} selected", + "self_host_docs": "Self Host Documentation", + "send_magic_link": "Send magic link", + "setup_failure": "Setup has failed!!", + "setup_success": "Setup completed successfully!!", + "sign_in_agreement": "By signing in, you are agreeing to our", + "sign_in_options": "All sign in option", + "sign_out": "Sign out", + "something_went_wrong": "Something went wrong", + "team_name_too_short": "Workspace name should be atleast 6 characters long!!", + "user_already_invited": "Failed to send invite. User is already invited!!", + "user_not_found": "User not found in the infra!!", + "users_to_admin_success": "Selected users are elevated to admin status!!", + "users_to_admin_failure": "Failed to elevate selected users to admin status!!" + }, + "support": { + "description": "Get help from the Hoppscotch community", + "documentation": "Documentation", + "more_info": "More Info" + }, + "teams": { + "add_member": "Add Member", + "add_members": "Add Members", + "add_new": "Add New", + "admin": "Admin", + "admin_Email": "Admin Email", + "admin_id": "Admin ID", + "cancel": "Cancel", + "confirm_team_deletion": "Confirm deletion of the workspace?", + "copy": "Copy", + "create_team": "Create Workspace", + "date": "Date", + "delete_team": "Delete Workspace", + "details": "Details", + "edit": "Edit", + "editor": "EDITOR", + "editor_description": "Editors can add, edit, and delete requests and collections.", + "email": "Workspace owner email", + "email_address": "Email Address", + "email_title": "Email", + "empty_name": "Team name cannot be empty!!", + "error": "Something went wrong. Please try again later.", + "id": "Workspace ID", + "invited_email": "Invitee Email", + "invited_on": "Invited On", + "invites": "Invites", + "load_info_error": "Unable to load Workspace info", + "load_list_error": "Unable to Load Workspace List", + "members": "Number of members", + "no_invite": "No invites", + "owner": "OWNER", + "owner_description": " Owners can add, edit, and delete requests, collections and workspace members.", + "permissions": "Permissions", + "name": "Workspace Name", + "no_members": "No members in this workspace. Add members to this workspace to collaborate", + "no_pending_invites": "No pending invites", + "no_search_results": "No workspaces found matching your search", + "no_teams": "No workspaces found..", + "pending_invites": "Pending invites", + "roles": "Roles", + "roles_description": "Roles are used to control access to the shared collections.", + "remove": "Remove", + "rename": "Rename", + "save": "Save", + "save_changes": "Save Changes", + "search_placeholder": "Search by workspace name or ID..", + "send_invite": "Send Invite", + "show_more": "Show more", + "team_details": "Workspace details", + "team_members": "Members", + "team_members_tab": "Workspace members", + "teams": "Workspace", + "uid": "UID", + "unnamed": "(Unnamed Workspace)", + "viewer": "VIEWER", + "viewer_description": "Viewers can only view and use requests", + "valid_name": "Please enter a valid workspace name", + "valid_owner_email": "Please enter a valid owner email" + }, + "user_teams": { + "load_error": "Unable to load workspace memberships", + "no_teams": "This user does not belong to any workspaces", + "role": "Role", + "role_unknown": "Unknown", + "show_more": "Show more", + "title": "Workspaces", + "workspace_id": "Workspace ID", + "workspace_name": "Workspace Name" + }, + "users": { + "add_user": "Add User", + "admin": "Admin", + "admin_id": "Admin ID", + "cancel": "Cancel", + "created_on": "Created On", + "copy_invite_link": "Copy Invite Link", + "copy_link": "Copy Link", + "date": "Date", + "delete": "Delete", + "delete_user": "Delete User", + "delete_users": "Delete Users", + "details": "Details", + "edit": "Edit", + "email": "Email", + "email_address": "Email Address", + "empty_name": "Name cannot be empty!!", + "id": "User ID", + "invalid_user": "Invalid User", + "invite_load_list_error": "Unable to Load Invited Users List", + "invite_user": "Invite User", + "invite_users_description": "Invite your team members to join Hoppscotch.", + "invited_by": "Invited By", + "invited_on": "Invited On", + "invited_users": "Invited Users", + "invitee_email": "Invitee Email", + "last_active_on": "Last Active", + "load_info_error": "Unable to load user info", + "load_list_error": "Unable to Load Users List", + "make_admin": "Make Admin", + "name": "Name", + "new_user_added": "New User Added", + "no_invite": "No pending invites found", + "no_shared_requests": "No shared requests created by the user", + "no_users": "No users found", + "not_available": "Not Available", + "not_found": "User not found", + "pending_invites": "Pending Invites", + "pending_invites_description": "Manage and track pending user invitations with clear status and actions.", + "remove_admin_privilege": "Remove Admin Privilege", + "remove_admin_status": "Remove Admin Status", + "rename": "Rename", + "revoke_invitation": "Revoke Invitation", + "searchbar_placeholder": "Search by name or email..", + "send_invite": "Send Invite", + "show_more": "Show more", + "uid": "UID", + "unnamed": "(Unnamed User)", + "user_not_found": "User not found in the infra!!", + "users": "Users", + "valid_email": "Please enter a valid email address" + } +} diff --git a/packages/hoppscotch-sh-admin/locales/es.json b/packages/hoppscotch-sh-admin/locales/es.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/es.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/fi.json b/packages/hoppscotch-sh-admin/locales/fi.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/fi.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/fr.json b/packages/hoppscotch-sh-admin/locales/fr.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/fr.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/he.json b/packages/hoppscotch-sh-admin/locales/he.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/he.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/hi.json b/packages/hoppscotch-sh-admin/locales/hi.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/hi.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/hu.json b/packages/hoppscotch-sh-admin/locales/hu.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/hu.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/id.json b/packages/hoppscotch-sh-admin/locales/id.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/id.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/it.json b/packages/hoppscotch-sh-admin/locales/it.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/it.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/ja.json b/packages/hoppscotch-sh-admin/locales/ja.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/ja.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/ko.json b/packages/hoppscotch-sh-admin/locales/ko.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/ko.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/mn.json b/packages/hoppscotch-sh-admin/locales/mn.json new file mode 100644 index 0000000..b5f3bb5 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/mn.json @@ -0,0 +1,506 @@ +{ + "action": { + "cancel": "Цуцлах", + "confirm": "Баталгаажуулах", + "close": "Хаах", + "delete": "Устгах", + "edit": "Засварлах", + "label": "Шошго", + "save": "Хадгалах" + }, + "app": { + "back": "Буцах", + "collapse_sidebar": "Хажуугийн самбарыг буулгах", + "continue_to_dashboard": "Хяналтын самбар руу үргэлжлүүлнэ үү", + "expand_sidebar": "Хажуугийн самбарыг өргөжүүлэх", + "name": "HOPPSCOTCH", + "no_name": "Нэргүй", + "open_navigation": "Навигацийг нээх", + "read_documentation": "Баримт бичгийг уншина уу" + }, + "auth": { + "email_auth": "Имэйл баталгаажуулалт", + "email_auth_description": "Хэрэглэгчиддээ имэйлд суурилсан баталгаажуулалтыг идэвхжүүлэх эсвэл идэвхгүй болгох.", + "email_auth_enabled": "Имэйл баталгаажуулалт идэвхжсэн бөгөөд ашиглахад бэлэн байна.", + "email_auth_smtp_note": "Имэйлд суурилсан баталгаажуулалтыг ашиглахын тулд эхлээд SMTP таб дээр SMTP-г идэвхжүүлж, тохируулах хэрэгтэй.", + "enable_email_auth": "Имэйл баталгаажуулалтыг идэвхжүүлнэ үү", + "smtp_required": "SMTP-г идэвхжүүлж, имэйлд суурилсан баталгаажуулалтыг ашиглахаар тохируулсан байх ёстой." + }, + "configs": { + "auth_providers": { + "auth_provider_config": "Баталгаажуулалтын тохиргоо", + "auth_provider_description": "OAuth эсвэл Email ашиглан Hoppscotch instance-д тань таних аргуудыг тохируулна уу.", + "callback_url": "БУЦАХ URL", + "client_id": "CLIENT ID", + "client_secret": "ҮЙЛЧЛҮҮЛЭГЧИЙН НУУЦ", + "description": "Сервертээ баталгаажуулах үйлчилгээ үзүүлэгчдийг тохируулна уу", + "email": "Имэйл", + "github_enterprise": "Github Enterprise-г идэвхжүүлнэ үү", + "oauth": "OAuth", + "oauth_providers": "OAuth үйлчилгээ үзүүлэгчид", + "provider_not_specified": "Дор хаяж нэг баталгаажуулалтын үйлчилгээ үзүүлэгчийг идэвхжүүлнэ үү", + "scope": "ХАМРАХ ХҮРЭЭ", + "sso": "SSO", + "tenant": "ТҮРЭЭЛЭГЧ", + "title": "OAuth үйлчилгээ үзүүлэгчид", + "token": { + "description": "Өөрийн серверт токеныг тохируулна уу", + "title": "Токен", + "jwt_secret": "JWT нууц", + "token_salt_complexity": "Токен давсны нарийн төвөгтэй байдал", + "magic_link_token_validity": "Magic Link жетон хүчинтэй байх хугацаа (цагт)", + "refresh_token_validity": "Токеныг сэргээх хүчинтэй хугацаа (миллисекунд)", + "access_token_validity": "Хандалтын токен хүчинтэй хугацаа (миллисекунд)", + "session_secret": "Сеанс нууц", + "session_cookie_name": "Сеанс күүки нэр (заавал биш)", + "session_cookie_name_help": "Зөвхөн үсэг, тоо, доогуур зураас, зураас. Үндсэн 'connect.sid'-г ашиглахын тулд хоосон орхино уу.", + "session_cookie_name_invalid": "Күүкийн нэр буруу. Зөвхөн үсэг, тоо, доогуур зураас, зураасыг зөвшөөрнө.", + "update_failure": "Токен тохиргоог шинэчилж чадсангүй!!" + }, + "update_failure": "Баталгаажуулалтын үйлчилгээ үзүүлэгчийн тохиргоог шинэчилж чадсангүй!!" + }, + "confirm_changes": "Шинэ өөрчлөлтүүдийг тусгахын тулд Hoppscotch серверийг дахин эхлүүлэх шаардлагатай. Серверийн тохиргоонд хийсэн өөрчлөлтийг баталгаажуулах уу?", + "invalid_number": "Талбарт хүчинтэй дугаар оруулна уу", + "input_empty": "Тохиргоог шинэчлэхийн өмнө бүх талбарыг бөглөнө үү", + "input_validation_error": "Зарим талбарт буруу утга байна. Тохиргоог шинэчлэхийн өмнө тэдгээрийг засна уу", + "data_sharing": { + "title": "Өгөгдөл хуваалцах", + "description": "Нэргүй өгөгдөл хуваалцаж Hoppscotch-г сайжруулахад тусална уу", + "enable": "Мэдээлэл хуваалцахыг идэвхжүүлнэ үү", + "secondary_title": "Өгөгдөл хуваалцах тохиргоо", + "see_shared": "Юу хуваалцаж байгааг хараарай", + "toggle_description": "Нэргүй өгөгдлийг хуваалцах", + "update_failure": "Өгөгдөл хуваалцах тохиргоог шинэчилж чадсангүй!!" + }, + "load_error": "Серверийн тохиргоог ачаалах боломжгүй байна", + "mail_configs": { + "address_from": "Хаягнаас ирсэн шуудан", + "custom_smtp_configs": "Тусгай SMTP тохиргоог ашиглах", + "description": "smtp тохиргоог тохируулна уу", + "enable_email_auth": "Имэйлд суурилсан баталгаажуулалтыг идэвхжүүлнэ үү", + "enable_smtp": "SMTP-г идэвхжүүлнэ үү", + "host": "SMTP хост", + "input_validation": "SMTP URL нь smtp(s)://-ээр эхлэх ёстой.", + "password": "SMTP нууц үг", + "port": "SMTP порт", + "secure": "SMTP аюулгүй", + "smtp_url": "SMTP URL", + "ignore_tls": "SMTP TLS-г үл тоомсорлодог", + "tls_reject_unauthorized": "TLS зөвшөөрөлгүй татгалздаг", + "title": "SMTP тохиргоо", + "smtp_auth_incomplete": "SMTP хэрэглэгчийн нэр болон нууц үгийг хоёуланг нь оруулах эсвэл хоёуланг нь хоосон орхисон байх ёстой", + "auth_switch_description": "Шилжүүлснээр одоогийн баталгаажуулалтын төрөлд оруулсан итгэмжлэлүүд арилна. Хадгалаагүй өгөгдөл устах бөгөөд буцаах боломжгүй.", + "auth_type": "Баталгаажуулалтын төрөл", + "auth_type_login": "Үндсэн баталгаажуулалт (нэвтрэх)", + "auth_type_oauth2": "OAuth 2.0", + "oauth2_user": "OAuth 2.0 Хэрэглэгч", + "oauth2_client_id": "OAuth 2.0 Үйлчлүүлэгчийн ID", + "oauth2_client_secret": "OAuth 2.0 Үйлчлүүлэгчийн нууц", + "oauth2_refresh_token": "OAuth 2.0 Токеныг шинэчлэх", + "oauth2_access_url": "OAuth 2.0 хандалтын токен URL", + "toggle_failure": "smtp-г асааж чадсангүй!!", + "update_failure": "smtp тохиргоог шинэчилж чадсангүй!!", + "user": "SMTP хэрэглэгч" + }, + "history_configs": { + "description": "Hoppscotch програмаас илгээсэн хүсэлтийг хянах түүхийг идэвхгүй болгох эсвэл идэвхжүүлэх", + "title": "Түүхийн тохиргоо", + "enable_history": "Түүхийг идэвхжүүл", + "clear_history": "Одоо байгаа түүхийг арилгах", + "clear_failure": "Түүхийг цэвэрлэж чадсангүй!!", + "clear_success": "Түүхийг амжилттай арилгалаа.", + "toggle_failure": "Түүхийг сэлгэж чадсангүй!!", + "clear_confirm": "Та бүх түүхийг арилгахдаа итгэлтэй байна уу?" + }, + "rate_limit": { + "description": "Hoppscotch instance-ийн хурдны хязгаарлалтыг тохируулна уу", + "rate_limit_max": "Хамгийн их хүсэлт", + "rate_limit_ttl": "Явах хугацаа (миллисекундээр)", + "title": "Үнийн хязгаарлалтын тохиргоо", + "update_failure": "Үнийн хязгаарлалтын тохиргоог шинэчилж чадсангүй!!", + "input_validation_error": "Тарифын хязгаарын тохиргоонд хүчинтэй утгыг оруулна уу" + }, + "reset": { + "confirm_reset": "Шинэ өөрчлөлтүүдийг тусгахын тулд Hoppscotch серверийг дахин эхлүүлэх шаардлагатай. Серверийн тохиргоог дахин тохируулахыг баталгаажуулах уу?", + "description": "Өгөгдмөл тохиргоонууд нь орчны файлд заасны дагуу ачаалагдах болно", + "failure": "Тохиргоог дахин тохируулж чадсангүй!!", + "title": "Тохиргоог дахин тохируулах", + "info": "Серверийн тохиргоог дахин тохируулах" + }, + "restart": { + "description": "Энэ хуудсыг автоматаар дахин ачаалахад {duration} секунд үлдлээ", + "initiate": "Серверийг дахин эхлүүлэхийг эхлүүлж байна...", + "title": "Сервер дахин эхэлж байна" + }, + "save_changes": "Өөрчлөлтүүдийг хадгалах", + "tabs": { + "auth": "Баталгаажуулалт", + "activity": "Үйл ажиллагаа", + "infra_tokens": "Инфра жетон", + "rate_limit": "Үнийн хязгаарлалт", + "smtp": "SMTP", + "miscellaneous": "Төрөл бүрийн", + "reset": "Тохиргоог дахин тохируулах" + }, + "title": "Тохиргоо", + "mock_server": { + "title": "Хуурамч сервер", + "description": "Жишээ хариултуудыг байршуулахад ашигладаг хуурамч серверийн тохиргоог тохируулна уу.", + "wildcard_domain": "Wildcard Domain", + "wildcard_domain_description": "Энэ талбарт бүтэн орлуулагч домэйн формат шаардлагатай. Оролт нь одоор (*) дараа нь цэг (.), дараа нь домэйн нэрээр эхэлж байх ёстой.", + "wildcard_domain_example": "Жишээ нь: *.mock.domain.com", + "subpath_content_type_notice_prefix": "Хэрэв та хуурамч серверт орлуулагч домэйн ашиглаагүй бол (өөрөөр хэлбэл, дэд зам ашиглах) дараах агуулгын төрлүүдтэй хариултууд автоматаар үйлчилнэ.", + "subpath_content_type_text_plain": "текст/энгийн", + "subpath_content_type_notice_suffix": "XSS халдлагаас урьдчилан сэргийлэхийн тулд:" + }, + "update_failure": "Серверийн тохиргоог шинэчилж чадсангүй" + }, + "data_sharing": { + "description": "Hoppscotch-ийг сайжруулахын тулд нэргүй дата ашиглалтыг хуваалцаарай", + "enable": "Мэдээлэл хуваалцахыг идэвхжүүлнэ үү", + "see_shared": "Юу хуваалцаж байгааг хараарай", + "toggle_description": "Өгөгдлийг хуваалцаж, Hoppscotch-ийг илүү сайн болго", + "title": "Хоппскочийг илүү сайн болго", + "welcome": "тавтай морил" + }, + "infra_tokens": { + "copy_token_warning": "Инфра токеноо яг одоо хуулж байгаарай. Та үүнийг дахиж харах боломжгүй болно!", + "deletion_success": "Инфра токен {label} устгагдсан", + "empty": "Инфра жетон хоосон байна", + "expired": "Хугацаа дууссан", + "expiration_label": "Хугацаа дуусах", + "expires_on": "Дуусах өдөр", + "generate_modal_title": "Шинэ инфра токен", + "generate_new_token": "Шинэ токен үүсгэх", + "generate_token": "Токен үүсгэх", + "invalid_label": "Токены шошгыг өгнө үү", + "last_used_on": "Хамгийн сүүлд ашигласан", + "no_expiration": "Хугацаа дуусахгүй", + "no_expiration_verbose": "Энэ токен хэзээ ч дуусахгүй!", + "section_description": "Infra жетон бүхий API-уудаар дамжуулан Hoppscotch хэрэглэгчидээ удирдаарай.", + "section_title": "Инфра жетон", + "tab_title": "Инфра жетон", + "token_expires_on": "Энэ токены хугацаа дуусах болно", + "token_purpose": "Энэ тэмдгийг тодорхойлохын тулд шошго оруулна уу" + }, + "metrics": { + "dashboard": "Хяналтын самбар", + "no_metrics": "Ямар ч үзүүлэлт олдсонгүй", + "total_collections": "Нийт цуглуулга", + "total_requests": "Нийт хүсэлт", + "total_teams": "Нийт ажлын талбай", + "total_users": "Нийт хэрэглэгчид" + }, + "newsletter": { + "description": "Манай хамгийн сүүлийн үеийн мэдээний талаар мэдээлэл аваарай", + "subscribe": "Бүртгүүлэх", + "title": "Холбоотой байгаарай", + "toggle_description": "Hoppscotch-ээс хамгийн сүүлийн үеийн мэдээг аваарай", + "unsubscribe": "Бүртгэлээ цуцлах" + }, + "onboarding": { + "add_atleast_one_auth_provider": "Үргэлжлүүлэхийн тулд дор хаяж нэг баталгаажуулалтын үйлчилгээ үзүүлэгч нэмнэ үү.", + "add_configurations": "Тохиргоо нэмэх", + "add_configurations_description": "Сонгосон баталгаажуулалтын аргуудын тохиргоог нэмнэ үү.", + "add_oauth_config": "Баталгаажуулах тохиргоог нэмнэ үү", + "auth_setup": "Баталгаажуулалтын тохиргоо", + "auth_successfully_configured": "баталгаажуулалтыг амжилттай тохирууллаа.", + "complete": "Бүрэн", + "complete_description": "Та элсэлтийн процессыг дуусгалаа. Та одоо Hoppscotch ашиглаж эхлэх боломжтой.", + "complete_title": "Ашиглалт дууссан", + "configurations": "Тохиргоо", + "configurations_added_successfully": "Нэвтрэх тохиргоог амжилттай нэмсэн.", + "configurations_adding_failed": "Нэвтрэх тохиргоог нэмж чадсангүй, дахин оролдоно уу.", + "configurations_description": "Hoppscotch жишээний тохиргоогоо тохируулна уу.", + "configuration_error": "Тохиргоог нэмж чадсангүй", + "configurations_title": "Серверийн тохиргоо", + "configuration_summary": "Тохиргооны хураангуй", + "oauth": { + "description": "Google, GitHub, Microsoft гэх мэт OAuth үйлчилгээ үзүүлэгчдийг тохируулна уу.", + "description_accordian": "Идэвхжүүлэхийг хүсч буй OAuth үйлчилгээ үзүүлэгчээ сонгоод шаардлагатай тохиргоог хийнэ үү.", + "title": "OAuth" + }, + "onboarding_incomplete": { + "description": "Та элсэлтийн процессыг дуусгаагүй байна. Үргэлжлүүлэхийн тулд дор хаяж нэг баталгаажуулалтын үйлчилгээ үзүүлэгчийг тохируулна уу.", + "title": "Ашиглалт бүрэн бус" + }, + "onboarding_fail_help": "Хэрэв та элсэлтийн явцад асуудалтай тулгарвал дэмжлэгтэй холбоо барина уу эсвэл шалгана уу", + "please_fill_configurations": "Шаардлагатай талбарыг бөглөнө үү {fieldName}", + "save_auth_config": "Auth Config-г хадгал", + "setup_complete": { + "title": "Тохиргоо дууссан", + "description": "Та өөрийн Hoppscotch instance-д элсэх процессыг амжилттай дуусгалаа.", + "description_sub": "Хэдхэн секундын дотор хуудас автоматаар хяналтын самбар руу шилжих эсвэл серверийг дахин ачаалсны дараа дахин ачаалах боломжтой." + }, + "server_restarting": "Сервер {time} секундын дараа дахин асаж байна...", + "smtp": { + "description": "Имэйл баталгаажуулалтад SMTP тохируулна уу.", + "description_accordian": "Имэйл илгээх SMTP тохиргоог хийнэ үү.", + "title": "SMTP" + }, + "smtp_advanced_config_enable": "Өөрийн SMTP итгэмжлэлийг тохируулахыг идэвхжүүлнэ үү", + "select_auth_methods": "Баталгаажуулалтыг сонгоно уу", + "select_auth_provider": "Та үргэлжлүүлэхийн тулд нэг буюу хэд хэдэн баталгаажуулалтын үйлчилгээ үзүүлэгчийг сонгож болно.", + "select_atleast_one": "Үргэлжлүүлэхийн тулд дор хаяж нэг баталгаажуулалтын үйлчилгээ үзүүлэгч сонгоно уу.", + "start_onboarding": "Ашиглаж эхлэх", + "welcome": "Тавтай морил", + "welcome_screen_description": "Hoppscotch-д тавтай морил! Тохиргооноос эхэлцгээе.", + "welcome_screen_sub_description": "Баталгаажуулахын тулд SMTP эсвэл OAuth-г тохируулна уу." + }, + "settings": { + "settings": "Тохиргоо" + }, + "shared_requests": { + "action": "Үйлдэл", + "clear_filter": "Шүүлтүүрийг арилгах", + "confirm_request_deletion": "Сонгосон хуваалцсан хүсэлтийг устгахыг баталгаажуулах уу?", + "copy": "Хуулах", + "created_on": "Үүсгэсэн", + "delete": "Устгах", + "email": "Имэйл", + "filter": "Шүүлтүүр", + "filter_by_email": "Имэйлээр шүүнэ үү", + "id": "ID", + "load_list_error": "Хуваалцсан хүсэлтийн жагсаалтыг ачаалах боломжгүй", + "no_requests": "Хуваалцсан хүсэлт олдсонгүй", + "open_request": "Хүсэлтийг нээх", + "properties": "Үл хөдлөх хөрөнгө", + "request": "Хүсэлт", + "show_more": "Илүү ихийг харуулах", + "title": "Хуваалцсан хүсэлтүүд", + "url": "URL" + }, + "state": { + "add_user_failure": "Хэрэглэгчийг ажлын талбарт нэмж чадсангүй!!", + "add_user_success": "Хэрэглэгч одоо ажлын талбарын гишүүн боллоо!!", + "admin_failure": "Хэрэглэгчийг админ болгож чадсангүй!!", + "admin_success": "Хэрэглэгч одоо админ боллоо!!", + "and": "болон", + "clear_selection": "Сонголтыг арилгах", + "configure_auth": "Баталгаажуулах үйлчилгээ үзүүлэгчийг тохируулахын тулд баримт бичгийг шалгана уу.", + "confirm_admin_to_user": "Та энэ хэрэглэгчээс админ статусыг устгахыг хүсэж байна уу?", + "confirm_admins_to_users": "Та сонгосон хэрэглэгчдээс админ статусыг устгахыг хүсэж байна уу?", + "confirm_delete_infra_token": "Та {tokenLabel} infra жетоныг устгахдаа итгэлтэй байна уу?", + "confirm_delete_invite": "Та сонгосон урилгыг хүчингүй болгохыг хүсэж байна уу?", + "confirm_delete_invites": "Та сонгосон урилгыг цуцлахыг хүсэж байна уу?", + "confirm_user_deletion": "Хэрэглэгчийн устгалыг баталгаажуулах уу?", + "confirm_users_deletion": "Та сонгосон хэрэглэгчдийг устгахыг хүсэж байна уу?", + "confirm_user_to_admin": "Та энэ хэрэглэгчийг админ болгохыг хүсэж байна уу?", + "confirm_users_to_admin": "Та сонгосон хэрэглэгчдийг админ болгохыг хүсэж байна уу?", + "confirm_logout": "Гарахыг баталгаажуулна уу", + "created_on": "Үүсгэсэн", + "continue_email": "Цахим шуудангаар үргэлжлүүлнэ үү", + "continue_github": "Github-ийг үргэлжлүүлнэ үү", + "continue_google": "Google-тэй үргэлжлүүлнэ үү", + "continue_microsoft": "Майкрософттой үргэлжлүүлнэ үү", + "copied_to_clipboard": "Түр санах ой руу хуулсан", + "create_team_failure": "Ажлын талбар үүсгэж чадсангүй!!", + "create_team_success": "Ажлын талбарыг амжилттай үүсгэсэн!!", + "data_sharing_failure": "Өгөгдөл хуваалцах тохиргоог шинэчилж чадсангүй", + "delete_infra_token_failure": "Инфра токеныг устгах явцад алдаа гарлаа", + "delete_invite_failure": "Урилгыг устгаж чадсангүй!!", + "delete_invites_failure": "Сонгосон урилгыг устгаж чадсангүй!!", + "delete_invite_success": "Урилгыг амжилттай устгалаа!!", + "delete_invites_success": "Сонгосон урилгыг амжилттай устгалаа!!", + "delete_request_failure": "Хуваалцсан хүсэлтийг устгаж чадсангүй!!", + "delete_request_success": "Хуваалцсан хүсэлтийг амжилттай устгалаа!!", + "delete_team_failure": "Ажлын талбарыг устгаж чадсангүй!!", + "delete_team_success": "Ажлын талбарыг амжилттай устгалаа!!", + "delete_some_users_failure": "Устаагүй хэрэглэгчдийн тоо: {count}", + "delete_some_users_success": "Устгасан хэрэглэгчдийн тоо: {count}", + "delete_user_failed_only_one_admin": "Хэрэглэгчийг устгаж чадсангүй. Дор хаяж нэг админ байх ёстой!!", + "delete_user_failure": "Хэрэглэгчийг устгаж чадсангүй!!", + "delete_users_failure": "Сонгосон хэрэглэгчдийг устгаж чадсангүй!!", + "delete_user_success": "Хэрэглэгчийг амжилттай устгалаа!!", + "delete_users_success": "Сонгосон хэрэглэгчдийг амжилттай устгалаа!!", + "email": "Имэйл", + "email_failure": "Урилга илгээж чадсангүй", + "email_signin_failure": "Имэйлээр нэвтэрч чадсангүй", + "email_success": "Имэйл урилгыг амжилттай илгээв", + "emails_cannot_be_same": "Та өөрийгөө урих боломжгүй, өөр имэйл хаяг сонгоно уу!!", + "enter_team_email": "Ажлын талбар эзэмшигчийн имэйл хаягийг оруулна уу!!", + "error": "Ямар нэг алдаа гарлаа", + "error_auth_providers": "Баталгаажуулах үйлчилгээ үзүүлэгчдийг ачаалах боломжгүй", + "generate_infra_token_failure": "Инфра жетон үүсгэх явцад алдаа гарлаа", + "github_signin_failure": "Github-ээр нэвтэрч чадсангүй", + "google_signin_failure": "Google-ээр нэвтэрч чадсангүй", + "infra_token_label_short": "Infra Token Label тэмдэгтийн урт хэт богино байна!!", + "invalid_email": "Хүчинтэй имэйл хаяг оруулна уу", + "link_copied_to_clipboard": "Холбоосыг санах ой руу хуулсан", + "logged_out": "Гарсан", + "login_as_admin": "болон админ бүртгэлээр нэвтэрнэ үү.", + "login_using_email": "Хэрэглэгчээс имэйлээ шалгах эсвэл доорх холбоосыг хуваалцахыг хүснэ үү", + "login_using_link": "Доорх линкээр нэвтэрч орохыг хэрэглэгчээс хүснэ үү", + "logout": "Гарах", + "magic_link_sign_in": "Холбоос дээр дарж нэвтэрнэ үү.", + "magic_link_success": "Бид шидэт холбоос илгээсэн", + "microsoft_signin_failure": "Microsoft-оор нэвтэрч чадсангүй", + "newsletter_failure": "Мэдээллийн товхимолын тохиргоог шинэчлэх боломжгүй байна", + "non_admin_logged_in": "Админ бус хэрэглэгчээр нэвтэрсэн.", + "non_admin_login": "Та нэвтэрсэн байна. Гэхдээ та админ биш", + "owner_not_present": "Ядаж нэг эзэн багт байх ёстой!!", + "privacy_policy": "Нууцлалын бодлого", + "reenter_email": "Имэйлээ дахин оруулна уу", + "remove_admin_failure": "Админ статусыг устгаж чадсангүй!!", + "remove_admin_failure_only_one_admin": "Админ статусыг устгаж чадсангүй. Дор хаяж нэг админ байх ёстой!!", + "remove_admin_success": "Админ статус хасагдсан!!", + "remove_admin_from_users_failure": "Сонгогдсон хэрэглэгчдээс админ статусыг устгаж чадсангүй!!", + "remove_admin_from_users_success": "Сонгогдсон хэрэглэгчдээс админ статусыг хассан!!", + "remove_admin_to_delete_user": "Хэрэглэгчийг устгахын тулд админы эрхийг хасна уу!!", + "remove_owner_to_delete_user": "Хэрэглэгчийг устгахын тулд багийн эзэмшлийн статусыг устгана уу!!", + "remove_owner_failure_only_one_owner": "Гишүүнийг хасаж чадсангүй. Нэг багт дор хаяж нэг эзэн байх ёстой!!", + "remove_admin_for_deletion": "Устгахыг оролдохоос өмнө админ статусыг устгана уу!!", + "remove_owner_for_deletion": "Нэг буюу хэд хэдэн хэрэглэгчид багийн эзэд байна. Устгахаас өмнө өмчлөлийг шинэчилнэ үү!!", + "remove_invitee_failure": "Урьсан хүнийг хасаж чадсангүй!!", + "remove_invitee_success": "Урьсан хүнийг устгалаа!", + "remove_member_failure": "Гишүүнийг хасаж чадсангүй!!", + "remove_member_success": "Гишүүнийг амжилттай хаслаа!!", + "rename_team_failure": "Ажлын талбарын нэрийг өөрчилж чадсангүй!!", + "rename_team_success": "Ажлын талбайн нэрийг амжилттай өөрчилсөн!", + "rename_user_failure": "Хэрэглэгчийн нэрийг өөрчилж чадсангүй!!", + "rename_user_success": "Хэрэглэгчийн нэрийг амжилттай өөрчилсөн!!", + "require_auth_provider": "Нэвтрэхийн тулд та дор хаяж нэг баталгаажуулалтын үйлчилгээ үзүүлэгч тохируулах шаардлагатай.", + "role_update_failed": "Дүрүүдийн шинэчлэл амжилтгүй боллоо!!", + "role_update_success": "Дүрүүдийг амжилттай шинэчилсэн!!", + "selected": "{count} сонгосон", + "self_host_docs": "Өөрөө хостын баримт бичиг", + "send_magic_link": "Шидэт холбоос илгээх", + "setup_failure": "Тохиргоо амжилтгүй боллоо!!", + "setup_success": "Тохиргоо амжилттай дууссан!!", + "sign_in_agreement": "Нэвтрэн орсноор та манайхыг зөвшөөрч байна", + "sign_in_options": "Бүгд нэвтрэх сонголт", + "sign_out": "Гарах", + "something_went_wrong": "Ямар нэг алдаа гарлаа", + "team_name_too_short": "Ажлын талбарын нэр дор хаяж 6 тэмдэгтээс бүрдэх ёстой!!", + "user_already_invited": "Урилгыг илгээж чадсангүй. Хэрэглэгчийг аль хэдийн урьсан байна!!", + "user_not_found": "Хэрэглэгчийг инфра-д олоогүй байна!!", + "users_to_admin_success": "Сонгогдсон хэрэглэгчдийг админ статустай болгож байна!!", + "users_to_admin_failure": "Сонгогдсон хэрэглэгчдийг админы статустай болгож чадсангүй!!" + }, + "support": { + "description": "Hoppscotch нийгэмлэгээс тусламж аваарай", + "documentation": "Баримт бичиг", + "more_info": "Дэлгэрэнгүй мэдээлэл" + }, + "teams": { + "add_member": "Гишүүн нэмэх", + "add_members": "Гишүүн нэмэх", + "add_new": "Шинэ нэмэх", + "admin": "Админ", + "admin_Email": "Админ Имэйл", + "admin_id": "Админ ID", + "cancel": "Цуцлах", + "confirm_team_deletion": "Ажлын талбарыг устгахыг баталгаажуулах уу?", + "copy": "Хуулах", + "create_team": "Ажлын талбар үүсгэх", + "date": "Огноо", + "delete_team": "Ажлын талбарыг устгах", + "details": "Дэлгэрэнгүй мэдээлэл", + "edit": "Засварлах", + "editor": "РЕДАКТОР", + "editor_description": "Редакторууд хүсэлт, цуглуулга нэмэх, засах, устгах боломжтой.", + "email": "Ажлын талбар эзэмшигчийн имэйл", + "email_address": "Имэйл хаяг", + "email_title": "Имэйл", + "empty_name": "Багийн нэр хоосон байж болохгүй!!", + "error": "Ямар нэг алдаа гарлаа. Дараа дахин оролдоно уу.", + "id": "Ажлын талбарын ID", + "invited_email": "Урьсан имэйл", + "invited_on": "Уригдсан", + "invites": "урьж байна", + "load_info_error": "Workspace-н мэдээллийг ачаалах боломжгүй", + "load_list_error": "Ажлын талбарын жагсаалтыг ачаалах боломжгүй", + "members": "Гишүүдийн тоо", + "no_invite": "Урилга байхгүй", + "owner": "ЭЗЭН", + "owner_description": "Эзэмшигчид хүсэлт, цуглуулга, ажлын талбарын гишүүдийг нэмэх, засах, устгах боломжтой.", + "permissions": "Зөвшөөрөл", + "name": "Ажлын талбарын нэр", + "no_members": "Энэ ажлын талбарт гишүүн алга. Хамтран ажиллахын тулд энэ ажлын талбарт гишүүд нэмнэ үү", + "no_pending_invites": "Хүлээгдэж буй урилга байхгүй", + "no_search_results": "Таны хайлтад тохирох ажлын талбар олдсонгүй", + "no_teams": "Ажлын талбар олдсонгүй.", + "pending_invites": "Хүлээгдэж буй урилгууд", + "roles": "Дүрүүд", + "roles_description": "Хуваалцсан цуглуулгад хандах хандалтыг хянахын тулд дүрүүдийг ашигладаг.", + "remove": "Устгах", + "rename": "Нэрээ өөрчлөх", + "save": "Хадгалах", + "save_changes": "Өөрчлөлтүүдийг хадгалах", + "search_placeholder": "Ажлын талбарын нэр эсвэл ID-аар хайх..", + "send_invite": "Урилга илгээх", + "show_more": "Илүү ихийг харуулах", + "team_details": "Ажлын байрны дэлгэрэнгүй мэдээлэл", + "team_members": "Гишүүд", + "team_members_tab": "Ажлын талбарын гишүүд", + "teams": "Ажлын талбар", + "uid": "UID", + "unnamed": "(Нэргүй ажлын талбар)", + "viewer": "ҮЗЭГЧ", + "viewer_description": "Үзэгчид зөвхөн хүсэлтийг үзэж, ашиглах боломжтой", + "valid_name": "Хүчинтэй ажлын талбайн нэрийг оруулна уу", + "valid_owner_email": "Зөв эзэмшигчийн имэйл хаягаа оруулна уу" + }, + "user_teams": { + "load_error": "Ажлын талбарын гишүүнчлэлийг ачаалах боломжгүй", + "no_teams": "Энэ хэрэглэгч ямар ч ажлын талбарт хамаарахгүй", + "role": "Үүрэг", + "role_unknown": "Тодорхойгүй", + "show_more": "Илүү ихийг харуулах", + "title": "Ажлын талбарууд", + "workspace_id": "Ажлын талбарын ID", + "workspace_name": "Ажлын талбарын нэр" + }, + "users": { + "add_user": "Хэрэглэгч нэмэх", + "admin": "Админ", + "admin_id": "Админ ID", + "cancel": "Цуцлах", + "created_on": "Үүсгэсэн", + "copy_invite_link": "Урилгын холбоосыг хуулах", + "copy_link": "Холбоосыг хуулах", + "date": "Огноо", + "delete": "Устгах", + "delete_user": "Хэрэглэгчийг устгах", + "delete_users": "Хэрэглэгчдийг устгах", + "details": "Дэлгэрэнгүй мэдээлэл", + "edit": "Засварлах", + "email": "Имэйл", + "email_address": "Имэйл хаяг", + "empty_name": "Нэр хоосон байж болохгүй!!", + "id": "Хэрэглэгчийн ID", + "invalid_user": "Буруу хэрэглэгч", + "invite_load_list_error": "Уригдсан хэрэглэгчдийн жагсаалтыг ачаалах боломжгүй байна", + "invite_user": "Хэрэглэгчийг урих", + "invite_users_description": "Багийн гишүүдээ Hoppscotch-д нэгдэхийг урь.", + "invited_by": "Урьсан", + "invited_on": "Уригдсан", + "invited_users": "Уригдсан хэрэглэгчид", + "invitee_email": "Урьсан имэйл", + "last_active_on": "Сүүлийн идэвхтэй", + "load_info_error": "Хэрэглэгчийн мэдээллийг ачаалах боломжгүй байна", + "load_list_error": "Хэрэглэгчдийн жагсаалтыг ачаалах боломжгүй", + "make_admin": "Админ болго", + "name": "Нэр", + "new_user_added": "Шинэ хэрэглэгч нэмэгдсэн", + "no_invite": "Хүлээгдэж буй урилга олдсонгүй", + "no_shared_requests": "Хэрэглэгчийн үүсгэсэн хуваалцсан хүсэлт байхгүй байна", + "no_users": "Хэрэглэгч олдсонгүй", + "not_available": "Боломжгүй", + "not_found": "Хэрэглэгч олдсонгүй", + "pending_invites": "Хүлээгдэж буй урилга", + "pending_invites_description": "Хүлээгдэж буй хэрэглэгчийн урилгыг тодорхой статус, үйлдлээр удирдаж, хянах.", + "remove_admin_privilege": "Админы эрхийг хасах", + "remove_admin_status": "Админ статусыг устгах", + "rename": "Нэрээ өөрчлөх", + "revoke_invitation": "Урилгыг хүчингүй болгох", + "searchbar_placeholder": "Нэр эсвэл имэйлээр хайх.", + "send_invite": "Урилга илгээх", + "show_more": "Илүү ихийг харуулах", + "uid": "UID", + "unnamed": "(Нэргүй хэрэглэгч)", + "user_not_found": "Хэрэглэгчийг инфра-д олоогүй байна!!", + "users": "Хэрэглэгчид", + "valid_email": "Хүчинтэй имэйл хаяг оруулна уу" + } +} \ No newline at end of file diff --git a/packages/hoppscotch-sh-admin/locales/nl.json b/packages/hoppscotch-sh-admin/locales/nl.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/nl.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/no.json b/packages/hoppscotch-sh-admin/locales/no.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/no.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/pl.json b/packages/hoppscotch-sh-admin/locales/pl.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/pl.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/pt-br.json b/packages/hoppscotch-sh-admin/locales/pt-br.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/pt-br.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/pt.json b/packages/hoppscotch-sh-admin/locales/pt.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/pt.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/ro.json b/packages/hoppscotch-sh-admin/locales/ro.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/ro.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/ru.json b/packages/hoppscotch-sh-admin/locales/ru.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/ru.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/sr.json b/packages/hoppscotch-sh-admin/locales/sr.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/sr.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/sv.json b/packages/hoppscotch-sh-admin/locales/sv.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/sv.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/th.json b/packages/hoppscotch-sh-admin/locales/th.json new file mode 100644 index 0000000..dabb99a --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/th.json @@ -0,0 +1,514 @@ +{ + "action": { + "cancel": "ยกเลิก", + "confirm": "ยืนยัน", + "close": "ปิด", + "delete": "ลบ", + "edit": "แก้ไข", + "label": "ป้ายกำกับ", + "save": "บันทึก" + }, + "app": { + "back": "ย้อนกลับ", + "collapse_sidebar": "ย่อแถบด้านข้าง", + "continue_to_dashboard": "ดำเนินการต่อไปยังแดชบอร์ด", + "expand_sidebar": "ขยายแถบด้านข้าง", + "name": "HOPPSCOTCH", + "no_name": "ไม่มีชื่อ", + "open_navigation": "เปิดการนำทาง", + "read_documentation": "อ่านเอกสารประกอบ" + }, + "auth": { + "email_auth": "การยืนยันตัวตนด้วยอีเมล", + "email_auth_description": "เปิดใช้งานหรือปิดใช้งานการยืนยันตัวตนด้วยอีเมลสำหรับผู้ใช้ของคุณ", + "email_auth_enabled": "การยืนยันตัวตนด้วยอีเมลเปิดใช้งานและพร้อมใช้งานแล้ว", + "email_auth_smtp_note": "หากต้องการใช้การยืนยันตัวตนด้วยอีเมล คุณต้องเปิดใช้งานและกำหนดค่า SMTP ในแท็บ SMTP ก่อน", + "enable_email_auth": "เปิดใช้งานการยืนยันตัวตนด้วยอีเมล", + "smtp_required": "ต้องเปิดใช้งานและกำหนดค่า SMTP เพื่อใช้การยืนยันตัวตนด้วยอีเมล" + }, + "configs": { + "auth_providers": { + "auth_provider_config": "การกำหนดค่าการยืนยันตัวตน", + "auth_provider_description": "ตั้งค่าวิธีการยืนยันตัวตนสำหรับอินสแตนซ์ Hoppscotch ของคุณโดยใช้ OAuth หรืออีเมล", + "callback_url": "CALLBACK URL", + "client_id": "CLIENT ID", + "client_secret": "CLIENT SECRET", + "description": "กำหนดค่าผู้ให้บริการยืนยันตัวตนสำหรับเซิร์ฟเวอร์ของคุณ", + "email": "อีเมล", + "github_enterprise": "เปิดใช้งาน Github Enterprise", + "oauth": "OAuth", + "oauth_providers": "ผู้ให้บริการ OAuth", + "provider_not_specified": "โปรดเปิดใช้งานผู้ให้บริการยืนยันตัวตนอย่างน้อยหนึ่งราย", + "scope": "SCOPE", + "sso": "SSO", + "tenant": "TENANT", + "title": "ผู้ให้บริการ OAuth", + "token": { + "description": "กำหนดค่าโทเค็นสำหรับเซิร์ฟเวอร์ของคุณ", + "title": "โทเค็น", + "jwt_secret": " JWT Secret", + "token_salt_complexity": "ความซับซ้อนของ Token Salt", + "magic_link_token_validity": "อายุของ Magic Link Token (เป็นชั่วโมง)", + "refresh_token_validity": "อายุของ Refresh Token (เป็นมิลลิวินาที)", + "access_token_validity": "อายุของ Access Token (เป็นมิลลิวินาที)", + "session_secret": "Session Secret", + "session_cookie_name": "ชื่อ Session Cookie (ไม่บังคับ)", + "session_cookie_name_help": "ใช้ได้เฉพาะตัวอักษร ตัวเลข ขีดล่าง และยัติภังค์เท่านั้น เว้นว่างไว้เพื่อใช้ค่าเริ่มต้น 'connect.sid'", + "session_cookie_name_invalid": "ชื่อคุกกี้ไม่ถูกต้อง อนุญาตเฉพาะตัวอักษร ตัวเลข ขีดล่าง และยัติภังค์เท่านั้น", + "update_failure": "ไม่สามารถอัปเดตการกำหนดค่าโทเค็นได้!!" + }, + "update_failure": "ไม่สามารถอัปเดตการกำหนดค่าผู้ให้บริการยืนยันตัวตนได้!!" + }, + "confirm_changes": "เซิร์ฟเวอร์ Hoppscotch ต้องรีสตาร์ทเพื่อให้การเปลี่ยนแปลงใหม่มีผล ยืนยันการเปลี่ยนแปลงที่ทำกับการกำหนดค่าเซิร์ฟเวอร์หรือไม่?", + "invalid_number": "โปรดป้อนตัวเลขที่ถูกต้องสำหรับช่องนี้", + "input_empty": "โปรดกรอกข้อมูลทุกช่องก่อนอัปเดตการกำหนดค่า", + "input_validation_error": "บางช่องมีค่าที่ไม่ถูกต้อง โปรดแก้ไขก่อนอัปเดตการกำหนดค่า", + "data_sharing": { + "title": "การแบ่งปันข้อมูล", + "description": "ช่วยปรับปรุง Hoppscotch ด้วยการแบ่งปันข้อมูลแบบไม่ระบุตัวตน", + "enable": "เปิดใช้งานการแบ่งปันข้อมูล", + "secondary_title": "การกำหนดค่าการแบ่งปันข้อมูล", + "see_shared": "ดูข้อมูลที่แบ่งปัน", + "toggle_description": "แบ่งปันข้อมูลแบบไม่ระบุตัวตน", + "update_failure": "ไม่สามารถอัปเดตการกำหนดค่าการแบ่งปันข้อมูลได้!!" + }, + "load_error": "ไม่สามารถโหลดการกำหนดค่าเซิร์ฟเวอร์ได้", + "mail_configs": { + "address_from": "ที่อยู่ผู้ส่งอีเมล", + "custom_smtp_configs": "ใช้การกำหนดค่า SMTP แบบกำหนดเอง", + "description": " กำหนดค่าการตั้งค่า SMTP", + "enable_email_auth": "เปิดใช้งานการยืนยันตัวตนด้วยอีเมล", + "enable_smtp": "เปิดใช้งาน SMTP", + "host": "SMTP Host", + "input_validation": "SMTP URL ต้องขึ้นต้นด้วย smtp(s)://", + "password": "SMTP Password", + "port": "SMTP Port", + "secure": "SMTP Secure", + "smtp_url": "SMTP URL", + "ignore_tls": "SMTP Ignore TLS", + "tls_reject_unauthorized": "TLS Reject Unauthorized", + "title": "การกำหนดค่า SMTP", + "smtp_auth_incomplete": "ต้องระบุชื่อผู้ใช้และรหัสผ่าน SMTP ทั้งคู่ หรือเว้นว่างทั้งคู่", + "auth_switch_description": "การสลับจะล้างข้อมูลรับรองที่ป้อนในประเภทการยืนยันตัวตนปัจจุบัน ข้อมูลที่ยังไม่ได้บันทึกจะสูญหายและไม่สามารถกู้คืนได้", + "auth_type": "ประเภทการยืนยันตัวตน", + "auth_type_login": "Basic Auth (ล็อกอิน)", + "auth_type_oauth2": "OAuth 2.0", + "oauth2_user": "OAuth 2.0 User", + "oauth2_client_id": "OAuth 2.0 Client ID", + "oauth2_client_secret": "OAuth 2.0 Client Secret", + "oauth2_refresh_token": "OAuth 2.0 Refresh Token", + "oauth2_access_url": "OAuth 2.0 Access Token URL", + "toggle_failure": "ไม่สามารถสลับ SMTP ได้!!", + "update_failure": "ไม่สามารถอัปเดตการกำหนดค่า SMTP ได้!!", + "user": "SMTP User" + }, + "history_configs": { + "description": "ปิดหรือเปิดการติดตามประวัติคำขอที่ส่งจากแอป Hoppscotch", + "title": "การกำหนดค่าประวัติ", + "enable_history": "เปิดใช้งานประวัติ", + "clear_history": "ล้างประวัติที่มีอยู่", + "clear_failure": "ไม่สามารถล้างประวัติได้!!", + "clear_success": "ล้างประวัติสำเร็จแล้ว", + "toggle_failure": "ไม่สามารถสลับประวัติได้!!", + "clear_confirm": "คุณแน่ใจหรือไม่ว่าต้องการล้างประวัติทั้งหมด?" + }, + "rate_limit": { + "description": "กำหนดค่าการจำกัดอัตราสำหรับอินสแตนซ์ Hoppscotch ของคุณ", + "rate_limit_max": "คำขอสูงสุด", + "rate_limit_ttl": "ระยะเวลาคงอยู่ (เป็นมิลลิวินาที)", + "title": "การกำหนดค่าการจำกัดอัตรา", + "update_failure": "ไม่สามารถอัปเดตการกำหนดค่าการจำกัดอัตราได้!!", + "input_validation_error": "โปรดป้อนค่าที่ถูกต้องสำหรับการกำหนดค่าการจำกัดอัตรา" + }, + "proxy_url_configs": { + "description": "กำหนดค่า Proxy URL สำหรับแอป", + "input_validation": "Proxy URL ต้องขึ้นต้นด้วย http(s):// และต้องไม่มีช่องว่าง", + "title": "การกำหนดค่า Proxy URL", + "update_failure": "ไม่สามารถอัปเดตการกำหนดค่า Proxy URL ได้!!", + "url_placeholder": "ป้อน Proxy URL" + }, + "reset": { + "confirm_reset": "เซิร์ฟเวอร์ Hoppscotch ต้องรีสตาร์ทเพื่อให้การเปลี่ยนแปลงใหม่มีผล ยืนยันการรีเซ็ตการกำหนดค่าเซิร์ฟเวอร์หรือไม่?", + "description": "การกำหนดค่าเริ่มต้นจะถูกโหลดตามที่ระบุไว้ในไฟล์ environment", + "failure": "ไม่สามารถรีเซ็ตการกำหนดค่าได้!!", + "title": "รีเซ็ตการกำหนดค่า", + "info": "รีเซ็ตการกำหนดค่าเซิร์ฟเวอร์" + }, + "restart": { + "description": "เหลือเวลาอีก {duration} วินาทีก่อนหน้านี้จะโหลดซ้ำโดยอัตโนมัติ", + "initiate": "กำลังเริ่มการรีสตาร์ทเซิร์ฟเวอร์...", + "title": "เซิร์ฟเวอร์กำลังรีสตาร์ท" + }, + "save_changes": "บันทึกการเปลี่ยนแปลง", + "tabs": { + "auth": "การยืนยันตัวตน", + "activity": "กิจกรรม", + "infra_tokens": "Infra Tokens", + "proxy": "Proxy", + "rate_limit": "การจำกัดอัตรา", + "smtp": "SMTP", + "miscellaneous": "อื่นๆ", + "reset": "รีเซ็ตการกำหนดค่า" + }, + "title": "การกำหนดค่า", + "mock_server": { + "title": "Mock Server", + "description": "กำหนดค่าการตั้งค่า Mock Server ที่ใช้สำหรับโฮสต์การตอบกลับตัวอย่าง", + "wildcard_domain": "Wildcard Domain", + "wildcard_domain_description": "ช่องนี้ต้องใช้รูปแบบ Wildcard Domain แบบเต็ม ข้อมูลที่ป้อนต้องขึ้นต้นด้วยเครื่องหมายดอกจัน (*) ตามด้วยจุด (.) แล้วจึงตามด้วยชื่อโดเมน", + "wildcard_domain_example": "ตัวอย่าง: *.mock.domain.com", + "subpath_content_type_notice_prefix": "หากคุณไม่ได้ใช้ Wildcard Domain สำหรับ Mock Server (กล่าวคือ ใช้ subpath แทน) การตอบกลับที่มีประเภทเนื้อหาต่อไปนี้จะถูกส่งโดยอัตโนมัติเป็น", + "subpath_content_type_text_plain": "text/plain", + "subpath_content_type_notice_suffix": "เพื่อช่วยป้องกันการโจมตีแบบ XSS:" + }, + "update_failure": "ไม่สามารถอัปเดตการกำหนดค่าเซิร์ฟเวอร์ได้" + }, + "data_sharing": { + "description": "แชร์ข้อมูลการใช้งานแบบไม่ระบุตัวตนเพื่อปรับปรุง Hoppscotch", + "enable": "เปิดใช้งานการแชร์ข้อมูล", + "see_shared": "ดูสิ่งที่แชร์", + "toggle_description": "แชร์ข้อมูลและทำให้ Hoppscotch ดียิ่งขึ้น", + "title": "ทำให้ Hoppscotch ดียิ่งขึ้น", + "welcome": "ยินดีต้อนรับสู่" + }, + "infra_tokens": { + "copy_token_warning": "ตรวจสอบให้แน่ใจว่าได้คัดลอก Infra Token ของคุณตอนนี้ คุณจะไม่สามารถดูได้อีก!", + "deletion_success": "Infra Token {label} ถูกลบแล้ว", + "empty": "ไม่มี Infra Token", + "expired": "หมดอายุแล้ว", + "expiration_label": "การหมดอายุ", + "expires_on": "หมดอายุเมื่อ", + "generate_modal_title": "Infra Token ใหม่", + "generate_new_token": "สร้างโทเค็นใหม่", + "generate_token": "สร้างโทเค็น", + "invalid_label": "โปรดระบุป้ายกำกับสำหรับโทเค็น", + "last_used_on": "ใช้งานล่าสุดเมื่อ", + "no_expiration": "ไม่มีการหมดอายุ", + "no_expiration_verbose": "โทเค็นนี้จะไม่มีวันหมดอายุ!", + "section_description": "จัดการผู้ใช้ Hoppscotch ของคุณผ่าน API ด้วย Infra Token", + "section_title": "Infra Tokens", + "tab_title": "Infra Tokens", + "token_expires_on": "โทเค็นนี้จะหมดอายุเมื่อ", + "token_purpose": "ป้อนป้ายกำกับเพื่อระบุโทเค็นนี้" + }, + "metrics": { + "dashboard": "แดชบอร์ด", + "no_metrics": "ไม่พบเมตริก", + "total_collections": "คอลเลกชันทั้งหมด", + "total_requests": "คำขอทั้งหมด", + "total_teams": "พื้นที่ทำงานทั้งหมด", + "total_users": "ผู้ใช้ทั้งหมด" + }, + "newsletter": { + "description": "รับข่าวสารล่าสุดของเรา", + "subscribe": "สมัครรับข้อมูล", + "title": "ติดต่อกันไว้", + "toggle_description": "รับข่าวสารล่าสุดเกี่ยวกับ Hoppscotch", + "unsubscribe": "ยกเลิกการสมัครรับข้อมูล" + }, + "onboarding": { + "add_atleast_one_auth_provider": "โปรดเพิ่มผู้ให้บริการยืนยันตัวตนอย่างน้อยหนึ่งรายเพื่อดำเนินการต่อ", + "add_configurations": "เพิ่มการกำหนดค่า", + "add_configurations_description": "โปรดเพิ่มการกำหนดค่าสำหรับวิธีการยืนยันตัวตนที่เลือก", + "add_oauth_config": "เพิ่มการกำหนดค่าการยืนยันตัวตน", + "auth_setup": "การตั้งค่าการยืนยันตัวตน", + "auth_successfully_configured": "กำหนดค่าการยืนยันตัวตนสำเร็จแล้ว", + "complete": "เสร็จสมบูรณ์", + "complete_description": "คุณได้เสร็จสิ้นกระบวนการเริ่มต้นใช้งานแล้ว ตอนนี้คุณสามารถเริ่มใช้งาน Hoppscotch ได้", + "complete_title": "การเริ่มต้นใช้งานเสร็จสมบูรณ์", + "configurations": "การกำหนดค่า", + "configurations_added_successfully": "เพิ่มการกำหนดค่าการเริ่มต้นใช้งานสำเร็จแล้ว", + "configurations_adding_failed": "ไม่สามารถเพิ่มการกำหนดค่าการเริ่มต้นใช้งานได้ โปรดลองอีกครั้ง", + "configurations_description": "กำหนดค่าการตั้งค่าอินสแตนซ์ Hoppscotch ของคุณ", + "configuration_error": "ไม่สามารถเพิ่มการกำหนดค่าได้", + "configurations_title": "การกำหนดค่าเซิร์ฟเวอร์", + "configuration_summary": "สรุปการกำหนดค่า", + "oauth": { + "description": "ตั้งค่าผู้ให้บริการ OAuth เช่น Google, GitHub, Microsoft ฯลฯ", + "description_accordian": "เลือกผู้ให้บริการ OAuth ที่คุณต้องการเปิดใช้งานและระบุการกำหนดค่าที่จำเป็น", + "title": "OAuth" + }, + "onboarding_incomplete": { + "description": "คุณยังไม่ได้ทำกระบวนการเริ่มต้นใช้งานให้เสร็จสมบูรณ์ โปรดตั้งค่าผู้ให้บริการยืนยันตัวตนอย่างน้อยหนึ่งรายเพื่อดำเนินการต่อ", + "title": "การเริ่มต้นใช้งานยังไม่เสร็จสมบูรณ์" + }, + "onboarding_fail_help": "หากคุณประสบปัญหากับกระบวนการเริ่มต้นใช้งาน โปรดติดต่อฝ่ายสนับสนุนหรือตรวจสอบ", + "please_fill_configurations": "โปรดกรอกช่องที่จำเป็น {fieldName}", + "save_auth_config": "บันทึกการกำหนดค่าการยืนยันตัวตน", + "setup_complete": { + "title": "การตั้งค่าเสร็จสมบูรณ์", + "description": "คุณได้ทำกระบวนการเริ่มต้นใช้งานสำหรับอินสแตนซ์ Hoppscotch ของคุณเสร็จสมบูรณ์แล้ว", + "description_sub": "หน้านี้จะเปลี่ยนเส้นทางไปยังแดชบอร์ดโดยอัตโนมัติในอีกไม่กี่วินาที หรือคุณสามารถโหลดซ้ำได้เมื่อเซิร์ฟเวอร์รีสตาร์ทแล้ว" + }, + "server_restarting": "เซิร์ฟเวอร์จะรีสตาร์ทในอีก {time} วินาที...", + "smtp": { + "description": "ตั้งค่า SMTP สำหรับการยืนยันตัวตนด้วยอีเมล", + "description_accordian": "กำหนดค่าการตั้งค่า SMTP สำหรับการส่งอีเมล", + "title": "SMTP" + }, + "smtp_advanced_config_enable": "เปิดใช้งานเพื่อกำหนดค่าข้อมูลรับรอง SMTP ของคุณเอง", + "select_auth_methods": "โปรดเลือกวิธีการยืนยันตัวตน", + "select_auth_provider": "คุณสามารถเลือกผู้ให้บริการยืนยันตัวตนหนึ่งรายหรือมากกว่าเพื่อดำเนินการต่อ", + "select_atleast_one": "โปรดเลือกผู้ให้บริการยืนยันตัวตนอย่างน้อยหนึ่งรายเพื่อดำเนินการต่อ", + "start_onboarding": "เริ่มการเริ่มต้นใช้งาน", + "welcome": "ยินดีต้อนรับ", + "welcome_screen_description": "ยินดีต้อนรับสู่ Hoppscotch! มาเริ่มต้นการตั้งค่ากันเลย", + "welcome_screen_sub_description": "โปรดตั้งค่า SMTP หรือ OAuth อย่างใดอย่างหนึ่งสำหรับการยืนยันตัวตน" + }, + "settings": { + "settings": "การตั้งค่า" + }, + "shared_requests": { + "action": "การดำเนินการ", + "clear_filter": "ล้างตัวกรอง", + "confirm_request_deletion": "ยืนยันการลบคำขอที่แชร์ที่เลือกหรือไม่?", + "copy": "คัดลอก", + "created_on": "สร้างเมื่อ", + "delete": "ลบ", + "email": "อีเมล", + "filter": "ตัวกรอง", + "filter_by_email": "กรองตามอีเมล", + "id": "ID", + "load_list_error": "ไม่สามารถโหลดรายการคำขอที่แชร์ได้", + "no_requests": "ไม่พบคำขอที่แชร์", + "open_request": "เปิดคำขอ", + "properties": "คุณสมบัติ", + "request": "คำขอ", + "show_more": "แสดงเพิ่มเติม", + "title": "คำขอที่แชร์", + "url": "URL" + }, + "state": { + "add_user_failure": "เพิ่มผู้ใช้เข้าพื้นที่ทำงานไม่สำเร็จ!!", + "add_user_success": "ผู้ใช้เป็นสมาชิกของพื้นที่ทำงานแล้ว!!", + "admin_failure": "ตั้งผู้ใช้เป็นผู้ดูแลระบบไม่สำเร็จ!!", + "admin_success": "ผู้ใช้เป็นผู้ดูแลระบบแล้ว!!", + "and": "และ", + "clear_selection": "ล้างการเลือก", + "configure_auth": "ดูเอกสารประกอบเพื่อกำหนดค่าผู้ให้บริการการยืนยันตัวตน", + "confirm_admin_to_user": "คุณต้องการนำสถานะผู้ดูแลระบบออกจากผู้ใช้รายนี้หรือไม่?", + "confirm_admins_to_users": "คุณต้องการนำสถานะผู้ดูแลระบบออกจากผู้ใช้ที่เลือกหรือไม่?", + "confirm_delete_infra_token": "คุณแน่ใจหรือไม่ว่าต้องการลบ Infra Token {tokenLabel}?", + "confirm_delete_invite": "คุณต้องการเพิกถอนคำเชิญที่เลือกหรือไม่?", + "confirm_delete_invites": "คุณต้องการเพิกถอนคำเชิญที่เลือกหรือไม่?", + "confirm_user_deletion": "ยืนยันการลบผู้ใช้?", + "confirm_users_deletion": "คุณต้องการลบผู้ใช้ที่เลือกหรือไม่?", + "confirm_user_to_admin": "คุณต้องการตั้งผู้ใช้รายนี้เป็นผู้ดูแลระบบหรือไม่?", + "confirm_users_to_admin": "คุณต้องการตั้งผู้ใช้ที่เลือกเป็นผู้ดูแลระบบหรือไม่?", + "confirm_logout": "ยืนยันการออกจากระบบ", + "created_on": "สร้างเมื่อ", + "continue_email": "ดำเนินการต่อด้วยอีเมล", + "continue_github": "ดำเนินการต่อด้วย Github", + "continue_google": "ดำเนินการต่อด้วย Google", + "continue_microsoft": "ดำเนินการต่อด้วย Microsoft", + "copied_to_clipboard": "คัดลอกไปยังคลิปบอร์ดแล้ว", + "create_team_failure": "สร้างพื้นที่ทำงานไม่สำเร็จ!!", + "create_team_success": "สร้างพื้นที่ทำงานสำเร็จแล้ว!!", + "data_sharing_failure": "อัปเดตการตั้งค่าการแชร์ข้อมูลไม่สำเร็จ", + "delete_infra_token_failure": "เกิดข้อผิดพลาดขณะลบ Infra Token", + "delete_invite_failure": "ลบคำเชิญไม่สำเร็จ!!", + "delete_invites_failure": "ลบคำเชิญที่เลือกไม่สำเร็จ!!", + "delete_invite_success": "ลบคำเชิญสำเร็จแล้ว!!", + "delete_invites_success": "ลบคำเชิญที่เลือกสำเร็จแล้ว!!", + "delete_request_failure": "ลบคำขอที่แชร์ไม่สำเร็จ!!", + "delete_request_success": "ลบคำขอที่แชร์สำเร็จแล้ว!!", + "delete_team_failure": "ลบพื้นที่ทำงานไม่สำเร็จ!!", + "delete_team_success": "ลบพื้นที่ทำงานสำเร็จแล้ว!!", + "delete_some_users_failure": "จำนวนผู้ใช้ที่ลบไม่สำเร็จ: {count}", + "delete_some_users_success": "จำนวนผู้ใช้ที่ลบแล้ว: {count}", + "delete_user_failed_only_one_admin": "ลบผู้ใช้ไม่สำเร็จ ต้องมีผู้ดูแลระบบอย่างน้อยหนึ่งราย!!", + "delete_user_failure": "ลบผู้ใช้ไม่สำเร็จ!!", + "delete_users_failure": "ลบผู้ใช้ที่เลือกไม่สำเร็จ!!", + "delete_user_success": "ลบผู้ใช้สำเร็จแล้ว!!", + "delete_users_success": "ลบผู้ใช้ที่เลือกสำเร็จแล้ว!!", + "email": "อีเมล", + "email_failure": "ส่งคำเชิญไม่สำเร็จ", + "email_signin_failure": "เข้าสู่ระบบด้วยอีเมลไม่สำเร็จ", + "email_success": "ส่งคำเชิญทางอีเมลสำเร็จแล้ว", + "emails_cannot_be_same": "คุณไม่สามารถเชิญตัวเองได้ โปรดเลือกที่อยู่อีเมลอื่น!!", + "enter_team_email": "โปรดป้อนอีเมลของเจ้าของพื้นที่ทำงาน!!", + "error": "เกิดข้อผิดพลาดบางอย่าง", + "error_auth_providers": "ไม่สามารถโหลดผู้ให้บริการการยืนยันตัวตนได้", + "generate_infra_token_failure": "เกิดข้อผิดพลาดขณะสร้าง Infra Token", + "github_signin_failure": "เข้าสู่ระบบด้วย Github ไม่สำเร็จ", + "google_signin_failure": "เข้าสู่ระบบด้วย Google ไม่สำเร็จ", + "infra_token_label_short": "ป้ายกำกับ Infra Token มีความยาวตัวอักษรน้อยเกินไป!!", + "invalid_email": "โปรดป้อนที่อยู่อีเมลที่ถูกต้อง", + "link_copied_to_clipboard": "คัดลอกลิงก์ไปยังคลิปบอร์ดแล้ว", + "logged_out": "ออกจากระบบแล้ว", + "login_as_admin": "และเข้าสู่ระบบด้วยบัญชีผู้ดูแลระบบ", + "login_using_email": "โปรดขอให้ผู้ใช้ตรวจสอบอีเมลหรือแชร์ลิงก์ด้านล่าง", + "login_using_link": "โปรดขอให้ผู้ใช้เข้าสู่ระบบโดยใช้ลิงก์ด้านล่าง", + "logout": "ออกจากระบบ", + "magic_link_sign_in": "คลิกที่ลิงก์เพื่อเข้าสู่ระบบ", + "magic_link_success": "เราได้ส่งลิงก์เวทมนตร์ไปยัง", + "microsoft_signin_failure": "เข้าสู่ระบบด้วย Microsoft ไม่สำเร็จ", + "newsletter_failure": "ไม่สามารถอัปเดตการตั้งค่าจดหมายข่าวได้", + "non_admin_logged_in": "เข้าสู่ระบบในฐานะผู้ใช้ที่ไม่ใช่ผู้ดูแลระบบ", + "non_admin_login": "คุณเข้าสู่ระบบแล้ว แต่คุณไม่ใช่ผู้ดูแลระบบ", + "owner_not_present": "ต้องมีเจ้าของอย่างน้อยหนึ่งรายในทีม!!", + "privacy_policy": "นโยบายความเป็นส่วนตัว", + "reenter_email": "ป้อนอีเมลอีกครั้ง", + "remove_admin_failure": "นำสถานะผู้ดูแลระบบออกไม่สำเร็จ!!", + "remove_admin_failure_only_one_admin": "นำสถานะผู้ดูแลระบบออกไม่สำเร็จ ต้องมีผู้ดูแลระบบอย่างน้อยหนึ่งราย!!", + "remove_admin_success": "นำสถานะผู้ดูแลระบบออกแล้ว!!", + "remove_admin_from_users_failure": "นำสถานะผู้ดูแลระบบออกจากผู้ใช้ที่เลือกไม่สำเร็จ!!", + "remove_admin_from_users_success": "นำสถานะผู้ดูแลระบบออกจากผู้ใช้ที่เลือกแล้ว!!", + "remove_admin_to_delete_user": "นำสิทธิ์ผู้ดูแลระบบออกเพื่อลบผู้ใช้!!", + "remove_owner_to_delete_user": "นำสถานะเจ้าของทีมออกเพื่อลบผู้ใช้!!", + "remove_owner_failure_only_one_owner": "นำสมาชิกออกไม่สำเร็จ ต้องมีเจ้าของอย่างน้อยหนึ่งรายในทีม!!", + "remove_admin_for_deletion": "นำสถานะผู้ดูแลระบบออกก่อนพยายามลบ!!", + "remove_owner_for_deletion": "มีผู้ใช้หนึ่งรายหรือมากกว่าเป็นเจ้าของทีม โปรดอัปเดตสถานะความเป็นเจ้าของก่อนลบ!!", + "remove_invitee_failure": "นำผู้ได้รับเชิญออกไม่สำเร็จ!!", + "remove_invitee_success": "นำผู้ได้รับเชิญออกสำเร็จแล้ว!!", + "remove_member_failure": "ไม่สามารถนำสมาชิกออกได้!!", + "remove_member_success": "นำสมาชิกออกสำเร็จแล้ว!!", + "rename_team_failure": "เปลี่ยนชื่อพื้นที่ทำงานไม่สำเร็จ!!", + "rename_team_success": "เปลี่ยนชื่อพื้นที่ทำงานสำเร็จแล้ว!", + "rename_user_failure": "เปลี่ยนชื่อผู้ใช้ไม่สำเร็จ!!", + "rename_user_success": "เปลี่ยนชื่อผู้ใช้สำเร็จแล้ว!!", + "require_auth_provider": "คุณต้องตั้งค่าผู้ให้บริการการยืนยันตัวตนอย่างน้อยหนึ่งรายเพื่อเข้าสู่ระบบ", + "role_update_failed": "อัปเดตบทบาทไม่สำเร็จ!!", + "role_update_success": "อัปเดตบทบาทสำเร็จแล้ว!!", + "selected": "เลือกแล้ว {count} รายการ", + "self_host_docs": "เอกสารประกอบการโฮสต์ด้วยตนเอง", + "send_magic_link": "ส่งลิงก์เวทมนตร์", + "setup_failure": "การตั้งค่าล้มเหลว!!", + "setup_success": "ตั้งค่าสำเร็จแล้ว!!", + "sign_in_agreement": "เมื่อเข้าสู่ระบบ แสดงว่าคุณยอมรับ", + "sign_in_options": "ตัวเลือกการเข้าสู่ระบบทั้งหมด", + "sign_out": "ออกจากระบบ", + "something_went_wrong": "เกิดข้อผิดพลาดบางอย่าง", + "team_name_too_short": "ชื่อพื้นที่ทำงานต้องมีความยาวอย่างน้อย 6 ตัวอักษร!!", + "user_already_invited": "ส่งคำเชิญไม่สำเร็จ ผู้ใช้ได้รับเชิญแล้ว!!", + "user_not_found": "ไม่พบผู้ใช้ในระบบโครงสร้างพื้นฐาน!!", + "users_to_admin_success": "ยกระดับผู้ใช้ที่เลือกเป็นสถานะผู้ดูแลระบบแล้ว!!", + "users_to_admin_failure": "ยกระดับผู้ใช้ที่เลือกเป็นสถานะผู้ดูแลระบบไม่สำเร็จ!!" + }, + "support": { + "description": "รับความช่วยเหลือจากชุมชน Hoppscotch", + "documentation": "เอกสารประกอบ", + "more_info": "ข้อมูลเพิ่มเติม" + }, + "teams": { + "add_member": "เพิ่มสมาชิก", + "add_members": "เพิ่มสมาชิก", + "add_new": "เพิ่มใหม่", + "admin": "ผู้ดูแลระบบ", + "admin_Email": "อีเมลผู้ดูแลระบบ", + "admin_id": "รหัสผู้ดูแลระบบ", + "cancel": "ยกเลิก", + "confirm_team_deletion": "ยืนยันการลบพื้นที่ทำงาน?", + "copy": "คัดลอก", + "create_team": "สร้างพื้นที่ทำงาน", + "date": "วันที่", + "delete_team": "ลบพื้นที่ทำงาน", + "details": "รายละเอียด", + "edit": "แก้ไข", + "editor": "ผู้แก้ไข", + "editor_description": "ผู้แก้ไขสามารถเพิ่ม แก้ไข และลบคำขอและคอลเลกชันได้", + "email": "อีเมลเจ้าของพื้นที่ทำงาน", + "email_address": "ที่อยู่อีเมล", + "email_title": "อีเมล", + "empty_name": "ชื่อทีมต้องไม่เว้นว่าง!!", + "error": "เกิดข้อผิดพลาดบางอย่าง โปรดลองอีกครั้งในภายหลัง", + "id": "รหัสพื้นที่ทำงาน", + "invited_email": "อีเมลผู้ได้รับเชิญ", + "invited_on": "เชิญเมื่อ", + "invites": "คำเชิญ", + "load_info_error": "ไม่สามารถโหลดข้อมูลพื้นที่ทำงานได้", + "load_list_error": "ไม่สามารถโหลดรายการพื้นที่ทำงานได้", + "members": "จำนวนสมาชิก", + "no_invite": "ไม่มีคำเชิญ", + "owner": "เจ้าของ", + "owner_description": " เจ้าของสามารถเพิ่ม แก้ไข และลบคำขอ คอลเลกชัน และสมาชิกพื้นที่ทำงานได้", + "permissions": "สิทธิ์การเข้าถึง", + "name": "ชื่อพื้นที่ทำงาน", + "no_members": "ไม่มีสมาชิกในพื้นที่ทำงานนี้ เพิ่มสมาชิกในพื้นที่ทำงานนี้เพื่อทำงานร่วมกัน", + "no_pending_invites": "ไม่มีคำเชิญที่รอดำเนินการ", + "no_search_results": "ไม่พบพื้นที่ทำงานที่ตรงกับการค้นหาของคุณ", + "no_teams": "ไม่พบพื้นที่ทำงาน..", + "pending_invites": "คำเชิญที่รอดำเนินการ", + "roles": "บทบาท", + "roles_description": "บทบาทใช้เพื่อควบคุมการเข้าถึงคอลเลกชันที่แชร์", + "remove": "นำออก", + "rename": "เปลี่ยนชื่อ", + "save": "บันทึก", + "save_changes": "บันทึกการเปลี่ยนแปลง", + "search_placeholder": "ค้นหาตามชื่อหรือรหัสพื้นที่ทำงาน..", + "send_invite": "ส่งคำเชิญ", + "show_more": "แสดงเพิ่มเติม", + "team_details": "รายละเอียดพื้นที่ทำงาน", + "team_members": "สมาชิก", + "team_members_tab": "สมาชิกพื้นที่ทำงาน", + "teams": "พื้นที่ทำงาน", + "uid": "UID", + "unnamed": "(พื้นที่ทำงานที่ไม่มีชื่อ)", + "viewer": "ผู้ดู", + "viewer_description": "ผู้ดูสามารถดูและใช้คำขอได้เท่านั้น", + "valid_name": "โปรดป้อนชื่อพื้นที่ทำงานที่ถูกต้อง", + "valid_owner_email": "โปรดป้อนอีเมลเจ้าของที่ถูกต้อง" + }, + "user_teams": { + "load_error": "ไม่สามารถโหลดสมาชิกพื้นที่ทำงานได้", + "no_teams": "ผู้ใช้รายนี้ไม่ได้อยู่ในพื้นที่ทำงานใดๆ", + "role": "บทบาท", + "role_unknown": "ไม่ทราบ", + "show_more": "แสดงเพิ่มเติม", + "title": "พื้นที่ทำงาน", + "workspace_id": "Workspace ID", + "workspace_name": "ชื่อพื้นที่ทำงาน" + }, + "users": { + "add_user": "เพิ่มผู้ใช้", + "admin": "ผู้ดูแลระบบ", + "admin_id": "รหัสผู้ดูแลระบบ", + "cancel": "ยกเลิก", + "created_on": "สร้างเมื่อ", + "copy_invite_link": "คัดลอกลิงก์คำเชิญ", + "copy_link": "คัดลอกลิงก์", + "date": "วันที่", + "delete": "ลบ", + "delete_user": "ลบผู้ใช้", + "delete_users": "ลบผู้ใช้", + "details": "รายละเอียด", + "edit": "แก้ไข", + "email": "อีเมล", + "email_address": "ที่อยู่อีเมล", + "empty_name": "ชื่อต้องไม่เว้นว่าง!!", + "id": "รหัสผู้ใช้", + "invalid_user": "ผู้ใช้ไม่ถูกต้อง", + "invite_load_list_error": "ไม่สามารถโหลดรายการผู้ใช้ที่ได้รับเชิญได้", + "invite_user": "เชิญผู้ใช้", + "invite_users_description": "เชิญสมาชิกในทีมของคุณเข้าร่วม Hoppscotch", + "invited_by": "เชิญโดย", + "invited_on": "เชิญเมื่อ", + "invited_users": "ผู้ใช้ที่ได้รับเชิญ", + "invitee_email": "อีเมลผู้ได้รับเชิญ", + "last_active_on": "ใช้งานล่าสุด", + "load_info_error": "ไม่สามารถโหลดข้อมูลผู้ใช้ได้", + "load_list_error": "ไม่สามารถโหลดรายการผู้ใช้ได้", + "make_admin": "ตั้งเป็นผู้ดูแลระบบ", + "name": "ชื่อ", + "new_user_added": "เพิ่มผู้ใช้ใหม่แล้ว", + "no_invite": "ไม่พบคำเชิญที่รอดำเนินการ", + "no_shared_requests": "ไม่มีคำขอที่แชร์ซึ่งสร้างโดยผู้ใช้", + "no_users": "ไม่พบผู้ใช้", + "not_available": "ไม่พร้อมใช้งาน", + "not_found": "ไม่พบผู้ใช้", + "pending_invites": "คำเชิญที่รอดำเนินการ", + "pending_invites_description": "จัดการและติดตามคำเชิญผู้ใช้ที่รอดำเนินการพร้อมสถานะและการดำเนินการที่ชัดเจน", + "remove_admin_privilege": "นำสิทธิ์ผู้ดูแลระบบออก", + "remove_admin_status": "นำสถานะผู้ดูแลระบบออก", + "rename": "เปลี่ยนชื่อ", + "revoke_invitation": "เพิกถอนคำเชิญ", + "searchbar_placeholder": "ค้นหาตามชื่อหรืออีเมล..", + "send_invite": "ส่งคำเชิญ", + "show_more": "แสดงเพิ่มเติม", + "uid": "UID", + "unnamed": "(ผู้ใช้ที่ไม่มีชื่อ)", + "user_not_found": "ไม่พบผู้ใช้ในระบบโครงสร้างพื้นฐาน!!", + "users": "ผู้ใช้", + "valid_email": "โปรดป้อนที่อยู่อีเมลที่ถูกต้อง" + } +} diff --git a/packages/hoppscotch-sh-admin/locales/tr.json b/packages/hoppscotch-sh-admin/locales/tr.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/tr.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/tw.json b/packages/hoppscotch-sh-admin/locales/tw.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/tw.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/uk.json b/packages/hoppscotch-sh-admin/locales/uk.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/uk.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/vi.json b/packages/hoppscotch-sh-admin/locales/vi.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/vi.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/package.json b/packages/hoppscotch-sh-admin/package.json new file mode 100644 index 0000000..b61d193 --- /dev/null +++ b/packages/hoppscotch-sh-admin/package.json @@ -0,0 +1,80 @@ +{ + "name": "hoppscotch-sh-admin", + "private": true, + "version": "2026.6.0", + "type": "module", + "scripts": { + "dev": "pnpm exec npm-run-all -p -l dev:*", + "dev:vite": "vite", + "dev:gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml --watch dotenv_config_path=\"../../.env\"", + "gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml dotenv_config_path=\"../../.env\"", + "postinstall": "pnpm run gql-codegen", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@fontsource-variable/inter": "5.2.8", + "@fontsource-variable/material-symbols-rounded": "5.2.45", + "@fontsource-variable/roboto-mono": "5.2.9", + "@graphql-typed-document-node/core": "3.2.0", + "@hoppscotch/ui": "0.2.6", + "@hoppscotch/vue-toasted": "0.1.0", + "@intlify/unplugin-vue-i18n": "11.2.4", + "@types/cors": "2.8.19", + "@urql/exchange-auth": "3.0.0", + "@urql/vue": "2.1.1", + "@vueuse/core": "14.3.0", + "axios": "1.18.0", + "cors": "2.8.6", + "date-fns": "4.4.0", + "fp-ts": "2.16.11", + "graphql": "16.13.2", + "io-ts": "2.2.22", + "lodash-es": "4.18.1", + "postcss": "8.5.15", + "prettier-plugin-tailwindcss": "0.7.1", + "rxjs": "7.8.2", + "tailwindcss": "3.4.16", + "tippy.js": "6.3.7", + "ts-node-dev": "2.0.0", + "unplugin-vue-components": "30.0.0", + "vue": "3.5.38", + "vue-i18n": "11.4.6", + "vue-router": "4.6.4", + "vue-tippy": "6.7.1" + }, + "devDependencies": { + "@graphql-codegen/cli": "6.3.1", + "@graphql-codegen/client-preset": "5.3.0", + "@graphql-codegen/introspection": "5.0.2", + "@graphql-codegen/typed-document-node": "6.1.8", + "@graphql-codegen/typescript": "5.0.10", + "@graphql-codegen/typescript-document-nodes": "5.0.10", + "@graphql-codegen/typescript-operations": "5.1.0", + "@graphql-codegen/urql-introspection": "3.0.1", + "@iconify-json/lucide": "1.2.114", + "@import-meta-env/cli": "0.7.4", + "@import-meta-env/unplugin": "0.6.3", + "@types/lodash-es": "4.17.12", + "@vitejs/plugin-vue": "6.0.7", + "@vue/compiler-sfc": "3.5.38", + "autoprefixer": "10.5.0", + "dotenv": "17.4.2", + "graphql-tag": "2.12.7", + "hoppscotch-backend": "workspace:^", + "npm-run-all": "4.1.5", + "sass": "1.101.0", + "ts-node": "10.9.2", + "typescript": "5.9.3", + "unplugin-fonts": "1.4.0", + "unplugin-icons": "22.5.0", + "vite": "7.3.2", + "vite-plugin-pages": "0.33.2", + "vite-plugin-vue-layouts": "0.11.0", + "vue-tsc": "2.1.6" + }, + "prettier": { + "singleQuote": true, + "semi": true + } +} diff --git a/packages/hoppscotch-sh-admin/postcss.config.cjs b/packages/hoppscotch-sh-admin/postcss.config.cjs new file mode 100644 index 0000000..e305dd9 --- /dev/null +++ b/packages/hoppscotch-sh-admin/postcss.config.cjs @@ -0,0 +1,8 @@ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +module.exports = config; diff --git a/packages/hoppscotch-sh-admin/prod_run.mjs b/packages/hoppscotch-sh-admin/prod_run.mjs new file mode 100755 index 0000000..12243f9 --- /dev/null +++ b/packages/hoppscotch-sh-admin/prod_run.mjs @@ -0,0 +1,71 @@ +#!/usr/local/bin/node +import { execSync, spawn } from 'child_process'; +import fs from 'fs'; +import process from 'process'; + +function runChildProcessWithPrefix(command, args, prefix) { + const childProcess = spawn(command, args); + + childProcess.stdout.on('data', (data) => { + const output = data.toString().trim().split('\n'); + output.forEach((line) => { + console.log(`${prefix} | ${line}`); + }); + }); + + childProcess.stderr.on('data', (data) => { + const error = data.toString().trim().split('\n'); + error.forEach((line) => { + console.error(`${prefix} | ${line}`); + }); + }); + + childProcess.on('close', (code) => { + console.log(`${prefix} Child process exited with code ${code}`); + }); + + childProcess.on('error', (stuff) => { + console.log('error'); + console.log(stuff); + }); + + return childProcess; +} + +const envFileContent = Object.entries(process.env) + .filter(([env]) => env.startsWith('VITE_')) + .sort(([envA], [envB]) => envA.localeCompare(envB)) + .map( + ([env, val]) => + `${env}=${val.startsWith('"') && val.endsWith('"') ? val : `"${val}"`}` + ) + .join('\n'); + +fs.writeFileSync('build.env', envFileContent); + +execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`); + +fs.rmSync('build.env'); + +const caddyFileName = + process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true' + ? 'sh-admin-subpath-access.Caddyfile' + : 'sh-admin-multiport-setup.Caddyfile'; +const caddyProcess = runChildProcessWithPrefix( + 'caddy', + ['run', '--config', `/etc/caddy/${caddyFileName}`, '--adapter', 'caddyfile'], + 'App/Admin Dashboard Caddy' +); + +caddyProcess.on('exit', (code) => { + console.log(`Exiting process because Caddy Server exited with code ${code}`); + process.exit(code); +}); + +process.on('SIGINT', () => { + console.log('SIGINT received, exiting...'); + + caddyProcess.kill('SIGINT'); + + process.exit(0); +}); diff --git a/packages/hoppscotch-sh-admin/public/favicon.ico b/packages/hoppscotch-sh-admin/public/favicon.ico new file mode 100644 index 0000000..7411836 Binary files /dev/null and b/packages/hoppscotch-sh-admin/public/favicon.ico differ diff --git a/packages/hoppscotch-sh-admin/public/images/add_group.svg b/packages/hoppscotch-sh-admin/public/images/add_group.svg new file mode 100644 index 0000000..4350ea1 --- /dev/null +++ b/packages/hoppscotch-sh-admin/public/images/add_group.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-sh-admin/public/images/pack.svg b/packages/hoppscotch-sh-admin/public/images/pack.svg new file mode 100644 index 0000000..56a669b --- /dev/null +++ b/packages/hoppscotch-sh-admin/public/images/pack.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-sh-admin/public/images/youre_lost.svg b/packages/hoppscotch-sh-admin/public/images/youre_lost.svg new file mode 100644 index 0000000..ea33353 --- /dev/null +++ b/packages/hoppscotch-sh-admin/public/images/youre_lost.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-sh-admin/public/logo.svg b/packages/hoppscotch-sh-admin/public/logo.svg new file mode 100644 index 0000000..d78a86b --- /dev/null +++ b/packages/hoppscotch-sh-admin/public/logo.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-sh-admin/sh-admin-multiport-setup.Caddyfile b/packages/hoppscotch-sh-admin/sh-admin-multiport-setup.Caddyfile new file mode 100644 index 0000000..cfcadc6 --- /dev/null +++ b/packages/hoppscotch-sh-admin/sh-admin-multiport-setup.Caddyfile @@ -0,0 +1,10 @@ +{ + admin off + persist_config off +} + +:80 :3100 { + try_files {path} / + root * /site/sh-admin-multiport-setup + file_server +} diff --git a/packages/hoppscotch-sh-admin/sh-admin-subpath-access.Caddyfile b/packages/hoppscotch-sh-admin/sh-admin-subpath-access.Caddyfile new file mode 100644 index 0000000..5a6ad81 --- /dev/null +++ b/packages/hoppscotch-sh-admin/sh-admin-subpath-access.Caddyfile @@ -0,0 +1,16 @@ +{ + admin off + persist_config off +} + +:80 :3100 { + handle_path /admin* { + root * /site/sh-admin-subpath-access + file_server + try_files {path} / + } + + handle { + respond 404 + } +} diff --git a/packages/hoppscotch-sh-admin/src/App.vue b/packages/hoppscotch-sh-admin/src/App.vue new file mode 100644 index 0000000..75e52b9 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/App.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components.d.ts b/packages/hoppscotch-sh-admin/src/components.d.ts new file mode 100644 index 0000000..2044e72 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components.d.ts @@ -0,0 +1,87 @@ +/* eslint-disable */ +// @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 + +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + AppHeader: typeof import('./components/app/Header.vue')['default'] + AppLogin: typeof import('./components/app/Login.vue')['default'] + AppLogout: typeof import('./components/app/Logout.vue')['default'] + AppModal: typeof import('./components/app/Modal.vue')['default'] + AppSidebar: typeof import('./components/app/Sidebar.vue')['default'] + AppToast: typeof import('./components/app/Toast.vue')['default'] + DashboardMetricsCard: typeof import('./components/dashboard/MetricsCard.vue')['default'] + HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary'] + HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary'] + HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor'] + HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete'] + HoppSmartCheckbox: typeof import('@hoppscotch/ui')['HoppSmartCheckbox'] + HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal'] + HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput'] + HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection'] + HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem'] + HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink'] + HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal'] + HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture'] + HoppSmartPlaceholder: typeof import('@hoppscotch/ui')['HoppSmartPlaceholder'] + HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper'] + HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'] + HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab'] + HoppSmartTable: typeof import('@hoppscotch/ui')['HoppSmartTable'] + HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs'] + HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle'] + IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default'] + IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] + IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default'] + IconLucideBadgeCheck: typeof import('~icons/lucide/badge-check')['default'] + IconLucideCheck: typeof import('~icons/lucide/check')['default'] + IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default'] + IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default'] + IconLucideInbox: typeof import('~icons/lucide/inbox')['default'] + IconLucideInfo: typeof import('~icons/lucide/info')['default'] + IconLucideSearch: typeof import('~icons/lucide/search')['default'] + IconLucideUser: typeof import('~icons/lucide/user')['default'] + OnboardingAuthProviderCard: typeof import('./components/onboarding/AuthProviderCard.vue')['default'] + OnboardingAuthSetup: typeof import('./components/onboarding/AuthSetup.vue')['default'] + OnboardingCompleteScreen: typeof import('./components/onboarding/CompleteScreen.vue')['default'] + OnboardingOAuthSetup: typeof import('./components/onboarding/OAuthSetup.vue')['default'] + OnboardingSmtpSetup: typeof import('./components/onboarding/SmtpSetup.vue')['default'] + OnboardingWelcomeScreen: typeof import('./components/onboarding/WelcomeScreen.vue')['default'] + SettingsAuthConfigurations: typeof import('./components/settings/AuthConfigurations.vue')['default'] + SettingsAuthToken: typeof import('./components/settings/AuthToken.vue')['default'] + SettingsDataSharing: typeof import('./components/settings/DataSharing.vue')['default'] + SettingsHistoryConfiguration: typeof import('./components/settings/HistoryConfiguration.vue')['default'] + SettingsMockServerConfig: typeof import('./components/settings/MockServerConfig.vue')['default'] + SettingsOAuthProviderConfigurations: typeof import('./components/settings/OAuthProviderConfigurations.vue')['default'] + SettingsProxyURLConfiguration: typeof import('./components/settings/ProxyURLConfiguration.vue')['default'] + SettingsRateLimit: typeof import('./components/settings/RateLimit.vue')['default'] + SettingsReset: typeof import('./components/settings/Reset.vue')['default'] + SettingsServerRestart: typeof import('./components/settings/ServerRestart.vue')['default'] + SettingsSmtpConfiguration: typeof import('./components/settings/SmtpConfiguration.vue')['default'] + SetupDataSharingAndNewsletter: typeof import('./components/setup/DataSharingAndNewsletter.vue')['default'] + TeamsAdd: typeof import('./components/teams/Add.vue')['default'] + TeamsDetails: typeof import('./components/teams/Details.vue')['default'] + TeamsInvite: typeof import('./components/teams/Invite.vue')['default'] + TeamsMembers: typeof import('./components/teams/Members.vue')['default'] + TeamsPendingInvites: typeof import('./components/teams/PendingInvites.vue')['default'] + Tippy: typeof import('vue-tippy')['Tippy'] + Tokens: typeof import('./components/tokens/index.vue')['default'] + TokensGenerateModal: typeof import('./components/tokens/GenerateModal.vue')['default'] + TokensList: typeof import('./components/tokens/List.vue')['default'] + TokensOverview: typeof import('./components/tokens/Overview.vue')['default'] + UiAccordion: typeof import('./components/ui/Accordion.vue')['default'] + UiAutoResetIcon: typeof import('./components/ui/AutoResetIcon.vue')['default'] + UsersDetails: typeof import('./components/users/Details.vue')['default'] + UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default'] + UsersSharedRequests: typeof import('./components/users/SharedRequests.vue')['default'] + UsersSuccessInviteModal: typeof import('./components/users/SuccessInviteModal.vue')['default'] + UsersTeams: typeof import('./components/users/Teams.vue')['default'] + } +} diff --git a/packages/hoppscotch-sh-admin/src/components/app/Header.vue b/packages/hoppscotch-sh-admin/src/components/app/Header.vue new file mode 100644 index 0000000..96fd804 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/app/Header.vue @@ -0,0 +1,101 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/app/Login.vue b/packages/hoppscotch-sh-admin/src/components/app/Login.vue new file mode 100644 index 0000000..1017d35 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/app/Login.vue @@ -0,0 +1,291 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/app/Logout.vue b/packages/hoppscotch-sh-admin/src/components/app/Logout.vue new file mode 100644 index 0000000..b12c010 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/app/Logout.vue @@ -0,0 +1,65 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/app/Modal.vue b/packages/hoppscotch-sh-admin/src/components/app/Modal.vue new file mode 100644 index 0000000..2fe8fc7 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/app/Modal.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/packages/hoppscotch-sh-admin/src/components/app/Sidebar.vue b/packages/hoppscotch-sh-admin/src/components/app/Sidebar.vue new file mode 100644 index 0000000..0fb1624 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/app/Sidebar.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/packages/hoppscotch-sh-admin/src/components/app/Toast.vue b/packages/hoppscotch-sh-admin/src/components/app/Toast.vue new file mode 100644 index 0000000..1a27e32 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/app/Toast.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/dashboard/MetricsCard.vue b/packages/hoppscotch-sh-admin/src/components/dashboard/MetricsCard.vue new file mode 100644 index 0000000..c6c833e --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/dashboard/MetricsCard.vue @@ -0,0 +1,27 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/onboarding/AuthProviderCard.vue b/packages/hoppscotch-sh-admin/src/components/onboarding/AuthProviderCard.vue new file mode 100644 index 0000000..a6e4ee6 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/onboarding/AuthProviderCard.vue @@ -0,0 +1,46 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/onboarding/AuthSetup.vue b/packages/hoppscotch-sh-admin/src/components/onboarding/AuthSetup.vue new file mode 100644 index 0000000..6d8ca18 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/onboarding/AuthSetup.vue @@ -0,0 +1,288 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/onboarding/CompleteScreen.vue b/packages/hoppscotch-sh-admin/src/components/onboarding/CompleteScreen.vue new file mode 100644 index 0000000..7161e93 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/onboarding/CompleteScreen.vue @@ -0,0 +1,134 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/onboarding/OAuthSetup.vue b/packages/hoppscotch-sh-admin/src/components/onboarding/OAuthSetup.vue new file mode 100644 index 0000000..f1cc7f0 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/onboarding/OAuthSetup.vue @@ -0,0 +1,118 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/onboarding/SmtpSetup.vue b/packages/hoppscotch-sh-admin/src/components/onboarding/SmtpSetup.vue new file mode 100644 index 0000000..c3ab735 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/onboarding/SmtpSetup.vue @@ -0,0 +1,298 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/onboarding/WelcomeScreen.vue b/packages/hoppscotch-sh-admin/src/components/onboarding/WelcomeScreen.vue new file mode 100644 index 0000000..3da8b29 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/onboarding/WelcomeScreen.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/settings/AuthConfigurations.vue b/packages/hoppscotch-sh-admin/src/components/settings/AuthConfigurations.vue new file mode 100644 index 0000000..560f499 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/settings/AuthConfigurations.vue @@ -0,0 +1,137 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/settings/AuthToken.vue b/packages/hoppscotch-sh-admin/src/components/settings/AuthToken.vue new file mode 100644 index 0000000..59bd8aa --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/settings/AuthToken.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/packages/hoppscotch-sh-admin/src/components/settings/DataSharing.vue b/packages/hoppscotch-sh-admin/src/components/settings/DataSharing.vue new file mode 100644 index 0000000..66c1af1 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/settings/DataSharing.vue @@ -0,0 +1,79 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/settings/HistoryConfiguration.vue b/packages/hoppscotch-sh-admin/src/components/settings/HistoryConfiguration.vue new file mode 100644 index 0000000..6f62dcf --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/settings/HistoryConfiguration.vue @@ -0,0 +1,104 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/settings/MockServerConfig.vue b/packages/hoppscotch-sh-admin/src/components/settings/MockServerConfig.vue new file mode 100644 index 0000000..40c574c --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/settings/MockServerConfig.vue @@ -0,0 +1,111 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/settings/OAuthProviderConfigurations.vue b/packages/hoppscotch-sh-admin/src/components/settings/OAuthProviderConfigurations.vue new file mode 100644 index 0000000..115495d --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/settings/OAuthProviderConfigurations.vue @@ -0,0 +1,189 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/settings/ProxyURLConfiguration.vue b/packages/hoppscotch-sh-admin/src/components/settings/ProxyURLConfiguration.vue new file mode 100644 index 0000000..d817f05 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/settings/ProxyURLConfiguration.vue @@ -0,0 +1,102 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/settings/RateLimit.vue b/packages/hoppscotch-sh-admin/src/components/settings/RateLimit.vue new file mode 100644 index 0000000..d6afd29 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/settings/RateLimit.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/packages/hoppscotch-sh-admin/src/components/settings/Reset.vue b/packages/hoppscotch-sh-admin/src/components/settings/Reset.vue new file mode 100644 index 0000000..3086a22 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/settings/Reset.vue @@ -0,0 +1,49 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/settings/ServerRestart.vue b/packages/hoppscotch-sh-admin/src/components/settings/ServerRestart.vue new file mode 100644 index 0000000..b12fff1 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/settings/ServerRestart.vue @@ -0,0 +1,160 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/settings/SmtpConfiguration.vue b/packages/hoppscotch-sh-admin/src/components/settings/SmtpConfiguration.vue new file mode 100644 index 0000000..5a4a8a0 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/settings/SmtpConfiguration.vue @@ -0,0 +1,451 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/setup/DataSharingAndNewsletter.vue b/packages/hoppscotch-sh-admin/src/components/setup/DataSharingAndNewsletter.vue new file mode 100644 index 0000000..3220e9c --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/setup/DataSharingAndNewsletter.vue @@ -0,0 +1,140 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/teams/Add.vue b/packages/hoppscotch-sh-admin/src/components/teams/Add.vue new file mode 100644 index 0000000..60e8025 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/teams/Add.vue @@ -0,0 +1,101 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/teams/Details.vue b/packages/hoppscotch-sh-admin/src/components/teams/Details.vue new file mode 100644 index 0000000..2e5bd99 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/teams/Details.vue @@ -0,0 +1,154 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/teams/Invite.vue b/packages/hoppscotch-sh-admin/src/components/teams/Invite.vue new file mode 100644 index 0000000..ba63ffb --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/teams/Invite.vue @@ -0,0 +1,350 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/teams/Members.vue b/packages/hoppscotch-sh-admin/src/components/teams/Members.vue new file mode 100644 index 0000000..806f9a1 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/teams/Members.vue @@ -0,0 +1,373 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/teams/PendingInvites.vue b/packages/hoppscotch-sh-admin/src/components/teams/PendingInvites.vue new file mode 100644 index 0000000..5389d49 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/teams/PendingInvites.vue @@ -0,0 +1,116 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/tokens/GenerateModal.vue b/packages/hoppscotch-sh-admin/src/components/tokens/GenerateModal.vue new file mode 100644 index 0000000..2b34c04 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/tokens/GenerateModal.vue @@ -0,0 +1,210 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/tokens/List.vue b/packages/hoppscotch-sh-admin/src/components/tokens/List.vue new file mode 100644 index 0000000..26ea861 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/tokens/List.vue @@ -0,0 +1,128 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/tokens/Overview.vue b/packages/hoppscotch-sh-admin/src/components/tokens/Overview.vue new file mode 100644 index 0000000..f1da4a9 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/tokens/Overview.vue @@ -0,0 +1,39 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/tokens/index.vue b/packages/hoppscotch-sh-admin/src/components/tokens/index.vue new file mode 100644 index 0000000..aedd8a9 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/tokens/index.vue @@ -0,0 +1,188 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/ui/Accordion.vue b/packages/hoppscotch-sh-admin/src/components/ui/Accordion.vue new file mode 100644 index 0000000..9b5c630 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/ui/Accordion.vue @@ -0,0 +1,77 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/ui/AutoResetIcon.vue b/packages/hoppscotch-sh-admin/src/components/ui/AutoResetIcon.vue new file mode 100644 index 0000000..89a72e9 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/ui/AutoResetIcon.vue @@ -0,0 +1,47 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/users/Details.vue b/packages/hoppscotch-sh-admin/src/components/users/Details.vue new file mode 100644 index 0000000..2015e6d --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/users/Details.vue @@ -0,0 +1,226 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/users/InviteModal.vue b/packages/hoppscotch-sh-admin/src/components/users/InviteModal.vue new file mode 100644 index 0000000..e3411ef --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/users/InviteModal.vue @@ -0,0 +1,70 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/users/SharedRequests.vue b/packages/hoppscotch-sh-admin/src/components/users/SharedRequests.vue new file mode 100644 index 0000000..d3a8638 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/users/SharedRequests.vue @@ -0,0 +1,188 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/users/SuccessInviteModal.vue b/packages/hoppscotch-sh-admin/src/components/users/SuccessInviteModal.vue new file mode 100644 index 0000000..ab969a6 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/users/SuccessInviteModal.vue @@ -0,0 +1,60 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/components/users/Teams.vue b/packages/hoppscotch-sh-admin/src/components/users/Teams.vue new file mode 100644 index 0000000..1d4efed --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/users/Teams.vue @@ -0,0 +1,112 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/composables/i18n.ts b/packages/hoppscotch-sh-admin/src/composables/i18n.ts new file mode 100644 index 0000000..114f536 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/composables/i18n.ts @@ -0,0 +1,6 @@ +import { flow } from "fp-ts/function" +import { useI18n as _useI18n } from "vue-i18n" + +export const useI18n = flow(_useI18n, (x) => x.t) + +export const useFullI18n = _useI18n diff --git a/packages/hoppscotch-sh-admin/src/composables/stream.ts b/packages/hoppscotch-sh-admin/src/composables/stream.ts new file mode 100644 index 0000000..b340c92 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/composables/stream.ts @@ -0,0 +1,174 @@ +import { clone, cloneDeep } from 'lodash-es'; +import { Observable, Subscription } from 'rxjs'; +import { customRef, onBeforeUnmount, readonly, Ref } from 'vue'; + +type CloneMode = 'noclone' | 'shallow' | 'deep'; + +/** + * Returns a readonly (no writes) ref for an RxJS Observable + * @param stream$ The RxJS Observable to listen to + * @param initialValue The initial value to apply until the stream emits a value + * @param cloneMode Determines whether or not and how deep to clone the emitted value. + * Useful for issues in reactivity due to reference sharing. Defaults to shallow clone + * @returns A readonly ref which has the latest value from the stream + */ +export function useReadonlyStream( + stream$: Observable, + initialValue: T, + cloneMode: CloneMode = 'shallow' +): Ref { + let sub: Subscription | null = null; + + onBeforeUnmount(() => { + if (sub) { + sub.unsubscribe(); + } + }); + + const r = customRef((track, trigger) => { + let val = initialValue; + + sub = stream$.subscribe((value) => { + if (cloneMode === 'noclone') { + val = value; + } else if (cloneMode === 'shallow') { + val = clone(value); + } else if (cloneMode === 'deep') { + val = cloneDeep(value); + } + + trigger(); + }); + + return { + get() { + track(); + return val; + }, + set() { + trigger(); // <- Not exactly needed here + throw new Error('Cannot write to a ref from useReadonlyStream'); + }, + }; + }); + + // Casting to still maintain the proper type signature for ease of use + return readonly(r) as Ref; +} + +export function useStream( + stream$: Observable, + initialValue: T, + setter: (val: T) => void +) { + let sub: Subscription | null = null; + + onBeforeUnmount(() => { + if (sub) { + sub.unsubscribe(); + } + }); + + return customRef((track, trigger) => { + let value = initialValue; + + sub = stream$.subscribe((val) => { + value = val; + trigger(); + }); + + return { + get() { + track(); + return value; + }, + set(value: T) { + trigger(); + setter(value); + }, + }; + }); +} + +/** A static (doesn't cleanup itself and doesn't + * require component instance) version of useStream + */ +export function useStreamStatic( + stream$: Observable, + initialValue: T, + setter: (val: T) => void +): [Ref, () => void] { + let sub: Subscription | null = null; + + const stopper = () => { + if (sub) { + sub.unsubscribe(); + } + }; + + return [ + customRef((track, trigger) => { + let value = initialValue; + + sub = stream$.subscribe((val) => { + value = val; + trigger(); + }); + + return { + get() { + track(); + return value; + }, + set(value: T) { + trigger(); + setter(value); + }, + }; + }), + stopper, + ]; +} + +export type StreamSubscriberFunc = ( + stream: Observable, + next?: ((value: T) => void) | undefined, + error?: ((e: any) => void) | undefined, + complete?: (() => void) | undefined +) => void; + +/** + * A composable that provides the ability to run streams + * and subscribe to them and respect the component lifecycle. + */ +export function useStreamSubscriber(): { + subscribeToStream: StreamSubscriberFunc; +} { + const subs: Subscription[] = []; + + const runAndSubscribe = ( + stream: Observable, + next?: (value: T) => void, + error?: (e: any) => void, + complete?: () => void + ) => { + const sub = stream.subscribe({ + next, + error, + complete: () => { + if (complete) complete(); + subs.splice(subs.indexOf(sub), 1); + }, + }); + + subs.push(sub); + }; + + onBeforeUnmount(() => { + subs.forEach((sub) => sub.unsubscribe()); + }); + + return { + subscribeToStream: runAndSubscribe, + }; +} diff --git a/packages/hoppscotch-sh-admin/src/composables/toast.ts b/packages/hoppscotch-sh-admin/src/composables/toast.ts new file mode 100644 index 0000000..8611e47 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/composables/toast.ts @@ -0,0 +1,3 @@ +import { useToasted } from '@hoppscotch/vue-toasted'; + +export const useToast = useToasted; diff --git a/packages/hoppscotch-sh-admin/src/composables/useClientHandler.ts b/packages/hoppscotch-sh-admin/src/composables/useClientHandler.ts new file mode 100644 index 0000000..4176e59 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/composables/useClientHandler.ts @@ -0,0 +1,58 @@ +import { TypedDocumentNode, useClientHandle } from '@urql/vue'; +import { DocumentNode } from 'graphql'; +import { Ref, ref } from 'vue'; + +/** A composable function to handle grapqhl requests + * using urql's useClientHandle + * @param query The query to be executed + * @param getList A function to get the list from the result + * @param variables The variables to be passed to the query + */ +export function useClientHandler< + Result, + Vars extends Record, + ListItem +>( + query: string | TypedDocumentNode | DocumentNode, + variables: Vars, + getList?: (result: Result) => ListItem[] +) { + const { client } = useClientHandle(); + const fetching = ref(true); + const error = ref(false); + const data = ref(); + const dataAsList: Ref = ref([]); + + const fetchData = async () => { + fetching.value = true; + + const result = await client + .query(query, { + ...variables, + }) + .toPromise(); + + if (result.error) { + error.value = true; + fetching.value = false; + return; + } + + if (getList) { + const resultList = getList(result.data!); + dataAsList.value.push(...resultList); + } else { + data.value = result.data; + } + + fetching.value = false; + }; + + return { + fetching, + error, + data, + dataAsList, + fetchData, + }; +} diff --git a/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts b/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts new file mode 100644 index 0000000..596d106 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts @@ -0,0 +1,583 @@ +import { AnyVariables, UseMutationResponse } from '@urql/vue'; +import { cloneDeep } from 'lodash-es'; +import { onMounted, ref } from 'vue'; +import { useI18n } from '~/composables/i18n'; +import { + AllowedAuthProvidersDocument, + AuthProvider, + EnableAndDisableSsoArgs, + EnableAndDisableSsoMutation, + InfraConfigArgs, + InfraConfigEnum, + InfraConfigsDocument, + ResetInfraConfigsMutation, + ServiceStatus, + ToggleAnalyticsCollectionMutation, + ToggleSmtpMutation, + ToggleUserHistoryStoreMutation, + UpdateInfraConfigsMutation, +} from '~/helpers/backend/graphql'; +import { + ALL_CONFIGS, + CUSTOM_MAIL_CONFIGS, + ConfigTransform, + GITHUB_CONFIGS, + GOOGLE_CONFIGS, + MAIL_CONFIGS, + MICROSOFT_CONFIGS, + MOCK_SERVER_CONFIGS, + PROXY_URL_CONFIGS, + ServerConfigs, + UpdatedConfigs, + isFieldEmpty, + isValidSessionCookieName, +} from '~/helpers/configs'; +import { getCompiledErrorMessage } from '~/helpers/errors'; +import { useToast } from './toast'; +import { useClientHandler } from './useClientHandler'; + +/** Composable that handles all operations related to server configurations + * @param updatedConfigs A Config Object containing the updated configs + */ +export function useConfigHandler(updatedConfigs?: ServerConfigs) { + const t = useI18n(); + const toast = useToast(); + + // Fetching infra configurations + const { + fetching: fetchingInfraConfigs, + error: infraConfigsError, + dataAsList: infraConfigs, + fetchData: fetchInfraConfigs, + } = useClientHandler( + InfraConfigsDocument, + { + configNames: ALL_CONFIGS.flat().map( + ({ name }) => name + ) as InfraConfigEnum[], + }, + (x) => x.infraConfigs + ); + + // Fetching allowed auth providers + const { + fetching: fetchingAllowedAuthProviders, + error: allowedAuthProvidersError, + dataAsList: allowedAuthProviders, + fetchData: fetchAllowedAuthProviders, + } = useClientHandler( + AllowedAuthProvidersDocument, + {}, + (x) => x.allowedAuthProviders + ); + + // Current and working configs + const currentConfigs = ref(); + const workingConfigs = ref(); + + onMounted(async () => { + await fetchInfraConfigs(); + await fetchAllowedAuthProviders(); + + const getFieldValue = (name: InfraConfigEnum) => + infraConfigs.value.find((x) => x.name === name)?.value ?? ''; + + // Transforming the fetched data into a Configs object + currentConfigs.value = { + providers: { + google: { + name: 'google', + enabled: allowedAuthProviders.value.includes(AuthProvider.Google), + fields: { + client_id: getFieldValue(InfraConfigEnum.GoogleClientId), + client_secret: getFieldValue(InfraConfigEnum.GoogleClientSecret), + callback_url: getFieldValue(InfraConfigEnum.GoogleCallbackUrl), + scope: getFieldValue(InfraConfigEnum.GoogleScope), + }, + }, + github: { + name: 'github', + enabled: allowedAuthProviders.value.includes(AuthProvider.Github), + fields: { + client_id: getFieldValue(InfraConfigEnum.GithubClientId), + client_secret: getFieldValue(InfraConfigEnum.GithubClientSecret), + callback_url: getFieldValue(InfraConfigEnum.GithubCallbackUrl), + scope: getFieldValue(InfraConfigEnum.GithubScope), + }, + }, + microsoft: { + name: 'microsoft', + enabled: allowedAuthProviders.value.includes(AuthProvider.Microsoft), + fields: { + client_id: getFieldValue(InfraConfigEnum.MicrosoftClientId), + client_secret: getFieldValue(InfraConfigEnum.MicrosoftClientSecret), + callback_url: getFieldValue(InfraConfigEnum.MicrosoftCallbackUrl), + scope: getFieldValue(InfraConfigEnum.MicrosoftScope), + tenant: getFieldValue(InfraConfigEnum.MicrosoftTenant), + }, + }, + }, + mailConfigs: { + name: 'email', + enabled: getFieldValue(InfraConfigEnum.MailerSmtpEnable) === 'true', + fields: { + email_auth: allowedAuthProviders.value.includes(AuthProvider.Email), + mailer_smtp_url: getFieldValue(InfraConfigEnum.MailerSmtpUrl), + mailer_from_address: getFieldValue(InfraConfigEnum.MailerAddressFrom), + mailer_smtp_host: getFieldValue(InfraConfigEnum.MailerSmtpHost), + mailer_smtp_port: getFieldValue(InfraConfigEnum.MailerSmtpPort), + mailer_smtp_user: getFieldValue(InfraConfigEnum.MailerSmtpUser), + mailer_smtp_password: getFieldValue( + InfraConfigEnum.MailerSmtpPassword + ), + mailer_smtp_secure: + getFieldValue(InfraConfigEnum.MailerSmtpSecure) === 'true', + mailer_smtp_ignore_tls: + getFieldValue(InfraConfigEnum.MailerSmtpIgnoreTls) === 'true', + mailer_tls_reject_unauthorized: + getFieldValue(InfraConfigEnum.MailerTlsRejectUnauthorized) === + 'true', + mailer_use_custom_configs: + getFieldValue(InfraConfigEnum.MailerUseCustomConfigs) === 'true', + mailer_smtp_auth_type: + getFieldValue(InfraConfigEnum.MailerSmtpAuthType) || 'login', + mailer_smtp_oauth2_user: getFieldValue( + InfraConfigEnum.MailerSmtpOauth2User + ), + mailer_smtp_oauth2_client_id: getFieldValue( + InfraConfigEnum.MailerSmtpOauth2ClientId + ), + mailer_smtp_oauth2_client_secret: getFieldValue( + InfraConfigEnum.MailerSmtpOauth2ClientSecret + ), + mailer_smtp_oauth2_refresh_token: getFieldValue( + InfraConfigEnum.MailerSmtpOauth2RefreshToken + ), + mailer_smtp_oauth2_access_url: getFieldValue( + InfraConfigEnum.MailerSmtpOauth2AccessUrl + ), + }, + }, + tokenConfigs: { + name: 'token', + fields: { + jwt_secret: getFieldValue(InfraConfigEnum.JwtSecret), + token_salt_complexity: getFieldValue( + InfraConfigEnum.TokenSaltComplexity + ), + magic_link_token_validity: getFieldValue( + InfraConfigEnum.MagicLinkTokenValidity + ), + refresh_token_validity: getFieldValue( + InfraConfigEnum.RefreshTokenValidity + ), + access_token_validity: getFieldValue( + InfraConfigEnum.AccessTokenValidity + ), + session_secret: getFieldValue(InfraConfigEnum.SessionSecret), + session_cookie_name: getFieldValue(InfraConfigEnum.SessionCookieName), + }, + }, + dataSharingConfigs: { + name: 'data_sharing', + enabled: !!infraConfigs.value.find( + (x) => x.name === 'ALLOW_ANALYTICS_COLLECTION' && x.value === 'true' + ), + }, + historyConfig: { + name: 'history_settings', + enabled: !!infraConfigs.value.find( + (config) => + config.name === 'USER_HISTORY_STORE_ENABLED' && + config.value === 'ENABLE' + ), + }, + rateLimitConfigs: { + name: 'rate_limit', + fields: { + rate_limit_ttl: getFieldValue(InfraConfigEnum.RateLimitTtl), + rate_limit_max: getFieldValue(InfraConfigEnum.RateLimitMax), + }, + }, + mockServerConfigs: { + name: 'mock_server', + fields: { + mock_server_wildcard_domain: getFieldValue( + InfraConfigEnum.MockServerWildcardDomain + ), + }, + }, + proxyUrlConfigs: { + name: 'proxy_app_url', + fields: { + proxy_app_url: getFieldValue(InfraConfigEnum.ProxyAppUrl), + }, + }, + }; + + // Cloning the current configs to working configs + // Changes are made only to working configs + workingConfigs.value = cloneDeep(currentConfigs.value); + }); + + // Check if custom mail config is enabled + const isCustomMailConfigEnabled = + updatedConfigs?.mailConfigs.fields.mailer_use_custom_configs; + + // Extract the mail config fields (excluding the custom mail config fields) + const mailConfigFields = Object.fromEntries( + Object.entries(updatedConfigs?.mailConfigs.fields ?? {}).filter(([key]) => { + if (isCustomMailConfigEnabled) { + return MAIL_CONFIGS.some( + (x) => + x.key === key && + key !== 'mailer_smtp_url' && + key !== 'mailer_smtp_enabled' + ); + } else + return MAIL_CONFIGS.some( + (x) => x.key === key && key !== 'mailer_smtp_enabled' + ); + }) + ); + + // Extract the custom mail config fields + const customMailConfigFields = Object.fromEntries( + Object.entries(updatedConfigs?.mailConfigs.fields ?? {}).filter(([key]) => + CUSTOM_MAIL_CONFIGS.some((x) => x.key === key) + ) + ); + + // Transforming the working configs back into the format required by the mutations + const transformInfraConfigs = () => { + const updatedWorkingConfigs: ConfigTransform[] = [ + { + config: GOOGLE_CONFIGS, + enabled: updatedConfigs?.providers.google.enabled, + fields: updatedConfigs?.providers.google.fields, + }, + { + config: GITHUB_CONFIGS, + enabled: updatedConfigs?.providers.github.enabled, + fields: updatedConfigs?.providers.github.fields, + }, + { + config: MICROSOFT_CONFIGS, + enabled: updatedConfigs?.providers.microsoft.enabled, + fields: updatedConfigs?.providers.microsoft.fields, + }, + { + config: MAIL_CONFIGS, + enabled: updatedConfigs?.mailConfigs.enabled, + fields: mailConfigFields, + }, + { + config: CUSTOM_MAIL_CONFIGS, + enabled: isCustomMailConfigEnabled, + fields: customMailConfigFields, + }, + { + config: MOCK_SERVER_CONFIGS, + enabled: true, + fields: updatedConfigs?.mockServerConfigs?.fields ?? {}, + }, + { + config: PROXY_URL_CONFIGS, + enabled: true, + fields: updatedConfigs?.proxyUrlConfigs?.fields, + }, + ]; + + const transformedConfigs: UpdatedConfigs[] = []; + + updatedWorkingConfigs.forEach(({ config, enabled, fields }) => { + config.forEach(({ name, key }) => { + if (name === 'MAILER_SMTP_ENABLE') return; + else if (isCustomMailConfigEnabled && name === 'MAILER_SMTP_URL') + return; + else if (enabled && fields) { + const value = + typeof fields === 'string' ? fields : String(fields[key]); + // BE rejects empty PROXY_APP_URL and would fail the whole batch. + // The form-level guard already blocks the save, but skip here too + // so a stray empty value can't blackhole unrelated settings. + if (name === InfraConfigEnum.ProxyAppUrl && !value.trim()) return; + transformedConfigs.push({ name, value }); + } + }); + }); + + return transformedConfigs; + }; + + // Generic function to handle mutation execution and error handling + const executeMutation = async ( + mutation: UseMutationResponse, + variables: AnyVariables = undefined, + errorMessage: string + ): Promise => { + const result = await mutation.executeMutation(variables); + + if (result.error) { + const { message } = result.error; + const compiledErrorMessage = getCompiledErrorMessage(message); + + compiledErrorMessage + ? toast.error(t(compiledErrorMessage)) + : toast.error(t(errorMessage)); + return false; + } + + return true; + }; + + // Updating the auth provider configurations + const updateAuthProvider = ( + updateProviderStatus: UseMutationResponse + ) => { + const updatedAllowedAuthProviders: EnableAndDisableSsoArgs[] = [ + { + provider: AuthProvider.Google, + status: updatedConfigs?.providers.google.enabled + ? ServiceStatus.Enable + : ServiceStatus.Disable, + }, + { + provider: AuthProvider.Microsoft, + status: updatedConfigs?.providers.microsoft.enabled + ? ServiceStatus.Enable + : ServiceStatus.Disable, + }, + { + provider: AuthProvider.Github, + status: updatedConfigs?.providers.github.enabled + ? ServiceStatus.Enable + : ServiceStatus.Disable, + }, + { + provider: AuthProvider.Email, + status: updatedConfigs?.mailConfigs.fields.email_auth + ? ServiceStatus.Enable + : ServiceStatus.Disable, + }, + ]; + + return executeMutation( + updateProviderStatus, + { + providerInfo: updatedAllowedAuthProviders, + }, + 'configs.auth_providers.update_failure' + ); + }; + + // Updating the infra configurations + const updateInfraConfigs = ( + updateInfraConfigsMutation: UseMutationResponse + ) => { + const infraConfigs: InfraConfigArgs[] = updatedConfigs + ? transformInfraConfigs() + : []; + + return executeMutation( + updateInfraConfigsMutation, + { + infraConfigs, + }, + 'configs.update_failure' + ); + }; + + // Resetting the infra configurations + const resetInfraConfigs = ( + resetInfraConfigsMutation: UseMutationResponse + ) => + executeMutation( + resetInfraConfigsMutation, + undefined, + 'configs.reset.failure' + ); + + // Toggle Data Sharing + const updateDataSharingConfigs = ( + toggleDataSharingMutation: UseMutationResponse + ) => + executeMutation( + toggleDataSharingMutation, + { + status: updatedConfigs?.dataSharingConfigs.enabled + ? ServiceStatus.Enable + : ServiceStatus.Disable, + }, + 'configs.data_sharing.update_failure' + ); + + // Toggle SMTP + const toggleSMTPConfigs = ( + toggleSMTP: UseMutationResponse + ) => + executeMutation( + toggleSMTP, + { + status: updatedConfigs?.mailConfigs.enabled + ? ServiceStatus.Enable + : ServiceStatus.Disable, + }, + 'configs.mail_configs.toggle_failure' + ); + + // Toggle User History Store + const toggleUserHistoryStore = ( + toggleUserHistoryStore: UseMutationResponse + ) => + executeMutation( + toggleUserHistoryStore, + { + status: updatedConfigs?.historyConfig.enabled + ? ServiceStatus.Enable + : ServiceStatus.Disable, + }, + 'configs.user_history_store.toggle_failure' + ); + + const updateRateLimitConfigs = ( + updateRateLimitMutation: UseMutationResponse + ) => { + if (!updatedConfigs?.rateLimitConfigs) { + toast.error(t('configs.rate_limit.input_validation_error')); + return false; + } + + const rateLimitTtl = String( + updatedConfigs?.rateLimitConfigs.fields.rate_limit_ttl + ); + const rateLimitMax = String( + updatedConfigs?.rateLimitConfigs.fields.rate_limit_max + ); + + if (isFieldEmpty(rateLimitTtl) || isFieldEmpty(rateLimitMax)) { + toast.error(t('configs.rate_limit.input_validation_error')); + return false; + } + + const rateLimitConfigs: InfraConfigArgs[] = [ + { + name: InfraConfigEnum.RateLimitTtl, + value: String(rateLimitTtl), + }, + { + name: InfraConfigEnum.RateLimitMax, + value: String(rateLimitMax), + }, + ]; + + return executeMutation( + updateRateLimitMutation, + { + infraConfigs: rateLimitConfigs, + }, + 'configs.rate_limit.update_failure' + ); + }; + + const updateAuthTokenConfigs = ( + updateAuthTokenMutation: UseMutationResponse + ) => { + if (!updatedConfigs?.tokenConfigs) { + toast.error(t('configs.auth_providers.token.update_failure')); + return false; + } + + const jwtSecret = String(updatedConfigs?.tokenConfigs.fields.jwt_secret); + const tokenSaltComplexity = String( + updatedConfigs?.tokenConfigs.fields.token_salt_complexity + ); + const magicLinkTokenValidity = String( + updatedConfigs?.tokenConfigs.fields.magic_link_token_validity + ); + const refreshTokenValidity = String( + updatedConfigs?.tokenConfigs.fields.refresh_token_validity + ); + const accessTokenValidity = String( + updatedConfigs?.tokenConfigs.fields.access_token_validity + ); + const sessionSecret = String( + updatedConfigs?.tokenConfigs.fields.session_secret + ); + const sessionCookieName = String( + updatedConfigs?.tokenConfigs.fields.session_cookie_name || '' + ); + // Keep save-time toast behavior aligned with proactive validation. + if (sessionCookieName && !isValidSessionCookieName(sessionCookieName)) { + toast.error(t('configs.auth_providers.token.session_cookie_name_invalid')); + return false; + } + if ( + isFieldEmpty(jwtSecret) || + isFieldEmpty(tokenSaltComplexity) || + isFieldEmpty(magicLinkTokenValidity) || + isFieldEmpty(refreshTokenValidity) || + isFieldEmpty(accessTokenValidity) || + isFieldEmpty(sessionSecret) + ) { + toast.error(t('configs.auth_providers.token.update_failure')); + return false; + } + + const authTokenConfigs: InfraConfigArgs[] = [ + { + name: InfraConfigEnum.JwtSecret, + value: jwtSecret, + }, + { + name: InfraConfigEnum.TokenSaltComplexity, + value: tokenSaltComplexity, + }, + { + name: InfraConfigEnum.MagicLinkTokenValidity, + value: magicLinkTokenValidity, + }, + { + name: InfraConfigEnum.RefreshTokenValidity, + value: refreshTokenValidity, + }, + { + name: InfraConfigEnum.AccessTokenValidity, + value: accessTokenValidity, + }, + { + name: InfraConfigEnum.SessionSecret, + value: sessionSecret, + }, + { + name: InfraConfigEnum.SessionCookieName, + value: sessionCookieName, + }, + ]; + + return executeMutation( + updateAuthTokenMutation, + { + infraConfigs: authTokenConfigs, + }, + 'configs.auth_providers.token.update_failure' + ); + }; + + return { + currentConfigs, + workingConfigs, + updateAuthProvider, + updateDataSharingConfigs, + toggleSMTPConfigs, + toggleUserHistoryStore, + updateRateLimitConfigs, + updateAuthTokenConfigs, + updateInfraConfigs, + resetInfraConfigs, + fetchingInfraConfigs, + fetchingAllowedAuthProviders, + infraConfigsError, + allowedAuthProvidersError, + }; +} diff --git a/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts b/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts new file mode 100644 index 0000000..542c6ef --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts @@ -0,0 +1,464 @@ +import { onMounted, ref, watch } from 'vue'; +import { useI18n } from './i18n'; +import { useToast } from './toast'; +import { auth } from '~/helpers/auth'; +import { InfraConfigEnum } from '~/helpers/backend/graphql'; +import { getLocalConfig, setLocalConfig } from '~/helpers/localpersistence'; +import { makeReadableKey } from '~/helpers/utils/readableKey'; + +export type OAuthProvider = 'GOOGLE' | 'GITHUB' | 'MICROSOFT'; +export type EnabledConfig = OAuthProvider | 'OAUTH' | 'MAILER' | 'EMAIL'; + +// common OAuth keys used across providers +type OAuthKeys = 'CLIENT_ID' | 'CLIENT_SECRET' | 'CALLBACK_URL' | 'SCOPE'; + +// Microsoft specific keys +type MicrosoftKeys = OAuthKeys | 'TENANT'; + +type OAuthConfig = { + [K in Keys as `${Prefix}_${K}`]: string; +}; + +// Mailer specific keys +export type MailerConfigKeys = + | 'SMTP_ENABLE' + | 'USE_CUSTOM_CONFIGS' + | 'SMTP_URL' + | 'ADDRESS_FROM' + | 'SMTP_HOST' + | 'SMTP_PORT' + | 'SMTP_SECURE' + | 'SMTP_USER' + | 'SMTP_PASSWORD' + | 'SMTP_IGNORE_TLS' + | 'TLS_REJECT_UNAUTHORIZED' + | 'SMTP_AUTH_TYPE' + | 'SMTP_OAUTH2_USER' + | 'SMTP_OAUTH2_CLIENT_ID' + | 'SMTP_OAUTH2_CLIENT_SECRET' + | 'SMTP_OAUTH2_REFRESH_TOKEN' + | 'SMTP_OAUTH2_ACCESS_URL'; + +export type Configs = { + oAuthProviders: { + GOOGLE: OAuthConfig; + GITHUB: OAuthConfig; + MICROSOFT: OAuthConfig; + }; + mailerConfigs: { + [K in `MAILER_${MailerConfigKeys}`]: string; + }; +}; + +export type OnBoardingSummary = { + type: 'success' | 'error'; + message: string; + description: string; + configsAdded: string[]; +}; + +function mapOAuthProviders( + configs: Partial>, +): Configs['oAuthProviders'] { + return { + GOOGLE: { + GOOGLE_CLIENT_ID: configs.GOOGLE_CLIENT_ID ?? '', + GOOGLE_CLIENT_SECRET: configs.GOOGLE_CLIENT_SECRET ?? '', + GOOGLE_CALLBACK_URL: '', + GOOGLE_SCOPE: configs.GOOGLE_SCOPE ?? '', + }, + GITHUB: { + GITHUB_CLIENT_ID: configs.GITHUB_CLIENT_ID ?? '', + GITHUB_CLIENT_SECRET: configs.GITHUB_CLIENT_SECRET ?? '', + GITHUB_CALLBACK_URL: '', + GITHUB_SCOPE: configs.GITHUB_SCOPE ?? '', + }, + MICROSOFT: { + MICROSOFT_CLIENT_ID: configs.MICROSOFT_CLIENT_ID ?? '', + MICROSOFT_CLIENT_SECRET: configs.MICROSOFT_CLIENT_SECRET ?? '', + MICROSOFT_CALLBACK_URL: '', + MICROSOFT_SCOPE: configs.MICROSOFT_SCOPE ?? '', + MICROSOFT_TENANT: configs.MICROSOFT_TENANT ?? '', + }, + }; +} + +function mapMailerConfigs( + configs: Partial>, +): Configs['mailerConfigs'] { + return { + MAILER_SMTP_ENABLE: configs.MAILER_SMTP_ENABLE ?? '', + MAILER_USE_CUSTOM_CONFIGS: configs.MAILER_USE_CUSTOM_CONFIGS || 'false', + MAILER_SMTP_URL: configs.MAILER_SMTP_URL ?? '', + MAILER_ADDRESS_FROM: configs.MAILER_ADDRESS_FROM ?? '', + MAILER_SMTP_HOST: configs.MAILER_SMTP_HOST ?? '', + MAILER_SMTP_PORT: configs.MAILER_SMTP_PORT ?? '', + MAILER_SMTP_SECURE: configs.MAILER_SMTP_SECURE || 'false', + MAILER_SMTP_USER: configs.MAILER_SMTP_USER ?? '', + MAILER_SMTP_PASSWORD: configs.MAILER_SMTP_PASSWORD ?? '', + MAILER_SMTP_IGNORE_TLS: configs.MAILER_SMTP_IGNORE_TLS || 'false', + MAILER_TLS_REJECT_UNAUTHORIZED: + configs.MAILER_TLS_REJECT_UNAUTHORIZED || 'false', + MAILER_SMTP_AUTH_TYPE: configs.MAILER_SMTP_AUTH_TYPE || 'login', + MAILER_SMTP_OAUTH2_USER: configs.MAILER_SMTP_OAUTH2_USER ?? '', + MAILER_SMTP_OAUTH2_CLIENT_ID: configs.MAILER_SMTP_OAUTH2_CLIENT_ID ?? '', + MAILER_SMTP_OAUTH2_CLIENT_SECRET: + configs.MAILER_SMTP_OAUTH2_CLIENT_SECRET ?? '', + MAILER_SMTP_OAUTH2_REFRESH_TOKEN: + configs.MAILER_SMTP_OAUTH2_REFRESH_TOKEN ?? '', + MAILER_SMTP_OAUTH2_ACCESS_URL: configs.MAILER_SMTP_OAUTH2_ACCESS_URL ?? '', + }; +} + +/** + * The handler for onboarding configuration. + * This composable manages the state and logic for onboarding configurations, + * including enabling/disabling configs, validating inputs, + * and submitting the onboarding form. + * @returns Composable for handling onboarding configuration. + */ +export function useOnboardingConfigHandler() { + const t = useI18n(); + const toast = useToast(); + + const enabledConfigs = ref([]); + const isProvidersLoading = ref(false); + const submittingConfigs = ref(false); + + const onBoardingSummary = ref({ + type: 'success', + message: t('onboarding.setup_complete.title'), + description: t('onboarding.setup_complete.description'), + configsAdded: [] as string[], + }); + + const currentConfigs = ref({ + oAuthProviders: mapOAuthProviders({}), + mailerConfigs: mapMailerConfigs({}), + }); + + const enableConfig = (config: EnabledConfig) => { + if (!enabledConfigs.value.includes(config)) { + enabledConfigs.value.push(config); + } + }; + + const toggleConfig = (key: EnabledConfig | 'OAUTH' | 'EMAIL') => { + if (key === 'OAUTH') { + enabledConfigs.value = enabledConfigs.value.filter( + (c) => !['GOOGLE', 'GITHUB', 'MICROSOFT'].includes(c), + ); + } + + if (key === 'EMAIL') { + const hasEmail = enabledConfigs.value.includes('EMAIL'); + const hasMailer = enabledConfigs.value.includes('MAILER'); + enabledConfigs.value = enabledConfigs.value.filter( + (c) => c !== 'EMAIL' && c !== 'MAILER', + ); + if (!hasEmail || !hasMailer) { + enabledConfigs.value.push('EMAIL', 'MAILER'); + } + return; + } + + if (enabledConfigs.value.includes(key)) { + enabledConfigs.value = enabledConfigs.value.filter((c) => c !== key); + } else { + enableConfig(key); + } + }; + + const toggleSmtpConfig = () => { + const current = currentConfigs.value.mailerConfigs.MAILER_SMTP_ENABLE; + currentConfigs.value.mailerConfigs.MAILER_SMTP_ENABLE = + current === 'true' ? 'false' : 'true'; + }; + + // Set callback URLs for OAuth providers based on the current backend API URL + const setCallbackUrls = () => { + const base = import.meta.env.VITE_BACKEND_API_URL; + const oAuth = currentConfigs.value.oAuthProviders; + + if (oAuth.GOOGLE.GOOGLE_CLIENT_ID) { + oAuth.GOOGLE.GOOGLE_CALLBACK_URL = `${base}/auth/google/callback`; + } + if (oAuth.GITHUB.GITHUB_CLIENT_ID) { + oAuth.GITHUB.GITHUB_CALLBACK_URL = `${base}/auth/github/callback`; + } + if (oAuth.MICROSOFT.MICROSOFT_CLIENT_ID) { + oAuth.MICROSOFT.MICROSOFT_CALLBACK_URL = `${base}/auth/microsoft/callback`; + } + }; + + const makeOnboardingSummary = (error?: Error): OnBoardingSummary => { + const addedConfigs = enabledConfigs.value; + + if (addedConfigs.length === 0) { + return { + type: 'error', + message: t('onboarding.onboarding_incomplete.title'), + description: t('onboarding.onboarding_incomplete.description', { + error: + error?.message || t('onboarding.onboarding_incomplete.description'), + }), + configsAdded: [], + }; + } + + return { + type: 'success', + message: t('onboarding.setup_complete.title'), + description: t('onboarding.setup_complete.description'), + configsAdded: addedConfigs.filter((key) => key !== 'MAILER'), + }; + }; + + /** + * Filters out unnecessary configs based on the current state. + * For example, if MAILER_USE_CUSTOM_CONFIGS is false, + * we don't need MAILER_SMTP_URL, MAILER_TLS_REJECT_UNAUTHORIZED, MAILER_SMTP_SECURE. + * @param keys Array of config keys to filter + * @returns Filtered array of keys that are needed based on the current state + */ + const filterNeededConfigs = (keys: string[]) => { + const mailer = currentConfigs.value.mailerConfigs; + const usingCustom = mailer.MAILER_USE_CUSTOM_CONFIGS === 'true'; + + return keys.filter((key) => { + if (!key.startsWith('MAILER_')) return true; + if (!enabledConfigs.value.includes('MAILER')) return false; + + if (!usingCustom) { + return [ + 'MAILER_SMTP_ENABLE', + 'MAILER_SMTP_URL', + 'MAILER_ADDRESS_FROM', + ].includes(key); + } + + return [ + 'MAILER_SMTP_HOST', + 'MAILER_SMTP_PORT', + 'MAILER_SMTP_USER', + 'MAILER_SMTP_PASSWORD', + 'MAILER_ADDRESS_FROM', + 'MAILER_USE_CUSTOM_CONFIGS', + 'MAILER_SMTP_SECURE', + 'MAILER_SMTP_IGNORE_TLS', + 'MAILER_TLS_REJECT_UNAUTHORIZED', + 'MAILER_SMTP_ENABLE', + 'MAILER_SMTP_AUTH_TYPE', + 'MAILER_SMTP_OAUTH2_USER', + 'MAILER_SMTP_OAUTH2_CLIENT_ID', + 'MAILER_SMTP_OAUTH2_CLIENT_SECRET', + 'MAILER_SMTP_OAUTH2_REFRESH_TOKEN', + 'MAILER_SMTP_OAUTH2_ACCESS_URL', + ].includes(key); + }); + }; + + /** + * Validates the provided configs. + * Checks if all required fields are filled and returns a filtered object + * with only the enabled configs that have values. + * @param configs Object containing config key-value pairs + * @returns Filtered object with valid configs or undefined if validation fails + */ + const validateConfigs = (configs: Partial>) => { + if (!configs || Object.keys(configs).length === 0) { + toast.error(t('onboarding.configuration_error')); + return; + } + + const relevantKeys = Object.keys(configs).filter((key) => + enabledConfigs.value.includes(key.split('_')[0] as EnabledConfig), + ); + + const neededKeys = filterNeededConfigs(relevantKeys); + // SMTP auth is optional (both blank = no-auth SMTP server). + // These keys are excluded from mandatory "filled" checks, but are still + // included in the returned payload even when empty so the backend can + // explicitly clear previously stored credentials on re-visits. + const optionalSmtpKeys = new Set([ + 'MAILER_SMTP_USER', + 'MAILER_SMTP_PASSWORD', + 'MAILER_SMTP_OAUTH2_USER', + 'MAILER_SMTP_OAUTH2_CLIENT_ID', + 'MAILER_SMTP_OAUTH2_CLIENT_SECRET', + 'MAILER_SMTP_OAUTH2_REFRESH_TOKEN', + 'MAILER_SMTP_OAUTH2_ACCESS_URL', + ]); + const allFilled = neededKeys.every( + (key) => configs[key] || optionalSmtpKeys.has(key), + ); + + if (!allFilled) { + neededKeys.forEach((key) => { + if (!configs[key] && !optionalSmtpKeys.has(key)) + toast.error( + t('onboarding.please_fill_configurations', { + fieldName: makeReadableKey(key), + }), + ); + }); + return; + } + + // SMTP credentials must be provided together or both left empty. + // Enforced regardless of auth_type: the backend validates the pair on + // every save, so stale login values left behind after switching to the + // OAuth2 tab would still be rejected. Surface this in the FE toast so + // users know to clear those fields before saving. + if ( + enabledConfigs.value.includes('MAILER') && + configs['MAILER_USE_CUSTOM_CONFIGS'] === 'true' + ) { + const smtpUser = configs['MAILER_SMTP_USER']?.trim(); + const smtpPass = configs['MAILER_SMTP_PASSWORD']?.trim(); + + if (!!smtpUser !== !!smtpPass) { + toast.error(t('configs.mail_configs.smtp_auth_incomplete')); + return; + } + } + + // Allow empty strings through for optional SMTP keys so the backend + // receives an explicit clear rather than silently retaining old values + return Object.fromEntries( + Object.entries(configs).filter( + ([key, val]) => + enabledConfigs.value.includes(key.split('_')[0] as EnabledConfig) && + (val || optionalSmtpKeys.has(key)), + ), + ); + }; + + /** + * Adds the onboarding configs to the backend. + * It validates the configs, prepares the payload, + * and sends it to the backend API. + * We set the token in localStorage for re-fetching configs later. + * @returns The token for re-fetching configs or undefined if failed + */ + const addOnBoardingConfigs = async () => { + submittingConfigs.value = true; + const payload = { + ...currentConfigs.value.oAuthProviders.GOOGLE, + ...currentConfigs.value.oAuthProviders.GITHUB, + ...currentConfigs.value.oAuthProviders.MICROSOFT, + ...currentConfigs.value.mailerConfigs, + }; + + const validated = validateConfigs(payload); + + if (!validated || Object.keys(validated).length === 0) { + toast.error(t('onboarding.add_atleast_one_auth_provider')); + submittingConfigs.value = false; + return; + } + + const filteredEnabledConfigs = enabledConfigs.value.filter( + (config) => config !== 'OAUTH' && config !== 'MAILER', + ); + + const configWithAuth = { + ...validated, + [InfraConfigEnum.ViteAllowedAuthProviders]: + filteredEnabledConfigs.join(','), + }; + + try { + const res = await auth.addOnBoardingConfigs(configWithAuth); + if (res?.token) { + setLocalConfig('access_token', res.token); + toast.success(t('onboarding.configurations_added_successfully')); + onBoardingSummary.value = makeOnboardingSummary(); + return res; + } + } catch (err) { + console.error('Failed to add onboarding configs', err); + toast.error(t('onboarding.configurations_adding_failed')); + onBoardingSummary.value = makeOnboardingSummary(err as Error); + } finally { + submittingConfigs.value = false; + } + }; + + // Fetch onboarding configs on mount and populate the currentConfigs + // and enabledConfigs based on the response. + // This is used to pre-fill the form with existing configs. + onMounted(async () => { + try { + isProvidersLoading.value = true; + const token = getLocalConfig('access_token'); + if (!token) return; + + const configs = await auth.getOnboardingConfigs(token); + if (!configs) return; + + const allowed = configs[InfraConfigEnum.ViteAllowedAuthProviders]; + if (allowed) { + // Trim each entry so whitespace variations ("GOOGLE, EMAIL") don't + // cause provider-name mismatches in downstream `.includes()` checks. + const parsed = allowed + .split(',') + .map((p) => p.trim()) + .filter(Boolean) as EnabledConfig[]; + + // The backend persists only 'EMAIL' in VITE_ALLOWED_AUTH_PROVIDERS, + // but internally we also track 'MAILER' as the signal that MAILER_* + // keys should be kept on save. toggleConfig('EMAIL') pairs them, so + // mirror that invariant on load to keep the two flags in sync. + if (parsed.includes('EMAIL') && !parsed.includes('MAILER')) { + parsed.push('MAILER'); + } + + enabledConfigs.value = parsed; + } + + currentConfigs.value = { + oAuthProviders: mapOAuthProviders(configs), + mailerConfigs: mapMailerConfigs(configs), + }; + } catch (err) { + console.error('Error fetching onboarding configs', err); + } finally { + isProvidersLoading.value = false; + } + }); + + // Watch for changes in currentConfigs and update the callback URLs + // and enable/disable configs based on the SMTP settings. + // This ensures that the form reflects the current state of the configs. + watch( + currentConfigs, + () => { + setCallbackUrls(); + + if ( + currentConfigs.value.mailerConfigs.MAILER_SMTP_ENABLE?.toLowerCase() === + 'true' + ) { + // Enable MAILER and EMAIL configs if SMTP is enabled + // because we need to add EMAIL in VITE_ALLOWED_AUTH_PROVIDERS + // and MAILER because the key is used in backend + enableConfig('MAILER'); + enableConfig('EMAIL'); + } + }, + { deep: true, immediate: true }, + ); + + return { + currentConfigs, + enabledConfigs, + isProvidersLoading, + onBoardingSummary, + submittingConfigs, + toggleConfig, + toggleSmtpConfig, + enabledConfig: enableConfig, + addOnBoardingConfigs, + }; +} diff --git a/packages/hoppscotch-sh-admin/src/composables/usePagedQuery.ts b/packages/hoppscotch-sh-admin/src/composables/usePagedQuery.ts new file mode 100644 index 0000000..d65a07a --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/composables/usePagedQuery.ts @@ -0,0 +1,88 @@ +import { onMounted, ref, Ref } from 'vue'; +import { DocumentNode } from 'graphql'; +import { TypedDocumentNode, useClientHandle } from '@urql/vue'; + +export function usePagedQuery< + Result, + Vars extends Record, + ListItem +>( + query: string | TypedDocumentNode | DocumentNode, + getList: (result: Result) => ListItem[], + itemsPerPage: number, + baseVariables: Vars, + getCursor?: (value: ListItem) => string +) { + const { client } = useClientHandle(); + const fetching = ref(true); + const error = ref(false); + const list: Ref = ref([]); + const currentPage = ref(0); + const hasNextPage = ref(true); + + const fetchNextPage = async (additionalVariables?: Vars) => { + let variables = { ...baseVariables }; + + fetching.value = true; + + // Cursor based pagination + if (getCursor) { + const cursor = + list.value.length > 0 + ? getCursor(list.value.at(-1) as ListItem) + : undefined; + variables = { ...variables, cursor }; + } + // Offset based pagination + else if (additionalVariables) { + variables = { ...variables, ...additionalVariables }; + } + + const result = await client.query(query, variables).toPromise(); + if (result.error) { + error.value = true; + fetching.value = false; + return; + } + + const resultList = getList(result.data!); + + if (resultList.length < itemsPerPage) { + hasNextPage.value = false; + } + + list.value.push(...resultList); + currentPage.value++; + + fetching.value = false; + }; + + onMounted(async () => { + await fetchNextPage(); + }); + + const goToNextPage = async () => { + if (hasNextPage.value) { + await fetchNextPage(); + } + }; + + const refetch = async (variables?: Vars) => { + currentPage.value = 0; + hasNextPage.value = true; + list.value = []; + + if (hasNextPage.value) { + variables ? await fetchNextPage(variables) : await fetchNextPage(); + } + }; + + return { + fetching, + error, + goToNextPage, + refetch, + list, + hasNextPage, + }; +} diff --git a/packages/hoppscotch-sh-admin/src/composables/useSidebar.ts b/packages/hoppscotch-sh-admin/src/composables/useSidebar.ts new file mode 100644 index 0000000..26aa607 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/composables/useSidebar.ts @@ -0,0 +1,13 @@ +import { ref } from 'vue'; + +/** isOpen is used to indicate whether the sidebar is now visible on the screen */ +const isOpen = ref(false); +/** isExpanded is used to indicate whether the sidebar is now expanded to also include page names or the sidebar is compressed to show just the icons */ +const isExpanded = ref(true); + +export function useSidebar() { + return { + isOpen, + isExpanded, + }; +} diff --git a/packages/hoppscotch-sh-admin/src/composables/useSmtpAuthTypeSwitch.ts b/packages/hoppscotch-sh-admin/src/composables/useSmtpAuthTypeSwitch.ts new file mode 100644 index 0000000..b0f65f5 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/composables/useSmtpAuthTypeSwitch.ts @@ -0,0 +1,63 @@ +import { computed, ref } from 'vue'; + +// Shared auth-type tab switching logic for SMTP configuration UIs. +// When switching between login/oauth2 tabs, if the current tab has data +// we confirm before clearing — so stale credentials don't get persisted +// alongside the active set. +// +// `fields` is a lazy accessor to the reactive record (the two call sites +// nest it differently: `currentConfigs.mailerConfigs` vs `smtpConfigs.fields`). +// Calling it inside computed getters/setters preserves reactivity. +export function useSmtpAuthTypeSwitch( + fields: () => Record, + authKey: K, + loginKeys: readonly K[], + oauth2Keys: readonly K[], +) { + const showAuthSwitchModal = ref(false); + const pendingAuthType = ref(null); + + const hasAnyValue = (keys: readonly K[]) => + keys.some((k) => (fields()[k] ?? '').trim() !== ''); + + const authType = computed({ + get: () => fields()[authKey], + set: (next: string) => { + const current = fields()[authKey]; + if (next === current) return; + + const keysToClear = current === 'oauth2' ? oauth2Keys : loginKeys; + if (hasAnyValue(keysToClear)) { + pendingAuthType.value = next; + showAuthSwitchModal.value = true; + return; + } + + fields()[authKey] = next; + }, + }); + + const confirmAuthSwitch = () => { + if (pendingAuthType.value === null) return; + const current = fields()[authKey]; + const keysToClear = current === 'oauth2' ? oauth2Keys : loginKeys; + keysToClear.forEach((k) => { + fields()[k] = ''; + }); + fields()[authKey] = pendingAuthType.value; + pendingAuthType.value = null; + showAuthSwitchModal.value = false; + }; + + const cancelAuthSwitch = () => { + pendingAuthType.value = null; + showAuthSwitchModal.value = false; + }; + + return { + authType, + showAuthSwitchModal, + confirmAuthSwitch, + cancelAuthSwitch, + }; +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/Email.ts b/packages/hoppscotch-sh-admin/src/helpers/Email.ts new file mode 100644 index 0000000..0fd0538 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/Email.ts @@ -0,0 +1,16 @@ +import * as t from "io-ts" + +const emailRegex = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + +interface EmailBrand { + readonly Email: unique symbol +} + +export const EmailCodec = t.brand( + t.string, + (x): x is t.Branded => emailRegex.test(x), + "Email" +) + +export type Email = t.TypeOf diff --git a/packages/hoppscotch-sh-admin/src/helpers/auth.ts b/packages/hoppscotch-sh-admin/src/helpers/auth.ts new file mode 100644 index 0000000..48e4291 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/auth.ts @@ -0,0 +1,303 @@ +import { BehaviorSubject, Subject } from 'rxjs'; +import { + getLocalConfig, + removeLocalConfig, + setLocalConfig, +} from './localpersistence'; +import { Ref, ref } from 'vue'; +import * as O from 'fp-ts/Option'; +import authQuery from './backend/rest/authQuery'; +import { COOKIES_NOT_FOUND, UNAUTHORIZED } from './errors'; + +/** + * A common (and required) set of fields that describe a user. + */ +export type HoppUser = { + /** A unique ID identifying the user */ + uid: string; + + /** The name to be displayed as the user's */ + displayName: string | null; + + /** The user's email address */ + email: string | null; + + /** URL to the profile picture of the user */ + photoURL: string | null; + + /** Name of the provider authenticating (NOTE: See notes on `platform/auth.ts`) */ + provider?: string; + /** Access Token for the auth of the user against the given `provider`. */ + accessToken?: string; + emailVerified: boolean; + /** Flag to check for admin status */ + isAdmin: boolean; +}; + +export type AuthEvent = + | { event: 'login'; user: HoppUser } // We are authenticated + | { event: 'logout' } // No authentication and we have no previous state + | { event: 'token_refresh' }; // We have previous login state, but the app is waiting for authentication + +export type GithubSignInResult = + | { type: 'success'; user: HoppUser } // The authentication was a success + | { type: 'account-exists-with-different-cred'; link: () => Promise } // We authenticated correctly, but the provider didn't match, so we give the user the opportunity to link to continue completing auth + | { type: 'error'; err: unknown }; // Auth failed completely and we don't know why + +export const authEvents$ = new Subject< + AuthEvent | { event: 'token_refresh' } +>(); + +export type OnboardingStatus = { + canReRunOnboarding: boolean; + onboardingCompleted: boolean; +}; + +const currentUser$ = new BehaviorSubject(null); + +const signOut = async (reloadWindow = false) => { + // Best-effort backend logout — local state must be cleared regardless + // so the UI never stays stuck in an authenticated state. + try { + await authQuery.logout(); + } catch (_) { + // Backend unreachable — continue with local cleanup + } + + currentUser$.next(null); + removeLocalConfig('login_state'); + + authEvents$.next({ + event: 'logout', + }); + + // Reload the window if both `access_token` and `refresh_token` are invalid + // thereby the user is taken to the login page + if (reloadWindow) { + window.location.reload(); + } +}; + +const getUserDetails = async () => { + const res = await authQuery.getUserDetails(); + return res.data; +}; +const isGettingInitialUser: Ref = ref(null); + +const setUser = (user: HoppUser | null) => { + currentUser$.next(user); + setLocalConfig('login_state', JSON.stringify(user)); +}; + +const setInitialUser = async () => { + isGettingInitialUser.value = true; + const res = await getUserDetails(); + + if (res.errors?.[0]) { + const [error] = res.errors; + + if (error.message === COOKIES_NOT_FOUND) { + setUser(null); + } else if (error.message === UNAUTHORIZED) { + const isRefreshSuccess = await refreshToken(); + + if (isRefreshSuccess) { + setInitialUser(); + } else { + setUser(null); + signOut(true); + } + } + } else if (res.data?.me) { + const { uid, displayName, email, photoURL, isAdmin } = res.data.me; + + const hoppUser: HoppUser = { + uid, + displayName, + email, + photoURL, + emailVerified: true, + isAdmin, + }; + + if (!hoppUser.isAdmin) { + hoppUser.isAdmin = await elevateUser(); + } + + setUser(hoppUser); + + authEvents$.next({ + event: 'login', + user: hoppUser, + }); + } + + isGettingInitialUser.value = false; +}; + +const refreshToken = async () => { + try { + const res = await authQuery.refreshToken(); + const isSuccessful = res.status === 200; + + if (isSuccessful) { + authEvents$.next({ + event: 'token_refresh', + }); + } + + return isSuccessful; + } catch { + return false; + } +}; + +const elevateUser = async () => { + const res = await authQuery.elevateUser(); + return Boolean(res.data?.isAdmin); +}; + +const sendMagicLink = async (email: string) => { + const res = await authQuery.sendMagicLink(email); + if (!res.data?.deviceIdentifier) { + throw new Error('test: does not get device identifier'); + } + setLocalConfig('deviceIdentifier', res.data.deviceIdentifier); + return res.data; +}; + +export const auth = { + getCurrentUserStream: () => currentUser$, + getAuthEventsStream: () => authEvents$, + getCurrentUser: () => currentUser$.value, + getUserDetails, + performAuthInit: () => { + const currentUser = JSON.parse(getLocalConfig('login_state') ?? 'null'); + currentUser$.next(currentUser); + return setInitialUser(); + }, + + signInWithEmail: (email: string) => sendMagicLink(email), + + isSignInWithEmailLink: (url: string) => { + const urlObject = new URL(url); + const searchParams = new URLSearchParams(urlObject.search); + return Boolean(searchParams.get('token')); + }, + + signInUserWithGoogle: () => { + window.location.href = `${ + import.meta.env.VITE_BACKEND_API_URL + }/auth/google?redirect_uri=${import.meta.env.VITE_ADMIN_URL}`; + }, + + signInUserWithGithub: () => { + window.location.href = `${ + import.meta.env.VITE_BACKEND_API_URL + }/auth/github?redirect_uri=${import.meta.env.VITE_ADMIN_URL}`; + }, + + signInUserWithMicrosoft: () => { + window.location.href = `${ + import.meta.env.VITE_BACKEND_API_URL + }/auth/microsoft?redirect_uri=${import.meta.env.VITE_ADMIN_URL}`; + }, + + signInWithEmailLink: (url: string) => { + const urlObject = new URL(url); + const searchParams = new URLSearchParams(urlObject.search); + const token = searchParams.get('token'); + const deviceIdentifier = getLocalConfig('deviceIdentifier'); + + return authQuery.signInWithEmailLink(token, deviceIdentifier); + }, + + performAuthRefresh: async () => { + const isRefreshSuccess = await refreshToken(); + + if (isRefreshSuccess) { + setInitialUser(); + return O.some(true); + } else { + setUser(null); + isGettingInitialUser.value = false; + return O.none; + } + }, + + signOutUser: (reloadWindow = false) => signOut(reloadWindow), + + processMagicLink: async () => { + if (auth.isSignInWithEmailLink(window.location.href)) { + const deviceIdentifier = getLocalConfig('deviceIdentifier'); + + if (!deviceIdentifier) { + throw new Error( + 'Device Identifier not found, you can only signin from the browser you generated the magic link' + ); + } + + await auth.signInWithEmailLink(window.location.href); + + removeLocalConfig('deviceIdentifier'); + window.location.href = import.meta.env.VITE_ADMIN_URL; + } + }, + + getAllowedAuthProviders: async () => { + const res = await authQuery.getProviders(); + return res.data?.providers; + }, + + getFirstTimeInfraSetupStatus: async (): Promise => { + try { + const res = await authQuery.getFirstTimeInfraSetupStatus(); + return res.data?.value === 'true'; + } catch (err) { + // Setup is not done + return true; + } + }, + + updateFirstTimeInfraSetupStatus: async () => { + try { + await authQuery.updateFirstTimeInfraSetupStatus(); + return true; + } catch (err) { + console.error(err); + return false; + } + }, + + getOnboardingStatus: async (): Promise => { + try { + const res = await authQuery.getOnboardingStatus(); + return res.data; + } catch (err) { + console.error(err); + return null; + } + }, + + addOnBoardingConfigs: async (config: Record) => { + try { + const res = await authQuery.addOnBoardingConfigs(config); + return res.data as { + token: string; + }; + } catch (err) { + console.error(err); + return null; + } + }, + + getOnboardingConfigs: async (token: string) => { + try { + const res = await authQuery.getOnBoardingConfigs(token); + return res.data; + } catch (err) { + console.error(err); + return null; + } + }, +}; diff --git a/packages/hoppscotch-sh-admin/src/helpers/axiosConfig.ts b/packages/hoppscotch-sh-admin/src/helpers/axiosConfig.ts new file mode 100644 index 0000000..a250d8c --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/axiosConfig.ts @@ -0,0 +1,26 @@ +import axios from 'axios'; + +const baseConfig = { + headers: { + 'Content-type': 'application/json', + }, + withCredentials: true, +}; + +const gqlApi = axios.create({ + ...baseConfig, + baseURL: import.meta.env.VITE_BACKEND_GQL_URL, +}); + +const restApi = axios.create({ + ...baseConfig, + baseURL: import.meta.env.VITE_BACKEND_API_URL, +}); + +const listmonkApi = axios.create({ + ...baseConfig, + withCredentials: false, + baseURL: 'https://listmonk.hoppscotch.com/api/public', +}); + +export { gqlApi, restApi, listmonkApi }; diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/AcceptTeamInvitation.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/AcceptTeamInvitation.graphql new file mode 100644 index 0000000..41d1255 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/AcceptTeamInvitation.graphql @@ -0,0 +1,12 @@ +mutation AcceptTeamInvitation($inviteID: ID!) { + acceptTeamInvitation(inviteID: $inviteID) { + membershipID + role + user { + uid + displayName + photoURL + email + } + } +} \ No newline at end of file diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/AddUserToTeamByAdmin.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/AddUserToTeamByAdmin.graphql new file mode 100644 index 0000000..59d454a --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/AddUserToTeamByAdmin.graphql @@ -0,0 +1,13 @@ +mutation AddUserToTeamByAdmin( + $userEmail: String! + $role: TeamAccessRole! + $teamID: ID! +) { + addUserToTeamByAdmin(role: $role, userEmail: $userEmail, teamID: $teamID) { + membershipID + role + user { + uid + } + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/ChangeUserRoleInTeamByAdmin.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/ChangeUserRoleInTeamByAdmin.graphql new file mode 100644 index 0000000..bc9c532 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/ChangeUserRoleInTeamByAdmin.graphql @@ -0,0 +1,14 @@ +mutation ChangeUserRoleInTeamByAdmin( + $userUID: ID! + $teamID: ID! + $newRole: TeamAccessRole! +) { + changeUserRoleInTeamByAdmin( + userUID: $userUID + teamID: $teamID + newRole: $newRole + ) { + membershipID + role + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/CreateInfraToken.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/CreateInfraToken.graphql new file mode 100644 index 0000000..c7da798 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/CreateInfraToken.graphql @@ -0,0 +1,12 @@ +mutation CreateInfraToken($label: String!, $expiryInDays: Int) { + createInfraToken(label: $label, expiryInDays: $expiryInDays) { + info { + id + label + lastUsedOn + createdOn + expiresOn + } + token + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/CreateTeam.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/CreateTeam.graphql new file mode 100644 index 0000000..70561f6 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/CreateTeam.graphql @@ -0,0 +1,19 @@ +mutation CreateTeam($userUid: ID!, $name: String!) { + createTeamByAdmin(userUid: $userUid, name: $name) { + id + name + members { + membershipID + role + user { + uid + displayName + email + photoURL + } + } + ownersCount + editorsCount + viewersCount + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/CreateTeamInvitation.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/CreateTeamInvitation.graphql new file mode 100644 index 0000000..1c3272a --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/CreateTeamInvitation.graphql @@ -0,0 +1,9 @@ +mutation CreateTeamInvitation($inviteeEmail: String!, $inviteeRole: TeamAccessRole!, $teamID: ID!) { + createTeamInvitation(inviteeRole: $inviteeRole, inviteeEmail: $inviteeEmail, teamID: $teamID) { + id + teamID + creatorUid + inviteeEmail + inviteeRole + } +} \ No newline at end of file diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/DemoteUsersByAdmin.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/DemoteUsersByAdmin.graphql new file mode 100644 index 0000000..b91992c --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/DemoteUsersByAdmin.graphql @@ -0,0 +1,3 @@ +mutation DemoteUsersByAdmin($userUIDs: [ID!]!) { + demoteUsersByAdmin(userUIDs: $userUIDs) +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/EnableAndDisableSso.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/EnableAndDisableSso.graphql new file mode 100644 index 0000000..5a494ca --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/EnableAndDisableSso.graphql @@ -0,0 +1,3 @@ +mutation EnableAndDisableSSO($providerInfo: [EnableAndDisableSSOArgs!]!) { + enableAndDisableSSO(providerInfo: $providerInfo) +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/InviteNewUser.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/InviteNewUser.graphql new file mode 100644 index 0000000..847d56e --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/InviteNewUser.graphql @@ -0,0 +1,5 @@ +mutation InviteNewUser($inviteeEmail: String!) { + inviteNewUser(inviteeEmail: $inviteeEmail) { + inviteeEmail + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/MakeUsersAdmin.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/MakeUsersAdmin.graphql new file mode 100644 index 0000000..f936562 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/MakeUsersAdmin.graphql @@ -0,0 +1,3 @@ +mutation MakeUsersAdmin($userUIDs: [ID!]!) { + makeUsersAdmin(userUIDs: $userUIDs) +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RemoveTeam.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RemoveTeam.graphql new file mode 100644 index 0000000..64f1c54 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RemoveTeam.graphql @@ -0,0 +1,3 @@ +mutation RemoveTeam($uid: ID!) { + deleteTeamByAdmin(teamID: $uid) +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RemoveUserFromTeamByAdmin.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RemoveUserFromTeamByAdmin.graphql new file mode 100644 index 0000000..caf44fe --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RemoveUserFromTeamByAdmin.graphql @@ -0,0 +1,3 @@ +mutation RemoveUserFromTeamByAdmin($userUid: ID!, $teamID: ID!) { + removeUserFromTeamByAdmin(userUid: $userUid, teamID: $teamID) +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RemoveUsersByAdmin.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RemoveUsersByAdmin.graphql new file mode 100644 index 0000000..d3649e5 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RemoveUsersByAdmin.graphql @@ -0,0 +1,7 @@ +mutation RemoveUsersByAdmin($userUIDs: [ID!]!) { + removeUsersByAdmin(userUIDs: $userUIDs) { + userUID + isDeleted + errorMessage + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RenameTeam.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RenameTeam.graphql new file mode 100644 index 0000000..7abfe71 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RenameTeam.graphql @@ -0,0 +1,6 @@ +mutation RenameTeam($uid: ID!, $name: String!) { + renameTeamByAdmin(teamID: $uid, newName: $name) { + id + name + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/ResetInfraConfigs.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/ResetInfraConfigs.graphql new file mode 100644 index 0000000..6ab7520 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/ResetInfraConfigs.graphql @@ -0,0 +1,3 @@ +mutation ResetInfraConfigs { + resetInfraConfigs +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RevokeAllUserHistoryByAdmin.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RevokeAllUserHistoryByAdmin.graphql new file mode 100644 index 0000000..e225182 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RevokeAllUserHistoryByAdmin.graphql @@ -0,0 +1,3 @@ +mutation RevokeAllUserHistoryByAdmin { + revokeAllUserHistoryByAdmin +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RevokeInfraToken.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RevokeInfraToken.graphql new file mode 100644 index 0000000..2f87bb3 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RevokeInfraToken.graphql @@ -0,0 +1,3 @@ +mutation RevokeInfraToken($id: ID!) { + revokeInfraToken(id: $id) +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RevokeShortcodeByAdmin.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RevokeShortcodeByAdmin.graphql new file mode 100644 index 0000000..5afe570 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RevokeShortcodeByAdmin.graphql @@ -0,0 +1,3 @@ +mutation RevokeShortcodeByAdmin($codeID: ID!) { + revokeShortcodeByAdmin(code: $codeID) +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RevokeTeamInvitation.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RevokeTeamInvitation.graphql new file mode 100644 index 0000000..f1e54b6 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RevokeTeamInvitation.graphql @@ -0,0 +1,3 @@ +mutation RevokeTeamInvitation($inviteID: ID!) { + revokeTeamInviteByAdmin(inviteID: $inviteID) +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RevokeUserInvitationsByAdmin.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RevokeUserInvitationsByAdmin.graphql new file mode 100644 index 0000000..98ccac4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/RevokeUserInvitationsByAdmin.graphql @@ -0,0 +1,3 @@ +mutation RevokeUserInvitationsByAdmin($inviteeEmails: [String!]!) { + revokeUserInvitationsByAdmin(inviteeEmails: $inviteeEmails) +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/TeamInvitationAdded.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/TeamInvitationAdded.graphql new file mode 100644 index 0000000..878ec13 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/TeamInvitationAdded.graphql @@ -0,0 +1,5 @@ +subscription TeamInvitationAdded($teamID: ID!) { + teamInvitationAdded(teamID: $teamID) { + id + } +} \ No newline at end of file diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/TeamInvitationRemoved.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/TeamInvitationRemoved.graphql new file mode 100644 index 0000000..d90488f --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/TeamInvitationRemoved.graphql @@ -0,0 +1,3 @@ +subscription TeamInvitationRemoved($teamID: ID!) { + teamInvitationRemoved(teamID: $teamID) +} \ No newline at end of file diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/ToggleAnalyticsCollection.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/ToggleAnalyticsCollection.graphql new file mode 100644 index 0000000..0696620 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/ToggleAnalyticsCollection.graphql @@ -0,0 +1,3 @@ +mutation ToggleAnalyticsCollection($status: ServiceStatus!) { + toggleAnalyticsCollection(status: $status) +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/ToggleSMTP.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/ToggleSMTP.graphql new file mode 100644 index 0000000..d8edfac --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/ToggleSMTP.graphql @@ -0,0 +1,3 @@ +mutation ToggleSMTP($status: ServiceStatus!) { + toggleSMTP(status: $status) +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/ToggleUserHistoryStore.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/ToggleUserHistoryStore.graphql new file mode 100644 index 0000000..fa0e637 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/ToggleUserHistoryStore.graphql @@ -0,0 +1,3 @@ +mutation ToggleUserHistoryStore($status: ServiceStatus!) { + toggleUserHistoryStore(status: $status) +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/UpdateInfraConfigs.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/UpdateInfraConfigs.graphql new file mode 100644 index 0000000..14217cc --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/UpdateInfraConfigs.graphql @@ -0,0 +1,6 @@ +mutation UpdateInfraConfigs($infraConfigs: [InfraConfigArgs!]!) { + updateInfraConfigs(infraConfigs: $infraConfigs) { + name + value + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/UpdateUserDisplayNameByAdmin.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/UpdateUserDisplayNameByAdmin.graphql new file mode 100644 index 0000000..5776dc3 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/mutations/UpdateUserDisplayNameByAdmin.graphql @@ -0,0 +1,3 @@ +mutation UpdateUserDisplayNameByAdmin($userUID: ID!, $name: String!) { + updateUserDisplayNameByAdmin(userUID: $userUID, displayName: $name) +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/AllowedAuthProviders.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/AllowedAuthProviders.graphql new file mode 100644 index 0000000..05a56b3 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/AllowedAuthProviders.graphql @@ -0,0 +1,3 @@ +query AllowedAuthProviders { + allowedAuthProviders +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/InfraConfigs.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/InfraConfigs.graphql new file mode 100644 index 0000000..f875fea --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/InfraConfigs.graphql @@ -0,0 +1,6 @@ +query InfraConfigs($configNames: [InfraConfigEnum!]!) { + infraConfigs(configNames: $configNames) { + name + value + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/InfraTokens.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/InfraTokens.graphql new file mode 100644 index 0000000..1c81df5 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/InfraTokens.graphql @@ -0,0 +1,9 @@ +query InfraTokens($skip: Int, $take: Int) { + infraTokens(skip: $skip, take: $take) { + id + label + createdOn + lastUsedOn + expiresOn + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/InvitedUsers.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/InvitedUsers.graphql new file mode 100644 index 0000000..f947a93 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/InvitedUsers.graphql @@ -0,0 +1,10 @@ +query InvitedUsers($skip: Int, $take: Int) { + infra { + invitedUsers(skip: $skip, take: $take) { + adminUid + adminEmail + inviteeEmail + invitedOn + } + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/IsSMTPEnabled.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/IsSMTPEnabled.graphql new file mode 100644 index 0000000..0804447 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/IsSMTPEnabled.graphql @@ -0,0 +1,3 @@ +query IsSMTPEnabled { + isSMTPEnabled +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/Metrics.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/Metrics.graphql new file mode 100644 index 0000000..ba7d3b7 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/Metrics.graphql @@ -0,0 +1,8 @@ +query Metrics { + infra { + usersCount + teamsCount + teamRequestsCount + teamCollectionsCount + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/SharedRequests.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/SharedRequests.graphql new file mode 100644 index 0000000..1bd8916 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/SharedRequests.graphql @@ -0,0 +1,13 @@ +query SharedRequests($cursor: ID, $take: Int, $email: String) { + infra { + allShortcodes(cursor: $cursor, take: $take, userEmail: $email) { + id + request + properties + createdOn + creator { + email + } + } + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/TeamInfo.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/TeamInfo.graphql new file mode 100644 index 0000000..d3956db --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/TeamInfo.graphql @@ -0,0 +1,31 @@ +query TeamInfo($teamID: ID!) { + infra { + teamInfo(teamID: $teamID) { + id + name + + teamMembers { + membershipID + role + user { + uid + displayName + email + photoURL + } + } + teamInvitations { + id + inviteeEmail + inviteeRole + } + teamEnvironments { + id + name + } + ownersCount + editorsCount + viewersCount + } + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/TeamListV2.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/TeamListV2.graphql new file mode 100644 index 0000000..999084b --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/TeamListV2.graphql @@ -0,0 +1,11 @@ +query TeamListV2($searchString: String, $skip: Int, $take: Int) { + infra { + allTeamsV2(searchString: $searchString, skip: $skip, take: $take) { + id + name + teamMembers { + membershipID + } + } + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/TeamsOfUserByAdmin.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/TeamsOfUserByAdmin.graphql new file mode 100644 index 0000000..c064311 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/TeamsOfUserByAdmin.graphql @@ -0,0 +1,13 @@ +query TeamsOfUserByAdmin($userUid: ID!, $cursor: ID, $take: Int) { + teamsOfUserByAdmin(userUid: $userUid, cursor: $cursor, take: $take) { + id + name + teamMembers { + membershipID + role + user { + uid + } + } + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/UserInfo.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/UserInfo.graphql new file mode 100644 index 0000000..32d1aa9 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/UserInfo.graphql @@ -0,0 +1,13 @@ +query UserInfo($uid: ID!) { + infra { + userInfo(userUid: $uid) { + uid + displayName + email + isAdmin + photoURL + createdOn + lastActiveOn + } + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/UsersList.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/UsersList.graphql new file mode 100644 index 0000000..9d483f8 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/UsersList.graphql @@ -0,0 +1,13 @@ +# Write your query or mutation here +query UsersList($cursor: ID, $take: Int) { + infra { + allUsers(cursor: $cursor, take: $take) { + uid + displayName + email + isAdmin + photoURL + createdOn + } + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/UsersListV2.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/UsersListV2.graphql new file mode 100644 index 0000000..2299a04 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/UsersListV2.graphql @@ -0,0 +1,13 @@ +query UsersListV2($searchString: String, $skip: Int, $take: Int) { + infra { + allUsersV2(searchString: $searchString, skip: $skip, take: $take) { + uid + displayName + email + isAdmin + photoURL + createdOn + lastActiveOn + } + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/pendingInvites.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/pendingInvites.graphql new file mode 100644 index 0000000..bbc1077 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/pendingInvites.graphql @@ -0,0 +1,10 @@ +query GetPendingInvites($teamID: ID!) { + team(teamID: $teamID) { + id + teamInvitations { + inviteeRole + inviteeEmail + id + } + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/rest/authQuery.ts b/packages/hoppscotch-sh-admin/src/helpers/backend/rest/authQuery.ts new file mode 100644 index 0000000..40f573e --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/rest/authQuery.ts @@ -0,0 +1,40 @@ +import { gqlApi, restApi } from '~/helpers/axiosConfig'; + +export default { + getUserDetails: () => + gqlApi.post('', { + query: `query Me { + me { + uid + displayName + email + photoURL + isAdmin + createdOn + } + }`, + }), + refreshToken: () => restApi.get('/auth/refresh'), + elevateUser: () => restApi.get('/auth/verify/admin'), + getProviders: () => restApi.get('/auth/providers'), + sendMagicLink: (email: string) => + restApi.post('/auth/signin?origin=admin', { + email, + }), + signInWithEmailLink: ( + token: string | null, + deviceIdentifier: string | null + ) => + restApi.post('/auth/verify', { + token, + deviceIdentifier, + }), + getFirstTimeInfraSetupStatus: () => restApi.get('/site/setup'), + updateFirstTimeInfraSetupStatus: () => restApi.put('/site/setup'), + addOnBoardingConfigs: (config: Record) => + restApi.post('/onboarding/config', config), + getOnboardingStatus: () => restApi.get('/onboarding/status'), + getOnBoardingConfigs: (token: string) => + restApi.get('/onboarding/config?token=' + token), + logout: () => restApi.get('/auth/logout'), +}; diff --git a/packages/hoppscotch-sh-admin/src/helpers/configs.ts b/packages/hoppscotch-sh-admin/src/helpers/configs.ts new file mode 100644 index 0000000..10cc70b --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/configs.ts @@ -0,0 +1,722 @@ +import { inject, provide, ref, type InjectionKey, type Ref } from 'vue'; +import { InfraConfigEnum } from './backend/graphql'; + +export type SsoAuthProviders = 'google' | 'microsoft' | 'github'; + +export type ServerConfigs = { + providers: { + google: { + name: SsoAuthProviders; + enabled: boolean; + fields: { + client_id: string; + client_secret: string; + callback_url: string; + scope: string; + }; + }; + github: { + name: SsoAuthProviders; + enabled: boolean; + fields: { + client_id: string; + client_secret: string; + callback_url: string; + scope: string; + }; + }; + microsoft: { + name: SsoAuthProviders; + enabled: boolean; + fields: { + client_id: string; + client_secret: string; + callback_url: string; + scope: string; + tenant: string; + }; + }; + }; + + mailConfigs: { + name: string; + enabled: boolean; + fields: { + email_auth: boolean; + mailer_smtp_url: string; + mailer_from_address: string; + mailer_smtp_host: string; + mailer_smtp_port: string; + mailer_smtp_user: string; + mailer_smtp_password: string; + mailer_smtp_secure: boolean; + mailer_smtp_ignore_tls: boolean; + mailer_tls_reject_unauthorized: boolean; + mailer_use_custom_configs: boolean; + mailer_smtp_auth_type: string; + mailer_smtp_oauth2_user: string; + mailer_smtp_oauth2_client_id: string; + mailer_smtp_oauth2_client_secret: string; + mailer_smtp_oauth2_refresh_token: string; + mailer_smtp_oauth2_access_url: string; + }; + }; + + tokenConfigs: { + name: string; + fields: { + jwt_secret: string; + token_salt_complexity: string; + magic_link_token_validity: string; + refresh_token_validity: string; + access_token_validity: string; + session_secret: string; + session_cookie_name: string; + }; + }; + + historyConfig: { + name: string; + enabled: boolean; + }; + + dataSharingConfigs: { + name: string; + enabled: boolean; + }; + + rateLimitConfigs: { + name: string; + fields: { + rate_limit_ttl: string; + rate_limit_max: string; + }; + }; + mockServerConfigs?: { + name: string; + fields: { + mock_server_wildcard_domain: string; + }; + }; + + proxyUrlConfigs: { + name: string; + fields: { + proxy_app_url: string; + }; + }; +}; + +export type UpdatedConfigs = { + name: InfraConfigEnum; + value: string; +}; + +export type ConfigTransform = { + config: Config[]; + enabled?: boolean; + fields?: Record | string; +}; + +export type ConfigSection = { + name: SsoAuthProviders | string; + enabled?: boolean; + fields: Record; +}; + +export type Config = { + name: InfraConfigEnum; + key: string; + // Marks fields that are optional and should be excluded from mandatory validation + optional?: boolean; + // Marks free-form secret strings (e.g. JWT/session secrets). Validated as + // non-empty only — never coerced to a number — so a numeric-looking secret + // like "0" or "1.5" stays valid (the backend stores these as opaque strings). + secret?: boolean; +}; + +export const GOOGLE_CONFIGS: Config[] = [ + { + name: InfraConfigEnum.GoogleClientId, + key: 'client_id', + }, + { + name: InfraConfigEnum.GoogleClientSecret, + key: 'client_secret', + }, + { + name: InfraConfigEnum.GoogleCallbackUrl, + key: 'callback_url', + }, + { + name: InfraConfigEnum.GoogleScope, + key: 'scope', + }, +]; + +export const MICROSOFT_CONFIGS: Config[] = [ + { + name: InfraConfigEnum.MicrosoftClientId, + key: 'client_id', + }, + { + name: InfraConfigEnum.MicrosoftClientSecret, + key: 'client_secret', + }, + { + name: InfraConfigEnum.MicrosoftCallbackUrl, + key: 'callback_url', + }, + { + name: InfraConfigEnum.MicrosoftScope, + key: 'scope', + }, + { + name: InfraConfigEnum.MicrosoftTenant, + key: 'tenant', + }, +]; + +export const GITHUB_CONFIGS: Config[] = [ + { + name: InfraConfigEnum.GithubClientId, + key: 'client_id', + }, + { + name: InfraConfigEnum.GithubClientSecret, + key: 'client_secret', + }, + { + name: InfraConfigEnum.GithubCallbackUrl, + key: 'callback_url', + }, + { + name: InfraConfigEnum.GithubScope, + key: 'scope', + }, +]; + +export const MAIL_CONFIGS: Config[] = [ + { + name: InfraConfigEnum.MailerSmtpUrl, + key: 'mailer_smtp_url', + }, + { + name: InfraConfigEnum.MailerAddressFrom, + key: 'mailer_from_address', + }, + { + name: InfraConfigEnum.MailerSmtpEnable, + key: 'mailer_smtp_enabled', + }, + { + name: InfraConfigEnum.MailerUseCustomConfigs, + key: 'mailer_use_custom_configs', + }, +]; + +export const CUSTOM_MAIL_CONFIGS: Config[] = [ + { + name: InfraConfigEnum.MailerSmtpHost, + key: 'mailer_smtp_host', + }, + { + name: InfraConfigEnum.MailerSmtpPort, + key: 'mailer_smtp_port', + }, + { + name: InfraConfigEnum.MailerSmtpUser, + key: 'mailer_smtp_user', + }, + { + name: InfraConfigEnum.MailerSmtpPassword, + key: 'mailer_smtp_password', + }, + { + name: InfraConfigEnum.MailerSmtpSecure, + key: 'mailer_smtp_secure', + }, + { + name: InfraConfigEnum.MailerSmtpIgnoreTls, + key: 'mailer_smtp_ignore_tls', + }, + { + name: InfraConfigEnum.MailerTlsRejectUnauthorized, + key: 'mailer_tls_reject_unauthorized', + }, + { + name: InfraConfigEnum.MailerSmtpAuthType, + key: 'mailer_smtp_auth_type', + }, + { + name: InfraConfigEnum.MailerSmtpOauth2User, + key: 'mailer_smtp_oauth2_user', + }, + { + name: InfraConfigEnum.MailerSmtpOauth2ClientId, + key: 'mailer_smtp_oauth2_client_id', + }, + { + name: InfraConfigEnum.MailerSmtpOauth2ClientSecret, + key: 'mailer_smtp_oauth2_client_secret', + }, + { + name: InfraConfigEnum.MailerSmtpOauth2RefreshToken, + key: 'mailer_smtp_oauth2_refresh_token', + }, + { + name: InfraConfigEnum.MailerSmtpOauth2AccessUrl, + key: 'mailer_smtp_oauth2_access_url', + }, +]; + +const DATA_SHARING_CONFIGS: Omit[] = [ + { + name: InfraConfigEnum.AllowAnalyticsCollection, + }, +]; + +export const HISTORY_STORE_CONFIG: Config[] = [ + { + name: InfraConfigEnum.UserHistoryStoreEnabled, + key: 'history_store_enabled', + }, +]; + +export const RATE_LIMIT_CONFIGS: Config[] = [ + { + name: InfraConfigEnum.RateLimitTtl, + key: 'rate_limit_ttl', + }, + { + name: InfraConfigEnum.RateLimitMax, + key: 'rate_limit_max', + }, +]; + +export const TOKEN_VALIDATION_CONFIGS: Config[] = [ + { + name: InfraConfigEnum.JwtSecret, + key: 'jwt_secret', + secret: true, + }, + { + name: InfraConfigEnum.SessionSecret, + key: 'session_secret', + secret: true, + }, + { + name: InfraConfigEnum.SessionCookieName, + key: 'session_cookie_name', + optional: true, + }, + { + name: InfraConfigEnum.TokenSaltComplexity, + key: 'token_salt_complexity', + }, + { + name: InfraConfigEnum.MagicLinkTokenValidity, + key: 'magic_link_token_validity', + }, + { + name: InfraConfigEnum.RefreshTokenValidity, + key: 'refresh_token_validity', + }, + { + name: InfraConfigEnum.AccessTokenValidity, + key: 'access_token_validity', + }, +]; + +export const MOCK_SERVER_CONFIGS: Config[] = [ + { + name: InfraConfigEnum.MockServerWildcardDomain, + key: 'mock_server_wildcard_domain', + }, +]; + +export const PROXY_URL_CONFIGS: Config[] = [ + { + name: InfraConfigEnum.ProxyAppUrl, + key: 'proxy_app_url', + }, +]; + +// Mirrors the backend validateUrl regex (packages/hoppscotch-backend/src/utils.ts). +// Keep these in sync — the backend rejects PROXY_APP_URL values that don't match. +export const PROXY_URL_REGEX = /^(http|https):\/\/[^ "]+$/; + +export const isValidProxyUrl = (value: string): boolean => + PROXY_URL_REGEX.test(value); + +// Mirrors the backend cookie-name validation. +export const SESSION_COOKIE_NAME_REGEX = /^[A-Za-z0-9_-]+$/; + +export const isValidSessionCookieName = (value: string): boolean => + SESSION_COOKIE_NAME_REGEX.test(value); + +export const ALL_CONFIGS = [ + GOOGLE_CONFIGS, + MICROSOFT_CONFIGS, + GITHUB_CONFIGS, + MAIL_CONFIGS, + CUSTOM_MAIL_CONFIGS, + DATA_SHARING_CONFIGS, + HISTORY_STORE_CONFIG, + RATE_LIMIT_CONFIGS, + TOKEN_VALIDATION_CONFIGS, + MOCK_SERVER_CONFIGS, + PROXY_URL_CONFIGS, +]; + +// Token fields that are optional and excluded from mandatory validation. +export const OPTIONAL_TOKEN_FIELD_KEYS = new Set( + TOKEN_VALIDATION_CONFIGS.filter((cfg) => cfg.optional).map((cfg) => cfg.key) +); + +// Token fields that are free-form secrets — validated as non-empty only, never +// numerically (see Config.secret). Everything else in the section is numeric. +export const TOKEN_SECRET_FIELD_KEYS = new Set( + TOKEN_VALIDATION_CONFIGS.filter((cfg) => cfg.secret).map((cfg) => cfg.key) +); + +export const isFieldEmpty = (field: string | boolean | number): boolean => { + if (typeof field === 'boolean' || typeof field === 'number') return false; + return field.trim() === ''; +}; + +// Rate-limit rule: the backend requires positive integers (>= 1) for both +// RATE_LIMIT_TTL and RATE_LIMIT_MAX, so blanks, non-numerics, decimals, zero, +// and negatives are all invalid. Boolean short-circuits to valid to keep the +// shared field-union signature (these fields are always numeric in practice). +export const isNotValidNumber = (field: string | boolean | number): boolean => { + if (typeof field === 'boolean') return false; + const num = typeof field === 'number' ? field : Number(field.trim()); + if (!Number.isFinite(num)) return true; + return !Number.isInteger(num) || num < 1; +}; + +// Single source of truth for config validation. `getConfigValidationIssues` drives +// the save guard, tab dots, field borders, and blocked-save console. +// Add a field: render it and wire `isConfigFieldErrored` from `useConfigValidation()` — no edits here, the section loops are generic. +// Add a section/tab: push from a new block below, extend `ConfigSectionId`/`ConfigTab`, and add `:indicator` in `settings.vue`. + +export type ConfigTab = 'auth' | 'smtp' | 'proxy' | 'rate-limit'; +export type ConfigSubTab = 'auth-providers' | 'token'; +export type ConfigSectionId = + | SsoAuthProviders + | 'email' + | 'token' + | 'rate_limit' + | 'proxy'; + +// What's wrong with a value; maps via GUARD_BY_KIND to its save guard / toast. +export type ConfigIssueKind = + | 'empty' // required field left blank + | 'invalid-number' // numeric field present but not a positive integer (>= 1) + | 'invalid-format' // value present but malformed (e.g. proxy / SMTP URL) + | 'incomplete'; // interdependent pair only half-filled (SMTP user/pass) + +export type ConfigGuard = 'required' | 'format' | 'smtp-pair'; + +// `invalid-number` sits with `invalid-format` under the `format` guard, not +// `required`: the field isn't empty, it holds a bad value, so it should tip the +// "invalid values" toast rather than "fill all the fields". Only truly blank +// fields (`empty`) map to `required`. +const GUARD_BY_KIND: Record = { + empty: 'required', + 'invalid-number': 'format', + 'invalid-format': 'format', + incomplete: 'smtp-pair', +}; + +export type ConfigValidationIssue = { + tab: ConfigTab; + subTab?: ConfigSubTab; + section: ConfigSectionId; + fieldKey: string; + envVar: string; // matching InfraConfig env var, e.g. PROXY_APP_URL + kind: ConfigIssueKind; +}; + +const lookupEnvVar = (configs: Config[], key: string): string => + configs.find((cfg) => cfg.key === key)?.name ?? ''; + +const emptyOrInvalidNumber = ( + value: string | boolean | number +): ConfigIssueKind => + typeof value === 'string' && value.trim() === '' + ? 'empty' + : 'invalid-number'; + +const PROVIDER_CONFIGS: Record = { + google: GOOGLE_CONFIGS, + github: GITHUB_CONFIGS, + microsoft: MICROSOFT_CONFIGS, +}; + +export const getConfigValidationIssues = ( + config: ServerConfigs +): ConfigValidationIssue[] => { + const issues: ConfigValidationIssue[] = []; + + // SSO providers → auth › auth-providers + // Only validate enabled providers — a disabled provider with blank fields + // is intentional config and must not block saves elsewhere. + (Object.keys(PROVIDER_CONFIGS) as SsoAuthProviders[]).forEach((name) => { + const provider = config.providers[name]; + if (!provider.enabled) return; + + Object.entries(provider.fields).forEach(([fieldKey, value]) => { + if (isFieldEmpty(value)) { + issues.push({ + tab: 'auth', + subTab: 'auth-providers', + section: name, + fieldKey, + envVar: lookupEnvVar(PROVIDER_CONFIGS[name], fieldKey), + kind: 'empty', + }); + } + }); + }); + + // Mail / SMTP → smtp + // Only runs when SMTP is enabled. Three concerns are checked separately: + // 1. required-empty (generic loop, mode-aware exclude list) + // 2. URL format (basic mode only — smtp:// or smtps://) + // 3. SMTP user/password pair completeness (custom mode) + const mail = config.mailConfigs; + if (mail.enabled) { + const useCustom = mail.fields.mailer_use_custom_configs; + // user/password are optional but only as a pair — see (3) below. + const optionalMailerKeys = ['mailer_smtp_user', 'mailer_smtp_password']; + // OAuth2 is opt-in; these never block a save on their own. + const oauth2Keys = [ + 'mailer_smtp_auth_type', + 'mailer_smtp_oauth2_user', + 'mailer_smtp_oauth2_client_id', + 'mailer_smtp_oauth2_client_secret', + 'mailer_smtp_oauth2_refresh_token', + 'mailer_smtp_oauth2_access_url', + ]; + // Required-field set depends on mode: + // custom-ON → host + port + from_address (URL excluded; not used) + // custom-OFF → URL + from_address (host/port/user/password excluded) + // OAuth2 fields are excluded in both modes (always optional). + const excludeKeys = useCustom + ? ['mailer_smtp_url', ...optionalMailerKeys, ...oauth2Keys] + : [ + 'mailer_smtp_host', + 'mailer_smtp_port', + 'mailer_smtp_user', + 'mailer_smtp_password', + ...oauth2Keys, + ]; + + // (1) Required-empty: iterate every mail field and flag the non-excluded + // ones that are blank. `mailer_use_custom_configs` is a toggle (not a + // validatable field); `isFieldEmpty` is a no-op for booleans so TLS/secure + // flags pass through without comment. + Object.entries(mail.fields).forEach(([fieldKey, value]) => { + if (fieldKey === 'mailer_use_custom_configs') return; + if (excludeKeys.includes(fieldKey)) return; + if (isFieldEmpty(value)) { + issues.push({ + tab: 'smtp', + section: 'email', + fieldKey, + envVar: lookupEnvVar( + [...MAIL_CONFIGS, ...CUSTOM_MAIL_CONFIGS], + fieldKey + ), + kind: 'empty', + }); + } + }); + + // (2) URL format — basic mode only. nodemailer's `createTransport` accepts + // smtp:// or smtps:// only. In custom mode the URL is hidden and never sent + // (see the transform in useConfigHandler), so a stale/malformed value must + // not block the save. Emptiness is owned by the required-empty loop above; + // flagging empty here too would double-classify and tip the wrong toast. + const smtpUrl = mail.fields.mailer_smtp_url; + if ( + !useCustom && + !isFieldEmpty(smtpUrl) && + !smtpUrl.startsWith('smtp://') && + !smtpUrl.startsWith('smtps://') + ) { + issues.push({ + tab: 'smtp', + section: 'email', + fieldKey: 'mailer_smtp_url', + envVar: lookupEnvVar(MAIL_CONFIGS, 'mailer_smtp_url'), + kind: 'invalid-format', + }); + } + + // (3) SMTP user/password must be provided together or not at all — the + // backend (validateSmtpCredentialPair) rejects a half-filled pair with + // INFRA_CONFIG_INVALID_INPUT, so mirror it here to surface the precise + // toast instead of a generic backend error. Only relevant in custom mode + // (basic mode embeds credentials in the URL). + if (useCustom) { + const hasUser = mail.fields.mailer_smtp_user.trim() !== ''; + const hasPass = mail.fields.mailer_smtp_password.trim() !== ''; + if (hasUser !== hasPass) { + const missingKey = hasUser + ? 'mailer_smtp_password' + : 'mailer_smtp_user'; + issues.push({ + tab: 'smtp', + section: 'email', + fieldKey: missingKey, + envVar: lookupEnvVar(CUSTOM_MAIL_CONFIGS, missingKey), + kind: 'incomplete', + }); + } + } + } + + // Token validity → auth › token + // Mixed-shape section validated per field identity (not value shape): + // - secret strings (jwt_secret, session_secret) only need to be non-empty, + // so a numeric-looking secret like "0" or "1.5" stays valid; + // - the numeric fields (salt complexity, token validities) must be positive + // integers (>= 1) to match the backend. + // `session_cookie_name` is format-validated separately below. + Object.entries(config.tokenConfigs.fields).forEach(([fieldKey, value]) => { + if (OPTIONAL_TOKEN_FIELD_KEYS.has(fieldKey)) return; + const invalid = TOKEN_SECRET_FIELD_KEYS.has(fieldKey) + ? isFieldEmpty(value) + : isNotValidNumber(value); + if (invalid) { + issues.push({ + tab: 'auth', + subTab: 'token', + section: 'token', + fieldKey, + envVar: lookupEnvVar(TOKEN_VALIDATION_CONFIGS, fieldKey), + kind: emptyOrInvalidNumber(value), + }); + } + }); + + // Empty falls back to the backend default; only flag malformed non-empty. + const sessionCookieName = + config.tokenConfigs.fields.session_cookie_name ?? ''; + if ( + !isFieldEmpty(sessionCookieName) && + !isValidSessionCookieName(sessionCookieName) + ) { + issues.push({ + tab: 'auth', + subTab: 'token', + section: 'token', + fieldKey: 'session_cookie_name', + envVar: lookupEnvVar(TOKEN_VALIDATION_CONFIGS, 'session_cookie_name'), + kind: 'invalid-format', + }); + } + + // Rate limit → rate-limit + // Both fields are unconditionally required numerics — no enable toggle. + Object.entries(config.rateLimitConfigs.fields).forEach(([fieldKey, value]) => { + if (isNotValidNumber(value)) { + issues.push({ + tab: 'rate-limit', + section: 'rate_limit', + fieldKey, + envVar: lookupEnvVar(RATE_LIMIT_CONFIGS, fieldKey), + kind: emptyOrInvalidNumber(value), + }); + } + }); + + // Proxy URL → proxy + // Required unconditionally (no enable toggle). The if/else ordering matters: + // an empty value also fails `isValidProxyUrl`, so we classify it as 'empty' + // first so it tips the input_empty toast (same priority as pre-refactor), + // not the input_validation_error toast. + const proxyUrl = config.proxyUrlConfigs.fields.proxy_app_url ?? ''; + if (isFieldEmpty(proxyUrl)) { + issues.push({ + tab: 'proxy', + section: 'proxy', + fieldKey: 'proxy_app_url', + envVar: lookupEnvVar(PROXY_URL_CONFIGS, 'proxy_app_url'), + kind: 'empty', + }); + } else if (!isValidProxyUrl(proxyUrl)) { + issues.push({ + tab: 'proxy', + section: 'proxy', + fieldKey: 'proxy_app_url', + envVar: lookupEnvVar(PROXY_URL_CONFIGS, 'proxy_app_url'), + kind: 'invalid-format', + }); + } + + return issues; +}; + +// True when any issue maps to the given save guard. +export const hasGuardIssue = ( + issues: ConfigValidationIssue[], + guard: ConfigGuard +): boolean => issues.some((issue) => GUARD_BY_KIND[issue.kind] === guard); + +// Proactive — drives indicator dots without waiting for a save attempt. +export const tabHasConfigIssue = ( + issues: ConfigValidationIssue[], + tab: ConfigTab, + subTab?: ConfigSubTab +): boolean => + issues.some( + (issue) => issue.tab === tab && (!subTab || issue.subTab === subTab) + ); + +/* Provide/inject (not module singleton) so an unscoped consumer throws loudly instead of + reading empty state. `configEdited` mirrors `settings.vue`'s `isConfigUpdated` (gates borders + to typing-time); `configValidationIssues` is rebuilt by its deep watch on `workingConfigs`. */ +export type ConfigValidationContext = { + configEdited: Ref; + configValidationIssues: Ref; +}; + +const CONFIG_VALIDATION_KEY: InjectionKey = + Symbol('config-validation'); + +// Called once by the owner (settings.vue); returns the refs so it can drive +// its watchers. +export const provideConfigValidation = (): ConfigValidationContext => { + const context: ConfigValidationContext = { + configEdited: ref(false), + configValidationIssues: ref([]), + }; + provide(CONFIG_VALIDATION_KEY, context); + return context; +}; + +// Called by any descendant; throws when no provider is above it, so a consumer +// outside settings.vue fails loudly instead of silently reading empty state. +export const useConfigValidation = () => { + const context = inject(CONFIG_VALIDATION_KEY); + if (!context) { + throw new Error( + 'useConfigValidation() must be used under a component that called ' + + 'provideConfigValidation() (settings.vue). These config components read ' + + 'validation state from that provider and cannot be mounted standalone.' + ); + } + + // Border lights up once the form is edited and the field has a live issue. + const isConfigFieldErrored = (section: ConfigSectionId, fieldKey: string) => + context.configEdited.value && + context.configValidationIssues.value.some( + (issue) => issue.section === section && issue.fieldKey === fieldKey + ); + + return { ...context, isConfigFieldErrored }; +}; diff --git a/packages/hoppscotch-sh-admin/src/helpers/errors.ts b/packages/hoppscotch-sh-admin/src/helpers/errors.ts new file mode 100644 index 0000000..71a3aa8 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/errors.ts @@ -0,0 +1,90 @@ +/* + * Type used to send error data to the Fallback catch-all component + */ +export type ErrorPageData = { + message: string; + statusCode?: number; +}; + +/* No cookies were found in the auth request + * (AuthService) + */ +export const COOKIES_NOT_FOUND = '[GraphQL] auth/cookies_not_found' as const; + +export const UNAUTHORIZED = 'Unauthorized' as const; + +// Sometimes the backend returns Unauthorized error message as follows: +export const GRAPHQL_UNAUTHORIZED = '[GraphQL] Unauthorized' as const; + +// When the email is invalid +export const INVALID_EMAIL = '[GraphQL] invalid/email' as const; + +// When trying to remove the only admin account +export const ONLY_ONE_ADMIN_ACCOUNT_FOUND = + '[GraphQL] admin/only_one_admin_account_found' as const; + +// When trying to delete an admin account +export const ADMIN_CANNOT_BE_DELETED = + 'admin/admin_can_not_be_deleted' as const; + +// When trying to invite a user that is already invited +export const USER_ALREADY_INVITED = + '[GraphQL] admin/user_already_invited' as const; + +// When attempting to delete a user who is an owner of a team +export const USER_IS_OWNER = 'user/is_owner'; + +// When attempting to delete a user who is the only owner of a team +export const TEAM_ONLY_ONE_OWNER = '[GraphQL] team/only_one_owner'; + +// Even one auth provider is not specified +export const AUTH_PROVIDER_NOT_SPECIFIED = + '[GraphQL] auth/provider_not_specified' as const; + +export const BOTH_EMAILS_CANNOT_BE_SAME = + '[GraphQL] email/both_emails_cannot_be_same' as const; + +export const INFRA_TOKEN_LABEL_SHORT = + '[GraphQL] infra_token/label_too_short' as const; + +type ErrorMessages = { + message: string; + alternateMessage?: string; +}; + +const ERROR_MESSAGES: Record = { + [INVALID_EMAIL]: { + message: 'state.invalid_email', + }, + [ONLY_ONE_ADMIN_ACCOUNT_FOUND]: { + message: 'state.remove_admin_failure_only_one_admin', + }, + [ADMIN_CANNOT_BE_DELETED]: { + message: 'state.remove_admin_to_delete_user', + alternateMessage: 'state.remove_admin_for_deletion', + }, + [USER_ALREADY_INVITED]: { + message: 'state.user_already_invited', + }, + [USER_IS_OWNER]: { + message: 'state.remove_owner_to_delete_user', + alternateMessage: 'state.remove_owner_for_deletion', + }, + [TEAM_ONLY_ONE_OWNER]: { + message: 'state.remove_owner_failure_only_one_owner', + }, + [AUTH_PROVIDER_NOT_SPECIFIED]: { + message: 'configs.auth_providers.provider_not_specified', + }, + [BOTH_EMAILS_CANNOT_BE_SAME]: { + message: 'state.emails_cannot_be_same', + }, + [INFRA_TOKEN_LABEL_SHORT]: { + message: 'state.infra_token_label_short', + }, +}; + +export const getCompiledErrorMessage = (name: string, altMessage = false) => { + const error = ERROR_MESSAGES[name]; + return altMessage ? error?.alternateMessage ?? '' : error?.message ?? ''; +}; diff --git a/packages/hoppscotch-sh-admin/src/helpers/localpersistence.ts b/packages/hoppscotch-sh-admin/src/helpers/localpersistence.ts new file mode 100644 index 0000000..52407a2 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/localpersistence.ts @@ -0,0 +1,27 @@ +/** + * Gets a value in LocalStorage. + * + * NOTE: Use LocalStorage to only store non-reactive simple data + * For more complex data, use stores and connect it to localpersistence + */ +export function getLocalConfig(name: string) { + return window.localStorage.getItem(name); +} + +/** + * Sets a value in LocalStorage. + * + * NOTE: Use LocalStorage to only store non-reactive simple data + * For more complex data, use stores and connect it to localpersistence + */ +export function setLocalConfig(key: string, value: string) { + window.localStorage.setItem(key, value); +} + +/** + * Clear config value in LocalStorage. + * @param key Key to be cleared + */ +export function removeLocalConfig(key: string) { + window.localStorage.removeItem(key); +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/retryAuthGuard.ts b/packages/hoppscotch-sh-admin/src/helpers/retryAuthGuard.ts new file mode 100644 index 0000000..4742cf9 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/retryAuthGuard.ts @@ -0,0 +1,82 @@ +/** + * Maximum number of consecutive auth refresh failures before signing out. + * @see https://github.com/hoppscotch/hoppscotch/issues/5885 + */ +const MAX_RETRIES = 3 + +/** + * Creates an auth retry guard that tracks consecutive refresh failures + * and triggers a sign-out after {@link MAX_RETRIES} consecutive failures. + * + * After exhaustion, subsequent calls short-circuit to `false` without + * invoking `refreshFn` or `onExhausted` again. Call `reset()` on + * successful login to re-enable refresh attempts. + * + * NOTE: This is a copy of `@hoppscotch/common/helpers/retryAuthGuard.ts`. + * `sh-admin` cannot depend on `@hoppscotch/common`, so the utility is + * duplicated here. Keep both copies in sync. + * + * @see https://github.com/hoppscotch/hoppscotch/issues/5885 + */ +export function createAuthRetryGuard(onExhausted: () => void | Promise) { + let failCount = 0 + let isExhausted = false + let exhaustionPromise: Promise | null = null + + return { + /** + * Wraps an auth refresh attempt with retry tracking. + * Resets on success. Calls `onExhausted` after {@link MAX_RETRIES} + * consecutive failures and stays exhausted until `reset()` is called. + */ + async execute(refreshFn: () => Promise): Promise { + // isExhausted covers the normal path; failCount >= MAX_RETRIES covers + // the concurrent-call edge case where two callers both passed the check + // at failCount = 2 before either could set isExhausted = true. + if (isExhausted || failCount >= MAX_RETRIES) { + return false + } + + let success: boolean + try { + success = await refreshFn() + } catch (_) { + // Treat thrown errors (network failures, etc.) as a failed refresh + // so they count toward exhaustion and don't bypass the guard. + success = false + } + + if (success) { + failCount = 0 + return true + } + + failCount++ + if (failCount >= MAX_RETRIES && !isExhausted) { + isExhausted = true + try { + exhaustionPromise = Promise.resolve().then(() => onExhausted()) + await exhaustionPromise + } catch (_) { + // Sign-out failed (e.g. network error), but the guard stays + // exhausted so we don't re-enter the refresh loop. + } finally { + exhaustionPromise = null + } + } + + return false + }, + + /** + * Reset the failure counter (e.g. on login or manual logout). + * No-op while an exhaustion callback (sign-out) is still in-flight. + */ + reset() { + if (exhaustionPromise) return + + failCount = 0 + isExhausted = false + }, + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/userManagement.ts b/packages/hoppscotch-sh-admin/src/helpers/userManagement.ts new file mode 100644 index 0000000..0866172 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/userManagement.ts @@ -0,0 +1,115 @@ +import { useToast } from '~/composables/toast'; +import { getI18n } from '~/modules/i18n'; +import { UserDeletionResult } from './backend/graphql'; +import { + ADMIN_CANNOT_BE_DELETED, + USER_IS_OWNER, + getCompiledErrorMessage, +} from './errors'; + +type ToastMessage = { + message: string; + state: 'success' | 'error'; +}; + +const t = getI18n(); +const toast = useToast(); + +const displayToastMessages = ( + toastMessages: ToastMessage[], + currentIndex: number +) => { + const { message, state } = toastMessages[currentIndex]; + + toast[state](message, { + duration: 2000, + onComplete: () => { + if (currentIndex < toastMessages.length - 1) { + displayToastMessages(toastMessages, currentIndex + 1); + } + }, + }); +}; + +export const handleUserDeletion = (deletedUsersList: UserDeletionResult[]) => { + const uniqueErrorMessages = new Set( + deletedUsersList.map(({ errorMessage }) => errorMessage).filter(Boolean) + ) as Set; + + const isBulkAction = deletedUsersList.length > 1; + + const deletedUserIDs = deletedUsersList + .filter((user) => user.isDeleted) + .map((user) => user.userUID); + + // Show the success toast based on the action type if there are no errors + if (uniqueErrorMessages.size === 0) { + toast.success( + isBulkAction + ? t('state.delete_users_success') + : t('state.delete_user_success') + ); + return; + } + + const errMsgMap = { + [ADMIN_CANNOT_BE_DELETED]: t( + getCompiledErrorMessage(ADMIN_CANNOT_BE_DELETED, isBulkAction) + ), + [USER_IS_OWNER]: t(getCompiledErrorMessage(USER_IS_OWNER, isBulkAction)), + }; + + const errMsgMapKeys = Object.keys(errMsgMap); + + const toastMessages: ToastMessage[] = []; + + if (isBulkAction) { + // Indicates the actual count of users deleted (filtered via the `isDeleted` field) + const deletedUsersCount = deletedUserIDs.length; + + if (deletedUsersCount > 0) { + toastMessages.push({ + message: t('state.delete_some_users_success', { + count: deletedUsersCount, + }), + state: 'success', + }); + } + const remainingDeletionsCount = deletedUsersList.length - deletedUsersCount; + if (remainingDeletionsCount > 0) { + toastMessages.push({ + message: t('state.delete_some_users_failure', { + count: remainingDeletionsCount, + }), + state: 'error', + }); + } + } + + uniqueErrorMessages.forEach((errorMessage) => { + if (errMsgMapKeys.includes(errorMessage)) { + toastMessages.push({ + message: errMsgMap[errorMessage as keyof typeof errMsgMap], + state: 'error', + }); + } + }); + + // Fallback for the case where the error message is not in the compiled list + if ( + Array.from(uniqueErrorMessages).some( + (key) => !((key as string) in errMsgMap) + ) + ) { + const fallbackErrMsg = isBulkAction + ? t('state.delete_users_failure') + : t('state.delete_user_failure'); + + toastMessages.push({ + message: fallbackErrMsg, + state: 'error', + }); + } + + displayToastMessages(toastMessages, 0); +}; diff --git a/packages/hoppscotch-sh-admin/src/helpers/utils/clipboard.ts b/packages/hoppscotch-sh-admin/src/helpers/utils/clipboard.ts new file mode 100644 index 0000000..3d7944f --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/utils/clipboard.ts @@ -0,0 +1,18 @@ +/** + * Copies a given string to the clipboard using + * the legacy exec method + * + * @param content The content to be copied + */ +export function copyToClipboard(content: string) { + if (navigator.clipboard) { + navigator.clipboard.writeText(content); + } else { + const dummy = document.createElement('textarea'); + document.body.appendChild(dummy); + dummy.value = content; + dummy.select(); + document.execCommand('copy'); + document.body.removeChild(dummy); + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/utils/date.ts b/packages/hoppscotch-sh-admin/src/helpers/utils/date.ts new file mode 100644 index 0000000..ee7b8d3 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/utils/date.ts @@ -0,0 +1,17 @@ +export function shortDateTime( + date: string | number | Date, + includeTime: boolean = true +) { + return new Date(date).toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + ...(includeTime + ? { + hour: "numeric", + minute: "numeric", + second: "numeric", + } + : {}), + }) +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/utils/readableKey.ts b/packages/hoppscotch-sh-admin/src/helpers/utils/readableKey.ts new file mode 100644 index 0000000..6f7cc31 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/utils/readableKey.ts @@ -0,0 +1,20 @@ +/** + * The makeReadableKey function formats a string to be more human-readable. + * It replaces underscores with spaces, converts it to lowercase, + * and capitalizes the first letter. + * @param string The string to be formatted + * @returns A human-readable version of the string + */ +export const makeReadableKey = (string: string, capitalizeAll?: boolean) => { + if (!string) return ''; + const val = string.replace(/_/g, ' ').toLocaleLowerCase(); + + if (capitalizeAll) { + return val + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + return String(val).charAt(0).toUpperCase() + String(val).slice(1); +}; diff --git a/packages/hoppscotch-sh-admin/src/layouts/default.vue b/packages/hoppscotch-sh-admin/src/layouts/default.vue new file mode 100644 index 0000000..f0c414d --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/layouts/default.vue @@ -0,0 +1,15 @@ + diff --git a/packages/hoppscotch-sh-admin/src/layouts/empty.vue b/packages/hoppscotch-sh-admin/src/layouts/empty.vue new file mode 100644 index 0000000..a14d0c3 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/layouts/empty.vue @@ -0,0 +1,5 @@ + diff --git a/packages/hoppscotch-sh-admin/src/main.ts b/packages/hoppscotch-sh-admin/src/main.ts new file mode 100644 index 0000000..75909af --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/main.ts @@ -0,0 +1,88 @@ +import { authExchange } from '@urql/exchange-auth'; +import urql, { cacheExchange, createClient, fetchExchange } from '@urql/vue'; +import { createApp, h } from 'vue'; +import * as O from 'fp-ts/Option'; +import App from './App.vue'; +import ErrorComponent from './pages/_.vue'; + +// STYLES +import '@fontsource-variable/inter'; +import '@fontsource-variable/material-symbols-rounded'; +import '@fontsource-variable/roboto-mono'; +import '@hoppscotch/ui/style.css'; +import '../assets/scss/styles.scss'; +import '../assets/scss/tailwind.scss'; +// END STYLES + +import { auth, authEvents$ } from './helpers/auth'; +import { GRAPHQL_UNAUTHORIZED } from './helpers/errors'; +import { createAuthRetryGuard } from './helpers/retryAuthGuard'; +import { HOPP_MODULES } from './modules'; + +/** + * Auth retry guard — prevents infinite refreshAuth loops when tokens + * are permanently invalid. Stays exhausted until the page reloads. + * @see https://github.com/hoppscotch/hoppscotch/issues/5885 + */ +const authRetryGuard = createAuthRetryGuard(() => auth.signOutUser(true)); + +authEvents$.subscribe((event) => { + if (event.event === 'login') { + authRetryGuard.reset(); + } +}); + +(async () => { + try { + // Create URQL client + const urqlClient = createClient({ + url: import.meta.env.VITE_BACKEND_GQL_URL, + requestPolicy: 'network-only', + fetchOptions: () => ({ + credentials: 'include', + }), + exchanges: [ + cacheExchange, + authExchange(async () => ({ + addAuthToOperation(operation) { + return operation; + }, + async refreshAuth() { + await authRetryGuard.execute(async () => { + const result = await auth.performAuthRefresh(); + return O.isSome(result); + }); + }, + didAuthError(error, _operation) { + return error.message === GRAPHQL_UNAUTHORIZED; + }, + })), + fetchExchange, + ], + preferGetMethod: false, + }); + + // Initialize auth + await auth.performAuthInit(); + + const app = createApp({ + render: () => h(App), + }).use(urql, urqlClient); + + // Initialize modules + HOPP_MODULES.forEach((mod) => mod.onVueAppInit?.(app)); + + app.mount('#app'); + } catch (error) { + // Mount the fallback component in case of an error + createApp({ + render: () => + h(ErrorComponent, { + error: { + message: + 'Failed to connect to the backend server, make sure the backend is setup correctly', + }, + }), + }).mount('#app'); + } +})(); diff --git a/packages/hoppscotch-sh-admin/src/modules/admin.ts b/packages/hoppscotch-sh-admin/src/modules/admin.ts new file mode 100644 index 0000000..12ec449 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/modules/admin.ts @@ -0,0 +1,107 @@ +import { auth } from '~/helpers/auth'; +import { UNAUTHORIZED } from '~/helpers/errors'; +import { HoppModule } from '.'; + +const isSetupRoute = (to: unknown) => to === 'setup'; + +const isGuestRoute = (to: unknown) => + ['index', 'enter', 'onboarding'].includes(to as string); + +const getFirstTimeInfraSetupStatus = async () => { + const isInfraNotSetup = await auth.getFirstTimeInfraSetupStatus(); + return isInfraNotSetup; +}; + +/** + * @module routers + */ + +/** + * @function + * @name onBeforeRouteChange + * @param {object} to + * @param {object} from + * @param {function} next + * @returns {void} + */ +export default { + async onBeforeRouteChange(to, _from, next) { + // Check if onboarding is completed + const onboardingStatus = await auth.getOnboardingStatus(); + + if ( + !onboardingStatus?.onboardingCompleted && + to.name !== 'onboarding' && + to.name === 'index' + ) { + // If onboarding is not completed, redirect to the onboarding page + return next({ name: 'onboarding' }); + } + + const res = await auth.getUserDetails(); + + // Allow performing the silent refresh flow for an invalid access token state + if (res.errors?.[0].message === UNAUTHORIZED) { + return next(); + } + + if ( + !onboardingStatus?.onboardingCompleted && + !onboardingStatus?.canReRunOnboarding && + to.name !== 'index' && + to.name === 'onboarding' + ) { + return next(); + } + + if ( + onboardingStatus?.onboardingCompleted && + !onboardingStatus.canReRunOnboarding && + to.name === 'onboarding' + ) { + // If onboarding is completed, redirect to the dashboard + return next({ name: 'index' }); + } + + const isAdmin = res.data?.me.isAdmin; + + // Route Guards + if (!isGuestRoute(to.name) && !isAdmin) { + /** + * Reroutes the user to the login page if user is not logged in + * and is not an admin + */ + return next({ name: 'index' }); + } + + if (isAdmin && onboardingStatus?.onboardingCompleted) { + // These route guards applies to the case where the user is logged in successfully and validated as an admin + const isInfraNotSetup = await getFirstTimeInfraSetupStatus(); + + /** + * Reroutes the user to the dashboard homepage if they have setup the infra already + * Else, the Setup page + */ + if (isGuestRoute(to.name)) { + const name = isInfraNotSetup ? 'setup' : 'dashboard'; + return next({ name }); + } + /** + * Reroutes the user to the dashboard homepage if they have setup the infra already + * and are trying to access the setup page + */ + if (isSetupRoute(to.name) && !isInfraNotSetup) { + return next({ name: 'dashboard' }); + } + /** + * Reroutes the user to the setup page if they have not setup the infra yet + * and tries to access a valid route which is not a guest route + */ + if (isInfraNotSetup && !isSetupRoute(to.name)) { + return next({ name: 'setup' }); + } + } + + next(); + }, +}; diff --git a/packages/hoppscotch-sh-admin/src/modules/i18n.ts b/packages/hoppscotch-sh-admin/src/modules/i18n.ts new file mode 100644 index 0000000..a7cd885 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/modules/i18n.ts @@ -0,0 +1,35 @@ +import { I18n, createI18n } from 'vue-i18n'; +import { HoppModule } from '.'; +import messages from '@intlify/unplugin-vue-i18n/messages'; + +// A reference to the i18n instance +let i18nInstance: I18n< + Record, + Record, + Record, + string, + false +> | null = null; + +/** + * Returns the i18n instance + */ +export function getI18n() { + return i18nInstance!.global.t; +} + +export default { + onVueAppInit(app) { + const i18n = createI18n({ + locale: 'en', + messages, + fallbackLocale: 'en', + legacy: false, + allowComposition: true, + }); + + app.use(i18n); + + i18nInstance = i18n; + }, +}; diff --git a/packages/hoppscotch-sh-admin/src/modules/index.ts b/packages/hoppscotch-sh-admin/src/modules/index.ts new file mode 100644 index 0000000..f5abdc9 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/modules/index.ts @@ -0,0 +1,57 @@ +import { App } from 'vue'; +import { pipe } from 'fp-ts/function'; +import * as A from 'fp-ts/Array'; +import { + NavigationGuardNext, + RouteLocationNormalized, + Router, +} from 'vue-router'; + +export type HoppModule = { + /** + * Define this function to get access to Vue App instance and augment + * it (installing components, directives and plugins). Also useful for + * early generic initializations. This function should be called first + */ + onVueAppInit?: (app: App) => void; + + /** + * Called when the router is done initializing. + * Used if a module requires access to the router instance + */ + onRouterInit?: (app: App, router: Router) => void; + + /** + * Called when the root component (App.vue) is running setup. + * This function is generally called last in the lifecycle. + * This function executes with a component setup context, so you can + * run composables within this and it should just be scoped to the + * root component + */ + onRootSetup?: () => void; + + /** + * Called by the router to tell all the modules before a route navigation + * is made. + */ + onBeforeRouteChange?: ( + to: RouteLocationNormalized, + from: RouteLocationNormalized, + next: NavigationGuardNext, + router: Router + ) => void; + + /** + * Called by the router to tell all the modules that a route navigation has completed + */ + onAfterRouteChange?: (to: RouteLocationNormalized, router: Router) => void; +}; + +/** + * All the modules Hoppscotch loads into the app + */ +export const HOPP_MODULES = pipe( + import.meta.glob('@modules/*.ts', { eager: true }), + Object.values, + A.map(({ default: defaultVal }) => defaultVal as HoppModule) +); diff --git a/packages/hoppscotch-sh-admin/src/modules/router.ts b/packages/hoppscotch-sh-admin/src/modules/router.ts new file mode 100644 index 0000000..f7ab4b7 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/modules/router.ts @@ -0,0 +1,72 @@ +import { HoppModule, HOPP_MODULES } from '.'; +import { + createRouter, + createWebHistory, + RouteLocationNormalized, +} from 'vue-router'; +import { setupLayouts } from 'virtual:generated-layouts'; +import generatedRoutes from 'virtual:generated-pages'; +import { readonly, ref } from 'vue'; + +const routes = setupLayouts(generatedRoutes); + +/** + * A reactive value signifying whether we are currently navigating + * into the first route the application is routing into. + * Useful, if you want to do stuff for the initial page load (for example splash screens!) + */ +const _isLoadingInitialRoute = ref(false); + +/** + * Says whether a given route looks like an initial route which + * is loaded as the first route. + * + * NOTE: This function assumes Vue Router represents that initial route + * in the way we expect (fullPath == "/" and name == undefined). If this + * function breaks later on, most probs vue-router updated its semantics + * and we have to correct this function. + */ +function isInitialRoute(route: RouteLocationNormalized) { + return route.fullPath === '/' && route.name === undefined; +} + +/** + * A reactive value signifying whether we are currently navigating + * into the first route the application is routing into. + * Useful, if you want to do stuff for the initial page load (for example splash screens!) + * + * NOTE: This reactive value is READONLY + */ +export const isLoadingInitialRoute = readonly(_isLoadingInitialRoute); + +export default { + onVueAppInit(app) { + const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes, + }); + + router.beforeEach((to, from, next) => { + _isLoadingInitialRoute.value = isInitialRoute(from); + + HOPP_MODULES.forEach((mod) => { + mod.onBeforeRouteChange?.(to, from, next, router); + }); + }); + + // Instead of this a better architecture is for the router + // module to expose a stream of router events that can be independently + // subbed to + router.afterEach((to) => { + _isLoadingInitialRoute.value = false; + + HOPP_MODULES.forEach((mod) => { + mod.onAfterRouteChange?.(to, router); + }); + }); + + app.use(router); + + HOPP_MODULES.forEach((mod) => mod.onRouterInit?.(app, router)); + }, +}; diff --git a/packages/hoppscotch-sh-admin/src/modules/tippy.ts b/packages/hoppscotch-sh-admin/src/modules/tippy.ts new file mode 100644 index 0000000..f50e3c5 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/modules/tippy.ts @@ -0,0 +1,120 @@ +import { HoppModule } from '.'; +import type { DirectiveBinding, VNode } from 'vue'; +import VueTippy, { useTippy, roundArrow, setDefaultProps } from 'vue-tippy'; + +import 'tippy.js/dist/tippy.css'; +import 'tippy.js/animations/scale-subtle.css'; +import 'tippy.js/dist/border.css'; +import 'tippy.js/dist/svg-arrow.css'; + +interface TippyElement extends HTMLElement { + $tippy?: { + props: Record; + setProps: (opts: Record) => void; + destroy: () => void; + }; + _tippy?: { + props: Record; + setProps: (opts: Record) => void; + destroy: () => void; + }; +} + +// Resolves tooltip options from directive binding, vnode props, and DOM attrs. +// Reads from vnode.props first (immune to DOM stripping by parent +// components), then falls back to DOM attributes. +function resolveOpts( + el: TippyElement, + binding: DirectiveBinding, + vnode: VNode, +): Record { + const opts = + typeof binding.value === 'string' + ? { content: binding.value } + : { ...(binding.value || {}) }; + + if (!opts.content) { + const title = vnode.props?.title ?? el.getAttribute('title'); + if (title) { + opts.content = title; + } + } + + if (!opts.content && el.getAttribute('content')) { + opts.content = el.getAttribute('content'); + } + + return opts; +} + +export default { + onVueAppInit(app) { + // Register VueTippy under a noop directive name so it doesn't conflict + // with the custom v-tippy below. + app.use(VueTippy, { directive: 'tippy-original' }); + + // Custom v-tippy directive: reads content from vnode.props instead of + // el.getAttribute('title') to fix tooltips inside wrappers where + // tippy.js strips the title attribute before the child directive reads it. + app.directive('tippy', { + mounted(el: TippyElement, binding: DirectiveBinding, vnode: VNode) { + const opts = resolveOpts(el, binding, vnode); + el.removeAttribute('title'); + // useTippy (not tippy() directly) so setDefaultProps are applied. + // Works outside setup() because vue-tippy v6.x guards getCurrentInstance(). + useTippy(el, opts); + }, + + // Explicit cleanup for v-if removals — useTippy's internal + // onBeforeUnmount only fires on component unmount, not element removal. + unmounted(el: TippyElement) { + if (el._tippy) { + el._tippy.destroy(); + } else if (el.$tippy) { + el.$tippy.destroy(); + } + }, + + // Sync tooltip content when reactive :title bindings change + // (e.g. "Add star" ↔ "Remove star" in History). + updated(el: TippyElement, binding: DirectiveBinding, vnode: VNode) { + const tippy = el.$tippy || el._tippy; + if (!tippy) return; + + const opts = resolveOpts(el, binding, vnode); + + if (!opts.content) { + opts.content = ''; + } + + // Vue re-applies :title on each patch cycle + el.removeAttribute('title'); + + // Skip if content unchanged — setProps is expensive (recreates Popper). + const currentContent = tippy.props?.content; + if (opts.content === currentContent) return; + + tippy.setProps(opts); + }, + }); + + setDefaultProps({ + animation: 'scale-subtle', + appendTo: document.body, + allowHTML: false, + animateFill: false, + arrow: roundArrow + roundArrow, + popperOptions: { + // https://popper.js.org/docs/v2/utils/detect-overflow/ + modifiers: [ + { + name: 'preventOverflow', + options: { + rootBoundary: 'document', + }, + }, + ], + }, + }); + }, +}; diff --git a/packages/hoppscotch-sh-admin/src/modules/toast.ts b/packages/hoppscotch-sh-admin/src/modules/toast.ts new file mode 100644 index 0000000..3708661 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/modules/toast.ts @@ -0,0 +1,19 @@ +import Toasted from '@hoppscotch/vue-toasted'; +import type { ToastOptions } from '@hoppscotch/vue-toasted'; +import { HoppModule } from '.'; + +import '@hoppscotch/vue-toasted/style.css'; + +// We are using a fork of Vue Toasted (github.com/clayzar/vue-toasted) which is a bit of +// an untrusted fork, we will either want to make our own fork or move to a more stable one +// The original Vue Toasted doesn't support Vue 3 and the OP has been irresponsive. + +export default { + onVueAppInit(app) { + app.use(Toasted, { + position: 'bottom-center', + duration: 3000, + keepOnHover: true, + }); + }, +}; diff --git a/packages/hoppscotch-sh-admin/src/modules/ui.ts b/packages/hoppscotch-sh-admin/src/modules/ui.ts new file mode 100644 index 0000000..0c23cb6 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/modules/ui.ts @@ -0,0 +1,13 @@ +import { HoppModule } from '.'; +import { plugin as HoppUI, HoppUIPluginOptions } from '@hoppscotch/ui'; + +const HoppUIOptions: HoppUIPluginOptions = {}; + +export default { + onVueAppInit(app) { + // disable eslint for this line. it's a hack because there's some unknown type error + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + app.use(HoppUI, HoppUIOptions); + }, +}; diff --git a/packages/hoppscotch-sh-admin/src/modules/v-focus.ts b/packages/hoppscotch-sh-admin/src/modules/v-focus.ts new file mode 100644 index 0000000..190a61d --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/modules/v-focus.ts @@ -0,0 +1,15 @@ +import { nextTick } from "vue" +import { HoppModule } from "." + +/* + Declares a `v-focus` directive that can be used for components + to acquire focus instantly once mounted +*/ + +export default { + onVueAppInit(app) { + app.directive("focus", { + mounted: (el) => nextTick(() => el.focus()), + }) + }, +} diff --git a/packages/hoppscotch-sh-admin/src/pages/_.vue b/packages/hoppscotch-sh-admin/src/pages/_.vue new file mode 100644 index 0000000..86d6ebe --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/pages/_.vue @@ -0,0 +1,59 @@ + + + + diff --git a/packages/hoppscotch-sh-admin/src/pages/dashboard.vue b/packages/hoppscotch-sh-admin/src/pages/dashboard.vue new file mode 100644 index 0000000..6604b06 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/pages/dashboard.vue @@ -0,0 +1,64 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/pages/enter.vue b/packages/hoppscotch-sh-admin/src/pages/enter.vue new file mode 100644 index 0000000..7cef955 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/pages/enter.vue @@ -0,0 +1,35 @@ + + + + + +meta: + layout: empty + diff --git a/packages/hoppscotch-sh-admin/src/pages/index.vue b/packages/hoppscotch-sh-admin/src/pages/index.vue new file mode 100644 index 0000000..0087131 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/pages/index.vue @@ -0,0 +1,22 @@ + + + +meta: + layout: empty + diff --git a/packages/hoppscotch-sh-admin/src/pages/onboarding.vue b/packages/hoppscotch-sh-admin/src/pages/onboarding.vue new file mode 100644 index 0000000..403bbe1 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/pages/onboarding.vue @@ -0,0 +1,139 @@ + + + + +meta: + layout: empty + diff --git a/packages/hoppscotch-sh-admin/src/pages/settings.vue b/packages/hoppscotch-sh-admin/src/pages/settings.vue new file mode 100644 index 0000000..f188f12 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/pages/settings.vue @@ -0,0 +1,234 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/pages/setup.vue b/packages/hoppscotch-sh-admin/src/pages/setup.vue new file mode 100644 index 0000000..64af343 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/pages/setup.vue @@ -0,0 +1,40 @@ + + + + + +meta: + layout: empty + diff --git a/packages/hoppscotch-sh-admin/src/pages/teams/_id.vue b/packages/hoppscotch-sh-admin/src/pages/teams/_id.vue new file mode 100644 index 0000000..285d572 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/pages/teams/_id.vue @@ -0,0 +1,138 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/pages/teams/index.vue b/packages/hoppscotch-sh-admin/src/pages/teams/index.vue new file mode 100644 index 0000000..ca3da6b --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/pages/teams/index.vue @@ -0,0 +1,355 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/pages/users/_id.vue b/packages/hoppscotch-sh-admin/src/pages/users/_id.vue new file mode 100644 index 0000000..7881fe1 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/pages/users/_id.vue @@ -0,0 +1,241 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/pages/users/index.vue b/packages/hoppscotch-sh-admin/src/pages/users/index.vue new file mode 100644 index 0000000..a971a46 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/pages/users/index.vue @@ -0,0 +1,660 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/pages/users/invited.vue b/packages/hoppscotch-sh-admin/src/pages/users/invited.vue new file mode 100644 index 0000000..6e58375 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/pages/users/invited.vue @@ -0,0 +1,262 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/shims-vue.d.ts b/packages/hoppscotch-sh-admin/src/shims-vue.d.ts new file mode 100644 index 0000000..87bc1fa --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/shims-vue.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import { defineComponent } from 'vue'; + const Component: ReturnType; + export default Component; +} diff --git a/packages/hoppscotch-sh-admin/src/vite-env.d.ts b/packages/hoppscotch-sh-admin/src/vite-env.d.ts new file mode 100644 index 0000000..9d639f6 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/packages/hoppscotch-sh-admin/tailwind.config.ts b/packages/hoppscotch-sh-admin/tailwind.config.ts new file mode 100644 index 0000000..f8dc662 --- /dev/null +++ b/packages/hoppscotch-sh-admin/tailwind.config.ts @@ -0,0 +1,7 @@ +import { Config } from 'tailwindcss'; +import preset from '@hoppscotch/ui/ui-preset'; + +export default { + content: ['src/**/*.{vue,html}'], + presets: [preset], +} satisfies Config; diff --git a/packages/hoppscotch-sh-admin/tsconfig.json b/packages/hoppscotch-sh-admin/tsconfig.json new file mode 100644 index 0000000..7a40d92 --- /dev/null +++ b/packages/hoppscotch-sh-admin/tsconfig.json @@ -0,0 +1,44 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "jsx": "preserve", + "sourceMap": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ESNext", "DOM"], + "skipLibCheck": true, + "noUnusedLocals": true, + "paths": { + "~/*": ["./src/*"], + "@composables/*": ["./src/composables/*"], + "@components/*": ["./src/components/*"], + "@helpers/*": ["./src/helpers/*"], + "@modules/*": ["./src/modules/*"], + "@workers/*": ["./src/workers/*"], + "@functional/*": ["./src/helpers/functional/*"] + }, + "types": [ + "vite/client", + "unplugin-icons/types/vue", + "unplugin-fonts/client", + "vite-plugin-pages/client", + "vite-plugin-vue-layouts/client" + ] + }, + "include": [ + "meta.ts", + "src/**/*.ts", + "src/**/*.d.ts", + "src/**/*.tsx", + "src/**/*.vue" + ], + "vueCompilerOptions": { + "jsxTemplates": true, + "experimentalRfc436": true + } +} diff --git a/packages/hoppscotch-sh-admin/tsconfig.node.json b/packages/hoppscotch-sh-admin/tsconfig.node.json new file mode 100644 index 0000000..9d31e2a --- /dev/null +++ b/packages/hoppscotch-sh-admin/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/hoppscotch-sh-admin/vite.config.ts b/packages/hoppscotch-sh-admin/vite.config.ts new file mode 100644 index 0000000..c7edf65 --- /dev/null +++ b/packages/hoppscotch-sh-admin/vite.config.ts @@ -0,0 +1,95 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import { FileSystemIconLoader } from 'unplugin-icons/loaders'; +import Icons from 'unplugin-icons/vite'; +import Unfonts from 'unplugin-fonts/vite'; +import IconResolver from 'unplugin-icons/resolver'; +import Components from 'unplugin-vue-components/vite'; +import Pages from 'vite-plugin-pages'; +import Layouts from 'vite-plugin-vue-layouts'; +import path from 'path'; +import ImportMetaEnv from '@import-meta-env/unplugin'; +import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'; + +// https://vitejs.dev/config/ +export default defineConfig({ + envPrefix: process.env.HOPP_ALLOW_RUNTIME_ENV ? 'VITE_BUILDTIME_' : 'VITE_', + envDir: path.resolve(__dirname, '../..'), + server: { + port: 3100, + }, + resolve: { + alias: { + '~': path.resolve(__dirname, '../hoppscotch-sh-admin/src'), + '@modules': path.resolve(__dirname, '../hoppscotch-sh-admin/src/modules'), + }, + }, + plugins: [ + vue(), + Pages({ + dirs: './src/pages', + routeStyle: 'nuxt', + }), + Layouts({ + defaultLayout: 'default', + layoutsDirs: 'src/layouts', + }), + VueI18nPlugin({ + runtimeOnly: false, + compositionOnly: true, + include: [path.resolve(__dirname, './locales/**')], + }), + Components({ + dts: './src/components.d.ts', + dirs: ['./src/components'], + directoryAsNamespace: true, + resolvers: [ + IconResolver({ + prefix: 'icon', + customCollections: ['auth'], + }), + (compName: string) => { + if (compName.startsWith('Hopp')) + return { name: compName, from: '@hoppscotch/ui' }; + else return undefined; + }, + ], + types: [ + { + from: 'vue-tippy', + names: ['Tippy'], + }, + ], + }), + Icons({ + compiler: 'vue3', + customCollections: { + auth: FileSystemIconLoader('../hoppscotch-sh-admin/assets/icons/auth'), + }, + }), + Unfonts({ + fontsource: { + families: [ + { + name: 'Inter Variable', + variables: ['variable-full'], + }, + { + name: 'Material Symbols Rounded Variable', + variables: ['variable-full'], + }, + { + name: 'Roboto Mono Variable', + variables: ['variable-full'], + }, + ], + }, + }), + process.env.HOPP_ALLOW_RUNTIME_ENV + ? ImportMetaEnv.vite({ + example: '../../.env.example', + env: '../../.env', + }) + : [], + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..7956b14 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,28831 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + '@xmldom/xmldom': 0.8.13 + apiconnect-wsdl: 2.0.36 + body-parser: 2.2.1 + cross-spawn: 7.0.6 + execa@<2.0.0: 2.0.0 + form-data@>=4.0.0 <4.0.6: 4.0.6 + glob@>=11.0.0 <11.1.0: 11.1.0 + hono@<4.12.25: 4.12.25 + liquidjs@<10.26.0: 10.26.0 + minimatch@>=4.0.0 <4.2.5: 4.2.5 + multer@2.1.1: 2.2.0 + serialize-javascript@<7.0.3: 7.0.3 + vue: 3.5.38 + ws: 8.21.0 + +importers: + + .: + devDependencies: + '@commitlint/cli': + specifier: 20.5.2 + version: 20.5.2(@types/node@24.10.1)(conventional-commits-parser@6.4.0)(typescript@5.9.3) + '@commitlint/config-conventional': + specifier: 20.5.0 + version: 20.5.0 + '@hoppscotch/ui': + specifier: 0.2.6 + version: 0.2.6(eslint@10.5.0(jiti@2.6.1))(terser@5.46.1)(typescript@5.9.3)(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + '@types/node': + specifier: 24.10.1 + version: 24.10.1 + cross-env: + specifier: 10.1.0 + version: 10.1.0 + http-server: + specifier: 14.1.1 + version: 14.1.1 + husky: + specifier: 9.1.7 + version: 9.1.7 + lint-staged: + specifier: 16.4.0 + version: 16.4.0 + + packages/codemirror-lang-graphql: + dependencies: + '@codemirror/language': + specifier: 6.11.3 + version: 6.11.3 + '@lezer/highlight': + specifier: 1.2.1 + version: 1.2.1 + '@lezer/lr': + specifier: 1.4.2 + version: 1.4.2 + devDependencies: + '@lezer/generator': + specifier: 1.8.0 + version: 1.8.0 + '@rollup/plugin-typescript': + specifier: 12.1.4 + version: 12.1.4(rollup@4.59.0)(tslib@2.8.1)(typescript@5.9.3) + mocha: + specifier: 11.7.6 + version: 11.7.6 + rollup: + specifier: 4.59.0 + version: 4.59.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + + packages/hoppscotch-agent: + dependencies: + '@hoppscotch/ui': + specifier: 0.2.6 + version: 0.2.6(eslint@9.39.2(jiti@2.6.1))(terser@5.46.1)(typescript@5.9.3)(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + '@tauri-apps/api': + specifier: 2.1.1 + version: 2.1.1 + '@tauri-apps/plugin-shell': + specifier: 2.3.3 + version: 2.3.3 + '@vueuse/core': + specifier: 14.3.0 + version: 14.3.0(vue@3.5.38(typescript@5.9.3)) + axios: + specifier: 1.18.0 + version: 1.18.0 + fp-ts: + specifier: 2.16.11 + version: 2.16.11 + lodash-es: + specifier: 4.18.1 + version: 4.18.1 + vue: + specifier: 3.5.38 + version: 3.5.38(typescript@5.9.3) + devDependencies: + '@iconify-json/lucide': + specifier: 1.2.114 + version: 1.2.114 + '@tauri-apps/cli': + specifier: 2.9.3 + version: 2.9.3 + '@types/lodash-es': + specifier: 4.17.12 + version: 4.17.12 + '@types/node': + specifier: 24.10.1 + version: 24.10.1 + '@typescript-eslint/eslint-plugin': + specifier: 8.61.1 + version: 8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: 8.61.1 + version: 8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@vitejs/plugin-vue': + specifier: 6.0.7 + version: 6.0.7(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + '@vue/eslint-config-typescript': + specifier: 14.8.0 + version: 14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.4.1(eslint@9.39.2(jiti@2.6.1))))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + autoprefixer: + specifier: 10.5.0 + version: 10.5.0(postcss@8.5.15) + cross-env: + specifier: 10.1.0 + version: 10.1.0 + eslint: + specifier: 9.39.2 + version: 9.39.2(jiti@2.6.1) + eslint-plugin-prettier: + specifier: 5.5.6 + version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.4) + eslint-plugin-vue: + specifier: 10.9.2 + version: 10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.4.1(eslint@9.39.2(jiti@2.6.1))) + globals: + specifier: 16.5.0 + version: 16.5.0 + postcss: + specifier: 8.5.15 + version: 8.5.15 + tailwindcss: + specifier: 3.4.16 + version: 3.4.16(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + typescript: + specifier: 5.9.3 + version: 5.9.3 + unplugin-icons: + specifier: 22.5.0 + version: 22.5.0(@vue/compiler-sfc@3.5.38)(svelte@3.59.2)(vue-template-compiler@2.7.16) + unplugin-vue-components: + specifier: 30.0.0 + version: 30.0.0(@babel/parser@7.29.7)(vue@3.5.38(typescript@5.9.3)) + vite: + specifier: 7.3.2 + version: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + vue-tsc: + specifier: 2.2.0 + version: 2.2.0(typescript@5.9.3) + + packages/hoppscotch-backend: + dependencies: + '@apollo/server': + specifier: 5.5.1 + version: 5.5.1(graphql@16.14.0) + '@as-integrations/express5': + specifier: 1.1.2 + version: 1.1.2(@apollo/server@5.5.1(graphql@16.14.0))(express@5.2.1) + '@nestjs-modules/mailer': + specifier: 2.3.7 + version: 2.3.7(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@nestjs/terminus@11.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2))(chokidar@4.0.3)(nodemailer@9.0.1)(terser@5.46.1)(typescript@5.9.3) + '@nestjs/apollo': + specifier: 13.4.2 + version: 13.4.2(@apollo/server@5.5.1(graphql@16.14.0))(@as-integrations/express5@1.1.2(@apollo/server@5.5.1(graphql@16.14.0))(express@5.2.1))(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@nestjs/graphql@13.4.2(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.14.0)(reflect-metadata@0.2.2))(graphql@16.14.0) + '@nestjs/common': + specifier: 11.1.27 + version: 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: 4.0.4 + version: 4.0.4(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: 11.1.27 + version: 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/graphql': + specifier: 13.4.2 + version: 13.4.2(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.14.0)(reflect-metadata@0.2.2) + '@nestjs/jwt': + specifier: 11.0.2 + version: 11.0.2(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/passport': + specifier: 11.0.0 + version: 11.0.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/platform-express': + specifier: 11.1.27 + version: 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27) + '@nestjs/schedule': + specifier: 6.1.3 + version: 6.1.3(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27) + '@nestjs/swagger': + specifier: 11.4.4 + version: 11.4.4(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + '@nestjs/terminus': + specifier: 11.1.1 + version: 11.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/throttler': + specifier: 6.5.0 + version: 6.5.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(reflect-metadata@0.2.2) + '@prisma/adapter-pg': + specifier: 7.8.0 + version: 7.8.0 + '@prisma/client': + specifier: 7.8.0 + version: 7.8.0(prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) + argon2: + specifier: 0.44.0 + version: 0.44.0 + bcrypt: + specifier: 6.0.0 + version: 6.0.0 + class-transformer: + specifier: 0.5.1 + version: 0.5.1 + class-validator: + specifier: 0.15.1 + version: 0.15.1 + cookie: + specifier: 1.1.1 + version: 1.1.1 + cookie-parser: + specifier: 1.4.7 + version: 1.4.7 + dotenv: + specifier: 17.4.2 + version: 17.4.2 + express: + specifier: 5.2.1 + version: 5.2.1 + fp-ts: + specifier: 2.16.11 + version: 2.16.11 + graphql: + specifier: 16.14.0 + version: 16.14.0 + graphql-query-complexity: + specifier: 1.1.1 + version: 1.1.1(graphql@16.14.0) + graphql-redis-subscriptions: + specifier: 2.7.0 + version: 2.7.0(graphql-subscriptions@3.0.0(graphql@16.14.0)) + graphql-subscriptions: + specifier: 3.0.0 + version: 3.0.0(graphql@16.14.0) + handlebars: + specifier: 4.7.9 + version: 4.7.9 + io-ts: + specifier: 2.2.22 + version: 2.2.22(fp-ts@2.16.11) + morgan: + specifier: 1.11.0 + version: 1.11.0 + nodemailer: + specifier: 9.0.1 + version: 9.0.1 + passport: + specifier: 0.7.0 + version: 0.7.0 + passport-github2: + specifier: 0.1.12 + version: 0.1.12 + passport-google-oauth20: + specifier: 2.0.0 + version: 2.0.0 + passport-jwt: + specifier: 4.0.1 + version: 4.0.1 + passport-local: + specifier: 1.0.0 + version: 1.0.0 + passport-microsoft: + specifier: 2.1.0 + version: 2.1.0 + pg: + specifier: 8.22.0 + version: 8.22.0 + posthog-node: + specifier: 5.38.2 + version: 5.38.2(rxjs@7.8.2) + prisma: + specifier: 7.8.0 + version: 7.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + reflect-metadata: + specifier: 0.2.2 + version: 0.2.2 + rimraf: + specifier: 6.1.3 + version: 6.1.3 + rxjs: + specifier: 7.8.2 + version: 7.8.2 + devDependencies: + '@eslint/eslintrc': + specifier: 3.3.5 + version: 3.3.5 + '@eslint/js': + specifier: 10.0.1 + version: 10.0.1(eslint@10.5.0(jiti@2.6.1)) + '@nestjs/cli': + specifier: 11.0.23 + version: 11.0.23(@types/node@25.9.3)(prettier@3.8.4) + '@nestjs/schematics': + specifier: 11.1.0 + version: 11.1.0(chokidar@4.0.3)(prettier@3.8.4)(typescript@5.9.3) + '@nestjs/testing': + specifier: 11.1.27 + version: 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@nestjs/platform-express@11.1.27) + '@relmify/jest-fp-ts': + specifier: 2.1.1 + version: 2.1.1(fp-ts@2.16.11)(io-ts@2.2.22(fp-ts@2.16.11)) + '@types/bcrypt': + specifier: 6.0.0 + version: 6.0.0 + '@types/cookie-parser': + specifier: 1.4.10 + version: 1.4.10(@types/express@5.0.6) + '@types/express': + specifier: 5.0.6 + version: 5.0.6 + '@types/jest': + specifier: 30.0.0 + version: 30.0.0 + '@types/node': + specifier: 25.9.3 + version: 25.9.3 + '@types/nodemailer': + specifier: 8.0.1 + version: 8.0.1 + '@types/passport-github2': + specifier: 1.2.9 + version: 1.2.9 + '@types/passport-google-oauth20': + specifier: 2.0.17 + version: 2.0.17 + '@types/passport-jwt': + specifier: 4.0.1 + version: 4.0.1 + '@types/passport-microsoft': + specifier: 2.1.1 + version: 2.1.1 + '@types/supertest': + specifier: 7.2.0 + version: 7.2.0 + '@typescript-eslint/eslint-plugin': + specifier: 8.61.1 + version: 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: 8.61.1 + version: 8.61.1(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + cross-env: + specifier: 10.1.0 + version: 10.1.0 + eslint: + specifier: 10.5.0 + version: 10.5.0(jiti@2.6.1) + eslint-config-prettier: + specifier: 10.1.8 + version: 10.1.8(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: 5.5.6 + version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.5.0(jiti@2.6.1)))(eslint@10.5.0(jiti@2.6.1))(prettier@3.8.4) + globals: + specifier: 17.6.0 + version: 17.6.0 + jest: + specifier: 30.4.2 + version: 30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) + jest-mock-extended: + specifier: 4.0.1 + version: 4.0.1(@jest/globals@30.4.1)(jest@30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)))(typescript@5.9.3) + prettier: + specifier: 3.8.4 + version: 3.8.4 + source-map-support: + specifier: 0.5.21 + version: 0.5.21 + supertest: + specifier: 7.2.2 + version: 7.2.2 + ts-jest: + specifier: 29.4.11 + version: 29.4.11(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)))(typescript@5.9.3) + ts-loader: + specifier: 9.6.1 + version: 9.6.1(typescript@5.9.3)(webpack@5.106.2) + ts-node: + specifier: 10.9.2 + version: 10.9.2(@types/node@25.9.3)(typescript@5.9.3) + tsconfig-paths: + specifier: 4.2.0 + version: 4.2.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + + packages/hoppscotch-cli: + dependencies: + aws4fetch: + specifier: 1.0.20 + version: 1.0.20 + axios: + specifier: 1.18.0 + version: 1.18.0 + axios-cookiejar-support: + specifier: 6.0.5 + version: 6.0.5(axios@1.18.0)(tough-cookie@6.0.1) + chalk: + specifier: 5.6.2 + version: 5.6.2 + commander: + specifier: 14.0.3 + version: 14.0.3 + isolated-vm: + specifier: 6.1.2 + version: 6.1.2 + js-md5: + specifier: 0.8.3 + version: 0.8.3 + jsonc-parser: + specifier: 3.3.1 + version: 3.3.1 + lodash-es: + specifier: 4.18.1 + version: 4.18.1 + papaparse: + specifier: 5.5.4 + version: 5.5.4 + qs: + specifier: 6.15.2 + version: 6.15.2 + semver: + specifier: 7.8.5 + version: 7.8.5 + tough-cookie: + specifier: 6.0.1 + version: 6.0.1 + verzod: + specifier: 0.4.0 + version: 0.4.0(zod@3.25.32) + xmlbuilder2: + specifier: 4.0.3 + version: 4.0.3 + zod: + specifier: 3.25.32 + version: 3.25.32 + devDependencies: + '@hoppscotch/data': + specifier: workspace:^ + version: link:../hoppscotch-data + '@hoppscotch/js-sandbox': + specifier: workspace:^ + version: link:../hoppscotch-js-sandbox + '@relmify/jest-fp-ts': + specifier: 2.1.1 + version: 2.1.1(fp-ts@2.16.11)(io-ts@2.2.22(fp-ts@2.16.11)) + '@types/lodash-es': + specifier: 4.17.12 + version: 4.17.12 + '@types/papaparse': + specifier: 5.5.2 + version: 5.5.2 + '@types/qs': + specifier: 6.15.1 + version: 6.15.1 + fp-ts: + specifier: 2.16.11 + version: 2.16.11 + prettier: + specifier: 3.8.4 + version: 3.8.4 + tsup: + specifier: 8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.1.9 + version: 4.1.9(@types/node@25.9.3)(jsdom@27.4.0(@noble/hashes@2.2.0))(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + + packages/hoppscotch-common: + dependencies: + '@apidevtools/swagger-parser': + specifier: 12.1.0 + version: 12.1.0(openapi-types@12.1.3) + '@codemirror/autocomplete': + specifier: 6.20.0 + version: 6.20.0 + '@codemirror/commands': + specifier: 6.10.0 + version: 6.10.0 + '@codemirror/lang-javascript': + specifier: 6.2.4 + version: 6.2.4 + '@codemirror/lang-json': + specifier: 6.0.2 + version: 6.0.2 + '@codemirror/lang-xml': + specifier: 6.1.0 + version: 6.1.0 + '@codemirror/language': + specifier: 6.11.3 + version: 6.11.3 + '@codemirror/legacy-modes': + specifier: 6.5.2 + version: 6.5.2 + '@codemirror/lint': + specifier: 6.9.2 + version: 6.9.2 + '@codemirror/merge': + specifier: 6.11.2 + version: 6.11.2 + '@codemirror/search': + specifier: 6.5.11 + version: 6.5.11 + '@codemirror/state': + specifier: 6.5.2 + version: 6.5.2 + '@codemirror/view': + specifier: 6.38.8 + version: 6.38.8 + '@guolao/vue-monaco-editor': + specifier: 1.6.0 + version: 1.6.0(monaco-editor@0.55.1)(vue@3.5.38(typescript@5.9.3)) + '@hoppscotch/codemirror-lang-graphql': + specifier: workspace:^ + version: link:../codemirror-lang-graphql + '@hoppscotch/data': + specifier: workspace:^ + version: link:../hoppscotch-data + '@hoppscotch/httpsnippet': + specifier: 3.0.9 + version: 3.0.9 + '@hoppscotch/js-sandbox': + specifier: workspace:^ + version: link:../hoppscotch-js-sandbox + '@hoppscotch/kernel': + specifier: workspace:^ + version: link:../hoppscotch-kernel + '@hoppscotch/plugin-appload': + specifier: github:CuriousCorrelation/tauri-plugin-appload#9d4528be4f385bccbe46859631d31aa2ee1ec0b6 + version: '@CuriousCorrelation/plugin-appload@https://codeload.github.com/CuriousCorrelation/tauri-plugin-appload/tar.gz/9d4528be4f385bccbe46859631d31aa2ee1ec0b6' + '@hoppscotch/ui': + specifier: 0.2.6 + version: 0.2.6(eslint@9.39.2(jiti@2.6.1))(terser@5.46.1)(typescript@5.9.3)(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + '@hoppscotch/vue-toasted': + specifier: 0.1.0 + version: 0.1.0(vue@3.5.38(typescript@5.9.3)) + '@lezer/highlight': + specifier: 1.2.1 + version: 1.2.1 + '@noble/curves': + specifier: 2.2.0 + version: 2.2.0 + '@scure/base': + specifier: 2.2.0 + version: 2.2.0 + '@shopify/lang-jsonc': + specifier: 1.0.1 + version: 1.0.1 + '@tauri-apps/api': + specifier: 2.1.1 + version: 2.1.1 + '@tauri-apps/plugin-store': + specifier: 2.4.1 + version: 2.4.1 + '@types/hawk': + specifier: 9.0.7 + version: 9.0.7 + '@types/markdown-it': + specifier: 14.1.2 + version: 14.1.2 + '@types/node': + specifier: 24.10.1 + version: 24.10.1 + '@unhead/vue': + specifier: 2.1.12 + version: 2.1.12(vue@3.5.38(typescript@5.9.3)) + '@urql/core': + specifier: 6.0.2 + version: 6.0.2(graphql@16.13.2) + '@urql/devtools': + specifier: 2.0.3 + version: 2.0.3(@urql/core@6.0.2(graphql@16.13.2))(graphql@16.13.2) + '@urql/exchange-auth': + specifier: 3.0.0 + version: 3.0.0(@urql/core@6.0.2(graphql@16.13.2)) + '@vueuse/core': + specifier: 14.3.0 + version: 14.3.0(vue@3.5.38(typescript@5.9.3)) + acorn-walk: + specifier: 8.3.5 + version: 8.3.5 + aws4fetch: + specifier: 1.0.20 + version: 1.0.20 + axios: + specifier: 1.18.0 + version: 1.18.0 + buffer: + specifier: 6.0.3 + version: 6.0.3 + cookie-es: + specifier: 2.0.0 + version: 2.0.0 + dioc: + specifier: 3.0.2 + version: 3.0.2(vue@3.5.38(typescript@5.9.3)) + dompurify: + specifier: 3.4.11 + version: 3.4.11 + esprima: + specifier: 4.0.1 + version: 4.0.1 + events: + specifier: 3.3.0 + version: 3.3.0 + fp-ts: + specifier: 2.16.11 + version: 2.16.11 + globalthis: + specifier: 1.0.4 + version: 1.0.4 + graphql: + specifier: 16.13.2 + version: 16.13.2 + graphql-language-service-interface: + specifier: 2.10.2 + version: 2.10.2(@types/node@24.10.1)(graphql@16.13.2) + graphql-tag: + specifier: 2.12.7 + version: 2.12.7(graphql@16.13.2) + hawk: + specifier: 9.0.2 + version: 9.0.2 + highlight.js: + specifier: 11.11.1 + version: 11.11.1 + highlightjs-curl: + specifier: 1.3.0 + version: 1.3.0 + insomnia-importers: + specifier: 3.6.0 + version: 3.6.0(openapi-types@12.1.3) + io-ts: + specifier: 2.2.22 + version: 2.2.22(fp-ts@2.16.11) + jq-wasm: + specifier: 1.1.0-jq-1.8.1 + version: 1.1.0-jq-1.8.1 + js-md5: + specifier: 0.8.3 + version: 0.8.3 + js-yaml: + specifier: 4.2.0 + version: 4.2.0 + jsonc-parser: + specifier: 3.3.1 + version: 3.3.1 + lodash-es: + specifier: 4.18.1 + version: 4.18.1 + lossless-json: + specifier: 4.3.0 + version: 4.3.0 + markdown-it: + specifier: 14.2.0 + version: 14.2.0 + minisearch: + specifier: 7.2.0 + version: 7.2.0 + monaco-editor: + specifier: 0.55.1 + version: 0.55.1 + nprogress: + specifier: 0.2.0 + version: 0.2.0 + paho-mqtt: + specifier: 1.1.0 + version: 1.1.0 + path: + specifier: 0.12.7 + version: 0.12.7 + postman-collection: + specifier: 5.3.0 + version: 5.3.0 + process: + specifier: 0.11.10 + version: 0.11.10 + qs: + specifier: 6.15.2 + version: 6.15.2 + quicktype-core: + specifier: 23.2.6 + version: 23.2.6 + rollup: + specifier: 4.60.2 + version: 4.60.2 + rxjs: + specifier: 7.8.2 + version: 7.8.2 + set-cookie-parser: + specifier: 2.7.2 + version: 2.7.2 + set-cookie-parser-es: + specifier: 1.0.5 + version: 1.0.5 + socket.io-client-v2: + specifier: npm:socket.io-client@2.5.0 + version: socket.io-client@2.5.0 + socket.io-client-v3: + specifier: npm:socket.io-client@3.1.3 + version: socket.io-client@3.1.3 + socket.io-client-v4: + specifier: npm:socket.io-client@4.8.1 + version: socket.io-client@4.8.1 + socketio-wildcard: + specifier: 2.0.0 + version: 2.0.0 + splitpanes: + specifier: 3.1.5 + version: 3.1.5 + stream-browserify: + specifier: 3.0.0 + version: 3.0.0 + subscriptions-transport-ws: + specifier: 0.11.0 + version: 0.11.0(graphql@16.13.2) + superjson: + specifier: 2.2.6 + version: 2.2.6 + tern: + specifier: 0.24.3 + version: 0.24.3 + timers: + specifier: 0.1.1 + version: 0.1.1 + tippy.js: + specifier: 6.3.7 + version: 6.3.7 + url: + specifier: 0.11.4 + version: 0.11.4 + util: + specifier: 0.12.5 + version: 0.12.5 + uuid: + specifier: 13.0.0 + version: 13.0.0 + verzod: + specifier: 0.4.0 + version: 0.4.0(zod@3.25.32) + vue: + specifier: 3.5.38 + version: 3.5.38(typescript@5.9.3) + vue-i18n: + specifier: 11.4.6 + version: 11.4.6(vue@3.5.38(typescript@5.9.3)) + vue-json-pretty: + specifier: 2.6.0 + version: 2.6.0(vue@3.5.38(typescript@5.9.3)) + vue-pdf-embed: + specifier: 2.1.4 + version: 2.1.4(vue@3.5.38(typescript@5.9.3)) + vue-router: + specifier: 4.6.4 + version: 4.6.4(vue@3.5.38(typescript@5.9.3)) + vue-tippy: + specifier: 6.7.1 + version: 6.7.1(vue@3.5.38(typescript@5.9.3)) + vuedraggable-es: + specifier: 4.1.1 + version: 4.1.1(vue@3.5.38(typescript@5.9.3)) + wonka: + specifier: 6.3.6 + version: 6.3.6 + workbox-window: + specifier: 7.4.1 + version: 7.4.1 + xml-formatter: + specifier: 3.7.0 + version: 3.7.0 + yargs-parser: + specifier: 22.0.0 + version: 22.0.0 + zod: + specifier: 3.25.32 + version: 3.25.32 + devDependencies: + '@esbuild-plugins/node-globals-polyfill': + specifier: 0.2.3 + version: 0.2.3(esbuild@0.27.4) + '@esbuild-plugins/node-modules-polyfill': + specifier: 0.2.2 + version: 0.2.2(esbuild@0.27.4) + '@eslint/eslintrc': + specifier: 3.3.5 + version: 3.3.5 + '@eslint/js': + specifier: 9.39.2 + version: 9.39.2 + '@graphql-codegen/add': + specifier: 6.0.1 + version: 6.0.1(graphql@16.13.2) + '@graphql-codegen/cli': + specifier: 6.3.1 + version: 6.3.1(@parcel/watcher@2.5.6)(@types/node@24.10.1)(graphql@16.13.2)(typescript@5.9.3) + '@graphql-codegen/typed-document-node': + specifier: 6.1.8 + version: 6.1.8(graphql@16.13.2) + '@graphql-codegen/typescript': + specifier: 5.0.10 + version: 5.0.10(graphql@16.13.2) + '@graphql-codegen/typescript-operations': + specifier: 5.1.0 + version: 5.1.0(graphql@16.13.2) + '@graphql-codegen/typescript-urql-graphcache': + specifier: 3.1.1 + version: 3.1.1(@urql/exchange-graphcache@7.2.4(@urql/core@6.0.2(graphql@16.13.2))(graphql@16.13.2))(graphql-tag@2.12.7(graphql@16.13.2))(graphql@16.13.2) + '@graphql-codegen/urql-introspection': + specifier: 3.0.1 + version: 3.0.1(graphql@16.13.2) + '@graphql-typed-document-node/core': + specifier: 3.2.0 + version: 3.2.0(graphql@16.13.2) + '@iconify-json/lucide': + specifier: 1.2.114 + version: 1.2.114 + '@import-meta-env/cli': + specifier: 0.7.4 + version: 0.7.4(@import-meta-env/unplugin@0.6.3) + '@intlify/unplugin-vue-i18n': + specifier: 11.2.4 + version: 11.2.4(@vue/compiler-dom@3.5.38)(eslint@9.39.2(jiti@2.6.1))(rollup@4.60.2)(typescript@5.9.3)(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-i18n@11.4.6(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3)) + '@relmify/jest-fp-ts': + specifier: 2.1.1 + version: 2.1.1(fp-ts@2.16.11)(io-ts@2.2.22(fp-ts@2.16.11)) + '@rushstack/eslint-patch': + specifier: 1.16.1 + version: 1.16.1 + '@types/har-format': + specifier: 1.2.16 + version: 1.2.16 + '@types/js-yaml': + specifier: 4.0.9 + version: 4.0.9 + '@types/lodash-es': + specifier: 4.17.12 + version: 4.17.12 + '@types/nprogress': + specifier: 0.2.3 + version: 0.2.3 + '@types/paho-mqtt': + specifier: 1.0.10 + version: 1.0.10 + '@types/postman-collection': + specifier: 3.5.11 + version: 3.5.11 + '@types/qs': + specifier: 6.15.1 + version: 6.15.1 + '@types/splitpanes': + specifier: 2.2.6 + version: 2.2.6(typescript@5.9.3) + '@types/yargs-parser': + specifier: 21.0.3 + version: 21.0.3 + '@typescript-eslint/eslint-plugin': + specifier: 8.61.1 + version: 8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: 8.61.1 + version: 8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@vitejs/plugin-vue': + specifier: 6.0.7 + version: 6.0.7(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + '@vue/compiler-sfc': + specifier: 3.5.38 + version: 3.5.38 + '@vue/eslint-config-typescript': + specifier: 14.8.0 + version: 14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.4.1(eslint@9.39.2(jiti@2.6.1))))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@vue/runtime-core': + specifier: 3.5.38 + version: 3.5.38 + autoprefixer: + specifier: 10.5.0 + version: 10.5.0(postcss@8.5.15) + cross-env: + specifier: 10.1.0 + version: 10.1.0 + dotenv: + specifier: 17.4.2 + version: 17.4.2 + eslint: + specifier: 9.39.2 + version: 9.39.2(jiti@2.6.1) + eslint-plugin-prettier: + specifier: 5.5.6 + version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.4) + eslint-plugin-vue: + specifier: 10.9.2 + version: 10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.4.1(eslint@9.39.2(jiti@2.6.1))) + glob: + specifier: 13.0.6 + version: 13.0.6 + globals: + specifier: 16.5.0 + version: 16.5.0 + jsdom: + specifier: 27.4.0 + version: 27.4.0(@noble/hashes@2.2.0) + npm-run-all: + specifier: 4.1.5 + version: 4.1.5 + openapi-types: + specifier: 12.1.3 + version: 12.1.3 + postcss: + specifier: 8.5.15 + version: 8.5.15 + prettier: + specifier: 3.8.4 + version: 3.8.4 + prettier-plugin-tailwindcss: + specifier: 0.7.2 + version: 0.7.2(prettier@3.8.4) + rollup-plugin-polyfill-node: + specifier: 0.13.0 + version: 0.13.0(rollup@4.60.2) + sass: + specifier: 1.101.0 + version: 1.101.0 + tailwindcss: + specifier: 3.4.16 + version: 3.4.16(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + tsup: + specifier: 8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 5.9.3 + version: 5.9.3 + unplugin-fonts: + specifier: 1.4.0 + version: 1.4.0(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + unplugin-icons: + specifier: 22.5.0 + version: 22.5.0(@vue/compiler-sfc@3.5.38)(svelte@3.59.2)(vue-template-compiler@2.7.16) + unplugin-vue-components: + specifier: 30.0.0 + version: 30.0.0(@babel/parser@7.29.7)(vue@3.5.38(typescript@5.9.3)) + vite: + specifier: 7.3.2 + version: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + vite-plugin-checker: + specifier: 0.12.0 + version: 0.12.0(eslint@9.39.2(jiti@2.6.1))(meow@13.2.0)(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-tsc@1.8.8(typescript@5.9.3)) + vite-plugin-fonts: + specifier: 0.7.0 + version: 0.7.0(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + vite-plugin-html-config: + specifier: 2.0.2 + version: 2.0.2(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + vite-plugin-pages: + specifier: 0.33.3 + version: 0.33.3(@vue/compiler-sfc@3.5.38)(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-router@4.6.4(vue@3.5.38(typescript@5.9.3))) + vite-plugin-pages-sitemap: + specifier: 1.7.1 + version: 1.7.1 + vite-plugin-pwa: + specifier: 1.2.0 + version: 1.2.0(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.1) + vite-plugin-vue-layouts: + specifier: 0.11.0 + version: 0.11.0(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-router@4.6.4(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3)) + vitest: + specifier: 4.1.9 + version: 4.1.9(@types/node@24.10.1)(jsdom@27.4.0(@noble/hashes@2.2.0))(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + vue-tsc: + specifier: 1.8.8 + version: 1.8.8(typescript@5.9.3) + + packages/hoppscotch-data: + dependencies: + fp-ts: + specifier: 2.16.11 + version: 2.16.11 + io-ts: + specifier: 2.2.22 + version: 2.2.22(fp-ts@2.16.11) + jose: + specifier: 6.2.3 + version: 6.2.3 + lodash: + specifier: 4.18.1 + version: 4.18.1 + parser-ts: + specifier: 0.7.0 + version: 0.7.0(fp-ts@2.16.11) + uuid: + specifier: 13.0.0 + version: 13.0.0 + verzod: + specifier: 0.4.0 + version: 0.4.0(zod@3.25.32) + zod: + specifier: 3.25.32 + version: 3.25.32 + devDependencies: + '@types/lodash': + specifier: 4.17.24 + version: 4.17.24 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 7.3.2 + version: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + packages/hoppscotch-desktop: + dependencies: + '@fontsource-variable/inter': + specifier: 5.2.8 + version: 5.2.8 + '@fontsource-variable/material-symbols-rounded': + specifier: 5.2.45 + version: 5.2.45 + '@fontsource-variable/roboto-mono': + specifier: 5.2.9 + version: 5.2.9 + '@hoppscotch/common': + specifier: workspace:^ + version: link:../hoppscotch-common + '@hoppscotch/kernel': + specifier: workspace:^ + version: link:../hoppscotch-kernel + '@hoppscotch/plugin-appload': + specifier: github:CuriousCorrelation/tauri-plugin-appload#9d4528be4f385bccbe46859631d31aa2ee1ec0b6 + version: '@CuriousCorrelation/plugin-appload@https://codeload.github.com/CuriousCorrelation/tauri-plugin-appload/tar.gz/9d4528be4f385bccbe46859631d31aa2ee1ec0b6' + '@hoppscotch/ui': + specifier: 0.2.6 + version: 0.2.6(eslint@9.39.2(jiti@2.6.1))(terser@5.46.1)(typescript@5.9.3)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + '@tauri-apps/api': + specifier: 2.1.1 + version: 2.1.1 + '@tauri-apps/plugin-fs': + specifier: 2.0.2 + version: 2.0.2 + '@tauri-apps/plugin-process': + specifier: 2.2.0 + version: 2.2.0 + '@tauri-apps/plugin-shell': + specifier: 2.3.3 + version: 2.3.3 + '@tauri-apps/plugin-store': + specifier: 2.4.1 + version: 2.4.1 + '@tauri-apps/plugin-updater': + specifier: 2.9.0 + version: 2.9.0 + fp-ts: + specifier: 2.16.11 + version: 2.16.11 + rxjs: + specifier: 7.8.2 + version: 7.8.2 + vue: + specifier: 3.5.38 + version: 3.5.38(typescript@5.9.3) + vue-router: + specifier: 4.6.4 + version: 4.6.4(vue@3.5.38(typescript@5.9.3)) + vue-tippy: + specifier: 6.7.1 + version: 6.7.1(vue@3.5.38(typescript@5.9.3)) + zod: + specifier: 3.25.32 + version: 3.25.32 + devDependencies: + '@eslint/eslintrc': + specifier: 3.3.5 + version: 3.3.5 + '@eslint/js': + specifier: 9.39.2 + version: 9.39.2 + '@iconify-json/lucide': + specifier: 1.2.114 + version: 1.2.114 + '@rushstack/eslint-patch': + specifier: 1.16.1 + version: 1.16.1 + '@tauri-apps/cli': + specifier: 2.9.3 + version: 2.9.3 + '@typescript-eslint/eslint-plugin': + specifier: 8.61.1 + version: 8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: 8.61.1 + version: 8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@vitejs/plugin-vue': + specifier: 6.0.7 + version: 6.0.7(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + '@vue/eslint-config-typescript': + specifier: 14.8.0 + version: 14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.4.1(eslint@9.39.2(jiti@2.6.1))))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + autoprefixer: + specifier: 10.5.0 + version: 10.5.0(postcss@8.5.15) + eslint: + specifier: 9.39.2 + version: 9.39.2(jiti@2.6.1) + eslint-plugin-prettier: + specifier: 5.5.6 + version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.4) + eslint-plugin-vue: + specifier: 10.9.2 + version: 10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.4.1(eslint@9.39.2(jiti@2.6.1))) + globals: + specifier: 16.5.0 + version: 16.5.0 + postcss: + specifier: 8.5.15 + version: 8.5.15 + sass: + specifier: 1.101.0 + version: 1.101.0 + tailwindcss: + specifier: 3.4.16 + version: 3.4.16(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) + typescript: + specifier: 5.9.3 + version: 5.9.3 + unplugin-icons: + specifier: 22.5.0 + version: 22.5.0(@vue/compiler-sfc@3.5.38)(svelte@3.59.2)(vue-template-compiler@2.7.16) + unplugin-vue-components: + specifier: 30.0.0 + version: 30.0.0(@babel/parser@7.29.7)(vue@3.5.38(typescript@5.9.3)) + vite: + specifier: 7.3.2 + version: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + vue-tsc: + specifier: 2.2.0 + version: 2.2.0(typescript@5.9.3) + + packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload: + dependencies: + '@tauri-apps/api': + specifier: 2.9.1 + version: 2.9.1 + devDependencies: + '@rollup/plugin-typescript': + specifier: ^12.3.0 + version: 12.3.0(rollup@4.59.0)(tslib@2.8.1)(typescript@5.9.3) + rollup: + specifier: ^4.59.0 + version: 4.59.0 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + + packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app: + dependencies: + '@tauri-apps/api': + specifier: 2.1.1 + version: 2.1.1 + tauri-plugin-appload-api: + specifier: file:../../ + version: link:../.. + devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: ^1.0.1 + version: 1.4.0(svelte@3.59.2)(vite@3.2.11(@types/node@25.9.3)(sass@1.101.0)(terser@5.46.1)) + '@tauri-apps/cli': + specifier: ^2.0.0-alpha.17 + version: 2.9.3 + svelte: + specifier: ^3.49.0 + version: 3.59.2 + vite: + specifier: ^3.0.2 + version: 3.2.11(@types/node@25.9.3)(sass@1.101.0)(terser@5.46.1) + + packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay: + dependencies: + '@tauri-apps/api': + specifier: 2.1.1 + version: 2.1.1 + devDependencies: + '@rollup/plugin-typescript': + specifier: ^11.1.6 + version: 11.1.6(rollup@4.59.0)(tslib@2.8.1)(typescript@5.9.2) + rollup: + specifier: ^4.59.0 + version: 4.59.0 + tslib: + specifier: ^2.6.2 + version: 2.8.1 + typescript: + specifier: 5.9.2 + version: 5.9.2 + + packages/hoppscotch-js-sandbox: + dependencies: + '@hoppscotch/data': + specifier: workspace:^ + version: link:../hoppscotch-data + '@types/lodash-es': + specifier: 4.17.12 + version: 4.17.12 + acorn: + specifier: 8.17.0 + version: 8.17.0 + chai: + specifier: 6.2.2 + version: 6.2.2 + faraday-cage: + specifier: 0.1.0 + version: 0.1.0 + fp-ts: + specifier: 2.16.11 + version: 2.16.11 + isolated-vm: + specifier: 6.1.2 + version: 6.1.2 + lodash: + specifier: 4.18.1 + version: 4.18.1 + lodash-es: + specifier: 4.18.1 + version: 4.18.1 + devDependencies: + '@digitak/esrun': + specifier: 3.2.26 + version: 3.2.26 + '@eslint/eslintrc': + specifier: 3.3.5 + version: 3.3.5 + '@eslint/js': + specifier: 9.39.2 + version: 9.39.2 + '@relmify/jest-fp-ts': + specifier: 2.1.1 + version: 2.1.1(fp-ts@2.16.11)(io-ts@2.2.22(fp-ts@2.16.11)) + '@types/chai': + specifier: 5.2.3 + version: 5.2.3 + '@types/jest': + specifier: 30.0.0 + version: 30.0.0 + '@types/lodash': + specifier: 4.17.24 + version: 4.17.24 + '@types/node': + specifier: 24.10.1 + version: 24.10.1 + '@typescript-eslint/eslint-plugin': + specifier: 8.61.1 + version: 8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: 8.61.1 + version: 8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: 9.39.2 + version: 9.39.2(jiti@2.6.1) + eslint-config-prettier: + specifier: 10.1.8 + version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: 5.5.6 + version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.4) + globals: + specifier: 16.5.0 + version: 16.5.0 + io-ts: + specifier: 2.2.22 + version: 2.2.22(fp-ts@2.16.11) + prettier: + specifier: 3.8.4 + version: 3.8.4 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 7.3.2 + version: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + vitest: + specifier: 4.1.9 + version: 4.1.9(@types/node@24.10.1)(jsdom@27.4.0(@noble/hashes@2.2.0))(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + + packages/hoppscotch-kernel: + dependencies: + '@hoppscotch/plugin-relay': + specifier: github:CuriousCorrelation/tauri-plugin-relay#273488c8f50a22ee707af6b50ccd5570851f8bc9 + version: '@CuriousCorrelation/plugin-relay@https://codeload.github.com/CuriousCorrelation/tauri-plugin-relay/tar.gz/273488c8f50a22ee707af6b50ccd5570851f8bc9' + '@tauri-apps/api': + specifier: 2.1.1 + version: 2.1.1 + '@tauri-apps/plugin-dialog': + specifier: 2.0.1 + version: 2.0.1 + '@tauri-apps/plugin-fs': + specifier: 2.0.2 + version: 2.0.2 + '@tauri-apps/plugin-shell': + specifier: 2.3.3 + version: 2.3.3 + '@tauri-apps/plugin-store': + specifier: 2.4.1 + version: 2.4.1 + aws4fetch: + specifier: 1.0.20 + version: 1.0.20 + axios: + specifier: 1.18.0 + version: 1.18.0 + fp-ts: + specifier: 2.16.11 + version: 2.16.11 + superjson: + specifier: 2.2.6 + version: 2.2.6 + zod: + specifier: 3.25.32 + version: 3.25.32 + devDependencies: + '@eslint/js': + specifier: 9.39.2 + version: 9.39.2 + '@types/node': + specifier: 24.9.1 + version: 24.9.1 + '@typescript-eslint/eslint-plugin': + specifier: 8.61.1 + version: 8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: 8.61.1 + version: 8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: 9.39.2 + version: 9.39.2(jiti@2.6.1) + eslint-plugin-prettier: + specifier: 5.5.6 + version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.4) + globals: + specifier: 16.5.0 + version: 16.5.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 7.3.2 + version: 7.3.2(@types/node@24.9.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + packages/hoppscotch-selfhost-web: + dependencies: + '@fontsource-variable/inter': + specifier: 5.2.8 + version: 5.2.8 + '@fontsource-variable/material-symbols-rounded': + specifier: 5.2.45 + version: 5.2.45 + '@fontsource-variable/roboto-mono': + specifier: 5.2.9 + version: 5.2.9 + '@hoppscotch/common': + specifier: workspace:^ + version: link:../hoppscotch-common + '@hoppscotch/data': + specifier: workspace:^ + version: link:../hoppscotch-data + '@hoppscotch/kernel': + specifier: workspace:^ + version: link:../hoppscotch-kernel + '@hoppscotch/plugin-appload': + specifier: github:CuriousCorrelation/tauri-plugin-appload#9d4528be4f385bccbe46859631d31aa2ee1ec0b6 + version: '@CuriousCorrelation/plugin-appload@https://codeload.github.com/CuriousCorrelation/tauri-plugin-appload/tar.gz/9d4528be4f385bccbe46859631d31aa2ee1ec0b6' + '@hoppscotch/ui': + specifier: 0.2.6 + version: 0.2.6(eslint@9.39.2(jiti@2.6.1))(terser@5.46.1)(typescript@5.9.3)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + '@import-meta-env/unplugin': + specifier: 0.6.3 + version: 0.6.3 + '@tauri-apps/api': + specifier: 2.1.1 + version: 2.1.1 + '@tauri-apps/plugin-dialog': + specifier: 2.0.1 + version: 2.0.1 + '@tauri-apps/plugin-fs': + specifier: 2.0.2 + version: 2.0.2 + '@tauri-apps/plugin-shell': + specifier: 2.3.3 + version: 2.3.3 + '@vueuse/core': + specifier: 14.3.0 + version: 14.3.0(vue@3.5.38(typescript@5.9.3)) + axios: + specifier: 1.18.0 + version: 1.18.0 + buffer: + specifier: 6.0.3 + version: 6.0.3 + dioc: + specifier: 3.0.2 + version: 3.0.2(vue@3.5.38(typescript@5.9.3)) + fp-ts: + specifier: 2.16.11 + version: 2.16.11 + process: + specifier: 0.11.10 + version: 0.11.10 + rxjs: + specifier: 7.8.2 + version: 7.8.2 + stream-browserify: + specifier: 3.0.0 + version: 3.0.0 + util: + specifier: 0.12.5 + version: 0.12.5 + verzod: + specifier: 0.4.0 + version: 0.4.0(zod@3.25.32) + vue: + specifier: 3.5.38 + version: 3.5.38(typescript@5.9.3) + workbox-window: + specifier: 7.4.1 + version: 7.4.1 + zod: + specifier: 3.25.32 + version: 3.25.32 + devDependencies: + '@eslint/eslintrc': + specifier: 3.3.5 + version: 3.3.5 + '@eslint/js': + specifier: 9.39.2 + version: 9.39.2 + '@graphql-codegen/add': + specifier: 6.0.1 + version: 6.0.1(graphql@16.14.0) + '@graphql-codegen/cli': + specifier: 6.3.1 + version: 6.3.1(@parcel/watcher@2.5.6)(@types/node@25.9.3)(graphql@16.14.0)(typescript@5.9.3) + '@graphql-codegen/typed-document-node': + specifier: 6.1.8 + version: 6.1.8(graphql@16.14.0) + '@graphql-codegen/typescript': + specifier: 5.0.10 + version: 5.0.10(graphql@16.14.0) + '@graphql-codegen/typescript-operations': + specifier: 5.1.0 + version: 5.1.0(graphql@16.14.0) + '@graphql-codegen/typescript-urql-graphcache': + specifier: 3.1.1 + version: 3.1.1(@urql/exchange-graphcache@7.2.4(@urql/core@6.0.2(graphql@16.14.0))(graphql@16.14.0))(graphql-tag@2.12.7(graphql@16.14.0))(graphql@16.14.0) + '@graphql-codegen/urql-introspection': + specifier: 3.0.1 + version: 3.0.1(graphql@16.14.0) + '@graphql-typed-document-node/core': + specifier: 3.2.0 + version: 3.2.0(graphql@16.14.0) + '@iconify-json/lucide': + specifier: 1.2.114 + version: 1.2.114 + '@intlify/unplugin-vue-i18n': + specifier: 11.2.4 + version: 11.2.4(@vue/compiler-dom@3.5.38)(eslint@9.39.2(jiti@2.6.1))(rollup@4.60.2)(typescript@5.9.3)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-i18n@11.4.6(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3)) + '@rushstack/eslint-patch': + specifier: 1.16.1 + version: 1.16.1 + '@typescript-eslint/eslint-plugin': + specifier: 8.61.1 + version: 8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: 8.61.1 + version: 8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@vitejs/plugin-legacy': + specifier: 7.2.1 + version: 7.2.1(terser@5.46.1)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + '@vitejs/plugin-vue': + specifier: 6.0.7 + version: 6.0.7(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + '@vue/eslint-config-typescript': + specifier: 14.8.0 + version: 14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.4.1(eslint@9.39.2(jiti@2.6.1))))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + autoprefixer: + specifier: 10.5.0 + version: 10.5.0(postcss@8.5.15) + cross-env: + specifier: 10.1.0 + version: 10.1.0 + dotenv: + specifier: 17.4.2 + version: 17.4.2 + eslint: + specifier: 9.39.2 + version: 9.39.2(jiti@2.6.1) + eslint-plugin-prettier: + specifier: 5.5.6 + version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.4) + eslint-plugin-vue: + specifier: 10.9.2 + version: 10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.4.1(eslint@9.39.2(jiti@2.6.1))) + globals: + specifier: 16.5.0 + version: 16.5.0 + npm-run-all: + specifier: 4.1.5 + version: 4.1.5 + postcss: + specifier: 8.5.15 + version: 8.5.15 + prettier-plugin-tailwindcss: + specifier: 0.7.2 + version: 0.7.2(prettier@3.8.4) + tailwindcss: + specifier: 3.4.16 + version: 3.4.16(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) + typescript: + specifier: 5.9.3 + version: 5.9.3 + unplugin-fonts: + specifier: 1.4.0 + version: 1.4.0(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + unplugin-icons: + specifier: 22.5.0 + version: 22.5.0(@vue/compiler-sfc@3.5.38)(svelte@3.59.2)(vue-template-compiler@2.7.16) + unplugin-vue-components: + specifier: 30.0.0 + version: 30.0.0(@babel/parser@7.29.7)(vue@3.5.38(typescript@5.9.3)) + vite: + specifier: 7.3.2 + version: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + vite-plugin-fonts: + specifier: 0.7.0 + version: 0.7.0(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + vite-plugin-html-config: + specifier: 2.0.2 + version: 2.0.2(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + vite-plugin-inspect: + specifier: 11.4.1 + version: 11.4.1(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + vite-plugin-pages: + specifier: 0.33.3 + version: 0.33.3(@vue/compiler-sfc@3.5.38)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-router@4.6.4(vue@3.5.38(typescript@5.9.3))) + vite-plugin-pages-sitemap: + specifier: 1.7.1 + version: 1.7.1 + vite-plugin-pwa: + specifier: 1.2.0 + version: 1.2.0(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.1) + vite-plugin-static-copy: + specifier: 3.3.0 + version: 3.3.0(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + vite-plugin-vue-layouts: + specifier: 0.11.0 + version: 0.11.0(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-router@4.6.4(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3)) + vue-tsc: + specifier: 2.1.6 + version: 2.1.6(typescript@5.9.3) + + packages/hoppscotch-sh-admin: + dependencies: + '@fontsource-variable/inter': + specifier: 5.2.8 + version: 5.2.8 + '@fontsource-variable/material-symbols-rounded': + specifier: 5.2.45 + version: 5.2.45 + '@fontsource-variable/roboto-mono': + specifier: 5.2.9 + version: 5.2.9 + '@graphql-typed-document-node/core': + specifier: 3.2.0 + version: 3.2.0(graphql@16.13.2) + '@hoppscotch/ui': + specifier: 0.2.6 + version: 0.2.6(eslint@10.5.0(jiti@2.6.1))(terser@5.46.1)(typescript@5.9.3)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + '@hoppscotch/vue-toasted': + specifier: 0.1.0 + version: 0.1.0(vue@3.5.38(typescript@5.9.3)) + '@intlify/unplugin-vue-i18n': + specifier: 11.2.4 + version: 11.2.4(@vue/compiler-dom@3.5.38)(eslint@10.5.0(jiti@2.6.1))(rollup@4.60.2)(typescript@5.9.3)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-i18n@11.4.6(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3)) + '@types/cors': + specifier: 2.8.19 + version: 2.8.19 + '@urql/exchange-auth': + specifier: 3.0.0 + version: 3.0.0(@urql/core@6.0.2(graphql@16.13.2)) + '@urql/vue': + specifier: 2.1.1 + version: 2.1.1(@urql/core@6.0.2(graphql@16.13.2))(vue@3.5.38(typescript@5.9.3)) + '@vueuse/core': + specifier: 14.3.0 + version: 14.3.0(vue@3.5.38(typescript@5.9.3)) + axios: + specifier: 1.18.0 + version: 1.18.0 + cors: + specifier: 2.8.6 + version: 2.8.6 + date-fns: + specifier: 4.4.0 + version: 4.4.0 + fp-ts: + specifier: 2.16.11 + version: 2.16.11 + graphql: + specifier: 16.13.2 + version: 16.13.2 + io-ts: + specifier: 2.2.22 + version: 2.2.22(fp-ts@2.16.11) + lodash-es: + specifier: 4.18.1 + version: 4.18.1 + postcss: + specifier: 8.5.15 + version: 8.5.15 + prettier-plugin-tailwindcss: + specifier: 0.7.1 + version: 0.7.1(prettier@3.8.4) + rxjs: + specifier: 7.8.2 + version: 7.8.2 + tailwindcss: + specifier: 3.4.16 + version: 3.4.16(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) + tippy.js: + specifier: 6.3.7 + version: 6.3.7 + ts-node-dev: + specifier: 2.0.0 + version: 2.0.0(@types/node@25.9.3)(typescript@5.9.3) + unplugin-vue-components: + specifier: 30.0.0 + version: 30.0.0(@babel/parser@7.29.7)(vue@3.5.38(typescript@5.9.3)) + vue: + specifier: 3.5.38 + version: 3.5.38(typescript@5.9.3) + vue-i18n: + specifier: 11.4.6 + version: 11.4.6(vue@3.5.38(typescript@5.9.3)) + vue-router: + specifier: 4.6.4 + version: 4.6.4(vue@3.5.38(typescript@5.9.3)) + vue-tippy: + specifier: 6.7.1 + version: 6.7.1(vue@3.5.38(typescript@5.9.3)) + devDependencies: + '@graphql-codegen/cli': + specifier: 6.3.1 + version: 6.3.1(@parcel/watcher@2.5.6)(@types/node@25.9.3)(graphql@16.13.2)(typescript@5.9.3) + '@graphql-codegen/client-preset': + specifier: 5.3.0 + version: 5.3.0(graphql@16.13.2) + '@graphql-codegen/introspection': + specifier: 5.0.2 + version: 5.0.2(graphql@16.13.2) + '@graphql-codegen/typed-document-node': + specifier: 6.1.8 + version: 6.1.8(graphql@16.13.2) + '@graphql-codegen/typescript': + specifier: 5.0.10 + version: 5.0.10(graphql@16.13.2) + '@graphql-codegen/typescript-document-nodes': + specifier: 5.0.10 + version: 5.0.10(graphql@16.13.2) + '@graphql-codegen/typescript-operations': + specifier: 5.1.0 + version: 5.1.0(graphql@16.13.2) + '@graphql-codegen/urql-introspection': + specifier: 3.0.1 + version: 3.0.1(graphql@16.13.2) + '@iconify-json/lucide': + specifier: 1.2.114 + version: 1.2.114 + '@import-meta-env/cli': + specifier: 0.7.4 + version: 0.7.4(@import-meta-env/unplugin@0.6.3) + '@import-meta-env/unplugin': + specifier: 0.6.3 + version: 0.6.3 + '@types/lodash-es': + specifier: 4.17.12 + version: 4.17.12 + '@vitejs/plugin-vue': + specifier: 6.0.7 + version: 6.0.7(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + '@vue/compiler-sfc': + specifier: 3.5.38 + version: 3.5.38 + autoprefixer: + specifier: 10.5.0 + version: 10.5.0(postcss@8.5.15) + dotenv: + specifier: 17.4.2 + version: 17.4.2 + graphql-tag: + specifier: 2.12.7 + version: 2.12.7(graphql@16.13.2) + hoppscotch-backend: + specifier: workspace:^ + version: link:../hoppscotch-backend + npm-run-all: + specifier: 4.1.5 + version: 4.1.5 + sass: + specifier: 1.101.0 + version: 1.101.0 + ts-node: + specifier: 10.9.2 + version: 10.9.2(@types/node@25.9.3)(typescript@5.9.3) + typescript: + specifier: 5.9.3 + version: 5.9.3 + unplugin-fonts: + specifier: 1.4.0 + version: 1.4.0(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + unplugin-icons: + specifier: 22.5.0 + version: 22.5.0(@vue/compiler-sfc@3.5.38)(svelte@3.59.2)(vue-template-compiler@2.7.16) + vite: + specifier: 7.3.2 + version: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + vite-plugin-pages: + specifier: 0.33.2 + version: 0.33.2(@vue/compiler-sfc@3.5.38)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-router@4.6.4(vue@3.5.38(typescript@5.9.3))) + vite-plugin-vue-layouts: + specifier: 0.11.0 + version: 0.11.0(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-router@4.6.4(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3)) + vue-tsc: + specifier: 2.1.6 + version: 2.1.6(typescript@5.9.3) + +packages: + + '@0no-co/graphql.web@1.3.2': + resolution: {integrity: sha512-Q1+pRlLhE31GOY/2c9BAEnFTNxO7Awtc6fhhEDlxyCBQ2N0IhD32cPVvPChrK9mwBNSgRdW/sF1kd2e0ojHj1Q==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + graphql: + optional: true + + '@CuriousCorrelation/plugin-appload@https://codeload.github.com/CuriousCorrelation/tauri-plugin-appload/tar.gz/9d4528be4f385bccbe46859631d31aa2ee1ec0b6': + resolution: {gitHosted: true, integrity: sha512-fH4riUTO4+6/Pr87Q4PTV7EqGXEnSuLl01g3ws54V2k+A3LDzbCrSa+50XBbmPgroHxnhefkXig8q8qL099f0g==, tarball: https://codeload.github.com/CuriousCorrelation/tauri-plugin-appload/tar.gz/9d4528be4f385bccbe46859631d31aa2ee1ec0b6} + version: 0.1.0 + + '@CuriousCorrelation/plugin-relay@https://codeload.github.com/CuriousCorrelation/tauri-plugin-relay/tar.gz/273488c8f50a22ee707af6b50ccd5570851f8bc9': + resolution: {gitHosted: true, integrity: sha512-ipTfU7ci4UGP5+lpR2oDQGfVY3PvF3p5OtqKBVoNoRyIogHoqlpw6VsK+IfvsNbNN+LmAGKpQFyiZaRKErB2Lg==, tarball: https://codeload.github.com/CuriousCorrelation/tauri-plugin-relay/tar.gz/273488c8f50a22ee707af6b50ccd5570851f8bc9} + version: 0.1.0 + + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@angular-devkit/core@19.2.24': + resolution: {integrity: sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/core@19.2.27': + resolution: {integrity: sha512-3amNzoCVSKd7ah6l6lBQL4onwwJvqvam7FMoQBILrxtW5LB5ezh8gMSPuA4zJjKjoRzf9uoWdlzqv/84I52xZA==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/schematics-cli@19.2.27': + resolution: {integrity: sha512-wHYH6SVXVykhLzovUHtYor3Nl4SpIiITi7r9DQDaKYUD4hpRBx25W6N9eGuakT9Vd5tV/x6wmvQFWQZQwFB7eA==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + hasBin: true + + '@angular-devkit/schematics@19.2.24': + resolution: {integrity: sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@angular-devkit/schematics@19.2.27': + resolution: {integrity: sha512-/PZmyAlb2NGWPikRRuiWLdfHQd8Wrx6lX4HqvTcaDhlU43M3T0ud4PH2T3QDp7BzHYY92xtD8iPxX2asg67G1A==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@apideck/better-ajv-errors@0.3.7': + resolution: {integrity: sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==} + engines: {node: '>=10'} + peerDependencies: + ajv: '>=8' + + '@apidevtools/json-schema-ref-parser@14.0.1': + resolution: {integrity: sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==} + engines: {node: '>= 16'} + + '@apidevtools/json-schema-ref-parser@9.1.2': + resolution: {integrity: sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==} + + '@apidevtools/openapi-schemas@2.1.0': + resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==} + engines: {node: '>=10'} + + '@apidevtools/swagger-methods@3.0.2': + resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} + + '@apidevtools/swagger-parser@10.0.2': + resolution: {integrity: sha512-JFxcEyp8RlNHgBCE98nwuTkZT6eNFPc1aosWV6wPcQph72TSEEu1k3baJD4/x1qznU+JiDdz8F5pTwabZh+Dhg==} + peerDependencies: + openapi-types: '>=7' + + '@apidevtools/swagger-parser@10.0.3': + resolution: {integrity: sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==} + peerDependencies: + openapi-types: '>=7' + + '@apidevtools/swagger-parser@12.1.0': + resolution: {integrity: sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==} + peerDependencies: + openapi-types: '>=7' + + '@apollo/cache-control-types@1.0.3': + resolution: {integrity: sha512-F17/vCp7QVwom9eG7ToauIKdAxpSoadsJnqIfyryLFSkLSOEqu+eC5Z3N8OXcUVStuOMcNHlyraRsA6rRICu4g==} + peerDependencies: + graphql: 14.x || 15.x || 16.x + + '@apollo/protobufjs@1.2.7': + resolution: {integrity: sha512-Lahx5zntHPZia35myYDBRuF58tlwPskwHc5CWBZC/4bMKB6siTBWwtMrkqXcsNwQiFSzSx5hKdRPUmemrEp3Gg==} + hasBin: true + + '@apollo/server-gateway-interface@2.0.0': + resolution: {integrity: sha512-3HEMD6fSantG2My3jWkb9dvfkF9vJ4BDLRjMgsnD790VINtuPaEp+h3Hg9HOHiWkML6QsOhnaRqZ+gvhp3y8Nw==} + peerDependencies: + graphql: 14.x || 15.x || 16.x + + '@apollo/server-plugin-landing-page-graphql-playground@4.0.1': + resolution: {integrity: sha512-tWhQzD7DtiTO/wfbGvasryz7eJSuEh9XJHgRTMZI7+Wu/omylG5gH6K6ksg1Vccg8/Xuglfi2f1M5Nm/IlBBGw==} + engines: {node: '>=14.0'} + deprecated: The use of GraphQL Playground in Apollo Server was supported in previous versions, but this is no longer the case as of December 31, 2022. This package exists for v4 migration purposes only. We do not intend to resolve security issues or other bugs with this package if they arise, so please migrate away from this to [Apollo Server's default Explorer](https://www.apollographql.com/docs/apollo-server/api/plugin/landing-pages) as soon as possible. + peerDependencies: + '@apollo/server': ^4.0.0 + + '@apollo/server@5.5.1': + resolution: {integrity: sha512-Rn3g5TJQsMSUY23CWZTghWdBWyjX7dP1eaEBPkvmM2RHi82cDcpgTIkSCbGvtTUEGjwopLv1AAooU/n7iIZ20A==} + engines: {node: '>=20'} + peerDependencies: + graphql: ^16.11.0 + + '@apollo/usage-reporting-protobuf@4.1.1': + resolution: {integrity: sha512-u40dIUePHaSKVshcedO7Wp+mPiZsaU6xjv9J+VyxpoU/zL6Jle+9zWeG98tr/+SZ0nZ4OXhrbb8SNr0rAPpIDA==} + + '@apollo/utils.createhash@3.0.1': + resolution: {integrity: sha512-CKrlySj4eQYftBE5MJ8IzKwIibQnftDT7yGfsJy5KSEEnLlPASX0UTpbKqkjlVEwPPd4mEwI7WOM7XNxEuO05A==} + engines: {node: '>=16'} + + '@apollo/utils.dropunuseddefinitions@2.0.1': + resolution: {integrity: sha512-EsPIBqsSt2BwDsv8Wu76LK5R1KtsVkNoO4b0M5aK0hx+dGg9xJXuqlr7Fo34Dl+y83jmzn+UvEW+t1/GP2melA==} + engines: {node: '>=14'} + peerDependencies: + graphql: 14.x || 15.x || 16.x + + '@apollo/utils.fetcher@3.1.0': + resolution: {integrity: sha512-Z3QAyrsQkvrdTuHAFwWDNd+0l50guwoQUoaDQssLOjkmnmVuvXlJykqlEJolio+4rFwBnWdoY1ByFdKaQEcm7A==} + engines: {node: '>=16'} + + '@apollo/utils.isnodelike@3.0.0': + resolution: {integrity: sha512-xrjyjfkzunZ0DeF6xkHaK5IKR8F1FBq6qV+uZ+h9worIF/2YSzA0uoBxGv6tbTeo9QoIQnRW4PVFzGix5E7n/g==} + engines: {node: '>=16'} + + '@apollo/utils.keyvaluecache@4.0.0': + resolution: {integrity: sha512-mKw1myRUkQsGPNB+9bglAuhviodJ2L2MRYLTafCMw5BIo7nbvCPNCkLnIHjZ1NOzH7SnMAr5c9LmXiqsgYqLZw==} + engines: {node: '>=20'} + + '@apollo/utils.logger@3.0.0': + resolution: {integrity: sha512-M8V8JOTH0F2qEi+ktPfw4RL7MvUycDfKp7aEap2eWXfL5SqWHN6jTLbj5f5fj1cceHpyaUSOZlvlaaryaxZAmg==} + engines: {node: '>=16'} + + '@apollo/utils.printwithreducedwhitespace@2.0.1': + resolution: {integrity: sha512-9M4LUXV/fQBh8vZWlLvb/HyyhjJ77/I5ZKu+NBWV/BmYGyRmoEP9EVAy7LCVoY3t8BDcyCAGfxJaLFCSuQkPUg==} + engines: {node: '>=14'} + peerDependencies: + graphql: 14.x || 15.x || 16.x + + '@apollo/utils.removealiases@2.0.1': + resolution: {integrity: sha512-0joRc2HBO4u594Op1nev+mUF6yRnxoUH64xw8x3bX7n8QBDYdeYgY4tF0vJReTy+zdn2xv6fMsquATSgC722FA==} + engines: {node: '>=14'} + peerDependencies: + graphql: 14.x || 15.x || 16.x + + '@apollo/utils.sortast@2.0.1': + resolution: {integrity: sha512-eciIavsWpJ09za1pn37wpsCGrQNXUhM0TktnZmHwO+Zy9O4fu/WdB4+5BvVhFiZYOXvfjzJUcc+hsIV8RUOtMw==} + engines: {node: '>=14'} + peerDependencies: + graphql: 14.x || 15.x || 16.x + + '@apollo/utils.stripsensitiveliterals@2.0.1': + resolution: {integrity: sha512-QJs7HtzXS/JIPMKWimFnUMK7VjkGQTzqD9bKD1h3iuPAqLsxd0mUNVbkYOPTsDhUKgcvUOfOqOJWYohAKMvcSA==} + engines: {node: '>=14'} + peerDependencies: + graphql: 14.x || 15.x || 16.x + + '@apollo/utils.usagereporting@2.1.0': + resolution: {integrity: sha512-LPSlBrn+S17oBy5eWkrRSGb98sWmnEzo3DPTZgp8IQc8sJe0prDgDuppGq4NeQlpoqEHz0hQeYHAOA0Z3aQsxQ==} + engines: {node: '>=14'} + peerDependencies: + graphql: 14.x || 15.x || 16.x + + '@apollo/utils.withrequired@3.0.0': + resolution: {integrity: sha512-aaxeavfJ+RHboh7c2ofO5HHtQobGX4AgUujXP4CXpREHp9fQ9jPi6K9T1jrAKe7HIipoP0OJ1gd6JamSkFIpvA==} + engines: {node: '>=16'} + + '@apollographql/graphql-playground-html@1.6.29': + resolution: {integrity: sha512-xCcXpoz52rI4ksJSdOCxeOCn2DLocxwHf9dVT/Q90Pte1LX+LY+91SFtJF3KXVHH8kEin+g1KKCQPKBjZJfWNA==} + + '@ardatan/relay-compiler@12.0.0': + resolution: {integrity: sha512-9anThAaj1dQr6IGmzBMcfzOQKTa5artjuPmw8NYK/fiGEMjADbSguBY2FMDykt+QhilR3wc9VA/3yVju7JHg7Q==} + hasBin: true + peerDependencies: + graphql: '*' + + '@ardatan/relay-compiler@13.0.1': + resolution: {integrity: sha512-afG3YPwuSA0E5foouZusz5GlXKs74dObv4cuWyLyfKsYFj2r7oGRNB28v18HvwuLSQtQFCi+DpIe0TZkgQDYyg==} + peerDependencies: + graphql: '*' + + '@ardatan/sync-fetch@0.0.1': + resolution: {integrity: sha512-xhlTqH0m31mnsG0tIP4ETgfSB6gXDaYYsUWTrlUV93fFQPI9dd8hE0Ot6MHLCtqgB32hwJAC3YZMWlXZw7AleA==} + engines: {node: '>=14'} + + '@as-integrations/express5@1.1.2': + resolution: {integrity: sha512-BxfwtcWNf2CELDkuPQxi5Zl3WqY/dQVJYafeCBOGoFQjv5M0fjhxmAFZ9vKx/5YKKNeok4UY6PkFbHzmQrdxIA==} + engines: {node: '>=20'} + peerDependencies: + '@apollo/server': ^4.0.0 || ^5.0.0 + express: ^5.0.0 + + '@asamuzakjp/css-color@4.1.2': + resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.28.5': + resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.29.7': + resolution: {integrity: sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.27.1': + resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-remap-async-to-generator@7.29.7': + resolution: {integrity: sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.28.6': + resolution: {integrity: sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.29.7': + resolution: {integrity: sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5': + resolution: {integrity: sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7': + resolution: {integrity: sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1': + resolution: {integrity: sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7': + resolution: {integrity: sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1': + resolution: {integrity: sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7': + resolution: {integrity: sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7': + resolution: {integrity: sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1': + resolution: {integrity: sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7': + resolution: {integrity: sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6': + resolution: {integrity: sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7': + resolution: {integrity: sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-proposal-class-properties@7.18.6': + resolution: {integrity: sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-object-rest-spread@7.20.7': + resolution: {integrity: sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-flow@7.28.6': + resolution: {integrity: sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-assertions@7.28.6': + resolution: {integrity: sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-assertions@7.29.7': + resolution: {integrity: sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-arrow-functions@7.27.1': + resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-arrow-functions@7.29.7': + resolution: {integrity: sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.0': + resolution: {integrity: sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.7': + resolution: {integrity: sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.28.6': + resolution: {integrity: sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.29.7': + resolution: {integrity: sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoped-functions@7.27.1': + resolution: {integrity: sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoped-functions@7.29.7': + resolution: {integrity: sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.28.6': + resolution: {integrity: sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.29.7': + resolution: {integrity: sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.28.6': + resolution: {integrity: sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.29.7': + resolution: {integrity: sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.28.6': + resolution: {integrity: sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-class-static-block@7.29.7': + resolution: {integrity: sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.28.6': + resolution: {integrity: sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-classes@7.29.7': + resolution: {integrity: sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.28.6': + resolution: {integrity: sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.29.7': + resolution: {integrity: sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.28.5': + resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.29.7': + resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dotall-regex@7.28.6': + resolution: {integrity: sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dotall-regex@7.29.7': + resolution: {integrity: sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-keys@7.27.1': + resolution: {integrity: sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-keys@7.29.7': + resolution: {integrity: sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0': + resolution: {integrity: sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-dynamic-import@7.27.1': + resolution: {integrity: sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dynamic-import@7.29.7': + resolution: {integrity: sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-explicit-resource-management@7.28.6': + resolution: {integrity: sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-explicit-resource-management@7.29.7': + resolution: {integrity: sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-exponentiation-operator@7.28.6': + resolution: {integrity: sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-exponentiation-operator@7.29.7': + resolution: {integrity: sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.27.1': + resolution: {integrity: sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.29.7': + resolution: {integrity: sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-flow-strip-types@7.27.1': + resolution: {integrity: sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.27.1': + resolution: {integrity: sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.29.7': + resolution: {integrity: sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.27.1': + resolution: {integrity: sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.29.7': + resolution: {integrity: sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-json-strings@7.28.6': + resolution: {integrity: sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-json-strings@7.29.7': + resolution: {integrity: sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.27.1': + resolution: {integrity: sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.29.7': + resolution: {integrity: sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6': + resolution: {integrity: sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7': + resolution: {integrity: sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.27.1': + resolution: {integrity: sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.29.7': + resolution: {integrity: sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-amd@7.27.1': + resolution: {integrity: sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-amd@7.29.7': + resolution: {integrity: sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-systemjs@7.29.0': + resolution: {integrity: sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-systemjs@7.29.7': + resolution: {integrity: sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-umd@7.27.1': + resolution: {integrity: sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-umd@7.29.7': + resolution: {integrity: sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0': + resolution: {integrity: sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-new-target@7.27.1': + resolution: {integrity: sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-new-target@7.29.7': + resolution: {integrity: sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6': + resolution: {integrity: sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7': + resolution: {integrity: sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.28.6': + resolution: {integrity: sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.29.7': + resolution: {integrity: sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.28.6': + resolution: {integrity: sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.29.7': + resolution: {integrity: sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.27.1': + resolution: {integrity: sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.29.7': + resolution: {integrity: sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.28.6': + resolution: {integrity: sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.29.7': + resolution: {integrity: sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.28.6': + resolution: {integrity: sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.29.7': + resolution: {integrity: sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.27.7': + resolution: {integrity: sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.29.7': + resolution: {integrity: sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.28.6': + resolution: {integrity: sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.29.7': + resolution: {integrity: sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.28.6': + resolution: {integrity: sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.29.7': + resolution: {integrity: sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.27.1': + resolution: {integrity: sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.29.7': + resolution: {integrity: sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@7.28.0': + resolution: {integrity: sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.28.6': + resolution: {integrity: sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.0': + resolution: {integrity: sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.7': + resolution: {integrity: sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regexp-modifiers@7.28.6': + resolution: {integrity: sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-regexp-modifiers@7.29.7': + resolution: {integrity: sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-reserved-words@7.27.1': + resolution: {integrity: sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-reserved-words@7.29.7': + resolution: {integrity: sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.27.1': + resolution: {integrity: sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.29.7': + resolution: {integrity: sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.28.6': + resolution: {integrity: sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.29.7': + resolution: {integrity: sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.27.1': + resolution: {integrity: sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.29.7': + resolution: {integrity: sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.27.1': + resolution: {integrity: sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.29.7': + resolution: {integrity: sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typeof-symbol@7.27.1': + resolution: {integrity: sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typeof-symbol@7.29.7': + resolution: {integrity: sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-escapes@7.27.1': + resolution: {integrity: sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-escapes@7.29.7': + resolution: {integrity: sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-property-regex@7.28.6': + resolution: {integrity: sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-property-regex@7.29.7': + resolution: {integrity: sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.27.1': + resolution: {integrity: sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.29.7': + resolution: {integrity: sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-sets-regex@7.28.6': + resolution: {integrity: sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7': + resolution: {integrity: sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/preset-env@7.29.2': + resolution: {integrity: sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-env@7.29.7': + resolution: {integrity: sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-modules@0.1.6-no-external-plugins': + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/standalone@7.29.2': + resolution: {integrity: sha512-VSuvywmVRS8efooKrvJzs6BlVSxRvAdLeGrAKUrWoBx1fFBSeE/oBpUZCQ5BcprLyXy04W8skzz7JT8GqlNRJg==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + + '@boringer-avatars/vue3@0.2.1': + resolution: {integrity: sha512-KzAfh31SDXToTvFL0tBNG5Ur+VzfD1PP4jmY5/GS+eIuObGTIAiUu9eiht0LjuAGI+0xCgnaEgsTrOx8H3vLOQ==} + peerDependencies: + vue: 3.5.38 + + '@codemirror/autocomplete@6.20.0': + resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} + + '@codemirror/commands@6.10.0': + resolution: {integrity: sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==} + + '@codemirror/lang-javascript@6.2.4': + resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/lang-xml@6.1.0': + resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==} + + '@codemirror/language@6.11.3': + resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==} + + '@codemirror/legacy-modes@6.5.2': + resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==} + + '@codemirror/lint@6.9.2': + resolution: {integrity: sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==} + + '@codemirror/merge@6.11.2': + resolution: {integrity: sha512-NO5EJd2rLRbwVWLgMdhIntDIhfDtMOKYEZgqV5WnkNUS2oXOCVWLPjG/kgl/Jth2fGiOuG947bteqxP9nBXmMg==} + + '@codemirror/search@6.5.11': + resolution: {integrity: sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==} + + '@codemirror/state@6.5.2': + resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==} + + '@codemirror/view@6.38.8': + resolution: {integrity: sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@commitlint/cli@20.5.2': + resolution: {integrity: sha512-IXr5xd3IX8SEG936P8gcpozRplkDeDSwJlt8UvoY1winwIy2udTbQ/cOCgbaaxcjdDqVoS29VUcz/wkwnSozbA==} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@20.5.0': + resolution: {integrity: sha512-t3Ni88rFw1XMa4nZHgOKJ8fIAT9M2j5TnKyTqJzsxea7FUetlNdYFus9dz+MhIRZmc16P0PPyEfh6X2d/qw8SA==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@20.5.0': + resolution: {integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==} + engines: {node: '>=v18'} + + '@commitlint/ensure@20.5.0': + resolution: {integrity: sha512-IpHqAUesBeW1EDDdjzJeaOxU9tnogLAyXLRBn03SHlj1SGENn2JGZqSWGkFvBJkJzfXAuCNtsoYzax+ZPS+puw==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@20.0.0': + resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==} + engines: {node: '>=v18'} + + '@commitlint/format@20.5.0': + resolution: {integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@20.5.0': + resolution: {integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==} + engines: {node: '>=v18'} + + '@commitlint/lint@20.5.0': + resolution: {integrity: sha512-jiM3hNUdu04jFBf1VgPdjtIPvbuVfDTBAc6L98AWcoLjF5sYqkulBHBzlVWll4rMF1T5zeQFB6r//a+s+BBKlA==} + engines: {node: '>=v18'} + + '@commitlint/load@20.5.2': + resolution: {integrity: sha512-zmr0RGDz7vThxW1I8ohb9yBjnGuH9mqwJpn21hInjGla+IlLOkS9ey0+dD5HlkzFlY0lX2NYdA2lDW6/0rO7Gw==} + engines: {node: '>=v18'} + + '@commitlint/message@20.4.3': + resolution: {integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==} + engines: {node: '>=v18'} + + '@commitlint/parse@20.5.0': + resolution: {integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==} + engines: {node: '>=v18'} + + '@commitlint/read@20.5.0': + resolution: {integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@20.5.2': + resolution: {integrity: sha512-8EhSCU9eNos/5cI1yg64GW79UH1c64O69AfStCsj4zqy6An/qIphVEXj4/+2M6056T8coz00f+UXFn4WUUP1HQ==} + engines: {node: '>=v18'} + + '@commitlint/rules@20.5.0': + resolution: {integrity: sha512-5NdQXQEdnDPT5pK8O39ZA7HohzPRHEsDGU23cyVCNPQy4WegAbAwrQk3nIu7p2sl3dutPk8RZd91yKTrMTnRkQ==} + engines: {node: '>=v18'} + + '@commitlint/to-lines@20.0.0': + resolution: {integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==} + engines: {node: '>=v18'} + + '@commitlint/top-level@20.4.3': + resolution: {integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==} + engines: {node: '>=v18'} + + '@commitlint/types@20.5.0': + resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} + engines: {node: '>=v18'} + + '@conventional-changelog/git-client@2.7.0': + resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==} + engines: {node: '>=18'} + peerDependencies: + conventional-commits-filter: ^5.0.0 + conventional-commits-parser: ^6.4.0 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@css-inline/css-inline-android-arm-eabi@0.20.0': + resolution: {integrity: sha512-w1+cicyd2xGzuOcFYxL9Yp3E/KT6opGpdhaKo1vFAepyn8zHrpAR3c1DHH1RWSWK3ZVcD3ne1naqJXQa4k/9uw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@css-inline/css-inline-android-arm64@0.20.0': + resolution: {integrity: sha512-V6ELV+DEjhYPqRcyCezUcMDSrXINMSCL4RBseEqWG18WhOZ9GoGd5XFpZzciQhHq/2MQS+06Jcc6RX59k7QS5g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@css-inline/css-inline-darwin-arm64@0.20.0': + resolution: {integrity: sha512-hT8Mtwp4PJ9mMYFDG/xg7KCa7nPqseJdXaajxXVjXB8+6oemxgx+7TUO7HSfmeY4Ox7X2vaNfaIfti4ySgNvyg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@css-inline/css-inline-darwin-x64@0.20.0': + resolution: {integrity: sha512-caFvVySabHZG4N1QUysbhWp2+VstMlYLysZYfAX0I6c9QfWcr+OZThoGOO2gC9lYy6QMCTBSD+9t+iceH9oBQw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@css-inline/css-inline-linux-arm-gnueabihf@0.20.0': + resolution: {integrity: sha512-9/TdB6cHgJ285uL7Y+1XjdJGjIQOeF9/YNq47N+azf2SiYdA3ahGYBmZhCGtYG7aBTJly9iJCxjP9ZZN8+Lajw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@css-inline/css-inline-linux-arm64-gnu@0.20.0': + resolution: {integrity: sha512-umcG26teSSUxWGQm7sECI8KweUgBgq9Cqd6yNMTNzqC1K56ZhW3Iwi7QewVNGOpVPXoGsywIIwIuY9lyed1Jdw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@css-inline/css-inline-linux-arm64-musl@0.20.0': + resolution: {integrity: sha512-wHQykZw3pX2R5Yp5VChOWzVXXO3XPdgWkQH1B9QBmft926EOkZpwxvubeAYy9I89qJzzdGNqiRl26+AUP88J3A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@css-inline/css-inline-linux-x64-gnu@0.20.0': + resolution: {integrity: sha512-lHQZ+31CN3XJTn5MMFv1NYjyvuhAWEvul0fAYyppw4haA1TM+9UuA9jXdjqWXp26ItmainUnDMBlNLYQwMYt7g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@css-inline/css-inline-linux-x64-musl@0.20.0': + resolution: {integrity: sha512-DO/cba/zndTY3s86nNNfeHx7DvrvLb+IxhkQWTr29IQeiUgbawCH9X3B/4Li2eo/sEx/imOOnYDdXYu8PeX2Fg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@css-inline/css-inline-win32-arm64-msvc@0.20.0': + resolution: {integrity: sha512-1o8xla5ljVHS7SguH6nTV6uoAIMRZv+2MrcxfLRObpkUtCO7oKXDeWnia7XlmnLhDX8Ncx5bDOenZb1N35kCiA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@css-inline/css-inline-win32-x64-msvc@0.20.0': + resolution: {integrity: sha512-pdO/QXLRwuq/RxO5PSFMhmWGJSQOvkiDyV4RrewEkE7SOCY8HAYNhx1T0y0pj2WzxuVdbNQbpwiCHAdQNplUJw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@css-inline/css-inline@0.20.0': + resolution: {integrity: sha512-WABsvMSBs/DDadwhAUDw6uXwUE6w4DC/bnC4E2Y2rqbCTw5E1PPfj6VksnSCALkv9ynZfYGYYO4+Rvhmn5kgpw==} + engines: {node: '>= 10'} + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.1': + resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@digitak/esrun@3.2.26': + resolution: {integrity: sha512-mL0bw7NhKVghp7mVsPwnAMhCn4NGAsk0KKFmAfnrYAZ/QCXR5xLXIYP82zLMjcsQag8DD6i1c+Yrm/57StYVzg==} + engines: {node: '>=14.0'} + hasBin: true + + '@digitak/grubber@3.1.4': + resolution: {integrity: sha512-pqsnp2BUYlDoTXWG34HWgEJse/Eo1okRgNex8IG84wHrJp8h3SakeR5WhB4VxSA2+/D+frNYJ0ch3yXzsfNDoA==} + + '@electric-sql/pglite-socket@0.1.1': + resolution: {integrity: sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1': + resolution: {integrity: sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==} + peerDependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': + resolution: {integrity: sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==} + + '@emnapi/core@1.9.1': + resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + + '@envelop/core@5.5.1': + resolution: {integrity: sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw==} + engines: {node: '>=18.0.0'} + + '@envelop/instrumentation@1.0.0': + resolution: {integrity: sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==} + engines: {node: '>=18.0.0'} + + '@envelop/types@5.2.1': + resolution: {integrity: sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==} + engines: {node: '>=18.0.0'} + + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + + '@esbuild-plugins/node-globals-polyfill@0.2.3': + resolution: {integrity: sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==} + peerDependencies: + esbuild: '*' + + '@esbuild-plugins/node-modules-polyfill@0.2.2': + resolution: {integrity: sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==} + peerDependencies: + esbuild: '*' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.17.19': + resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.15.18': + resolution: {integrity: sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.17.19': + resolution: {integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.17.19': + resolution: {integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.17.19': + resolution: {integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.17.19': + resolution: {integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.17.19': + resolution: {integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.17.19': + resolution: {integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.17.19': + resolution: {integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.17.19': + resolution: {integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.17.19': + resolution: {integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.15.18': + resolution: {integrity: sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.17.19': + resolution: {integrity: sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.17.19': + resolution: {integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.17.19': + resolution: {integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.17.19': + resolution: {integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.17.19': + resolution: {integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.17.19': + resolution: {integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.17.19': + resolution: {integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.17.19': + resolution: {integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.17.19': + resolution: {integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.17.19': + resolution: {integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.17.19': + resolution: {integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.17.19': + resolution: {integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/js@9.39.2': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@exodus/schemasafe@1.3.0': + resolution: {integrity: sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==} + + '@faker-js/faker@5.5.3': + resolution: {integrity: sha512-R11tGE6yIFwqpaIqcfkcg7AICXzFg14+5h5v0TfF/9+RMDL6jhzCy/pxHVOfbALGdtVYdt6JdR21tuxEgl34dw==} + deprecated: Please update to a newer version. + + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + + '@fontsource-variable/inter@5.2.8': + resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} + + '@fontsource-variable/material-symbols-rounded@5.2.45': + resolution: {integrity: sha512-CBHbmWtViOI0Qo2+rloF1lTfNEfpW/3JCykh1i3XIJ1xQUY/JCMdf72N9Za4LVuEDJDl7FL3XbSWuMJSyPXRkQ==} + + '@fontsource-variable/roboto-mono@5.2.9': + resolution: {integrity: sha512-OzFO2AXlSGcXl/NcXS3CGjImb6rczCByPJ1C+Dzp9kkYOrUPyrGTuAtqPcmA/d+nZGX5oyOWKXLk5BrwVLYqkw==} + + '@glideapps/ts-necessities@2.2.3': + resolution: {integrity: sha512-gXi0awOZLHk3TbW55GZLCPP6O+y/b5X1pBXKBVckFONSwF1z1E5ND2BGJsghQFah+pW7pkkyFb2VhUQI2qhL5w==} + + '@graphql-codegen/add@6.0.1': + resolution: {integrity: sha512-MSylSekjpVWbOBw2A/2ssk1fPY54sYb6Qk2C4AX5u7s2R+2pMQ9ws7DTXo8VU9qwTgWwVp6vGfdQ0AMpAn4Iug==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/cli@6.3.1': + resolution: {integrity: sha512-I5KkyX1SgQZPojMeQTRydB6fml4cysZq/mIdhNW4rmqdoOcTgdMPq1Tl+wtRp1VpBAOrBazJUJh1nAqJMMSPIQ==} + engines: {node: '>=16'} + hasBin: true + peerDependencies: + '@parcel/watcher': ^2.1.0 + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + '@parcel/watcher': + optional: true + + '@graphql-codegen/client-preset@5.3.0': + resolution: {integrity: sha512-K9FON+j7qyxAUDuSGqI3ofb7lWTBs16oPTYpu14lhdL4DKZQSHLyc8EMYU9e3KcyQ/13gU/d6culOppzAuexLA==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + graphql-sock: ^1.0.0 + peerDependenciesMeta: + graphql-sock: + optional: true + + '@graphql-codegen/core@5.0.2': + resolution: {integrity: sha512-7RX0wwjoWPlLG/tUmpaTK91ZZqHcACNWpRL0nGnnJaJrORie9pgmX8JPrcwBgYiHSC+3ERo9xY91RFPem/VrpQ==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/gql-tag-operations@5.2.0': + resolution: {integrity: sha512-B9gtJ4ziqpIv+7mHqwjtpYLFOuv0GmmRGpNDoWKM2VIx4OQqgI84d6OHKYCVeO7yu3mUr0QPvUgkSyuLVrdukA==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/introspection@5.0.2': + resolution: {integrity: sha512-2Y1xC4A/6yudxvpyHLF6wcrZSm1BBGsaxabbZJCWebImXdYNU+yAdbiiaHfYrHMUEVgPnjo/qo4gt0m8JqeRHQ==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/plugin-helpers@3.1.2': + resolution: {integrity: sha512-emOQiHyIliVOIjKVKdsI5MXj312zmRDwmHpyUTZMjfpvxq/UVAHUJIVdVf+lnjjrI+LXBTgMlTWTgHQfmICxjg==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/plugin-helpers@6.3.0': + resolution: {integrity: sha512-Auc+/B7okDx9+pVgLVliZtZLYh6iltWXlnzzM+bRE+zh1T4r3hKbnr8xAmtT937ArfSgk5GHcQHr8LfPYnrRBg==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/schema-ast@5.0.2': + resolution: {integrity: sha512-jl1F/9IjRkJisEb9B0ayG4QGqYlPldLRy8ojDdmL9NE1NsdB5ROfxQnSqyC3g+wuvBhWX7kZgMRQYn3RU1I5bA==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/typed-document-node@6.1.8': + resolution: {integrity: sha512-+qDdiJSQ7Ol+vpLMAH8ZJok50CvlYxA6seQ7cwEa3emXt8MmH5hh3zdc9unQlPc7bynoJHRCgoKk7E0B7hry0w==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/typescript-document-nodes@5.0.10': + resolution: {integrity: sha512-GwvQFw1cP1kDYL7p0CoXkqfcMqbweKSrqcWzpw0LIomz0QrrwkZJQeQjhlfFe2DjRTtRzRpS/jd+Prg4mJ6QbQ==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/typescript-operations@5.1.0': + resolution: {integrity: sha512-JlmjbFl0EnsfMDIYvTE1Q0kAOrntVEZ+ZfBqWTP91g4e0F/TzuwJ/V4tiFmeDf5dx/rf9AK4VkPehIdxu7TYhw==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + graphql-sock: ^1.0.0 + peerDependenciesMeta: + graphql-sock: + optional: true + + '@graphql-codegen/typescript-urql-graphcache@3.1.1': + resolution: {integrity: sha512-ArnNFH/NGQkIhc6R6BLJ//qzudDExMGxiqiOoHolVzqVoxLbxVUFzy9XCeZOx0ZML2IUpBhJPPCzfyUxBkVcQA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + '@urql/exchange-graphcache': ^5.2.0 || ^6.0.0 || ^7.0.0 + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + graphql-tag: ^2.0.0 + + '@graphql-codegen/typescript@5.0.10': + resolution: {integrity: sha512-Pa8OFmL9TdhEYnLYJLYA9EhP8eEeivP/YDYq4Nb8LQaL7GXm4TGX8zELYaCM9Fu8M3iZb7iQGMt7qc+1lXz8XQ==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/urql-introspection@3.0.1': + resolution: {integrity: sha512-SNpC3L3l1aJgacdo2OqaPB0/bj6xwixCXYFrLjraSfgGj12L6o3LhKYS+kEI4jIjr7KkPKAFsPke3C8EqC3Tig==} + engines: {node: '>= 16.0.0'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/visitor-plugin-common@2.13.8': + resolution: {integrity: sha512-IQWu99YV4wt8hGxIbBQPtqRuaWZhkQRG2IZKbMoSvh0vGeWb3dB0n0hSgKaOOxDY+tljtOf9MTcUYvJslQucMQ==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/visitor-plugin-common@6.3.0': + resolution: {integrity: sha512-vGBoE+4huzZyNhyGSAhXAkdROHlwKxxuziZm4XtP1mxe7nuI+VgyOmXebafLijbmuDsptPXQN0C/htL54O8hrg==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-hive/signal@2.0.0': + resolution: {integrity: sha512-Pz8wB3K0iU6ae9S1fWfsmJX24CcGeTo6hE7T44ucmV/ALKRj+bxClmqrYcDT7v3f0d12Rh4FAXBb6gon+WkDpQ==} + engines: {node: '>=20.0.0'} + + '@graphql-tools/apollo-engine-loader@8.0.30': + resolution: {integrity: sha512-hUydKGGECrWloERMmfoMzHZi12X99AM9geCGF5XVsv4iMRl/Iyuet24th4kC9bZ8MlAdCwAwtUsCyv9uRfYwSA==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/batch-execute@10.0.8': + resolution: {integrity: sha512-Kobt37qrVTFhX4HUK5/vPgMXFw/5f97AzmAlfmDBSRh/GnoAmLKCb48FrEI3gdeIwZB2fEhVHJyDqsojldnLQA==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/batch-execute@8.5.22': + resolution: {integrity: sha512-hcV1JaY6NJQFQEwCKrYhpfLK8frSXDbtNMoTur98u10Cmecy1zrqNKSqhEyGetpgHxaJRqszGzKeI3RuroDN6A==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/code-file-loader@8.1.32': + resolution: {integrity: sha512-gR5mNQjn0BugDL8a4A+ovS2KEvU52RNOGnbwiq9oWAEHiSv7iqJu77bpWARTzlE1ZFPK5MSQe9218+1t5PbXmQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/delegate@12.0.14': + resolution: {integrity: sha512-/xCDM8zlCk1Lccww9asOIpxna9IFpIlol4yGsBD9Y2+3/Zu5k4/HzDC8LKJtw5MxdG+uJN1l9nRepr4GeBC4kA==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/delegate@9.0.35': + resolution: {integrity: sha512-jwPu8NJbzRRMqi4Vp/5QX1vIUeUPpWmlQpOkXQD2r1X45YsVceyUUBnktCrlJlDB4jPRVy7JQGwmYo3KFiOBMA==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/documents@1.0.1': + resolution: {integrity: sha512-aweoMH15wNJ8g7b2r4C4WRuJxZ0ca8HtNO54rkye/3duxTkW4fGBEutCx03jCIr5+a1l+4vFJNP859QnAVBVCA==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-common@1.0.6': + resolution: {integrity: sha512-23/K5C+LSlHDI0mj2SwCJ33RcELCcyDUgABm1Z8St7u/4Z5+95i925H/NAjUyggRjiaY8vYtNiMOPE49aPX1sg==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-graphql-ws@0.0.14': + resolution: {integrity: sha512-P2nlkAsPZKLIXImFhj0YTtny5NQVGSsKnhi7PzXiaHSXc6KkzqbWZHKvikD4PObanqg+7IO58rKFpGXP7eeO+w==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-graphql-ws@3.1.5': + resolution: {integrity: sha512-WXRsfwu9AkrORD9nShrd61OwwxeQ5+eXYcABRR3XPONFIS8pWQfDJGGqxql9/227o/s0DV5SIfkBURb5Knzv+A==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-http@0.1.10': + resolution: {integrity: sha512-hnAfbKv0/lb9s31LhWzawQ5hghBfHS+gYWtqxME6Rl0Aufq9GltiiLBcl7OVVOnkLF0KhwgbYP1mB5VKmgTGpg==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-http@3.2.1': + resolution: {integrity: sha512-53i0TYO0cznIlZDJcnq4gQ6SOZ8efGgCDV33MYh6oqEapcp36tCMEVnVGVxcX5qRRyNHkqTY6hkA+/AyK9kicQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-legacy-ws@0.0.11': + resolution: {integrity: sha512-4ai+NnxlNfvIQ4c70hWFvOZlSUN8lt7yc+ZsrwtNFbFPH/EroIzFMapAxM9zwyv9bH38AdO3TQxZ5zNxgBdvUw==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-legacy-ws@1.1.28': + resolution: {integrity: sha512-O4uj93GG9iUb3s32eyhUohvyfA8mLhN8FvGzEdK628hFQPhZN75yurtVFrR08DHex71mQ3wYCCFkErpwdJbDDQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor@0.0.20': + resolution: {integrity: sha512-GdvNc4vszmfeGvUqlcaH1FjBoguvMYzxAfT6tDd4/LgwymepHhinqLNA5otqwVLW+JETcDaK7xGENzFomuE6TA==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor@1.5.3': + resolution: {integrity: sha512-mgBFC0bsrZPZLu9EnydpMnAuQ8Iiq0CEbUcsmvXsm2/iYektGHDN/+bmb7hicA6dWZtdPfklYJmr21WD0GnOfA==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/git-loader@8.0.36': + resolution: {integrity: sha512-PDDakesRu8FJYHJLf9/gkTweh8M19Bymz9i+vOlk9OTs9XmNcCqKM+1S610KX2AodvuBFz/xbesjTtTJIppLPg==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/github-loader@9.1.2': + resolution: {integrity: sha512-jhRJncj9Wkr1Cd8Mo3QI2oG6fTw5ILr1/OXcHIqx744NBj8pPwQBXmQzZqh7MXxbekl2EAcum7SJIjq1HpYcPA==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/graphql-file-loader@7.5.17': + resolution: {integrity: sha512-hVwwxPf41zOYgm4gdaZILCYnKB9Zap7Ys9OhY1hbwuAuC4MMNY9GpUjoTU3CQc3zUiPoYStyRtUGkHSJZ3HxBw==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/graphql-file-loader@8.1.14': + resolution: {integrity: sha512-CfAcsSEVkkHfEXLFzrd5rUYpcQEGWNV8lfc1Tb1p5m9HnYICzDDH08I5V33iMrEDza3GuujjjRBYqplBkqwIow==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/graphql-tag-pluck@8.3.31': + resolution: {integrity: sha512-ema2RRPZGj8TKruNElyDBHVCNFMxioGIVfLBuiA+GdfmRGt95b/i7Uksnj4EwItA6MCmhxokxZoa/fl6mJt3tw==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/import@6.7.18': + resolution: {integrity: sha512-XQDdyZTp+FYmT7as3xRWH/x8dx0QZA2WZqfMF5EWb36a0PiH7WwlRQYIdyYXj8YCLpiWkeBXgBRHmMnwEYR8iQ==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/import@7.1.14': + resolution: {integrity: sha512-aqLcu04aEidszbXM6M0PWWL8bP17eX9sxXwjYWpglLvIRd4NFqb3C9QzBY8pleqXNMtWqXktlm9BQjevgSrirQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/json-file-loader@7.4.18': + resolution: {integrity: sha512-AJ1b6Y1wiVgkwsxT5dELXhIVUPs/u3VZ8/0/oOtpcoyO/vAeM5rOvvWegzicOOnQw8G45fgBRMkkRfeuwVt6+w==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/json-file-loader@8.0.28': + resolution: {integrity: sha512-qgCsSkPArnjlNkcYpgGKiXxCTNkrAT9E+l1LhR+Por2jTlKBBeZ8stortkQ/PNDDjuL0WPrLQmHKhNPHabnB3A==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/load@7.8.14': + resolution: {integrity: sha512-ASQvP+snHMYm+FhIaLxxFgVdRaM0vrN9wW2BKInQpktwWTXVyk+yP5nQUCEGmn0RTdlPKrffBaigxepkEAJPrg==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/load@8.1.10': + resolution: {integrity: sha512-hjcvfEFtwtc8vGi46wtpmGWadNzfEhzbjqinyFIZuIZPlR4aYdWQtqWtY/RMM4Ew4t1USkMNm6xrqC2TH1vCSA==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/merge@8.4.2': + resolution: {integrity: sha512-XbrHAaj8yDuINph+sAfuq3QCZ/tKblrTLOpirK0+CAgNlZUCHs0Fa+xtMUURgwCVThLle1AF7svJCxFizygLsw==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/merge@9.1.9': + resolution: {integrity: sha512-iHUWNjRHeQRYdgIMIuChThOwoKzA9vrzYeslgfBo5eUYEyHGZCoDPjAavssoYXLwstYt1dZj2J22jSzc2DrN0Q==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/optimize@1.4.0': + resolution: {integrity: sha512-dJs/2XvZp+wgHH8T5J2TqptT9/6uVzIYvA6uFACha+ufvdMBedkfR4b4GbT8jAKLRARiqRTxy3dctnwkTM2tdw==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/optimize@2.0.0': + resolution: {integrity: sha512-nhdT+CRGDZ+bk68ic+Jw1OZ99YCDIKYA5AlVAnBHJvMawSx9YQqQAIj4refNc1/LRieGiuWvhbG3jvPVYho0Dg==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/relay-operation-optimizer@6.5.18': + resolution: {integrity: sha512-mc5VPyTeV+LwiM+DNvoDQfPqwQYhPV/cl5jOBjTgSniyaq8/86aODfMkrE2OduhQ5E00hqrkuL2Fdrgk0w1QJg==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/relay-operation-optimizer@7.1.4': + resolution: {integrity: sha512-cwOD/GEo/R//1uGCP0/urIxsMFoUgzkJVyMt9BDM2HhQhU6rSgH5l6lFukAFTJyPJVdyeOdYm2i0Jj5vYWbHTw==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/schema@10.0.33': + resolution: {integrity: sha512-O6P3RIftO0jafnSsFAqpjurUuUxJ43s/AdPVLQsBkI6y4Ic/tKm4C1Qm1KKQsCDTOxXPJClh/v3g7k7yLKCFBQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/schema@9.0.19': + resolution: {integrity: sha512-oBRPoNBtCkk0zbUsyP4GaIzCt8C0aCI4ycIRUL67KK5pOHljKLBBtGT+Jr6hkzA74C8Gco8bpZPe7aWFjiaK2w==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/url-loader@7.17.18': + resolution: {integrity: sha512-ear0CiyTj04jCVAxi7TvgbnGDIN2HgqzXzwsfcqiVg9cvjT40NcMlZ2P1lZDgqMkZ9oyLTV8Bw6j+SyG6A+xPw==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/url-loader@9.1.2': + resolution: {integrity: sha512-pVSiPrfWQKb3jq23Pl7EjbB2uv3tgZLnWo/axkmg4itAEZ5s/vV/jKa8P1HZzUnSVUTR+8tcEZVeNsUbzFCbkg==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/utils@11.0.1': + resolution: {integrity: sha512-pNyCOb95ab/z3zkkiPwIPYxigX7IcpyFVcgD1XACDEvg/7yGnKCESx3k/XHEeneKYx/aWKGzEh/uuf6M6Q8HOw==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/utils@11.1.0': + resolution: {integrity: sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/utils@9.2.1': + resolution: {integrity: sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/wrap@11.1.14': + resolution: {integrity: sha512-ebSVT7apxr+88q3Wy0i4AyRmJ42I0SuMqjPIn1fqW14yCTQRZG8YLuIALG1gKR936+GkfMLOrADh6egJvdlN6Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/wrap@9.4.2': + resolution: {integrity: sha512-DFcd9r51lmcEKn0JW43CWkkI2D6T9XI1juW/Yo86i04v43O9w2/k4/nx2XTJv4Yv+iXwUw7Ok81PGltwGJSDSA==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-typed-document-node/core@3.2.0': + resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@guolao/vue-monaco-editor@1.6.0': + resolution: {integrity: sha512-w2IiJ6eJGGeuIgCK6EKZOAfhHTTUB5aZwslzwGbZ5e89Hb4avx6++GkLTW8p84Sng/arFMjLPPxSBI56cFudyQ==} + peerDependencies: + '@vue/composition-api': ^1.7.2 + monaco-editor: '>=0.43.0' + vue: 3.5.38 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + '@hapi/b64@5.0.0': + resolution: {integrity: sha512-ngu0tSEmrezoiIaNGG6rRvKOUkUuDdf4XTPnONHGYfSGRmDqPZX5oJL6HAdKTo1UQHECbdB4OzhWrfgVppjHUw==} + + '@hapi/boom@9.1.4': + resolution: {integrity: sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==} + + '@hapi/cryptiles@5.1.0': + resolution: {integrity: sha512-fo9+d1Ba5/FIoMySfMqPBR/7Pa29J2RsiPrl7bkwo5W5o+AN1dAYQRi4SPrPwwVxVGKjgLOEWrsvt1BonJSfLA==} + engines: {node: '>=12.0.0'} + + '@hapi/hoek@9.3.0': + resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: 4.12.25 + + '@hoppscotch/httpsnippet@3.0.9': + resolution: {integrity: sha512-XTs2SYxOItC7Go38VsFYtnxEk6C5JhscEpZHgd9+klyah+Iy2uKLFaBFq9M/10YLhwfPNVP3UpGsL/jY50zQgQ==} + engines: {node: '^14.19.1 || ^16.14.2 || ^18.0.0 '} + hasBin: true + + '@hoppscotch/ui@0.2.6': + resolution: {integrity: sha512-wVM+Ba7XTswVZ0hNKAf1559Hw2/zp4KLBz9nxDaIH3t9JtB7/DCV+Nvjm3aiGdiw7NKX9tKN4ESQQCqqvH9bGQ==} + engines: {node: '>=16'} + peerDependencies: + vue: 3.5.38 + + '@hoppscotch/vue-sonner@1.2.3': + resolution: {integrity: sha512-P1gyvHHLsPeB8lsLP5SrqwQatuwOKtbsP83sKhyIV3WL2rJj3+DiFfqo2ErNBa+Sl0gM68o1V+wuOS7zbR//6g==} + + '@hoppscotch/vue-toasted@0.1.0': + resolution: {integrity: sha512-DIgmeTHxWwX5UeaHLEqDYNLJFGRosx/5N1fCHkaO8zt+sZv8GrHlkrIpjfKF2drmA3kKw5cY42Cw7WuCoabR3g==} + peerDependencies: + vue: 3.5.38 + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@iconify-json/lucide@1.2.114': + resolution: {integrity: sha512-NbvH3B1BYo6wBtS7joLi7f2UVQOqK2dtZodMFf3kkBs+Tnh9TkRuy8oVHr1RM8UK6bUtvAXxfNlGAah0CuvPCw==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + + '@import-meta-env/cli@0.7.4': + resolution: {integrity: sha512-Wv89gWWSPEZYV6BznaOvckOQ3pcRelsTi2+4zTyM60O9pSwHjuIecIR5QfZqCpKqzomvbZStHl519C5YsfyrcQ==} + hasBin: true + peerDependencies: + '@import-meta-env/babel': '>=0.5.0' + '@import-meta-env/swc': '>=0.4.5' + '@import-meta-env/unplugin': '>=0.6.0' + peerDependenciesMeta: + '@import-meta-env/babel': + optional: true + '@import-meta-env/swc': + optional: true + '@import-meta-env/unplugin': + optional: true + + '@import-meta-env/unplugin@0.6.3': + resolution: {integrity: sha512-E5immW55uSmqPwsztB5zw5i/fFoHHQRMHFs0tSjGkRqatZma40SYnEA1FWhP48OO1W0H9r5ky3HQWGJII0/P/Q==} + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.3.2': + resolution: {integrity: sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@intlify/bundle-utils@11.2.4': + resolution: {integrity: sha512-eE18yR9eM9k5n8snCkHIYp2MuVTxa19aF8z9OMyxXWv0frz2HlBZDGIPFjA38pP3OJ1IlRBXC/dW5GILeLMSCQ==} + engines: {node: '>= 22.13'} + peerDependencies: + petite-vue-i18n: '*' + vue-i18n: '*' + peerDependenciesMeta: + petite-vue-i18n: + optional: true + vue-i18n: + optional: true + + '@intlify/core-base@11.4.6': + resolution: {integrity: sha512-EOeHO95XESK9IFHgHeZXunsM/WBAoCA0DlaWODvx14vKmetAuS97t+l6Xe9hTUqntPpF93vtVSjjUDafw3wXMw==} + engines: {node: '>= 22'} + + '@intlify/devtools-types@11.4.6': + resolution: {integrity: sha512-wowQPpNem56b2d43IJmqbrzG2FeBKe5f/kUGlpNuBmXs6OSqncF8m1+1lxHuW8ISZJF0ma2RkW3iLkw0g0G4VA==} + engines: {node: '>= 22'} + + '@intlify/message-compiler@11.4.6': + resolution: {integrity: sha512-5nj3jULqeTAC1WovwMs1LQWgatTa2pM/rXN9T3XW8rdOtXW9ZF6/GLSNFTKDQmPLwclhPdgUWLJ/4w3fMeeC/Q==} + engines: {node: '>= 22'} + + '@intlify/shared@11.4.6': + resolution: {integrity: sha512-m1p1HHAMLhqSpTRH7VnXdrN0CQ4y+9vunFkpLkbD8soIuBsnQdawZXqMCgvwI2UVF9Ww7sVaw7g9tV2VO7shoA==} + engines: {node: '>= 22'} + + '@intlify/unplugin-vue-i18n@11.2.4': + resolution: {integrity: sha512-bY0ZOaVUvWTyvy4bRGCUKw4Brx5uH/ojjVKsZ1aWzY2drFKIJbeP8DpGMD2QZT8aLpZSUsHtiJlRGLQSZenrvw==} + engines: {node: '>= 22.13'} + peerDependencies: + petite-vue-i18n: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: 3.5.38 + vue-i18n: '*' + peerDependenciesMeta: + petite-vue-i18n: + optional: true + vite: + optional: true + vue-i18n: + optional: true + + '@intlify/vue-i18n-extensions@8.0.0': + resolution: {integrity: sha512-w0+70CvTmuqbskWfzeYhn0IXxllr6mU+IeM2MU0M+j9OW64jkrvqY+pYFWrUnIIC9bEdij3NICruicwd5EgUuQ==} + engines: {node: '>= 18'} + peerDependencies: + '@intlify/shared': ^9.0.0 || ^10.0.0 || ^11.0.0 + '@vue/compiler-dom': ^3.0.0 + vue: 3.5.38 + vue-i18n: ^9.0.0 || ^10.0.0 || ^11.0.0 + peerDependenciesMeta: + '@intlify/shared': + optional: true + '@vue/compiler-dom': + optional: true + vue: + optional: true + vue-i18n: + optional: true + + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/console@30.4.1': + resolution: {integrity: sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/core@30.4.2': + resolution: {integrity: sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/diff-sequences@30.3.0': + resolution: {integrity: sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/diff-sequences@30.4.0': + resolution: {integrity: sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@30.4.1': + resolution: {integrity: sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@30.3.0': + resolution: {integrity: sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@30.4.1': + resolution: {integrity: sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect@30.4.1': + resolution: {integrity: sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/fake-timers@30.4.1': + resolution: {integrity: sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@30.4.1': + resolution: {integrity: sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.0.1': + resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.4.0': + resolution: {integrity: sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/reporters@30.4.1': + resolution: {integrity: sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/schemas@30.0.5': + resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/schemas@30.4.1': + resolution: {integrity: sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/snapshot-utils@30.4.1': + resolution: {integrity: sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@30.0.1': + resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-result@30.4.1': + resolution: {integrity: sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-sequencer@30.4.1': + resolution: {integrity: sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/transform@30.4.1': + resolution: {integrity: sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@30.3.0': + resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/types@30.4.1': + resolution: {integrity: sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jitl/quickjs-ffi-types@0.31.0': + resolution: {integrity: sha512-1yrgvXlmXH2oNj3eFTrkwacGJbmM0crwipA3ohCrjv52gBeDaD7PsTvFYinlAnqU8iPME3LGP437yk05a2oejw==} + + '@jitl/quickjs-singlefile-mjs-release-asyncify@0.31.0': + resolution: {integrity: sha512-usrttM3cCk6lrykUp1jdRI3I3j5zDZlJsq7QjHeJbcujqhqJriPH1GKjEWZuQr+LU53gUw6ZkX4z00TC04yXSw==} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.31.0': + resolution: {integrity: sha512-YkdzQdr1uaftFhgEnTRjTTZHk2SFZdpWO7XhOmRVbi6CEVsH9g5oNF8Ta1q3OuSJHRwwT8YsuR1YzEiEIJEk6w==} + + '@jitl/quickjs-wasmfile-debug-sync@0.31.0': + resolution: {integrity: sha512-8XvloaaWBONqcHXYs5tWOjdhQVxzULilIfB2hvZfS6S+fI4m2+lFiwQy7xeP8ExHmiZ7D8gZGChNkdLgjGfknw==} + + '@jitl/quickjs-wasmfile-release-asyncify@0.31.0': + resolution: {integrity: sha512-uz0BbQYTxNsFkvkurd7vk2dOg57ElTBLCuvNtRl4rgrtbC++NIndD5qv2+AXb6yXDD3Uy1O2PCwmoaH0eXgEOg==} + + '@jitl/quickjs-wasmfile-release-sync@0.31.0': + resolution: {integrity: sha512-hYduecOByj9AsAfsJhZh5nA6exokmuFC8cls39+lYmTCGY51bgjJJJwReEu7Ff7vBWaQCL6TeDdVlnp2WYz0jw==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + + '@lezer/common@1.5.1': + resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} + + '@lezer/generator@1.8.0': + resolution: {integrity: sha512-/SF4EDWowPqV1jOgoGSGTIFsE7Ezdr7ZYxyihl5eMKVO5tlnpIhFcDavgm1hHY5GEonoOAEnJ0CU0x+tvuAuUg==} + hasBin: true + + '@lezer/highlight@1.2.1': + resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.2': + resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==} + + '@lezer/xml@1.0.6': + resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@napi-rs/canvas-android-arm64@0.1.97': + resolution: {integrity: sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.97': + resolution: {integrity: sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.97': + resolution: {integrity: sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.97': + resolution: {integrity: sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.97': + resolution: {integrity: sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@0.1.97': + resolution: {integrity: sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.97': + resolution: {integrity: sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@0.1.97': + resolution: {integrity: sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@0.1.97': + resolution: {integrity: sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.97': + resolution: {integrity: sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.97': + resolution: {integrity: sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.97': + resolution: {integrity: sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==} + engines: {node: '>= 10'} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@nestjs-modules/mailer@2.3.7': + resolution: {integrity: sha512-7prUdL8rt7Lz9m/RP/PFvZHwYgAewDexQHLwy4kyYueksoTGGxOzu7q4N/4Q+SU2+hKLakasbHdw8GrvQJi85w==} + peerDependencies: + '@nestjs/common': '>=7.0.9' + '@nestjs/core': '>=7.0.9' + '@nestjs/event-emitter': '>=2.0.0' + '@nestjs/terminus': '>=10.0.0' + bullmq: '>=4.0.0' + nodemailer: '>=8.0.5' + peerDependenciesMeta: + '@nestjs/event-emitter': + optional: true + '@nestjs/terminus': + optional: true + bullmq: + optional: true + + '@nestjs/apollo@13.4.2': + resolution: {integrity: sha512-kkIC7ini4a3ApJpOByfd0uqDH9rM4ndrn3prDd7JZD1xl81Thd/ekz3g0UvMJmtvsuCYtS36In/zayNo8GuMTA==} + peerDependencies: + '@apollo/gateway': ^2.0.0 + '@apollo/server': ^5.0.0 + '@apollo/subgraph': ^2.0.0 + '@as-integrations/express5': '*' + '@as-integrations/fastify': ^2.1.1 || ^3.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + '@nestjs/graphql': ^13.0.0 + graphql: ^16.10.0 + peerDependenciesMeta: + '@apollo/gateway': + optional: true + '@apollo/subgraph': + optional: true + '@as-integrations/express5': + optional: true + '@as-integrations/fastify': + optional: true + + '@nestjs/cli@11.0.23': + resolution: {integrity: sha512-2V0Bf5jz0KXhUZk3eJi9GljIyqH04otwsE/mYLbqJR+X0iiYx+6bkNJ2Qz28uHNFj1cpHgimf9xDzHkqarie0g==} + engines: {node: '>= 20.11'} + hasBin: true + peerDependencies: + '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0 + '@swc/core': ^1.3.62 + peerDependenciesMeta: + '@swc/cli': + optional: true + '@swc/core': + optional: true + + '@nestjs/common@11.1.27': + resolution: {integrity: sha512-kEGSzqM2lWr4whh4Ubflw+oPZSEzxvRMu9WL+LveZploJWTjec5bBlCiRVlVzTPg2kIwBiLwWSvCCW7Wnin1gg==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/config@4.0.4': + resolution: {integrity: sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + + '@nestjs/core@11.1.27': + resolution: {integrity: sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/graphql@13.4.2': + resolution: {integrity: sha512-MIaMIaV9o3Tj2LsoGGwhISTZVXEIfDK8rDXplE3tSYULj6cXSY1dofOSLMF/aY+BZLwlrN4BUUowgu8qNdDZFg==} + peerDependencies: + '@apollo/subgraph': ^2.9.3 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + graphql: ^16.11.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + ts-morph: ^20.0.0 || ^21.0.0 || ^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 + peerDependenciesMeta: + '@apollo/subgraph': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + ts-morph: + optional: true + + '@nestjs/jwt@11.0.2': + resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/passport@11.0.0': + resolution: {integrity: sha512-Zp6LZZCiTxRL/pD8mg5ZUlFQR/Ny9leqx3BauEVSr1GYG4o+h0ZBvB/umeY7y1U84/UkzDLcOOZIasE4p7z5iA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + passport: ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 + + '@nestjs/platform-express@11.1.27': + resolution: {integrity: sha512-0ZFhz6H6EdGh4xQVbUNwjoAwBuz73P7FvUAl67h9CTdMqQlJDaQYJApBv8pKfVZ1fGjMCbl0m9DcC6pXaZPWSQ==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + + '@nestjs/schedule@6.1.3': + resolution: {integrity: sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + + '@nestjs/schematics@11.1.0': + resolution: {integrity: sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==} + peerDependencies: + prettier: ^3.0.0 + typescript: '>=4.8.2' + peerDependenciesMeta: + prettier: + optional: true + + '@nestjs/swagger@11.4.4': + resolution: {integrity: sha512-VaIo1ruV2G7b+f2zPzkBSUNy9a/WQ9sg8TLKhWlrTfg4O6U10M/PA7Xi6XMXadOVhwOqoesijba8jH3i/3adrA==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/terminus@11.1.1': + resolution: {integrity: sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==} + peerDependencies: + '@grpc/grpc-js': '*' + '@grpc/proto-loader': '*' + '@mikro-orm/core': '*' + '@mikro-orm/nestjs': '*' + '@nestjs/axios': ^2.0.0 || ^3.0.0 || ^4.0.0 + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/microservices': ^10.0.0 || ^11.0.0 + '@nestjs/mongoose': ^11.0.0 + '@nestjs/sequelize': ^10.0.0 || ^11.0.0 + '@nestjs/typeorm': ^10.0.0 || ^11.0.0 + '@prisma/client': '*' + mongoose: '*' + reflect-metadata: 0.1.x || 0.2.x + rxjs: 7.x + sequelize: '*' + typeorm: '*' + peerDependenciesMeta: + '@grpc/grpc-js': + optional: true + '@grpc/proto-loader': + optional: true + '@mikro-orm/core': + optional: true + '@mikro-orm/nestjs': + optional: true + '@nestjs/axios': + optional: true + '@nestjs/microservices': + optional: true + '@nestjs/mongoose': + optional: true + '@nestjs/sequelize': + optional: true + '@nestjs/typeorm': + optional: true + '@prisma/client': + optional: true + mongoose: + optional: true + sequelize: + optional: true + typeorm: + optional: true + + '@nestjs/testing@11.1.27': + resolution: {integrity: sha512-I35po13UHZZeGenLWJ3QYwh77RsLau5RcFKWBZ4waVHeARpwjtC7v7n7lGh98swLQdGmZgTnbvKaZ0B5dsUIKA==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + + '@nestjs/throttler@6.5.0': + resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + + '@noble/curves@2.2.0': + resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oozcitak/dom@2.0.2': + resolution: {integrity: sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==} + engines: {node: '>=20.0'} + + '@oozcitak/infra@2.0.2': + resolution: {integrity: sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==} + engines: {node: '>=20.0'} + + '@oozcitak/url@3.0.0': + resolution: {integrity: sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==} + engines: {node: '>=20.0'} + + '@oozcitak/util@10.0.0': + resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} + engines: {node: '>=20.0'} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + + '@peculiar/asn1-schema@2.6.0': + resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==} + + '@peculiar/json-schema@1.1.12': + resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==} + engines: {node: '>=8.0.0'} + + '@peculiar/webcrypto@1.5.0': + resolution: {integrity: sha512-BRs5XUAwiyCDQMsVA9IDvDa7UBR9gAvPHgugOeGng3YN6vJ9JYonyDc0lNczErgtCWtucjR5N7VtaonboD/ezg==} + engines: {node: '>=10.12.0'} + + '@phc/format@1.0.0': + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@pkgr/core@0.3.6': + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + + '@posthog/core@1.35.3': + resolution: {integrity: sha512-EsGPbSLl39Jgo2KZ+kI9UAxFnh5nddaN5bNm2rXvUwF+vGmam9eN1EXeNbxhRU7ulEeIiGdm7XjoU7pzavkgIQ==} + + '@posthog/types@1.390.2': + resolution: {integrity: sha512-WcfKz2GNn2vfDX8vXmJYbKxegPxVWHuDQ/pHdAn0HoZDXDFnEp/+x3qBQA+fEvtbPjjtjgAt2wIgJMlM7asx7g==} + + '@prisma/adapter-pg@7.8.0': + resolution: {integrity: sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==} + + '@prisma/client-runtime-utils@7.8.0': + resolution: {integrity: sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==} + + '@prisma/client@7.8.0': + resolution: {integrity: sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + peerDependencies: + prisma: '*' + typescript: '>=5.4.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@7.8.0': + resolution: {integrity: sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/debug@7.8.0': + resolution: {integrity: sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==} + + '@prisma/dev@0.24.3': + resolution: {integrity: sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==} + + '@prisma/driver-adapter-utils@7.8.0': + resolution: {integrity: sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==} + + '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': + resolution: {integrity: sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==} + + '@prisma/engines@7.8.0': + resolution: {integrity: sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==} + + '@prisma/fetch-engine@7.8.0': + resolution: {integrity: sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==} + + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/get-platform@7.8.0': + resolution: {integrity: sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==} + + '@prisma/query-plan-executor@7.2.0': + resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + + '@prisma/streams-local@0.1.2': + resolution: {integrity: sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==} + engines: {bun: '>=1.3.6', node: '>=22.0.0'} + + '@prisma/studio-core@0.27.3': + resolution: {integrity: sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==} + engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@relmify/jest-fp-ts@2.1.1': + resolution: {integrity: sha512-ljNGMAINGa8YjIjbkufx6qV0+1HRoX3yNJi92A/RSZifYJpaztqjNtYm9jvKPBmQuZtnkWv+Vik+zMa2rZI0TQ==} + peerDependencies: + fp-ts: 2.x + io-ts: 2.x + + '@repeaterjs/repeater@3.0.4': + resolution: {integrity: sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA==} + + '@repeaterjs/repeater@3.0.6': + resolution: {integrity: sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==} + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/plugin-babel@5.3.1': + resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} + engines: {node: '>= 10.0.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@types/babel__core': ^7.1.9 + rollup: ^1.20.0||^2.0.0 + peerDependenciesMeta: + '@types/babel__core': + optional: true + + '@rollup/plugin-inject@5.0.5': + resolution: {integrity: sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-node-resolve@15.3.1': + resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-replace@2.4.2': + resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==} + peerDependencies: + rollup: ^1.20.0 || ^2.0.0 + + '@rollup/plugin-terser@0.4.4': + resolution: {integrity: sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-typescript@11.1.6': + resolution: {integrity: sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.14.0||^3.0.0||^4.0.0 + tslib: '*' + typescript: '>=3.7.0' + peerDependenciesMeta: + rollup: + optional: true + tslib: + optional: true + + '@rollup/plugin-typescript@12.1.4': + resolution: {integrity: sha512-s5Hx+EtN60LMlDBvl5f04bEiFZmAepk27Q+mr85L/00zPDn1jtzlTV6FWn81MaIwqfWzKxmOJrBWHU6vtQyedQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.14.0||^3.0.0||^4.0.0 + tslib: '*' + typescript: '>=3.7.0' + peerDependenciesMeta: + rollup: + optional: true + tslib: + optional: true + + '@rollup/plugin-typescript@12.3.0': + resolution: {integrity: sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.14.0||^3.0.0||^4.0.0 + tslib: '*' + typescript: '>=3.7.0' + peerDependenciesMeta: + rollup: + optional: true + tslib: + optional: true + + '@rollup/pluginutils@3.1.0': + resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==} + engines: {node: '>= 8.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0 + + '@rollup/pluginutils@4.2.1': + resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} + engines: {node: '>= 8.0.0'} + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm-eabi@4.60.2': + resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-android-arm64@4.60.2': + resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-arm64@4.60.2': + resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.2': + resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-arm64@4.60.2': + resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.2': + resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-musl@4.60.2': + resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-musl@4.60.2': + resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.2': + resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-x64-musl@4.60.2': + resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openbsd-x64@4.60.2': + resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-openharmony-arm64@4.60.2': + resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.2': + resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.2': + resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} + cpu: [x64] + os: [win32] + + '@rushstack/eslint-patch@1.16.1': + resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@scure/base@2.2.0': + resolution: {integrity: sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==} + + '@selderee/plugin-htmlparser2@0.11.0': + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + + '@shopify/lang-jsonc@1.0.1': + resolution: {integrity: sha512-KrBrRFhvr1qJiZBODAtqbL1u1e67UR3plBN79Z8nd5TQAAzpx66jS4zs7Ss9M22ygGrpWFhyhSoNVlp5VCYktQ==} + + '@simple-libs/child-process-utils@1.0.2': + resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} + engines: {node: '>=18'} + + '@simple-libs/stream-utils@1.2.0': + resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} + engines: {node: '>=18'} + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@sinclair/typebox@0.34.48': + resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@15.4.0': + resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==} + + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@surma/rollup-plugin-off-main-thread@2.2.3': + resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} + + '@sveltejs/vite-plugin-svelte@1.4.0': + resolution: {integrity: sha512-6QupI/jemMfK+yI2pMtJcu5iO2gtgTfcBdGwMZZt+lgbFELhszbDl6Qjh000HgAV8+XUA+8EY8DusOFk8WhOIg==} + engines: {node: ^14.18.0 || >= 16} + peerDependencies: + svelte: ^3.44.0 + vite: ^3.0.0 + + '@tauri-apps/api@2.1.1': + resolution: {integrity: sha512-fzUfFFKo4lknXGJq8qrCidkUcKcH2UHhfaaCNt4GzgzGaW2iS26uFOg4tS3H4P8D6ZEeUxtiD5z0nwFF0UN30A==} + + '@tauri-apps/api@2.9.1': + resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==} + + '@tauri-apps/cli-darwin-arm64@2.9.3': + resolution: {integrity: sha512-W8FQXZXQmQ0Fmj9UJXNrm2mLdIaLLriKVY7o/FzmizyIKTPIvHjfZALTNybbpTQRbJvKoGHLrW1DNzAWVDWJYg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.9.3': + resolution: {integrity: sha512-zDwu40rlshijt3TU6aRvzPUyVpapsx1sNfOlreDMTaMelQLHl6YoQzSRpLHYwrHrhimxyX2uDqnKIiuGel0Lhg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.9.3': + resolution: {integrity: sha512-+Oc2OfcTRwYtW93VJqd/HOk77buORwC9IToj/qsEvM7bTMq6Kda4alpZprzwrCHYANSw+zD8PgjJdljTpe4p+g==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.9.3': + resolution: {integrity: sha512-59GqU/J1n9wFyAtleoQOaU0oVIo+kwQynEw4meFDoKRXszKGor6lTsbsS3r0QKLSPbc0o/yYGJhqqCtkYjb/eg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-arm64-musl@2.9.3': + resolution: {integrity: sha512-fzvG+jEn5/iYGNH6Z2IRMheYFC4pJdXa19BR9fFm6Bdn2cuajRLDKdUcEME/DCtwqclphXtFZTrT4oezY5vI/A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-linux-riscv64-gnu@2.9.3': + resolution: {integrity: sha512-qV8DZXI/fZwawk6T3Th1g6smiNC2KeQTk7XFgKvqZ6btC01z3UTsQmNGvI602zwm3Ld1TBZb4+rEWu2QmQimmw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-gnu@2.9.3': + resolution: {integrity: sha512-tquyEONCNRfqEBWEe4eAHnxFN5yY5lFkCuD4w79XLIovUxVftQ684+xLp7zkhntkt4y20SMj2AgJa/+MOlx4Kg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-musl@2.9.3': + resolution: {integrity: sha512-v2cBIB/6ji8DL+aiL5QUykU3ZO8OoJGyx50/qv2HQVzkf85KdaYSis3D/oVRemN/pcDz+vyCnnL3XnzFnDl4JQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-win32-arm64-msvc@2.9.3': + resolution: {integrity: sha512-ZGvBy7nvrHPbE0HeKp/ioaiw8bNgAHxWnb7JRZ4/G0A+oFj0SeSFxl9k5uU6FKnM7bHM23Gd1oeaDex9g5Fceg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.9.3': + resolution: {integrity: sha512-UsgIwOnpCoY9NK9/65QiwgmWVIE80LE7SwRYVblGtmlY9RYfsYvpbItwsovA/AcHMTiO+OCvS/q9yLeqS3m6Sg==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.9.3': + resolution: {integrity: sha512-fmw7NrrHE5m49idCvJAx9T9bsupjdJ0a3p3DPCNCZRGANU6R1tA1L+KTlVuUtdAldX2NqU/9UPo2SCslYKgJHQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.9.3': + resolution: {integrity: sha512-BQ7iLUXTQcyG1PpzLWeVSmBCedYDpnA/6Cm/kRFGtqjTf/eVUlyYO5S2ee07tLum3nWwDBWTGFZeruO8yEukfA==} + engines: {node: '>= 10'} + hasBin: true + + '@tauri-apps/plugin-dialog@2.0.1': + resolution: {integrity: sha512-fnUrNr6EfvTqdls/ufusU7h6UbNFzLKvHk/zTuOiBq01R3dTODqwctZlzakdbfSp/7pNwTKvgKTAgl/NAP/Z0Q==} + + '@tauri-apps/plugin-fs@2.0.2': + resolution: {integrity: sha512-4YZaX2j7ta81M5/DL8aN10kTnpUkEpkPo1FTYPT8Dd0ImHe3azM8i8MrtjrDGoyBYLPO3zFv7df/mSCYF8oA0Q==} + + '@tauri-apps/plugin-process@2.2.0': + resolution: {integrity: sha512-uypN2Crmyop9z+KRJr3zl71OyVFgTuvHFjsJ0UxxQ/J5212jVa5w4nPEYjIewcn8bUEXacRebwE6F7owgrbhSw==} + + '@tauri-apps/plugin-shell@2.3.3': + resolution: {integrity: sha512-Xod+pRcFxmOWFWEnqH5yZcA7qwAMuaaDkMR1Sply+F8VfBj++CGnj2xf5UoialmjZ2Cvd8qrvSCbU+7GgNVsKQ==} + + '@tauri-apps/plugin-store@2.4.1': + resolution: {integrity: sha512-ckGSEzZ5Ii4Hf2D5x25Oqnm2Zf9MfDWAzR+volY0z/OOBz6aucPKEY0F649JvQ0Vupku6UJo7ugpGRDOFOunkA==} + + '@tauri-apps/plugin-updater@2.9.0': + resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==} + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/bcrypt@6.0.0': + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/caseless@0.12.5': + resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/component-emitter@1.2.14': + resolution: {integrity: sha512-lmPil1g82wwWg/qHSxMWkSKyJGQOK+ejXeMAAWyxNtVUD0/Ycj2maL63RAqpxVfdtvTfZkRnqzB0A9ft59y69g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cookie-parser@1.4.10': + resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==} + peerDependencies: + '@types/express': '*' + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + + '@types/crypto-js@4.2.2': + resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/ejs@3.1.5': + resolution: {integrity: sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==} + + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + + '@types/eslint@8.56.12': + resolution: {integrity: sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@0.0.39': + resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/har-format@1.2.16': + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + + '@types/hawk@9.0.7': + resolution: {integrity: sha512-CWR2niz0oxf9Q7etJO33DDsAIu6oa1aNw6ui0Gv398RqX1uB68DMYpRZgyFhMEihW7jUyr8rRfZg/oOfu2gJIA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@30.0.0': + resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json-schema@7.0.9': + resolution: {integrity: sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + + '@types/long@4.0.2': + resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} + + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/mjml-core@4.15.2': + resolution: {integrity: sha512-Q7SxFXgoX979HP57DEVsRI50TV8x1V4lfCA4Up9AvfINDM5oD/X9ARgfoyX1qS987JCnDLv85JjkqAjt3hZSiQ==} + + '@types/mjml@4.7.4': + resolution: {integrity: sha512-vyi1vzWgMzFMwZY7GSZYX0GU0dmtC8vLHwpgk+NWmwbwRSrlieVyJ9sn5elodwUfklJM7yGl0zQeet1brKTWaQ==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@17.0.45': + resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} + + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + + '@types/node@24.9.1': + resolution: {integrity: sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==} + + '@types/node@25.9.3': + resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + + '@types/nprogress@0.2.3': + resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==} + + '@types/oauth@0.9.6': + resolution: {integrity: sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==} + + '@types/paho-mqtt@1.0.10': + resolution: {integrity: sha512-xOEii1m7jw7mIKjufDkolpz7VlyqptUmm/YFPtLJCybrPCuLhN+WYgNpulQ/CXo7wtEW7x4uGon2v89+6g/pcA==} + + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + + '@types/passport-github2@1.2.9': + resolution: {integrity: sha512-/nMfiPK2E6GKttwBzwj0Wjaot8eHrM57hnWxu52o6becr5/kXlH/4yE2v2rh234WGvSgEEzIII02Nc5oC5xEHA==} + + '@types/passport-google-oauth20@2.0.17': + resolution: {integrity: sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==} + + '@types/passport-jwt@4.0.1': + resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + + '@types/passport-microsoft@2.1.1': + resolution: {integrity: sha512-mtxO0nUt8PSAQd1MPD4JZXacYRx9MXYCTFjKyBEarmCJYyJlKHMwBRYltBEi/zpvC6xYX0LgK1AFXp+hQI+DEw==} + + '@types/passport-oauth2@1.8.0': + resolution: {integrity: sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==} + + '@types/passport-strategy@0.2.38': + resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} + + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + + '@types/postman-collection@3.5.11': + resolution: {integrity: sha512-BZgBJDdX6jyy9hzSTIMRhCsxhF0IlzPr1i98q2wdkDo8rZrbNoBvs+3/Vw+LOIIAFH1G+FyXo5Fjf8qbawGeHg==} + + '@types/pug@2.0.10': + resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/relateurl@0.2.33': + resolution: {integrity: sha512-bTQCKsVbIdzLqZhLkF5fcJQreE4y1ro4DIyVrlDNSCJRRwHhB8Z+4zXXa8jN6eDvc2HbRsEYgbvrnGvi54EpSw==} + + '@types/request@2.48.13': + resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/sax@1.2.7': + resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/splitpanes@2.2.6': + resolution: {integrity: sha512-3dV5sO1Ht74iER4jJU03mreL3f+Q2h47ZqXS6Sfbqc6hkCvsDrX1GA0NbYWRdNvZemPyTDzUoApWKeoGbALwkQ==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/strip-bom@3.0.0': + resolution: {integrity: sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==} + + '@types/strip-json-comments@0.0.30': + resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==} + + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@7.2.0': + resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + + '@types/web-bluetooth@0.0.14': + resolution: {integrity: sha512-5d2RhCard1nQUC3aHcq/gHzWYO6K0WJmAbjO7mQJgCQKtZpgXxv1rOM6O/dBDhDYYVutk1sciOgNSe+5YyfM8A==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@typescript-eslint/eslint-plugin@8.61.1': + resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.61.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.61.1': + resolution: {integrity: sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.61.1': + resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.61.1': + resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.61.1': + resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.61.1': + resolution: {integrity: sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.61.1': + resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.61.1': + resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.61.1': + resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.61.1': + resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher + + '@unhead/vue@2.1.12': + resolution: {integrity: sha512-zEWqg0nZM8acpuTZE40wkeUl8AhIe0tU0OkilVi1D4fmVjACrwoh5HP6aNqJ8kUnKsoy6D+R3Vi/O+fmdNGO7g==} + peerDependencies: + vue: 3.5.38 + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + '@urql/core@6.0.2': + resolution: {integrity: sha512-9Z4/o97iJujIHKIXcT6jTz147nEb2myZGUKRZ7caHomnKNmm6vd3kpIrkaCuoWJDWrF21eVbNhHWhrBP7gItYw==} + + '@urql/devtools@2.0.3': + resolution: {integrity: sha512-TktPLiBS9LcBPHD6qcnb8wqOVcg3Bx0iCtvQ80uPpfofwwBGJmqnQTjUdEFU6kwaLOFZULQ9+Uo4831G823mQw==} + peerDependencies: + '@urql/core': '>= 1.14.0' + graphql: '>= 0.11.0' + + '@urql/exchange-auth@3.0.0': + resolution: {integrity: sha512-tj09xiOR2f1J2h8TE9uZWjRZipCdmDoTewEytOacDQ+0Teo+yIZxm3ppHxolQtiA51OHrGYiNTkMte8HtfvaBw==} + peerDependencies: + '@urql/core': ^6.0.0 + + '@urql/exchange-graphcache@7.2.4': + resolution: {integrity: sha512-kiKbT0ZrtyQmmgNLYR0qkZAJjWHQOtrd+6Dt9JMtm3L/A8r3D6ptcJn668BADP6J+vkxcfNFtdI+0OdmBBkRgw==} + peerDependencies: + '@urql/core': ^5.0.0 + + '@urql/introspection@0.3.3': + resolution: {integrity: sha512-tekSLLqWnusfV6V7xaEnLJQSdXOD/lWy7f8JYQwrX+88Md+voGSCSx5WJXI7KLBN3Tat2OV08tAr8UROykls4Q==} + peerDependencies: + graphql: ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@urql/vue@2.1.1': + resolution: {integrity: sha512-gmQBQtHO7Rtw1W+CiLb6YcnQEczog3ZxLsFhMvTex1aM1mczT6dDJuiYpJDg2KkuJ8zqXSbMnbwwbm4ukr9vTA==} + peerDependencies: + '@urql/core': ^6.0.0 + vue: 3.5.38 + + '@vitejs/plugin-legacy@2.3.1': + resolution: {integrity: sha512-J5KaGBlSt2tEYPVjM/C8dA6DkRzkFkbPe+Xb4IX5G+XOV5OGbVAfkMjKywdrkO3gGynO8S98i71Lmsff4cWkCQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + terser: ^5.4.0 + vite: ^3.0.0 + + '@vitejs/plugin-legacy@7.2.1': + resolution: {integrity: sha512-CaXb/y0mlfu7jQRELEJJc2/5w2bX2m1JraARgFnvSB2yfvnCNJVWWlqAo6WjnKoepOwKx8gs0ugJThPLKCOXIg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + terser: ^5.16.0 + vite: ^7.0.0 + + '@vitejs/plugin-vue@6.0.7': + resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: 3.5.38 + + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + + '@volar/language-core@1.10.10': + resolution: {integrity: sha512-nsV1o3AZ5n5jaEAObrS3MWLBWaGwUj/vAsc15FVNIv+DbpizQRISg9wzygsHBr56ELRH8r4K75vkYNMtsSNNWw==} + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@1.10.10': + resolution: {integrity: sha512-GVKjLnifV4voJ9F0vhP56p4+F3WGf+gXlRtjFZsv6v3WxBTWU3ZVeaRaEHJmWrcv5LXmoYYpk/SC25BKemPRkg==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@1.10.10': + resolution: {integrity: sha512-4a2r5bdUub2m+mYVnLu2wt59fuoYWe7nf0uXtGHU8QQ5LDNfzAR0wK7NgDiQ9rcl2WT3fxT2AA9AylAwFtj50A==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vue/compiler-core@3.5.38': + resolution: {integrity: sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==} + + '@vue/compiler-dom@3.5.38': + resolution: {integrity: sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==} + + '@vue/compiler-sfc@3.5.38': + resolution: {integrity: sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==} + + '@vue/compiler-ssr@3.5.38': + resolution: {integrity: sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/eslint-config-typescript@14.8.0': + resolution: {integrity: sha512-yIquzhXH7ZsrwSSm+rYvoGCRY6wcuF4qBi76e0l7hHLq7YU0f9aC+RcR5fL+XJNfmBZxgX5cVl4sppt4x7ZCBg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.10.0 || ^10.0.0 + eslint-plugin-vue: ^9.28.0 || ^10.0.0 + typescript: '>=4.8.4' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/language-core@1.8.8': + resolution: {integrity: sha512-i4KMTuPazf48yMdYoebTkgSOJdFraE4pQf0B+FTOFkbB+6hAfjrSou/UmYWRsWyZV6r4Rc6DDZdI39CJwL0rWw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/language-core@2.1.6': + resolution: {integrity: sha512-MW569cSky9R/ooKMh6xa2g1D0AtRKbL56k83dzus/bx//RDJk24RHWkMzbAlXjMdDNyxAaagKPRquBIxkxlCkg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/language-core@2.2.0': + resolution: {integrity: sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.38': + resolution: {integrity: sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==} + + '@vue/runtime-core@3.5.38': + resolution: {integrity: sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==} + + '@vue/runtime-dom@3.5.38': + resolution: {integrity: sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==} + + '@vue/server-renderer@3.5.38': + resolution: {integrity: sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==} + peerDependencies: + vue: 3.5.38 + + '@vue/shared@3.5.38': + resolution: {integrity: sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==} + + '@vue/typescript@1.8.8': + resolution: {integrity: sha512-jUnmMB6egu5wl342eaUH236v8tdcEPXXkPgj+eI/F6JwW/lb+yAU6U07ZbQ3MVabZRlupIlPESB7ajgAGixhow==} + + '@vueuse/core@14.3.0': + resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==} + peerDependencies: + vue: 3.5.38 + + '@vueuse/core@8.9.4': + resolution: {integrity: sha512-B/Mdj9TK1peFyWaPof+Zf/mP9XuGAngaJZBwPaXBvU3aCTZlx3ltlrFFFyMV4iGBwsjSCeUCgZrtkEj9dS2Y3Q==} + peerDependencies: + '@vue/composition-api': ^1.1.0 + vue: 3.5.38 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + vue: + optional: true + + '@vueuse/metadata@14.3.0': + resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==} + + '@vueuse/metadata@8.9.4': + resolution: {integrity: sha512-IwSfzH80bnJMzqhaapqJl9JRIiyQU0zsRGEgnxN6jhq7992cPUJIRfV+JHRIZXjYqbwt07E1gTEp0R0zPJ1aqw==} + + '@vueuse/shared@14.3.0': + resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==} + peerDependencies: + vue: 3.5.38 + + '@vueuse/shared@8.9.4': + resolution: {integrity: sha512-wt+T30c4K6dGRMVqPddexEVLa28YwxW5OFIPmzUHICjphfAuBFTTdDoyqREZNDOFJZ44ARH1WWQNCUK8koJ+Ag==} + peerDependencies: + '@vue/composition-api': ^1.1.0 + vue: 3.5.38 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + vue: + optional: true + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@whatwg-node/disposablestack@0.0.6': + resolution: {integrity: sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/events@0.0.3': + resolution: {integrity: sha512-IqnKIDWfXBJkvy/k6tzskWTc2NK3LcqHlb+KHGCrjOCH4jfQckRX0NAiIcC/vIqQkzLYw2r2CTSwAxcrtcD6lA==} + + '@whatwg-node/fetch@0.10.13': + resolution: {integrity: sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/fetch@0.8.8': + resolution: {integrity: sha512-CdcjGC2vdKhc13KKxgsc6/616BQ7ooDIgPeTuAiE8qfCnS0mGzcfCOoZXypQSz73nxI+GWc7ZReIAVhxoE1KCg==} + + '@whatwg-node/node-fetch@0.3.6': + resolution: {integrity: sha512-w9wKgDO4C95qnXZRwZTfCmLWqyRnooGjcIwG0wADWjw9/HN0p7dtvtgSvItZtUyNteEvgTrd8QojNEqV6DAGTA==} + + '@whatwg-node/node-fetch@0.8.5': + resolution: {integrity: sha512-4xzCl/zphPqlp9tASLVeUhB5+WJHbuWGYpfoC2q1qh5dw0AqZBW7L27V5roxYWijPxj4sspRAAoOH3d2ztaHUQ==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/promise-helpers@1.3.2': + resolution: {integrity: sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==} + engines: {node: '>=16.0.0'} + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + '@zone-eu/mailsplit@5.4.8': + resolution: {integrity: sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==} + + a-sync-waterfall@1.0.1: + resolution: {integrity: sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-import-phases@1.0.4: + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} + peerDependencies: + acorn: ^8.14.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-loose@6.1.0: + resolution: {integrity: sha512-FHhXoiF0Uch3IqsrnPpWwCtiv5PYvipTpT1k9lDMgQVVYc9iDuSl5zdJV358aI8twfHCYMFBRVYvAVki9wC/ng==} + engines: {node: '>=0.4.0'} + + acorn-walk@6.2.0: + resolution: {integrity: sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==} + engines: {node: '>=0.4.0'} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@6.4.2: + resolution: {integrity: sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + acorn@7.4.1: + resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} + engines: {node: '>=0.4.0'} + hasBin: true + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + after@0.8.2: + resolution: {integrity: sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + alce@1.2.0: + resolution: {integrity: sha512-XppPf2S42nO2WhvKzlwzlfcApcXHzjlod30pKmcWjRgLOtqoe5DMuqdiYoM6AgyXksc6A6pV4v1L/WW217e57w==} + engines: {node: '>=0.8.0'} + + alien-signals@0.4.14: + resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + apiconnect-wsdl@2.0.36: + resolution: {integrity: sha512-jHXC6y/duZ+zzn756/znpfDeVM0hprt2Yk/4QfiIhzNG4YmJXt7NSCup4DFRCitBZVK85lM6UYRaa36fSWs3JQ==} + engines: {node: '>=18.7.0 <21.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argon2@0.44.0: + resolution: {integrity: sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==} + engines: {node: '>=16.17.0'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + arraybuffer.slice@0.0.7: + resolution: {integrity: sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + asn1js@3.0.7: + resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} + engines: {node: '>=12.0.0'} + + assert-never@1.4.0: + resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + auto-bind@4.0.0: + resolution: {integrity: sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ==} + engines: {node: '>=8'} + + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + aws4fetch@1.0.20: + resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + + axios-cookiejar-support@6.0.5: + resolution: {integrity: sha512-ldPOQCJWB0ipugkTNVB8QRl/5L2UgfmVNVQtS9en1JQJ1wW588PqAmymnwmmgc12HLDzDtsJ28xE2ppj4rD4ng==} + engines: {node: '>=20.0.0'} + peerDependencies: + axios: '>=0.20.0' + tough-cookie: '>=4.0.0' + + axios@1.18.0: + resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==} + + babel-jest@30.4.1: + resolution: {integrity: sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-0 + + babel-plugin-istanbul@7.0.1: + resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} + engines: {node: '>=12'} + + babel-plugin-jest-hoist@30.4.0: + resolution: {integrity: sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.13.0: + resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.14.2: + resolution: {integrity: sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-syntax-trailing-function-commas@7.0.0-beta.0: + resolution: {integrity: sha512-Xj9XuRuz3nTSbaTXWv3itLOcxyF4oPD8douBBmj7U9BBC6nEBYfyOJYQMf/8PJAFotC62UY5dFfIGEPr7WswzQ==} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-fbjs@3.4.0: + resolution: {integrity: sha512-9ywCsCvo1ojrw0b+XYk7aFvTH6D9064t0RIL1rtMf3nsa02Xw41MS7sZw216Im35xj/UY0PDBQsa1brUDDF1Ow==} + peerDependencies: + '@babel/core': ^7.0.0 + + babel-preset-jest@30.4.0: + resolution: {integrity: sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-beta.1 + + babel-walk@3.0.0-canary-5: + resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==} + engines: {node: '>= 10.0.0'} + + backo2@1.0.2: + resolution: {integrity: sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-arraybuffer@0.1.4: + resolution: {integrity: sha512-a1eIFi4R9ySrbiMuyTGx5e92uRH5tQY6kArNcFaKBUleIoLjdjBg7Zxm3Mqm3Kmkf27HLR/1fnxX9q8GQ7Iavg==} + engines: {node: '>= 0.6.0'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + base64url@3.0.1: + resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} + engines: {node: '>=6.0.0'} + + baseline-browser-mapping@2.10.11: + resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==} + engines: {node: '>=6.0.0'} + hasBin: true + + baseline-browser-mapping@2.10.23: + resolution: {integrity: sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==} + engines: {node: '>=6.0.0'} + hasBin: true + + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + + bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + + better-result@2.8.2: + resolution: {integrity: sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + birpc@4.0.0: + resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + blob@0.0.5: + resolution: {integrity: sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==} + + body-parser@2.2.1: + resolution: {integrity: sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==} + engines: {node: '>=18'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browser-or-node@3.0.0: + resolution: {integrity: sha512-iczIdVJzGEYhP5DqQxYM9Hh7Ztpqqi+CXZpSmX8ALFs9ecXkQIeqRyM6TfxEfMVpwhl3dSuDvxdzzo9sUOIVBQ==} + + browser-stdout@1.3.1: + resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} + + browserslist-to-esbuild@2.1.1: + resolution: {integrity: sha512-KN+mty6C3e9AN8Z5dI1xeN15ExcRNeISoC3g7V0Kax/MMF9MSoYA2G7lkTTcVUFntiEjkpI0HNgqJC1NjdyNUw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + browserslist: '*' + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-api@3.0.0: + resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} + + caniuse-lite@1.0.30001781: + resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + + caniuse-lite@1.0.30001791: + resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} + + capital-case@1.0.4: + resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + change-case-all@1.0.15: + resolution: {integrity: sha512-3+GIFhk3sNuvFAJKU46o26OdzudQlPNBCu1ZQi3cMeMHhty1bhDxu2WrEilVNYaGvqUtR1VSigFcJOiS13dRhQ==} + + change-case@4.1.2: + resolution: {integrity: sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + character-parser@2.2.0: + resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + charset@1.0.1: + resolution: {integrity: sha512-6dVyOOYjpfFcL1Y4qChrAoQLRHvj2ziyhcm0QJlhOcAhykL/k1kTUPbeo+87MNRTRdk2OIIsIXbuF3x2wi5EXg==} + engines: {node: '>=4.0.0'} + + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + + check-disk-space@3.4.0: + resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==} + engines: {node: '>=16'} + + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.0.0-rc.12: + resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==} + engines: {node: '>= 6'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.15.1: + resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==} + + cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + collection-utils@1.0.1: + resolution: {integrity: sha512-LA2YTIlR7biSpXkKYwwuzGjwL5rjWEZVOSnvdUc7gObvWe4WkjxOpfrdhoP7Hs09YWDVfg0Mal9BpAqLfVEzQg==} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + + comment-json@5.0.0: + resolution: {integrity: sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==} + engines: {node: '>= 6'} + + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + + component-bind@1.0.0: + resolution: {integrity: sha512-WZveuKPeKAG9qY+FkYDeADzdHyTYdIboXS59ixDeRJL5ZhxpqUnxSOwop4FQjMsiYm3/Or8cegVbpAHNA7pHxw==} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + component-inherit@0.0.3: + resolution: {integrity: sha512-w+LhYREhatpVqTESyGFg3NlP6Iu0kEKUHETY9GoZP/pQyW4mHFZuFWRUCIqVPZ36ueVLtoOEZaAqbCF2RDndaA==} + + computeds@0.0.1: + resolution: {integrity: sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + constant-case@3.0.4: + resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} + + constantinople@4.0.1: + resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + conventional-changelog-angular@8.3.1: + resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} + engines: {node: '>=18'} + + conventional-changelog-conventionalcommits@9.3.0: + resolution: {integrity: sha512-kYFx6gAyjSIMwNtASkI3ZE99U1fuVDJr0yTYgVy+I2QG46zNZfl2her+0+eoviG82c5WQvW1jMt1eOQTeJLodA==} + engines: {node: '>=18'} + + conventional-commits-parser@6.3.0: + resolution: {integrity: sha512-RfOq/Cqy9xV9bOA8N+ZH6DlrDR+5S3Mi0B5kACEjESpE+AviIpAptx9a9cFpWCCvgRtWT+0BbUw+e1BZfts9jg==} + engines: {node: '>=18'} + hasBin: true + + conventional-commits-parser@6.4.0: + resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} + engines: {node: '>=18'} + hasBin: true + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@2.0.0: + resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + corser@2.0.1: + resolution: {integrity: sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==} + engines: {node: '>= 0.4.0'} + + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + + cosmiconfig@8.0.0: + resolution: {integrity: sha512-da1EafcpH6b/TD8vDRaWV7xFINlHlF6zKsGwS1TsuVJTZRkquaS5HTMq7uq6h31619QjbsYl21gVDOm32KM1vQ==} + engines: {node: '>=14'} + + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} + + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} + + cross-inspect@1.0.1: + resolution: {integrity: sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==} + engines: {node: '>=16.0.0'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypto-random-string@2.0.0: + resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} + engines: {node: '>=8'} + + css-declaration-sorter@7.3.1: + resolution: {integrity: sha512-gz6x+KkgNCjxq3Var03pRYLhyNfwhkKF1g/yoLgDNtFvVu0/fOLV9C8fFEZRjACp/XQLumjAYo7JVjzH3wLbxA==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.0.9 + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssfilter@0.0.10: + resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==} + + cssnano-preset-default@7.0.11: + resolution: {integrity: sha512-waWlAMuCakP7//UCY+JPrQS1z0OSLeOXk2sKWJximKWGupVxre50bzPlvpbUwZIDylhf/ptf0Pk+Yf7C+hoa3g==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + cssnano-utils@5.0.1: + resolution: {integrity: sha512-ZIP71eQgG9JwjVZsTPSqhc6GHgEr53uJ7tK5///VfyWj6Xp2DBmixWHqJgPno+PqATzn48pL42ww9x5SSGmhZg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + cssnano@7.1.3: + resolution: {integrity: sha512-mLFHQAzyapMVFLiJIn7Ef4C2UCEvtlTlbyILR6B5ZsUAV3D/Pa761R5uC1YPhyBkRd3eqaDm2ncaNrD7R4mTRg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + cssstyle@5.3.7: + resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} + engines: {node: '>=20'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-urls@6.0.1: + resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} + engines: {node: '>=20'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + dataloader@2.2.3: + resolution: {integrity: sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==} + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debounce@2.2.0: + resolution: {integrity: sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==} + engines: {node: '>=18'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@3.1.0: + resolution: {integrity: sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + decamelize@4.0.0: + resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} + engines: {node: '>=10'} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dependency-graph@0.11.0: + resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==} + engines: {node: '>= 0.6.0'} + + dependency-graph@1.0.0: + resolution: {integrity: sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==} + engines: {node: '>=4'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + diff@4.0.4: + resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} + engines: {node: '>=0.3.1'} + + diff@7.0.0: + resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} + engines: {node: '>=0.3.1'} + + dioc@3.0.2: + resolution: {integrity: sha512-D8S1vMTtBeXeUW2dR0rJ7xiPHxp1zm1NzO2B4Aj4RAJB6E6urA0/xD/CnGs6J1JkgUZvUgaC+oedx/k5NrT+/g==} + peerDependencies: + vue: 3.5.38 + peerDependenciesMeta: + vue: + optional: true + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + display-notification@2.0.0: + resolution: {integrity: sha512-TdmtlAcdqy1NU+j7zlkDdMnCL878zriLaBmoD9quOoq1ySSSGv03l0hXK5CvIFZlIfFI/hizqdQuW+Num7xuhw==} + engines: {node: '>=4'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + doctypes@1.1.0: + resolution: {integrity: sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==} + + dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@3.3.0: + resolution: {integrity: sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==} + engines: {node: '>= 4'} + + domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + engines: {node: '>= 4'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + + domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.5.0: + resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.4.1: + resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} + engines: {node: '>=12'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + + dynamic-dedupe@0.3.0: + resolution: {integrity: sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + effect@3.20.0: + resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + ejs@5.0.2: + resolution: {integrity: sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==} + engines: {node: '>=0.12.18'} + hasBin: true + + electron-to-chromium@1.5.325: + resolution: {integrity: sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==} + + electron-to-chromium@1.5.344: + resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + encoding-japanese@2.2.0: + resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==} + engines: {node: '>=8.10.0'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + engine.io-client@3.5.6: + resolution: {integrity: sha512-2fDMKiXSU7bGRDCWEw9cHEdRNfoU8cpP6lt+nwJhv72tSJpO7YBsqMqYZ63eVvwX3l9prPl2k/mxhfVhY+SDWg==} + + engine.io-client@4.1.4: + resolution: {integrity: sha512-843fqAdKeUMFqKi1sSjnR11tJ4wi8sIefu6+JC1OzkkJBmjtc/gM/rZ53tJfu5Iae/3gApm5veoS+v+gtT0+Fg==} + + engine.io-client@6.6.4: + resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==} + + engine.io-parser@2.2.1: + resolution: {integrity: sha512-x+dN/fBH8Ro8TFwJ+rkB2AmuVw9Yu2mockR/p3W8f8YtExwFgDvBDi0GWyb4ZLkpahtDGZgtr3zLovanJghPqg==} + + engine.io-parser@4.0.3: + resolution: {integrity: sha512-xEAAY0msNnESNPc00e19y5heTPX4y/TJ36gr8t1voOaNmTojP9b3oK3BbJLFufW2XFPQaaijpFewm2g2Um3uqA==} + engines: {node: '>=8.0.0'} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + enhanced-resolve@2.3.0: + resolution: {integrity: sha512-n6e4bsCpzsP0OB76X+vEWhySUQI8GHPVFVK+3QkX35tbryy2WoeGeK5kQ+oxzgDVHjIZyz5fyS60Mi3EpQLc0Q==} + engines: {node: '>=0.6'} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + + entities@3.0.1: + resolution: {integrity: sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==} + engines: {node: '>=0.12'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + errno@0.1.8: + resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} + hasBin: true + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.1: + resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} + engines: {node: '>= 0.4'} + + es6-promise@3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + + esbuild-android-64@0.15.18: + resolution: {integrity: sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + esbuild-android-arm64@0.15.18: + resolution: {integrity: sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + esbuild-darwin-64@0.15.18: + resolution: {integrity: sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + esbuild-darwin-arm64@0.15.18: + resolution: {integrity: sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + esbuild-freebsd-64@0.15.18: + resolution: {integrity: sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + esbuild-freebsd-arm64@0.15.18: + resolution: {integrity: sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + esbuild-linux-32@0.15.18: + resolution: {integrity: sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + esbuild-linux-64@0.15.18: + resolution: {integrity: sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + esbuild-linux-arm64@0.15.18: + resolution: {integrity: sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + esbuild-linux-arm@0.15.18: + resolution: {integrity: sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + esbuild-linux-mips64le@0.15.18: + resolution: {integrity: sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + esbuild-linux-ppc64le@0.15.18: + resolution: {integrity: sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + esbuild-linux-riscv64@0.15.18: + resolution: {integrity: sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + esbuild-linux-s390x@0.15.18: + resolution: {integrity: sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + esbuild-netbsd-64@0.15.18: + resolution: {integrity: sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + esbuild-openbsd-64@0.15.18: + resolution: {integrity: sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + esbuild-sunos-64@0.15.18: + resolution: {integrity: sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + esbuild-windows-32@0.15.18: + resolution: {integrity: sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + esbuild-windows-64@0.15.18: + resolution: {integrity: sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + esbuild-windows-arm64@0.15.18: + resolution: {integrity: sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + esbuild@0.15.18: + resolution: {integrity: sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.17.19: + resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-goat@3.0.0: + resolution: {integrity: sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==} + engines: {node: '>=10'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-applescript@1.0.0: + resolution: {integrity: sha512-4/hFwoYaC6TkpDn9A3pTC52zQPArFeXuIfhUtCGYdauTzXVP9H3BDr3oO/QzQehMpLDC7srvYgfwvImPFGfvBA==} + engines: {node: '>=0.10.0'} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-prettier@5.5.6: + resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-plugin-vue@10.9.2: + resolution: {integrity: sha512-4g7ZP3pYcuqd7Zp0pzUKcos0W+RkjBz4EGdhJ92FcYk6v03Ti/GK5NwjgsjxHK+98eXDbHeK7VtX1az7/8doZA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-eslint-parser: ^10.3.0 + peerDependenciesMeta: + '@stylistic/eslint-plugin': + optional: true + '@typescript-eslint/parser': + optional: true + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.5.0: + resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + eslint@9.39.2: + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esprima-extract-comments@1.1.0: + resolution: {integrity: sha512-sBQUnvJwpeE9QnPrxh7dpI/dp67erYG4WXEAreAMoelPRpMR7NWb4YtwRPn9b+H1uLQKl/qS8WYmyaljTpjIsw==} + engines: {node: '>=4'} + + esprima@1.2.5: + resolution: {integrity: sha512-S9VbPDU0adFErpDai3qDkjq8+G05ONtKzcyNrPKg/ZKa+tf879nX2KexNU95b31UoTJjRLInNBHHHjFPoCd7lQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@1.9.3: + resolution: {integrity: sha512-25w1fMXQrGdoquWnScXZGckOv+Wes+JDnuN/+7ex3SauFRS72r2lFDec0EKPt2YD1wUJ/IrfEex+9yp4hfSOJA==} + engines: {node: '>=0.10.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@0.6.1: + resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} + + estree-walker@1.0.1: + resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-stream@4.0.1: + resolution: {integrity: sha512-qACXdu/9VHPBzcyhdOWR5/IahhGMf0roTeZJfzz077GwylcDd90yOHLouhmv7GJ5XzPi6ekaQWd8AvPP2nOvpA==} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + eventemitter3@3.1.2: + resolution: {integrity: sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + execa@2.0.0: + resolution: {integrity: sha512-+ym7S09yUVPHEhYBsdLm53ZjCmCSeAQVtM/iN9dDj9tbvcBnCeBXTXHPWR9HXzht+vslGROteM8bSUdr4YszUg==} + engines: {node: '>=8'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit-x@0.2.2: + resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} + engines: {node: '>= 0.8.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + expect@30.3.0: + resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + expect@30.4.1: + resolution: {integrity: sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + extend-object@1.0.0: + resolution: {integrity: sha512-0dHDIXC7y7LDmCh/lp1oYkmv73K25AMugQI07r8eFopkW6f7Ufn1q+ETMsJjnV9Am14SlElkqy3O92r6xEaxPw==} + + extract-comments@1.1.0: + resolution: {integrity: sha512-dzbZV2AdSSVW/4E7Ti5hZdHWbA+Z80RJsJhr5uiL10oyjl/gy7/o+HI1HwK4/WSZhlq4SNKU3oUzXlM13Qx02Q==} + engines: {node: '>=6'} + + extract-files@11.0.0: + resolution: {integrity: sha512-FuoE1qtbJ4bBVvv94CC7s0oTnKUGvQs+Rjf1L2SJFfS+HTVVjhPFtehPdQ0JiGPqVNfSSZvL5yzHHQq2Z4WNhQ==} + engines: {node: ^12.20 || >= 14.13} + + faraday-cage@0.1.0: + resolution: {integrity: sha512-tzTqTDYO/OKrJ2y9GDfztPM6YuqGkD0GLwtdpf9mX1n29jRiOcgpElljp4F6Pr8iq7UIkVUXjV2yNnKuNSgzBQ==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fast-url-parser@1.1.3: + resolution: {integrity: sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fbjs-css-vars@1.0.2: + resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} + + fbjs@3.0.5: + resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + + file-type@3.9.0: + resolution: {integrity: sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==} + engines: {node: '>=0.10.0'} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fixpack@4.0.0: + resolution: {integrity: sha512-5SM1+H2CcuJ3gGEwTiVo/+nd/hYpNj9Ch3iMDOQ58ndY+VGQ2QdvaUTkd3otjZvYnd/8LF/HkJ5cx7PBq0orCQ==} + hasBin: true + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fork-ts-checker-webpack-plugin@9.1.0: + resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} + engines: {node: '>=14.21.3'} + peerDependencies: + typescript: '>3.6.0' + webpack: ^5.11.0 + + form-data@2.5.6: + resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==} + engines: {node: '>= 0.12'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fp-ts@2.16.11: + resolution: {integrity: sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + from@0.1.7: + resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs-monkey@1.1.0: + resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-own-enumerable-property-symbols@3.0.2: + resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + giget@3.2.0: + resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} + hasBin: true + + git-raw-commits@5.0.1: + resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} + engines: {node: '>=18'} + hasBin: true + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + global-directory@5.0.0: + resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} + engines: {node: '>=20'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grammex@3.1.12: + resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==} + + graphmatch@1.1.1: + resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + + graphql-config@4.5.0: + resolution: {integrity: sha512-x6D0/cftpLUJ0Ch1e5sj1TZn6Wcxx4oMfmhaG9shM0DKajA9iR+j1z86GSTQ19fShbGvrSSvbIQsHku6aQ6BBw==} + engines: {node: '>= 10.0.0'} + peerDependencies: + cosmiconfig-toml-loader: ^1.0.0 + graphql: ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + cosmiconfig-toml-loader: + optional: true + + graphql-config@5.1.6: + resolution: {integrity: sha512-fCkYnm4Kdq3un0YIM4BCZHVR5xl0UeLP6syxxO7KAstdY7QVyVvTHP0kRPDYEP1v08uwtJVgis5sj3IOTLOniQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + cosmiconfig-toml-loader: ^1.0.0 + graphql: ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + cosmiconfig-toml-loader: + optional: true + + graphql-language-service-interface@2.10.2: + resolution: {integrity: sha512-RKIEBPhRMWdXY3fxRs99XysTDnEgAvNbu8ov/5iOlnkZsWQNzitjtd0O0l1CutQOQt3iXoHde7w8uhCnKL4tcg==} + deprecated: this package has been merged into graphql-language-service + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 + + graphql-language-service-parser@1.10.4: + resolution: {integrity: sha512-duDE+0aeKLFVrb9Kf28U84ZEHhHcvTjWIT6dJbIAQJWBaDoht0D4BK9EIhd94I3DtKRc1JCJb2+70y1lvP/hiA==} + deprecated: this package has been merged into graphql-language-service + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 + + graphql-language-service-types@1.8.7: + resolution: {integrity: sha512-LP/Mx0nFBshYEyD0Ny6EVGfacJAGVx+qXtlJP4hLzUdBNOGimfDNtMVIdZANBXHXcM41MDgMHTnyEx2g6/Ttbw==} + deprecated: this package has been merged into graphql-language-service + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 + + graphql-language-service-utils@2.7.1: + resolution: {integrity: sha512-Wci5MbrQj+6d7rfvbORrA9uDlfMysBWYaG49ST5TKylNaXYFf3ixFOa74iM1KtM9eidosUbI3E1JlWi0JaidJA==} + deprecated: this package has been merged into graphql-language-service + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 + + graphql-query-complexity@1.1.1: + resolution: {integrity: sha512-q0u1yrPYdEYnhEL4x6xeEoOHdHUV4hISrI/uDFhOaxLvIN60RqlzV3kW6N0f+0pygGM9wjskl1cD5/55G+TPmw==} + peerDependencies: + graphql: ^15.0.0 || ^16.0.0 + + graphql-redis-subscriptions@2.7.0: + resolution: {integrity: sha512-IK4uCKx1UNhkcnG9lIqFWz9PpltSbuM8RygwGoB/e1HZMuKpAGeqqfHFeLKkQjjubvk4tAdUdx48AUkTAXJ17Q==} + peerDependencies: + graphql-subscriptions: ^1.0.0 || ^2.0.0 || ^3.0.0 + + graphql-subscriptions@3.0.0: + resolution: {integrity: sha512-kZCdevgmzDjGAOqH7GlDmQXYAkuHoKpMlJrqF40HMPhUhM5ZWSFSxCwD/nSi6AkaijmMfsFhoJRGJ27UseCvRA==} + peerDependencies: + graphql: ^15.7.2 || ^16.0.0 + + graphql-tag@2.12.6: + resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==} + engines: {node: '>=10'} + peerDependencies: + graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + graphql-tag@2.12.7: + resolution: {integrity: sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==} + engines: {node: '>=10'} + peerDependencies: + graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + graphql-ws@5.12.1: + resolution: {integrity: sha512-umt4f5NnMK46ChM2coO36PTFhHouBrK9stWWBczERguwYrGnPNxJ9dimU6IyOBfOkC6Izhkg4H8+F51W/8CYDg==} + engines: {node: '>=10'} + peerDependencies: + graphql: '>=0.11 <=16' + + graphql-ws@6.0.8: + resolution: {integrity: sha512-m3EOaNsUBXwAnkBWbzPfe0Nq8pXUfxsWnolC54sru3FzHvhTZL0Ouf/BoQsaGAXqM+YPerXOJ47BUnmgmoupCw==} + engines: {node: '>=20'} + peerDependencies: + '@fastify/websocket': ^10 || ^11 + crossws: ~0.3 + graphql: ^15.10.1 || ^16 + ws: 8.21.0 + peerDependenciesMeta: + '@fastify/websocket': + optional: true + crossws: + optional: true + ws: + optional: true + + graphql@16.13.2: + resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + graphql@16.14.0: + resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + har-validator-compiled@1.0.0: + resolution: {integrity: sha512-dher7nFSx+Ef6OoqVveLClh8itAR3vd8Qx70Lh/hEgP1iGeARAolbci7Y8JBrHIYgFCT6xRdvvL16AR9Zh07Dw==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-binary2@1.0.3: + resolution: {integrity: sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==} + + has-cors@1.1.0: + resolution: {integrity: sha512-g5VNKdkFuUuVCP9gYfDJHjK2nqdQJ7aDLTnycnc2+RvsOQbuLdF5pm7vuE5J76SEBIQjs4kQY/BWq74JUmjbXA==} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hawk@9.0.2: + resolution: {integrity: sha512-EJMLBZAWg+EoI/aAJWDhrYGvucWYvY37CdGXolkol0ITGswkDHtDnjbFcPBaIv3jbtpfWqYjXMm4KhfXPOLCRg==} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + header-case@2.0.4: + resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + highlightjs-curl@1.3.0: + resolution: {integrity: sha512-50UEfZq1KR0Lfk2Tr6xb/MUIZH3h10oNC0OTy9g7WELcs5Fgy/mKN1vEhuKTkKbdo8vr5F9GXstu2eLhApfQ3A==} + + hono@4.12.25: + resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + engines: {node: '>=16.9.0'} + + hookable@6.1.0: + resolution: {integrity: sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==} + + hosted-git-info@2.8.9: + resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + + htmlnano@2.1.5: + resolution: {integrity: sha512-IXffzXq1beGQN2rsr03aIPK/rVU1jR2uwHymlAIEf97Tl5WdpG50IsQ5nWGvSGQJ+x6U7S6yac9rRiFgAg4/xQ==} + peerDependencies: + cssnano: ^7.0.0 + postcss: ^8.3.11 + purgecss: ^7.0.2 + relateurl: ^0.2.7 + srcset: 5.0.1 + svgo: ^3.0.2 + terser: ^5.10.0 + uncss: ^0.17.3 + peerDependenciesMeta: + cssnano: + optional: true + postcss: + optional: true + purgecss: + optional: true + relateurl: + optional: true + srcset: + optional: true + svgo: + optional: true + terser: + optional: true + uncss: + optional: true + + htmlparser2@5.0.1: + resolution: {integrity: sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==} + + htmlparser2@7.2.0: + resolution: {integrity: sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + htmlparser2@9.1.0: + resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + + http-cookie-agent@7.0.3: + resolution: {integrity: sha512-EeZo7CGhfqPW6R006rJa4QtZZUpBygDa2HZH3DJqsTzTjyRE6foDBVQIv/pjVsxHC8z2GIdbB1Hvn9SRorP3WQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + tough-cookie: ^4.0.0 || ^5.0.0 || ^6.0.0 + undici: ^7.0.0 + peerDependenciesMeta: + undici: + optional: true + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + + http-reasons@0.1.0: + resolution: {integrity: sha512-P6kYh0lKZ+y29T2Gqz+RlC9WBLhKe8kDmcJ+A+611jFfxdPsbMRQ5aNmFRM3lENqFkK+HTTL+tlQviAiv0AbLQ==} + + http-server@14.1.1: + resolution: {integrity: sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==} + engines: {node: '>=12'} + hasBin: true + + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + + http2-client@1.3.5: + resolution: {integrity: sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + immutable@3.7.6: + resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==} + engines: {node: '>=0.8.0'} + + immutable@5.1.6: + resolution: {integrity: sha512-q1swsS8K7L8usSHuOqF2TAoCCkonYz0SG38wLAggaa4Wml70zixIvt2ql4coQ2C2B3hTjltJry4r6bULwgAXLQ==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-from@4.0.0: + resolution: {integrity: sha512-P9J71vT5nLlDeV8FHs5nNxaLbrpfAV5cF5srvbZfpwpcJoM/xZR3hiv+q+SAnuSmuGbXMWud063iIMx/V/EWZQ==} + engines: {node: '>=12.2'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indexof@0.0.1: + resolution: {integrity: sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.3: + resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + insomnia-importers@3.6.0: + resolution: {integrity: sha512-42FvUCwQcHg8OSr2EtppkfByPVQFhBgaxhpu7zslnvQXtf/tLU568xmAxI6fMIY0S69u2J2SyIli3x7qm+9r2w==} + deprecated: Package no longer supported. Use at your own risk. + hasBin: true + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + + io-ts@2.2.22: + resolution: {integrity: sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA==} + peerDependencies: + fp-ts: ^2.5.0 + + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-absolute@1.0.0: + resolution: {integrity: sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==} + engines: {node: '>=0.10.0'} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + + is-expression@4.0.0: + resolution: {integrity: sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-json@2.0.1: + resolution: {integrity: sha512-6BEnpVn1rcf3ngfmViLM6vjUjGErbdrL4rwlv+u1NO1XO8kqT4YGL8+19Q+Z/bas8tY90BTWMk2+fW1g6hQjbA==} + + is-lower-case@2.0.2: + resolution: {integrity: sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@1.0.1: + resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} + engines: {node: '>=0.10.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-promise@2.2.2: + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-regexp@1.0.0: + resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} + engines: {node: '>=0.10.0'} + + is-relative@1.0.0: + resolution: {integrity: sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==} + engines: {node: '>=0.10.0'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-unc-path@1.0.0: + resolution: {integrity: sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==} + engines: {node: '>=0.10.0'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-upper-case@2.0.2: + resolution: {integrity: sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==} + + is-url@1.2.4: + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.1: + resolution: {integrity: sha512-c2cu3UxbI+b6kR3fy0nRnAhodsvR9dx7U5+znCOzdj6IfP3upFURTr0Xl5BlQZNKZjEtxrmVyfSdeE3O57smoQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isolated-vm@6.1.2: + resolution: {integrity: sha512-GGfsHqtlZiiurZaxB/3kY7LLAXR3sgzDul0fom4cSyBjx6ZbjpTrFWiH3z/nUfLJGJ8PIq9LQmQFiAxu24+I7A==} + engines: {node: '>=22.0.0'} + + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: 8.21.0 + + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: 8.21.0 + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterall@1.3.0: + resolution: {integrity: sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + jest-changed-files@30.4.1: + resolution: {integrity: sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-circus@30.4.2: + resolution: {integrity: sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-cli@30.4.2: + resolution: {integrity: sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@30.4.2: + resolution: {integrity: sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@types/node': '*' + esbuild-register: '>=3.4.0' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-diff@30.3.0: + resolution: {integrity: sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-diff@30.4.1: + resolution: {integrity: sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@30.4.0: + resolution: {integrity: sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-each@30.4.1: + resolution: {integrity: sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-environment-node@30.4.1: + resolution: {integrity: sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@30.4.1: + resolution: {integrity: sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-leak-detector@30.4.1: + resolution: {integrity: sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@30.3.0: + resolution: {integrity: sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-matcher-utils@30.4.1: + resolution: {integrity: sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@30.3.0: + resolution: {integrity: sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@30.4.1: + resolution: {integrity: sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-mock-extended@4.0.1: + resolution: {integrity: sha512-Q/4k/yefiv/Al3n755V9xDEwMiL+7LwkjRKjaORkgCdovZv00hF/D0QypLoqO+MVfrYkzCYa4BYlcEKA74iOgQ==} + peerDependencies: + '@jest/globals': ^28.0.0 || ^29.0.0 || ^30.0.0 + jest: ^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0 || ^30.0.0 + typescript: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + + jest-mock@30.3.0: + resolution: {integrity: sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-mock@30.4.1: + resolution: {integrity: sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@30.0.1: + resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-regex-util@30.4.0: + resolution: {integrity: sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve-dependencies@30.4.2: + resolution: {integrity: sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve@30.4.1: + resolution: {integrity: sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runner@30.4.2: + resolution: {integrity: sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runtime@30.4.2: + resolution: {integrity: sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-snapshot@30.4.1: + resolution: {integrity: sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@30.3.0: + resolution: {integrity: sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-util@30.4.1: + resolution: {integrity: sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-validate@30.4.1: + resolution: {integrity: sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-watcher@30.4.1: + resolution: {integrity: sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jest-worker@30.4.1: + resolution: {integrity: sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest@30.4.2: + resolution: {integrity: sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jiti@1.17.1: + resolution: {integrity: sha512-NZIITw8uZQFuzQimqjUxIrIcEdxYDFIe/0xYfIlVXTkiBjjyBEvgasj5bb0/cHtPRD/NziPbT312sFrkI5ALpw==} + hasBin: true + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + jq-wasm@1.1.0-jq-1.8.1: + resolution: {integrity: sha512-lWfu34lpDFIygOYcL5TzxhZIApDR9iR5XywcVoyUAZ6jlQrj8HKHOKeCcHgUm2dE9RVdbP3eqNAKGLuj+k4seQ==} + + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + + js-md5@0.8.3: + resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==} + + js-stringify@1.0.2: + resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + jsdom@27.4.0: + resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-better-errors@1.0.2: + resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json-to-pretty-yaml@1.2.2: + resolution: {integrity: sha512-rvm6hunfCcqegwYaG5T4yKJWxc9FXFgBVrcTZ4XfSVRwa5HA/Xs+vB/Eo9treYYHCeNM0nrSUr82V/M31Urc7A==} + engines: {node: '>= 0.2.0'} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-eslint-parser@2.4.2: + resolution: {integrity: sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jstransformer@1.0.0: + resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + juice@10.0.1: + resolution: {integrity: sha512-ZhJT1soxJCkOiO55/mz8yeBKTAJhRzX9WBO+16ZTqNTONnnVlUPyVBIzQ7lDRjaBdTbid+bAnyIon/GM3yp4cA==} + engines: {node: '>=10.0.0'} + hasBin: true + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + libbase64@1.3.0: + resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} + + libmime@5.3.7: + resolution: {integrity: sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==} + + libphonenumber-js@1.12.40: + resolution: {integrity: sha512-HKGs7GowShNls3Zh+7DTr6wYpPk5jC78l508yQQY3e8ZgJChM3A9JZghmMJZuK+5bogSfuTafpjksGSR3aMIEg==} + + libqp@2.1.1: + resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + linkify-it@5.0.1: + resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} + + lint-staged@16.4.0: + resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} + engines: {node: '>=20.17'} + hasBin: true + + liquid-json@0.3.1: + resolution: {integrity: sha512-wUayTU8MS827Dam6MxgD72Ui+KOSF+u/eIqpatOtjnvgJ0+mnDq33uC2M7J0tPK+upe/DpUAuK4JUU89iBoNKQ==} + engines: {node: '>=4'} + + liquidjs@10.26.0: + resolution: {integrity: sha512-Ub9FFNOLB9tdH/gB2MKkeU7x9NifoVidxAam6YWU7LUcy7Glumi6q5C3tDpWOEpeNaT8Cdwdb1axEdlvLSoyaQ==} + engines: {node: '>=16'} + hasBin: true + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + + load-json-file@4.0.0: + resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} + engines: {node: '>=4'} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + loader-runner@4.3.1: + resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} + engines: {node: '>=6.11.5'} + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.get@4.4.2: + resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.kebabcase@4.1.1: + resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + + lodash.omit@4.18.0: + resolution: {integrity: sha512-hZXIupXdHtocTnvIJ2aCd2vxKYtxex6gbiGuPvgBRnFQO9yu3AtmDAbVuCXcSsQx3INo/1g71OktlFFA/ES8Xg==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + lodash.upperfirst@4.3.1: + resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + loglevel@1.9.2: + resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} + engines: {node: '>= 0.6.0'} + + long@4.0.0: + resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lossless-json@4.3.0: + resolution: {integrity: sha512-ToxOC+SsduRmdSuoLZLYAr5zy1Qu7l5XhmPWM3zefCZ5IcrzW/h108qbJUKfOlDlhvhjUK84+8PSVX0kxnit0g==} + + lower-case-first@2.0.2: + resolution: {integrity: sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==} + + lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + magic-string@0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + + magic-string@0.26.7: + resolution: {integrity: sha512-hX9XH3ziStPoPhJxLq1syWuZMxbDvGNbVchfrdCtanC7D13888bMFow61x8axrx+GfHLtVeAx2kxL7tTGRl+Ow==} + engines: {node: '>=12'} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mailparser@3.9.3: + resolution: {integrity: sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + map-cache@0.2.2: + resolution: {integrity: sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==} + engines: {node: '>=0.10.0'} + + map-stream@0.0.7: + resolution: {integrity: sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==} + + markdown-it@14.2.0: + resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} + hasBin: true + + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + memfs@3.5.3: + resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} + engines: {node: '>= 4.0.0'} + + memory-fs@0.3.0: + resolution: {integrity: sha512-QTNXnl79X97kZ9jJk/meJrtDuvgvRakX5LU7HZW1L7MsXHuSTwoMIzN9tOLLH3Xfsj/gbsSqX/ovnsqz246zKQ==} + + memorystream@0.3.1: + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} + engines: {node: '>= 0.10.0'} + + mensch@0.3.4: + resolution: {integrity: sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + meros@1.3.2: + resolution: {integrity: sha512-Q3mobPbvEx7XbwhnC1J1r60+5H6EZyNccdzSz0eGexJRwouUtTZxPVRGdqKtxlpD84ScK4+tIGldkqDtCKdI0A==} + engines: {node: '>=13'} + peerDependencies: + '@types/node': '>=13' + peerDependenciesMeta: + '@types/node': + optional: true + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-format@2.0.2: + resolution: {integrity: sha512-Y5ERWVcyh3sby9Fx2U5F1yatiTFjNsqF5NltihTWI9QgNtr5o3dbCZdcKa1l2wyfhnwwoP9HGNxga7LqZLA6gw==} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.2.3: + resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==} + engines: {node: 18 || 20 || >=22} + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@4.2.5: + resolution: {integrity: sha512-0htgU9jHBKHj95GPRwJaz73vT2ABPD7ge+OYBarvKbNtKkVQtxlIJb12LIl5ImbKqh1zcreY2fwKc8FnemLy5A==} + engines: {node: '>=10'} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + mjml-accordion@5.0.0-alpha.4: + resolution: {integrity: sha512-Mw1DnHRJHwHLqkwAXcRLBHZMYLtw7qqDNJdxISihz5KyY2arc8MbZixoUHCd3M/2zw04J8fU5HJ8WslANrmu9g==} + + mjml-body@5.0.0-alpha.4: + resolution: {integrity: sha512-hPa4JpaF7rmKgKdC/DqC9SM97XoXoWPAf8c+8GpSvn/9AwXnt9X0TgBoP7/sUR26N06j26+/fprB7cTiqy/glA==} + + mjml-button@5.0.0-alpha.4: + resolution: {integrity: sha512-4rOobUMBuoDjsnqFgtLMBZMdnTmS8vMLI+ZfrvyyxaPL9RbeISZlbl3/RvxiZjAyctPh92X/PazKhHJyeSSqeg==} + + mjml-carousel@5.0.0-alpha.4: + resolution: {integrity: sha512-cUPIFLoseSlsq0/w/gB5/sMd88P2LCPK+ISllSdvyO4Lo2+uHDlmwMxRCBBIuWJBunqVH9v2Z2MtAIOL6DqJsQ==} + + mjml-cli@5.0.0-alpha.4: + resolution: {integrity: sha512-YXaCYxQ64I1DFmlJe5OI6S1U3jbF3CdfSw+IsOTxxY+i0lEyuiqJxLceKA2ogAwMjlZEm1BBGENqNnvDjeXmUw==} + hasBin: true + + mjml-column@5.0.0-alpha.4: + resolution: {integrity: sha512-5gT0YNU+aAjpUxS39ySS2SqL+NLyXkCi4BPutzZTnmz2CvIwrBIOJVEHRAWSjNUWFfFLS4scquI8yO4g8AVfdA==} + + mjml-core@5.0.0-alpha.4: + resolution: {integrity: sha512-QioM27JKUWhCfDbHxY1YnkgpTF0Y+hV1MHy5XeVTQlvIbEeRcO+gAPzhVooGYsKqQL/dWNM9jl34el0peRoscQ==} + + mjml-divider@5.0.0-alpha.4: + resolution: {integrity: sha512-sPv5CARR7NX6ohbpJCzErgv3Y1rUnmtOs3SeiEgp4Y9J+O+wKaOZa/ffuNHVrxkC26U91e3zmbWItfIILPUgYA==} + + mjml-group@5.0.0-alpha.4: + resolution: {integrity: sha512-V+YuKGwL6JMTAnvTsKQM4wF6VPiHCgo92aN9iNuY46N8oYM349pEgrHDBAWRhyZ7UAov/UoPUUJRydJk4PCGyw==} + + mjml-head-attributes@5.0.0-alpha.4: + resolution: {integrity: sha512-EmyiNar6SeaMDcTa8gchUoONfNbUfCjI3eAwjkHy1SfDl5tXKku2W1oXCst8vtNpjoBzllHcTW81x0OpgDM4Cg==} + + mjml-head-breakpoint@5.0.0-alpha.4: + resolution: {integrity: sha512-S8FBpMKO2wDTJscy6EtQuQRZMu1YSOD5fCZ6sHINWC2A40I1ZFsCAvlLtW/vr9P50XjgX06m1T/vTcYifMCMMQ==} + + mjml-head-font@5.0.0-alpha.4: + resolution: {integrity: sha512-bc/bduI1BljN1rjcF8w5TOBZ+D0eBu5O0BnSqLwoct7xeoTTvYLxuTsdgoloh6Jm1vf3RMqr4ANySDrXvFkoPw==} + + mjml-head-html-attributes@5.0.0-alpha.4: + resolution: {integrity: sha512-NJwXgE3o1E3BcVTG6+Hl/ofCZFsoKnjt//Sm/Ks+0u+aZD7VycsF+nXxBlMLOWhMQrP+JIZAok7mYE+A1ztAPg==} + + mjml-head-preview@5.0.0-alpha.4: + resolution: {integrity: sha512-cH2VaTVapSeYd+OIfeG7yQtZVDSGqV86iUE4UHasTFpaxcPigpaS5NzAiDL9f7Pzp83q/eL6tdc3r7jX7IHkBQ==} + + mjml-head-style@5.0.0-alpha.4: + resolution: {integrity: sha512-7WAsEctOMFOsH8WYrJ/6ZZ2x+m4SKCdpgXWoJwcIVVXiwt/I9C0iGW5b82ZJh0jaGEH5i1dsKMcMcvKnHuiTog==} + + mjml-head-title@5.0.0-alpha.4: + resolution: {integrity: sha512-GL/LKPkqbyCb0fRrf5NL0Xx/1xX0nF5dVQsmwfH7YdGM8Syx+ging2lrOhRxUic6NE0STXz5H16c0+oisU2HCQ==} + + mjml-head@5.0.0-alpha.4: + resolution: {integrity: sha512-QF+l4pCYbmTvFPz522k8hbzJgWGmOj16/bTwE+mhGueRRMGmVAp7gCqeNnI9PO/O8zTF7fisgseUuHmAWkCIFg==} + + mjml-hero@5.0.0-alpha.4: + resolution: {integrity: sha512-KNjc+uEuEs5edlQxkoLnSSQw302M+GSBuGYEO1kThiFeJavZvdCeV9W+bTdeM6i7Cbn+UjfJQPPVaAo+yT6ETg==} + + mjml-image@5.0.0-alpha.4: + resolution: {integrity: sha512-9oQJOOav9dWQcl8lUnn0ZVHCKnV/4Z8G6roT5FZBF6yKoqMCcgCJ9Sfhp3KqRzDvTVAsTnM8EzNDC+tBImD6Og==} + + mjml-navbar@5.0.0-alpha.4: + resolution: {integrity: sha512-cMgeW1SeSlqYuMe7knVk/PXkroLwdI/jBopXetJVWFSURJij9AHto6vKmd+/aFlfPC8oWKPBKvEieCwEDgk6Lg==} + + mjml-parser-xml@5.0.0-alpha.4: + resolution: {integrity: sha512-pk2sWuaUgiX2CwbL2qsh1g7Ry110YQMnX84KuIcEnzOQaCyuvGtOGIXuOiOthLRbVnKz15P7EsNnwHRg/d/Ihw==} + + mjml-preset-core@5.0.0-alpha.4: + resolution: {integrity: sha512-V5I+3NJoSV/pFia5MjP5u8BgqJwHqR4KigUjGtOr5chZljyehFNOeL8ghEZ551BCzMrtMzarnChsEnkHI1Qirw==} + + mjml-raw@5.0.0-alpha.4: + resolution: {integrity: sha512-puCKbIuMVFlFyZx1vaKy45iS3iTgFpmFcah5C+E5VnEyKDOB6su6Fs8OnuAHkq+TIdGc6q9kqI1MwlRn0Mrr8w==} + + mjml-section@5.0.0-alpha.4: + resolution: {integrity: sha512-sbXvB9ik9i1zueCj996LvmiGn7EsZR5E8KXu08My3YxRbIoQrZtYdVOFM//858zDXtE/HB39HcLVXt1sG7GLig==} + + mjml-social@5.0.0-alpha.4: + resolution: {integrity: sha512-lP+ykZB0wppYulBv1q0xM3kFCoYaKLyROZJgDjzvMlBRUA+p21/nu4JEjqYGdq0gQqoAhLQGW8hOUnEnS0Aydw==} + + mjml-spacer@5.0.0-alpha.4: + resolution: {integrity: sha512-xHEunDOUL7Al3Rs5z20mwJsPllZdClriOptti5DP2hJjPkF2X/nwFTaH/kXvaPd2/CSZGHO+aQ5r/X2huV/43w==} + + mjml-table@5.0.0-alpha.4: + resolution: {integrity: sha512-TCh5IJ6IDkv0bkn/8r7GslEpDiRaRoUonHzbFbsi1rNojayg+oOJbaUhpMh1gvBzVlmAyMeX2XGA92A1EiqJjw==} + + mjml-text@5.0.0-alpha.4: + resolution: {integrity: sha512-yJi6D1hDaKxtJLu0M330yHn0BLo55T9+TaOw9GaWWlF28yphUZ6Ge+ppSZYeMbmzWwCUMVPPOcsYMpaLHtd7Iw==} + + mjml-validator@5.0.0-alpha.4: + resolution: {integrity: sha512-0RWcTmUxluJc6XR/7Wmve9z4ydUGnLTUuyaHWX624V/xOaRPIThCllluh67TbSK6W2t4mwIHCdT+MgQC/wFwog==} + + mjml-wrapper@5.0.0-alpha.4: + resolution: {integrity: sha512-sISlNUC3EVj5YMZfdQw19B9AIwCmgT8XWJ5r6HsBfYtxwdeYBHA/stygx84lEjDYPJK7U5FL65u01vfP71vM/w==} + + mjml@5.0.0-alpha.4: + resolution: {integrity: sha512-SUdO4F/XYtXkIYKgjC3hO2oplSllb3DRsHxdNNMuyYh0y2HMxVgqjCcViCBLKc8zJrWM4NO5deZwO+8NjLcM2Q==} + hasBin: true + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + mocha@11.7.6: + resolution: {integrity: sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + + morgan@1.11.0: + resolution: {integrity: sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==} + engines: {node: '>= 0.8.0'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.3.1: + resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + multer@2.2.0: + resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + engines: {node: '>= 10.16.0'} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + nanoid@3.3.14: + resolution: {integrity: sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-addon-api@8.7.0: + resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==} + engines: {node: ^18 || ^20 || >= 21} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-emoji@1.11.0: + resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + + node-fetch-h2@2.3.0: + resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==} + engines: {node: 4.x || >=6.0.0} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-readfiles@0.2.0: + resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==} + + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + + nodemailer@7.0.13: + resolution: {integrity: sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==} + engines: {node: '>=6.0.0'} + + nodemailer@9.0.1: + resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==} + engines: {node: '>=6.0.0'} + + normalize-package-data@2.5.0: + resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} + + normalize-path@2.1.1: + resolution: {integrity: sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==} + engines: {node: '>=0.10.0'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-all@4.1.5: + resolution: {integrity: sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==} + engines: {node: '>= 4'} + hasBin: true + + npm-run-path@3.1.0: + resolution: {integrity: sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg==} + engines: {node: '>=8'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + nprogress@0.2.0: + resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + + nunjucks@3.2.4: + resolution: {integrity: sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==} + engines: {node: '>= 6.9.0'} + hasBin: true + peerDependencies: + chokidar: ^3.3.0 + peerDependenciesMeta: + chokidar: + optional: true + + oas-kit-common@1.0.8: + resolution: {integrity: sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==} + + oas-linter@3.2.2: + resolution: {integrity: sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ==} + + oas-resolver@2.5.6: + resolution: {integrity: sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ==} + hasBin: true + + oas-schema-walker@1.1.5: + resolution: {integrity: sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ==} + + oas-validator@5.0.8: + resolution: {integrity: sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==} + + oauth@0.10.2: + resolution: {integrity: sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + + opener@1.5.2: + resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} + hasBin: true + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-event@4.2.0: + resolution: {integrity: sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==} + engines: {node: '>=8'} + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-finally@2.0.1: + resolution: {integrity: sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@7.0.4: + resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} + engines: {node: '>=18'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + p-wait-for@3.2.0: + resolution: {integrity: sha512-wpgERjNkLrBiFmkMEjuZJEWKKDrNfHCKA1OhyN1wg1FrLkULbviEy6py1AyJUgZ72YWFbZ38FIpnqvVqAlDUwA==} + engines: {node: '>=8'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + paho-mqtt@1.1.0: + resolution: {integrity: sha512-KPbL9KAB0ASvhSDbOrZBaccXS+/s7/LIofbPyERww8hM5Ko71GUJQ6Nmg0BWqj8phAIT8zdf/Sd/RftHU9i2HA==} + + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + papaparse@5.5.4: + resolution: {integrity: sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==} + + param-case@3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-code-context@1.0.0: + resolution: {integrity: sha512-OZQaqKaQnR21iqhlnPfVisFjBWjhnMl5J9MgbP8xC+EwoVqbXrq78lp+9Zb3ahmLzrIX5Us/qbvBnaS3hkH6OA==} + engines: {node: '>=6'} + + parse-filepath@1.0.2: + resolution: {integrity: sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==} + engines: {node: '>=0.8'} + + parse-json@4.0.0: + resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} + engines: {node: '>=4'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + + parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + + parseqs@0.0.6: + resolution: {integrity: sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==} + + parser-ts@0.7.0: + resolution: {integrity: sha512-YBJYgQ6j2DiKryKkYUrw0a7WiUb2DGnanffFZNz5cRTaoTncSxjKCio6ocEBSAa26jFXr0XSNjaYXUrL61BISA==} + peerDependencies: + fp-ts: ^2.14.0 + + parseuri@0.0.6: + resolution: {integrity: sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + + passport-github2@0.1.12: + resolution: {integrity: sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==} + engines: {node: '>= 0.8.0'} + + passport-google-oauth20@2.0.0: + resolution: {integrity: sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==} + engines: {node: '>= 0.4.0'} + + passport-jwt@4.0.1: + resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + + passport-local@1.0.0: + resolution: {integrity: sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==} + engines: {node: '>= 0.4.0'} + + passport-microsoft@2.1.0: + resolution: {integrity: sha512-7bOcjEmZCHg5qD55iHaMD/mgBxPtXLbqAwmKox5IsqOSEU50WJk5nQKK4lxKdBHLZ0hf+gzrFgDsTybJP18/JA==} + engines: {node: '>= 0.4.0'} + + passport-oauth2@1.8.0: + resolution: {integrity: sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==} + engines: {node: '>= 0.4.0'} + + passport-strategy@1.0.0: + resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} + engines: {node: '>= 0.4.0'} + + passport@0.7.0: + resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + engines: {node: '>= 0.4.0'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-case@3.0.4: + resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-root-regex@0.1.2: + resolution: {integrity: sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==} + engines: {node: '>=0.10.0'} + + path-root@0.1.1: + resolution: {integrity: sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==} + engines: {node: '>=0.10.0'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@8.4.0: + resolution: {integrity: sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + path-type@3.0.0: + resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==} + engines: {node: '>=4'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + path@0.12.7: + resolution: {integrity: sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pause-stream@0.0.11: + resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + + pause@0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + + pdfjs-dist@4.10.38: + resolution: {integrity: sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==} + engines: {node: '>=20'} + + peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pidtree@0.3.1: + resolution: {integrity: sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==} + engines: {node: '>=0.10'} + hasBin: true + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pify@3.0.0: + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} + engines: {node: '>=4'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + portfinder@1.0.38: + resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==} + engines: {node: '>= 10.12'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-calc@10.1.1: + resolution: {integrity: sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==} + engines: {node: ^18.12 || ^20.9 || >=22.0} + peerDependencies: + postcss: ^8.4.38 + + postcss-colormin@7.0.6: + resolution: {integrity: sha512-oXM2mdx6IBTRm39797QguYzVEWzbdlFiMNfq88fCCN1Wepw3CYmJ/1/Ifa/KjWo+j5ZURDl2NTldLJIw51IeNQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-convert-values@7.0.9: + resolution: {integrity: sha512-l6uATQATZaCa0bckHV+r6dLXfWtUBKXxO3jK+AtxxJJtgMPD+VhhPCCx51I4/5w8U5uHV67g3w7PXj+V3wlMlg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-discard-comments@7.0.6: + resolution: {integrity: sha512-Sq+Fzj1Eg5/CPf1ERb0wS1Im5cvE2gDXCE+si4HCn1sf+jpQZxDI4DXEp8t77B/ImzDceWE2ebJQFXdqZ6GRJw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-discard-duplicates@7.0.2: + resolution: {integrity: sha512-eTonaQvPZ/3i1ASDHOKkYwAybiM45zFIc7KXils4mQmHLqIswXD9XNOKEVxtTFnsmwYzF66u4LMgSr0abDlh5w==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-discard-empty@7.0.1: + resolution: {integrity: sha512-cFrJKZvcg/uxB6Ijr4l6qmn3pXQBna9zyrPC+sK0zjbkDUZew+6xDltSF7OeB7rAtzaaMVYSdbod+sZOCWnMOg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-discard-overridden@7.0.1: + resolution: {integrity: sha512-7c3MMjjSZ/qYrx3uc1940GSOzN1Iqjtlqe8uoSg+qdVPYyRb0TILSqqmtlSFuE4mTDECwsm397Ya7iXGzfF7lg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-merge-longhand@7.0.5: + resolution: {integrity: sha512-Kpu5v4Ys6QI59FxmxtNB/iHUVDn9Y9sYw66D6+SZoIk4QTz1prC4aYkhIESu+ieG1iylod1f8MILMs1Em3mmIw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-merge-rules@7.0.8: + resolution: {integrity: sha512-BOR1iAM8jnr7zoQSlpeBmCsWV5Uudi/+5j7k05D0O/WP3+OFMPD86c1j/20xiuRtyt45bhxw/7hnhZNhW2mNFA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-minify-font-values@7.0.1: + resolution: {integrity: sha512-2m1uiuJeTplll+tq4ENOQSzB8LRnSUChBv7oSyFLsJRtUgAAJGP6LLz0/8lkinTgxrmJSPOEhgY1bMXOQ4ZXhQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-minify-gradients@7.0.1: + resolution: {integrity: sha512-X9JjaysZJwlqNkJbUDgOclyG3jZEpAMOfof6PUZjPnPrePnPG62pS17CjdM32uT1Uq1jFvNSff9l7kNbmMSL2A==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-minify-params@7.0.6: + resolution: {integrity: sha512-YOn02gC68JijlaXVuKvFSCvQOhTpblkcfDre2hb/Aaa58r2BIaK4AtE/cyZf2wV7YKAG+UlP9DT+By0ry1E4VQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-minify-selectors@7.0.6: + resolution: {integrity: sha512-lIbC0jy3AAwDxEgciZlBullDiMBeBCT+fz5G8RcA9MWqh/hfUkpOI3vNDUNEZHgokaoiv0juB9Y8fGcON7rU/A==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-normalize-charset@7.0.1: + resolution: {integrity: sha512-sn413ofhSQHlZFae//m9FTOfkmiZ+YQXsbosqOWRiVQncU2BA3daX3n0VF3cG6rGLSFVc5Di/yns0dFfh8NFgQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-display-values@7.0.1: + resolution: {integrity: sha512-E5nnB26XjSYz/mGITm6JgiDpAbVuAkzXwLzRZtts19jHDUBFxZ0BkXAehy0uimrOjYJbocby4FVswA/5noOxrQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-positions@7.0.1: + resolution: {integrity: sha512-pB/SzrIP2l50ZIYu+yQZyMNmnAcwyYb9R1fVWPRxm4zcUFCY2ign7rcntGFuMXDdd9L2pPNUgoODDk91PzRZuQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-repeat-style@7.0.1: + resolution: {integrity: sha512-NsSQJ8zj8TIDiF0ig44Byo3Jk9e4gNt9x2VIlJudnQQ5DhWAHJPF4Tr1ITwyHio2BUi/I6Iv0HRO7beHYOloYQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-string@7.0.1: + resolution: {integrity: sha512-QByrI7hAhsoze992kpbMlJSbZ8FuCEc1OT9EFbZ6HldXNpsdpZr+YXC5di3UEv0+jeZlHbZcoCADgb7a+lPmmQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-timing-functions@7.0.1: + resolution: {integrity: sha512-bHifyuuSNdKKsnNJ0s8fmfLMlvsQwYVxIoUBnowIVl2ZAdrkYQNGVB4RxjfpvkMjipqvbz0u7feBZybkl/6NJg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-unicode@7.0.6: + resolution: {integrity: sha512-z6bwTV84YW6ZvvNoaNLuzRW4/uWxDKYI1iIDrzk6D2YTL7hICApy+Q1LP6vBEsljX8FM7YSuV9qI79XESd4ddQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-url@7.0.1: + resolution: {integrity: sha512-sUcD2cWtyK1AOL/82Fwy1aIVm/wwj5SdZkgZ3QiUzSzQQofrbq15jWJ3BA7Z+yVRwamCjJgZJN0I9IS7c6tgeQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-whitespace@7.0.1: + resolution: {integrity: sha512-vsbgFHMFQrJBJKrUFJNZ2pgBeBkC2IvvoHjz1to0/0Xk7sII24T0qFOiJzG6Fu3zJoq/0yI4rKWi7WhApW+EFA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-ordered-values@7.0.2: + resolution: {integrity: sha512-AMJjt1ECBffF7CEON/Y0rekRLS6KsePU6PRP08UqYW4UGFRnTXNrByUzYK1h8AC7UWTZdQ9O3Oq9kFIhm0SFEw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-reduce-initial@7.0.6: + resolution: {integrity: sha512-G6ZyK68AmrPdMB6wyeA37ejnnRG2S8xinJrZJnOv+IaRKf6koPAVbQsiC7MfkmXaGmF1UO+QCijb27wfpxuRNg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-reduce-transforms@7.0.1: + resolution: {integrity: sha512-MhyEbfrm+Mlp/36hvZ9mT9DaO7dbncU0CvWI8V93LRkY6IYlu38OPg3FObnuKTUxJ4qA8HpurdQOo5CyqqO76g==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-selector-parser@7.1.4: + resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} + engines: {node: '>=4'} + + postcss-svgo@7.1.1: + resolution: {integrity: sha512-zU9H9oEDrUFKa0JB7w+IYL7Qs9ey1mZyjhbf0KLxwJDdDRtoPvCmaEfknzqfHj44QS9VD6c5sJnBAVYTLRg/Sg==} + engines: {node: ^18.12.0 || ^20.9.0 || >= 18} + peerDependencies: + postcss: ^8.4.32 + + postcss-unique-selectors@7.0.5: + resolution: {integrity: sha512-3QoYmEt4qg/rUWDn6Tc8+ZVPmbp4G1hXDtCNWDx0st8SjtCbRcxRXDDM1QrEiXGG3A45zscSJFb4QH90LViyxg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + + posthog-node@5.38.2: + resolution: {integrity: sha512-eiKpU+vX4hVuHbO/EosvPHsmh2AVIdoVmWss/uUOs1t4b0ViCblw2o8OIFqHxKj3mYRnSOBlX0Dw3wBvcCaYpA==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + + posthtml-parser@0.11.0: + resolution: {integrity: sha512-QecJtfLekJbWVo/dMAA+OSwY79wpRmbqS5TeXvXSX+f0c6pW4/SE6inzZ2qkU7oAMCPqIDkZDvd/bQsSFUnKyw==} + engines: {node: '>=12'} + + posthtml-render@3.0.0: + resolution: {integrity: sha512-z+16RoxK3fUPgwaIgH9NGnK1HKY9XIDpydky5eQGgAFVXTCSezalv9U2jQuNV+Z9qV1fDWNzldcw4eK0SSbqKA==} + engines: {node: '>=12'} + + posthtml@0.16.7: + resolution: {integrity: sha512-7Hc+IvlQ7hlaIfQFZnxlRl0jnpWq2qwibORBhQYIb0QbNtuicc5ZxvKkVT71HJ4Py1wSZ/3VR1r8LfkCtoCzhw==} + engines: {node: '>=12.0.0'} + + postman-collection@5.3.0: + resolution: {integrity: sha512-PMa5vRheqDFfS1bkRg8WBidWxunRA80sT5YNLP27YC5+ycyfiLMCwPnqQd1zfvxkGk04Pr9UronWmmgsbpsVyQ==} + engines: {node: '>=18'} + + postman-url-encoder@3.0.8: + resolution: {integrity: sha512-EOgUMBazo7JNP4TDrd64TsooCiWzzo4143Ws8E8WYGEpn2PKpq+S4XRTDhuRTYHm3VKOpUZs7ZYZq7zSDuesqA==} + engines: {node: '>=10'} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} + engines: {node: '>=6.0.0'} + + prettier-plugin-tailwindcss@0.7.1: + resolution: {integrity: sha512-Bzv1LZcuiR1Sk02iJTS1QzlFNp/o5l2p3xkopwOrbPmtMeh3fK9rVW5M3neBQzHq+kGKj/4LGQMTNcTH4NGPtQ==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + + prettier-plugin-tailwindcss@0.7.2: + resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.8.4: + resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} + engines: {node: '>=14'} + hasBin: true + + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + + pretty-bytes@6.1.1: + resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} + engines: {node: ^14.13.1 || >=16.0.0} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + pretty-format@30.3.0: + resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + pretty-format@30.4.1: + resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + preview-email@3.1.1: + resolution: {integrity: sha512-nrdhnt+E9ClJ4khk9rNzqgsxubH7xSJSKoqXx/7aed2eghegNGNWkSGOelNgFgUtMz3LmKGks0waH2NuXWWmPg==} + engines: {node: '>=14'} + + prisma@7.8.0: + resolution: {integrity: sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + hasBin: true + peerDependencies: + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' + peerDependenciesMeta: + better-sqlite3: + optional: true + typescript: + optional: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + prr@1.0.1: + resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + + pug-attrs@3.0.0: + resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==} + + pug-code-gen@3.0.4: + resolution: {integrity: sha512-6okWYIKdasTyXICyEtvobmTZAVX57JkzgzIi4iRJlin8kmhG+Xry2dsus+Mun/nGCn6F2U49haHI5mkELXB14g==} + + pug-error@2.1.0: + resolution: {integrity: sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==} + + pug-filters@4.0.0: + resolution: {integrity: sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==} + + pug-lexer@5.0.1: + resolution: {integrity: sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==} + + pug-linker@4.0.0: + resolution: {integrity: sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==} + + pug-load@3.0.0: + resolution: {integrity: sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==} + + pug-parser@6.0.0: + resolution: {integrity: sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==} + + pug-runtime@3.0.1: + resolution: {integrity: sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==} + + pug-strip-comments@2.0.0: + resolution: {integrity: sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==} + + pug-walk@2.0.0: + resolution: {integrity: sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==} + + pug@3.0.4: + resolution: {integrity: sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@1.4.1: + resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quickjs-emscripten-core@0.31.0: + resolution: {integrity: sha512-oQz8p0SiKDBc1TC7ZBK2fr0GoSHZKA0jZIeXxsnCyCs4y32FStzCW4d1h6E1sE0uHDMbGITbk2zhNaytaoJwXQ==} + + quickjs-emscripten@0.31.0: + resolution: {integrity: sha512-K7Yt78aRPLjPcqv3fIuLW1jW3pvwO21B9pmFOolsjM/57ZhdVXBr51GqJpalgBlkPu9foAvhEAuuQPnvIGvLvQ==} + engines: {node: '>=16.0.0'} + + quicktype-core@23.2.6: + resolution: {integrity: sha512-asfeSv7BKBNVb9WiYhFRBvBZHcRutPRBwJMxW0pefluK4kkKu4lv0IvZBwFKvw2XygLcL1Rl90zxWDHYgkwCmA==} + + ramda-adjunct@2.36.0: + resolution: {integrity: sha512-8w+/Hx73oByS+vo+BfAPOG3HYL2ay6O5fjrJpR7NFxMoFWksKz6vSOtvjqdfMM6MfAimHizq9tpdI0OD4xbKog==} + engines: {node: '>=0.10.3'} + peerDependencies: + ramda: '>= 0.19.0 <= 0.27.2' + + ramda@0.27.2: + resolution: {integrity: sha512-SbiLPU40JuJniHexQSAgad32hfwd+DRUdwF2PlVuI5RZD0/vahUco7R8vD86J/tcEKKF9vZrUVwgtmGCqlCKyA==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.7: + resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + read-pkg@3.0.0: + resolution: {integrity: sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==} + engines: {node: '>=4'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readable-stream@4.5.2: + resolution: {integrity: sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + reftools@1.1.9: + resolution: {integrity: sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.0: + resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} + hasBin: true + + relay-runtime@12.0.0: + resolution: {integrity: sha512-QU6JKr1tMsry22DXNy9Whsq5rmvwr3LSZiiWV/9+DFpuTWvp+WFhobWMc8TC4OjKFfNhEZy7mOiqUAn5atQtug==} + + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + + remedial@1.0.8: + resolution: {integrity: sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg==} + + remove-trailing-separator@1.1.0: + resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==} + + remove-trailing-spaces@1.0.9: + resolution: {integrity: sha512-xzG7w5IRijvIkHIjDk65URsJJ7k4J95wmcArY5PRcmjldIOl7oTvG8+X2Ag690R7SfwiOcHrWZKVc1Pp5WIOzA==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@2.0.0: + resolution: {integrity: sha512-qpFcKaXsq8+oRoLilkwyc7zHGF5i9Q2/25NIgLQQ/+VVv9rU4qvr6nXVAw1DsnXJyQkZsR4Ytfbtg5ehfcUssQ==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + + rollup-plugin-inject@3.0.2: + resolution: {integrity: sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==} + deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject. + + rollup-plugin-node-polyfills@0.2.1: + resolution: {integrity: sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==} + + rollup-plugin-polyfill-node@0.13.0: + resolution: {integrity: sha512-FYEvpCaD5jGtyBuBFcQImEGmTxDTPbiHjJdrYIp+mFIwgXiXabxvKUK7ZT9P31ozu2Tqm9llYQMRWsfvTMTAOw==} + peerDependencies: + rollup: ^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 + + rollup-pluginutils@2.8.2: + resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} + + rollup@2.80.0: + resolution: {integrity: sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==} + engines: {node: '>=10.0.0'} + hasBin: true + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rollup@4.60.2: + resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-applescript@3.2.0: + resolution: {integrity: sha512-Ep0RsvAjnRcBX1p5vogbaBdAGu/8j/ewpvGqnQYunnLd9SM0vWcPJewPKNnWFggf0hF0pwIgwV5XK7qQ7UZ8Qg==} + engines: {node: '>=4'} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sass@1.101.0: + resolution: {integrity: sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==} + engines: {node: '>=20.19.0'} + hasBin: true + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + + secure-compare@3.0.1: + resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} + + selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.1: + resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + sentence-case@3.0.4: + resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} + + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + + serialize-javascript@7.0.3: + resolution: {integrity: sha512-h+cZ/XXarqDgCjo+YSyQU/ulDEESGGf8AMK9pPNmhNSl/FzPl6L8pMp1leca5z6NuG6tvV/auC8/43tmovowww==} + engines: {node: '>=20.0.0'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + set-cookie-parser-es@1.0.5: + resolution: {integrity: sha512-nU27kVj4O6+a1wOOWB6uezxB9SWLCjEmYJr6eRBmkAZfOx/TBg2p0jkCl1dMgeYtmFRAJSGe4u9VN7dwPu9PRQ==} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + should-equal@2.0.0: + resolution: {integrity: sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==} + + should-format@3.0.3: + resolution: {integrity: sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q==} + + should-type-adaptors@1.1.0: + resolution: {integrity: sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==} + + should-type@1.4.0: + resolution: {integrity: sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ==} + + should-util@1.0.1: + resolution: {integrity: sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==} + + should@13.2.3: + resolution: {integrity: sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + signedsource@1.0.0: + resolution: {integrity: sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + sitemap@8.0.3: + resolution: {integrity: sha512-9Ew1tR2WYw8RGE2XLy7GjkusvYXy8Rg6y8TYuBuQMfIEdGcWoJpY2Wr5DzsEiL/TKCw56+YKTCCUHglorEYK+A==} + engines: {node: '>=14.0.0', npm: '>=6.0.0'} + hasBin: true + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + + slick@1.12.2: + resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==} + + smob@1.6.2: + resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} + engines: {node: '>=20.0.0'} + + snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + + socket.io-client@2.5.0: + resolution: {integrity: sha512-lOO9clmdgssDykiOmVQQitwBAF3I6mYcQAo7hQ7AM6Ny5X7fp8hIJ3HcQs3Rjz4SoggoxA1OgrQyY8EgTbcPYw==} + + socket.io-client@3.1.3: + resolution: {integrity: sha512-4sIGOGOmCg3AOgGi7EEr6ZkTZRkrXwub70bBB/F0JSkMOUFpA77WsL87o34DffQQ31PkbMUIadGOk+3tx1KGbw==} + engines: {node: '>=10.0.0'} + + socket.io-client@4.8.1: + resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==} + engines: {node: '>=10.0.0'} + + socket.io-parser@3.3.5: + resolution: {integrity: sha512-pn+xG/oVnofxqteOawycpHw9QKclpNRa+Z7RW0vZmrIpkgZOVSVzBvY1YGR+p9kIEZXkeAjpHa21wRNLCZ6UAA==} + + socket.io-parser@4.0.5: + resolution: {integrity: sha512-sNjbT9dX63nqUFIOv95tTVm6elyIU4RvB1m8dOeZt+IgWwcWklFDOdmGcfo3zSiRsnR/3pJkjY5lfoGqEe4Eig==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.6: + resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} + engines: {node: '>=10.0.0'} + + socketio-wildcard@2.0.0: + resolution: {integrity: sha512-Bf3ioZq15Z2yhFLDasRvbYitg82rwm+5AuER5kQvEQHhNFf4R4K5o/h57nEpN7A59T9FyRtTj34HZfMWAruw/A==} + + sortablejs@1.14.0: + resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + source-map@0.8.0-beta.0: + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions + + sourcemap-codec@1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + deprecated: Please use @jridgewell/sourcemap-codec instead + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + split@1.0.1: + resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} + + splitpanes@3.1.5: + resolution: {integrity: sha512-r3Mq2ITFQ5a2VXLOy4/Sb2Ptp7OfEO8YIbhVJqJXoFc9hc5nTXXkCvtVDjIGbvC0vdE7tse+xTM9BMjsszP6bw==} + + sponge-case@1.0.1: + resolution: {integrity: sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + stream-browserify@3.0.0: + resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} + + stream-combiner@0.2.2: + resolution: {integrity: sha512-6yHMqgLYDzQDcAkL+tjJDC5nSNuNIx0vZtRZeiPh7Saef7VHX9H5Ijn9l2VIol2zaNYlYEX6KyuT/237A58qEQ==} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-env-interpolation@1.0.1: + resolution: {integrity: sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.padend@3.1.6: + resolution: {integrity: sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==} + engines: {node: '>= 0.4'} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-object@3.3.0: + resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} + engines: {node: '>=4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-comments@2.0.1: + resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} + engines: {node: '>=10'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + stylehacks@7.0.8: + resolution: {integrity: sha512-I3f053GBLIiS5Fg6OMFhq/c+yW+5Hc2+1fgq7gElDMMSqwlRb3tBf2ef6ucLStYRpId4q//bQO1FjcyNyy4yDQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + subscriptions-transport-ws@0.11.0: + resolution: {integrity: sha512-8D4C6DIH5tGiAIpp5I0wD/xRlNiZAPGHygzCe7VzyzUoxHtawzjNAY9SUTXU05/EY2NMY9/9GF0ycizkXr1CWQ==} + deprecated: The `subscriptions-transport-ws` package is no longer maintained. We recommend you use `graphql-ws` instead. For help migrating Apollo software to `graphql-ws`, see https://www.apollographql.com/docs/apollo-server/data/subscriptions/#switching-from-subscriptions-transport-ws For general help using `graphql-ws`, see https://github.com/enisdenjo/graphql-ws/blob/master/README.md + peerDependencies: + graphql: ^15.7.2 || ^16.0.0 + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svelte-hmr@0.15.3: + resolution: {integrity: sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==} + engines: {node: ^12.20 || ^14.13.1 || >= 16} + peerDependencies: + svelte: ^3.19.0 || ^4.0.0 + + svelte@3.59.2: + resolution: {integrity: sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA==} + engines: {node: '>= 8'} + + svgo@4.0.1: + resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} + engines: {node: '>=16'} + hasBin: true + + swagger-parser@10.0.3: + resolution: {integrity: sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==} + engines: {node: '>=10'} + + swagger-ui-dist@5.32.6: + resolution: {integrity: sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==} + + swagger2openapi@7.0.8: + resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==} + hasBin: true + + swap-case@2.0.2: + resolution: {integrity: sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==} + + symbol-observable@1.2.0: + resolution: {integrity: sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==} + engines: {node: '>=0.10.0'} + + symbol-observable@4.0.0: + resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} + engines: {node: '>=0.10'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + sync-fetch@0.6.0: + resolution: {integrity: sha512-IELLEvzHuCfc1uTsshPK58ViSdNqXxlml1U+fmwJIKLYKOr/rAtBrorE2RYm5IHaMpDNlmC0fr1LAvdXvyheEQ==} + engines: {node: '>=18'} + + synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} + engines: {node: ^14.18.0 || >=16.0.0} + + systemjs@6.15.1: + resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==} + + tailwindcss@3.4.16: + resolution: {integrity: sha512-TI4Cyx7gDiZ6r44ewaJmt0o6BrMCT5aK5e0rmJ/G9Xq3w7CX/5VXl/zIPEJZFUK5VEqwByyhqNPycPlvcK4ZNw==} + engines: {node: '>=14.0.0'} + hasBin: true + + tapable@0.2.9: + resolution: {integrity: sha512-2wsvQ+4GwBvLPLWsNfLCDYGsW6xb7aeC6utq2Qh0PFwgEy7K7dsma9Jsmb2zSQj7GvYAyUGSntLtsv++GmgL1A==} + engines: {node: '>=0.6'} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + temp-dir@2.0.0: + resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} + engines: {node: '>=8'} + + tempy@0.6.0: + resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==} + engines: {node: '>=10'} + + tern@0.24.3: + resolution: {integrity: sha512-Z8uvtdWIlFn1GWy0HW5FhZ8VDryZwoJUdnjZU25C7/PBOltLIn1uv+WF3rVq6S1761YbsmbZYRP/l0ZJBCkvrw==} + hasBin: true + + terser-webpack-plugin@5.4.0: + resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + + terser@5.46.1: + resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==} + engines: {node: '>=10'} + hasBin: true + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + timeout-signal@2.0.0: + resolution: {integrity: sha512-YBGpG4bWsHoPvofT6y/5iqulfXIiIErl5B0LdtHT1mGXDFTAhhRrbUpTvBgYbovr+3cKblya2WAOcpoy90XguA==} + engines: {node: '>=16'} + + timers@0.1.1: + resolution: {integrity: sha512-pkJC8uIP/gxDHxNQUBUbjHyl6oZfT+ofn7tbaHW+CFIUjI+Q2MBbHcx1JSBQfhDaTcO9bNg328q0i7Vk5PismQ==} + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tippy.js@6.3.7: + resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + + title-case@3.0.3: + resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} + + tlds@1.261.0: + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} + hasBin: true + + tldts-core@7.0.27: + resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} + + tldts@7.0.27: + resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} + hasBin: true + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-array@0.1.4: + resolution: {integrity: sha512-LhVdShQD/4Mk4zXNroIQZJC+Ap3zgLcDuwEdcmLv9CCO73NWockQDwyUnW/m8VX/EElfL6FcYx7EeutN4HJA6A==} + + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-stream@1.0.0: + resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@1.0.1: + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-essentials@10.1.1: + resolution: {integrity: sha512-4aTB7KLHKmUvkjNj8V+EdnmuVTiECzn3K+zIbRthumvHu+j44x3w63xpfs0JL3NGIzGXqoQ7AV591xHO+XrOTw==} + peerDependencies: + typescript: '>=4.5.0' + peerDependenciesMeta: + typescript: + optional: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + ts-jest@29.4.11: + resolution: {integrity: sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <7' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + + ts-loader@9.6.1: + resolution: {integrity: sha512-8FMHnmxtpncUAu0ZjkqpXnOTlwc9eY95esH8WVN94guTPPdkg2ofVdiVM5j8L2lmjiGerXd56zXb/D2JyVQPLg==} + engines: {node: '>=12.0.0'} + peerDependencies: + loader-utils: '*' + typescript: '*' + webpack: ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + loader-utils: + optional: true + + ts-log@2.2.7: + resolution: {integrity: sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg==} + + ts-node-dev@2.0.0: + resolution: {integrity: sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==} + engines: {node: '>=0.8.0'} + hasBin: true + peerDependencies: + node-notifier: '*' + typescript: '*' + peerDependenciesMeta: + node-notifier: + optional: true + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + tsconfig-paths-webpack-plugin@4.2.0: + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} + engines: {node: '>=10.13.0'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tsconfig@7.0.0: + resolution: {integrity: sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==} + + tslib@2.4.1: + resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.16.0: + resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} + engines: {node: '>=10'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript-eslint@8.61.1: + resolution: {integrity: sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-parser-js@1.0.41: + resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + uid2@0.0.4: + resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + unc-path-regex@0.1.2: + resolution: {integrity: sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==} + engines: {node: '>=0.10.0'} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + unhead@2.1.12: + resolution: {integrity: sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA==} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + union@0.5.0: + resolution: {integrity: sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==} + engines: {node: '>= 0.8.0'} + + unique-string@2.0.0: + resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} + engines: {node: '>=8'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unixify@1.0.0: + resolution: {integrity: sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg==} + engines: {node: '>=0.10.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unplugin-fonts@1.4.0: + resolution: {integrity: sha512-TIJqr5rSlK/+3oL5nnrrEJ+Ty2taQ/bTJY1C5abYnksl553Q3HoHVqS4pnRLDkwpZq8AYqywib3kEVvHH+CtRQ==} + peerDependencies: + '@nuxt/kit': ^3.0.0 || ^4.0.0 + vite: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + + unplugin-icons@22.5.0: + resolution: {integrity: sha512-MBlMtT5RuMYZy4TZgqUL2OTtOdTUVsS1Mhj6G1pEzMlFJlEnq6mhUfoIt45gBWxHcsOdXJDWLg3pRZ+YmvAVWQ==} + peerDependencies: + '@svgr/core': '>=7.0.0' + '@svgx/core': ^1.0.1 + '@vue/compiler-sfc': ^3.0.2 || ^2.7.0 + svelte: ^3.0.0 || ^4.0.0 || ^5.0.0 + vue-template-compiler: ^2.6.12 + vue-template-es2015-compiler: ^1.9.0 + peerDependenciesMeta: + '@svgr/core': + optional: true + '@svgx/core': + optional: true + '@vue/compiler-sfc': + optional: true + svelte: + optional: true + vue-template-compiler: + optional: true + vue-template-es2015-compiler: + optional: true + + unplugin-utils@0.3.1: + resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} + engines: {node: '>=20.19.0'} + + unplugin-vue-components@30.0.0: + resolution: {integrity: sha512-4qVE/lwCgmdPTp6h0qsRN2u642tt4boBQtcpn4wQcWZAsr8TQwq+SPT3NDu/6kBFxzo/sSEK4ioXhOOBrXc3iw==} + engines: {node: '>=14'} + peerDependencies: + '@babel/parser': ^7.15.8 + '@nuxt/kit': ^3.2.2 || ^4.0.0 + vue: 3.5.38 + peerDependenciesMeta: + '@babel/parser': + optional: true + '@nuxt/kit': + optional: true + + unplugin@2.2.2: + resolution: {integrity: sha512-Qp+iiD+qCRnUek+nDoYvtWX7tfnYyXsrOnJ452FRTgOyKmTM7TUJ3l+PLPJOOWPTUyKISKp4isC5JJPSXUjGgw==} + engines: {node: '>=18.12.0'} + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + unplugin@2.3.5: + resolution: {integrity: sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==} + engines: {node: '>=18.12.0'} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + upath@1.2.0: + resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} + engines: {node: '>=4'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + upper-case-first@2.0.2: + resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==} + + upper-case@2.0.2: + resolution: {integrity: sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + urijs@1.19.11: + resolution: {integrity: sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==} + + url-join@4.0.1: + resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + + url@0.11.4: + resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} + engines: {node: '>= 0.4'} + + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + + urlpattern-polyfill@8.0.2: + resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + util@0.10.4: + resolution: {integrity: sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==} + + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + valid-data-url@3.0.1: + resolution: {integrity: sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==} + engines: {node: '>=10'} + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + + validator@13.15.26: + resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==} + engines: {node: '>= 0.10'} + + value-or-promise@1.0.12: + resolution: {integrity: sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q==} + engines: {node: '>=12'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + verzod@0.4.0: + resolution: {integrity: sha512-TYAukAYYC1sK4MWSGoi1a5FrqJnknP7fp6Z9oJNt7BJACUW9oj8Yt4JZhF6nxbuhHn69Ay44zrXsD+W8jlcK6A==} + engines: {node: '>=16'} + peerDependencies: + zod: ^3.22.0 + + vite-dev-rpc@2.0.0: + resolution: {integrity: sha512-yKwbTwdHKSD2k/aGqyWpPHepo45OQc8lH3/6IfT4ZqeKE26ooKvi4WIEKzqWav8v+9Is8u1k8q54hvOmqASazA==} + peerDependencies: + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0 || ^8.0.0 + + vite-hot-client@2.2.0: + resolution: {integrity: sha512-76Zs9zrHbH7M7wqeyooGQKdX+yg0pQ0xuQ1PbFp4z5a0Lzn2e5IPFoCswnmqZ4GiwqB4Jo3WcDAMO9jARTJl8w==} + peerDependencies: + vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0 + + vite-plugin-checker@0.12.0: + resolution: {integrity: sha512-CmdZdDOGss7kdQwv73UyVgLPv0FVYe5czAgnmRX2oKljgEvSrODGuClaV3PDR2+3ou7N/OKGauDDBjy2MB07Rg==} + engines: {node: '>=16.11'} + peerDependencies: + '@biomejs/biome': '>=1.7' + eslint: '>=9.39.1' + meow: ^13.2.0 + optionator: ^0.9.4 + oxlint: '>=1' + stylelint: '>=16' + typescript: '*' + vite: '>=5.4.21' + vls: '*' + vti: '*' + vue-tsc: ~2.2.10 || ^3.0.0 + peerDependenciesMeta: + '@biomejs/biome': + optional: true + eslint: + optional: true + meow: + optional: true + optionator: + optional: true + oxlint: + optional: true + stylelint: + optional: true + typescript: + optional: true + vls: + optional: true + vti: + optional: true + vue-tsc: + optional: true + + vite-plugin-eslint@1.8.1: + resolution: {integrity: sha512-PqdMf3Y2fLO9FsNPmMX+//2BF5SF8nEWspZdgl4kSt7UvHDRHVVfHvxsD7ULYzZrJDGRxR81Nq7TOFgwMnUang==} + peerDependencies: + eslint: '>=7' + vite: '>=2' + + vite-plugin-fonts@0.7.0: + resolution: {integrity: sha512-fisKirkQrA2RFwcyI96SENLu1FyRYNIiC/l5DGdD8oV3OsAWGrYKs0e7/VZF6l0rm0QiYA2sOVTzYfrLAzP9cw==} + deprecated: renamed to `unplugin-fonts`, see https://github.com/cssninjaStudio/unplugin-fonts/releases/tag/v1.0.0 + peerDependencies: + vite: ^2.0.0 || ^3.0.0 || ^4.0.0 + + vite-plugin-html-config@2.0.2: + resolution: {integrity: sha512-g09u0XsmgKyMUIp1RZSyNSkJWvIusaXxw3KylyxU3vkCq7/G8hyemLctT+4IvO42fCPlNySmrNC9g0qSoKmvpw==} + engines: {node: '>=12.0.0'} + peerDependencies: + vite: '>=5.0.0' + + vite-plugin-inspect@11.4.1: + resolution: {integrity: sha512-ShOFe2PURXGvRS5OrgmOLZOCwDTD7dEBVt0tMpFPKb9AsvqXKCRGM8QgKrUbRbJYFXScHvDPpGRd28rYidC0tA==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': '*' + vite: ^6.0.0 || ^7.0.0-0 || ^8.0.0-0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + + vite-plugin-pages-sitemap@1.7.1: + resolution: {integrity: sha512-XtrMxDTECbEGMXWPB22I+cUB7aij6rQvVq4/q6vqCfZ34IdUyVwMexCYrcvrEBuOZF0knivfLfJUDm45mgJHWg==} + + vite-plugin-pages@0.33.2: + resolution: {integrity: sha512-tuWrpIXCMjbCXyOvpMSXiQ+m72Tgl7wmQnwzAV9T6Q0OpEAnxKv8ZPG5yBiBljp+zVcRq/2b1IqmJHPpsE/LFg==} + peerDependencies: + '@solidjs/router': '*' + '@vue/compiler-sfc': ^2.7.0 || ^3.0.0 + react-router: '*' + vite: ^2.0.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0 + vue-router: '*' + peerDependenciesMeta: + '@solidjs/router': + optional: true + '@vue/compiler-sfc': + optional: true + react-router: + optional: true + vue-router: + optional: true + + vite-plugin-pages@0.33.3: + resolution: {integrity: sha512-k97CAlVN7VUD5CIkRaose8Ty1xjtpuSeJQngFxsinPyM7PCtAIp23QdnkygNRdoo4gjX21TORYdEAAN/lGtCIA==} + peerDependencies: + '@solidjs/router': '*' + '@vue/compiler-sfc': ^2.7.0 || ^3.0.0 + react-router: '*' + vite: ^2.0.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0 + vue-router: '*' + peerDependenciesMeta: + '@solidjs/router': + optional: true + '@vue/compiler-sfc': + optional: true + react-router: + optional: true + vue-router: + optional: true + + vite-plugin-pwa@1.2.0: + resolution: {integrity: sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@vite-pwa/assets-generator': ^1.0.0 + vite: ^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + workbox-build: ^7.4.0 + workbox-window: ^7.4.0 + peerDependenciesMeta: + '@vite-pwa/assets-generator': + optional: true + + vite-plugin-static-copy@3.3.0: + resolution: {integrity: sha512-XiAtZcev7nppxNFgKoD55rfL+ukVp/RtrnTJONRwRuzv/B2FK2h2ZRCYjvxhwBV/Oarse83SiyXBSxMTfeEM0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + vite-plugin-vue-layouts@0.11.0: + resolution: {integrity: sha512-uh6NW7lt+aOXujK4eHfiNbeo55K9OTuB7fnv+5RVc4OBn/cZull6ThXdYH03JzKanUfgt6QZ37NbbtJ0og59qw==} + peerDependencies: + vite: ^4.0.0 || ^5.0.0 + vue: 3.5.38 + vue-router: ^4.0.11 + + vite@3.2.11: + resolution: {integrity: sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vite@7.3.2: + resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@0.2.5: + resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + vite: + optional: true + + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: 3.5.38 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-eslint-parser@10.4.1: + resolution: {integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + vue-i18n@11.4.6: + resolution: {integrity: sha512-l0gE7Rfy0phCa5ChKYkOq543Wgd39BCK6hkktfr1Ed4D99oRkgPK9ffShASZdeC8OJxGfdWmpYoAaAH6iLEuIg==} + engines: {node: '>= 22'} + peerDependencies: + vue: 3.5.38 + + vue-json-pretty@2.6.0: + resolution: {integrity: sha512-glz1aBVS35EO8+S9agIl3WOQaW2cJZW192UVKTuGmryx01ZvOVWc4pR3t+5UcyY4jdOfBUgVHjcpRpcnjRhCAg==} + engines: {node: '>= 10.0.0', npm: '>= 5.0.0'} + peerDependencies: + vue: 3.5.38 + + vue-pdf-embed@2.1.4: + resolution: {integrity: sha512-rZuRpQ4kJXKXCdZBCg3WZcYfrhDMJElcJQsS1V8KlJICDtFzzAzeDDSJwQU89Dx447Dv018P3zj/4UiAjBwvyg==} + peerDependencies: + vue: 3.5.38 + + vue-promise-modals@0.1.0: + resolution: {integrity: sha512-LmPejeqvZSkxj4KkJe6ZUEJmCUQXVeEAj9ihTX+BRFfZftVCZSZd3B4uuZSKF0iCeQUemkodXUZFxcsNT/2dmg==} + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: 3.5.38 + + vue-template-compiler@2.7.16: + resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==} + + vue-tippy@6.7.1: + resolution: {integrity: sha512-gdHbBV5/Vc8gH87hQHLA7TN1K4BlLco3MAPrTb70ZYGXxx+55rAU4a4mt0fIoP+gB3etu1khUZ6c29Br1n0CiA==} + peerDependencies: + vue: 3.5.38 + + vue-tsc@1.8.8: + resolution: {integrity: sha512-bSydNFQsF7AMvwWsRXD7cBIXaNs/KSjvzWLymq/UtKE36697sboX4EccSHFVxvgdBlI1frYPc/VMKJNB7DFeDQ==} + hasBin: true + peerDependencies: + typescript: '*' + + vue-tsc@2.1.6: + resolution: {integrity: sha512-f98dyZp5FOukcYmbFpuSCJ4Z0vHSOSmxGttZJCsFeX0M4w/Rsq0s4uKXjcSRsZqsRgQa6z7SfuO+y0HVICE57Q==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue-tsc@2.2.0: + resolution: {integrity: sha512-gtmM1sUuJ8aSb0KoAFmK9yMxb8TxjewmxqTJ1aKphD5Cbu0rULFY6+UQT51zW7SpUcenfPUuflKyVwyx9Qdnxg==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.38: + resolution: {integrity: sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + vuedraggable-es@4.1.1: + resolution: {integrity: sha512-F35pjSwC8HS/lnaOd+B59nYR4FZmwuhWAzccK9xftRuWds8SU1TZh5myKVM86j5dFOI7S26O64Kwe7LUHnXjlA==} + peerDependencies: + vue: 3.5.38 + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + watchpack@2.5.1: + resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} + engines: {node: '>=10.13.0'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-resource-inliner@6.0.1: + resolution: {integrity: sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A==} + engines: {node: '>=10.0.0'} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webcrypto-core@1.8.1: + resolution: {integrity: sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@4.0.2: + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + webpack-node-externals@3.0.0: + resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} + engines: {node: '>=6'} + + webpack-sources@3.3.4: + resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==} + engines: {node: '>=10.13.0'} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + webpack@5.106.2: + resolution: {integrity: sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + whatwg-url@7.1.0: + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + with@7.0.2: + resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==} + engines: {node: '>= 10.0.0'} + + wonka@6.3.6: + resolution: {integrity: sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + workbox-background-sync@7.4.0: + resolution: {integrity: sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==} + + workbox-broadcast-update@7.4.0: + resolution: {integrity: sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==} + + workbox-build@7.4.0: + resolution: {integrity: sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==} + engines: {node: '>=20.0.0'} + + workbox-cacheable-response@7.4.0: + resolution: {integrity: sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==} + + workbox-core@7.4.0: + resolution: {integrity: sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==} + + workbox-core@7.4.1: + resolution: {integrity: sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==} + + workbox-expiration@7.4.0: + resolution: {integrity: sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==} + + workbox-google-analytics@7.4.0: + resolution: {integrity: sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==} + + workbox-navigation-preload@7.4.0: + resolution: {integrity: sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==} + + workbox-precaching@7.4.0: + resolution: {integrity: sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==} + + workbox-range-requests@7.4.0: + resolution: {integrity: sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==} + + workbox-recipes@7.4.0: + resolution: {integrity: sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==} + + workbox-routing@7.4.0: + resolution: {integrity: sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==} + + workbox-strategies@7.4.0: + resolution: {integrity: sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==} + + workbox-streams@7.4.0: + resolution: {integrity: sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==} + + workbox-sw@7.4.0: + resolution: {integrity: sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==} + + workbox-window@7.4.0: + resolution: {integrity: sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==} + + workbox-window@7.4.1: + resolution: {integrity: sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==} + + workerpool@9.3.4: + resolution: {integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + xml-formatter@3.7.0: + resolution: {integrity: sha512-+8qTc3zv2UcJ1v9IsSIce37Dl4MQG14Cp7tWrwmy202UaI1wqRukw5QMX1JHsV+DX64yw77EgGsj2s5wGvuMbQ==} + engines: {node: '>= 16'} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xml-parser-xo@4.1.5: + resolution: {integrity: sha512-TxyRxk9sTOUg3glxSIY6f0nfuqRll2OEF8TspLgh5mZkLuBgheCn3zClcDSGJ58TvNmiwyCCuat4UajPud/5Og==} + engines: {node: '>= 16'} + + xml2js@0.5.0: + resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} + engines: {node: '>=4.0.0'} + + xmlbuilder2@4.0.3: + resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} + engines: {node: '>=20.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xmlhttprequest-ssl@1.6.3: + resolution: {integrity: sha512-3XfeQE/wNkvrIktn2Kf0869fC0BN6UpydVasGIeSm2B1Llihf7/0UfZM+eCkOw3P7bP4+qPgqhm7ZoxuJtFU0Q==} + engines: {node: '>=0.4.0'} + + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + + xss@1.0.15: + resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} + engines: {node: '>= 0.10.0'} + hasBin: true + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml-eslint-parser@1.3.2: + resolution: {integrity: sha512-odxVsHAkZYYglR30aPYRY4nUGJnoJ2y1ww2HDvZALo0BDETv9kWbi16J52eHs+PWRNmF4ub6nZqfVOeesOvntg==} + engines: {node: ^14.17.0 || >=16.0.0} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs-unparser@2.0.0: + resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} + engines: {node: '>=10'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yeast@0.1.2: + resolution: {integrity: sha512-8HFIh676uyGYP6wP13R/j6OJ/1HwJ46snpvzE7aHAN3Ryqh2yX6Xox2B4CUmTwwOIzlG3Bs7ocsP5dZH/R1Qbg==} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + z-schema@4.2.4: + resolution: {integrity: sha512-YvBeW5RGNeNzKOUJs3rTL4+9rpcvHXt5I051FJbOcitV8bl40pEfcG0Q+dWSwS0/BIYrMZ/9HHoqLllMkFhD0w==} + engines: {node: '>=6.0.0'} + hasBin: true + + z-schema@5.0.5: + resolution: {integrity: sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==} + engines: {node: '>=8.0.0'} + hasBin: true + + zeptomatch@2.1.0: + resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + + zod@3.25.32: + resolution: {integrity: sha512-OSm2xTIRfW8CV5/QKgngwmQW/8aPfGdaQFlrGoErlgg/Epm7cjb6K6VEyExfe65a3VybUOnu381edLb0dfJl0g==} + +snapshots: + + '@0no-co/graphql.web@1.3.2(graphql@16.13.2)': + optionalDependencies: + graphql: 16.13.2 + + '@0no-co/graphql.web@1.3.2(graphql@16.14.0)': + optionalDependencies: + graphql: 16.14.0 + + '@CuriousCorrelation/plugin-appload@https://codeload.github.com/CuriousCorrelation/tauri-plugin-appload/tar.gz/9d4528be4f385bccbe46859631d31aa2ee1ec0b6': + dependencies: + '@tauri-apps/api': 2.9.1 + + '@CuriousCorrelation/plugin-relay@https://codeload.github.com/CuriousCorrelation/tauri-plugin-relay/tar.gz/273488c8f50a22ee707af6b50ccd5570851f8bc9': + dependencies: + '@tauri-apps/api': 2.1.1 + + '@acemir/cssom@0.9.31': {} + + '@alloc/quick-lru@5.2.0': {} + + '@angular-devkit/core@19.2.24(chokidar@4.0.3)': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + jsonc-parser: 3.3.1 + picomatch: 4.0.4 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/core@19.2.27(chokidar@4.0.3)': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + jsonc-parser: 3.3.1 + picomatch: 4.0.4 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/schematics-cli@19.2.27(@types/node@25.9.3)(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.27(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.27(chokidar@4.0.3) + '@inquirer/prompts': 7.3.2(@types/node@25.9.3) + ansi-colors: 4.1.3 + symbol-observable: 4.0.0 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - '@types/node' + - chokidar + + '@angular-devkit/schematics@19.2.24(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.24(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/schematics@19.2.27(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.27(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.0.4 + + '@apideck/better-ajv-errors@0.3.7(ajv@8.20.0)': + dependencies: + ajv: 8.20.0 + jsonpointer: 5.0.1 + leven: 3.1.0 + + '@apidevtools/json-schema-ref-parser@14.0.1': + dependencies: + '@types/json-schema': 7.0.15 + js-yaml: 4.2.0 + + '@apidevtools/json-schema-ref-parser@9.1.2': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + call-me-maybe: 1.0.2 + js-yaml: 4.2.0 + + '@apidevtools/openapi-schemas@2.1.0': {} + + '@apidevtools/swagger-methods@3.0.2': {} + + '@apidevtools/swagger-parser@10.0.2(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 9.1.2 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + '@jsdevtools/ono': 7.1.3 + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + z-schema: 4.2.4 + + '@apidevtools/swagger-parser@10.0.3(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 9.1.2 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + '@jsdevtools/ono': 7.1.3 + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + z-schema: 5.0.5 + + '@apidevtools/swagger-parser@12.1.0(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 14.0.1 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + ajv: 8.18.0 + ajv-draft-04: 1.0.0(ajv@8.18.0) + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + + '@apollo/cache-control-types@1.0.3(graphql@16.14.0)': + dependencies: + graphql: 16.14.0 + + '@apollo/protobufjs@1.2.7': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/long': 4.0.2 + long: 4.0.0 + + '@apollo/server-gateway-interface@2.0.0(graphql@16.14.0)': + dependencies: + '@apollo/usage-reporting-protobuf': 4.1.1 + '@apollo/utils.fetcher': 3.1.0 + '@apollo/utils.keyvaluecache': 4.0.0 + '@apollo/utils.logger': 3.0.0 + graphql: 16.14.0 + + '@apollo/server-plugin-landing-page-graphql-playground@4.0.1(@apollo/server@5.5.1(graphql@16.14.0))': + dependencies: + '@apollo/server': 5.5.1(graphql@16.14.0) + '@apollographql/graphql-playground-html': 1.6.29 + + '@apollo/server@5.5.1(graphql@16.14.0)': + dependencies: + '@apollo/cache-control-types': 1.0.3(graphql@16.14.0) + '@apollo/server-gateway-interface': 2.0.0(graphql@16.14.0) + '@apollo/usage-reporting-protobuf': 4.1.1 + '@apollo/utils.createhash': 3.0.1 + '@apollo/utils.fetcher': 3.1.0 + '@apollo/utils.isnodelike': 3.0.0 + '@apollo/utils.keyvaluecache': 4.0.0 + '@apollo/utils.logger': 3.0.0 + '@apollo/utils.usagereporting': 2.1.0(graphql@16.14.0) + '@apollo/utils.withrequired': 3.0.0 + '@graphql-tools/schema': 10.0.33(graphql@16.14.0) + async-retry: 1.3.3 + body-parser: 2.2.1 + content-type: 1.0.5 + cors: 2.8.6 + finalhandler: 2.1.1 + graphql: 16.14.0 + loglevel: 1.9.2 + lru-cache: 11.2.7 + negotiator: 1.0.0 + whatwg-mimetype: 4.0.0 + transitivePeerDependencies: + - supports-color + + '@apollo/usage-reporting-protobuf@4.1.1': + dependencies: + '@apollo/protobufjs': 1.2.7 + + '@apollo/utils.createhash@3.0.1': + dependencies: + '@apollo/utils.isnodelike': 3.0.0 + sha.js: 2.4.12 + + '@apollo/utils.dropunuseddefinitions@2.0.1(graphql@16.14.0)': + dependencies: + graphql: 16.14.0 + + '@apollo/utils.fetcher@3.1.0': {} + + '@apollo/utils.isnodelike@3.0.0': {} + + '@apollo/utils.keyvaluecache@4.0.0': + dependencies: + '@apollo/utils.logger': 3.0.0 + lru-cache: 11.2.7 + + '@apollo/utils.logger@3.0.0': {} + + '@apollo/utils.printwithreducedwhitespace@2.0.1(graphql@16.14.0)': + dependencies: + graphql: 16.14.0 + + '@apollo/utils.removealiases@2.0.1(graphql@16.14.0)': + dependencies: + graphql: 16.14.0 + + '@apollo/utils.sortast@2.0.1(graphql@16.14.0)': + dependencies: + graphql: 16.14.0 + lodash.sortby: 4.7.0 + + '@apollo/utils.stripsensitiveliterals@2.0.1(graphql@16.14.0)': + dependencies: + graphql: 16.14.0 + + '@apollo/utils.usagereporting@2.1.0(graphql@16.14.0)': + dependencies: + '@apollo/usage-reporting-protobuf': 4.1.1 + '@apollo/utils.dropunuseddefinitions': 2.0.1(graphql@16.14.0) + '@apollo/utils.printwithreducedwhitespace': 2.0.1(graphql@16.14.0) + '@apollo/utils.removealiases': 2.0.1(graphql@16.14.0) + '@apollo/utils.sortast': 2.0.1(graphql@16.14.0) + '@apollo/utils.stripsensitiveliterals': 2.0.1(graphql@16.14.0) + graphql: 16.14.0 + + '@apollo/utils.withrequired@3.0.0': {} + + '@apollographql/graphql-playground-html@1.6.29': + dependencies: + xss: 1.0.15 + + '@ardatan/relay-compiler@12.0.0(graphql@16.13.2)': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.7 + '@babel/runtime': 7.29.2 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.7 + babel-preset-fbjs: 3.4.0(@babel/core@7.29.0) + chalk: 4.1.2 + fb-watchman: 2.0.2 + fbjs: 3.0.5 + glob: 7.2.3 + graphql: 16.13.2 + immutable: 3.7.6 + invariant: 2.2.4 + nullthrows: 1.1.1 + relay-runtime: 12.0.0 + signedsource: 1.0.0 + yargs: 15.4.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@ardatan/relay-compiler@12.0.0(graphql@16.14.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.7 + '@babel/runtime': 7.29.2 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.7 + babel-preset-fbjs: 3.4.0(@babel/core@7.29.0) + chalk: 4.1.2 + fb-watchman: 2.0.2 + fbjs: 3.0.5 + glob: 7.2.3 + graphql: 16.14.0 + immutable: 3.7.6 + invariant: 2.2.4 + nullthrows: 1.1.1 + relay-runtime: 12.0.0 + signedsource: 1.0.0 + yargs: 15.4.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@ardatan/relay-compiler@13.0.1(graphql@16.13.2)': + dependencies: + '@babel/runtime': 7.29.2 + graphql: 16.13.2 + immutable: 5.1.6 + invariant: 2.2.4 + + '@ardatan/relay-compiler@13.0.1(graphql@16.14.0)': + dependencies: + '@babel/runtime': 7.29.2 + graphql: 16.14.0 + immutable: 5.1.6 + invariant: 2.2.4 + + '@ardatan/sync-fetch@0.0.1': + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + '@as-integrations/express5@1.1.2(@apollo/server@5.5.1(graphql@16.14.0))(express@5.2.1)': + dependencies: + '@apollo/server': 5.5.1(graphql@16.14.0) + express: 5.2.1 + + '@asamuzakjp/css-color@4.1.2': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.7 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.7 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@8.1.1) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@8.1.1) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.27.3 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + debug: 4.4.3(supports-color@8.1.1) + lodash.debounce: 4.0.8 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + debug: 4.4.3(supports-color@8.1.1) + lodash.debounce: 4.0.8 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-wrap-function': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-wrap-function': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helper-wrap-function@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-wrap-function@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.7 + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-class-properties@7.18.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-object-rest-spread@7.20.7(@babel/core@7.29.0)': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-block-scoped-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.28.6 + + '@babel/plugin-transform-computed-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/template': 7.29.7 + + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-dotall-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-duplicate-keys@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-dynamic-import@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-exponentiation-operator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) + + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-json-strings@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-member-expression-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-amd@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-systemjs@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-systemjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-umd@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-new-target@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-numeric-separator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-object-super@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-property-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-regenerator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-regexp-modifiers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-reserved-words@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-spread@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-sticky-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typeof-symbol@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-unicode-escapes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-unicode-property-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/preset-env@7.29.2(@babel/core@7.29.0)': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0) + '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-json-strings': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-systemjs': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-property-regex': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.0) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.0) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) + core-js-compat: 3.49.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/preset-env@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7) + '@babel/plugin-syntax-import-assertions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7) + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoped-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-computed-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-dotall-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-duplicate-keys': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-exponentiation-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-function-name': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-json-strings': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-systemjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-numeric-separator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-super': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-property-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-regexp-modifiers': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-reserved-words': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-spread': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-sticky-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typeof-symbol': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-escapes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-property-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-sets-regex': 7.29.7(@babel/core@7.29.7) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + core-js-compat: 3.49.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/types': 7.29.7 + esutils: 2.0.3 + + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/types': 7.29.7 + esutils: 2.0.3 + + '@babel/runtime@7.29.2': {} + + '@babel/runtime@7.29.7': {} + + '@babel/standalone@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.7 + '@babel/template': 7.28.6 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@0.2.3': {} + + '@borewit/text-codec@0.2.2': {} + + '@boringer-avatars/vue3@0.2.1(vue@3.5.38(typescript@5.9.3))': + dependencies: + vue: 3.5.38(typescript@5.9.3) + + '@codemirror/autocomplete@6.20.0': + dependencies: + '@codemirror/language': 6.11.3 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.8 + '@lezer/common': 1.5.1 + + '@codemirror/commands@6.10.0': + dependencies: + '@codemirror/language': 6.11.3 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.8 + '@lezer/common': 1.5.1 + + '@codemirror/lang-javascript@6.2.4': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.11.3 + '@codemirror/lint': 6.9.2 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.8 + '@lezer/common': 1.5.1 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.11.3 + '@lezer/json': 1.0.3 + + '@codemirror/lang-xml@6.1.0': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.11.3 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.8 + '@lezer/common': 1.5.1 + '@lezer/xml': 1.0.6 + + '@codemirror/language@6.11.3': + dependencies: + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.8 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 + style-mod: 4.1.3 + + '@codemirror/legacy-modes@6.5.2': + dependencies: + '@codemirror/language': 6.11.3 + + '@codemirror/lint@6.9.2': + dependencies: + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.8 + crelt: 1.0.6 + + '@codemirror/merge@6.11.2': + dependencies: + '@codemirror/language': 6.11.3 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.8 + '@lezer/highlight': 1.2.1 + style-mod: 4.1.3 + + '@codemirror/search@6.5.11': + dependencies: + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.8 + crelt: 1.0.6 + + '@codemirror/state@6.5.2': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/view@6.38.8': + dependencies: + '@codemirror/state': 6.5.2 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@colors/colors@1.5.0': + optional: true + + '@commitlint/cli@20.5.2(@types/node@24.10.1)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': + dependencies: + '@commitlint/format': 20.5.0 + '@commitlint/lint': 20.5.0 + '@commitlint/load': 20.5.2(@types/node@24.10.1)(typescript@5.9.3) + '@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0) + '@commitlint/types': 20.5.0 + tinyexec: 1.1.1 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - conventional-commits-filter + - conventional-commits-parser + - typescript + + '@commitlint/config-conventional@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-conventionalcommits: 9.3.0 + + '@commitlint/config-validator@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + ajv: 8.20.0 + + '@commitlint/ensure@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + lodash.camelcase: 4.3.0 + lodash.kebabcase: 4.1.1 + lodash.snakecase: 4.1.1 + lodash.startcase: 4.4.0 + lodash.upperfirst: 4.3.1 + + '@commitlint/execute-rule@20.0.0': {} + + '@commitlint/format@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + picocolors: 1.1.1 + + '@commitlint/is-ignored@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + semver: 7.8.5 + + '@commitlint/lint@20.5.0': + dependencies: + '@commitlint/is-ignored': 20.5.0 + '@commitlint/parse': 20.5.0 + '@commitlint/rules': 20.5.0 + '@commitlint/types': 20.5.0 + + '@commitlint/load@20.5.2(@types/node@24.10.1)(typescript@5.9.3)': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/execute-rule': 20.0.0 + '@commitlint/resolve-extends': 20.5.2 + '@commitlint/types': 20.5.0 + cosmiconfig: 9.0.1(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@24.10.1)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) + is-plain-obj: 4.1.0 + lodash.mergewith: 4.6.2 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@20.4.3': {} + + '@commitlint/parse@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-angular: 8.3.1 + conventional-commits-parser: 6.4.0 + + '@commitlint/read@20.5.0(conventional-commits-parser@6.4.0)': + dependencies: + '@commitlint/top-level': 20.4.3 + '@commitlint/types': 20.5.0 + git-raw-commits: 5.0.1(conventional-commits-parser@6.4.0) + minimist: 1.2.8 + tinyexec: 1.1.1 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@commitlint/resolve-extends@20.5.2': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/types': 20.5.0 + global-directory: 5.0.0 + import-meta-resolve: 4.2.0 + lodash.mergewith: 4.6.2 + resolve-from: 5.0.0 + + '@commitlint/rules@20.5.0': + dependencies: + '@commitlint/ensure': 20.5.0 + '@commitlint/message': 20.4.3 + '@commitlint/to-lines': 20.0.0 + '@commitlint/types': 20.5.0 + + '@commitlint/to-lines@20.0.0': {} + + '@commitlint/top-level@20.4.3': + dependencies: + escalade: 3.2.0 + + '@commitlint/types@20.5.0': + dependencies: + conventional-commits-parser: 6.3.0 + picocolors: 1.1.1 + + '@conventional-changelog/git-client@2.7.0(conventional-commits-parser@6.4.0)': + dependencies: + '@simple-libs/child-process-utils': 1.0.2 + '@simple-libs/stream-utils': 1.2.0 + semver: 7.8.5 + optionalDependencies: + conventional-commits-parser: 6.4.0 + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@css-inline/css-inline-android-arm-eabi@0.20.0': + optional: true + + '@css-inline/css-inline-android-arm64@0.20.0': + optional: true + + '@css-inline/css-inline-darwin-arm64@0.20.0': + optional: true + + '@css-inline/css-inline-darwin-x64@0.20.0': + optional: true + + '@css-inline/css-inline-linux-arm-gnueabihf@0.20.0': + optional: true + + '@css-inline/css-inline-linux-arm64-gnu@0.20.0': + optional: true + + '@css-inline/css-inline-linux-arm64-musl@0.20.0': + optional: true + + '@css-inline/css-inline-linux-x64-gnu@0.20.0': + optional: true + + '@css-inline/css-inline-linux-x64-musl@0.20.0': + optional: true + + '@css-inline/css-inline-win32-arm64-msvc@0.20.0': + optional: true + + '@css-inline/css-inline-win32-x64-msvc@0.20.0': + optional: true + + '@css-inline/css-inline@0.20.0': + optionalDependencies: + '@css-inline/css-inline-android-arm-eabi': 0.20.0 + '@css-inline/css-inline-android-arm64': 0.20.0 + '@css-inline/css-inline-darwin-arm64': 0.20.0 + '@css-inline/css-inline-darwin-x64': 0.20.0 + '@css-inline/css-inline-linux-arm-gnueabihf': 0.20.0 + '@css-inline/css-inline-linux-arm64-gnu': 0.20.0 + '@css-inline/css-inline-linux-arm64-musl': 0.20.0 + '@css-inline/css-inline-linux-x64-gnu': 0.20.0 + '@css-inline/css-inline-linux-x64-musl': 0.20.0 + '@css-inline/css-inline-win32-arm64-msvc': 0.20.0 + '@css-inline/css-inline-win32-x64-msvc': 0.20.0 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@digitak/esrun@3.2.26': + dependencies: + '@digitak/grubber': 3.1.4 + chokidar: 3.6.0 + esbuild: 0.17.19 + + '@digitak/grubber@3.1.4': {} + + '@electric-sql/pglite-socket@0.1.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': {} + + '@emnapi/core@1.9.1': + dependencies: + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@envelop/core@5.5.1': + dependencies: + '@envelop/instrumentation': 1.0.0 + '@envelop/types': 5.2.1 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@envelop/instrumentation@1.0.0': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@envelop/types@5.2.1': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@epic-web/invariant@1.0.0': {} + + '@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.27.4)': + dependencies: + esbuild: 0.27.4 + + '@esbuild-plugins/node-modules-polyfill@0.2.2(esbuild@0.27.4)': + dependencies: + esbuild: 0.27.4 + escape-string-regexp: 4.0.0 + rollup-plugin-node-polyfills: 0.2.1 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.17.19': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.15.18': + optional: true + + '@esbuild/android-arm@0.17.19': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.17.19': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.17.19': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.17.19': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.17.19': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.17.19': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.17.19': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.17.19': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.17.19': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.15.18': + optional: true + + '@esbuild/linux-loong64@0.17.19': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.17.19': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.17.19': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.17.19': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.17.19': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.17.19': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.17.19': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.17.19': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.17.19': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.17.19': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.17.19': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.17.19': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.6.1))': + dependencies: + eslint: 10.5.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + dependencies: + eslint: 9.39.2(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3(supports-color@8.1.1) + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3(supports-color@8.1.1) + minimatch: 10.2.4 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.14.0 + debug: 4.4.3(supports-color@8.1.1) + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@10.0.1(eslint@10.5.0(jiti@2.6.1))': + optionalDependencies: + eslint: 10.5.0(jiti@2.6.1) + + '@eslint/js@9.39.2': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@exodus/bytes@1.15.0(@noble/hashes@2.2.0)': + optionalDependencies: + '@noble/hashes': 2.2.0 + + '@exodus/schemasafe@1.3.0': {} + + '@faker-js/faker@5.5.3': {} + + '@fastify/busboy@3.2.0': {} + + '@fontsource-variable/inter@5.2.8': {} + + '@fontsource-variable/material-symbols-rounded@5.2.45': {} + + '@fontsource-variable/roboto-mono@5.2.9': {} + + '@glideapps/ts-necessities@2.2.3': {} + + '@graphql-codegen/add@6.0.1(graphql@16.13.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.13.2) + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-codegen/add@6.0.1(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/cli@6.3.1(@parcel/watcher@2.5.6)(@types/node@24.10.1)(graphql@16.13.2)(typescript@5.9.3)': + dependencies: + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@graphql-codegen/client-preset': 5.3.0(graphql@16.13.2) + '@graphql-codegen/core': 5.0.2(graphql@16.13.2) + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.13.2) + '@graphql-tools/apollo-engine-loader': 8.0.30(graphql@16.13.2) + '@graphql-tools/code-file-loader': 8.1.32(graphql@16.13.2) + '@graphql-tools/git-loader': 8.0.36(graphql@16.13.2) + '@graphql-tools/github-loader': 9.1.2(@types/node@24.10.1)(graphql@16.13.2) + '@graphql-tools/graphql-file-loader': 8.1.14(graphql@16.13.2) + '@graphql-tools/json-file-loader': 8.0.28(graphql@16.13.2) + '@graphql-tools/load': 8.1.10(graphql@16.13.2) + '@graphql-tools/merge': 9.1.9(graphql@16.13.2) + '@graphql-tools/url-loader': 9.1.2(@types/node@24.10.1)(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + '@inquirer/prompts': 7.10.1(@types/node@24.10.1) + '@whatwg-node/fetch': 0.10.13 + chalk: 4.1.2 + cosmiconfig: 9.0.1(typescript@5.9.3) + debounce: 2.2.0 + detect-indent: 6.1.0 + graphql: 16.13.2 + graphql-config: 5.1.6(@types/node@24.10.1)(graphql@16.13.2)(typescript@5.9.3) + is-glob: 4.0.3 + jiti: 2.6.1 + json-to-pretty-yaml: 1.2.2 + listr2: 9.0.5 + log-symbols: 4.1.0 + micromatch: 4.0.8 + shell-quote: 1.8.3 + string-env-interpolation: 1.0.1 + ts-log: 2.2.7 + tslib: 2.8.1 + yaml: 2.8.3 + yargs: 17.7.2 + optionalDependencies: + '@parcel/watcher': 2.5.6 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - cosmiconfig-toml-loader + - crossws + - graphql-sock + - supports-color + - typescript + - utf-8-validate + + '@graphql-codegen/cli@6.3.1(@parcel/watcher@2.5.6)(@types/node@25.9.3)(graphql@16.13.2)(typescript@5.9.3)': + dependencies: + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@graphql-codegen/client-preset': 5.3.0(graphql@16.13.2) + '@graphql-codegen/core': 5.0.2(graphql@16.13.2) + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.13.2) + '@graphql-tools/apollo-engine-loader': 8.0.30(graphql@16.13.2) + '@graphql-tools/code-file-loader': 8.1.32(graphql@16.13.2) + '@graphql-tools/git-loader': 8.0.36(graphql@16.13.2) + '@graphql-tools/github-loader': 9.1.2(@types/node@25.9.3)(graphql@16.13.2) + '@graphql-tools/graphql-file-loader': 8.1.14(graphql@16.13.2) + '@graphql-tools/json-file-loader': 8.0.28(graphql@16.13.2) + '@graphql-tools/load': 8.1.10(graphql@16.13.2) + '@graphql-tools/merge': 9.1.9(graphql@16.13.2) + '@graphql-tools/url-loader': 9.1.2(@types/node@25.9.3)(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + '@inquirer/prompts': 7.10.1(@types/node@25.9.3) + '@whatwg-node/fetch': 0.10.13 + chalk: 4.1.2 + cosmiconfig: 9.0.1(typescript@5.9.3) + debounce: 2.2.0 + detect-indent: 6.1.0 + graphql: 16.13.2 + graphql-config: 5.1.6(@types/node@25.9.3)(graphql@16.13.2)(typescript@5.9.3) + is-glob: 4.0.3 + jiti: 2.6.1 + json-to-pretty-yaml: 1.2.2 + listr2: 9.0.5 + log-symbols: 4.1.0 + micromatch: 4.0.8 + shell-quote: 1.8.3 + string-env-interpolation: 1.0.1 + ts-log: 2.2.7 + tslib: 2.8.1 + yaml: 2.8.3 + yargs: 17.7.2 + optionalDependencies: + '@parcel/watcher': 2.5.6 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - cosmiconfig-toml-loader + - crossws + - graphql-sock + - supports-color + - typescript + - utf-8-validate + + '@graphql-codegen/cli@6.3.1(@parcel/watcher@2.5.6)(@types/node@25.9.3)(graphql@16.14.0)(typescript@5.9.3)': + dependencies: + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@graphql-codegen/client-preset': 5.3.0(graphql@16.14.0) + '@graphql-codegen/core': 5.0.2(graphql@16.14.0) + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.14.0) + '@graphql-tools/apollo-engine-loader': 8.0.30(graphql@16.14.0) + '@graphql-tools/code-file-loader': 8.1.32(graphql@16.14.0) + '@graphql-tools/git-loader': 8.0.36(graphql@16.14.0) + '@graphql-tools/github-loader': 9.1.2(@types/node@25.9.3)(graphql@16.14.0) + '@graphql-tools/graphql-file-loader': 8.1.14(graphql@16.14.0) + '@graphql-tools/json-file-loader': 8.0.28(graphql@16.14.0) + '@graphql-tools/load': 8.1.10(graphql@16.14.0) + '@graphql-tools/merge': 9.1.9(graphql@16.14.0) + '@graphql-tools/url-loader': 9.1.2(@types/node@25.9.3)(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@inquirer/prompts': 7.10.1(@types/node@25.9.3) + '@whatwg-node/fetch': 0.10.13 + chalk: 4.1.2 + cosmiconfig: 9.0.1(typescript@5.9.3) + debounce: 2.2.0 + detect-indent: 6.1.0 + graphql: 16.14.0 + graphql-config: 5.1.6(@types/node@25.9.3)(graphql@16.14.0)(typescript@5.9.3) + is-glob: 4.0.3 + jiti: 2.6.1 + json-to-pretty-yaml: 1.2.2 + listr2: 9.0.5 + log-symbols: 4.1.0 + micromatch: 4.0.8 + shell-quote: 1.8.3 + string-env-interpolation: 1.0.1 + ts-log: 2.2.7 + tslib: 2.8.1 + yaml: 2.8.3 + yargs: 17.7.2 + optionalDependencies: + '@parcel/watcher': 2.5.6 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - cosmiconfig-toml-loader + - crossws + - graphql-sock + - supports-color + - typescript + - utf-8-validate + + '@graphql-codegen/client-preset@5.3.0(graphql@16.13.2)': + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.28.6 + '@graphql-codegen/add': 6.0.1(graphql@16.13.2) + '@graphql-codegen/gql-tag-operations': 5.2.0(graphql@16.13.2) + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.13.2) + '@graphql-codegen/typed-document-node': 6.1.8(graphql@16.13.2) + '@graphql-codegen/typescript': 5.0.10(graphql@16.13.2) + '@graphql-codegen/typescript-operations': 5.1.0(graphql@16.13.2) + '@graphql-codegen/visitor-plugin-common': 6.3.0(graphql@16.13.2) + '@graphql-tools/documents': 1.0.1(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.2) + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-codegen/client-preset@5.3.0(graphql@16.14.0)': + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.28.6 + '@graphql-codegen/add': 6.0.1(graphql@16.14.0) + '@graphql-codegen/gql-tag-operations': 5.2.0(graphql@16.14.0) + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.14.0) + '@graphql-codegen/typed-document-node': 6.1.8(graphql@16.14.0) + '@graphql-codegen/typescript': 5.0.10(graphql@16.14.0) + '@graphql-codegen/typescript-operations': 5.1.0(graphql@16.14.0) + '@graphql-codegen/visitor-plugin-common': 6.3.0(graphql@16.14.0) + '@graphql-tools/documents': 1.0.1(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/core@5.0.2(graphql@16.13.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.13.2) + '@graphql-tools/schema': 10.0.33(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-codegen/core@5.0.2(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.14.0) + '@graphql-tools/schema': 10.0.33(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/gql-tag-operations@5.2.0(graphql@16.13.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.13.2) + '@graphql-codegen/visitor-plugin-common': 6.3.0(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + auto-bind: 4.0.0 + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-codegen/gql-tag-operations@5.2.0(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.14.0) + '@graphql-codegen/visitor-plugin-common': 6.3.0(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + auto-bind: 4.0.0 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/introspection@5.0.2(graphql@16.13.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.13.2) + '@graphql-codegen/visitor-plugin-common': 6.3.0(graphql@16.13.2) + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-codegen/plugin-helpers@3.1.2(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + change-case-all: 1.0.15 + common-tags: 1.8.2 + graphql: 16.13.2 + import-from: 4.0.0 + lodash: 4.17.23 + tslib: 2.4.1 + + '@graphql-codegen/plugin-helpers@3.1.2(graphql@16.14.0)': + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.14.0) + change-case-all: 1.0.15 + common-tags: 1.8.2 + graphql: 16.14.0 + import-from: 4.0.0 + lodash: 4.17.23 + tslib: 2.4.1 + + '@graphql-codegen/plugin-helpers@6.3.0(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 11.0.1(graphql@16.13.2) + change-case-all: 1.0.15 + common-tags: 1.8.2 + graphql: 16.13.2 + import-from: 4.0.0 + tslib: 2.8.1 + + '@graphql-codegen/plugin-helpers@6.3.0(graphql@16.14.0)': + dependencies: + '@graphql-tools/utils': 11.0.1(graphql@16.14.0) + change-case-all: 1.0.15 + common-tags: 1.8.2 + graphql: 16.14.0 + import-from: 4.0.0 + tslib: 2.8.1 + + '@graphql-codegen/schema-ast@5.0.2(graphql@16.13.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-codegen/schema-ast@5.0.2(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/typed-document-node@6.1.8(graphql@16.13.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.13.2) + '@graphql-codegen/visitor-plugin-common': 6.3.0(graphql@16.13.2) + auto-bind: 4.0.0 + change-case-all: 1.0.15 + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-codegen/typed-document-node@6.1.8(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.14.0) + '@graphql-codegen/visitor-plugin-common': 6.3.0(graphql@16.14.0) + auto-bind: 4.0.0 + change-case-all: 1.0.15 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/typescript-document-nodes@5.0.10(graphql@16.13.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.13.2) + '@graphql-codegen/visitor-plugin-common': 6.3.0(graphql@16.13.2) + auto-bind: 4.0.0 + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-codegen/typescript-operations@5.1.0(graphql@16.13.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.13.2) + '@graphql-codegen/typescript': 5.0.10(graphql@16.13.2) + '@graphql-codegen/visitor-plugin-common': 6.3.0(graphql@16.13.2) + auto-bind: 4.0.0 + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-codegen/typescript-operations@5.1.0(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.14.0) + '@graphql-codegen/typescript': 5.0.10(graphql@16.14.0) + '@graphql-codegen/visitor-plugin-common': 6.3.0(graphql@16.14.0) + auto-bind: 4.0.0 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/typescript-urql-graphcache@3.1.1(@urql/exchange-graphcache@7.2.4(@urql/core@6.0.2(graphql@16.13.2))(graphql@16.13.2))(graphql-tag@2.12.7(graphql@16.13.2))(graphql@16.13.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.13.2) + '@graphql-codegen/visitor-plugin-common': 2.13.8(graphql@16.13.2) + '@urql/exchange-graphcache': 7.2.4(@urql/core@6.0.2(graphql@16.13.2))(graphql@16.13.2) + auto-bind: 4.0.0 + change-case-all: 1.0.15 + graphql: 16.13.2 + graphql-tag: 2.12.7(graphql@16.13.2) + tslib: 2.8.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@graphql-codegen/typescript-urql-graphcache@3.1.1(@urql/exchange-graphcache@7.2.4(@urql/core@6.0.2(graphql@16.14.0))(graphql@16.14.0))(graphql-tag@2.12.7(graphql@16.14.0))(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.14.0) + '@graphql-codegen/visitor-plugin-common': 2.13.8(graphql@16.14.0) + '@urql/exchange-graphcache': 7.2.4(@urql/core@6.0.2(graphql@16.14.0))(graphql@16.14.0) + auto-bind: 4.0.0 + change-case-all: 1.0.15 + graphql: 16.14.0 + graphql-tag: 2.12.7(graphql@16.14.0) + tslib: 2.8.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@graphql-codegen/typescript@5.0.10(graphql@16.13.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.13.2) + '@graphql-codegen/schema-ast': 5.0.2(graphql@16.13.2) + '@graphql-codegen/visitor-plugin-common': 6.3.0(graphql@16.13.2) + auto-bind: 4.0.0 + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-codegen/typescript@5.0.10(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.14.0) + '@graphql-codegen/schema-ast': 5.0.2(graphql@16.14.0) + '@graphql-codegen/visitor-plugin-common': 6.3.0(graphql@16.14.0) + auto-bind: 4.0.0 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/urql-introspection@3.0.1(graphql@16.13.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.13.2) + '@urql/introspection': 0.3.3(graphql@16.13.2) + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-codegen/urql-introspection@3.0.1(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.14.0) + '@urql/introspection': 0.3.3(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/visitor-plugin-common@2.13.8(graphql@16.13.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.13.2) + '@graphql-tools/optimize': 1.4.0(graphql@16.13.2) + '@graphql-tools/relay-operation-optimizer': 6.5.18(graphql@16.13.2) + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + auto-bind: 4.0.0 + change-case-all: 1.0.15 + dependency-graph: 0.11.0 + graphql: 16.13.2 + graphql-tag: 2.12.7(graphql@16.13.2) + parse-filepath: 1.0.2 + tslib: 2.4.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@graphql-codegen/visitor-plugin-common@2.13.8(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.14.0) + '@graphql-tools/optimize': 1.4.0(graphql@16.14.0) + '@graphql-tools/relay-operation-optimizer': 6.5.18(graphql@16.14.0) + '@graphql-tools/utils': 9.2.1(graphql@16.14.0) + auto-bind: 4.0.0 + change-case-all: 1.0.15 + dependency-graph: 0.11.0 + graphql: 16.14.0 + graphql-tag: 2.12.7(graphql@16.14.0) + parse-filepath: 1.0.2 + tslib: 2.4.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@graphql-codegen/visitor-plugin-common@6.3.0(graphql@16.13.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.13.2) + '@graphql-tools/optimize': 2.0.0(graphql@16.13.2) + '@graphql-tools/relay-operation-optimizer': 7.1.4(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + auto-bind: 4.0.0 + change-case-all: 1.0.15 + dependency-graph: 1.0.0 + graphql: 16.13.2 + graphql-tag: 2.12.7(graphql@16.13.2) + parse-filepath: 1.0.2 + tslib: 2.8.1 + + '@graphql-codegen/visitor-plugin-common@6.3.0(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 6.3.0(graphql@16.14.0) + '@graphql-tools/optimize': 2.0.0(graphql@16.14.0) + '@graphql-tools/relay-operation-optimizer': 7.1.4(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + auto-bind: 4.0.0 + change-case-all: 1.0.15 + dependency-graph: 1.0.0 + graphql: 16.14.0 + graphql-tag: 2.12.7(graphql@16.14.0) + parse-filepath: 1.0.2 + tslib: 2.8.1 + + '@graphql-hive/signal@2.0.0': {} + + '@graphql-tools/apollo-engine-loader@8.0.30(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + '@whatwg-node/fetch': 0.10.13 + graphql: 16.13.2 + sync-fetch: 0.6.0 + tslib: 2.8.1 + + '@graphql-tools/apollo-engine-loader@8.0.30(graphql@16.14.0)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@whatwg-node/fetch': 0.10.13 + graphql: 16.14.0 + sync-fetch: 0.6.0 + tslib: 2.8.1 + + '@graphql-tools/batch-execute@10.0.8(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + '@whatwg-node/promise-helpers': 1.3.2 + dataloader: 2.2.3 + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-tools/batch-execute@10.0.8(graphql@16.14.0)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@whatwg-node/promise-helpers': 1.3.2 + dataloader: 2.2.3 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-tools/batch-execute@8.5.22(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + dataloader: 2.2.3 + graphql: 16.13.2 + tslib: 2.8.1 + value-or-promise: 1.0.12 + + '@graphql-tools/code-file-loader@8.1.32(graphql@16.13.2)': + dependencies: + '@graphql-tools/graphql-tag-pluck': 8.3.31(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + globby: 11.1.0 + graphql: 16.13.2 + tslib: 2.8.1 + unixify: 1.0.0 + transitivePeerDependencies: + - supports-color + + '@graphql-tools/code-file-loader@8.1.32(graphql@16.14.0)': + dependencies: + '@graphql-tools/graphql-tag-pluck': 8.3.31(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + globby: 11.1.0 + graphql: 16.14.0 + tslib: 2.8.1 + unixify: 1.0.0 + transitivePeerDependencies: + - supports-color + + '@graphql-tools/delegate@12.0.14(graphql@16.13.2)': + dependencies: + '@graphql-tools/batch-execute': 10.0.8(graphql@16.13.2) + '@graphql-tools/executor': 1.5.3(graphql@16.13.2) + '@graphql-tools/schema': 10.0.33(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + dataloader: 2.2.3 + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-tools/delegate@12.0.14(graphql@16.14.0)': + dependencies: + '@graphql-tools/batch-execute': 10.0.8(graphql@16.14.0) + '@graphql-tools/executor': 1.5.3(graphql@16.14.0) + '@graphql-tools/schema': 10.0.33(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + dataloader: 2.2.3 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-tools/delegate@9.0.35(graphql@16.13.2)': + dependencies: + '@graphql-tools/batch-execute': 8.5.22(graphql@16.13.2) + '@graphql-tools/executor': 0.0.20(graphql@16.13.2) + '@graphql-tools/schema': 9.0.19(graphql@16.13.2) + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + dataloader: 2.2.3 + graphql: 16.13.2 + tslib: 2.8.1 + value-or-promise: 1.0.12 + + '@graphql-tools/documents@1.0.1(graphql@16.13.2)': + dependencies: + graphql: 16.13.2 + lodash.sortby: 4.7.0 + tslib: 2.8.1 + + '@graphql-tools/documents@1.0.1(graphql@16.14.0)': + dependencies: + graphql: 16.14.0 + lodash.sortby: 4.7.0 + tslib: 2.8.1 + + '@graphql-tools/executor-common@1.0.6(graphql@16.13.2)': + dependencies: + '@envelop/core': 5.5.1 + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + graphql: 16.13.2 + + '@graphql-tools/executor-common@1.0.6(graphql@16.14.0)': + dependencies: + '@envelop/core': 5.5.1 + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + + '@graphql-tools/executor-graphql-ws@0.0.14(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + '@repeaterjs/repeater': 3.0.4 + '@types/ws': 8.18.1 + graphql: 16.13.2 + graphql-ws: 5.12.1(graphql@16.13.2) + isomorphic-ws: 5.0.0(ws@8.21.0) + tslib: 2.8.1 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@graphql-tools/executor-graphql-ws@3.1.5(graphql@16.13.2)': + dependencies: + '@graphql-tools/executor-common': 1.0.6(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + '@whatwg-node/disposablestack': 0.0.6 + graphql: 16.13.2 + graphql-ws: 6.0.8(graphql@16.13.2)(ws@8.21.0) + isows: 1.0.7(ws@8.21.0) + tslib: 2.8.1 + ws: 8.21.0 + transitivePeerDependencies: + - '@fastify/websocket' + - bufferutil + - crossws + - utf-8-validate + + '@graphql-tools/executor-graphql-ws@3.1.5(graphql@16.14.0)': + dependencies: + '@graphql-tools/executor-common': 1.0.6(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@whatwg-node/disposablestack': 0.0.6 + graphql: 16.14.0 + graphql-ws: 6.0.8(graphql@16.14.0)(ws@8.21.0) + isows: 1.0.7(ws@8.21.0) + tslib: 2.8.1 + ws: 8.21.0 + transitivePeerDependencies: + - '@fastify/websocket' + - bufferutil + - crossws + - utf-8-validate + + '@graphql-tools/executor-http@0.1.10(@types/node@24.10.1)(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/fetch': 0.8.8 + dset: 3.1.4 + extract-files: 11.0.0 + graphql: 16.13.2 + meros: 1.3.2(@types/node@24.10.1) + tslib: 2.8.1 + value-or-promise: 1.0.12 + transitivePeerDependencies: + - '@types/node' + + '@graphql-tools/executor-http@3.2.1(@types/node@24.10.1)(graphql@16.13.2)': + dependencies: + '@graphql-hive/signal': 2.0.0 + '@graphql-tools/executor-common': 1.0.6(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.13.2 + meros: 1.3.2(@types/node@24.10.1) + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/node' + + '@graphql-tools/executor-http@3.2.1(@types/node@25.9.3)(graphql@16.13.2)': + dependencies: + '@graphql-hive/signal': 2.0.0 + '@graphql-tools/executor-common': 1.0.6(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.13.2 + meros: 1.3.2(@types/node@25.9.3) + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/node' + + '@graphql-tools/executor-http@3.2.1(@types/node@25.9.3)(graphql@16.14.0)': + dependencies: + '@graphql-hive/signal': 2.0.0 + '@graphql-tools/executor-common': 1.0.6(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.14.0 + meros: 1.3.2(@types/node@25.9.3) + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/node' + + '@graphql-tools/executor-legacy-ws@0.0.11(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + '@types/ws': 8.18.1 + graphql: 16.13.2 + isomorphic-ws: 5.0.0(ws@8.21.0) + tslib: 2.8.1 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@graphql-tools/executor-legacy-ws@1.1.28(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + '@types/ws': 8.18.1 + graphql: 16.13.2 + isomorphic-ws: 5.0.0(ws@8.21.0) + tslib: 2.8.1 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@graphql-tools/executor-legacy-ws@1.1.28(graphql@16.14.0)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@types/ws': 8.18.1 + graphql: 16.14.0 + isomorphic-ws: 5.0.0(ws@8.21.0) + tslib: 2.8.1 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@graphql-tools/executor@0.0.20(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.2) + '@repeaterjs/repeater': 3.0.6 + graphql: 16.13.2 + tslib: 2.8.1 + value-or-promise: 1.0.12 + + '@graphql-tools/executor@1.5.3(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.2) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-tools/executor@1.5.3(graphql@16.14.0)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-tools/git-loader@8.0.36(graphql@16.13.2)': + dependencies: + '@graphql-tools/graphql-tag-pluck': 8.3.31(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + graphql: 16.13.2 + is-glob: 4.0.3 + micromatch: 4.0.8 + tslib: 2.8.1 + unixify: 1.0.0 + transitivePeerDependencies: + - supports-color + + '@graphql-tools/git-loader@8.0.36(graphql@16.14.0)': + dependencies: + '@graphql-tools/graphql-tag-pluck': 8.3.31(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + is-glob: 4.0.3 + micromatch: 4.0.8 + tslib: 2.8.1 + unixify: 1.0.0 + transitivePeerDependencies: + - supports-color + + '@graphql-tools/github-loader@9.1.2(@types/node@24.10.1)(graphql@16.13.2)': + dependencies: + '@graphql-tools/executor-http': 3.2.1(@types/node@24.10.1)(graphql@16.13.2) + '@graphql-tools/graphql-tag-pluck': 8.3.31(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.13.2 + sync-fetch: 0.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/node' + - supports-color + + '@graphql-tools/github-loader@9.1.2(@types/node@25.9.3)(graphql@16.13.2)': + dependencies: + '@graphql-tools/executor-http': 3.2.1(@types/node@25.9.3)(graphql@16.13.2) + '@graphql-tools/graphql-tag-pluck': 8.3.31(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.13.2 + sync-fetch: 0.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/node' + - supports-color + + '@graphql-tools/github-loader@9.1.2(@types/node@25.9.3)(graphql@16.14.0)': + dependencies: + '@graphql-tools/executor-http': 3.2.1(@types/node@25.9.3)(graphql@16.14.0) + '@graphql-tools/graphql-tag-pluck': 8.3.31(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.14.0 + sync-fetch: 0.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/node' + - supports-color + + '@graphql-tools/graphql-file-loader@7.5.17(graphql@16.13.2)': + dependencies: + '@graphql-tools/import': 6.7.18(graphql@16.13.2) + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + globby: 11.1.0 + graphql: 16.13.2 + tslib: 2.8.1 + unixify: 1.0.0 + + '@graphql-tools/graphql-file-loader@8.1.14(graphql@16.13.2)': + dependencies: + '@graphql-tools/import': 7.1.14(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + globby: 11.1.0 + graphql: 16.13.2 + tslib: 2.8.1 + unixify: 1.0.0 + + '@graphql-tools/graphql-file-loader@8.1.14(graphql@16.14.0)': + dependencies: + '@graphql-tools/import': 7.1.14(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + globby: 11.1.0 + graphql: 16.14.0 + tslib: 2.8.1 + unixify: 1.0.0 + + '@graphql-tools/graphql-tag-pluck@8.3.31(graphql@16.13.2)': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.7 + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + graphql: 16.13.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@graphql-tools/graphql-tag-pluck@8.3.31(graphql@16.14.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.7 + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@graphql-tools/import@6.7.18(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + graphql: 16.13.2 + resolve-from: 5.0.0 + tslib: 2.8.1 + + '@graphql-tools/import@7.1.14(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + graphql: 16.13.2 + resolve-from: 5.0.0 + tslib: 2.8.1 + + '@graphql-tools/import@7.1.14(graphql@16.14.0)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + resolve-from: 5.0.0 + tslib: 2.8.1 + + '@graphql-tools/json-file-loader@7.4.18(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + globby: 11.1.0 + graphql: 16.13.2 + tslib: 2.8.1 + unixify: 1.0.0 + + '@graphql-tools/json-file-loader@8.0.28(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + globby: 11.1.0 + graphql: 16.13.2 + tslib: 2.8.1 + unixify: 1.0.0 + + '@graphql-tools/json-file-loader@8.0.28(graphql@16.14.0)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + globby: 11.1.0 + graphql: 16.14.0 + tslib: 2.8.1 + unixify: 1.0.0 + + '@graphql-tools/load@7.8.14(graphql@16.13.2)': + dependencies: + '@graphql-tools/schema': 9.0.19(graphql@16.13.2) + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + graphql: 16.13.2 + p-limit: 3.1.0 + tslib: 2.8.1 + + '@graphql-tools/load@8.1.10(graphql@16.13.2)': + dependencies: + '@graphql-tools/schema': 10.0.33(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + graphql: 16.13.2 + p-limit: 3.1.0 + tslib: 2.8.1 + + '@graphql-tools/load@8.1.10(graphql@16.14.0)': + dependencies: + '@graphql-tools/schema': 10.0.33(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + p-limit: 3.1.0 + tslib: 2.8.1 + + '@graphql-tools/merge@8.4.2(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-tools/merge@9.1.9(graphql@16.13.2)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-tools/merge@9.1.9(graphql@16.14.0)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-tools/optimize@1.4.0(graphql@16.13.2)': + dependencies: + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-tools/optimize@1.4.0(graphql@16.14.0)': + dependencies: + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-tools/optimize@2.0.0(graphql@16.13.2)': + dependencies: + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-tools/optimize@2.0.0(graphql@16.14.0)': + dependencies: + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-tools/relay-operation-optimizer@6.5.18(graphql@16.13.2)': + dependencies: + '@ardatan/relay-compiler': 12.0.0(graphql@16.13.2) + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + graphql: 16.13.2 + tslib: 2.8.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@graphql-tools/relay-operation-optimizer@6.5.18(graphql@16.14.0)': + dependencies: + '@ardatan/relay-compiler': 12.0.0(graphql@16.14.0) + '@graphql-tools/utils': 9.2.1(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@graphql-tools/relay-operation-optimizer@7.1.4(graphql@16.13.2)': + dependencies: + '@ardatan/relay-compiler': 13.0.1(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-tools/relay-operation-optimizer@7.1.4(graphql@16.14.0)': + dependencies: + '@ardatan/relay-compiler': 13.0.1(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-tools/schema@10.0.33(graphql@16.13.2)': + dependencies: + '@graphql-tools/merge': 9.1.9(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-tools/schema@10.0.33(graphql@16.14.0)': + dependencies: + '@graphql-tools/merge': 9.1.9(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-tools/schema@9.0.19(graphql@16.13.2)': + dependencies: + '@graphql-tools/merge': 8.4.2(graphql@16.13.2) + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + graphql: 16.13.2 + tslib: 2.8.1 + value-or-promise: 1.0.12 + + '@graphql-tools/url-loader@7.17.18(@types/node@24.10.1)(graphql@16.13.2)': + dependencies: + '@ardatan/sync-fetch': 0.0.1 + '@graphql-tools/delegate': 9.0.35(graphql@16.13.2) + '@graphql-tools/executor-graphql-ws': 0.0.14(graphql@16.13.2) + '@graphql-tools/executor-http': 0.1.10(@types/node@24.10.1)(graphql@16.13.2) + '@graphql-tools/executor-legacy-ws': 0.0.11(graphql@16.13.2) + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + '@graphql-tools/wrap': 9.4.2(graphql@16.13.2) + '@types/ws': 8.18.1 + '@whatwg-node/fetch': 0.8.8 + graphql: 16.13.2 + isomorphic-ws: 5.0.0(ws@8.21.0) + tslib: 2.8.1 + value-or-promise: 1.0.12 + ws: 8.21.0 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - encoding + - utf-8-validate + + '@graphql-tools/url-loader@9.1.2(@types/node@24.10.1)(graphql@16.13.2)': + dependencies: + '@graphql-tools/executor-graphql-ws': 3.1.5(graphql@16.13.2) + '@graphql-tools/executor-http': 3.2.1(@types/node@24.10.1)(graphql@16.13.2) + '@graphql-tools/executor-legacy-ws': 1.1.28(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + '@graphql-tools/wrap': 11.1.14(graphql@16.13.2) + '@types/ws': 8.18.1 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.13.2 + isomorphic-ws: 5.0.0(ws@8.21.0) + sync-fetch: 0.6.0 + tslib: 2.8.1 + ws: 8.21.0 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - crossws + - utf-8-validate + + '@graphql-tools/url-loader@9.1.2(@types/node@25.9.3)(graphql@16.13.2)': + dependencies: + '@graphql-tools/executor-graphql-ws': 3.1.5(graphql@16.13.2) + '@graphql-tools/executor-http': 3.2.1(@types/node@25.9.3)(graphql@16.13.2) + '@graphql-tools/executor-legacy-ws': 1.1.28(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + '@graphql-tools/wrap': 11.1.14(graphql@16.13.2) + '@types/ws': 8.18.1 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.13.2 + isomorphic-ws: 5.0.0(ws@8.21.0) + sync-fetch: 0.6.0 + tslib: 2.8.1 + ws: 8.21.0 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - crossws + - utf-8-validate + + '@graphql-tools/url-loader@9.1.2(@types/node@25.9.3)(graphql@16.14.0)': + dependencies: + '@graphql-tools/executor-graphql-ws': 3.1.5(graphql@16.14.0) + '@graphql-tools/executor-http': 3.2.1(@types/node@25.9.3)(graphql@16.14.0) + '@graphql-tools/executor-legacy-ws': 1.1.28(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-tools/wrap': 11.1.14(graphql@16.14.0) + '@types/ws': 8.18.1 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.14.0 + isomorphic-ws: 5.0.0(ws@8.21.0) + sync-fetch: 0.6.0 + tslib: 2.8.1 + ws: 8.21.0 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - crossws + - utf-8-validate + + '@graphql-tools/utils@11.0.1(graphql@16.13.2)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.2) + '@whatwg-node/promise-helpers': 1.3.2 + cross-inspect: 1.0.1 + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-tools/utils@11.0.1(graphql@16.14.0)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0) + '@whatwg-node/promise-helpers': 1.3.2 + cross-inspect: 1.0.1 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-tools/utils@11.1.0(graphql@16.13.2)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.2) + '@whatwg-node/promise-helpers': 1.3.2 + cross-inspect: 1.0.1 + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-tools/utils@11.1.0(graphql@16.14.0)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0) + '@whatwg-node/promise-helpers': 1.3.2 + cross-inspect: 1.0.1 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-tools/utils@9.2.1(graphql@16.13.2)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.2) + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-tools/utils@9.2.1(graphql@16.14.0)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-tools/wrap@11.1.14(graphql@16.13.2)': + dependencies: + '@graphql-tools/delegate': 12.0.14(graphql@16.13.2) + '@graphql-tools/schema': 10.0.33(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.13.2 + tslib: 2.8.1 + + '@graphql-tools/wrap@11.1.14(graphql@16.14.0)': + dependencies: + '@graphql-tools/delegate': 12.0.14(graphql@16.14.0) + '@graphql-tools/schema': 10.0.33(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-tools/wrap@9.4.2(graphql@16.13.2)': + dependencies: + '@graphql-tools/delegate': 9.0.35(graphql@16.13.2) + '@graphql-tools/schema': 9.0.19(graphql@16.13.2) + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + graphql: 16.13.2 + tslib: 2.8.1 + value-or-promise: 1.0.12 + + '@graphql-typed-document-node/core@3.2.0(graphql@16.13.2)': + dependencies: + graphql: 16.13.2 + + '@graphql-typed-document-node/core@3.2.0(graphql@16.14.0)': + dependencies: + graphql: 16.14.0 + + '@guolao/vue-monaco-editor@1.6.0(monaco-editor@0.55.1)(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.55.1 + vue: 3.5.38(typescript@5.9.3) + vue-demi: 0.14.10(vue@3.5.38(typescript@5.9.3)) + + '@hapi/b64@5.0.0': + dependencies: + '@hapi/hoek': 9.3.0 + + '@hapi/boom@9.1.4': + dependencies: + '@hapi/hoek': 9.3.0 + + '@hapi/cryptiles@5.1.0': + dependencies: + '@hapi/boom': 9.1.4 + + '@hapi/hoek@9.3.0': {} + + '@hono/node-server@1.19.11(hono@4.12.25)': + dependencies: + hono: 4.12.25 + + '@hoppscotch/httpsnippet@3.0.9': + dependencies: + chalk: 4.1.2 + event-stream: 4.0.1 + form-data: 4.0.6 + har-validator-compiled: 1.0.0 + stringify-object: 3.3.0 + yargs: 17.7.2 + + '@hoppscotch/ui@0.2.6(eslint@10.5.0(jiti@2.6.1))(terser@5.46.1)(typescript@5.9.3)(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@boringer-avatars/vue3': 0.2.1(vue@3.5.38(typescript@5.9.3)) + '@fontsource-variable/inter': 5.2.8 + '@fontsource-variable/material-symbols-rounded': 5.2.45 + '@fontsource-variable/roboto-mono': 5.2.9 + '@hoppscotch/vue-sonner': 1.2.3 + '@hoppscotch/vue-toasted': 0.1.0(vue@3.5.38(typescript@5.9.3)) + '@vitejs/plugin-legacy': 2.3.1(terser@5.46.1)(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + '@vueuse/core': 8.9.4(vue@3.5.38(typescript@5.9.3)) + fp-ts: 2.16.11 + lodash-es: 4.18.1 + path: 0.12.7 + vite-plugin-eslint: 1.8.1(eslint@10.5.0(jiti@2.6.1))(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + vue: 3.5.38(typescript@5.9.3) + vue-promise-modals: 0.1.0(typescript@5.9.3) + vuedraggable-es: 4.1.1(vue@3.5.38(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - eslint + - terser + - typescript + - vite + + '@hoppscotch/ui@0.2.6(eslint@10.5.0(jiti@2.6.1))(terser@5.46.1)(typescript@5.9.3)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@boringer-avatars/vue3': 0.2.1(vue@3.5.38(typescript@5.9.3)) + '@fontsource-variable/inter': 5.2.8 + '@fontsource-variable/material-symbols-rounded': 5.2.45 + '@fontsource-variable/roboto-mono': 5.2.9 + '@hoppscotch/vue-sonner': 1.2.3 + '@hoppscotch/vue-toasted': 0.1.0(vue@3.5.38(typescript@5.9.3)) + '@vitejs/plugin-legacy': 2.3.1(terser@5.46.1)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + '@vueuse/core': 8.9.4(vue@3.5.38(typescript@5.9.3)) + fp-ts: 2.16.11 + lodash-es: 4.18.1 + path: 0.12.7 + vite-plugin-eslint: 1.8.1(eslint@10.5.0(jiti@2.6.1))(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + vue: 3.5.38(typescript@5.9.3) + vue-promise-modals: 0.1.0(typescript@5.9.3) + vuedraggable-es: 4.1.1(vue@3.5.38(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - eslint + - terser + - typescript + - vite + + '@hoppscotch/ui@0.2.6(eslint@9.39.2(jiti@2.6.1))(terser@5.46.1)(typescript@5.9.3)(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@boringer-avatars/vue3': 0.2.1(vue@3.5.38(typescript@5.9.3)) + '@fontsource-variable/inter': 5.2.8 + '@fontsource-variable/material-symbols-rounded': 5.2.45 + '@fontsource-variable/roboto-mono': 5.2.9 + '@hoppscotch/vue-sonner': 1.2.3 + '@hoppscotch/vue-toasted': 0.1.0(vue@3.5.38(typescript@5.9.3)) + '@vitejs/plugin-legacy': 2.3.1(terser@5.46.1)(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + '@vueuse/core': 8.9.4(vue@3.5.38(typescript@5.9.3)) + fp-ts: 2.16.11 + lodash-es: 4.18.1 + path: 0.12.7 + vite-plugin-eslint: 1.8.1(eslint@9.39.2(jiti@2.6.1))(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + vue: 3.5.38(typescript@5.9.3) + vue-promise-modals: 0.1.0(typescript@5.9.3) + vuedraggable-es: 4.1.1(vue@3.5.38(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - eslint + - terser + - typescript + - vite + + '@hoppscotch/ui@0.2.6(eslint@9.39.2(jiti@2.6.1))(terser@5.46.1)(typescript@5.9.3)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@boringer-avatars/vue3': 0.2.1(vue@3.5.38(typescript@5.9.3)) + '@fontsource-variable/inter': 5.2.8 + '@fontsource-variable/material-symbols-rounded': 5.2.45 + '@fontsource-variable/roboto-mono': 5.2.9 + '@hoppscotch/vue-sonner': 1.2.3 + '@hoppscotch/vue-toasted': 0.1.0(vue@3.5.38(typescript@5.9.3)) + '@vitejs/plugin-legacy': 2.3.1(terser@5.46.1)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + '@vueuse/core': 8.9.4(vue@3.5.38(typescript@5.9.3)) + fp-ts: 2.16.11 + lodash-es: 4.18.1 + path: 0.12.7 + vite-plugin-eslint: 1.8.1(eslint@9.39.2(jiti@2.6.1))(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + vue: 3.5.38(typescript@5.9.3) + vue-promise-modals: 0.1.0(typescript@5.9.3) + vuedraggable-es: 4.1.1(vue@3.5.38(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - eslint + - terser + - typescript + - vite + + '@hoppscotch/vue-sonner@1.2.3': {} + + '@hoppscotch/vue-toasted@0.1.0(vue@3.5.38(typescript@5.9.3))': + dependencies: + vue: 3.5.38(typescript@5.9.3) + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@iconify-json/lucide@1.2.114': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.2 + + '@import-meta-env/cli@0.7.4(@import-meta-env/unplugin@0.6.3)': + dependencies: + commander: 13.1.0 + dotenv: 16.5.0 + glob: 11.1.0 + picocolors: 1.1.1 + serialize-javascript: 7.0.3 + optionalDependencies: + '@import-meta-env/unplugin': 0.6.3 + + '@import-meta-env/unplugin@0.6.3': + dependencies: + dotenv: 16.5.0 + magic-string: 0.30.17 + object-hash: 3.0.0 + picocolors: 1.1.1 + unplugin: 2.2.2 + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@24.10.1)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.10.1) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.10.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.10.1 + + '@inquirer/checkbox@4.3.2(@types/node@25.9.3)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@25.9.3) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.9.3) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/confirm@5.1.21(@types/node@24.10.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.10.1) + '@inquirer/type': 3.0.10(@types/node@24.10.1) + optionalDependencies: + '@types/node': 24.10.1 + + '@inquirer/confirm@5.1.21(@types/node@25.9.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.3) + '@inquirer/type': 3.0.10(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/core@10.3.2(@types/node@24.10.1)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.10.1) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.10.1 + + '@inquirer/core@10.3.2(@types/node@25.9.3)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.9.3) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/editor@4.2.23(@types/node@24.10.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.10.1) + '@inquirer/external-editor': 1.0.3(@types/node@24.10.1) + '@inquirer/type': 3.0.10(@types/node@24.10.1) + optionalDependencies: + '@types/node': 24.10.1 + + '@inquirer/editor@4.2.23(@types/node@25.9.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.3) + '@inquirer/external-editor': 1.0.3(@types/node@25.9.3) + '@inquirer/type': 3.0.10(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/expand@4.0.23(@types/node@24.10.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.10.1) + '@inquirer/type': 3.0.10(@types/node@24.10.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.10.1 + + '@inquirer/expand@4.0.23(@types/node@25.9.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.3) + '@inquirer/type': 3.0.10(@types/node@25.9.3) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/external-editor@1.0.3(@types/node@24.10.1)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 24.10.1 + + '@inquirer/external-editor@1.0.3(@types/node@25.9.3)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@24.10.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.10.1) + '@inquirer/type': 3.0.10(@types/node@24.10.1) + optionalDependencies: + '@types/node': 24.10.1 + + '@inquirer/input@4.3.1(@types/node@25.9.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.3) + '@inquirer/type': 3.0.10(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/number@3.0.23(@types/node@24.10.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.10.1) + '@inquirer/type': 3.0.10(@types/node@24.10.1) + optionalDependencies: + '@types/node': 24.10.1 + + '@inquirer/number@3.0.23(@types/node@25.9.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.3) + '@inquirer/type': 3.0.10(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/password@4.0.23(@types/node@24.10.1)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.10.1) + '@inquirer/type': 3.0.10(@types/node@24.10.1) + optionalDependencies: + '@types/node': 24.10.1 + + '@inquirer/password@4.0.23(@types/node@25.9.3)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@25.9.3) + '@inquirer/type': 3.0.10(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/prompts@7.10.1(@types/node@24.10.1)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.10.1) + '@inquirer/confirm': 5.1.21(@types/node@24.10.1) + '@inquirer/editor': 4.2.23(@types/node@24.10.1) + '@inquirer/expand': 4.0.23(@types/node@24.10.1) + '@inquirer/input': 4.3.1(@types/node@24.10.1) + '@inquirer/number': 3.0.23(@types/node@24.10.1) + '@inquirer/password': 4.0.23(@types/node@24.10.1) + '@inquirer/rawlist': 4.1.11(@types/node@24.10.1) + '@inquirer/search': 3.2.2(@types/node@24.10.1) + '@inquirer/select': 4.4.2(@types/node@24.10.1) + optionalDependencies: + '@types/node': 24.10.1 + + '@inquirer/prompts@7.10.1(@types/node@25.9.3)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@25.9.3) + '@inquirer/confirm': 5.1.21(@types/node@25.9.3) + '@inquirer/editor': 4.2.23(@types/node@25.9.3) + '@inquirer/expand': 4.0.23(@types/node@25.9.3) + '@inquirer/input': 4.3.1(@types/node@25.9.3) + '@inquirer/number': 3.0.23(@types/node@25.9.3) + '@inquirer/password': 4.0.23(@types/node@25.9.3) + '@inquirer/rawlist': 4.1.11(@types/node@25.9.3) + '@inquirer/search': 3.2.2(@types/node@25.9.3) + '@inquirer/select': 4.4.2(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/prompts@7.3.2(@types/node@25.9.3)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@25.9.3) + '@inquirer/confirm': 5.1.21(@types/node@25.9.3) + '@inquirer/editor': 4.2.23(@types/node@25.9.3) + '@inquirer/expand': 4.0.23(@types/node@25.9.3) + '@inquirer/input': 4.3.1(@types/node@25.9.3) + '@inquirer/number': 3.0.23(@types/node@25.9.3) + '@inquirer/password': 4.0.23(@types/node@25.9.3) + '@inquirer/rawlist': 4.1.11(@types/node@25.9.3) + '@inquirer/search': 3.2.2(@types/node@25.9.3) + '@inquirer/select': 4.4.2(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/rawlist@4.1.11(@types/node@24.10.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.10.1) + '@inquirer/type': 3.0.10(@types/node@24.10.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.10.1 + + '@inquirer/rawlist@4.1.11(@types/node@25.9.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.3) + '@inquirer/type': 3.0.10(@types/node@25.9.3) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/search@3.2.2(@types/node@24.10.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.10.1) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.10.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.10.1 + + '@inquirer/search@3.2.2(@types/node@25.9.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.3) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.9.3) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/select@4.4.2(@types/node@24.10.1)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.10.1) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.10.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.10.1 + + '@inquirer/select@4.4.2(@types/node@25.9.3)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@25.9.3) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.9.3) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/type@3.0.10(@types/node@24.10.1)': + optionalDependencies: + '@types/node': 24.10.1 + + '@inquirer/type@3.0.10(@types/node@25.9.3)': + optionalDependencies: + '@types/node': 25.9.3 + + '@intlify/bundle-utils@11.2.4(vue-i18n@11.4.6(vue@3.5.38(typescript@5.9.3)))': + dependencies: + '@intlify/message-compiler': 11.4.6 + '@intlify/shared': 11.4.6 + acorn: 8.17.0 + esbuild: 0.25.12 + escodegen: 2.1.0 + estree-walker: 2.0.2 + jsonc-eslint-parser: 2.4.2 + source-map-js: 1.2.1 + yaml-eslint-parser: 1.3.2 + optionalDependencies: + vue-i18n: 11.4.6(vue@3.5.38(typescript@5.9.3)) + + '@intlify/core-base@11.4.6': + dependencies: + '@intlify/devtools-types': 11.4.6 + '@intlify/message-compiler': 11.4.6 + '@intlify/shared': 11.4.6 + + '@intlify/devtools-types@11.4.6': + dependencies: + '@intlify/core-base': 11.4.6 + '@intlify/shared': 11.4.6 + + '@intlify/message-compiler@11.4.6': + dependencies: + '@intlify/shared': 11.4.6 + source-map-js: 1.2.1 + + '@intlify/shared@11.4.6': {} + + '@intlify/unplugin-vue-i18n@11.2.4(@vue/compiler-dom@3.5.38)(eslint@10.5.0(jiti@2.6.1))(rollup@4.60.2)(typescript@5.9.3)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-i18n@11.4.6(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@intlify/bundle-utils': 11.2.4(vue-i18n@11.4.6(vue@3.5.38(typescript@5.9.3))) + '@intlify/shared': 11.4.6 + '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.4.6)(@vue/compiler-dom@3.5.38)(vue-i18n@11.4.6(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3)) + '@rollup/pluginutils': 5.4.0(rollup@4.60.2) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + debug: 4.4.3(supports-color@8.1.1) + fast-glob: 3.3.3 + pathe: 2.0.3 + picocolors: 1.1.1 + unplugin: 2.3.11 + vue: 3.5.38(typescript@5.9.3) + optionalDependencies: + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + vue-i18n: 11.4.6(vue@3.5.38(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/compiler-dom' + - eslint + - rollup + - supports-color + - typescript + + '@intlify/unplugin-vue-i18n@11.2.4(@vue/compiler-dom@3.5.38)(eslint@9.39.2(jiti@2.6.1))(rollup@4.60.2)(typescript@5.9.3)(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-i18n@11.4.6(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@intlify/bundle-utils': 11.2.4(vue-i18n@11.4.6(vue@3.5.38(typescript@5.9.3))) + '@intlify/shared': 11.4.6 + '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.4.6)(@vue/compiler-dom@3.5.38)(vue-i18n@11.4.6(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3)) + '@rollup/pluginutils': 5.4.0(rollup@4.60.2) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + debug: 4.4.3(supports-color@8.1.1) + fast-glob: 3.3.3 + pathe: 2.0.3 + picocolors: 1.1.1 + unplugin: 2.3.11 + vue: 3.5.38(typescript@5.9.3) + optionalDependencies: + vite: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + vue-i18n: 11.4.6(vue@3.5.38(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/compiler-dom' + - eslint + - rollup + - supports-color + - typescript + + '@intlify/unplugin-vue-i18n@11.2.4(@vue/compiler-dom@3.5.38)(eslint@9.39.2(jiti@2.6.1))(rollup@4.60.2)(typescript@5.9.3)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-i18n@11.4.6(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@intlify/bundle-utils': 11.2.4(vue-i18n@11.4.6(vue@3.5.38(typescript@5.9.3))) + '@intlify/shared': 11.4.6 + '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.4.6)(@vue/compiler-dom@3.5.38)(vue-i18n@11.4.6(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3)) + '@rollup/pluginutils': 5.4.0(rollup@4.60.2) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + debug: 4.4.3(supports-color@8.1.1) + fast-glob: 3.3.3 + pathe: 2.0.3 + picocolors: 1.1.1 + unplugin: 2.3.11 + vue: 3.5.38(typescript@5.9.3) + optionalDependencies: + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + vue-i18n: 11.4.6(vue@3.5.38(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/compiler-dom' + - eslint + - rollup + - supports-color + - typescript + + '@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.4.6)(@vue/compiler-dom@3.5.38)(vue-i18n@11.4.6(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@babel/parser': 7.29.7 + optionalDependencies: + '@intlify/shared': 11.4.6 + '@vue/compiler-dom': 3.5.38 + vue: 3.5.38(typescript@5.9.3) + vue-i18n: 11.4.6(vue@3.5.38(typescript@5.9.3)) + + '@ioredis/commands@1.5.1': + optional: true + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@isaacs/cliui@9.0.0': {} + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/console@30.4.1': + dependencies: + '@jest/types': 30.4.1 + '@types/node': 25.9.3 + chalk: 4.1.2 + jest-message-util: 30.4.1 + jest-util: 30.4.1 + slash: 3.0.0 + + '@jest/core@30.4.2(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3))': + dependencies: + '@jest/console': 30.4.1 + '@jest/pattern': 30.4.0 + '@jest/reporters': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 25.9.3 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.4.0 + exit-x: 0.2.2 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-changed-files: 30.4.1 + jest-config: 30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) + jest-haste-map: 30.4.1 + jest-message-util: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-resolve-dependencies: 30.4.2 + jest-runner: 30.4.2 + jest-runtime: 30.4.2 + jest-snapshot: 30.4.1 + jest-util: 30.4.1 + jest-validate: 30.4.1 + jest-watcher: 30.4.1 + pretty-format: 30.4.1 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + '@jest/diff-sequences@30.3.0': {} + + '@jest/diff-sequences@30.4.0': {} + + '@jest/environment@30.4.1': + dependencies: + '@jest/fake-timers': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 25.9.3 + jest-mock: 30.4.1 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect-utils@30.3.0': + dependencies: + '@jest/get-type': 30.1.0 + + '@jest/expect-utils@30.4.1': + dependencies: + '@jest/get-type': 30.1.0 + + '@jest/expect@30.4.1': + dependencies: + expect: 30.4.1 + jest-snapshot: 30.4.1 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@30.4.1': + dependencies: + '@jest/types': 30.4.1 + '@sinonjs/fake-timers': 15.4.0 + '@types/node': 25.9.3 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-util: 30.4.1 + + '@jest/get-type@30.1.0': {} + + '@jest/globals@30.4.1': + dependencies: + '@jest/environment': 30.4.1 + '@jest/expect': 30.4.1 + '@jest/types': 30.4.1 + jest-mock: 30.4.1 + transitivePeerDependencies: + - supports-color + + '@jest/pattern@30.0.1': + dependencies: + '@types/node': 25.9.3 + jest-regex-util: 30.0.1 + + '@jest/pattern@30.4.0': + dependencies: + '@types/node': 25.9.3 + jest-regex-util: 30.4.0 + + '@jest/reporters@30.4.1': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 25.9.3 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit-x: 0.2.2 + glob: 10.5.0 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + jest-message-util: 30.4.1 + jest-util: 30.4.1 + jest-worker: 30.4.1 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jest/schemas@30.0.5': + dependencies: + '@sinclair/typebox': 0.34.48 + + '@jest/schemas@30.4.1': + dependencies: + '@sinclair/typebox': 0.34.48 + + '@jest/snapshot-utils@30.4.1': + dependencies: + '@jest/types': 30.4.1 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 + + '@jest/source-map@30.0.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@30.4.1': + dependencies: + '@jest/console': 30.4.1 + '@jest/types': 30.4.1 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@30.4.1': + dependencies: + '@jest/test-result': 30.4.1 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + slash: 3.0.0 + + '@jest/transform@30.4.1': + dependencies: + '@babel/core': 7.29.0 + '@jest/types': 30.4.1 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 7.0.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + jest-regex-util: 30.4.0 + jest-util: 30.4.1 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 25.9.3 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jest/types@30.3.0': + dependencies: + '@jest/pattern': 30.0.1 + '@jest/schemas': 30.0.5 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 25.9.3 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jest/types@30.4.1': + dependencies: + '@jest/pattern': 30.4.0 + '@jest/schemas': 30.4.1 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 25.9.3 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jitl/quickjs-ffi-types@0.31.0': {} + + '@jitl/quickjs-singlefile-mjs-release-asyncify@0.31.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.31.0 + + '@jitl/quickjs-wasmfile-debug-asyncify@0.31.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.31.0 + + '@jitl/quickjs-wasmfile-debug-sync@0.31.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.31.0 + + '@jitl/quickjs-wasmfile-release-asyncify@0.31.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.31.0 + + '@jitl/quickjs-wasmfile-release-sync@0.31.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.31.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jsdevtools/ono@7.1.3': {} + + '@kurkle/color@0.3.4': {} + + '@lezer/common@1.5.1': {} + + '@lezer/generator@1.8.0': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/lr': 1.4.2 + + '@lezer/highlight@1.2.1': + dependencies: + '@lezer/common': 1.5.1 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 + + '@lezer/lr@1.4.2': + dependencies: + '@lezer/common': 1.5.1 + + '@lezer/xml@1.0.6': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 + + '@lukeed/csprng@1.1.0': {} + + '@marijn/find-cluster-break@1.0.2': {} + + '@microsoft/tsdoc@0.16.0': {} + + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@napi-rs/canvas-android-arm64@0.1.97': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.97': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.97': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.97': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.97': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.97': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.97': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.97': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.97': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.97': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.97': + optional: true + + '@napi-rs/canvas@0.1.97': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.97 + '@napi-rs/canvas-darwin-arm64': 0.1.97 + '@napi-rs/canvas-darwin-x64': 0.1.97 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.97 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.97 + '@napi-rs/canvas-linux-arm64-musl': 0.1.97 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.97 + '@napi-rs/canvas-linux-x64-gnu': 0.1.97 + '@napi-rs/canvas-linux-x64-musl': 0.1.97 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.97 + '@napi-rs/canvas-win32-x64-msvc': 0.1.97 + optional: true + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@nestjs-modules/mailer@2.3.7(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@nestjs/terminus@11.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2))(chokidar@4.0.3)(nodemailer@9.0.1)(terser@5.46.1)(typescript@5.9.3)': + dependencies: + '@css-inline/css-inline': 0.20.0 + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + glob: 13.0.6 + nodemailer: 9.0.1 + tslib: 2.8.1 + optionalDependencies: + '@nestjs/terminus': 11.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/ejs': 3.1.5 + '@types/mjml': 4.7.4 + '@types/pug': 2.0.10 + ejs: 5.0.2 + handlebars: 4.7.9 + liquidjs: 10.26.0 + mjml: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + nunjucks: 3.2.4(chokidar@4.0.3) + preview-email: 3.1.1 + pug: 3.0.4 + transitivePeerDependencies: + - chokidar + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + + '@nestjs/apollo@13.4.2(@apollo/server@5.5.1(graphql@16.14.0))(@as-integrations/express5@1.1.2(@apollo/server@5.5.1(graphql@16.14.0))(express@5.2.1))(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@nestjs/graphql@13.4.2(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.14.0)(reflect-metadata@0.2.2))(graphql@16.14.0)': + dependencies: + '@apollo/server': 5.5.1(graphql@16.14.0) + '@apollo/server-plugin-landing-page-graphql-playground': 4.0.1(@apollo/server@5.5.1(graphql@16.14.0)) + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/graphql': 13.4.2(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.14.0)(reflect-metadata@0.2.2) + graphql: 16.14.0 + iterall: 1.3.0 + lodash.omit: 4.18.0 + tslib: 2.8.1 + optionalDependencies: + '@as-integrations/express5': 1.1.2(@apollo/server@5.5.1(graphql@16.14.0))(express@5.2.1) + + '@nestjs/cli@11.0.23(@types/node@25.9.3)(prettier@3.8.4)': + dependencies: + '@angular-devkit/core': 19.2.27(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.27(chokidar@4.0.3) + '@angular-devkit/schematics-cli': 19.2.27(@types/node@25.9.3)(chokidar@4.0.3) + '@inquirer/prompts': 7.10.1(@types/node@25.9.3) + '@nestjs/schematics': 11.1.0(chokidar@4.0.3)(prettier@3.8.4)(typescript@5.9.3) + ansis: 4.2.0 + chokidar: 4.0.3 + cli-table3: 0.6.5 + commander: 4.1.1 + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.2) + glob: 13.0.6 + node-emoji: 1.11.0 + ora: 5.4.1 + tsconfig-paths: 4.2.0 + tsconfig-paths-webpack-plugin: 4.2.0 + typescript: 5.9.3 + webpack: 5.106.2 + webpack-node-externals: 3.0.0 + transitivePeerDependencies: + - '@types/node' + - esbuild + - prettier + - uglify-js + - webpack-cli + + '@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.4 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.15.1 + transitivePeerDependencies: + - supports-color + + '@nestjs/config@4.0.4(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.4.1 + dotenv-expand: 12.0.3 + lodash: 4.18.1 + rxjs: 7.8.2 + + '@nestjs/core@11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27) + + '@nestjs/graphql@13.4.2(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.14.0)(reflect-metadata@0.2.2)': + dependencies: + '@graphql-tools/merge': 9.1.9(graphql@16.14.0) + '@graphql-tools/schema': 10.0.33(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + chokidar: 4.0.3 + fast-glob: 3.3.3 + graphql: 16.14.0 + graphql-tag: 2.12.6(graphql@16.14.0) + graphql-ws: 6.0.8(graphql@16.14.0)(ws@8.21.0) + lodash: 4.18.1 + normalize-path: 3.0.0 + reflect-metadata: 0.2.2 + subscriptions-transport-ws: 0.11.0(graphql@16.14.0) + tslib: 2.8.1 + ws: 8.21.0 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.15.1 + transitivePeerDependencies: + - '@fastify/websocket' + - bufferutil + - crossws + - utf-8-validate + + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 + + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.15.1 + + '@nestjs/passport@11.0.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + + '@nestjs/platform-express@11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)': + dependencies: + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cors: 2.8.6 + express: 5.2.1 + multer: 2.2.0 + path-to-regexp: 8.4.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@nestjs/schedule@6.1.3(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)': + dependencies: + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.4.0 + + '@nestjs/schematics@11.1.0(chokidar@4.0.3)(prettier@3.8.4)(typescript@5.9.3)': + dependencies: + '@angular-devkit/core': 19.2.24(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3) + comment-json: 5.0.0 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.9.3 + optionalDependencies: + prettier: 3.8.4 + transitivePeerDependencies: + - chokidar + + '@nestjs/swagger@11.4.4(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.18.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.32.6 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.15.1 + + '@nestjs/terminus@11.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + boxen: 5.1.2 + check-disk-space: 3.4.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + optionalDependencies: + '@prisma/client': 7.8.0(prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) + + '@nestjs/testing@11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@nestjs/platform-express@11.1.27)': + dependencies: + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27) + + '@nestjs/throttler@6.5.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + + '@noble/curves@2.2.0': + dependencies: + '@noble/hashes': 2.2.0 + + '@noble/hashes@1.8.0': {} + + '@noble/hashes@2.2.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oozcitak/dom@2.0.2': + dependencies: + '@oozcitak/infra': 2.0.2 + '@oozcitak/url': 3.0.0 + '@oozcitak/util': 10.0.0 + + '@oozcitak/infra@2.0.2': + dependencies: + '@oozcitak/util': 10.0.0 + + '@oozcitak/url@3.0.0': + dependencies: + '@oozcitak/infra': 2.0.2 + '@oozcitak/util': 10.0.0 + + '@oozcitak/util@10.0.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.4 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + optional: true + + '@peculiar/asn1-schema@2.6.0': + dependencies: + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/json-schema@1.1.12': + dependencies: + tslib: 2.8.1 + + '@peculiar/webcrypto@1.5.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/json-schema': 1.1.12 + pvtsutils: 1.3.6 + tslib: 2.8.1 + webcrypto-core: 1.8.1 + + '@phc/format@1.0.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.3.6': {} + + '@polka/url@1.0.0-next.29': {} + + '@popperjs/core@2.11.8': {} + + '@posthog/core@1.35.3': + dependencies: + '@posthog/types': 1.390.2 + + '@posthog/types@1.390.2': {} + + '@prisma/adapter-pg@7.8.0': + dependencies: + '@prisma/driver-adapter-utils': 7.8.0 + '@types/pg': 8.20.0 + pg: 8.22.0 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native + + '@prisma/client-runtime-utils@7.8.0': {} + + '@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@prisma/client-runtime-utils': 7.8.0 + optionalDependencies: + prisma: 7.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@7.8.0': + dependencies: + c12: 3.3.4 + deepmerge-ts: 7.1.5 + effect: 3.20.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@7.2.0': {} + + '@prisma/debug@7.8.0': {} + + '@prisma/dev@0.24.3(typescript@5.9.3)': + dependencies: + '@electric-sql/pglite': 0.4.1 + '@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1) + '@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1) + '@hono/node-server': 1.19.11(hono@4.12.25) + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + '@prisma/streams-local': 0.1.2 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + hono: 4.12.25 + http-status-codes: 2.3.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.2.0(typescript@5.9.3) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + + '@prisma/driver-adapter-utils@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + + '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': {} + + '@prisma/engines@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + '@prisma/engines-version': 7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a + '@prisma/fetch-engine': 7.8.0 + '@prisma/get-platform': 7.8.0 + + '@prisma/fetch-engine@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + '@prisma/engines-version': 7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a + '@prisma/get-platform': 7.8.0 + + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/get-platform@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + + '@prisma/query-plan-executor@7.2.0': {} + + '@prisma/streams-local@0.1.2': + dependencies: + ajv: 8.20.0 + better-result: 2.8.2 + env-paths: 3.0.0 + proper-lockfile: 4.1.2 + + '@prisma/studio-core@0.27.3(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/react': 19.2.14 + chart.js: 4.5.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/react-dom' + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-primitive@2.1.3(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-toggle@1.1.10(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@relmify/jest-fp-ts@2.1.1(fp-ts@2.16.11)(io-ts@2.2.22(fp-ts@2.16.11))': + dependencies: + '@jest/expect-utils': 29.7.0 + expect: 29.7.0 + fp-ts: 2.16.11 + io-ts: 2.2.22(fp-ts@2.16.11) + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + + '@repeaterjs/repeater@3.0.4': {} + + '@repeaterjs/repeater@3.0.6': {} + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/plugin-babel@5.3.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@2.80.0)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@rollup/pluginutils': 3.1.0(rollup@2.80.0) + rollup: 2.80.0 + optionalDependencies: + '@types/babel__core': 7.20.5 + transitivePeerDependencies: + - supports-color + + '@rollup/plugin-inject@5.0.5(rollup@4.60.2)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.2) + estree-walker: 2.0.2 + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.60.2 + + '@rollup/plugin-node-resolve@15.3.1(rollup@2.80.0)': + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.12 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/plugin-replace@2.4.2(rollup@2.80.0)': + dependencies: + '@rollup/pluginutils': 3.1.0(rollup@2.80.0) + magic-string: 0.25.9 + rollup: 2.80.0 + + '@rollup/plugin-terser@0.4.4(rollup@2.80.0)': + dependencies: + serialize-javascript: 7.0.3 + smob: 1.6.2 + terser: 5.46.1 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/plugin-typescript@11.1.6(rollup@4.59.0)(tslib@2.8.1)(typescript@5.9.2)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + resolve: 1.22.11 + typescript: 5.9.2 + optionalDependencies: + rollup: 4.59.0 + tslib: 2.8.1 + + '@rollup/plugin-typescript@12.1.4(rollup@4.59.0)(tslib@2.8.1)(typescript@5.9.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + resolve: 1.22.11 + typescript: 5.9.3 + optionalDependencies: + rollup: 4.59.0 + tslib: 2.8.1 + + '@rollup/plugin-typescript@12.3.0(rollup@4.59.0)(tslib@2.8.1)(typescript@5.9.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + resolve: 1.22.11 + typescript: 5.9.3 + optionalDependencies: + rollup: 4.59.0 + tslib: 2.8.1 + + '@rollup/pluginutils@3.1.0(rollup@2.80.0)': + dependencies: + '@types/estree': 0.0.39 + estree-walker: 1.0.1 + picomatch: 2.3.2 + rollup: 2.80.0 + + '@rollup/pluginutils@4.2.1': + dependencies: + estree-walker: 2.0.2 + picomatch: 2.3.2 + + '@rollup/pluginutils@5.3.0(rollup@4.59.0)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.59.0 + + '@rollup/pluginutils@5.3.0(rollup@4.60.2)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.2 + + '@rollup/pluginutils@5.4.0(rollup@2.80.0)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/pluginutils@5.4.0(rollup@4.60.2)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.2 + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.2': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.2': + optional: true + + '@rushstack/eslint-patch@1.16.1': {} + + '@scarf/scarf@1.4.0': {} + + '@scure/base@2.2.0': {} + + '@selderee/plugin-htmlparser2@0.11.0': + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + optional: true + + '@shopify/lang-jsonc@1.0.1': + dependencies: + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 + + '@simple-libs/child-process-utils@1.0.2': + dependencies: + '@simple-libs/stream-utils': 1.2.0 + + '@simple-libs/stream-utils@1.2.0': {} + + '@sinclair/typebox@0.27.10': {} + + '@sinclair/typebox@0.34.48': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@15.4.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@socket.io/component-emitter@3.1.2': {} + + '@standard-schema/spec@1.1.0': {} + + '@surma/rollup-plugin-off-main-thread@2.2.3': + dependencies: + ejs: 3.1.10 + json5: 2.2.3 + magic-string: 0.25.9 + string.prototype.matchall: 4.0.12 + + '@sveltejs/vite-plugin-svelte@1.4.0(svelte@3.59.2)(vite@3.2.11(@types/node@25.9.3)(sass@1.101.0)(terser@5.46.1))': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.26.7 + svelte: 3.59.2 + svelte-hmr: 0.15.3(svelte@3.59.2) + vite: 3.2.11(@types/node@25.9.3)(sass@1.101.0)(terser@5.46.1) + vitefu: 0.2.5(vite@3.2.11(@types/node@25.9.3)(sass@1.101.0)(terser@5.46.1)) + transitivePeerDependencies: + - supports-color + + '@tauri-apps/api@2.1.1': {} + + '@tauri-apps/api@2.9.1': {} + + '@tauri-apps/cli-darwin-arm64@2.9.3': + optional: true + + '@tauri-apps/cli-darwin-x64@2.9.3': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.9.3': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.9.3': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.9.3': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.9.3': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.9.3': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.9.3': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.9.3': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.9.3': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.9.3': + optional: true + + '@tauri-apps/cli@2.9.3': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.9.3 + '@tauri-apps/cli-darwin-x64': 2.9.3 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.9.3 + '@tauri-apps/cli-linux-arm64-gnu': 2.9.3 + '@tauri-apps/cli-linux-arm64-musl': 2.9.3 + '@tauri-apps/cli-linux-riscv64-gnu': 2.9.3 + '@tauri-apps/cli-linux-x64-gnu': 2.9.3 + '@tauri-apps/cli-linux-x64-musl': 2.9.3 + '@tauri-apps/cli-win32-arm64-msvc': 2.9.3 + '@tauri-apps/cli-win32-ia32-msvc': 2.9.3 + '@tauri-apps/cli-win32-x64-msvc': 2.9.3 + + '@tauri-apps/plugin-dialog@2.0.1': + dependencies: + '@tauri-apps/api': 2.9.1 + + '@tauri-apps/plugin-fs@2.0.2': + dependencies: + '@tauri-apps/api': 2.9.1 + + '@tauri-apps/plugin-process@2.2.0': + dependencies: + '@tauri-apps/api': 2.9.1 + + '@tauri-apps/plugin-shell@2.3.3': + dependencies: + '@tauri-apps/api': 2.9.1 + + '@tauri-apps/plugin-store@2.4.1': + dependencies: + '@tauri-apps/api': 2.9.1 + + '@tauri-apps/plugin-updater@2.9.0': + dependencies: + '@tauri-apps/api': 2.9.1 + + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@tsconfig/node10@1.0.12': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/bcrypt@6.0.0': + dependencies: + '@types/node': 25.9.3 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 25.9.3 + + '@types/caseless@0.12.5': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/component-emitter@1.2.14': {} + + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.9.3 + + '@types/cookie-parser@1.4.10(@types/express@5.0.6)': + dependencies: + '@types/express': 5.0.6 + + '@types/cookiejar@2.1.5': {} + + '@types/cors@2.8.19': + dependencies: + '@types/node': 25.9.3 + + '@types/crypto-js@4.2.2': {} + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/ejs@3.1.5': + optional: true + + '@types/eslint-scope@3.7.7': + dependencies: + '@types/eslint': 9.6.1 + '@types/estree': 1.0.8 + + '@types/eslint@8.56.12': + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + + '@types/esrecurse@4.3.1': {} + + '@types/estree@0.0.39': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 25.9.3 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + + '@types/har-format@1.2.16': {} + + '@types/hawk@9.0.7': + dependencies: + '@hapi/boom': 9.1.4 + '@types/crypto-js': 4.2.2 + '@types/node': 25.9.3 + '@types/request': 2.48.13 + + '@types/http-errors@2.0.5': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@30.0.0': + dependencies: + expect: 30.3.0 + pretty-format: 30.3.0 + + '@types/js-yaml@4.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/json-schema@7.0.9': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 25.9.3 + + '@types/linkify-it@5.0.0': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.24 + + '@types/lodash@4.17.24': {} + + '@types/long@4.0.2': {} + + '@types/luxon@3.7.1': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + + '@types/methods@1.1.4': {} + + '@types/mjml-core@4.15.2': + optional: true + + '@types/mjml@4.7.4': + dependencies: + '@types/mjml-core': 4.15.2 + optional: true + + '@types/ms@2.1.0': {} + + '@types/node@17.0.45': {} + + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + + '@types/node@24.9.1': + dependencies: + undici-types: 7.16.0 + + '@types/node@25.9.3': + dependencies: + undici-types: 7.24.6 + + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 25.9.3 + + '@types/nprogress@0.2.3': {} + + '@types/oauth@0.9.6': + dependencies: + '@types/node': 25.9.3 + + '@types/paho-mqtt@1.0.10': {} + + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 25.9.3 + + '@types/passport-github2@1.2.9': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + '@types/passport-oauth2': 1.8.0 + + '@types/passport-google-oauth20@2.0.17': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + '@types/passport-oauth2': 1.8.0 + + '@types/passport-jwt@4.0.1': + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/passport-strategy': 0.2.38 + + '@types/passport-microsoft@2.1.1': + dependencies: + '@types/passport-oauth2': 1.8.0 + + '@types/passport-oauth2@1.8.0': + dependencies: + '@types/express': 5.0.6 + '@types/oauth': 0.9.6 + '@types/passport': 1.0.17 + + '@types/passport-strategy@0.2.38': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 5.0.6 + + '@types/pg@8.20.0': + dependencies: + '@types/node': 25.9.3 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + + '@types/postman-collection@3.5.11': + dependencies: + '@types/node': 25.9.3 + + '@types/pug@2.0.10': + optional: true + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/relateurl@0.2.33': + optional: true + + '@types/request@2.48.13': + dependencies: + '@types/caseless': 0.12.5 + '@types/node': 25.9.3 + '@types/tough-cookie': 4.0.5 + form-data: 2.5.6 + + '@types/resolve@1.20.2': {} + + '@types/sax@1.2.7': + dependencies: + '@types/node': 25.9.3 + + '@types/send@1.2.1': + dependencies: + '@types/node': 25.9.3 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 25.9.3 + + '@types/splitpanes@2.2.6(typescript@5.9.3)': + dependencies: + vue: 3.5.38(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@types/stack-utils@2.0.3': {} + + '@types/strip-bom@3.0.0': {} + + '@types/strip-json-comments@0.0.30': {} + + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 25.9.3 + form-data: 4.0.6 + + '@types/supertest@7.2.0': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + + '@types/tough-cookie@4.0.5': {} + + '@types/trusted-types@2.0.7': {} + + '@types/validator@13.15.10': {} + + '@types/web-bluetooth@0.0.14': {} + + '@types/web-bluetooth@0.0.21': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.9.3 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.61.1(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/type-utils': 8.61.1(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.1 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/type-utils': 8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.1 + eslint: 9.39.2(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.1 + debug: 4.4.3(supports-color@8.1.1) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.1 + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.61.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3(supports-color@8.1.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.61.1': + dependencies: + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 + + '@typescript-eslint/tsconfig-utils@8.61.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.61.1(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3(supports-color@8.1.1) + eslint: 10.5.0(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.2(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.61.1': {} + + '@typescript-eslint/typescript-estree@8.61.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.61.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 + debug: 4.4.3(supports-color@8.1.1) + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.61.1': + dependencies: + '@typescript-eslint/types': 8.61.1 + eslint-visitor-keys: 5.0.1 + + '@ungap/structured-clone@1.3.0': {} + + '@unhead/vue@2.1.12(vue@3.5.38(typescript@5.9.3))': + dependencies: + hookable: 6.1.0 + unhead: 2.1.12 + vue: 3.5.38(typescript@5.9.3) + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + '@urql/core@6.0.2(graphql@16.13.2)': + dependencies: + '@0no-co/graphql.web': 1.3.2(graphql@16.13.2) + wonka: 6.3.6 + transitivePeerDependencies: + - graphql + + '@urql/core@6.0.2(graphql@16.14.0)': + dependencies: + '@0no-co/graphql.web': 1.3.2(graphql@16.14.0) + wonka: 6.3.6 + transitivePeerDependencies: + - graphql + + '@urql/devtools@2.0.3(@urql/core@6.0.2(graphql@16.13.2))(graphql@16.13.2)': + dependencies: + '@urql/core': 6.0.2(graphql@16.13.2) + graphql: 16.13.2 + wonka: 6.3.6 + + '@urql/exchange-auth@3.0.0(@urql/core@6.0.2(graphql@16.13.2))': + dependencies: + '@urql/core': 6.0.2(graphql@16.13.2) + wonka: 6.3.6 + + '@urql/exchange-graphcache@7.2.4(@urql/core@6.0.2(graphql@16.13.2))(graphql@16.13.2)': + dependencies: + '@0no-co/graphql.web': 1.3.2(graphql@16.13.2) + '@urql/core': 6.0.2(graphql@16.13.2) + wonka: 6.3.6 + transitivePeerDependencies: + - graphql + + '@urql/exchange-graphcache@7.2.4(@urql/core@6.0.2(graphql@16.14.0))(graphql@16.14.0)': + dependencies: + '@0no-co/graphql.web': 1.3.2(graphql@16.14.0) + '@urql/core': 6.0.2(graphql@16.14.0) + wonka: 6.3.6 + transitivePeerDependencies: + - graphql + + '@urql/introspection@0.3.3(graphql@16.13.2)': + dependencies: + graphql: 16.13.2 + + '@urql/introspection@0.3.3(graphql@16.14.0)': + dependencies: + graphql: 16.14.0 + + '@urql/vue@2.1.1(@urql/core@6.0.2(graphql@16.13.2))(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@urql/core': 6.0.2(graphql@16.13.2) + vue: 3.5.38(typescript@5.9.3) + wonka: 6.3.6 + + '@vitejs/plugin-legacy@2.3.1(terser@5.46.1)(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))': + dependencies: + '@babel/standalone': 7.29.2 + core-js: 3.49.0 + magic-string: 0.26.7 + regenerator-runtime: 0.13.11 + systemjs: 6.15.1 + terser: 5.46.1 + vite: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + '@vitejs/plugin-legacy@2.3.1(terser@5.46.1)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))': + dependencies: + '@babel/standalone': 7.29.2 + core-js: 3.49.0 + magic-string: 0.26.7 + regenerator-runtime: 0.13.11 + systemjs: 6.15.1 + terser: 5.46.1 + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + '@vitejs/plugin-legacy@7.2.1(terser@5.46.1)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-systemjs': 7.29.0(@babel/core@7.29.0) + '@babel/preset-env': 7.29.2(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) + browserslist: 4.28.1 + browserslist-to-esbuild: 2.1.1(browserslist@4.28.1) + core-js: 3.49.0 + magic-string: 0.30.21 + regenerator-runtime: 0.14.1 + systemjs: 6.15.1 + terser: 5.46.1 + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + + '@vitejs/plugin-vue@6.0.7(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + vue: 3.5.38(typescript@5.9.3) + + '@vitejs/plugin-vue@6.0.7(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + vue: 3.5.38(typescript@5.9.3) + + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + '@vitest/mocker@4.1.9(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@volar/language-core@1.10.10': + dependencies: + '@volar/source-map': 1.10.10 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@1.10.10': + dependencies: + muggle-string: 0.3.1 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@1.10.10': + dependencies: + '@volar/language-core': 1.10.10 + path-browserify: 1.0.1 + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.38': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.38 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.38': + dependencies: + '@vue/compiler-core': 3.5.38 + '@vue/shared': 3.5.38 + + '@vue/compiler-sfc@3.5.38': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.38 + '@vue/compiler-dom': 3.5.38 + '@vue/compiler-ssr': 3.5.38 + '@vue/shared': 3.5.38 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.38': + dependencies: + '@vue/compiler-dom': 3.5.38 + '@vue/shared': 3.5.38 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.4': {} + + '@vue/eslint-config-typescript@14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.4.1(eslint@9.39.2(jiti@2.6.1))))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/utils': 8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + eslint-plugin-vue: 10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.4.1(eslint@9.39.2(jiti@2.6.1))) + fast-glob: 3.3.3 + typescript-eslint: 8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + vue-eslint-parser: 10.4.1(eslint@9.39.2(jiti@2.6.1)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@vue/language-core@1.8.8(typescript@5.9.3)': + dependencies: + '@volar/language-core': 1.10.10 + '@volar/source-map': 1.10.10 + '@vue/compiler-dom': 3.5.38 + '@vue/reactivity': 3.5.38 + '@vue/shared': 3.5.38 + minimatch: 9.0.9 + muggle-string: 0.3.1 + vue-template-compiler: 2.7.16 + optionalDependencies: + typescript: 5.9.3 + + '@vue/language-core@2.1.6(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.38 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.38 + computeds: 0.0.1 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/language-core@2.2.0(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.38 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.38 + alien-signals: 0.4.14 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.38': + dependencies: + '@vue/shared': 3.5.38 + + '@vue/runtime-core@3.5.38': + dependencies: + '@vue/reactivity': 3.5.38 + '@vue/shared': 3.5.38 + + '@vue/runtime-dom@3.5.38': + dependencies: + '@vue/reactivity': 3.5.38 + '@vue/runtime-core': 3.5.38 + '@vue/shared': 3.5.38 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.38(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.38 + '@vue/shared': 3.5.38 + vue: 3.5.38(typescript@5.9.3) + + '@vue/shared@3.5.38': {} + + '@vue/typescript@1.8.8(typescript@5.9.3)': + dependencies: + '@volar/typescript': 1.10.10 + '@vue/language-core': 1.8.8(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@vueuse/core@14.3.0(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.3.0 + '@vueuse/shared': 14.3.0(vue@3.5.38(typescript@5.9.3)) + vue: 3.5.38(typescript@5.9.3) + + '@vueuse/core@8.9.4(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.14 + '@vueuse/metadata': 8.9.4 + '@vueuse/shared': 8.9.4(vue@3.5.38(typescript@5.9.3)) + vue-demi: 0.14.10(vue@3.5.38(typescript@5.9.3)) + optionalDependencies: + vue: 3.5.38(typescript@5.9.3) + + '@vueuse/metadata@14.3.0': {} + + '@vueuse/metadata@8.9.4': {} + + '@vueuse/shared@14.3.0(vue@3.5.38(typescript@5.9.3))': + dependencies: + vue: 3.5.38(typescript@5.9.3) + + '@vueuse/shared@8.9.4(vue@3.5.38(typescript@5.9.3))': + dependencies: + vue-demi: 0.14.10(vue@3.5.38(typescript@5.9.3)) + optionalDependencies: + vue: 3.5.38(typescript@5.9.3) + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@whatwg-node/disposablestack@0.0.6': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/events@0.0.3': {} + + '@whatwg-node/fetch@0.10.13': + dependencies: + '@whatwg-node/node-fetch': 0.8.5 + urlpattern-polyfill: 10.1.0 + + '@whatwg-node/fetch@0.8.8': + dependencies: + '@peculiar/webcrypto': 1.5.0 + '@whatwg-node/node-fetch': 0.3.6 + busboy: 1.6.0 + urlpattern-polyfill: 8.0.2 + web-streams-polyfill: 3.3.3 + + '@whatwg-node/node-fetch@0.3.6': + dependencies: + '@whatwg-node/events': 0.0.3 + busboy: 1.6.0 + fast-querystring: 1.1.2 + fast-url-parser: 1.1.3 + tslib: 2.8.1 + + '@whatwg-node/node-fetch@0.8.5': + dependencies: + '@fastify/busboy': 3.2.0 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/promise-helpers@1.3.2': + dependencies: + tslib: 2.8.1 + + '@xmldom/xmldom@0.8.13': {} + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + '@zone-eu/mailsplit@5.4.8': + dependencies: + libbase64: 1.3.0 + libmime: 5.3.7 + libqp: 2.1.1 + optional: true + + a-sync-waterfall@1.0.1: + optional: true + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-import-phases@1.0.4(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn-loose@6.1.0: + dependencies: + acorn: 6.4.2 + + acorn-walk@6.2.0: {} + + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + + acorn@6.4.2: {} + + acorn@7.4.1: + optional: true + + acorn@8.16.0: {} + + acorn@8.17.0: {} + + after@0.8.2: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + ajv-draft-04@1.0.0(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv-keywords@3.5.2(ajv@6.14.0): + dependencies: + ajv: 6.14.0 + + ajv-keywords@5.1.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + fast-deep-equal: 3.1.3 + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + alce@1.2.0: + dependencies: + esprima: 1.2.5 + estraverse: 1.9.3 + optional: true + + alien-signals@0.4.14: {} + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + ansis@4.2.0: {} + + ansis@4.3.1: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + apiconnect-wsdl@2.0.36(openapi-types@12.1.3): + dependencies: + '@xmldom/xmldom': 0.8.13 + iconv-lite: 0.6.3 + js-yaml: 4.2.0 + jszip: 3.10.1 + lodash: 4.18.1 + oas-validator: 5.0.8 + swagger-parser: 10.0.3(openapi-types@12.1.3) + swagger2openapi: 7.0.8 + xml2js: 0.5.0 + yauzl: 2.10.0 + transitivePeerDependencies: + - encoding + - openapi-types + + append-field@1.0.0: {} + + arg@4.1.3: {} + + arg@5.0.2: {} + + argon2@0.44.0: + dependencies: + '@phc/format': 1.0.0 + cross-env: 10.1.0 + node-addon-api: 8.7.0 + node-gyp-build: 4.8.4 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-ify@1.0.0: {} + + array-timsort@1.0.3: {} + + array-union@2.1.0: {} + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + arraybuffer.slice@0.0.7: {} + + asap@2.0.6: {} + + asn1js@3.0.7: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + + assert-never@1.4.0: + optional: true + + assertion-error@2.0.1: {} + + async-function@1.0.0: {} + + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + auto-bind@4.0.0: {} + + autoprefixer@10.5.0(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001791 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + aws-ssl-profiles@1.1.2: {} + + aws4fetch@1.0.20: {} + + axios-cookiejar-support@6.0.5(axios@1.18.0)(tough-cookie@6.0.1): + dependencies: + axios: 1.18.0 + http-cookie-agent: 7.0.3(tough-cookie@6.0.1) + tough-cookie: 6.0.1 + transitivePeerDependencies: + - undici + + axios@1.18.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + babel-jest@30.4.1(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/transform': 30.4.1 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 7.0.1 + babel-preset-jest: 30.4.0(@babel/core@7.29.0) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@7.0.1: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 6.0.3 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@30.4.0: + dependencies: + '@types/babel__core': 7.20.5 + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0): + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + babel-plugin-syntax-trailing-function-commas@7.0.0-beta.0: {} + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + + babel-preset-fbjs@3.4.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.29.0) + '@babel/plugin-proposal-object-rest-spread': 7.20.7(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) + babel-plugin-syntax-trailing-function-commas: 7.0.0-beta.0 + transitivePeerDependencies: + - supports-color + + babel-preset-jest@30.4.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 30.4.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + + babel-walk@3.0.0-canary-5: + dependencies: + '@babel/types': 7.29.7 + optional: true + + backo2@1.0.2: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-arraybuffer@0.1.4: {} + + base64-js@1.5.1: {} + + base64url@3.0.1: {} + + baseline-browser-mapping@2.10.11: {} + + baseline-browser-mapping@2.10.23: {} + + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + + bcrypt@6.0.0: + dependencies: + node-addon-api: 8.7.0 + node-gyp-build: 4.8.4 + + better-result@2.8.2: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + binary-extensions@2.3.0: {} + + birpc@4.0.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + blob@0.0.5: {} + + body-parser@2.2.1: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3(supports-color@8.1.1) + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + boolbase@1.0.0: {} + + boxen@5.1.2: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browser-or-node@3.0.0: {} + + browser-stdout@1.3.1: {} + + browserslist-to-esbuild@2.1.1(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + meow: 13.2.0 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.11 + caniuse-lite: 1.0.30001781 + electron-to-chromium: 1.5.325 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.23 + caniuse-lite: 1.0.30001791 + electron-to-chromium: 1.5.344 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-crc32@0.2.13: {} + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bundle-require@5.1.0(esbuild@0.27.4): + dependencies: + esbuild: 0.27.4 + load-tsconfig: 0.2.5 + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.0.8 + giget: 3.2.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + rc9: 3.0.1 + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + call-me-maybe@1.0.2: {} + + callsites@3.1.0: {} + + camel-case@4.1.2: + dependencies: + pascal-case: 3.1.2 + tslib: 2.8.1 + + camelcase-css@2.0.1: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-api@3.0.0: + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001791 + lodash.memoize: 4.1.2 + lodash.uniq: 4.5.0 + optional: true + + caniuse-lite@1.0.30001781: {} + + caniuse-lite@1.0.30001791: {} + + capital-case@1.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + upper-case-first: 2.0.2 + + chai@6.2.2: {} + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + optional: true + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + change-case-all@1.0.15: + dependencies: + change-case: 4.1.2 + is-lower-case: 2.0.2 + is-upper-case: 2.0.2 + lower-case: 2.0.2 + lower-case-first: 2.0.2 + sponge-case: 1.0.1 + swap-case: 2.0.2 + title-case: 3.0.3 + upper-case: 2.0.2 + upper-case-first: 2.0.2 + + change-case@4.1.2: + dependencies: + camel-case: 4.1.2 + capital-case: 1.0.4 + constant-case: 3.0.4 + dot-case: 3.0.4 + header-case: 2.0.4 + no-case: 3.0.4 + param-case: 3.0.4 + pascal-case: 3.1.2 + path-case: 3.0.4 + sentence-case: 3.0.4 + snake-case: 3.0.4 + tslib: 2.8.1 + + char-regex@1.0.2: {} + + character-parser@2.2.0: + dependencies: + is-regex: 1.2.1 + optional: true + + chardet@2.1.1: {} + + charset@1.0.1: {} + + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + + check-disk-space@3.4.0: {} + + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + optional: true + + cheerio@1.0.0-rc.12: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + htmlparser2: 8.0.2 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + optional: true + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chrome-trace-event@1.0.4: {} + + ci-info@3.9.0: {} + + ci-info@4.4.0: {} + + cjs-module-lexer@2.2.0: {} + + class-transformer@0.5.1: {} + + class-validator@0.15.1: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.12.40 + validator: 13.15.26 + + cli-boxes@2.2.1: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.0 + + cli-width@4.1.0: {} + + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + cluster-key-slot@1.1.2: + optional: true + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + collection-utils@1.0.1: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + colord@2.9.3: + optional: true + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@10.0.1: + optional: true + + commander@11.1.0: + optional: true + + commander@13.1.0: {} + + commander@14.0.3: {} + + commander@2.20.3: {} + + commander@4.1.1: {} + + commander@5.1.0: + optional: true + + commander@6.2.1: + optional: true + + commander@7.2.0: {} + + commander@9.5.0: + optional: true + + comment-json@5.0.0: + dependencies: + array-timsort: 1.0.3 + esprima: 4.0.1 + + common-tags@1.8.2: {} + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + component-bind@1.0.0: {} + + component-emitter@1.3.1: {} + + component-inherit@0.0.3: {} + + computeds@0.0.1: {} + + concat-map@0.0.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + consola@3.4.2: {} + + constant-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + upper-case: 2.0.2 + + constantinople@4.0.1: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + optional: true + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + conventional-changelog-angular@8.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@9.3.0: + dependencies: + compare-func: 2.0.0 + + conventional-commits-parser@6.3.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 + + conventional-commits-parser@6.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 + + convert-source-map@2.0.0: {} + + cookie-es@2.0.0: {} + + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + cookiejar@2.1.4: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.1 + + core-js@3.49.0: {} + + core-util-is@1.0.3: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + corser@2.0.1: {} + + cosmiconfig-typescript-loader@6.3.0(@types/node@24.10.1)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): + dependencies: + '@types/node': 24.10.1 + cosmiconfig: 9.0.1(typescript@5.9.3) + jiti: 2.6.1 + typescript: 5.9.3 + + cosmiconfig@8.0.0: + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.2.0 + parse-json: 5.2.0 + path-type: 4.0.0 + + cosmiconfig@8.3.6(typescript@5.9.3): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.2.0 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.9.3 + + cosmiconfig@9.0.1(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.2.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + create-require@1.1.1: {} + + crelt@1.0.6: {} + + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-fetch@4.1.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-inspect@1.0.1: + dependencies: + tslib: 2.8.1 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypto-random-string@2.0.0: {} + + css-declaration-sorter@7.3.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + optional: true + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + optional: true + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + optional: true + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@6.2.2: + optional: true + + cssesc@3.0.0: {} + + cssfilter@0.0.10: {} + + cssnano-preset-default@7.0.11(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + css-declaration-sorter: 7.3.1(postcss@8.5.15) + cssnano-utils: 5.0.1(postcss@8.5.15) + postcss: 8.5.15 + postcss-calc: 10.1.1(postcss@8.5.15) + postcss-colormin: 7.0.6(postcss@8.5.15) + postcss-convert-values: 7.0.9(postcss@8.5.15) + postcss-discard-comments: 7.0.6(postcss@8.5.15) + postcss-discard-duplicates: 7.0.2(postcss@8.5.15) + postcss-discard-empty: 7.0.1(postcss@8.5.15) + postcss-discard-overridden: 7.0.1(postcss@8.5.15) + postcss-merge-longhand: 7.0.5(postcss@8.5.15) + postcss-merge-rules: 7.0.8(postcss@8.5.15) + postcss-minify-font-values: 7.0.1(postcss@8.5.15) + postcss-minify-gradients: 7.0.1(postcss@8.5.15) + postcss-minify-params: 7.0.6(postcss@8.5.15) + postcss-minify-selectors: 7.0.6(postcss@8.5.15) + postcss-normalize-charset: 7.0.1(postcss@8.5.15) + postcss-normalize-display-values: 7.0.1(postcss@8.5.15) + postcss-normalize-positions: 7.0.1(postcss@8.5.15) + postcss-normalize-repeat-style: 7.0.1(postcss@8.5.15) + postcss-normalize-string: 7.0.1(postcss@8.5.15) + postcss-normalize-timing-functions: 7.0.1(postcss@8.5.15) + postcss-normalize-unicode: 7.0.6(postcss@8.5.15) + postcss-normalize-url: 7.0.1(postcss@8.5.15) + postcss-normalize-whitespace: 7.0.1(postcss@8.5.15) + postcss-ordered-values: 7.0.2(postcss@8.5.15) + postcss-reduce-initial: 7.0.6(postcss@8.5.15) + postcss-reduce-transforms: 7.0.1(postcss@8.5.15) + postcss-svgo: 7.1.1(postcss@8.5.15) + postcss-unique-selectors: 7.0.5(postcss@8.5.15) + optional: true + + cssnano-utils@5.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + optional: true + + cssnano@7.1.3(postcss@8.5.15): + dependencies: + cssnano-preset-default: 7.0.11(postcss@8.5.15) + lilconfig: 3.1.3 + postcss: 8.5.15 + optional: true + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + optional: true + + cssstyle@5.3.7: + dependencies: + '@asamuzakjp/css-color': 4.1.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1) + css-tree: 3.2.1 + lru-cache: 11.2.7 + + csstype@3.2.3: {} + + data-uri-to-buffer@4.0.1: {} + + data-urls@6.0.1: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 15.1.0 + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + dataloader@2.2.3: {} + + date-fns@4.4.0: {} + + de-indent@1.0.2: {} + + debounce@2.2.0: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@3.1.0: + dependencies: + ms: 2.0.0 + + debug@4.3.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decamelize@1.2.0: {} + + decamelize@4.0.0: {} + + decimal.js@10.6.0: {} + + dedent@1.7.2: {} + + deep-extend@0.6.0: + optional: true + + deep-is@0.1.4: {} + + deepmerge-ts@7.1.5: {} + + deepmerge@4.3.1: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-lazy-prop@3.0.0: {} + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + defu@6.1.7: {} + + delayed-stream@1.0.0: {} + + denque@2.1.0: {} + + depd@2.0.0: {} + + dependency-graph@0.11.0: {} + + dependency-graph@1.0.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-indent@6.1.0: {} + + detect-libc@2.1.2: + optional: true + + detect-newline@3.1.0: {} + + detect-node@2.1.0: + optional: true + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + didyoumean@1.2.2: {} + + diff-sequences@29.6.3: {} + + diff@4.0.4: {} + + diff@7.0.0: {} + + dioc@3.0.2(vue@3.5.38(typescript@5.9.3)): + dependencies: + rxjs: 7.8.2 + optionalDependencies: + vue: 3.5.38(typescript@5.9.3) + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + display-notification@2.0.0: + dependencies: + escape-string-applescript: 1.0.0 + run-applescript: 3.2.0 + optional: true + + dlv@1.1.3: {} + + doctypes@1.1.0: + optional: true + + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + optional: true + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + optional: true + + domelementtype@2.3.0: + optional: true + + domhandler@3.3.0: + dependencies: + domelementtype: 2.3.0 + optional: true + + domhandler@4.3.1: + dependencies: + domelementtype: 2.3.0 + optional: true + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + optional: true + + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dompurify@3.4.11: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + domutils@2.8.0: + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + optional: true + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + optional: true + + dot-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + + dotenv@16.5.0: {} + + dotenv@16.6.1: {} + + dotenv@17.4.1: {} + + dotenv@17.4.2: {} + + dset@3.1.4: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer@0.1.2: {} + + dynamic-dedupe@0.3.0: + dependencies: + xtend: 4.0.2 + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + effect@3.20.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + ejs@5.0.2: + optional: true + + electron-to-chromium@1.5.325: {} + + electron-to-chromium@1.5.344: {} + + emittery@0.13.1: {} + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + empathic@2.0.0: {} + + encodeurl@2.0.0: {} + + encoding-japanese@2.2.0: + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + optional: true + + engine.io-client@3.5.6: + dependencies: + component-emitter: 1.3.1 + component-inherit: 0.0.3 + debug: 3.1.0 + engine.io-parser: 2.2.1 + has-cors: 1.1.0 + indexof: 0.0.1 + parseqs: 0.0.6 + parseuri: 0.0.6 + ws: 8.21.0 + xmlhttprequest-ssl: 1.6.3 + yeast: 0.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-client@4.1.4: + dependencies: + base64-arraybuffer: 0.1.4 + component-emitter: 1.3.1 + debug: 4.3.7 + engine.io-parser: 4.0.3 + has-cors: 1.1.0 + parseqs: 0.0.6 + parseuri: 0.0.6 + ws: 8.21.0 + xmlhttprequest-ssl: 1.6.3 + yeast: 0.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-client@6.6.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@8.1.1) + engine.io-parser: 5.2.3 + ws: 8.21.0 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@2.2.1: + dependencies: + after: 0.8.2 + arraybuffer.slice: 0.0.7 + base64-arraybuffer: 0.1.4 + blob: 0.0.5 + has-binary2: 1.0.3 + + engine.io-parser@4.0.3: + dependencies: + base64-arraybuffer: 0.1.4 + + engine.io-parser@5.2.3: {} + + enhanced-resolve@2.3.0: + dependencies: + graceful-fs: 4.2.11 + memory-fs: 0.3.0 + object-assign: 4.1.1 + tapable: 0.2.9 + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + + entities@2.2.0: + optional: true + + entities@3.0.1: + optional: true + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + env-paths@2.2.1: {} + + env-paths@3.0.0: {} + + environment@1.1.0: {} + + errno@0.1.8: + dependencies: + prr: 1.0.1 + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + error-stack-parser-es@1.0.5: {} + + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.1: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.1 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.22 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.0.0: {} + + es-module-lexer@2.1.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es-to-primitive@1.3.1: + dependencies: + es-abstract-get: 1.0.0 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es6-promise@3.3.1: {} + + esbuild-android-64@0.15.18: + optional: true + + esbuild-android-arm64@0.15.18: + optional: true + + esbuild-darwin-64@0.15.18: + optional: true + + esbuild-darwin-arm64@0.15.18: + optional: true + + esbuild-freebsd-64@0.15.18: + optional: true + + esbuild-freebsd-arm64@0.15.18: + optional: true + + esbuild-linux-32@0.15.18: + optional: true + + esbuild-linux-64@0.15.18: + optional: true + + esbuild-linux-arm64@0.15.18: + optional: true + + esbuild-linux-arm@0.15.18: + optional: true + + esbuild-linux-mips64le@0.15.18: + optional: true + + esbuild-linux-ppc64le@0.15.18: + optional: true + + esbuild-linux-riscv64@0.15.18: + optional: true + + esbuild-linux-s390x@0.15.18: + optional: true + + esbuild-netbsd-64@0.15.18: + optional: true + + esbuild-openbsd-64@0.15.18: + optional: true + + esbuild-sunos-64@0.15.18: + optional: true + + esbuild-windows-32@0.15.18: + optional: true + + esbuild-windows-64@0.15.18: + optional: true + + esbuild-windows-arm64@0.15.18: + optional: true + + esbuild@0.15.18: + optionalDependencies: + '@esbuild/android-arm': 0.15.18 + '@esbuild/linux-loong64': 0.15.18 + esbuild-android-64: 0.15.18 + esbuild-android-arm64: 0.15.18 + esbuild-darwin-64: 0.15.18 + esbuild-darwin-arm64: 0.15.18 + esbuild-freebsd-64: 0.15.18 + esbuild-freebsd-arm64: 0.15.18 + esbuild-linux-32: 0.15.18 + esbuild-linux-64: 0.15.18 + esbuild-linux-arm: 0.15.18 + esbuild-linux-arm64: 0.15.18 + esbuild-linux-mips64le: 0.15.18 + esbuild-linux-ppc64le: 0.15.18 + esbuild-linux-riscv64: 0.15.18 + esbuild-linux-s390x: 0.15.18 + esbuild-netbsd-64: 0.15.18 + esbuild-openbsd-64: 0.15.18 + esbuild-sunos-64: 0.15.18 + esbuild-windows-32: 0.15.18 + esbuild-windows-64: 0.15.18 + esbuild-windows-arm64: 0.15.18 + + esbuild@0.17.19: + optionalDependencies: + '@esbuild/android-arm': 0.17.19 + '@esbuild/android-arm64': 0.17.19 + '@esbuild/android-x64': 0.17.19 + '@esbuild/darwin-arm64': 0.17.19 + '@esbuild/darwin-x64': 0.17.19 + '@esbuild/freebsd-arm64': 0.17.19 + '@esbuild/freebsd-x64': 0.17.19 + '@esbuild/linux-arm': 0.17.19 + '@esbuild/linux-arm64': 0.17.19 + '@esbuild/linux-ia32': 0.17.19 + '@esbuild/linux-loong64': 0.17.19 + '@esbuild/linux-mips64el': 0.17.19 + '@esbuild/linux-ppc64': 0.17.19 + '@esbuild/linux-riscv64': 0.17.19 + '@esbuild/linux-s390x': 0.17.19 + '@esbuild/linux-x64': 0.17.19 + '@esbuild/netbsd-x64': 0.17.19 + '@esbuild/openbsd-x64': 0.17.19 + '@esbuild/sunos-x64': 0.17.19 + '@esbuild/win32-arm64': 0.17.19 + '@esbuild/win32-ia32': 0.17.19 + '@esbuild/win32-x64': 0.17.19 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + escalade@3.2.0: {} + + escape-goat@3.0.0: + optional: true + + escape-html@1.0.3: {} + + escape-string-applescript@1.0.0: + optional: true + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + eslint-config-prettier@10.1.8(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + + eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): + dependencies: + eslint: 9.39.2(jiti@2.6.1) + + eslint-plugin-prettier@5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.5.0(jiti@2.6.1)))(eslint@10.5.0(jiti@2.6.1))(prettier@3.8.4): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + prettier: 3.8.4 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.13 + optionalDependencies: + '@types/eslint': 9.6.1 + eslint-config-prettier: 10.1.8(eslint@10.5.0(jiti@2.6.1)) + + eslint-plugin-prettier@5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.4): + dependencies: + eslint: 9.39.2(jiti@2.6.1) + prettier: 3.8.4 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.13 + optionalDependencies: + '@types/eslint': 9.6.1 + eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + + eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.4.1(eslint@9.39.2(jiti@2.6.1))): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + eslint: 9.39.2(jiti@2.6.1) + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 7.1.4 + semver: 7.8.5 + vue-eslint-parser: 10.4.1(eslint@9.39.2(jiti@2.6.1)) + xml-name-validator: 4.0.0 + optionalDependencies: + '@typescript-eslint/parser': 8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.5.0(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@8.1.1) + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.4 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + eslint@9.39.2(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.2 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@8.1.1) + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + espree@9.6.1: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 3.4.3 + + esprima-extract-comments@1.1.0: + dependencies: + esprima: 4.0.1 + + esprima@1.2.5: + optional: true + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@1.9.3: + optional: true + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + estree-walker@0.6.1: {} + + estree-walker@1.0.1: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + event-stream@4.0.1: + dependencies: + duplexer: 0.1.2 + from: 0.1.7 + map-stream: 0.0.7 + pause-stream: 0.0.11 + split: 1.0.1 + stream-combiner: 0.2.2 + through: 2.3.8 + + event-target-shim@5.0.1: {} + + eventemitter3@3.1.2: {} + + eventemitter3@4.0.7: {} + + eventemitter3@5.0.4: {} + + events@3.3.0: {} + + execa@2.0.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 5.2.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 3.1.0 + p-finally: 2.0.1 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + optional: true + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit-x@0.2.2: {} + + expect-type@1.3.0: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + expect@30.3.0: + dependencies: + '@jest/expect-utils': 30.3.0 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.3.0 + jest-message-util: 30.3.0 + jest-mock: 30.3.0 + jest-util: 30.3.0 + + expect@30.4.1: + dependencies: + '@jest/expect-utils': 30.4.1 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-util: 30.4.1 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.1 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@8.1.1) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + exsolve@1.0.8: {} + + extend-object@1.0.0: + optional: true + + extract-comments@1.1.0: + dependencies: + esprima-extract-comments: 1.1.0 + parse-code-context: 1.0.0 + + extract-files@11.0.0: {} + + faraday-cage@0.1.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.31.0 + '@jitl/quickjs-singlefile-mjs-release-asyncify': 0.31.0 + quickjs-emscripten: 0.31.0 + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.2: {} + + fast-url-parser@1.1.3: + dependencies: + punycode: 1.4.1 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fbjs-css-vars@1.0.2: {} + + fbjs@3.0.5: + dependencies: + cross-fetch: 3.2.0 + fbjs-css-vars: 1.0.2 + loose-envify: 1.4.0 + object-assign: 4.1.1 + promise: 7.3.1 + setimmediate: 1.0.5 + ua-parser-js: 1.0.41 + transitivePeerDependencies: + - encoding + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + file-type@3.9.0: {} + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.59.0 + + fixpack@4.0.0: + dependencies: + alce: 1.2.0 + chalk: 3.0.0 + detect-indent: 6.1.0 + detect-newline: 3.1.0 + extend-object: 1.0.0 + rc: 1.2.8 + optional: true + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flat@5.0.2: {} + + flatted@3.4.2: {} + + follow-redirects@1.16.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.2): + dependencies: + '@babel/code-frame': 7.29.0 + chalk: 4.1.2 + chokidar: 4.0.3 + cosmiconfig: 8.3.6(typescript@5.9.3) + deepmerge: 4.3.1 + fs-extra: 10.1.0 + memfs: 3.5.3 + minimatch: 3.1.5 + node-abort-controller: 3.1.1 + schema-utils: 3.3.0 + semver: 7.8.5 + tapable: 2.3.2 + typescript: 5.9.3 + webpack: 5.106.2 + + form-data@2.5.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + safe-buffer: 5.2.1 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fp-ts@2.16.11: {} + + fraction.js@5.3.4: {} + + fresh@2.0.0: {} + + from@0.1.7: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-monkey@1.1.0: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.4 + is-callable: 1.2.7 + + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.5.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-own-enumerable-property-symbols@3.0.2: {} + + get-package-type@0.1.0: {} + + get-port-please@3.2.0: {} + + get-port@5.1.1: + optional: true + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + optional: true + + get-stream@6.0.1: {} + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + giget@3.2.0: {} + + git-raw-commits@5.0.1(conventional-commits-parser@6.4.0): + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-parser@6.4.0) + meow: 13.2.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + + glob@13.0.6: + dependencies: + minimatch: 10.2.3 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-directory@5.0.0: + dependencies: + ini: 6.0.0 + + globals@14.0.0: {} + + globals@16.5.0: {} + + globals@17.6.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + grammex@3.1.12: {} + + graphmatch@1.1.1: {} + + graphql-config@4.5.0(@types/node@24.10.1)(graphql@16.13.2): + dependencies: + '@graphql-tools/graphql-file-loader': 7.5.17(graphql@16.13.2) + '@graphql-tools/json-file-loader': 7.4.18(graphql@16.13.2) + '@graphql-tools/load': 7.8.14(graphql@16.13.2) + '@graphql-tools/merge': 8.4.2(graphql@16.13.2) + '@graphql-tools/url-loader': 7.17.18(@types/node@24.10.1)(graphql@16.13.2) + '@graphql-tools/utils': 9.2.1(graphql@16.13.2) + cosmiconfig: 8.0.0 + graphql: 16.13.2 + jiti: 1.17.1 + minimatch: 4.2.5 + string-env-interpolation: 1.0.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - encoding + - utf-8-validate + + graphql-config@5.1.6(@types/node@24.10.1)(graphql@16.13.2)(typescript@5.9.3): + dependencies: + '@graphql-tools/graphql-file-loader': 8.1.14(graphql@16.13.2) + '@graphql-tools/json-file-loader': 8.0.28(graphql@16.13.2) + '@graphql-tools/load': 8.1.10(graphql@16.13.2) + '@graphql-tools/merge': 9.1.9(graphql@16.13.2) + '@graphql-tools/url-loader': 9.1.2(@types/node@24.10.1)(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + cosmiconfig: 8.3.6(typescript@5.9.3) + graphql: 16.13.2 + jiti: 2.6.1 + minimatch: 10.2.4 + string-env-interpolation: 1.0.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - crossws + - typescript + - utf-8-validate + + graphql-config@5.1.6(@types/node@25.9.3)(graphql@16.13.2)(typescript@5.9.3): + dependencies: + '@graphql-tools/graphql-file-loader': 8.1.14(graphql@16.13.2) + '@graphql-tools/json-file-loader': 8.0.28(graphql@16.13.2) + '@graphql-tools/load': 8.1.10(graphql@16.13.2) + '@graphql-tools/merge': 9.1.9(graphql@16.13.2) + '@graphql-tools/url-loader': 9.1.2(@types/node@25.9.3)(graphql@16.13.2) + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + cosmiconfig: 8.3.6(typescript@5.9.3) + graphql: 16.13.2 + jiti: 2.6.1 + minimatch: 10.2.4 + string-env-interpolation: 1.0.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - crossws + - typescript + - utf-8-validate + + graphql-config@5.1.6(@types/node@25.9.3)(graphql@16.14.0)(typescript@5.9.3): + dependencies: + '@graphql-tools/graphql-file-loader': 8.1.14(graphql@16.14.0) + '@graphql-tools/json-file-loader': 8.0.28(graphql@16.14.0) + '@graphql-tools/load': 8.1.10(graphql@16.14.0) + '@graphql-tools/merge': 9.1.9(graphql@16.14.0) + '@graphql-tools/url-loader': 9.1.2(@types/node@25.9.3)(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + cosmiconfig: 8.3.6(typescript@5.9.3) + graphql: 16.14.0 + jiti: 2.6.1 + minimatch: 10.2.4 + string-env-interpolation: 1.0.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - crossws + - typescript + - utf-8-validate + + graphql-language-service-interface@2.10.2(@types/node@24.10.1)(graphql@16.13.2): + dependencies: + graphql: 16.13.2 + graphql-config: 4.5.0(@types/node@24.10.1)(graphql@16.13.2) + graphql-language-service-parser: 1.10.4(@types/node@24.10.1)(graphql@16.13.2) + graphql-language-service-types: 1.8.7(@types/node@24.10.1)(graphql@16.13.2) + graphql-language-service-utils: 2.7.1(@types/node@24.10.1)(graphql@16.13.2) + vscode-languageserver-types: 3.17.5 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - cosmiconfig-toml-loader + - encoding + - utf-8-validate + + graphql-language-service-parser@1.10.4(@types/node@24.10.1)(graphql@16.13.2): + dependencies: + graphql: 16.13.2 + graphql-language-service-types: 1.8.7(@types/node@24.10.1)(graphql@16.13.2) + transitivePeerDependencies: + - '@types/node' + - bufferutil + - cosmiconfig-toml-loader + - encoding + - utf-8-validate + + graphql-language-service-types@1.8.7(@types/node@24.10.1)(graphql@16.13.2): + dependencies: + graphql: 16.13.2 + graphql-config: 4.5.0(@types/node@24.10.1)(graphql@16.13.2) + vscode-languageserver-types: 3.17.5 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - cosmiconfig-toml-loader + - encoding + - utf-8-validate + + graphql-language-service-utils@2.7.1(@types/node@24.10.1)(graphql@16.13.2): + dependencies: + '@types/json-schema': 7.0.9 + graphql: 16.13.2 + graphql-language-service-types: 1.8.7(@types/node@24.10.1)(graphql@16.13.2) + nullthrows: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - cosmiconfig-toml-loader + - encoding + - utf-8-validate + + graphql-query-complexity@1.1.1(graphql@16.14.0): + dependencies: + graphql: 16.14.0 + lodash.get: 4.4.2 + + graphql-redis-subscriptions@2.7.0(graphql-subscriptions@3.0.0(graphql@16.14.0)): + dependencies: + graphql-subscriptions: 3.0.0(graphql@16.14.0) + optionalDependencies: + ioredis: 5.10.1 + transitivePeerDependencies: + - supports-color + + graphql-subscriptions@3.0.0(graphql@16.14.0): + dependencies: + graphql: 16.14.0 + + graphql-tag@2.12.6(graphql@16.14.0): + dependencies: + graphql: 16.14.0 + tslib: 2.8.1 + + graphql-tag@2.12.7(graphql@16.13.2): + dependencies: + graphql: 16.13.2 + tslib: 2.8.1 + + graphql-tag@2.12.7(graphql@16.14.0): + dependencies: + graphql: 16.14.0 + tslib: 2.8.1 + + graphql-ws@5.12.1(graphql@16.13.2): + dependencies: + graphql: 16.13.2 + + graphql-ws@6.0.8(graphql@16.13.2)(ws@8.21.0): + dependencies: + graphql: 16.13.2 + optionalDependencies: + ws: 8.21.0 + + graphql-ws@6.0.8(graphql@16.14.0)(ws@8.21.0): + dependencies: + graphql: 16.14.0 + optionalDependencies: + ws: 8.21.0 + + graphql@16.13.2: {} + + graphql@16.14.0: {} + + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + har-validator-compiled@1.0.0: {} + + has-bigints@1.1.0: {} + + has-binary2@1.0.3: + dependencies: + isarray: 2.0.1 + + has-cors@1.1.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hawk@9.0.2: + dependencies: + '@hapi/b64': 5.0.0 + '@hapi/boom': 9.1.4 + '@hapi/cryptiles': 5.1.0 + '@hapi/hoek': 9.3.0 + + he@1.2.0: {} + + header-case@2.0.4: + dependencies: + capital-case: 1.0.4 + tslib: 2.8.1 + + highlight.js@11.11.1: {} + + highlightjs-curl@1.3.0: {} + + hono@4.12.25: {} + + hookable@6.1.0: {} + + hosted-git-info@2.8.9: {} + + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + + html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@2.2.0) + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + html-to-text@9.0.5: + dependencies: + '@selderee/plugin-htmlparser2': 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + optional: true + + htmlnano@2.1.5(cssnano@7.1.3(postcss@8.5.15))(postcss@8.5.15)(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@types/relateurl': 0.2.33 + cosmiconfig: 9.0.1(typescript@5.9.3) + posthtml: 0.16.7 + optionalDependencies: + cssnano: 7.1.3(postcss@8.5.15) + postcss: 8.5.15 + terser: 5.46.1 + transitivePeerDependencies: + - typescript + optional: true + + htmlparser2@5.0.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 3.3.0 + domutils: 2.8.0 + entities: 2.2.0 + optional: true + + htmlparser2@7.2.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + domutils: 2.8.0 + entities: 3.0.1 + optional: true + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + optional: true + + htmlparser2@9.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + optional: true + + http-cookie-agent@7.0.3(tough-cookie@6.0.1): + dependencies: + agent-base: 7.1.4 + tough-cookie: 6.0.1 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + http-proxy@1.18.1: + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.16.0 + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + + http-reasons@0.1.0: {} + + http-server@14.1.1: + dependencies: + basic-auth: 2.0.1 + chalk: 4.1.2 + corser: 2.0.1 + he: 1.2.0 + html-encoding-sniffer: 3.0.0 + http-proxy: 1.18.1 + mime: 1.6.0 + minimist: 1.2.8 + opener: 1.5.2 + portfinder: 1.0.38 + secure-compare: 3.0.1 + union: 0.5.0 + url-join: 4.0.1 + transitivePeerDependencies: + - debug + - supports-color + + http-status-codes@2.3.0: {} + + http2-client@1.3.5: {} + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + husky@9.1.7: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + idb@7.1.1: {} + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immediate@3.0.6: {} + + immutable@3.7.6: {} + + immutable@5.1.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-from@4.0.0: {} + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + import-meta-resolve@4.2.0: {} + + imurmurhash@0.1.4: {} + + indexof@0.0.1: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.3: {} + + inherits@2.0.4: {} + + ini@1.3.8: + optional: true + + ini@6.0.0: {} + + insomnia-importers@3.6.0(openapi-types@12.1.3): + dependencies: + '@apidevtools/swagger-parser': 10.0.2(openapi-types@12.1.3) + apiconnect-wsdl: 2.0.36(openapi-types@12.1.3) + change-case: 4.1.2 + commander: 7.2.0 + ramda: 0.27.2 + ramda-adjunct: 2.36.0(ramda@0.27.2) + shell-quote: 1.8.3 + yaml: 1.10.3 + transitivePeerDependencies: + - encoding + - openapi-types + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + + io-ts@2.2.22(fp-ts@2.16.11): + dependencies: + fp-ts: 2.16.11 + + ioredis@5.10.1: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3(supports-color@8.1.1) + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + optional: true + + ipaddr.js@1.9.1: {} + + is-absolute@1.0.0: + dependencies: + is-relative: 1.0.0 + is-windows: 1.0.2 + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-docker@2.2.1: + optional: true + + is-docker@3.0.0: {} + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + + is-expression@4.0.0: + dependencies: + acorn: 7.4.1 + object-assign: 4.1.1 + optional: true + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.5.0 + + is-generator-fn@2.1.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@1.0.0: {} + + is-json@2.0.1: + optional: true + + is-lower-case@2.0.2: + dependencies: + tslib: 2.8.1 + + is-map@2.0.3: {} + + is-module@1.0.0: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-obj@1.0.1: {} + + is-obj@2.0.0: {} + + is-path-inside@3.0.3: {} + + is-plain-obj@2.1.0: {} + + is-plain-obj@4.1.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-promise@2.2.2: + optional: true + + is-promise@4.0.0: {} + + is-property@1.0.2: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-regexp@1.0.0: {} + + is-relative@1.0.0: + dependencies: + is-unc-path: 1.0.0 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-unc-path@1.0.0: + dependencies: + unc-path-regex: 0.1.2 + + is-unicode-supported@0.1.0: {} + + is-upper-case@2.0.2: + dependencies: + tslib: 2.8.1 + + is-url@1.2.4: {} + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-what@5.5.0: {} + + is-windows@1.0.2: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + optional: true + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isarray@1.0.0: {} + + isarray@2.0.1: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isolated-vm@6.1.2: + dependencies: + node-gyp-build: 4.8.4 + + isomorphic-ws@5.0.0(ws@8.21.0): + dependencies: + ws: 8.21.0 + + isows@1.0.7(ws@8.21.0): + dependencies: + ws: 8.21.0 + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.7 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterall@1.3.0: {} + + iterare@1.2.1: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + jest-changed-files@30.4.1: + dependencies: + execa: 5.1.1 + jest-util: 30.4.1 + p-limit: 3.1.0 + + jest-circus@30.4.2: + dependencies: + '@jest/environment': 30.4.1 + '@jest/expect': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 25.9.3 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 30.4.1 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-runtime: 30.4.2 + jest-snapshot: 30.4.1 + jest-util: 30.4.1 + p-limit: 3.1.0 + pretty-format: 30.4.1 + pure-rand: 7.0.1 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.4.2(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) + jest-util: 30.4.1 + jest-validate: 30.4.1 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jest-config@30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.29.0 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.4.0 + '@jest/test-sequencer': 30.4.1 + '@jest/types': 30.4.1 + babel-jest: 30.4.1(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.4.2 + jest-docblock: 30.4.0 + jest-environment-node: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-runner: 30.4.2 + jest-util: 30.4.1 + jest-validate: 30.4.1 + parse-json: 5.2.0 + pretty-format: 30.4.1 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 25.9.3 + ts-node: 10.9.2(@types/node@25.9.3)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-diff@30.3.0: + dependencies: + '@jest/diff-sequences': 30.3.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.3.0 + + jest-diff@30.4.1: + dependencies: + '@jest/diff-sequences': 30.4.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.4.1 + + jest-docblock@30.4.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.4.1 + chalk: 4.1.2 + jest-util: 30.4.1 + pretty-format: 30.4.1 + + jest-environment-node@30.4.1: + dependencies: + '@jest/environment': 30.4.1 + '@jest/fake-timers': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 25.9.3 + jest-mock: 30.4.1 + jest-util: 30.4.1 + jest-validate: 30.4.1 + + jest-get-type@29.6.3: {} + + jest-haste-map@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 25.9.3 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 30.4.0 + jest-util: 30.4.1 + jest-worker: 30.4.1 + picomatch: 4.0.4 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + pretty-format: 30.4.1 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@30.3.0: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.3.0 + pretty-format: 30.3.0 + + jest-matcher-utils@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.4.1 + pretty-format: 30.4.1 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-message-util@30.3.0: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 30.3.0 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + picomatch: 4.0.4 + pretty-format: 30.3.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-message-util@30.4.1: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 30.4.1 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-util: 30.4.1 + picomatch: 4.0.4 + pretty-format: 30.4.1 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock-extended@4.0.1(@jest/globals@30.4.1)(jest@30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)))(typescript@5.9.3): + dependencies: + '@jest/globals': 30.4.1 + jest: 30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) + lodash.isequal: 4.5.0 + ts-essentials: 10.1.1(typescript@5.9.3) + typescript: 5.9.3 + + jest-mock@30.3.0: + dependencies: + '@jest/types': 30.3.0 + '@types/node': 25.9.3 + jest-util: 30.3.0 + + jest-mock@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 25.9.3 + jest-util: 30.4.1 + + jest-pnp-resolver@1.2.3(jest-resolve@30.4.1): + optionalDependencies: + jest-resolve: 30.4.1 + + jest-regex-util@30.0.1: {} + + jest-regex-util@30.4.0: {} + + jest-resolve-dependencies@30.4.2: + dependencies: + jest-regex-util: 30.4.0 + jest-snapshot: 30.4.1 + transitivePeerDependencies: + - supports-color + + jest-resolve@30.4.1: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + jest-pnp-resolver: 1.2.3(jest-resolve@30.4.1) + jest-util: 30.4.1 + jest-validate: 30.4.1 + slash: 3.0.0 + unrs-resolver: 1.11.1 + + jest-runner@30.4.2: + dependencies: + '@jest/console': 30.4.1 + '@jest/environment': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 25.9.3 + chalk: 4.1.2 + emittery: 0.13.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-docblock: 30.4.0 + jest-environment-node: 30.4.1 + jest-haste-map: 30.4.1 + jest-leak-detector: 30.4.1 + jest-message-util: 30.4.1 + jest-resolve: 30.4.1 + jest-runtime: 30.4.2 + jest-util: 30.4.1 + jest-watcher: 30.4.1 + jest-worker: 30.4.1 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@30.4.2: + dependencies: + '@jest/environment': 30.4.1 + '@jest/fake-timers': 30.4.1 + '@jest/globals': 30.4.1 + '@jest/source-map': 30.0.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 25.9.3 + chalk: 4.1.2 + cjs-module-lexer: 2.2.0 + collect-v8-coverage: 1.0.3 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-snapshot: 30.4.1 + jest-util: 30.4.1 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@30.4.1: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.7 + '@jest/expect-utils': 30.4.1 + '@jest/get-type': 30.1.0 + '@jest/snapshot-utils': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + chalk: 4.1.2 + expect: 30.4.1 + graceful-fs: 4.2.11 + jest-diff: 30.4.1 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-util: 30.4.1 + pretty-format: 30.4.1 + semver: 7.8.5 + synckit: 0.11.13 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 25.9.3 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + jest-util@30.3.0: + dependencies: + '@jest/types': 30.3.0 + '@types/node': 25.9.3 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.4 + + jest-util@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 25.9.3 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.4 + + jest-validate@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.4.1 + camelcase: 6.3.0 + chalk: 4.1.2 + leven: 3.1.0 + pretty-format: 30.4.1 + + jest-watcher@30.4.1: + dependencies: + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 25.9.3 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 30.4.1 + string-length: 4.0.2 + + jest-worker@27.5.1: + dependencies: + '@types/node': 25.9.3 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest-worker@30.4.1: + dependencies: + '@types/node': 25.9.3 + '@ungap/structured-clone': 1.3.0 + jest-util: 30.4.1 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.4.2(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) + '@jest/types': 30.4.1 + import-local: 3.2.0 + jest-cli: 30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jiti@1.17.1: {} + + jiti@1.21.7: {} + + jiti@2.6.1: {} + + jose@6.2.3: {} + + joycon@3.1.1: {} + + jq-wasm@1.1.0-jq-1.8.1: {} + + js-base64@3.7.8: {} + + js-md5@0.8.3: {} + + js-stringify@1.0.2: + optional: true + + js-tokens@4.0.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + jsdom@27.4.0(@noble/hashes@2.2.0): + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@exodus/bytes': 1.15.0(@noble/hashes@2.2.0) + cssstyle: 5.3.7 + data-urls: 6.0.1 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.21.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-better-errors@1.0.2: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json-to-pretty-yaml@1.2.2: + dependencies: + remedial: 1.0.8 + remove-trailing-spaces: 1.0.9 + + json5@2.2.3: {} + + jsonc-eslint-parser@2.4.2: + dependencies: + acorn: 8.17.0 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + semver: 7.8.5 + + jsonc-parser@3.3.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonpointer@5.0.1: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jstransformer@1.0.0: + dependencies: + is-promise: 2.2.2 + promise: 7.3.1 + optional: true + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + juice@10.0.1: + dependencies: + cheerio: 1.0.0-rc.12 + commander: 6.2.1 + mensch: 0.3.4 + slick: 1.12.2 + web-resource-inliner: 6.0.1 + transitivePeerDependencies: + - encoding + optional: true + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@4.1.5: {} + + leac@0.6.0: + optional: true + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + libbase64@1.3.0: + optional: true + + libmime@5.3.7: + dependencies: + encoding-japanese: 2.2.0 + iconv-lite: 0.6.3 + libbase64: 1.3.0 + libqp: 2.1.1 + optional: true + + libphonenumber-js@1.12.40: {} + + libqp@2.1.1: + optional: true + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + optional: true + + linkify-it@5.0.1: + dependencies: + uc.micro: 2.1.0 + + lint-staged@16.4.0: + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + picomatch: 4.0.4 + string-argv: 0.3.2 + tinyexec: 1.0.4 + yaml: 2.8.3 + + liquid-json@0.3.1: {} + + liquidjs@10.26.0: + dependencies: + commander: 10.0.1 + optional: true + + listr2@9.0.5: + dependencies: + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + + load-esm@1.0.3: {} + + load-json-file@4.0.0: + dependencies: + graceful-fs: 4.2.11 + parse-json: 4.0.0 + pify: 3.0.0 + strip-bom: 3.0.0 + + load-tsconfig@0.2.5: {} + + loader-runner@4.3.1: {} + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.0 + quansync: 0.2.11 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash-es@4.18.1: {} + + lodash.camelcase@4.3.0: {} + + lodash.debounce@4.0.8: {} + + lodash.defaults@4.2.0: + optional: true + + lodash.get@4.4.2: {} + + lodash.includes@4.3.0: {} + + lodash.isarguments@3.1.0: + optional: true + + lodash.isboolean@3.0.3: {} + + lodash.isequal@4.5.0: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.kebabcase@4.1.1: {} + + lodash.memoize@4.1.2: {} + + lodash.merge@4.6.2: {} + + lodash.mergewith@4.6.2: {} + + lodash.omit@4.18.0: {} + + lodash.once@4.1.1: {} + + lodash.snakecase@4.1.1: {} + + lodash.sortby@4.7.0: {} + + lodash.startcase@4.4.0: {} + + lodash.uniq@4.5.0: + optional: true + + lodash.upperfirst@4.3.1: {} + + lodash@4.17.23: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + loglevel@1.9.2: {} + + long@4.0.0: {} + + long@5.3.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lossless-json@4.3.0: {} + + lower-case-first@2.0.2: + dependencies: + tslib: 2.8.1 + + lower-case@2.0.2: + dependencies: + tslib: 2.8.1 + + lru-cache@10.4.3: {} + + lru-cache@11.2.7: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru.min@1.1.4: {} + + luxon@3.7.2: {} + + magic-string@0.25.9: + dependencies: + sourcemap-codec: 1.4.8 + + magic-string@0.26.7: + dependencies: + sourcemap-codec: 1.4.8 + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mailparser@3.9.3: + dependencies: + '@zone-eu/mailsplit': 5.4.8 + encoding-japanese: 2.2.0 + he: 1.2.0 + html-to-text: 9.0.5 + iconv-lite: 0.7.2 + libmime: 5.3.7 + linkify-it: 5.0.0 + nodemailer: 7.0.13 + punycode.js: 2.3.1 + tlds: 1.261.0 + optional: true + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + map-cache@0.2.2: {} + + map-stream@0.0.7: {} + + markdown-it@14.2.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.1 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + marked@14.0.0: {} + + math-intrinsics@1.1.0: {} + + mdn-data@2.0.28: + optional: true + + mdn-data@2.27.1: {} + + mdurl@2.0.0: {} + + media-typer@0.3.0: {} + + media-typer@1.1.0: {} + + memfs@3.5.3: + dependencies: + fs-monkey: 1.1.0 + + memory-fs@0.3.0: + dependencies: + errno: 0.1.8 + readable-stream: 2.3.8 + + memorystream@0.3.1: {} + + mensch@0.3.4: + optional: true + + meow@13.2.0: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + meros@1.3.2(@types/node@24.10.1): + optionalDependencies: + '@types/node': 24.10.1 + + meros@1.3.2(@types/node@25.9.3): + optionalDependencies: + '@types/node': 25.9.3 + + methods@1.1.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-format@2.0.2: + dependencies: + charset: 1.0.1 + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@1.6.0: {} + + mime@2.6.0: {} + + mime@3.0.0: {} + + mimic-fn@2.1.0: {} + + mimic-function@5.0.1: {} + + minimatch@10.2.3: + dependencies: + brace-expansion: 5.0.5 + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.5 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimatch@4.2.5: + dependencies: + brace-expansion: 1.1.12 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.1 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + minisearch@7.2.0: {} + + mjml-accordion@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-body@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-button@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-carousel@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-cli@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + chokidar: 3.6.0 + glob: 10.5.0 + lodash: 4.18.1 + minimatch: 9.0.9 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-parser-xml: 5.0.0-alpha.4 + mjml-validator: 5.0.0-alpha.4 + yargs: 17.7.3 + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-column@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-core@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + cheerio: 1.0.0-rc.12 + cssnano: 7.1.3(postcss@8.5.15) + detect-node: 2.1.0 + htmlnano: 2.1.5(cssnano@7.1.3(postcss@8.5.15))(postcss@8.5.15)(terser@5.46.1)(typescript@5.9.3) + juice: 10.0.1 + lodash: 4.18.1 + mjml-parser-xml: 5.0.0-alpha.4 + mjml-validator: 5.0.0-alpha.4 + postcss: 8.5.15 + prettier: 3.8.4 + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-divider@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-group@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-head-attributes@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-head-breakpoint@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-head-font@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-head-html-attributes@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-head-preview@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-head-style@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-head-title@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-head@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-hero@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-image@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-navbar@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-parser-xml@5.0.0-alpha.4: + dependencies: + '@babel/runtime': 7.29.7 + detect-node: 2.1.0 + htmlparser2: 9.1.0 + lodash: 4.18.1 + optional: true + + mjml-preset-core@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + mjml-accordion: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-body: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-button: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-carousel: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-column: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-divider: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-group: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-head: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-head-attributes: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-head-breakpoint: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-head-font: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-head-html-attributes: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-head-preview: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-head-style: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-head-title: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-hero: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-image: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-navbar: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-raw: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-section: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-social: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-spacer: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-table: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-text: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-wrapper: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-raw@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-section@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-social@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-spacer@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-table@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-text@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-validator@5.0.0-alpha.4: + dependencies: + '@babel/runtime': 7.29.7 + optional: true + + mjml-wrapper@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-section: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml@5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + mjml-cli: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-preset-core: 5.0.0-alpha.4(terser@5.46.1)(typescript@5.9.3) + mjml-validator: 5.0.0-alpha.4 + transitivePeerDependencies: + - encoding + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mkdirp@1.0.4: {} + + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + mocha@11.7.6: + dependencies: + browser-stdout: 1.3.1 + chokidar: 4.0.3 + debug: 4.4.3(supports-color@8.1.1) + diff: 7.0.0 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 10.5.0 + he: 1.2.0 + is-path-inside: 3.0.3 + js-yaml: 4.2.0 + log-symbols: 4.1.0 + minimatch: 9.0.9 + ms: 2.1.3 + picocolors: 1.1.1 + serialize-javascript: 7.0.3 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 9.3.4 + yargs: 17.7.3 + yargs-parser: 21.1.1 + yargs-unparser: 2.0.0 + + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + + morgan@1.11.0: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.4.1 + on-headers: 1.1.0 + transitivePeerDependencies: + - supports-color + + mrmime@2.0.1: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + muggle-string@0.3.1: {} + + muggle-string@0.4.1: {} + + multer@2.2.0: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + + mute-stream@2.0.0: {} + + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + + nanoid@3.3.14: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + neo-async@2.6.2: {} + + no-case@3.0.4: + dependencies: + lower-case: 2.0.2 + tslib: 2.8.1 + + node-abort-controller@3.1.1: {} + + node-addon-api@7.1.1: + optional: true + + node-addon-api@8.7.0: {} + + node-domexception@1.0.0: {} + + node-emoji@1.11.0: + dependencies: + lodash: 4.18.1 + + node-fetch-h2@2.3.0: + dependencies: + http2-client: 1.3.5 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-gyp-build@4.8.4: {} + + node-int64@0.4.0: {} + + node-readfiles@0.2.0: + dependencies: + es6-promise: 3.3.1 + + node-releases@2.0.36: {} + + node-releases@2.0.38: {} + + nodemailer@7.0.13: + optional: true + + nodemailer@9.0.1: {} + + normalize-package-data@2.5.0: + dependencies: + hosted-git-info: 2.8.9 + resolve: 1.22.11 + semver: 5.7.2 + validate-npm-package-license: 3.0.4 + + normalize-path@2.1.1: + dependencies: + remove-trailing-separator: 1.1.0 + + normalize-path@3.0.0: {} + + npm-run-all@4.1.5: + dependencies: + ansi-styles: 3.2.1 + chalk: 2.4.2 + cross-spawn: 7.0.6 + memorystream: 0.3.1 + minimatch: 3.1.5 + pidtree: 0.3.1 + read-pkg: 3.0.0 + shell-quote: 1.8.3 + string.prototype.padend: 3.1.6 + + npm-run-path@3.1.0: + dependencies: + path-key: 3.1.1 + optional: true + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + nprogress@0.2.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nullthrows@1.1.1: {} + + nunjucks@3.2.4(chokidar@4.0.3): + dependencies: + a-sync-waterfall: 1.0.1 + asap: 2.0.6 + commander: 5.1.0 + optionalDependencies: + chokidar: 4.0.3 + optional: true + + oas-kit-common@1.0.8: + dependencies: + fast-safe-stringify: 2.1.1 + + oas-linter@3.2.2: + dependencies: + '@exodus/schemasafe': 1.3.0 + should: 13.2.3 + yaml: 1.10.3 + + oas-resolver@2.5.6: + dependencies: + node-fetch-h2: 2.3.0 + oas-kit-common: 1.0.8 + reftools: 1.1.9 + yaml: 1.10.3 + yargs: 17.7.3 + + oas-schema-walker@1.1.5: {} + + oas-validator@5.0.8: + dependencies: + call-me-maybe: 1.0.2 + oas-kit-common: 1.0.8 + oas-linter: 3.2.2 + oas-resolver: 2.5.6 + oas-schema-walker: 1.1.5 + reftools: 1.1.9 + should: 13.2.3 + yaml: 1.10.3 + + oauth@0.10.2: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + obug@2.1.3: {} + + ohash@2.0.11: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + optional: true + + openapi-types@12.1.3: {} + + opener@1.5.2: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-event@4.2.0: + dependencies: + p-timeout: 3.2.0 + optional: true + + p-finally@1.0.0: + optional: true + + p-finally@2.0.1: + optional: true + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@7.0.4: {} + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + optional: true + + p-try@2.2.0: {} + + p-wait-for@3.2.0: + dependencies: + p-timeout: 3.2.0 + optional: true + + package-json-from-dist@1.0.1: {} + + package-manager-detector@1.6.0: {} + + paho-mqtt@1.1.0: {} + + pako@0.2.9: {} + + pako@1.0.11: {} + + papaparse@5.5.4: {} + + param-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-code-context@1.0.0: {} + + parse-filepath@1.0.2: + dependencies: + is-absolute: 1.0.0 + map-cache: 0.2.2 + path-root: 0.1.1 + + parse-json@4.0.0: + dependencies: + error-ex: 1.3.4 + json-parse-better-errors: 1.0.2 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + optional: true + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + optional: true + + parse5@8.0.0: + dependencies: + entities: 6.0.1 + + parseley@0.12.1: + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + optional: true + + parseqs@0.0.6: {} + + parser-ts@0.7.0(fp-ts@2.16.11): + dependencies: + '@babel/code-frame': 7.29.0 + fp-ts: 2.16.11 + + parseuri@0.0.6: {} + + parseurl@1.3.3: {} + + pascal-case@3.1.2: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + passport-github2@0.1.12: + dependencies: + passport-oauth2: 1.8.0 + + passport-google-oauth20@2.0.0: + dependencies: + passport-oauth2: 1.8.0 + + passport-jwt@4.0.1: + dependencies: + jsonwebtoken: 9.0.3 + passport-strategy: 1.0.0 + + passport-local@1.0.0: + dependencies: + passport-strategy: 1.0.0 + + passport-microsoft@2.1.0: + dependencies: + passport-oauth2: 1.8.0 + + passport-oauth2@1.8.0: + dependencies: + base64url: 3.0.1 + oauth: 0.10.2 + passport-strategy: 1.0.0 + uid2: 0.0.4 + utils-merge: 1.0.1 + + passport-strategy@1.0.0: {} + + passport@0.7.0: + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + + path-browserify@1.0.1: {} + + path-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: {} + + path-root-regex@0.1.2: {} + + path-root@0.1.1: + dependencies: + path-root-regex: 0.1.2 + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.7 + minipass: 7.1.3 + + path-to-regexp@8.4.0: {} + + path-to-regexp@8.4.2: {} + + path-type@3.0.0: + dependencies: + pify: 3.0.0 + + path-type@4.0.0: {} + + path@0.12.7: + dependencies: + process: 0.11.10 + util: 0.10.4 + + pathe@2.0.3: {} + + pause-stream@0.0.11: + dependencies: + through: 2.3.8 + + pause@0.0.1: {} + + pdfjs-dist@4.10.38: + optionalDependencies: + '@napi-rs/canvas': 0.1.97 + + peberminta@0.9.0: + optional: true + + pend@1.2.0: {} + + perfect-debounce@2.1.0: {} + + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.13.0: {} + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pidtree@0.3.1: {} + + pify@2.3.0: {} + + pify@3.0.0: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + pluralize@8.0.0: {} + + portfinder@1.0.38: + dependencies: + async: 3.2.6 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + possible-typed-array-names@1.1.0: {} + + postcss-calc@10.1.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.4 + postcss-value-parser: 4.2.0 + optional: true + + postcss-colormin@7.0.6(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-api: 3.0.0 + colord: 2.9.3 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-convert-values@7.0.9(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-discard-comments@7.0.6(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.4 + optional: true + + postcss-discard-duplicates@7.0.2(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + optional: true + + postcss-discard-empty@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + optional: true + + postcss-discard-overridden@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + optional: true + + postcss-import@15.1.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.11 + + postcss-js@4.1.0(postcss@8.5.15): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.15 + + postcss-load-config@4.0.2(postcss@8.5.15)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): + dependencies: + lilconfig: 3.1.3 + yaml: 2.8.3 + optionalDependencies: + postcss: 8.5.15 + ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3) + + postcss-load-config@4.0.2(postcss@8.5.15)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)): + dependencies: + lilconfig: 3.1.3 + yaml: 2.8.3 + optionalDependencies: + postcss: 8.5.15 + ts-node: 10.9.2(@types/node@25.9.3)(typescript@5.9.3) + + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.15)(yaml@2.9.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 2.6.1 + postcss: 8.5.15 + yaml: 2.9.0 + + postcss-merge-longhand@7.0.5(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + stylehacks: 7.0.8(postcss@8.5.15) + optional: true + + postcss-merge-rules@7.0.8(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-api: 3.0.0 + cssnano-utils: 5.0.1(postcss@8.5.15) + postcss: 8.5.15 + postcss-selector-parser: 7.1.4 + optional: true + + postcss-minify-font-values@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-minify-gradients@7.0.1(postcss@8.5.15): + dependencies: + colord: 2.9.3 + cssnano-utils: 5.0.1(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-minify-params@7.0.6(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + cssnano-utils: 5.0.1(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-minify-selectors@7.0.6(postcss@8.5.15): + dependencies: + cssesc: 3.0.0 + postcss: 8.5.15 + postcss-selector-parser: 7.1.4 + optional: true + + postcss-nested@6.2.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 6.1.2 + + postcss-normalize-charset@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + optional: true + + postcss-normalize-display-values@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-normalize-positions@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-normalize-repeat-style@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-normalize-string@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-normalize-timing-functions@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-normalize-unicode@7.0.6(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-normalize-url@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-normalize-whitespace@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-ordered-values@7.0.2(postcss@8.5.15): + dependencies: + cssnano-utils: 5.0.1(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-reduce-initial@7.0.6(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-api: 3.0.0 + postcss: 8.5.15 + optional: true + + postcss-reduce-transforms@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@7.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-svgo@7.1.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + svgo: 4.0.1 + optional: true + + postcss-unique-selectors@7.0.5(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.4 + optional: true + + postcss-value-parser@4.2.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.14 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres@3.4.7: {} + + posthog-node@5.38.2(rxjs@7.8.2): + dependencies: + '@posthog/core': 1.35.3 + optionalDependencies: + rxjs: 7.8.2 + + posthtml-parser@0.11.0: + dependencies: + htmlparser2: 7.2.0 + optional: true + + posthtml-render@3.0.0: + dependencies: + is-json: 2.0.1 + optional: true + + posthtml@0.16.7: + dependencies: + posthtml-parser: 0.11.0 + posthtml-render: 3.0.0 + optional: true + + postman-collection@5.3.0: + dependencies: + '@faker-js/faker': 5.5.3 + file-type: 3.9.0 + http-reasons: 0.1.0 + iconv-lite: 0.6.3 + liquid-json: 0.3.1 + lodash: 4.17.23 + mime: 3.0.0 + mime-format: 2.0.2 + postman-url-encoder: 3.0.8 + semver: 7.7.1 + uuid: 8.3.2 + + postman-url-encoder@3.0.8: + dependencies: + punycode: 2.3.1 + + powershell-utils@0.1.0: {} + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + + prettier-plugin-tailwindcss@0.7.1(prettier@3.8.4): + dependencies: + prettier: 3.8.4 + + prettier-plugin-tailwindcss@0.7.2(prettier@3.8.4): + dependencies: + prettier: 3.8.4 + + prettier@3.8.4: {} + + pretty-bytes@5.6.0: {} + + pretty-bytes@6.1.1: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + pretty-format@30.3.0: + dependencies: + '@jest/schemas': 30.0.5 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + pretty-format@30.4.1: + dependencies: + '@jest/schemas': 30.4.1 + ansi-styles: 5.2.0 + react-is-18: react-is@18.3.1 + react-is-19: react-is@19.2.7 + + preview-email@3.1.1: + dependencies: + ci-info: 3.9.0 + display-notification: 2.0.0 + fixpack: 4.0.0 + get-port: 5.1.1 + mailparser: 3.9.3 + nodemailer: 7.0.13 + open: 7.4.2 + p-event: 4.2.0 + p-wait-for: 3.2.0 + pug: 3.0.4 + uuid: 9.0.1 + optional: true + + prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@prisma/config': 7.8.0 + '@prisma/dev': 0.24.3(typescript@5.9.3) + '@prisma/engines': 7.8.0 + '@prisma/studio-core': 0.27.3(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - magicast + - react + - react-dom + + process-nextick-args@2.0.1: {} + + process@0.11.10: {} + + promise@7.3.1: + dependencies: + asap: 2.0.6 + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@2.1.0: {} + + prr@1.0.1: {} + + pug-attrs@3.0.0: + dependencies: + constantinople: 4.0.1 + js-stringify: 1.0.2 + pug-runtime: 3.0.1 + optional: true + + pug-code-gen@3.0.4: + dependencies: + constantinople: 4.0.1 + doctypes: 1.1.0 + js-stringify: 1.0.2 + pug-attrs: 3.0.0 + pug-error: 2.1.0 + pug-runtime: 3.0.1 + void-elements: 3.1.0 + with: 7.0.2 + optional: true + + pug-error@2.1.0: + optional: true + + pug-filters@4.0.0: + dependencies: + constantinople: 4.0.1 + jstransformer: 1.0.0 + pug-error: 2.1.0 + pug-walk: 2.0.0 + resolve: 1.22.12 + optional: true + + pug-lexer@5.0.1: + dependencies: + character-parser: 2.2.0 + is-expression: 4.0.0 + pug-error: 2.1.0 + optional: true + + pug-linker@4.0.0: + dependencies: + pug-error: 2.1.0 + pug-walk: 2.0.0 + optional: true + + pug-load@3.0.0: + dependencies: + object-assign: 4.1.1 + pug-walk: 2.0.0 + optional: true + + pug-parser@6.0.0: + dependencies: + pug-error: 2.1.0 + token-stream: 1.0.0 + optional: true + + pug-runtime@3.0.1: + optional: true + + pug-strip-comments@2.0.0: + dependencies: + pug-error: 2.1.0 + optional: true + + pug-walk@2.0.0: + optional: true + + pug@3.0.4: + dependencies: + pug-code-gen: 3.0.4 + pug-filters: 4.0.0 + pug-lexer: 5.0.1 + pug-linker: 4.0.0 + pug-load: 3.0.0 + pug-parser: 6.0.0 + pug-runtime: 3.0.1 + pug-strip-comments: 2.0.0 + optional: true + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + optional: true + + punycode.js@2.3.1: {} + + punycode@1.4.1: {} + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + pure-rand@7.0.1: {} + + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + qs@6.15.2: + dependencies: + side-channel: 1.1.1 + + quansync@0.2.11: {} + + queue-microtask@1.2.3: {} + + quickjs-emscripten-core@0.31.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.31.0 + + quickjs-emscripten@0.31.0: + dependencies: + '@jitl/quickjs-wasmfile-debug-asyncify': 0.31.0 + '@jitl/quickjs-wasmfile-debug-sync': 0.31.0 + '@jitl/quickjs-wasmfile-release-asyncify': 0.31.0 + '@jitl/quickjs-wasmfile-release-sync': 0.31.0 + quickjs-emscripten-core: 0.31.0 + + quicktype-core@23.2.6: + dependencies: + '@glideapps/ts-necessities': 2.2.3 + browser-or-node: 3.0.0 + collection-utils: 1.0.1 + cross-fetch: 4.1.0 + is-url: 1.2.4 + js-base64: 3.7.8 + lodash: 4.18.1 + pako: 1.0.11 + pluralize: 8.0.0 + readable-stream: 4.5.2 + unicode-properties: 1.4.1 + urijs: 1.19.11 + wordwrap: 1.0.0 + yaml: 2.8.3 + transitivePeerDependencies: + - encoding + + ramda-adjunct@2.36.0(ramda@0.27.2): + dependencies: + ramda: 0.27.2 + + ramda@0.27.2: {} + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + optional: true + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-is@18.3.1: {} + + react-is@19.2.7: {} + + react@19.2.4: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + read-pkg@3.0.0: + dependencies: + load-json-file: 4.0.0 + normalize-package-data: 2.5.0 + path-type: 3.0.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readable-stream@4.5.2: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + readdirp@4.1.2: {} + + readdirp@5.0.0: {} + + redis-errors@1.2.0: + optional: true + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + optional: true + + reflect-metadata@0.2.2: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + reftools@1.1.9: {} + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regenerator-runtime@0.13.11: {} + + regenerator-runtime@0.14.1: {} + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.0 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.0: + dependencies: + jsesc: 3.1.0 + + relay-runtime@12.0.0: + dependencies: + '@babel/runtime': 7.29.2 + fbjs: 3.0.5 + invariant: 2.2.4 + transitivePeerDependencies: + - encoding + + remeda@2.33.4: {} + + remedial@1.0.8: {} + + remove-trailing-separator@1.1.0: {} + + remove-trailing-spaces@1.0.9: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-main-filename@2.0.0: {} + + requires-port@1.0.0: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@2.0.0: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + retry@0.12.0: {} + + retry@0.13.1: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + + rollup-plugin-inject@3.0.2: + dependencies: + estree-walker: 0.6.1 + magic-string: 0.25.9 + rollup-pluginutils: 2.8.2 + + rollup-plugin-node-polyfills@0.2.1: + dependencies: + rollup-plugin-inject: 3.0.2 + + rollup-plugin-polyfill-node@0.13.0(rollup@4.60.2): + dependencies: + '@rollup/plugin-inject': 5.0.5(rollup@4.60.2) + rollup: 4.60.2 + + rollup-pluginutils@2.8.2: + dependencies: + estree-walker: 0.6.1 + + rollup@2.80.0: + optionalDependencies: + fsevents: 2.3.3 + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + rollup@4.60.2: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.2 + '@rollup/rollup-android-arm64': 4.60.2 + '@rollup/rollup-darwin-arm64': 4.60.2 + '@rollup/rollup-darwin-x64': 4.60.2 + '@rollup/rollup-freebsd-arm64': 4.60.2 + '@rollup/rollup-freebsd-x64': 4.60.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 + '@rollup/rollup-linux-arm-musleabihf': 4.60.2 + '@rollup/rollup-linux-arm64-gnu': 4.60.2 + '@rollup/rollup-linux-arm64-musl': 4.60.2 + '@rollup/rollup-linux-loong64-gnu': 4.60.2 + '@rollup/rollup-linux-loong64-musl': 4.60.2 + '@rollup/rollup-linux-ppc64-gnu': 4.60.2 + '@rollup/rollup-linux-ppc64-musl': 4.60.2 + '@rollup/rollup-linux-riscv64-gnu': 4.60.2 + '@rollup/rollup-linux-riscv64-musl': 4.60.2 + '@rollup/rollup-linux-s390x-gnu': 4.60.2 + '@rollup/rollup-linux-x64-gnu': 4.60.2 + '@rollup/rollup-linux-x64-musl': 4.60.2 + '@rollup/rollup-openbsd-x64': 4.60.2 + '@rollup/rollup-openharmony-arm64': 4.60.2 + '@rollup/rollup-win32-arm64-msvc': 4.60.2 + '@rollup/rollup-win32-ia32-msvc': 4.60.2 + '@rollup/rollup-win32-x64-gnu': 4.60.2 + '@rollup/rollup-win32-x64-msvc': 4.60.2 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.0 + transitivePeerDependencies: + - supports-color + + run-applescript@3.2.0: + dependencies: + execa: 2.0.0 + optional: true + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + sass@1.101.0: + dependencies: + chokidar: 5.0.0 + immutable: 5.1.6 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.6 + + sax@1.6.0: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + schema-utils@3.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.14.0 + ajv-keywords: 3.5.2(ajv@6.14.0) + + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-keywords: 5.1.0(ajv@8.20.0) + + secure-compare@3.0.1: {} + + selderee@0.11.0: + dependencies: + parseley: 0.12.1 + optional: true + + semver@5.7.2: {} + + semver@6.3.1: {} + + semver@7.7.1: {} + + semver@7.8.0: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + sentence-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + upper-case-first: 2.0.2 + + seq-queue@0.0.5: {} + + serialize-javascript@7.0.3: {} + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + + set-cookie-parser-es@1.0.5: {} + + set-cookie-parser@2.7.2: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.3: {} + + should-equal@2.0.0: + dependencies: + should-type: 1.4.0 + + should-format@3.0.3: + dependencies: + should-type: 1.4.0 + should-type-adaptors: 1.1.0 + + should-type-adaptors@1.1.0: + dependencies: + should-type: 1.4.0 + should-util: 1.0.1 + + should-type@1.4.0: {} + + should-util@1.0.1: {} + + should@13.2.3: + dependencies: + should-equal: 2.0.0 + should-format: 3.0.3 + should-type: 1.4.0 + should-type-adaptors: 1.1.0 + should-util: 1.0.1 + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + signedsource@1.0.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + sitemap@8.0.3: + dependencies: + '@types/node': 17.0.45 + '@types/sax': 1.2.7 + arg: 5.0.2 + sax: 1.6.0 + + slash@3.0.0: {} + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slick@1.12.2: + optional: true + + smob@1.6.2: {} + + snake-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + socket.io-client@2.5.0: + dependencies: + backo2: 1.0.2 + component-bind: 1.0.0 + component-emitter: 1.3.1 + debug: 3.1.0 + engine.io-client: 3.5.6 + has-binary2: 1.0.3 + indexof: 0.0.1 + parseqs: 0.0.6 + parseuri: 0.0.6 + socket.io-parser: 3.3.5 + to-array: 0.1.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-client@3.1.3: + dependencies: + '@types/component-emitter': 1.2.14 + backo2: 1.0.2 + component-emitter: 1.3.1 + debug: 4.3.7 + engine.io-client: 4.1.4 + parseuri: 0.0.6 + socket.io-parser: 4.0.5 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-client@4.8.1: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + engine.io-client: 6.6.4 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@3.3.5: + dependencies: + component-emitter: 1.3.1 + debug: 3.1.0 + isarray: 2.0.1 + transitivePeerDependencies: + - supports-color + + socket.io-parser@4.0.5: + dependencies: + '@types/component-emitter': 1.2.14 + component-emitter: 1.3.1 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + + socket.io-parser@4.2.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + socketio-wildcard@2.0.0: {} + + sortablejs@1.14.0: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.4: {} + + source-map@0.7.6: {} + + source-map@0.8.0-beta.0: + dependencies: + whatwg-url: 7.1.0 + + sourcemap-codec@1.4.8: {} + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.23 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + split2@4.2.0: {} + + split@1.0.1: + dependencies: + through: 2.3.8 + + splitpanes@3.1.5: {} + + sponge-case@1.0.1: + dependencies: + tslib: 2.8.1 + + sprintf-js@1.0.3: {} + + sqlstring@2.3.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stackback@0.0.2: {} + + standard-as-callback@2.1.0: + optional: true + + state-local@1.0.7: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + std-env@4.1.0: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + stream-browserify@3.0.0: + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + + stream-combiner@0.2.2: + dependencies: + duplexer: 0.1.2 + through: 2.3.8 + + streamsearch@1.1.0: {} + + string-argv@0.3.2: {} + + string-env-interpolation@1.0.1: {} + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 + + string.prototype.padend@3.1.6: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-object@3.3.0: + dependencies: + get-own-enumerable-property-symbols: 3.0.2 + is-obj: 1.0.1 + is-regexp: 1.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-bom@4.0.0: {} + + strip-comments@2.0.1: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@2.0.1: {} + + strip-json-comments@3.1.1: {} + + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + + style-mod@4.1.3: {} + + stylehacks@7.0.8(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + postcss: 8.5.15 + postcss-selector-parser: 7.1.4 + optional: true + + subscriptions-transport-ws@0.11.0(graphql@16.13.2): + dependencies: + backo2: 1.0.2 + eventemitter3: 3.1.2 + graphql: 16.13.2 + iterall: 1.3.0 + symbol-observable: 1.2.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + subscriptions-transport-ws@0.11.0(graphql@16.14.0): + dependencies: + backo2: 1.0.2 + eventemitter3: 3.1.2 + graphql: 16.14.0 + iterall: 1.3.0 + symbol-observable: 1.2.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3(supports-color@8.1.1) + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.2 + transitivePeerDependencies: + - supports-color + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svelte-hmr@0.15.3(svelte@3.59.2): + dependencies: + svelte: 3.59.2 + + svelte@3.59.2: {} + + svgo@4.0.1: + dependencies: + commander: 11.1.0 + css-select: 5.2.2 + css-tree: 3.2.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.0 + optional: true + + swagger-parser@10.0.3(openapi-types@12.1.3): + dependencies: + '@apidevtools/swagger-parser': 10.0.3(openapi-types@12.1.3) + transitivePeerDependencies: + - openapi-types + + swagger-ui-dist@5.32.6: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger2openapi@7.0.8: + dependencies: + call-me-maybe: 1.0.2 + node-fetch: 2.7.0 + node-fetch-h2: 2.3.0 + node-readfiles: 0.2.0 + oas-kit-common: 1.0.8 + oas-resolver: 2.5.6 + oas-schema-walker: 1.1.5 + oas-validator: 5.0.8 + reftools: 1.1.9 + yaml: 1.10.3 + yargs: 17.7.3 + transitivePeerDependencies: + - encoding + + swap-case@2.0.2: + dependencies: + tslib: 2.8.1 + + symbol-observable@1.2.0: {} + + symbol-observable@4.0.0: {} + + symbol-tree@3.2.4: {} + + sync-fetch@0.6.0: + dependencies: + node-fetch: 3.3.2 + timeout-signal: 2.0.0 + whatwg-mimetype: 4.0.0 + + synckit@0.11.13: + dependencies: + '@pkgr/core': 0.3.6 + + systemjs@6.15.1: {} + + tailwindcss@3.4.16(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-import: 15.1.0(postcss@8.5.15) + postcss-js: 4.1.0(postcss@8.5.15) + postcss-load-config: 4.0.2(postcss@8.5.15)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + postcss-nested: 6.2.0(postcss@8.5.15) + postcss-selector-parser: 6.1.2 + resolve: 1.22.11 + sucrase: 3.35.1 + transitivePeerDependencies: + - ts-node + + tailwindcss@3.4.16(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)): + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-import: 15.1.0(postcss@8.5.15) + postcss-js: 4.1.0(postcss@8.5.15) + postcss-load-config: 4.0.2(postcss@8.5.15)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) + postcss-nested: 6.2.0(postcss@8.5.15) + postcss-selector-parser: 6.1.2 + resolve: 1.22.11 + sucrase: 3.35.1 + transitivePeerDependencies: + - ts-node + + tapable@0.2.9: {} + + tapable@2.3.2: {} + + temp-dir@2.0.0: {} + + tempy@0.6.0: + dependencies: + is-stream: 2.0.1 + temp-dir: 2.0.0 + type-fest: 0.16.0 + unique-string: 2.0.0 + + tern@0.24.3: + dependencies: + acorn: 6.4.2 + acorn-loose: 6.1.0 + acorn-walk: 6.2.0 + enhanced-resolve: 2.3.0 + glob: 7.2.3 + minimatch: 3.1.5 + resolve-from: 2.0.0 + + terser-webpack-plugin@5.4.0(webpack@5.106.2): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.46.1 + webpack: 5.106.2 + + terser@5.46.1: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.17.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.5 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + through@2.3.8: {} + + timeout-signal@2.0.0: {} + + timers@0.1.1: {} + + tiny-inflate@1.0.3: {} + + tiny-invariant@1.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyexec@1.0.4: {} + + tinyexec@1.1.1: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tippy.js@6.3.7: + dependencies: + '@popperjs/core': 2.11.8 + + title-case@3.0.3: + dependencies: + tslib: 2.8.1 + + tlds@1.261.0: + optional: true + + tldts-core@7.0.27: {} + + tldts@7.0.27: + dependencies: + tldts-core: 7.0.27 + + tmpl@1.0.5: {} + + to-array@0.1.4: {} + + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + token-stream@1.0.0: + optional: true + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + totalist@3.0.1: {} + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.27 + + tr46@0.0.3: {} + + tr46@1.0.1: + dependencies: + punycode: 2.3.1 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tree-kill@1.2.2: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-essentials@10.1.1(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + ts-interface-checker@0.1.13: {} + + ts-jest@29.4.11(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.9 + jest: 30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.8.0 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.0 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + babel-jest: 30.4.1(@babel/core@7.29.0) + jest-util: 30.4.1 + + ts-loader@9.6.1(typescript@5.9.3)(webpack@5.106.2): + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.20.1 + micromatch: 4.0.8 + semver: 7.8.0 + source-map: 0.7.6 + typescript: 5.9.3 + webpack: 5.106.2 + + ts-log@2.2.7: {} + + ts-node-dev@2.0.0(@types/node@25.9.3)(typescript@5.9.3): + dependencies: + chokidar: 3.6.0 + dynamic-dedupe: 0.3.0 + minimist: 1.2.8 + mkdirp: 1.0.4 + resolve: 1.22.11 + rimraf: 2.7.1 + source-map-support: 0.5.21 + tree-kill: 1.2.2 + ts-node: 10.9.2(@types/node@25.9.3)(typescript@5.9.3) + tsconfig: 7.0.0 + typescript: 5.9.3 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + + ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 24.10.1 + acorn: 8.16.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optional: true + + ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 25.9.3 + acorn: 8.16.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + tsconfig-paths-webpack-plugin@4.2.0: + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.20.1 + tapable: 2.3.2 + tsconfig-paths: 4.2.0 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tsconfig@7.0.0: + dependencies: + '@types/strip-bom': 3.0.0 + '@types/strip-json-comments': 0.0.30 + strip-bom: 3.0.0 + strip-json-comments: 2.0.1 + + tslib@2.4.1: {} + + tslib@2.8.1: {} + + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.4) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3(supports-color@8.1.1) + esbuild: 0.27.4 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.15)(yaml@2.9.0) + resolve-from: 5.0.0 + rollup: 4.59.0 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.15 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.16.0: {} + + type-fest@0.20.2: {} + + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typedarray@0.0.6: {} + + typescript-eslint@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.2: {} + + typescript@5.9.3: {} + + ua-parser-js@1.0.41: {} + + uc.micro@2.1.0: {} + + ufo@1.6.3: {} + + uglify-js@3.19.3: + optional: true + + uid2@0.0.4: {} + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + unc-path-regex@0.1.2: {} + + undici-types@7.16.0: {} + + undici-types@7.24.6: {} + + unhead@2.1.12: + dependencies: + hookable: 6.1.0 + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + + unicode-property-aliases-ecmascript@2.2.0: {} + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + + unicorn-magic@0.3.0: {} + + union@0.5.0: + dependencies: + qs: 6.15.2 + + unique-string@2.0.0: + dependencies: + crypto-random-string: 2.0.0 + + universalify@2.0.1: {} + + unixify@1.0.0: + dependencies: + normalize-path: 2.1.1 + + unpipe@1.0.0: {} + + unplugin-fonts@1.4.0(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)): + dependencies: + fast-glob: 3.3.3 + unplugin: 2.3.5 + vite: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + unplugin-fonts@1.4.0(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)): + dependencies: + fast-glob: 3.3.3 + unplugin: 2.3.5 + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + unplugin-icons@22.5.0(@vue/compiler-sfc@3.5.38)(svelte@3.59.2)(vue-template-compiler@2.7.16): + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/utils': 3.1.0 + debug: 4.4.3(supports-color@8.1.1) + local-pkg: 1.1.2 + unplugin: 2.3.11 + optionalDependencies: + '@vue/compiler-sfc': 3.5.38 + svelte: 3.59.2 + vue-template-compiler: 2.7.16 + transitivePeerDependencies: + - supports-color + + unplugin-utils@0.3.1: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.4 + + unplugin-vue-components@30.0.0(@babel/parser@7.29.7)(vue@3.5.38(typescript@5.9.3)): + dependencies: + chokidar: 4.0.3 + debug: 4.4.3(supports-color@8.1.1) + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.2 + tinyglobby: 0.2.15 + unplugin: 2.3.11 + unplugin-utils: 0.3.1 + vue: 3.5.38(typescript@5.9.3) + optionalDependencies: + '@babel/parser': 7.29.7 + transitivePeerDependencies: + - supports-color + + unplugin@2.2.2: + dependencies: + acorn: 8.17.0 + webpack-virtual-modules: 0.6.2 + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.17.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + unplugin@2.3.5: + dependencies: + acorn: 8.17.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + upath@1.2.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + upper-case-first@2.0.2: + dependencies: + tslib: 2.8.1 + + upper-case@2.0.2: + dependencies: + tslib: 2.8.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + urijs@1.19.11: {} + + url-join@4.0.1: {} + + url@0.11.4: + dependencies: + punycode: 1.4.1 + qs: 6.15.2 + + urlpattern-polyfill@10.1.0: {} + + urlpattern-polyfill@8.0.2: {} + + util-deprecate@1.0.2: {} + + util@0.10.4: + dependencies: + inherits: 2.0.3 + + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.20 + + utils-merge@1.0.1: {} + + uuid@13.0.0: {} + + uuid@8.3.2: {} + + uuid@9.0.1: + optional: true + + v8-compile-cache-lib@3.0.1: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + valid-data-url@3.0.1: + optional: true + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + + validator@13.15.26: {} + + value-or-promise@1.0.12: {} + + vary@1.1.2: {} + + verzod@0.4.0(zod@3.25.32): + dependencies: + zod: 3.25.32 + + vite-dev-rpc@2.0.0(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)): + dependencies: + birpc: 4.0.0 + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + vite-hot-client: 2.2.0(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + + vite-hot-client@2.2.0(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)): + dependencies: + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + vite-plugin-checker@0.12.0(eslint@9.39.2(jiti@2.6.1))(meow@13.2.0)(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-tsc@1.8.8(typescript@5.9.3)): + dependencies: + '@babel/code-frame': 7.29.0 + chokidar: 4.0.3 + npm-run-path: 6.0.0 + picocolors: 1.1.1 + picomatch: 4.0.4 + tiny-invariant: 1.3.3 + tinyglobby: 0.2.15 + vite: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + vscode-uri: 3.1.0 + optionalDependencies: + eslint: 9.39.2(jiti@2.6.1) + meow: 13.2.0 + optionator: 0.9.4 + typescript: 5.9.3 + vue-tsc: 1.8.8(typescript@5.9.3) + + vite-plugin-eslint@1.8.1(eslint@10.5.0(jiti@2.6.1))(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)): + dependencies: + '@rollup/pluginutils': 4.2.1 + '@types/eslint': 8.56.12 + eslint: 10.5.0(jiti@2.6.1) + rollup: 2.80.0 + vite: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + vite-plugin-eslint@1.8.1(eslint@10.5.0(jiti@2.6.1))(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)): + dependencies: + '@rollup/pluginutils': 4.2.1 + '@types/eslint': 8.56.12 + eslint: 10.5.0(jiti@2.6.1) + rollup: 2.80.0 + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + vite-plugin-eslint@1.8.1(eslint@9.39.2(jiti@2.6.1))(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)): + dependencies: + '@rollup/pluginutils': 4.2.1 + '@types/eslint': 8.56.12 + eslint: 9.39.2(jiti@2.6.1) + rollup: 2.80.0 + vite: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + vite-plugin-eslint@1.8.1(eslint@9.39.2(jiti@2.6.1))(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)): + dependencies: + '@rollup/pluginutils': 4.2.1 + '@types/eslint': 8.56.12 + eslint: 9.39.2(jiti@2.6.1) + rollup: 2.80.0 + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + vite-plugin-fonts@0.7.0(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)): + dependencies: + fast-glob: 3.3.3 + vite: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + vite-plugin-fonts@0.7.0(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)): + dependencies: + fast-glob: 3.3.3 + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + vite-plugin-html-config@2.0.2(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)): + dependencies: + vite: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + vite-plugin-html-config@2.0.2(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)): + dependencies: + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + vite-plugin-inspect@11.4.1(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)): + dependencies: + ansis: 4.3.1 + error-stack-parser-es: 1.0.5 + obug: 2.1.3 + ohash: 2.0.11 + open: 11.0.0 + perfect-debounce: 2.1.0 + sirv: 3.0.2 + unplugin-utils: 0.3.1 + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + vite-dev-rpc: 2.0.0(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + + vite-plugin-pages-sitemap@1.7.1: + dependencies: + sitemap: 8.0.3 + xml-formatter: 3.7.0 + + vite-plugin-pages@0.33.2(@vue/compiler-sfc@3.5.38)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-router@4.6.4(vue@3.5.38(typescript@5.9.3))): + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3(supports-color@8.1.1) + dequal: 2.0.3 + extract-comments: 1.1.0 + json5: 2.2.3 + local-pkg: 1.1.2 + micromatch: 4.0.8 + picocolors: 1.1.1 + tinyglobby: 0.2.15 + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + yaml: 2.8.3 + optionalDependencies: + '@vue/compiler-sfc': 3.5.38 + vue-router: 4.6.4(vue@3.5.38(typescript@5.9.3)) + transitivePeerDependencies: + - supports-color + + vite-plugin-pages@0.33.3(@vue/compiler-sfc@3.5.38)(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-router@4.6.4(vue@3.5.38(typescript@5.9.3))): + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3(supports-color@8.1.1) + dequal: 2.0.3 + extract-comments: 1.1.0 + json5: 2.2.3 + local-pkg: 1.1.2 + micromatch: 4.0.8 + picocolors: 1.1.1 + tinyglobby: 0.2.15 + vite: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + yaml: 2.8.3 + optionalDependencies: + '@vue/compiler-sfc': 3.5.38 + vue-router: 4.6.4(vue@3.5.38(typescript@5.9.3)) + transitivePeerDependencies: + - supports-color + + vite-plugin-pages@0.33.3(@vue/compiler-sfc@3.5.38)(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-router@4.6.4(vue@3.5.38(typescript@5.9.3))): + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3(supports-color@8.1.1) + dequal: 2.0.3 + extract-comments: 1.1.0 + json5: 2.2.3 + local-pkg: 1.1.2 + micromatch: 4.0.8 + picocolors: 1.1.1 + tinyglobby: 0.2.15 + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + yaml: 2.8.3 + optionalDependencies: + '@vue/compiler-sfc': 3.5.38 + vue-router: 4.6.4(vue@3.5.38(typescript@5.9.3)) + transitivePeerDependencies: + - supports-color + + vite-plugin-pwa@1.2.0(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.1): + dependencies: + debug: 4.4.3(supports-color@8.1.1) + pretty-bytes: 6.1.1 + tinyglobby: 0.2.15 + vite: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + workbox-build: 7.4.0(@types/babel__core@7.20.5) + workbox-window: 7.4.1 + transitivePeerDependencies: + - supports-color + + vite-plugin-pwa@1.2.0(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.1): + dependencies: + debug: 4.4.3(supports-color@8.1.1) + pretty-bytes: 6.1.1 + tinyglobby: 0.2.15 + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + workbox-build: 7.4.0(@types/babel__core@7.20.5) + workbox-window: 7.4.1 + transitivePeerDependencies: + - supports-color + + vite-plugin-static-copy@3.3.0(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)): + dependencies: + chokidar: 3.6.0 + p-map: 7.0.4 + picocolors: 1.1.1 + tinyglobby: 0.2.15 + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + + vite-plugin-vue-layouts@0.11.0(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-router@4.6.4(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3)): + dependencies: + debug: 4.4.3(supports-color@8.1.1) + fast-glob: 3.3.3 + vite: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + vue: 3.5.38(typescript@5.9.3) + vue-router: 4.6.4(vue@3.5.38(typescript@5.9.3)) + transitivePeerDependencies: + - supports-color + + vite-plugin-vue-layouts@0.11.0(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0))(vue-router@4.6.4(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3)): + dependencies: + debug: 4.4.3(supports-color@8.1.1) + fast-glob: 3.3.3 + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + vue: 3.5.38(typescript@5.9.3) + vue-router: 4.6.4(vue@3.5.38(typescript@5.9.3)) + transitivePeerDependencies: + - supports-color + + vite@3.2.11(@types/node@25.9.3)(sass@1.101.0)(terser@5.46.1): + dependencies: + esbuild: 0.15.18 + postcss: 8.5.15 + resolve: 1.22.11 + rollup: 2.80.0 + optionalDependencies: + '@types/node': 25.9.3 + fsevents: 2.3.3 + sass: 1.101.0 + terser: 5.46.1 + + vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0): + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.1 + fsevents: 2.3.3 + jiti: 2.6.1 + sass: 1.101.0 + terser: 5.46.1 + yaml: 2.9.0 + + vite@7.3.2(@types/node@24.9.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0): + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.9.1 + fsevents: 2.3.3 + jiti: 2.6.1 + sass: 1.101.0 + terser: 5.46.1 + yaml: 2.9.0 + + vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0): + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.9.3 + fsevents: 2.3.3 + jiti: 2.6.1 + sass: 1.101.0 + terser: 5.46.1 + yaml: 2.9.0 + + vitefu@0.2.5(vite@3.2.11(@types/node@25.9.3)(sass@1.101.0)(terser@5.46.1)): + optionalDependencies: + vite: 3.2.11(@types/node@25.9.3)(sass@1.101.0)(terser@5.46.1) + + vitest@4.1.9(@types/node@24.10.1)(jsdom@27.4.0(@noble/hashes@2.2.0))(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.1 + jsdom: 27.4.0(@noble/hashes@2.2.0) + transitivePeerDependencies: + - msw + + vitest@4.1.9(@types/node@25.9.3)(jsdom@27.4.0(@noble/hashes@2.2.0))(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.2(@types/node@25.9.3)(jiti@2.6.1)(sass@1.101.0)(terser@5.46.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.3 + jsdom: 27.4.0(@noble/hashes@2.2.0) + transitivePeerDependencies: + - msw + + void-elements@3.1.0: + optional: true + + vscode-languageserver-types@3.17.5: {} + + vscode-uri@3.1.0: {} + + vue-demi@0.14.10(vue@3.5.38(typescript@5.9.3)): + dependencies: + vue: 3.5.38(typescript@5.9.3) + + vue-eslint-parser@10.4.1(eslint@9.39.2(jiti@2.6.1)): + dependencies: + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.2(jiti@2.6.1) + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + vue-i18n@11.4.6(vue@3.5.38(typescript@5.9.3)): + dependencies: + '@intlify/core-base': 11.4.6 + '@intlify/devtools-types': 11.4.6 + '@intlify/shared': 11.4.6 + '@vue/devtools-api': 6.6.4 + vue: 3.5.38(typescript@5.9.3) + + vue-json-pretty@2.6.0(vue@3.5.38(typescript@5.9.3)): + dependencies: + vue: 3.5.38(typescript@5.9.3) + + vue-pdf-embed@2.1.4(vue@3.5.38(typescript@5.9.3)): + dependencies: + pdfjs-dist: 4.10.38 + vue: 3.5.38(typescript@5.9.3) + + vue-promise-modals@0.1.0(typescript@5.9.3): + dependencies: + vue: 3.5.38(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + vue-router@4.6.4(vue@3.5.38(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.38(typescript@5.9.3) + + vue-template-compiler@2.7.16: + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + vue-tippy@6.7.1(vue@3.5.38(typescript@5.9.3)): + dependencies: + tippy.js: 6.3.7 + vue: 3.5.38(typescript@5.9.3) + + vue-tsc@1.8.8(typescript@5.9.3): + dependencies: + '@vue/language-core': 1.8.8(typescript@5.9.3) + '@vue/typescript': 1.8.8(typescript@5.9.3) + semver: 7.8.5 + typescript: 5.9.3 + + vue-tsc@2.1.6(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 2.1.6(typescript@5.9.3) + semver: 7.8.5 + typescript: 5.9.3 + + vue-tsc@2.2.0(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 2.2.0(typescript@5.9.3) + typescript: 5.9.3 + + vue@3.5.38(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.38 + '@vue/compiler-sfc': 3.5.38 + '@vue/runtime-dom': 3.5.38 + '@vue/server-renderer': 3.5.38(vue@3.5.38(typescript@5.9.3)) + '@vue/shared': 3.5.38 + optionalDependencies: + typescript: 5.9.3 + + vuedraggable-es@4.1.1(vue@3.5.38(typescript@5.9.3)): + dependencies: + sortablejs: 1.14.0 + vue: 3.5.38(typescript@5.9.3) + + w3c-keyname@2.2.8: {} + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + watchpack@2.5.1: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + web-resource-inliner@6.0.1: + dependencies: + ansi-colors: 4.1.3 + escape-goat: 3.0.0 + htmlparser2: 5.0.1 + mime: 2.6.0 + node-fetch: 2.7.0 + valid-data-url: 3.0.1 + transitivePeerDependencies: + - encoding + optional: true + + web-streams-polyfill@3.3.3: {} + + webcrypto-core@1.8.1: + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/json-schema': 1.1.12 + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + webidl-conversions@3.0.1: {} + + webidl-conversions@4.0.2: {} + + webidl-conversions@8.0.1: {} + + webpack-node-externals@3.0.0: {} + + webpack-sources@3.3.4: {} + + webpack-virtual-modules@0.6.2: {} + + webpack@5.106.2: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.17.0 + acorn-import-phases: 1.0.4(acorn@8.17.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.20.1 + es-module-lexer: 2.0.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + loader-runner: 4.3.1 + mime-db: 1.54.0 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.2 + terser-webpack-plugin: 5.4.0(webpack@5.106.2) + watchpack: 2.5.1 + webpack-sources: 3.3.4 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.1 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + whatwg-url@7.1.0: + dependencies: + lodash.sortby: 4.7.0 + tr46: 1.0.1 + webidl-conversions: 4.0.2 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-module@2.0.1: {} + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + + with@7.0.2: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + assert-never: 1.4.0 + babel-walk: 3.0.0-canary-5 + optional: true + + wonka@6.3.6: {} + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + workbox-background-sync@7.4.0: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.0 + + workbox-broadcast-update@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-build@7.4.0(@types/babel__core@7.20.5): + dependencies: + '@apideck/better-ajv-errors': 0.3.7(ajv@8.20.0) + '@babel/core': 7.29.7 + '@babel/preset-env': 7.29.7(@babel/core@7.29.7) + '@babel/runtime': 7.29.7 + '@rollup/plugin-babel': 5.3.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@2.80.0) + '@rollup/plugin-node-resolve': 15.3.1(rollup@2.80.0) + '@rollup/plugin-replace': 2.4.2(rollup@2.80.0) + '@rollup/plugin-terser': 0.4.4(rollup@2.80.0) + '@surma/rollup-plugin-off-main-thread': 2.2.3 + ajv: 8.20.0 + common-tags: 1.8.2 + fast-json-stable-stringify: 2.1.0 + fs-extra: 9.1.0 + glob: 11.1.0 + lodash: 4.18.1 + pretty-bytes: 5.6.0 + rollup: 2.80.0 + source-map: 0.8.0-beta.0 + stringify-object: 3.3.0 + strip-comments: 2.0.1 + tempy: 0.6.0 + upath: 1.2.0 + workbox-background-sync: 7.4.0 + workbox-broadcast-update: 7.4.0 + workbox-cacheable-response: 7.4.0 + workbox-core: 7.4.0 + workbox-expiration: 7.4.0 + workbox-google-analytics: 7.4.0 + workbox-navigation-preload: 7.4.0 + workbox-precaching: 7.4.0 + workbox-range-requests: 7.4.0 + workbox-recipes: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + workbox-streams: 7.4.0 + workbox-sw: 7.4.0 + workbox-window: 7.4.0 + transitivePeerDependencies: + - '@types/babel__core' + - supports-color + + workbox-cacheable-response@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-core@7.4.0: {} + + workbox-core@7.4.1: {} + + workbox-expiration@7.4.0: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.0 + + workbox-google-analytics@7.4.0: + dependencies: + workbox-background-sync: 7.4.0 + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-navigation-preload@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-precaching@7.4.0: + dependencies: + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-range-requests@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-recipes@7.4.0: + dependencies: + workbox-cacheable-response: 7.4.0 + workbox-core: 7.4.0 + workbox-expiration: 7.4.0 + workbox-precaching: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-routing@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-strategies@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-streams@7.4.0: + dependencies: + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + + workbox-sw@7.4.0: {} + + workbox-window@7.4.0: + dependencies: + '@types/trusted-types': 2.0.7 + workbox-core: 7.4.0 + + workbox-window@7.4.1: + dependencies: + '@types/trusted-types': 2.0.7 + workbox-core: 7.4.1 + + workerpool@9.3.4: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + ws@8.21.0: {} + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + xml-formatter@3.7.0: + dependencies: + xml-parser-xo: 4.1.5 + + xml-name-validator@4.0.0: {} + + xml-name-validator@5.0.0: {} + + xml-parser-xo@4.1.5: {} + + xml2js@0.5.0: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xmlbuilder2@4.0.3: + dependencies: + '@oozcitak/dom': 2.0.2 + '@oozcitak/infra': 2.0.2 + '@oozcitak/util': 10.0.0 + js-yaml: 4.1.1 + + xmlbuilder@11.0.1: {} + + xmlchars@2.2.0: {} + + xmlhttprequest-ssl@1.6.3: {} + + xmlhttprequest-ssl@2.1.2: {} + + xss@1.0.15: + dependencies: + commander: 2.20.3 + cssfilter: 0.0.10 + + xtend@4.0.2: {} + + y18n@4.0.3: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml-eslint-parser@1.3.2: + dependencies: + eslint-visitor-keys: 3.4.3 + yaml: 2.9.0 + + yaml@1.10.3: {} + + yaml@2.8.3: {} + + yaml@2.9.0: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs-parser@21.1.1: {} + + yargs-parser@22.0.0: {} + + yargs-unparser@2.0.0: + dependencies: + camelcase: 6.3.0 + decamelize: 4.0.0 + flat: 5.0.2 + is-plain-obj: 2.1.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yeast@0.1.2: {} + + yn@3.1.1: {} + + yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} + + z-schema@4.2.4: + dependencies: + lodash.get: 4.4.2 + lodash.isequal: 4.5.0 + validator: 13.15.26 + optionalDependencies: + commander: 2.20.3 + + z-schema@5.0.5: + dependencies: + lodash.get: 4.4.2 + lodash.isequal: 4.5.0 + validator: 13.15.26 + optionalDependencies: + commander: 9.5.0 + + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.12 + graphmatch: 1.1.1 + + zod@3.25.32: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..1b50755 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +packages: + - 'packages/**' + +minimumReleaseAge: 4320 +minimumReleaseAgeExclude: + - '@hoppscotch/ui' diff --git a/prod.Dockerfile b/prod.Dockerfile new file mode 100644 index 0000000..a8dcfb2 --- /dev/null +++ b/prod.Dockerfile @@ -0,0 +1,262 @@ +# Base Go builder with Go lang installation +# This stage is used to build both Caddy and the webapp server, +# preventing vulnerable packages on the dependency chain +FROM alpine:3.24.1 AS go_builder +RUN apk add --no-cache curl git openssh-client + +ARG TARGETARCH +ENV GOLANG_VERSION=1.26.4 +# Download Go tarball +RUN case "${TARGETARCH}" in amd64) GOARCH=amd64 ;; arm64) GOARCH=arm64 ;; *) echo "Unsupported arch: ${TARGETARCH}" && exit 1 ;; esac && \ + curl -fsSL "https://go.dev/dl/go${GOLANG_VERSION}.linux-${GOARCH}.tar.gz" -o go.tar.gz +# Checksum verification of Go tarball +RUN case "${TARGETARCH}" in \ + amd64) expected="1153d3d50e0ac764b447adfe05c2bcf08e889d42a02e0fe0259bd47f6733ad7f" ;; \ + arm64) expected="ef758ae7c6cf9267c9c0ef080b8965f453d89ab2d25d9eb22de4405925238768" ;; \ + esac && \ + actual=$(sha256sum go.tar.gz | cut -d' ' -f1) && \ + [ "$actual" = "$expected" ] && \ + echo "✅ Go Tarball Checksum OK" || \ + (echo "❌ Go Tarball Checksum failed! Expected: ${expected} Got: ${actual}" && exit 1) +# Install Go from verified tarball +RUN tar -C /usr/local -xzf go.tar.gz && rm go.tar.gz +# Set up Go environment variables +ENV PATH="/usr/local/go/bin:${PATH}" \ + GOPATH="/go" \ + GOBIN="/go/bin" + + + +# Build Caddy from the Go base +FROM go_builder AS caddy_builder +RUN mkdir -p /tmp/caddy-build && \ + curl -fsSL -o /tmp/caddy-build/src.tar.gz https://github.com/caddyserver/caddy/releases/download/v2.11.4/caddy_2.11.4_src.tar.gz +# Checksum verification of caddy source +RUN expected="e44e457ba3f2b5b8447952d2de0ae0a91b09d1a013e2521527e08b6f52acc9eb" && \ + actual=$(sha256sum /tmp/caddy-build/src.tar.gz | cut -d' ' -f1) && \ + [ "$actual" = "$expected" ] && \ + echo "✅ Caddy Source Checksum OK" || \ + (echo "❌ Caddy Source Checksum failed!" && exit 1) +WORKDIR /tmp/caddy-build +RUN tar -xzf /tmp/caddy-build/src.tar.gz && \ + # Fix CVE-2026-34986: upgrade go-jose v3 (HIGH - DoS via crafted JWE) + go get github.com/go-jose/go-jose/v3@v3.0.5 && \ + # Clean up any existing vendor directory and regenerate with updated deps + rm -rf vendor && \ + go mod tidy && \ + go mod vendor +WORKDIR /tmp/caddy-build/cmd/caddy +RUN go build + + + +# Build webapp server from the Go base +# This reuses the Go installation from go_builder, avoiding a separate image pull +# and significantly reducing build time (especially on ARM64 in CI) +FROM go_builder AS webapp_server_builder +WORKDIR /usr/src/app +COPY . . +WORKDIR /usr/src/app/packages/hoppscotch-selfhost-web/webapp-server +RUN go mod download +RUN CGO_ENABLED=0 GOOS=linux go build -o webapp-server . + + + +# Shared Node.js base with optimized NPM installation +FROM alpine:3.24.1 AS node_base +# Install dependencies +RUN apk upgrade --no-cache && \ + apk add --no-cache nodejs curl bash tini ca-certificates +# Set working directory for NPM installation +RUN mkdir -p /tmp/npm-install +WORKDIR /tmp/npm-install +# Download NPM tarball +RUN curl -fsSL https://registry.npmjs.org/npm/-/npm-11.17.0.tgz -o npm.tgz +# Verify checksum +RUN expected="b290bbb35b9e72c3ef84edbe041f28c4479c4d9ee79f555817b8caafe7ce4bba" \ + && actual=$(sha256sum npm.tgz | cut -d' ' -f1) \ + && [ "$actual" = "$expected" ] \ + && echo "✅ NPM Tarball Checksum OK" \ + || (echo "❌ NPM Tarball Checksum failed!" && exit 1) +# Install NPM from verified tarball and global packages +RUN tar -xzf npm.tgz && \ + cd package && \ + node bin/npm-cli.js install -g /tmp/npm-install/npm.tgz && \ + cd / && \ + rm -rf /tmp/npm-install +RUN mkdir -p /tmp/pnpm-install && cd /tmp/pnpm-install && \ + curl -fsSL https://registry.npmjs.org/pnpm/-/pnpm-10.33.4.tgz -o pnpm.tgz && \ + curl -fsSL https://registry.npmjs.org/@import-meta-env/cli/-/cli-0.7.4.tgz -o cli.tgz && \ + echo "8e70ddc6649b18bc3d895cf3a908c0291ea4c38039ad8722c47e018daf1e9cfc pnpm.tgz" | sha256sum -c - && \ + echo "9edada700b616b4224ba69ce713e68c36e22cb2548be9134dd3af00c164d8ca0 cli.tgz" | sha256sum -c - && \ + npm install -g ./pnpm.tgz ./cli.tgz && \ + cd / && rm -rf /tmp/pnpm-install + +# Fix CVE-2026-12151: replace vulnerable undici bundled in npm (ships 6.26.0, fix requires >=6.27.0) +RUN mkdir -p /tmp/undici-fix && \ + cd /tmp/undici-fix && \ + npm install undici@6.27.0 && \ + rm -rf /usr/lib/node_modules/npm/node_modules/undici && \ + cp -r node_modules/undici /usr/lib/node_modules/npm/node_modules/ && \ + rm -rf /tmp/undici-fix + +# Fix CVE-2025-64756 by replacing vulnerable glob in @import-meta-env/cli (ships glob@11.0.2, fix requires >=11.1.0) +RUN mkdir -p /tmp/glob-fix && \ + cd /tmp/glob-fix && \ + npm install glob@11.1.0 && \ + rm -rf /usr/lib/node_modules/@import-meta-env/cli/node_modules/glob && \ + cp -r node_modules/glob /usr/lib/node_modules/@import-meta-env/cli/node_modules/ && \ + rm -rf /tmp/glob-fix + +# Fix CVE: upgrade serialize-javascript in @import-meta-env/cli (ships 6.0.2, fix requires >=7.0.3) +RUN mkdir -p /tmp/serialize-fix && \ + cd /tmp/serialize-fix && \ + npm install serialize-javascript@7.0.3 && \ + rm -rf /usr/lib/node_modules/@import-meta-env/cli/node_modules/serialize-javascript && \ + cp -r node_modules/serialize-javascript /usr/lib/node_modules/@import-meta-env/cli/node_modules/ && \ + rm -rf /tmp/serialize-fix + + + +FROM node_base AS base_builder +# Required by @hoppscotch/js-sandbox to build `isolated-vm` +RUN apk add --no-cache python3 make g++ git openssh-client zlib-dev brotli-dev c-ares-dev nghttp2-dev openssl-dev icu-dev ada-dev simdjson-dev simdutf-dev sqlite-dev zstd-dev + +WORKDIR /usr/src/app +ENV HOPP_ALLOW_RUNTIME_ENV=true +ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder" + +COPY pnpm-lock.yaml . +RUN pnpm fetch + +COPY . . +RUN pnpm install -f --prefer-offline + + + +FROM base_builder AS backend_builder + +WORKDIR /usr/src/app/packages/hoppscotch-backend +ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder" +RUN pnpm exec prisma generate +RUN pnpm run build +RUN pnpm --filter=hoppscotch-backend deploy /dist/backend --prod --legacy +WORKDIR /dist/backend +RUN pnpm exec prisma generate + +FROM node_base AS backend +# Install caddy +COPY --from=caddy_builder /tmp/caddy-build/cmd/caddy/caddy /usr/bin/caddy +COPY --from=base_builder /usr/src/app/packages/hoppscotch-backend/backend.Caddyfile /etc/caddy/backend.Caddyfile +COPY --from=backend_builder /dist/backend /dist/backend +COPY --from=base_builder /usr/src/app/packages/hoppscotch-backend/prod_run.mjs /dist/backend + +# Remove the env file to avoid backend copying it in and using it +ENV PRODUCTION="true" +ENV PORT=8080 + +WORKDIR /dist/backend + +CMD ["node", "prod_run.mjs"] +EXPOSE 80 +EXPOSE 3170 + + + +FROM base_builder AS fe_builder +WORKDIR /usr/src/app/packages/hoppscotch-selfhost-web +RUN pnpm run generate + + + +FROM node_base AS app +# Install caddy +COPY --from=caddy_builder /tmp/caddy-build/cmd/caddy/caddy /usr/bin/caddy + +# Copy over webapp server bin +COPY --from=webapp_server_builder /usr/src/app/packages/hoppscotch-selfhost-web/webapp-server/webapp-server /usr/local/bin/ + +COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/prod_run.mjs /site/prod_run.mjs +COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/selfhost-web.Caddyfile /etc/caddy/selfhost-web.Caddyfile +COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/dist/ /site/selfhost-web + +WORKDIR /site +# Run both webapp-server and Caddy after env processing (NOTE: env processing is required by both) +CMD ["/bin/sh", "-c", "node /site/prod_run.mjs && (webapp-server & caddy run --config /etc/caddy/selfhost-web.Caddyfile --adapter caddyfile)"] + +EXPOSE 80 +EXPOSE 3000 +EXPOSE 3200 + + + +FROM base_builder AS sh_admin_builder +WORKDIR /usr/src/app/packages/hoppscotch-sh-admin +# Generate two builds for `sh-admin`, one based on subpath-access and the regular build +RUN pnpm run build --outDir dist-multiport-setup +RUN pnpm run build --outDir dist-subpath-access --base /admin/ + + +FROM node_base AS sh_admin +# Install caddy +COPY --from=caddy_builder /tmp/caddy-build/cmd/caddy/caddy /usr/bin/caddy + +COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/prod_run.mjs /site/prod_run.mjs +COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/sh-admin-multiport-setup.Caddyfile /etc/caddy/sh-admin-multiport-setup.Caddyfile +COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/sh-admin-subpath-access.Caddyfile /etc/caddy/sh-admin-subpath-access.Caddyfile +COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-multiport-setup /site/sh-admin-multiport-setup +COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-subpath-access /site/sh-admin-subpath-access + +WORKDIR /site +CMD ["node","/site/prod_run.mjs"] + +EXPOSE 80 +EXPOSE 3100 + + + +FROM node_base AS aio + +# Caddy install +COPY --from=caddy_builder /tmp/caddy-build/cmd/caddy/caddy /usr/bin/caddy + +ENV PRODUCTION="true" +ENV PORT=8080 + +# Open Containers Initiative (OCI) labels - useful for bots like Renovate +LABEL org.opencontainers.image.source="https://github.com/hoppscotch/hoppscotch" \ + org.opencontainers.image.url="https://docs.hoppscotch.io" \ + org.opencontainers.image.licenses="MIT" + +# Copy necessary files +# Backend files +COPY --from=base_builder /usr/src/app/packages/hoppscotch-backend/backend.Caddyfile /etc/caddy/backend.Caddyfile +COPY --from=backend_builder /dist/backend /dist/backend +COPY --from=base_builder /usr/src/app/packages/hoppscotch-backend/prod_run.mjs /dist/backend + +# Static Server +COPY --from=webapp_server_builder /usr/src/app/packages/hoppscotch-selfhost-web/webapp-server/webapp-server /usr/local/bin/ +RUN mkdir -p /site/selfhost-web +COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/dist /site/selfhost-web + +# FE Files +COPY --from=base_builder /usr/src/app/aio_run.mjs /usr/src/app/aio_run.mjs +COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/dist /site/selfhost-web +COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-multiport-setup /site/sh-admin-multiport-setup +COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-subpath-access /site/sh-admin-subpath-access +COPY aio-multiport-setup.Caddyfile /etc/caddy/aio-multiport-setup.Caddyfile +COPY aio-subpath-access.Caddyfile /etc/caddy/aio-subpath-access.Caddyfile + +ENTRYPOINT [ "tini", "--" ] +COPY --chmod=755 healthcheck.sh / +HEALTHCHECK --interval=2s --start-period=15s CMD /bin/sh /healthcheck.sh + +WORKDIR /dist/backend +CMD ["node", "/usr/src/app/aio_run.mjs"] + +# NOTE: Although these ports are exposed, the HOPP_ALTERNATE_AIO_PORT variable can be used to assign a user-specified port +EXPOSE 3170 +EXPOSE 3000 +EXPOSE 3100 +EXPOSE 3200 +EXPOSE 80 diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..916a621 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,10 @@ +import preset from "@hoppscotch/ui/ui-preset" + +export default { + content: [ + "packages/hoppscotch-common/src/**/*.{vue,html}", + "packages/hoppscotch-sh-admin/src/**/*.{vue,html}", + "packages/hoppscotch-desktop/src/**/*.{vue,html}", + ], + presets: [preset], +}